Jacky преди 6 месеца
родител
ревизия
e6e1876c54

+ 31 - 0
api/notification/live.go

@@ -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
+		})
+	}
+}

+ 2 - 0
api/notification/router.go

@@ -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)
 }

+ 1 - 0
app/package.json

@@ -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 - 0
app/pnpm-lock.yaml

@@ -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: {}

+ 56 - 6
app/src/components/Notification/Notification.vue

@@ -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"

+ 1 - 1
app/src/components/Notification/config.ts

@@ -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) {

+ 41 - 35
app/src/components/Notification/detailRender.ts

@@ -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
   }
 }

+ 3 - 1
app/src/constants/index.ts

@@ -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

+ 5 - 2
app/src/layouts/HeaderLayout.vue

@@ -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 />
 

+ 38 - 2
internal/notification/notification.go

@@ -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
+	}
 }

+ 1 - 0
internal/site/sync.go

@@ -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) {