package web import ( "bytes" "encoding/json" "fmt" "html/template" "io" "mime" "mime/multipart" "io/fs" "net/http" "net/url" "os" "path/filepath" "sort" "strconv" "strings" "time" "3588AdminBackend/internal/models" "3588AdminBackend/internal/service" "3588AdminBackend/internal/storage" "github.com/go-chi/chi/v5" ) type UI struct { discovery *service.DiscoveryService registry *service.RegistryService agent *service.AgentClient tasks *service.TaskService templates *service.TemplateService preview *service.ConfigPreviewService stateRepo *storage.DeviceConfigStateRepo auditRepo *storage.AuditLogsRepo dbPath string resourcesRepo *storage.ResourcesRepo alarmCollector *service.AlarmCollector autoConfig *service.AutoConfigService tpl *template.Template } const ( deviceAssignmentPreviewDevicePrefix = "demo-edge-" deviceAssignmentPreviewDeviceCount = 8 ) const version = "1.0" type PageData struct { Title string Version string Year int ContentHTML template.HTML Message string Error string DeviceCount int OnlineCount int OfflineCount int FoundCount int Devices []*models.Device DeviceRows []DeviceOverviewRow AttentionDevices []*models.Device Found []*models.Device Device *models.Device ConfigStatus *ConfigStatusView ConfigStatusText string ConfigStatusErr string ConfigSources service.ConfigPreviewSources ConfigPreview *service.ConfigPreviewResult ResultTitle string SelectedTemplate string SelectedProfile string SelectedRecognitionUnit string SelectedAssignmentDevice string SelectedOverlays []string SelectedConfigID string SelectedVersion string Tasks []models.Task Task *models.Task TaskDeviceRows []TaskDeviceRow StandardModels []storage.StandardModelRecord ModelStatusBoard *service.ModelStatusBoard StandardResources []storage.StandardResourceRecord ResourceStatusBoard *service.ResourceStatusBoard AlarmRecords []service.AlarmRecord TotalAlarmCount int TotalAlarmPages int PageNumbers []int CurrentPage int PerPage int SelectedFrom string SelectedTo string WeekStart string WeekEnd string MonthStart string MonthEnd string TodayAlarmCount int DeviceMetrics []DeviceMetric ConsoleDevices []ConsoleDeviceData ConsoleVideoSources []service.ConfigVideoSourceAsset ConsoleAllVideoSources []service.ConfigVideoSourceAsset ConsoleUnassignedUnits []service.RecognitionUnitAsset FaceGalleryPersons []storage.PersonRecord Templates []service.Template Template *service.Template AssetTab string AssetTemplates []service.ConfigTemplateAsset AssetTemplate *service.ConfigTemplateAsset AssetTemplateEditing bool AssetProfiles []service.ConfigProfileAsset AssetProfile *service.ConfigProfileAsset AssetProfileEditor *service.ConfigProfileEditor AssetProfileEditing bool AssetProfileFormAction string ActiveInstanceIndex int RecognitionUnits []service.RecognitionUnitAsset RecognitionUnit *service.RecognitionUnitAsset RecognitionUnitEditing bool DeviceAssignments []service.DeviceAssignmentAsset DeviceAssignment *service.DeviceAssignmentAsset DeviceAssignmentEditing bool DeviceAssignmentBoard *service.DeviceAssignmentBoard DeviceAssignmentBoardJSON template.JS MaxUnitsPerDevice int AssetTemplateMap map[string]service.ConfigTemplateAsset AssetVideoSources []service.ConfigVideoSourceAsset AssetVideoSource *service.ConfigVideoSourceAsset AssetVideoSourceEditing bool AssetIntegrations []service.ConfigIntegrationServiceAsset AssetIntegration *service.ConfigIntegrationServiceAsset AssetIntegrationEditing bool AssetOverlays []service.ConfigOverlayAsset AssetOverlay *service.ConfigOverlayAsset AssetOverlayEditing bool AssetInstanceCount int SelectedDeviceIDs []string SelectedDevices []*models.Device SelectedQuery string SelectedDevicesURL string BatchConfigURL string ReloadSummary string RollbackSummary string TemplateDraftName string TemplateDraftDescription string TemplateCloneSource string TemplateCloneName string TemplateCloneDesc string TemplateCreateMode string OverlayDraftJSON string IntegrationConfigDraft string AuditEntries []storage.AuditLogRecord PersistedConfig *storage.DeviceConfigStateRecord DBPath string RawJSON string RawText string SchemaJSON string StateJSON string FaceGalleryJSON string TaskID string DeviceIDs string RunningTaskCount int FailedTaskCount int SuccessTaskCount int } type DeviceOverviewRow struct { Device *models.Device ConfigStatus *ConfigStatusView ConfigStatusErr string } type TaskDeviceRow struct { Device *models.Device Status models.TaskStatus Progress float64 Error string } type ConfigStatusView struct { OK bool `json:"ok"` ConfigPath string `json:"config_path"` Exists bool `json:"exists"` Sha256 string `json:"sha256"` Size int64 `json:"size"` Metadata ConfigStatusMetadata `json:"metadata"` Candidate *ConfigStatusLastGoodFile `json:"candidate"` MediaServer ConfigStatusMediaServer `json:"media_server"` PreviousConfig *ConfigStatusLastGoodFile `json:"previous_config"` PreviousConfigPath string `json:"previous_config_path"` } type ConfigStatusMetadata struct { ConfigID string `json:"config_id"` ConfigVersion string `json:"config_version"` BusinessName string `json:"business_name"` Template string `json:"template"` Profile string `json:"profile"` Overlays []string `json:"overlays"` RenderedAt string `json:"rendered_at"` RenderedBy string `json:"rendered_by"` InstanceName string `json:"instance_name"` InstanceDisplayName string `json:"instance_display_name"` } type ConfigStatusMediaServer struct { Running bool `json:"running"` PID int `json:"pid"` Version string `json:"version"` } type ConfigStatusLastGoodFile struct { Path string `json:"path"` Exists bool `json:"exists"` Sha256 string `json:"sha256"` Metadata ConfigStatusMetadata `json:"metadata"` } func NewUI(discovery *service.DiscoveryService, registry *service.RegistryService, agent *service.AgentClient, tasks *service.TaskService, templates *service.TemplateService, preview ...*service.ConfigPreviewService) (*UI, error) { tpl, err := template.New("layout").Funcs(template.FuncMap{ "json": func(v any) string { b, _ := json.MarshalIndent(v, "", " ") return string(b) }, "rawHTML": func(v string) template.HTML { return template.HTML(v) }, "hasString": func(items []string, want string) bool { for _, item := range items { if item == want { return true } } return false }, "shortHash": func(v string) string { v = strings.TrimSpace(v) if len(v) > 8 { return v[:8] } return v }, "modelTypeLabel": func(v string) string { switch strings.TrimSpace(v) { case "face_detection": return "人脸检测" case "face_recognition": return "人脸识别" case "object_detection": return "通用检测" case "ppe_detection": return "PPE检测" case "shoe_detection": return "工鞋检测" case "other": return "其他" default: return "-" } }, "resourceTypeLabel": func(v string) string { switch strings.TrimSpace(v) { case "face_gallery": return "人脸库" case "dataset": return "数据集" case "calibration": return "标定文件" default: return v } }, "ruleLabel": func(v string) string { switch strings.TrimSpace(v) { case "unknown_face": return "陌生人员告警" case "known_person": return "已登记人员" case "non_compliant_workshoe": return "未穿劳保鞋" case "non_black_shoe": return "工鞋不合规" default: return v } }, "displayDeviceName": func(dev *models.Device, status *ConfigStatusView) string { if dev == nil { return "-" } return dev.DisplayName() }, "displayDeviceTechnicalName": func(dev *models.Device) string { if dev == nil { return "" } if v := strings.TrimSpace(dev.TechnicalName()); v != "" { return v } return "" }, "taskGroupLabel": func(v any) string { switch fmt.Sprint(v) { case "config_apply": return "批量配置" case "media_start", "media_restart", "media_stop": return "批量服务" case "reload", "rollback": return "设备操作" case "resource_sync_one", "resource_sync_all": return "资源同步" default: return "其他任务" } }, "taskActionLabel": func(v any) string { switch fmt.Sprint(v) { case "config_apply": return "下发设备分配" case "model_sync_one": return "更新单个模型" case "model_sync_all": return "更新全部模型" case "resource_sync_one": return "同步单个资源" case "resource_sync_all": return "同步全部资源" case "reload": return "重载识别服务" case "rollback": return "回滚识别配置" case "media_start": return "启动视频分析服务" case "media_restart": return "重启视频分析服务" case "media_stop": return "停止视频分析服务" default: return fmt.Sprint(v) } }, "taskGroupClass": func(v any) string { switch fmt.Sprint(v) { case "config_apply": return "pill run" case "model_sync_one", "model_sync_all": return "pill warn" case "resource_sync_one", "resource_sync_all": return "pill warn" case "media_start", "media_restart", "media_stop": return "pill ok" case "reload", "rollback": return "pill warn" default: return "pill" } }, "taskStatusLabel": func(v any) string { switch fmt.Sprint(v) { case "success": return "成功" case "failed": return "失败" case "running": return "执行中" default: return "待执行" } }, "taskStatusClass": func(v any) string { switch fmt.Sprint(v) { case "success": return "pill ok" case "failed": return "pill bad" case "running": return "pill run" default: return "pill" } }, "auditField": func(details string, key string) string { var m map[string]any if err := json.Unmarshal([]byte(details), &m); err != nil { return "" } if v, ok := m[key].(string); ok { return strings.TrimSpace(v) } return "" }, "auditActionLabel": func(v string) string { switch strings.TrimSpace(v) { case "config_apply": return "下发设备分配" case "reload": return "重载运行配置" case "rollback": return "回滚运行配置" case "media_start": return "启动服务" case "media_restart": return "重启服务" case "media_stop": return "停止服务" default: return strings.TrimSpace(v) } }, "auditStatusLabel": func(v string) string { switch strings.TrimSpace(v) { case "success": return "成功" case "failed": return "失败" case "running": return "执行中" case "pending": return "待执行" default: return strings.TrimSpace(v) } }, "ago": func(ms int64) string { if ms <= 0 { return "-" } d := time.Since(time.UnixMilli(ms)) if d < 0 { d = 0 } s := int64(d.Seconds()) switch { case s < 60: return fmt.Sprintf("%d秒前", s) case s < 3600: return fmt.Sprintf("%d分钟前", s/60) case s < 86400: return fmt.Sprintf("%d小时前", s/3600) default: return fmt.Sprintf("%d天前", s/86400) } }, "icon": func(name string) template.HTML { return template.HTML(tablerIconSVG(name)) }, "slotTypeLabel": func(v string) string { switch strings.TrimSpace(v) { case "video_source": return "视频源" case "object_storage": return "对象存储" case "alarm_service": return "告警服务" case "stream_publish": return "视频输出" default: return strings.TrimSpace(v) } }, "inputBindingRef": func(bindings map[string]service.InputBindingEditor, slot string) string { if len(bindings) == 0 { return "" } return strings.TrimSpace(bindings[slot].VideoSourceRef) }, "serviceBindingRef": func(bindings map[string]service.ServiceBindingEditor, slot string) string { if len(bindings) == 0 { return "" } return strings.TrimSpace(bindings[slot].ServiceRef) }, "outputBindingValue": func(bindings map[string]service.OutputBindingEditor, slot string, field string) string { if len(bindings) == 0 { return "" } item, ok := bindings[slot] if !ok { return "" } switch strings.TrimSpace(field) { case "publish_hls_path": return strings.TrimSpace(item.PublishHLSPath) case "publish_rtsp_port": return strings.TrimSpace(item.PublishRTSPPort) case "publish_rtsp_path": return strings.TrimSpace(item.PublishRTSPPath) case "channel_no": return strings.TrimSpace(item.ChannelNo) default: return "" } }, "sub": func(a, b int) int { return a - b }, "loop": func(count int) []int { var r []int for i := 0; i < count; i++ { r = append(r, i) } return r }, "mul": func(a, b float64) float64 { return a * b }, "div": func(a, b float64) float64 { if b == 0 { return 0 } return a / b }, "shortID": func(v string) string { if len(v) > 8 { return v[:8] } return v }, "add": func(a, b int) int { return a + b }, }).ParseFS(uiFS, "ui/templates/*.html") if err != nil { return nil, err } previewSvc := service.NewConfigPreviewService(nil) if len(preview) > 0 && preview[0] != nil { previewSvc = preview[0] } return &UI{ discovery: discovery, registry: registry, agent: agent, tasks: tasks, templates: templates, preview: previewSvc, tpl: tpl, }, nil } func (u *UI) SetStateRepo(repo *storage.DeviceConfigStateRepo) { if u == nil { return } u.stateRepo = repo } func (u *UI) SetAuditRepo(repo *storage.AuditLogsRepo) { if u == nil { return } u.auditRepo = repo } func (u *UI) SetDBPath(path string) { if u == nil { return } u.dbPath = strings.TrimSpace(path) } func (u *UI) SetResourcesRepo(repo *storage.ResourcesRepo) { if u == nil { return } u.resourcesRepo = repo } func (u *UI) SetAlarmCollector(ac *service.AlarmCollector) { if u == nil { return } u.alarmCollector = ac } func (u *UI) SetAutoConfig(ac *service.AutoConfigService) { if u == nil { return } u.autoConfig = ac } func tablerIconSVG(name string) string { icons := map[string]string{ "devices": ``, "assets": ``, "audit": ``, "system": ``, "theme": ``, "bell": ``, "online": ``, "detail": ``, "control": ``, "device": ``, "status": ``, "config": ``, "overview": ``, "tech": ``, "preview": ``, "apply": ``, "service": ``, "task": ``, "result": ``, "logs": ``, "meta": ``, "template": ``, "edit": ``, "profile": ``, "overlay": ``, "release": ``, "discovery": ``, "shield": ``, "heartbeat": ``, } if svg, ok := icons[name]; ok { return svg } return "" } func (u *UI) Routes() (chi.Router, error) { r := chi.NewRouter() assets, err := fs.Sub(uiFS, "ui/assets") if err != nil { return nil, err } assetHandler := http.StripPrefix("/assets/", http.FileServer(http.FS(assets))) r.Handle("/assets/*", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { p := req.URL.Path w.Header().Set("Cache-Control", "no-store, max-age=0") w.Header().Set("Pragma", "no-cache") switch { case strings.HasSuffix(p, ".css"): w.Header().Set("Content-Type", "text/css; charset=utf-8") case strings.HasSuffix(p, ".js"): w.Header().Set("Content-Type", "text/javascript; charset=utf-8") } assetHandler.ServeHTTP(w, req) })) r.Get("/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ui/dashboard", http.StatusFound) }) r.Get("/dashboard", u.pageDashboard) r.Get("/console", u.pageConsole) r.Post("/console", u.actionConsoleSave) r.Get("/devices", u.pageDevices) r.Get("/devices/{id}/control", u.pageDeviceControl) r.Get("/plans", u.redirectPlansToSceneTemplates) r.Get("/plans/{name}", u.redirectPlanToSceneTemplate) r.Get("/scene-templates", u.pagePlans) r.Get("/scene-templates/{name}", u.pagePlan) r.Post("/scene-templates/create", u.actionPlanCreate) r.Post("/scene-templates/{name}", u.actionPlanSave) r.Post("/scene-templates/{name}/delete", u.actionPlanDelete) r.Get("/scene-templates/{name}/export", u.pagePlanExport) r.Get("/recognition-units", u.pageRecognitionUnits) r.Post("/recognition-units", u.actionRecognitionUnitSave) r.Post("/recognition-units/delete", u.actionRecognitionUnitDelete) r.Get("/device-assignments", u.pageDeviceAssignments) r.Post("/device-assignments", u.actionDeviceAssignmentSave) r.Post("/device-assignments/{id}/delete", u.actionDeviceAssignmentDelete) r.Get("/assets", u.pageAssets) r.Get("/assets/video-sources", u.pageAssetVideoSources) r.Post("/assets/video-sources", u.actionAssetVideoSourceSave) r.Post("/assets/video-sources/{name}/delete", u.actionAssetVideoSourceDelete) r.Get("/assets/templates", u.pageAssetTemplates) r.Post("/assets/templates/create", u.actionAssetTemplateCreate) r.Post("/assets/templates/{name}/clone", u.actionAssetTemplateClone) r.Get("/assets/templates/{name}", u.pageAssetTemplate) r.Post("/assets/templates/{name}/rename", u.actionAssetTemplateRename) r.Post("/assets/templates/{name}/delete", u.actionAssetTemplateDelete) r.Get("/assets/templates/{name}/graph", u.pageAssetTemplateGraph) r.Post("/assets/templates/{name}/graph", u.actionAssetTemplateGraphSave) r.Get("/assets/templates/{name}/export", u.pageAssetTemplateExport) r.Get("/assets/profiles", u.redirectAssetProfilesToPlans) r.Get("/assets/profiles/{name}", u.redirectAssetProfileToPlan) r.Post("/assets/profiles/{name}", u.actionPlanSave) r.Get("/assets/profiles/{name}/export", u.pagePlanExport) r.Get("/assets/integrations", u.pageAssetIntegrations) r.Post("/assets/integrations", u.actionAssetIntegrationSave) r.Post("/assets/integrations/{name}/delete", u.actionAssetIntegrationDelete) r.Get("/assets/overlays", u.pageAssetOverlays) r.Post("/assets/overlays", u.actionAssetOverlaySave) r.Post("/assets/overlays/{name}/clone", u.actionAssetOverlayClone) r.Post("/assets/overlays/{name}/delete", u.actionAssetOverlayDelete) r.Get("/assets/overlays/{name}", u.pageAssetOverlay) r.Get("/assets/overlays/{name}/export", u.pageAssetOverlayExport) r.Get("/audit", u.pageAudit) r.Get("/system", u.pageSystem) r.Get("/system/db-backup", u.pageSystemDBBackup) r.Get("/resources", u.pageResources) r.Post("/system/db-restore", u.actionSystemDBRestore) r.Get("/api/graph-node-types", u.apiGraphNodeTypes) r.Get("/device-config", u.pageDeviceConfig) r.Get("/device-config/{id}", u.pageDeviceConfigDetail) r.Get("/devices-add", u.pageDeviceAdd) r.Post("/devices-add", u.actionDeviceAdd) r.Post("/devices/batch-action", u.actionDevicesBatchAction) r.Get("/devices/batch-config", u.pageDeviceBatchConfig) r.Post("/devices/batch-config", u.actionDeviceBatchConfig) r.Post("/discovery/search", u.actionDiscoverySearch) r.Get("/devices/{id}", u.pageDevice) r.Post("/devices/{id}/alias", u.actionDeviceAliasSave) r.Post("/devices/{id}/action", u.actionDeviceAction) r.Get("/devices/{id}/logs", u.pageDeviceLogs) r.Get("/devices/{id}/graphs", u.pageDeviceGraphs) r.Post("/devices/{id}/config/apply", u.actionDeviceConfigApply) r.Post("/devices/{id}/plan-apply", u.actionDevicePlanApply) r.Get("/devices/{id}/config-ui", u.pageDeviceConfigUI) r.Get("/devices/{id}/config-friendly", u.pageDeviceConfigFriendly) r.Get("/devices/{id}/config-preview", u.pageDeviceConfigPreview) r.Post("/devices/{id}/config-preview", u.actionDeviceConfigPreview) r.Post("/devices/{id}/config-candidate", u.actionDeviceConfigCandidate) r.Post("/devices/{id}/config-candidate/apply", u.actionDeviceConfigCandidateApply) r.Post("/devices/{id}/config-ui/plan", u.actionDeviceConfigUIPlan) r.Post("/devices/{id}/config-ui/apply", u.actionDeviceConfigUIApply) r.Post("/devices/{id}/face-gallery/upload", u.actionDeviceFaceGalleryUpload) r.Post("/devices/{id}/face-gallery/reload", u.actionDeviceFaceGalleryReload) r.Post("/devices/{id}/models/upload", u.actionDeviceModelUpload) r.Post("/devices/{id}/media-server/configs/upload", u.actionDeviceMediaServerConfigUpload) r.Post("/devices/{id}/media-server/configs/upload-batch", u.actionDeviceMediaServerConfigUploadBatch) r.Get("/tasks", u.pageTasks) r.Post("/tasks", u.actionCreateTask) r.Get("/tasks/{id}", u.pageTask) r.Get("/templates", u.pageTemplates) r.Get("/templates/{name}", u.pageTemplate) r.Get("/models", u.pageModels) r.Post("/models/sync", u.actionModelSync) r.Post("/resources/sync", u.actionResourceSync) r.Get("/diagnostics", u.pageDiagnostics) r.Get("/alarms", u.pageAlarms) r.Get("/face-gallery", u.pageFaceGallery) r.Get("/face-photo/*", u.serveFacePhoto) r.Post("/face-gallery/import", u.actionFaceGalleryImport) r.Post("/face-gallery/build", u.actionFaceGalleryBuild) r.Post("/face-gallery/add", u.actionFaceGalleryAdd) r.Post("/face-gallery/delete", u.actionFaceGalleryDelete) r.Post("/face-gallery/rename", u.actionFaceGalleryRename) r.Post("/face-gallery/add-photo", u.actionFaceGalleryAddPhoto) r.Post("/face-gallery/delete-photo", u.actionFaceGalleryDeletePhoto) r.Get("/monitor", u.pageMonitor) r.Get("/hls/*", u.proxyHLS) r.Get("/api/monitor/channels", u.apiMonitorChannels) r.Get("/api/device-metrics", u.apiDeviceMetrics) r.Get("/recognition", u.pageRecognition) r.Get("/logs", u.pageLogs) r.Get("/api", u.pageAPIConsole) return r, nil } func (u *UI) render(w http.ResponseWriter, r *http.Request, content string, data PageData) { data.Version = version data.Year = time.Now().Year() var buf bytes.Buffer if err := u.tpl.ExecuteTemplate(&buf, content, data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } data.ContentHTML = template.HTML(buf.String()) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store, max-age=0") w.Header().Set("Pragma", "no-cache") if err := u.tpl.ExecuteTemplate(w, "layout", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } } func (u *UI) findDevice(id string) (*models.Device, bool) { u.ensureDevicesLoaded() devices := u.registry.GetDevices() for _, d := range devices { if d.DeviceID == id { return d, true } } if u.discovery != nil { _, _ = u.discovery.SearchDefault() devices = u.registry.GetDevices() for _, d := range devices { if d.DeviceID == id { return d, true } } } return nil, false } func (u *UI) ensureDevicesLoaded() { if u.registry == nil || u.discovery == nil { return } devices := u.registry.GetDevices() if len(devices) > 0 { // Check if any device is online; if not, try discovery to refresh hasOnline := false for _, d := range devices { if d.Online { hasOnline = true break } } if hasOnline { return } } _, _ = u.discovery.SearchDefault() if len(u.registry.GetDevices()) == 0 { _, _ = u.discovery.SearchDefault() } } func (u *UI) pageDashboard(w http.ResponseWriter, r *http.Request) { data := u.deviceOverviewPageData(r, nil, "") if u.tasks != nil { for _, task := range u.tasks.ListTasks() { switch task.Status { case models.TaskRunning: data.RunningTaskCount++ case models.TaskFailed: data.FailedTaskCount++ case models.TaskSuccess: data.SuccessTaskCount++ } } } data.Title = "总览" if u.alarmCollector != nil { data.AlarmRecords = u.alarmCollector.GetRecent(5) all := u.alarmCollector.GetRecent(9999) today := time.Now().Format("2006-01-02") for _, a := range all { if strings.HasPrefix(a.Timestamp, today) { data.TodayAlarmCount++ } } } data.Tasks = nil if u.tasks != nil { data.Tasks = u.tasks.ListTasks() } data.AttentionDevices = nil // Load device metrics if u.agent != nil { for _, dev := range data.Devices { if dev == nil || !dev.Online { continue } body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/metrics", nil) if err != nil || code != 200 { continue } var m struct { CPU struct{ UsagePct float64 `json:"usage_pct"` } `json:"cpu"` Memory struct{ TotalKB uint64 `json:"total_kb"`; AvailableKB uint64 `json:"available_kb"` } `json:"memory"` NPU struct { UsagePct float64 `json:"usage_pct"` Cores map[string]float64 `json:"cores"` } `json:"npu"` Disk struct{ UsedPct float64 `json:"used_pct"` } `json:"disk"` Temperature struct{ Celsius float64 `json:"celsius"` } `json:"temperature"` } json.Unmarshal(body, &m) var mem float64 if m.Memory.TotalKB > 0 { mem = float64(m.Memory.TotalKB-m.Memory.AvailableKB) / float64(m.Memory.TotalKB) * 100 } npuVal := m.NPU.UsagePct if len(m.NPU.Cores) > 0 { for _, v := range m.NPU.Cores { if v > npuVal { npuVal = v } } } data.DeviceMetrics = append(data.DeviceMetrics, DeviceMetric{ Name: dev.DisplayName(), CPU: m.CPU.UsagePct, Mem: mem, NPU: npuVal, Disk: m.Disk.UsedPct, Temperature: m.Temperature.Celsius, }) } } for _, dev := range data.Devices { if dev != nil && !dev.Online { data.AttentionDevices = append(data.AttentionDevices, dev) } } u.render(w, r, "dashboard", data) } func (u *UI) pageConsole(w http.ResponseWriter, r *http.Request) { u.ensureDevicesLoaded() data := PageData{Title: "管控台"} data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) devices := u.registry.GetDevices() data.Devices = devices // Load video sources var allSources []service.ConfigVideoSourceAsset if u.preview != nil { if items, err := u.preview.ListVideoSources(); err == nil { allSources = items } } // Load recognition units and device assignments var units []service.RecognitionUnitAsset var assignments []service.DeviceAssignmentAsset if u.preview != nil { if items, err := u.preview.ListRecognitionUnits(); err == nil { units = items } if items, err := u.preview.ListDeviceAssignments(); err == nil { assignments = items } } // Build device->unit mapping assignedUnits := map[string][]service.RecognitionUnitAsset{} for _, a := range assignments { for _, ref := range a.RecognitionUnits { for _, u := range units { if u.Ref == ref { assignedUnits[a.DeviceID] = append(assignedUnits[a.DeviceID], u) break } } } } // Track used video sources usedSources := map[string]bool{} for _, unitList := range assignedUnits { for _, u := range unitList { if u.VideoSourceRef != "" { usedSources[u.VideoSourceRef] = true } } } // Build source name lookup sourceNames := map[string]string{} for _, s := range allSources { sourceNames[s.Name] = s.Name } // Query each device's capabilities from agent to filter available features. deviceAvailableFeatures := map[string][]ConsoleFeature{} if u.agent != nil { for _, dev := range devices { if dev == nil || !dev.Online || dev.DeviceID == "" { continue } body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/capabilities", nil) if err != nil || code != 200 { continue } var capsResp struct { Capabilities []struct { Key string `json:"key"` Label string `json:"label"` Available bool `json:"available"` Description string `json:"description"` } `json:"capabilities"` } if json.Unmarshal(body, &capsResp) != nil { continue } features := make([]ConsoleFeature, 0, len(capsResp.Capabilities)) for _, c := range capsResp.Capabilities { features = append(features, ConsoleFeature{ Key: c.Key, Label: c.Label, Description: c.Description, Available: c.Available, Enabled: false, }) } deviceAvailableFeatures[dev.DeviceID] = features } } // Load persisted device features as the source of truth. // This represents what capabilities the device is configured for, // not just what the last deployment used. persistedFeatures := map[string]map[string]bool{} if u.preview != nil { for _, dev := range devices { if dev == nil || dev.DeviceID == "" { continue } if feats, err := u.preview.GetDeviceFeatures(dev.DeviceID); err == nil && len(feats) > 0 { m := map[string]bool{} for _, f := range feats { m[f] = true } persistedFeatures[dev.DeviceID] = m } } } // Load device metrics (NPU, etc) metrics := map[string]DeviceMetric{} if u.agent != nil { for _, dev := range devices { if dev == nil || !dev.Online { continue } body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/metrics", nil) if err != nil || code != 200 { continue } var m struct { CPU struct{ UsagePct float64 `json:"usage_pct"` } `json:"cpu"` Memory struct { TotalKB float64 `json:"total_kb"` AvailableKB float64 `json:"available_kb"` } `json:"memory"` NPU struct { UsagePct float64 `json:"usage_pct"` Cores map[string]float64 `json:"cores"` } `json:"npu"` Disk struct{ UsedPct float64 `json:"used_pct"` } `json:"disk"` Temperature struct{ Celsius float64 `json:"celsius"` } `json:"temperature"` } json.Unmarshal(body, &m) npuVal := m.NPU.UsagePct // Pick max core if per-core data available if len(m.NPU.Cores) > 0 { for _, v := range m.NPU.Cores { if v > npuVal { npuVal = v } } } memPct := 0.0 if m.Memory.TotalKB > 0 { memPct = (m.Memory.TotalKB - m.Memory.AvailableKB) / m.Memory.TotalKB * 100 } metrics[dev.DeviceID] = DeviceMetric{ Name: dev.DisplayName(), CPU: m.CPU.UsagePct, Mem: memPct, NPU: npuVal, Disk: m.Disk.UsedPct, Temperature: m.Temperature.Celsius, } } } // Build console device data var consoleDevices []ConsoleDeviceData for _, dev := range devices { if dev == nil { continue } maxUnits := 4 // Gather channels (one per assigned recognition unit) var channels []ConsoleChannel var cameras []string for _, u := range assignedUnits[dev.DeviceID] { // HLS channel name matches recognition unit Name (used in publish path) chName := u.Name displayName := u.Name if u.DisplayName != "" { displayName = u.DisplayName } srcName := u.VideoSourceRef if srcName == "" { srcName = displayName } channels = append(channels, ConsoleChannel{Name: chName, Display: displayName, SourceName: srcName}) if u.VideoSourceRef != "" { cameras = append(cameras, u.VideoSourceRef) } } // Determine which features are enabled from persisted device features. activeKeys := persistedFeatures[dev.DeviceID] if activeKeys == nil { activeKeys = map[string]bool{} } // Features from device capabilities (from agent /v1/capabilities). // Overlay persisted DB state for checkmarks and conflict detection. available := deviceAvailableFeatures[dev.DeviceID] var features []ConsoleFeature if len(available) > 0 { for _, f := range available { f.Enabled = activeKeys[f.Key] // Conflict: user enabled a feature that the device no longer supports. if f.Enabled && !f.Available { f.Conflict = true } features = append(features, f) } // Show persisted features that the agent didn't report (e.g. new features // added to registry but old agent doesn't know them). seen := map[string]bool{} for _, f := range features { seen[f.Key] = true } for key := range activeKeys { if !seen[key] { features = append(features, ConsoleFeature{ Key: key, Label: key, Enabled: true, Available: false, Conflict: true, }) } } } else { // Old agent: show persisted keys. for key := range activeKeys { features = append(features, ConsoleFeature{Key: key, Label: key, Enabled: true, Available: true}) } } npu, diskUsage, temp, cpuUsage, memUsage := 0.0, 0.0, 0.0, 0.0, 0.0 if m, ok := metrics[dev.DeviceID]; ok { cpuUsage = m.CPU memUsage = m.Mem npu = m.NPU diskUsage = m.Disk temp = m.Temperature } consoleDevices = append(consoleDevices, ConsoleDeviceData{ Device: dev, CPUUsage: cpuUsage, MemUsage: memUsage, NPUUsage: npu, DiskUsage: diskUsage, Temperature: temp, MaxUnits: maxUnits, Channels: channels, Features: features, Cameras: cameras, }) } // Unassigned video sources var unassigned []service.ConfigVideoSourceAsset for _, s := range allSources { if !usedSources[s.Name] { unassigned = append(unassigned, s) } } data.ConsoleDevices = consoleDevices data.ConsoleVideoSources = unassigned data.ConsoleAllVideoSources = allSources u.render(w, r, "console", data) } func (u *UI) actionConsoleSave(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if u.autoConfig == nil || u.preview == nil { http.Redirect(w, r, "/ui/console?error="+url.QueryEscape("自动配置服务未初始化"), http.StatusFound) return } u.ensureDevicesLoaded() devices := u.registry.GetDevices() // Step 1: collect form features per device. formFeatures := map[string][]string{} for _, dev := range devices { if dev == nil || dev.DeviceID == "" { continue } features := cleanFormList(r.Form["device_"+dev.DeviceID+"_feature"]) if len(features) > 0 { formFeatures[dev.DeviceID] = features } } // Step 2: build requests using form features (user intent) and // existing recognition unit video sources. var requests []service.AutoConfigRequest for _, dev := range devices { if dev == nil || dev.DeviceID == "" { continue } features := formFeatures[dev.DeviceID] var sources []string if assignment, err := u.preview.GetDeviceAssignment(dev.DeviceID); err == nil && assignment != nil { for _, ref := range assignment.RecognitionUnits { if unit, err := u.preview.GetRecognitionUnit(ref); err == nil && unit != nil { if unit.VideoSourceRef != "" { sources = append(sources, unit.VideoSourceRef) } } } } if len(features) == 0 { continue } requests = append(requests, service.AutoConfigRequest{ DeviceID: dev.DeviceID, Features: features, SourceNames: sources, }) } if len(requests) == 0 { http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape("未选择检测功能"), http.StatusFound) return } // Step 3: save features to DB BEFORE creating tasks (avoids SQLITE_BUSY race). for _, req := range requests { _ = u.preview.SaveDeviceFeatures(req.DeviceID, req.Features) } // Step 4: run pipeline (creates tasks asynchronously). results, _ := u.autoConfig.BuildPipelineBatch(requests, true) // Collect task IDs for redirect. taskIDs := make([]string, 0) var errs []string for _, res := range results { if res.TaskID != "" { taskIDs = append(taskIDs, res.TaskID) } if res.Error != "" { errs = append(errs, res.DeviceID+": "+res.Error) } } if len(taskIDs) == 1 { http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape("正在下发配置...")+"&task="+taskIDs[0], http.StatusFound) return } if len(taskIDs) > 1 { http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape(fmt.Sprintf("正在为 %d 台设备下发配置...", len(taskIDs))), http.StatusFound) return } if len(errs) > 0 { http.Redirect(w, r, "/ui/console?error="+url.QueryEscape(strings.Join(errs, "; ")), http.StatusFound) return } http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape("配置已保存"), http.StatusFound) } func (u *UI) pageDevices(w http.ResponseWriter, r *http.Request) { u.render(w, r, "devices", u.deviceOverviewPageData(r, nil, "")) } func (u *UI) pageDeviceAdd(w http.ResponseWriter, r *http.Request) { u.render(w, r, "device_add", PageData{Title: "新增设备"}) } func (u *UI) pageDeviceConfig(w http.ResponseWriter, r *http.Request) { u.ensureDevicesLoaded() u.render(w, r, "device_config", PageData{ Title: "设备配置入口", Devices: u.registry.GetDevices(), }) } func (u *UI) pageDeviceConfigDetail(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") http.Redirect(w, r, "/ui/devices/"+url.PathEscape(id)+"#device-config", http.StatusFound) } func (u *UI) actionDeviceAdd(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() deviceID := strings.TrimSpace(r.FormValue("device_id")) deviceName := strings.TrimSpace(r.FormValue("device_name")) ip := strings.TrimSpace(r.FormValue("ip")) agentPort, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("agent_port"))) mediaPort, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("media_port"))) if deviceID == "" || ip == "" { u.render(w, r, "device_add", PageData{Title: "新增设备", Error: "节点标识和 IP 不能为空"}) return } if agentPort == 0 { agentPort = 9100 } if mediaPort == 0 { mediaPort = 9000 } dev := &models.Device{ DeviceID: deviceID, DeviceName: deviceName, IP: ip, AgentPort: agentPort, MediaPort: mediaPort, Online: true, LastSeenMs: time.Now().UnixMilli(), } u.registry.UpdateDevice(dev) http.Redirect(w, r, "/ui/devices", http.StatusFound) } func (u *UI) actionDiscoverySearch(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() timeoutMs, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("timeout_ms"))) if timeoutMs <= 0 { timeoutMs = 1200 } found, err := u.discovery.Search(timeoutMs) devices := u.registry.GetDevices() online := 0 for _, d := range devices { if d.Online { online++ } } data := PageData{Title: "设备", Devices: devices, Found: found, FoundCount: len(found), DeviceCount: len(devices), OnlineCount: online, OfflineCount: len(devices) - online} if err != nil { data.Error = err.Error() } u.render(w, r, "devices", data) } func (u *UI) actionDevicesBatchAction(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() action := strings.TrimSpace(r.FormValue("action")) deviceIDs := filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"]) if len(deviceIDs) == 0 { u.render(w, r, "devices", u.deviceOverviewPageData(r, nil, "请先选择设备")) return } typeStr := "" switch action { case "media_start", "media_restart", "media_stop", "reload", "rollback": typeStr = action default: u.render(w, r, "devices", u.deviceOverviewPageData(r, deviceIDs, "不支持的操作: "+action)) return } if u.tasks == nil { http.Error(w, "task service not initialized", http.StatusInternalServerError) return } var payload any if typeStr == "media_start" || typeStr == "media_restart" { cfgName := strings.TrimSpace(r.FormValue("config")) if cfgName != "" { payload = map[string]any{"config": cfgName} } } task, err := u.tasks.CreateTask(typeStr, deviceIDs, payload) if err != nil { u.render(w, r, "devices", u.deviceOverviewPageData(r, deviceIDs, err.Error())) return } http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound) } func (u *UI) pageDeviceBatchConfig(w http.ResponseWriter, r *http.Request) { data := u.deviceBatchConfigPageData(r, selectedIDsFromQuery(r.URL.Query()["selected"])) u.render(w, r, "device_batch_config", data) } func (u *UI) actionDeviceBatchConfig(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() selectedIDs := filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"]) data := u.deviceBatchConfigPageData(r, selectedIDs) if len(selectedIDs) == 0 { data.Error = "请先选择需要下发分配的设备" u.render(w, r, "device_batch_config", data) return } if u.tasks == nil { data.Error = "task service not initialized" u.render(w, r, "device_batch_config", data) return } configs := make(map[string]any, len(selectedIDs)) for _, deviceID := range selectedIDs { preview, err := u.preview.RenderDeviceAssignment(deviceID) if err != nil { data.Error = err.Error() u.render(w, r, "device_batch_config", data) return } if data.ConfigPreview == nil { data.ConfigPreview = preview } var configDoc any if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil { data.Error = "生成配置 JSON 无效: " + err.Error() u.render(w, r, "device_batch_config", data) return } configs[deviceID] = configDoc } task, err := u.tasks.CreateTask("config_apply", selectedIDs, map[string]any{"configs": configs}) if err != nil { data.Error = err.Error() u.render(w, r, "device_batch_config", data) return } http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound) } func (u *UI) pageDevice(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } u.render(w, r, "device", u.deviceDetailPageData(dev)) } func (u *UI) deviceDetailPageData(dev *models.Device) PageData { data := u.deviceControlPageData(dev) data.Title = "设备详情" if data.ConfigStatus == nil && u.stateRepo != nil && dev != nil { if state, err := u.stateRepo.Get(dev.DeviceID); err == nil && state != nil { data.PersistedConfig = state } } if u.preview != nil { if profiles, err := u.preview.ListProfileAssets(); err == nil { data.AssetProfiles = profiles selectedProfile := "" if data.ConfigStatus != nil && strings.TrimSpace(data.ConfigStatus.Metadata.Profile) != "" { selectedProfile = strings.TrimSpace(data.ConfigStatus.Metadata.Profile) } else if data.PersistedConfig != nil && strings.TrimSpace(data.PersistedConfig.ProfileName) != "" { selectedProfile = strings.TrimSpace(data.PersistedConfig.ProfileName) } if selectedProfile == "" && len(profiles) > 0 { selectedProfile = profiles[0].Name } data.SelectedProfile = selectedProfile for i := range profiles { if strings.TrimSpace(profiles[i].Name) == selectedProfile { data.AssetProfile = &profiles[i] data.SelectedTemplate = profileAssetTemplate(&profiles[i]) break } } if data.AssetProfile == nil && len(profiles) > 0 { data.AssetProfile = &profiles[0] data.SelectedProfile = profiles[0].Name data.SelectedTemplate = profileAssetTemplate(&profiles[0]) } } else if data.Error == "" { data.Error = err.Error() } if assignment, err := u.preview.GetDeviceAssignment(dev.DeviceID); err == nil { data.DeviceAssignment = assignment data.SelectedAssignmentDevice = assignment.DeviceID data.SelectedProfile = assignment.ProfileName } } return data } func (u *UI) pageDeviceControl(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") http.Redirect(w, r, "/ui/devices/"+url.PathEscape(id)+"#device-config", http.StatusFound) } func (u *UI) actionDeviceAction(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } _ = r.ParseForm() action := strings.TrimSpace(r.FormValue("action")) method := "POST" path := "" switch action { case "reload": path = "/v1/media-server/reload" case "rollback": path = "/v1/media-server/rollback" case "media_start": path = "/v1/media-server/start" case "media_restart": path = "/v1/media-server/restart" case "media_stop": path = "/v1/media-server/stop" case "media_status": method = "GET" path = "/v1/media-server/status" case "info": method = "GET" path = "/v1/info" default: http.Error(w, "unknown action", http.StatusBadRequest) return } body, code, err := u.agent.Do(method, dev.IP, dev.AgentPort, path, nil) msg := fmt.Sprintf("%s %s -> %d", method, path, code) returnTo := strings.TrimSpace(r.FormValue("return_to")) if returnTo == "control" || returnTo == "config" { data := u.deviceDetailPageData(dev) data.Message = msg data.RawText = string(body) data.ResultTitle = "执行结果摘要" if err != nil { data.Error = err.Error() } u.render(w, r, "device", data) return } data := PageData{Title: "设备详情", Device: dev, Message: msg, RawText: string(body)} if err != nil { data.Error = err.Error() } u.render(w, r, "device", data) } func (u *UI) pageDeviceLogs(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } limit := strings.TrimSpace(r.URL.Query().Get("limit")) path := "/v1/logs/recent" if limit != "" { path += "?limit=" + urlQueryEscape(limit) } body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, path, nil) data := PageData{Title: "诊断日志", Device: dev, Message: fmt.Sprintf("GET %s -> %d", path, code), RawText: string(body)} if err != nil { data.Error = err.Error() } u.render(w, r, "device_logs", data) } func (u *UI) pageDeviceGraphs(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/graphs", nil) data := PageData{Title: "运行指标", Device: dev, Message: fmt.Sprintf("GET /v1/graphs -> %d", code), RawText: string(body)} if err != nil { data.Error = err.Error() } u.render(w, r, "device_graphs", data) } func (u *UI) actionDeviceConfigApply(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } _ = r.ParseForm() raw := strings.TrimSpace(r.FormValue("json")) if raw == "" { raw = `{"config":{}}` } body, code, err := u.agent.Do("PUT", dev.IP, dev.AgentPort, "/v1/config", []byte(raw)) data := PageData{Title: "设备详情", Device: dev, Message: fmt.Sprintf("PUT /v1/config -> %d", code), RawText: string(body), RawJSON: raw} if err != nil { data.Error = err.Error() } u.render(w, r, "device", data) } func (u *UI) actionDeviceAliasSave(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } _ = r.ParseForm() alias := strings.TrimSpace(r.FormValue("device_alias")) data := u.deviceDetailPageData(dev) if err := u.registry.SetDeviceAlias(id, alias); err != nil { data.Error = err.Error() u.render(w, r, "device", data) return } dev.DeviceAlias = alias data.Device = dev data.Message = "设备别名已保存" u.render(w, r, "device", data) } func (u *UI) actionDeviceModelUpload(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } if err := r.ParseMultipartForm(100 << 20); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } name := strings.TrimSpace(r.FormValue("name")) file, hdr, err := r.FormFile("file") if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } defer file.Close() if name == "" { http.Error(w, "name is required", http.StatusBadRequest) return } path := fmt.Sprintf("/v1/models/%s", name) resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, path, file, "application/octet-stream", hdr.Size) out := PageData{Title: "设备详情", Device: dev, Message: fmt.Sprintf("PUT %s -> %d", path, code), RawText: string(resp)} if derr != nil { out.Error = derr.Error() } u.render(w, r, "device", out) } func (u *UI) actionDeviceMediaServerConfigUpload(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } if err := r.ParseMultipartForm(50 << 20); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } name, err := normalizeConfigName(r.FormValue("name")) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } file, hdr, err := r.FormFile("file") if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } defer file.Close() path := "/v1/media-server/configs/" + url.PathEscape(name) resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, path, file, "application/json", hdr.Size) data := PageData{Title: "设备详情", Device: dev, Message: fmt.Sprintf("PUT %s -> %d", path, code), RawText: string(resp)} if derr != nil { data.Error = derr.Error() } u.render(w, r, "device", data) } func (u *UI) actionDeviceMediaServerConfigUploadBatch(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } if err := r.ParseMultipartForm(200 << 20); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if r.MultipartForm == nil || len(r.MultipartForm.File) == 0 { http.Error(w, "files is required", http.StatusBadRequest) return } files := r.MultipartForm.File["files"] if len(files) == 0 { http.Error(w, "files is required", http.StatusBadRequest) return } var sb strings.Builder errCount := 0 for _, hdr := range files { name, nerr := normalizeConfigName(filepath.Base(hdr.Filename)) if nerr != nil { errCount++ sb.WriteString(fmt.Sprintf("%s -> invalid name: %v\n", hdr.Filename, nerr)) continue } file, err := hdr.Open() if err != nil { errCount++ sb.WriteString(fmt.Sprintf("%s -> open failed: %v\n", name, err)) continue } path := "/v1/media-server/configs/" + url.PathEscape(name) resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, path, file, "application/json", hdr.Size) _ = file.Close() if derr != nil { errCount++ sb.WriteString(fmt.Sprintf("%s -> %d error: %v\n", name, code, derr)) continue } if len(resp) > 0 { sb.WriteString(fmt.Sprintf("%s -> %d %s\n", name, code, strings.TrimSpace(string(resp)))) } else { sb.WriteString(fmt.Sprintf("%s -> %d\n", name, code)) } } data := PageData{Title: "设备详情", Device: dev, Message: "批量上传完成", RawText: sb.String()} if errCount > 0 { data.Error = fmt.Sprintf("部分失败: %d/%d", errCount, len(files)) } u.render(w, r, "device", data) } func (u *UI) pageTasks(w http.ResponseWriter, r *http.Request) { u.ensureDevicesLoaded() devices := u.registry.GetDevices() selectedIDs := filterSelectedDeviceIDs(devices, selectedIDsFromQuery(r.URL.Query()["selected"])) data := PageData{ Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: devices, SelectedDeviceIDs: selectedIDs, SelectedDevices: selectedDevicesFromIDs(devices, selectedIDs), DeviceIDs: strings.Join(selectedIDs, ","), } u.render(w, r, "tasks", data) } func (u *UI) taskPageData(task *models.Task) PageData { data := PageData{Title: "任务中心", Task: task} if task == nil { return data } devices := make(map[string]*models.Device) if u.registry != nil { for _, dev := range u.registry.GetDevices() { if dev == nil { continue } devices[dev.DeviceID] = dev } } rows := make([]TaskDeviceRow, 0, len(task.DeviceIDs)) for _, did := range task.DeviceIDs { row := TaskDeviceRow{} if dev := devices[did]; dev != nil { row.Device = dev } else { row.Device = &models.Device{DeviceID: did} } if ds := task.Devices[did]; ds != nil { row.Status = ds.Status row.Progress = ds.Progress row.Error = ds.Error } rows = append(rows, row) } data.TaskDeviceRows = rows return data } func (u *UI) actionCreateTask(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() typeStr := strings.TrimSpace(r.FormValue("type")) if typeStr == "" { typeStr = "config_apply" } ids := strings.TrimSpace(r.FormValue("device_ids")) var deviceIDs []string if ids != "" { for _, p := range strings.Split(ids, ",") { p = strings.TrimSpace(p) if p != "" { deviceIDs = append(deviceIDs, p) } } } else { deviceIDs = filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"]) ids = strings.Join(deviceIDs, ",") } raw := strings.TrimSpace(r.FormValue("payload_json")) if raw == "" { raw = `{"config":{}}` } var payload any if err := json.Unmarshal([]byte(raw), &payload); err != nil { u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: "高级参数 JSON 无效: " + err.Error(), RawJSON: raw, DeviceIDs: ids}) return } task, err := u.tasks.CreateTask(typeStr, deviceIDs, payload) if err != nil { u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: err.Error(), RawJSON: raw, DeviceIDs: ids}) return } http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound) } func (u *UI) pageTask(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") items := u.tasks.ListTasks() var task *models.Task for i := range items { if items[i].ID == id { t := items[i] task = &t break } } if task == nil { http.NotFound(w, r) return } data := u.taskPageData(task) data.TaskID = id u.render(w, r, "task", data) } func (u *UI) pageTemplates(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ui/assets/templates", http.StatusFound) } func (u *UI) pageTemplate(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ui/assets/templates/"+url.PathEscape(chi.URLParam(r, "name")), http.StatusFound) } func (u *UI) pageModels(w http.ResponseWriter, r *http.Request) { u.ensureDevicesLoaded() data := PageData{Title: "模型管理", Devices: u.registry.GetDevices()} for _, dev := range data.Devices { if dev == nil { continue } if dev.Online { data.OnlineCount++ } } board := service.ModelStatusBoard{} if strings.TrimSpace(u.dbPath) != "" { if store, err := storage.OpenSQLite(u.dbPath); err == nil { modelsRepo := storage.NewModelsRepo(store.DB()) if items, err := modelsRepo.List(); err == nil { data.StandardModels = items installed := map[string][]service.InstalledModelStatus{} for _, device := range data.Devices { if device == nil || !device.Online { continue } items, err := service.FetchInstalledModelStatuses(u.agent, device) if err == nil { installed[device.DeviceID] = items } } board = service.BuildModelStatusBoard(items, data.Devices, installed) } _ = store.Close() } } data.ModelStatusBoard = &board u.render(w, r, "models", data) } func (u *UI) actionModelSync(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } action := strings.TrimSpace(r.FormValue("action")) if action == "" { action = "model_sync_all" } if action != "model_sync_one" && action != "model_sync_all" { http.Error(w, "invalid action", http.StatusBadRequest) return } deviceIDs := make([]string, 0) for _, id := range r.Form["device_id"] { id = strings.TrimSpace(id) if id != "" { deviceIDs = append(deviceIDs, id) } } if len(deviceIDs) == 0 { http.Error(w, "missing device_id", http.StatusBadRequest) return } payload := map[string]any{} if modelName := strings.TrimSpace(r.FormValue("model_name")); modelName != "" { payload["model_name"] = modelName } task, err := u.tasks.CreateTask(action, deviceIDs, payload) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/ui/tasks/"+url.PathEscape(task.ID), http.StatusFound) }