package web import ( "bytes" "crypto/sha256" "encoding/csv" "encoding/hex" "encoding/json" "fmt" "html/template" "io" "mime" "mime/multipart" "io/fs" "log" "net/http" "net/url" "os" "path/filepath" "sort" "strconv" "strings" "time" "unicode/utf8" "safesight-control/internal/config" "safesight-control/internal/models" "safesight-control/internal/service" "safesight-control/internal/storage" "github.com/go-chi/chi/v5" "golang.org/x/text/encoding/simplifiedchinese" ) 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 configPath string cfg *config.Config resourcesRepo *storage.ResourcesRepo alarmCollector *service.AlarmCollector autoConfig *service.AutoConfigService configVersionsRepo *storage.ConfigVersionsRepo 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 WizardStale map[string]bool 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 ConfigVersions []storage.ConfigVersionRecord 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 UnacknowledgedAlarmCount int DetectionChannelCount int StaleCount int SystemAlarmRetention int SystemConcurrency int SystemCompanyName string AlarmFilterDevices []string AlarmFilterRuleTypes []string SelectedAlarmDevice string SelectedAlarmRuleType string DeviceMetrics []DeviceMetric ConsoleDevices []ConsoleDeviceData ConsoleVideoSources []service.ConfigVideoSourceAsset ConsoleAllVideoSources []service.ConfigVideoSourceAsset WizardVideoSourcesJSON template.HTML ConsoleUnassignedUnits []service.RecognitionUnitAsset FaceGalleryPersons []storage.PersonRecord FaceGalleryQuality map[string]string DeviceModelStatuses []service.InstalledModelStatus DeviceResourceStatuses []service.InstalledResourceStatus 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 IntegrationStale bool } 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 "资源同步" case "face_gallery_reload": 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 "face_gallery_reload": 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 "face_gallery_reload": return "pill ok" 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 }, "severityClass": func(v string) string { switch strings.TrimSpace(v) { case "critical": return "bad" case "warning": return "warn" case "info": return "ok" default: return "" } }, "severityLabel": func(v string) string { switch strings.TrimSpace(v) { case "critical": return "严重" case "warning": return "警告" case "info": return "信息" default: return v } }, "statusClass": func(v string) string { switch strings.TrimSpace(v) { case "unacknowledged": return "bad" case "acknowledged": return "run" case "resolved": return "ok" default: return "" } }, "statusLabel": func(v string) string { switch strings.TrimSpace(v) { case "unacknowledged": return "未处理" case "acknowledged": return "已确认" case "resolved": return "已关闭" default: return v } }, }).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) SetConfig(cfg *config.Config, cfgPath string) { if u == nil { return } u.cfg = cfg u.configPath = cfgPath } 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 (u *UI) SetConfigVersionsRepo(repo *storage.ConfigVersionsRepo) { if u == nil { return } u.configVersionsRepo = repo } 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) RegisterAlarmRoutes(r chi.Router) { r.Get("/api/alarms/stream", u.apiAlarmStream) r.Get("/api/alarms/unacknowledged-count", u.apiAlarmUnacknowledgedCount) r.Get("/api/alarms/daily-count", u.apiAlarmDailyCount) r.Get("/api/alarms/export", u.apiAlarmExport) r.Get("/api/monitor/channels", u.apiMonitorChannels) r.Get("/api/device-metrics", u.apiDeviceMetrics) } 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, "/dashboard", http.StatusFound) }) r.Get("/dashboard", u.pageDashboard) r.Get("/console", u.pageConsole) r.Post("/console", u.actionConsoleSave) r.Get("/wizard", u.pageWizard) r.Get("/wizard/device-info", u.apiWizardDeviceInfo) r.Post("/wizard/apply", u.actionWizardApply) r.Post("/wizard/create-source", u.actionWizardCreateSource) r.Get("/devices", u.pageDevices) r.Post("/devices/cleanup-offline", u.actionDevicesCleanupOffline) 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.Get("/assets/video-sources/template", u.actionVideoSourceCSVTemplate) r.Post("/assets/video-sources/import", u.actionVideoSourceCSVImport) 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.Post("/system/config", u.actionSystemConfig) 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.Get("/devices/{id}/config-versions", u.pageDeviceConfigVersions) r.Get("/devices/{id}/config-versions/{versionID}", u.apiDeviceConfigVersion) 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}/models/upload", u.actionDeviceModelUpload) r.Post("/devices/{id}/edge-server/configs/upload", u.actionDeviceMediaServerConfigUpload) r.Post("/devices/{id}/edge-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/upload", u.actionModelUpload) r.Post("/models/sync", u.actionModelSync) r.Post("/resources/sync", u.actionResourceSync) r.Get("/diagnostics", u.pageDiagnostics) r.Get("/alarms", u.pageAlarms) r.Post("/alarms/{id}/acknowledge", u.actionAcknowledgeAlarm) r.Post("/alarms/{id}/resolve", u.actionResolveAlarm) r.Post("/alarms/{id}/severity", u.actionUpdateAlarmSeverity) 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.Post("/face-gallery/sync", u.actionFaceGallerySync) r.Get("/monitor", u.pageMonitor) r.Get("/hls/*", u.proxyHLS) 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() if u.cfg != nil { if data.SystemCompanyName == "" { data.SystemCompanyName = u.cfg.CompanyName } } if u.alarmCollector != nil { data.UnacknowledgedAlarmCount = u.alarmCollector.GetUnacknowledgedCount() } 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 offline; trigger discovery to refresh hasOffline := false for _, d := range devices { if !d.Online { hasOffline = true break } } if !hasOffline { return } } _, _ = u.discovery.SearchDefault() if len(u.registry.GetDevices()) == 0 { _, _ = u.discovery.SearchDefault() } } func (u *UI) pageDashboard(w http.ResponseWriter, r *http.Request) { // First-time setup: if no recognition units exist, guide to wizard. if u.preview != nil { if units, err := u.preview.ListRecognitionUnits(); err == nil && len(units) == 0 { http.Redirect(w, r, "/wizard", http.StatusFound) return } } 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) data.UnacknowledgedAlarmCount = u.alarmCollector.GetUnacknowledgedCount() 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 // Count detection channels if u.preview != nil { if units, err := u.preview.ListRecognitionUnits(); err == nil { data.DetectionChannelCount = len(units) } } // Count stale integration devices for _, row := range data.DeviceRows { if row.IntegrationStale { data.StaleCount++ } } // 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 and active features from agent. deviceAvailableFeatures := map[string][]ConsoleFeature{} deviceActiveFeatures := map[string]map[string]bool{} if u.agent != nil { for _, dev := range devices { if dev == nil || dev.DeviceID == "" { continue } // Get capabilities 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 // Get active features from config template body2, code2, err2 := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/status", nil) if err2 == nil && code2 == 200 { tmpl := jsonGetStr(body2, "template") if tmpl != "" { active := featuresFromTemplate(tmpl) m := map[string]bool{} for _, f := range active { m[f] = true } deviceActiveFeatures[dev.DeviceID] = m } } } } // Load device metrics (NPU, etc) metrics := map[string]DeviceMetric{} if u.agent != nil { for _, dev := range devices { if dev == nil { 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 := deviceActiveFeatures[dev.DeviceID] if activeKeys == nil { activeKeys = map[string]bool{} } 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 { if !f.Available { continue // skip features the device doesn't support } f.Enabled = activeKeys[f.Key] features = append(features, f) } // Only show features the device actually supports. seen := map[string]bool{} for _, f := range features { seen[f.Key] = true } } else { // Old agent without /v1/capabilities: show nothing for now. } 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, "/console?error="+url.QueryEscape("自动配置服务未初始化"), http.StatusFound) return } u.ensureDevicesLoaded() devices := u.registry.GetDevices() // Step 1: collect form features per device. // Only process devices listed in dirty_device_ids (client-side change detection). dirtyIDs := strings.Split(strings.TrimSpace(r.FormValue("dirty_device_ids")), ",") dirtySet := map[string]bool{} for _, id := range dirtyIDs { if id != "" { dirtySet[id] = true } } formFeatures := map[string][]string{} for _, dev := range devices { if dev == nil || dev.DeviceID == "" { continue } if len(dirtySet) > 0 && !dirtySet[dev.DeviceID] { continue } features := cleanFormList(r.Form["device_"+dev.DeviceID+"_feature"]) if len(features) > 0 { formFeatures[dev.DeviceID] = features } } // Step 2: build requests only for devices whose features actually changed. var requests []service.AutoConfigRequest for _, dev := range devices { if dev == nil || dev.DeviceID == "" { continue } features := formFeatures[dev.DeviceID] if len(features) == 0 { continue } var sources []string // Merge: existing assignment sources + UI-added sources sourceSet := map[string]struct{}{} 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 != "" { sourceSet[unit.VideoSourceRef] = struct{}{} } } } } // Read UI-added sources from form (JS adds device_xxx_source hidden inputs) for _, src := range r.Form["device_"+dev.DeviceID+"_source"] { src = strings.TrimSpace(src) if src != "" { sourceSet[src] = struct{}{} } } for src := range sourceSet { sources = append(sources, src) } 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, "/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). if u.discovery != nil { u.discovery.SearchDefault() } 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, "/console?msg="+url.QueryEscape("正在下发配置...")+"&task="+taskIDs[0], http.StatusFound) return } if len(taskIDs) > 1 { http.Redirect(w, r, "/console?msg="+url.QueryEscape(fmt.Sprintf("正在为 %d 台设备下发配置...", len(taskIDs))), http.StatusFound) return } if len(errs) > 0 { http.Redirect(w, r, "/console?error="+url.QueryEscape(strings.Join(errs, "; ")), http.StatusFound) return } http.Redirect(w, r, "/console?msg="+url.QueryEscape("配置已保存"), http.StatusFound) } func (u *UI) pageWizard(w http.ResponseWriter, r *http.Request) { u.ensureDevicesLoaded() data := PageData{Title: "部署向导"} data.Devices = u.registry.GetDevices() data.WizardStale = u.integrationStaleMap() if u.preview != nil { if sources, err := u.preview.ListVideoSources(); err == nil { data.ConsoleAllVideoSources = sources type wizardSource struct { Name string `json:"name"` URL string `json:"url"` } var list []wizardSource for _, s := range sources { list = append(list, wizardSource{Name: s.Name, URL: s.Config.URL}) } if b, err := json.Marshal(list); err == nil { data.WizardVideoSourcesJSON = template.HTML(b) } } } u.render(w, r, "wizard", data) } func (u *UI) actionWizardApply(w http.ResponseWriter, r *http.Request) { if u.autoConfig == nil { http.Error(w, "not available", http.StatusServiceUnavailable) return } var req struct { DeviceID string `json:"device_id"` Features []string `json:"features"` SourceNames []string `json:"source_names"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } if u.preview != nil && len(req.Features) > 0 { _ = u.preview.SaveDeviceFeatures(req.DeviceID, req.Features) } result, err := u.autoConfig.BuildPipeline(service.AutoConfigRequest{ DeviceID: req.DeviceID, Features: req.Features, SourceNames: req.SourceNames, }, true) if err != nil { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(result) } func (u *UI) actionWizardCreateSource(w http.ResponseWriter, r *http.Request) { if u.preview == nil { http.Error(w, "not available", http.StatusServiceUnavailable) return } name := strings.TrimSpace(r.FormValue("name")) rtspURL := strings.TrimSpace(r.FormValue("url")) if name == "" || rtspURL == "" { http.Error(w, "name and url required", http.StatusBadRequest) return } asset := service.ConfigVideoSourceAsset{ Name: name, SourceType: "rtsp", Config: service.VideoSourceConfig{ URL: rtspURL, Resolution: "1920x1080", FPS: "25", }, } if err := u.preview.SaveVideoSourceAsset(asset); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"name": name}) } func (u *UI) apiWizardDeviceInfo(w http.ResponseWriter, r *http.Request) { deviceID := strings.TrimSpace(r.URL.Query().Get("device_id")) if deviceID == "" { http.Error(w, "device_id required", http.StatusBadRequest) return } var dev *models.Device for _, d := range u.registry.GetDevices() { if d != nil && d.DeviceID == deviceID { dev = d break } } if dev == nil { http.Error(w, "device not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") resp := map[string]any{ "online": dev.Online, "device_id": dev.DeviceID, "name": dev.DisplayName(), } if u.agent != nil { // Always attempt to query agent - Online flag may be stale channels := queryDeviceChannelsFromDB(u.preview, deviceID) resp["channels"] = channels caps := queryAgentCapabilities(u.agent, dev) resp["capabilities"] = caps if u.preview != nil { if enabled, err := u.preview.GetDeviceFeatures(deviceID); err == nil { resp["enabled_features"] = enabled } } } json.NewEncoder(w).Encode(resp) } func queryDeviceChannelsFromDB(preview *service.ConfigPreviewService, deviceID string) []map[string]string { if preview == nil { return nil } assignment, err := preview.GetDeviceAssignment(deviceID) if err != nil || assignment == nil { return nil } var channels []map[string]string for _, ref := range assignment.RecognitionUnits { unit, err := preview.GetRecognitionUnit(ref) if err != nil || unit == nil { continue } ch := map[string]string{ "name": unit.Name, } if unit.DisplayName != "" { ch["display"] = unit.DisplayName } else { ch["display"] = unit.Name } if unit.VideoSourceRef != "" { src, err := preview.GetVideoSource(unit.VideoSourceRef) if err == nil && src != nil && src.Config.URL != "" { ch["rtsp_url"] = src.Config.URL } } channels = append(channels, ch) } return channels } func queryAgentChannels(agent *service.AgentClient, dev *models.Device) []map[string]string { body, code, err := agent.Do("GET", dev.IP, dev.AgentPort, "/v1/preview/channels", nil) if err != nil || code != 200 { return nil } var result struct { Channels []struct { Name string `json:"name"` HlsURL string `json:"hls_url"` } `json:"channels"` } if json.Unmarshal(body, &result) != nil { return nil } var channels []map[string]string for _, ch := range result.Channels { channels = append(channels, map[string]string{ "name": ch.Name, "hls_url": ch.HlsURL, }) } return channels } func queryAgentCapabilities(agent *service.AgentClient, dev *models.Device) []map[string]any { body, code, err := agent.Do("GET", dev.IP, dev.AgentPort, "/v1/capabilities", nil) if err != nil || code != 200 { return nil } 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 { return nil } var caps []map[string]any for _, c := range capsResp.Capabilities { caps = append(caps, map[string]any{ "key": c.Key, "label": c.Label, "available": c.Available, "description": c.Description, }) } return caps } func (u *UI) pageDevices(w http.ResponseWriter, r *http.Request) { u.render(w, r, "devices", u.deviceOverviewPageData(r, nil, "")) } func (u *UI) actionDevicesCleanupOffline(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() ids := r.Form["device_id"] if len(ids) == 0 { http.Redirect(w, r, "/devices?error="+urlQueryEscape("未选择设备"), http.StatusFound) return } u.ensureDevicesLoaded() devices := u.registry.GetDevices() deviceMap := map[string]*models.Device{} for _, dev := range devices { if dev != nil { deviceMap[dev.DeviceID] = dev } } deleted := 0 for _, id := range ids { id = strings.TrimSpace(id) if id == "" { continue } dev := deviceMap[id] if dev != nil && dev.Online { continue // skip online devices } if strings.TrimSpace(u.dbPath) != "" { if store, err := storage.OpenSQLite(u.dbPath); err == nil { db := store.DB() db.Exec("DELETE FROM device_assignments WHERE device_id = ?", id) db.Exec("DELETE FROM device_config_state WHERE device_id = ?", id) db.Exec("DELETE FROM device_features WHERE device_id = ?", id) db.Exec("DELETE FROM config_versions WHERE device_id = ?", id) db.Exec("DELETE FROM devices WHERE device_id = ?", id) store.Close() u.registry.RemoveDevice(id) deleted++ } } } http.Redirect(w, r, "/devices?msg="+urlQueryEscape(fmt.Sprintf("已清理 %d 台离线设备", deleted)), http.StatusFound) } 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, "/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, "/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")) // cleanup_offline handles device IDs directly (may include DB-only offline devices) if action == "cleanup_offline" { u.actionDevicesCleanupOffline(w, r) return } 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, "/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, "/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 } } // Load device model and resource status vs management standards. if u.agent != nil { if items, err := service.FetchInstalledModelStatuses(u.agent, dev); err == nil { data.DeviceModelStatuses = items } if items, err := service.FetchInstalledResourceStatuses(u.agent, dev); err == nil { data.DeviceResourceStatuses = items } } 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 } resourcesRepo := storage.NewResourcesRepo(store.DB()) if items, err := resourcesRepo.List(); err == nil { data.StandardResources = items } store.Close() } } return data } func (u *UI) pageDeviceControl(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") http.Redirect(w, r, "/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/edge-server/reload" case "rollback": path = "/v1/edge-server/rollback" case "media_start": path = "/v1/edge-server/start" case "media_restart": path = "/v1/edge-server/restart" case "media_stop": path = "/v1/edge-server/stop" case "media_status": method = "GET" path = "/v1/edge-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) pageDeviceConfigVersions(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } data := u.deviceDetailPageData(dev) data.Title = "配置历史" if u.configVersionsRepo != nil { versions, _ := u.configVersionsRepo.List(id) data.ConfigVersions = versions } u.render(w, r, "config_versions", data) } func (u *UI) apiDeviceConfigVersion(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "versionID") if u.configVersionsRepo == nil { http.Error(w, "not available", http.StatusServiceUnavailable) return } vid, _ := strconv.Atoi(id) rec, err := u.configVersionsRepo.GetByID(vid) if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(rec) } 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/edge-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/edge-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, "/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, "/assets/templates", http.StatusFound) } func (u *UI) pageTemplate(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/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{} var installed map[string][]service.InstalledModelStatus 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, nil) } _ = store.Close() } } // Query device capabilities for compatibility matrix. // Devices without a required capability show "N/A" instead of "缺失". if u.agent != nil && data.StandardModels != nil { deviceCapabilities := map[string]map[string]bool{} for _, dev := range data.Devices { if dev == nil || !dev.Online { 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"` Available bool `json:"available"` } `json:"capabilities"` } if json.Unmarshal(body, &capsResp) != nil { continue } caps := map[string]bool{} for _, c := range capsResp.Capabilities { if c.Available { caps[c.Key] = true } } deviceCapabilities[dev.DeviceID] = caps } if len(deviceCapabilities) > 0 { if installed == nil { installed = map[string][]service.InstalledModelStatus{} } board = service.BuildModelStatusBoard(data.StandardModels, data.Devices, installed, deviceCapabilities) } } 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, "/tasks/"+url.PathEscape(task.ID), http.StatusFound) } func (u *UI) actionModelUpload(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(100 << 20); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } name := strings.TrimSpace(r.FormValue("name")) modelType := strings.TrimSpace(r.FormValue("model_type")) version := strings.TrimSpace(r.FormValue("version")) description := strings.TrimSpace(r.FormValue("description")) autoSync := r.FormValue("auto_sync") == "1" if name == "" { http.Error(w, "name is required", http.StatusBadRequest) return } if modelType == "" { modelType = "other" } file, hdr, err := r.FormFile("file") if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } defer file.Close() if !strings.HasSuffix(strings.ToLower(hdr.Filename), ".rknn") { http.Error(w, "only .rknn files are supported", http.StatusBadRequest) return } modelsDir := filepath.Join("models", "standard_models") if err := os.MkdirAll(modelsDir, 0o755); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } dstPath := filepath.Join(modelsDir, hdr.Filename) dst, err := os.Create(dstPath) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer dst.Close() hasher := sha256.New() writer := io.MultiWriter(dst, hasher) size, err := io.Copy(writer, file) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } sha256sum := hex.EncodeToString(hasher.Sum(nil)) if version == "" { version = time.Now().Format("20060102-150405") } if strings.TrimSpace(u.dbPath) != "" { if store, err := storage.OpenSQLite(u.dbPath); err == nil { modelsRepo := storage.NewModelsRepo(store.DB()) modelsRepo.Save(storage.StandardModelRecord{ Name: name, FileName: hdr.Filename, Version: version, SHA256: sha256sum, SizeBytes: size, ModelType: modelType, Description: description, }) store.Close() } } if autoSync { u.ensureDevicesLoaded() deviceIDs := make([]string, 0) for _, dev := range u.registry.GetDevices() { if dev != nil && dev.Online { deviceIDs = append(deviceIDs, dev.DeviceID) } } if len(deviceIDs) > 0 { payload := map[string]any{"model_name": name} task, _ := u.tasks.CreateTask("model_sync_all", deviceIDs, payload) http.Redirect(w, r, "/tasks/"+url.PathEscape(task.ID), http.StatusFound) return } } http.Redirect(w, r, "/models?msg="+url.QueryEscape("模型 "+name+" 上传成功"), http.StatusFound) } func (u *UI) pageDiagnostics(w http.ResponseWriter, r *http.Request) { u.ensureDevicesLoaded() data := PageData{Title: "日志审计", Devices: u.registry.GetDevices()} if u.auditRepo != nil { items, err := u.auditRepo.List() if err != nil { data.Error = err.Error() } else { data.AuditEntries = items } } if len(data.AuditEntries) == 0 && u.tasks != nil { data.Tasks = u.tasks.ListTasks() } u.render(w, r, "diagnostics", data) } func (u *UI) pageAlarms(w http.ResponseWriter, r *http.Request) { u.ensureDevicesLoaded() data := PageData{Title: "告警中心", Devices: u.registry.GetDevices()} // Parse date filter: default to past 7 days. q := r.URL.Query() today := time.Now().Format("2006-01-02") weekAgo := time.Now().AddDate(0, 0, -7).Format("2006-01-02") from := strings.TrimSpace(q.Get("from")) to := strings.TrimSpace(q.Get("to")) if from == "" && to == "" { from = weekAgo to = today } // Parse page: ?page=N (default 1), ?per_page=N (default 20) page := 1 if p, err := strconv.Atoi(q.Get("page")); err == nil && p > 0 { page = p } perPage := 20 if pp, err := strconv.Atoi(q.Get("per_page")); err == nil && pp >= 10 && pp <= 200 { perPage = pp } data.SelectedFrom = from data.SelectedTo = to data.CurrentPage = page data.PerPage = perPage // Compute quick-link ranges. now := time.Now() weekday := int(now.Weekday()) if weekday == 0 { weekday = 7 } mon := now.AddDate(0, 0, -(weekday - 1)) data.WeekStart = mon.Format("2006-01-02") data.WeekEnd = today data.MonthStart = now.Format("2006-01-02")[:8] + "01" data.MonthEnd = today data.SelectedAlarmDevice = strings.TrimSpace(q.Get("device")) data.SelectedAlarmRuleType = strings.TrimSpace(q.Get("rule_type")) if u.alarmCollector != nil { data.AlarmRecords, data.TotalAlarmCount = u.alarmCollector.GetFiltered(from, to, perPage, (page-1)*perPage, data.SelectedAlarmDevice, data.SelectedAlarmRuleType) data.AlarmFilterDevices = u.alarmCollector.GetDistinctDeviceIDs() data.AlarmFilterRuleTypes = u.alarmCollector.GetDistinctRuleTypes() } // Calculate total pages and build page list: 1, ..., cur-2, cur-1, cur, cur+1, cur+2, ..., N if data.TotalAlarmCount > 0 { data.TotalAlarmPages = (data.TotalAlarmCount + perPage - 1) / perPage tp := data.TotalAlarmPages // Always include first and last; include current ±2; fill gaps with -1 (ellipsis). seen := map[int]bool{} add := func(p int) { if p >= 1 && p <= tp && !seen[p] { seen[p] = true data.PageNumbers = append(data.PageNumbers, p) } } add(1) for p := page - 2; p <= page+2; p++ { add(p) } add(tp) // Insert -1 for gaps > 1. compact := make([]int, 0, len(data.PageNumbers)) for i, p := range data.PageNumbers { if i > 0 && p > data.PageNumbers[i-1]+1 { compact = append(compact, -1) } compact = append(compact, p) } data.PageNumbers = compact } u.render(w, r, "alarms", data) } func (u *UI) actionAcknowledgeAlarm(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if u.alarmCollector == nil || id == "" { http.Error(w, "not found", http.StatusNotFound) return } if err := u.alarmCollector.AcknowledgeAlarm(id, "admin"); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } func (u *UI) actionResolveAlarm(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if u.alarmCollector == nil || id == "" { http.Error(w, "not found", http.StatusNotFound) return } if err := u.alarmCollector.ResolveAlarm(id, "admin"); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } func (u *UI) actionUpdateAlarmSeverity(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if u.alarmCollector == nil || id == "" { http.Error(w, "not found", http.StatusNotFound) return } severity := strings.TrimSpace(r.FormValue("severity")) if severity == "" { http.Error(w, "missing severity", http.StatusBadRequest) return } if err := u.alarmCollector.UpdateSeverity(id, severity); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } func (u *UI) apiAlarmStream(w http.ResponseWriter, r *http.Request) { if u.alarmCollector == nil { http.Error(w, "not available", http.StatusServiceUnavailable) return } flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming not supported", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") ch := u.alarmCollector.Subscribe() defer u.alarmCollector.Unsubscribe(ch) for { select { case alarm := <-ch: data, err := json.Marshal(alarm) if err != nil { continue } fmt.Fprintf(w, "data: %s\n\n", data) flusher.Flush() case <-r.Context().Done(): return } } } func (u *UI) apiAlarmUnacknowledgedCount(w http.ResponseWriter, r *http.Request) { count := 0 if u.alarmCollector != nil { count = u.alarmCollector.GetUnacknowledgedCount() } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]int{"count": count}) } func (u *UI) apiAlarmDailyCount(w http.ResponseWriter, r *http.Request) { days := 7 if d, err := strconv.Atoi(r.URL.Query().Get("days")); err == nil && d > 0 && d <= 90 { days = d } result := u.alarmCollector.GetDailyCounts(days) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(result) } func (u *UI) apiAlarmExport(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() from := strings.TrimSpace(q.Get("from")) to := strings.TrimSpace(q.Get("to")) deviceID := strings.TrimSpace(q.Get("device")) ruleType := strings.TrimSpace(q.Get("rule_type")) if from == "" { from = time.Now().AddDate(0, 0, -7).Format("2006-01-02") } if to == "" { to = time.Now().Format("2006-01-02") } records := u.alarmCollector.ExportFiltered(from, to, deviceID, ruleType) w.Header().Set("Content-Type", "text/csv; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="alarms_%s_%s.csv"`, from, to)) w.Header().Set("Cache-Control", "no-cache") w.Write([]byte("\xef\xbb\xbf")) w.Write([]byte("时间,设备ID,通道,规则,目标,置信度,等级,状态\n")) for _, a := range records { fmt.Fprintf(w, "%s,%s,%s,%s,%s,%.2f,%s,%s\n", escapeCSV(a.Timestamp), escapeCSV(a.DeviceID), escapeCSV(a.Channel), escapeCSV(a.RuleName), escapeCSV(a.ObjectLabel), a.Confidence, escapeCSV(a.Severity), escapeCSV(a.Status)) } } func escapeCSV(s string) string { if strings.ContainsAny(s, ",\"\n") { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` } return s } func (u *UI) pageResources(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.ResourceStatusBoard{} if strings.TrimSpace(u.dbPath) != "" { if store, err := storage.OpenSQLite(u.dbPath); err == nil { resourcesRepo := storage.NewResourcesRepo(store.DB()) if items, err := resourcesRepo.List(); err == nil { data.StandardResources = items board = service.FetchAndBuildResourceBoard(u.agent, data.Devices, items) } _ = store.Close() } } data.ResourceStatusBoard = &board u.render(w, r, "resources", data) } func (u *UI) actionResourceSync(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 = "resource_sync_all" } if action != "resource_sync_one" && action != "resource_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 resourceName := strings.TrimSpace(r.FormValue("resource_name")); resourceName != "" { payload["resource_name"] = resourceName } task, err := u.tasks.CreateTask(action, deviceIDs, payload) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/tasks/"+url.PathEscape(task.ID), http.StatusFound) } func (u *UI) pageRecognition(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/assets", http.StatusFound) } func (u *UI) pageLogs(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/diagnostics", http.StatusFound) } func (u *UI) pageAPIConsole(w http.ResponseWriter, r *http.Request) { u.render(w, r, "api", PageData{Title: "高级调试"}) } func (u *UI) pageAssets(w http.ResponseWriter, r *http.Request) { data := u.assetPageData("overview") u.render(w, r, "assets", data) } func (u *UI) pageAssetTemplates(w http.ResponseWriter, r *http.Request) { data := u.assetPageData("templates") data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) if data.Error == "" { data.Error = strings.TrimSpace(r.URL.Query().Get("error")) } editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1" cloneSource := strings.TrimSpace(r.URL.Query().Get("clone_source")) cloneName := strings.TrimSpace(r.URL.Query().Get("clone_name")) cloneDesc := strings.TrimSpace(r.URL.Query().Get("clone_desc")) if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" { if item, err := u.preview.GetTemplateAsset(name); err == nil { data.AssetTemplate = item } else if data.Error == "" { data.Error = err.Error() } } else if len(data.AssetTemplates) > 0 { if item, err := u.preview.GetTemplateAsset(data.AssetTemplates[0].Name); err == nil { data.AssetTemplate = item } else if data.Error == "" { data.Error = err.Error() } } if cloneSource != "" && cloneName != "" { data.TemplateCloneSource = cloneSource data.TemplateCloneName = cloneName data.TemplateCloneDesc = cloneDesc if source, err := u.preview.GetTemplateAsset(cloneSource); err == nil { body, _ := json.Marshal(source.Raw) doc := map[string]any{} if json.Unmarshal(body, &doc) == nil { desc := cloneDesc if desc == "" { desc = source.Description } doc["name"] = cloneName doc["description"] = desc data.AssetTemplate = &service.ConfigTemplateAsset{ Name: cloneName, Description: desc, Raw: doc, ReadOnly: false, } } } } data.AssetTemplateEditing = (editMode || cloneSource != "") && data.AssetTemplate != nil && !data.AssetTemplate.ReadOnly u.render(w, r, "asset_templates", data) } func (u *UI) actionAssetTemplateCreate(w http.ResponseWriter, r *http.Request) { if u.preview == nil { http.Error(w, "config preview service is not configured", http.StatusInternalServerError) return } name, err := normalizeConfigName(r.FormValue("name")) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } name = strings.TrimSuffix(name, ".json") description := strings.TrimSpace(r.FormValue("description")) cloneSource := strings.TrimSpace(r.FormValue("clone_source")) if cloneSource == "" { http.Error(w, "create template requires clone_source", http.StatusBadRequest) return } var doc map[string]any item, err := u.preview.GetTemplateAsset(cloneSource) if err != nil || item == nil { http.Error(w, "clone source template not found", http.StatusNotFound) return } body, err := json.Marshal(item.Raw) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if err := json.Unmarshal(body, &doc); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } doc["name"] = name doc["description"] = description body, err = json.MarshalIndent(doc, "", " ") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if err := u.preview.SaveTemplateAsset(name, description, string(body)+"\n"); err != nil { status := http.StatusInternalServerError if strings.Contains(err.Error(), "read-only") || strings.Contains(err.Error(), "copy it before editing") { status = http.StatusBadRequest } http.Error(w, err.Error(), status) return } http.Redirect(w, r, "/assets/templates?name="+url.PathEscape(name)+"&edit=1&msg="+urlQueryEscape("模板已创建"), http.StatusFound) } func (u *UI) actionAssetTemplateClone(w http.ResponseWriter, r *http.Request) { if u.preview == nil { http.Error(w, "config preview service is not configured", http.StatusInternalServerError) return } sourceName := chi.URLParam(r, "name") item, err := u.preview.GetTemplateAsset(sourceName) if err != nil || item == nil { http.NotFound(w, r) return } if !item.ReadOnly { http.Redirect(w, r, "/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape("只允许从标准模板复制创建"), http.StatusFound) return } targetName, err := u.nextTemplateCloneName(item.Name) if err != nil { http.Redirect(w, r, "/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) return } cloneSource := url.QueryEscape(sourceName) cloneName := url.QueryEscape(targetName) cloneDesc := url.QueryEscape(item.Description) http.Redirect(w, r, "/assets/templates?clone_source="+cloneSource+"&clone_name="+cloneName+"&clone_desc="+cloneDesc, http.StatusFound) } func (u *UI) nextTemplateCloneName(sourceName string) (string, error) { base := strings.TrimSpace(sourceName) if base == "" { return "", fmt.Errorf("template name is required") } if strings.HasPrefix(base, "std_") { base = strings.TrimPrefix(base, "std_") } candidate := base + "_copy" if item, err := u.preview.GetTemplateAsset(candidate); err == nil && item != nil { for i := 2; i < 1000; i++ { name := fmt.Sprintf("%s_copy_%d", base, i) item, err := u.preview.GetTemplateAsset(name) if err != nil || item == nil { return name, nil } } return "", fmt.Errorf("无法生成可用的模板副本名称") } return candidate, nil } func (u *UI) pageAssetTemplate(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") data := u.assetPageData("templates") data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) if data.Error == "" { data.Error = strings.TrimSpace(r.URL.Query().Get("error")) } item, err := u.preview.GetTemplateAsset(name) if err != nil { http.NotFound(w, r) return } data.AssetTemplate = item u.render(w, r, "asset_templates", data) } func (u *UI) pageAssetTemplateGraph(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") cloneSource := strings.TrimSpace(r.URL.Query().Get("clone_source")) data := u.assetPageData("templates") data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) if data.Error == "" { data.Error = strings.TrimSpace(r.URL.Query().Get("error")) } if u.preview == nil { data.Error = "资产管理服务未初始化" u.render(w, r, "asset_templates", data) return } if cloneSource != "" { source, err := u.preview.GetTemplateAsset(cloneSource) if err != nil { http.NotFound(w, r) return } body, _ := json.Marshal(source.Raw) doc := map[string]any{} if json.Unmarshal(body, &doc) != nil { http.NotFound(w, r) return } doc["name"] = name doc["description"] = source.Description data.Title = "模板可视化编辑(从 " + cloneSource + " 克隆)" data.AssetTemplate = &service.ConfigTemplateAsset{ Name: name, Description: source.Description, Raw: doc, ReadOnly: false, } data.TemplateCloneSource = cloneSource } else { item, err := u.preview.GetTemplateAsset(name) if err != nil { http.NotFound(w, r) return } data.Title = "模板可视化编辑" if item.ReadOnly { data.Title = "标准模板可视化预览" } data.AssetTemplate = item } raw, err := compactJSON(data.AssetTemplate.Raw) if err != nil { data.Error = err.Error() u.render(w, r, "asset_templates", data) return } data.RawJSON = raw u.render(w, r, "asset_template_graph", data) } func (u *UI) actionAssetTemplateGraphSave(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") cloneSource := strings.TrimSpace(r.FormValue("clone_source")) if u.preview == nil { http.Error(w, "config preview service is not configured", http.StatusInternalServerError) return } raw := strings.TrimSpace(r.FormValue("json")) if raw == "" { http.Error(w, "template json is required", http.StatusBadRequest) return } var doc map[string]any if err := json.Unmarshal([]byte(raw), &doc); err != nil { http.Error(w, "invalid template json: "+err.Error(), http.StatusBadRequest) return } if err := validateTemplateGraphDocument(doc); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } targetName := name if rawName := strings.TrimSpace(r.FormValue("name")); rawName != "" { normalized, err := normalizeConfigName(rawName) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } targetName = strings.TrimSuffix(normalized, ".json") } description := strings.TrimSpace(r.FormValue("description")) if description == "" { description, _ = doc["description"].(string) } doc["name"] = targetName doc["description"] = description body, err := json.MarshalIndent(doc, "", " ") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if cloneSource != "" || targetName != name { if cloneSource != "" { err = u.preview.SaveTemplateAsset(targetName, description, string(body)+"\n") } else { err = u.preview.RenameTemplateAsset(name, targetName, description, string(body)+"\n") } } else { err = u.preview.SaveTemplateAsset(name, description, string(body)+"\n") } if err != nil { status := http.StatusInternalServerError if strings.Contains(err.Error(), "read-only") || strings.Contains(err.Error(), "copy it before editing") { status = http.StatusBadRequest } http.Error(w, err.Error(), status) return } message := "模板已保存" if targetName != name { message = "模板已保存,名称已更新" } http.Redirect(w, r, "/assets/templates/"+url.PathEscape(targetName)+"/graph?msg="+urlQueryEscape(message), http.StatusFound) } func (u *UI) actionAssetTemplateRename(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") if u.preview == nil { http.Error(w, "config preview service is not configured", http.StatusInternalServerError) return } item, err := u.preview.GetTemplateAsset(name) if err != nil || item == nil { http.NotFound(w, r) return } targetName := name if rawName := strings.TrimSpace(r.FormValue("name")); rawName != "" { normalized, err := normalizeConfigName(rawName) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } targetName = strings.TrimSuffix(normalized, ".json") } description := strings.TrimSpace(r.FormValue("description")) if description == "" { description = item.Description } body, err := json.Marshal(item.Raw) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } doc := map[string]any{} if err := json.Unmarshal(body, &doc); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } doc["name"] = targetName doc["description"] = description body, err = json.MarshalIndent(doc, "", " ") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if targetName != name { err = u.preview.RenameTemplateAsset(name, targetName, description, string(body)+"\n") } else { err = u.preview.SaveTemplateAsset(name, description, string(body)+"\n") } if err != nil { http.Redirect(w, r, "/assets/templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) return } http.Redirect(w, r, "/assets/templates?name="+url.PathEscape(targetName)+"&msg="+urlQueryEscape("模板已保存,名称已更新"), http.StatusFound) } func (u *UI) actionAssetTemplateDelete(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") if u.preview == nil { http.Error(w, "config preview service is not configured", http.StatusInternalServerError) return } if err := u.preview.DeleteTemplateAsset(name); err != nil { http.Redirect(w, r, "/assets/templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) return } http.Redirect(w, r, "/assets/templates?msg="+urlQueryEscape("用户模板已删除"), http.StatusFound) } func (u *UI) apiGraphNodeTypes(w http.ResponseWriter, r *http.Request) { if body, ok := u.loadAgentGraphNodeTypes(); ok { w.Header().Set("Content-Type", "application/json; charset=utf-8") _, _ = w.Write(body) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{"items": graphNodeTypesCatalog()}) } func (u *UI) loadAgentGraphNodeTypes() ([]byte, bool) { if u.agent == nil || u.registry == nil { return nil, false } u.ensureDevicesLoaded() for _, dev := range u.registry.GetDevices() { if dev == nil || strings.TrimSpace(dev.IP) == "" || dev.AgentPort <= 0 { continue } body, code, err := u.agent.Do(http.MethodGet, dev.IP, dev.AgentPort, "/v1/graph-node-types", nil) if err != nil || code < 200 || code >= 300 { continue } var payload struct { Items []graphNodeTypeInfo `json:"items"` } if err := json.Unmarshal(body, &payload); err != nil || len(payload.Items) == 0 { continue } return body, true } return nil, false } func (u *UI) pageAssetTemplateExport(w http.ResponseWriter, r *http.Request) { u.exportAssetJSON(w, r, "templates", chi.URLParam(r, "name")) } func (u *UI) pagePlans(w http.ResponseWriter, r *http.Request) { data := u.assetPageData("") data.Title = "场景" data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) if data.Error == "" { data.Error = strings.TrimSpace(r.URL.Query().Get("error")) } newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1" editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1" selected := strings.TrimSpace(r.URL.Query().Get("name")) if selected == "" && !newMode && len(data.AssetProfiles) > 0 { selected = data.AssetProfiles[0].Name } if newMode { editor := &service.ConfigProfileEditor{Queue: service.DefaultConfigProfileQueue()} if len(data.AssetTemplates) > 0 { editor.PrimaryTemplateName = data.AssetTemplates[0].Name } data.AssetProfileEditor = editor data.AssetProfileEditing = true data.AssetProfileFormAction = "/scene-templates/create" data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), 0) u.render(w, r, "scene_templates", data) return } if selected != "" { editor, err := u.preview.GetProfileEditor(selected) if err == nil { data.AssetProfileEditor = editor data.SelectedProfile = editor.Name data.AssetProfileEditing = editMode data.AssetProfileFormAction = "/scene-templates/" + url.PathEscape(editor.Name) if len(editor.Instances) > 0 && editor.Instances[0].Template != "" { data.SelectedTemplate = editor.Instances[0].Template } data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.URL.Query())) } else if data.Error == "" { data.Error = err.Error() } } else if len(data.AssetProfiles) == 0 { editor := &service.ConfigProfileEditor{Queue: service.DefaultConfigProfileQueue()} if len(data.AssetTemplates) > 0 { editor.PrimaryTemplateName = data.AssetTemplates[0].Name } data.AssetProfileEditor = editor data.AssetProfileEditing = true data.AssetProfileFormAction = "/scene-templates/create" data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), 0) } u.render(w, r, "scene_templates", data) } func (u *UI) pageAssetProfiles(w http.ResponseWriter, r *http.Request) { u.pagePlans(w, r) } func (u *UI) pagePlan(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") data, err := u.profileEditorPageData(name) if err != nil { http.NotFound(w, r) return } data.Title = "场景" data.AssetProfileEditing = strings.TrimSpace(r.URL.Query().Get("edit")) == "1" data.AssetProfileFormAction = "/scene-templates/" + url.PathEscape(name) data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), activeInstanceIndexFromValues(r.URL.Query())) u.render(w, r, "scene_templates", data) } func (u *UI) pageAssetProfile(w http.ResponseWriter, r *http.Request) { u.pagePlan(w, r) } func (u *UI) pagePlanExport(w http.ResponseWriter, r *http.Request) { u.exportAssetJSON(w, r, "profiles", chi.URLParam(r, "name")) } func (u *UI) pageAssetProfileExport(w http.ResponseWriter, r *http.Request) { u.pagePlanExport(w, r) } func (u *UI) actionPlanSave(w http.ResponseWriter, r *http.Request) { u.actionPlanSaveWithName(w, r, chi.URLParam(r, "name")) } func (u *UI) actionPlanCreate(w http.ResponseWriter, r *http.Request) { u.actionPlanSaveWithName(w, r, "") } func (u *UI) actionPlanSaveWithName(w http.ResponseWriter, r *http.Request, name string) { var ( editor service.ConfigProfileEditor data PageData err error ) if strings.TrimSpace(name) == "" { data = u.assetPageData("") data.Title = "场景模板" _ = r.ParseForm() editor = service.ConfigProfileEditor{ Name: strings.TrimSpace(r.FormValue("profile_name")), PrimaryTemplateName: strings.TrimSpace(r.FormValue("primary_template_name")), BusinessName: strings.TrimSpace(r.FormValue("business_name")), Description: strings.TrimSpace(r.FormValue("description")), OverlayName: strings.TrimSpace(r.FormValue("overlay_name")), SiteName: strings.TrimSpace(r.FormValue("site_name")), Queue: service.DefaultConfigProfileQueue(), Instances: parseProfileInstanceForm(r.Form), } data.AssetProfileEditor = &editor data.AssetProfileEditing = true data.AssetProfileFormAction = "/scene-templates/create" data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.Form)) } else { editor, data, err = u.profileEditorActionData(r, name) if err != nil { http.NotFound(w, r) return } data.AssetProfileEditing = true data.AssetProfileFormAction = "/scene-templates/" + url.PathEscape(name) } if err := u.preview.SaveProfileEditor(editor); err != nil { data.Error = err.Error() data.Title = "场景模板" data.AssetProfileEditing = true u.render(w, r, "scene_templates", data) return } msg := "场景已保存" if strings.TrimSpace(name) != "" && editor.Name != name { msg = "场景已保存,名称已更新" } http.Redirect(w, r, "/scene-templates?msg="+urlQueryEscape(msg)+"&name="+url.PathEscape(editor.Name), http.StatusFound) } func (u *UI) actionPlanDelete(w http.ResponseWriter, r *http.Request) { name := strings.TrimSpace(chi.URLParam(r, "name")) if name == "" { http.NotFound(w, r) return } if u.stateRepo != nil { items, err := u.stateRepo.ListByProfileName(name) if err != nil { http.Redirect(w, r, "/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) return } if len(items) > 0 { deviceIDs := make([]string, 0, len(items)) for _, item := range items { deviceIDs = append(deviceIDs, item.DeviceID) } msg := fmt.Sprintf("场景 %q 正被以下设备使用,无法删除:%s", name, strings.Join(deviceIDs, "、")) http.Redirect(w, r, "/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(msg), http.StatusFound) return } } if err := u.preview.DeleteProfileAsset(name); err != nil { http.Redirect(w, r, "/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) return } http.Redirect(w, r, "/scene-templates?msg="+urlQueryEscape("场景已删除"), http.StatusFound) } func (u *UI) actionAssetProfileSave(w http.ResponseWriter, r *http.Request) { u.actionPlanSave(w, r) } func (u *UI) redirectAssetProfilesToPlans(w http.ResponseWriter, r *http.Request) { target := "/scene-templates" if r.URL.RawQuery != "" { target += "?" + r.URL.RawQuery } http.Redirect(w, r, target, http.StatusFound) } func (u *UI) redirectAssetProfileToPlan(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/scene-templates/"+url.PathEscape(chi.URLParam(r, "name")), http.StatusFound) } func (u *UI) redirectPlansToSceneTemplates(w http.ResponseWriter, r *http.Request) { target := "/scene-templates" if r.URL.RawQuery != "" { target += "?" + r.URL.RawQuery } http.Redirect(w, r, target, http.StatusFound) } func (u *UI) redirectPlanToSceneTemplate(w http.ResponseWriter, r *http.Request) { target := "/scene-templates/" + url.PathEscape(chi.URLParam(r, "name")) if r.URL.RawQuery != "" { target += "?" + r.URL.RawQuery } http.Redirect(w, r, target, http.StatusFound) } func (u *UI) pageRecognitionUnits(w http.ResponseWriter, r *http.Request) { data := u.assetPageData("") data.Title = "检测通道" data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) if data.Error == "" { data.Error = strings.TrimSpace(r.URL.Query().Get("error")) } newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1" editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1" selected := strings.TrimSpace(r.URL.Query().Get("ref")) if selected == "" && !newMode && len(data.RecognitionUnits) > 0 { selected = data.RecognitionUnits[0].Ref } data.SelectedRecognitionUnit = selected if newMode { defaultTemplate := "" if len(data.AssetProfiles) > 0 { defaultTemplate = data.AssetProfiles[0].Name } data.RecognitionUnit = &service.RecognitionUnitAsset{ SceneTemplateName: defaultTemplate, OutputChannel: "cam1", RTSPPort: "8555", } data.RecognitionUnitEditing = true } else if selected != "" { if item, err := u.preview.GetRecognitionUnit(selected); err == nil { data.RecognitionUnit = item data.RecognitionUnitEditing = editMode } else if data.Error == "" { data.Error = err.Error() } } u.render(w, r, "recognition_units", data) } func (u *UI) actionRecognitionUnitSave(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() asset := service.RecognitionUnitAsset{ SceneTemplateName: strings.TrimSpace(r.FormValue("scene_template_name")), Name: strings.TrimSpace(r.FormValue("name")), DisplayName: strings.TrimSpace(r.FormValue("display_name")), SiteName: strings.TrimSpace(r.FormValue("site_name")), VideoSourceRef: strings.TrimSpace(r.FormValue("video_source_ref")), OutputChannel: strings.TrimSpace(r.FormValue("output_channel")), RTSPPort: strings.TrimSpace(r.FormValue("rtsp_port")), } originalRef := strings.TrimSpace(r.FormValue("original_ref")) if err := u.preview.SaveRecognitionUnit(asset, originalRef); err != nil { data := u.assetPageData("") data.Title = "检测通道" data.Error = err.Error() data.RecognitionUnit = &asset data.RecognitionUnitEditing = true data.SelectedRecognitionUnit = originalRef u.render(w, r, "recognition_units", data) return } ref := serviceRecognitionUnitRef(asset.SceneTemplateName, asset.Name) http.Redirect(w, r, "/recognition-units?msg="+urlQueryEscape("视频通道已保存")+"&ref="+url.QueryEscape(ref), http.StatusFound) } func serviceRecognitionUnitRef(profileName string, unitName string) string { return strings.TrimSpace(profileName) + "::" + strings.TrimSpace(unitName) } func (u *UI) actionRecognitionUnitDelete(w http.ResponseWriter, r *http.Request) { ref := strings.TrimSpace(r.FormValue("ref")) if err := u.preview.DeleteRecognitionUnit(ref); err != nil { http.Redirect(w, r, "/recognition-units?error="+urlQueryEscape(err.Error())+"&ref="+url.QueryEscape(ref), http.StatusFound) return } http.Redirect(w, r, "/recognition-units?msg="+urlQueryEscape("视频通道已删除"), http.StatusFound) } func (u *UI) pageDeviceAssignments(w http.ResponseWriter, r *http.Request) { data := u.assetPageData("") data.Title = "通道部署" data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) if data.Error == "" { data.Error = strings.TrimSpace(r.URL.Query().Get("error")) } u.ensureDevicesLoaded() data.Devices = deviceAssignmentBoardDevices(u.registry.GetDevices()) maxUnits := service.DefaultMaxUnitsPerDevice() if value, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("max_units_per_device"))); err == nil && value > 0 { maxUnits = value } data.MaxUnitsPerDevice = maxUnits if u.preview != nil { if board, err := u.preview.BuildDeviceAssignmentBoard(data.Devices, maxUnits); err == nil { // Enrich board cards with config sync status. if u.agent != nil && u.configVersionsRepo != nil { for i, card := range board.Cards { versions, _ := u.configVersionsRepo.List(card.DeviceID) if len(versions) == 0 { continue } expectedSHA := versions[0].ConfigID for _, dev := range data.Devices { if dev == nil || dev.DeviceID != card.DeviceID || !dev.Online { continue } body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/status", nil) if err != nil || code != 200 { continue } var statusResp struct { SHA256 string `json:"sha256"` } if json.Unmarshal(body, &statusResp) == nil { board.Cards[i].ConfigSynced = strings.EqualFold(statusResp.SHA256, expectedSHA) } break } } } data.DeviceAssignmentBoard = board if payload, marshalErr := json.Marshal(board); marshalErr == nil { data.DeviceAssignmentBoardJSON = template.JS(payload) } } else if data.Error == "" { data.Error = err.Error() } } u.render(w, r, "device_assignments", data) } func (u *UI) actionDeviceAssignmentSave(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() assignments, err := parseDeviceAssignmentBoardState(strings.TrimSpace(r.FormValue("board_state_json"))) if err != nil { data := u.assetPageData("") data.Title = "通道部署" u.ensureDevicesLoaded() data.Devices = deviceAssignmentBoardDevices(u.registry.GetDevices()) data.Error = err.Error() maxUnits := service.DefaultMaxUnitsPerDevice() if value, convErr := strconv.Atoi(strings.TrimSpace(r.FormValue("max_units_per_device"))); convErr == nil && value > 0 { maxUnits = value } data.MaxUnitsPerDevice = maxUnits if u.preview != nil { if board, buildErr := u.preview.BuildDeviceAssignmentBoard(data.Devices, maxUnits); buildErr == nil { data.DeviceAssignmentBoard = board if payload, marshalErr := json.Marshal(board); marshalErr == nil { data.DeviceAssignmentBoardJSON = template.JS(payload) } } } u.render(w, r, "device_assignments", data) return } if err := u.preview.SaveDeviceAssignmentBoard(filterPreviewDeviceAssignments(assignments)); err != nil { http.Redirect(w, r, "/device-assignments?error="+urlQueryEscape(err.Error()), http.StatusFound) return } http.Redirect(w, r, "/device-assignments?msg="+urlQueryEscape("通道部署已保存"), http.StatusFound) } func deviceAssignmentBoardDevices(devices []*models.Device) []*models.Device { out := make([]*models.Device, 0, len(devices)) seen := make(map[string]struct{}, len(devices)) for _, dev := range devices { if dev == nil || strings.TrimSpace(dev.DeviceID) == "" { continue } deviceID := strings.TrimSpace(dev.DeviceID) if _, ok := seen[deviceID]; ok { continue } seen[deviceID] = struct{}{} out = append(out, dev) } for len(out) < deviceAssignmentPreviewDeviceCount { index := len(out) + 1 deviceID := fmt.Sprintf("%s%02d", deviceAssignmentPreviewDevicePrefix, index) out = append(out, &models.Device{ DeviceID: deviceID, DeviceName: fmt.Sprintf("测试设备 %02d", index), Online: true, }) } return out } func filterPreviewDeviceAssignments(assignments map[string][]string) map[string][]string { if len(assignments) == 0 { return assignments } filtered := make(map[string][]string, len(assignments)) for deviceID, refs := range assignments { if strings.HasPrefix(strings.TrimSpace(deviceID), deviceAssignmentPreviewDevicePrefix) { continue } filtered[deviceID] = refs } return filtered } func (u *UI) actionDeviceAssignmentDelete(w http.ResponseWriter, r *http.Request) { deviceID := strings.TrimSpace(chi.URLParam(r, "id")) if err := u.preview.DeleteDeviceAssignment(deviceID); err != nil { http.Redirect(w, r, "/device-assignments?error="+urlQueryEscape(err.Error())+"&device_id="+url.PathEscape(deviceID), http.StatusFound) return } http.Redirect(w, r, "/device-assignments?msg="+urlQueryEscape("通道部署已删除"), http.StatusFound) } func (u *UI) pageAssetVideoSources(w http.ResponseWriter, r *http.Request) { data := u.assetPageData("video-sources") data.Title = "资产管理" data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) if data.Error == "" { data.Error = strings.TrimSpace(r.URL.Query().Get("error")) } newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1" editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1" selected := strings.TrimSpace(r.URL.Query().Get("name")) if selected != "" { if item, err := u.preview.GetVideoSource(selected); err == nil { data.AssetVideoSource = item } else if data.Error == "" { data.Error = err.Error() } } else if !newMode && len(data.AssetVideoSources) > 0 { if item, err := u.preview.GetVideoSource(data.AssetVideoSources[0].Name); err == nil { data.AssetVideoSource = item } } if data.AssetVideoSource == nil { if newMode { data.AssetVideoSource = &service.ConfigVideoSourceAsset{ SourceType: "rtsp", SourceTypeLabel: "RTSP", } data.AssetVideoSourceEditing = true } // otherwise just leave nil → nothing selected, no editor shown } else { data.AssetVideoSourceEditing = newMode || editMode } u.render(w, r, "assets", data) } func (u *UI) actionAssetVideoSourceSave(w http.ResponseWriter, r *http.Request) { if u.preview == nil { http.Error(w, "config preview service is not configured", http.StatusInternalServerError) return } _ = r.ParseForm() asset := service.ConfigVideoSourceAsset{ Name: strings.TrimSpace(r.FormValue("name")), SourceType: strings.TrimSpace(r.FormValue("source_type")), Area: strings.TrimSpace(r.FormValue("area")), Description: strings.TrimSpace(r.FormValue("description")), Config: service.VideoSourceConfig{ URL: strings.TrimSpace(r.FormValue("url")), Resolution: strings.TrimSpace(r.FormValue("resolution")), FrameSize: strings.TrimSpace(r.FormValue("frame_size")), FPS: strings.TrimSpace(r.FormValue("fps")), VideoFormat: strings.TrimSpace(r.FormValue("video_format")), FocalLength: strings.TrimSpace(r.FormValue("focal_length")), MountHeight: strings.TrimSpace(r.FormValue("mount_height")), MountAngle: strings.TrimSpace(r.FormValue("mount_angle")), }, } if err := u.preview.SaveVideoSourceAsset(asset); err != nil { data := u.assetPageData("video-sources") data.Title = "资产管理" data.Error = err.Error() data.AssetVideoSource = &asset data.AssetVideoSource.SourceTypeLabel = serviceVideoSourceTypeLabel(asset.SourceType) data.AssetVideoSourceEditing = true u.render(w, r, "assets", data) return } http.Redirect(w, r, "/assets/video-sources?msg="+urlQueryEscape("视频源已保存")+"&name="+url.PathEscape(asset.Name), http.StatusFound) } func (u *UI) actionAssetVideoSourceDelete(w http.ResponseWriter, r *http.Request) { if u.preview == nil { http.Error(w, "config preview service is not configured", http.StatusInternalServerError) return } name := chi.URLParam(r, "name") if err := u.preview.DeleteVideoSource(name); err != nil { http.Redirect(w, r, "/assets/video-sources?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound) return } http.Redirect(w, r, "/assets/video-sources?msg="+urlQueryEscape("视频源已删除"), http.StatusFound) } func (u *UI) actionVideoSourceCSVTemplate(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/csv; charset=utf-8") w.Header().Set("Content-Disposition", "attachment; filename=video_sources_template.csv") w.Write([]byte("\xef\xbb\xbf")) // BOM for Excel w.Write([]byte("name,source_type,area,url,resolution,fps,description\n")) w.Write([]byte("车间东门,rtsp,车间A,rtsp://192.168.1.100:554/stream1,1920x1080,25,\n")) w.Write([]byte("车间西门,rtsp,车间A,rtsp://192.168.1.101:554/stream1,1920x1080,25,\n")) } func (u *UI) actionVideoSourceCSVImport(w http.ResponseWriter, r *http.Request) { if u.preview == nil { http.Error(w, "not available", http.StatusServiceUnavailable) return } if err := r.ParseMultipartForm(10 << 20); err != nil { http.Redirect(w, r, "/assets/video-sources?error="+urlQueryEscape("文件解析失败"), http.StatusFound) return } file, _, err := r.FormFile("file") if err != nil { http.Redirect(w, r, "/assets/video-sources?error="+urlQueryEscape("请选择文件"), http.StatusFound) return } defer file.Close() // Read raw bytes for encoding detection rawBytes, err := io.ReadAll(file) if err != nil { http.Redirect(w, r, "/assets/video-sources?error="+urlQueryEscape("读取文件失败: "+err.Error()), http.StatusFound) return } // Strip UTF-8 BOM if present rawBytes = bytes.TrimPrefix(rawBytes, []byte("\xef\xbb\xbf")) // Try UTF-8 first; if invalid, try GBK if !utf8.Valid(rawBytes) { decoder := simplifiedchinese.GBK.NewDecoder() decoded, err := decoder.Bytes(rawBytes) if err != nil { http.Redirect(w, r, "/assets/video-sources?error="+urlQueryEscape("文件编码不支持,请保存为 UTF-8 格式"), http.StatusFound) return } rawBytes = decoded } reader := csv.NewReader(bytes.NewReader(rawBytes)) reader.LazyQuotes = true records, err := reader.ReadAll() if err != nil { http.Redirect(w, r, "/assets/video-sources?error="+urlQueryEscape("CSV 解析失败: "+err.Error()), http.StatusFound) return } if len(records) < 2 { http.Redirect(w, r, "/assets/video-sources?error="+urlQueryEscape("CSV 文件为空或无数据行"), http.StatusFound) return } imported := 0 skipped := 0 for i, row := range records { if i == 0 { continue // skip header } if len(row) < 4 { skipped++ continue } name := strings.TrimSpace(row[0]) sourceType := strings.TrimSpace(row[1]) area := "" url := "" resolution := "" fps := "" if len(row) > 2 { area = strings.TrimSpace(row[2]) } if len(row) > 3 { url = strings.TrimSpace(row[3]) } if len(row) > 4 { resolution = strings.TrimSpace(row[4]) } if len(row) > 5 { fps = strings.TrimSpace(row[5]) } if name == "" || url == "" { skipped++ continue } if sourceType == "" { sourceType = "rtsp" } if resolution == "" { resolution = "1920x1080" } if fps == "" { fps = "25" } asset := service.ConfigVideoSourceAsset{ Name: name, SourceType: sourceType, Area: area, Config: service.VideoSourceConfig{ URL: url, Resolution: resolution, FPS: fps, }, } if err := u.preview.SaveVideoSourceAsset(asset); err != nil { skipped++ continue } imported++ } msg := fmt.Sprintf("导入完成:%d 个视频源,%d 条跳过", imported, skipped) http.Redirect(w, r, "/assets/video-sources?msg="+urlQueryEscape(msg), http.StatusFound) } func (u *UI) pageAssetIntegrations(w http.ResponseWriter, r *http.Request) { data := u.assetPageData("integrations") data.Title = "资产管理" data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) if data.Error == "" { data.Error = strings.TrimSpace(r.URL.Query().Get("error")) } newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1" editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1" selected := strings.TrimSpace(r.URL.Query().Get("name")) if selected != "" { if item, err := u.preview.GetIntegrationService(selected); err == nil { data.AssetIntegration = item } else if data.Error == "" { data.Error = err.Error() } } else if !newMode && len(data.AssetIntegrations) > 0 { if item, err := u.preview.GetIntegrationService(data.AssetIntegrations[0].Name); err == nil { data.AssetIntegration = item } } if data.AssetIntegration == nil { if newMode { data.AssetIntegration = &service.ConfigIntegrationServiceAsset{ Type: "object_storage", TypeLabel: "对象存储", Enabled: true, ObjectStorage: &service.ObjectStorageConfig{}, } data.AssetIntegrationEditing = true } } else { data.AssetIntegrationEditing = newMode || editMode } if data.AssetIntegration != nil && data.AssetIntegration.Type == "custom" { if configRaw, ok := data.AssetIntegration.Raw["config"]; ok { if s, err := compactJSON(configRaw); err == nil { data.IntegrationConfigDraft = s } } if data.IntegrationConfigDraft == "" { data.IntegrationConfigDraft = "{}" } } u.render(w, r, "assets", data) } func (u *UI) actionAssetIntegrationSave(w http.ResponseWriter, r *http.Request) { if u.preview == nil { http.Error(w, "config preview service is not configured", http.StatusInternalServerError) return } _ = r.ParseForm() enabled := strings.TrimSpace(r.FormValue("enabled")) == "1" || strings.EqualFold(strings.TrimSpace(r.FormValue("enabled")), "true") || strings.EqualFold(strings.TrimSpace(r.FormValue("enabled")), "on") serviceType := strings.TrimSpace(r.FormValue("type")) asset := service.ConfigIntegrationServiceAsset{ Name: strings.TrimSpace(r.FormValue("name")), Type: serviceType, Description: strings.TrimSpace(r.FormValue("description")), Enabled: enabled, ObjectStorage: &service.ObjectStorageConfig{}, AlarmService: &service.AlarmServiceConfig{}, } if serviceType == "custom" { configRaw := strings.TrimSpace(r.FormValue("config_json")) if configRaw == "" { configRaw = "{}" } var raw map[string]any if err := json.Unmarshal([]byte(configRaw), &raw); err != nil { http.Redirect(w, r, "/assets/integrations?error="+urlQueryEscape("自定义配置 JSON 格式不正确:"+err.Error())+"&name="+url.PathEscape(asset.Name), http.StatusFound) return } asset.Raw = raw } else { asset.ObjectStorage.Endpoint = strings.TrimSpace(r.FormValue("endpoint")) asset.ObjectStorage.Bucket = strings.TrimSpace(r.FormValue("bucket")) asset.ObjectStorage.AccessKey = strings.TrimSpace(r.FormValue("access_key")) asset.ObjectStorage.SecretKey = strings.TrimSpace(r.FormValue("secret_key")) asset.AlarmService.GetTokenURL = strings.TrimSpace(r.FormValue("get_token_url")) asset.AlarmService.PutMessageURL = strings.TrimSpace(r.FormValue("put_message_url")) asset.AlarmService.TenantCode = strings.TrimSpace(r.FormValue("tenant_code")) } if err := u.preview.SaveIntegrationServiceAsset(asset); err != nil { http.Redirect(w, r, "/assets/integrations?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(asset.Name), http.StatusFound) return } http.Redirect(w, r, "/assets/integrations?msg="+urlQueryEscape("第三方服务已保存,请通过部署向导重新下发生效")+"&name="+url.PathEscape(asset.Name), http.StatusFound) } func (u *UI) actionAssetIntegrationDelete(w http.ResponseWriter, r *http.Request) { if u.preview == nil { http.Error(w, "config preview service is not configured", http.StatusInternalServerError) return } name := chi.URLParam(r, "name") if err := u.preview.DeleteIntegrationService(name); err != nil { http.Redirect(w, r, "/assets/integrations?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound) return } http.Redirect(w, r, "/assets/integrations?msg="+urlQueryEscape("第三方服务已删除"), http.StatusFound) } func (u *UI) pageAssetOverlays(w http.ResponseWriter, r *http.Request) { data := u.assetPageData("overlays") data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) if data.Error == "" { data.Error = strings.TrimSpace(r.URL.Query().Get("error")) } newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1" editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1" cloneSource := strings.TrimSpace(r.URL.Query().Get("clone_source")) cloneName := strings.TrimSpace(r.URL.Query().Get("clone_name")) if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" && !newMode { if item, err := u.preview.GetOverlayAsset(name); err == nil { data.AssetOverlay = item } else if data.Error == "" { data.Error = err.Error() } } else if !newMode && len(data.AssetOverlays) > 0 { if item, err := u.preview.GetOverlayAsset(data.AssetOverlays[0].Name); err == nil { data.AssetOverlay = item } else if data.Error == "" { data.Error = err.Error() } } if newMode && cloneSource != "" { if item, err := u.preview.GetOverlayAsset(cloneSource); err == nil { rawJSON, err := json.Marshal(item.Raw) if err == nil { doc := map[string]any{} if err := json.Unmarshal(rawJSON, &doc); err == nil { doc["name"] = cloneName doc["description"] = item.Description data.AssetOverlay = &service.ConfigOverlayAsset{ Name: cloneName, Description: item.Description, Raw: doc, } } } } if data.AssetOverlay == nil { newMode = false data.Error = "无法加载源调试参数" } } if newMode { data.AssetOverlayEditing = data.AssetOverlay != nil } else { data.AssetOverlayEditing = editMode && data.AssetOverlay != nil && !data.AssetOverlay.ReadOnly } if data.AssetOverlay != nil { rawJSON, err := compactJSON(data.AssetOverlay.Raw) if err == nil { data.OverlayDraftJSON = rawJSON } } u.render(w, r, "asset_overlays", data) } func (u *UI) pageAssetOverlay(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") data := u.assetPageData("overlays") item, err := u.preview.GetOverlayAsset(name) if err != nil { http.NotFound(w, r) return } data.AssetOverlay = item u.render(w, r, "asset_overlays", data) } func (u *UI) pageAssetOverlayExport(w http.ResponseWriter, r *http.Request) { u.exportAssetJSON(w, r, "overlays", chi.URLParam(r, "name")) } func (u *UI) actionAssetOverlaySave(w http.ResponseWriter, r *http.Request) { if u.preview == nil { http.Error(w, "config preview service is not configured", http.StatusInternalServerError) return } _ = r.ParseForm() name := strings.TrimSpace(r.FormValue("name")) description := strings.TrimSpace(r.FormValue("description")) rawText := strings.TrimSpace(r.FormValue("json")) if rawText == "" { rawText = "{}" } raw := map[string]any{} if err := json.Unmarshal([]byte(rawText), &raw); err != nil { data := u.assetPageData("overlays") data.Title = "资产管理" data.Error = "调试参数 JSON 格式不正确:" + err.Error() data.AssetOverlayEditing = true data.AssetOverlay = &service.ConfigOverlayAsset{Name: name, Description: description, Raw: raw} data.OverlayDraftJSON = rawText u.render(w, r, "asset_overlays", data) return } asset := service.ConfigOverlayAsset{Name: name, Description: description, Raw: raw} if err := u.preview.SaveOverlayAsset(asset, raw); err != nil { data := u.assetPageData("overlays") data.Title = "资产管理" data.Error = err.Error() data.AssetOverlayEditing = true data.AssetOverlay = &asset data.OverlayDraftJSON = rawText u.render(w, r, "asset_overlays", data) return } http.Redirect(w, r, "/assets/overlays?msg="+urlQueryEscape("调试参数已保存")+"&name="+url.PathEscape(name), http.StatusFound) } func (u *UI) actionAssetOverlayClone(w http.ResponseWriter, r *http.Request) { if u.preview == nil { http.Error(w, "config preview service is not configured", http.StatusInternalServerError) return } sourceName := chi.URLParam(r, "name") item, err := u.preview.GetOverlayAsset(sourceName) if err != nil || item == nil { http.NotFound(w, r) return } if !item.ReadOnly { http.Redirect(w, r, "/assets/overlays?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape("只允许从标准调试参数复制创建"), http.StatusFound) return } targetName, err := u.nextOverlayCloneName(item.Name) if err != nil { http.Redirect(w, r, "/assets/overlays?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) return } cloneSource := url.QueryEscape(sourceName) cloneName := url.QueryEscape(targetName) http.Redirect(w, r, "/assets/overlays?new=1&clone_source="+cloneSource+"&clone_name="+cloneName, http.StatusFound) } func (u *UI) nextOverlayCloneName(sourceName string) (string, error) { base := strings.TrimSpace(sourceName) if base == "" { return "", fmt.Errorf("调试参数名称不能为空") } if strings.HasPrefix(base, "std_") { base = strings.TrimPrefix(base, "std_") } candidate := base + "_copy" if item, err := u.preview.GetOverlayAsset(candidate); err == nil && item != nil { for i := 2; i < 1000; i++ { name := fmt.Sprintf("%s_copy_%d", base, i) item, err := u.preview.GetOverlayAsset(name) if err != nil || item == nil { return name, nil } } return "", fmt.Errorf("无法生成可用的调试参数副本名称") } return candidate, nil } func (u *UI) actionAssetOverlayDelete(w http.ResponseWriter, r *http.Request) { if u.preview == nil { http.Error(w, "config preview service is not configured", http.StatusInternalServerError) return } name := chi.URLParam(r, "name") if err := u.preview.DeleteOverlayAsset(name); err != nil { http.Redirect(w, r, "/assets/overlays?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound) return } http.Redirect(w, r, "/assets/overlays?msg="+urlQueryEscape("调试参数已删除"), http.StatusFound) } func (u *UI) assetPageData(tab string) PageData { data := PageData{ Title: "资产管理", AssetTab: tab, } if u.preview == nil { data.Error = "资产管理服务未初始化" return data } sources, err := u.preview.ListSources() data.ConfigSources = sources if err != nil { data.Error = err.Error() } if items, listErr := u.preview.ListTemplateAssets(); listErr == nil { data.AssetTemplates = items data.AssetTemplateMap = make(map[string]service.ConfigTemplateAsset, len(items)) for _, item := range items { data.AssetTemplateMap[item.Name] = item } } else if data.Error == "" { data.Error = listErr.Error() } if items, listErr := u.preview.ListProfileAssets(); listErr == nil { data.AssetProfiles = items for _, item := range items { data.AssetInstanceCount += len(item.Instances) } } else if data.Error == "" { data.Error = listErr.Error() } if items, listErr := u.preview.ListOverlayAssets(); listErr == nil { data.AssetOverlays = items } else if data.Error == "" { data.Error = listErr.Error() } if items, listErr := u.preview.ListVideoSources(); listErr == nil { data.AssetVideoSources = items } else if data.Error == "" { data.Error = listErr.Error() } if items, listErr := u.preview.ListIntegrationServices(); listErr == nil { data.AssetIntegrations = items } else if data.Error == "" { data.Error = listErr.Error() } if items, listErr := u.preview.ListRecognitionUnits(); listErr == nil { data.RecognitionUnits = items } else if data.Error == "" { data.Error = listErr.Error() } if items, listErr := u.preview.ListDeviceAssignments(); listErr == nil { data.DeviceAssignments = items } else if data.Error == "" { data.Error = listErr.Error() } return data } func (u *UI) profileEditorPageData(name string) (PageData, error) { data := u.assetPageData("profiles") if u.preview == nil { return data, fmt.Errorf("preview service not initialized") } editor, err := u.preview.GetProfileEditor(name) if err != nil { return data, err } data.AssetProfileEditor = editor data.SelectedProfile = editor.Name if len(editor.Instances) > 0 && editor.Instances[0].Template != "" { data.SelectedTemplate = editor.Instances[0].Template } else { data.SelectedTemplate = "std_workshop_face_recognition_shoe_alarm" } return data, nil } func clampActiveInstanceIndex(count int, preferred int) int { if count <= 0 { return 0 } if preferred < 0 { return 0 } if preferred >= count { return count - 1 } return preferred } func activeInstanceIndexFromValues(values url.Values) int { raw := strings.TrimSpace(values.Get("active_instance")) if raw == "" { return 0 } idx, err := strconv.Atoi(raw) if err != nil { return 0 } return idx } func (u *UI) profileEditorActionData(r *http.Request, name string) (service.ConfigProfileEditor, PageData, error) { data, err := u.profileEditorPageData(name) if err != nil { return service.ConfigProfileEditor{}, data, err } _ = r.ParseForm() editor := service.ConfigProfileEditor{ Name: strings.TrimSpace(r.FormValue("profile_name")), PrimaryTemplateName: strings.TrimSpace(r.FormValue("primary_template_name")), BusinessName: strings.TrimSpace(r.FormValue("business_name")), Description: strings.TrimSpace(r.FormValue("description")), OverlayName: strings.TrimSpace(r.FormValue("overlay_name")), SiteName: strings.TrimSpace(r.FormValue("site_name")), Queue: service.DefaultConfigProfileQueue(), Instances: parseProfileInstanceForm(r.Form), } if editor.Name == "" && data.AssetProfileEditor != nil { editor.Name = data.AssetProfileEditor.Name } if editor.PrimaryTemplateName == "" && data.AssetProfileEditor != nil { editor.PrimaryTemplateName = data.AssetProfileEditor.PrimaryTemplateName } if editor.DeviceCode == "" && data.AssetProfileEditor != nil { editor.DeviceCode = data.AssetProfileEditor.DeviceCode } if len(editor.Instances) == 0 && data.AssetProfileEditor != nil { editor.Instances = append([]service.ConfigProfileInstanceEditor(nil), data.AssetProfileEditor.Instances...) } data.AssetProfileEditor = &editor data.SelectedProfile = editor.Name data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.Form)) return editor, data, nil } func (u *UI) exportAssetJSON(w http.ResponseWriter, r *http.Request, kind string, name string) { if u.preview == nil { http.Error(w, "preview service not initialized", http.StatusInternalServerError) return } body, filename, err := u.preview.ExportAssetJSON(kind, name) if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) _, _ = w.Write(body) } func (u *UI) pageAudit(w http.ResponseWriter, r *http.Request) { data := PageData{Title: "审计记录"} if u.auditRepo != nil { items, err := u.auditRepo.List() if err != nil { data.Error = err.Error() } else { data.AuditEntries = items } } if len(data.AuditEntries) == 0 && u.tasks != nil { data.Tasks = u.tasks.ListTasks() } u.render(w, r, "audit", data) } func (u *UI) pageSystem(w http.ResponseWriter, r *http.Request) { u.renderSystemPage( w, r, http.StatusOK, strings.TrimSpace(r.URL.Query().Get("msg")), strings.TrimSpace(r.URL.Query().Get("error")), ) } func (u *UI) pageSystemDBBackup(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(u.dbPath) == "" { http.Error(w, "database path is not configured", http.StatusNotFound) return } body, err := os.ReadFile(u.dbPath) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } filename := "app-" + time.Now().Format("20060102-150405") + ".db" w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) _, _ = w.Write(body) } func (u *UI) renderSystemPage(w http.ResponseWriter, r *http.Request, status int, message string, errText string) { w.WriteHeader(status) data := PageData{ Title: "系统状态", Devices: u.registry.GetDevices(), DBPath: u.dbPath, Message: message, Error: errText, SystemAlarmRetention: 30, SystemConcurrency: 5, } if u.cfg != nil { data.SystemAlarmRetention = u.cfg.AlarmRetentionDays data.SystemCompanyName = u.cfg.CompanyName } u.render(w, r, "system", data) } func (u *UI) actionSystemConfig(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() changed := false if days, err := strconv.Atoi(strings.TrimSpace(r.FormValue("alarm_retention_days"))); err == nil && days >= 0 { if u.alarmCollector != nil { u.alarmCollector.SetRetention(days) go u.alarmCollector.RunCleanup() } if u.cfg != nil { u.cfg.AlarmRetentionDays = days } changed = true } company := strings.TrimSpace(r.FormValue("company_name")) if company != "" && u.cfg != nil { u.cfg.CompanyName = company changed = true } if changed && u.cfg != nil && strings.TrimSpace(u.configPath) != "" { if data, err := json.MarshalIndent(u.cfg, "", " "); err == nil { os.WriteFile(u.configPath, data, 0o644) } } u.renderSystemPage(w, r, http.StatusOK, "配置已保存", "") } func (u *UI) actionSystemDBRestore(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(u.dbPath) == "" { http.Error(w, "database path is not configured", http.StatusNotFound) return } if err := r.ParseMultipartForm(50 << 20); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } file, _, err := r.FormFile("file") if err != nil { u.renderSystemPage(w, r, http.StatusBadRequest, "", "请先选择数据库备份文件") return } defer file.Close() body, err := io.ReadAll(file) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if err := os.WriteFile(u.dbPath, body, 0o644); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/system?msg="+urlQueryEscape("数据库恢复完成"), http.StatusFound) } func urlQueryEscape(s string) string { r := strings.NewReplacer("%", "%25", " ", "%20", "+", "%2B", "&", "%26", "=", "%3D", "?", "%3F") return r.Replace(s) } func normalizeConfigName(name string) (string, error) { name = strings.TrimSpace(name) if name == "" { return "", fmt.Errorf("name is required") } if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") { return "", fmt.Errorf("name contains invalid path") } if !strings.HasSuffix(strings.ToLower(name), ".json") { name += ".json" } return name, nil } func serviceVideoSourceTypeLabel(v string) string { switch strings.TrimSpace(v) { case "rtsp": return "RTSP" case "rtmp": return "RTMP" case "file": return "文件" case "usb_camera": return "USB 摄像头" default: return strings.TrimSpace(v) } } func compactJSON(v any) (string, error) { body, err := json.Marshal(v) if err != nil { return "", err } return string(body), nil } func validateTemplateGraphDocument(doc map[string]any) error { knownTypes := knownGraphNodeTypes() templateMap, ok := doc["template"].(map[string]any) if !ok { return fmt.Errorf("template must be an object") } nodes, ok := templateMap["nodes"].([]any) if !ok { return fmt.Errorf("template.nodes must be an array") } edges, ok := templateMap["edges"].([]any) if !ok { return fmt.Errorf("template.edges must be an array") } seen := map[string]bool{} for _, item := range nodes { node, ok := item.(map[string]any) if !ok { return fmt.Errorf("template node must be an object") } id := strings.TrimSpace(fmt.Sprint(node["id"])) if id == "" { return fmt.Errorf("template node id is required") } if seen[id] { return fmt.Errorf("duplicate node id: %s", id) } seen[id] = true nodeType := strings.TrimSpace(fmt.Sprint(node["type"])) if nodeType == "" { return fmt.Errorf("template node type is required: %s", id) } if !knownTypes[nodeType] { return fmt.Errorf("unknown node type: %s", nodeType) } } for _, item := range edges { var from, to string if edge, ok := item.([]any); ok { if len(edge) < 2 { return fmt.Errorf("edge must have from and to") } from = strings.TrimSpace(fmt.Sprint(edge[0])) to = strings.TrimSpace(fmt.Sprint(edge[1])) } else if edge, ok := item.(map[string]any); ok { from = strings.TrimSpace(fmt.Sprint(edge["from"])) to = strings.TrimSpace(fmt.Sprint(edge["to"])) } else { return fmt.Errorf("edge must be an array or object") } if from == "" || to == "" { return fmt.Errorf("edge has empty endpoint") } if !seen[from] || !seen[to] { return fmt.Errorf("edge references unknown node: %s -> %s", from, to) } } return nil } func prettyJSON(raw []byte) string { var out bytes.Buffer if err := json.Indent(&out, raw, "", " "); err != nil { return string(raw) } return out.String() } func (u *UI) loadConfigStatus(dev *models.Device) (*ConfigStatusView, string, error) { if u.agent == nil || dev == nil { return nil, "", nil } body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/status", nil) raw := fmt.Sprintf("GET /v1/config/status -> %d\n%s", code, prettyJSON(body)) if err != nil { dev.Online = false return nil, raw, err } if code < 200 || code >= 300 { dev.Online = false return nil, raw, fmt.Errorf("GET /v1/config/status -> %d", code) } dev.Online = true dev.LastSeenMs = time.Now().UnixMilli() var status ConfigStatusView if err := json.Unmarshal(body, &status); err != nil { return nil, raw, err } if v := strings.TrimSpace(status.Metadata.InstanceName); v != "" { dev.InstanceName = v } return &status, raw, nil } func (u *UI) loadConfigUIData(dev *models.Device) PageData { schemaBody, schemaCode, schemaErr := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/schema", nil) stateBody, stateCode, stateErr := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/state", nil) faceBody, faceCode, faceErr := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/face-gallery", nil) data := PageData{ Title: "高级识别配置", Device: dev, SchemaJSON: fmt.Sprintf("GET /v1/config/schema -> %d\n%s", schemaCode, prettyJSON(schemaBody)), StateJSON: fmt.Sprintf("GET /v1/config/state -> %d\n%s", stateCode, prettyJSON(stateBody)), FaceGalleryJSON: fmt.Sprintf("GET /v1/face-gallery -> %d\n%s", faceCode, prettyJSON(faceBody)), RawJSON: strings.TrimSpace(prettyJSON(stateBody)), } if schemaErr != nil { data.Error = schemaErr.Error() } else if stateErr != nil { data.Error = stateErr.Error() } else if faceErr != nil { data.Error = faceErr.Error() } return data } func (u *UI) pageDeviceConfigUI(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, "config_ui", u.loadConfigUIData(dev)) } func (u *UI) pageDeviceConfigFriendly(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, "config_friendly", PageData{Title: "识别方案配置", Device: dev}) } func (u *UI) pageDeviceConfigPreview(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } data := u.configPreviewPageData(dev) u.render(w, r, "config_preview", data) } func (u *UI) actionDeviceConfigPreview(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } preview, err := u.preview.RenderDeviceAssignment(dev.DeviceID) data := u.configPreviewPageData(dev) data.ConfigPreview = preview if err != nil { data.Error = err.Error() } u.render(w, r, "config_preview", data) } func (u *UI) actionDeviceConfigCandidate(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")) data := u.configPreviewPageData(dev) if raw == "" { data.Error = "候选配置 JSON 不能为空" u.render(w, r, "config_preview", data) return } if err := json.Unmarshal([]byte(raw), new(any)); err != nil { data.Error = "候选配置 JSON 无效: " + err.Error() u.render(w, r, "config_preview", data) return } data.ConfigPreview = previewResultFromJSON(raw) populateSelectionsFromPreview(&data) body, code, err := u.agent.Do("PUT", dev.IP, dev.AgentPort, "/v1/config/candidate", []byte(raw)) data.Message = fmt.Sprintf("PUT /v1/config/candidate -> %d", code) data.RawText = prettyJSON(body) data.ResultTitle = "候选配置结果" if err != nil { data.Error = err.Error() } u.render(w, r, "config_preview", data) } func (u *UI) actionDeviceConfigCandidateApply(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } returnTo := strings.TrimSpace(r.FormValue("return_to")) var data PageData if returnTo == "control" || returnTo == "config" { data = u.deviceDetailPageData(dev) } else { data = u.configPreviewPageData(dev) } raw := strings.TrimSpace(r.FormValue("json")) if raw != "" { data.ConfigPreview = previewResultFromJSON(raw) populateSelectionsFromPreview(&data) } body, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/config/candidate/apply", []byte(`{}`)) data.Message = fmt.Sprintf("POST /v1/config/candidate/apply -> %d", code) data.RawText = prettyJSON(body) data.ResultTitle = "应用候选配置结果" if err != nil { data.Error = err.Error() } else { status, _, statusErr := u.loadConfigStatus(dev) data.ConfigStatus = status if statusErr != nil { data.ConfigStatusErr = statusErr.Error() } else { data.ConfigStatusErr = "" } } if returnTo == "control" || returnTo == "config" { u.render(w, r, "device", data) return } u.render(w, r, "config_preview", data) } func (u *UI) configPreviewPageData(dev *models.Device) PageData { sources, err := u.preview.ListSources() data := PageData{ Title: "配置预览", Device: dev, ConfigSources: sources, } if err != nil { data.Error = err.Error() } if dev != nil { if assignment, err := u.preview.GetDeviceAssignment(dev.DeviceID); err == nil { data.DeviceAssignment = assignment data.SelectedAssignmentDevice = assignment.DeviceID data.SelectedProfile = assignment.ProfileName } } status, _, statusErr := u.loadConfigStatus(dev) data.ConfigStatus = status if statusErr != nil { data.ConfigStatusErr = statusErr.Error() } return data } func (u *UI) deviceControlPageData(dev *models.Device) PageData { data := PageData{ Title: "设备控制", Device: dev, } status, raw, statusErr := u.loadConfigStatus(dev) data.ConfigStatus = status data.ConfigStatusText = raw if statusErr != nil { data.ConfigStatusErr = statusErr.Error() } return data } func (u *UI) deviceConfigWorkspacePageData(dev *models.Device) PageData { data := u.deviceControlPageData(dev) data.Title = "配置管理" return data } func (u *UI) listTemplatesSafe() ([]service.Template, error) { if u.templates == nil { return nil, nil } return u.templates.ListTemplates() } func featuresFromTemplate(templateName string) []string { var features []string if strings.Contains(templateName, "face_recognition") { features = append(features, "face") } if strings.Contains(templateName, "shoe") { features = append(features, "shoe") } return features } func jsonGetStr(body []byte, key string) string { var m map[string]any if json.Unmarshal(body, &m) == nil { if s, ok := m[key].(string); ok { return s } } return "" } func cleanFormList(values []string) []string { out := make([]string, 0, len(values)) for _, value := range values { value = strings.TrimSpace(value) if value != "" { out = append(out, value) } } return out } func sortedCopy(s []string) []string { if s == nil { return nil } c := make([]string, len(s)) copy(c, s) sort.Strings(c) return c } func stringSlicesEqual(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } func parseDeviceAssignmentBoardState(raw string) (map[string][]string, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, fmt.Errorf("设备分配数据为空") } var payload struct { Devices map[string][]string `json:"devices"` } if err := json.Unmarshal([]byte(raw), &payload); err != nil { return nil, fmt.Errorf("设备分配数据格式错误") } if payload.Devices == nil { return map[string][]string{}, nil } out := make(map[string][]string, len(payload.Devices)) for deviceID, refs := range payload.Devices { deviceID = strings.TrimSpace(deviceID) if deviceID == "" { continue } out[deviceID] = cleanFormList(refs) } return out, nil } func parseAdvancedParams(raw string) map[string]any { raw = strings.TrimSpace(raw) if raw == "" { return nil } var out map[string]any if err := json.Unmarshal([]byte(raw), &out); err != nil { return map[string]any{} } if len(out) == 0 { return nil } return out } func defaultProfileEditorDraft(templates []service.ConfigTemplateAsset) *service.ConfigProfileEditor { templateName := "std_workshop_face_recognition_shoe_alarm" if len(templates) > 0 && strings.TrimSpace(templates[0].Name) != "" { templateName = strings.TrimSpace(templates[0].Name) } inst := newProfileInstanceDraft(templateName, "cam1") return &service.ConfigProfileEditor{ Name: "", BusinessName: "", Description: "", OverlayName: "", Queue: service.DefaultConfigProfileQueue(), Instances: []service.ConfigProfileInstanceEditor{inst}, } } func (u *UI) actionDevicePlanApply(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } data := u.deviceDetailPageData(dev) if data.DeviceAssignment == nil { data.Error = "请先到通道部署中为该设备指定视频通道" u.render(w, r, "device", data) return } if u.tasks == nil { data.Error = "task service not initialized" u.render(w, r, "device", data) return } preview, err := u.preview.RenderDeviceAssignment(dev.DeviceID) data.ConfigPreview = preview if err != nil { data.Error = err.Error() u.render(w, r, "device", data) return } var configDoc any if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil { data.Error = "生成配置 JSON 无效: " + err.Error() u.render(w, r, "device", data) return } task, err := u.tasks.CreateTask("config_apply", []string{dev.DeviceID}, map[string]any{"config": configDoc}) if err != nil { data.Error = err.Error() u.render(w, r, "device", data) return } http.Redirect(w, r, "/tasks/"+task.ID, http.StatusFound) } func nextProfileInstanceName(instances []service.ConfigProfileInstanceEditor) string { used := make(map[string]struct{}, len(instances)) for _, inst := range instances { name := strings.TrimSpace(inst.Name) if name == "" { continue } used[name] = struct{}{} } for i := 1; ; i++ { candidate := fmt.Sprintf("cam%d", i) if _, ok := used[candidate]; !ok { return candidate } } } func newProfileInstanceDraft(templateName string, channelName string) service.ConfigProfileInstanceEditor { outputs := map[string]service.OutputBindingEditor{ "stream_output_main": { PublishHLSPath: "./web/hls/" + channelName + "/index.m3u8", PublishRTSPPort: "8555", PublishRTSPPath: "/live/" + channelName, ChannelNo: channelName, }, } return service.ConfigProfileInstanceEditor{ Name: channelName, Template: templateName, PublishHLSPath: outputs["stream_output_main"].PublishHLSPath, PublishRTSPPort: outputs["stream_output_main"].PublishRTSPPort, PublishRTSPPath: outputs["stream_output_main"].PublishRTSPPath, ChannelNo: outputs["stream_output_main"].ChannelNo, OutputBindings: outputs, } } func parseProfileInstanceForm(form url.Values) []service.ConfigProfileInstanceEditor { indices := make([]int, 0) seen := map[int]struct{}{} for key := range form { if !strings.HasPrefix(key, "instances[") { continue } rest := strings.TrimPrefix(key, "instances[") end := strings.Index(rest, "]") if end <= 0 { continue } idx, err := strconv.Atoi(rest[:end]) if err != nil { continue } if _, ok := seen[idx]; ok { continue } seen[idx] = struct{}{} indices = append(indices, idx) } sort.Ints(indices) out := make([]service.ConfigProfileInstanceEditor, 0, len(indices)) for _, idx := range indices { prefix := fmt.Sprintf("instances[%d].", idx) inputBindings := parseInputBindingForm(form, prefix) serviceBindings := parseServiceBindingForm(form, prefix) outputBindings := parseOutputBindingForm(form, prefix) inst := service.ConfigProfileInstanceEditor{ Name: strings.TrimSpace(form.Get(prefix + "name")), Template: strings.TrimSpace(form.Get(prefix + "template")), VideoSourceRef: firstString(inputBindingValue(inputBindings, "video_input_main"), strings.TrimSpace(form.Get(prefix+"video_source_ref"))), DisplayName: strings.TrimSpace(form.Get(prefix + "display_name")), PublishHLSPath: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "publish_hls_path"), strings.TrimSpace(form.Get(prefix+"publish_hls_path"))), PublishRTSPPort: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "publish_rtsp_port"), strings.TrimSpace(form.Get(prefix+"publish_rtsp_port"))), PublishRTSPPath: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "publish_rtsp_path"), strings.TrimSpace(form.Get(prefix+"publish_rtsp_path"))), ChannelNo: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "channel_no"), strings.TrimSpace(form.Get(prefix+"channel_no"))), InputBindings: inputBindings, ServiceBindings: serviceBindings, OutputBindings: outputBindings, AdvancedParams: parseAdvancedParams(strings.TrimSpace(form.Get(prefix + "advanced_params"))), Delete: strings.TrimSpace(form.Get(prefix+"delete")) == "1", } if inst.Name != "" || inst.VideoSourceRef != "" || len(inst.ServiceBindings) > 0 || len(inst.OutputBindings) > 0 || inst.Delete { out = append(out, inst) } } if strings.TrimSpace(form.Get("add_instance")) == "1" { templateName := "std_workshop_face_recognition_shoe_alarm" if len(out) > 0 && strings.TrimSpace(out[0].Template) != "" { templateName = strings.TrimSpace(out[0].Template) } out = append(out, newProfileInstanceDraft(templateName, nextProfileInstanceName(out))) } if len(out) > 0 { fallbackTemplate := strings.TrimSpace(out[0].Template) if fallbackTemplate == "" { fallbackTemplate = "std_workshop_face_recognition_shoe_alarm" } for i := range out { if strings.TrimSpace(out[i].Template) == "" { out[i].Template = fallbackTemplate } } } return out } func parseInputBindingForm(form url.Values, prefix string) map[string]service.InputBindingEditor { bindings := map[string]service.InputBindingEditor{} for key := range form { if !strings.HasPrefix(key, prefix+"input_bindings.") || !strings.HasSuffix(key, ".video_source_ref") { continue } slot := strings.TrimPrefix(key, prefix+"input_bindings.") slot = strings.TrimSuffix(slot, ".video_source_ref") slot = strings.TrimSpace(slot) if slot == "" { continue } value := strings.TrimSpace(form.Get(key)) if value == "" { continue } bindings[slot] = service.InputBindingEditor{VideoSourceRef: value} } if len(bindings) == 0 { return nil } return bindings } func parseServiceBindingForm(form url.Values, prefix string) map[string]service.ServiceBindingEditor { bindings := map[string]service.ServiceBindingEditor{} for key := range form { if !strings.HasPrefix(key, prefix+"service_bindings.") || !strings.HasSuffix(key, ".service_ref") { continue } slot := strings.TrimPrefix(key, prefix+"service_bindings.") slot = strings.TrimSuffix(slot, ".service_ref") slot = strings.TrimSpace(slot) if slot == "" { continue } value := strings.TrimSpace(form.Get(key)) if value == "" { continue } bindings[slot] = service.ServiceBindingEditor{ServiceRef: value} } if len(bindings) == 0 { return nil } return bindings } func parseOutputBindingForm(form url.Values, prefix string) map[string]service.OutputBindingEditor { bindings := map[string]service.OutputBindingEditor{} for key := range form { if !strings.HasPrefix(key, prefix+"output_bindings.") { continue } slotField := strings.TrimPrefix(key, prefix+"output_bindings.") dot := strings.LastIndex(slotField, ".") if dot <= 0 { continue } slot := strings.TrimSpace(slotField[:dot]) field := strings.TrimSpace(slotField[dot+1:]) if slot == "" || field == "" { continue } value := strings.TrimSpace(form.Get(key)) entry := bindings[slot] switch field { case "publish_hls_path": entry.PublishHLSPath = value case "publish_rtsp_port": entry.PublishRTSPPort = value case "publish_rtsp_path": entry.PublishRTSPPath = value case "channel_no": entry.ChannelNo = value default: continue } if strings.TrimSpace(entry.PublishHLSPath) == "" && strings.TrimSpace(entry.PublishRTSPPort) == "" && strings.TrimSpace(entry.PublishRTSPPath) == "" && strings.TrimSpace(entry.ChannelNo) == "" { continue } bindings[slot] = entry } if len(bindings) == 0 { return nil } return bindings } func inputBindingValue(bindings map[string]service.InputBindingEditor, slot string) string { if len(bindings) == 0 { return "" } return strings.TrimSpace(bindings[slot].VideoSourceRef) } func outputBindingFormValue(bindings map[string]service.OutputBindingEditor, slot string, field string) string { if len(bindings) == 0 { return "" } item, ok := bindings[slot] if !ok { return "" } switch 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 "" } } func firstString(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" } func selectedIDsFromQuery(values []string) []string { values = cleanFormList(values) if len(values) == 0 { return nil } seen := make(map[string]struct{}, len(values)) out := make([]string, 0, len(values)) for _, value := range values { if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} out = append(out, value) } return out } func filterSelectedDeviceIDs(devices []*models.Device, candidates []string) []string { if len(candidates) == 0 || len(devices) == 0 { return nil } known := make(map[string]struct{}, len(devices)) for _, dev := range devices { if dev == nil { continue } id := strings.TrimSpace(dev.DeviceID) if id != "" { known[id] = struct{}{} } } seen := make(map[string]struct{}, len(candidates)) out := make([]string, 0, len(candidates)) for _, id := range candidates { id = strings.TrimSpace(id) if id == "" { continue } if _, ok := known[id]; !ok { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} out = append(out, id) } if len(out) == 0 { return nil } return out } func selectedQueryString(ids []string) string { if len(ids) == 0 { return "" } values := url.Values{} for _, id := range ids { values.Add("selected", id) } return values.Encode() } func selectedURL(path string, ids []string) string { query := selectedQueryString(ids) if query == "" { return path } return path + "?" + query } func (u *UI) deviceOverviewPageData(r *http.Request, selectedIDs []string, errMsg string) PageData { // Skip discovery when just toggling selection (device_id in query). if len(r.URL.Query()["device_id"]) == 0 && len(r.URL.Query()["selected"]) == 0 { u.ensureDevicesLoaded() } devices := u.registry.GetDevices() rows := make([]DeviceOverviewRow, 0, len(devices)) for _, dev := range devices { row := DeviceOverviewRow{Device: dev} status, _, err := u.loadConfigStatus(dev) row.ConfigStatus = status if err != nil { row.ConfigStatusErr = err.Error() } rows = append(rows, row) } online := 0 attention := 0 for _, d := range devices { if d.Online { online++ } else { attention++ } } failedTasks := 0 if u.tasks != nil { for _, t := range u.tasks.ListTasks() { if t.Status == models.TaskFailed { failedTasks++ } } } if selectedIDs == nil { selectedIDs = selectedIDsFromQuery(r.URL.Query()["selected"]) } selectedIDs = filterSelectedDeviceIDs(devices, selectedIDs) // Check integrations staleness staleMap := u.integrationStaleMap() for i := range rows { if rows[i].Device != nil { rows[i].IntegrationStale = staleMap[rows[i].Device.DeviceID] } } data := PageData{ Title: "设备管理", Devices: devices, DeviceRows: rows, DeviceCount: len(devices), OnlineCount: online, OfflineCount: len(devices) - online, RunningTaskCount: 0, FailedTaskCount: failedTasks, FoundCount: attention, SelectedDeviceIDs: selectedIDs, SelectedDevices: selectedDevicesFromIDs(devices, selectedIDs), SelectedQuery: selectedQueryString(selectedIDs), SelectedDevicesURL: selectedURL("/devices", selectedIDs), BatchConfigURL: selectedURL("/devices/batch-config", selectedIDs), ReloadSummary: batchActionSummary(rows, selectedIDs, "reload"), RollbackSummary: batchActionSummary(rows, selectedIDs, "rollback"), } if errMsg != "" { data.Error = errMsg } return data } func (u *UI) deviceBatchConfigPageData(r *http.Request, selectedIDs []string) PageData { data := u.deviceOverviewPageData(r, selectedIDs, "") sources, err := u.preview.ListSources() data.Title = "下发设备分配" data.ConfigSources = sources data.SelectedDevices = selectedDevicesFromIDs(data.Devices, data.SelectedDeviceIDs) assignments, assignErr := u.preview.ListDeviceAssignments() filteredAssignments := make([]service.DeviceAssignmentAsset, 0, len(assignments)) selectedSet := make(map[string]struct{}, len(data.SelectedDeviceIDs)) for _, id := range data.SelectedDeviceIDs { selectedSet[id] = struct{}{} } for _, item := range assignments { if _, ok := selectedSet[item.DeviceID]; ok { filteredAssignments = append(filteredAssignments, item) } } data.DeviceAssignments = filteredAssignments if err != nil { data.Error = err.Error() } else if assignErr != nil { data.Error = assignErr.Error() } return data } func selectedDevicesFromIDs(devices []*models.Device, ids []string) []*models.Device { if len(devices) == 0 || len(ids) == 0 { return nil } byID := make(map[string]*models.Device, len(devices)) for _, dev := range devices { if dev == nil { continue } byID[strings.TrimSpace(dev.DeviceID)] = dev } selected := make([]*models.Device, 0, len(ids)) for _, id := range ids { if dev := byID[strings.TrimSpace(id)]; dev != nil { selected = append(selected, dev) } } return selected } func previewResultFromJSON(raw string) *service.ConfigPreviewResult { raw = strings.TrimSpace(raw) if raw == "" { return nil } var doc map[string]any if err := json.Unmarshal([]byte(raw), &doc); err != nil { return nil } metadata, _ := doc["metadata"].(map[string]any) return &service.ConfigPreviewResult{ JSON: raw, Metadata: metadata, Size: len([]byte(raw)), } } func populateSelectionsFromPreview(data *PageData) { if data == nil || data.ConfigPreview == nil { return } if metadata := data.ConfigPreview.Metadata; metadata != nil { if v, _ := metadata["template"].(string); strings.TrimSpace(v) != "" { data.SelectedTemplate = v } if v, _ := metadata["profile"].(string); strings.TrimSpace(v) != "" { data.SelectedProfile = v } if v, _ := metadata["config_id"].(string); strings.TrimSpace(v) != "" { data.SelectedConfigID = v } if v, _ := metadata["config_version"].(string); strings.TrimSpace(v) != "" { data.SelectedVersion = v } if items, ok := metadata["overlays"].([]any); ok { overlays := make([]string, 0, len(items)) for _, item := range items { if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { overlays = append(overlays, s) } } if len(overlays) > 0 { data.SelectedOverlays = overlays } } } } func profileAssetTemplate(asset *service.ConfigProfileAsset) string { if asset == nil { return "" } for _, item := range asset.Instances { if v := strings.TrimSpace(item.Template); v != "" { return v } } return "" } func profileAssetBusinessName(asset *service.ConfigProfileAsset) string { if asset == nil { return "" } if v := strings.TrimSpace(asset.BusinessName); v != "" { return v } return strings.TrimSpace(asset.Name) } func (u *UI) integrationStaleMap() map[string]bool { stale := map[string]bool{} if u.preview == nil { return stale } svcs, _ := u.preview.ListIntegrationServices() if len(svcs) == 0 { return stale } maxUpdated := "" for _, s := range svcs { if s.UpdatedAt > maxUpdated { maxUpdated = s.UpdatedAt } } if maxUpdated == "" { return stale } for _, dev := range u.registry.GetDevices() { if dev == nil || !dev.Online { continue } status, _, _ := u.loadConfigStatus(dev) if status == nil || status.Metadata.RenderedAt == "" { continue // not deployed yet, skip } if maxUpdated > status.Metadata.RenderedAt { stale[dev.DeviceID] = true } } return stale } func batchActionSummary(rows []DeviceOverviewRow, selectedIDs []string, action string) string { if len(selectedIDs) == 0 { return "" } rowByID := make(map[string]DeviceOverviewRow, len(rows)) for _, row := range rows { if row.Device == nil { continue } rowByID[strings.TrimSpace(row.Device.DeviceID)] = row } lines := make([]string, 0, len(selectedIDs)) for _, id := range selectedIDs { row, ok := rowByID[strings.TrimSpace(id)] if !ok || row.Device == nil { continue } label := row.Device.DisplayName() switch action { case "reload": summary := "未取到当前运行配置" if row.ConfigStatus != nil { meta := row.ConfigStatus.Metadata if name := strings.TrimSpace(meta.BusinessName); name != "" { summary = name if profile := strings.TrimSpace(meta.Profile); profile != "" { summary += " (" + profile + ")" } } else if profile := strings.TrimSpace(meta.Profile); profile != "" { summary = profile } else if configID := strings.TrimSpace(meta.ConfigID); configID != "" { summary = configID } } lines = append(lines, label+" -> "+summary) case "rollback": summary := "未取到可回滚运行配置" if row.ConfigStatus != nil && row.ConfigStatus.PreviousConfig != nil { meta := row.ConfigStatus.PreviousConfig.Metadata if name := strings.TrimSpace(meta.BusinessName); name != "" { summary = name if profile := strings.TrimSpace(meta.Profile); profile != "" { summary += " (" + profile + ")" } } else if profile := strings.TrimSpace(meta.Profile); profile != "" { summary = profile } else if configID := strings.TrimSpace(meta.ConfigID); configID != "" { summary = configID } } lines = append(lines, label+" -> "+summary) } } return strings.Join(lines, ";") } func (u *UI) actionDeviceConfigUIPlan(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 = `{"instances":[]}` } if err := json.Unmarshal([]byte(raw), new(any)); err != nil { data := u.loadConfigUIData(dev) data.Error = "json 无效: " + err.Error() data.RawJSON = raw u.render(w, r, "config_ui", data) return } body, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/config/plan", []byte(raw)) data := u.loadConfigUIData(dev) data.Message = fmt.Sprintf("POST /v1/config/plan -> %d", code) data.RawText = prettyJSON(body) data.RawJSON = raw if err != nil { data.Error = err.Error() } u.render(w, r, "config_ui", data) } func (u *UI) actionDeviceConfigUIApply(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 = `{"instances":[]}` } if err := json.Unmarshal([]byte(raw), new(any)); err != nil { data := u.loadConfigUIData(dev) data.Error = "json 无效: " + err.Error() data.RawJSON = raw u.render(w, r, "config_ui", data) return } body, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/config/apply", []byte(raw)) data := u.loadConfigUIData(dev) data.Message = fmt.Sprintf("POST /v1/config/apply -> %d", code) data.RawText = prettyJSON(body) data.RawJSON = raw if err != nil { data.Error = err.Error() } u.render(w, r, "config_ui", data) } func (u *UI) actionDeviceFaceGalleryUpload(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(500 << 20); 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() resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, "/v1/face-gallery", file, "application/octet-stream", hdr.Size) data := u.loadConfigUIData(dev) data.Message = fmt.Sprintf("PUT /v1/face-gallery -> %d", code) data.RawText = prettyJSON(resp) if derr != nil { data.Error = derr.Error() } u.render(w, r, "config_ui", data) } func (u *UI) actionDeviceFaceGalleryReload(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") _, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } task, err := u.tasks.CreateTask("face_gallery_reload", []string{id}, nil) if err != nil { http.Redirect(w, r, "/devices/"+url.PathEscape(id)+"?error="+url.QueryEscape(err.Error()), http.StatusFound) return } http.Redirect(w, r, "/tasks/"+url.PathEscape(task.ID), http.StatusFound) } func (u *UI) pageMonitor(w http.ResponseWriter, r *http.Request) { u.ensureDevicesLoaded() u.render(w, r, "monitor", PageData{Title: "视频监控", Devices: u.registry.GetDevices()}) } func (u *UI) apiMonitorChannels(w http.ResponseWriter, r *http.Request) { deviceID := r.URL.Query().Get("device_id") if deviceID == "" { http.Error(w, "missing device_id", http.StatusBadRequest) return } dev, ok := u.findDevice(deviceID) if !ok { http.Error(w, "device not found", http.StatusNotFound) return } body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/preview/channels", nil) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) w.Write(body) } func (u *UI) proxyHLS(w http.ResponseWriter, r *http.Request) { // URL format: /hls/{deviceID}/{hls_path} path := strings.TrimPrefix(r.URL.Path, "/hls/") idx := strings.Index(path, "/") if idx < 0 { http.Error(w, "invalid path", http.StatusBadRequest) return } deviceID := path[:idx] hlsPath := path[idx+1:] dev, ok := u.findDevice(deviceID) if !ok { http.Error(w, "device not found", http.StatusNotFound) return } body, code, err := u.agent.Do("GET", dev.IP, dev.MediaPort, "/"+hlsPath, nil) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } // Set HLS-friendly headers if strings.HasSuffix(hlsPath, ".m3u8") { w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") w.Header().Set("Access-Control-Allow-Origin", "*") } else if strings.HasSuffix(hlsPath, ".ts") { w.Header().Set("Content-Type", "video/mp2t") w.Header().Set("Access-Control-Allow-Origin", "*") } w.Header().Set("Cache-Control", "no-cache") w.WriteHeader(code) w.Write(body) } func (u *UI) actionFaceGalleryAdd(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(10 << 20); err != nil { http.Error(w, "invalid form", http.StatusBadRequest) return } name := strings.TrimSpace(r.FormValue("name")) if name == "" { http.Error(w, "name required", http.StatusBadRequest) return } // Save photo to dataset directory datasetDir := filepath.Join("dataset") personDir := filepath.Join(datasetDir, name) if err := os.MkdirAll(personDir, 0o755); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } file, hdr, err := r.FormFile("photo") if err != nil { http.Error(w, "photo required", http.StatusBadRequest) return } defer file.Close() dst := filepath.Join(personDir, hdr.Filename) f, err := os.Create(dst) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer f.Close() io.Copy(f, file) // Register person and photo in DB so the face gallery list reflects it. if strings.TrimSpace(u.dbPath) != "" { repo := storage.NewFaceGalleryRepo(u.dbPath) pid, _ := repo.FindOrCreatePerson(name) _ = repo.AddPhoto(pid, name+"/"+hdr.Filename) } http.Redirect(w, r, "/face-gallery?msg=ok", http.StatusFound) } func (u *UI) actionFaceGalleryBuild(w http.ResponseWriter, r *http.Request) { addFaceDir := filepath.Join("..", "AddFaceTo3588") pythonExe := filepath.Join(addFaceDir, ".venv", "Scripts", "python.exe") // Fallback to system python if venv not available if _, err := os.Stat(pythonExe); os.IsNotExist(err) { pythonExe = "python" } builder := &service.FaceGalleryBuilder{ PythonExe: pythonExe, BuildScript: filepath.Join("tools", "build_gallery.py"), DetModel: filepath.Join("tools", "models", "RetinaFace_mobile320.onnx"), RecogModel: filepath.Join("tools", "models", "mobilefacenet_arcface_prenorm.onnx"), DetOutputsConfig: filepath.Join("tools", "models", "retinaface_mobile320_config.json"), DatasetDir: filepath.Join("dataset"), OutputDB: filepath.Join("resources", "standard_resources", "face_gallery", "face_gallery.db"), } output, err := builder.Build() if err != nil { msg := fmt.Sprintf("生成失败: %v", err) http.Redirect(w, r, "/face-gallery?msg="+url.QueryEscape(msg), http.StatusFound) return } // Parse build report for quality diagnostics. // Output sections: per person used samples, failure reasons, persons skipped. persons := "0" photosOK := "0" photosFailed := "0" failureReasons := map[string]string{} perPerson := map[string]string{} currentSection := "" for _, line := range strings.Split(output, "\n") { line = strings.TrimSpace(line) switch { case strings.HasPrefix(line, "enrolled_persons:"): persons = strings.TrimSpace(strings.TrimPrefix(line, "enrolled_persons:")) case strings.HasPrefix(line, "ok_images:"): photosOK = strings.TrimSpace(strings.TrimPrefix(line, "ok_images:")) case strings.HasPrefix(line, "failed_images:"): photosFailed = strings.TrimSpace(strings.TrimPrefix(line, "failed_images:")) case strings.HasPrefix(line, "-- per person"): currentSection = "per_person" case strings.HasPrefix(line, "-- failure reasons"): currentSection = "failures" case strings.HasPrefix(line, "-- persons skipped"): currentSection = "skipped" case strings.HasPrefix(line, "-- db selfcheck"): currentSection = "" default: if currentSection == "per_person" && strings.Contains(line, ":") { parts := strings.SplitN(line, ":", 2) if len(parts) == 2 { perPerson[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) } } else if currentSection == "failures" && strings.Contains(line, ":") { parts := strings.SplitN(line, ":", 2) if len(parts) == 2 { failureReasons[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) } } } } // Register the built face_gallery.db as a standard resource so the // Resource sync system can detect and distribute it to edge devices. if u.resourcesRepo != nil { resourceSvc := service.NewResourceManagementService(u.resourcesRepo) if err := resourceSvc.RegisterResource(builder.OutputDB, "face_gallery", "face_gallery"); err != nil { log.Printf("register face_gallery resource: %v", err) } } // Build result message with quality diagnostics. reasonCN := map[string]string{ "no_face": "无人脸", "small_face": "人脸太小", "align_fail": "对齐失败", "infer_fail": "识别失败", "read_fail": "读取失败", "det_fail": "检测失败", } var reasonParts []string for k, v := range failureReasons { label := k if cn, ok := reasonCN[k]; ok { label = cn } reasonParts = append(reasonParts, fmt.Sprintf("%s×%s", label, v)) } msg := fmt.Sprintf("生成成功,共 %s 人,%s 张通过", persons, photosOK) if photosFailed != "0" && photosFailed != "" { msg += fmt.Sprintf(",%s 张失败", photosFailed) if len(reasonParts) > 0 { msg += fmt.Sprintf("(%s)", strings.Join(reasonParts, "、")) } } // Write per-person quality stats for the face gallery page to display. if len(perPerson) > 0 { metaPath := filepath.Join("resources", "standard_resources", "face_gallery", "build_meta.json") if metaJSON, err := json.Marshal(perPerson); err == nil { os.WriteFile(metaPath, metaJSON, 0o644) } } http.Redirect(w, r, "/face-gallery?msg="+url.QueryEscape(msg), http.StatusFound) } func (u *UI) pageFaceGallery(w http.ResponseWriter, r *http.Request) { data := PageData{Title: "人脸库"} data.Message = r.URL.Query().Get("msg") // Load build quality stats if available. metaPath := filepath.Join("resources", "standard_resources", "face_gallery", "build_meta.json") if metaBytes, err := os.ReadFile(metaPath); err == nil { var meta map[string]string if json.Unmarshal(metaBytes, &meta) == nil { data.FaceGalleryQuality = meta } } if strings.TrimSpace(u.dbPath) != "" { if repo := storage.NewFaceGalleryRepo(u.dbPath); repo != nil { if persons, err := repo.ListPersons(); err == nil { data.FaceGalleryPersons = persons } } } // Load face_gallery resource status board for device sync section u.ensureDevicesLoaded() data.Devices = u.registry.GetDevices() if u.resourcesRepo != nil { allResources, _ := u.resourcesRepo.List() faceResources := make([]storage.StandardResourceRecord, 0) for _, r := range allResources { if r.ResourceType == "face_gallery" { faceResources = append(faceResources, r) } } if len(faceResources) > 0 { data.StandardResources = faceResources board := service.FetchAndBuildResourceBoard(u.agent, data.Devices, faceResources) data.ResourceStatusBoard = &board } } u.render(w, r, "face_gallery", data) } func (u *UI) actionFaceGallerySync(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), 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{"resource_name": "face_gallery"} task, err := u.tasks.CreateTask("resource_sync_all", deviceIDs, payload) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/tasks/"+url.PathEscape(task.ID), http.StatusFound) } func (u *UI) actionFaceGalleryDelete(w http.ResponseWriter, r *http.Request) { id, _ := strconv.Atoi(r.FormValue("id")) if id <= 0 { http.Error(w, "invalid", http.StatusBadRequest) return } if strings.TrimSpace(u.dbPath) != "" { repo := storage.NewFaceGalleryRepo(u.dbPath) repo.DeletePerson(id) } http.Redirect(w, r, "/face-gallery", http.StatusFound) } func (u *UI) actionFaceGalleryImport(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(100 << 20); err != nil { http.Error(w, "form too large", http.StatusBadRequest) return } datasetDir := filepath.Join("dataset") count := 0 for _, files := range r.MultipartForm.File { for _, hdr := range files { fp := fullFilename(hdr) personName := personNameFromPath(fp) fileName := filepath.Base(fp) if personName == "" || fileName == "" { continue } personDir := filepath.Join(datasetDir, personName) os.MkdirAll(personDir, 0o755) dst := filepath.Join(personDir, fileName) // Skip if file already exists if _, err := os.Stat(dst); err == nil { continue } file, err := hdr.Open() if err != nil { continue } f, err := os.Create(dst) if err != nil { file.Close(); continue } io.Copy(f, file) f.Close() file.Close() count++ // Register in DB if strings.TrimSpace(u.dbPath) != "" { repo := storage.NewFaceGalleryRepo(u.dbPath) pid, _ := repo.FindOrCreatePerson(personName) repo.AddPhoto(pid, personName+"/"+fileName) } } } http.Redirect(w, r, fmt.Sprintf("/face-gallery?msg=已导入 %d 张照片", count), http.StatusFound) } func (u *UI) rebuildFaceGallery() { builder := &service.FaceGalleryBuilder{ PythonExe: "python", BuildScript: filepath.Join("tools", "build_gallery.py"), DetModel: filepath.Join("tools", "models", "RetinaFace_mobile320.onnx"), RecogModel: filepath.Join("tools", "models", "mobilefacenet_arcface_prenorm.onnx"), DetOutputsConfig: filepath.Join("tools", "models", "retinaface_mobile320_config.json"), DatasetDir: filepath.Join("dataset"), OutputDB: filepath.Join("resources", "standard_resources", "face_gallery", "face_gallery.db"), } builder.Build() } // fullFilename extracts the full relative path from a webkitdirectory file upload, // bypassing Go 1.20+ sanitization that strips directory components. func fullFilename(hdr *multipart.FileHeader) string { cd := hdr.Header.Get("Content-Disposition") _, params, err := mime.ParseMediaType(cd) if err == nil { if name := params["filename"]; name != "" { return name } } return hdr.Filename } // personNameFromPath extracts the person name from a webkitdirectory path like "李清/photo.jpg". func personNameFromPath(path string) string { path = strings.ReplaceAll(path, "\\", "/") parts := strings.Split(path, "/") if len(parts) < 2 { return "" } return strings.TrimSpace(parts[len(parts)-2]) } func (u *UI) serveFacePhoto(w http.ResponseWriter, r *http.Request) { path := chi.URLParam(r, "*") // URL-decode to normalize case-insensitive percent-encoding if decoded, err := url.PathUnescape(path); err == nil { path = decoded } if path == "" || strings.Contains(path, "..") { http.Error(w, "invalid", http.StatusBadRequest) return } fullPath := filepath.Join("dataset", path) http.ServeFile(w, r, fullPath) } func (u *UI) actionFaceGalleryRename(w http.ResponseWriter, r *http.Request) { id, _ := strconv.Atoi(r.FormValue("id")) name := strings.TrimSpace(r.FormValue("name")) if id <= 0 || name == "" { http.Error(w, "invalid", http.StatusBadRequest) return } if strings.TrimSpace(u.dbPath) != "" { storage.NewFaceGalleryRepo(u.dbPath).RenamePerson(id, name) } http.Redirect(w, r, "/face-gallery", http.StatusFound) } func (u *UI) actionFaceGalleryAddPhoto(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(10 << 20); err != nil { http.Error(w, "form error", http.StatusBadRequest); return } id, _ := strconv.Atoi(r.FormValue("id")) if id <= 0 { http.Error(w, "invalid id", http.StatusBadRequest); return } file, hdr, err := r.FormFile("photo") if err != nil { http.Error(w, "photo required", http.StatusBadRequest); return } defer file.Close() // Get person name for directory if strings.TrimSpace(u.dbPath) == "" { http.Redirect(w, r, "/face-gallery", http.StatusFound); return } repo := storage.NewFaceGalleryRepo(u.dbPath) persons, _ := repo.ListPersons() var personName string for _, p := range persons { if p.ID == id { personName = p.Name; break } } if personName == "" { http.Error(w, "person not found", http.StatusNotFound); return } personDir := filepath.Join("dataset", personName) os.MkdirAll(personDir, 0o755) dst := filepath.Join(personDir, hdr.Filename) f, err := os.Create(dst) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError); return } defer f.Close() io.Copy(f, file) repo.AddPhoto(id, personName+"/"+hdr.Filename) http.Redirect(w, r, "/face-gallery", http.StatusFound) } func (u *UI) actionFaceGalleryDeletePhoto(w http.ResponseWriter, r *http.Request) { id, _ := strconv.Atoi(r.FormValue("id")) if id > 0 && strings.TrimSpace(u.dbPath) != "" { storage.NewFaceGalleryRepo(u.dbPath).DeletePhoto(id) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"ok": true}) } func (u *UI) apiDeviceMetrics(w http.ResponseWriter, r *http.Request) { deviceID := r.URL.Query().Get("device_id") if deviceID == "" { http.Error(w, "missing device_id", http.StatusBadRequest); return } dev, ok := u.findDevice(deviceID) if !ok { http.Error(w, "device not found", http.StatusNotFound); return } body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/metrics", nil) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway); return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) w.Write(body) } type DeviceMetric struct { Name string `json:"name"` CPU float64 `json:"cpu"` Mem float64 `json:"mem"` NPU float64 `json:"npu"` Disk float64 `json:"disk"` Temperature float64 `json:"temperature"` } type ConsoleDeviceData struct { Device *models.Device CPUUsage float64 MemUsage float64 NPUUsage float64 DiskUsage float64 Temperature float64 MaxUnits int Channels []ConsoleChannel Features []ConsoleFeature Cameras []string Busy bool BusyMsg string } type ConsoleChannel struct { Name string Display string SourceName string URL string } type ConsoleFeature struct { Key string Label string Description string Enabled bool Available bool // device actually supports this (from agent /v1/capabilities) Conflict bool // enabled in DB but unavailable on device }