更新agent
This commit is contained in:
parent
f8d8c6aba7
commit
1737419aed
259
PRD_04_Manager_ConfigGUI_AgentAPI.md
Normal file
259
PRD_04_Manager_ConfigGUI_AgentAPI.md
Normal file
@ -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=<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`
|
||||
- Body:SQLite 文件二进制(`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 patch:bump `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 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 源结构。
|
||||
184
agent/internal/httpapi/face_gallery.go
Normal file
184
agent/internal/httpapi/face_gallery.go
Normal 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})
|
||||
}
|
||||
@ -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._-]+$`)
|
||||
|
||||
563
agent/internal/httpapi/uiconfig.go
Normal file
563
agent/internal/httpapi/uiconfig.go
Normal 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"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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.
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user