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/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 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 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 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 } 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) 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.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}/face-gallery/reload", u.actionDeviceFaceGalleryReload) 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.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) } } // 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.Online || 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 || !dev.Online { continue } body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/metrics", nil) if err != nil || code != 200 { continue } var m struct { CPU struct{ UsagePct float64 `json:"usage_pct"` } `json:"cpu"` Memory struct { TotalKB float64 `json:"total_kb"` AvailableKB float64 `json:"available_kb"` } `json:"memory"` NPU struct { UsagePct float64 `json:"usage_pct"` Cores map[string]float64 `json:"cores"` } `json:"npu"` Disk struct{ UsedPct float64 `json:"used_pct"` } `json:"disk"` Temperature struct{ Celsius float64 `json:"celsius"` } `json:"temperature"` } json.Unmarshal(body, &m) npuVal := m.NPU.UsagePct // Pick max core if per-core data available if len(m.NPU.Cores) > 0 { for _, v := range m.NPU.Cores { if v > npuVal { npuVal = v } } } memPct := 0.0 if m.Memory.TotalKB > 0 { memPct = (m.Memory.TotalKB - m.Memory.AvailableKB) / m.Memory.TotalKB * 100 } metrics[dev.DeviceID] = DeviceMetric{ Name: dev.DisplayName(), CPU: m.CPU.UsagePct, Mem: memPct, NPU: npuVal, Disk: m.Disk.UsedPct, Temperature: m.Temperature.Celsius, } } } // Build console device data var consoleDevices []ConsoleDeviceData for _, dev := range devices { if dev == nil { continue } maxUnits := 4 // Gather channels (one per assigned recognition unit) var channels []ConsoleChannel var cameras []string for _, u := range assignedUnits[dev.DeviceID] { // HLS channel name matches recognition unit Name (used in publish path) chName := u.Name displayName := u.Name if u.DisplayName != "" { displayName = u.DisplayName } srcName := u.VideoSourceRef if srcName == "" { srcName = displayName } channels = append(channels, ConsoleChannel{Name: chName, Display: displayName, SourceName: srcName}) if u.VideoSourceRef != "" { cameras = append(cameras, u.VideoSourceRef) } } // Determine which features are enabled from persisted device features. activeKeys := 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 if assignment, err := u.preview.GetDeviceAssignment(dev.DeviceID); err == nil && assignment != nil { for _, ref := range assignment.RecognitionUnits { if unit, err := u.preview.GetRecognitionUnit(ref); err == nil && unit != nil { if unit.VideoSourceRef != "" { sources = append(sources, unit.VideoSourceRef) } } } } if len(features) == 0 { continue } requests = append(requests, service.AutoConfigRequest{ DeviceID: dev.DeviceID, Features: features, SourceNames: sources, }) } if len(requests) == 0 { http.Redirect(w, r, "/console?msg="+url.QueryEscape("未选择检测功能"), http.StatusFound) return } // Step 3: save features to DB BEFORE creating tasks (avoids SQLITE_BUSY race). for _, req := range requests { _ = u.preview.SaveDeviceFeatures(req.DeviceID, req.Features) } // Step 4: run pipeline (creates tasks asynchronously). results, _ := u.autoConfig.BuildPipelineBatch(requests, true) // Collect task IDs for redirect. taskIDs := make([]string, 0) var errs []string for _, res := range results { if res.TaskID != "" { taskIDs = append(taskIDs, res.TaskID) } if res.Error != "" { errs = append(errs, res.DeviceID+": "+res.Error) } } if len(taskIDs) == 1 { http.Redirect(w, r, "/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() 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 && dev.Online { // Channels: use management DB for friendly names and RTSP URLs channels := queryDeviceChannelsFromDB(u.preview, deviceID) resp["channels"] = channels caps := queryAgentCapabilities(u.agent, dev) resp["capabilities"] = caps // Include currently enabled features from management DB 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 dev.Online && 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) u.render(w, r, "system", PageData{ Title: "系统状态", Devices: u.registry.GetDevices(), DBPath: u.dbPath, Message: message, Error: errText, }) } 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) 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 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 }