feat: sse notify

This commit is contained in:
Jacky 2024-11-03 23:46:47 +08:00
parent b4add42039
commit e6e1876c54
No known key found for this signature in database
GPG key ID: 215C21B10DF38B4D
11 changed files with 187 additions and 47 deletions

31
api/notification/live.go Normal file
View file

@ -0,0 +1,31 @@
package notification
import (
"github.com/0xJacky/Nginx-UI/internal/notification"
"github.com/0xJacky/Nginx-UI/model"
"github.com/gin-gonic/gin"
"io"
)
func Live(c *gin.Context) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
evtChan := make(chan *model.Notification)
notification.SetClient(c, evtChan)
notify := c.Writer.CloseNotify()
go func() {
<-notify
notification.RemoveClient(c)
}()
for n := range evtChan {
c.Stream(func(w io.Writer) bool {
c.SSEvent("message", n)
return false
})
}
}

View file

@ -7,4 +7,6 @@ func InitRouter(r *gin.RouterGroup) {
r.GET("notifications/:id", Get) r.GET("notifications/:id", Get)
r.DELETE("notifications/:id", Destroy) r.DELETE("notifications/:id", Destroy)
r.DELETE("notifications", DestroyAll) r.DELETE("notifications", DestroyAll)
r.GET("notifications/live", Live)
} }

View file

@ -38,6 +38,7 @@
"pinia-plugin-persistedstate": "^4.1.2", "pinia-plugin-persistedstate": "^4.1.2",
"reconnecting-websocket": "^4.4.0", "reconnecting-websocket": "^4.4.0",
"sortablejs": "^1.15.3", "sortablejs": "^1.15.3",
"sse.js": "^2.5.0",
"universal-cookie": "^7.2.2", "universal-cookie": "^7.2.2",
"unocss": "^0.63.6", "unocss": "^0.63.6",
"vite-plugin-build-id": "0.5.0", "vite-plugin-build-id": "0.5.0",

8
app/pnpm-lock.yaml generated
View file

@ -83,6 +83,9 @@ importers:
sortablejs: sortablejs:
specifier: ^1.15.3 specifier: ^1.15.3
version: 1.15.3 version: 1.15.3
sse.js:
specifier: ^2.5.0
version: 2.5.0
universal-cookie: universal-cookie:
specifier: ^7.2.2 specifier: ^7.2.2
version: 7.2.2 version: 7.2.2
@ -4271,6 +4274,9 @@ packages:
spdx-license-ids@3.0.20: spdx-license-ids@3.0.20:
resolution: {integrity: sha512-jg25NiDV/1fLtSgEgyvVyDunvaNHbuwF9lfNV17gSmPFAlYzdfNBlLtLzXTevwkPj7DhGbmN9VnmJIgLnhvaBw==} resolution: {integrity: sha512-jg25NiDV/1fLtSgEgyvVyDunvaNHbuwF9lfNV17gSmPFAlYzdfNBlLtLzXTevwkPj7DhGbmN9VnmJIgLnhvaBw==}
sse.js@2.5.0:
resolution: {integrity: sha512-I7zYndqOOkNpz9KIdFZ8c8A7zs1YazNewBr8Nsi/tqThfJkVPuP1q7UE2h4B0RwoWZxbBYpd06uoW3NI3SaZXg==}
stable-hash@0.0.4: stable-hash@0.0.4:
resolution: {integrity: sha512-LjdcbuBeLcdETCrPn9i8AYAZ1eCtu4ECAWtP7UleOiZ9LzVxRzzUZEoZ8zB24nhkQnDWyET0I+3sWokSDS3E7g==} resolution: {integrity: sha512-LjdcbuBeLcdETCrPn9i8AYAZ1eCtu4ECAWtP7UleOiZ9LzVxRzzUZEoZ8zB24nhkQnDWyET0I+3sWokSDS3E7g==}
@ -9690,6 +9696,8 @@ snapshots:
spdx-license-ids@3.0.20: {} spdx-license-ids@3.0.20: {}
sse.js@2.5.0: {}
stable-hash@0.0.4: {} stable-hash@0.0.4: {}
std-env@3.7.0: {} std-env@3.7.0: {}

View file

@ -1,22 +1,71 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Notification } from '@/api/notification' import type { Notification } from '@/api/notification'
import type { CustomRenderProps } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer' import type { CustomRenderProps } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
import type { SSEvent } from 'sse.js'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import notification from '@/api/notification' import notificationApi from '@/api/notification'
import { detailRender } from '@/components/Notification/detailRender' import { detailRender } from '@/components/Notification/detailRender'
import { NotificationTypeT } from '@/constants' import { NotificationTypeT } from '@/constants'
import { useUserStore } from '@/pinia' import { useUserStore } from '@/pinia'
import { BellOutlined, CheckCircleOutlined, CloseCircleOutlined, DeleteOutlined, InfoCircleOutlined, WarningOutlined } from '@ant-design/icons-vue' import { BellOutlined, CheckCircleOutlined, CloseCircleOutlined, DeleteOutlined, InfoCircleOutlined, WarningOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue' import { message, notification } from 'ant-design-vue'
import { SSE } from 'sse.js'
defineProps<{
headerRef: HTMLElement
}>()
const loading = ref(false) const loading = ref(false)
const { unreadCount } = storeToRefs(useUserStore()) const { token, unreadCount } = storeToRefs(useUserStore())
const data = ref([]) as Ref<Notification[]> const data = ref([]) as Ref<Notification[]>
const sse = shallowRef(newSSE())
function reconnect() {
setTimeout(() => {
sse.value = newSSE()
}, 5000)
}
function newSSE() {
const s = new SSE('/api/notifications/live', {
headers: {
Authorization: token.value,
},
})
s.onmessage = (e: SSEvent) => {
const data = JSON.parse(e.data)
// data.type may be 0
if (data.type === undefined || data.type === null || data.type === '') {
return
}
const typeTrans = {
0: 'error',
1: 'warning',
2: 'info',
3: 'success',
}
notification[typeTrans[data.type]]({
message: $gettext(data.title),
description: detailRender({ text: data.details, record: data } as CustomRenderProps),
})
}
// reconnect
s.onerror = reconnect
s.onabort = reconnect
return s
}
function init() { function init() {
loading.value = true loading.value = true
notification.get_list().then(r => { notificationApi.get_list().then(r => {
data.value = r.data data.value = r.data
unreadCount.value = r.pagination?.total || 0 unreadCount.value = r.pagination?.total || 0
}).catch(e => { }).catch(e => {
@ -38,7 +87,7 @@ watch(open, v => {
}) })
function clear() { function clear() {
notification.clear().then(() => { notificationApi.clear().then(() => {
message.success($gettext('Cleared successfully')) message.success($gettext('Cleared successfully'))
data.value = [] data.value = []
unreadCount.value = 0 unreadCount.value = 0
@ -48,7 +97,7 @@ function clear() {
} }
function remove(id: number) { function remove(id: number) {
notification.destroy(id).then(() => { notificationApi.destroy(id).then(() => {
message.success($gettext('Removed successfully')) message.success($gettext('Removed successfully'))
init() init()
}).catch(e => { }).catch(e => {
@ -70,6 +119,7 @@ function viewAll() {
placement="bottomRight" placement="bottomRight"
overlay-class-name="notification-popover" overlay-class-name="notification-popover"
trigger="click" trigger="click"
:get-popup-container="() => headerRef"
> >
<ABadge <ABadge
:count="unreadCount" :count="unreadCount"

View file

@ -32,7 +32,7 @@ export function syncRenameConfigError(text: string) {
export function saveSiteSuccess(text: string) { export function saveSiteSuccess(text: string) {
const data = JSON.parse(text) const data = JSON.parse(text)
return $gettext('Save Site %{site} to %{node} successfully', { site: data.site, node: data.node }) return $gettext('Save Site %{site} to %{node} successfully', { site: data.name, node: data.node })
} }
export function saveSiteError(text: string) { export function saveSiteError(text: string) {

View file

@ -18,42 +18,48 @@ import {
} from '@/components/Notification/config' } from '@/components/Notification/config'
export function detailRender(args: CustomRenderProps) { export function detailRender(args: CustomRenderProps) {
switch (args.record.title) { try {
case 'Sync Certificate Success': switch (args.record.title) {
return syncCertificateSuccess(args.text) case 'Sync Certificate Success':
case 'Sync Certificate Error': return syncCertificateSuccess(args.text)
return syncCertificateError(args.text) case 'Sync Certificate Error':
case 'Rename Remote Config Success': return syncCertificateError(args.text)
return syncRenameConfigSuccess(args.text) case 'Rename Remote Config Success':
case 'Rename Remote Config Error': return syncRenameConfigSuccess(args.text)
return syncRenameConfigError(args.text) case 'Rename Remote Config Error':
return syncRenameConfigError(args.text)
case 'Save Remote Site Success': case 'Save Remote Site Success':
return saveSiteSuccess(args.text) return saveSiteSuccess(args.text)
case 'Save Remote Site Error': case 'Save Remote Site Error':
return saveSiteError(args.text) return saveSiteError(args.text)
case 'Delete Remote Site Success': case 'Delete Remote Site Success':
return deleteSiteSuccess(args.text) return deleteSiteSuccess(args.text)
case 'Delete Remote Site Error': case 'Delete Remote Site Error':
return deleteSiteError(args.text) return deleteSiteError(args.text)
case 'Enable Remote Site Success': case 'Enable Remote Site Success':
return enableSiteSuccess(args.text) return enableSiteSuccess(args.text)
case 'Enable Remote Site Error': case 'Enable Remote Site Error':
return enableSiteError(args.text) return enableSiteError(args.text)
case 'Disable Remote Site Success': case 'Disable Remote Site Success':
return disableSiteSuccess(args.text) return disableSiteSuccess(args.text)
case 'Disable Remote Site Error': case 'Disable Remote Site Error':
return disableSiteError(args.text) return disableSiteError(args.text)
case 'Rename Remote Site Success': case 'Rename Remote Site Success':
return renameSiteSuccess(args.text) return renameSiteSuccess(args.text)
case 'Rename Remote Site Error': case 'Rename Remote Site Error':
return renameSiteError(args.text) return renameSiteError(args.text)
case 'Sync Config Success': case 'Sync Config Success':
return syncConfigSuccess(args.text) return syncConfigSuccess(args.text)
case 'Sync Config Error': case 'Sync Config Error':
return syncConfigError(args.text) return syncConfigError(args.text)
default: default:
return args.text return args.text
}
}
// eslint-disable-next-line sonarjs/no-ignored-exceptions
catch (e) {
return args.text
} }
} }

View file

@ -35,6 +35,8 @@ export const PrivateKeyTypeMask = {
P384: 'EC384', P384: 'EC384',
} as const } as const
export const PrivateKeyTypeList = Object.entries(PrivateKeyTypeMask).map(([key, name]) => ({ key, name })) export const PrivateKeyTypeList
= Object.entries(PrivateKeyTypeMask).map(([key, name]) =>
({ key, name }))
export type PrivateKeyType = keyof typeof PrivateKeyTypeMask export type PrivateKeyType = keyof typeof PrivateKeyTypeMask

View file

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ShallowRef } from 'vue'
import auth from '@/api/auth' import auth from '@/api/auth'
import NginxControl from '@/components/NginxControl/NginxControl.vue' import NginxControl from '@/components/NginxControl/NginxControl.vue'
import Notification from '@/components/Notification/Notification.vue' import Notification from '@/components/Notification/Notification.vue'
@ -21,10 +22,12 @@ function logout() {
router.push('/login') router.push('/login')
}) })
} }
const headerRef = useTemplateRef('headerRef') as Readonly<ShallowRef<HTMLDivElement>>
</script> </script>
<template> <template>
<div class="header"> <div ref="headerRef" class="header">
<div class="tool"> <div class="tool">
<MenuUnfoldOutlined @click="emit('clickUnFold')" /> <MenuUnfoldOutlined @click="emit('clickUnFold')" />
</div> </div>
@ -37,7 +40,7 @@ function logout() {
<SwitchAppearance /> <SwitchAppearance />
<Notification /> <Notification :header-ref="headerRef" />
<NginxControl /> <NginxControl />

View file

@ -3,8 +3,29 @@ package notification
import ( import (
"github.com/0xJacky/Nginx-UI/model" "github.com/0xJacky/Nginx-UI/model"
"github.com/0xJacky/Nginx-UI/query" "github.com/0xJacky/Nginx-UI/query"
"github.com/gin-gonic/gin"
"github.com/uozi-tech/cosy/logger"
"sync"
) )
var (
clientMap = make(map[*gin.Context]chan *model.Notification)
mutex = &sync.RWMutex{}
)
func SetClient(c *gin.Context, evtChan chan *model.Notification) {
mutex.Lock()
defer mutex.Unlock()
clientMap[c] = evtChan
}
func RemoveClient(c *gin.Context) {
mutex.Lock()
defer mutex.Unlock()
close(clientMap[c])
delete(clientMap, c)
}
func Info(title string, details string) { func Info(title string, details string) {
push(model.NotificationInfo, title, details) push(model.NotificationInfo, title, details)
} }
@ -24,9 +45,24 @@ func Success(title string, details string) {
func push(nType model.NotificationType, title string, details string) { func push(nType model.NotificationType, title string, details string) {
n := query.Notification n := query.Notification
_ = n.Create(&model.Notification{ data := &model.Notification{
Type: nType, Type: nType,
Title: title, Title: title,
Details: details, Details: details,
}) }
err := n.Create(data)
if err != nil {
logger.Error(err)
return
}
broadcast(data)
}
func broadcast(data *model.Notification) {
mutex.RLock()
defer mutex.RUnlock()
for _, evtChan := range clientMap {
evtChan <- data
}
} }

View file

@ -44,6 +44,7 @@ type SyncResult struct {
Name string `json:"name"` Name string `json:"name"`
NewName string `json:"new_name,omitempty"` NewName string `json:"new_name,omitempty"`
Response gin.H `json:"response"` Response gin.H `json:"response"`
Error string `json:"error"`
} }
func NewSyncResult(node string, siteName string, resp *resty.Response) (s *SyncResult) { func NewSyncResult(node string, siteName string, resp *resty.Response) (s *SyncResult) {