Browse Source

feat: add env group rendering and sync nodes preview #1268

0xJacky 2 weeks ago
parent
commit
c065c41c0a

+ 58 - 2
api/cluster/group.go

@@ -5,18 +5,74 @@ import (
 
 	"github.com/0xJacky/Nginx-UI/model"
 	"github.com/gin-gonic/gin"
+	"github.com/samber/lo"
 	"github.com/uozi-tech/cosy"
 	"gorm.io/gorm"
 )
 
+type APIRespEnvGroup struct {
+	model.EnvGroup
+	SyncNodes []*model.Environment `json:"sync_nodes,omitempty" gorm:"-"`
+}
+
 func GetGroup(c *gin.Context) {
-	cosy.Core[model.EnvGroup](c).Get()
+	cosy.Core[model.EnvGroup](c).
+		SetTransformer(func(m *model.EnvGroup) any {
+			db := cosy.UseDB(c)
+
+			var nodes []*model.Environment
+			if len(m.SyncNodeIds) > 0 {
+				db.Model(&model.Environment{}).
+					Where("id IN (?)", m.SyncNodeIds).
+					Find(&nodes)
+			}
+
+			return &APIRespEnvGroup{
+				EnvGroup:  *m,
+				SyncNodes: nodes,
+			}
+		}).
+		Get()
 }
 
 func GetGroupList(c *gin.Context) {
 	cosy.Core[model.EnvGroup](c).GormScope(func(tx *gorm.DB) *gorm.DB {
 		return tx.Order("order_id ASC")
-	}).PagingList()
+	}).
+		SetScan(func(tx *gorm.DB) any {
+			var groups []*APIRespEnvGroup
+
+			var nodeIDs []uint64
+			tx.Find(&groups)
+
+			for _, group := range groups {
+				nodeIDs = append(nodeIDs, group.SyncNodeIds...)
+			}
+
+			var nodes []*model.Environment
+			nodeIDs = lo.Uniq(nodeIDs)
+			if len(nodeIDs) > 0 {
+				db := cosy.UseDB(c)
+				db.Model(&model.Environment{}).
+					Where("id IN (?)", nodeIDs).
+					Find(&nodes)
+			}
+
+			nodeMap := lo.SliceToMap(nodes, func(node *model.Environment) (uint64, *model.Environment) {
+				return node.ID, node
+			})
+
+			for _, group := range groups {
+				for _, nodeID := range group.SyncNodeIds {
+					if node, ok := nodeMap[nodeID]; ok {
+						group.SyncNodes = append(group.SyncNodes, node)
+					}
+				}
+			}
+
+			return groups
+		}).
+		PagingList()
 }
 
 func ReloadNginx(c *gin.Context) {

+ 5 - 0
app/components.d.ts

@@ -82,6 +82,9 @@ declare module 'vue' {
     CodeEditorCodeEditor: typeof import('./src/components/CodeEditor/CodeEditor.vue')['default']
     ConfigHistoryConfigHistory: typeof import('./src/components/ConfigHistory/ConfigHistory.vue')['default']
     ConfigHistoryDiffViewer: typeof import('./src/components/ConfigHistory/DiffViewer.vue')['default']
+    EnvGroupRenderEnvGroupRender: typeof import('./src/components/EnvGroupRender/EnvGroupRender.vue')['default']
+    EnvGroupRenderEnvGroupRenderer: typeof import('./src/components/EnvGroupRender/EnvGroupRenderer.vue')['default']
+    EnvGroupRendererEnvGroupRenderer: typeof import('./src/components/EnvGroupRenderer/EnvGroupRenderer.vue')['default']
     EnvGroupTabsEnvGroupTabs: typeof import('./src/components/EnvGroupTabs/EnvGroupTabs.vue')['default']
     EnvIndicatorEnvIndicator: typeof import('./src/components/EnvIndicator/EnvIndicator.vue')['default']
     FooterToolbarFooterToolBar: typeof import('./src/components/FooterToolbar/FooterToolBar.vue')['default']
@@ -98,6 +101,7 @@ declare module 'vue' {
     NgxConfigEditorNgxConfigEditor: typeof import('./src/components/NgxConfigEditor/NgxConfigEditor.vue')['default']
     NgxConfigEditorNgxServer: typeof import('./src/components/NgxConfigEditor/NgxServer.vue')['default']
     NgxConfigEditorNgxUpstream: typeof import('./src/components/NgxConfigEditor/NgxUpstream.vue')['default']
+    NodeCardNodeCard: typeof import('./src/components/NodeCard/NodeCard.vue')['default']
     NodeSelectorNodeSelector: typeof import('./src/components/NodeSelector/NodeSelector.vue')['default']
     NotificationNotification: typeof import('./src/components/Notification/Notification.vue')['default']
     OTPInputOTPInput: typeof import('./src/components/OTPInput/OTPInput.vue')['default']
@@ -115,6 +119,7 @@ declare module 'vue' {
     SwitchAppearanceIconsVPIconMoon: typeof import('./src/components/SwitchAppearance/icons/VPIconMoon.vue')['default']
     SwitchAppearanceIconsVPIconSun: typeof import('./src/components/SwitchAppearance/icons/VPIconSun.vue')['default']
     SwitchAppearanceSwitchAppearance: typeof import('./src/components/SwitchAppearance/SwitchAppearance.vue')['default']
+    SyncNodesPreviewSyncNodesPreview: typeof import('./src/components/SyncNodesPreview/SyncNodesPreview.vue')['default']
     SystemRestoreSystemRestoreContent: typeof import('./src/components/SystemRestore/SystemRestoreContent.vue')['default']
     TwoFAAuthorization: typeof import('./src/components/TwoFA/Authorization.vue')['default']
     VPSwitchVPSwitch: typeof import('./src/components/VPSwitch/VPSwitch.vue')['default']

+ 1 - 1
app/package.json

@@ -18,7 +18,7 @@
     "@fingerprintjs/fingerprintjs": "^4.6.2",
     "@formkit/auto-animate": "^0.8.2",
     "@simplewebauthn/browser": "^13.1.2",
-    "@uozi-admin/curd": "^4.5.8",
+    "@uozi-admin/curd": "^4.5.9",
     "@uozi-admin/request": "^2.8.4",
     "@vue/reactivity": "^3.5.18",
     "@vue/shared": "^3.5.18",

+ 9 - 9
app/pnpm-lock.yaml

@@ -24,8 +24,8 @@ importers:
         specifier: ^13.1.2
         version: 13.1.2
       '@uozi-admin/curd':
-        specifier: ^4.5.8
-        version: 4.5.8(@ant-design/icons-vue@7.0.1(vue@3.5.18(typescript@5.8.3)))(ant-design-vue@4.2.6(vue@3.5.18(typescript@5.8.3)))(dayjs@1.11.13)(lodash-es@4.17.21)(vue-router@4.5.1(vue@3.5.18(typescript@5.8.3)))(vue@3.5.18(typescript@5.8.3))
+        specifier: ^4.5.9
+        version: 4.5.9(@ant-design/icons-vue@7.0.1(vue@3.5.18(typescript@5.8.3)))(ant-design-vue@4.2.6(vue@3.5.18(typescript@5.8.3)))(dayjs@1.11.13)(lodash-es@4.17.21)(vue-router@4.5.1(vue@3.5.18(typescript@5.8.3)))(vue@3.5.18(typescript@5.8.3))
       '@uozi-admin/request':
         specifier: ^2.8.4
         version: 2.8.4(lodash-es@4.17.21)
@@ -1244,8 +1244,8 @@ packages:
     peerDependencies:
       vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0
 
-  '@uozi-admin/curd@4.5.8':
-    resolution: {integrity: sha512-ap1Dy8DGGUb4fbZZGF+eUufPeEzrRKoz9bSk2UKieqHlCnrOAg7TCTJ9I6Kn0ZdPwaahI9yY4h6ySKF+8ujL6w==}
+  '@uozi-admin/curd@4.5.9':
+    resolution: {integrity: sha512-622RC1CoBmKDmRQdcjrkSNrBbBq5sTp23JZg+JcLXydlElg0braiWXU31A6E4eUERln0cQpnXuv0ZAZoPyBybA==}
     hasBin: true
     peerDependencies:
       '@ant-design/icons-vue': '>=7.0.1'
@@ -2109,8 +2109,8 @@ packages:
     peerDependencies:
       eslint: '>=6.0.0'
 
-  eslint-plugin-n@17.21.2:
-    resolution: {integrity: sha512-s3ai4Msfk5mbSvOgCkYo6k5+zP3W/OK+AvLmMmx++Ki4a5CPO7luIDwOnjUZm/t+oZYP0YADTxe+u4JqnT8+Dg==}
+  eslint-plugin-n@17.21.3:
+    resolution: {integrity: sha512-MtxYjDZhMQgsWRm/4xYLL0i2EhusWT7itDxlJ80l1NND2AL2Vi5Mvneqv/ikG9+zpran0VsVRXTEHrpLmUZRNw==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
     peerDependencies:
       eslint: '>=8.23.0'
@@ -4132,7 +4132,7 @@ snapshots:
       eslint-plugin-import-lite: 0.3.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3)
       eslint-plugin-jsdoc: 51.4.1(eslint@9.32.0(jiti@2.5.1))
       eslint-plugin-jsonc: 2.20.1(eslint@9.32.0(jiti@2.5.1))
-      eslint-plugin-n: 17.21.2(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3)
+      eslint-plugin-n: 17.21.3(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3)
       eslint-plugin-no-only-tests: 3.3.0
       eslint-plugin-perfectionist: 4.15.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3)
       eslint-plugin-pnpm: 1.1.0(eslint@9.32.0(jiti@2.5.1))
@@ -5149,7 +5149,7 @@ snapshots:
     transitivePeerDependencies:
       - vue
 
-  '@uozi-admin/curd@4.5.8(@ant-design/icons-vue@7.0.1(vue@3.5.18(typescript@5.8.3)))(ant-design-vue@4.2.6(vue@3.5.18(typescript@5.8.3)))(dayjs@1.11.13)(lodash-es@4.17.21)(vue-router@4.5.1(vue@3.5.18(typescript@5.8.3)))(vue@3.5.18(typescript@5.8.3))':
+  '@uozi-admin/curd@4.5.9(@ant-design/icons-vue@7.0.1(vue@3.5.18(typescript@5.8.3)))(ant-design-vue@4.2.6(vue@3.5.18(typescript@5.8.3)))(dayjs@1.11.13)(lodash-es@4.17.21)(vue-router@4.5.1(vue@3.5.18(typescript@5.8.3)))(vue@3.5.18(typescript@5.8.3))':
     dependencies:
       '@ant-design/icons-vue': 7.0.1(vue@3.5.18(typescript@5.8.3))
       '@vueuse/core': 13.6.0(vue@3.5.18(typescript@5.8.3))
@@ -6173,7 +6173,7 @@ snapshots:
     transitivePeerDependencies:
       - '@eslint/json'
 
-  eslint-plugin-n@17.21.2(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3):
+  eslint-plugin-n@17.21.3(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3):
     dependencies:
       '@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0(jiti@2.5.1))
       enhanced-resolve: 5.18.2

+ 75 - 0
app/src/components/EnvGroupRender/EnvGroupRender.vue

@@ -0,0 +1,75 @@
+<script setup lang="ts">
+import type { EnvGroup } from '@/api/env_group'
+import NodeCard from '@/components/NodeCard'
+
+defineProps<{
+  envGroup: EnvGroup | null
+}>()
+
+const modalVisible = ref(false)
+
+function showModal() {
+  modalVisible.value = true
+}
+
+function handleCancel() {
+  modalVisible.value = false
+}
+</script>
+
+<template>
+  <div v-if="envGroup">
+    <span
+      class="cursor-pointer text-blue-500 hover:text-blue-700"
+      @click="showModal"
+    >
+      {{ envGroup.name }}
+    </span>
+
+    <AModal
+      v-model:open="modalVisible"
+      :title="envGroup.name"
+      :footer="null"
+      width="680px"
+      @cancel="handleCancel"
+    >
+      <div class="py-4">
+        <div class="mb-4">
+          <strong class="text-gray-900 dark:text-gray-100">{{ $gettext('Post-sync Action') }}:</strong>
+          <span class="ml-2 text-gray-700 dark:text-gray-300">
+            <template v-if="!envGroup.post_sync_action || envGroup.post_sync_action === 'none'">
+              {{ $gettext('No Action') }}
+            </template>
+            <template v-else-if="envGroup.post_sync_action === 'reload_nginx'">
+              {{ $gettext('Reload Nginx') }}
+            </template>
+            <template v-else>
+              {{ envGroup.post_sync_action }}
+            </template>
+          </span>
+        </div>
+
+        <div>
+          <strong class="text-gray-900 dark:text-gray-100">{{ $gettext('Sync Nodes') }}</strong>
+          <div v-if="!envGroup.sync_node_ids || envGroup.sync_node_ids.length === 0" class="mt-2 text-gray-400 dark:text-gray-500">
+            {{ $gettext('No nodes selected') }}
+          </div>
+          <div v-else class="mt-2">
+            <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
+              <NodeCard
+                v-for="nodeId in envGroup.sync_node_ids"
+                :key="nodeId"
+                :node-id="nodeId"
+                size="sm"
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+    </AModal>
+  </div>
+  <span v-else class="text-gray-400">-</span>
+</template>
+
+<style lang="less" scoped>
+</style>

+ 1 - 0
app/src/components/EnvGroupRender/index.ts

@@ -0,0 +1 @@
+export { default } from './EnvGroupRender.vue'

+ 4 - 62
app/src/components/EnvGroupTabs/EnvGroupTabs.vue

@@ -1,79 +1,21 @@
 <script setup lang="ts">
-import type ReconnectingWebSocket from 'reconnecting-websocket'
 import type { EnvGroup } from '@/api/env_group'
-import type { Environment } from '@/api/environment'
 import { message } from 'ant-design-vue'
 import nodeApi from '@/api/node'
-import ws from '@/lib/websocket'
+import { useNodeAvailabilityStore } from '@/pinia/moudule/nodeAvailability'
 
 const props = defineProps<{
   envGroups: EnvGroup[]
 }>()
 
 const modelValue = defineModel<string | number>('activeKey')
+const nodeStore = useNodeAvailabilityStore()
 
-const environments = ref<Environment[]>([])
-const environmentsMap = ref<Record<number, Environment>>({})
 const loading = ref({
   reload: false,
   restart: false,
 })
 
-// WebSocket connection for environment monitoring
-let socket: ReconnectingWebSocket | WebSocket | null = null
-
-// Get node data when tab is not 'All'
-watch(modelValue, newVal => {
-  if (newVal && newVal !== 0) {
-    connectWebSocket()
-  }
-  else {
-    disconnectWebSocket()
-  }
-}, { immediate: true })
-
-function connectWebSocket() {
-  if (socket) {
-    socket.close()
-  }
-
-  socket = ws('/api/cluster/environments/enabled/ws', true)
-
-  socket.onmessage = event => {
-    try {
-      const message = JSON.parse(event.data)
-
-      if (message.event === 'message') {
-        const data: Environment[] = message.data
-        environments.value = data
-        environmentsMap.value = environments.value.reduce((acc, node) => {
-          acc[node.id] = node
-          return acc
-        }, {} as Record<number, Environment>)
-      }
-    }
-    catch (error) {
-      console.error('Error parsing WebSocket message:', error)
-    }
-  }
-
-  socket.onerror = error => {
-    console.warn('Failed to connect to environments WebSocket endpoint', error)
-  }
-}
-
-function disconnectWebSocket() {
-  if (socket) {
-    socket.close()
-    socket = null
-  }
-}
-
-// Cleanup on unmount
-onUnmounted(() => {
-  disconnectWebSocket()
-})
-
 // Get the current Node Group data
 const currentEnvGroup = computed(() => {
   if (!modelValue.value || modelValue.value === 0)
@@ -90,8 +32,8 @@ const syncNodes = computed(() => {
     return []
 
   return currentEnvGroup.value.sync_node_ids
-    .map(id => environmentsMap.value[id])
-    .filter(Boolean)
+    .map(id => nodeStore.getNodeStatus(id))
+    .filter((node): node is NonNullable<typeof node> => Boolean(node))
 })
 
 // Handle reload Nginx on all sync nodes

+ 58 - 0
app/src/components/NodeCard/NodeCard.vue

@@ -0,0 +1,58 @@
+<script setup lang="ts">
+import { useNodeAvailabilityStore } from '@/pinia/moudule/nodeAvailability'
+
+const props = defineProps<{
+  nodeId: number
+  size?: 'sm' | 'md'
+}>()
+
+const nodeStore = useNodeAvailabilityStore()
+
+// Get node info from store
+const nodeInfo = computed(() => {
+  const node = nodeStore.getNodeStatus(props.nodeId)
+  return {
+    name: node?.name || `Node ${props.nodeId}`,
+    isOnline: node?.status ?? false,
+  }
+})
+
+// Size-dependent classes
+const sizeClasses = computed(() => {
+  if (props.size === 'sm') {
+    return {
+      container: 'p-2',
+      indicator: 'w-2 h-2 mr-2',
+      nameText: 'text-sm',
+      statusText: 'text-xs',
+    }
+  }
+  return {
+    container: 'p-3',
+    indicator: 'w-3 h-3 mr-3',
+    nameText: 'text-base',
+    statusText: 'text-sm',
+  }
+})
+</script>
+
+<template>
+  <div
+    :class="`flex items-center bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 ${sizeClasses.container}`"
+  >
+    <span
+      :class="`inline-block rounded-full flex-shrink-0 ${sizeClasses.indicator} ${nodeInfo.isOnline ? 'bg-green-500' : 'bg-red-500'}`"
+    />
+    <div class="flex-1 min-w-0">
+      <div :class="`font-medium truncate text-gray-900 dark:text-gray-100 ${sizeClasses.nameText}`">
+        {{ nodeInfo.name }}
+      </div>
+      <div :class="`text-gray-500 dark:text-gray-400 ${sizeClasses.statusText}`">
+        {{ nodeInfo.isOnline ? $gettext('Online') : $gettext('Offline') }}
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+</style>

+ 1 - 0
app/src/components/NodeCard/index.ts

@@ -0,0 +1 @@
+export { default } from './NodeCard.vue'

+ 11 - 34
app/src/components/NodeSelector/NodeSelector.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
-import type { Environment } from '@/api/environment'
-import ws from '@/lib/websocket'
+import type { NodeStatus } from '@/pinia/moudule/nodeAvailability'
+import { useNodeAvailabilityStore } from '@/pinia/moudule/nodeAvailability'
 
 const props = defineProps<{
   hiddenLocal?: boolean
@@ -9,39 +9,16 @@ const props = defineProps<{
 const target = defineModel<number[]>('target')
 const map = defineModel<Record<number, string>>('map')
 
-const data = ref<Environment[]>([])
-const data_map = ref<Record<number, Environment>>({})
+const nodeStore = useNodeAvailabilityStore()
 
-// WebSocket connection for environment monitoring
-const socket = ws('/api/environments/enabled', true)
-
-socket.onmessage = event => {
-  try {
-    const message = JSON.parse(event.data)
-
-    if (message.event === 'message') {
-      const environments: Environment[] = message.data
-      data.value = environments
-      nextTick(() => {
-        data_map.value = data.value.reduce((acc, node) => {
-          acc[node.id] = node
-          return acc
-        }, {} as Record<number, Environment>)
-      })
-    }
-  }
-  catch (error) {
-    console.error('Error parsing WebSocket message:', error)
-  }
-}
-
-socket.onerror = error => {
-  console.warn('Failed to connect to environments WebSocket endpoint', error)
-}
-
-// Cleanup on unmount
-onUnmounted(() => {
-  socket.close()
+// Computed data based on store
+const data = computed(() => nodeStore.getAllNodes())
+const data_map = computed(() => {
+  const nodes = nodeStore.getAllNodes()
+  return nodes.reduce((acc, node) => {
+    acc[node.id] = node
+    return acc
+  }, {} as Record<number, NodeStatus>)
 })
 
 const value = computed({

+ 65 - 0
app/src/components/SyncNodesPreview/SyncNodesPreview.vue

@@ -0,0 +1,65 @@
+<script setup lang="ts">
+import type { EnvGroup } from '@/api/env_group'
+import envGroup from '@/api/env_group'
+import NodeCard from '@/components/NodeCard'
+
+const props = defineProps<{
+  envGroupId?: number | null
+  syncNodeIds?: number[]
+}>()
+
+// Get environment group info
+const envGroupInfo = ref<EnvGroup | null>(null)
+
+watch(() => props.envGroupId, async newEnvGroupId => {
+  if (!newEnvGroupId) {
+    envGroupInfo.value = null
+    return
+  }
+
+  try {
+    const response = await envGroup.getItem(newEnvGroupId)
+    envGroupInfo.value = response
+  }
+  catch (error) {
+    console.error('Failed to fetch env group:', error)
+    envGroupInfo.value = null
+  }
+}, { immediate: true })
+
+// Merge nodes from env group and manually selected nodes
+const allSyncNodeIds = computed(() => {
+  const envGroupNodes = envGroupInfo.value?.sync_node_ids || []
+  const manualNodes = props.syncNodeIds || []
+
+  // Merge and deduplicate
+  const allNodes = [...new Set([...envGroupNodes, ...manualNodes])]
+  return allNodes
+})
+</script>
+
+<template>
+  <div v-if="allSyncNodeIds.length > 0" class="my-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
+    <div class="mb-3">
+      <strong class="text-blue-800 dark:text-blue-300">
+        {{ $gettext('Sync Preview') }}
+      </strong>
+    </div>
+
+    <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
+      <NodeCard
+        v-for="nodeId in allSyncNodeIds"
+        :key="nodeId"
+        :node-id="nodeId"
+        size="sm"
+      />
+    </div>
+
+    <div v-if="envGroupInfo" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
+      {{ $gettext('* Includes nodes from group "%{groupName}" and manually selected nodes', { groupName: envGroupInfo.name }) }}
+    </div>
+  </div>
+</template>
+
+<style scoped>
+</style>

+ 1 - 0
app/src/components/SyncNodesPreview/index.ts

@@ -0,0 +1 @@
+export { default } from './SyncNodesPreview.vue'

+ 7 - 1
app/src/layouts/BaseLayout.vue

@@ -4,6 +4,7 @@ import { storeToRefs } from 'pinia'
 import settings from '@/api/settings'
 import PageHeader from '@/components/PageHeader'
 import { useProxyAvailabilityStore, useSettingsStore } from '@/pinia'
+import { useNodeAvailabilityStore } from '@/pinia/moudule/nodeAvailability'
 import FooterLayout from './FooterLayout.vue'
 import HeaderLayout from './HeaderLayout.vue'
 import SideBar from './SideBar.vue'
@@ -35,8 +36,9 @@ settings.get_server_name().then(r => {
   server_name.value = r.name
 })
 
-// Initialize proxy availability monitoring after user is logged in and layout is mounted
+// Initialize stores monitoring after user is logged in and layout is mounted
 const proxyAvailabilityStore = useProxyAvailabilityStore()
+const nodeAvailabilityStore = useNodeAvailabilityStore()
 
 onMounted(() => {
   // Initialize layout
@@ -44,6 +46,9 @@ onMounted(() => {
 
   // Start monitoring for upstream availability
   proxyAvailabilityStore.startMonitoring()
+
+  // Start monitoring for node availability
+  nodeAvailabilityStore.startMonitoring()
 })
 
 onUnmounted(() => {
@@ -52,6 +57,7 @@ onUnmounted(() => {
 
   // Stop monitoring when layout is unmounted
   proxyAvailabilityStore.stopMonitoring()
+  nodeAvailabilityStore.stopMonitoring()
 })
 
 const breadList = ref([])

+ 157 - 0
app/src/pinia/moudule/nodeAvailability.ts

@@ -0,0 +1,157 @@
+import type ReconnectingWebSocket from 'reconnecting-websocket'
+import type { Environment } from '@/api/environment'
+import { defineStore } from 'pinia'
+import ws from '@/lib/websocket'
+
+export interface NodeStatus {
+  id: number
+  name: string
+  status: boolean
+  url?: string
+  token?: string
+  enabled?: boolean
+}
+
+export const useNodeAvailabilityStore = defineStore('nodeAvailability', () => {
+  const nodes = ref<Record<number, NodeStatus>>({})
+  const websocket = shallowRef<ReconnectingWebSocket | WebSocket>()
+  const isConnected = ref(false)
+  const isInitialized = ref(false)
+  const lastUpdateTime = ref<string>('')
+
+  // Initialize node data from WebSocket
+  function initialize() {
+    if (isInitialized.value) {
+      return
+    }
+
+    connectWebSocket()
+    isInitialized.value = true
+  }
+
+  // Connect to WebSocket for real-time updates
+  function connectWebSocket() {
+    if (websocket.value && isConnected.value) {
+      return
+    }
+
+    // Close existing connection if any
+    if (websocket.value) {
+      websocket.value.close()
+    }
+
+    try {
+      // Create new WebSocket connection
+      const socket = ws('/api/environments/enabled', true)
+      websocket.value = socket
+
+      socket.onopen = () => {
+        isConnected.value = true
+      }
+
+      socket.onmessage = event => {
+        try {
+          const message = JSON.parse(event.data)
+
+          if (message.event === 'message') {
+            const environments: Environment[] = message.data
+            const nodeMap: Record<number, NodeStatus> = {}
+
+            environments.forEach(env => {
+              nodeMap[env.id] = {
+                id: env.id,
+                name: env.name,
+                status: env.status ?? false,
+                url: env.url,
+                token: env.token,
+                enabled: true,
+              }
+            })
+
+            nodes.value = nodeMap
+            lastUpdateTime.value = new Date().toISOString()
+          }
+        }
+        catch (error) {
+          console.error('Error parsing WebSocket message:', error)
+        }
+      }
+
+      socket.onclose = () => {
+        isConnected.value = false
+      }
+
+      socket.onerror = error => {
+        console.warn('Failed to connect to environments WebSocket endpoint', error)
+        isConnected.value = false
+      }
+    }
+    catch (error) {
+      console.error('Failed to create WebSocket connection:', error)
+    }
+  }
+
+  // Start monitoring (initialize + WebSocket)
+  function startMonitoring() {
+    initialize()
+  }
+
+  // Stop monitoring and cleanup
+  function stopMonitoring() {
+    if (websocket.value) {
+      websocket.value.close()
+      websocket.value = undefined
+      isConnected.value = false
+    }
+  }
+
+  // Get node status by ID
+  function getNodeStatus(nodeId: number): NodeStatus | undefined {
+    return nodes.value[nodeId]
+  }
+
+  // Get all nodes as array
+  function getAllNodes(): NodeStatus[] {
+    return Object.values(nodes.value)
+  }
+
+  // Get enabled nodes only
+  function getEnabledNodes(): NodeStatus[] {
+    return Object.values(nodes.value).filter(node => node.enabled)
+  }
+
+  // Check if node is online
+  function isNodeOnline(nodeId: number): boolean {
+    const node = nodes.value[nodeId]
+    return node?.status ?? false
+  }
+
+  // Get node name by ID
+  function getNodeName(nodeId: number): string {
+    const node = nodes.value[nodeId]
+    return node?.name ?? ''
+  }
+
+  // Auto-cleanup WebSocket on page unload
+  if (typeof window !== 'undefined') {
+    window.addEventListener('beforeunload', () => {
+      stopMonitoring()
+    })
+  }
+
+  return {
+    nodes: readonly(nodes),
+    isConnected: readonly(isConnected),
+    isInitialized: readonly(isInitialized),
+    lastUpdateTime: readonly(lastUpdateTime),
+    initialize,
+    startMonitoring,
+    stopMonitoring,
+    connectWebSocket,
+    getNodeStatus,
+    getAllNodes,
+    getEnabledNodes,
+    isNodeOnline,
+    getNodeName,
+  }
+})

+ 12 - 7
app/src/views/dashboard/Environments.vue

@@ -1,5 +1,4 @@
 <script setup lang="ts">
-import type ReconnectingWebSocket from 'reconnecting-websocket'
 import type { Ref } from 'vue'
 import type { Node } from '@/api/environment'
 import Icon, { LinkOutlined, ThunderboltOutlined } from '@ant-design/icons-vue'
@@ -9,9 +8,11 @@ import logo from '@/assets/img/logo.png'
 import pulse from '@/assets/svg/pulse.svg?component'
 import { formatDateTime } from '@/lib/helper'
 import { useSettingsStore } from '@/pinia'
+import { useNodeAvailabilityStore } from '@/pinia/moudule/nodeAvailability'
 import { version } from '@/version.json'
 import NodeAnalyticItem from './components/NodeAnalyticItem.vue'
 
+const nodeStore = useNodeAvailabilityStore()
 const data = ref([]) as Ref<Node[]>
 
 const nodeMap = computed(() => {
@@ -24,8 +25,6 @@ const nodeMap = computed(() => {
   return o
 })
 
-let websocket: ReconnectingWebSocket | WebSocket
-
 onMounted(() => {
   environment.getList({ enabled: true }).then(r => {
     data.value.push(...r.data)
@@ -33,7 +32,7 @@ onMounted(() => {
 })
 
 onMounted(() => {
-  websocket = analytic.nodes()
+  const websocket = analytic.nodes()
   websocket.onmessage = async m => {
     const nodes = JSON.parse(m.data)
 
@@ -44,13 +43,19 @@ onMounted(() => {
       if (nodeMap.value[key]) {
         Object.assign(nodeMap.value[key], nodes[key])
         nodeMap.value[key].response_at = new Date()
+
+        // Also update global store
+        const nodeStatus = nodeStore.getNodeStatus(key)
+        if (nodeStatus) {
+          nodeStatus.status = nodes[key].status ?? false
+        }
       }
     })
   }
-})
 
-onUnmounted(() => {
-  websocket.close()
+  onUnmounted(() => {
+    websocket.close()
+  })
 })
 
 const { environment: env } = useSettingsStore()

+ 30 - 0
app/src/views/environments/group/columns.ts

@@ -1,6 +1,7 @@
 import type { StdTableColumn } from '@uozi-admin/curd'
 import { datetimeRender } from '@uozi-admin/curd'
 import { PostSyncAction } from '@/api/env_group'
+import { useNodeAvailabilityStore } from '@/pinia/moudule/nodeAvailability'
 
 const columns: StdTableColumn[] = [{
   dataIndex: 'name',
@@ -11,6 +12,35 @@ const columns: StdTableColumn[] = [{
   },
   pure: true,
   width: 120,
+}, {
+  title: () => $gettext('Sync Nodes'),
+  dataIndex: 'sync_node_ids',
+  customRender: ({ text }) => {
+    const nodeStore = useNodeAvailabilityStore()
+
+    if (!text || text.length === 0) {
+      return h('span', { class: 'text-gray-400' }, '-')
+    }
+
+    const nodeElements = text.map((nodeId: number) => {
+      const nodeStatus = nodeStore.getNodeStatus(nodeId)
+      const nodeName = nodeStatus?.name || `Node ${nodeId}`
+      const isOnline = nodeStatus?.status ?? false
+
+      return h('div', {
+        class: 'inline-flex items-center mr-2 mb-1',
+      }, [
+        h('span', {
+          class: `inline-block w-2 h-2 rounded-full mr-1 flex-shrink-0 ${isOnline ? 'bg-green-500' : 'bg-red-500'}`,
+        }),
+        h('span', nodeName),
+      ])
+    })
+
+    return h('div', { class: 'flex flex-wrap' }, nodeElements)
+  },
+  pure: true,
+  width: 200,
 }, {
   title: () => $gettext('Post-sync Action'),
   dataIndex: 'post_sync_action',

+ 7 - 0
app/src/views/site/site_edit/components/RightPanel/Basic.vue

@@ -4,6 +4,7 @@ import { InfoCircleOutlined } from '@ant-design/icons-vue'
 import { StdSelector } from '@uozi-admin/curd'
 import envGroup from '@/api/env_group'
 import NodeSelector from '@/components/NodeSelector'
+import SyncNodesPreview from '@/components/SyncNodesPreview'
 import { formatDateTime } from '@/lib/helper'
 import { useSettingsStore } from '@/pinia'
 import envGroupColumns from '@/views/environments/group/columns'
@@ -77,6 +78,12 @@ function handleStatusChanged(event: { status: SiteStatus }) {
         class="mb-4"
         hidden-local
       />
+
+      <!-- Sync nodes preview -->
+      <SyncNodesPreview
+        :env-group-id="data.env_group_id"
+        :sync-node-ids="data.sync_node_ids"
+      />
     </div>
   </div>
 </template>

+ 7 - 2
app/src/views/site/site_list/columns.tsx

@@ -4,9 +4,10 @@ import type {
 } from '@uozi-admin/curd'
 import type { Site, SiteStatus } from '@/api/site'
 import type { JSXElements } from '@/types'
-import { actualFieldRender, datetimeRender } from '@uozi-admin/curd'
+import { datetimeRender } from '@uozi-admin/curd'
 import { Tag } from 'ant-design-vue'
 import env_group from '@/api/env_group'
+import EnvGroupRender from '@/components/EnvGroupRender'
 import ProxyTargets from '@/components/ProxyTargets'
 import { ConfigStatus } from '@/constants'
 import envGroupColumns from '@/views/environments/group/columns'
@@ -88,7 +89,11 @@ const columns: StdTableColumn[] = [{
 }, {
   title: () => $gettext('Node Group'),
   dataIndex: 'env_group_id',
-  customRender: actualFieldRender('env_group.name'),
+  customRender: ({ record }: CustomRenderArgs<Site>) => {
+    return h(EnvGroupRender, {
+      envGroup: record.env_group || null,
+    })
+  },
   edit: {
     type: 'selector',
     selector: {

+ 7 - 2
app/src/views/stream/columns.tsx

@@ -2,8 +2,9 @@ import type { CustomRenderArgs, StdTableColumn } from '@uozi-admin/curd'
 import type { SiteStatus } from '@/api/site'
 import type { Stream } from '@/api/stream'
 import type { JSXElements } from '@/types'
-import { actualFieldRender, datetimeRender } from '@uozi-admin/curd'
+import { datetimeRender } from '@uozi-admin/curd'
 import env_group from '@/api/env_group'
+import EnvGroupRender from '@/components/EnvGroupRender'
 import ProxyTargets from '@/components/ProxyTargets'
 import envGroupColumns from '@/views/environments/group/columns'
 import StreamStatusSelect from '@/views/stream/components/StreamStatusSelect.vue'
@@ -56,7 +57,11 @@ const columns: StdTableColumn[] = [{
 }, {
   title: () => $gettext('Node Group'),
   dataIndex: 'env_group_id',
-  customRender: actualFieldRender('env_group.name'),
+  customRender: ({ record }: CustomRenderArgs<Stream>) => {
+    return h(EnvGroupRender, {
+      envGroup: record.env_group || null,
+    })
+  },
   edit: {
     type: 'selector',
     selector: {

+ 7 - 0
app/src/views/stream/components/RightPanel/Basic.vue

@@ -4,6 +4,7 @@ import { StdSelector } from '@uozi-admin/curd'
 import { storeToRefs } from 'pinia'
 import envGroup from '@/api/env_group'
 import NodeSelector from '@/components/NodeSelector'
+import SyncNodesPreview from '@/components/SyncNodesPreview'
 import { formatDateTime } from '@/lib/helper'
 import { useSettingsStore } from '@/pinia'
 import envGroupColumns from '@/views/environments/group/columns'
@@ -72,6 +73,12 @@ const showSync = computed(() => !settings.is_remote)
         class="mb-4"
         hidden-local
       />
+
+      <!-- Sync nodes preview -->
+      <SyncNodesPreview
+        :env-group-id="data.env_group_id"
+        :sync-node-ids="data.sync_node_ids"
+      />
     </div>
   </div>
 </template>