From 2921c180cb74669867961eb259072b68767324db Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Wed, 10 Jun 2026 14:31:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=B9=B3=E5=8F=B0=E5=90=8D?= =?UTF-8?q?=E7=A7=B0=E5=92=8C=E7=95=8C=E9=9D=A2=EF=BC=8C=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E4=BA=86=E9=A1=B9=E7=9B=AE=E4=BB=8B=E7=BB=8D=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E5=92=8C=E9=83=A8=E5=88=86=E4=BB=A3=E7=A0=81=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=EF=BC=9B=E4=B8=BA=E7=94=B3=E8=AF=B7=E8=BD=AF=E8=91=97=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E7=9A=84=E4=BB=A3=E7=A0=81=E6=96=87=E4=BB=B6=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/project_info.md | 162 ++ docs/source_code_p1_35.txt | 1912 +++++++++++++++++++++ docs/source_code_p31_65.txt | 1924 ++++++++++++++++++++++ extract_src.go | 132 ++ internal/web/ui.go | 20 + internal/web/ui/assets/style.css | 4 +- internal/web/ui/templates/alarms.html | 2 +- internal/web/ui/templates/dashboard.html | 2 +- internal/web/ui/templates/layout.html | 6 +- 9 files changed, 4160 insertions(+), 4 deletions(-) create mode 100644 docs/project_info.md create mode 100644 docs/source_code_p1_35.txt create mode 100644 docs/source_code_p31_65.txt create mode 100644 extract_src.go diff --git a/docs/project_info.md b/docs/project_info.md new file mode 100644 index 0000000..e3767d1 --- /dev/null +++ b/docs/project_info.md @@ -0,0 +1,162 @@ +# 工业安全生产视觉监测平台——项目信息 + +## 摘要 + +本平台由**管理后台**和**边缘计算节点**两部分组成。管理后台基于 **Go 1.23** 开发,部署于 Windows 环境,采用 SQLite 嵌入式数据库,前端使用 Tabler CSS 框架及服务端模板渲染。边缘节点运行于 **RK3588 ARM Linux**,核心分析引擎用 **C++17** 实现,通过 NPU/VPU/RGA 硬件加速进行实时视频 AI 推理;设备管理代理用 **Go 1.21** 开发,负责与管理后台通信。平台总代码量 **109,924 行**,覆盖设备管理、AI 模型部署、人脸识别、劳保用品合规检测、告警联动等完整功能链路。 + +--- + +## 一、管理后台(managerd) + +### 1. 软件开发环境/开发工具 + +| 类别 | 详情 | +|------|------| +| 编程语言 | Go 1.23.3 | +| IDE/编辑器 | VS Code / Cursor | +| 构建工具 | Go build + PowerShell 脚本(scripts/managerd.ps1) | +| 版本控制 | Git | +| 开发操作系统 | Windows 11 | + +### 2. 软件运行支撑环境/支持软件 + +| 类别 | 详情 | +|------|------| +| 运行时 | Go 编译为单一可执行文件(managerd.exe),无需额外运行时 | +| 操作系统 | Windows(服务端) | +| 数据库 | SQLite(嵌入式数据库,modernc.org/sqlite 纯 Go 驱动) | +| Web 框架 | go-chi/chi v5.2.3(HTTP 路由) | +| 前端框架 | Tabler CSS + 原生 JavaScript(服务端模板渲染) | +| 浏览器 | Chrome / Edge / Firefox 等现代浏览器 | +| 依赖库 | chi(路由)、cors(跨域)、uuid(标识生成) | + +### 3. 编程语言 + +**Go**(版本 1.23.3) + +### 4. 代码行数 + +| 类型 | 文件数 | 总行数 | +|------|--------|--------| +| Go 源码 | 59 个 | 23,500 行 | +| 前端模板(HTML/CSS) | 62 个 | 6,675 行 | +| JSON 配置文件 | 19 个 | — | +| 核心文件 `internal/web/ui.go` | 1 个 | 4,861 行 | + +### 5. 软件架构 + +``` +cmd/managerd/main.go —— 组装入口,手动依赖注入 +internal/api/ —— HTTP API 处理器(chi router) +internal/web/ —— Web UI 处理器 + 嵌入式前端模板 +internal/service/ —— 业务逻辑层 +internal/storage/ —— SQLite 数据持久层 +internal/config/ —— JSON 配置文件加载 +internal/models/ —— 核心领域模型 +templates/ —— 本地 JSON 模板文件 +``` + +### 6. 主要模块 + +| 模块 | 说明 | +|------|------| +| 仪表盘 | 设备统计、在线率、告警概览、异常设备、最近任务 | +| 管控台 | 设备能力矩阵、视频源分配、通道管理 | +| 设备管理 | 局域网设备发现、注册、在线监控、批量操作 | +| 设备控制 | 配置预览、应用下发、服务控制、模型上传 | +| 告警中心 | 告警采集、分页查询、规则中文翻译 | +| 人脸库 | 人员注册、照片管理、人脸库构建与下发 | +| 视频监控 | 多路视频推流代理(HLS/RTSP) | +| 任务中心 | 批量任务创建、执行、状态追踪 | +| 配置中心 | 模板/场景/视频源/第三方服务/调试参数管理 | +| 模型管理 | AI 模型概览与设备同步 | +| 场景管理 | 场景配置编辑、视频通道、通道部署 | +| 系统设置 | 数据备份恢复、服务状态、日志审计 | + +--- + +## 二、边缘计算节点(RK3588) + +### 1. 软件开发环境/开发工具 + +| 类别 | 详情 | +|------|------| +| 编程语言 | C++17(媒体分析引擎)、Go 1.21(设备代理) | +| 构建系统 | CMake 3.20+(C++)、Go build(Agent) | +| 交叉编译 | Ubuntu 22.04 ARM64 工具链,aarch64-linux-gnu-g++ | +| IDE/编辑器 | VS Code | +| 版本控制 | Git | +| 开发/运行平台 | Orange Pi 5 Plus(RK3588)、Ubuntu 22.04 | + +### 2. 软件运行支撑环境/支持软件 + +| 类别 | 详情 | +|------|------| +| 操作系统 | Ubuntu 22.04(ARM64) | +| 硬件平台 | 瑞芯微 RK3588(8 核 CPU + 6 TOPS NPU + VPU + RGA) | +| AI 推理框架 | RKNN Toolkit 2(Rockchip 官方 NPU 推理框架) | +| 硬件加速 | MPP(Media Process Platform,硬编解码)、RGA(图像缩放/颜色转换) | +| 视频协议 | RTSP 输入、RTSP/HLS 输出 | +| 流媒体 | FFmpeg(软件编解码备选) | +| 存储 | MinIO(对象存储,抓拍与录像片段) | +| 编译依赖 | CMake、GCC/G++(ARM64 交叉编译或本地编译) | +| 进程管理 | systemd(media-server.service、rk3588-agent.service) | + +### 3. 编程语言 + +- **C++17**(媒体分析引擎,核心 AI 推理管线) +- **Go 1.21**(设备管理代理 rk3588-agent) +- **Python**(模型训练、数据预处理、模型转换工具) + +### 4. 代码行数 + +| 组件 | 语言 | 文件数 | 行数 | +|------|------|--------|------| +| 媒体分析引擎 | C++17 | 200+ | 61,531 行 | +| 设备管理代理 | Go 1.21 | 41 个 | 7,251 行 | +| 工具脚本 | Python | 51 个 | 10,967 行 | +| **合计** | | **300+** | **79,749 行** | + +### 5. 软件架构 + +``` +agent/ —— Go 设备代理:HTTP API、进程管理、度量采集 + internal/ + httpapi/ —— REST API 服务(端口 9100) + mediaserver/ —— 媒体服务管理(启动/停止/状态) + metrics/ —— CPU/NPU/内存/磁盘指标采集 + modelstore/ —— AI 模型文件管理 + procctl/ —— 进程控制 + tasks/ —— 任务执行 +src/ —— C++ 媒体分析引擎 +plugins/ —— AI 分析插件(人脸检测/识别、人员检测/跟踪、工鞋检测等) + ai_scrfd_sliding/ —— 滑窗人脸检测 + ai_face_recog/ —— 人脸识别 + ai_yolo/ —— YOLO 目标检测 + ai_shoe_det/ —— 劳保鞋检测 + tracker/ —— ByteTrack 多目标跟踪 + logic_gate/ —— 业务规则(人鞋关联、颜色判定) + alarm/ —— 告警上报 + osd/ —— 画面叠加 + publish/ —— 视频推流 +``` + +### 6. AI 分析管线 + +| 管线 | 说明 | +|------|------| +| 人脸识别 | RTSP 输入 → 预处理 → SCRFD 滑窗人脸检测 → MobileFaceNet 人脸识别 → OSD → HLS/RTSP 输出 | +| 劳保鞋检测 | RTSP 输入 → YOLO 人员检测 → ByteTrack 跟踪 → 动态 ROI 鞋检测 → 人鞋关联 → 颜色判定 → 告警 | +| 全功能车间 | 人脸检测 + 人脸识别 + 人员检测 + 跟踪 + 劳保鞋合规 + OSD + 视频发布 + 告警上报 + 抓拍存证 | + +--- + +## 三、系统总览 + +| 项目 | 管理后台 | 边缘节点 | 合计 | +|------|----------|----------|------| +| 语言 | Go | C++17 + Go + Python | 3 种 | +| 代码行数 | 30,175 行 | 79,749 行 | **109,924 行** | +| 文件数 | 140 个 | 300+ 个 | **440+ 个** | +| 运行平台 | Windows | Ubuntu ARM64 | — | +| 数据库 | SQLite | — | SQLite | diff --git a/docs/source_code_p1_35.txt b/docs/source_code_p1_35.txt new file mode 100644 index 0000000..e523769 --- /dev/null +++ b/docs/source_code_p1_35.txt @@ -0,0 +1,1912 @@ +package web + +import ( + "bytes" + "encoding/json" + "fmt" + "html/template" + "io" + "mime" + "mime/multipart" + "io/fs" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "3588AdminBackend/internal/models" + "3588AdminBackend/internal/service" + "3588AdminBackend/internal/storage" + "github.com/go-chi/chi/v5" +) + +type UI struct { + discovery *service.DiscoveryService + registry *service.RegistryService + agent *service.AgentClient + tasks *service.TaskService + templates *service.TemplateService + preview *service.ConfigPreviewService + stateRepo *storage.DeviceConfigStateRepo + auditRepo *storage.AuditLogsRepo + dbPath string + resourcesRepo *storage.ResourcesRepo + alarmCollector *service.AlarmCollector + autoConfig *service.AutoConfigService + + tpl *template.Template +} + +const ( + deviceAssignmentPreviewDevicePrefix = "demo-edge-" + deviceAssignmentPreviewDeviceCount = 8 +) + +const version = "1.0" + +type PageData struct { + Title string + Version string + Year int + ContentHTML template.HTML + Message string + Error string + + DeviceCount int + OnlineCount int + OfflineCount int + FoundCount int + + Devices []*models.Device + DeviceRows []DeviceOverviewRow + AttentionDevices []*models.Device + Found []*models.Device + Device *models.Device + ConfigStatus *ConfigStatusView + ConfigStatusText string + ConfigStatusErr string + ConfigSources service.ConfigPreviewSources + ConfigPreview *service.ConfigPreviewResult + ResultTitle string + SelectedTemplate string + SelectedProfile string + SelectedRecognitionUnit string + SelectedAssignmentDevice string + SelectedOverlays []string + SelectedConfigID string + SelectedVersion string + Tasks []models.Task + Task *models.Task + TaskDeviceRows []TaskDeviceRow + StandardModels []storage.StandardModelRecord + ModelStatusBoard *service.ModelStatusBoard + StandardResources []storage.StandardResourceRecord + ResourceStatusBoard *service.ResourceStatusBoard + AlarmRecords []service.AlarmRecord + TotalAlarmCount int + TotalAlarmPages int + PageNumbers []int + CurrentPage int + PerPage int + SelectedFrom string + SelectedTo string + WeekStart string + WeekEnd string + MonthStart string + MonthEnd string + TodayAlarmCount int + DeviceMetrics []DeviceMetric + ConsoleDevices []ConsoleDeviceData + ConsoleVideoSources []service.ConfigVideoSourceAsset + ConsoleAllVideoSources []service.ConfigVideoSourceAsset + ConsoleUnassignedUnits []service.RecognitionUnitAsset + FaceGalleryPersons []storage.PersonRecord + Templates []service.Template + Template *service.Template + AssetTab string + AssetTemplates []service.ConfigTemplateAsset + AssetTemplate *service.ConfigTemplateAsset + AssetTemplateEditing bool + AssetProfiles []service.ConfigProfileAsset + AssetProfile *service.ConfigProfileAsset + AssetProfileEditor *service.ConfigProfileEditor + AssetProfileEditing bool + AssetProfileFormAction string + ActiveInstanceIndex int + RecognitionUnits []service.RecognitionUnitAsset + RecognitionUnit *service.RecognitionUnitAsset + RecognitionUnitEditing bool + DeviceAssignments []service.DeviceAssignmentAsset + DeviceAssignment *service.DeviceAssignmentAsset + DeviceAssignmentEditing bool + DeviceAssignmentBoard *service.DeviceAssignmentBoard + DeviceAssignmentBoardJSON template.JS + MaxUnitsPerDevice int + AssetTemplateMap map[string]service.ConfigTemplateAsset + AssetVideoSources []service.ConfigVideoSourceAsset + AssetVideoSource *service.ConfigVideoSourceAsset + AssetVideoSourceEditing bool + AssetIntegrations []service.ConfigIntegrationServiceAsset + AssetIntegration *service.ConfigIntegrationServiceAsset + AssetIntegrationEditing bool + AssetOverlays []service.ConfigOverlayAsset + AssetOverlay *service.ConfigOverlayAsset + AssetOverlayEditing bool + AssetInstanceCount int + SelectedDeviceIDs []string + SelectedDevices []*models.Device + SelectedQuery string + SelectedDevicesURL string + BatchConfigURL string + ReloadSummary string + RollbackSummary string + TemplateDraftName string + TemplateDraftDescription string + TemplateCloneSource string + TemplateCloneName string + TemplateCloneDesc string + TemplateCreateMode string + OverlayDraftJSON string + IntegrationConfigDraft string + AuditEntries []storage.AuditLogRecord + PersistedConfig *storage.DeviceConfigStateRecord + DBPath string + + RawJSON string + RawText string + SchemaJSON string + StateJSON string + FaceGalleryJSON string + TaskID string + DeviceIDs string + RunningTaskCount int + FailedTaskCount int + SuccessTaskCount int +} + +type DeviceOverviewRow struct { + Device *models.Device + ConfigStatus *ConfigStatusView + ConfigStatusErr string +} + +type TaskDeviceRow struct { + Device *models.Device + Status models.TaskStatus + Progress float64 + Error string +} + +type ConfigStatusView struct { + OK bool `json:"ok"` + ConfigPath string `json:"config_path"` + Exists bool `json:"exists"` + Sha256 string `json:"sha256"` + Size int64 `json:"size"` + Metadata ConfigStatusMetadata `json:"metadata"` + Candidate *ConfigStatusLastGoodFile `json:"candidate"` + MediaServer ConfigStatusMediaServer `json:"media_server"` + PreviousConfig *ConfigStatusLastGoodFile `json:"previous_config"` + PreviousConfigPath string `json:"previous_config_path"` +} + +type ConfigStatusMetadata struct { + ConfigID string `json:"config_id"` + ConfigVersion string `json:"config_version"` + BusinessName string `json:"business_name"` + Template string `json:"template"` + Profile string `json:"profile"` + Overlays []string `json:"overlays"` + RenderedAt string `json:"rendered_at"` + RenderedBy string `json:"rendered_by"` + InstanceName string `json:"instance_name"` + InstanceDisplayName string `json:"instance_display_name"` +} + +type ConfigStatusMediaServer struct { + Running bool `json:"running"` + PID int `json:"pid"` + Version string `json:"version"` +} + +type ConfigStatusLastGoodFile struct { + Path string `json:"path"` + Exists bool `json:"exists"` + Sha256 string `json:"sha256"` + Metadata ConfigStatusMetadata `json:"metadata"` +} + +func NewUI(discovery *service.DiscoveryService, registry *service.RegistryService, agent *service.AgentClient, tasks *service.TaskService, templates *service.TemplateService, preview ...*service.ConfigPreviewService) (*UI, error) { + tpl, err := template.New("layout").Funcs(template.FuncMap{ + "json": func(v any) string { + b, _ := json.MarshalIndent(v, "", " ") + return string(b) + }, + "rawHTML": func(v string) template.HTML { + return template.HTML(v) + }, + "hasString": func(items []string, want string) bool { + for _, item := range items { + if item == want { + return true + } + } + return false + }, + "shortHash": func(v string) string { + v = strings.TrimSpace(v) + if len(v) > 8 { + return v[:8] + } + return v + }, + "modelTypeLabel": func(v string) string { + switch strings.TrimSpace(v) { + case "face_detection": + return "人脸检测" + case "face_recognition": + return "人脸识别" + case "object_detection": + return "通用检测" + case "ppe_detection": + return "PPE检测" + case "shoe_detection": + return "工鞋检测" + case "other": + return "其他" + default: + return "-" + } + }, + "resourceTypeLabel": func(v string) string { + switch strings.TrimSpace(v) { + case "face_gallery": + return "人脸库" + case "dataset": + return "数据集" + case "calibration": + return "标定文件" + default: + return v + } + }, + "ruleLabel": func(v string) string { + switch strings.TrimSpace(v) { + case "unknown_face": + return "陌生人员告警" + case "known_person": + return "已登记人员" + case "non_compliant_workshoe": + return "未穿劳保鞋" + case "non_black_shoe": + return "工鞋不合规" + default: + return v + } + }, + "displayDeviceName": func(dev *models.Device, status *ConfigStatusView) string { + if dev == nil { + return "-" + } + return dev.DisplayName() + }, + "displayDeviceTechnicalName": func(dev *models.Device) string { + if dev == nil { + return "" + } + if v := strings.TrimSpace(dev.TechnicalName()); v != "" { + return v + } + return "" + }, + "taskGroupLabel": func(v any) string { + switch fmt.Sprint(v) { + case "config_apply": + return "批量配置" + case "media_start", "media_restart", "media_stop": + return "批量服务" + case "reload", "rollback": + return "设备操作" + case "resource_sync_one", "resource_sync_all": + return "资源同步" + default: + return "其他任务" + } + }, + "taskActionLabel": func(v any) string { + switch fmt.Sprint(v) { + case "config_apply": + return "下发设备分配" + case "model_sync_one": + return "更新单个模型" + case "model_sync_all": + return "更新全部模型" + case "resource_sync_one": + return "同步单个资源" + case "resource_sync_all": + return "同步全部资源" + case "reload": + return "重载识别服务" + case "rollback": + return "回滚识别配置" + case "media_start": + return "启动视频分析服务" + case "media_restart": + return "重启视频分析服务" + case "media_stop": + return "停止视频分析服务" + default: + return fmt.Sprint(v) + } + }, + "taskGroupClass": func(v any) string { + switch fmt.Sprint(v) { + case "config_apply": + return "pill run" + case "model_sync_one", "model_sync_all": + return "pill warn" + case "resource_sync_one", "resource_sync_all": + return "pill warn" + case "media_start", "media_restart", "media_stop": + return "pill ok" + case "reload", "rollback": + return "pill warn" + default: + return "pill" + } + }, + "taskStatusLabel": func(v any) string { + switch fmt.Sprint(v) { + case "success": + return "成功" + case "failed": + return "失败" + case "running": + return "执行中" + default: + return "待执行" + } + }, + "taskStatusClass": func(v any) string { + switch fmt.Sprint(v) { + case "success": + return "pill ok" + case "failed": + return "pill bad" + case "running": + return "pill run" + default: + return "pill" + } + }, + "auditField": func(details string, key string) string { + var m map[string]any + if err := json.Unmarshal([]byte(details), &m); err != nil { + return "" + } + if v, ok := m[key].(string); ok { + return strings.TrimSpace(v) + } + return "" + }, + "auditActionLabel": func(v string) string { + switch strings.TrimSpace(v) { + case "config_apply": + return "下发设备分配" + case "reload": + return "重载运行配置" + case "rollback": + return "回滚运行配置" + case "media_start": + return "启动服务" + case "media_restart": + return "重启服务" + case "media_stop": + return "停止服务" + default: + return strings.TrimSpace(v) + } + }, + "auditStatusLabel": func(v string) string { + switch strings.TrimSpace(v) { + case "success": + return "成功" + case "failed": + return "失败" + case "running": + return "执行中" + case "pending": + return "待执行" + default: + return strings.TrimSpace(v) + } + }, + "ago": func(ms int64) string { + if ms <= 0 { + return "-" + } + d := time.Since(time.UnixMilli(ms)) + if d < 0 { + d = 0 + } + s := int64(d.Seconds()) + switch { + case s < 60: + return fmt.Sprintf("%d秒前", s) + case s < 3600: + return fmt.Sprintf("%d分钟前", s/60) + case s < 86400: + return fmt.Sprintf("%d小时前", s/3600) + default: + return fmt.Sprintf("%d天前", s/86400) + } + }, + "icon": func(name string) template.HTML { + return template.HTML(tablerIconSVG(name)) + }, + "slotTypeLabel": func(v string) string { + switch strings.TrimSpace(v) { + case "video_source": + return "视频源" + case "object_storage": + return "对象存储" + case "alarm_service": + return "告警服务" + case "stream_publish": + return "视频输出" + default: + return strings.TrimSpace(v) + } + }, + "inputBindingRef": func(bindings map[string]service.InputBindingEditor, slot string) string { + if len(bindings) == 0 { + return "" + } + return strings.TrimSpace(bindings[slot].VideoSourceRef) + }, + "serviceBindingRef": func(bindings map[string]service.ServiceBindingEditor, slot string) string { + if len(bindings) == 0 { + return "" + } + return strings.TrimSpace(bindings[slot].ServiceRef) + }, + "outputBindingValue": func(bindings map[string]service.OutputBindingEditor, slot string, field string) string { + if len(bindings) == 0 { + return "" + } + item, ok := bindings[slot] + if !ok { + return "" + } + switch strings.TrimSpace(field) { + case "publish_hls_path": + return strings.TrimSpace(item.PublishHLSPath) + case "publish_rtsp_port": + return strings.TrimSpace(item.PublishRTSPPort) + case "publish_rtsp_path": + return strings.TrimSpace(item.PublishRTSPPath) + case "channel_no": + return strings.TrimSpace(item.ChannelNo) + default: + return "" + } + }, + "sub": func(a, b int) int { + return a - b + }, + "loop": func(count int) []int { + var r []int + for i := 0; i < count; i++ { + r = append(r, i) + } + return r + }, + "mul": func(a, b float64) float64 { + return a * b + }, + "div": func(a, b float64) float64 { + if b == 0 { + return 0 + } + return a / b + }, + "shortID": func(v string) string { + if len(v) > 8 { + return v[:8] + } + return v + }, + "add": func(a, b int) int { + return a + b + }, + }).ParseFS(uiFS, "ui/templates/*.html") + if err != nil { + return nil, err + } + + previewSvc := service.NewConfigPreviewService(nil) + if len(preview) > 0 && preview[0] != nil { + previewSvc = preview[0] + } + return &UI{ + discovery: discovery, + registry: registry, + agent: agent, + tasks: tasks, + templates: templates, + preview: previewSvc, + tpl: tpl, + }, nil +} + +func (u *UI) SetStateRepo(repo *storage.DeviceConfigStateRepo) { + if u == nil { + return + } + u.stateRepo = repo +} + +func (u *UI) SetAuditRepo(repo *storage.AuditLogsRepo) { + if u == nil { + return + } + u.auditRepo = repo +} + +func (u *UI) SetDBPath(path string) { + if u == nil { + return + } + u.dbPath = strings.TrimSpace(path) +} + +func (u *UI) SetResourcesRepo(repo *storage.ResourcesRepo) { + if u == nil { + return + } + u.resourcesRepo = repo +} + +func (u *UI) SetAlarmCollector(ac *service.AlarmCollector) { + if u == nil { + return + } + u.alarmCollector = ac +} + +func (u *UI) SetAutoConfig(ac *service.AutoConfigService) { + if u == nil { + return + } + u.autoConfig = ac +} + +func tablerIconSVG(name string) string { + icons := map[string]string{ + "devices": ``, + "assets": ``, + "audit": ``, + "system": ``, + "theme": ``, + "bell": ``, + "online": ``, + "detail": ``, + "control": ``, + "device": ``, + "status": ``, + "config": ``, + "overview": ``, + "tech": ``, + "preview": ``, + "apply": ``, + "service": ``, + "task": ``, + "result": ``, + "logs": ``, + "meta": ``, + "template": ``, + "edit": ``, + "profile": ``, + "overlay": ``, + "release": ``, + "discovery": ``, + "shield": ``, + "heartbeat": ``, + } + if svg, ok := icons[name]; ok { + return svg + } + return "" +} + +func (u *UI) Routes() (chi.Router, error) { + r := chi.NewRouter() + + assets, err := fs.Sub(uiFS, "ui/assets") + if err != nil { + return nil, err + } + assetHandler := http.StripPrefix("/assets/", http.FileServer(http.FS(assets))) + r.Handle("/assets/*", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + p := req.URL.Path + w.Header().Set("Cache-Control", "no-store, max-age=0") + w.Header().Set("Pragma", "no-cache") + switch { + case strings.HasSuffix(p, ".css"): + w.Header().Set("Content-Type", "text/css; charset=utf-8") + case strings.HasSuffix(p, ".js"): + w.Header().Set("Content-Type", "text/javascript; charset=utf-8") + } + assetHandler.ServeHTTP(w, req) + })) + + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/ui/dashboard", http.StatusFound) + }) + + r.Get("/dashboard", u.pageDashboard) + r.Get("/console", u.pageConsole) + r.Post("/console", u.actionConsoleSave) + r.Get("/devices", u.pageDevices) + r.Get("/devices/{id}/control", u.pageDeviceControl) + r.Get("/plans", u.redirectPlansToSceneTemplates) + r.Get("/plans/{name}", u.redirectPlanToSceneTemplate) + r.Get("/scene-templates", u.pagePlans) + r.Get("/scene-templates/{name}", u.pagePlan) + r.Post("/scene-templates/create", u.actionPlanCreate) + r.Post("/scene-templates/{name}", u.actionPlanSave) + r.Post("/scene-templates/{name}/delete", u.actionPlanDelete) + r.Get("/scene-templates/{name}/export", u.pagePlanExport) + r.Get("/recognition-units", u.pageRecognitionUnits) + r.Post("/recognition-units", u.actionRecognitionUnitSave) + r.Post("/recognition-units/delete", u.actionRecognitionUnitDelete) + r.Get("/device-assignments", u.pageDeviceAssignments) + r.Post("/device-assignments", u.actionDeviceAssignmentSave) + r.Post("/device-assignments/{id}/delete", u.actionDeviceAssignmentDelete) + r.Get("/assets", u.pageAssets) + r.Get("/assets/video-sources", u.pageAssetVideoSources) + r.Post("/assets/video-sources", u.actionAssetVideoSourceSave) + r.Post("/assets/video-sources/{name}/delete", u.actionAssetVideoSourceDelete) + r.Get("/assets/templates", u.pageAssetTemplates) + r.Post("/assets/templates/create", u.actionAssetTemplateCreate) + r.Post("/assets/templates/{name}/clone", u.actionAssetTemplateClone) + r.Get("/assets/templates/{name}", u.pageAssetTemplate) + r.Post("/assets/templates/{name}/rename", u.actionAssetTemplateRename) + r.Post("/assets/templates/{name}/delete", u.actionAssetTemplateDelete) + r.Get("/assets/templates/{name}/graph", u.pageAssetTemplateGraph) + r.Post("/assets/templates/{name}/graph", u.actionAssetTemplateGraphSave) + r.Get("/assets/templates/{name}/export", u.pageAssetTemplateExport) + r.Get("/assets/profiles", u.redirectAssetProfilesToPlans) + r.Get("/assets/profiles/{name}", u.redirectAssetProfileToPlan) + r.Post("/assets/profiles/{name}", u.actionPlanSave) + r.Get("/assets/profiles/{name}/export", u.pagePlanExport) + r.Get("/assets/integrations", u.pageAssetIntegrations) + r.Post("/assets/integrations", u.actionAssetIntegrationSave) + r.Post("/assets/integrations/{name}/delete", u.actionAssetIntegrationDelete) + r.Get("/assets/overlays", u.pageAssetOverlays) + r.Post("/assets/overlays", u.actionAssetOverlaySave) + r.Post("/assets/overlays/{name}/clone", u.actionAssetOverlayClone) + r.Post("/assets/overlays/{name}/delete", u.actionAssetOverlayDelete) + r.Get("/assets/overlays/{name}", u.pageAssetOverlay) + r.Get("/assets/overlays/{name}/export", u.pageAssetOverlayExport) + r.Get("/audit", u.pageAudit) + r.Get("/system", u.pageSystem) + r.Get("/system/db-backup", u.pageSystemDBBackup) + r.Get("/resources", u.pageResources) + r.Post("/system/db-restore", u.actionSystemDBRestore) + r.Get("/api/graph-node-types", u.apiGraphNodeTypes) + r.Get("/device-config", u.pageDeviceConfig) + r.Get("/device-config/{id}", u.pageDeviceConfigDetail) + r.Get("/devices-add", u.pageDeviceAdd) + r.Post("/devices-add", u.actionDeviceAdd) + r.Post("/devices/batch-action", u.actionDevicesBatchAction) + r.Get("/devices/batch-config", u.pageDeviceBatchConfig) + r.Post("/devices/batch-config", u.actionDeviceBatchConfig) + r.Post("/discovery/search", u.actionDiscoverySearch) + r.Get("/devices/{id}", u.pageDevice) + r.Post("/devices/{id}/alias", u.actionDeviceAliasSave) + r.Post("/devices/{id}/action", u.actionDeviceAction) + r.Get("/devices/{id}/logs", u.pageDeviceLogs) + r.Get("/devices/{id}/graphs", u.pageDeviceGraphs) + r.Post("/devices/{id}/config/apply", u.actionDeviceConfigApply) + r.Post("/devices/{id}/plan-apply", u.actionDevicePlanApply) + r.Get("/devices/{id}/config-ui", u.pageDeviceConfigUI) + r.Get("/devices/{id}/config-friendly", u.pageDeviceConfigFriendly) + r.Get("/devices/{id}/config-preview", u.pageDeviceConfigPreview) + r.Post("/devices/{id}/config-preview", u.actionDeviceConfigPreview) + r.Post("/devices/{id}/config-candidate", u.actionDeviceConfigCandidate) + r.Post("/devices/{id}/config-candidate/apply", u.actionDeviceConfigCandidateApply) + r.Post("/devices/{id}/config-ui/plan", u.actionDeviceConfigUIPlan) + r.Post("/devices/{id}/config-ui/apply", u.actionDeviceConfigUIApply) + r.Post("/devices/{id}/face-gallery/upload", u.actionDeviceFaceGalleryUpload) + r.Post("/devices/{id}/face-gallery/reload", u.actionDeviceFaceGalleryReload) + r.Post("/devices/{id}/models/upload", u.actionDeviceModelUpload) + r.Post("/devices/{id}/media-server/configs/upload", u.actionDeviceMediaServerConfigUpload) + r.Post("/devices/{id}/media-server/configs/upload-batch", u.actionDeviceMediaServerConfigUploadBatch) + + r.Get("/tasks", u.pageTasks) + r.Post("/tasks", u.actionCreateTask) + r.Get("/tasks/{id}", u.pageTask) + + r.Get("/templates", u.pageTemplates) + r.Get("/templates/{name}", u.pageTemplate) + r.Get("/models", u.pageModels) + r.Post("/models/sync", u.actionModelSync) + r.Post("/resources/sync", u.actionResourceSync) + r.Get("/diagnostics", u.pageDiagnostics) + r.Get("/alarms", u.pageAlarms) + r.Get("/face-gallery", u.pageFaceGallery) + r.Get("/face-photo/*", u.serveFacePhoto) + r.Post("/face-gallery/import", u.actionFaceGalleryImport) + r.Post("/face-gallery/build", u.actionFaceGalleryBuild) + r.Post("/face-gallery/add", u.actionFaceGalleryAdd) + r.Post("/face-gallery/delete", u.actionFaceGalleryDelete) + r.Post("/face-gallery/rename", u.actionFaceGalleryRename) + r.Post("/face-gallery/add-photo", u.actionFaceGalleryAddPhoto) + r.Post("/face-gallery/delete-photo", u.actionFaceGalleryDeletePhoto) + r.Get("/monitor", u.pageMonitor) + r.Get("/hls/*", u.proxyHLS) + r.Get("/api/monitor/channels", u.apiMonitorChannels) + r.Get("/api/device-metrics", u.apiDeviceMetrics) + r.Get("/recognition", u.pageRecognition) + r.Get("/logs", u.pageLogs) + + r.Get("/api", u.pageAPIConsole) + + return r, nil +} + +func (u *UI) render(w http.ResponseWriter, r *http.Request, content string, data PageData) { + data.Version = version + data.Year = time.Now().Year() + var buf bytes.Buffer + if err := u.tpl.ExecuteTemplate(&buf, content, data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + data.ContentHTML = template.HTML(buf.String()) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store, max-age=0") + w.Header().Set("Pragma", "no-cache") + if err := u.tpl.ExecuteTemplate(w, "layout", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func (u *UI) findDevice(id string) (*models.Device, bool) { + u.ensureDevicesLoaded() + devices := u.registry.GetDevices() + for _, d := range devices { + if d.DeviceID == id { + return d, true + } + } + if u.discovery != nil { + _, _ = u.discovery.SearchDefault() + devices = u.registry.GetDevices() + for _, d := range devices { + if d.DeviceID == id { + return d, true + } + } + } + return nil, false +} + +func (u *UI) ensureDevicesLoaded() { + if u.registry == nil || u.discovery == nil { + return + } + devices := u.registry.GetDevices() + if len(devices) > 0 { + // Check if any device is online; if not, try discovery to refresh + hasOnline := false + for _, d := range devices { + if d.Online { + hasOnline = true + break + } + } + if hasOnline { + return + } + } + _, _ = u.discovery.SearchDefault() + if len(u.registry.GetDevices()) == 0 { + _, _ = u.discovery.SearchDefault() + } +} + +func (u *UI) pageDashboard(w http.ResponseWriter, r *http.Request) { + data := u.deviceOverviewPageData(r, nil, "") + if u.tasks != nil { + for _, task := range u.tasks.ListTasks() { + switch task.Status { + case models.TaskRunning: + data.RunningTaskCount++ + case models.TaskFailed: + data.FailedTaskCount++ + case models.TaskSuccess: + data.SuccessTaskCount++ + } + } + } + data.Title = "总览" + if u.alarmCollector != nil { + data.AlarmRecords = u.alarmCollector.GetRecent(5) + all := u.alarmCollector.GetRecent(9999) + today := time.Now().Format("2006-01-02") + for _, a := range all { + if strings.HasPrefix(a.Timestamp, today) { + data.TodayAlarmCount++ + } + } + } + data.Tasks = nil + if u.tasks != nil { + data.Tasks = u.tasks.ListTasks() + } + data.AttentionDevices = nil + // Load device metrics + if u.agent != nil { + for _, dev := range data.Devices { + if dev == nil || !dev.Online { continue } + body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/metrics", nil) + if err != nil || code != 200 { continue } + var m struct { + CPU struct{ UsagePct float64 `json:"usage_pct"` } `json:"cpu"` + Memory struct{ TotalKB uint64 `json:"total_kb"`; AvailableKB uint64 `json:"available_kb"` } `json:"memory"` + NPU struct { + UsagePct float64 `json:"usage_pct"` + Cores map[string]float64 `json:"cores"` + } `json:"npu"` + Disk struct{ UsedPct float64 `json:"used_pct"` } `json:"disk"` + Temperature struct{ Celsius float64 `json:"celsius"` } `json:"temperature"` + } + json.Unmarshal(body, &m) + var mem float64 + if m.Memory.TotalKB > 0 { mem = float64(m.Memory.TotalKB-m.Memory.AvailableKB) / float64(m.Memory.TotalKB) * 100 } + npuVal := m.NPU.UsagePct + if len(m.NPU.Cores) > 0 { + for _, v := range m.NPU.Cores { + if v > npuVal { + npuVal = v + } + } + } + data.DeviceMetrics = append(data.DeviceMetrics, DeviceMetric{ + Name: dev.DisplayName(), CPU: m.CPU.UsagePct, Mem: mem, NPU: npuVal, Disk: m.Disk.UsedPct, Temperature: m.Temperature.Celsius, + }) + } + } + for _, dev := range data.Devices { + if dev != nil && !dev.Online { + data.AttentionDevices = append(data.AttentionDevices, dev) + } + } + u.render(w, r, "dashboard", data) +} + +func (u *UI) pageConsole(w http.ResponseWriter, r *http.Request) { + u.ensureDevicesLoaded() + data := PageData{Title: "管控台"} + data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) + devices := u.registry.GetDevices() + data.Devices = devices + + // Load video sources + var allSources []service.ConfigVideoSourceAsset + if u.preview != nil { + if items, err := u.preview.ListVideoSources(); err == nil { + allSources = items + } + } + + // Load recognition units and device assignments + var units []service.RecognitionUnitAsset + var assignments []service.DeviceAssignmentAsset + if u.preview != nil { + if items, err := u.preview.ListRecognitionUnits(); err == nil { + units = items + } + if items, err := u.preview.ListDeviceAssignments(); err == nil { + assignments = items + } + } + + // Build device->unit mapping + assignedUnits := map[string][]service.RecognitionUnitAsset{} + for _, a := range assignments { + for _, ref := range a.RecognitionUnits { + for _, u := range units { + if u.Ref == ref { + assignedUnits[a.DeviceID] = append(assignedUnits[a.DeviceID], u) + break + } + } + } + } + + // Track used video sources + usedSources := map[string]bool{} + for _, unitList := range assignedUnits { + for _, u := range unitList { + if u.VideoSourceRef != "" { + usedSources[u.VideoSourceRef] = true + } + } + } + + // Build source name lookup + sourceNames := map[string]string{} + for _, s := range allSources { + sourceNames[s.Name] = s.Name + } + + // Query each device's capabilities from agent to filter available features. + deviceAvailableFeatures := map[string][]ConsoleFeature{} + if u.agent != nil { + for _, dev := range devices { + if dev == nil || !dev.Online || dev.DeviceID == "" { + continue + } + body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/capabilities", nil) + if err != nil || code != 200 { + continue + } + var capsResp struct { + Capabilities []struct { + Key string `json:"key"` + Label string `json:"label"` + Available bool `json:"available"` + Description string `json:"description"` + } `json:"capabilities"` + } + if json.Unmarshal(body, &capsResp) != nil { + continue + } + features := make([]ConsoleFeature, 0, len(capsResp.Capabilities)) + for _, c := range capsResp.Capabilities { + features = append(features, ConsoleFeature{ + Key: c.Key, Label: c.Label, Description: c.Description, + Available: c.Available, Enabled: false, + }) + } + deviceAvailableFeatures[dev.DeviceID] = features + } + } + + // Load persisted device features as the source of truth. + // This represents what capabilities the device is configured for, + // not just what the last deployment used. + persistedFeatures := map[string]map[string]bool{} + if u.preview != nil { + for _, dev := range devices { + if dev == nil || dev.DeviceID == "" { + continue + } + if feats, err := u.preview.GetDeviceFeatures(dev.DeviceID); err == nil && len(feats) > 0 { + m := map[string]bool{} + for _, f := range feats { + m[f] = true + } + persistedFeatures[dev.DeviceID] = m + } + } + } + + // Load device metrics (NPU, etc) + metrics := map[string]DeviceMetric{} + if u.agent != nil { + for _, dev := range devices { + if dev == nil || !dev.Online { + continue + } + body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/metrics", nil) + if err != nil || code != 200 { + continue + } + var m struct { + CPU struct{ UsagePct float64 `json:"usage_pct"` } `json:"cpu"` + Memory struct { + TotalKB float64 `json:"total_kb"` + AvailableKB float64 `json:"available_kb"` + } `json:"memory"` + NPU struct { + UsagePct float64 `json:"usage_pct"` + Cores map[string]float64 `json:"cores"` + } `json:"npu"` + Disk struct{ UsedPct float64 `json:"used_pct"` } `json:"disk"` + Temperature struct{ Celsius float64 `json:"celsius"` } `json:"temperature"` + } + json.Unmarshal(body, &m) + npuVal := m.NPU.UsagePct + // Pick max core if per-core data available + if len(m.NPU.Cores) > 0 { + for _, v := range m.NPU.Cores { + if v > npuVal { + npuVal = v + } + } + } + memPct := 0.0 + if m.Memory.TotalKB > 0 { + memPct = (m.Memory.TotalKB - m.Memory.AvailableKB) / m.Memory.TotalKB * 100 + } + metrics[dev.DeviceID] = DeviceMetric{ + Name: dev.DisplayName(), CPU: m.CPU.UsagePct, Mem: memPct, NPU: npuVal, Disk: m.Disk.UsedPct, Temperature: m.Temperature.Celsius, + } + } + } + + // Build console device data + var consoleDevices []ConsoleDeviceData + for _, dev := range devices { + if dev == nil { + continue + } + maxUnits := 4 + + // Gather channels (one per assigned recognition unit) + var channels []ConsoleChannel + var cameras []string + for _, u := range assignedUnits[dev.DeviceID] { + // HLS channel name matches recognition unit Name (used in publish path) + chName := u.Name + displayName := u.Name + if u.DisplayName != "" { + displayName = u.DisplayName + } + srcName := u.VideoSourceRef + if srcName == "" { + srcName = displayName + } + channels = append(channels, ConsoleChannel{Name: chName, Display: displayName, SourceName: srcName}) + if u.VideoSourceRef != "" { + cameras = append(cameras, u.VideoSourceRef) + } + } + + // Determine which features are enabled from persisted device features. + activeKeys := persistedFeatures[dev.DeviceID] + if activeKeys == nil { + activeKeys = map[string]bool{} + } + + // Features from device capabilities (from agent /v1/capabilities). + // Overlay persisted DB state for checkmarks and conflict detection. + available := deviceAvailableFeatures[dev.DeviceID] + var features []ConsoleFeature + if len(available) > 0 { + for _, f := range available { + f.Enabled = activeKeys[f.Key] + // Conflict: user enabled a feature that the device no longer supports. + if f.Enabled && !f.Available { + f.Conflict = true + } + features = append(features, f) + } + // Show persisted features that the agent didn't report (e.g. new features + // added to registry but old agent doesn't know them). + seen := map[string]bool{} + for _, f := range features { + seen[f.Key] = true + } + for key := range activeKeys { + if !seen[key] { + features = append(features, ConsoleFeature{ + Key: key, Label: key, Enabled: true, Available: false, Conflict: true, + }) + } + } + } else { + // Old agent: show persisted keys. + for key := range activeKeys { + features = append(features, ConsoleFeature{Key: key, Label: key, Enabled: true, Available: true}) + } + } + + npu, diskUsage, temp, cpuUsage, memUsage := 0.0, 0.0, 0.0, 0.0, 0.0 + if m, ok := metrics[dev.DeviceID]; ok { + cpuUsage = m.CPU + memUsage = m.Mem + npu = m.NPU + diskUsage = m.Disk + temp = m.Temperature + } + consoleDevices = append(consoleDevices, ConsoleDeviceData{ + Device: dev, + CPUUsage: cpuUsage, + MemUsage: memUsage, + NPUUsage: npu, + DiskUsage: diskUsage, + Temperature: temp, + MaxUnits: maxUnits, + Channels: channels, + Features: features, + Cameras: cameras, + }) + } + + // Unassigned video sources + var unassigned []service.ConfigVideoSourceAsset + for _, s := range allSources { + if !usedSources[s.Name] { + unassigned = append(unassigned, s) + } + } + + data.ConsoleDevices = consoleDevices + data.ConsoleVideoSources = unassigned + data.ConsoleAllVideoSources = allSources + + u.render(w, r, "console", data) +} + +func (u *UI) actionConsoleSave(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if u.autoConfig == nil || u.preview == nil { + http.Redirect(w, r, "/ui/console?error="+url.QueryEscape("自动配置服务未初始化"), http.StatusFound) + return + } + + u.ensureDevicesLoaded() + devices := u.registry.GetDevices() + + // Step 1: collect form features per device. + formFeatures := map[string][]string{} + for _, dev := range devices { + if dev == nil || dev.DeviceID == "" { + continue + } + features := cleanFormList(r.Form["device_"+dev.DeviceID+"_feature"]) + if len(features) > 0 { + formFeatures[dev.DeviceID] = features + } + } + + // Step 2: build requests using form features (user intent) and + // existing recognition unit video sources. + var requests []service.AutoConfigRequest + for _, dev := range devices { + if dev == nil || dev.DeviceID == "" { + continue + } + features := formFeatures[dev.DeviceID] + var sources []string + if assignment, err := u.preview.GetDeviceAssignment(dev.DeviceID); err == nil && assignment != nil { + for _, ref := range assignment.RecognitionUnits { + if unit, err := u.preview.GetRecognitionUnit(ref); err == nil && unit != nil { + if unit.VideoSourceRef != "" { + sources = append(sources, unit.VideoSourceRef) + } + } + } + } + if len(features) == 0 { + continue + } + requests = append(requests, service.AutoConfigRequest{ + DeviceID: dev.DeviceID, + Features: features, + SourceNames: sources, + }) + } + + if len(requests) == 0 { + http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape("未选择检测功能"), http.StatusFound) + return + } + + // Step 3: save features to DB BEFORE creating tasks (avoids SQLITE_BUSY race). + for _, req := range requests { + _ = u.preview.SaveDeviceFeatures(req.DeviceID, req.Features) + } + + // Step 4: run pipeline (creates tasks asynchronously). + results, _ := u.autoConfig.BuildPipelineBatch(requests, true) + + // Collect task IDs for redirect. + taskIDs := make([]string, 0) + var errs []string + for _, res := range results { + if res.TaskID != "" { + taskIDs = append(taskIDs, res.TaskID) + } + if res.Error != "" { + errs = append(errs, res.DeviceID+": "+res.Error) + } + } + + if len(taskIDs) == 1 { + http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape("正在下发配置...")+"&task="+taskIDs[0], http.StatusFound) + return + } + if len(taskIDs) > 1 { + http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape(fmt.Sprintf("正在为 %d 台设备下发配置...", len(taskIDs))), http.StatusFound) + return + } + if len(errs) > 0 { + http.Redirect(w, r, "/ui/console?error="+url.QueryEscape(strings.Join(errs, "; ")), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape("配置已保存"), http.StatusFound) +} + +func (u *UI) pageDevices(w http.ResponseWriter, r *http.Request) { + u.render(w, r, "devices", u.deviceOverviewPageData(r, nil, "")) +} + +func (u *UI) pageDeviceAdd(w http.ResponseWriter, r *http.Request) { + u.render(w, r, "device_add", PageData{Title: "新增设备"}) +} + +func (u *UI) pageDeviceConfig(w http.ResponseWriter, r *http.Request) { + u.ensureDevicesLoaded() + u.render(w, r, "device_config", PageData{ + Title: "设备配置入口", + Devices: u.registry.GetDevices(), + }) +} + +func (u *UI) pageDeviceConfigDetail(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + http.Redirect(w, r, "/ui/devices/"+url.PathEscape(id)+"#device-config", http.StatusFound) +} + +func (u *UI) actionDeviceAdd(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + deviceID := strings.TrimSpace(r.FormValue("device_id")) + deviceName := strings.TrimSpace(r.FormValue("device_name")) + ip := strings.TrimSpace(r.FormValue("ip")) + agentPort, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("agent_port"))) + mediaPort, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("media_port"))) + + if deviceID == "" || ip == "" { + u.render(w, r, "device_add", PageData{Title: "新增设备", Error: "节点标识和 IP 不能为空"}) + return + } + if agentPort == 0 { + agentPort = 9100 + } + if mediaPort == 0 { + mediaPort = 9000 + } + + dev := &models.Device{ + DeviceID: deviceID, + DeviceName: deviceName, + IP: ip, + AgentPort: agentPort, + MediaPort: mediaPort, + Online: true, + LastSeenMs: time.Now().UnixMilli(), + } + u.registry.UpdateDevice(dev) + http.Redirect(w, r, "/ui/devices", http.StatusFound) +} + +func (u *UI) actionDiscoverySearch(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + timeoutMs, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("timeout_ms"))) + if timeoutMs <= 0 { + timeoutMs = 1200 + } + + found, err := u.discovery.Search(timeoutMs) + devices := u.registry.GetDevices() + online := 0 + for _, d := range devices { + if d.Online { + online++ + } + } + data := PageData{Title: "设备", Devices: devices, Found: found, FoundCount: len(found), DeviceCount: len(devices), OnlineCount: online, OfflineCount: len(devices) - online} + if err != nil { + data.Error = err.Error() + } + u.render(w, r, "devices", data) +} + +func (u *UI) actionDevicesBatchAction(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + action := strings.TrimSpace(r.FormValue("action")) + deviceIDs := filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"]) + if len(deviceIDs) == 0 { + u.render(w, r, "devices", u.deviceOverviewPageData(r, nil, "请先选择设备")) + return + } + + typeStr := "" + switch action { + case "media_start", "media_restart", "media_stop", "reload", "rollback": + typeStr = action + default: + u.render(w, r, "devices", u.deviceOverviewPageData(r, deviceIDs, "不支持的操作: "+action)) + return + } + + if u.tasks == nil { + http.Error(w, "task service not initialized", http.StatusInternalServerError) + return + } + + var payload any + if typeStr == "media_start" || typeStr == "media_restart" { + cfgName := strings.TrimSpace(r.FormValue("config")) + if cfgName != "" { + payload = map[string]any{"config": cfgName} + } + } + + task, err := u.tasks.CreateTask(typeStr, deviceIDs, payload) + if err != nil { + u.render(w, r, "devices", u.deviceOverviewPageData(r, deviceIDs, err.Error())) + return + } + + http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound) +} + +func (u *UI) pageDeviceBatchConfig(w http.ResponseWriter, r *http.Request) { + data := u.deviceBatchConfigPageData(r, selectedIDsFromQuery(r.URL.Query()["selected"])) + u.render(w, r, "device_batch_config", data) +} + +func (u *UI) actionDeviceBatchConfig(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + selectedIDs := filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"]) + data := u.deviceBatchConfigPageData(r, selectedIDs) + + if len(selectedIDs) == 0 { + data.Error = "请先选择需要下发分配的设备" + u.render(w, r, "device_batch_config", data) + return + } + if u.tasks == nil { + data.Error = "task service not initialized" + u.render(w, r, "device_batch_config", data) + return + } + configs := make(map[string]any, len(selectedIDs)) + for _, deviceID := range selectedIDs { + preview, err := u.preview.RenderDeviceAssignment(deviceID) + if err != nil { + data.Error = err.Error() + u.render(w, r, "device_batch_config", data) + return + } + if data.ConfigPreview == nil { + data.ConfigPreview = preview + } + var configDoc any + if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil { + data.Error = "生成配置 JSON 无效: " + err.Error() + u.render(w, r, "device_batch_config", data) + return + } + configs[deviceID] = configDoc + } + + task, err := u.tasks.CreateTask("config_apply", selectedIDs, map[string]any{"configs": configs}) + if err != nil { + data.Error = err.Error() + u.render(w, r, "device_batch_config", data) + return + } + + http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound) +} + +func (u *UI) pageDevice(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } + u.render(w, r, "device", u.deviceDetailPageData(dev)) +} + +func (u *UI) deviceDetailPageData(dev *models.Device) PageData { + data := u.deviceControlPageData(dev) + data.Title = "设备详情" + if data.ConfigStatus == nil && u.stateRepo != nil && dev != nil { + if state, err := u.stateRepo.Get(dev.DeviceID); err == nil && state != nil { + data.PersistedConfig = state + } + } + if u.preview != nil { + if profiles, err := u.preview.ListProfileAssets(); err == nil { + data.AssetProfiles = profiles + selectedProfile := "" + if data.ConfigStatus != nil && strings.TrimSpace(data.ConfigStatus.Metadata.Profile) != "" { + selectedProfile = strings.TrimSpace(data.ConfigStatus.Metadata.Profile) + } else if data.PersistedConfig != nil && strings.TrimSpace(data.PersistedConfig.ProfileName) != "" { + selectedProfile = strings.TrimSpace(data.PersistedConfig.ProfileName) + } + if selectedProfile == "" && len(profiles) > 0 { + selectedProfile = profiles[0].Name + } + data.SelectedProfile = selectedProfile + for i := range profiles { + if strings.TrimSpace(profiles[i].Name) == selectedProfile { + data.AssetProfile = &profiles[i] + data.SelectedTemplate = profileAssetTemplate(&profiles[i]) + break + } + } + if data.AssetProfile == nil && len(profiles) > 0 { + data.AssetProfile = &profiles[0] + data.SelectedProfile = profiles[0].Name + data.SelectedTemplate = profileAssetTemplate(&profiles[0]) + } + } else if data.Error == "" { + data.Error = err.Error() + } + if assignment, err := u.preview.GetDeviceAssignment(dev.DeviceID); err == nil { + data.DeviceAssignment = assignment + data.SelectedAssignmentDevice = assignment.DeviceID + data.SelectedProfile = assignment.ProfileName + } + } + return data +} + +func (u *UI) pageDeviceControl(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + http.Redirect(w, r, "/ui/devices/"+url.PathEscape(id)+"#device-config", http.StatusFound) +} + +func (u *UI) actionDeviceAction(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } + _ = r.ParseForm() + action := strings.TrimSpace(r.FormValue("action")) + + method := "POST" + path := "" + switch action { + case "reload": + path = "/v1/media-server/reload" + case "rollback": + path = "/v1/media-server/rollback" + case "media_start": + path = "/v1/media-server/start" + case "media_restart": + path = "/v1/media-server/restart" + case "media_stop": + path = "/v1/media-server/stop" + case "media_status": + method = "GET" + path = "/v1/media-server/status" + case "info": + method = "GET" + path = "/v1/info" + default: + http.Error(w, "unknown action", http.StatusBadRequest) + return + } + + body, code, err := u.agent.Do(method, dev.IP, dev.AgentPort, path, nil) + msg := fmt.Sprintf("%s %s -> %d", method, path, code) + returnTo := strings.TrimSpace(r.FormValue("return_to")) + if returnTo == "control" || returnTo == "config" { + data := u.deviceDetailPageData(dev) + data.Message = msg + data.RawText = string(body) + data.ResultTitle = "执行结果摘要" + if err != nil { + data.Error = err.Error() + } + u.render(w, r, "device", data) + return + } + data := PageData{Title: "设备详情", Device: dev, Message: msg, RawText: string(body)} + if err != nil { + data.Error = err.Error() + } + u.render(w, r, "device", data) +} + +func (u *UI) pageDeviceLogs(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } + limit := strings.TrimSpace(r.URL.Query().Get("limit")) + path := "/v1/logs/recent" + if limit != "" { + path += "?limit=" + urlQueryEscape(limit) + } + + body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, path, nil) + data := PageData{Title: "诊断日志", Device: dev, Message: fmt.Sprintf("GET %s -> %d", path, code), RawText: string(body)} + if err != nil { + data.Error = err.Error() + } + u.render(w, r, "device_logs", data) +} + +func (u *UI) pageDeviceGraphs(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } + body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/graphs", nil) + data := PageData{Title: "运行指标", Device: dev, Message: fmt.Sprintf("GET /v1/graphs -> %d", code), RawText: string(body)} + if err != nil { + data.Error = err.Error() + } + u.render(w, r, "device_graphs", data) +} + +func (u *UI) actionDeviceConfigApply(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } + _ = r.ParseForm() + raw := strings.TrimSpace(r.FormValue("json")) + if raw == "" { + raw = `{"config":{}}` + } + + body, code, err := u.agent.Do("PUT", dev.IP, dev.AgentPort, "/v1/config", []byte(raw)) + data := PageData{Title: "设备详情", Device: dev, Message: fmt.Sprintf("PUT /v1/config -> %d", code), RawText: string(body), RawJSON: raw} + if err != nil { + data.Error = err.Error() + } + u.render(w, r, "device", data) +} + +func (u *UI) actionDeviceAliasSave(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } + _ = r.ParseForm() + alias := strings.TrimSpace(r.FormValue("device_alias")) + data := u.deviceDetailPageData(dev) + if err := u.registry.SetDeviceAlias(id, alias); err != nil { + data.Error = err.Error() + u.render(w, r, "device", data) + return + } + dev.DeviceAlias = alias + data.Device = dev + data.Message = "设备别名已保存" + u.render(w, r, "device", data) +} + +func (u *UI) actionDeviceModelUpload(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } + + if err := r.ParseMultipartForm(100 << 20); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.FormValue("name")) + file, hdr, err := r.FormFile("file") + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defer file.Close() + if name == "" { + http.Error(w, "name is required", http.StatusBadRequest) + return + } + path := fmt.Sprintf("/v1/models/%s", name) + resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, path, file, "application/octet-stream", hdr.Size) + out := PageData{Title: "设备详情", Device: dev, Message: fmt.Sprintf("PUT %s -> %d", path, code), RawText: string(resp)} + if derr != nil { + out.Error = derr.Error() + } + u.render(w, r, "device", out) +} + +func (u *UI) actionDeviceMediaServerConfigUpload(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } + + if err := r.ParseMultipartForm(50 << 20); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + name, err := normalizeConfigName(r.FormValue("name")) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + file, hdr, err := r.FormFile("file") + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defer file.Close() + + path := "/v1/media-server/configs/" + url.PathEscape(name) + resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, path, file, "application/json", hdr.Size) + data := PageData{Title: "设备详情", Device: dev, Message: fmt.Sprintf("PUT %s -> %d", path, code), RawText: string(resp)} + if derr != nil { + data.Error = derr.Error() + } + u.render(w, r, "device", data) +} + +func (u *UI) actionDeviceMediaServerConfigUploadBatch(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } + + if err := r.ParseMultipartForm(200 << 20); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if r.MultipartForm == nil || len(r.MultipartForm.File) == 0 { + http.Error(w, "files is required", http.StatusBadRequest) + return + } + files := r.MultipartForm.File["files"] + if len(files) == 0 { + http.Error(w, "files is required", http.StatusBadRequest) + return + } + + var sb strings.Builder + errCount := 0 + for _, hdr := range files { + name, nerr := normalizeConfigName(filepath.Base(hdr.Filename)) + if nerr != nil { + errCount++ + sb.WriteString(fmt.Sprintf("%s -> invalid name: %v\n", hdr.Filename, nerr)) + continue + } + file, err := hdr.Open() + if err != nil { + errCount++ + sb.WriteString(fmt.Sprintf("%s -> open failed: %v\n", name, err)) + continue + } + path := "/v1/media-server/configs/" + url.PathEscape(name) + resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, path, file, "application/json", hdr.Size) + _ = file.Close() + if derr != nil { + errCount++ + sb.WriteString(fmt.Sprintf("%s -> %d error: %v\n", name, code, derr)) + continue + } + if len(resp) > 0 { + sb.WriteString(fmt.Sprintf("%s -> %d %s\n", name, code, strings.TrimSpace(string(resp)))) + } else { + sb.WriteString(fmt.Sprintf("%s -> %d\n", name, code)) + } + } + + data := PageData{Title: "设备详情", Device: dev, Message: "批量上传完成", RawText: sb.String()} + if errCount > 0 { + data.Error = fmt.Sprintf("部分失败: %d/%d", errCount, len(files)) + } + u.render(w, r, "device", data) +} + +func (u *UI) pageTasks(w http.ResponseWriter, r *http.Request) { + u.ensureDevicesLoaded() + devices := u.registry.GetDevices() + selectedIDs := filterSelectedDeviceIDs(devices, selectedIDsFromQuery(r.URL.Query()["selected"])) + data := PageData{ + Title: "任务中心", + Tasks: u.tasks.ListTasks(), + Devices: devices, + SelectedDeviceIDs: selectedIDs, + SelectedDevices: selectedDevicesFromIDs(devices, selectedIDs), + DeviceIDs: strings.Join(selectedIDs, ","), + } + u.render(w, r, "tasks", data) +} + +func (u *UI) taskPageData(task *models.Task) PageData { + data := PageData{Title: "任务中心", Task: task} + if task == nil { + return data + } + + devices := make(map[string]*models.Device) + if u.registry != nil { + for _, dev := range u.registry.GetDevices() { + if dev == nil { + continue + } + devices[dev.DeviceID] = dev + } + } + + rows := make([]TaskDeviceRow, 0, len(task.DeviceIDs)) + for _, did := range task.DeviceIDs { + row := TaskDeviceRow{} + if dev := devices[did]; dev != nil { + row.Device = dev + } else { + row.Device = &models.Device{DeviceID: did} + } + if ds := task.Devices[did]; ds != nil { + row.Status = ds.Status + row.Progress = ds.Progress + row.Error = ds.Error + } + rows = append(rows, row) + } + data.TaskDeviceRows = rows + return data +} + +func (u *UI) actionCreateTask(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + typeStr := strings.TrimSpace(r.FormValue("type")) + if typeStr == "" { + typeStr = "config_apply" + } + ids := strings.TrimSpace(r.FormValue("device_ids")) + var deviceIDs []string + if ids != "" { + for _, p := range strings.Split(ids, ",") { + p = strings.TrimSpace(p) + if p != "" { + deviceIDs = append(deviceIDs, p) + } + } + } else { + deviceIDs = filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"]) + ids = strings.Join(deviceIDs, ",") + } + raw := strings.TrimSpace(r.FormValue("payload_json")) + if raw == "" { + raw = `{"config":{}}` + } + var payload any + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: "高级参数 JSON 无效: " + err.Error(), RawJSON: raw, DeviceIDs: ids}) + return + } + + task, err := u.tasks.CreateTask(typeStr, deviceIDs, payload) + if err != nil { + u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: err.Error(), RawJSON: raw, DeviceIDs: ids}) + return + } + http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound) +} + +func (u *UI) pageTask(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + items := u.tasks.ListTasks() + var task *models.Task + for i := range items { + if items[i].ID == id { + t := items[i] + task = &t + break + } + } + if task == nil { + http.NotFound(w, r) + return + } + data := u.taskPageData(task) + data.TaskID = id + u.render(w, r, "task", data) +} + +func (u *UI) pageTemplates(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/ui/assets/templates", http.StatusFound) +} + +func (u *UI) pageTemplate(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/ui/assets/templates/"+url.PathEscape(chi.URLParam(r, "name")), http.StatusFound) +} + +func (u *UI) pageModels(w http.ResponseWriter, r *http.Request) { + u.ensureDevicesLoaded() + data := PageData{Title: "模型管理", Devices: u.registry.GetDevices()} + for _, dev := range data.Devices { + if dev == nil { + continue + } + if dev.Online { + data.OnlineCount++ + } + } + board := service.ModelStatusBoard{} + if strings.TrimSpace(u.dbPath) != "" { + if store, err := storage.OpenSQLite(u.dbPath); err == nil { + modelsRepo := storage.NewModelsRepo(store.DB()) + if items, err := modelsRepo.List(); err == nil { + data.StandardModels = items + installed := map[string][]service.InstalledModelStatus{} + for _, device := range data.Devices { + if device == nil || !device.Online { + continue + } + items, err := service.FetchInstalledModelStatuses(u.agent, device) + if err == nil { + installed[device.DeviceID] = items + } + } + board = service.BuildModelStatusBoard(items, data.Devices, installed) + } + _ = store.Close() + } + } + data.ModelStatusBoard = &board + u.render(w, r, "models", data) +} + +func (u *UI) actionModelSync(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + action := strings.TrimSpace(r.FormValue("action")) + if action == "" { + action = "model_sync_all" + } + if action != "model_sync_one" && action != "model_sync_all" { + http.Error(w, "invalid action", http.StatusBadRequest) + return + } + deviceIDs := make([]string, 0) + for _, id := range r.Form["device_id"] { + id = strings.TrimSpace(id) + if id != "" { + deviceIDs = append(deviceIDs, id) + } + } + if len(deviceIDs) == 0 { + http.Error(w, "missing device_id", http.StatusBadRequest) + return + } + payload := map[string]any{} + if modelName := strings.TrimSpace(r.FormValue("model_name")); modelName != "" { + payload["model_name"] = modelName + } + task, err := u.tasks.CreateTask(action, deviceIDs, payload) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/ui/tasks/"+url.PathEscape(task.ID), http.StatusFound) +} diff --git a/docs/source_code_p31_65.txt b/docs/source_code_p31_65.txt new file mode 100644 index 0000000..7e58c3f --- /dev/null +++ b/docs/source_code_p31_65.txt @@ -0,0 +1,1924 @@ +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 { + 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, "/ui/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, "/ui/assets/integrations?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(asset.Name), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/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, "/ui/assets/integrations?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/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, "/ui/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, "/ui/assets/overlays?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape("只允许从标准调试参数复制创建"), http.StatusFound) + return + } + targetName, err := u.nextOverlayCloneName(item.Name) + if err != nil { + http.Redirect(w, r, "/ui/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, "/ui/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, "/ui/assets/overlays?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/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, "/ui/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/ui/schema", nil) + stateBody, stateCode, stateErr := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/ui/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/ui/schema -> %d\n%s", schemaCode, prettyJSON(schemaBody)), + StateJSON: fmt.Sprintf("GET /v1/config/ui/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 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 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, "/ui/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 { + 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, + SelectedQuery: selectedQueryString(selectedIDs), + SelectedDevicesURL: selectedURL("/ui/devices", selectedIDs), + BatchConfigURL: selectedURL("/ui/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/ui/plan", []byte(raw)) + data := u.loadConfigUIData(dev) + data.Message = fmt.Sprintf("POST /v1/config/ui/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/ui/apply", []byte(raw)) + data := u.loadConfigUIData(dev) + data.Message = fmt.Sprintf("POST /v1/config/ui/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") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } + resp, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/face-gallery/reload", nil) + data := u.loadConfigUIData(dev) + data.Message = fmt.Sprintf("POST /v1/face-gallery/reload -> %d", code) + data.RawText = prettyJSON(resp) + if err != nil { + data.Error = err.Error() + } + u.render(w, r, "config_ui", data) +} + +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) + http.Redirect(w, r, "/ui/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, "/ui/face-gallery?msg="+url.QueryEscape(msg), http.StatusFound) + return + } + // Parse key stats from output for a clean message + persons := "0" + photos := "0" + for _, line := range strings.Split(output, "\n") { + if strings.HasPrefix(line, "enrolled_persons:") { + persons = strings.TrimSpace(strings.TrimPrefix(line, "enrolled_persons:")) + } + if strings.HasPrefix(line, "ok_images:") { + photos = strings.TrimSpace(strings.TrimPrefix(line, "ok_images:")) + } + } + msg := fmt.Sprintf("生成成功,共 %s 人,%s 张照片", persons, photos) + http.Redirect(w, r, "/ui/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") + if strings.TrimSpace(u.dbPath) != "" { + if repo := storage.NewFaceGalleryRepo(u.dbPath); repo != nil { + if persons, err := repo.ListPersons(); err == nil { + data.FaceGalleryPersons = persons + } + } + } + u.render(w, r, "face_gallery", data) +} + +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, "/ui/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("/ui/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, "*") + 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, "/ui/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, "/ui/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, "/ui/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 +} diff --git a/extract_src.go b/extract_src.go new file mode 100644 index 0000000..b391701 --- /dev/null +++ b/extract_src.go @@ -0,0 +1,132 @@ +//go:build ignore + +package main + +import ( + "bufio" + "fmt" + "os" + "regexp" + "strings" +) + +// Module definitions: line number ranges and their descriptions +type Module struct { + Start int // inclusive 1-indexed + End int // inclusive 1-indexed + Title string + Comment string +} + +var modules = []Module{ + {1, 26, "包声明与依赖导入", "程序入口包声明及标准库、项目内部包、第三方库导入"}, + {27, 48, "核心数据结构 UI", "UI 结构体,聚合所有服务依赖,是 Web 层的核心中枢"}, + {49, 170, "页面数据模型 PageData", "统一的页面渲染数据载体,包含设备、任务、告警、配置等所有页面所需数据"}, + {171, 222, "辅助视图类型", "DeviceOverviewRow、TaskDeviceRow、ConfigStatusView 等轻量视图模型"}, + {223, 545, "构造函数与模板函数注册", "NewUI 创建 UI 实例,注册模板渲染所需的 FuncMap(JSON序列化、类型标签、规则映射等)"}, + {546, 625, "依赖注入方法", "SetStateRepo、SetAuditRepo、SetAlarmCollector 等延迟注入方法"}, + {626, 762, "路由注册 Routes", "将 HTTP 路径映射到对应的页面处理函数和 API 动作函数"}, + {763, 825, "模板渲染与设备工具方法", "render 模板渲染、findDevice 设备查找、ensureDevicesLoaded 缓存加载"}, + {826, 895, "仪表盘页面", "pageDashboard 渲染系统总览:设备统计、告警概览、异常设备、最近任务"}, + {896, 1244, "管控台页面", "pageConsole 设备能力矩阵展示、actionConsoleSave 保存管控配置"}, + {1245, 1410, "设备列表与批量操作", "pageDevices 列表、actionDeviceAdd 添加、actionDiscoverySearch 发现、actionDevicesBatchAction 批量操作"}, + {1410, 1726, "设备详情与单设备控制", "pageDevice 详情、pageDeviceControl 控制、actionDeviceAction 下发动作、actionDeviceConfigApply 配置应用"}, + {1727, 1833, "任务管理模块", "pageTasks 列表、taskPageData 数据、actionCreateTask 创建任务、pageTask 详情"}, + {1834, 1913, "模型管理", "pageModels 模型概览、actionModelSync 同步模型到设备"}, + {1914, 2004, "告警与诊断", "pageDiagnostics 诊断页、pageAlarms 告警中心(分页过滤)"}, + {2005, 2066, "资源管理", "pageResources 资源概览、actionResourceSync 资源同步"}, + {2067, 2133, "配置资产管理——模板", "pageAssetTemplates 列表、CRUD 操作"}, + {2134, 2475, "配置资产管理——模板操作", "actionAssetTemplateCreate 创建、Clone 克隆、Rename 重命名、Delete 删除、GraphSave 可视化保存、Export 导出"}, + {2476, 2748, "场景配置与视频通道", "pagePlan/pageAssetProfiles 场景管理、pageRecognitionUnits 视频通道、CRUD"}, + {2749, 2856, "通道部署与设备分配", "pageDeviceAssignments 部署矩阵、actionDeviceAssignmentSave/Delete 分配操作"}, + {2857, 3038, "视频源与第三方服务", "pageAssetVideoSources 视频源管理、pageAssetIntegrations 集成服务管理、CRUD"}, + {3039, 3209, "调试参数管理", "pageAssetOverlays 调试参数列表、actionAssetOverlaySave/Clone/Delete"}, + {3210, 3600, "配置预览与格式转换", "assetPageData 跨页共享数据、profileEditor 场景编辑器、configPreview 配置预览渲染"}, + {3601, 4414, "设备配置交互 UI", "pageDeviceConfigUI/Friendly 友好配置页面、configPreviewPageData 预览、actionDeviceConfigUIPlan/Apply 应用下发"}, + {4415, 4476, "人脸库上传与设备代理", "actionDeviceFaceGalleryUpload 上传人脸图片、actionDeviceFaceGalleryReload 重新加载人脸库"}, + {4477, 4652, "视频监控与人脸库管理", "pageMonitor 监控页、proxyHLS 代理推流、actionFaceGalleryAdd/Build 人脸库构建"}, + {4653, 4822, "人脸库页面与设备指标 API", "pageFaceGallery 人脸库展示、serveFacePhoto 照片服务、apiDeviceMetrics 指标接口"}, + {4823, 4863, "控制台视图模型", "DeviceMetric、ConsoleDeviceData、ConsoleChannel、ConsoleFeature 等控制台用数据结构"}, +} + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "Usage: go run extract_src.go ") + os.Exit(1) + } + srcPath := os.Args[1] + + f, err := os.Open(srcPath) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer f.Close() + + var lines []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + + outPath := "docs/source_code.md" + out, err := os.Create(outPath) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer out.Close() + + w := bufio.NewWriter(out) + + // Header + w.WriteString("# 工业安全生产视觉监测平台——核心源代码\n\n") + w.WriteString("**软件名称**:工业安全生产视觉监测平台\n") + w.WriteString("**版本**:V1.0\n") + w.WriteString("**开发语言**:Go\n") + w.WriteString("**源文件**:`internal/web/ui.go`\n") + w.WriteString("**总行数**:" + fmt.Sprintf("%d", len(lines)) + "\n\n") + w.WriteString("---\n\n") + w.WriteString("## 模块概览\n\n") + for _, m := range modules { + w.WriteString(fmt.Sprintf("- **%s**(第 %d-%d 行):%s\n", m.Title, m.Start, m.End, m.Comment)) + } + w.WriteString("\n---\n\n") + + // Find function/type boundaries for better formatting + typeBoundaryRe := regexp.MustCompile(`^(func |type )`) + commentRe := regexp.MustCompile(`^//`) // Sometimes we use // comments + + moduleIdx := 0 + for i, line := range lines { + lineNum := i + 1 + + // Check if we need to start a new module section + for moduleIdx < len(modules) && lineNum == modules[moduleIdx].Start { + m := modules[moduleIdx] + w.WriteString(fmt.Sprintf("## 模块 %d:%s\n", moduleIdx+1, m.Title)) + w.WriteString(fmt.Sprintf("> %s\n\n", m.Comment)) + w.WriteString("```go\n") + moduleIdx++ + } + + // Check if we need to close a module section + if moduleIdx > 0 && lineNum == modules[moduleIdx-1].End+1 { + w.WriteString("```\n\n") + } + + // Mark important type/function boundaries (but these are already covered by modules) + _ = typeBoundaryRe + _ = commentRe + + w.WriteString(line + "\n") + } + + // Close last module if needed + if moduleIdx > 0 && moduleIdx <= len(modules) { + w.WriteString("```\n\n") + } + + w.Flush() + fmt.Printf("Done: %s (%d lines)\n", outPath, len(lines)) +} diff --git a/internal/web/ui.go b/internal/web/ui.go index 447ec35..d1abeb1 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -46,8 +46,12 @@ const ( deviceAssignmentPreviewDeviceCount = 8 ) +const version = "1.0" + type PageData struct { Title string + Version string + Year int ContentHTML template.HTML Message string Error string @@ -270,6 +274,20 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic 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 "-" @@ -743,6 +761,8 @@ func (u *UI) Routes() (chi.Router, error) { } func (u *UI) render(w http.ResponseWriter, r *http.Request, content string, data PageData) { + data.Version = version + data.Year = time.Now().Year() var buf bytes.Buffer if err := u.tpl.ExecuteTemplate(&buf, content, data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/internal/web/ui/assets/style.css b/internal/web/ui/assets/style.css index 49eaa08..2f6757c 100644 --- a/internal/web/ui/assets/style.css +++ b/internal/web/ui/assets/style.css @@ -118,7 +118,7 @@ code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} .app-shell{display:grid;grid-template-columns:240px minmax(0,1fr);min-height:100vh} .sidebar{background:var(--sidebar);color:var(--sidebar-text);display:flex;flex-direction:column;padding:18px 14px;position:sticky;top:0;height:100vh} -.brand-block{display:flex;gap:12px;align-items:center;padding:4px 8px 20px} +.brand-block{display:flex;gap:8px;align-items:center;padding:4px 0 20px 4px;margin-left:-8px} .brand-mark{width:38px;height:38px;border-radius:var(--radius);background:rgba(0,0,0,.28);border:1px solid rgba(255,255,255,.12);display:grid;place-items:center;font-size:13px;font-weight:600;color:var(--primary)} .brand-title{font-size:14px;font-weight:600} .side-nav{display:flex;flex-direction:column;gap:4px} @@ -431,3 +431,5 @@ pre{margin-top:12px;padding:12px;border-radius:var(--radius);border:1px solid va .assignment-kpis{grid-template-columns:repeat(2,minmax(0,1fr))} .assignment-board-grid{grid-template-columns:1fr} } + +.sidebar-footer{margin-top:auto;padding:16px 8px 0;border-top:1px solid rgba(255,255,255,.08);font-size:11px;color:var(--sidebar-muted);line-height:1.6;text-align:center} diff --git a/internal/web/ui/templates/alarms.html b/internal/web/ui/templates/alarms.html index 1a53180..9ec90eb 100644 --- a/internal/web/ui/templates/alarms.html +++ b/internal/web/ui/templates/alarms.html @@ -42,7 +42,7 @@ {{.Channel}} - {{.RuleName}} + {{ruleLabel .RuleName}} {{if .ObjectLabel}}{{.ObjectLabel}}{{else}}-{{end}} {{if .Confidence}}{{printf "%.2f" .Confidence}}{{else}}-{{end}} diff --git a/internal/web/ui/templates/dashboard.html b/internal/web/ui/templates/dashboard.html index b90e940..86e2a6d 100644 --- a/internal/web/ui/templates/dashboard.html +++ b/internal/web/ui/templates/dashboard.html @@ -8,7 +8,7 @@
{{range .AlarmRecords}}
-
{{.RuleName}}
+
{{ruleLabel .RuleName}}
{{.Timestamp}}
{{end}} diff --git a/internal/web/ui/templates/layout.html b/internal/web/ui/templates/layout.html index 408acc7..fcd1e57 100644 --- a/internal/web/ui/templates/layout.html +++ b/internal/web/ui/templates/layout.html @@ -15,7 +15,7 @@
AI
-
视觉识别运维平台
+
工业安全生产视觉监测平台
+