@@ -75,18 +137,20 @@
-
{{icon "profile"}}业务配置
+ {{icon "device"}}视频源
{{range .AssetProfiles}}
-
- {{.Name}}
- {{len .Instances}} 路通道
-
+ {{range .Instances}}
+
+ {{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}}
+ {{if .RTSPURL}}{{.RTSPURL}}{{else}}未配置 RTSP{{end}}
+
+ {{end}}
{{else}}
{{end}}
@@ -95,7 +159,27 @@
-
{{icon "overlay"}}叠加项
+ {{icon "service"}}第三方服务
+
+
+
+
+
+
+
+
+
{{icon "overlay"}}调试参数
@@ -106,26 +190,14 @@
{{else}}
{{end}}
-
-
-
-
{{icon "config"}}资产说明
-
-
-
-
模板定义处理链与共享服务接入
-
业务配置定义站点、视频源、输出流与通道参数
-
叠加项定义调试、敏感度和运行模式差异
-
原则不回到手工维护多份完整 JSON
-
-
+{{end}}
{{if .Error}}
{{.Error}}
{{end}}
{{template "asset_tabs_end" .}}
diff --git a/internal/web/ui_test.go b/internal/web/ui_test.go
index 4c936eb..f132c7c 100644
--- a/internal/web/ui_test.go
+++ b/internal/web/ui_test.go
@@ -289,7 +289,7 @@ func TestUI_DeviceOverviewHidesBatchBarWithoutSelection(t *testing.T) {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
- for _, forbidden := range []string{"batch-toolbar", "已选", "重启服务", "启动服务", "停止服务", "重载配置", "回滚配置", "下发业务配置", "清空选择"} {
+ for _, forbidden := range []string{"batch-toolbar", "已选", "重启服务", "启动服务", "停止服务", "重载配置", "回滚配置", "下发场景配置", "清空选择"} {
if strings.Contains(body, forbidden) {
t.Fatalf("device overview should not show batch controls without selection, found %q in:\n%s", forbidden, body)
}
@@ -308,7 +308,7 @@ func TestUI_DeviceOverviewShowsBatchBarWhenDevicesSelected(t *testing.T) {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
- for _, want := range []string{"batch-toolbar", "已选 2 台", "重启服务", "启动服务", "停止服务", "重载配置", "回滚配置", "下发业务配置", "清空选择", "将重载当前业务配置", "将回滚到上一版业务配置"} {
+ for _, want := range []string{"batch-toolbar", "已选 2 台", "重启服务", "启动服务", "停止服务", "重载配置", "回滚配置", "下发场景配置", "清空选择", "将重载当前场景配置", "将回滚到上一版场景配置"} {
if !strings.Contains(body, want) {
t.Fatalf("expected batch controls HTML to contain %q, got:\n%s", want, body)
}
@@ -338,12 +338,12 @@ func TestUI_DeviceBatchConfigPageShowsSelectedSummaryAndSources(t *testing.T) {
}
body := rr.Body.String()
for _, want := range []string{
- "下发业务配置",
- "业务配置",
+ "下发场景配置",
+ "场景配置",
"已选设备",
"入口识别节点",
"辅助节点",
- "业务配置摘要",
+ "场景配置摘要",
"local_3588_test",
"A厂区视觉识别",
"std_workshop_face_recognition_shoe_alarm",
@@ -490,7 +490,17 @@ func TestUI_ActionDevicesBatchActionKeepsDevicesOnError(t *testing.T) {
func TestUI_AssetProfilePageShowsEditorTabs(t *testing.T) {
ui := newTestUI(t)
- ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createProfileEditorMediaRepo(t)})
+ root := createProfileEditorMediaRepo(t)
+ store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
+ if err != nil {
+ t.Fatalf("OpenSQLite: %v", err)
+ }
+ defer store.Close()
+ repo := storage.NewAssetsRepo(store.DB())
+ mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
+ mustSaveIntegrationService(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","description":"认证","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`)
+ mustSaveIntegrationService(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","description":"告警","config":{"put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`)
+ ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
req := httptest.NewRequest(http.MethodGet, "/ui/assets/profiles/local_3588_test", nil)
req = withChiURLParam(req, "name", "local_3588_test")
rr := httptest.NewRecorder()
@@ -502,10 +512,14 @@ func TestUI_AssetProfilePageShowsEditorTabs(t *testing.T) {
}
body := rr.Body.String()
for _, want := range []string{
- "业务配置",
- "业务配置名称",
+ "场景配置",
+ "场景名称",
"业务名称",
"站点名",
+ "第三方服务",
+ "对象存储",
+ "认证服务",
+ "告警服务",
"视频通道",
"cam1",
"cam2",
@@ -535,13 +549,25 @@ func TestUI_AssetProfilePageShowsEditorTabs(t *testing.T) {
func TestUI_ActionAssetProfileSaveWritesProfileFile(t *testing.T) {
root := createProfileEditorMediaRepo(t)
ui := newTestUI(t)
- ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root})
+ store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
+ if err != nil {
+ t.Fatalf("OpenSQLite: %v", err)
+ }
+ defer store.Close()
+ repo := storage.NewAssetsRepo(store.DB())
+ mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
+ mustSaveIntegrationService(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","description":"认证","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`)
+ mustSaveIntegrationService(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","description":"告警","config":{"put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`)
+ ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
form := url.Values{}
form.Set("profile_name", "local_3588_test")
form.Set("business_name", "B厂区视觉识别")
form.Set("description", "updated profile")
form.Set("site_name", "B厂区")
+ form.Set("object_storage_ref", "minio_main")
+ form.Set("token_service_ref", "token_main")
+ form.Set("alarm_service_ref", "alarm_main")
form.Set("instances[0].name", "cam1")
form.Set("instances[0].template", "std_workshop_face_recognition_shoe_alarm")
form.Set("instances[0].display_name", "西门入口")
@@ -573,21 +599,27 @@ func TestUI_ActionAssetProfileSaveWritesProfileFile(t *testing.T) {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
- if !strings.Contains(body, "业务配置已保存") {
+ if !strings.Contains(body, "场景配置已保存") {
t.Fatalf("expected save success message, got:\n%s", body)
}
- raw, err := os.ReadFile(filepath.Join(root, "configs", "profiles", "local_3588_test.json"))
+ saved, err := repo.GetProfile("local_3588_test")
if err != nil {
- t.Fatalf("read saved profile: %v", err)
+ t.Fatalf("GetProfile: %v", err)
+ }
+ if saved == nil {
+ t.Fatal("expected saved profile in repo")
}
var doc map[string]any
- if err := json.Unmarshal(raw, &doc); err != nil {
+ if err := json.Unmarshal([]byte(saved.BodyJSON), &doc); err != nil {
t.Fatalf("unmarshal saved profile: %v", err)
}
if doc["description"] != "updated profile" {
t.Fatalf("unexpected description: %#v", doc)
}
+ if doc["object_storage_ref"] != "minio_main" || doc["token_service_ref"] != "token_main" || doc["alarm_service_ref"] != "alarm_main" {
+ t.Fatalf("expected integration refs in saved profile, got %#v", doc)
+ }
queue, _ := doc["queue"].(map[string]any)
if queue["size"] != float64(9) {
t.Fatalf("expected queue size 9, got %#v", queue)
@@ -772,6 +804,13 @@ func writeTestFile(t *testing.T, path string, body string) {
}
}
+func mustSaveIntegrationService(t *testing.T, repo *storage.AssetsRepo, name string, serviceType string, body string) {
+ t.Helper()
+ if err := repo.SaveIntegrationService(name, serviceType, name, true, body); err != nil {
+ t.Fatalf("SaveIntegrationService(%s): %v", name, err)
+ }
+}
+
func TestUI_TaskPageRendersBatchSummaryAndDeviceResults(t *testing.T) {
ui := newTestUI(t)
ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
@@ -863,7 +902,7 @@ func TestUI_DevicePageIncludesFilterAndControlEntry(t *testing.T) {
t.Fatalf("expected device page HTML to contain %q", want)
}
}
- for _, forbidden := range []string{"查看配置资产", "查看操作审计"} {
+ for _, forbidden := range []string{"查看基础配置", "查看操作审计"} {
if strings.Contains(body, forbidden) {
t.Fatalf("device overview should not contain redundant link %q", forbidden)
}
@@ -1247,8 +1286,8 @@ func TestUI_ConfigPreviewPageShowsTemplateProfileOverlayForm(t *testing.T) {
for _, want := range []string{
"配置预览",
"模板",
- "业务配置",
- "配置叠加项",
+ "场景配置",
+ "调试参数",
"config_id",
"config_version",
"生成预览",
@@ -1542,7 +1581,7 @@ func TestUI_ConfigPreviewExplainsConfigIdentityFields(t *testing.T) {
"config_id",
"config_version",
"SHA256",
- "配置叠加项",
+ "调试参数",
"face_debug",
} {
if !strings.Contains(body, want) {
@@ -1750,11 +1789,13 @@ func TestUI_SidebarMatchesInformationArchitecture(t *testing.T) {
for _, want := range []string{
"总览",
"设备",
- "识别配置",
+ "场景配置",
+ "基础配置",
"任务",
"诊断",
`href="/ui/dashboard"`,
`href="/ui/devices"`,
+ `href="/ui/plans"`,
`href="/ui/assets"`,
`href="/ui/tasks"`,
`href="/ui/diagnostics"`,
@@ -1786,7 +1827,7 @@ func TestUI_DeviceConfigPageShowsDeviceSelector(t *testing.T) {
t.Fatalf("expected config selector page to contain %q, got:\n%s", want, body)
}
}
- for _, forbidden := range []string{"模板", "业务配置", "叠加项", "配置资产"} {
+ for _, forbidden := range []string{"识别模板", "调试参数"} {
if strings.Contains(body, forbidden) {
t.Fatalf("expected config selector page to avoid asset-library copy %q", forbidden)
}
@@ -1939,18 +1980,56 @@ func TestUI_AssetsPageDefinesConfigAssetScope(t *testing.T) {
writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","description":"template","params":{"minio_endpoint":"http://10.0.0.49:9000"},"template":{"nodes":[{},{}],"edges":[[]]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"local_3588_test","business_name":"A厂区视觉识别","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","params":{"display_name":"东门入口"}}]}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`)
- ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root})
+ store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
+ if err != nil {
+ t.Fatalf("OpenSQLite: %v", err)
+ }
+ defer store.Close()
+ repo := storage.NewAssetsRepo(store.DB())
+ mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
+ ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
req := httptest.NewRequest(http.MethodGet, "/ui/assets", nil)
rr := httptest.NewRecorder()
ui.pageAssets(rr, req)
body := rr.Body.String()
- for _, want := range []string{"识别配置", "总览", "模板", "业务配置", "叠加项", "local_3588_test", "std_workshop_face_recognition_shoe_alarm", "face_debug"} {
+ for _, want := range []string{"基础配置", "总览", "视频源", "识别模板", "第三方服务", "调试参数", "std_workshop_face_recognition_shoe_alarm", "face_debug"} {
if !strings.Contains(body, want) {
t.Fatalf("expected assets HTML to contain %q", want)
}
}
+ if !strings.Contains(body, "minio_main") {
+ t.Fatalf("expected assets overview to contain integration service, got:\n%s", body)
+ }
+}
+
+func TestUI_AssetIntegrationsPageShowsStructuredServices(t *testing.T) {
+ ui := newTestUI(t)
+ root := t.TempDir()
+ writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`)
+ writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","object_storage_ref":"minio_main","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","params":{"rtsp_url":"rtsp://10.0.0.1/live"}}]}`)
+ writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
+ store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
+ if err != nil {
+ t.Fatalf("OpenSQLite: %v", err)
+ }
+ defer store.Close()
+ repo := storage.NewAssetsRepo(store.DB())
+ mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
+ mustSaveIntegrationService(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","description":"认证","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`)
+ ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
+
+ req := httptest.NewRequest(http.MethodGet, "/ui/assets/integrations", nil)
+ rr := httptest.NewRecorder()
+ ui.pageAssetIntegrations(rr, req)
+
+ body := rr.Body.String()
+ for _, want := range []string{"第三方服务列表", "minio_main", "token_main", "对象存储", "认证服务", "http://10.0.0.49:9000 / myminio", "1"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("expected integrations page to contain %q, got:\n%s", want, body)
+ }
+ }
}
func TestUI_ProfileAssetPageShowsInstanceSummary(t *testing.T) {
@@ -1992,7 +2071,7 @@ func TestUI_ProfileAssetPageShowsInstanceSummary(t *testing.T) {
ui.pageAssetProfile(rr, req)
body := rr.Body.String()
- for _, want := range []string{"业务配置", "local_3588_test", "业务名称", "A厂区视觉识别", "站点名", "A厂区", "东门入口", "rtsp://10.0.0.1/live", "高级设置", "queue_debug"} {
+ for _, want := range []string{"场景配置", "local_3588_test", "业务名称", "A厂区视觉识别", "站点名", "A厂区", "东门入口", "rtsp://10.0.0.1/live", "高级设置", "queue_debug"} {
if !strings.Contains(body, want) {
t.Fatalf("expected profile asset HTML to contain %q, got:\n%s", want, body)
}
@@ -2019,7 +2098,7 @@ func TestUI_TemplateAssetsPageShowsListAndSelectedDetail(t *testing.T) {
ui.pageAssetTemplates(rr, req)
body := rr.Body.String()
- for _, want := range []string{"模板列表", "std_workshop_face_recognition_shoe_alarm", "helmet", "模板详情", "helmet template"} {
+ for _, want := range []string{"识别模板列表", "std_workshop_face_recognition_shoe_alarm", "helmet", "模板详情", "helmet template"} {
if !strings.Contains(body, want) {
t.Fatalf("expected template assets page to contain %q, got:\n%s", want, body)
}
@@ -2041,7 +2120,7 @@ func TestUI_OverlayAssetsPageShowsListAndSelectedDetail(t *testing.T) {
ui.pageAssetOverlays(rr, req)
body := rr.Body.String()
- for _, want := range []string{"配置叠加项列表", "face_debug", "night_relaxed", "relaxed", "cam2"} {
+ for _, want := range []string{"调试参数列表", "face_debug", "night_relaxed", "relaxed", "cam2"} {
if !strings.Contains(body, want) {
t.Fatalf("expected overlay assets page to contain %q, got:\n%s", want, body)
}
@@ -2075,7 +2154,7 @@ func TestUI_ProfileAssetsPageShowsListAndSelectedEditor(t *testing.T) {
ui.pageAssetProfiles(rr, req)
body := rr.Body.String()
- for _, want := range []string{"业务配置列表", "local_3588_test", "night_shift", "业务配置", "夜班巡检", "night profile", "西门", "rtsp://10.0.0.9/live"} {
+ for _, want := range []string{"场景配置列表", "local_3588_test", "night_shift", "场景配置", "夜班巡检", "night profile", "西门", "rtsp://10.0.0.9/live"} {
if !strings.Contains(body, want) {
t.Fatalf("expected profile assets page to contain %q, got:\n%s", want, body)
}
@@ -2183,7 +2262,7 @@ func TestUI_SidebarMatchesApprovedIoTArchitecture(t *testing.T) {
ui := newTestUI(t)
html := renderPage(t, ui, "/ui/devices")
- for _, label := range []string{"总览", "设备", "识别配置", "任务", "诊断"} {
+ for _, label := range []string{"总览", "设备", "场景配置", "基础配置", "任务", "诊断"} {
if !strings.Contains(html, label) {
t.Fatalf("expected sidebar label %q in html: %s", label, html)
}
@@ -3006,7 +3085,7 @@ func TestUI_AuditPagePrefersPersistedAuditLogs(t *testing.T) {
ui.auditRepo = auditRepo
html := renderPage(t, ui, "/ui/audit")
- for _, want := range []string{"task-99", "gate_a", "下发业务配置", "成功", "edge-01"} {
+ for _, want := range []string{"task-99", "gate_a", "下发场景配置", "成功", "edge-01"} {
if !strings.Contains(html, want) {
t.Fatalf("expected audit page to contain %q, got: %s", want, html)
}
From f41a924a2dfba6775a52fba8b29593cd4377d040 Mon Sep 17 00:00:00 2001
From: tian <11429339@qq.com>
Date: Wed, 29 Apr 2026 11:25:48 +0800
Subject: [PATCH 03/10] Add video sources design spec
---
.../specs/2026-04-29-video-sources-design.md | 323 ++++++++++++++++++
1 file changed, 323 insertions(+)
create mode 100644 docs/superpowers/specs/2026-04-29-video-sources-design.md
diff --git a/docs/superpowers/specs/2026-04-29-video-sources-design.md b/docs/superpowers/specs/2026-04-29-video-sources-design.md
new file mode 100644
index 0000000..aa9f225
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-29-video-sources-design.md
@@ -0,0 +1,323 @@
+# 视频源结构化设计
+
+## 背景
+
+当前后台已经将配置体系逐步拆分为两层:
+
+- `场景配置`:定义一个最终要运行的业务场景
+- `基础配置`:承载可被场景配置复用的公共配置
+
+第三方服务已经明确要作为一类可复用的基础配置存在。视频输入端也具有相同特征:
+
+- 由客户现场提供
+- 生命周期通常长于单个场景配置
+- 多个场景可能复用同一路输入流
+- 修改地址或元信息时,不应该逐个修改场景配置
+
+因此,视频输入不应继续散落在场景配置实例的 `rtsp_url` 等字段中,而应独立为一类基础配置。
+
+## 核心定义
+
+本设计中的**一个视频源 = 一路输入流**。
+
+它不是一台物理摄像机设备,也不是一台 NVR,而是一条能够被场景配置直接引用的输入流。
+
+例如:
+
+- 一台摄像机只有一路 RTSP,则对应一个视频源
+- 一个 NVR 下有 8 路通道,则应建 8 个视频源
+
+这个定义与当前识别链路最匹配,也最利于场景配置引用和后续维护。
+
+## 目标
+
+本次设计要解决以下问题:
+
+1. 为视频源建立统一、结构化的数据模型
+2. 在 `基础配置` 中增加独立的 `视频源` 管理页面
+3. 让 `场景配置` 通过引用视频源,而不是直接填写 `rtsp_url`
+4. 在不破坏现有配置链路的前提下,为后续逐步替换场景内联输入字段打基础
+
+## 非目标
+
+本次不包含以下内容:
+
+- 不做完整的摄像机资产台账
+- 不引入厂商、型号、采购信息、维护记录等固定资产字段
+- 不做视频源在线探测或取流连通性检测
+- 不做自动读取码流元信息
+- 不处理多通道设备实体模型
+
+## 设计原则
+
+1. 一个视频源只表示一路可引用输入流
+2. 识别相关字段优先,现场安装字段作为可选补充
+3. 表单默认保持简洁,非必要字段不阻塞创建
+4. 页面交互和第三方服务保持一致,降低认知成本
+
+## 字段设计
+
+推荐将视频源字段分为三组。
+
+### 1. 基本信息
+
+- `name`:视频源名称,唯一标识
+- `source_type`:视频源类型
+- `area`:区域
+- `description`:描述
+
+说明:
+
+- `name` 用于被场景配置引用
+- `area` 用于表达现场语义,例如“东门入口”“1号产线”“仓库西侧”
+- `description` 用于补充说明
+
+### 2. 输入参数
+
+- `url`:输入地址
+- `resolution`:分辨率,例如 `1920x1080`
+- `fps`:帧率
+- `video_format`:视频格式,例如 `h264`、`h265`、`mjpeg`
+
+说明:
+
+- `url` 必填
+- `resolution`、`fps`、`video_format` 可选
+
+### 3. 安装信息
+
+- `focal_length`:焦距
+- `mount_height`:安装高度
+- `mount_angle`:安装角度
+
+说明:
+
+- 三个字段均为可选
+- 它们主要用于表达现场安装条件,不应阻塞视频源创建
+
+## 类型设计
+
+第一版推荐支持以下视频源类型:
+
+- `rtsp`
+- `rtmp`
+- `file`
+- `usb_camera`
+
+当前主路径仍然是 `rtsp`,但枚举应预先保留扩展空间。
+
+页面文案可使用:
+
+- RTSP
+- RTMP
+- 文件流
+- USB 摄像头
+
+## 数据结构
+
+视频源建议使用与第三方服务类似的“顶层基础字段 + config 承载详细参数”的方式。
+
+推荐结构:
+
+```json
+{
+ "name": "gate_cam_01",
+ "source_type": "rtsp",
+ "area": "东门入口",
+ "description": "东门主入口摄像头",
+ "config": {
+ "url": "rtsp://10.0.0.1/live",
+ "resolution": "1920x1080",
+ "fps": 25,
+ "video_format": "h264",
+ "focal_length": "4mm",
+ "mount_height": "3.2m",
+ "mount_angle": "15deg"
+ }
+}
+```
+
+说明:
+
+- 顶层保持对象识别字段
+- `config` 用于承载输入参数和安装信息
+- 第一版不再继续向下拆分成多层嵌套,避免结构过重
+
+## 与现有配置字段的关系
+
+当前场景配置实例中已存在:
+
+- `display_name`
+- `site_name`
+- `rtsp_url`
+- `channel_no`
+
+在新结构下:
+
+- `rtsp_url` 应逐步由视频源 `config.url` 替代
+- `display_name` 仍属于场景实例显示语义,不属于视频源本身
+- `site_name` 仍偏场景/站点上下文,不建议塞进视频源
+- `channel_no` 如果表达的是输入流标识,可保留在场景实例或后续再决定是否并入视频源
+
+## 场景配置中的引用方式
+
+场景配置不应长期继续直接持有 `rtsp_url`,而应改为引用视频源。
+
+由于当前场景配置是多实例结构,推荐按实例增加引用字段:
+
+- `video_source_ref`
+
+示例:
+
+```json
+{
+ "instances": [
+ {
+ "name": "cam1",
+ "template": "std_workshop_face_recognition_shoe_alarm",
+ "video_source_ref": "gate_cam_01",
+ "params": {
+ "display_name": "东门入口"
+ }
+ }
+ ]
+}
+```
+
+这比把视频源引用放在整个场景顶层更合理,因为一个场景通常会包含多路输入。
+
+## 兼容策略
+
+第一阶段建议采用兼容式落地:
+
+1. 新建视频源资产
+2. 场景配置实例开始支持 `video_source_ref`
+3. 若实例中存在 `video_source_ref`,则预览和下发时优先使用视频源中的 `url`
+4. 若不存在 `video_source_ref`,仍兼容现有 `rtsp_url`
+
+这样可以逐步迁移,而不需要一次性改完所有历史配置。
+
+## UI 设计
+
+入口:
+
+- `基础配置 -> 视频源`
+
+页面交互与第三方服务保持一致:
+
+- 顶部按钮:`新增视频源`、`编辑`、`删除`
+- 列表区:视频源列表
+- 详情区:默认只读
+- 点击 `编辑` 后进入编辑态
+- 点击 `新增视频源` 后清空详情并聚焦名称输入框
+
+## 列表页字段
+
+推荐列表列:
+
+- 视频源名称
+- 类型
+- 区域
+- URL 摘要
+- 分辨率
+- 帧率
+
+说明:
+
+- 列表优先展示能帮助快速识别和定位的视频信息
+- 焦距、安装高度、安装角度不放在列表里
+
+## 详情页字段分组
+
+### 基本信息
+
+- 视频源名称
+- 类型
+- 区域
+- 描述
+
+### 输入参数
+
+- URL
+- 分辨率
+- 帧率
+- 视频格式
+
+### 安装信息
+
+- 焦距
+- 安装高度
+- 安装角度
+
+## 校验规则
+
+### 必填
+
+- `name`
+- `source_type`
+- `config.url`
+
+### 可选
+
+- `area`
+- `description`
+- `config.resolution`
+- `config.fps`
+- `config.video_format`
+- `config.focal_length`
+- `config.mount_height`
+- `config.mount_angle`
+
+### 基本格式建议
+
+- `name` 必须唯一
+- `fps` 如填写,应可解析为数值
+- `resolution` 第一版可先作为字符串保存,不强制拆成宽高
+
+## 删除约束
+
+如果某个视频源已被场景配置实例引用,则不允许直接删除。
+
+删除前应检查:
+
+- 是否存在场景实例的 `video_source_ref` 指向该视频源
+
+如果存在引用,应提示:
+
+- 当前视频源已被某些场景配置使用,不能删除
+
+## 存储建议
+
+建议沿用现有 SQLite 基础配置仓储模式,新增一类 `video_sources` 存储对象。
+
+每条记录至少包含:
+
+- `name`
+- `source_type`
+- `area`
+- `description`
+- `body_json`
+- `created_at`
+- `updated_at`
+
+## 第一阶段实施范围
+
+第一阶段只实现以下内容:
+
+1. 视频源的数据结构与存储
+2. `基础配置 -> 视频源` 列表和详情维护页
+3. 场景配置实例支持 `video_source_ref`
+4. 预览/下发时优先用视频源 URL 展开
+5. 删除前引用检查
+
+## 结论
+
+视频源应被定义为**一路可被场景配置引用的输入流**。
+
+采用“基础识别字段 + 输入参数 + 可选安装信息”的结构最适合当前系统阶段:
+
+- 比单纯只存 URL 更完整
+- 比完整摄像机资产台账更轻
+- 既能服务识别配置,又能保留现场语义
+
+第一版推荐按 `video_source_ref` 的方式逐步替换场景配置中的内联 `rtsp_url`,从而让视频源真正成为可复用、可维护的基础配置对象。
From a5205ac359f39e4c64fbae348917cf007a138552 Mon Sep 17 00:00:00 2001
From: tian <11429339@qq.com>
Date: Wed, 29 Apr 2026 11:27:29 +0800
Subject: [PATCH 04/10] Refine video source resolution fields
---
.../specs/2026-04-29-video-sources-design.md | 16 +++++++++++-----
1 file changed, 11 insertions(+), 5 deletions(-)
diff --git a/docs/superpowers/specs/2026-04-29-video-sources-design.md b/docs/superpowers/specs/2026-04-29-video-sources-design.md
index aa9f225..b080c69 100644
--- a/docs/superpowers/specs/2026-04-29-video-sources-design.md
+++ b/docs/superpowers/specs/2026-04-29-video-sources-design.md
@@ -75,14 +75,15 @@
### 2. 输入参数
- `url`:输入地址
-- `resolution`:分辨率,例如 `1920x1080`
+- `resolution`:标准分辨率等级,例如 `720p`、`1080p`、`1440p`、`4k`
+- `frame_size`:像素尺寸,例如 `1280x720`、`1920x1080`
- `fps`:帧率
- `video_format`:视频格式,例如 `h264`、`h265`、`mjpeg`
说明:
- `url` 必填
-- `resolution`、`fps`、`video_format` 可选
+- `resolution`、`frame_size`、`fps`、`video_format` 可选
### 3. 安装信息
@@ -127,7 +128,8 @@
"description": "东门主入口摄像头",
"config": {
"url": "rtsp://10.0.0.1/live",
- "resolution": "1920x1080",
+ "resolution": "1080p",
+ "frame_size": "1920x1080",
"fps": 25,
"video_format": "h264",
"focal_length": "4mm",
@@ -224,7 +226,8 @@
说明:
-- 列表优先展示能帮助快速识别和定位的视频信息
+- 列表中的“分辨率”优先显示 `resolution` 这类标准等级
+- `frame_size` 作为详情中的技术补充字段,不默认放在列表里
- 焦距、安装高度、安装角度不放在列表里
## 详情页字段分组
@@ -240,6 +243,7 @@
- URL
- 分辨率
+- 像素尺寸
- 帧率
- 视频格式
@@ -262,6 +266,7 @@
- `area`
- `description`
- `config.resolution`
+- `config.frame_size`
- `config.fps`
- `config.video_format`
- `config.focal_length`
@@ -272,7 +277,8 @@
- `name` 必须唯一
- `fps` 如填写,应可解析为数值
-- `resolution` 第一版可先作为字符串保存,不强制拆成宽高
+- `resolution` 使用标准分辨率等级表达,例如 `720p`、`1080p`
+- `frame_size` 第一版可先作为字符串保存,不强制拆成宽高整数
## 删除约束
From 33d5f6f8ab11388f76559e62f8f3d41ace1b1874 Mon Sep 17 00:00:00 2001
From: tian <11429339@qq.com>
Date: Wed, 29 Apr 2026 11:56:39 +0800
Subject: [PATCH 05/10] Add template scene binding refactor spec
---
...026-04-29-template-scene-binding-design.md | 462 ++++++++++++++++++
1 file changed, 462 insertions(+)
create mode 100644 docs/superpowers/specs/2026-04-29-template-scene-binding-design.md
diff --git a/docs/superpowers/specs/2026-04-29-template-scene-binding-design.md b/docs/superpowers/specs/2026-04-29-template-scene-binding-design.md
new file mode 100644
index 0000000..13d98fd
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-29-template-scene-binding-design.md
@@ -0,0 +1,462 @@
+# Template / Scene Binding Refactor Design
+
+## Goal
+
+Refactor the configuration model across the admin backend and the OrangePi media service so that:
+
+- `识别模板` only defines a single-stream processing flow and its default technical parameters.
+- `基础配置` owns reusable concrete instances such as `视频源` and `第三方服务`.
+- `场景配置` becomes the single assembly layer that binds template slots to concrete instances and scene-level output parameters.
+- The agent runtime config format stays stable in the first phase; the main change is in the modeling and render pipeline.
+
+This is a cross-repo refactor involving:
+
+- `C:/Users/Tellme/apps/3588AdminBackend`
+- `C:/Users/Tellme/apps/OrangePi3588Media`
+
+## Problem Statement
+
+The current template JSON mixes three different concerns:
+
+1. Flow definition
+2. Scene instance parameters
+3. Concrete third-party service endpoints and credentials
+
+That creates several problems:
+
+- Templates are not reusable enough because they carry site-specific and service-specific values.
+- Service endpoints and credentials are duplicated in templates instead of being centrally managed.
+- Scene configuration is not the true assembly layer even though it is the closest object to actual deployment.
+- The same conceptual binding is split between template params, profile params, and runtime substitution.
+
+The target model should separate these concerns cleanly.
+
+## Target Information Architecture
+
+### 1. 识别模板
+
+Template is a reusable processing definition for **one video stream**.
+
+Template owns:
+
+- Node graph
+- Edge graph
+- Default algorithm parameters
+- Default preprocess and publish technical parameters
+- Declarations of required input / output / service slots
+
+Template does **not** own:
+
+- Concrete video source URLs
+- Concrete third-party service instances
+- Scene-specific publish paths or ports
+- Site-specific display labels
+
+### 2. 基础配置
+
+Base config owns reusable concrete instances.
+
+Initial scope:
+
+- `视频源`
+- `第三方服务`
+
+These objects are real instances that can be shared by many scenes.
+
+### 3. 场景配置
+
+Scene config is the only assembly layer.
+
+It owns:
+
+- Template selection
+- Binding of template input slots to video-source instances
+- Binding of template service slots to third-party service instances
+- Binding of template output slots to scene-specific publish settings
+- Scene-level labels and business parameters
+
+## Data Model Boundaries
+
+### Template-owned fields
+
+These remain inside template definitions:
+
+- Node types
+- Edges
+- Model paths
+- Thresholds
+- Inference rates
+- Preprocess defaults
+- Publish codec defaults
+- Alarm rule structure
+- Default sink behavior
+
+Examples from existing templates:
+
+- `infer_fps`
+- `conf_thresh`
+- `nms_thresh`
+- `dst_w`
+- `dst_h`
+- `codec`
+- `bitrate_kbps`
+- `rules`
+- `face_rules`
+
+### Scene-owned fields
+
+These move to scene config:
+
+- `publish_hls_path`
+- `publish_rtsp_port`
+- `publish_rtsp_path`
+- `channel_no`
+- `display_name`
+- `site_name`
+- any scene-specific output behavior or business label
+
+`device_code` should be treated as legacy and kept only for compatibility during migration. It should not be expanded further as a primary scene-model concept.
+
+### Base-config-owned fields
+
+These move into reusable instance objects:
+
+#### 视频源
+
+- input URL
+- source type
+- resolution metadata
+- frame size
+- fps
+- format
+- installation metadata
+
+#### 第三方服务
+
+- object storage endpoint / bucket / keys
+- token service URL
+- alarm service URL
+- tenant or service-side connection metadata
+
+## Slot-Based Template Model
+
+The core change is to stop treating template placeholders as generic flat vars and instead treat them as **declared slots**.
+
+### Template slot categories
+
+Templates may declare:
+
+- `input` slots
+- `service` slots
+- `output` slots
+
+Examples:
+
+- `video_input_main`
+- `object_storage_main`
+- `token_service_main`
+- `alarm_service_main`
+- `stream_output_main`
+
+### Meaning of slots
+
+A slot expresses a requirement, not an instance.
+
+Examples:
+
+- `video_input_main` means this template requires one concrete video input.
+- `object_storage_main` means this template requires one object storage service instance.
+- `stream_output_main` means this template produces a stream and needs scene-level output binding.
+
+## Scene Binding Model
+
+Scene config binds template slots to concrete values.
+
+### Input binding
+
+Example:
+
+```json
+{
+ "input_bindings": {
+ "video_input_main": {
+ "video_source_ref": "gate_cam_01"
+ }
+ }
+}
+```
+
+### Service binding
+
+Example:
+
+```json
+{
+ "service_bindings": {
+ "object_storage_main": {
+ "service_ref": "minio_prod_main"
+ },
+ "token_service_main": {
+ "service_ref": "token_service_prod"
+ },
+ "alarm_service_main": {
+ "service_ref": "alarm_center_prod"
+ }
+ }
+}
+```
+
+### Output binding
+
+Example:
+
+```json
+{
+ "output_bindings": {
+ "stream_output_main": {
+ "publish_hls_path": "./web/hls/cam1/index.m3u8",
+ "publish_rtsp_port": 8555,
+ "publish_rtsp_path": "/live/cam1"
+ }
+ }
+}
+```
+
+### Scene metadata
+
+Scene keeps business-facing labels:
+
+- `display_name`
+- `site_name`
+- `channel_no`
+
+These are scene-level values and should not live in templates.
+
+## Backward Compatibility Strategy
+
+Phase 1 must preserve the existing agent runtime config format.
+
+That means:
+
+- `render_config.py` may continue producing the current final flat config shape.
+- Agent runtime does not need to understand slots yet.
+- Binding logic is introduced in the config-render pipeline, not in the agent runtime.
+
+Compatibility rules:
+
+1. If new slot bindings exist, they take priority.
+2. If old flat fields still exist, they remain supported during migration.
+3. Existing templates and scene configs must continue rendering during the transition.
+
+## Existing Field Migration Matrix
+
+### Input-related
+
+- `rtsp_url`
+ - Current location: scene/profile params
+ - Target location: video source instance referenced from scene input binding
+ - Compatibility: continue supporting direct inline `rtsp_url`
+
+### Output-related
+
+- `publish_hls_path`
+ - Current location: scene/profile params
+ - Target location: scene output binding
+- `publish_rtsp_port`
+ - Current location: scene/profile params
+ - Target location: scene output binding
+- `publish_rtsp_path`
+ - Current location: scene/profile params
+ - Target location: scene output binding
+
+### Scene label fields
+
+- `channel_no`
+ - Current location: scene/profile params and used by alarm external API
+ - Target location: scene metadata or output binding context
+- `display_name`
+ - Current location: scene/profile params
+ - Target location: scene metadata
+- `site_name`
+ - Current location: scene/profile params
+ - Target location: scene metadata
+- `device_code`
+ - Current location: scene/profile params
+ - Target location: legacy compatibility only in phase 1
+
+### Third-party service fields
+
+- `minio_endpoint`
+ - Current location: template params
+ - Target location: object storage service instance
+- `minio_bucket`
+ - Current location: template params
+ - Target location: object storage service instance
+- `minio_access_key`
+ - Current location: template params
+ - Target location: object storage service instance
+- `minio_secret_key`
+ - Current location: template params
+ - Target location: object storage service instance
+- `external_get_token_url`
+ - Current location: template params
+ - Target location: token service instance
+- `external_put_message_url`
+ - Current location: template params
+ - Target location: alarm service instance
+- `tenant_code`
+ - Current location: template params
+ - Target location: token/alarm service instance, with optional scene-level override if needed later
+
+## Cross-Repo Responsibilities
+
+### 3588AdminBackend
+
+Admin backend changes:
+
+- Extend template asset model to expose slot declarations.
+- Extend scene config model to store slot bindings.
+- Maintain `视频源` and `第三方服务` as reusable base config objects.
+- Update scene-config UI so users bind template slots to concrete instances.
+- Update preview pipeline inputs to pass structured bindings to the renderer.
+
+### OrangePi3588Media
+
+Media service changes:
+
+- Upgrade template JSON model to support explicit slot declarations.
+- Update `render_config.py` to resolve slot bindings into the current runtime config format.
+- Keep final generated runtime config compatible with the current agent in phase 1.
+- Support fallback rendering for older templates and profiles during migration.
+
+## Rendering Strategy
+
+Rendering should become a two-stage process.
+
+### Stage 1: Resolve scene bindings
+
+Resolve:
+
+- video source refs
+- third-party service refs
+- scene output parameters
+
+into a structured bound scene model.
+
+### Stage 2: Expand into final runtime config
+
+Expand the bound scene model into the current flat runtime config fields expected by the agent.
+
+Examples:
+
+- resolved video source URL becomes runtime `rtsp_url`
+- resolved object storage instance becomes runtime `minio_*`
+- resolved alarm/token services become runtime `external_*` and `tenant_code`
+
+This lets us modernize the model without forcing an immediate runtime-schema rewrite.
+
+## UI Implications
+
+### Template page
+
+Template page should gradually shift from editing concrete values to editing:
+
+- graph structure
+- default technical parameters
+- declared slots
+
+It should no longer encourage editing concrete service endpoints or stream addresses inside templates.
+
+### Scene page
+
+Scene page becomes the main assembly workspace.
+
+It should expose:
+
+- input bindings
+- service bindings
+- output bindings
+- scene labels and business fields
+
+### Base-config pages
+
+These remain the concrete instance maintenance surfaces:
+
+- video source list/detail
+- third-party service list/detail
+
+## Phased Rollout
+
+### Phase 1
+
+- Keep current runtime config shape.
+- Add slot-aware scene bindings.
+- Add renderer logic that expands new bindings into old flat values.
+- Preserve legacy flat template/profile fields.
+
+### Phase 2
+
+- Move standard templates to explicit slot declarations.
+- Stop storing real service endpoints in new template definitions.
+- Prefer video-source refs over inline stream URLs.
+
+### Phase 3
+
+- Remove or deprecate legacy flat substitutions from template authoring.
+- Reduce compatibility code once all standard and active custom configs are migrated.
+
+## Risks
+
+### 1. Dual-model complexity during migration
+
+Supporting both legacy flat fields and new bindings will increase renderer complexity temporarily.
+
+Mitigation:
+
+- Keep priority order explicit.
+- Add fixture-based render tests for old and new models.
+
+### 2. Template authoring confusion
+
+Users may still expect templates to own instance values.
+
+Mitigation:
+
+- Shift template UI away from concrete service editing.
+- Make scene config the clear assembly surface.
+
+### 3. Cross-repo drift
+
+If backend and media-repo schema changes are not kept aligned, preview and apply flows will break.
+
+Mitigation:
+
+- Treat slot/binding schema as a shared contract.
+- Land cross-repo fixture tests around the render boundary.
+
+## Testing Strategy
+
+### Admin backend
+
+- Unit tests for slot-aware scene models
+- Unit tests for binding persistence
+- UI tests for scene binding forms
+- Preview tests for structured binding expansion
+
+### Media repo
+
+- Fixture tests for template slot declarations
+- Fixture tests for legacy-template rendering
+- Fixture tests for new scene-binding rendering
+- Golden tests for final runtime config output
+
+## Recommendation
+
+Use the ideal layered model now, but keep runtime compatibility in phase 1:
+
+- Template defines requirements and defaults.
+- Base config provides reusable concrete instances.
+- Scene config performs all actual binding.
+- Render pipeline expands the new structure into the current agent runtime config.
+
+This delivers the clean architecture we want without forcing a dangerous all-at-once runtime rewrite.
From 328705ebc64c746030a06165deaea82e04782388 Mon Sep 17 00:00:00 2001
From: tian <11429339@qq.com>
Date: Wed, 29 Apr 2026 18:59:57 +0800
Subject: [PATCH 06/10] Refactor config management flow and admin IA
---
cmd/managerd/main.go | 6 +
internal/config/config.go | 2 +-
internal/service/config_assets.go | 479 ++++++---
internal/service/config_assets_test.go | 394 ++++++--
internal/service/config_preview.go | 562 +++++------
internal/service/config_preview_test.go | 362 +++++--
internal/service/config_runtime_render.go | 415 ++++++++
internal/service/profile_editor.go | 324 ++++--
internal/service/standard_templates.go | 75 ++
internal/service/template_slots.go | 67 ++
internal/service/template_slots_test.go | 34 +
internal/storage/assets_repo.go | 92 +-
internal/storage/assets_repo_test.go | 41 +
internal/storage/migrate.go | 97 +-
internal/storage/sqlite_test.go | 57 ++
internal/web/graph_node_types.go | 4 +-
internal/web/ui.go | 581 ++++++++++-
internal/web/ui/assets/graph_editor.js | 4 +-
internal/web/ui/assets/style.css | 25 +-
internal/web/ui/templates/api.html | 2 +-
internal/web/ui/templates/asset_overlay.html | 4 +-
internal/web/ui/templates/asset_overlays.html | 10 +-
internal/web/ui/templates/asset_profile.html | 99 --
internal/web/ui/templates/asset_profiles.html | 178 ++--
internal/web/ui/templates/asset_template.html | 8 +-
.../web/ui/templates/asset_templates.html | 31 +-
internal/web/ui/templates/assets.html | 170 +++-
internal/web/ui/templates/audit.html | 2 +-
internal/web/ui/templates/config_preview.html | 6 +-
internal/web/ui/templates/device.html | 54 +-
.../web/ui/templates/device_batch_config.html | 18 +-
internal/web/ui/templates/devices.html | 6 +-
internal/web/ui/templates/diagnostics.html | 20 +-
internal/web/ui/templates/layout.html | 83 +-
internal/web/ui/templates/models.html | 32 +-
internal/web/ui/templates/resources.html | 43 +
internal/web/ui/templates/system.html | 2 +-
internal/web/ui_test.go | 927 +++++++++++++++---
scripts/restart.bat | 30 +
.../std_face_recognition_stream.json | 214 ++++
.../std_service_test_stream.json | 83 ++
.../std_workshoe_detection_stream.json | 302 ++++++
..._workshop_face_recognition_shoe_alarm.json | 552 +++++++++++
43 files changed, 5306 insertions(+), 1191 deletions(-)
create mode 100644 internal/service/config_runtime_render.go
create mode 100644 internal/service/standard_templates.go
create mode 100644 internal/service/template_slots.go
create mode 100644 internal/service/template_slots_test.go
delete mode 100644 internal/web/ui/templates/asset_profile.html
create mode 100644 internal/web/ui/templates/resources.html
create mode 100644 scripts/restart.bat
create mode 100644 templates/standard_templates/std_face_recognition_stream.json
create mode 100644 templates/standard_templates/std_service_test_stream.json
create mode 100644 templates/standard_templates/std_workshoe_detection_stream.json
create mode 100644 templates/standard_templates/std_workshop_face_recognition_shoe_alarm.json
diff --git a/cmd/managerd/main.go b/cmd/managerd/main.go
index ed1c083..1dbec70 100644
--- a/cmd/managerd/main.go
+++ b/cmd/managerd/main.go
@@ -5,6 +5,7 @@ import (
"log"
"net/http"
"os"
+ "path/filepath"
"3588AdminBackend/internal/api"
"3588AdminBackend/internal/config"
@@ -39,6 +40,11 @@ func main() {
defer store.Close()
taskRepo := storage.NewTasksRepo(store.DB())
assetsRepo := storage.NewAssetsRepo(store.DB())
+ if imported, err := service.ImportStandardTemplatesFromDir(assetsRepo, filepath.Join("templates", "standard_templates")); err != nil {
+ log.Fatalf("import standard templates: %v", err)
+ } else if imported > 0 {
+ log.Printf("imported %d standard templates", imported)
+ }
stateRepo := storage.NewDeviceConfigStateRepo(store.DB())
auditRepo := storage.NewAuditLogsRepo(store.DB())
taskSvc := service.NewTaskService(cfg, agentClient, regSvc, taskRepo)
diff --git a/internal/config/config.go b/internal/config/config.go
index 480e666..16b3394 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -17,7 +17,7 @@ type Config struct {
DataDir string `json:"data_dir,omitempty"`
DBPath string `json:"db_path,omitempty"`
LogDir string `json:"log_dir,omitempty"`
- MediaRepoPath string `json:"media_repo_path,omitempty"`
+ MediaRepoPath string `json:"media_repo_path,omitempty"` // explicit import-only source; not used for runtime rendering
DeviceAliases map[string]string `json:"device_aliases,omitempty"`
path string
}
diff --git a/internal/service/config_assets.go b/internal/service/config_assets.go
index e91863e..9a90c88 100644
--- a/internal/service/config_assets.go
+++ b/internal/service/config_assets.go
@@ -17,21 +17,22 @@ var legacyBuiltinTemplateAliases = map[string]string{
}
type ConfigTemplateAsset struct {
- Name string `json:"name"`
- Path string `json:"path"`
- Origin string `json:"origin"`
- ReadOnly bool `json:"read_only"`
- Description string `json:"description"`
- Source string `json:"source"`
- NodeCount int `json:"node_count"`
- EdgeCount int `json:"edge_count"`
- MinIOEndpoint string `json:"minio_endpoint"`
- MinIOBucket string `json:"minio_bucket"`
- ExternalGetTokenURL string `json:"external_get_token_url"`
- ExternalPutMessageURL string `json:"external_put_message_url"`
- TenantCode string `json:"tenant_code"`
- AdvancedParams map[string]any `json:"advanced_params"`
- Raw map[string]any `json:"raw"`
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Origin string `json:"origin"`
+ ReadOnly bool `json:"read_only"`
+ Description string `json:"description"`
+ Source string `json:"source"`
+ Slots TemplateSlotGroup `json:"slots"`
+ NodeCount int `json:"node_count"`
+ EdgeCount int `json:"edge_count"`
+ MinIOEndpoint string `json:"minio_endpoint"`
+ MinIOBucket string `json:"minio_bucket"`
+ ExternalGetTokenURL string `json:"external_get_token_url"`
+ ExternalPutMessageURL string `json:"external_put_message_url"`
+ TenantCode string `json:"tenant_code"`
+ AdvancedParams map[string]any `json:"advanced_params"`
+ Raw map[string]any `json:"raw"`
}
type ConfigProfileAsset struct {
@@ -39,9 +40,6 @@ type ConfigProfileAsset struct {
Path string `json:"path"`
Description string `json:"description"`
BusinessName string `json:"business_name"`
- ObjectStorageRef string `json:"object_storage_ref"`
- TokenServiceRef string `json:"token_service_ref"`
- AlarmServiceRef string `json:"alarm_service_ref"`
QueueSize int `json:"queue_size"`
QueueStrategy string `json:"queue_strategy"`
Instances []ConfigProfileInstanceAsset `json:"instances"`
@@ -51,10 +49,10 @@ type ConfigProfileAsset struct {
type ConfigProfileInstanceAsset struct {
Name string `json:"name"`
Template string `json:"template"`
+ VideoSourceRef string `json:"video_source_ref"`
DisplayName string `json:"display_name"`
DeviceCode string `json:"device_code"`
SiteName string `json:"site_name"`
- RTSPURL string `json:"rtsp_url"`
PublishHLSPath string `json:"publish_hls_path"`
PublishRTSPPort string `json:"publish_rtsp_port"`
PublishRTSPPath string `json:"publish_rtsp_path"`
@@ -107,29 +105,34 @@ type AlarmServiceConfig struct {
TenantCode string `json:"tenant_code"`
}
+type ConfigVideoSourceAsset struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ SourceType string `json:"source_type"`
+ SourceTypeLabel string `json:"source_type_label"`
+ Area string `json:"area"`
+ Description string `json:"description"`
+ RefCount int `json:"ref_count"`
+ SourceSummary string `json:"source_summary"`
+ Config VideoSourceConfig `json:"config"`
+ Raw map[string]any `json:"raw"`
+}
+
+type VideoSourceConfig struct {
+ URL string `json:"url"`
+ Resolution string `json:"resolution"`
+ FrameSize string `json:"frame_size"`
+ FPS string `json:"fps"`
+ VideoFormat string `json:"video_format"`
+ FocalLength string `json:"focal_length"`
+ MountHeight string `json:"mount_height"`
+ MountAngle string `json:"mount_angle"`
+}
+
func (s *ConfigPreviewService) ListTemplateAssets() ([]ConfigTemplateAsset, error) {
items := make([]ConfigTemplateAsset, 0)
seen := map[string]bool{}
- root := s.mediaRepoRoot()
- if root != "" {
- sources, err := listConfigSources(filepath.Join(root, "configs", "templates"))
- if err != nil {
- if s.hasExplicitRoot() {
- return nil, err
- }
- } else {
- for _, source := range sources {
- item, err := s.templateAssetFromPath(source.Name, source.Path, "builtin", true)
- if err != nil {
- continue
- }
- items = append(items, *item)
- seen[item.Name] = true
- }
- }
- }
-
if s != nil && s.assets != nil {
records, err := s.assets.ListTemplates()
if err != nil {
@@ -140,7 +143,12 @@ func (s *ConfigPreviewService) ListTemplateAssets() ([]ConfigTemplateAsset, erro
if name == "" || seen[name] || legacyBuiltinTemplateAliases[name] != "" {
continue
}
- item, err := s.templateAssetFromRecord(record, "user", false)
+ readOnly := isStandardTemplateName(name)
+ origin := "user"
+ if readOnly {
+ origin = "standard"
+ }
+ item, err := s.templateAssetFromRecord(record, origin, readOnly)
if err != nil {
continue
}
@@ -163,16 +171,18 @@ func (s *ConfigPreviewService) GetTemplateAsset(name string) (*ConfigTemplateAss
if err := validateConfigName(name); err != nil {
return nil, err
}
- if path, ok := s.mediaAssetPath("templates", name); ok {
- return s.templateAssetFromPath(name, path, "builtin", true)
- }
if s != nil && s.assets != nil {
record, err := s.assets.GetTemplate(name)
if err != nil {
return nil, err
}
if record != nil {
- return s.templateAssetFromRecord(*record, "user", false)
+ readOnly := isStandardTemplateName(name)
+ origin := "user"
+ if readOnly {
+ origin = "standard"
+ }
+ return s.templateAssetFromRecord(*record, origin, readOnly)
}
}
return nil, os.ErrNotExist
@@ -186,7 +196,7 @@ func (s *ConfigPreviewService) SaveTemplateAsset(name string, description string
if err := validateConfigName(name); err != nil {
return err
}
- if s.templateIsBuiltin(name) {
+ if isStandardTemplateName(name) {
return fmt.Errorf("standard template %q is read-only; please copy it before editing", name)
}
return s.assets.SaveTemplate(name, description, bodyJSON)
@@ -204,10 +214,10 @@ func (s *ConfigPreviewService) RenameTemplateAsset(oldName string, newName strin
if err := validateConfigName(newName); err != nil {
return err
}
- if s.templateIsBuiltin(oldName) {
+ if isStandardTemplateName(oldName) {
return fmt.Errorf("standard template %q is read-only; please copy it before editing", oldName)
}
- if oldName != newName && s.templateIsBuiltin(newName) {
+ if oldName != newName && isStandardTemplateName(newName) {
return fmt.Errorf("standard template name %q is reserved", newName)
}
return s.assets.RenameTemplate(oldName, newName, description, bodyJSON)
@@ -221,7 +231,7 @@ func (s *ConfigPreviewService) DeleteTemplateAsset(name string) error {
if err := validateConfigName(name); err != nil {
return err
}
- if s.templateIsBuiltin(name) {
+ if isStandardTemplateName(name) {
return fmt.Errorf("standard template %q is read-only and cannot be deleted", name)
}
refs, err := s.profileNamesReferencingTemplate(name)
@@ -279,12 +289,15 @@ func (s *ConfigPreviewService) GetProfileAsset(name string) (*ConfigProfileAsset
for _, item := range instancesRaw {
instanceMap, _ := item.(map[string]any)
paramsMap, _ := instanceMap["params"].(map[string]any)
+ sceneMeta, _ := instanceMap["scene_meta"].(map[string]any)
+ inputBindings, _ := instanceMap["input_bindings"].(map[string]any)
+ outputBindings, _ := instanceMap["output_bindings"].(map[string]any)
advanced := cloneMap(paramsMap)
for _, key := range []string{
"display_name",
"device_code",
"site_name",
- "rtsp_url",
+ "video_source_ref",
"publish_hls_path",
"publish_rtsp_port",
"publish_rtsp_path",
@@ -298,29 +311,26 @@ func (s *ConfigPreviewService) GetProfileAsset(name string) (*ConfigProfileAsset
instances = append(instances, ConfigProfileInstanceAsset{
Name: stringValue(instanceMap["name"]),
Template: stringValue(instanceMap["template"]),
- DisplayName: stringValue(paramsMap["display_name"]),
- DeviceCode: stringValue(paramsMap["device_code"]),
- SiteName: stringValue(paramsMap["site_name"]),
- RTSPURL: stringValue(paramsMap["rtsp_url"]),
- PublishHLSPath: stringValue(paramsMap["publish_hls_path"]),
- PublishRTSPPort: valueString(paramsMap["publish_rtsp_port"]),
- PublishRTSPPath: stringValue(paramsMap["publish_rtsp_path"]),
- ChannelNo: stringValue(paramsMap["channel_no"]),
+ VideoSourceRef: bindingField(inputBindings, "video_input_main", "video_source_ref"),
+ DisplayName: stringValue(sceneMeta["display_name"]),
+ DeviceCode: stringValue(sceneMeta["device_code"]),
+ SiteName: stringValue(sceneMeta["site_name"]),
+ PublishHLSPath: bindingField(outputBindings, "stream_output_main", "publish_hls_path"),
+ PublishRTSPPort: valueString(bindingAny(outputBindings, "stream_output_main", "publish_rtsp_port")),
+ PublishRTSPPath: bindingField(outputBindings, "stream_output_main", "publish_rtsp_path"),
+ ChannelNo: bindingField(outputBindings, "stream_output_main", "channel_no"),
AdvancedParams: advanced,
})
}
return &ConfigProfileAsset{
- Name: firstString(raw["name"], name),
- Path: path,
- Description: stringValue(raw["description"]),
- BusinessName: stringValue(raw["business_name"]),
- ObjectStorageRef: stringValue(raw["object_storage_ref"]),
- TokenServiceRef: stringValue(raw["token_service_ref"]),
- AlarmServiceRef: stringValue(raw["alarm_service_ref"]),
- QueueSize: intValue(queueMap["size"]),
- QueueStrategy: stringValue(queueMap["strategy"]),
- Instances: instances,
- Raw: raw,
+ Name: firstString(raw["name"], name),
+ Path: path,
+ Description: stringValue(raw["description"]),
+ BusinessName: stringValue(raw["business_name"]),
+ QueueSize: intValue(queueMap["size"]),
+ QueueStrategy: stringValue(queueMap["strategy"]),
+ Instances: instances,
+ Raw: raw,
}, nil
}
@@ -385,6 +395,109 @@ func (s *ConfigPreviewService) ListIntegrationServices() ([]ConfigIntegrationSer
return items, nil
}
+func (s *ConfigPreviewService) ListVideoSources() ([]ConfigVideoSourceAsset, error) {
+ if s == nil || s.assets == nil {
+ return nil, nil
+ }
+ records, err := s.assets.ListVideoSources()
+ if err != nil {
+ return nil, err
+ }
+ items := make([]ConfigVideoSourceAsset, 0, len(records))
+ for _, record := range records {
+ item, err := videoSourceAssetFromRecord(record)
+ if err != nil {
+ continue
+ }
+ if refs, err := s.profileNamesReferencingVideoSource(item.Name); err == nil {
+ item.RefCount = len(refs)
+ }
+ items = append(items, *item)
+ }
+ sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
+ return items, nil
+}
+
+func (s *ConfigPreviewService) GetVideoSource(name string) (*ConfigVideoSourceAsset, error) {
+ if s == nil || s.assets == nil {
+ return nil, fmt.Errorf("基础配置仓库未初始化")
+ }
+ if err := validateVideoSourceName(name); err != nil {
+ return nil, err
+ }
+ record, err := s.assets.GetVideoSource(name)
+ if err != nil {
+ return nil, err
+ }
+ if record == nil {
+ return nil, os.ErrNotExist
+ }
+ item, err := videoSourceAssetFromRecord(*record)
+ if err != nil {
+ return nil, err
+ }
+ if refs, err := s.profileNamesReferencingVideoSource(item.Name); err == nil {
+ item.RefCount = len(refs)
+ }
+ return item, nil
+}
+
+func (s *ConfigPreviewService) SaveVideoSourceAsset(asset ConfigVideoSourceAsset) error {
+ if s == nil || s.assets == nil {
+ return fmt.Errorf("基础配置仓库未初始化")
+ }
+ name := strings.TrimSpace(asset.Name)
+ if err := validateVideoSourceName(name); err != nil {
+ return fmt.Errorf("视频源名称不合法:%w", err)
+ }
+ sourceType := strings.TrimSpace(asset.SourceType)
+ if sourceType == "" {
+ return fmt.Errorf("视频源类型不能为空")
+ }
+ urlValue := strings.TrimSpace(asset.Config.URL)
+ if urlValue == "" {
+ return fmt.Errorf("视频源地址不能为空")
+ }
+ raw := map[string]any{
+ "name": name,
+ "source_type": sourceType,
+ "area": strings.TrimSpace(asset.Area),
+ "description": strings.TrimSpace(asset.Description),
+ "config": map[string]any{},
+ }
+ configMap := raw["config"].(map[string]any)
+ setAnyString(configMap, "url", asset.Config.URL)
+ setAnyString(configMap, "resolution", asset.Config.Resolution)
+ setAnyString(configMap, "frame_size", asset.Config.FrameSize)
+ setAnyString(configMap, "fps", asset.Config.FPS)
+ setAnyString(configMap, "video_format", asset.Config.VideoFormat)
+ setAnyString(configMap, "focal_length", asset.Config.FocalLength)
+ setAnyString(configMap, "mount_height", asset.Config.MountHeight)
+ setAnyString(configMap, "mount_angle", asset.Config.MountAngle)
+ body, err := marshalConfigJSON(raw)
+ if err != nil {
+ return err
+ }
+ return s.assets.SaveVideoSource(name, sourceType, strings.TrimSpace(asset.Area), strings.TrimSpace(asset.Description), string(body))
+}
+
+func (s *ConfigPreviewService) DeleteVideoSource(name string) error {
+ if s == nil || s.assets == nil {
+ return fmt.Errorf("基础配置仓库未初始化")
+ }
+ if err := validateVideoSourceName(name); err != nil {
+ return err
+ }
+ refs, err := s.profileNamesReferencingVideoSource(name)
+ if err != nil {
+ return err
+ }
+ if len(refs) > 0 {
+ return fmt.Errorf("视频源 %q 已被场景配置引用:%s", name, strings.Join(refs, ", "))
+ }
+ return s.assets.DeleteVideoSource(name)
+}
+
func (s *ConfigPreviewService) GetIntegrationService(name string) (*ConfigIntegrationServiceAsset, error) {
if s == nil || s.assets == nil {
return nil, fmt.Errorf("asset repository is not configured")
@@ -426,6 +539,72 @@ func (s *ConfigPreviewService) DeleteIntegrationService(name string) error {
return s.assets.DeleteIntegrationService(name)
}
+func (s *ConfigPreviewService) SaveIntegrationServiceAsset(asset ConfigIntegrationServiceAsset) error {
+ if s == nil || s.assets == nil {
+ return fmt.Errorf("asset repository is not configured")
+ }
+ name := strings.TrimSpace(asset.Name)
+ if err := validateConfigName(name); err != nil {
+ return fmt.Errorf("invalid third-party service name: %w", err)
+ }
+ serviceType := strings.TrimSpace(asset.Type)
+ if serviceType == "" {
+ return fmt.Errorf("third-party service type is required")
+ }
+ raw := map[string]any{
+ "name": name,
+ "type": serviceType,
+ "description": strings.TrimSpace(asset.Description),
+ "enabled": asset.Enabled,
+ }
+ configMap := map[string]any{}
+ switch serviceType {
+ case "object_storage":
+ if asset.ObjectStorage == nil {
+ return fmt.Errorf("object storage config is required")
+ }
+ setAnyString(configMap, "endpoint", asset.ObjectStorage.Endpoint)
+ setAnyString(configMap, "bucket", asset.ObjectStorage.Bucket)
+ setAnyString(configMap, "access_key", asset.ObjectStorage.AccessKey)
+ setAnyString(configMap, "secret_key", asset.ObjectStorage.SecretKey)
+ for _, key := range []string{"endpoint", "bucket", "access_key", "secret_key"} {
+ if strings.TrimSpace(stringValue(configMap[key])) == "" {
+ return fmt.Errorf("object storage %s is required", key)
+ }
+ }
+ case "token_service":
+ if asset.TokenService == nil {
+ return fmt.Errorf("token service config is required")
+ }
+ setAnyString(configMap, "get_token_url", asset.TokenService.GetTokenURL)
+ setAnyString(configMap, "username", asset.TokenService.Username)
+ setAnyString(configMap, "password", asset.TokenService.Password)
+ setAnyString(configMap, "tenant_code", asset.TokenService.TenantCode)
+ if strings.TrimSpace(stringValue(configMap["get_token_url"])) == "" {
+ return fmt.Errorf("token service get_token_url is required")
+ }
+ case "alarm_service":
+ if asset.AlarmService == nil {
+ return fmt.Errorf("alarm service config is required")
+ }
+ setAnyString(configMap, "put_message_url", asset.AlarmService.PutMessageURL)
+ setAnyString(configMap, "username", asset.AlarmService.Username)
+ setAnyString(configMap, "password", asset.AlarmService.Password)
+ setAnyString(configMap, "tenant_code", asset.AlarmService.TenantCode)
+ if strings.TrimSpace(stringValue(configMap["put_message_url"])) == "" {
+ return fmt.Errorf("alarm service put_message_url is required")
+ }
+ default:
+ return fmt.Errorf("unsupported third-party service type: %s", serviceType)
+ }
+ raw["config"] = configMap
+ body, err := marshalConfigJSON(raw)
+ if err != nil {
+ return err
+ }
+ return s.assets.SaveIntegrationService(name, serviceType, strings.TrimSpace(asset.Description), asset.Enabled, string(body))
+}
+
func (s *ConfigPreviewService) profileNamesReferencingIntegrationService(name string) ([]string, error) {
if s == nil || s.assets == nil {
return nil, nil
@@ -447,8 +626,50 @@ func (s *ConfigPreviewService) profileNamesReferencingIntegrationService(name st
if raw == nil {
continue
}
- for _, key := range []string{"object_storage_ref", "token_service_ref", "alarm_service_ref"} {
- if strings.TrimSpace(stringValue(raw[key])) == name {
+ instances, _ := raw["instances"].([]any)
+ for _, item := range instances {
+ instanceMap, _ := item.(map[string]any)
+ serviceBindings, _ := instanceMap["service_bindings"].(map[string]any)
+ found := false
+ for _, slot := range []string{"object_storage_main", "token_service_main", "alarm_service_main"} {
+ if strings.TrimSpace(bindingField(serviceBindings, slot, "service_ref")) == name {
+ refs = append(refs, record.Name)
+ found = true
+ break
+ }
+ }
+ if found {
+ break
+ }
+ }
+ }
+ sort.Strings(refs)
+ return refs, nil
+}
+
+func (s *ConfigPreviewService) profileNamesReferencingVideoSource(name string) ([]string, error) {
+ if s == nil || s.assets == nil {
+ return nil, nil
+ }
+ records, err := s.assets.ListProfiles()
+ if err != nil {
+ return nil, err
+ }
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return nil, nil
+ }
+ refs := make([]string, 0)
+ for _, record := range records {
+ var raw map[string]any
+ if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
+ continue
+ }
+ instances, _ := raw["instances"].([]any)
+ for _, item := range instances {
+ instanceMap, _ := item.(map[string]any)
+ inputBindings, _ := instanceMap["input_bindings"].(map[string]any)
+ if strings.TrimSpace(bindingField(inputBindings, "video_input_main", "video_source_ref")) == name {
refs = append(refs, record.Name)
break
}
@@ -459,32 +680,17 @@ func (s *ConfigPreviewService) profileNamesReferencingIntegrationService(name st
}
func (s *ConfigPreviewService) readAssetJSON(kind string, name string) (map[string]any, string, error) {
- if s != nil && s.assets != nil {
- raw, path, ok, err := s.readRepoAssetJSON(kind, name)
- if err != nil {
- return nil, "", err
- }
- if ok {
- return raw, path, nil
- }
+ if s == nil || s.assets == nil {
+ return nil, "", fmt.Errorf("asset repository is not configured")
}
- root := s.mediaRepoRoot()
- if root == "" {
- return nil, "", fmt.Errorf("media repo path is not configured")
- }
- if err := validateConfigName(name); err != nil {
- return nil, "", err
- }
- path := filepath.Join(root, "configs", kind, name+".json")
- body, err := os.ReadFile(path)
+ raw, path, ok, err := s.readRepoAssetJSON(kind, name)
if err != nil {
return nil, "", err
}
- var raw map[string]any
- if err := json.Unmarshal(body, &raw); err != nil {
- return nil, "", err
+ if ok {
+ return raw, path, nil
}
- return raw, path, nil
+ return nil, "", os.ErrNotExist
}
func (s *ConfigPreviewService) readRepoAssetJSON(kind string, name string) (map[string]any, string, bool, error) {
@@ -523,7 +729,7 @@ func (s *ConfigPreviewService) readRepoAssetJSON(kind string, name string) (map[
}
if kind == "profiles" {
if strings.TrimSpace(record.TemplateName) != "" {
- raw["template_name"] = record.TemplateName
+ raw["primary_template_name"] = record.TemplateName
}
if strings.TrimSpace(record.BusinessName) != "" && stringValue(raw["business_name"]) == "" {
raw["business_name"] = record.BusinessName
@@ -543,16 +749,9 @@ func cloneMap(in map[string]any) map[string]any {
return out
}
-func (s *ConfigPreviewService) templateAssetFromPath(name string, path string, origin string, readOnly bool) (*ConfigTemplateAsset, error) {
- body, err := os.ReadFile(path)
- if err != nil {
- return nil, err
- }
- var raw map[string]any
- if err := json.Unmarshal(body, &raw); err != nil {
- return nil, err
- }
- return buildTemplateAsset(raw, path, origin, readOnly), nil
+func bindingAny(bindings map[string]any, slot string, field string) any {
+ entry, _ := bindings[slot].(map[string]any)
+ return entry[field]
}
func (s *ConfigPreviewService) templateAssetFromRecord(record storage.AssetRecord, origin string, readOnly bool) (*ConfigTemplateAsset, error) {
@@ -574,6 +773,7 @@ func buildTemplateAsset(raw map[string]any, path string, origin string, readOnly
paramsMap, _ := raw["params"].(map[string]any)
nodes, _ := templateMap["nodes"].([]any)
edges, _ := templateMap["edges"].([]any)
+ slots, _ := parseTemplateSlots(raw)
advanced := cloneMap(paramsMap)
for _, key := range []string{
"minio_endpoint",
@@ -594,6 +794,7 @@ func buildTemplateAsset(raw map[string]any, path string, origin string, readOnly
ReadOnly: readOnly,
Description: stringValue(raw["description"]),
Source: stringValue(raw["source"]),
+ Slots: slots,
NodeCount: len(nodes),
EdgeCount: len(edges),
MinIOEndpoint: stringValue(paramsMap["minio_endpoint"]),
@@ -657,6 +858,42 @@ func integrationServiceAssetFromRecord(record storage.IntegrationServiceRecord)
return item, nil
}
+func videoSourceAssetFromRecord(record storage.VideoSourceRecord) (*ConfigVideoSourceAsset, error) {
+ var raw map[string]any
+ if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
+ return nil, err
+ }
+ if raw == nil {
+ raw = map[string]any{}
+ }
+ configMap, _ := raw["config"].(map[string]any)
+ sourceMap := raw
+ if len(configMap) > 0 {
+ sourceMap = configMap
+ }
+ item := &ConfigVideoSourceAsset{
+ Name: firstString(raw["name"], record.Name),
+ Path: repoAssetPath("video_sources", record.Name),
+ SourceType: firstString(record.SourceType, stringValue(raw["source_type"])),
+ Area: firstString(raw["area"], record.Area),
+ Description: firstString(raw["description"], record.Description),
+ SourceTypeLabel: videoSourceTypeLabel(firstString(record.SourceType, stringValue(raw["source_type"]))),
+ Config: VideoSourceConfig{
+ URL: stringValue(sourceMap["url"]),
+ Resolution: stringValue(sourceMap["resolution"]),
+ FrameSize: stringValue(sourceMap["frame_size"]),
+ FPS: valueString(sourceMap["fps"]),
+ VideoFormat: stringValue(sourceMap["video_format"]),
+ FocalLength: stringValue(sourceMap["focal_length"]),
+ MountHeight: stringValue(sourceMap["mount_height"]),
+ MountAngle: stringValue(sourceMap["mount_angle"]),
+ },
+ Raw: raw,
+ }
+ item.SourceSummary = item.Config.URL
+ return item, nil
+}
+
func integrationTypeLabel(v string) string {
switch strings.TrimSpace(v) {
case "object_storage":
@@ -670,22 +907,42 @@ func integrationTypeLabel(v string) string {
}
}
-func (s *ConfigPreviewService) mediaAssetPath(kind string, name string) (string, bool) {
- root := s.mediaRepoRoot()
- if root == "" {
- return "", false
+func videoSourceTypeLabel(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)
}
- path := filepath.Join(root, "configs", kind, name+".json")
- if _, err := os.Stat(path); err != nil {
- return "", false
+}
+
+func validateVideoSourceName(name string) error {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return fmt.Errorf("不能为空")
}
- return path, true
+ if strings.Contains(name, "..") {
+ return fmt.Errorf("不能包含 '..'")
+ }
+ if strings.ContainsAny(name, `/\`) {
+ return fmt.Errorf("不能包含 / 或 \\")
+ }
+ return nil
}
func (s *ConfigPreviewService) templateIsBuiltin(name string) bool {
+ return isStandardTemplateName(name)
+}
+
+func isStandardTemplateName(name string) bool {
name = canonicalTemplateAssetName(name)
- _, ok := s.mediaAssetPath("templates", name)
- return ok
+ return strings.HasPrefix(strings.TrimSpace(name), "std_")
}
func canonicalTemplateAssetName(name string) string {
diff --git a/internal/service/config_assets_test.go b/internal/service/config_assets_test.go
index ed91e1d..ed5e76c 100644
--- a/internal/service/config_assets_test.go
+++ b/internal/service/config_assets_test.go
@@ -11,10 +11,34 @@ import (
"3588AdminBackend/internal/storage"
)
+func mustSaveTemplateRecord(t *testing.T, repo *storage.AssetsRepo, name string, description string, body string) {
+ t.Helper()
+ if err := repo.SaveTemplate(name, description, body); err != nil {
+ t.Fatalf("SaveTemplate(%s): %v", name, err)
+ }
+}
+
+func mustReadFileBytes(t *testing.T, path string) []byte {
+ t.Helper()
+ body, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("ReadFile(%s): %v", path, err)
+ }
+ return body
+}
+
+func mustImportPreviewAssets(t *testing.T, svc *ConfigPreviewService) {
+ t.Helper()
+ if _, err := svc.ImportAssetsFromMediaRepo(); err != nil {
+ t.Fatalf("ImportAssetsFromMediaRepo: %v", err)
+ }
+}
+
func TestConfigPreviewServiceGetsProfileAssetSummary(t *testing.T) {
root := t.TempDir()
- mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{
+ templateBody := `{
"name": "std_workshop_face_recognition_shoe_alarm",
+ "source": "standard",
"params": {
"minio_endpoint": "http://10.0.0.49:9000",
"minio_bucket": "myminio",
@@ -24,31 +48,38 @@ func TestConfigPreviewServiceGetsProfileAssetSummary(t *testing.T) {
"snapshot_region": "us-east-1"
},
"template": {"nodes": [], "edges": []}
-}`)
+}`
mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
"name": "local_3588_test",
"business_name": "A厂区视觉识别",
"description": "test profile",
"queue": {"size": 8, "strategy": "drop_oldest"},
- "instances": [{
+ "instances": [{
"name": "cam1",
"template": "std_workshop_face_recognition_shoe_alarm",
- "params": {
- "display_name": "东门入口",
- "device_code": "rk3588-a-001",
- "site_name": "A厂区",
- "rtsp_url": "rtsp://10.0.0.1/live",
- "publish_hls_path": "./web/hls/cam1/index.m3u8",
- "publish_rtsp_port": 8555,
- "publish_rtsp_path": "/live/cam1",
- "channel_no": "cam1",
- "queue_debug": true
- }
+ "params": {"queue_debug": true},
+ "scene_meta": {"display_name": "东门入口", "device_code": "rk3588-a-001", "site_name": "A厂区"},
+ "input_bindings": {"video_input_main": {"video_source_ref": "gate_cam_01"}},
+ "output_bindings": {"stream_output_main": {"publish_hls_path": "./web/hls/cam1/index.m3u8", "publish_rtsp_port": 8555, "publish_rtsp_path": "/live/cam1", "channel_no": "cam1"}}
}]
}`)
mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
- svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root})
+ store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
+ if err != nil {
+ t.Fatalf("OpenSQLite: %v", err)
+ }
+ defer store.Close()
+ repo := storage.NewAssetsRepo(store.DB())
+ mustSaveTemplateRecord(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", templateBody)
+
+ svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
+ if err := repo.SaveProfile("local_3588_test", "std_workshop_face_recognition_shoe_alarm", "A厂区视觉识别", "test profile", string(mustReadFileBytes(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json")))); err != nil {
+ t.Fatalf("SaveProfile: %v", err)
+ }
+ if err := repo.SaveOverlay("face_debug", "", `{}`); err != nil {
+ t.Fatalf("SaveOverlay: %v", err)
+ }
item, err := svc.GetProfileAsset("local_3588_test")
if err != nil {
t.Fatalf("GetProfileAsset: %v", err)
@@ -70,9 +101,6 @@ func TestConfigPreviewServiceGetsProfileAssetSummary(t *testing.T) {
if item, err := svc.GetTemplateAsset("std_workshop_face_recognition_shoe_alarm"); err != nil {
t.Fatalf("GetTemplateAsset: %v", err)
} else {
- if item.MinIOEndpoint != "http://10.0.0.49:9000" || item.TenantCode != "32" {
- t.Fatalf("expected shared service params on template asset, got %#v", item)
- }
if _, ok := item.AdvancedParams["snapshot_region"]; !ok {
t.Fatalf("expected template advanced params to preserve extra keys, got %#v", item.AdvancedParams)
}
@@ -91,7 +119,14 @@ func TestConfigPreviewServiceGetsOverlayAssetTargets(t *testing.T) {
}
}`)
- svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root})
+ store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
+ if err != nil {
+ t.Fatalf("OpenSQLite: %v", err)
+ }
+ defer store.Close()
+ repo := storage.NewAssetsRepo(store.DB())
+ svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
+ mustImportPreviewAssets(t, svc)
item, err := svc.GetOverlayAsset("face_debug")
if err != nil {
t.Fatalf("GetOverlayAsset: %v", err)
@@ -116,34 +151,34 @@ func TestConfigPreviewServiceBuildsProfileEditor(t *testing.T) {
{
"name": "cam1",
"template": "std_workshop_face_recognition_shoe_alarm",
- "params": {
- "display_name": "东门入口",
- "device_code": "rk3588-a-001",
- "site_name": "A厂区",
- "rtsp_url": "rtsp://10.0.0.1/live",
- "publish_hls_path": "./web/hls/cam1/index.m3u8",
- "publish_rtsp_port": 8555,
- "publish_rtsp_path": "/live/cam1",
- "channel_no": "cam1",
- "queue_debug": true
- }
+ "params": {"queue_debug": true},
+ "scene_meta": {"display_name": "东门入口", "device_code": "rk3588-a-001", "site_name": "A厂区"},
+ "input_bindings": {"video_input_main": {"video_source_ref": "gate_cam_01"}},
+ "output_bindings": {"stream_output_main": {"publish_hls_path": "./web/hls/cam1/index.m3u8", "publish_rtsp_port": 8555, "publish_rtsp_path": "/live/cam1", "channel_no": "cam1"}}
},
{
"name": "cam2",
"template": "std_workshop_face_recognition_shoe_alarm",
- "params": {
- "display_name": "西门入口",
- "rtsp_url": "rtsp://10.0.0.2/live",
- "publish_hls_path": "./web/hls/cam2/index.m3u8",
- "publish_rtsp_port": 8555,
- "publish_rtsp_path": "/live/cam2",
- "channel_no": "cam2"
- }
+ "scene_meta": {"display_name": "西门入口", "site_name": "A厂区"},
+ "input_bindings": {"video_input_main": {"video_source_ref": "line_cam_02"}},
+ "output_bindings": {"stream_output_main": {"publish_hls_path": "./web/hls/cam2/index.m3u8", "publish_rtsp_port": 8555, "publish_rtsp_path": "/live/cam2", "channel_no": "cam2"}}
}
]
}`)
- svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root})
+ store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
+ if err != nil {
+ t.Fatalf("OpenSQLite: %v", err)
+ }
+ defer store.Close()
+ repo := storage.NewAssetsRepo(store.DB())
+ if err := repo.SaveTemplate("std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","template":{"nodes":[],"edges":[]}}`); err != nil {
+ t.Fatalf("SaveTemplate: %v", err)
+ }
+ if err := repo.SaveProfile("local_3588_test", "std_workshop_face_recognition_shoe_alarm", "A厂区视觉识别", "test profile", string(mustReadFileBytes(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json")))); err != nil {
+ t.Fatalf("SaveProfile: %v", err)
+ }
+ svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
editor, err := svc.GetProfileEditor("local_3588_test")
if err != nil {
t.Fatalf("GetProfileEditor: %v", err)
@@ -167,7 +202,7 @@ func TestConfigPreviewServiceBuildsProfileEditor(t *testing.T) {
if editor.Instances[0].Name != "cam1" || editor.Instances[0].DisplayName != "东门入口" {
t.Fatalf("unexpected first instance summary: %#v", editor.Instances[0])
}
- if editor.Instances[1].Name != "cam2" || editor.Instances[1].RTSPURL != "rtsp://10.0.0.2/live" {
+ if editor.Instances[1].Name != "cam2" || editor.Instances[1].VideoSourceRef != "line_cam_02" {
t.Fatalf("unexpected second instance summary: %#v", editor.Instances[1])
}
if editor.Instances[0].PublishRTSPPort != "8555" {
@@ -176,8 +211,8 @@ func TestConfigPreviewServiceBuildsProfileEditor(t *testing.T) {
if editor.Queue.Size != "8" || editor.Queue.Strategy != "drop_oldest" {
t.Fatalf("unexpected queue model: %#v", editor.Queue)
}
- if editor.ObjectStorageRef != "" || editor.TokenServiceRef != "" || editor.AlarmServiceRef != "" {
- t.Fatalf("expected no integration refs in legacy fixture, got %#v", editor)
+ if editor.Instances[0].VideoSourceRef != "gate_cam_01" {
+ t.Fatalf("expected slot-driven video source ref, got %#v", editor.Instances[0])
}
if _, ok := editor.Instances[0].AdvancedParams["queue_debug"]; !ok {
t.Fatalf("expected advanced param to remain in editor, got %#v", editor.Instances[0].AdvancedParams)
@@ -192,9 +227,6 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) {
Description: "test profile",
DeviceCode: "rk3588-a-001",
SiteName: "A厂区",
- ObjectStorageRef: "minio_main",
- TokenServiceRef: "token_main",
- AlarmServiceRef: "alarm_main",
Queue: ConfigProfileQueueEditor{
Size: "8",
Strategy: "drop_oldest",
@@ -203,8 +235,8 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) {
{
Name: "cam1",
Template: "std_workshop_face_recognition_shoe_alarm",
+ VideoSourceRef: "gate_cam_01",
DisplayName: "东门入口",
- RTSPURL: "rtsp://10.0.0.1/live",
PublishHLSPath: "./web/hls/cam1/index.m3u8",
PublishRTSPPort: "8555",
PublishRTSPPath: "/live/cam1",
@@ -216,8 +248,8 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) {
{
Name: "cam2",
Template: "std_workshop_face_recognition_shoe_alarm",
+ VideoSourceRef: "line_cam_02",
DisplayName: "视觉识别终端-B厂区",
- RTSPURL: "rtsp://10.0.0.2/live",
PublishHLSPath: "./web/hls/cam2/index.m3u8",
PublishRTSPPort: "8556",
PublishRTSPPath: "/live/cam2",
@@ -237,9 +269,6 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) {
if doc["business_name"] != "A厂区视觉识别" {
t.Fatalf("unexpected business name: %#v", doc)
}
- if doc["object_storage_ref"] != "minio_main" || doc["token_service_ref"] != "token_main" || doc["alarm_service_ref"] != "alarm_main" {
- t.Fatalf("expected integration refs in document, got %#v", doc)
- }
queue, _ := doc["queue"].(map[string]any)
if queue["size"] != 8 || queue["strategy"] != "drop_oldest" {
t.Fatalf("unexpected queue doc: %#v", queue)
@@ -249,24 +278,169 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) {
t.Fatalf("expected two instances, got %#v", doc["instances"])
}
params, _ := instances[0]["params"].(map[string]any)
- if params["publish_rtsp_port"] != 8555 {
- t.Fatalf("expected numeric rtsp port, got %#v", params["publish_rtsp_port"])
- }
if params["queue_debug"] != true {
t.Fatalf("expected advanced param to survive rebuild, got %#v", params)
}
- if params["device_code"] != "rk3588-a-001" {
- t.Fatalf("expected legacy device code to be preserved in params, got %#v", params)
- }
- if params["site_name"] != "A厂区" {
- t.Fatalf("expected profile site name to be written to instance params, got %#v", params)
+ if _, exists := params["video_source_ref"]; exists {
+ t.Fatalf("expected new profile document to avoid legacy video_source_ref in params, got %#v", params)
}
params2, _ := instances[1]["params"].(map[string]any)
- if params2["publish_rtsp_path"] != "/live/cam2" {
- t.Fatalf("expected second instance to survive rebuild, got %#v", params2)
+ if len(params2) != 0 {
+ t.Fatalf("expected second instance params to stay empty under new model, got %#v", params2)
}
- if params2["site_name"] != "A厂区" {
- t.Fatalf("expected profile site name on second instance, got %#v", params2)
+ sceneMeta, _ := instances[0]["scene_meta"].(map[string]any)
+ if sceneMeta["display_name"] != "东门入口" || sceneMeta["site_name"] != "A厂区" || sceneMeta["device_code"] != "rk3588-a-001" {
+ t.Fatalf("expected scene meta to carry scene fields, got %#v", sceneMeta)
+ }
+}
+
+func TestBuildProfileDocumentUsesSlotBindings(t *testing.T) {
+ svc := NewConfigPreviewService(&config.Config{})
+ doc, err := svc.BuildProfileDocument(ConfigProfileEditor{
+ Name: "line_a",
+ Instances: []ConfigProfileInstanceEditor{{
+ Name: "cam1",
+ Template: "std_workshop_face_recognition_shoe_alarm",
+ DisplayName: "B厂区通道1",
+ SiteName: "B厂区",
+ InputBindings: map[string]InputBindingEditor{
+ "video_input_main": {VideoSourceRef: "gate_cam_01"},
+ },
+ ServiceBindings: map[string]ServiceBindingEditor{
+ "object_storage_main": {ServiceRef: "minio_main"},
+ "token_service_main": {ServiceRef: "token_main"},
+ "alarm_service_main": {ServiceRef: "alarm_main"},
+ },
+ OutputBindings: map[string]OutputBindingEditor{
+ "stream_output_main": {
+ PublishHLSPath: "./web/hls/cam1/index.m3u8",
+ PublishRTSPPort: "8555",
+ PublishRTSPPath: "/live/cam1",
+ ChannelNo: "cam1",
+ },
+ },
+ }},
+ })
+ if err != nil {
+ t.Fatalf("BuildProfileDocument: %v", err)
+ }
+ instances, _ := doc["instances"].([]map[string]any)
+ if len(instances) != 1 {
+ t.Fatalf("expected one instance, got %#v", doc["instances"])
+ }
+ inst := instances[0]
+ inputBindings, _ := inst["input_bindings"].(map[string]any)
+ if inputBindings == nil {
+ t.Fatalf("expected input_bindings, got %#v", inst)
+ }
+ videoInput, _ := inputBindings["video_input_main"].(map[string]any)
+ if videoInput["video_source_ref"] != "gate_cam_01" {
+ t.Fatalf("unexpected input binding: %#v", videoInput)
+ }
+ serviceBindings, _ := inst["service_bindings"].(map[string]any)
+ objectStorage, _ := serviceBindings["object_storage_main"].(map[string]any)
+ if objectStorage["service_ref"] != "minio_main" {
+ t.Fatalf("unexpected service binding: %#v", serviceBindings)
+ }
+ outputBindings, _ := inst["output_bindings"].(map[string]any)
+ streamOutput, _ := outputBindings["stream_output_main"].(map[string]any)
+ if streamOutput["publish_rtsp_port"] != 8555 {
+ t.Fatalf("unexpected output binding: %#v", streamOutput)
+ }
+ sceneMeta, _ := inst["scene_meta"].(map[string]any)
+ if sceneMeta["display_name"] != "B厂区通道1" || sceneMeta["site_name"] != "B厂区" {
+ t.Fatalf("unexpected scene meta: %#v", sceneMeta)
+ }
+}
+
+func TestListVideoSources(t *testing.T) {
+ 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","area":"东门入口","description":"东门主入口摄像头","config":{"url":"rtsp://10.0.0.1/live","resolution":"1080p","frame_size":"1920x1080","fps":"25","video_format":"h264","focal_length":"4mm","mount_height":"3.2m","mount_angle":"15deg"}}`,
+ ); err != nil {
+ t.Fatalf("SaveVideoSource: %v", err)
+ }
+
+ svc := NewConfigPreviewService(&config.Config{}, repo)
+ items, err := svc.ListVideoSources()
+ if err != nil {
+ t.Fatalf("ListVideoSources: %v", err)
+ }
+ if len(items) != 1 {
+ t.Fatalf("expected 1 video source, got %#v", items)
+ }
+ if items[0].SourceType != "rtsp" || items[0].SourceTypeLabel != "RTSP" {
+ t.Fatalf("unexpected video source summary: %#v", items[0])
+ }
+ if items[0].Config.Resolution != "1080p" || items[0].Config.FrameSize != "1920x1080" {
+ t.Fatalf("unexpected video source config: %#v", items[0])
+ }
+}
+
+func TestDeleteVideoSourceBlocksWhenReferenced(t *testing.T) {
+ 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)
+ }
+ if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
+ t.Fatalf("SaveProfile: %v", err)
+ }
+
+ svc := NewConfigPreviewService(&config.Config{}, repo)
+ err = svc.DeleteVideoSource("gate_cam_01")
+ if err == nil || !strings.Contains(err.Error(), "已被场景配置引用") {
+ t.Fatalf("expected referenced delete to be blocked, got %v", err)
+ }
+}
+
+func TestSaveVideoSourceAssetAllowsChineseName(t *testing.T) {
+ store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
+ if err != nil {
+ t.Fatalf("OpenSQLite: %v", err)
+ }
+ defer store.Close()
+
+ svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB()))
+ err = svc.SaveVideoSourceAsset(ConfigVideoSourceAsset{
+ Name: "东门主入口",
+ SourceType: "rtsp",
+ Area: "东门",
+ Description: "入口相机",
+ Config: VideoSourceConfig{
+ URL: "rtsp://10.0.0.1/live",
+ },
+ })
+ if err != nil {
+ t.Fatalf("expected chinese video source name to be accepted, got %v", err)
+ }
+ item, err := svc.GetVideoSource("东门主入口")
+ if err != nil {
+ t.Fatalf("GetVideoSource: %v", err)
+ }
+ if item == nil || item.Name != "东门主入口" {
+ t.Fatalf("unexpected saved item: %#v", item)
}
}
@@ -277,8 +451,8 @@ func TestConfigPreviewServiceBuildProfileDocumentRejectsBadPort(t *testing.T) {
Instances: []ConfigProfileInstanceEditor{
{
Name: "cam1",
+ VideoSourceRef: "gate_cam_01",
DisplayName: "视觉识别终端-A厂区",
- RTSPURL: "rtsp://10.0.0.1/live",
PublishRTSPPort: "bad-port",
},
},
@@ -294,9 +468,9 @@ func TestConfigPreviewServiceBuildProfileDocumentJSONShape(t *testing.T) {
Name: "local_3588_test",
Instances: []ConfigProfileInstanceEditor{
{
- Name: "cam1",
- DisplayName: "视觉识别终端-A厂区",
- RTSPURL: "rtsp://10.0.0.1/live",
+ Name: "cam1",
+ VideoSourceRef: "gate_cam_01",
+ DisplayName: "视觉识别终端-A厂区",
},
},
})
@@ -323,7 +497,7 @@ func TestConfigPreviewServiceListsSourcesFromAssetsRepo(t *testing.T) {
if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
- if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`); err != nil {
+ if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
if err := repo.SaveOverlay("night_relaxed", "night overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"override":{}}}}`); err != nil {
@@ -427,7 +601,7 @@ func TestListIntegrationServicesCountsProfileReferences(t *testing.T) {
); err != nil {
t.Fatalf("SaveIntegrationService: %v", err)
}
- if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","object_storage_ref":"minio_primary","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","params":{"rtsp_url":"rtsp://10.0.0.1/live"}}]}`); err != nil {
+ if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_primary"}}}]}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
@@ -506,7 +680,7 @@ func TestDeleteIntegrationServiceBlocksWhenReferenced(t *testing.T) {
); err != nil {
t.Fatalf("SaveIntegrationService: %v", err)
}
- if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","object_storage_ref":"minio_primary","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","params":{"rtsp_url":"rtsp://10.0.0.1/live"}}]}`); err != nil {
+ if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_primary"}}}]}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
@@ -533,10 +707,10 @@ func TestConfigPreviewServiceSavesProfileEditorToAssetsRepo(t *testing.T) {
SiteName: "A厂区",
Instances: []ConfigProfileInstanceEditor{
{
- Name: "cam1",
- Template: "helmet",
- DisplayName: "东门入口",
- RTSPURL: "rtsp://10.0.0.1/live",
+ Name: "cam1",
+ Template: "helmet",
+ DisplayName: "东门入口",
+ VideoSourceRef: "gate_cam_01",
},
},
}
@@ -563,7 +737,7 @@ func TestConfigPreviewServiceSavesProfileEditorToAssetsRepo(t *testing.T) {
}
}
-func TestConfigPreviewServicePrefersBuiltinTemplateOverRepoShadow(t *testing.T) {
+func TestConfigPreviewServicePrefersRepoTemplateOverBuiltinFallback(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{
"name": "helmet",
@@ -585,24 +759,23 @@ func TestConfigPreviewServicePrefersBuiltinTemplateOverRepoShadow(t *testing.T)
if err != nil {
t.Fatalf("GetTemplateAsset: %v", err)
}
- if !item.ReadOnly || item.Origin != "builtin" {
- t.Fatalf("expected builtin readonly template, got %#v", item)
+ if item.ReadOnly || item.Origin != "user" {
+ t.Fatalf("expected sqlite template to be preferred, got %#v", item)
}
- if item.Description != "builtin template" {
- t.Fatalf("expected builtin template payload, got %#v", item)
+ if item.Description != "shadow template" {
+ t.Fatalf("expected sqlite template payload, got %#v", item)
}
items, err := svc.ListTemplateAssets()
if err != nil {
t.Fatalf("ListTemplateAssets: %v", err)
}
- if len(items) != 1 || items[0].Name != "helmet" || !items[0].ReadOnly {
- t.Fatalf("expected only builtin template in merged list, got %#v", items)
+ if len(items) != 1 || items[0].Name != "helmet" || items[0].ReadOnly {
+ t.Fatalf("expected only sqlite template in merged list, got %#v", items)
}
}
-func TestConfigPreviewServiceRejectsSavingBuiltinTemplateName(t *testing.T) {
+func TestConfigPreviewServiceRejectsSavingStandardTemplateName(t *testing.T) {
root := t.TempDir()
- mustWrite(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{"name":"helmet","template":{"nodes":[],"edges":[]}}`)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
@@ -611,7 +784,7 @@ func TestConfigPreviewServiceRejectsSavingBuiltinTemplateName(t *testing.T) {
repo := storage.NewAssetsRepo(store.DB())
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
- err = svc.SaveTemplateAsset("helmet", "new body", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`)
+ err = svc.SaveTemplateAsset("std_face_recognition_stream", "new body", `{"name":"std_face_recognition_stream","template":{"nodes":[],"edges":[]}}`)
if err == nil || !strings.Contains(err.Error(), "read-only") {
t.Fatalf("expected readonly rejection, got %v", err)
}
@@ -627,7 +800,7 @@ func TestConfigPreviewServiceRenamesTemplateAndUpdatesProfileRefs(t *testing.T)
if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
- if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`); err != nil {
+ if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
svc := NewConfigPreviewService(&config.Config{}, repo)
@@ -663,7 +836,7 @@ func TestConfigPreviewServiceRejectsDeletingReferencedTemplate(t *testing.T) {
if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
- if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`); err != nil {
+ if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
svc := NewConfigPreviewService(&config.Config{}, repo)
@@ -674,10 +847,10 @@ func TestConfigPreviewServiceRejectsDeletingReferencedTemplate(t *testing.T) {
}
}
-func TestConfigPreviewServiceImportAssetsSkipsBuiltinTemplates(t *testing.T) {
+func TestConfigPreviewServiceImportAssetsIncludesTemplates(t *testing.T) {
root := t.TempDir()
- mustWrite(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{"name":"helmet","template":{"nodes":[],"edges":[]}}`)
- mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`)
+ mustWrite(t, filepath.Join(root, "configs", "templates", "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","template":{"nodes":[],"edges":[]}}`)
+ mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`)
mustWrite(t, filepath.Join(root, "configs", "overlays", "night_relaxed.json"), `{"name":"night_relaxed","instance_overrides":{"cam1":{"override":{}}}}`)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
@@ -691,14 +864,51 @@ func TestConfigPreviewServiceImportAssetsSkipsBuiltinTemplates(t *testing.T) {
if err != nil {
t.Fatalf("ImportAssetsFromMediaRepo: %v", err)
}
- if result.Templates != 0 || result.Profiles != 1 || result.Overlays != 1 {
+ if result.Templates != 1 || result.Profiles != 1 || result.Overlays != 1 {
t.Fatalf("unexpected import result: %#v", result)
}
- record, err := repo.GetTemplate("helmet")
+ record, err := repo.GetTemplate("std_face_recognition_stream")
if err != nil {
t.Fatalf("GetTemplate: %v", err)
}
- if record != nil {
- t.Fatalf("expected builtin template to stay out of sqlite, got %#v", record)
+ if record == nil || !strings.Contains(record.BodyJSON, `"source": "standard"`) && !strings.Contains(record.BodyJSON, `"source":"standard"`) {
+ t.Fatalf("expected standard template to be imported into sqlite, got %#v", record)
+ }
+}
+
+func TestImportStandardTemplatesFromDirSkipsExisting(t *testing.T) {
+ root := t.TempDir()
+ mustWrite(t, filepath.Join(root, "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","description":"standard face","template":{"nodes":[],"edges":[]}}`)
+ mustWrite(t, filepath.Join(root, "std_service_test_stream.json"), `{"name":"std_service_test_stream","description":"standard service","template":{"nodes":[],"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.SaveTemplate("std_service_test_stream", "existing service", `{"name":"std_service_test_stream","source":"standard","template":{"nodes":[],"edges":[]}}`); err != nil {
+ t.Fatalf("SaveTemplate: %v", err)
+ }
+
+ imported, err := ImportStandardTemplatesFromDir(repo, root)
+ if err != nil {
+ t.Fatalf("ImportStandardTemplatesFromDir: %v", err)
+ }
+ if imported != 1 {
+ t.Fatalf("expected one imported standard template, got %d", imported)
+ }
+ face, err := repo.GetTemplate("std_face_recognition_stream")
+ if err != nil || face == nil {
+ t.Fatalf("expected imported face template, got %#v err=%v", face, err)
+ }
+ if !strings.Contains(face.BodyJSON, `"source": "standard"`) && !strings.Contains(face.BodyJSON, `"source":"standard"`) {
+ t.Fatalf("expected imported template source marker, got %#v", face)
+ }
+ serviceRecord, err := repo.GetTemplate("std_service_test_stream")
+ if err != nil || serviceRecord == nil {
+ t.Fatalf("expected existing service template, got %#v err=%v", serviceRecord, err)
+ }
+ if serviceRecord.Description != "existing service" {
+ t.Fatalf("expected existing template preserved, got %#v", serviceRecord)
}
}
diff --git a/internal/service/config_preview.go b/internal/service/config_preview.go
index 1904cae..8242bf8 100644
--- a/internal/service/config_preview.go
+++ b/internal/service/config_preview.go
@@ -1,13 +1,11 @@
package service
import (
- "bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
- "os/exec"
"path/filepath"
"regexp"
"sort"
@@ -70,159 +68,105 @@ func NewConfigPreviewService(cfg *config.Config, repo ...*storage.AssetsRepo) *C
}
func (s *ConfigPreviewService) ListSources() (ConfigPreviewSources, error) {
- root := s.mediaRepoRoot()
- out := ConfigPreviewSources{Root: root}
- seenTemplates := map[string]bool{}
- if root != "" {
- templates, err := listConfigSources(filepath.Join(root, "configs", "templates"))
- if err != nil {
- if s.hasExplicitRoot() {
- return out, err
- }
- } else {
- out.Templates = append(out.Templates, templates...)
- for _, item := range templates {
- seenTemplates[item.Name] = true
- }
- }
- profiles, err := listConfigSources(filepath.Join(root, "configs", "profiles"))
- if err != nil {
- if s.hasExplicitRoot() {
- return out, err
- }
- } else {
- out.Profiles = profiles
- }
- overlays, err := listConfigSources(filepath.Join(root, "configs", "overlays"))
- if err != nil {
- if s.hasExplicitRoot() {
- return out, err
- }
- } else {
- out.Overlays = overlays
- }
- }
-
- if s != nil && s.assets != nil {
- templates, err := s.assets.ListTemplates()
- if err != nil {
- return out, err
- }
- for _, item := range templates {
- if seenTemplates[item.Name] {
- continue
- }
- out.Templates = append(out.Templates, ConfigSource{Name: item.Name, Path: repoAssetPath("templates", item.Name)})
- }
- profiles, err := s.assets.ListProfiles()
- if err != nil {
- return out, err
- }
- if len(profiles) > 0 {
- out.Profiles = out.Profiles[:0]
- for _, item := range profiles {
- out.Profiles = append(out.Profiles, ConfigSource{Name: item.Name, Path: repoAssetPath("profiles", item.Name)})
- }
- }
- overlays, err := s.assets.ListOverlays()
- if err != nil {
- return out, err
- }
- if len(overlays) > 0 {
- out.Overlays = out.Overlays[:0]
- for _, item := range overlays {
- out.Overlays = append(out.Overlays, ConfigSource{Name: item.Name, Path: repoAssetPath("overlays", item.Name)})
- }
- }
- }
- sort.Slice(out.Templates, func(i, j int) bool { return out.Templates[i].Name < out.Templates[j].Name })
- sort.Slice(out.Profiles, func(i, j int) bool { return out.Profiles[i].Name < out.Profiles[j].Name })
- sort.Slice(out.Overlays, func(i, j int) bool { return out.Overlays[i].Name < out.Overlays[j].Name })
- if out.Root == "" && s != nil && s.assets != nil && (len(out.Templates) > 0 || len(out.Profiles) > 0 || len(out.Overlays) > 0) {
- out.Root = "SQLite"
- }
- if out.Root == "" && len(out.Templates) == 0 && len(out.Profiles) == 0 && len(out.Overlays) == 0 {
- return defaultConfigPreviewSources(""), nil
- }
- return out, nil
-}
-
-func (s *ConfigPreviewService) listRepoSources() (ConfigPreviewSources, bool, error) {
+ out := ConfigPreviewSources{}
if s == nil || s.assets == nil {
- return ConfigPreviewSources{}, false, nil
+ return out, nil
}
templates, err := s.assets.ListTemplates()
if err != nil {
- return ConfigPreviewSources{}, true, err
+ return out, err
+ }
+ for _, item := range templates {
+ out.Templates = append(out.Templates, ConfigSource{Name: item.Name, Path: repoAssetPath("templates", item.Name)})
}
profiles, err := s.assets.ListProfiles()
if err != nil {
- return ConfigPreviewSources{}, true, err
- }
- overlays, err := s.assets.ListOverlays()
- if err != nil {
- return ConfigPreviewSources{}, true, err
- }
- if len(templates) == 0 && len(profiles) == 0 && len(overlays) == 0 {
- return ConfigPreviewSources{}, false, nil
- }
- out := ConfigPreviewSources{Root: "SQLite"}
- for _, item := range templates {
- out.Templates = append(out.Templates, ConfigSource{Name: item.Name, Path: repoAssetPath("templates", item.Name)})
+ return out, err
}
for _, item := range profiles {
out.Profiles = append(out.Profiles, ConfigSource{Name: item.Name, Path: repoAssetPath("profiles", item.Name)})
}
+ overlays, err := s.assets.ListOverlays()
+ if err != nil {
+ return out, err
+ }
for _, item := range overlays {
out.Overlays = append(out.Overlays, ConfigSource{Name: item.Name, Path: repoAssetPath("overlays", item.Name)})
}
- return out, true, nil
+ sort.Slice(out.Templates, func(i, j int) bool { return out.Templates[i].Name < out.Templates[j].Name })
+ sort.Slice(out.Profiles, func(i, j int) bool { return out.Profiles[i].Name < out.Profiles[j].Name })
+ sort.Slice(out.Overlays, func(i, j int) bool { return out.Overlays[i].Name < out.Overlays[j].Name })
+ if len(out.Templates) > 0 || len(out.Profiles) > 0 || len(out.Overlays) > 0 {
+ out.Root = "SQLite"
+ }
+ return out, nil
}
func (s *ConfigPreviewService) Render(req ConfigPreviewRequest) (*ConfigPreviewResult, error) {
- root := s.mediaRepoRoot()
- if root == "" {
- return nil, fmt.Errorf("media repo path is not configured")
+ if err := validateConfigName(req.Template); err != nil {
+ return nil, fmt.Errorf("invalid template: %w", err)
}
- templatePath := filepath.Join(root, "configs", "templates", req.Template+".json")
- profilePath := filepath.Join(root, "configs", "profiles", req.Profile+".json")
- return s.renderFromPaths(root, req, templatePath, profilePath)
+ if strings.TrimSpace(req.Profile) != "" {
+ if err := validateConfigName(req.Profile); err != nil {
+ return nil, fmt.Errorf("invalid profile: %w", err)
+ }
+ }
+ for _, overlay := range req.Overlays {
+ if err := validateConfigName(overlay); err != nil {
+ return nil, fmt.Errorf("invalid overlay %q: %w", overlay, err)
+ }
+ }
+ templateRaw, templatePath, err := s.readAssetJSON("templates", req.Template)
+ if err != nil {
+ return nil, err
+ }
+ profileRaw, profilePath, err := s.readAssetJSON("profiles", req.Profile)
+ if err != nil {
+ return nil, err
+ }
+ overlays := make([]runtimeOverlayInput, 0, len(req.Overlays))
+ for _, overlay := range req.Overlays {
+ raw, path, err := s.readAssetJSON("overlays", overlay)
+ if err != nil {
+ return nil, err
+ }
+ overlays = append(overlays, runtimeOverlayInput{Name: overlay, Path: path, Raw: raw})
+ }
+ return s.renderFromAssets(req, templateRaw, templatePath, profileRaw, profilePath, overlays)
}
func (s *ConfigPreviewService) RenderProfileEditor(editor ConfigProfileEditor, req ConfigPreviewRequest) (*ConfigPreviewResult, error) {
- root := s.mediaRepoRoot()
- if root == "" {
- return nil, fmt.Errorf("media repo path is not configured")
+ if err := validateConfigName(req.Template); err != nil {
+ return nil, fmt.Errorf("invalid template: %w", err)
+ }
+ for _, overlay := range req.Overlays {
+ if err := validateConfigName(overlay); err != nil {
+ return nil, fmt.Errorf("invalid overlay %q: %w", overlay, err)
+ }
}
doc, err := s.BuildProfileDocument(editor)
if err != nil {
return nil, err
}
- body, err := marshalConfigJSON(doc)
- if err != nil {
- return nil, err
- }
- tempProfile, err := os.CreateTemp("", "rk3588-profile-editor-*.json")
- if err != nil {
- return nil, err
- }
- tempProfilePath := tempProfile.Name()
- if _, err := tempProfile.Write(body); err != nil {
- _ = tempProfile.Close()
- _ = os.Remove(tempProfilePath)
- return nil, err
- }
- _ = tempProfile.Close()
- defer os.Remove(tempProfilePath)
-
if strings.TrimSpace(req.Profile) == "" {
req.Profile = strings.TrimSpace(editor.Name)
}
- templatePath := filepath.Join(root, "configs", "templates", req.Template+".json")
- return s.renderFromPaths(root, req, templatePath, tempProfilePath)
+ templateRaw, templatePath, err := s.readAssetJSON("templates", req.Template)
+ if err != nil {
+ return nil, err
+ }
+ overlays := make([]runtimeOverlayInput, 0, len(req.Overlays))
+ for _, overlay := range req.Overlays {
+ raw, path, err := s.readAssetJSON("overlays", overlay)
+ if err != nil {
+ return nil, err
+ }
+ overlays = append(overlays, runtimeOverlayInput{Name: overlay, Path: path, Raw: raw})
+ }
+ return s.renderFromAssets(req, templateRaw, templatePath, doc, repoAssetPath("profiles", req.Profile), overlays)
}
-func (s *ConfigPreviewService) renderFromPaths(root string, req ConfigPreviewRequest, templatePath string, profilePath string) (*ConfigPreviewResult, error) {
+func (s *ConfigPreviewService) renderFromAssets(req ConfigPreviewRequest, templateRaw map[string]any, templatePath string, profileRaw map[string]any, profilePath string, overlays []runtimeOverlayInput) (*ConfigPreviewResult, error) {
if err := validateConfigName(req.Template); err != nil {
return nil, fmt.Errorf("invalid template: %w", err)
}
@@ -242,76 +186,28 @@ func (s *ConfigPreviewService) renderFromPaths(root string, req ConfigPreviewReq
if req.ConfigVersion == "" {
req.ConfigVersion = time.Now().Format("20060102.150405")
}
- if _, err := os.Stat(templatePath); err != nil {
- return nil, err
- }
- if _, err := os.Stat(profilePath); err != nil {
- return nil, fmt.Errorf("invalid profile: %w", err)
- }
- profileRaw, err := readConfigJSONFile(profilePath)
- if err != nil {
- return nil, fmt.Errorf("read profile for integrations: %w", err)
- }
- integrationParams, err := s.integrationParamsForProfile(profileRaw)
+ resolvedProfileRaw, err := s.resolveSceneBindings(profileRaw)
if err != nil {
return nil, err
}
- resolvedTemplatePath := templatePath
- if len(integrationParams) > 0 {
- tempTemplatePath, err := buildResolvedTemplateFile(templatePath, integrationParams)
- if err != nil {
- return nil, err
- }
- resolvedTemplatePath = tempTemplatePath
- defer os.Remove(tempTemplatePath)
- }
-
- out, err := os.CreateTemp("", "rk3588-config-preview-*.json")
+ doc, err := renderRuntimeConfig(templateRaw, templatePath, resolvedProfileRaw, profilePath, overlays, map[string]any{
+ "config_id": req.ConfigID,
+ "config_version": req.ConfigVersion,
+ "rendered_at": time.Now().Format(time.RFC3339),
+ "rendered_by": "managerd",
+ })
if err != nil {
return nil, err
}
- outPath := out.Name()
- _ = out.Close()
- defer os.Remove(outPath)
-
- args := []string{
- filepath.Join(root, "tools", "render_config.py"),
- "--template", resolvedTemplatePath,
- "--profile", profilePath,
- "--out", outPath,
- "--config-id", req.ConfigID,
- "--config-version", req.ConfigVersion,
- "--rendered-at", time.Now().Format(time.RFC3339),
- }
- for _, overlay := range req.Overlays {
- args = append(args, "--overlay", filepath.Join(root, "configs", "overlays", overlay+".json"))
- }
-
- cmd := exec.Command("python", args...)
- cmd.Dir = root
- var stderr bytes.Buffer
- cmd.Stderr = &stderr
- if err := cmd.Run(); err != nil {
- msg := strings.TrimSpace(stderr.String())
- if msg == "" {
- msg = err.Error()
- }
- return nil, fmt.Errorf("render config preview: %s", msg)
- }
-
- body, err := os.ReadFile(outPath)
+ body, err := marshalConfigJSON(doc)
if err != nil {
return nil, err
}
- var doc map[string]any
- if err := json.Unmarshal(body, &doc); err != nil {
- return nil, err
- }
metadata, _ := doc["metadata"].(map[string]any)
sum := sha256.Sum256(body)
return &ConfigPreviewResult{
Request: req,
- Root: root,
+ Root: previewRenderRoot(templatePath, profilePath),
Sha256: hex.EncodeToString(sum[:]),
Size: len(body),
Metadata: metadata,
@@ -319,114 +215,160 @@ func (s *ConfigPreviewService) renderFromPaths(root string, req ConfigPreviewReq
}, nil
}
-func (s *ConfigPreviewService) integrationParamsForProfile(raw map[string]any) (map[string]any, error) {
- params := map[string]any{}
+func (s *ConfigPreviewService) resolveSceneBindings(raw map[string]any) (map[string]any, error) {
if raw == nil {
- return params, nil
+ return map[string]any{}, nil
}
- type refDef struct {
- key string
- expected string
- apply func(asset *ConfigIntegrationServiceAsset)
+ body, err := json.Marshal(raw)
+ if err != nil {
+ return nil, err
}
- defs := []refDef{
- {
- key: "object_storage_ref",
- expected: "object_storage",
- apply: func(asset *ConfigIntegrationServiceAsset) {
- if asset.ObjectStorage == nil {
- return
- }
- setAnyString(params, "minio_endpoint", asset.ObjectStorage.Endpoint)
- setAnyString(params, "minio_bucket", asset.ObjectStorage.Bucket)
- setAnyString(params, "minio_access_key", asset.ObjectStorage.AccessKey)
- setAnyString(params, "minio_secret_key", asset.ObjectStorage.SecretKey)
- },
- },
- {
- key: "token_service_ref",
- expected: "token_service",
- apply: func(asset *ConfigIntegrationServiceAsset) {
- if asset.TokenService == nil {
- return
- }
- setAnyString(params, "external_get_token_url", asset.TokenService.GetTokenURL)
- setAnyString(params, "tenant_code", asset.TokenService.TenantCode)
- },
- },
- {
- key: "alarm_service_ref",
- expected: "alarm_service",
- apply: func(asset *ConfigIntegrationServiceAsset) {
- if asset.AlarmService == nil {
- return
- }
- setAnyString(params, "external_put_message_url", asset.AlarmService.PutMessageURL)
- setAnyString(params, "tenant_code", asset.AlarmService.TenantCode)
- },
- },
+ var clone map[string]any
+ if err := json.Unmarshal(body, &clone); err != nil {
+ return nil, err
}
- for _, def := range defs {
- refName := strings.TrimSpace(stringValue(raw[def.key]))
- if refName == "" {
- continue
+ instances, _ := clone["instances"].([]any)
+ for _, item := range instances {
+ instanceMap, _ := item.(map[string]any)
+ paramsMap, _ := instanceMap["params"].(map[string]any)
+ if paramsMap == nil {
+ paramsMap = map[string]any{}
+ instanceMap["params"] = paramsMap
}
- asset, err := s.GetIntegrationService(refName)
- if err != nil {
- return nil, fmt.Errorf("load %s %q: %w", def.key, refName, err)
+ inputBindings, _ := instanceMap["input_bindings"].(map[string]any)
+ if inputBindings == nil {
+ inputBindings = map[string]any{}
+ instanceMap["input_bindings"] = inputBindings
}
- if strings.TrimSpace(asset.Type) != def.expected {
- return nil, fmt.Errorf("%s %q has type %q, expected %q", def.key, refName, asset.Type, def.expected)
+ videoSourceRef := bindingField(inputBindings, "video_input_main", "video_source_ref")
+ if videoSourceRef != "" {
+ asset, err := s.GetVideoSource(videoSourceRef)
+ if err != nil {
+ return nil, fmt.Errorf("load video_source_ref %q: %w", videoSourceRef, err)
+ }
+ entry, _ := inputBindings["video_input_main"].(map[string]any)
+ if entry == nil {
+ entry = map[string]any{}
+ }
+ entry["video_source_ref"] = videoSourceRef
+ entry["resolved"] = map[string]any{
+ "url": asset.Config.URL,
+ "resolution": asset.Config.Resolution,
+ "frame_size": asset.Config.FrameSize,
+ "fps": asset.Config.FPS,
+ "video_format": asset.Config.VideoFormat,
+ }
+ inputBindings["video_input_main"] = entry
+ }
+
+ serviceBindings, _ := instanceMap["service_bindings"].(map[string]any)
+ if serviceBindings == nil {
+ serviceBindings = map[string]any{}
+ instanceMap["service_bindings"] = serviceBindings
+ }
+ for _, binding := range []struct {
+ slot string
+ expected string
+ }{
+ {slot: "object_storage_main", expected: "object_storage"},
+ {slot: "token_service_main", expected: "token_service"},
+ {slot: "alarm_service_main", expected: "alarm_service"},
+ } {
+ serviceRef := bindingField(serviceBindings, binding.slot, "service_ref")
+ if serviceRef == "" {
+ continue
+ }
+ asset, err := s.GetIntegrationService(serviceRef)
+ if err != nil {
+ return nil, fmt.Errorf("load service_ref %q: %w", serviceRef, err)
+ }
+ if strings.TrimSpace(asset.Type) != binding.expected {
+ return nil, fmt.Errorf("service_ref %q has type %q, expected %q", serviceRef, asset.Type, binding.expected)
+ }
+ entry, _ := serviceBindings[binding.slot].(map[string]any)
+ if entry == nil {
+ entry = map[string]any{}
+ }
+ entry["service_ref"] = serviceRef
+ entry["resolved"] = resolvedServiceBinding(asset)
+ serviceBindings[binding.slot] = entry
}
- def.apply(asset)
}
- return params, nil
+ return clone, nil
}
-func buildResolvedTemplateFile(templatePath string, params map[string]any) (string, error) {
- raw, err := readConfigJSONFile(templatePath)
- if err != nil {
- return "", err
+func bindingField(bindings map[string]any, slot string, field string) string {
+ entry, _ := bindings[slot].(map[string]any)
+ return stringValue(entry[field])
+}
+
+func resolvedServiceBinding(asset *ConfigIntegrationServiceAsset) map[string]any {
+ if asset == nil {
+ return nil
}
- paramsMap, _ := raw["params"].(map[string]any)
- if paramsMap == nil {
- paramsMap = map[string]any{}
+ switch asset.Type {
+ case "object_storage":
+ if asset.ObjectStorage == nil {
+ return nil
+ }
+ return map[string]any{
+ "endpoint": asset.ObjectStorage.Endpoint,
+ "bucket": asset.ObjectStorage.Bucket,
+ "access_key": asset.ObjectStorage.AccessKey,
+ "secret_key": asset.ObjectStorage.SecretKey,
+ }
+ case "token_service":
+ if asset.TokenService == nil {
+ return nil
+ }
+ return map[string]any{
+ "get_token_url": asset.TokenService.GetTokenURL,
+ "username": asset.TokenService.Username,
+ "password": asset.TokenService.Password,
+ "tenant_code": asset.TokenService.TenantCode,
+ }
+ case "alarm_service":
+ if asset.AlarmService == nil {
+ return nil
+ }
+ return map[string]any{
+ "put_message_url": asset.AlarmService.PutMessageURL,
+ "username": asset.AlarmService.Username,
+ "password": asset.AlarmService.Password,
+ "tenant_code": asset.AlarmService.TenantCode,
+ }
+ default:
+ return nil
}
- for key, value := range params {
- paramsMap[key] = value
+}
+
+func previewRenderRoot(templatePath string, profilePath string) string {
+ if strings.HasPrefix(templatePath, "sqlite:") || strings.HasPrefix(profilePath, "sqlite:") {
+ return "SQLite"
}
- raw["params"] = paramsMap
+ if dir := filepath.Dir(templatePath); strings.TrimSpace(dir) != "" && dir != "." {
+ return dir
+ }
+ return "managerd"
+}
+
+func writeResolvedConfigFile(pattern string, raw map[string]any) (string, error) {
body, err := marshalConfigJSON(raw)
if err != nil {
return "", err
}
- tempTemplate, err := os.CreateTemp("", "rk3588-template-resolved-*.json")
+ tempFile, err := os.CreateTemp("", pattern)
if err != nil {
return "", err
}
- tempTemplatePath := tempTemplate.Name()
- if _, err := tempTemplate.Write(body); err != nil {
- _ = tempTemplate.Close()
- _ = os.Remove(tempTemplatePath)
+ path := tempFile.Name()
+ if _, err := tempFile.Write(body); err != nil {
+ _ = tempFile.Close()
+ _ = os.Remove(path)
return "", err
}
- _ = tempTemplate.Close()
- return tempTemplatePath, nil
-}
-
-func readConfigJSONFile(path string) (map[string]any, error) {
- body, err := os.ReadFile(path)
- if err != nil {
- return nil, err
- }
- var raw map[string]any
- if err := json.Unmarshal(body, &raw); err != nil {
- return nil, err
- }
- if raw == nil {
- raw = map[string]any{}
- }
- return raw, nil
+ _ = tempFile.Close()
+ return path, nil
}
func setAnyString(m map[string]any, key string, value string) {
@@ -436,36 +378,21 @@ func setAnyString(m map[string]any, key string, value string) {
}
func (s *ConfigPreviewService) mediaRepoRoot() string {
- if s.cfg != nil && strings.TrimSpace(s.cfg.MediaRepoPath) != "" {
- return filepath.Clean(strings.TrimSpace(s.cfg.MediaRepoPath))
- }
- if env := strings.TrimSpace(os.Getenv("ORANGEPI_MEDIA_REPO")); env != "" {
- return filepath.Clean(env)
- }
- wd, err := os.Getwd()
- if err != nil {
+ if s.cfg == nil {
return ""
}
- candidates := []string{
- filepath.Join(wd, "..", "OrangePi3588Media"),
- filepath.Join(wd, "..", "..", "OrangePi3588Media"),
- filepath.Join(filepath.Dir(wd), "OrangePi3588Media"),
+ if strings.TrimSpace(s.cfg.MediaRepoPath) == "" {
+ return ""
}
- for _, candidate := range candidates {
- if _, err := os.Stat(filepath.Join(candidate, "tools", "render_config.py")); err == nil {
- return filepath.Clean(candidate)
- }
- }
- return ""
-}
-
-func (s *ConfigPreviewService) hasExplicitRoot() bool {
- return s.cfg != nil && strings.TrimSpace(s.cfg.MediaRepoPath) != ""
+ return filepath.Clean(strings.TrimSpace(s.cfg.MediaRepoPath))
}
func listConfigSources(dir string) ([]ConfigSource, error) {
files, err := os.ReadDir(dir)
if err != nil {
+ if os.IsNotExist(err) {
+ return []ConfigSource{}, nil
+ }
return nil, err
}
out := make([]ConfigSource, 0)
@@ -490,45 +417,24 @@ func validateConfigName(name string) error {
return nil
}
-func defaultConfigPreviewSources(root string) ConfigPreviewSources {
- return ConfigPreviewSources{
- Root: root,
- Templates: []ConfigSource{
- {Name: "std_face_recognition_stream"},
- {Name: "std_service_test_stream"},
- {Name: "std_workshoe_detection_stream"},
- {Name: "std_workshop_face_recognition_shoe_alarm"},
- },
- Profiles: []ConfigSource{
- {Name: "local_3588_test"},
- },
- Overlays: []ConfigSource{
- {Name: "face_debug"},
- {Name: "face_test_sensitive"},
- {Name: "production_quiet"},
- {Name: "shoe_debug"},
- {Name: "shoe_test_sensitive"},
- },
- }
-}
-
func repoAssetPath(kind string, name string) string {
return "sqlite:" + kind + "/" + strings.TrimSpace(name)
}
func (s *ConfigPreviewService) ImportAssetsFromMediaRepo() (*ConfigAssetImportResult, error) {
if s == nil || s.assets == nil {
- return nil, fmt.Errorf("assets repository is not configured")
+ return nil, fmt.Errorf("asset repository is not configured")
}
root := s.mediaRepoRoot()
if root == "" {
- return nil, fmt.Errorf("media repo path is not configured")
+ return nil, fmt.Errorf("legacy import source path is not configured")
}
result := &ConfigAssetImportResult{Root: root}
for _, item := range []struct {
kind string
inc *int
}{
+ {kind: "templates", inc: &result.Templates},
{kind: "profiles", inc: &result.Profiles},
{kind: "overlays", inc: &result.Overlays},
} {
@@ -549,6 +455,16 @@ func (s *ConfigPreviewService) ImportAssetsFromMediaRepo() (*ConfigAssetImportRe
description := stringValue(raw["description"])
switch item.kind {
case "templates":
+ if raw == nil {
+ raw = map[string]any{}
+ }
+ if isStandardTemplateName(name) && strings.TrimSpace(stringValue(raw["source"])) == "" {
+ raw["source"] = "standard"
+ body, err = marshalConfigJSON(raw)
+ if err != nil {
+ return nil, err
+ }
+ }
if err := s.assets.SaveTemplate(name, description, string(body)); err != nil {
return nil, err
}
@@ -571,21 +487,13 @@ func (s *ConfigPreviewService) ExportAssetJSON(kind string, name string) ([]byte
if err := validateConfigName(name); err != nil {
return nil, "", err
}
- if s != nil && s.assets != nil {
- if body, ok, err := s.exportRepoAssetJSON(kind, name); ok || err != nil {
- return body, name + ".json", err
- }
+ if s == nil || s.assets == nil {
+ return nil, "", fmt.Errorf("asset repository is not configured")
}
- root := s.mediaRepoRoot()
- if root == "" {
- return nil, "", fmt.Errorf("media repo path is not configured")
+ if body, ok, err := s.exportRepoAssetJSON(kind, name); ok || err != nil {
+ return body, name + ".json", err
}
- path := filepath.Join(root, "configs", kind, name+".json")
- body, err := os.ReadFile(path)
- if err != nil {
- return nil, "", err
- }
- return body, name + ".json", nil
+ return nil, "", os.ErrNotExist
}
func (s *ConfigPreviewService) exportRepoAssetJSON(kind string, name string) ([]byte, bool, error) {
@@ -620,5 +528,5 @@ func profileRawTemplateName(raw map[string]any) string {
return v
}
}
- return stringValue(raw["template_name"])
+ return stringValue(raw["primary_template_name"])
}
diff --git a/internal/service/config_preview_test.go b/internal/service/config_preview_test.go
index 89b2340..7cb96f3 100644
--- a/internal/service/config_preview_test.go
+++ b/internal/service/config_preview_test.go
@@ -11,20 +11,34 @@ import (
"3588AdminBackend/internal/storage"
)
+func mustImportAssetsFromMediaRepo(t *testing.T, svc *ConfigPreviewService) {
+ t.Helper()
+ if _, err := svc.ImportAssetsFromMediaRepo(); err != nil {
+ t.Fatalf("ImportAssetsFromMediaRepo: %v", err)
+ }
+}
+
func TestConfigPreviewServiceListsSources(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{}`)
mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{}`)
mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
- svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root})
+ 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())
+ svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
+ mustImportAssetsFromMediaRepo(t, svc)
sources, err := svc.ListSources()
if err != nil {
t.Fatalf("ListSources: %v", err)
}
- if sources.Root != root {
- t.Fatalf("expected root %q, got %q", root, sources.Root)
+ if sources.Root != "SQLite" {
+ t.Fatalf("expected root %q, got %q", "SQLite", sources.Root)
}
if got := sourceNames(sources.Templates); strings.Join(got, ",") != "std_workshop_face_recognition_shoe_alarm" {
t.Fatalf("unexpected templates: %v", got)
@@ -37,6 +51,35 @@ func TestConfigPreviewServiceListsSources(t *testing.T) {
}
}
+func TestConfigPreviewServiceListSourcesAllowsEmptyConfigsDir(t *testing.T) {
+ root := t.TempDir()
+ 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.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil {
+ t.Fatalf("SaveTemplate: %v", err)
+ }
+ if err := repo.SaveProfile("line_a", "helmet", "Line A", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"helmet","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
+ t.Fatalf("SaveProfile: %v", err)
+ }
+
+ svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
+ sources, err := svc.ListSources()
+ if err != nil {
+ t.Fatalf("ListSources: %v", err)
+ }
+ if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "helmet" {
+ t.Fatalf("unexpected templates: %#v", got)
+ }
+ if got := sourceNames(sources.Profiles); len(got) != 1 || got[0] != "line_a" {
+ t.Fatalf("unexpected profiles: %#v", got)
+ }
+}
+
func TestConfigPreviewServiceRejectsUnsafeNames(t *testing.T) {
root := t.TempDir()
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root})
@@ -54,8 +97,8 @@ func TestConfigPreviewServiceRejectsUnsafeNames(t *testing.T) {
func TestConfigPreviewServiceImportsAssetsIntoSQLite(t *testing.T) {
root := t.TempDir()
- mustWrite(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`)
- mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"name":"gate_a","business_name":"Gate A","description":"gate profile","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`)
+ mustWrite(t, filepath.Join(root, "configs", "templates", "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","description":"helmet template","template":{"nodes":[],"edges":[]}}`)
+ mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"name":"gate_a","business_name":"Gate A","description":"gate profile","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`)
mustWrite(t, filepath.Join(root, "configs", "overlays", "night_relaxed.json"), `{"name":"night_relaxed","description":"overlay","instance_overrides":{"cam1":{"override":{}}}}`)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
@@ -70,7 +113,7 @@ func TestConfigPreviewServiceImportsAssetsIntoSQLite(t *testing.T) {
if err != nil {
t.Fatalf("ImportAssetsFromMediaRepo: %v", err)
}
- if result.Templates != 0 || result.Profiles != 1 || result.Overlays != 1 {
+ if result.Templates != 1 || result.Profiles != 1 || result.Overlays != 1 {
t.Fatalf("unexpected import result: %#v", result)
}
@@ -78,13 +121,13 @@ func TestConfigPreviewServiceImportsAssetsIntoSQLite(t *testing.T) {
if err != nil {
t.Fatalf("ListSources: %v", err)
}
- if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "helmet" {
+ if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "std_face_recognition_stream" {
t.Fatalf("unexpected templates after import: %#v", got)
}
- if record, err := repo.GetTemplate("helmet"); err != nil {
+ if record, err := repo.GetTemplate("std_face_recognition_stream"); err != nil {
t.Fatalf("GetTemplate: %v", err)
- } else if record != nil {
- t.Fatalf("expected builtin template to remain outside sqlite, got %#v", record)
+ } else if record == nil {
+ t.Fatal("expected imported standard template")
}
}
@@ -114,82 +157,18 @@ func TestConfigPreviewServiceExportsAssetJSONFromSQLite(t *testing.T) {
}
}
-func TestConfigPreviewServiceIntegrationParamsForProfile(t *testing.T) {
- 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())
- saveIntegrationServiceForPreviewTest(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
- saveIntegrationServiceForPreviewTest(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`)
- saveIntegrationServiceForPreviewTest(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","config":{"put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`)
-
- svc := NewConfigPreviewService(&config.Config{}, repo)
- params, err := svc.integrationParamsForProfile(map[string]any{
- "object_storage_ref": "minio_main",
- "token_service_ref": "token_main",
- "alarm_service_ref": "alarm_main",
- })
- if err != nil {
- t.Fatalf("integrationParamsForProfile: %v", err)
- }
- for key, want := range map[string]string{
- "minio_endpoint": "http://10.0.0.49:9000",
- "minio_bucket": "myminio",
- "minio_access_key": "admin",
- "minio_secret_key": "password",
- "external_get_token_url": "http://10.0.0.49:8080/api/getToken",
- "external_put_message_url": "http://10.0.0.49:8080/api/putMessage",
- "tenant_code": "32",
- } {
- if got := params[key]; got != want {
- t.Fatalf("expected %s=%q, got %#v", key, want, got)
- }
- }
-}
-
-func TestConfigPreviewServiceRenderProfileEditorExpandsIntegrationRefs(t *testing.T) {
+func TestConfigPreviewServiceRenderProfileEditorWritesResolvedBindings(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",
- "params":{"existing":"keep"},
- "template":{"nodes":[],"edges":[]}
+ "template":{
+ "nodes":[
+ {"id":"input_rtsp_main","type":"input_rtsp","url":"${slot:video_input_main.url}"},
+ {"id":"publish_stream","type":"publish","outputs":[{"proto":"hls","path":"${slot:stream_output_main.publish_hls_path}"}]}
+ ],
+ "edges":[]
+ }
}`)
- mustWrite(t, filepath.Join(root, "tools", "render_config.py"), `import argparse
-import json
-
-parser = argparse.ArgumentParser()
-parser.add_argument("--template", required=True)
-parser.add_argument("--profile", required=True)
-parser.add_argument("--out", required=True)
-parser.add_argument("--config-id", required=True)
-parser.add_argument("--config-version", required=True)
-parser.add_argument("--rendered-at", required=True)
-parser.add_argument("--overlay", action="append", default=[])
-args = parser.parse_args()
-
-with open(args.template, "r", encoding="utf-8") as fh:
- template = json.load(fh)
-with open(args.profile, "r", encoding="utf-8") as fh:
- profile = json.load(fh)
-
-doc = {
- "metadata": {
- "template": template.get("name"),
- "profile": profile.get("name"),
- },
- "template_params": template.get("params", {}),
- "profile_refs": {
- "object_storage_ref": profile.get("object_storage_ref"),
- "token_service_ref": profile.get("token_service_ref"),
- "alarm_service_ref": profile.get("alarm_service_ref"),
- }
-}
-with open(args.out, "w", encoding="utf-8") as fh:
- json.dump(doc, fh, ensure_ascii=False, indent=2)
-`)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
@@ -197,20 +176,36 @@ with open(args.out, "w", encoding="utf-8") as fh:
}
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)
+ }
saveIntegrationServiceForPreviewTest(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
saveIntegrationServiceForPreviewTest(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`)
saveIntegrationServiceForPreviewTest(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","config":{"put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`)
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
+ mustImportAssetsFromMediaRepo(t, svc)
editor := ConfigProfileEditor{
- Name: "line_a",
- ObjectStorageRef: "minio_main",
- TokenServiceRef: "token_main",
- AlarmServiceRef: "alarm_main",
+ Name: "line_a",
Instances: []ConfigProfileInstanceEditor{{
- Name: "cam1",
- Template: "std_workshop_face_recognition_shoe_alarm",
- RTSPURL: "rtsp://10.0.0.1/live",
+ Name: "cam1",
+ Template: "std_workshop_face_recognition_shoe_alarm",
+ VideoSourceRef: "gate_cam_01",
+ PublishHLSPath: "./web/hls/cam1/index.m3u8",
+ PublishRTSPPort: "8555",
+ PublishRTSPPath: "/live/cam1",
+ ChannelNo: "cam1",
+ ServiceBindings: map[string]ServiceBindingEditor{
+ "object_storage_main": {ServiceRef: "minio_main"},
+ "token_service_main": {ServiceRef: "token_main"},
+ "alarm_service_main": {ServiceRef: "alarm_main"},
+ },
}},
}
@@ -227,20 +222,181 @@ with open(args.out, "w", encoding="utf-8") as fh:
if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil {
t.Fatalf("unmarshal render result: %v", err)
}
- templateParams, _ := doc["template_params"].(map[string]any)
- for key, want := range map[string]string{
- "existing": "keep",
- "minio_endpoint": "http://10.0.0.49:9000",
- "minio_bucket": "myminio",
- "minio_access_key": "admin",
- "minio_secret_key": "password",
- "external_get_token_url": "http://10.0.0.49:8080/api/getToken",
- "external_put_message_url": "http://10.0.0.49:8080/api/putMessage",
- "tenant_code": "32",
- } {
- if got := stringValue(templateParams[key]); got != want {
- t.Fatalf("expected template param %s=%q, got %#v", key, want, templateParams[key])
- }
+ templates, _ := doc["templates"].(map[string]any)
+ renderedTemplate, _ := templates["std_workshop_face_recognition_shoe_alarm__cam1"].(map[string]any)
+ nodes, _ := renderedTemplate["nodes"].([]any)
+ inputNode, _ := nodes[0].(map[string]any)
+ if got := stringValue(inputNode["url"]); got != "rtsp://10.0.0.1/live" {
+ t.Fatalf("expected expanded input url, got %#v", inputNode)
+ }
+ publishNode, _ := nodes[1].(map[string]any)
+ outputs, _ := publishNode["outputs"].([]any)
+ output, _ := outputs[0].(map[string]any)
+ if got := stringValue(output["path"]); got != "./web/hls/cam1/index.m3u8" {
+ t.Fatalf("expected expanded output path, got %#v", output)
+ }
+ metadata, _ := doc["metadata"].(map[string]any)
+ if got := stringValue(metadata["rendered_by"]); got != "managerd" {
+ t.Fatalf("expected managerd renderer metadata, got %#v", metadata)
+ }
+}
+
+func TestConfigPreviewServiceRenderUsesSQLiteProfileAndOverlay(t *testing.T) {
+ root := t.TempDir()
+ mustWrite(t, filepath.Join(root, "configs", "templates", "std_service_test_stream.json"), `{
+ "name":"std_service_test_stream",
+ "template":{
+ "nodes":[
+ {"id":"input_rtsp_main","type":"input_rtsp","url":"${slot:video_input_main.url}"},
+ {"id":"publish_stream","type":"publish","outputs":[{"proto":"hls","path":"${slot:stream_output_main.publish_hls_path}"}]}
+ ],
+ "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.SaveProfile("line_a", "std_service_test_stream", "Line A", "scene profile", `{
+ "name":"line_a",
+ "business_name":"Line A",
+ "instances":[
+ {
+ "name":"cam1",
+ "template":"std_service_test_stream",
+ "input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},
+ "output_bindings":{"stream_output_main":{"publish_hls_path":"./web/hls/cam1/index.m3u8","publish_rtsp_port":8555,"publish_rtsp_path":"/live/cam1","channel_no":"cam1"}}
+ }
+ ]
+}`); err != nil {
+ t.Fatalf("SaveProfile: %v", err)
+ }
+ if err := repo.SaveOverlay("night_relaxed", "night overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"params":{"debug":true}}}}`); err != nil {
+ t.Fatalf("SaveOverlay: %v", err)
+ }
+ 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)
+ result, err := svc.Render(ConfigPreviewRequest{
+ Template: "std_service_test_stream",
+ Profile: "line_a",
+ Overlays: []string{"night_relaxed"},
+ ConfigID: "preview",
+ ConfigVersion: "v1",
+ })
+ if err != nil {
+ t.Fatalf("Render: %v", err)
+ }
+
+ var doc map[string]any
+ if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil {
+ t.Fatalf("unmarshal render result: %v", err)
+ }
+ metadata, _ := doc["metadata"].(map[string]any)
+ if got := stringValue(metadata["profile"]); got != "line_a" {
+ t.Fatalf("expected sqlite profile metadata, got %#v", metadata)
+ }
+ names, _ := metadata["overlays"].([]any)
+ if len(names) != 1 || stringValue(names[0]) != "night_relaxed" {
+ t.Fatalf("expected sqlite overlay metadata, got %#v", metadata["overlays"])
+ }
+ templates, _ := doc["templates"].(map[string]any)
+ renderedTemplate, _ := templates["std_service_test_stream__cam1"].(map[string]any)
+ nodes, _ := renderedTemplate["nodes"].([]any)
+ inputNode, _ := nodes[0].(map[string]any)
+ if got := stringValue(inputNode["url"]); got != "rtsp://10.0.0.1/live" {
+ t.Fatalf("expected expanded input url, got %#v", inputNode)
+ }
+ instances, _ := doc["instances"].([]any)
+ instance, _ := instances[0].(map[string]any)
+ params, _ := instance["params"].(map[string]any)
+ if got := boolValue(params["debug"], false); !got {
+ t.Fatalf("expected overlay params to merge into runtime instance, got %#v", instance)
+ }
+}
+
+func TestSaveProfileEditorRequiresAssetRepository(t *testing.T) {
+ svc := NewConfigPreviewService(&config.Config{})
+ err := svc.SaveProfileEditor(ConfigProfileEditor{
+ Name: "line_a",
+ Instances: []ConfigProfileInstanceEditor{{
+ Name: "cam1",
+ Template: "helmet",
+ VideoSourceRef: "gate_cam_01",
+ }},
+ })
+ if err == nil || !strings.Contains(err.Error(), "asset repository is not configured") {
+ t.Fatalf("expected asset repository error, got %v", err)
+ }
+}
+
+func TestConfigPreviewServiceResolveSceneBindings(t *testing.T) {
+ 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)
+ }
+ saveIntegrationServiceForPreviewTest(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
+
+ svc := NewConfigPreviewService(&config.Config{}, repo)
+ resolved, err := svc.resolveSceneBindings(map[string]any{
+ "name": "line_a",
+ "instances": []any{
+ map[string]any{
+ "name": "cam1",
+ "template": "std_workshop_face_recognition_shoe_alarm",
+ "input_bindings": map[string]any{
+ "video_input_main": map[string]any{
+ "video_source_ref": "gate_cam_01",
+ },
+ },
+ "service_bindings": map[string]any{
+ "object_storage_main": map[string]any{
+ "service_ref": "minio_main",
+ },
+ },
+ },
+ },
+ })
+ if err != nil {
+ t.Fatalf("resolveSceneBindings: %v", err)
+ }
+ instances, _ := resolved["instances"].([]any)
+ instanceMap, _ := instances[0].(map[string]any)
+ inputBindings, _ := instanceMap["input_bindings"].(map[string]any)
+ videoInput, _ := inputBindings["video_input_main"].(map[string]any)
+ resolvedInput, _ := videoInput["resolved"].(map[string]any)
+ if got := stringValue(resolvedInput["url"]); got != "rtsp://10.0.0.1/live" {
+ t.Fatalf("expected resolved input url, got %#v", resolvedInput)
+ }
+ serviceBindings, _ := instanceMap["service_bindings"].(map[string]any)
+ objectStorage, _ := serviceBindings["object_storage_main"].(map[string]any)
+ resolvedService, _ := objectStorage["resolved"].(map[string]any)
+ if got := stringValue(resolvedService["bucket"]); got != "myminio" {
+ t.Fatalf("expected resolved service binding, got %#v", resolvedService)
}
}
diff --git a/internal/service/config_runtime_render.go b/internal/service/config_runtime_render.go
new file mode 100644
index 0000000..bee4e55
--- /dev/null
+++ b/internal/service/config_runtime_render.go
@@ -0,0 +1,415 @@
+package service
+
+import (
+ "encoding/json"
+ "fmt"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+var slotTokenRE = regexp.MustCompile(`^\$\{slot:([A-Za-z0-9_.-]+)\.([A-Za-z0-9_.-]+)\}$`)
+
+func renderRuntimeConfig(templateRaw map[string]any, templatePath string, profileRaw map[string]any, profilePath string, overlays []runtimeOverlayInput, metadata map[string]any) (map[string]any, error) {
+ tplName, err := runtimeTemplateName(templateRaw, templatePath)
+ if err != nil {
+ return nil, err
+ }
+ instances, err := runtimeProfileInstances(profileRaw, tplName)
+ if err != nil {
+ return nil, err
+ }
+ instances, err = mergeRuntimeTemplateParams(instances, runtimeTemplateParams(templateRaw))
+ if err != nil {
+ return nil, err
+ }
+
+ renderedTemplates := map[string]any{}
+ renderedInstances := make([]any, 0, len(instances))
+ for _, instance := range instances {
+ boundName, boundTemplate, renderedInstance, err := renderRuntimeSceneInstance(templateRaw, templatePath, instance)
+ if err != nil {
+ return nil, err
+ }
+ renderedTemplates[boundName] = boundTemplate
+ renderedInstances = append(renderedInstances, renderedInstance)
+ }
+
+ root := map[string]any{
+ "templates": renderedTemplates,
+ "instances": renderedInstances,
+ }
+ for _, key := range []string{"global", "queue"} {
+ if value, ok := profileRaw[key]; ok {
+ root[key] = deepCopyAny(value)
+ }
+ }
+
+ for _, overlay := range overlays {
+ var err error
+ root, err = applyRuntimeOverlay(root, overlay.Raw)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ if metadata != nil {
+ root["metadata"] = buildRuntimeMetadata(tplName, templatePath, profileRaw, profilePath, overlays, root, metadata)
+ }
+ return root, nil
+}
+
+type runtimeOverlayInput struct {
+ Name string
+ Path string
+ Raw map[string]any
+}
+
+func runtimeTemplateName(templateRaw map[string]any, templatePath string) (string, error) {
+ name := strings.TrimSpace(firstString(templateRaw["name"], strings.TrimSuffix(filepath.Base(templatePath), filepath.Ext(templatePath))))
+ if name == "" {
+ return "", fmt.Errorf("%s: template name is empty", templatePath)
+ }
+ return name, nil
+}
+
+func runtimeTemplateBody(templateRaw map[string]any) (map[string]any, error) {
+ body := templateRaw
+ if nested, ok := templateRaw["template"].(map[string]any); ok {
+ body = nested
+ }
+ nodes, hasNodes := body["nodes"].([]any)
+ edges, hasEdges := body["edges"].([]any)
+ if !hasNodes || !hasEdges {
+ return nil, fmt.Errorf("template body must contain nodes[] and edges[]")
+ }
+ out := map[string]any{
+ "nodes": deepCopyAny(nodes),
+ "edges": deepCopyAny(edges),
+ }
+ if executor, ok := body["executor"]; ok {
+ out["executor"] = deepCopyAny(executor)
+ }
+ return out, nil
+}
+
+func runtimeTemplateParams(templateRaw map[string]any) map[string]any {
+ params, _ := templateRaw["params"].(map[string]any)
+ if params == nil {
+ return map[string]any{}
+ }
+ return cloneMap(params)
+}
+
+func runtimeProfileInstances(profileRaw map[string]any, tplName string) ([]map[string]any, error) {
+ if items, ok := profileRaw["instances"].([]any); ok {
+ out := make([]map[string]any, 0, len(items))
+ for _, item := range items {
+ instance, _ := item.(map[string]any)
+ if instance == nil {
+ return nil, fmt.Errorf("profile.instances entries must be objects")
+ }
+ cloned := deepCopyMap(instance)
+ if strings.TrimSpace(stringValue(cloned["template"])) == "" {
+ cloned["template"] = tplName
+ }
+ out = append(out, cloned)
+ }
+ return out, nil
+ }
+ name := strings.TrimSpace(stringValue(profileRaw["name"]))
+ if name == "" {
+ return nil, fmt.Errorf("profile must contain name or instances[]")
+ }
+ instance := map[string]any{
+ "name": name,
+ "template": tplName,
+ }
+ if params, ok := profileRaw["params"].(map[string]any); ok && len(params) > 0 {
+ instance["params"] = deepCopyMap(params)
+ }
+ if override, ok := profileRaw["override"].(map[string]any); ok && len(override) > 0 {
+ instance["override"] = deepCopyMap(override)
+ }
+ return []map[string]any{instance}, nil
+}
+
+func mergeRuntimeTemplateParams(instances []map[string]any, sharedParams map[string]any) ([]map[string]any, error) {
+ if len(sharedParams) == 0 {
+ return instances, nil
+ }
+ out := make([]map[string]any, 0, len(instances))
+ for _, item := range instances {
+ inst := deepCopyMap(item)
+ params, _ := inst["params"].(map[string]any)
+ if params == nil {
+ params = map[string]any{}
+ }
+ inst["params"] = deepMergeMap(sharedParams, params)
+ out = append(out, inst)
+ }
+ return out, nil
+}
+
+func renderRuntimeSceneInstance(templateRaw map[string]any, templatePath string, instance map[string]any) (string, map[string]any, map[string]any, error) {
+ instanceName := strings.TrimSpace(stringValue(instance["name"]))
+ if instanceName == "" {
+ return "", nil, nil, fmt.Errorf("scene instance name is required")
+ }
+ tplName, err := runtimeTemplateName(templateRaw, templatePath)
+ if err != nil {
+ return "", nil, nil, err
+ }
+ boundName := tplName + "__" + instanceName
+ context, err := buildRuntimeBindingContext(instance)
+ if err != nil {
+ return "", nil, nil, err
+ }
+ templateBody, err := runtimeTemplateBody(templateRaw)
+ if err != nil {
+ return "", nil, nil, err
+ }
+ renderedTemplateAny, err := expandRuntimeSlotTokens(templateBody, context)
+ if err != nil {
+ return "", nil, nil, err
+ }
+ renderedTemplate, _ := renderedTemplateAny.(map[string]any)
+ renderedInstance := map[string]any{
+ "name": instanceName,
+ "template": boundName,
+ }
+ if sceneMeta, ok := instance["scene_meta"].(map[string]any); ok && len(sceneMeta) > 0 {
+ renderedInstance["scene_meta"] = deepCopyMap(sceneMeta)
+ }
+ if params, ok := instance["params"].(map[string]any); ok && len(params) > 0 {
+ renderedInstance["params"] = deepCopyMap(params)
+ }
+ return boundName, renderedTemplate, renderedInstance, 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 {
+ context["scene"] = deepCopyMap(sceneMeta)
+ }
+ for _, groupName := range []string{"input_bindings", "service_bindings", "output_bindings"} {
+ group, _ := instance[groupName].(map[string]any)
+ for slotName, raw := range group {
+ entry, _ := raw.(map[string]any)
+ if entry == nil {
+ return nil, fmt.Errorf("binding entry must be an object")
+ }
+ context[slotName] = resolvedRuntimeBindingValue(entry)
+ }
+ }
+ return context, nil
+}
+
+func resolvedRuntimeBindingValue(entry map[string]any) map[string]any {
+ if resolved, ok := entry["resolved"].(map[string]any); ok && resolved != nil {
+ return deepCopyMap(resolved)
+ }
+ return deepCopyMap(entry)
+}
+
+func expandRuntimeSlotTokens(value any, context map[string]any) (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)
+ if err != nil {
+ return nil, err
+ }
+ out[key] = expanded
+ }
+ return out, nil
+ case []any:
+ out := make([]any, 0, len(typed))
+ for _, item := range typed {
+ expanded, err := expandRuntimeSlotTokens(item, context)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, expanded)
+ }
+ return out, nil
+ case string:
+ match := slotTokenRE.FindStringSubmatch(strings.TrimSpace(typed))
+ if len(match) != 3 {
+ return typed, nil
+ }
+ slotValues, _ := context[match[1]].(map[string]any)
+ if slotValues == nil {
+ return nil, fmt.Errorf("required slot '%s' is not bound", match[1])
+ }
+ fieldValue, ok := slotValues[match[2]]
+ if !ok {
+ return nil, fmt.Errorf("required slot field '%s.%s' is not bound", match[1], match[2])
+ }
+ return deepCopyAny(fieldValue), nil
+ default:
+ return deepCopyAny(value), nil
+ }
+}
+
+func applyRuntimeOverlay(root map[string]any, overlay map[string]any) (map[string]any, error) {
+ out := deepCopyMap(root)
+ for _, key := range []string{"global", "queue", "templates"} {
+ if value, ok := overlay[key]; ok {
+ out[key] = deepMergeAny(out[key], value)
+ }
+ }
+ if rawPatches, ok := overlay["instance_overrides"]; ok {
+ patches, _ := rawPatches.(map[string]any)
+ if patches == nil {
+ return nil, fmt.Errorf("overlay.instance_overrides must be an object")
+ }
+ instances, _ := out["instances"].([]any)
+ mergedInstances := make([]any, 0, len(instances))
+ for _, item := range instances {
+ instance, _ := item.(map[string]any)
+ merged := deepCopyMap(instance)
+ if patch, ok := patches["*"].(map[string]any); ok {
+ merged = mergeRuntimeInstancePatch(merged, patch)
+ }
+ name := stringValue(merged["name"])
+ if patch, ok := patches[name].(map[string]any); ok {
+ merged = mergeRuntimeInstancePatch(merged, patch)
+ }
+ mergedInstances = append(mergedInstances, merged)
+ }
+ out["instances"] = mergedInstances
+ }
+ if rawInstances, ok := overlay["instances"]; ok {
+ patchList, _ := rawInstances.([]any)
+ if patchList == nil {
+ return nil, fmt.Errorf("overlay.instances must be an array")
+ }
+ instances, _ := out["instances"].([]any)
+ byName := map[string]int{}
+ for i, item := range instances {
+ instance, _ := item.(map[string]any)
+ byName[stringValue(instance["name"])] = i
+ }
+ for _, item := range patchList {
+ patch, _ := item.(map[string]any)
+ name := stringValue(patch["name"])
+ if patch == nil || name == "" {
+ return nil, fmt.Errorf("overlay.instances entries must be objects with name")
+ }
+ idx, ok := byName[name]
+ if !ok {
+ return nil, fmt.Errorf("overlay instance not found in profile: %s", name)
+ }
+ instance, _ := instances[idx].(map[string]any)
+ instances[idx] = mergeRuntimeInstancePatch(instance, patch)
+ }
+ out["instances"] = instances
+ }
+ return out, nil
+}
+
+func mergeRuntimeInstancePatch(instance map[string]any, patch map[string]any) map[string]any {
+ merged := deepCopyMap(instance)
+ if params, ok := patch["params"].(map[string]any); ok {
+ existing, _ := merged["params"].(map[string]any)
+ merged["params"] = deepMergeMap(existing, params)
+ }
+ if override, ok := patch["override"].(map[string]any); ok {
+ existing, _ := merged["override"].(map[string]any)
+ merged["override"] = deepMergeMap(existing, override)
+ }
+ for key, value := range patch {
+ if key == "name" || key == "template" || key == "params" || key == "override" {
+ continue
+ }
+ merged[key] = deepMergeAny(merged[key], value)
+ }
+ return merged
+}
+
+func buildRuntimeMetadata(templateName string, templatePath string, profileRaw map[string]any, profilePath string, overlays []runtimeOverlayInput, root map[string]any, metadata map[string]any) map[string]any {
+ instanceNames := make([]string, 0)
+ instanceDisplayNames := make([]string, 0)
+ instances, _ := root["instances"].([]any)
+ for _, item := range instances {
+ instance, _ := item.(map[string]any)
+ if name := strings.TrimSpace(stringValue(instance["name"])); name != "" {
+ instanceNames = append(instanceNames, name)
+ }
+ if sceneMeta, ok := instance["scene_meta"].(map[string]any); ok {
+ if displayName := strings.TrimSpace(stringValue(sceneMeta["display_name"])); displayName != "" {
+ instanceDisplayNames = append(instanceDisplayNames, displayName)
+ }
+ }
+ }
+ overlayNames := make([]any, 0, len(overlays))
+ overlayPaths := make([]any, 0, len(overlays))
+ for _, overlay := range overlays {
+ overlayNames = append(overlayNames, overlay.Name)
+ overlayPaths = append(overlayPaths, overlay.Path)
+ }
+ out := map[string]any{
+ "template": templateName,
+ "template_path": templatePath,
+ "profile": strings.TrimSpace(firstString(profileRaw["name"], strings.TrimSuffix(filepath.Base(profilePath), filepath.Ext(profilePath)))),
+ "business_name": strings.TrimSpace(stringValue(profileRaw["business_name"])),
+ "profile_path": profilePath,
+ "instance_names": instanceNames,
+ "instance_display_names": instanceDisplayNames,
+ "overlays": overlayNames,
+ "overlay_paths": overlayPaths,
+ }
+ for key, value := range metadata {
+ out[key] = deepCopyAny(value)
+ }
+ return out
+}
+
+func deepMergeMap(base map[string]any, override map[string]any) map[string]any {
+ if base == nil {
+ base = map[string]any{}
+ }
+ return deepMergeAny(base, override).(map[string]any)
+}
+
+func deepMergeAny(base any, override any) any {
+ baseMap, baseIsMap := base.(map[string]any)
+ overrideMap, overrideIsMap := override.(map[string]any)
+ if baseIsMap && overrideIsMap {
+ merged := deepCopyMap(baseMap)
+ for key, value := range overrideMap {
+ if existing, ok := merged[key]; ok {
+ merged[key] = deepMergeAny(existing, value)
+ } else {
+ merged[key] = deepCopyAny(value)
+ }
+ }
+ return merged
+ }
+ return deepCopyAny(override)
+}
+
+func deepCopyMap(in map[string]any) map[string]any {
+ if in == nil {
+ return map[string]any{}
+ }
+ out, _ := deepCopyAny(in).(map[string]any)
+ return out
+}
+
+func deepCopyAny(value any) any {
+ if value == nil {
+ return nil
+ }
+ body, err := json.Marshal(value)
+ if err != nil {
+ return value
+ }
+ var out any
+ if err := json.Unmarshal(body, &out); err != nil {
+ return value
+ }
+ return out
+}
diff --git a/internal/service/profile_editor.go b/internal/service/profile_editor.go
index 90d1131..7976aab 100644
--- a/internal/service/profile_editor.go
+++ b/internal/service/profile_editor.go
@@ -3,25 +3,20 @@ package service
import (
"encoding/json"
"fmt"
- "os"
- "path/filepath"
"strconv"
"strings"
)
type ConfigProfileEditor struct {
- Name string `json:"name"`
- Path string `json:"path"`
- BusinessName string `json:"business_name"`
- Description string `json:"description"`
- DeviceCode string `json:"device_code"`
- SiteName string `json:"site_name"`
- ObjectStorageRef string `json:"object_storage_ref"`
- TokenServiceRef string `json:"token_service_ref"`
- AlarmServiceRef string `json:"alarm_service_ref"`
- Queue ConfigProfileQueueEditor `json:"queue"`
- Instances []ConfigProfileInstanceEditor `json:"instances"`
- Raw map[string]any `json:"raw"`
+ Name string `json:"name"`
+ Path string `json:"path"`
+ BusinessName string `json:"business_name"`
+ Description string `json:"description"`
+ DeviceCode string `json:"device_code"`
+ SiteName string `json:"site_name"`
+ Queue ConfigProfileQueueEditor `json:"queue"`
+ Instances []ConfigProfileInstanceEditor `json:"instances"`
+ Raw map[string]any `json:"raw"`
}
type ConfigProfileQueueEditor struct {
@@ -29,17 +24,36 @@ type ConfigProfileQueueEditor struct {
Strategy string `json:"strategy"`
}
+type InputBindingEditor struct {
+ VideoSourceRef string `json:"video_source_ref"`
+}
+
+type ServiceBindingEditor struct {
+ ServiceRef string `json:"service_ref"`
+}
+
+type OutputBindingEditor struct {
+ PublishHLSPath string `json:"publish_hls_path"`
+ PublishRTSPPort string `json:"publish_rtsp_port"`
+ PublishRTSPPath string `json:"publish_rtsp_path"`
+ ChannelNo string `json:"channel_no"`
+}
+
type ConfigProfileInstanceEditor struct {
- Name string `json:"name"`
- Template string `json:"template"`
- DisplayName string `json:"display_name"`
- RTSPURL string `json:"rtsp_url"`
- PublishHLSPath string `json:"publish_hls_path"`
- PublishRTSPPort string `json:"publish_rtsp_port"`
- PublishRTSPPath string `json:"publish_rtsp_path"`
- ChannelNo string `json:"channel_no"`
- AdvancedParams map[string]any `json:"advanced_params"`
- Delete bool `json:"delete"`
+ Name string `json:"name"`
+ Template string `json:"template"`
+ VideoSourceRef string `json:"video_source_ref"`
+ DisplayName string `json:"display_name"`
+ SiteName string `json:"site_name"`
+ PublishHLSPath string `json:"publish_hls_path"`
+ PublishRTSPPort string `json:"publish_rtsp_port"`
+ PublishRTSPPath string `json:"publish_rtsp_path"`
+ ChannelNo string `json:"channel_no"`
+ InputBindings map[string]InputBindingEditor `json:"input_bindings,omitempty"`
+ ServiceBindings map[string]ServiceBindingEditor `json:"service_bindings,omitempty"`
+ OutputBindings map[string]OutputBindingEditor `json:"output_bindings,omitempty"`
+ AdvancedParams map[string]any `json:"advanced_params"`
+ Delete bool `json:"delete"`
}
func (s *ConfigPreviewService) GetProfileEditor(name string) (*ConfigProfileEditor, error) {
@@ -55,17 +69,22 @@ func (s *ConfigPreviewService) GetProfileEditor(name string) (*ConfigProfileEdit
for _, item := range instancesRaw {
instanceMap, _ := item.(map[string]any)
paramsMap, _ := instanceMap["params"].(map[string]any)
+ sceneMeta, _ := instanceMap["scene_meta"].(map[string]any)
+ inputBindings := parseInputBindingEditors(instanceMap["input_bindings"])
+ serviceBindings := parseServiceBindingEditors(instanceMap["service_bindings"])
+ outputBindings := parseOutputBindingEditors(instanceMap["output_bindings"])
if deviceCode == "" {
- deviceCode = stringValue(paramsMap["device_code"])
+ deviceCode = firstString(sceneMeta["device_code"], stringValue(paramsMap["device_code"]))
}
if siteName == "" {
- siteName = stringValue(paramsMap["site_name"])
+ siteName = firstString(sceneMeta["site_name"], stringValue(paramsMap["site_name"]))
}
advanced := cloneMap(paramsMap)
for _, key := range []string{
"display_name",
"device_code",
"site_name",
+ "video_source_ref",
"rtsp_url",
"publish_hls_path",
"publish_rtsp_port",
@@ -80,25 +99,26 @@ func (s *ConfigPreviewService) GetProfileEditor(name string) (*ConfigProfileEdit
instances = append(instances, ConfigProfileInstanceEditor{
Name: stringValue(instanceMap["name"]),
Template: stringValue(instanceMap["template"]),
- DisplayName: stringValue(paramsMap["display_name"]),
- RTSPURL: stringValue(paramsMap["rtsp_url"]),
- PublishHLSPath: stringValue(paramsMap["publish_hls_path"]),
- PublishRTSPPort: valueString(paramsMap["publish_rtsp_port"]),
- PublishRTSPPath: stringValue(paramsMap["publish_rtsp_path"]),
- ChannelNo: stringValue(paramsMap["channel_no"]),
+ VideoSourceRef: inputBindingRef(inputBindings, "video_input_main"),
+ DisplayName: stringValue(sceneMeta["display_name"]),
+ SiteName: stringValue(sceneMeta["site_name"]),
+ PublishHLSPath: outputBindingValue(outputBindings, "stream_output_main", "publish_hls_path"),
+ PublishRTSPPort: outputBindingValue(outputBindings, "stream_output_main", "publish_rtsp_port"),
+ PublishRTSPPath: outputBindingValue(outputBindings, "stream_output_main", "publish_rtsp_path"),
+ ChannelNo: outputBindingValue(outputBindings, "stream_output_main", "channel_no"),
+ InputBindings: inputBindings,
+ ServiceBindings: serviceBindings,
+ OutputBindings: outputBindings,
AdvancedParams: advanced,
})
}
return &ConfigProfileEditor{
- Name: firstString(raw["name"], name),
- Path: path,
- BusinessName: stringValue(raw["business_name"]),
- Description: stringValue(raw["description"]),
- DeviceCode: deviceCode,
- SiteName: siteName,
- ObjectStorageRef: stringValue(raw["object_storage_ref"]),
- TokenServiceRef: stringValue(raw["token_service_ref"]),
- AlarmServiceRef: stringValue(raw["alarm_service_ref"]),
+ Name: firstString(raw["name"], name),
+ Path: path,
+ BusinessName: stringValue(raw["business_name"]),
+ Description: stringValue(raw["description"]),
+ DeviceCode: deviceCode,
+ SiteName: siteName,
Queue: ConfigProfileQueueEditor{
Size: valueString(queueMap["size"]),
Strategy: stringValue(queueMap["strategy"]),
@@ -138,37 +158,48 @@ func (s *ConfigPreviewService) BuildProfileDocument(editor ConfigProfileEditor)
}
seen[instanceName] = struct{}{}
- rtspURL := strings.TrimSpace(inst.RTSPURL)
- if rtspURL == "" {
- return nil, fmt.Errorf("rtsp url is required for %s", instanceName)
+ videoSourceRef := strings.TrimSpace(inputBindingRef(inst.InputBindings, "video_input_main"))
+ if videoSourceRef == "" {
+ videoSourceRef = strings.TrimSpace(inst.VideoSourceRef)
+ }
+ if videoSourceRef == "" {
+ return nil, fmt.Errorf("video source is required for %s", instanceName)
}
params := map[string]any{}
- setString(params, "display_name", inst.DisplayName)
- setString(params, "device_code", editor.DeviceCode)
- setString(params, "site_name", editor.SiteName)
- setString(params, "rtsp_url", rtspURL)
- setString(params, "publish_hls_path", inst.PublishHLSPath)
- setString(params, "publish_rtsp_path", inst.PublishRTSPPath)
- setString(params, "channel_no", inst.ChannelNo)
-
- if port := strings.TrimSpace(inst.PublishRTSPPort); port != "" {
- value, err := strconv.Atoi(port)
- if err != nil {
- return nil, fmt.Errorf("publish rtsp port must be a number for %s", instanceName)
- }
- params["publish_rtsp_port"] = value
- }
-
for key, value := range cloneMap(inst.AdvancedParams) {
params[key] = value
}
instance := map[string]any{
- "name": instanceName,
- "params": params,
+ "name": instanceName,
+ }
+ if len(params) > 0 {
+ instance["params"] = params
}
setString(instance, "template", inst.Template)
+ sceneMeta := map[string]any{}
+ setString(sceneMeta, "display_name", inst.DisplayName)
+ setString(sceneMeta, "site_name", firstString(inst.SiteName, editor.SiteName))
+ setString(sceneMeta, "device_code", editor.DeviceCode)
+ if len(sceneMeta) > 0 {
+ instance["scene_meta"] = sceneMeta
+ }
+ inputBindings := buildInputBindingDocument(inst)
+ if len(inputBindings) > 0 {
+ instance["input_bindings"] = inputBindings
+ }
+ serviceBindings := buildServiceBindingDocument(inst)
+ if len(serviceBindings) > 0 {
+ instance["service_bindings"] = serviceBindings
+ }
+ outputBindings, err := buildOutputBindingDocument(inst)
+ if err != nil {
+ return nil, err
+ }
+ if len(outputBindings) > 0 {
+ instance["output_bindings"] = outputBindings
+ }
instances = append(instances, instance)
}
if len(instances) == 0 {
@@ -181,10 +212,6 @@ func (s *ConfigPreviewService) BuildProfileDocument(editor ConfigProfileEditor)
}
setString(doc, "business_name", editor.BusinessName)
setString(doc, "description", editor.Description)
- setString(doc, "object_storage_ref", editor.ObjectStorageRef)
- setString(doc, "token_service_ref", editor.TokenServiceRef)
- setString(doc, "alarm_service_ref", editor.AlarmServiceRef)
-
queue := map[string]any{}
if size := strings.TrimSpace(editor.Queue.Size); size != "" {
value, err := strconv.Atoi(size)
@@ -219,12 +246,7 @@ func (s *ConfigPreviewService) SaveProfileEditor(editor ConfigProfileEditor) err
string(body),
)
}
- root := s.mediaRepoRoot()
- if root == "" {
- return fmt.Errorf("media repo path is not configured")
- }
- path := filepath.Join(root, "configs", "profiles", strings.TrimSpace(editor.Name)+".json")
- return os.WriteFile(path, body, 0o644)
+ return fmt.Errorf("asset repository is not configured")
}
func setString(m map[string]any, key string, value string) {
@@ -252,3 +274,161 @@ func firstProfileTemplate(instances []ConfigProfileInstanceEditor) string {
}
return ""
}
+
+func parseInputBindingEditors(raw any) map[string]InputBindingEditor {
+ items, _ := raw.(map[string]any)
+ if len(items) == 0 {
+ return nil
+ }
+ out := map[string]InputBindingEditor{}
+ for key, value := range items {
+ entry, _ := value.(map[string]any)
+ out[key] = InputBindingEditor{
+ VideoSourceRef: stringValue(entry["video_source_ref"]),
+ }
+ }
+ return out
+}
+
+func parseServiceBindingEditors(raw any) map[string]ServiceBindingEditor {
+ items, _ := raw.(map[string]any)
+ if len(items) == 0 {
+ return nil
+ }
+ out := map[string]ServiceBindingEditor{}
+ for key, value := range items {
+ entry, _ := value.(map[string]any)
+ out[key] = ServiceBindingEditor{
+ ServiceRef: stringValue(entry["service_ref"]),
+ }
+ }
+ return out
+}
+
+func parseOutputBindingEditors(raw any) map[string]OutputBindingEditor {
+ items, _ := raw.(map[string]any)
+ if len(items) == 0 {
+ return nil
+ }
+ out := map[string]OutputBindingEditor{}
+ for key, value := range items {
+ entry, _ := value.(map[string]any)
+ out[key] = OutputBindingEditor{
+ PublishHLSPath: stringValue(entry["publish_hls_path"]),
+ PublishRTSPPort: valueString(entry["publish_rtsp_port"]),
+ PublishRTSPPath: stringValue(entry["publish_rtsp_path"]),
+ ChannelNo: stringValue(entry["channel_no"]),
+ }
+ }
+ return out
+}
+
+func inputBindingRef(bindings map[string]InputBindingEditor, slot string) string {
+ if len(bindings) == 0 {
+ return ""
+ }
+ return strings.TrimSpace(bindings[slot].VideoSourceRef)
+}
+
+func outputBindingValue(bindings map[string]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 buildInputBindingDocument(inst ConfigProfileInstanceEditor) map[string]any {
+ out := map[string]any{}
+ for key, value := range inst.InputBindings {
+ entry := map[string]any{}
+ setString(entry, "video_source_ref", value.VideoSourceRef)
+ if len(entry) > 0 {
+ out[key] = entry
+ }
+ }
+ if strings.TrimSpace(inst.VideoSourceRef) != "" {
+ if _, ok := out["video_input_main"]; !ok {
+ out["video_input_main"] = map[string]any{"video_source_ref": strings.TrimSpace(inst.VideoSourceRef)}
+ }
+ }
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+}
+
+func buildServiceBindingDocument(inst ConfigProfileInstanceEditor) map[string]any {
+ out := map[string]any{}
+ for key, value := range inst.ServiceBindings {
+ entry := map[string]any{}
+ setString(entry, "service_ref", value.ServiceRef)
+ if len(entry) > 0 {
+ out[key] = entry
+ }
+ }
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+}
+
+func buildOutputBindingDocument(inst ConfigProfileInstanceEditor) (map[string]any, error) {
+ out := map[string]any{}
+ for key, value := range inst.OutputBindings {
+ entry, err := outputBindingEntry(value)
+ if err != nil {
+ return nil, err
+ }
+ if len(entry) > 0 {
+ out[key] = entry
+ }
+ }
+ if _, ok := out["stream_output_main"]; !ok {
+ entry, err := outputBindingEntry(OutputBindingEditor{
+ PublishHLSPath: inst.PublishHLSPath,
+ PublishRTSPPort: inst.PublishRTSPPort,
+ PublishRTSPPath: inst.PublishRTSPPath,
+ ChannelNo: inst.ChannelNo,
+ })
+ if err != nil {
+ return nil, err
+ }
+ if len(entry) > 0 {
+ out["stream_output_main"] = entry
+ }
+ }
+ if len(out) == 0 {
+ return nil, nil
+ }
+ return out, nil
+}
+
+func outputBindingEntry(value OutputBindingEditor) (map[string]any, error) {
+ entry := map[string]any{}
+ setString(entry, "publish_hls_path", value.PublishHLSPath)
+ setString(entry, "publish_rtsp_path", value.PublishRTSPPath)
+ setString(entry, "channel_no", value.ChannelNo)
+ if port := strings.TrimSpace(value.PublishRTSPPort); port != "" {
+ parsed, err := strconv.Atoi(port)
+ if err != nil {
+ return nil, fmt.Errorf("publish rtsp port must be a number")
+ }
+ entry["publish_rtsp_port"] = parsed
+ }
+ return entry, nil
+}
diff --git a/internal/service/standard_templates.go b/internal/service/standard_templates.go
new file mode 100644
index 0000000..8b62b31
--- /dev/null
+++ b/internal/service/standard_templates.go
@@ -0,0 +1,75 @@
+package service
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "3588AdminBackend/internal/storage"
+)
+
+func ImportStandardTemplatesFromDir(repo *storage.AssetsRepo, dir string) (int, error) {
+ if repo == nil {
+ return 0, fmt.Errorf("asset repository is not configured")
+ }
+ dir = filepath.Clean(strings.TrimSpace(dir))
+ if dir == "" {
+ return 0, fmt.Errorf("standard template dir is empty")
+ }
+ files, err := os.ReadDir(dir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return 0, nil
+ }
+ return 0, err
+ }
+ imported := 0
+ for _, file := range files {
+ if file.IsDir() || strings.ToLower(filepath.Ext(file.Name())) != ".json" {
+ continue
+ }
+ path := filepath.Join(dir, file.Name())
+ body, err := os.ReadFile(path)
+ if err != nil {
+ return imported, err
+ }
+ var raw map[string]any
+ if err := json.Unmarshal(body, &raw); err != nil {
+ return imported, fmt.Errorf("%s: %w", path, err)
+ }
+ if raw == nil {
+ raw = map[string]any{}
+ }
+ name := strings.TrimSpace(firstString(raw["name"], strings.TrimSuffix(file.Name(), filepath.Ext(file.Name()))))
+ if name == "" {
+ return imported, fmt.Errorf("%s: template name is empty", path)
+ }
+ if err := validateConfigName(name); err != nil {
+ return imported, fmt.Errorf("%s: invalid template name %q: %w", path, name, err)
+ }
+ if !isStandardTemplateName(name) {
+ return imported, fmt.Errorf("%s: standard template name must start with std_", path)
+ }
+ existing, err := repo.GetTemplate(name)
+ if err != nil {
+ return imported, err
+ }
+ if existing != nil {
+ continue
+ }
+ if strings.TrimSpace(stringValue(raw["source"])) == "" {
+ raw["source"] = "standard"
+ }
+ body, err = marshalConfigJSON(raw)
+ if err != nil {
+ return imported, err
+ }
+ if err := repo.SaveTemplate(name, stringValue(raw["description"]), string(body)); err != nil {
+ return imported, err
+ }
+ imported++
+ }
+ return imported, nil
+}
diff --git a/internal/service/template_slots.go b/internal/service/template_slots.go
new file mode 100644
index 0000000..241dcef
--- /dev/null
+++ b/internal/service/template_slots.go
@@ -0,0 +1,67 @@
+package service
+
+import "fmt"
+
+type TemplateSlotGroup struct {
+ Inputs []TemplateSlot `json:"inputs"`
+ Services []TemplateSlot `json:"services"`
+ Outputs []TemplateSlot `json:"outputs"`
+}
+
+type TemplateSlot struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Required bool `json:"required"`
+ Description string `json:"description"`
+}
+
+func parseTemplateSlots(raw map[string]any) (TemplateSlotGroup, error) {
+ group := TemplateSlotGroup{}
+ slotsMap, _ := raw["slots"].(map[string]any)
+ if slotsMap == nil {
+ return group, nil
+ }
+ var err error
+ if group.Inputs, err = parseTemplateSlotList(slotsMap["inputs"]); err != nil {
+ return TemplateSlotGroup{}, fmt.Errorf("parse input slots: %w", err)
+ }
+ if group.Services, err = parseTemplateSlotList(slotsMap["services"]); err != nil {
+ return TemplateSlotGroup{}, fmt.Errorf("parse service slots: %w", err)
+ }
+ if group.Outputs, err = parseTemplateSlotList(slotsMap["outputs"]); err != nil {
+ return TemplateSlotGroup{}, fmt.Errorf("parse output slots: %w", err)
+ }
+ return group, nil
+}
+
+func parseTemplateSlotList(raw any) ([]TemplateSlot, error) {
+ items, _ := raw.([]any)
+ if len(items) == 0 {
+ return nil, nil
+ }
+ out := make([]TemplateSlot, 0, len(items))
+ for _, item := range items {
+ slotMap, _ := item.(map[string]any)
+ if slotMap == nil {
+ return nil, fmt.Errorf("slot entry must be an object")
+ }
+ slot := TemplateSlot{
+ Name: stringValue(slotMap["name"]),
+ Type: stringValue(slotMap["type"]),
+ Required: boolValue(slotMap["required"], false),
+ Description: stringValue(slotMap["description"]),
+ }
+ if slot.Name == "" {
+ return nil, fmt.Errorf("slot name is required")
+ }
+ if err := validateConfigName(slot.Name); err != nil {
+ return nil, fmt.Errorf("invalid slot name %q: %w", slot.Name, err)
+ }
+ if slot.Type == "" {
+ return nil, fmt.Errorf("slot type is required for %s", slot.Name)
+ }
+ out = append(out, slot)
+ }
+ return out, nil
+}
+
diff --git a/internal/service/template_slots_test.go b/internal/service/template_slots_test.go
new file mode 100644
index 0000000..e2c4b85
--- /dev/null
+++ b/internal/service/template_slots_test.go
@@ -0,0 +1,34 @@
+package service
+
+import "testing"
+
+func TestParseTemplateSlots(t *testing.T) {
+ raw := map[string]any{
+ "slots": map[string]any{
+ "inputs": []any{
+ map[string]any{"name": "video_input_main", "type": "video_source", "required": true},
+ },
+ "services": []any{
+ map[string]any{"name": "object_storage_main", "type": "object_storage", "required": true},
+ },
+ "outputs": []any{
+ map[string]any{"name": "stream_output_main", "type": "stream_publish", "required": true},
+ },
+ },
+ }
+
+ slots, err := parseTemplateSlots(raw)
+ if err != nil {
+ t.Fatalf("parseTemplateSlots: %v", err)
+ }
+ if len(slots.Inputs) != 1 || slots.Inputs[0].Name != "video_input_main" {
+ t.Fatalf("unexpected input slots: %#v", slots.Inputs)
+ }
+ if len(slots.Services) != 1 || slots.Services[0].Type != "object_storage" {
+ t.Fatalf("unexpected service slots: %#v", slots.Services)
+ }
+ if len(slots.Outputs) != 1 || slots.Outputs[0].Name != "stream_output_main" {
+ t.Fatalf("unexpected output slots: %#v", slots.Outputs)
+ }
+}
+
diff --git a/internal/storage/assets_repo.go b/internal/storage/assets_repo.go
index cdfd607..1dc9954 100644
--- a/internal/storage/assets_repo.go
+++ b/internal/storage/assets_repo.go
@@ -28,6 +28,16 @@ type IntegrationServiceRecord struct {
UpdatedAt string
}
+type VideoSourceRecord struct {
+ Name string
+ SourceType string
+ Area string
+ Description string
+ BodyJSON string
+ CreatedAt string
+ UpdatedAt string
+}
+
type AssetsRepo struct {
db *sql.DB
}
@@ -80,6 +90,24 @@ ON CONFLICT(name) DO UPDATE SET
return err
}
+func (r *AssetsRepo) SaveVideoSource(name string, sourceType string, area string, description string, bodyJSON string) error {
+ if r == nil || r.db == nil {
+ return nil
+ }
+ now := time.Now().Format(time.RFC3339)
+ _, err := r.db.Exec(`
+INSERT INTO video_sources(name, source_type, area, description, body_json, created_at, updated_at)
+VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM video_sources WHERE name = ?), ?), ?)
+ON CONFLICT(name) DO UPDATE SET
+ source_type=excluded.source_type,
+ area=excluded.area,
+ description=excluded.description,
+ body_json=excluded.body_json,
+ updated_at=excluded.updated_at
+`, name, sourceType, area, description, bodyJSON, name, now, now)
+ return err
+}
+
func (r *AssetsRepo) ListTemplates() ([]AssetRecord, error) {
return r.listAssets("templates")
}
@@ -117,6 +145,31 @@ ORDER BY updated_at DESC, name ASC
return out, rows.Err()
}
+func (r *AssetsRepo) ListVideoSources() ([]VideoSourceRecord, error) {
+ if r == nil || r.db == nil {
+ return nil, nil
+ }
+ rows, err := r.db.Query(`
+SELECT name, source_type, area, description, body_json, created_at, updated_at
+FROM video_sources
+ORDER BY updated_at DESC, name ASC
+`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var out []VideoSourceRecord
+ for rows.Next() {
+ var item VideoSourceRecord
+ if err := rows.Scan(&item.Name, &item.SourceType, &item.Area, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, item)
+ }
+ return out, rows.Err()
+}
+
func (r *AssetsRepo) GetTemplate(name string) (*AssetRecord, error) {
return r.getAsset("templates", name)
}
@@ -148,6 +201,25 @@ WHERE name = ?
return &item, nil
}
+func (r *AssetsRepo) GetVideoSource(name string) (*VideoSourceRecord, error) {
+ if r == nil || r.db == nil {
+ return nil, nil
+ }
+ var item VideoSourceRecord
+ err := r.db.QueryRow(`
+SELECT name, source_type, area, description, body_json, created_at, updated_at
+FROM video_sources
+WHERE name = ?
+`, name).Scan(&item.Name, &item.SourceType, &item.Area, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt)
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &item, nil
+}
+
func (r *AssetsRepo) DeleteTemplate(name string) error {
return r.deleteAsset("templates", name)
}
@@ -160,6 +232,14 @@ func (r *AssetsRepo) DeleteIntegrationService(name string) error {
return err
}
+func (r *AssetsRepo) DeleteVideoSource(name string) error {
+ if r == nil || r.db == nil {
+ return nil
+ }
+ _, err := r.db.Exec(`DELETE FROM video_sources WHERE name = ?`, name)
+ return err
+}
+
func (r *AssetsRepo) RenameTemplate(oldName string, newName string, description string, bodyJSON string) error {
if r == nil || r.db == nil {
return nil
@@ -210,7 +290,7 @@ WHERE name = ?
rows, err := tx.Query(`
SELECT name, description, business_name, body_json
FROM profiles
-WHERE template_name = ? OR body_json LIKE ?
+WHERE primary_template_name = ? OR body_json LIKE ?
`, oldName, "%\"template\":\""+oldName+"\"%")
if err != nil {
return err
@@ -244,7 +324,7 @@ WHERE template_name = ? OR body_json LIKE ?
for _, item := range updates {
if _, err := tx.Exec(`
UPDATE profiles
-SET template_name = ?, body_json = ?, updated_at = ?
+SET primary_template_name = ?, body_json = ?, updated_at = ?
WHERE name = ?
`, newName, item.bodyJSON, now, item.name); err != nil {
return err
@@ -279,10 +359,10 @@ ON CONFLICT(name) DO UPDATE SET
return err
case "profiles":
_, err := r.db.Exec(`
-INSERT INTO profiles(name, template_name, business_name, description, body_json, created_at, updated_at)
+INSERT INTO profiles(name, primary_template_name, business_name, description, body_json, created_at, updated_at)
VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM profiles WHERE name = ?), ?), ?)
ON CONFLICT(name) DO UPDATE SET
- template_name=excluded.template_name,
+ primary_template_name=excluded.primary_template_name,
business_name=excluded.business_name,
description=excluded.description,
body_json=excluded.body_json,
@@ -305,7 +385,7 @@ ORDER BY updated_at DESC, name ASC
`
if table == "profiles" {
query = `
-SELECT name, description, body_json, created_at, updated_at, template_name, business_name
+SELECT name, description, body_json, created_at, updated_at, primary_template_name, business_name
FROM profiles
ORDER BY updated_at DESC, name ASC
`
@@ -338,7 +418,7 @@ WHERE name = ?
`
if table == "profiles" {
query = `
-SELECT name, description, body_json, created_at, updated_at, template_name, business_name
+SELECT name, description, body_json, created_at, updated_at, primary_template_name, business_name
FROM profiles
WHERE name = ?
`
diff --git a/internal/storage/assets_repo_test.go b/internal/storage/assets_repo_test.go
index 0c9d679..e891ecb 100644
--- a/internal/storage/assets_repo_test.go
+++ b/internal/storage/assets_repo_test.go
@@ -143,3 +143,44 @@ func TestAssetsRepoStoresIntegrationServiceDisabledState(t *testing.T) {
t.Fatalf("expected body_json payload preserved, got %#v", record)
}
}
+
+func TestAssetsRepoStoresVideoSource(t *testing.T) {
+ store := openTestStore(t)
+ defer store.Close()
+
+ repo := NewAssetsRepo(store.DB())
+ if err := repo.SaveVideoSource(
+ "gate_cam_01",
+ "rtsp",
+ "东门入口",
+ "东门主入口摄像头",
+ `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","description":"东门主入口摄像头","config":{"url":"rtsp://10.0.0.1/live","resolution":"1080p","frame_size":"1920x1080","fps":"25","video_format":"h264"}}`,
+ ); err != nil {
+ t.Fatalf("SaveVideoSource: %v", err)
+ }
+
+ records, err := repo.ListVideoSources()
+ if err != nil {
+ t.Fatalf("ListVideoSources: %v", err)
+ }
+ if len(records) != 1 {
+ t.Fatalf("expected one video source, got %#v", records)
+ }
+ if records[0].SourceType != "rtsp" || records[0].Area != "东门入口" {
+ t.Fatalf("unexpected video source record: %#v", records[0])
+ }
+
+ record, err := repo.GetVideoSource("gate_cam_01")
+ if err != nil {
+ t.Fatalf("GetVideoSource: %v", err)
+ }
+ if record == nil {
+ t.Fatal("expected video source record")
+ }
+ if record.SourceType != "rtsp" || record.Area != "东门入口" {
+ t.Fatalf("unexpected video source fetch: %#v", record)
+ }
+ if !strings.Contains(record.BodyJSON, `"resolution":"1080p"`) {
+ t.Fatalf("expected body_json payload preserved, got %#v", record)
+ }
+}
diff --git a/internal/storage/migrate.go b/internal/storage/migrate.go
index 0b56b6a..443722f 100644
--- a/internal/storage/migrate.go
+++ b/internal/storage/migrate.go
@@ -1,6 +1,9 @@
package storage
-import "database/sql"
+import (
+ "database/sql"
+ "fmt"
+)
const schema001 = `
CREATE TABLE IF NOT EXISTS templates (
@@ -14,7 +17,7 @@ CREATE TABLE IF NOT EXISTS templates (
CREATE TABLE IF NOT EXISTS profiles (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
- template_name TEXT NOT NULL DEFAULT '',
+ primary_template_name TEXT NOT NULL DEFAULT '',
business_name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
body_json TEXT NOT NULL,
@@ -39,6 +42,16 @@ CREATE TABLE IF NOT EXISTS integration_services (
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
+CREATE TABLE IF NOT EXISTS video_sources (
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE,
+ source_type TEXT NOT NULL DEFAULT '',
+ area TEXT NOT NULL DEFAULT '',
+ description TEXT NOT NULL DEFAULT '',
+ body_json TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
CREATE TABLE IF NOT EXISTS devices (
device_id TEXT PRIMARY KEY,
hostname TEXT NOT NULL DEFAULT '',
@@ -93,6 +106,82 @@ CREATE TABLE IF NOT EXISTS audit_logs (
`
func migrate(db *sql.DB) error {
- _, err := db.Exec(schema001)
- return err
+ if _, err := db.Exec(schema001); err != nil {
+ return err
+ }
+ return migrateProfilePrimaryTemplateName(db)
+}
+
+func migrateProfilePrimaryTemplateName(db *sql.DB) error {
+ hasPrimary, err := hasColumn(db, "profiles", "primary_template_name")
+ if err != nil {
+ return err
+ }
+ if hasPrimary {
+ return nil
+ }
+ hasLegacy, err := hasColumn(db, "profiles", "template_name")
+ if err != nil {
+ return err
+ }
+ if !hasLegacy {
+ return fmt.Errorf("profiles table is missing both primary_template_name and template_name")
+ }
+ tx, err := db.Begin()
+ if err != nil {
+ return err
+ }
+ defer func() { _ = tx.Rollback() }()
+ if _, err := tx.Exec(`ALTER TABLE profiles RENAME TO profiles_legacy_template_name`); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(`
+CREATE TABLE profiles (
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE,
+ primary_template_name TEXT NOT NULL DEFAULT '',
+ business_name TEXT NOT NULL DEFAULT '',
+ description TEXT NOT NULL DEFAULT '',
+ body_json TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+)`); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(`
+INSERT INTO profiles(id, name, primary_template_name, business_name, description, body_json, created_at, updated_at)
+SELECT id, name, template_name, business_name, description, body_json, created_at, updated_at
+FROM profiles_legacy_template_name
+`); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(`DROP TABLE profiles_legacy_template_name`); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+func hasColumn(db *sql.DB, table string, column string) (bool, error) {
+ rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
+ if err != nil {
+ return false, err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var (
+ cid int
+ name string
+ ctype string
+ notnull int
+ dfltValue sql.NullString
+ pk int
+ )
+ if err := rows.Scan(&cid, &name, &ctype, ¬null, &dfltValue, &pk); err != nil {
+ return false, err
+ }
+ if name == column {
+ return true, nil
+ }
+ }
+ return false, rows.Err()
}
diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go
index 8efea62..e0b3bf9 100644
--- a/internal/storage/sqlite_test.go
+++ b/internal/storage/sqlite_test.go
@@ -1,6 +1,7 @@
package storage
import (
+ "database/sql"
"path/filepath"
"testing"
)
@@ -18,6 +19,7 @@ func TestSQLiteStoreBootstrapsSchema(t *testing.T) {
"profiles",
"overlays",
"integration_services",
+ "video_sources",
"devices",
"device_config_state",
"tasks",
@@ -33,3 +35,58 @@ func TestSQLiteStoreBootstrapsSchema(t *testing.T) {
}
}
}
+
+func TestSQLiteStoreMigratesLegacyProfileTemplateColumn(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "app.db")
+ legacyDB, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open legacy sqlite: %v", err)
+ }
+ _, err = legacyDB.Exec(`
+CREATE TABLE profiles (
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE,
+ template_name TEXT NOT NULL DEFAULT '',
+ business_name TEXT NOT NULL DEFAULT '',
+ description TEXT NOT NULL DEFAULT '',
+ body_json TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+INSERT INTO profiles(name, template_name, business_name, description, body_json, created_at, updated_at)
+VALUES('line_a', 'helmet', 'gate', 'desc', '{"name":"line_a","instances":[{"name":"cam1","template":"helmet"}]}', '2026-04-29T00:00:00+08:00', '2026-04-29T00:00:00+08:00');
+`)
+ if err != nil {
+ t.Fatalf("seed legacy schema: %v", err)
+ }
+ _ = legacyDB.Close()
+
+ store, err := OpenSQLite(dbPath)
+ if err != nil {
+ t.Fatalf("OpenSQLite migrate legacy: %v", err)
+ }
+ defer store.Close()
+
+ hasOld, err := hasColumn(store.DB(), "profiles", "template_name")
+ if err != nil {
+ t.Fatalf("has old column: %v", err)
+ }
+ if hasOld {
+ t.Fatal("expected legacy template_name column to be removed")
+ }
+ hasNew, err := hasColumn(store.DB(), "profiles", "primary_template_name")
+ if err != nil {
+ t.Fatalf("has new column: %v", err)
+ }
+ if !hasNew {
+ t.Fatal("expected primary_template_name column to exist after migration")
+ }
+ repo := NewAssetsRepo(store.DB())
+ profile, err := repo.GetProfile("line_a")
+ if err != nil {
+ t.Fatalf("GetProfile after migration: %v", err)
+ }
+ if profile == nil || profile.TemplateName != "helmet" {
+ t.Fatalf("expected migrated primary template value, got %#v", profile)
+ }
+}
diff --git a/internal/web/graph_node_types.go b/internal/web/graph_node_types.go
index 94fd14a..6cec1e7 100644
--- a/internal/web/graph_node_types.go
+++ b/internal/web/graph_node_types.go
@@ -21,8 +21,8 @@ type graphNodeTypeInfo struct {
func graphNodeTypesCatalog() []graphNodeTypeInfo {
return []graphNodeTypeInfo{
- nodeType("input_rtsp", "RTSP 输入", "输入", "camera", "从网络摄像机或流媒体地址读取视频流。", "source", map[string]any{"url": "${rtsp_url}"}, []graphNodeParam{
- textParam("url", "RTSP 地址", "${rtsp_url}"),
+ nodeType("input_rtsp", "RTSP 输入", "输入", "camera", "从网络摄像机或流媒体地址读取视频流。", "source", map[string]any{"url": "${slot:video_input_main.url}"}, []graphNodeParam{
+ textParam("url", "RTSP 地址", "${slot:video_input_main.url}"),
numberParam("fps", "输入帧率", "1"),
numberParam("width", "宽度", "1"),
numberParam("height", "高度", "1"),
diff --git a/internal/web/ui.go b/internal/web/ui.go
index 7569e14..6037400 100644
--- a/internal/web/ui.go
+++ b/internal/web/ui.go
@@ -74,7 +74,14 @@ type PageData struct {
AssetProfiles []service.ConfigProfileAsset
AssetProfile *service.ConfigProfileAsset
AssetProfileEditor *service.ConfigProfileEditor
+ ActiveInstanceIndex 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
AssetInstanceCount int
@@ -327,6 +334,55 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic
"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 "token_service":
+ 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 ""
+ }
+ },
}).ParseFS(uiFS, "ui/templates/*.html")
if err != nil {
return nil, err
@@ -440,6 +496,8 @@ func (u *UI) Routes() (chi.Router, error) {
r.Get("/plans/{name}/export", u.pagePlanExport)
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.Get("/assets/templates/{name}", u.pageAssetTemplate)
@@ -453,12 +511,15 @@ func (u *UI) Routes() (chi.Router, error) {
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.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)
@@ -475,6 +536,7 @@ func (u *UI) Routes() (chi.Router, error) {
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)
@@ -784,6 +846,35 @@ func (u *UI) deviceDetailPageData(dev *models.Device) PageData {
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()
+ }
+ }
return data
}
@@ -1169,7 +1260,11 @@ func (u *UI) pageModels(w http.ResponseWriter, r *http.Request) {
}
func (u *UI) pageDiagnostics(w http.ResponseWriter, r *http.Request) {
- u.render(w, r, "diagnostics", PageData{Title: "诊断", Devices: u.registry.GetDevices()})
+ u.render(w, r, "diagnostics", PageData{Title: "日志审计", Devices: u.registry.GetDevices()})
+}
+
+func (u *UI) pageResources(w http.ResponseWriter, r *http.Request) {
+ u.render(w, r, "resources", PageData{Title: "资源管理", Devices: u.registry.GetDevices()})
}
func (u *UI) pageRecognition(w http.ResponseWriter, r *http.Request) {
@@ -1513,6 +1608,7 @@ func (u *UI) pagePlans(w http.ResponseWriter, r *http.Request) {
if len(editor.Instances) > 0 && editor.Instances[0].Template != "" {
data.SelectedTemplate = editor.Instances[0].Template
}
+ data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.URL.Query()))
} else if data.Error == "" {
data.Error = err.Error()
}
@@ -1532,6 +1628,7 @@ func (u *UI) pagePlan(w http.ResponseWriter, r *http.Request) {
return
}
data.Title = "场景配置"
+ data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), activeInstanceIndexFromValues(r.URL.Query()))
u.render(w, r, "plans", data)
}
@@ -1554,6 +1651,23 @@ func (u *UI) actionPlanSave(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
+ if strings.TrimSpace(r.FormValue("add_instance")) == "1" {
+ data.AssetProfileEditor = &editor
+ data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), len(editor.Instances)-1)
+ data.Title = "场景配置"
+ u.render(w, r, "plans", data)
+ return
+ }
+ if raw := strings.TrimSpace(r.FormValue("remove_instance")); raw != "" {
+ if idx, convErr := strconv.Atoi(raw); convErr == nil && idx >= 0 && idx < len(editor.Instances) {
+ editor.Instances = append(editor.Instances[:idx], editor.Instances[idx+1:]...)
+ }
+ data.AssetProfileEditor = &editor
+ data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.Form))
+ data.Title = "场景配置"
+ u.render(w, r, "plans", data)
+ return
+ }
if err := u.preview.SaveProfileEditor(editor); err != nil {
data.Error = err.Error()
data.Title = "场景配置"
@@ -1588,15 +1702,170 @@ func (u *UI) redirectAssetProfileToPlan(w http.ResponseWriter, r *http.Request)
func (u *UI) pageAssetVideoSources(w http.ResponseWriter, r *http.Request) {
data := u.assetPageData("video-sources")
data.Title = "基础配置"
+ data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
+ if data.Error == "" {
+ data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
+ }
+ newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1"
+ editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1"
+ selected := strings.TrimSpace(r.URL.Query().Get("name"))
+ if selected != "" {
+ if item, err := u.preview.GetVideoSource(selected); err == nil {
+ data.AssetVideoSource = item
+ } else if data.Error == "" {
+ data.Error = err.Error()
+ }
+ } else if !newMode && len(data.AssetVideoSources) > 0 {
+ if item, err := u.preview.GetVideoSource(data.AssetVideoSources[0].Name); err == nil {
+ data.AssetVideoSource = item
+ }
+ }
+ if data.AssetVideoSource == nil {
+ data.AssetVideoSource = &service.ConfigVideoSourceAsset{
+ SourceType: "rtsp",
+ SourceTypeLabel: "RTSP",
+ }
+ data.AssetVideoSourceEditing = true
+ } else {
+ data.AssetVideoSourceEditing = newMode || editMode
+ }
u.render(w, r, "assets", data)
}
+func (u *UI) actionAssetVideoSourceSave(w http.ResponseWriter, r *http.Request) {
+ if u.preview == nil {
+ http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
+ return
+ }
+ _ = r.ParseForm()
+ asset := service.ConfigVideoSourceAsset{
+ Name: strings.TrimSpace(r.FormValue("name")),
+ SourceType: strings.TrimSpace(r.FormValue("source_type")),
+ Area: strings.TrimSpace(r.FormValue("area")),
+ Description: strings.TrimSpace(r.FormValue("description")),
+ Config: service.VideoSourceConfig{
+ URL: strings.TrimSpace(r.FormValue("url")),
+ Resolution: strings.TrimSpace(r.FormValue("resolution")),
+ FrameSize: strings.TrimSpace(r.FormValue("frame_size")),
+ FPS: strings.TrimSpace(r.FormValue("fps")),
+ VideoFormat: strings.TrimSpace(r.FormValue("video_format")),
+ FocalLength: strings.TrimSpace(r.FormValue("focal_length")),
+ MountHeight: strings.TrimSpace(r.FormValue("mount_height")),
+ MountAngle: strings.TrimSpace(r.FormValue("mount_angle")),
+ },
+ }
+ if err := u.preview.SaveVideoSourceAsset(asset); err != nil {
+ data := u.assetPageData("video-sources")
+ data.Title = "基础配置"
+ data.Error = err.Error()
+ data.AssetVideoSource = &asset
+ data.AssetVideoSource.SourceTypeLabel = serviceVideoSourceTypeLabel(asset.SourceType)
+ data.AssetVideoSourceEditing = true
+ u.render(w, r, "assets", data)
+ return
+ }
+ http.Redirect(w, r, "/ui/assets/video-sources?msg="+urlQueryEscape("视频源已保存")+"&name="+url.PathEscape(asset.Name), http.StatusFound)
+}
+
+func (u *UI) actionAssetVideoSourceDelete(w http.ResponseWriter, r *http.Request) {
+ if u.preview == nil {
+ http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
+ return
+ }
+ name := chi.URLParam(r, "name")
+ if err := u.preview.DeleteVideoSource(name); err != nil {
+ http.Redirect(w, r, "/ui/assets/video-sources?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound)
+ return
+ }
+ http.Redirect(w, r, "/ui/assets/video-sources?msg="+urlQueryEscape("视频源已删除"), http.StatusFound)
+}
+
func (u *UI) pageAssetIntegrations(w http.ResponseWriter, r *http.Request) {
data := u.assetPageData("integrations")
data.Title = "基础配置"
+ data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
+ if data.Error == "" {
+ data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
+ }
+ newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1"
+ editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1"
+ selected := strings.TrimSpace(r.URL.Query().Get("name"))
+ if selected != "" {
+ if item, err := u.preview.GetIntegrationService(selected); err == nil {
+ data.AssetIntegration = item
+ } else if data.Error == "" {
+ data.Error = err.Error()
+ }
+ } else if !newMode && len(data.AssetIntegrations) > 0 {
+ if item, err := u.preview.GetIntegrationService(data.AssetIntegrations[0].Name); err == nil {
+ data.AssetIntegration = item
+ }
+ }
+ if data.AssetIntegration == nil {
+ data.AssetIntegration = &service.ConfigIntegrationServiceAsset{
+ Type: "object_storage",
+ TypeLabel: "对象存储",
+ Enabled: true,
+ ObjectStorage: &service.ObjectStorageConfig{},
+ }
+ data.AssetIntegrationEditing = true
+ } else {
+ data.AssetIntegrationEditing = newMode || editMode
+ }
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")
+ asset := service.ConfigIntegrationServiceAsset{
+ Name: strings.TrimSpace(r.FormValue("name")),
+ Type: strings.TrimSpace(r.FormValue("type")),
+ Description: strings.TrimSpace(r.FormValue("description")),
+ Enabled: enabled,
+ ObjectStorage: &service.ObjectStorageConfig{
+ Endpoint: strings.TrimSpace(r.FormValue("endpoint")),
+ Bucket: strings.TrimSpace(r.FormValue("bucket")),
+ AccessKey: strings.TrimSpace(r.FormValue("access_key")),
+ SecretKey: strings.TrimSpace(r.FormValue("secret_key")),
+ },
+ TokenService: &service.TokenServiceConfig{
+ GetTokenURL: strings.TrimSpace(r.FormValue("get_token_url")),
+ Username: strings.TrimSpace(r.FormValue("username")),
+ Password: strings.TrimSpace(r.FormValue("password")),
+ TenantCode: strings.TrimSpace(r.FormValue("tenant_code")),
+ },
+ AlarmService: &service.AlarmServiceConfig{
+ PutMessageURL: strings.TrimSpace(r.FormValue("put_message_url")),
+ Username: strings.TrimSpace(r.FormValue("alarm_username")),
+ Password: strings.TrimSpace(r.FormValue("alarm_password")),
+ TenantCode: strings.TrimSpace(r.FormValue("alarm_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")
if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" {
@@ -1647,6 +1916,10 @@ func (u *UI) assetPageData(tab string) PageData {
}
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()
}
@@ -1663,6 +1936,11 @@ func (u *UI) assetPageData(tab string) PageData {
} 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 == "" {
@@ -1690,6 +1968,31 @@ func (u *UI) profileEditorPageData(name string) (PageData, error) {
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 {
@@ -1697,13 +2000,10 @@ func (u *UI) profileEditorActionData(r *http.Request, name string) (service.Conf
}
_ = r.ParseForm()
editor := service.ConfigProfileEditor{
- Name: strings.TrimSpace(r.FormValue("profile_name")),
- BusinessName: strings.TrimSpace(r.FormValue("business_name")),
- Description: strings.TrimSpace(r.FormValue("description")),
- SiteName: strings.TrimSpace(r.FormValue("site_name")),
- ObjectStorageRef: strings.TrimSpace(r.FormValue("object_storage_ref")),
- TokenServiceRef: strings.TrimSpace(r.FormValue("token_service_ref")),
- AlarmServiceRef: strings.TrimSpace(r.FormValue("alarm_service_ref")),
+ Name: strings.TrimSpace(r.FormValue("profile_name")),
+ BusinessName: strings.TrimSpace(r.FormValue("business_name")),
+ Description: strings.TrimSpace(r.FormValue("description")),
+ SiteName: strings.TrimSpace(r.FormValue("site_name")),
Queue: service.ConfigProfileQueueEditor{
Size: strings.TrimSpace(r.FormValue("queue_size")),
Strategy: strings.TrimSpace(r.FormValue("queue_strategy")),
@@ -1721,6 +2021,7 @@ func (u *UI) profileEditorActionData(r *http.Request, name string) (service.Conf
}
data.AssetProfileEditor = &editor
data.SelectedProfile = editor.Name
+ data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.Form))
return editor, data, nil
}
@@ -1838,6 +2139,21 @@ func normalizeConfigName(name string) (string, error) {
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 {
@@ -2177,6 +2493,104 @@ func parseAdvancedParams(raw string) map[string]any {
return out
}
+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
+ }
+ _ = r.ParseForm()
+ data := u.deviceDetailPageData(dev)
+ req := service.ConfigPreviewRequest{Profile: strings.TrimSpace(r.FormValue("profile"))}
+ if req.Profile == "" {
+ req.Profile = data.SelectedProfile
+ }
+ data.SelectedProfile = req.Profile
+ if req.Profile != "" && len(data.AssetProfiles) > 0 {
+ for i := range data.AssetProfiles {
+ if strings.TrimSpace(data.AssetProfiles[i].Name) == req.Profile {
+ data.AssetProfile = &data.AssetProfiles[i]
+ data.SelectedTemplate = profileAssetTemplate(&data.AssetProfiles[i])
+ break
+ }
+ }
+ }
+ if req.Profile == "" {
+ data.Error = "请先选择场景配置"
+ u.render(w, r, "device", data)
+ return
+ }
+ if data.SelectedTemplate == "" {
+ 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
+ }
+ req.Template = data.SelectedTemplate
+ preview, err := u.preview.Render(req)
+ 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{}{}
@@ -2203,19 +2617,25 @@ func parseProfileInstanceForm(form url.Values) []service.ConfigProfileInstanceEd
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")),
- RTSPURL: strings.TrimSpace(form.Get(prefix + "rtsp_url")),
- PublishHLSPath: strings.TrimSpace(form.Get(prefix + "publish_hls_path")),
- PublishRTSPPort: strings.TrimSpace(form.Get(prefix + "publish_rtsp_port")),
- PublishRTSPPath: strings.TrimSpace(form.Get(prefix + "publish_rtsp_path")),
- ChannelNo: strings.TrimSpace(form.Get(prefix + "channel_no")),
+ 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.RTSPURL != "" || inst.Delete {
+ if inst.Name != "" || inst.VideoSourceRef != "" || len(inst.ServiceBindings) > 0 || len(inst.OutputBindings) > 0 || inst.Delete {
out = append(out, inst)
}
}
@@ -2224,10 +2644,7 @@ func parseProfileInstanceForm(form url.Values) []service.ConfigProfileInstanceEd
if len(out) > 0 && strings.TrimSpace(out[0].Template) != "" {
templateName = strings.TrimSpace(out[0].Template)
}
- out = append(out, service.ConfigProfileInstanceEditor{
- Template: templateName,
- PublishRTSPPort: "8555",
- })
+ out = append(out, newProfileInstanceDraft(templateName, nextProfileInstanceName(out)))
}
if len(out) > 0 {
fallbackTemplate := strings.TrimSpace(out[0].Template)
@@ -2243,6 +2660,134 @@ func parseProfileInstanceForm(form url.Values) []service.ConfigProfileInstanceEd
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 {
diff --git a/internal/web/ui/assets/graph_editor.js b/internal/web/ui/assets/graph_editor.js
index cc3c9d6..e04a549 100644
--- a/internal/web/ui/assets/graph_editor.js
+++ b/internal/web/ui/assets/graph_editor.js
@@ -29,7 +29,7 @@
doc.template = graph;
const fallbackNodeTypes = [
- { type: "input_rtsp", label: "RTSP 输入", category: "输入", icon: "camera", description: "从网络摄像机或流媒体地址读取视频流。", defaults: { id: "input_rtsp", type: "input_rtsp", role: "source", enable: true, url: "${rtsp_url}" } },
+ { type: "input_rtsp", label: "RTSP 输入", category: "输入", icon: "camera", description: "从网络摄像机或流媒体地址读取视频流。", defaults: { id: "input_rtsp", type: "input_rtsp", role: "source", enable: true, url: "${slot:video_input_main.url}" } },
{ type: "input_file", label: "文件输入", category: "输入", icon: "file", description: "从本地视频文件读取帧,常用于离线验证和回放。", defaults: { id: "input_file", type: "input_file", role: "source", enable: true } },
{ type: "preprocess", label: "图像预处理", category: "处理", icon: "adjust", description: "调整尺寸、格式和硬件加速路径,为后续推理或编码准备图像。", defaults: { id: "preprocess", type: "preprocess", role: "filter", enable: true, dst_format: "rgb" } },
{ type: "ai_scrfd", label: "SCRFD 人脸检测", category: "AI 推理", icon: "scan-face", description: "使用 SCRFD 模型做人脸检测,适合固定输入尺寸场景。", defaults: { id: "ai_scrfd", type: "ai_scrfd", role: "filter", enable: true } },
@@ -56,7 +56,7 @@
const coreEdgeKeys = new Set(["from", "to"]);
const paramSchemas = {
input_rtsp: [
- { key: "url", label: "RTSP 地址", type: "text", placeholder: "${rtsp_url}" },
+ { key: "url", label: "RTSP 地址", type: "text", placeholder: "${slot:video_input_main.url}" },
{ key: "fps", label: "输入帧率", type: "number", step: "1" },
{ key: "width", label: "宽度", type: "number", step: "1" },
{ key: "height", label: "高度", type: "number", step: "1" },
diff --git a/internal/web/ui/assets/style.css b/internal/web/ui/assets/style.css
index 2183c4f..413c702 100644
--- a/internal/web/ui/assets/style.css
+++ b/internal/web/ui/assets/style.css
@@ -116,6 +116,18 @@ code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
.nav-section{padding:14px 10px 6px;font-size:11px;color:var(--sidebar-muted);text-transform:uppercase;letter-spacing:.06em}
.side-nav a{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:var(--radius);color:var(--sidebar-text);font-size:13px;font-weight:500}
.side-nav a:hover{background:var(--sidebar-hover)}
+.side-nav .nav-group{margin:0}
+.side-nav .nav-group summary{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:var(--radius);color:var(--sidebar-text);font-size:13px;font-weight:500;list-style:none;cursor:pointer;user-select:none}
+.side-nav .nav-group summary:hover{background:var(--sidebar-hover)}
+.side-nav .nav-group summary::-webkit-details-marker{display:none}
+.side-nav .nav-group summary::after{content:"";margin-left:auto;width:7px;height:7px;border-right:1.5px solid var(--sidebar-muted);border-bottom:1.5px solid var(--sidebar-muted);transform:rotate(45deg);transition:transform .16s ease,border-color .16s ease}
+.side-nav .nav-group[open] summary::after{transform:rotate(225deg);border-color:var(--sidebar-text)}
+.side-nav .nav-group[open] summary{background:var(--sidebar-hover)}
+.side-nav .nav-group-items{display:flex;flex-direction:column;gap:4px;margin-top:4px}
+.side-nav .nav-subitem{padding:8px 10px 8px 34px;font-size:12px;font-weight:500;color:var(--sidebar-muted)}
+.side-nav .nav-subitem:hover{background:var(--sidebar-hover);color:var(--sidebar-text)}
+.side-nav .nav-subicon{width:22px;height:20px;font-size:9px}
+.side-nav .nav-subicon .ui-icon{width:12px;height:12px}
.nav-icon{width:28px;height:24px;border-radius:3px;border:1px solid rgba(255,255,255,.12);display:grid;place-items:center;font-size:10px;color:var(--primary)}
.nav-icon .ui-icon{width:14px;height:14px;stroke-width:1.75}
@@ -232,8 +244,19 @@ tbody tr.selected{background:var(--selected-row);outline:1px solid var(--primary
.selector-card .actions{margin-top:auto}
.panel-block{border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft);padding:14px}
.panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px}
+.service-actions-row{margin-top:16px}
+.scene-config-form{margin-top:14px}
+.scene-summary-details{margin-top:12px;padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface)}
+.scene-summary-details summary{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:12px;font-weight:500;color:var(--muted);list-style:none}
+.scene-summary-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}
+.scene-summary-details summary::-webkit-details-marker{display:none}
+.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}
+.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)}
+.field-grid label>span{display:block;margin-bottom:6px;font-size:12px;color:var(--muted)}
+.field-grid label>span .required-mark{display:inline;color:var(--red);font-weight:600;margin-left:4px}
.field-grid .full{grid-column:1/-1}
.field-grid input,.field-grid select,.field-grid textarea{width:100%;padding:9px 10px;border:1px solid var(--border);border-radius:var(--radius);background:var(--input-bg);color:var(--text);font:inherit}
.field-grid textarea{resize:vertical;min-height:120px}
diff --git a/internal/web/ui/templates/api.html b/internal/web/ui/templates/api.html
index 920b72a..e18e4c6 100644
--- a/internal/web/ui/templates/api.html
+++ b/internal/web/ui/templates/api.html
@@ -1,6 +1,6 @@
{{define "api"}}
-
诊断 / 高级调试
+
系统管理 / 日志审计 / 高级调试
高级调试
diff --git a/internal/web/ui/templates/asset_overlay.html b/internal/web/ui/templates/asset_overlay.html
index 7de4a85..8459a13 100644
--- a/internal/web/ui/templates/asset_overlay.html
+++ b/internal/web/ui/templates/asset_overlay.html
@@ -6,10 +6,10 @@
{{icon "overlay"}}{{.AssetOverlay.Name}}
-
返回叠加项列表
+
返回调试参数列表