Refine profile-centric config metadata and agent status
This commit is contained in:
parent
04de2308fb
commit
f4b20f3e32
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
97
agent/internal/discovery/discovery_test.go
Normal file
97
agent/internal/discovery/discovery_test.go
Normal 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
|
||||
}
|
||||
@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) == "" {
|
||||
|
||||
@ -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.
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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`。
|
||||
|
||||
@ -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 用于测试或运行场景覆盖:
|
||||
|
||||
@ -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.
|
||||
这些内容应放到下一轮设计和实施计划中继续展开。
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user