Переглянути джерело

refactor: add config to disable site health check #1427, #1415, #1413

0xJacky 2 місяців тому
батько
коміт
cfb6cae78a

+ 81 - 29
api/nginx/websocket.go

@@ -21,6 +21,36 @@ type PerformanceClient struct {
 	ctx    context.Context
 	cancel context.CancelFunc
 	mutex  sync.RWMutex
+	closed bool
+}
+
+func (c *PerformanceClient) trySend(message interface{}) bool {
+	c.mutex.RLock()
+	if c.closed {
+		c.mutex.RUnlock()
+		return false
+	}
+
+	select {
+	case c.send <- message:
+		c.mutex.RUnlock()
+		return true
+	default:
+		c.mutex.RUnlock()
+		return false
+	}
+}
+
+func (c *PerformanceClient) closeSendChannel() {
+	c.mutex.Lock()
+	if c.closed {
+		c.mutex.Unlock()
+		return
+	}
+
+	close(c.send)
+	c.closed = true
+	c.mutex.Unlock()
 }
 
 // PerformanceHub manages WebSocket connections for Nginx performance monitoring
@@ -60,20 +90,18 @@ func (h *PerformanceHub) run() {
 		case client := <-h.register:
 			h.mutex.Lock()
 			h.clients[client] = true
+			currentClients := len(h.clients)
 			h.mutex.Unlock()
-			logger.Debug("Nginx performance client connected, total clients:", len(h.clients))
+			logger.Debug("Nginx performance client connected, total clients:", currentClients)
 
 			// Send initial data to the new client
 			go h.sendPerformanceDataToClient(client)
 
 		case client := <-h.unregister:
-			h.mutex.Lock()
-			if _, ok := h.clients[client]; ok {
-				delete(h.clients, client)
-				close(client.send)
+			currentClients, removed := h.removeClient(client)
+			if removed {
+				logger.Debug("Nginx performance client disconnected, total clients:", currentClients)
 			}
-			h.mutex.Unlock()
-			logger.Debug("Nginx performance client disconnected, total clients:", len(h.clients))
 
 		case <-h.ticker.C:
 			// Send performance data to all connected clients
@@ -82,24 +110,55 @@ func (h *PerformanceHub) run() {
 		case <-kernel.Context.Done():
 			logger.Debug("PerformanceHub: Context cancelled, closing WebSocket")
 			// Shutdown all clients
-			h.mutex.Lock()
-			for client := range h.clients {
-				close(client.send)
-				delete(h.clients, client)
+			for _, client := range h.activeClients() {
+				h.removeClient(client)
 			}
-			h.mutex.Unlock()
 			return
 		}
 	}
 }
 
+func (h *PerformanceHub) activeClients() []*PerformanceClient {
+	h.mutex.RLock()
+	if len(h.clients) == 0 {
+		h.mutex.RUnlock()
+		return nil
+	}
+
+	clients := make([]*PerformanceClient, 0, len(h.clients))
+	for client := range h.clients {
+		clients = append(clients, client)
+	}
+	h.mutex.RUnlock()
+	return clients
+}
+
+func (h *PerformanceHub) removeClient(client *PerformanceClient) (remaining int, removed bool) {
+	h.mutex.Lock()
+	_, removed = h.clients[client]
+	if removed {
+		delete(h.clients, client)
+	}
+	remaining = len(h.clients)
+	h.mutex.Unlock()
+
+	if removed {
+		client.closeSendChannel()
+	}
+	return remaining, removed
+}
+
 // sendPerformanceDataToClient sends performance data to a specific client
 func (h *PerformanceHub) sendPerformanceDataToClient(client *PerformanceClient) {
-	response := performance.GetPerformanceData()
-
 	select {
-	case client.send <- response:
+	case <-client.ctx.Done():
+		return
 	default:
+	}
+
+	response := performance.GetPerformanceData()
+
+	if !client.trySend(response) {
 		// Channel is full, remove client
 		h.unregister <- client
 	}
@@ -107,27 +166,20 @@ func (h *PerformanceHub) sendPerformanceDataToClient(client *PerformanceClient)
 
 // broadcastPerformanceData sends performance data to all connected clients
 func (h *PerformanceHub) broadcastPerformanceData() {
-	h.mutex.RLock()
-
-	// Check if there are any connected clients
-	if len(h.clients) == 0 {
-		h.mutex.RUnlock()
+	clients := h.activeClients()
+	if len(clients) == 0 {
 		return
 	}
 
-	// Only get performance data if there are connected clients
 	response := performance.GetPerformanceData()
 
-	for client := range h.clients {
-		select {
-		case client.send <- response:
-		default:
-			// Channel is full, remove client
-			close(client.send)
-			delete(h.clients, client)
+	for _, client := range clients {
+		if client.trySend(response) {
+			continue
 		}
+
+		h.removeClient(client)
 	}
-	h.mutex.RUnlock()
 }
 
 // WebSocket upgrader configuration

+ 1 - 1
api/sites/router.go

@@ -18,7 +18,7 @@ func InitRouter(r *gin.RouterGroup) {
 	r.GET("site_navigation/status", GetSiteNavigationStatus)
 	r.POST("site_navigation/order", UpdateSiteOrder)
 	r.GET("site_navigation/health_check/:id", GetHealthCheck)
-	r.PUT("site_navigation/health_check/:id", UpdateHealthCheck)
+	r.POST("site_navigation/health_check/:id", UpdateHealthCheck)
 	r.POST("site_navigation/test_health_check/:id", TestHealthCheck)
 	r.GET("site_navigation_ws", SiteNavigationWebSocket)
 

+ 16 - 39
api/sites/sitecheck.go

@@ -12,6 +12,7 @@ import (
 	"github.com/spf13/cast"
 	"github.com/uozi-tech/cosy"
 	"github.com/uozi-tech/cosy/logger"
+	"gorm.io/gorm/clause"
 )
 
 // GetSiteNavigation returns all sites for navigation dashboard
@@ -54,16 +55,26 @@ func UpdateSiteOrder(c *gin.Context) {
 }
 
 // updateSiteOrderBatchByIds updates site order in batch using IDs
+// Uses INSERT INTO ... ON DUPLICATE KEY UPDATE for better performance
 func updateSiteOrderBatchByIds(orderedIds []uint64) error {
+	if len(orderedIds) == 0 {
+		return nil
+	}
+
 	sc := query.SiteConfig
 
+	records := make([]*model.SiteConfig, 0, len(orderedIds))
 	for i, id := range orderedIds {
-		if _, err := sc.Where(sc.ID.Eq(id)).Update(sc.CustomOrder, i); err != nil {
-			return err
-		}
+		records = append(records, &model.SiteConfig{
+			Model:       model.Model{ID: id},
+			CustomOrder: i,
+		})
 	}
 
-	return nil
+	return sc.Clauses(clause.OnConflict{
+		Columns:   []clause.Column{{Name: "id"}},
+		DoUpdates: clause.AssignmentColumns([]string{"custom_order"}),
+	}).Create(records...)
 }
 
 // GetHealthCheck gets health check configuration for a site
@@ -102,41 +113,7 @@ func ensureHealthCheckConfig(siteConfig *model.SiteConfig) {
 
 // UpdateHealthCheck updates health check configuration for a site
 func UpdateHealthCheck(c *gin.Context) {
-	id := cast.ToUint64(c.Param("id"))
-
-	var req model.SiteConfig
-
-	if !cosy.BindAndValid(c, &req) {
-		return
-	}
-
-	sc := query.SiteConfig
-	siteConfig, err := sc.Where(sc.ID.Eq(id)).First()
-	if err != nil {
-		cosy.ErrHandler(c, err)
-		return
-	}
-
-	siteConfig.HealthCheckEnabled = req.HealthCheckEnabled
-	siteConfig.CheckInterval = req.CheckInterval
-	siteConfig.Timeout = req.Timeout
-	siteConfig.UserAgent = req.UserAgent
-	siteConfig.MaxRedirects = req.MaxRedirects
-	siteConfig.FollowRedirects = req.FollowRedirects
-	siteConfig.CheckFavicon = req.CheckFavicon
-
-	if req.HealthCheckConfig != nil {
-		siteConfig.HealthCheckConfig = req.HealthCheckConfig
-	}
-
-	if err = query.SiteConfig.Save(siteConfig); err != nil {
-		cosy.ErrHandler(c, err)
-		return
-	}
-
-	c.JSON(http.StatusOK, gin.H{
-		"message": "Health check configuration updated successfully",
-	})
+	cosy.Core[model.SiteConfig](c).Modify()
 }
 
 // TestHealthCheck tests a health check configuration without saving it

+ 0 - 1
api/sites/websocket.go

@@ -74,7 +74,6 @@ func (wm *WSManager) BroadcastUpdate(sites []*sitecheck.SiteInfo) {
 	for conn := range wm.connections {
 		go func(c *websocket.Conn) {
 			if err := sendSiteData(c, MessageTypeUpdate, sites); err != nil {
-				logger.Error("Failed to send broadcast update:", err)
 				wm.RemoveConnection(c)
 				c.Close()
 			}

+ 3 - 1
app/src/api/site_navigation.ts

@@ -3,6 +3,7 @@ import { http } from '@uozi-admin/request'
 
 export interface SiteInfo {
   id: number // primary identifier for API operations
+  health_check_enabled: boolean // whether health check is enabled
   host: string // host:port format
   port: number
   scheme: string // http, https, grpc, grpcs
@@ -23,6 +24,7 @@ export interface SiteInfo {
 }
 
 export interface HealthCheckConfig {
+  health_check_enabled?: boolean
   check_interval?: number
   timeout?: number
   user_agent?: string
@@ -135,7 +137,7 @@ export const siteNavigationApi = {
 
   // Update health check configuration
   updateHealthCheck(id: number, config: HealthCheckConfig): Promise<{ message: string }> {
-    return http.put(`/site_navigation/health_check/${id}`, config)
+    return http.post(`/site_navigation/health_check/${id}`, config)
   },
 
   // Test health check configuration

+ 0 - 4
app/src/components/PageHeader/PageHeader.vue

@@ -56,10 +56,6 @@ const name = computed(() => {
     margin-bottom: 16px;
   }
 
-  @media (max-height: 800px) {
-    display: none;
-  }
-
   .detail {
     display: flex;
     /*margin-bottom: 16px;*/

+ 51 - 136
app/src/views/dashboard/SiteNavigation.vue

@@ -1,8 +1,9 @@
 <script setup lang="ts">
 import type { SiteInfo } from '@/api/site_navigation'
 import { GlobalOutlined } from '@ant-design/icons-vue'
-import Sortable from 'sortablejs'
+import VueDraggable from 'vuedraggable'
 import { siteNavigationApi } from '@/api/site_navigation'
+import { useWebSocket } from '@/lib/websocket'
 import SiteCard from './components/SiteCard.vue'
 import SiteHealthCheckModal from './components/SiteHealthCheckModal.vue'
 import SiteNavigationToolbar from './components/SiteNavigationToolbar.vue'
@@ -11,64 +12,26 @@ const sites = ref<SiteInfo[]>([])
 const { message } = useGlobalApp()
 const loading = ref(true)
 const refreshing = ref(false)
-const isConnected = ref(false)
 const settingsMode = ref(false)
 const draggableSites = ref<SiteInfo[]>([])
 const configModalVisible = ref(false)
 const configTarget = ref<SiteInfo>()
 
-let sortableInstance: Sortable | null = null
-let websocket: WebSocket | null = null
-
-// Display sites - use draggable sites in settings mode, backend sorted sites otherwise
-const displaySites = computed(() => {
-  return settingsMode.value ? draggableSites.value : sites.value
-})
-
-// WebSocket connection
-async function connectWebSocket() {
-  try {
-    const { useWebSocket } = await import('@/lib/websocket')
-    const { ws } = useWebSocket(siteNavigationApi.websocketUrl)
-    websocket = ws.value!
-
-    if (!websocket) {
-      isConnected.value = false
-      return
-    }
+watch(sites, newSites => {
+  if (!settingsMode.value) {
+    draggableSites.value = newSites
+  }
+}, { immediate: true })
 
-    websocket.onopen = () => {
-      isConnected.value = true
-    }
+const { status, data, send, close } = useWebSocket(siteNavigationApi.websocketUrl)
+const isConnected = computed(() => status.value === 'OPEN')
 
-    websocket.onmessage = (event: MessageEvent) => {
-      try {
-        const data = JSON.parse(event.data)
-        if (data.type === 'initial' || data.type === 'update') {
-          sites.value = data.data || []
-        }
-      }
-      catch (error) {
-        console.error('Failed to parse WebSocket message:', error)
-      }
-    }
-
-    websocket.onclose = () => {
-      isConnected.value = false
-    }
-
-    websocket.onerror = error => {
-      console.error('Site navigation WebSocket error:', error)
-      isConnected.value = false
-    }
+watch(data, newData => {
+  if (newData.type === 'initial' || newData.type === 'update') {
+    sites.value = newData.data || []
   }
-  catch (error) {
-    console.error('Failed to connect WebSocket:', error)
-    isConnected.value = false
-  }
-}
+})
 
-// Load sites via HTTP (fallback)
 async function loadSites() {
   try {
     loading.value = true
@@ -83,19 +46,11 @@ async function loadSites() {
   }
 }
 
-// Refresh sites
 async function handleRefresh() {
   try {
     refreshing.value = true
-
-    // Only use WebSocket refresh
-    if (websocket && isConnected.value) {
-      websocket.send(JSON.stringify({ type: 'refresh' }))
-      message.success($gettext('Site refresh initiated'))
-    }
-    else {
-      message.warning($gettext('WebSocket not connected, please wait for connection'))
-    }
+    send(JSON.stringify({ type: 'refresh' }))
+    message.success($gettext('Site refresh initiated'))
   }
   catch (error) {
     console.error('Failed to refresh sites:', error)
@@ -106,61 +61,20 @@ async function handleRefresh() {
   }
 }
 
-// Toggle settings mode
 function toggleSettingsMode() {
   settingsMode.value = !settingsMode.value
-
   if (settingsMode.value) {
     draggableSites.value = [...sites.value]
-    nextTick(() => initSortable())
-  }
-  else {
-    destroySortable()
-  }
-}
-
-// Initialize sortable
-function initSortable() {
-  const gridElement = document.querySelector('.site-grid')
-  if (gridElement && !sortableInstance) {
-    sortableInstance = new Sortable(gridElement as HTMLElement, {
-      animation: 150,
-      ghostClass: 'site-card-ghost',
-      chosenClass: 'site-card-chosen',
-      dragClass: 'site-card-drag',
-      onEnd: () => {
-        // Update draggableSites order based on DOM order
-        const cards = Array.from(gridElement.children)
-        const newOrder = cards.map(card => {
-          const url = card.getAttribute('data-url')
-          return draggableSites.value.find(site => site.url === url)!
-        })
-        draggableSites.value = newOrder
-      },
-    })
-  }
-}
-
-// Destroy sortable
-function destroySortable() {
-  if (sortableInstance) {
-    sortableInstance.destroy()
-    sortableInstance = null
   }
 }
 
-// Save order
 async function saveOrder() {
   try {
     const orderedIds = draggableSites.value.map(site => site.id)
     await siteNavigationApi.updateOrder(orderedIds)
     message.success($gettext('Order saved successfully'))
-
-    // Update sites.value immediately to reflect the new order
     sites.value = [...draggableSites.value]
-
     settingsMode.value = false
-    destroySortable()
   }
   catch (error) {
     console.error('Failed to save order:', error)
@@ -168,20 +82,16 @@ async function saveOrder() {
   }
 }
 
-// Cancel settings mode
 function cancelSettingsMode() {
+  draggableSites.value = [...sites.value]
   settingsMode.value = false
-  destroySortable()
-  draggableSites.value = []
 }
 
-// Open config modal
 function openConfigModal(site: SiteInfo) {
   configTarget.value = site
   configModalVisible.value = true
 }
 
-// Handle health check config save
 async function handleConfigSave(config: import('@/api/site_navigation').HealthCheckConfig) {
   try {
     if (configTarget.value) {
@@ -195,38 +105,37 @@ async function handleConfigSave(config: import('@/api/site_navigation').HealthCh
   }
 }
 
+const mounted = ref(false)
+
 onMounted(async () => {
-  // First load data via HTTP
   await loadSites()
-  // Then connect WebSocket for real-time updates
-  connectWebSocket()
+  mounted.value = true
 })
 
 onUnmounted(() => {
-  destroySortable()
-  if (websocket) {
-    websocket.close()
-  }
+  close()
 })
 </script>
 
 <template>
   <div class="site-navigation">
-    <SiteNavigationToolbar
-      :is-connected="isConnected"
-      :refreshing="refreshing"
-      :settings-mode="settingsMode"
-      @refresh="handleRefresh"
-      @toggle-settings="toggleSettingsMode"
-      @save-order="saveOrder"
-      @cancel-settings="cancelSettingsMode"
-    />
+    <Teleport v-if="mounted" to=".action">
+      <SiteNavigationToolbar
+        :is-connected="isConnected"
+        :refreshing="refreshing"
+        :settings-mode="settingsMode"
+        @refresh="handleRefresh"
+        @toggle-settings="toggleSettingsMode"
+        @save-order="saveOrder"
+        @cancel-settings="cancelSettingsMode"
+      />
+    </Teleport>
 
     <div v-if="loading" class="flex items-center justify-center py-12">
       <ASpin size="large" />
     </div>
 
-    <div v-else-if="displaySites.length === 0" class="empty-state">
+    <div v-else-if="draggableSites.length === 0" class="empty-state">
       <GlobalOutlined class="text-6xl text-gray-400 mb-4" />
       <h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
         {{ $gettext('No sites found') }}
@@ -236,15 +145,25 @@ onUnmounted(() => {
       </p>
     </div>
 
-    <div v-else class="site-grid">
-      <SiteCard
-        v-for="site in displaySites"
-        :key="site.id"
-        :site="site"
-        :settings-mode="settingsMode"
-        @open-config="openConfigModal"
-      />
-    </div>
+    <VueDraggable
+      v-else
+      v-model="draggableSites"
+      :disabled="!settingsMode"
+      class="site-grid"
+      item-key="id"
+      :animation="150"
+      ghost-class="site-card-ghost"
+      chosen-class="site-card-chosen"
+      drag-class="site-card-drag"
+    >
+      <template #item="{ element }">
+        <SiteCard
+          :site="element"
+          :settings-mode="settingsMode"
+          @open-config="openConfigModal"
+        />
+      </template>
+    </VueDraggable>
 
     <SiteHealthCheckModal
       v-model:open="configModalVisible"
@@ -256,10 +175,6 @@ onUnmounted(() => {
 </template>
 
 <style scoped>
-.site-navigation {
-  @apply p-6;
-}
-
 .empty-state {
   @apply flex flex-col items-center justify-center py-16 text-center;
 }

+ 3 - 3
app/src/views/dashboard/components/SiteCard.vue

@@ -130,7 +130,7 @@ function getStatusClass(status: string): string {
         </div>
       </div>
 
-      <div v-if="!settingsMode" class="site-status">
+      <div v-if="!settingsMode && site.health_check_enabled" class="site-status">
         <div
           class="status-indicator"
           :class="getStatusClass(site.status)"
@@ -293,11 +293,11 @@ function getStatusClass(status: string): string {
 }
 
 .site-card-config {
-  @apply absolute top-2 right-2;
+  @apply absolute top-3 right-3 opacity-50;
 }
 
 .drag-handle {
-  @apply absolute bottom-2 right-2 opacity-50 hover:opacity-100 transition-opacity;
+  @apply absolute bottom-3 right-3 opacity-50 hover:opacity-100 transition-opacity;
 }
 
 .drag-dots {

+ 18 - 12
app/src/views/dashboard/components/SiteHealthCheckModal.vue

@@ -4,12 +4,10 @@ import { CloseOutlined, PlusOutlined } from '@ant-design/icons-vue'
 import { siteNavigationApi } from '@/api/site_navigation'
 
 interface Props {
-  open: boolean
   site?: SiteInfo
 }
 
 interface Emits {
-  (e: 'update:open', value: boolean): void
   (e: 'save', config: EnhancedHealthCheckConfig): void
   (e: 'refresh'): void
 }
@@ -18,13 +16,9 @@ const props = defineProps<Props>()
 const emit = defineEmits<Emits>()
 const { message } = useGlobalApp()
 
+const visible = defineModel<boolean>('open', { required: true })
 const testing = ref(false)
 
-const visible = computed({
-  get: () => props.open,
-  set: value => emit('update:open', value),
-})
-
 const formData = ref<EnhancedHealthCheckConfig>({
   // Basic settings (health check is always enabled)
   enabled: true,
@@ -76,8 +70,8 @@ async function loadExistingConfig() {
 
     // Convert backend config to frontend format
     formData.value = {
-      // Basic settings (health check is always enabled)
-      enabled: true,
+      // Basic settings
+      enabled: config.health_check_enabled ?? true,
       interval: config.check_interval ?? 300,
       timeout: config.timeout ?? 10,
       userAgent: config.user_agent ?? 'Nginx-UI Enhanced Checker/2.0',
@@ -268,7 +262,7 @@ async function handleSave() {
     // Create the config object for the backend
     const backendConfig = {
       url: props.site.url,
-      health_check_enabled: true, // Always enabled
+      health_check_enabled: config.enabled,
       check_interval: config.interval,
       timeout: config.timeout,
       user_agent: config.userAgent,
@@ -276,7 +270,7 @@ async function handleSave() {
       follow_redirects: config.followRedirects,
       check_favicon: config.checkFavicon,
 
-      // Enhanced health check config (always included)
+      // Enhanced health check config
       health_check_config: {
         protocol: config.protocol,
         method: config.method,
@@ -366,7 +360,7 @@ async function handleTest() {
     width="800px"
     @cancel="handleCancel"
   >
-    <div class="p-2">
+    <div>
       <AForm
         :model="formData"
         layout="vertical"
@@ -374,6 +368,18 @@ async function handleTest() {
         :wrapper-col="{ span: 24 }"
       >
         <div>
+          <!-- Enable/Disable Health Check -->
+          <AFormItem :label="$gettext('Enable Health Check')">
+            <div class="flex items-center gap-2">
+              <ASwitch v-model:checked="formData.enabled" />
+              <span class="text-sm text-gray-500 dark:text-gray-400">
+                {{ formData.enabled ? $gettext('Health check is enabled') : $gettext('Health check is disabled') }}
+              </span>
+            </div>
+          </AFormItem>
+
+          <ADivider />
+
           <!-- Protocol Selection -->
           <AFormItem :label="$gettext('Protocol')">
             <ARadioGroup v-model:value="formData.protocol">

+ 1 - 12
app/src/views/dashboard/components/SiteNavigationToolbar.vue

@@ -25,10 +25,6 @@ defineEmits<Emits>()
 
 <template>
   <div class="site-navigation-header">
-    <h2 class="text-2xl font-500 text-gray-900 dark:text-gray-100 mb-4">
-      {{ $gettext('Site Navigation') }}
-    </h2>
-
     <div class="flex items-center gap-4">
       <div class="flex items-center gap-2">
         <div
@@ -44,7 +40,6 @@ defineEmits<Emits>()
         <AButton
           v-if="settingsMode"
           type="primary"
-          size="small"
           @click="$emit('saveOrder')"
         >
           <template #icon>
@@ -55,37 +50,31 @@ defineEmits<Emits>()
 
         <AButton
           v-if="settingsMode"
-          size="small"
           @click="$emit('cancelSettings')"
         >
           <template #icon>
             <CloseOutlined />
           </template>
-          {{ $gettext('Cancel') }}
         </AButton>
 
         <AButton
           v-if="!settingsMode"
           type="primary"
-          size="small"
           :loading="refreshing"
           @click="$emit('refresh')"
         >
           <template #icon>
             <ReloadOutlined />
           </template>
-          {{ $gettext('Refresh') }}
         </AButton>
 
         <AButton
           v-if="!settingsMode"
-          size="small"
           @click="$emit('toggleSettings')"
         >
           <template #icon>
             <SettingOutlined />
           </template>
-          {{ $gettext('Settings') }}
         </AButton>
       </div>
     </div>
@@ -94,7 +83,7 @@ defineEmits<Emits>()
 
 <style scoped>
 .site-navigation-header {
-  @apply flex items-center justify-between mb-6;
+  @apply flex items-center justify-end;
 }
 
 /* Responsive design */

+ 16 - 16
go.mod

@@ -8,7 +8,7 @@ require (
 	github.com/BurntSushi/toml v1.5.0
 	github.com/blevesearch/bleve/v2 v2.5.4
 	github.com/caarlos0/env/v11 v11.3.1
-	github.com/casdoor/casdoor-go-sdk v1.29.0
+	github.com/casdoor/casdoor-go-sdk v1.30.0
 	github.com/creack/pty v1.1.24
 	github.com/dgraph-io/ristretto/v2 v2.3.0
 	github.com/docker/docker v28.5.2+incompatible
@@ -25,7 +25,7 @@ require (
 	github.com/go-playground/validator/v10 v10.28.0
 	github.com/go-resty/resty/v2 v2.16.5
 	github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible
-	github.com/go-webauthn/webauthn v0.14.0
+	github.com/go-webauthn/webauthn v0.15.0
 	github.com/golang-jwt/jwt/v5 v5.3.0
 	github.com/google/uuid v1.6.0
 	github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
@@ -46,7 +46,7 @@ require (
 	github.com/stretchr/testify v1.11.1
 	github.com/tufanbarisyildirim/gonginx v0.0.0-20250620092546-c3e307e36701
 	github.com/ulikunitz/xz v0.5.15
-	github.com/uozi-tech/cosy v1.27.2
+	github.com/uozi-tech/cosy v1.27.3
 	github.com/uozi-tech/cosy-driver-sqlite v0.2.1
 	github.com/urfave/cli/v3 v3.5.0
 	golang.org/x/crypto v0.43.0
@@ -84,7 +84,7 @@ require (
 	github.com/Azure/go-autorest/tracing v0.6.1 // indirect
 	github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
 	github.com/Microsoft/go-winio v0.6.2 // indirect
-	github.com/RoaringBitmap/roaring/v2 v2.14.0 // indirect
+	github.com/RoaringBitmap/roaring/v2 v2.14.2 // indirect
 	github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0 // indirect
 	github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
 	github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 // indirect
@@ -93,7 +93,7 @@ require (
 	github.com/alibabacloud-go/tea v1.3.13 // indirect
 	github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect
 	github.com/aliyun/aliyun-log-go-sdk v0.1.111 // indirect
-	github.com/aliyun/credentials-go v1.4.7 // indirect
+	github.com/aliyun/credentials-go v1.4.8 // indirect
 	github.com/aws/aws-sdk-go-v2 v1.39.6 // indirect
 	github.com/aws/aws-sdk-go-v2/config v1.31.17 // indirect
 	github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect
@@ -115,13 +115,13 @@ require (
 	github.com/benbjohnson/clock v1.3.5 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/bits-and-blooms/bitset v1.24.3 // indirect
-	github.com/blevesearch/bleve_index_api v1.2.10 // indirect
+	github.com/blevesearch/bleve_index_api v1.2.11 // indirect
 	github.com/blevesearch/geo v0.2.4 // indirect
-	github.com/blevesearch/go-faiss v1.0.25 // indirect
+	github.com/blevesearch/go-faiss v1.0.26 // indirect
 	github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
 	github.com/blevesearch/gtreap v0.1.1 // indirect
 	github.com/blevesearch/mmap-go v1.0.4 // indirect
-	github.com/blevesearch/scorch_segment_api/v2 v2.3.12 // indirect
+	github.com/blevesearch/scorch_segment_api/v2 v2.3.13 // indirect
 	github.com/blevesearch/segment v0.9.1 // indirect
 	github.com/blevesearch/snowballstem v0.9.0 // indirect
 	github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
@@ -131,7 +131,7 @@ require (
 	github.com/blevesearch/zapx/v13 v13.4.2 // indirect
 	github.com/blevesearch/zapx/v14 v14.4.2 // indirect
 	github.com/blevesearch/zapx/v15 v15.4.2 // indirect
-	github.com/blevesearch/zapx/v16 v16.2.6 // indirect
+	github.com/blevesearch/zapx/v16 v16.2.7 // indirect
 	github.com/blinkbean/dingtalk v1.1.3 // indirect
 	github.com/boombuler/barcode v1.1.0 // indirect
 	github.com/bsm/redislock v0.9.4 // indirect
@@ -182,7 +182,7 @@ require (
 	github.com/go-playground/universal-translator v0.18.1 // indirect
 	github.com/go-sql-driver/mysql v1.9.3 // indirect
 	github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
-	github.com/go-webauthn/x v0.1.25 // indirect
+	github.com/go-webauthn/x v0.1.26 // indirect
 	github.com/goccy/go-json v0.10.5 // indirect
 	github.com/goccy/go-yaml v1.18.0 // indirect
 	github.com/gofrs/flock v0.13.0 // indirect
@@ -225,7 +225,7 @@ require (
 	github.com/labbsr0x/goh v1.0.1 // indirect
 	github.com/leodido/go-urn v1.4.0 // indirect
 	github.com/lib/pq v1.10.9 // indirect
-	github.com/linode/linodego v1.60.0 // indirect
+	github.com/linode/linodego v1.61.0 // indirect
 	github.com/liquidweb/liquidweb-cli v0.7.0 // indirect
 	github.com/liquidweb/liquidweb-go v1.6.4 // indirect
 	github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
@@ -280,7 +280,7 @@ require (
 	github.com/prometheus/procfs v0.19.2 // indirect
 	github.com/prometheus/prometheus v0.307.3 // indirect
 	github.com/quic-go/qpack v0.5.1 // indirect
-	github.com/quic-go/quic-go v0.55.0 // indirect
+	github.com/quic-go/quic-go v0.56.0 // indirect
 	github.com/redis/go-redis/v9 v9.16.0 // indirect
 	github.com/regfish/regfish-dnsapi-go v0.1.1 // indirect
 	github.com/robfig/cron/v3 v3.0.1 // indirect
@@ -341,11 +341,11 @@ require (
 	go.uber.org/zap v1.27.0 // indirect
 	go.yaml.in/yaml/v2 v2.4.3 // indirect
 	go.yaml.in/yaml/v3 v3.0.4 // indirect
-	golang.org/x/arch v0.22.0 // indirect
+	golang.org/x/arch v0.23.0 // indirect
 	golang.org/x/mod v0.29.0 // indirect
-	golang.org/x/oauth2 v0.32.0 // indirect
-	golang.org/x/sync v0.17.0 // indirect
-	golang.org/x/sys v0.37.0 // indirect
+	golang.org/x/oauth2 v0.33.0 // indirect
+	golang.org/x/sync v0.18.0 // indirect
+	golang.org/x/sys v0.38.0 // indirect
 	golang.org/x/text v0.30.0 // indirect
 	golang.org/x/time v0.14.0 // indirect
 	golang.org/x/tools v0.38.0 // indirect

+ 35 - 0
go.sum

@@ -681,6 +681,8 @@ github.com/Netflix/go-env v0.0.0-20220526054621-78278af1949d/go.mod h1:9XMFaCeRy
 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
 github.com/RoaringBitmap/roaring/v2 v2.14.0 h1:NjlfrI3SmA9Zm5yM1FV+IR096NyVt2R8wRp56y6I8zU=
 github.com/RoaringBitmap/roaring/v2 v2.14.0/go.mod h1:oMvV6omPWr+2ifRdeZvVJyaz+aoEUopyv5iH0u/+wbY=
+github.com/RoaringBitmap/roaring/v2 v2.14.2 h1:Axst08mZTSH93IhjLibRQ/0FJKVbRTZfW2b7qosyvSI=
+github.com/RoaringBitmap/roaring/v2 v2.14.2/go.mod h1:oMvV6omPWr+2ifRdeZvVJyaz+aoEUopyv5iH0u/+wbY=
 github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
 github.com/Shopify/sarama v1.30.1/go.mod h1:hGgx05L/DiW8XYBXeJdKIN6V2QUy2H6JqME5VT1NLRw=
 github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
@@ -752,6 +754,8 @@ github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmP
 github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
 github.com/aliyun/credentials-go v1.4.7 h1:T17dLqEtPUFvjDRRb5giVvLh6dFT8IcNFJJb7MeyCxw=
 github.com/aliyun/credentials-go v1.4.7/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
+github.com/aliyun/credentials-go v1.4.8 h1:MEfZGWGC3L1icM1nGcYF8rWdQBG2k1Sya2pq9uRwd30=
+github.com/aliyun/credentials-go v1.4.8/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
 github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
 github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
 github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0=
@@ -824,10 +828,14 @@ github.com/blevesearch/bleve/v2 v2.5.4 h1:1iur8e+PHsxtncV2xIVuqlQme/V8guEDO2uV6W
 github.com/blevesearch/bleve/v2 v2.5.4/go.mod h1:yB4PnV4N2q5rTEpB2ndG8N2ISexBQEFIYgwx4ztfvoo=
 github.com/blevesearch/bleve_index_api v1.2.10 h1:FMFmZCmTX6PdoLLvwUnKF2RsmILFFwO3h0WPevXY9fE=
 github.com/blevesearch/bleve_index_api v1.2.10/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0=
+github.com/blevesearch/bleve_index_api v1.2.11 h1:bXQ54kVuwP8hdrXUSOnvTQfgK0KI1+f9A0ITJT8tX1s=
+github.com/blevesearch/bleve_index_api v1.2.11/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0=
 github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk=
 github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8=
 github.com/blevesearch/go-faiss v1.0.25 h1:lel1rkOUGbT1CJ0YgzKwC7k+XH0XVBHnCVWahdCXk4U=
 github.com/blevesearch/go-faiss v1.0.25/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
+github.com/blevesearch/go-faiss v1.0.26 h1:4dRLolFgjPyjkaXwff4NfbZFdE/dfywbzDqporeQvXI=
+github.com/blevesearch/go-faiss v1.0.26/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
 github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
 github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
 github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
@@ -836,6 +844,8 @@ github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCD
 github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs=
 github.com/blevesearch/scorch_segment_api/v2 v2.3.12 h1:GGZc2qwbyRBwtckPPkHkLyXw64mmsLJxdturBI1cM+c=
 github.com/blevesearch/scorch_segment_api/v2 v2.3.12/go.mod h1:JBRGAneqgLSI2+jCNjtwMqp2B7EBF3/VUzgDPIU33MM=
+github.com/blevesearch/scorch_segment_api/v2 v2.3.13 h1:ZPjv/4VwWvHJZKeMSgScCapOy8+DdmsmRyLmSB88UoY=
+github.com/blevesearch/scorch_segment_api/v2 v2.3.13/go.mod h1:ENk2LClTehOuMS8XzN3UxBEErYmtwkE7MAArFTXs9Vc=
 github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
 github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
 github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
@@ -856,6 +866,8 @@ github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFx
 github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
 github.com/blevesearch/zapx/v16 v16.2.6 h1:OHuUl2GhM+FpBq9RwNsJ4k/QodqbMMHoQEgn/IHYpu8=
 github.com/blevesearch/zapx/v16 v16.2.6/go.mod h1:cuAPB+YoIyRngNhno1S1GPr9SfMk+x/SgAHBLXSIq3k=
+github.com/blevesearch/zapx/v16 v16.2.7 h1:xcgFRa7f/tQXOwApVq7JWgPYSlzyUMmkuYa54tMDuR0=
+github.com/blevesearch/zapx/v16 v16.2.7/go.mod h1:murSoCJPCk25MqURrcJaBQ1RekuqSCSfMjXH4rHyA14=
 github.com/blinkbean/dingtalk v1.1.3 h1:MbidFZYom7DTFHD/YIs+eaI7kRy52kmWE/sy0xjo6E4=
 github.com/blinkbean/dingtalk v1.1.3/go.mod h1:9BaLuGSBqY3vT5hstValh48DbsKO7vaHaJnG9pXwbto=
 github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
@@ -884,6 +896,8 @@ github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vaui
 github.com/casbin/casbin/v2 v2.37.0/go.mod h1:vByNa/Fchek0KZUgG5wEsl7iFsiviAYKRtgrQfcJqHg=
 github.com/casdoor/casdoor-go-sdk v1.29.0 h1:bINkqgVjTaXRFJwsBshbtAJnSOoP7SB0NL2aWyqXsI8=
 github.com/casdoor/casdoor-go-sdk v1.29.0/go.mod h1:hVSgmSdwTCsBEJNt9r2K5aLVsoeMc37/N4Zzescy5SA=
+github.com/casdoor/casdoor-go-sdk v1.30.0 h1:EKwkaQfRaXmryJUWEzq7DWh863gfDXfCr3txi2fJIDw=
+github.com/casdoor/casdoor-go-sdk v1.30.0/go.mod h1:hVSgmSdwTCsBEJNt9r2K5aLVsoeMc37/N4Zzescy5SA=
 github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
 github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
 github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
@@ -895,6 +909,7 @@ github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F9
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
+github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
 github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
 github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
@@ -906,6 +921,7 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P
 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
 github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
 github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
+github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
 github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
 github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
 github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
@@ -1130,8 +1146,12 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
 github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
 github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
 github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
+github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
+github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
 github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
 github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
+github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
+github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
 github.com/go-zookeeper/zk v1.0.2/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw=
 github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b h1:/vQ+oYKu+JoyaMPDsv5FzwuL2wwWBgBbtj/YLCi4LuA=
 github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw=
@@ -1536,6 +1556,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
 github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/linode/linodego v1.60.0 h1:SgsebJFRCi+lSmYy+C40wmKZeJllGGm+W12Qw4+yVdI=
 github.com/linode/linodego v1.60.0/go.mod h1:1+Bt0oTz5rBnDOJbGhccxn7LYVytXTIIfAy7QYmijDs=
+github.com/linode/linodego v1.61.0 h1:9g20NWl+/SbhDFj6X5EOZXtM2hBm1Mx8I9h8+F3l1LM=
+github.com/linode/linodego v1.61.0/go.mod h1:64o30geLNwR0NeYh5HM/WrVCBXcSqkKnRK3x9xoRuJI=
 github.com/liquidweb/go-lwApi v0.0.0-20190605172801-52a4864d2738/go.mod h1:0sYF9rMXb0vlG+4SzdiGMXHheCZxjguMq+Zb4S2BfBs=
 github.com/liquidweb/go-lwApi v0.0.5/go.mod h1:0sYF9rMXb0vlG+4SzdiGMXHheCZxjguMq+Zb4S2BfBs=
 github.com/liquidweb/liquidweb-cli v0.6.9/go.mod h1:cE1uvQ+x24NGUL75D0QagOFCG8Wdvmwu8aL9TLmA/eQ=
@@ -1753,6 +1775,7 @@ github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2
 github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
 github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
 github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc=
+github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM=
 github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
 github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
 github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
@@ -1830,6 +1853,8 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
 github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
 github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
 github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
+github.com/quic-go/quic-go v0.56.0 h1:q/TW+OLismmXAehgFLczhCDTYB3bFmua4D9lsNBWxvY=
+github.com/quic-go/quic-go v0.56.0/go.mod h1:9gx5KsFQtw2oZ6GZTyh+7YEvOxWCL9WZAepnHxgAo6c=
 github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
 github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
 github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4=
@@ -2010,6 +2035,8 @@ github.com/ultradns/ultradns-go-sdk v1.8.1-20250722213956-faef419 h1:/VaznPrb/b6
 github.com/ultradns/ultradns-go-sdk v1.8.1-20250722213956-faef419/go.mod h1:QN0/PdenvYWB0GRMz6JJbPeZz2Lph2iys1p8AFVHm2c=
 github.com/uozi-tech/cosy v1.27.2 h1:iVqMx7+yqqFdfHlGy3XXegQWn9xTNCTNOIHGqill7Cg=
 github.com/uozi-tech/cosy v1.27.2/go.mod h1:dCaZpbpw/RXLNuonmYZ8WyPbpdvND8GBur2qxoOnQRI=
+github.com/uozi-tech/cosy v1.27.3 h1:Cj/YyXJbtOgxoXyHWjLDQ+x0P+LphneiVYepKPAosp4=
+github.com/uozi-tech/cosy v1.27.3/go.mod h1:dCaZpbpw/RXLNuonmYZ8WyPbpdvND8GBur2qxoOnQRI=
 github.com/uozi-tech/cosy-driver-mysql v0.2.2 h1:22S/XNIvuaKGqxQPsYPXN8TZ8hHjCQdcJKVQ83Vzxoo=
 github.com/uozi-tech/cosy-driver-mysql v0.2.2/go.mod h1:EZnRIbSj1V5U0gEeTobrXai/d1SV11lkl4zP9NFEmyE=
 github.com/uozi-tech/cosy-driver-postgres v0.2.1 h1:OICakGuT+omva6QOJCxTJ5Lfr7CGXLmk/zD+aS51Z2o=
@@ -2147,6 +2174,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
 golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
+golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
+golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
 golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -2374,6 +2403,8 @@ golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw
 golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4=
 golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
 golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
+golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
+golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -2395,6 +2426,8 @@ golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
 golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
+golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
 golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -2525,6 +2558,8 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
 golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=

+ 29 - 40
internal/sitecheck/checker.go

@@ -120,24 +120,15 @@ func (sc *SiteChecker) CollectSites() {
 				}
 
 				// Parse URL components for legacy fields
-				_, hostPort := parseURLComponents(url, protocol)
 
 				// Get or create site config to get ID
 				siteConfig := getOrCreateSiteConfigForURL(url)
 
 				siteInfo := &SiteInfo{
-					ID:          siteConfig.ID,
-					Host:        siteConfig.Host,
-					Port:        siteConfig.Port,
-					Scheme:      siteConfig.Scheme,
-					DisplayURL:  siteConfig.GetURL(),
+					SiteConfig:  *siteConfig,
 					Name:        extractDomainName(url),
 					Status:      StatusChecking,
 					LastChecked: time.Now().Unix(),
-					// Legacy fields for backward compatibility
-					URL:                 url,
-					HealthCheckProtocol: protocol,
-					HostPort:            hostPort,
 				}
 				sc.sites[url] = siteInfo
 			}
@@ -301,23 +292,43 @@ func getOrCreateSiteConfigForURL(url string) *model.SiteConfig {
 func (sc *SiteChecker) CheckSite(ctx context.Context, siteURL string) (*SiteInfo, error) {
 	// Try enhanced health check first if config exists
 	config, err := LoadSiteConfig(siteURL)
+
+	// If health check is disabled, return a SiteInfo without status
+	if err == nil && config != nil && !config.HealthCheckEnabled {
+		protocol := "http"
+		if config.HealthCheckConfig != nil && config.HealthCheckConfig.Protocol != "" {
+			protocol = config.HealthCheckConfig.Protocol
+		}
+
+		siteInfo := &SiteInfo{
+			SiteConfig: *config,
+			Name:       extractDomainName(siteURL),
+			Title:      config.DisplayURL,
+		}
+
+		// Try to get favicon if enabled and not a gRPC check
+		if sc.options.CheckFavicon && !isGRPCProtocol(protocol) {
+			faviconURL, faviconData := sc.tryGetFavicon(ctx, siteURL)
+			siteInfo.FaviconURL = faviconURL
+			siteInfo.FaviconData = faviconData
+		}
+
+		return siteInfo, nil
+	}
+
 	if err == nil && config != nil && config.HealthCheckConfig != nil {
 		enhancedChecker := NewEnhancedSiteChecker()
 		siteInfo, err := enhancedChecker.CheckSiteWithConfig(ctx, siteURL, config.HealthCheckConfig)
 		if err == nil && siteInfo != nil {
 			// Fill in additional details
+			siteInfo.ID = config.ID
+			siteInfo.HealthCheckEnabled = config.HealthCheckEnabled
 			siteInfo.Name = extractDomainName(siteURL)
 			siteInfo.LastChecked = time.Now().Unix()
 
 			// Set health check protocol and display URL
-			siteInfo.HealthCheckProtocol = config.HealthCheckConfig.Protocol
 			siteInfo.DisplayURL = generateDisplayURL(siteURL, config.HealthCheckConfig.Protocol)
 
-			// Parse URL components
-			scheme, hostPort := parseURLComponents(siteURL, config.HealthCheckConfig.Protocol)
-			siteInfo.Scheme = scheme
-			siteInfo.HostPort = hostPort
-
 			// Try to get favicon if enabled and not a gRPC check
 			if sc.options.CheckFavicon && !isGRPCProtocol(config.HealthCheckConfig.Protocol) {
 				faviconURL, faviconData := sc.tryGetFavicon(ctx, siteURL)
@@ -350,53 +361,31 @@ func (sc *SiteChecker) checkSiteBasic(ctx context.Context, siteURL string, origi
 
 	resp, err := sc.client.Do(req)
 	if err != nil {
-		// Parse URL components for legacy fields
-		_, hostPort := parseURLComponents(siteURL, originalProtocol)
-
 		// Get or create site config to get ID
 		siteConfig := getOrCreateSiteConfigForURL(siteURL)
 
 		return &SiteInfo{
-			ID:           siteConfig.ID,
-			Host:         siteConfig.Host,
-			Port:         siteConfig.Port,
-			Scheme:       siteConfig.Scheme,
-			DisplayURL:   siteConfig.GetURL(),
+			SiteConfig:   *siteConfig,
 			Name:         extractDomainName(siteURL),
 			Status:       StatusOffline,
 			ResponseTime: time.Since(start).Milliseconds(),
 			LastChecked:  time.Now().Unix(),
 			Error:        err.Error(),
-			// Legacy fields for backward compatibility
-			URL:                 siteURL,
-			HealthCheckProtocol: originalProtocol,
-			HostPort:            hostPort,
 		}, nil
 	}
 	defer resp.Body.Close()
 
 	responseTime := time.Since(start).Milliseconds()
 
-	// Parse URL components for legacy fields
-	_, hostPort := parseURLComponents(siteURL, originalProtocol)
-
 	// Get or create site config to get ID
 	siteConfig := getOrCreateSiteConfigForURL(siteURL)
 
 	siteInfo := &SiteInfo{
-		ID:           siteConfig.ID,
-		Host:         siteConfig.Host,
-		Port:         siteConfig.Port,
-		Scheme:       siteConfig.Scheme,
-		DisplayURL:   siteConfig.GetURL(),
+		SiteConfig:   *siteConfig,
 		Name:         extractDomainName(siteURL),
 		StatusCode:   resp.StatusCode,
 		ResponseTime: responseTime,
 		LastChecked:  time.Now().Unix(),
-		// Legacy fields for backward compatibility
-		URL:                 siteURL,
-		HealthCheckProtocol: originalProtocol,
-		HostPort:            hostPort,
 	}
 
 	// Determine status based on status code

+ 18 - 74
internal/sitecheck/enhanced_checker.go

@@ -84,16 +84,9 @@ func (ec *EnhancedSiteChecker) checkHTTP(ctx context.Context, siteURL string, co
 	// Create request
 	req, err := http.NewRequestWithContext(ctx, config.Method, checkURL, nil)
 	if err != nil {
-		// Parse URL components for error case
-		scheme, hostPort := parseURLComponents(siteURL, config.Protocol)
-
 		return &SiteInfo{
-			URL:                 siteURL,
-			Status:              StatusError,
-			Error:               fmt.Sprintf("Failed to create request: %v", err),
-			HealthCheckProtocol: config.Protocol,
-			Scheme:              scheme,
-			HostPort:            hostPort,
+			Status: StatusError,
+			Error:  fmt.Sprintf("Failed to create request: %v", err),
 		}, err
 	}
 
@@ -147,17 +140,10 @@ func (ec *EnhancedSiteChecker) checkHTTP(ctx context.Context, siteURL string, co
 	// Make request
 	resp, err := client.Do(req)
 	if err != nil {
-		// Parse URL components for error case
-		scheme, hostPort := parseURLComponents(siteURL, config.Protocol)
-
 		return &SiteInfo{
-			URL:                 siteURL,
-			Status:              StatusError,
-			ResponseTime:        time.Since(startTime).Milliseconds(),
-			Error:               err.Error(),
-			HealthCheckProtocol: config.Protocol,
-			Scheme:              scheme,
-			HostPort:            hostPort,
+			Status:       StatusError,
+			ResponseTime: time.Since(startTime).Milliseconds(),
+			Error:        err.Error(),
 		}, err
 	}
 	defer resp.Body.Close()
@@ -202,26 +188,15 @@ func (ec *EnhancedSiteChecker) checkHTTP(ctx context.Context, siteURL string, co
 		}
 	}
 
-	// Parse URL components for legacy fields
-	_, hostPort := parseURLComponents(siteURL, config.Protocol)
-
 	// Get or create site config to get ID
 	siteConfig := getOrCreateSiteConfigForURL(siteURL)
 
 	return &SiteInfo{
-		ID:           siteConfig.ID,
-		Host:         siteConfig.Host,
-		Port:         siteConfig.Port,
-		Scheme:       siteConfig.Scheme,
-		DisplayURL:   siteConfig.GetURL(),
+		SiteConfig:   *siteConfig,
 		Status:       status,
 		StatusCode:   resp.StatusCode,
 		ResponseTime: responseTime,
 		Error:        errorMsg,
-		// Legacy fields for backward compatibility
-		URL:                 siteURL,
-		HealthCheckProtocol: config.Protocol,
-		HostPort:            hostPort,
 	}, nil
 }
 
@@ -242,16 +217,9 @@ func (ec *EnhancedSiteChecker) checkGRPC(ctx context.Context, siteURL string, co
 	// Parse URL to get host and port
 	parsedURL, err := parseGRPCURL(siteURL)
 	if err != nil {
-		// Parse URL components for error case
-		scheme, hostPort := parseURLComponents(siteURL, config.Protocol)
-
 		return &SiteInfo{
-			URL:                 siteURL,
-			Status:              StatusError,
-			Error:               fmt.Sprintf("Invalid gRPC URL: %v", err),
-			HealthCheckProtocol: config.Protocol,
-			Scheme:              scheme,
-			HostPort:            hostPort,
+			Status: StatusError,
+			Error:  fmt.Sprintf("Invalid gRPC URL: %v", err),
 		}, err
 	}
 
@@ -284,11 +252,8 @@ func (ec *EnhancedSiteChecker) checkGRPC(ctx context.Context, siteURL string, co
 		opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
 	}
 
-	// Create connection with shorter timeout for faster failure detection
-	dialCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
-	defer cancel()
-
-	conn, err := grpc.DialContext(dialCtx, parsedURL.Host, opts...)
+	// Create gRPC client (connection established lazily on first RPC call)
+	conn, err := grpc.NewClient(parsedURL.Host, opts...)
 	if err != nil {
 		errorMsg := fmt.Sprintf("Failed to connect to gRPC server: %v", err)
 
@@ -301,17 +266,10 @@ func (ec *EnhancedSiteChecker) checkGRPC(ctx context.Context, siteURL string, co
 			errorMsg = fmt.Sprintf("Protocol mismatch - %s may not be a gRPC server or wrong TLS configuration", parsedURL.Host)
 		}
 
-		// Parse URL components for error case
-		scheme, hostPort := parseURLComponents(siteURL, config.Protocol)
-
 		return &SiteInfo{
-			URL:                 siteURL,
-			Status:              StatusError,
-			ResponseTime:        time.Since(startTime).Milliseconds(),
-			Error:               errorMsg,
-			HealthCheckProtocol: config.Protocol,
-			Scheme:              scheme,
-			HostPort:            hostPort,
+			Status:       StatusError,
+			ResponseTime: time.Since(startTime).Milliseconds(),
+			Error:        errorMsg,
 		}, err
 	}
 	defer conn.Close()
@@ -347,17 +305,10 @@ func (ec *EnhancedSiteChecker) checkGRPC(ctx context.Context, siteURL string, co
 			errorMsg = "Connection lost during health check"
 		}
 
-		// Parse URL components for error case
-		scheme, hostPort := parseURLComponents(siteURL, config.Protocol)
-
 		return &SiteInfo{
-			URL:                 siteURL,
-			Status:              StatusError,
-			ResponseTime:        responseTime,
-			Error:               errorMsg,
-			HealthCheckProtocol: config.Protocol,
-			Scheme:              scheme,
-			HostPort:            hostPort,
+			Status:       StatusError,
+			ResponseTime: responseTime,
+			Error:        errorMsg,
 		}, err
 	}
 
@@ -367,16 +318,9 @@ func (ec *EnhancedSiteChecker) checkGRPC(ctx context.Context, siteURL string, co
 		status = StatusOnline
 	}
 
-	// Parse URL components
-	scheme, hostPort := parseURLComponents(siteURL, config.Protocol)
-
 	return &SiteInfo{
-		URL:                 siteURL,
-		Status:              status,
-		ResponseTime:        responseTime,
-		HealthCheckProtocol: config.Protocol,
-		Scheme:              scheme,
-		HostPort:            hostPort,
+		Status:       status,
+		ResponseTime: responseTime,
 	}, nil
 }
 

+ 3 - 3
internal/sitecheck/ordering.go

@@ -30,8 +30,8 @@ func applyCustomOrdering(sites []*SiteInfo) []*SiteInfo {
 
 	// Sort sites based on custom order, with fallback to default ordering
 	sort.Slice(sites, func(i, j int) bool {
-		orderI, hasOrderI := orderMap[sites[i].URL]
-		orderJ, hasOrderJ := orderMap[sites[j].URL]
+		orderI, hasOrderI := orderMap[sites[i].DisplayURL]
+		orderJ, hasOrderJ := orderMap[sites[j].DisplayURL]
 
 		// If both have custom order, use custom order
 		if hasOrderI && hasOrderJ {
@@ -91,5 +91,5 @@ func defaultCompare(a, b *SiteInfo) bool {
 	}
 
 	// Final sort: by URL (for complete stability)
-	return a.URL < b.URL
+	return a.DisplayURL < b.DisplayURL
 }

+ 12 - 18
internal/sitecheck/types.go

@@ -2,6 +2,8 @@ package sitecheck
 
 import (
 	"time"
+
+	"github.com/0xJacky/Nginx-UI/model"
 )
 
 // Site health check status constants
@@ -14,24 +16,16 @@ const (
 
 // SiteInfo represents the information about a site
 type SiteInfo struct {
-	ID           uint64 `json:"id"`          // Site config ID for API operations
-	Host         string `json:"host"`        // host:port format
-	Port         int    `json:"port"`        // port number
-	Scheme       string `json:"scheme"`      // http, https, grpc, grpcs
-	DisplayURL   string `json:"display_url"` // computed URL for display
-	Name         string `json:"name"`
-	Status       string `json:"status"` // StatusOnline, StatusOffline, StatusError, StatusChecking
-	StatusCode   int    `json:"status_code"`
-	ResponseTime int64  `json:"response_time"` // in milliseconds
-	FaviconURL   string `json:"favicon_url"`
-	FaviconData  string `json:"favicon_data"` // base64 encoded favicon
-	Title        string `json:"title"`
-	LastChecked  int64  `json:"last_checked"` // Unix timestamp in seconds
-	Error        string `json:"error,omitempty"`
-	// Legacy fields for backward compatibility
-	URL                 string `json:"url,omitempty"`                   // deprecated, use display_url instead
-	HealthCheckProtocol string `json:"health_check_protocol,omitempty"` // deprecated, use scheme instead
-	HostPort            string `json:"host_port,omitempty"`             // deprecated, use host instead
+	model.SiteConfig
+	Name               string `json:"name"`
+	Status             string `json:"status"` // StatusOnline, StatusOffline, StatusError, StatusChecking
+	StatusCode         int    `json:"status_code"`
+	ResponseTime       int64  `json:"response_time"` // in milliseconds
+	FaviconURL         string `json:"favicon_url"`
+	FaviconData        string `json:"favicon_data"` // base64 encoded favicon
+	Title              string `json:"title"`
+	LastChecked        int64  `json:"last_checked"` // Unix timestamp in seconds
+	Error              string `json:"error,omitempty"`
 }
 
 // CheckOptions represents options for site checking

+ 13 - 13
model/site_config.go

@@ -33,19 +33,19 @@ type HealthCheckConfig struct {
 
 type SiteConfig struct {
 	Model
-	Host               string             `gorm:"index" json:"host"`            // host:port format
-	Port               int                `gorm:"index" json:"port"`            // port number
-	Scheme             string             `gorm:"default:'http'" json:"scheme"` // http, https, grpc, grpcs
-	DisplayURL         string             `json:"display_url"`                  // computed URL for display
-	CustomOrder        int                `gorm:"default:0" json:"custom_order"`
-	HealthCheckEnabled bool               `gorm:"default:true" json:"health_check_enabled"`
-	CheckInterval      int                `gorm:"default:300" json:"check_interval"` // seconds
-	Timeout            int                `gorm:"default:10" json:"timeout"`         // seconds
-	UserAgent          string             `gorm:"default:'Nginx-UI Site Checker/1.0'" json:"user_agent"`
-	MaxRedirects       int                `gorm:"default:3" json:"max_redirects"`
-	FollowRedirects    bool               `gorm:"default:true" json:"follow_redirects"`
-	CheckFavicon       bool               `gorm:"default:true" json:"check_favicon"`
-	HealthCheckConfig  *HealthCheckConfig `gorm:"serializer:json" json:"health_check_config"`
+	Host               string             `gorm:"index" json:"host" cosy:"all:omitempty"`            // host:port format
+	Port               int                `gorm:"index" json:"port" cosy:"all:omitempty"`            // port number
+	Scheme             string             `gorm:"default:'http'" json:"scheme" cosy:"all:omitempty"` // http, https, grpc, grpcs
+	DisplayURL         string             `json:"display_url" cosy:"all:omitempty"`                  // computed URL for display
+	CustomOrder        int                `gorm:"default:0" json:"custom_order" cosy:"all:omitempty"`
+	HealthCheckEnabled bool               `gorm:"default:true" json:"health_check_enabled" cosy:"all:omitempty"`
+	CheckInterval      int                `gorm:"default:300" json:"check_interval" cosy:"all:omitempty"` // seconds
+	Timeout            int                `gorm:"default:10" json:"timeout" cosy:"all:omitempty"`         // seconds
+	UserAgent          string             `gorm:"default:'Nginx-UI Site Checker/1.0'" json:"user_agent" cosy:"all:omitempty"`
+	MaxRedirects       int                `gorm:"default:3" json:"max_redirects" cosy:"all:omitempty"`
+	FollowRedirects    bool               `gorm:"default:true" json:"follow_redirects" cosy:"all:omitempty"`
+	CheckFavicon       bool               `gorm:"default:true" json:"check_favicon" cosy:"all:omitempty"`
+	HealthCheckConfig  *HealthCheckConfig `gorm:"serializer:json" json:"health_check_config" cosy:"all:omitempty"`
 }
 
 // GetURL returns the computed URL for this site config