Refine profile-centric config metadata and agent status

This commit is contained in:
tian 2026-04-20 11:41:09 +08:00
parent 04de2308fb
commit f4b20f3e32
14 changed files with 720 additions and 177 deletions

View File

@ -123,6 +123,7 @@ func main() {
BuildID: BuildID,
BuildType: BuildType,
GitSHA: GitSHA,
ConfigPath: cfg.Agent.ConfigPath,
}
log.Info("udp discovery listening on :" + strconv.Itoa(cfg.Agent.DiscoveryPort))
if err := resp.Run(ctx); err != nil {

View File

@ -6,6 +6,8 @@ import (
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"time"
@ -25,6 +27,7 @@ type Responder struct {
BuildID string
BuildType string
GitSHA string
ConfigPath string
}
type discoverReq struct {
@ -46,6 +49,13 @@ type discoverReply struct {
BuildID string `json:"build_id"`
BuildType string `json:"build_type"`
GitSHA string `json:"git_sha"`
ConfigID string `json:"config_id,omitempty"`
ConfigVersion string `json:"config_version,omitempty"`
Template string `json:"template,omitempty"`
Profile string `json:"profile,omitempty"`
Overlays []string `json:"overlays,omitempty"`
InstanceName string `json:"instance_name,omitempty"`
InstanceDisplayName string `json:"instance_display_name,omitempty"`
UptimeSec int64 `json:"uptime_sec"`
}
@ -110,6 +120,15 @@ func (r *Responder) Run(ctx context.Context) error {
GitSHA: r.GitSHA,
UptimeSec: sysinfo.UptimeSec(),
}
if summary := readConfigMetadataSummary(r.ConfigPath); summary != nil {
reply.ConfigID = summary.ConfigID
reply.ConfigVersion = summary.ConfigVersion
reply.Template = summary.Template
reply.Profile = summary.Profile
reply.Overlays = copyStringSlice(summary.Overlays)
reply.InstanceName = summary.InstanceName
reply.InstanceDisplayName = summary.InstanceDisplayName
}
b, _ := json.Marshal(reply)
out := Magic + "\n" + string(b) + "\n"
_, _ = conn.WriteToUDP([]byte(out), remote)
@ -128,3 +147,84 @@ func split2lines(s string) (string, string, bool) {
}
return line1, line2, true
}
type configMetadataSummary struct {
ConfigID string
ConfigVersion string
Template string
Profile string
Overlays []string
InstanceName string
InstanceDisplayName string
}
func readConfigMetadataSummary(path string) *configMetadataSummary {
if strings.TrimSpace(path) == "" {
return nil
}
b, err := os.ReadFile(filepath.Clean(path))
if err != nil {
return nil
}
var root struct {
Metadata map[string]any `json:"metadata"`
}
if err := json.Unmarshal(b, &root); err != nil {
return nil
}
return metadataSummaryFromMap(root.Metadata)
}
func metadataSummaryFromMap(metadata map[string]any) *configMetadataSummary {
if len(metadata) == 0 {
return nil
}
summary := &configMetadataSummary{
ConfigID: stringValue(metadata["config_id"]),
ConfigVersion: stringValue(metadata["config_version"]),
Template: stringValue(metadata["template"]),
Profile: stringValue(metadata["profile"]),
Overlays: stringSliceValue(metadata["overlays"]),
}
if names := stringSliceValue(metadata["instance_names"]); len(names) > 0 {
summary.InstanceName = names[0]
}
if names := stringSliceValue(metadata["instance_display_names"]); len(names) > 0 {
summary.InstanceDisplayName = names[0]
}
return summary
}
func stringValue(v any) string {
s, _ := v.(string)
return strings.TrimSpace(s)
}
func stringSliceValue(v any) []string {
switch vv := v.(type) {
case []string:
return copyStringSlice(vv)
case []any:
out := make([]string, 0, len(vv))
for _, item := range vv {
if s, ok := item.(string); ok {
s = strings.TrimSpace(s)
if s != "" {
out = append(out, s)
}
}
}
return out
default:
return nil
}
}
func copyStringSlice(v []string) []string {
if len(v) == 0 {
return nil
}
out := make([]string, len(v))
copy(out, v)
return out
}

View File

@ -0,0 +1,97 @@
package discovery
import (
"context"
"encoding/json"
"net"
"os"
"path/filepath"
"testing"
"time"
)
func TestResponderIncludesConfigSummaryInReply(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "media-server.json")
body := []byte(`{"metadata":{"config_id":"cfg-1","config_version":"v1","template":"workshop_face_shoe_alarm","profile":"local_3588_test","overlays":["face_debug"],"instance_names":["cam1"],"instance_display_names":["视觉识别终端-A厂区"]},"instances":[]}`)
if err := os.WriteFile(cfgPath, body, 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
port := freeUDPPort(t)
resp := &Responder{
Port: port,
DeviceID: "dev-1",
DeviceName: "rk3588_orangepi5plus",
Hostname: "orangepi5plus",
AgentPort: 9100,
MediaPort: 9000,
Version: "0.1.0",
BuildID: "20260420.001",
BuildType: "release",
GitSHA: "abc1234",
ConfigPath: cfgPath,
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
_ = resp.Run(ctx)
}()
time.Sleep(100 * time.Millisecond)
conn, err := net.DialUDP("udp4", nil, &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port})
if err != nil {
t.Fatalf("dial udp: %v", err)
}
defer conn.Close()
if err := conn.SetDeadline(time.Now().Add(2 * time.Second)); err != nil {
t.Fatalf("set deadline: %v", err)
}
req := Magic + "\n" + `{"type":"discover","req_id":"req-1","reply_port":0}` + "\n"
if _, err := conn.Write([]byte(req)); err != nil {
t.Fatalf("write req: %v", err)
}
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("read reply: %v", err)
}
_, line2, ok := split2lines(string(buf[:n]))
if !ok {
t.Fatalf("invalid reply: %q", string(buf[:n]))
}
var reply map[string]any
if err := json.Unmarshal([]byte(line2), &reply); err != nil {
t.Fatalf("decode reply: %v", err)
}
if reply["config_id"] != "cfg-1" {
t.Fatalf("config_id = %#v", reply["config_id"])
}
if reply["template"] != "workshop_face_shoe_alarm" {
t.Fatalf("template = %#v", reply["template"])
}
if reply["profile"] != "local_3588_test" {
t.Fatalf("profile = %#v", reply["profile"])
}
if reply["instance_display_name"] != "视觉识别终端-A厂区" {
t.Fatalf("instance_display_name = %#v", reply["instance_display_name"])
}
overlays, ok := reply["overlays"].([]any)
if !ok || len(overlays) != 1 || overlays[0] != "face_debug" {
t.Fatalf("overlays = %#v", reply["overlays"])
}
}
func freeUDPPort(t *testing.T) int {
t.Helper()
l, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
t.Fatalf("listen udp: %v", err)
}
defer l.Close()
return l.LocalAddr().(*net.UDPAddr).Port
}

View File

@ -46,7 +46,7 @@ func (f fakeProcessController) RollbackBinary(string) (procctl.BinaryUpdateResul
func TestHandleConfigStatusReportsMetadataHashAndMediaStatus(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
body := []byte(`{"metadata":{"config_id":"cfg-1","config_version":"v1"},"instances":[]}`)
body := []byte(`{"metadata":{"config_id":"cfg-1","config_version":"v1","template":"workshop_face_shoe_alarm","profile":"local_3588_test","overlays":["face_debug"],"instance_names":["cam1"],"instance_display_names":["视觉识别终端-A厂区"]},"instances":[]}`)
if err := os.WriteFile(cfgPath, body, 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
@ -85,6 +85,19 @@ func TestHandleConfigStatusReportsMetadataHashAndMediaStatus(t *testing.T) {
if metadata["config_id"] != "cfg-1" || metadata["config_version"] != "v1" {
t.Fatalf("metadata = %#v", metadata)
}
if got["template"] != "workshop_face_shoe_alarm" {
t.Fatalf("template = %#v", got["template"])
}
if got["profile"] != "local_3588_test" {
t.Fatalf("profile = %#v", got["profile"])
}
if got["instance_display_name"] != "视觉识别终端-A厂区" {
t.Fatalf("instance_display_name = %#v", got["instance_display_name"])
}
overlays, ok := got["overlays"].([]any)
if !ok || len(overlays) != 1 || overlays[0] != "face_debug" {
t.Fatalf("overlays = %#v", got["overlays"])
}
sum := sha256.Sum256(body)
wantSHA := hex.EncodeToString(sum[:])
if got["sha256"] != wantSHA {
@ -102,3 +115,58 @@ func TestHandleConfigStatusReportsMetadataHashAndMediaStatus(t *testing.T) {
t.Fatalf("previous_config_path = %#v", got["previous_config_path"])
}
}
func TestHandleInfoIncludesCurrentConfigSummary(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
body := []byte(`{"metadata":{"config_id":"cfg-1","config_version":"v1","template":"workshop_face_shoe_alarm","profile":"local_3588_test","overlays":["face_debug","production_quiet"],"instance_names":["cam1"],"instance_display_names":["视觉识别终端-A厂区"]},"instances":[]}`)
if err := os.WriteFile(cfgPath, body, 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
s := &Server{
agentCfg: config.AgentConfig{
ConfigPath: cfgPath,
DeviceName: "rk3588_orangepi5plus",
},
deviceID: "dev-1",
hostname: "orangepi5plus",
agentPort: 9100,
mediaPort: 9000,
version: "0.1.0",
buildID: "20260420.001",
buildType: "release",
gitSHA: "abc1234",
}
req := httptest.NewRequest(http.MethodGet, "/v1/info", nil)
rr := httptest.NewRecorder()
s.handleInfo(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status code: got %d body=%s", rr.Code, rr.Body.String())
}
var got map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatalf("decode response: %v", err)
}
if got["config_id"] != "cfg-1" {
t.Fatalf("config_id = %#v", got["config_id"])
}
if got["config_version"] != "v1" {
t.Fatalf("config_version = %#v", got["config_version"])
}
if got["template"] != "workshop_face_shoe_alarm" {
t.Fatalf("template = %#v", got["template"])
}
if got["profile"] != "local_3588_test" {
t.Fatalf("profile = %#v", got["profile"])
}
if got["instance_display_name"] != "视觉识别终端-A厂区" {
t.Fatalf("instance_display_name = %#v", got["instance_display_name"])
}
overlays, ok := got["overlays"].([]any)
if !ok || len(overlays) != 2 {
t.Fatalf("overlays = %#v", got["overlays"])
}
}

View File

@ -27,6 +27,16 @@ type configFileStatus struct {
Error string `json:"error,omitempty"`
}
type configMetadataSummary struct {
ConfigID string
ConfigVersion string
Template string
Profile string
Overlays []string
InstanceName string
InstanceDisplayName string
}
func defaultAuditPath(baseDir string) string {
if strings.TrimSpace(baseDir) == "" {
return filepath.Join("logs", "agent_audit.jsonl")
@ -216,6 +226,29 @@ func (s *Server) configStatusPayload() map[string]any {
}
if len(current.Metadata) > 0 {
resp["metadata"] = current.Metadata
if summary := metadataSummaryFromMap(current.Metadata); summary != nil {
if summary.ConfigID != "" {
resp["config_id"] = summary.ConfigID
}
if summary.ConfigVersion != "" {
resp["config_version"] = summary.ConfigVersion
}
if summary.Template != "" {
resp["template"] = summary.Template
}
if summary.Profile != "" {
resp["profile"] = summary.Profile
}
if len(summary.Overlays) > 0 {
resp["overlays"] = copyStringSlice(summary.Overlays)
}
if summary.InstanceName != "" {
resp["instance_name"] = summary.InstanceName
}
if summary.InstanceDisplayName != "" {
resp["instance_display_name"] = summary.InstanceDisplayName
}
}
}
if current.Error != "" {
resp["error"] = current.Error
@ -236,6 +269,69 @@ func (s *Server) configStatusPayload() map[string]any {
return resp
}
func readConfigMetadataSummary(path string) *configMetadataSummary {
st := readConfigFileStatus(path)
if len(st.Metadata) == 0 {
return nil
}
return metadataSummaryFromMap(st.Metadata)
}
func metadataSummaryFromMap(metadata map[string]any) *configMetadataSummary {
if len(metadata) == 0 {
return nil
}
summary := &configMetadataSummary{
ConfigID: stringValue(metadata["config_id"]),
ConfigVersion: stringValue(metadata["config_version"]),
Template: stringValue(metadata["template"]),
Profile: stringValue(metadata["profile"]),
Overlays: stringSliceValue(metadata["overlays"]),
}
if names := stringSliceValue(metadata["instance_names"]); len(names) > 0 {
summary.InstanceName = names[0]
}
if names := stringSliceValue(metadata["instance_display_names"]); len(names) > 0 {
summary.InstanceDisplayName = names[0]
}
return summary
}
func stringValue(v any) string {
s, _ := v.(string)
return strings.TrimSpace(s)
}
func stringSliceValue(v any) []string {
switch vv := v.(type) {
case []string:
return copyStringSlice(vv)
case []any:
out := make([]string, 0, len(vv))
for _, item := range vv {
if s, ok := item.(string); ok {
s = strings.TrimSpace(s)
if s != "" {
out = append(out, s)
}
}
}
return out
default:
return nil
}
}
func copyStringSlice(v []string) []string {
if len(v) == 0 {
return nil
}
out := make([]string, len(v))
copy(out, v)
return out
}
func readConfigFileStatus(path string) configFileStatus {
out := configFileStatus{Path: filepath.ToSlash(path)}
if strings.TrimSpace(path) == "" {

View File

@ -69,6 +69,13 @@ type InfoResponse struct {
GitSHA string `json:"git_sha"`
ConfigPath string `json:"config_path"`
PreviousConfigPath string `json:"previous_config_path"`
ConfigID string `json:"config_id,omitempty"`
ConfigVersion string `json:"config_version,omitempty"`
Template string `json:"template,omitempty"`
Profile string `json:"profile,omitempty"`
Overlays []string `json:"overlays,omitempty"`
InstanceName string `json:"instance_name,omitempty"`
InstanceDisplayName string `json:"instance_display_name,omitempty"`
UptimeSec int64 `json:"uptime_sec"`
}
@ -168,6 +175,15 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
PreviousConfigPath: s.agentCfg.ConfigPath + ".last_good.json",
UptimeSec: sysinfo.UptimeSec(),
}
if summary := readConfigMetadataSummary(s.agentCfg.ConfigPath); summary != nil {
resp.ConfigID = summary.ConfigID
resp.ConfigVersion = summary.ConfigVersion
resp.Template = summary.Template
resp.Profile = summary.Profile
resp.Overlays = copyStringSlice(summary.Overlays)
resp.InstanceName = summary.InstanceName
resp.InstanceDisplayName = summary.InstanceDisplayName
}
writeJSON(w, http.StatusOK, resp)
}

Binary file not shown.

View File

@ -10,9 +10,14 @@
"name": "cam1",
"template": "workshop_face_shoe_alarm",
"params": {
"display_name": "视觉识别终端-A厂区",
"device_code": "rk3588-a-001",
"site_name": "A厂区",
"rtsp_url": "rtsp://10.0.0.49:8554/cam",
"rga_gate": "full_pipeline_1080p",
"face_gallery_path": "./models/face_gallery.db",
"publish_hls_path": "./web/hls/cam1/index.m3u8",
"publish_rtsp_port": 8555,
"publish_rtsp_path": "/live/cam1",
"channel_no": "cam1",
"minio_endpoint": "http://10.0.0.49:9000",
"minio_bucket": "myminio",
"minio_access_key": "admin",

View File

@ -37,7 +37,7 @@
"dst_packed": true,
"resize_mode": "stretch",
"keep_ratio": false,
"rga_gate": "${rga_gate}",
"rga_gate": "main_pipeline_rga",
"use_rga": true
},
{
@ -98,7 +98,7 @@
},
"gallery": {
"backend": "sqlite",
"path": "${face_gallery_path}",
"path": "./models/face_gallery.db",
"load_on_start": true,
"dtype": "auto"
},
@ -117,7 +117,7 @@
5
],
"use_rga": true,
"rga_gate": "${rga_gate}",
"rga_gate": "main_pipeline_rga",
"rga_max_inflight": 4,
"dst_packed": true,
"use_dma_input": true,
@ -177,7 +177,7 @@
6
],
"use_rga": true,
"rga_gate": "${rga_gate}",
"rga_gate": "main_pipeline_rga",
"rga_max_inflight": 4,
"dst_packed": true,
"use_dma_input": false,
@ -263,7 +263,7 @@
"dst_h": 1080,
"dst_format": "nv12",
"resize_mode": "stretch",
"rga_gate": "${rga_gate}",
"rga_gate": "main_pipeline_rga",
"use_rga": true
},
{
@ -311,13 +311,13 @@
"outputs": [
{
"proto": "hls",
"path": "./web/hls//index.m3u8",
"path": "${publish_hls_path}",
"segment_sec": 2
},
{
"proto": "rtsp_server",
"port": 8555,
"path": "/live/"
"port": "${publish_rtsp_port}",
"path": "${publish_rtsp_path}"
}
]
},
@ -433,7 +433,7 @@
"getTokenUrl": "${external_get_token_url}",
"putMessageUrl": "${external_put_message_url}",
"tenantCode": "${tenant_code}",
"channelNo": "${name}",
"channelNo": "${channel_no}",
"timeout_ms": 3000,
"include_media_url": true,
"token_header": "X-Access-Token",

View File

@ -694,6 +694,28 @@ python tools/render_config.py \
| `configs/overlays/` | 测试或运行场景覆盖,例如 debug、阈值、频率 |
| `configs/generated/` | 渲染产物,不手工维护,不提交生成的 JSON |
当前主线 profile 的设备级字段建议集中在:
- `display_name`
- `device_code`
- `site_name`
- `rtsp_url`
- `publish_hls_path`
- `publish_rtsp_port`
- `publish_rtsp_path`
- `channel_no`
当前主线 template 中已经固定或内收的默认值:
- `gallery.path` 固定为 `./models/face_gallery.db`
- `rga_gate``workshop_face_shoe_alarm` 中固定为 `main_pipeline_rga`
这意味着:
- `face_gallery_path` 不再作为普通 profile 配置项
- `rga_gate` 不再作为该模板的普通 profile 配置项
- profile 主要承载设备身份、输入源和输出发布参数
`--overlay` 可以指定多次,后面的 overlay 会覆盖前面的同名字段。建议:
- 生产:不加测试敏感 overlay或只加 `production_quiet.json`

View File

@ -32,12 +32,30 @@ configs/
- HLS/RTSP 发布
- 告警、截图、录像、MinIO 上传、External API 上传
模板中保留 DAG、插件结构和生产默认阈值。设备差异通过占位符表达例如 `${rtsp_url}`、`${face_gallery_path}`、`${minio_endpoint}`、`${external_get_token_url}`。短视频验证、临时放宽阈值或打开高频日志,应通过 overlay 完成,不应直接改模板。
模板中保留 DAG、插件结构和生产默认阈值。设备差异通过占位符表达例如 `${rtsp_url}`、`${publish_hls_path}`、`${publish_rtsp_port}`、`${publish_rtsp_path}`、`${channel_no}`。共享外部服务参数继续保留为模板参数,例如 `${minio_endpoint}`、`${external_get_token_url}`。短视频验证、临时放宽阈值或打开高频日志,应通过 overlay 完成,不应直接改模板。
当前主线模板已明确内收两类默认值:
- `gallery.path` 固定为 `./models/face_gallery.db`,由部署时复制到安装目录,不再要求 profile 显式填写 `face_gallery_path`
- `rga_gate``workshop_face_shoe_alarm` 中固定为 `main_pipeline_rga`,不再作为主 profile 字段
## Profile
`configs/profiles/local_3588_test.json` 描述具体设备或测试盒子的参数。多台设备或多路相机应新增 profile 或在同一 profile 中新增 instances而不是复制完整 pipeline。
当前主 profile 推荐只保留设备级字段,例如:
- `display_name`
- `device_code`
- `site_name`
- `rtsp_url`
- `publish_hls_path`
- `publish_rtsp_port`
- `publish_rtsp_path`
- `channel_no`
`face_gallery_path`、`rga_gate` 这类工程运行默认值,不再进入普通 profile 主字段。
## Overlay
Overlay 用于测试或运行场景覆盖:

View File

@ -1,123 +1,120 @@
# Profile-Centered Config Design
# 以 Profile 为中心的配置设计
## Purpose
## 目的
Clarify the field boundary between `template`, `profile`, and `overlay` so the backend management system can treat `profile` as a first-class asset instead of a simple dropdown.
明确 `template`、`profile`、`overlay` 三层之间的字段边界,使后台管理系统能够把 `profile` 当作一等配置资产,而不是仅仅把它当成一个下拉选项。
The target operating model is:
目标运行模型如下:
- one physical device maps to one profile
- templates stay reusable across multiple devices and sites
- overlays stay limited to debug, sensitivity, and temporary runtime modes
- internal media-server implementation parameters remain hidden from normal users
- 一台物理设备对应一份 profile
- template 可以在多台设备、多个现场之间复用
- overlay 只负责 debug、灵敏度和临时运行模式
- media-server 的内部实现参数默认不暴露给普通用户
This design builds on the existing template rendering workflow and does not return to hand-maintained full JSON files.
该设计建立在现有模板渲染工作流之上,不会回到手工维护多份完整 JSON 的旧模式。
## Current Context
## 当前背景
The current maintained config source is:
当前受维护的配置来源为:
- template: `configs/templates/workshop_face_shoe_alarm.json`
- profile: `configs/profiles/local_3588_test.json`
- overlays: `configs/overlays/*.json`
- generated runtime config: `configs/generated/*.json`
- template`configs/templates/workshop_face_shoe_alarm.json`
- profile`configs/profiles/local_3588_test.json`
- overlays`configs/overlays/*.json`
- 生成配置:`configs/generated/*.json`
Current reality from the config files:
从现有配置文件看,当前实际情况是:
- `template` already carries the pipeline skeleton, node graph, models, alarm structure, and default thresholds
- `overlay` already behaves well as debug/test/quiet mode overrides
- `profile` already contains some device-specific fields such as `rtsp_url`, but it also mixes in shared external-service settings
- `template` 已经承载了流水线骨架、节点图、模型、告警结构和默认阈值
- `overlay` 当前的定位基本正确,已经主要用于 debug / 测试 / 安静运行模式
- `profile` 已经开始承载设备独特属性,例如 `rtsp_url`,但同时又混入了共享的外部服务配置
That mix is the main problem. It prevents the backend from cleanly exposing profile management as "device/site identity and connection settings".
这类混放是当前的核心问题。它会让后台很难把 profile 清晰地表达成“设备/现场身份与设备级接入配置”。
## Design Goals
## 设计目标
1. Make `profile` the canonical home of one device's identity and per-device connection settings.
2. Keep shared scheme-level settings reusable through templates.
3. Keep overlays small and operational.
4. Prevent low-level implementation knobs from leaking into the user-facing config UI.
5. Support multiple templates, with each template remaining flexible enough to connect to different external services and storage backends.
1. `profile` 成为单台设备身份与设备级接入配置的唯一归属层。
2. 让方案级共享配置继续通过 template 复用。
3. 保持 overlay 小而纯粹,专注运行模式。
4. 阻止底层实现细节泄漏到面向用户的配置界面。
5. 支持多模板并存,且每个模板都能灵活对接不同的外部服务和存储后端。
## Field Ownership
## 字段归属边界
### Template
`template` owns scheme-level shared configuration.
`template` 负责方案级共享配置。
Typical contents:
典型内容包括:
- DAG structure: `nodes`, `edges`
- plugin/node selection
- shared model defaults
- shared algorithm defaults
- shared alarm action structure
- shared external service settings
- shared storage settings
- default publish protocol structure
- DAG 结构:`nodes`、`edges`
- 插件/节点选择
- 共享模型默认值
- 共享算法默认值
- 共享告警动作结构
- 共享外部服务配置
- 共享存储配置
- 默认发布协议结构
Examples from `workshop_face_shoe_alarm.json`:
`workshop_face_shoe_alarm.json` 为例,适合归属于 template 的内容包括:
- node types and node IDs
- model paths and model sizes
- tracker mode and default tracking thresholds
- alarm rule structure
- snapshot/clip upload structure
- external API action structure
- publish outputs structure
- 节点类型和节点 ID
- 模型路径和模型尺寸
- 跟踪器模式及默认阈值
- 告警规则结构
- 快照/录像上传结构
- External API 动作结构
- 发布输出结构
Important rule:
重要规则:
- shared services in `template` must remain editable template parameters
- they must not be treated as hard-coded constants
- template 中的共享服务字段必须保持“可编辑模板参数”的属性
- 不能把它们当成写死的常量
That allows multiple templates to point at different MinIO or external alarm services when needed.
这样才能支持不同 template 对接不同 MinIO 或不同外部告警服务。
### Profile
`profile` owns per-device identity and per-device runtime bindings.
`profile` 负责设备级身份和设备级运行绑定。
One profile corresponds to one physical device.
一份 profile 对应一台物理设备。
Typical contents:
典型内容包括:
- business display name
- site/area identity
- device-local input source
- device-local publish output parameters
- device-local resource paths
- any device-bound runtime identifier needed by external systems
- 业务显示名
- 站点身份
- 设备本地输入源
- 设备本地发布输出参数
- 设备本地资源路径
- 外部系统需要的设备级业务标识
Current fields that should stay in profile:
当前已经明显适合保留在 profile 的字段:
- `rtsp_url`
- `rga_gate`
- `face_gallery_path`
New profile-level fields recommended by this design:
本设计建议新增的 profile 字段:
- `display_name`
- `device_code`
- `site_name`
- `area_name`
- `publish_hls_path`
- `publish_rtsp_port`
- `publish_rtsp_path`
- `channel_no`
The backend device list should primarily show `display_name`. Technical identifiers such as `device_id` and `hostname` belong in device detail views.
后台设备列表应优先展示 `display_name`。像 `device_id`、`hostname` 这样的技术标识,应该收纳到设备详情页。
### Overlay
`overlay` owns short-lived operating modes.
`overlay` 负责短周期运行模式。
Allowed uses:
允许的用途包括:
- debug switches
- sensitive test thresholds
- quiet production mode
- temporary validation behavior
- debug 开关
- 测试灵敏度
- 安静生产模式
- 临时验证行为
Examples already aligned with this rule:
当前已经比较符合这一定位的 overlay
- `face_debug.json`
- `shoe_debug.json`
@ -125,65 +122,82 @@ Examples already aligned with this rule:
- `shoe_test_sensitive.json`
- `production_quiet.json`
Overlays should not carry:
overlay 不应承载:
- device identity
- device-local video sources
- per-device publish host/path/port
- long-lived external service ownership
- 设备身份
- 设备本地视频源
- 每台设备自己的输出主机/路径/端口
- 长期稳定的外部服务归属
### Hidden Internal Defaults
### 程序内部默认参数与高级设置
These should stay out of normal backend config editing:
以下内容不应进入普通后台主配置编辑:
- `cpu_affinity`
- queue implementation details
- queue 实现细节
- `rga_max_inflight`
- low-level tensor/input/output assumptions
- internal plugin glue parameters
- other fields that are easy to misconfigure and mainly matter to engineering
- 底层 tensor / 输入输出假设
- 插件之间的内部 glue 参数
- 其他容易配坏、主要面向工程调试的字段
These may still exist in template JSON, but they should not be presented as normal user-editable fields.
这些参数应按两类处理:
## Standard Profile Model
#### 1. 程序内部默认参数
Recommended logical sections for one profile:
如果某些参数在当前部署模式下应固定,则应直接回收到程序默认、模板默认或部署默认,不再暴露成配置字段。
### 1. Device Identity
当前明确建议内收的字段:
- `face_gallery_path`
- 应由部署流程将人脸库放到约定默认目录
- 不应允许在后台中修改
- `rga_gate`
- 如果最终确认始终固定使用同一组 RGA 资源分组,则应回收到模板默认或程序默认
- 当前 `workshop_face_shoe_alarm` 已固定为 `main_pipeline_rga`
#### 2. 高级设置
如果某些字段仅在工程调试、特殊部署或排障时才需要保留,则可以在 UI 中进入“高级设置”,并默认折叠,不进入日常运维主流程。
## 标准 Profile 模型
建议把单个 profile 组织为以下几组字段:
### 1. 设备身份
- `display_name`
- `device_code`
- `site_name`
- `area_name`
### 2. Input Source
### 2. 输入源
- `rtsp_url`
### 3. Publish Output
### 3. 输出发布
- `publish_hls_path`
- `publish_rtsp_port`
- `publish_rtsp_path`
- `channel_no`
Reasoning:
原因:
- publish output is per-device, not shared
- it includes device-specific IP/host/port/path semantics
- it often determines how downstream systems consume that specific box
- 发布输出是设备级属性,不是共享属性
- 它天然包含设备自己的 IP / 主机 / 端口 / 路径语义
- 它往往直接决定外部系统如何消费这台盒子的输出流
### 4. Device-Local Resources
### 4. 设备本地资源
- `face_gallery_path`
- `rga_gate`
- future device-local model or mount paths if needed
- 仅保留未来确有设备差异的本地资源路径
- 当前不建议把 `face_gallery_path` 暴露为 profile 字段
- 当前不建议把 `rga_gate` 暴露为 profile 字段
- 只有在后续确认不同设备之间确实存在差异时,才考虑进入高级设置
## Template Parameter Model
## Template 参数模型
Shared services should remain template-level parameters, not profile fields, when they are scheme-level common settings.
当某些外部服务属于“方案级共享配置”时,它们应保留在 template而不是塞进每台设备自己的 profile。
Recommended template-managed shared fields:
建议由 template 管理的共享字段包括:
- `minio_endpoint`
- `minio_bucket`
@ -193,18 +207,17 @@ Recommended template-managed shared fields:
- `external_put_message_url`
- `tenant_code`
Reasoning:
原因:
- these are often shared across a deployment scheme
- repeating them in every profile creates duplication and drift
- different templates may legitimately use different shared services, so they must remain editable per template
- 这类配置通常在同一类部署方案中是共享的
- 如果把它们复制进每台设备的 profile会造成重复和漂移
- 不同 template 仍然可能对接不同共享服务,因此它们必须保持 template 级可编辑,而不是写死
## Render Parameter Migration
## 渲染参数迁移方向
The following current parameters already exist in the template:
当前 template 中已经存在的占位符包括:
- `${rtsp_url}`
- `${rga_gate}`
- `${face_gallery_path}`
- `${minio_endpoint}`
- `${minio_bucket}`
@ -215,15 +228,13 @@ The following current parameters already exist in the template:
- `${tenant_code}`
- `${name}`
This design recommends the following next migration:
本设计建议下一步按以下方向迁移:
### Keep as profile-driven placeholders
### 保留为 profile 驱动占位符
- `${rtsp_url}`
- `${rga_gate}`
- `${face_gallery_path}`
### Keep as template-driven placeholders
### 保留为 template 驱动占位符
- `${minio_endpoint}`
- `${minio_bucket}`
@ -233,99 +244,105 @@ This design recommends the following next migration:
- `${external_put_message_url}`
- `${tenant_code}`
### Add new profile-driven placeholders
当前 `workshop_face_shoe_alarm` 已将 `rga_gate` 从占位符体系中移除,改回模板默认。
- `${display_name}` if needed by backend-facing metadata or labels
当前主线模板已将人脸库路径固定为 `./models/face_gallery.db`,后续由部署默认目录解决。
### 新增为 profile 驱动占位符
- `${display_name}`,如后续用于后台展示元数据或业务标签
- `${publish_hls_path}`
- `${publish_rtsp_port}`
- `${publish_rtsp_path}`
- `${channel_no}`
## Publish Output Adjustment
## 输出发布部分的调整
Current template publish outputs are structurally correct, but some fields should stop being fixed literals.
当前 template 中的 publish 输出结构本身是合理的,但其中部分字段不应再写成固定值。
Recommended change:
建议调整为:
- keep output protocol structure in template
- move per-device output values to profile-backed placeholders
- 输出协议结构继续保留在 template
- 每台设备不同的输出值改为引用 profile 字段
Suggested publish section direction:
建议演进方向:
- HLS output path: use `${publish_hls_path}`
- RTSP server port: use `${publish_rtsp_port}`
- RTSP server path: use `${publish_rtsp_path}`
- external API `channelNo`: use `${channel_no}` instead of `${name}`
- HLS 输出路径:使用 `${publish_hls_path}`
- RTSP server 端口:使用 `${publish_rtsp_port}`
- RTSP server 路径:使用 `${publish_rtsp_path}`
- External API 中的 `channelNo`:改为使用 `${channel_no}`,不再直接使用 `${name}`
Reasoning:
原因:
- `name` currently mixes instance identity with external business channel semantics
- external platform channel identity should be explicit
- publish paths and ports are clearly per-device runtime settings
- 当前 `${name}` 把实例名和外部业务通道语义混在了一起
- 对外平台通道号应当是明确字段,而不是靠实例名隐式复用
- 输出路径和端口显然属于单设备运行属性
## Backend Management Implications
## 对后台管理模型的影响
This boundary implies the backend should evolve toward the following asset model:
这套字段边界意味着后台应逐步演进成以下资产模型:
### Device
Operational object discovered from the agent:
由 agent 发现得到的运行对象:
- device ID
- hostname
- agent reachability
- media-server state
- current applied config metadata
- agent 可达性
- media-server 状态
- 当前应用配置元数据
### Profile
Managed configuration asset bound one-to-one to a device:
与设备一对一绑定的配置资产:
- edited in a dedicated profile detail page
- contains device identity, input, output, and local resource settings
- 在独立的 profile 详情页中编辑
- 承载设备身份、输入、输出和本地资源设置
### Template
Managed reusable scheme asset:
可复用的方案资产:
- edited in a dedicated template detail page
- contains pipeline structure and shared service parameters
- 在独立的 template 详情页中编辑
- 承载流水线结构和共享服务参数
### Overlay
Managed operational mode asset:
运行模式资产:
- selected during preview/apply workflows
- not used as the primary place to hold stable device identity
- 在预览/下发流程中进行选择
- 不作为长期设备身份配置的主要承载层
## UI Consequences
## 对 UI 的直接含义
The backend should stop treating profile as only a dropdown.
后台不应再把 profile 仅仅当成一个下拉框。
Minimum intended direction:
最小方向应当是:
1. profile becomes a first-class asset under config management
2. every device detail page should clearly show which profile is bound to it
3. device display name should come from profile, not from raw agent defaults
4. preview/apply pages only select profile; they should not be the main place where profile data is edited
1. profile 成为配置资产中的一等对象
2. 每台设备详情页都要明确显示当前绑定的 profile
3. 设备显示名应该来自 profile而不是直接来自 agent 默认命名
4. 预览/应用页面只负责“选择 profile”而不应成为 profile 的主要编辑入口
## Migration Direction
## 迁移路径
This design does not require immediate large-scale config rewrites. A practical migration path is:
这套设计不要求立刻重写全部配置。更实际的迁移路径是:
1. define the standard profile field model
2. parameterize publish output fields in the template
3. move shared external-service settings from profile into template-managed parameters
4. introduce backend profile pages and device-profile binding UI
5. keep overlays unchanged except for normal cleanup
1. 先定义标准 profile 字段模型
2. 把 template 中的发布输出字段参数化
3. 把共享外部服务配置从 profile 迁到 template 参数
4. 将 `face_gallery_path` 收回部署默认目录
5. 若模板中的 `rga_gate` 已固定,则收回模板默认或程序默认
6. 在后台引入 profile 页面和设备-profile 绑定界面
7. overlay 保持现状,仅做必要清理
## Out of Scope
## 暂不覆盖的内容
This design does not yet define:
本设计当前不包含以下内容:
- the full backend profile editor UI layout
- template editor UI schema generation
- automatic migration tooling for old profile JSON files
- permission models for editing templates vs profiles
- 完整的后台 profile 编辑页布局
- template 编辑器的 UI schema 生成方式
- 老 profile JSON 的自动迁移工具
- template 与 profile 的权限模型
Those should be handled in the next design and implementation planning step.
这些内容应放到下一轮设计和实施计划中继续展开。

View File

@ -1,4 +1,5 @@
import importlib.util
import json
import pathlib
import sys
import tempfile
@ -18,6 +19,45 @@ def load_module():
class RenderConfigTest(unittest.TestCase):
def test_render_supports_profile_publish_placeholders(self):
template_path = REPO_ROOT / "configs" / "templates" / "workshop_face_shoe_alarm.json"
profile_path = REPO_ROOT / "configs" / "profiles" / "local_3588_test.json"
template = json.loads(template_path.read_text(encoding="utf-8"))
profile = json.loads(profile_path.read_text(encoding="utf-8"))
publish_node = next(node for node in template["template"]["nodes"] if node["id"] == "publish")
alarm_node = next(node for node in template["template"]["nodes"] if node["id"] == "alarm")
params = profile["instances"][0]["params"]
self.assertEqual(publish_node["outputs"][0]["path"], "${publish_hls_path}")
self.assertEqual(publish_node["outputs"][1]["port"], "${publish_rtsp_port}")
self.assertEqual(publish_node["outputs"][1]["path"], "${publish_rtsp_path}")
self.assertEqual(alarm_node["actions"]["external_api"]["channelNo"], "${channel_no}")
self.assertIn("publish_hls_path", params)
self.assertIn("publish_rtsp_port", params)
self.assertIn("publish_rtsp_path", params)
self.assertIn("channel_no", params)
def test_render_profile_no_longer_requires_face_gallery_path(self):
profile_path = REPO_ROOT / "configs" / "profiles" / "local_3588_test.json"
profile = json.loads(profile_path.read_text(encoding="utf-8"))
params = profile["instances"][0]["params"]
self.assertNotIn("face_gallery_path", params)
self.assertNotIn("rga_gate", params)
def test_render_template_internalizes_clear_rga_gate_name(self):
template_path = REPO_ROOT / "configs" / "templates" / "workshop_face_shoe_alarm.json"
template = json.loads(template_path.read_text(encoding="utf-8"))
nodes = {node["id"]: node for node in template["template"]["nodes"]}
self.assertEqual(nodes["pre_rgb"]["rga_gate"], "main_pipeline_rga")
self.assertEqual(nodes["person_det"]["rga_gate"], "main_pipeline_rga")
self.assertEqual(nodes["shoe_det"]["rga_gate"], "main_pipeline_rga")
self.assertEqual(nodes["pre_osd"]["rga_gate"], "main_pipeline_rga")
def test_renders_profile_and_overlay(self):
module = load_module()
template = {
@ -188,6 +228,54 @@ class RenderConfigTest(unittest.TestCase):
self.assertEqual(rendered["metadata"]["overlays"], [])
self.assertEqual(rendered["metadata"]["rendered_by"], "test")
def test_render_metadata_includes_instance_identity_summary(self):
module = load_module()
with tempfile.TemporaryDirectory() as tmp_dir:
tmp = pathlib.Path(tmp_dir)
template_path = tmp / "template.json"
profile_path = tmp / "profile.json"
template_path.write_text(
"""
{
"name": "pipeline",
"template": {
"nodes": [{"id": "in", "type": "input_rtsp"}],
"edges": []
}
}
""",
encoding="utf-8",
)
profile_path.write_text(
"""
{
"name": "local_3588_test",
"instances": [
{
"name": "cam1",
"params": {
"display_name": "视觉识别终端-A厂区",
"rtsp_url": "rtsp://example/cam1"
}
}
]
}
""",
encoding="utf-8",
)
rendered = module.render(
template_path,
profile_path,
[],
metadata={"config_id": "cfg1", "rendered_by": "test"},
)
self.assertEqual(rendered["metadata"]["profile"], "local_3588_test")
self.assertEqual(rendered["metadata"]["instance_names"], ["cam1"])
self.assertEqual(rendered["metadata"]["instance_display_names"], ["视觉识别终端-A厂区"])
if __name__ == "__main__":
unittest.main()

View File

@ -147,11 +147,26 @@ def render(
root = apply_overlay(root, load_json(overlay_path))
if metadata is not None:
profile_name = str(profile.get("name") or profile_path.stem).strip()
instance_names: list[str] = []
instance_display_names: list[str] = []
for instance in root.get("instances", []):
if not isinstance(instance, dict):
continue
name = str(instance.get("name") or "").strip()
if name:
instance_names.append(name)
params = instance.get("params", {})
if isinstance(params, dict):
display_name = str(params.get("display_name") or "").strip()
if display_name:
instance_display_names.append(display_name)
root["metadata"] = {
"template": tpl_name,
"template_path": template_path.as_posix(),
"profile": profile_name,
"profile_path": profile_path.as_posix(),
"instance_names": instance_names,
"instance_display_names": instance_display_names,
"overlays": [p.stem for p in overlay_paths],
"overlay_paths": [p.as_posix() for p in overlay_paths],
**copy.deepcopy(metadata),