更新agent
Some checks are pending
CI / host-build (push) Waiting to run
CI / rk3588-cross-build (push) Waiting to run

This commit is contained in:
sladro 2026-01-10 22:21:48 +08:00
parent f8d8c6aba7
commit 1737419aed
9 changed files with 1134 additions and 51 deletions

View File

@ -0,0 +1,259 @@
# PRD ④ 控制端配置窗口GUI对接方案templates/instances-only
> 适用版本V12026-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=<rk3588sys 根目录>` 且 `agent.models_dir=<work_dir>/models`
---
## 3. 鉴权与错误处理
### 3.1 鉴权
- Header`X-RK-Token: <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`
- BodySQLite 文件二进制(`face_gallery.db`
保存位置:`<agent.models_dir>/face_gallery.db`
#### 4.3.2 立即生效(无需重启)
- `POST /v1/face-gallery/reload`
行为:
- agent 遍历所有 graphs找到 `type==ai_face_recog` 的节点
- 对每个节点下发 runtime config patchbump `gallery.reload_seq`
- 节点收到后会重新加载 SQLite db
---
## 5. Agent 对外 API控制端对接清单
> Base`http://<device_ip>:<agent_port>`(默认 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 stateinstances 列表),返回生成 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 源结构。

View File

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

View File

@ -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._-]+$`)

View File

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

View File

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

Binary file not shown.

View File

@ -170,7 +170,10 @@ private:
bool running_ = false;
PluginLoader loader_;
std::vector<std::unique_ptr<Graph>> 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;

View File

@ -564,11 +564,12 @@ struct FaceRecogConfigSnapshot {
std::array<float, 3> norm_mean{{0.0f, 0.0f, 0.0f}};
std::array<float, 3> 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<bool>("load_on_start", snap->gallery_load_on_start);
snap->gallery_expected_dim = std::max(0, g->ValueOr<int>("expected_dim", snap->gallery_expected_dim));
snap->gallery_dtype = g->ValueOr<std::string>("dtype", snap->gallery_dtype);
snap->gallery_reload_seq = g->ValueOr<int>("reload_seq", snap->gallery_reload_seq);
}
for (auto& c : snap->gallery_backend) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
for (auto& c : snap->gallery_dtype) c = static_cast<char>(std::tolower(static_cast<unsigned char>(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);
}
{

View File

@ -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<std::mutex> 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);
}
}