Refine model management status and optional slot rendering

This commit is contained in:
tian 2026-05-05 12:57:45 +08:00
parent 7ae66b2569
commit 28764a1a6d
8 changed files with 287 additions and 19 deletions

View File

@ -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"), `{

View File

@ -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

View File

@ -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++

View File

@ -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)
}
}

View File

@ -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 "-"

View File

@ -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)}

View File

@ -49,19 +49,20 @@
<div class="table-wrap">
<table>
<thead>
<tr><th>模型名</th><th>文件名</th><th>版本</th><th>哈希</th><th>大小</th></tr>
<tr><th>模型名</th><th>分类</th><th>文件名</th><th>版本</th><th>哈希</th><th>大小</th></tr>
</thead>
<tbody>
{{range .StandardModels}}
<tr>
<td><span class="mono table-key">{{.Name}}</span></td>
<td>{{modelTypeLabel .ModelType}}</td>
<td class="mono">{{.FileName}}</td>
<td>{{if .Version}}{{.Version}}{{else}}auto{{end}}</td>
<td class="mono">{{shortHash .SHA256}}</td>
<td>{{.SizeBytes}}</td>
</tr>
{{else}}
<tr><td colspan="5" class="muted">标准模型目录为空。</td></tr>
<tr><td colspan="6" class="muted">标准模型目录为空。</td></tr>
{{end}}
</tbody>
</table>
@ -76,11 +77,12 @@
</div>
</div>
<div class="table-wrap">
<table>
<table class="models-status-table">
<thead>
<tr>
<th>设备</th>
{{range .StandardModels}}<th>{{.Name}}</th>{{end}}
{{range .StandardModels}}<th class="model-status-col" title="{{.Name}}"><span class="model-status-label">{{modelTypeLabel .ModelType}}</span></th>{{end}}
<th class="model-extra-col">非标准模型</th>
</tr>
</thead>
<tbody>
@ -98,10 +100,24 @@
{{else}}<span class="pill bad">缺失</span>{{end}}
</td>
{{end}}
<td>
{{if gt .ExtraModelCount 0}}
<details class="mini-details">
<summary>{{.ExtraModelCount}} 个 · 更多</summary>
<div class="mini-details-body">
{{range .ExtraModels}}
<div class="mini-details-item mono">{{.FileName}}</div>
{{end}}
</div>
</details>
{{else}}
<span class="muted">0</span>
{{end}}
</td>
</tr>
{{end}}
{{else}}
<tr><td colspan="2" class="muted">暂无设备模型状态。</td></tr>
<tr><td colspan="99" class="muted">暂无设备模型状态。</td></tr>
{{end}}
</tbody>
</table>

View File

@ -1843,7 +1843,51 @@ func TestUI_LoadConfigStatusPrefersPreviousConfigFields(t *testing.T) {
}
func TestUI_ModelsPageShowsStandardModelsAndDeviceStatus(t *testing.T) {
ui := newTestUI(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/models/status":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"models":[{"name":"face_det_scrfd_500m_640_rk3588","file_name":"face_det_scrfd_500m_640_rk3588.rknn","sha256":"sha-1","size_bytes":123},{"name":"best-640","file_name":"best-640.rknn","sha256":"sha-x","size_bytes":456}]}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
host, portText, err := net.SplitHostPort(strings.TrimPrefix(server.Listener.Addr().String(), "[::]"))
if err != nil {
t.Fatalf("SplitHostPort: %v", err)
}
port, err := strconv.Atoi(portText)
if err != nil {
t.Fatalf("Atoi: %v", err)
}
cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
dbPath := filepath.Join(t.TempDir(), "models.db")
store, err := storage.OpenSQLite(dbPath)
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
reg := service.NewRegistryService(cfg, nil, storage.NewDevicesRepo(store.DB()))
reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true})
agent := service.NewAgentClient(cfg)
tasks := service.NewTaskService(cfg, agent, reg)
ui, err := NewUI(nil, reg, agent, tasks, nil)
if err != nil {
t.Fatalf("NewUI: %v", err)
}
modelsRepo := storage.NewModelsRepo(store.DB())
if err := modelsRepo.Save(storage.StandardModelRecord{
Name: "face_det_scrfd_500m_640_rk3588",
FileName: "face_det_scrfd_500m_640_rk3588.rknn",
Version: "v1",
SHA256: "sha-1",
SizeBytes: 123,
ModelType: "face_detection",
}); err != nil {
t.Fatalf("Save: %v", err)
}
ui.SetDBPath(dbPath)
req := httptest.NewRequest(http.MethodGet, "/ui/models", nil)
rr := httptest.NewRecorder()
@ -1853,7 +1897,12 @@ func TestUI_ModelsPageShowsStandardModelsAndDeviceStatus(t *testing.T) {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{"模型管理", "标准模型", "设备模型状态", "完整设备数", "缺失设备数", "版本不一致设备数", "更新全部模型"} {
for _, want := range []string{"模型管理", "标准模型", "设备模型状态", "完整设备数", "缺失设备数", "版本不一致设备数", "更新全部模型", "非标准模型", "人脸检测"} {
if !strings.Contains(body, want) {
t.Fatalf("expected model management HTML to contain %q", want)
}
}
for _, want := range []string{"1 个 · 更多", "best-640.rknn"} {
if !strings.Contains(body, want) {
t.Fatalf("expected model management HTML to contain %q", want)
}