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.DELETE("notifications/:id", Destroy)
r.DELETE("notifications", DestroyAll)
r.GET("notifications/live", Live)
}

View file

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

8
app/pnpm-lock.yaml generated
View file

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

View file

@ -1,22 +1,71 @@
<script setup lang="ts">
import type { Notification } from '@/api/notification'
import type { CustomRenderProps } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
import type { SSEvent } from 'sse.js'
import type { Ref } from 'vue'
import notification from '@/api/notification'
import notificationApi from '@/api/notification'
import { detailRender } from '@/components/Notification/detailRender'
import { NotificationTypeT } from '@/constants'
import { useUserStore } from '@/pinia'
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 { unreadCount } = storeToRefs(useUserStore())
const { token, unreadCount } = storeToRefs(useUserStore())
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() {
loading.value = true
notification.get_list().then(r => {
notificationApi.get_list().then(r => {
data.value = r.data
unreadCount.value = r.pagination?.total || 0
}).catch(e => {
@ -38,7 +87,7 @@ watch(open, v => {
})
function clear() {
notification.clear().then(() => {
notificationApi.clear().then(() => {
message.success($gettext('Cleared successfully'))
data.value = []
unreadCount.value = 0
@ -48,7 +97,7 @@ function clear() {
}
function remove(id: number) {
notification.destroy(id).then(() => {
notificationApi.destroy(id).then(() => {
message.success($gettext('Removed successfully'))
init()
}).catch(e => {
@ -70,6 +119,7 @@ function viewAll() {
placement="bottomRight"
overlay-class-name="notification-popover"
trigger="click"
:get-popup-container="() => headerRef"
>
<ABadge
:count="unreadCount"

View file

@ -32,7 +32,7 @@ export function syncRenameConfigError(text: string) {
export function saveSiteSuccess(text: string) {
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) {

View file

@ -18,42 +18,48 @@ import {
} from '@/components/Notification/config'
export function detailRender(args: CustomRenderProps) {
switch (args.record.title) {
case 'Sync Certificate Success':
return syncCertificateSuccess(args.text)
case 'Sync Certificate Error':
return syncCertificateError(args.text)
case 'Rename Remote Config Success':
return syncRenameConfigSuccess(args.text)
case 'Rename Remote Config Error':
return syncRenameConfigError(args.text)
try {
switch (args.record.title) {
case 'Sync Certificate Success':
return syncCertificateSuccess(args.text)
case 'Sync Certificate Error':
return syncCertificateError(args.text)
case 'Rename Remote Config Success':
return syncRenameConfigSuccess(args.text)
case 'Rename Remote Config Error':
return syncRenameConfigError(args.text)
case 'Save Remote Site Success':
return saveSiteSuccess(args.text)
case 'Save Remote Site Error':
return saveSiteError(args.text)
case 'Delete Remote Site Success':
return deleteSiteSuccess(args.text)
case 'Delete Remote Site Error':
return deleteSiteError(args.text)
case 'Enable Remote Site Success':
return enableSiteSuccess(args.text)
case 'Enable Remote Site Error':
return enableSiteError(args.text)
case 'Disable Remote Site Success':
return disableSiteSuccess(args.text)
case 'Disable Remote Site Error':
return disableSiteError(args.text)
case 'Rename Remote Site Success':
return renameSiteSuccess(args.text)
case 'Rename Remote Site Error':
return renameSiteError(args.text)
case 'Save Remote Site Success':
return saveSiteSuccess(args.text)
case 'Save Remote Site Error':
return saveSiteError(args.text)
case 'Delete Remote Site Success':
return deleteSiteSuccess(args.text)
case 'Delete Remote Site Error':
return deleteSiteError(args.text)
case 'Enable Remote Site Success':
return enableSiteSuccess(args.text)
case 'Enable Remote Site Error':
return enableSiteError(args.text)
case 'Disable Remote Site Success':
return disableSiteSuccess(args.text)
case 'Disable Remote Site Error':
return disableSiteError(args.text)
case 'Rename Remote Site Success':
return renameSiteSuccess(args.text)
case 'Rename Remote Site Error':
return renameSiteError(args.text)
case 'Sync Config Success':
return syncConfigSuccess(args.text)
case 'Sync Config Error':
return syncConfigError(args.text)
default:
return args.text
case 'Sync Config Success':
return syncConfigSuccess(args.text)
case 'Sync Config Error':
return syncConfigError(args.text)
default:
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',
} 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

View file

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

View file

@ -3,8 +3,29 @@ package notification
import (
"github.com/0xJacky/Nginx-UI/model"
"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) {
push(model.NotificationInfo, title, details)
}
@ -24,9 +45,24 @@ func Success(title string, details string) {
func push(nType model.NotificationType, title string, details string) {
n := query.Notification
_ = n.Create(&model.Notification{
data := &model.Notification{
Type: nType,
Title: title,
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"`
NewName string `json:"new_name,omitempty"`
Response gin.H `json:"response"`
Error string `json:"error"`
}
func NewSyncResult(node string, siteName string, resp *resty.Response) (s *SyncResult) {