diff --git a/internal/service/config_preview_test.go b/internal/service/config_preview_test.go index b62165f..999f301 100644 --- a/internal/service/config_preview_test.go +++ b/internal/service/config_preview_test.go @@ -241,6 +241,79 @@ func TestConfigPreviewServiceRenderProfileEditorWritesResolvedBindings(t *testin } } +func TestConfigPreviewServiceRenderProfileEditorAllowsUnboundOptionalServiceSlot(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{ + "name":"std_workshop_face_recognition_shoe_alarm", + "source":"standard", + "slots":{ + "inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}], + "services":[{"name":"token_service_main","type":"token_service","required":false,"description":"认证服务"}], + "outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}] + }, + "template":{ + "nodes":[ + {"id":"input_rtsp_main","type":"input_rtsp","url":"${slot:video_input_main.url}"}, + {"id":"alarm_violation","type":"alarm","outputs":[{"external_api":{"getTokenUrl":"${slot:token_service_main.get_token_url}"}}]} + ], + "edges":[] + } +}`) + + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveVideoSource( + "gate_cam_01", + "rtsp", + "东门入口", + "东门主入口摄像头", + `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`, + ); err != nil { + t.Fatalf("SaveVideoSource: %v", err) + } + + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsFromMediaRepo(t, svc) + editor := ConfigProfileEditor{ + Name: "line_a", + Instances: []ConfigProfileInstanceEditor{{ + Name: "cam1", + Template: "std_workshop_face_recognition_shoe_alarm", + VideoSourceRef: "gate_cam_01", + PublishRTSPPort: "8555", + ChannelNo: "cam1", + }}, + } + + result, err := svc.RenderProfileEditor(editor, ConfigPreviewRequest{ + Template: "std_workshop_face_recognition_shoe_alarm", + ConfigID: "preview", + ConfigVersion: "v1", + }) + if err != nil { + t.Fatalf("RenderProfileEditor: %v", err) + } + + var doc map[string]any + if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil { + t.Fatalf("unmarshal render result: %v", err) + } + templates, _ := doc["templates"].(map[string]any) + renderedTemplate, _ := templates["std_workshop_face_recognition_shoe_alarm__cam1"].(map[string]any) + nodes, _ := renderedTemplate["nodes"].([]any) + alarmNode, _ := nodes[1].(map[string]any) + outputs, _ := alarmNode["outputs"].([]any) + output, _ := outputs[0].(map[string]any) + externalAPI, _ := output["external_api"].(map[string]any) + if got := stringValue(externalAPI["getTokenUrl"]); got != "" { + t.Fatalf("expected empty getTokenUrl for unbound optional slot, got %#v", externalAPI) + } +} + func TestConfigPreviewServiceRenderUsesSQLiteProfileAndOverlay(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "configs", "templates", "std_service_test_stream.json"), `{ diff --git a/internal/service/config_runtime_render.go b/internal/service/config_runtime_render.go index bee4e55..6df997a 100644 --- a/internal/service/config_runtime_render.go +++ b/internal/service/config_runtime_render.go @@ -169,7 +169,11 @@ func renderRuntimeSceneInstance(templateRaw map[string]any, templatePath string, if err != nil { return "", nil, nil, err } - renderedTemplateAny, err := expandRuntimeSlotTokens(templateBody, context) + slotRequirements, err := runtimeSlotRequirements(templateRaw) + if err != nil { + return "", nil, nil, err + } + renderedTemplateAny, err := expandRuntimeSlotTokens(templateBody, context, slotRequirements) if err != nil { return "", nil, nil, err } @@ -187,6 +191,24 @@ func renderRuntimeSceneInstance(templateRaw map[string]any, templatePath string, return boundName, renderedTemplate, renderedInstance, nil } +func runtimeSlotRequirements(templateRaw map[string]any) (map[string]bool, error) { + group, err := parseTemplateSlots(templateRaw) + if err != nil { + return nil, err + } + out := map[string]bool{} + for _, slot := range group.Inputs { + out[slot.Name] = slot.Required + } + for _, slot := range group.Services { + out[slot.Name] = slot.Required + } + for _, slot := range group.Outputs { + out[slot.Name] = slot.Required + } + return out, nil +} + func buildRuntimeBindingContext(instance map[string]any) (map[string]any, error) { context := map[string]any{} if sceneMeta, ok := instance["scene_meta"].(map[string]any); ok && len(sceneMeta) > 0 { @@ -212,12 +234,12 @@ func resolvedRuntimeBindingValue(entry map[string]any) map[string]any { return deepCopyMap(entry) } -func expandRuntimeSlotTokens(value any, context map[string]any) (any, error) { +func expandRuntimeSlotTokens(value any, context map[string]any, slotRequirements map[string]bool) (any, error) { switch typed := value.(type) { case map[string]any: out := make(map[string]any, len(typed)) for key, item := range typed { - expanded, err := expandRuntimeSlotTokens(item, context) + expanded, err := expandRuntimeSlotTokens(item, context, slotRequirements) if err != nil { return nil, err } @@ -227,7 +249,7 @@ func expandRuntimeSlotTokens(value any, context map[string]any) (any, error) { case []any: out := make([]any, 0, len(typed)) for _, item := range typed { - expanded, err := expandRuntimeSlotTokens(item, context) + expanded, err := expandRuntimeSlotTokens(item, context, slotRequirements) if err != nil { return nil, err } @@ -239,12 +261,22 @@ func expandRuntimeSlotTokens(value any, context map[string]any) (any, error) { if len(match) != 3 { return typed, nil } + required, known := slotRequirements[match[1]] + if !known { + required = true + } slotValues, _ := context[match[1]].(map[string]any) if slotValues == nil { + if !required { + return "", nil + } return nil, fmt.Errorf("required slot '%s' is not bound", match[1]) } fieldValue, ok := slotValues[match[2]] if !ok { + if !required { + return "", nil + } return nil, fmt.Errorf("required slot field '%s.%s' is not bound", match[1], match[2]) } return deepCopyAny(fieldValue), nil diff --git a/internal/service/model_management.go b/internal/service/model_management.go index dc0f0bc..bdfd8af 100644 --- a/internal/service/model_management.go +++ b/internal/service/model_management.go @@ -35,10 +35,12 @@ type ModelStatusCell struct { } type ModelStatusRow struct { - DeviceID string `json:"device_id"` - DeviceName string `json:"device_name"` - Online bool `json:"online"` - Cells []ModelStatusCell `json:"cells"` + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name"` + Online bool `json:"online"` + Cells []ModelStatusCell `json:"cells"` + ExtraModelCount int `json:"extra_model_count"` + ExtraModels []InstalledModelStatus `json:"extra_models"` } type ModelStatusSummary struct { @@ -88,6 +90,7 @@ func (s *ModelManagementService) SyncStandardModelsFromDirectory(dir string) err Version: "auto", SHA256: sum, SizeBytes: size, + ModelType: inferModelType(entry.Name()), } if err := s.models.Save(record); err != nil { return err @@ -111,6 +114,24 @@ func hashFile(path string) (string, int64, error) { return hex.EncodeToString(hasher.Sum(nil)), size, nil } +func inferModelType(fileName string) string { + name := strings.ToLower(strings.TrimSpace(fileName)) + switch { + case strings.Contains(name, "face_det"), strings.Contains(name, "retinaface"), strings.Contains(name, "scrfd"): + return "face_detection" + case strings.Contains(name, "face_recog"), strings.Contains(name, "mobilefacenet"), strings.Contains(name, "arcface"): + return "face_recognition" + case strings.Contains(name, "ppe"): + return "ppe_detection" + case strings.Contains(name, "shoe"): + return "shoe_detection" + case strings.Contains(name, "object_det"), strings.Contains(name, "yolo"): + return "object_detection" + default: + return "other" + } +} + func BuildModelStatusBoard(standardModels []storage.StandardModelRecord, devices []*models.Device, installed map[string][]InstalledModelStatus) ModelStatusBoard { board := ModelStatusBoard{ Summary: ModelStatusSummary{ @@ -127,11 +148,16 @@ func BuildModelStatusBoard(standardModels []storage.StandardModelRecord, devices for _, item := range installed[device.DeviceID] { index[item.Name] = item } + standardIndex := make(map[string]struct{}, len(standardModels)) + for _, model := range standardModels { + standardIndex[model.Name] = struct{}{} + } row := ModelStatusRow{ - DeviceID: device.DeviceID, - DeviceName: device.DisplayName(), - Online: device.Online, - Cells: make([]ModelStatusCell, 0, len(standardModels)), + DeviceID: device.DeviceID, + DeviceName: device.DisplayName(), + Online: device.Online, + Cells: make([]ModelStatusCell, 0, len(standardModels)), + ExtraModels: make([]InstalledModelStatus, 0), } hasMissing := false hasMismatch := false @@ -158,6 +184,16 @@ func BuildModelStatusBoard(standardModels []storage.StandardModelRecord, devices } row.Cells = append(row.Cells, cell) } + for _, item := range installed[device.DeviceID] { + if _, ok := standardIndex[item.Name]; ok { + continue + } + row.ExtraModels = append(row.ExtraModels, item) + } + sort.Slice(row.ExtraModels, func(i, j int) bool { + return row.ExtraModels[i].Name < row.ExtraModels[j].Name + }) + row.ExtraModelCount = len(row.ExtraModels) switch { case hasMismatch: board.Summary.MismatchDevices++ diff --git a/internal/service/model_management_test.go b/internal/service/model_management_test.go index e6b2b1d..87b131f 100644 --- a/internal/service/model_management_test.go +++ b/internal/service/model_management_test.go @@ -39,6 +39,9 @@ func TestSyncStandardModelsFromDirectory(t *testing.T) { if items[0].SHA256 == "" { t.Fatalf("expected sha256 to be populated: %#v", items[0]) } + if items[0].ModelType != "face_detection" { + t.Fatalf("expected inferred model type face_detection, got %#v", items[0]) + } } func TestBuildModelStatusBoardMarksMissingAndMismatch(t *testing.T) { @@ -67,3 +70,34 @@ func TestBuildModelStatusBoardMarksMissingAndMismatch(t *testing.T) { t.Fatalf("expected second model to be missing, got %#v", board.Rows[0].Cells[1]) } } + +func TestBuildModelStatusBoardSeparatesExtraModels(t *testing.T) { + modelsList := []storage.StandardModelRecord{ + {Name: "face_det", FileName: "face_det.rknn", SHA256: "sha-1"}, + } + devices := []*models.Device{ + {DeviceID: "edge-01", DeviceName: "设备一", Online: true}, + } + installed := map[string][]InstalledModelStatus{ + "edge-01": { + {Name: "face_det", FileName: "face_det.rknn", SHA256: "sha-1"}, + {Name: "best-640", FileName: "best-640.rknn", SHA256: "sha-x"}, + {Name: "mobilefacenet_arcface", FileName: "mobilefacenet_arcface.rknn", SHA256: "sha-y"}, + }, + } + + board := BuildModelStatusBoard(modelsList, devices, installed) + + if len(board.Rows) != 1 { + t.Fatalf("unexpected board rows: %#v", board.Rows) + } + if got := board.Rows[0].ExtraModelCount; got != 2 { + t.Fatalf("expected 2 extra models, got %#v", board.Rows[0]) + } + if len(board.Rows[0].ExtraModels) != 2 { + t.Fatalf("expected extra model details to be preserved, got %#v", board.Rows[0].ExtraModels) + } + if board.Rows[0].ExtraModels[0].Name != "best-640" || board.Rows[0].ExtraModels[1].Name != "mobilefacenet_arcface" { + t.Fatalf("expected sorted extra models, got %#v", board.Rows[0].ExtraModels) + } +} diff --git a/internal/web/ui.go b/internal/web/ui.go index c2ca1af..9a85810 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -211,6 +211,24 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic } 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 "-" + } + }, "displayDeviceName": func(dev *models.Device, status *ConfigStatusView) string { if dev == nil { return "-" diff --git a/internal/web/ui/assets/style.css b/internal/web/ui/assets/style.css index 992ac53..02bc02c 100644 --- a/internal/web/ui/assets/style.css +++ b/internal/web/ui/assets/style.css @@ -275,6 +275,16 @@ tbody tr[data-nav-row]{cursor:pointer} .scene-summary-details[open] summary::before{transform:rotate(45deg)} .scene-summary-details[open] summary{margin-bottom:10px;color:var(--text)} .scene-summary-details .info-list{margin-top:0} +.mini-details summary{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:12px;color:var(--table-link);list-style:none} +.mini-details summary::before{content:"";width:7px;height:7px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .16s ease} +.mini-details summary::-webkit-details-marker{display:none} +.mini-details[open] summary::before{transform:rotate(45deg)} +.mini-details-body{margin-top:8px;padding:8px 10px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft)} +.mini-details-item{font-size:11px;line-height:1.5;color:var(--text)} +.mini-details-item+.mini-details-item{margin-top:4px} +.models-status-table .model-status-col{min-width:92px;max-width:124px;vertical-align:bottom} +.models-status-table .model-status-label{display:block;font-size:11px;line-height:1.25;white-space:normal;word-break:break-word;overflow-wrap:anywhere} +.models-status-table .model-extra-col{min-width:150px} .scene-actions-row{margin-top:12px} .field-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-bottom:14px} .field-grid label>span{display:block;margin-bottom:6px;font-size:12px;color:var(--muted)} diff --git a/internal/web/ui/templates/models.html b/internal/web/ui/templates/models.html index 29cfb22..f4de65f 100644 --- a/internal/web/ui/templates/models.html +++ b/internal/web/ui/templates/models.html @@ -49,19 +49,20 @@
| 模型名 | 文件名 | 版本 | 哈希 | 大小 | |
|---|---|---|---|---|---|
| 模型名 | 分类 | 文件名 | 版本 | 哈希 | 大小 |
| {{.Name}} | +{{modelTypeLabel .ModelType}} | {{.FileName}} | {{if .Version}}{{.Version}}{{else}}auto{{end}} | {{shortHash .SHA256}} | {{.SizeBytes}} |
| 标准模型目录为空。 | |||||
| 标准模型目录为空。 | |||||
| 设备 | - {{range .StandardModels}}{{.Name}} | {{end}} + {{range .StandardModels}}{{modelTypeLabel .ModelType}} | {{end}} +非标准模型 |
+ {{if gt .ExtraModelCount 0}}
+
+
+ {{else}}
+ 0
+ {{end}}
+ {{.ExtraModelCount}} 个 · 更多+
+ {{range .ExtraModels}}
+
+ {{.FileName}}
+ {{end}}
+ |
{{end}}
{{else}}
- ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 暂无设备模型状态。 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 暂无设备模型状态。 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||