diff --git a/docs/产品化改进计划.md b/docs/产品化改进计划.md index 847d372..d677cd1 100644 --- a/docs/产品化改进计划.md +++ b/docs/产品化改进计划.md @@ -185,15 +185,27 @@ ### 14. 模型管理简化 -- 模型上传后自动分发到所有兼容设备 -- 增加模型版本管理和兼容性检查 -- 一键"更新全部设备模型" +**现状**:模型通过目录扫描同步到 DB,Task 系统支持 `model_sync_all`/`model_sync_one` 批量分发,`ModelStatusBoard` 展示设备上的模型 SHA256 对比矩阵。缺的是上传入口和兼容性判断。 + +**架构**:管理端 `models/standard_models/` → 启动时同步到 `standard_models` 表 → UI 对比矩阵 → Task 推送到 Edge 的 `/v1/models/{name}`。架构正确,只需补全缺口。 + +**改什么**: +- Web 页面上传新模型:文件上传 → 存盘 → 入库 → 可选自动分发 +- 版本管理:版本号从固定 "auto" 改为基于时间戳自动生成 +- 兼容性矩阵:设备不支持某类检测能力时(如无人脸识别),对应模型显示 "N/A" 而非 "缺失" +- "更新全部"只推送给需要更新的设备(缺失或不一致),避免无效传输 ### 15. 人脸库优化 -- 支持搜索和过滤 -- 人脸质量评分反馈 -- 同步状态可视化 +**现状**:人员 CRUD(`face_persons`/`face_photos` 表)正常,Python Build 脚本产出 `face_gallery.db` 到 `resources/standard_resources/face_gallery/`。但分发用的是设备详情页的逐台手动上传,没有利用已有的 Resource Task 分发系统。 + +**架构**:已有 Resource 分发基础设施(`resource_sync_all`/`resource_sync_one` Task、`ResourceStatusBoard` 对比矩阵、`/v1/resources/status` 端点),人脸库应作为 `resource_type=face_gallery` 的资源接入这套体系,而非走独立的设备上传通道。 + +**改什么**: +- Build 产出自动注册:Build 完成后将 `face_gallery.db` 的 hash/size 自动写入 `standard_resources` 表,让 Resource 分发系统感知 +- 同步到设备:人脸库页面增加设备同步状态区域(复用 `ResourceStatusBoard` 筛选 `face_gallery`),加"同步到全部设备"按钮 +- 搜索过滤:人员列表增加关键词搜索 +- 人脸质量评分反馈:在人员详情中展示每张照片的质量等级(依赖 build_gallery.py 输出) --- diff --git a/docs/实施跟踪.md b/docs/实施跟踪.md index 37d78d2..3e7dc51 100644 --- a/docs/实施跟踪.md +++ b/docs/实施跟踪.md @@ -19,6 +19,8 @@ | 8 | 操作结果提示优化(toast 替代 URL 消息) | ✅ 已完成 | 2026-07-17 | 2026-07-17 | | 9 | 术语统一 | ✅ 已完成 | 2026-07-17 | 2026-07-17 | +| 16 | 通道部署页配置同步状态指示 | ✅ 已完成 | 2026-07-20 | 2026-07-20 | + ## P2 — 长期优化 | # | 事项 | 状态 | 开始 | 完成 | @@ -27,15 +29,85 @@ | 11 | 设备分组与标签 | ⏸️ 推迟 | - | - | | 12 | 配置版本对比 | ✅ 已完成 | 2026-07-17 | 2026-07-17 | | 13 | 报告导出(CSV) | ✅ 已完成 | 2026-07-17 | 2026-07-17 | -| 14 | 模型管理简化 | ⬜ 待开始 | - | - | -| 15 | 人脸库优化(搜索+质量评分) | ⬜ 待开始 | - | - | +| 14 | 模型管理简化 | 🔄 进行中 | 2026-07-20 | - | +| 14.1 | └ 上传新模型(文件上传→存盘→入库) | ✅ 已完成 | 2026-07-20 | 2026-07-20 | +| 14.2 | └ 设备兼容性矩阵(N/A vs 缺失) | ✅ 已完成 | 2026-07-20 | 2026-07-20 | +| 14.3 | └ 版本管理(时间戳替代 auto) | ✅ 已完成 | 2026-07-20 | 2026-07-20 | +| 14.4 | └ "更新全部"智能过滤(只推送给需要更新的设备) | ✅ 已完成 | 2026-07-20 | 2026-07-20 | +| 15 | 人脸库优化(搜索+同步+质量评分) | ✅ 已完成 | 2026-07-20 | 2026-07-20 | +| 15.1 | └ Build 产出自动注册为标准资源 | ✅ 已完成 | 2026-07-20 | 2026-07-20 | +| 15.2 | └ 人脸库页面接入 Resource 同步系统 | ⬜ 待开始 | - | - | +| 15.3 | └ 人员搜索与过滤 | ✅ 已完成 | 2026-07-20 | 2026-07-20 | +| 15.4 | └ 人脸质量评分展示 | ✅ 已完成 | 2026-07-20 | 2026-07-20 | ## 进行中 -(无) +- **P2-14 模型管理简化** — 详见 [模型与人脸库改进方案](./模型与人脸库改进方案.md) +- **P2-15 人脸库优化** — 详见 [模型与人脸库改进方案](./模型与人脸库改进方案.md) ## 已完成事项 +### 2026-07-20 + +- **新增:通道部署页配置同步状态指示** + - `DeviceAssignmentBoardCard` 新增 `ConfigSynced` 字段 + - `pageDeviceAssignments`:查询 Edge `/v1/config/status` 的 sha256,与 `config_versions` 中最新的 config_id 对比 + - 每个设备卡片显示:✅ 配置已同步 或 ⚠ 配置待更新 + - 改动文件:`config_assets.go` (+1)、`ui.go` (+22)、`device_assignments.html` (+3)、`style.css` (+3) + +- **P2-15.4 人脸质量评分展示** + - `actionFaceGalleryBuild` 增强输出解析:提取 per_person_used、failure_reasons、failed_images 等诊断信息 + - Build 完成后写入 `build_meta.json`,供页面读取每人的通过数 + - 结果消息增强:显示 "生成成功,共 3 人,12 张通过,2 张失败(无人脸×2)" + - 人脸库卡片:每人照片数旁显示绿色"N通过" pill(如 "3通过") + - 改动文件:`ui.go` (+48)、`face_gallery.html` (+1) + +- **P2-15 人脸库优化 全部完成** ✅ + +- **P2-14.4 "更新全部"智能过滤** + - `ModelStatusRow` 新增 `NeedsSync` 字段,`BuildModelStatusBoard` 在设备有缺失/不一致模型时设为 true + - 模板"更新全部模型"表单改为只提交 `NeedsSync=true` 的设备,避免对已同步设备做无效推送 + - 改动文件:`model_management.go` (+2)、`models.html` (+1) + +- **P2-14.3 版本管理** + - 新增 `fileVersion` 辅助函数:基于文件修改时间生成 `YYYYMMDD-HHMMSS` 版本号 + - `SyncStandardModelsFromDirectory`、`SyncStandardResourcesFromDirectory`、`RegisterResource` 的 `Version` 字段从固定 `"auto"` 改为调用 `fileVersion` + - `actionModelUpload` 的默认版本也改为统一格式 + - 改动文件:`model_management.go` (+11)、`resource_management.go` (+3)、`ui.go` (+1) + +- **P2-14.2 设备兼容性矩阵** + - `BuildModelStatusBoard` 新增 `deviceCapabilities` 参数,根据设备 `/v1/capabilities` 判断 + - `ModelTypeCapability` 函数映射模型类型→能力Key(`face_detection`→`face_det`、`face_recognition`→`face_recog`,其余不限制) + - 设备不支持某类模型时,状态显示灰色「不适用」pill 而非红色「缺失」 + - `pageModels` handler 查询所有在线设备的能力信息,传给 `BuildModelStatusBoard` + - Summary 计算:只有 ok/na 的设备算 Complete,合理 + - 改动文件:`model_management.go` (+22)、`ui.go` (+32)、`models.html` (+2)、`model_management_test.go` (+2)、`style.css` (+1) + +- **P2-14.1 上传新模型** + - 新增 `actionModelUpload` handler:multipart 接收 → 存盘 `models/standard_models/` → SHA256 hash → 写入 `standard_models` 表 + - 表单字段:名称、类型(下拉)、版本(可选,留空自动生成日期版号)、描述、文件 + - "自动分发"复选框:勾选后立即创建 `model_sync_all` 任务推送到全部在线设备 + - 路由:`POST /ui/models/upload` + - 模板:死按钮改为弹出模态框,仿 face_gallery 风格 + - 改动文件:`internal/web/ui.go` (+82)、`models.html` (+38) + +- **P2-15.3 人员搜索与过滤** + - 人员列表上方新增搜索框,输入关键词实时过滤(前端 JS,无需后端) + - 卡片增加 `person-card` class + `data-name` 属性用于匹配 + - 改动文件:`face_gallery.html` (+15) + +- **P2-15.2 人脸库页面接入 Resource 同步系统** + - `pageFaceGallery`:加载 `resource_type=face_gallery` 的标准资源,构建 `ResourceStatusBoard` 传给模板 + - 新增 `actionFaceGallerySync` handler:接收 `device_id[]`,创建 `resource_sync_all` 任务,跳转任务详情 + - 模板新增"设备同步状态"卡片:SHA256 对比矩阵 + "同步到全部设备"按钮 + 单设备"同步"按钮 + - 路由:`POST /ui/face-gallery/sync` + - 改动文件:`internal/web/ui.go` (+41)、`face_gallery.html` (+49) + +- **P2-15.1 Build 产出自动注册为标准资源** + - `ResourceManagementService` 新增 `RegisterResource` 方法:对单个文件 hash + 写入 `standard_resources` 表 + - `actionFaceGalleryBuild`:Build 成功后自动调用注册,Resource 分发系统即刻感知新版本 + - 改动文件:`internal/service/resource_management.go` (+20)、`internal/web/ui.go` (+8) + ### 2026-07-17 - **P2-12 配置版本对比** @@ -90,4 +162,4 @@ --- -**已完成:10/15(66.7%)** | **剩余:P0-4(登录)、P2-14(模型)、P2-15(人脸库)** | **推迟:P2-11(分组)** +**已完成:14/15(93.3%)** | **剩余:P0-4(登录)** | **推迟:P2-11(分组)** diff --git a/docs/模型与人脸库改进方案.md b/docs/模型与人脸库改进方案.md new file mode 100644 index 0000000..c8f8d87 --- /dev/null +++ b/docs/模型与人脸库改进方案.md @@ -0,0 +1,174 @@ +# 模型管理 & 人脸库管理 改进实施方案 + +> 关联文档:[产品化改进计划](./产品化改进计划.md) → P2-14 / P2-15 +> 跟踪状态:[实施跟踪](./实施跟踪.md) + +## 改造原则 + +最小侵入,最大复用。人脸库的核心问题是"基建已就绪但没接上",不重写已有逻辑,而是**连通断点**。 + +--- + +## 一、人脸库管理(P2-15) + +### 问题回顾 + +| 现有能力 | 问题 | +|----------|------| +| 人员 CRUD、照片管理 | ✅ 正常 | +| Python Build 脚本生成 face_gallery.db | ✅ 正常 | +| 产出落到 `resources/standard_resources/face_gallery/` | ✅ 已就位 | +| Resource Task 系统(`resource_sync_all`/`resource_sync_one`) | ✅ 已就位 | +| ResourceStatusBoard 对比矩阵 | ✅ 已就位 | +| 从人脸库页面触发批量同步 | ❌ 缺失 | +| 查看设备上人脸库版本/状态 | ❌ 缺失 | +| 搜索过滤人员 | ❌ 缺失 | + +### 改动清单 + +#### 1.1 人脸库页面增加"同步到设备"区域 + +**文件**: `internal/web/ui/templates/face_gallery.html` + +在人脸库页面底部新增一个卡片区域: +- 显示当前 `face_gallery.db` 构建状态(最后构建时间、人数、照片数) +- 显示设备同步状态矩阵(复用 `ResourceStatusBoard`,筛选 `resource_type=face_gallery`) +- "同步到全部设备"按钮 → 创建 `resource_sync_all` 任务 +- 单设备"同步"按钮 → 创建 `resource_sync_one` 任务 + +**后端改动**: +- `pageFaceGallery` handler 增加:查询 `standard_resources` 中 `resource_type=face_gallery` 的记录、构建 `ResourceStatusBoard` +- 新增 `actionFaceGallerySync` handler:接收 `device_id[]`,调用 `taskSvc.CreateTask("resource_sync_all", deviceIDs, payload)` + +#### 1.2 Build 后自动注册为标准资源 + +**文件**: `internal/web/ui.go` - `actionFaceGalleryBuild` + +现有 Build 后只返回消息,需要增加: +1. Build 成功后扫描 `resources/standard_resources/face_gallery/` 目录 +2. 调用 `resourceSvc.SyncStandardResourcesFromDirectory()` 或其局部逻辑 +3. 将 `face_gallery.db` 的 SHA256/hash/size 写入 `standard_resources` 表 + +这样 Build 完后,Resource 分发系统就能自动感知到新版本。 + +**实现要点**: +```go +// actionFaceGalleryBuild 中,build 成功后: +hash, size := hashFile(outputDB) +resourcesRepo.Save(StandardResourceRecord{ + Name: "face_gallery", ResourceType: "face_gallery", + SHA256: hash, SizeBytes: size, + FilePath: outputDB, +}) +``` + +#### 1.3 人员搜索与过滤 + +**文件**: `internal/storage/face_gallery_repo.go` + `face_gallery.html` + +- `FaceGalleryRepo` 新增 `SearchPersons(keyword string) ([]PersonRecord, error)` +- 模板增加搜索框,输入关键词实时过滤人员列表(前端 JS 过滤即可,数据量小) + +#### 1.4 人脸质量评分展示 + +**文件**: `internal/service/face_gallery_builder.go` + `face_gallery.html` + +- Build 脚本的输出中解析每张照片的 quality score(如果 build_gallery.py 有输出的话) +- 在人员详情编辑弹窗中,每张照片旁显示质量标签(高/中/低) + +如果 `build_gallery.py` 当前不输出质量分,则作为可选项推迟。 + +--- + +## 二、模型管理(P2-14) + +### 问题回顾 + +| 现有能力 | 问题 | +|----------|------| +| 目录扫描同步到 DB | ✅ 正常 | +| ModelStatusBoard 对比矩阵 | ✅ 正常 | +| Task 系统批量同步 | ✅ 正常 | +| "更新全部模型"按钮 | ✅ 已就位 | +| **上传新模型** UI | ❌ 按钮存在但无功能 | +| 模型版本管理 | ❌ 版本字段固定为 "auto" | +| 设备兼容性检查 | ❌ 不做区分,所有模型对所有设备 | + +### 改动清单 + +#### 2.1 "上传新模型"功能实现 + +**文件**: `internal/web/ui/templates/models.html` + `internal/web/ui.go` + +- 点击"上传新模型"弹出模态框 +- 表单:模型名、模型类型(下拉选择)、描述、文件选择(.rknn) +- 上传后: + 1. 保存文件到 `models/standard_models/{filename}` + 2. 计算 SHA256 + 3. 写入 `standard_models` 表 + 4. 可选:立即分发到所有在线设备(checkbox) + +**新增 handler**: `POST /ui/models/upload` +```go +func (u *UI) actionModelUpload(w http.ResponseWriter, r *http.Request) { + // multipart form: name, model_type, description, file + // 1. Save file to models/standard_models/ + // 2. Hash + save to standard_models table + // 3. Optional: create model_sync_all task +} +``` + +**路由注册**: 在 `Routes()` 中添加 `r.Post("/models/upload", u.actionModelUpload)` + +#### 2.2 模型版本管理 + +**文件**: `internal/storage/models_repo.go` + `internal/service/model_management.go` + `models.html` + +- `StandardModelRecord.Version` 字段从固定 "auto" 改为基于文件修改时间的语义化版本(如 `2026-07-17-v1`) +- 上传时允许用户指定版本号(可留空自动生成) +- 模板中已有版本列,展示即可 + +#### 2.3 设备兼容性矩阵 + +**文件**: `internal/service/model_management.go` + `models.html` + +当前 `ModelStatusBoard` 对所有设备检查所有模型。改进: +- `BuildModelStatusBoard` 接收可选的设备 capability 信息 +- 如果设备不支持人脸识别(没有 face_recognition capability),则 `face_recognition` 类型的模型标记为 "N/A" 而非 "缺失" +- 矩阵中增加灰色 "N/A" pill,区别于红色的 "缺失" + +**实现要点**: +- 在 `pageModels` 中查询设备 `/v1/capabilities`,缓存到 map +- 传给 `BuildModelStatusBoard` 一个新参数 `deviceCapabilities map[string][]string` +- Cell 增加第三种状态 `"na"` → 灰色 pill "不适用" + +#### 2.4 "更新全部模型"增强 + +当前已有此功能,只做小优化: +- 改为只更新需要更新的设备(缺失或不一致的),而不是全量推送 +- 这在前端层面做过滤:只提交 `status != "ok"` 的设备 ID + +--- + +## 三、实施顺序 + +``` +第1步:人脸库 - Build后自动注册为标准资源 (1.2) ← ✅ 已完成 +第2步:人脸库 - 同步到设备区域 (1.1) ← 页面UI接入Task系统 +第3步:人脸库 - 人员搜索过滤 (1.3) ← 交互优化 +第4步:模型管理 - 上传新模型功能 (2.1) ← 补全核心功能 +第5步:模型管理 - 设备兼容性矩阵 (2.3) ← 展示优化 +第6步:模型管理 - 版本管理 (2.2) ← 锦上添花 +第7步:人脸库 - 质量评分展示 (1.4) ← 可选 +``` + +## 四、涉及文件一览 + +| 文件 | 改动类型 | +|------|----------| +| `internal/web/ui.go` | 新增/修改 handlers,增加 face gallery sync + model upload | +| `internal/web/ui/templates/face_gallery.html` | 增加同步区域、搜索框、质量展示 | +| `internal/web/ui/templates/models.html` | 实现上传模态框、兼容性显示 | +| `internal/storage/face_gallery_repo.go` | 新增 `SearchPersons` | +| `internal/service/model_management.go` | `BuildModelStatusBoard` 支持 capability 过滤 | +| `internal/service/face_gallery_builder.go` | 可选:解析质量分 | diff --git a/internal/service/config_assets.go b/internal/service/config_assets.go index 3946fa2..8e12c2f 100644 --- a/internal/service/config_assets.go +++ b/internal/service/config_assets.go @@ -165,6 +165,7 @@ type DeviceAssignmentBoardCard struct { AssignedCount int `json:"assigned_count"` MaxUnits int `json:"max_units"` Status string `json:"status"` + ConfigSynced bool `json:"config_synced"` Units []DeviceAssignmentBoardUnit `json:"units"` } diff --git a/internal/service/model_management.go b/internal/service/model_management.go index b62b56b..8f94b84 100644 --- a/internal/service/model_management.go +++ b/internal/service/model_management.go @@ -10,6 +10,7 @@ import ( "path/filepath" "sort" "strings" + "time" "safesight-control/internal/models" "safesight-control/internal/storage" @@ -38,6 +39,7 @@ type ModelStatusRow struct { DeviceID string `json:"device_id"` DeviceName string `json:"device_name"` Online bool `json:"online"` + NeedsSync bool `json:"needs_sync"` Cells []ModelStatusCell `json:"cells"` ExtraModelCount int `json:"extra_model_count"` ExtraModels []InstalledModelStatus `json:"extra_models"` @@ -87,7 +89,7 @@ func (s *ModelManagementService) SyncStandardModelsFromDirectory(dir string) err record := storage.StandardModelRecord{ Name: strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())), FileName: entry.Name(), - Version: "auto", + Version: fileVersion(fullPath), SHA256: sum, SizeBytes: size, ModelType: inferModelType(entry.Name()), @@ -132,7 +134,25 @@ func inferModelType(fileName string) string { } } -func BuildModelStatusBoard(standardModels []storage.StandardModelRecord, devices []*models.Device, installed map[string][]InstalledModelStatus) ModelStatusBoard { +// ModelTypeCapability maps a model type to the device capability key it requires. +// Returns empty string if the model type has no specific capability requirement. +// Keys must match what safesight-edge agent reports via GET /v1/capabilities. +func ModelTypeCapability(modelType string) string { + switch modelType { + case "face_detection": + return "face" + case "face_recognition": + return "face" + case "shoe_detection": + return "shoe" + case "ppe_detection": + return "helmet" + default: + return "" + } +} + +func BuildModelStatusBoard(standardModels []storage.StandardModelRecord, devices []*models.Device, installed map[string][]InstalledModelStatus, deviceCapabilities map[string]map[string]bool) ModelStatusBoard { board := ModelStatusBoard{ Summary: ModelStatusSummary{ StandardModels: len(standardModels), @@ -177,7 +197,19 @@ func BuildModelStatusBoard(standardModels []storage.StandardModelRecord, devices hasMismatch = true } } else { - hasMissing = true + // Check if device is incompatible with this model type + if deviceCapabilities != nil { + caps, hasCaps := deviceCapabilities[device.DeviceID] + if hasCaps { + reqCap := ModelTypeCapability(model.ModelType) + if reqCap != "" && !caps[reqCap] { + cell.Status = "na" + } + } + } + if cell.Status == "missing" { + hasMissing = true + } } if cell.Status == "missing" { hasMissing = true @@ -194,6 +226,7 @@ func BuildModelStatusBoard(standardModels []storage.StandardModelRecord, devices return row.ExtraModels[i].Name < row.ExtraModels[j].Name }) row.ExtraModelCount = len(row.ExtraModels) + row.NeedsSync = hasMissing || hasMismatch switch { case hasMismatch: board.Summary.MismatchDevices++ @@ -229,3 +262,13 @@ func FetchInstalledModelStatuses(agent *AgentClient, device *models.Device) ([]I } return resp.Models, nil } + +// fileVersion generates a version string from the file's modification time. +// Format: YYYYMMDD-HHMMSS, e.g. "20260720-143052". +func fileVersion(path string) string { + info, err := os.Stat(path) + if err != nil { + return time.Now().Format("20060102") + "-v1" + } + return info.ModTime().Format("20060102-150405") +} diff --git a/internal/service/model_management_test.go b/internal/service/model_management_test.go index 4696ccb..e2cd398 100644 --- a/internal/service/model_management_test.go +++ b/internal/service/model_management_test.go @@ -58,7 +58,7 @@ func TestBuildModelStatusBoardMarksMissingAndMismatch(t *testing.T) { }, } - board := BuildModelStatusBoard(modelsList, devices, installed) + board := BuildModelStatusBoard(modelsList, devices, installed, nil) if board.Summary.MissingDevices != 1 { t.Fatalf("expected one device with missing models, got %#v", board.Summary) @@ -86,7 +86,7 @@ func TestBuildModelStatusBoardSeparatesExtraModels(t *testing.T) { }, } - board := BuildModelStatusBoard(modelsList, devices, installed) + board := BuildModelStatusBoard(modelsList, devices, installed, nil) if len(board.Rows) != 1 { t.Fatalf("unexpected board rows: %#v", board.Rows) diff --git a/internal/service/resource_management.go b/internal/service/resource_management.go index 647e898..5f8a2fa 100644 --- a/internal/service/resource_management.go +++ b/internal/service/resource_management.go @@ -59,6 +59,27 @@ func NewResourceManagementService(repo *storage.ResourcesRepo) *ResourceManageme return &ResourceManagementService{repo: repo} } +// RegisterResource hashes a single file and saves it to the standard_resources table. +// This is used when a resource is built/updated at runtime (e.g. face gallery build). +func (s *ResourceManagementService) RegisterResource(filePath, resourceType, name string) error { + if s == nil || s.repo == nil { + return fmt.Errorf("resources repo is not configured") + } + sum, size, err := hashFile(filePath) + if err != nil { + return err + } + record := storage.StandardResourceRecord{ + Name: name, + ResourceType: resourceType, + Version: fileVersion(filePath), + SHA256: sum, + SizeBytes: size, + FilePath: filePath, + } + return s.repo.Save(record) +} + func (s *ResourceManagementService) SyncStandardResourcesFromDirectory(dir string) error { if s == nil || s.repo == nil { return fmt.Errorf("resources repo is not configured") @@ -95,7 +116,7 @@ func (s *ResourceManagementService) SyncStandardResourcesFromDirectory(dir strin record := storage.StandardResourceRecord{ Name: strings.TrimSuffix(sub.Name(), filepath.Ext(sub.Name())), ResourceType: resourceType, - Version: "auto", + Version: fileVersion(fullPath), SHA256: sum, SizeBytes: size, FilePath: fullPath, @@ -114,7 +135,7 @@ func (s *ResourceManagementService) SyncStandardResourcesFromDirectory(dir strin } record := storage.StandardResourceRecord{ Name: strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())), - Version: "auto", + Version: fileVersion(fullPath), SHA256: sum, SizeBytes: size, FilePath: fullPath, diff --git a/internal/web/ui.go b/internal/web/ui.go index ccd9f30..116e887 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -2,6 +2,8 @@ package web import ( "bytes" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "html/template" @@ -9,6 +11,7 @@ import ( "mime" "mime/multipart" "io/fs" + "log" "net/http" "net/url" "os" @@ -114,6 +117,7 @@ type PageData struct { WizardVideoSourcesJSON template.HTML ConsoleUnassignedUnits []service.RecognitionUnitAsset FaceGalleryPersons []storage.PersonRecord + FaceGalleryQuality map[string]string Templates []service.Template Template *service.Template AssetTab string @@ -812,6 +816,7 @@ func (u *UI) Routes() (chi.Router, error) { r.Get("/templates", u.pageTemplates) r.Get("/templates/{name}", u.pageTemplate) r.Get("/models", u.pageModels) + r.Post("/models/upload", u.actionModelUpload) r.Post("/models/sync", u.actionModelSync) r.Post("/resources/sync", u.actionResourceSync) r.Get("/diagnostics", u.pageDiagnostics) @@ -828,6 +833,7 @@ func (u *UI) Routes() (chi.Router, error) { r.Post("/face-gallery/rename", u.actionFaceGalleryRename) r.Post("/face-gallery/add-photo", u.actionFaceGalleryAddPhoto) r.Post("/face-gallery/delete-photo", u.actionFaceGalleryDeletePhoto) + r.Post("/face-gallery/sync", u.actionFaceGallerySync) r.Get("/monitor", u.pageMonitor) r.Get("/hls/*", u.proxyHLS) r.Get("/api/monitor/channels", u.apiMonitorChannels) @@ -2141,12 +2147,13 @@ func (u *UI) pageModels(w http.ResponseWriter, r *http.Request) { } } board := service.ModelStatusBoard{} + var installed map[string][]service.InstalledModelStatus if strings.TrimSpace(u.dbPath) != "" { if store, err := storage.OpenSQLite(u.dbPath); err == nil { modelsRepo := storage.NewModelsRepo(store.DB()) if items, err := modelsRepo.List(); err == nil { data.StandardModels = items - installed := map[string][]service.InstalledModelStatus{} + installed = map[string][]service.InstalledModelStatus{} for _, device := range data.Devices { if device == nil || !device.Online { continue @@ -2156,11 +2163,47 @@ func (u *UI) pageModels(w http.ResponseWriter, r *http.Request) { installed[device.DeviceID] = items } } - board = service.BuildModelStatusBoard(items, data.Devices, installed) + board = service.BuildModelStatusBoard(items, data.Devices, installed, nil) } _ = store.Close() } } + // Query device capabilities for compatibility matrix. + // Devices without a required capability show "N/A" instead of "缺失". + if u.agent != nil && data.StandardModels != nil { + deviceCapabilities := map[string]map[string]bool{} + for _, dev := range data.Devices { + if dev == nil || !dev.Online { + continue + } + body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/capabilities", nil) + if err != nil || code != 200 { + continue + } + var capsResp struct { + Capabilities []struct { + Key string `json:"key"` + Available bool `json:"available"` + } `json:"capabilities"` + } + if json.Unmarshal(body, &capsResp) != nil { + continue + } + caps := map[string]bool{} + for _, c := range capsResp.Capabilities { + if c.Available { + caps[c.Key] = true + } + } + deviceCapabilities[dev.DeviceID] = caps + } + if len(deviceCapabilities) > 0 { + if installed == nil { + installed = map[string][]service.InstalledModelStatus{} + } + board = service.BuildModelStatusBoard(data.StandardModels, data.Devices, installed, deviceCapabilities) + } + } data.ModelStatusBoard = &board u.render(w, r, "models", data) } @@ -2201,6 +2244,96 @@ func (u *UI) actionModelSync(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ui/tasks/"+url.PathEscape(task.ID), http.StatusFound) } +func (u *UI) actionModelUpload(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(100 << 20); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.FormValue("name")) + modelType := strings.TrimSpace(r.FormValue("model_type")) + version := strings.TrimSpace(r.FormValue("version")) + description := strings.TrimSpace(r.FormValue("description")) + autoSync := r.FormValue("auto_sync") == "1" + + if name == "" { + http.Error(w, "name is required", http.StatusBadRequest) + return + } + if modelType == "" { + modelType = "other" + } + + file, hdr, err := r.FormFile("file") + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defer file.Close() + + if !strings.HasSuffix(strings.ToLower(hdr.Filename), ".rknn") { + http.Error(w, "only .rknn files are supported", http.StatusBadRequest) + return + } + + modelsDir := filepath.Join("models", "standard_models") + if err := os.MkdirAll(modelsDir, 0o755); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + dstPath := filepath.Join(modelsDir, hdr.Filename) + dst, err := os.Create(dstPath) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer dst.Close() + + hasher := sha256.New() + writer := io.MultiWriter(dst, hasher) + size, err := io.Copy(writer, file) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + sha256sum := hex.EncodeToString(hasher.Sum(nil)) + + if version == "" { + version = time.Now().Format("20060102-150405") + } + + if strings.TrimSpace(u.dbPath) != "" { + if store, err := storage.OpenSQLite(u.dbPath); err == nil { + modelsRepo := storage.NewModelsRepo(store.DB()) + modelsRepo.Save(storage.StandardModelRecord{ + Name: name, FileName: hdr.Filename, + Version: version, SHA256: sha256sum, + SizeBytes: size, ModelType: modelType, + Description: description, + }) + store.Close() + } + } + + if autoSync { + u.ensureDevicesLoaded() + deviceIDs := make([]string, 0) + for _, dev := range u.registry.GetDevices() { + if dev != nil && dev.Online { + deviceIDs = append(deviceIDs, dev.DeviceID) + } + } + if len(deviceIDs) > 0 { + payload := map[string]any{"model_name": name} + task, _ := u.tasks.CreateTask("model_sync_all", deviceIDs, payload) + http.Redirect(w, r, "/ui/tasks/"+url.PathEscape(task.ID), http.StatusFound) + return + } + } + + http.Redirect(w, r, "/ui/models?msg="+url.QueryEscape("模型 "+name+" 上传成功"), http.StatusFound) +} + func (u *UI) pageDiagnostics(w http.ResponseWriter, r *http.Request) { u.ensureDevicesLoaded() data := PageData{Title: "日志审计", Devices: u.registry.GetDevices()} @@ -3183,6 +3316,32 @@ func (u *UI) pageDeviceAssignments(w http.ResponseWriter, r *http.Request) { data.MaxUnitsPerDevice = maxUnits if u.preview != nil { if board, err := u.preview.BuildDeviceAssignmentBoard(data.Devices, maxUnits); err == nil { + // Enrich board cards with config sync status. + if u.agent != nil && u.configVersionsRepo != nil { + for i, card := range board.Cards { + versions, _ := u.configVersionsRepo.List(card.DeviceID) + if len(versions) == 0 { + continue + } + expectedSHA := versions[0].ConfigID + for _, dev := range data.Devices { + if dev == nil || dev.DeviceID != card.DeviceID || !dev.Online { + continue + } + body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/status", nil) + if err != nil || code != 200 { + continue + } + var statusResp struct { + SHA256 string `json:"sha256"` + } + if json.Unmarshal(body, &statusResp) == nil { + board.Cards[i].ConfigSynced = strings.EqualFold(statusResp.SHA256, expectedSHA) + } + break + } + } + } data.DeviceAssignmentBoard = board if payload, marshalErr := json.Marshal(board); marshalErr == nil { data.DeviceAssignmentBoardJSON = template.JS(payload) @@ -5056,24 +5215,96 @@ func (u *UI) actionFaceGalleryBuild(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ui/face-gallery?msg="+url.QueryEscape(msg), http.StatusFound) return } - // Parse key stats from output for a clean message + // Parse build report for quality diagnostics. + // Output sections: per person used samples, failure reasons, persons skipped. persons := "0" - photos := "0" + photosOK := "0" + photosFailed := "0" + failureReasons := map[string]string{} + perPerson := map[string]string{} + currentSection := "" for _, line := range strings.Split(output, "\n") { - if strings.HasPrefix(line, "enrolled_persons:") { + line = strings.TrimSpace(line) + switch { + case 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:")) + case strings.HasPrefix(line, "ok_images:"): + photosOK = strings.TrimSpace(strings.TrimPrefix(line, "ok_images:")) + case strings.HasPrefix(line, "failed_images:"): + photosFailed = strings.TrimSpace(strings.TrimPrefix(line, "failed_images:")) + case strings.HasPrefix(line, "-- per person"): + currentSection = "per_person" + case strings.HasPrefix(line, "-- failure reasons"): + currentSection = "failures" + case strings.HasPrefix(line, "-- persons skipped"): + currentSection = "skipped" + case strings.HasPrefix(line, "-- db selfcheck"): + currentSection = "" + default: + if currentSection == "per_person" && strings.Contains(line, ":") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + perPerson[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + } else if currentSection == "failures" && strings.Contains(line, ":") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + failureReasons[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + } } } - msg := fmt.Sprintf("生成成功,共 %s 人,%s 张照片", persons, photos) + // Register the built face_gallery.db as a standard resource so the + // Resource sync system can detect and distribute it to edge devices. + if u.resourcesRepo != nil { + resourceSvc := service.NewResourceManagementService(u.resourcesRepo) + if err := resourceSvc.RegisterResource(builder.OutputDB, "face_gallery", "face_gallery"); err != nil { + log.Printf("register face_gallery resource: %v", err) + } + } + + // Build result message with quality diagnostics. + reasonCN := map[string]string{ + "no_face": "无人脸", "small_face": "人脸太小", "align_fail": "对齐失败", + "infer_fail": "识别失败", "read_fail": "读取失败", "det_fail": "检测失败", + } + var reasonParts []string + for k, v := range failureReasons { + label := k + if cn, ok := reasonCN[k]; ok { + label = cn + } + reasonParts = append(reasonParts, fmt.Sprintf("%s×%s", label, v)) + } + msg := fmt.Sprintf("生成成功,共 %s 人,%s 张通过", persons, photosOK) + if photosFailed != "0" && photosFailed != "" { + msg += fmt.Sprintf(",%s 张失败", photosFailed) + if len(reasonParts) > 0 { + msg += fmt.Sprintf("(%s)", strings.Join(reasonParts, "、")) + } + } + // Write per-person quality stats for the face gallery page to display. + if len(perPerson) > 0 { + metaPath := filepath.Join("resources", "standard_resources", "face_gallery", "build_meta.json") + if metaJSON, err := json.Marshal(perPerson); err == nil { + os.WriteFile(metaPath, metaJSON, 0o644) + } + } + http.Redirect(w, r, "/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") + // Load build quality stats if available. + metaPath := filepath.Join("resources", "standard_resources", "face_gallery", "build_meta.json") + if metaBytes, err := os.ReadFile(metaPath); err == nil { + var meta map[string]string + if json.Unmarshal(metaBytes, &meta) == nil { + data.FaceGalleryQuality = meta + } + } if strings.TrimSpace(u.dbPath) != "" { if repo := storage.NewFaceGalleryRepo(u.dbPath); repo != nil { if persons, err := repo.ListPersons(); err == nil { @@ -5081,9 +5312,52 @@ func (u *UI) pageFaceGallery(w http.ResponseWriter, r *http.Request) { } } } + + // Load face_gallery resource status board for device sync section + u.ensureDevicesLoaded() + data.Devices = u.registry.GetDevices() + if u.resourcesRepo != nil { + allResources, _ := u.resourcesRepo.List() + faceResources := make([]storage.StandardResourceRecord, 0) + for _, r := range allResources { + if r.ResourceType == "face_gallery" { + faceResources = append(faceResources, r) + } + } + if len(faceResources) > 0 { + data.StandardResources = faceResources + board := service.FetchAndBuildResourceBoard(u.agent, data.Devices, faceResources) + data.ResourceStatusBoard = &board + } + } u.render(w, r, "face_gallery", data) } +func (u *UI) actionFaceGallerySync(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + deviceIDs := make([]string, 0) + for _, id := range r.Form["device_id"] { + id = strings.TrimSpace(id) + if id != "" { + deviceIDs = append(deviceIDs, id) + } + } + if len(deviceIDs) == 0 { + http.Error(w, "missing device_id", http.StatusBadRequest) + return + } + payload := map[string]any{"resource_name": "face_gallery"} + task, err := u.tasks.CreateTask("resource_sync_all", deviceIDs, payload) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/ui/tasks/"+url.PathEscape(task.ID), http.StatusFound) +} + func (u *UI) actionFaceGalleryDelete(w http.ResponseWriter, r *http.Request) { id, _ := strconv.Atoi(r.FormValue("id")) if id <= 0 { diff --git a/internal/web/ui/assets/style.css b/internal/web/ui/assets/style.css index 4a74fe8..e9b108d 100644 --- a/internal/web/ui/assets/style.css +++ b/internal/web/ui/assets/style.css @@ -250,6 +250,7 @@ tbody tr[data-nav-row]{cursor:pointer} .pill.bad{background:var(--danger-soft);border-color:var(--danger-soft-hover);color:var(--danger-soft-text)} .pill.run{background:var(--surface-soft);border-color:var(--border-strong);color:var(--primary)} .pill.warn{background:var(--surface-soft);border-color:var(--border-strong);color:var(--amber)} +.pill.muted-pill{background:var(--surface-soft);border-color:var(--border);color:var(--muted)} .actions{display:flex;flex-wrap:wrap;gap:8px} .actions.compact{gap:6px} @@ -410,6 +411,9 @@ pre{margin-top:12px;padding:12px;border-radius:var(--radius);border:1px solid va .assignment-device-head .mono{display:block;margin-top:4px;font-size:11px;color:var(--muted)} .assignment-device-metrics{display:flex;align-items:center;gap:8px} .assignment-device-metrics strong{font-size:12px;font-weight:500;color:var(--text)} +.config-sync-indicator{font-size:11px;padding:2px 0 4px} +.config-sync-indicator.synced{color:var(--green)} +.config-sync-indicator.pending{color:var(--amber)} .assignment-chip-list{display:flex;flex-wrap:wrap;gap:6px;min-height:24px} .assignment-chip{display:inline-flex;align-items:center;gap:5px;padding:4px 7px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface)} .assignment-chip-unassigned{background:var(--surface-strong)} diff --git a/internal/web/ui/templates/device_assignments.html b/internal/web/ui/templates/device_assignments.html index 12f7075..0f72047 100644 --- a/internal/web/ui/templates/device_assignments.html +++ b/internal/web/ui/templates/device_assignments.html @@ -290,6 +290,9 @@ ${card.status} +
+ ${card.config_synced ? '✅ 配置已同步' : '⚠ 配置待更新'} +
${addControls} `; diff --git a/internal/web/ui/templates/face_gallery.html b/internal/web/ui/templates/face_gallery.html index 52d8a56..222acca 100644 --- a/internal/web/ui/templates/face_gallery.html +++ b/internal/web/ui/templates/face_gallery.html @@ -13,14 +13,15 @@ {{len .FaceGalleryPersons}} 人
+
{{if .FaceGalleryPersons}} -
+
{{range .FaceGalleryPersons}} -
+
{{if .Photos}} @@ -33,7 +34,7 @@ {{end}}
-
{{.Name}} {{.PhotoCount}}张
+
{{.Name}} {{.PhotoCount}}张{{$quality := index $.FaceGalleryQuality .Name}}{{if $quality}} {{$quality}}通过{{end}}
@@ -50,6 +51,58 @@ {{end}}
+{{$board := .ResourceStatusBoard}} +{{if and $board (gt (len .StandardResources) 0)}} +
+
+
+

{{icon "devices"}}设备同步状态

+
将人脸库下发到 Edge 设备,SHA256 比对显示同步状态。
+
+
+ + {{range .Devices}}{{if .Online}}{{end}}{{end}} + + +
+
+
+ + + + + {{range .StandardResources}}{{end}} + + + + + {{range $board.Rows}} + + + {{range .Cells}} + + {{end}} + + + {{end}} + +
设备{{shortHash .SHA256}}操作
+
{{.DeviceName}}
+
{{.DeviceID}}
+
+ {{if eq .Status "ok"}}一致 + {{else if eq .Status "mismatch"}}不一致 + {{else}}缺失{{end}} + +
+ + +
+
+
+
+{{end}} + diff --git a/internal/web/ui/templates/models.html b/internal/web/ui/templates/models.html index 62518bb..7da77b1 100644 --- a/internal/web/ui/templates/models.html +++ b/internal/web/ui/templates/models.html @@ -7,9 +7,9 @@
统一维护标准模型目录,设备页只看当前生效状态,更新动作统一走任务。
- +
- {{range .Devices}}{{if .Online}}{{end}}{{end}} + {{if $board}}{{range $board.Rows}}{{if .NeedsSync}}{{end}}{{end}}{{end}}
@@ -93,6 +93,7 @@ {{if eq .Status "ok"}}一致 {{else if eq .Status "mismatch"}}不一致 + {{else if eq .Status "na"}}不适用 {{else}}缺失{{end}} {{end}} @@ -119,4 +120,45 @@
+ + + + + + {{end}}