diff --git a/PRD_04_Manager_ConfigGUI_AgentAPI.md b/PRD_04_Manager_ConfigGUI_AgentAPI.md new file mode 100644 index 0000000..ffc40ca --- /dev/null +++ b/PRD_04_Manager_ConfigGUI_AgentAPI.md @@ -0,0 +1,259 @@ +# PRD ④ 控制端配置窗口(GUI)对接方案(templates/instances-only) + +> 适用版本:V1(2026-01) +> +> 范围:**仅控制端 GUI/managerd 如何调用设备端 rk3588-agent**。 +> - 本项目中已在 `agent + media-server` 实现本文档所需能力。 +> - **不包含**配置程序(前端/后端)具体 UI 代码实现细节。 + +--- + +## 1. 目标 + +让非技术人员通过 GUI 完成: + +1) 新增/删除一路摄像头通道(channel) +2) 选择流程模板(转码/YOLO/报警/人脸检测/人脸识别)并配置参数 +3) 一键应用配置(写盘 + reload;失败自动 rollback) +4) 人脸库(SQLite `face_gallery.db`)上传并**无需重启**立即生效 + +--- + +## 2. 核心约束(必须遵守) + +1) GUI **只支持 `templates/instances` 模式**: + - 不提供手写 `graphs` 的编辑。 + - 运行态 graphs 只读查看(用于监控/联调)。 + +2) 人脸数据通过上传整个 SQLite 文件:`face_gallery.db`。 + +3) 人脸库默认路径:`./models/face_gallery.db`。 + - 要求部署时 **media-server 的工作目录** 与 **agent.models_dir** 关系合理,使该相对路径能找到 agent 上传的 db。 + - 推荐:`media_server_process.work_dir=` 且 `agent.models_dir=/models`。 + +--- + +## 3. 鉴权与错误处理 + +### 3.1 鉴权 +- Header:`X-RK-Token: ` +- 写接口(会写盘/改状态)必须鉴权。 +- 读接口默认可不鉴权;若设备端配置 `agent.require_token_for_read=true`,则读接口也必须鉴权。 + +### 3.2 统一错误返回 +- 成功:`2xx`,一般为 `{"ok":true,...}` 或业务 JSON +- 失败:`4xx/5xx`,返回 `{"error":"..."}` + +常见 HTTP: +- `401`:unauthorized +- `400`:validation failed / invalid json +- `404`:not found +- `409`:conflict +- `500`:internal error + +--- + +## 4. GUI 页面与调用流程 + +### 4.1 设备详情页(只读) +1) 设备信息:`GET /v1/info` +2) 运行态通道摘要:`GET /v1/graphs` +3) 单通道详情(可选):`GET /v1/graphs/{name}` +4) 日志:`GET /v1/logs/recent?limit=200` + +### 4.2 通道配置页(核心:instances) + +#### 4.2.1 初始化 +1) 获取 schema(渲染表单):`GET /v1/config/ui/schema` +2) 获取当前 state(回显):`GET /v1/config/ui/state` + +GUI 侧以 `instances[]` 为“通道列表”。 + +#### 4.2.2 校验/预览(dry-run) +用户编辑完成后: +- `POST /v1/config/ui/plan` + +返回: +- `generated_config`:生成出来的 root config(可用于预览/导出) +- `diff`:added/removed/changed(实例级别) + +> 说明:plan 的校验是“基础校验”(必填字段/模板名/实例名唯一)。 +> 真正的构图/插件/模型加载等校验发生在 apply → media-server reload 阶段。 + +#### 4.2.3 应用配置 +- `POST /v1/config/ui/apply` + +行为: +1) agent 生成新的 root config(只包含 `global/queue/templates/instances`) +2) 写盘到 `agent.config_path` +3) 调用 media-server reload +4) reload 失败则自动 rollback 并返回 500 + +#### 4.2.4 回滚(手动) +- `POST /v1/media-server/rollback` + +> 回滚语义:回滚到“上一次成功的**源配置**”(保留 templates/instances,不会被 expanded 覆盖),便于 GUI 二次编辑。 + +### 4.3 人脸库管理页 + +#### 4.3.1 上传人脸库 +- `PUT /v1/face-gallery` +- Content-Type:`application/octet-stream` +- Body:SQLite 文件二进制(`face_gallery.db`) + +保存位置:`/face_gallery.db` + +#### 4.3.2 立即生效(无需重启) +- `POST /v1/face-gallery/reload` + +行为: +- agent 遍历所有 graphs,找到 `type==ai_face_recog` 的节点 +- 对每个节点下发 runtime config patch:bump `gallery.reload_seq` +- 节点收到后会重新加载 SQLite db + +--- + +## 5. Agent 对外 API(控制端对接清单) + +> Base:`http://:`(默认 9100) + +### 5.1 设备信息 +#### `GET /v1/info` +用于设备列表/详情。 + +### 5.2 运行态(只读代理) +#### `GET /v1/graphs` +#### `GET /v1/graphs/{name}` +#### `GET /v1/logs/recent?limit=200` + +### 5.3 配置文件(root config) +#### `GET /v1/config` +返回 `agent.config_path` 对应 JSON(用于导出/高级查看)。 + +#### `PUT /v1/config` +上传完整 root config JSON(写盘 + reload;失败自动 rollback)。 + +### 5.4 语义化配置(GUI 推荐使用) +#### `GET /v1/config/ui/schema` +返回:可选模板列表 + 字段 schema(类型/默认/必填)。 + +#### `GET /v1/config/ui/state` +返回:当前 config 映射到 GUI state(主要是 `instances[]`)+ 内置模板列表。 + +#### `POST /v1/config/ui/plan` +输入 desired state(instances 列表),返回生成 config 与 diff。 + +#### `POST /v1/config/ui/apply` +同 plan,但会写盘并 reload(失败自动 rollback)。 + +### 5.5 模型管理(可选,但建议 GUI 支持) +#### `PUT /v1/models/{name}` +上传模型(.rknn),返回可引用的 `path`。 + +#### `GET /v1/models` +列出已上传模型(包含 `name/path/sha256/mtime_ms`)。 + +### 5.6 人脸库 +#### `GET /v1/face-gallery` +返回当前 db 文件信息(exists/size/mtime/path)。 + +#### `PUT /v1/face-gallery` +上传 `face_gallery.db`。 + +#### `POST /v1/face-gallery/reload` +让所有 `ai_face_recog` 节点热加载新 db。 + +--- + +## 6. `config/ui/*` 的数据结构(控制端直接照此传) + +### 6.1 `POST /v1/config/ui/plan|apply` Request + +```json +{ + "global": { }, + "queue": { }, + "instances": [ + { + "name": "cam1", + "template": "face_det_recog_rtsp_hls", + "params": { + "url": "rtsp://10.0.0.5:8554/cam", + "fps": 30, + "src_w": 1280, + "src_h": 720, + + "det_model_path": "./models/RetinaFace_mobile320.rknn", + "recog_model_path": "./models/mobilefacenet_arcface.rknn", + "gallery_path": "./models/face_gallery.db", + + "gop": 60, + "bitrate_kbps": 2000, + "rtsp_port": 8555, + "hls_path": "./web/hls/cam1/index.m3u8" + } + } + ] +} +``` + +说明: +- `global/queue` 可省略:agent 会沿用当前 config 中的值。 +- `instances` 为全量期望状态:控制端应把当前列表 + 修改后的列表一起提交。 +- 当前实现会生成新的 root config(只包含 `global/queue/templates/instances`),不会保留 `graphs`。 + +### 6.2 `POST /v1/config/ui/plan|apply` Response + +```json +{ + "ok": true, + "generated_config": { "global":{}, "queue":{}, "templates":{}, "instances":[] }, + "diff": { "added": ["cam1"], "removed": [], "changed": [] }, + "warnings": [] +} +``` + +--- + +## 7. 内置流程模板与必填参数表 + +> 模板名来自 `GET /v1/config/ui/schema` 的 `templates[]`。 + +### 7.1 `transcode_rtsp_hls` +- 必填:`url` +- 常用:`fps,src_w,src_h,gop,bitrate_kbps,rtsp_port,hls_path` + +### 7.2 `yolo_rtsp_hls` +- 必填:`url, model_path` + +### 7.3 `yolo_alarm_minio` +- 必填:`url, model_path, minio_endpoint, minio_bucket, minio_ak, minio_sk` +- 常用:`cooldown_ms`(默认 3000) + +### 7.4 `face_det_rtsp_hls` +- 必填:`url, det_model_path` + +### 7.5 `face_det_recog_rtsp_hls` +- 必填:`url, det_model_path, recog_model_path` +- 默认:`gallery_path=./models/face_gallery.db`,`thr_accept=0.45`,`thr_margin=0.05` + +--- + +## 8. 人脸库路径对齐建议(避免“上传了但识别不到”) + +1) 默认推荐(最省事): +- media-server work_dir:`/opt/rk3588sys` +- agent.models_dir:`/opt/rk3588sys/models` +- ai_face_recog.gallery.path:`./models/face_gallery.db` + +2) 若你们的 work_dir/models_dir 不是这种关系: +- 控制端在 instances params 里把 `gallery_path` 设置为**绝对路径**(例如 `/opt/rk3588sys/models/face_gallery.db`)。 + +--- + +## 9. 验收标准 + +1) GUI 新增通道 → apply 成功后:`GET /v1/graphs` 出现该通道;`GET /v1/graphs/{name}` 能看到节点链路。 +2) 删除通道 → apply 成功后 graphs 中消失。 +3) 上传 `face_gallery.db` → `POST /v1/face-gallery/reload` 后无需重启即可生效。 +4) apply 失败时:自动 rollback,且 rollback 后 config 仍为 templates/instances 源结构。 diff --git a/agent/internal/httpapi/face_gallery.go b/agent/internal/httpapi/face_gallery.go new file mode 100644 index 0000000..fcf6778 --- /dev/null +++ b/agent/internal/httpapi/face_gallery.go @@ -0,0 +1,184 @@ +package httpapi + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "rk3588sys/agent/internal/files" +) + +type faceGalleryInfo struct { + Ok bool `json:"ok"` + Path string `json:"path"` + Exists bool `json:"exists"` + Size int64 `json:"size"` + MtimeMS int64 `json:"mtime_ms"` +} + +func (s *Server) faceGalleryPath() string { + return filepath.Join(s.agentCfg.ModelsDir, "face_gallery.db") +} + +func (s *Server) handleFaceGallery(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + if !s.authorize(r, false) { + errorJSON(w, http.StatusUnauthorized, "unauthorized") + return + } + p := s.faceGalleryPath() + st, err := os.Stat(p) + if err != nil { + if os.IsNotExist(err) { + writeJSON(w, http.StatusOK, faceGalleryInfo{Ok: true, Path: p, Exists: false}) + return + } + errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, faceGalleryInfo{Ok: true, Path: p, Exists: true, Size: st.Size(), MtimeMS: st.ModTime().UnixMilli()}) + return + + case http.MethodPut: + if !s.authorize(r, true) { + errorJSON(w, http.StatusUnauthorized, "unauthorized") + return + } + if ct := strings.TrimSpace(r.Header.Get("Content-Type")); ct != "application/octet-stream" { + errorJSON(w, http.StatusBadRequest, "validation failed: Content-Type must be application/octet-stream") + return + } + maxBytes := int64(s.agentCfg.MaxUploadMB) * 1024 * 1024 + r.Body = http.MaxBytesReader(w, r.Body, maxBytes) + + dst := s.faceGalleryPath() + dir := filepath.Dir(dst) + if err := files.EnsureDir(dir, 0o755); err != nil { + errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error()) + return + } + + f, err := os.CreateTemp(dir, ".tmp-face-gallery-*") + if err != nil { + errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error()) + return + } + tmp := f.Name() + ok := false + defer func() { + _ = f.Close() + if !ok { + _ = os.Remove(tmp) + } + }() + + if _, err := f.ReadFrom(r.Body); err != nil { + errorJSON(w, http.StatusBadRequest, "invalid body: "+err.Error()) + return + } + if err := f.Chmod(0o644); err != nil { + errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error()) + return + } + if err := f.Sync(); err != nil { + errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error()) + return + } + if err := f.Close(); err != nil { + errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error()) + return + } + + if err := files.ReplaceFile(tmp, dst); err != nil { + errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error()) + return + } + ok = true + + st, err := os.Stat(dst) + if err != nil { + errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, faceGalleryInfo{Ok: true, Path: dst, Exists: true, Size: st.Size(), MtimeMS: st.ModTime().UnixMilli()}) + return + default: + errorJSON(w, http.StatusMethodNotAllowed, "method not allowed") + return + } +} + +func (s *Server) handleFaceGalleryReload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + errorJSON(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if !s.authorize(r, true) { + errorJSON(w, http.StatusUnauthorized, "unauthorized") + return + } + + ctx := r.Context() + st, b, err := s.ms.GetGraphs(ctx) + if err != nil { + errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error()) + return + } + if st < 200 || st > 299 { + errorJSON(w, http.StatusInternalServerError, fmt.Sprintf("internal error: media-server status=%d body=%s", st, strings.TrimSpace(string(bytes.TrimSpace(b))))) + return + } + + var graphs []struct { + Name string `json:"name"` + } + if err := json.Unmarshal(b, &graphs); err != nil { + errorJSON(w, http.StatusInternalServerError, "internal error: parse graphs failed: "+err.Error()) + return + } + + reloadSeq := time.Now().UnixMilli() + reloaded := 0 + for _, g := range graphs { + if strings.TrimSpace(g.Name) == "" { + continue + } + st2, b2, err := s.ms.GetGraph(ctx, g.Name) + if err != nil { + errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error()) + return + } + if st2 < 200 || st2 > 299 { + errorJSON(w, http.StatusInternalServerError, fmt.Sprintf("internal error: media-server status=%d body=%s", st2, strings.TrimSpace(string(bytes.TrimSpace(b2))))) + return + } + var snap struct { + Nodes []struct { + ID string `json:"id"` + Type string `json:"type"` + } `json:"nodes"` + } + if err := json.Unmarshal(b2, &snap); err != nil { + errorJSON(w, http.StatusInternalServerError, "internal error: parse graph snapshot failed: "+err.Error()) + return + } + for _, n := range snap.Nodes { + if n.Type != "ai_face_recog" { + continue + } + patch := map[string]any{"gallery": map[string]any{"reload_seq": reloadSeq}} + if err := s.ms.UpdateNodeConfig(ctx, n.ID, g.Name, patch); err != nil { + errorJSON(w, http.StatusInternalServerError, "internal error: update node config failed: "+err.Error()) + return + } + reloaded++ + } + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "reloaded": reloaded, "reload_seq": reloadSeq}) +} diff --git a/agent/internal/httpapi/server.go b/agent/internal/httpapi/server.go index dcc77d2..cfb58f9 100644 --- a/agent/internal/httpapi/server.go +++ b/agent/internal/httpapi/server.go @@ -9,6 +9,7 @@ import ( "io" "mime" "net/http" + "os" "regexp" "strconv" "strings" @@ -70,6 +71,12 @@ func New(agentCfg config.AgentConfig, baseDir string, ms *mediaserver.Client, st mux := http.NewServeMux() mux.HandleFunc("/v1/info", s.handleInfo) mux.HandleFunc("/v1/config", s.handleConfig) + mux.HandleFunc("/v1/config/ui/schema", s.handleConfigUISchema) + mux.HandleFunc("/v1/config/ui/state", s.handleConfigUIState) + mux.HandleFunc("/v1/config/ui/plan", s.handleConfigUIPlan) + mux.HandleFunc("/v1/config/ui/apply", s.handleConfigUIApply) + mux.HandleFunc("/v1/face-gallery", s.handleFaceGallery) + mux.HandleFunc("/v1/face-gallery/reload", s.handleFaceGalleryReload) mux.HandleFunc("/v1/models", s.handleModelsList) mux.HandleFunc("/v1/models/", s.handleModelUpload) mux.HandleFunc("/v1/media-server/reload", s.handleMediaReload) @@ -113,58 +120,88 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPut { + switch r.Method { + case http.MethodGet: + if !s.authorize(r, false) { + errorJSON(w, http.StatusUnauthorized, "unauthorized") + return + } + b, err := os.ReadFile(s.agentCfg.ConfigPath) + if err != nil { + if os.IsNotExist(err) { + errorJSON(w, http.StatusNotFound, "not found") + return + } + errorJSON(w, http.StatusInternalServerError, "internal error: read config failed: "+err.Error()) + return + } + var tmp any + if err := json.Unmarshal(b, &tmp); err != nil { + errorJSON(w, http.StatusInternalServerError, "internal error: config is not valid json: "+err.Error()) + return + } + writeRawJSON(w, http.StatusOK, b) + return + + case http.MethodPut: + if !s.authorize(r, true) { + errorJSON(w, http.StatusUnauthorized, "unauthorized") + return + } + if mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")); err != nil || mt != "application/json" { + errorJSON(w, http.StatusBadRequest, "validation failed: Content-Type must be application/json") + return + } + + const maxConfigBytes = int64(20 << 20) + r.Body = http.MaxBytesReader(w, r.Body, maxConfigBytes) + body, err := io.ReadAll(r.Body) + if err != nil { + if strings.Contains(err.Error(), "request body too large") { + errorJSON(w, http.StatusRequestEntityTooLarge, "payload too large") + return + } + errorJSON(w, http.StatusBadRequest, "invalid json: "+err.Error()) + return + } + if len(body) == 0 { + errorJSON(w, http.StatusBadRequest, "validation failed: empty body") + return + } + var tmp any + if err := json.Unmarshal(body, &tmp); err != nil { + errorJSON(w, http.StatusBadRequest, "invalid json: "+err.Error()) + return + } + + if err := s.applyRootConfigBytes(r.Context(), body); err != nil { + errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + return + default: errorJSON(w, http.StatusMethodNotAllowed, "method not allowed") return } - if !s.authorize(r, true) { - errorJSON(w, http.StatusUnauthorized, "unauthorized") - return - } - if mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")); err != nil || mt != "application/json" { - errorJSON(w, http.StatusBadRequest, "validation failed: Content-Type must be application/json") - return - } - - const maxConfigBytes = int64(20 << 20) - r.Body = http.MaxBytesReader(w, r.Body, maxConfigBytes) - body, err := io.ReadAll(r.Body) - if err != nil { - if strings.Contains(err.Error(), "request body too large") { - errorJSON(w, http.StatusRequestEntityTooLarge, "payload too large") - return - } - errorJSON(w, http.StatusBadRequest, "invalid json: "+err.Error()) - return - } - if len(body) == 0 { - errorJSON(w, http.StatusBadRequest, "validation failed: empty body") - return - } - var tmp any - if err := json.Unmarshal(body, &tmp); err != nil { - errorJSON(w, http.StatusBadRequest, "invalid json: "+err.Error()) - return - } +} +func (s *Server) applyRootConfigBytes(ctx context.Context, body []byte) error { if err := files.WriteFileAtomic(s.agentCfg.ConfigPath, append(body, '\n'), 0o644); err != nil { - errorJSON(w, http.StatusInternalServerError, "internal error: write config failed: "+err.Error()) - return + return fmt.Errorf("write config failed: %w", err) } - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() if err := s.ms.Reload(ctx); err != nil { rerr := err rbErr := s.ms.Rollback(ctx) if rbErr != nil { - errorJSON(w, http.StatusInternalServerError, fmt.Sprintf("internal error: reload failed: %v; rollback failed: %v", rerr, rbErr)) - return + return fmt.Errorf("reload failed: %v; rollback failed: %v", rerr, rbErr) } - errorJSON(w, http.StatusInternalServerError, fmt.Sprintf("internal error: reload failed: %v; rollback ok", rerr)) - return + return fmt.Errorf("reload failed: %v; rollback ok", rerr) } - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + return nil } var modelNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) diff --git a/agent/internal/httpapi/uiconfig.go b/agent/internal/httpapi/uiconfig.go new file mode 100644 index 0000000..b9c281d --- /dev/null +++ b/agent/internal/httpapi/uiconfig.go @@ -0,0 +1,563 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "os" + "sort" + "strings" + "time" +) + +type UIInstance struct { + Name string `json:"name"` + Template string `json:"template"` + Params map[string]any `json:"params,omitempty"` + Override map[string]any `json:"override,omitempty"` +} + +type uiRootConfig struct { + Global map[string]any `json:"global,omitempty"` + Queue map[string]any `json:"queue,omitempty"` + Templates map[string]any `json:"templates,omitempty"` + Instances []UIInstance `json:"instances,omitempty"` + Graphs []map[string]any `json:"graphs,omitempty"` +} + +type uiPlanRequest struct { + Global map[string]any `json:"global,omitempty"` + Queue map[string]any `json:"queue,omitempty"` + Instances []UIInstance `json:"instances"` +} + +type uiDiff struct { + Added []string `json:"added"` + Removed []string `json:"removed"` + Changed []string `json:"changed"` +} + +type uiPlanResponse struct { + Ok bool `json:"ok"` + GeneratedConfig map[string]any `json:"generated_config"` + Diff uiDiff `json:"diff"` + Warnings []string `json:"warnings,omitempty"` +} + +func (s *Server) handleConfigUISchema(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + errorJSON(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if !s.authorize(r, false) { + errorJSON(w, http.StatusUnauthorized, "unauthorized") + return + } + writeJSON(w, http.StatusOK, uiSchema()) +} + +func (s *Server) handleConfigUIState(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + errorJSON(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if !s.authorize(r, false) { + errorJSON(w, http.StatusUnauthorized, "unauthorized") + return + } + + root, _ := readUIRootConfig(s.agentCfg.ConfigPath) + // Force templates/instances-only view. + resp := map[string]any{ + "ok": true, + "global": root.Global, + "queue": root.Queue, + "instances": root.Instances, + "templates": sortedKeys(uiTemplates()), + } + writeJSON(w, http.StatusOK, resp) +} + +func (s *Server) handleConfigUIPlan(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + errorJSON(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if !s.authorize(r, true) { + errorJSON(w, http.StatusUnauthorized, "unauthorized") + return + } + req, err := readRequiredJSON[uiPlanRequest](w, r, 1<<20) + if err != nil { + errorJSON(w, http.StatusBadRequest, err.Error()) + return + } + resp, err := s.planUIConfig(req) + if err != nil { + errorJSON(w, http.StatusBadRequest, "validation failed: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, resp) +} + +func (s *Server) handleConfigUIApply(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + errorJSON(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if !s.authorize(r, true) { + errorJSON(w, http.StatusUnauthorized, "unauthorized") + return + } + req, err := readRequiredJSON[uiPlanRequest](w, r, 1<<20) + if err != nil { + errorJSON(w, http.StatusBadRequest, err.Error()) + return + } + resp, err := s.planUIConfig(req) + if err != nil { + errorJSON(w, http.StatusBadRequest, "validation failed: "+err.Error()) + return + } + b, err := json.MarshalIndent(resp.GeneratedConfig, "", " ") + if err != nil { + errorJSON(w, http.StatusInternalServerError, "internal error: marshal config failed: "+err.Error()) + return + } + if err := s.applyRootConfigBytes(r.Context(), b); err != nil { + errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, resp) +} + +func (s *Server) planUIConfig(req uiPlanRequest) (uiPlanResponse, error) { + cur, _ := readUIRootConfig(s.agentCfg.ConfigPath) + curInst := map[string]UIInstance{} + for _, it := range cur.Instances { + if strings.TrimSpace(it.Name) == "" { + continue + } + curInst[it.Name] = it + } + + warnings := []string{} + tpls := uiTemplates() + + seen := map[string]bool{} + for i := range req.Instances { + it := &req.Instances[i] + it.Name = strings.TrimSpace(it.Name) + it.Template = strings.TrimSpace(it.Template) + if it.Params == nil { + it.Params = map[string]any{} + } + if it.Name == "" { + return uiPlanResponse{}, errors.New("instances[].name is required") + } + if seen[it.Name] { + return uiPlanResponse{}, errors.New("instance name not unique: " + it.Name) + } + seen[it.Name] = true + if _, ok := tpls[it.Template]; !ok { + return uiPlanResponse{}, errors.New("unknown template: " + it.Template) + } + + // Apply per-template defaults. + applyUIDefaults(it) + + // Basic required params validation. + missing := validateUIRequiredParams(*it) + if len(missing) > 0 { + return uiPlanResponse{}, errors.New("instance " + it.Name + " missing params: " + strings.Join(missing, ", ")) + } + } + + // Build root config. + root := uiRootConfig{} + if req.Global != nil { + root.Global = req.Global + } else { + root.Global = cur.Global + } + if req.Queue != nil { + root.Queue = req.Queue + } else { + root.Queue = cur.Queue + } + root.Templates = tpls + root.Instances = req.Instances + + gen := map[string]any{} + b, _ := json.Marshal(root) + _ = json.Unmarshal(b, &gen) + + // Diff summary (instances only). + added := []string{} + removed := []string{} + changed := []string{} + for name := range seen { + if _, ok := curInst[name]; !ok { + added = append(added, name) + continue + } + prev := curInst[name] + now := seenInstance(req.Instances, name) + if prev.Template != now.Template { + changed = append(changed, name) + continue + } + // Rough compare params by json. + pb, _ := json.Marshal(prev.Params) + nb, _ := json.Marshal(now.Params) + if string(pb) != string(nb) { + changed = append(changed, name) + } + } + for name := range curInst { + if !seen[name] { + removed = append(removed, name) + } + } + sort.Strings(added) + sort.Strings(removed) + sort.Strings(changed) + + return uiPlanResponse{ + Ok: true, + GeneratedConfig: gen, + Diff: uiDiff{Added: added, Removed: removed, Changed: changed}, + Warnings: warnings, + }, nil +} + +func readUIRootConfig(path string) (uiRootConfig, error) { + var out uiRootConfig + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return out, nil + } + return out, err + } + _ = json.Unmarshal(b, &out) + if out.Instances == nil { + out.Instances = []UIInstance{} + } + if out.Global == nil { + out.Global = map[string]any{} + } + if out.Queue == nil { + out.Queue = map[string]any{} + } + return out, nil +} + +func readRequiredJSON[T any](w http.ResponseWriter, r *http.Request, maxBytes int64) (T, error) { + var zero T + if r.Body == nil { + return zero, errors.New("validation failed: empty body") + } + if mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")); err != nil || mt != "application/json" { + return zero, errors.New("validation failed: Content-Type must be application/json") + } + r.Body = http.MaxBytesReader(w, r.Body, maxBytes) + body, err := io.ReadAll(r.Body) + if err != nil { + if strings.Contains(err.Error(), "request body too large") { + return zero, errors.New("payload too large") + } + return zero, fmt.Errorf("invalid json: %v", err) + } + if len(strings.TrimSpace(string(body))) == 0 { + return zero, errors.New("validation failed: empty body") + } + var v T + if err := json.Unmarshal(body, &v); err != nil { + return zero, fmt.Errorf("invalid json: %v", err) + } + return v, nil +} + +func seenInstance(insts []UIInstance, name string) UIInstance { + for _, it := range insts { + if it.Name == name { + return it + } + } + return UIInstance{} +} + +func sortedKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func applyUIDefaults(it *UIInstance) { + if it.Params == nil { + it.Params = map[string]any{} + } + // Common defaults. + setDefault(it.Params, "name", it.Name) + setDefault(it.Params, "fps", 25) + setDefault(it.Params, "src_w", 1920) + setDefault(it.Params, "src_h", 1080) + setDefault(it.Params, "gop", 50) + setDefault(it.Params, "bitrate_kbps", 2000) + setDefault(it.Params, "rtsp_port", 8555) + setDefault(it.Params, "hls_path", "./web/hls/"+it.Name+"/index.m3u8") + + // Face defaults. + if it.Template == "face_det_recog_rtsp_hls" { + setDefault(it.Params, "gallery_path", "./models/face_gallery.db") + setDefault(it.Params, "thr_accept", 0.45) + setDefault(it.Params, "thr_margin", 0.05) + } + + // Alarm defaults. + if it.Template == "yolo_alarm_minio" { + setDefault(it.Params, "minio_region", "us-east-1") + setDefault(it.Params, "cooldown_ms", 3000) + } +} + +func validateUIRequiredParams(it UIInstance) []string { + req := []string{"url"} + switch it.Template { + case "transcode_rtsp_hls": + // url is enough + case "yolo_rtsp_hls", "yolo_alarm_minio": + req = append(req, "model_path") + case "face_det_rtsp_hls": + req = append(req, "det_model_path") + case "face_det_recog_rtsp_hls": + req = append(req, "det_model_path", "recog_model_path") + } + missing := []string{} + for _, k := range req { + v, ok := it.Params[k] + if !ok { + missing = append(missing, k) + continue + } + if s, ok := v.(string); ok { + if strings.TrimSpace(s) == "" { + missing = append(missing, k) + } + } + } + return missing +} + +func setDefault(m map[string]any, key string, val any) { + if _, ok := m[key]; ok { + return + } + m[key] = val +} + +func uiSchema() map[string]any { + return map[string]any{ + "version": 1, + "templates": sortedKeys(uiTemplates()), + "fields": map[string]any{ + "common": []map[string]any{ + {"key": "name", "type": "string", "required": true}, + {"key": "url", "type": "string", "required": true}, + {"key": "fps", "type": "int", "default": 25}, + {"key": "src_w", "type": "int", "default": 1920}, + {"key": "src_h", "type": "int", "default": 1080}, + {"key": "gop", "type": "int", "default": 50}, + {"key": "bitrate_kbps", "type": "int", "default": 2000}, + }, + "yolo": []map[string]any{ + {"key": "model_path", "type": "string", "required": true}, + }, + "face": []map[string]any{ + {"key": "det_model_path", "type": "string", "required": true}, + {"key": "recog_model_path", "type": "string", "required": false}, + {"key": "gallery_path", "type": "string", "default": "./models/face_gallery.db"}, + {"key": "thr_accept", "type": "float", "default": 0.45}, + {"key": "thr_margin", "type": "float", "default": 0.05}, + }, + "alarm_minio": []map[string]any{ + {"key": "minio_endpoint", "type": "string", "required": true}, + {"key": "minio_bucket", "type": "string", "required": true}, + {"key": "minio_ak", "type": "string", "required": true}, + {"key": "minio_sk", "type": "string", "required": true}, + {"key": "cooldown_ms", "type": "int", "default": 3000}, + }, + }, + "generated_at_ms": time.Now().UnixMilli(), + } +} + +func uiTemplates() map[string]any { + // Templates are expressed in media-server native format with placeholders used by config_expand. + return map[string]any{ + "transcode_rtsp_hls": map[string]any{ + "nodes": []any{ + map[string]any{"id": "in", "type": "input_rtsp", "role": "source", "enable": true, + "url": "${url}", "fps": "${fps}", "width": "${src_w}", "height": "${src_h}", + "use_mpp": true, "use_ffmpeg": false, "force_tcp": true, + "reconnect_sec": 5, "reconnect_backoff_max_sec": 30}, + map[string]any{"id": "pre", "type": "preprocess", "role": "filter", "enable": true, + "dst_w": "${src_w}", "dst_h": "${src_h}", "dst_format": "nv12", "keep_ratio": false, "use_rga": true}, + map[string]any{"id": "pub", "type": "publish", "role": "filter", "enable": true, + "codec": "h264", "fps": "${fps}", "gop": "${gop}", "bitrate_kbps": "${bitrate_kbps}", + "use_mpp": true, "use_ffmpeg_mux": true, + "outputs": []any{ + map[string]any{"proto": "rtsp_server", "port": "${rtsp_port}", "path": "/live/${name}"}, + map[string]any{"proto": "hls", "path": "${hls_path}", "segment_sec": 2}, + }}, + }, + "edges": []any{ + []any{"in", "pre"}, + []any{"pre", "pub"}, + }, + }, + "yolo_rtsp_hls": map[string]any{ + "nodes": []any{ + map[string]any{"id": "in", "type": "input_rtsp", "role": "source", "enable": true, + "url": "${url}", "fps": "${fps}", "width": "${src_w}", "height": "${src_h}", + "use_mpp": true, "use_ffmpeg": false, "force_tcp": true, + "reconnect_sec": 5, "reconnect_backoff_max_sec": 30}, + map[string]any{"id": "pre", "type": "preprocess", "role": "filter", "enable": true, + "dst_w": 640, "dst_h": 640, "dst_format": "rgb", "keep_ratio": false, "use_rga": true}, + map[string]any{"id": "ai", "type": "ai_yolo", "role": "filter", "enable": true, + "infer_fps": 10, "model_path": "${model_path}", "model_version": "auto", "num_classes": 80, + "conf": 0.25, "nms": 0.45, "class_filter": []any{}}, + map[string]any{"id": "osd", "type": "osd", "role": "filter", "enable": true, + "draw_bbox": true, "draw_text": true, "line_width": 2, "font_scale": 1, "labels": []any{}}, + map[string]any{"id": "post", "type": "preprocess", "role": "filter", "enable": true, + "dst_w": "${src_w}", "dst_h": "${src_h}", "dst_format": "nv12", "keep_ratio": false, "use_rga": true}, + map[string]any{"id": "pub", "type": "publish", "role": "filter", "enable": true, + "codec": "h264", "fps": "${fps}", "gop": "${gop}", "bitrate_kbps": "${bitrate_kbps}", + "use_mpp": true, "use_ffmpeg_mux": true, + "outputs": []any{ + map[string]any{"proto": "rtsp_server", "port": "${rtsp_port}", "path": "/live/${name}"}, + map[string]any{"proto": "hls", "path": "${hls_path}", "segment_sec": 2}, + }}, + }, + "edges": []any{ + []any{"in", "pre"}, + []any{"pre", "ai"}, + []any{"ai", "osd"}, + []any{"osd", "post"}, + []any{"post", "pub"}, + }, + }, + "yolo_alarm_minio": map[string]any{ + "nodes": []any{ + map[string]any{"id": "in", "type": "input_rtsp", "role": "source", "enable": true, + "url": "${url}", "fps": "${fps}", "width": "${src_w}", "height": "${src_h}", + "use_mpp": true, "use_ffmpeg": false, "force_tcp": true, + "reconnect_sec": 5, "reconnect_backoff_max_sec": 30}, + map[string]any{"id": "pre", "type": "preprocess", "role": "filter", "enable": true, + "dst_w": 640, "dst_h": 640, "dst_format": "rgb", "keep_ratio": false, "use_rga": true}, + map[string]any{"id": "ai", "type": "ai_yolo", "role": "filter", "enable": true, + "infer_fps": 10, "model_path": "${model_path}", "model_version": "auto", "num_classes": 80, + "conf": 0.25, "nms": 0.45, "class_filter": []any{}}, + map[string]any{"id": "osd", "type": "osd", "role": "filter", "enable": true, + "draw_bbox": true, "draw_text": true, "line_width": 2, "font_scale": 1, "labels": []any{}}, + map[string]any{"id": "post", "type": "preprocess", "role": "filter", "enable": true, + "dst_w": "${src_w}", "dst_h": "${src_h}", "dst_format": "nv12", "keep_ratio": false, "use_rga": true}, + map[string]any{"id": "pub", "type": "publish", "role": "filter", "enable": true, + "codec": "h264", "fps": "${fps}", "gop": "${gop}", "bitrate_kbps": "${bitrate_kbps}", + "use_mpp": true, "use_ffmpeg_mux": true, + "outputs": []any{map[string]any{"proto": "rtsp_server", "port": "${rtsp_port}", "path": "/live/${name}"}}}, + map[string]any{"id": "alarm", "type": "alarm", "role": "sink", "enable": true, + "eval_fps": 10, + "labels": []any{}, + "rules": []any{ + map[string]any{"name": "person_in_view", "class_ids": []any{0}, + "roi": map[string]any{"x": 0.0, "y": 0.0, "w": 1.0, "h": 1.0}, + "min_duration_ms": 0, "cooldown_ms": "${cooldown_ms}"}, + }, + "actions": map[string]any{ + "log": map[string]any{"enable": true, "level": "info"}, + "snapshot": map[string]any{"enable": true, "format": "jpg", "quality": 85, + "upload": map[string]any{"type": "minio", "endpoint": "${minio_endpoint}", "bucket": "${minio_bucket}", "region": "${minio_region}", "access_key": "${minio_ak}", "secret_key": "${minio_sk}"}}, + "clip": map[string]any{"enable": true, "pre_sec": 5, "post_sec": 10, "format": "mp4", "fps": "${fps}", + "upload": map[string]any{"type": "minio", "endpoint": "${minio_endpoint}", "bucket": "${minio_bucket}", "region": "${minio_region}", "access_key": "${minio_ak}", "secret_key": "${minio_sk}"}}, + "http": map[string]any{"enable": false, "url": "http://127.0.0.1:8080/api/alarm", "timeout_ms": 3000, "include_media_url": true, "method": "POST"}, + }, + }, + }, + "edges": []any{ + []any{"in", "pre"}, + []any{"pre", "ai"}, + []any{"ai", "osd"}, + []any{"osd", "post"}, + []any{"post", "pub"}, + []any{"pub", "alarm"}, + }, + }, + "face_det_rtsp_hls": map[string]any{ + "nodes": []any{ + map[string]any{"id": "in", "type": "input_rtsp", "role": "source", "enable": true, + "url": "${url}", "fps": "${fps}", "width": "${src_w}", "height": "${src_h}", + "use_mpp": true, "use_ffmpeg": false, "force_tcp": true, + "reconnect_sec": 5, "reconnect_backoff_max_sec": 30}, + map[string]any{"id": "pre", "type": "preprocess", "role": "filter", "enable": true, + "dst_w": 320, "dst_h": 320, "dst_format": "rgb", "keep_ratio": false, "use_rga": true}, + map[string]any{"id": "face_det", "type": "ai_face_det", "role": "filter", "enable": true, + "model_path": "${det_model_path}", "conf": 0.6, "nms": 0.4, "max_faces": 10, "output_landmarks": true, "input_format": "rgb"}, + map[string]any{"id": "osd", "type": "osd", "role": "filter", "enable": true, + "draw_bbox": true, "draw_text": true, "draw_face_det": true, "line_width": 2, "font_scale": 1, "labels": []any{}}, + map[string]any{"id": "post", "type": "preprocess", "role": "filter", "enable": true, + "dst_w": "${src_w}", "dst_h": "${src_h}", "dst_format": "nv12", "keep_ratio": false, "use_rga": true}, + map[string]any{"id": "pub", "type": "publish", "role": "filter", "enable": true, + "codec": "h264", "fps": "${fps}", "gop": "${gop}", "bitrate_kbps": "${bitrate_kbps}", + "use_mpp": true, "use_ffmpeg_mux": true, + "outputs": []any{map[string]any{"proto": "rtsp_server", "port": "${rtsp_port}", "path": "/live/${name}"}}}, + }, + "edges": []any{ + []any{"in", "pre"}, + []any{"pre", "face_det"}, + []any{"face_det", "osd"}, + []any{"osd", "post"}, + []any{"post", "pub"}, + }, + }, + "face_det_recog_rtsp_hls": map[string]any{ + "nodes": []any{ + map[string]any{"id": "in", "type": "input_rtsp", "role": "source", "enable": true, + "url": "${url}", "fps": "${fps}", "width": "${src_w}", "height": "${src_h}", + "use_mpp": true, "use_ffmpeg": false, "force_tcp": true, + "reconnect_sec": 5, "reconnect_backoff_max_sec": 30}, + map[string]any{"id": "pre", "type": "preprocess", "role": "filter", "enable": true, + "dst_w": "${src_w}", "dst_h": "${src_h}", "dst_format": "rgb", "keep_ratio": false, "use_rga": true}, + map[string]any{"id": "face_det", "type": "ai_face_det", "role": "filter", "enable": true, + "model_path": "${det_model_path}", "conf": 0.6, "nms": 0.4, "max_faces": 10, "output_landmarks": true, "input_format": "rgb"}, + map[string]any{"id": "face_recog", "type": "ai_face_recog", "role": "filter", "enable": true, + "model_path": "${recog_model_path}", "align": true, "emit_embedding": false, "max_faces": 10, + "threshold": map[string]any{"accept": "${thr_accept}", "margin": "${thr_margin}"}, + "gallery": map[string]any{"backend": "sqlite", "path": "${gallery_path}", "load_on_start": true, "expected_dim": 512, "dtype": "auto"}}, + map[string]any{"id": "osd", "type": "osd", "role": "filter", "enable": true, + "draw_bbox": true, "draw_text": true, "draw_face_det": false, "line_width": 2, "font_scale": 1, "labels": []any{}}, + map[string]any{"id": "post", "type": "preprocess", "role": "filter", "enable": true, + "dst_w": "${src_w}", "dst_h": "${src_h}", "dst_format": "nv12", "keep_ratio": false, "use_rga": true}, + map[string]any{"id": "pub", "type": "publish", "role": "filter", "enable": true, + "codec": "h264", "fps": "${fps}", "gop": "${gop}", "bitrate_kbps": "${bitrate_kbps}", + "use_mpp": true, "use_ffmpeg_mux": true, + "outputs": []any{map[string]any{"proto": "rtsp_server", "port": "${rtsp_port}", "path": "/live/${name}"}}}, + }, + "edges": []any{ + []any{"in", "pre"}, + []any{"pre", "face_det"}, + []any{"face_det", "face_recog"}, + []any{"face_recog", "osd"}, + []any{"osd", "post"}, + []any{"post", "pub"}, + }, + }, + } +} diff --git a/agent/internal/mediaserver/client.go b/agent/internal/mediaserver/client.go index 8435bf9..b6754dc 100644 --- a/agent/internal/mediaserver/client.go +++ b/agent/internal/mediaserver/client.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "path" + "encoding/json" "strings" "time" ) @@ -74,6 +75,22 @@ func (c *Client) Rollback(ctx context.Context) error { return err } +func (c *Client) UpdateNodeConfig(ctx context.Context, nodeID string, graph string, patch any) error { + if strings.TrimSpace(nodeID) == "" { + return errors.New("node id is empty") + } + b, err := json.Marshal(patch) + if err != nil { + return err + } + q := url.Values{} + if strings.TrimSpace(graph) != "" { + q.Set("graph", graph) + } + _, _, err = c.doControlQ(ctx, http.MethodPost, "/api/nodes/"+url.PathEscape(nodeID)+"/config", q, b) + return err +} + func (c *Client) doRead(ctx context.Context, method, p string, q url.Values, body []byte) (int, []byte, error) { ctx, cancel := context.WithTimeout(ctx, c.readTO) defer cancel() @@ -81,10 +98,14 @@ func (c *Client) doRead(ctx context.Context, method, p string, q url.Values, bod } func (c *Client) doControl(ctx context.Context, method, p string, body []byte) (int, []byte, error) { + return c.doControlQ(ctx, method, p, nil, body) +} + +func (c *Client) doControlQ(ctx context.Context, method, p string, q url.Values, body []byte) (int, []byte, error) { if c.retry.MaxAttempts <= 1 { ctx, cancel := context.WithTimeout(ctx, c.ctrlTO) defer cancel() - st, b, err := c.doOnce(ctx, method, p, nil, body) + st, b, err := c.doOnce(ctx, method, p, q, body) return st, b, classifyControlErr(st, b, err) } @@ -94,7 +115,7 @@ func (c *Client) doControl(ctx context.Context, method, p string, body []byte) ( for attempt := 1; attempt <= c.retry.MaxAttempts; attempt++ { ctxAttempt, cancel := context.WithTimeout(ctx, c.ctrlTO) - st, b, err := c.doOnce(ctxAttempt, method, p, nil, body) + st, b, err := c.doOnce(ctxAttempt, method, p, q, body) cancel() lastStatus, lastBody = st, b diff --git a/agent/rk3588-agent b/agent/rk3588-agent index 8d3c43d..259128d 100644 Binary files a/agent/rk3588-agent and b/agent/rk3588-agent differ diff --git a/include/graph_manager.h b/include/graph_manager.h index 5ae0b35..7457a87 100644 --- a/include/graph_manager.h +++ b/include/graph_manager.h @@ -170,7 +170,10 @@ private: bool running_ = false; PluginLoader loader_; std::vector> graphs_; - SimpleJson last_good_root_; + // Persisted "source" root config (may contain templates/instances). + SimpleJson last_good_source_root_; + // Expanded root config used for running graphs (instances expanded into graphs). + SimpleJson last_good_expanded_root_; std::string config_path_; std::string last_good_path_; size_t default_queue_size_ = 8; diff --git a/plugins/ai_face_recog/ai_face_recog_node.cpp b/plugins/ai_face_recog/ai_face_recog_node.cpp index 2375fc9..e56d650 100644 --- a/plugins/ai_face_recog/ai_face_recog_node.cpp +++ b/plugins/ai_face_recog/ai_face_recog_node.cpp @@ -564,11 +564,12 @@ struct FaceRecogConfigSnapshot { std::array norm_mean{{0.0f, 0.0f, 0.0f}}; std::array norm_std{{1.0f, 1.0f, 1.0f}}; - std::string gallery_backend = "file"; - std::string gallery_path; + std::string gallery_backend = "sqlite"; + std::string gallery_path = "./models/face_gallery.db"; bool gallery_load_on_start = true; int gallery_expected_dim = 512; std::string gallery_dtype = "auto"; + int gallery_reload_seq = 0; }; static bool BuildFaceRecogConfigSnapshot(const SimpleJson& config, @@ -624,6 +625,7 @@ static bool BuildFaceRecogConfigSnapshot(const SimpleJson& config, snap->gallery_load_on_start = g->ValueOr("load_on_start", snap->gallery_load_on_start); snap->gallery_expected_dim = std::max(0, g->ValueOr("expected_dim", snap->gallery_expected_dim)); snap->gallery_dtype = g->ValueOr("dtype", snap->gallery_dtype); + snap->gallery_reload_seq = g->ValueOr("reload_seq", snap->gallery_reload_seq); } for (auto& c : snap->gallery_backend) c = static_cast(std::tolower(static_cast(c))); for (auto& c : snap->gallery_dtype) c = static_cast(std::tolower(static_cast(c))); @@ -726,7 +728,8 @@ public: reload = (snap->gallery_backend != base->gallery_backend || snap->gallery_path != base->gallery_path || snap->gallery_expected_dim != base->gallery_expected_dim || - snap->gallery_dtype != base->gallery_dtype); + snap->gallery_dtype != base->gallery_dtype || + snap->gallery_reload_seq != base->gallery_reload_seq); } { diff --git a/src/graph_manager.cpp b/src/graph_manager.cpp index 005d480..7d6030c 100644 --- a/src/graph_manager.cpp +++ b/src/graph_manager.cpp @@ -821,13 +821,14 @@ bool GraphManager::Build(const SimpleJson& root_cfg, std::string& err) { graphs_.push_back(std::move(graph)); } - last_good_root_ = expanded; + last_good_source_root_ = root_cfg; + last_good_expanded_root_ = expanded; default_queue_size_ = default_queue_size; default_strategy_ = default_strategy; if (!last_good_path_.empty()) { std::string werr; - if (!WriteTextFileAtomic(last_good_path_, StringifySimpleJson(last_good_root_), werr)) { + if (!WriteTextFileAtomic(last_good_path_, StringifySimpleJson(last_good_source_root_), werr)) { LogWarn("[GraphManager] persist last_good failed: " + werr); } } @@ -893,6 +894,9 @@ bool GraphManager::ReloadFromFile(const std::string& path, std::string& err) { return false; } + // Keep the original source config (templates/instances preserved). + const SimpleJson source_root = root_cfg; + SimpleJson expanded; if (!ExpandRootConfig(root_cfg, expanded, err)) { return false; @@ -938,7 +942,7 @@ bool GraphManager::ReloadFromFile(const std::string& path, std::string& err) { std::lock_guard lock(graphs_mu_); - const SimpleJson prev_last_good = last_good_root_; + const SimpleJson prev_last_good = last_good_expanded_root_; const size_t prev_default_queue_size = default_queue_size_; const QueueDropStrategy prev_default_strategy = default_strategy_; const std::string prev_plugin_dir = loader_.PluginDir(); @@ -1012,7 +1016,7 @@ bool GraphManager::ReloadFromFile(const std::string& path, std::string& err) { graphs_ = std::move(recovered); default_queue_size_ = prev_default_queue_size; default_strategy_ = prev_default_strategy; - // last_good_root_ remains unchanged. + // last_good_source_root_/last_good_expanded_root_ remain unchanged. return true; }; @@ -1052,12 +1056,20 @@ bool GraphManager::ReloadFromFile(const std::string& path, std::string& err) { return false; } - last_good_root_ = expanded; + last_good_source_root_ = source_root; + last_good_expanded_root_ = expanded; default_queue_size_ = new_default_queue_size; default_strategy_ = new_default_strategy; if (new_log_level) { Logger::Instance().SetLevel(*new_log_level); } + + if (!last_good_path_.empty()) { + std::string werr; + if (!WriteTextFileAtomic(last_good_path_, StringifySimpleJson(last_good_source_root_), werr)) { + LogWarn("[GraphManager] persist last_good failed: " + werr); + } + } return true; } @@ -1156,7 +1168,8 @@ bool GraphManager::ReloadFromFile(const std::string& path, std::string& err) { } } - last_good_root_ = expanded; + last_good_source_root_ = source_root; + last_good_expanded_root_ = expanded; default_queue_size_ = new_default_queue_size; default_strategy_ = new_default_strategy; @@ -1166,7 +1179,7 @@ bool GraphManager::ReloadFromFile(const std::string& path, std::string& err) { if (!last_good_path_.empty()) { std::string werr; - if (!WriteTextFileAtomic(last_good_path_, StringifySimpleJson(last_good_root_), werr)) { + if (!WriteTextFileAtomic(last_good_path_, StringifySimpleJson(last_good_source_root_), werr)) { LogWarn("[GraphManager] persist last_good failed: " + werr); } }