mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2025-05-11 02:15:48 +02:00
feat: sse notify
This commit is contained in:
parent
b4add42039
commit
e6e1876c54
11 changed files with 187 additions and 47 deletions
31
api/notification/live.go
Normal file
31
api/notification/live.go
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
8
app/pnpm-lock.yaml
generated
|
@ -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: {}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 />
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue