update,可以恢复的版本

This commit is contained in:
sladro 2026-01-10 22:56:31 +08:00
parent 6148498f0d
commit 3e4373ef25
8 changed files with 902 additions and 40 deletions

View File

@ -41,7 +41,7 @@ func main() {
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"http://localhost:5173", "http://127.0.0.1:5173"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-RK-Token", "X-Model-Sha256"},
MaxAge: 300,
}))
@ -83,6 +83,9 @@ func main() {
r.Post("/devices/{id}/media-server/stop", h.ProxyAgent)
r.Get("/devices/{id}/media-server/status", h.ProxyAgent)
// Generic passthrough for device agent /v1/* (covers config/ui/*, face-gallery, etc.)
r.Handle("/devices/{id}/v1/*", http.HandlerFunc(h.ProxyAgentV1))
// Task routes
r.Post("/tasks", h.CreateTask)
r.Get("/tasks", h.ListTasks)

View File

@ -3,9 +3,9 @@ package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"3588AdminBackend/internal/models"
"3588AdminBackend/internal/service"
@ -127,8 +127,8 @@ func (h *Handler) ProxyAgent(w http.ResponseWriter, r *http.Request) {
return
}
body, _ := io.ReadAll(r.Body)
resp, code, err := h.agent.Do(method, dev.IP, dev.AgentPort, agentPath, body)
contentType := r.Header.Get("Content-Type")
resp, code, err := h.agent.DoStream(method, dev.IP, dev.AgentPort, agentPath, r.Body, contentType, r.ContentLength)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@ -138,6 +138,36 @@ func (h *Handler) ProxyAgent(w http.ResponseWriter, r *http.Request) {
w.Write(resp)
}
// ProxyAgentV1 proxies any request under /api/devices/{id}/v1/* to the device agent as-is (path preserved).
func (h *Handler) ProxyAgentV1(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := h.findDevice(id)
if !ok {
http.Error(w, "device not found", http.StatusNotFound)
return
}
prefix := fmt.Sprintf("/api/devices/%s", id)
agentPath := strings.TrimPrefix(r.URL.Path, prefix)
if !strings.HasPrefix(agentPath, "/v1/") && agentPath != "/v1" {
http.Error(w, "path not mapped", http.StatusNotFound)
return
}
if q := r.URL.RawQuery; q != "" {
agentPath += "?" + q
}
contentType := r.Header.Get("Content-Type")
resp, code, err := h.agent.DoStream(r.Method, dev.IP, dev.AgentPort, agentPath, r.Body, contentType, r.ContentLength)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(code)
_, _ = w.Write(resp)
}
func (h *Handler) CreateTask(w http.ResponseWriter, r *http.Request) {
var req struct {
Type string `json:"type"`
@ -231,7 +261,7 @@ func (h *Handler) UploadModel(w http.ResponseWriter, r *http.Request) {
}
name := r.FormValue("name")
file, _, err := r.FormFile("file")
file, hdr, err := r.FormFile("file")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@ -243,16 +273,9 @@ func (h *Handler) UploadModel(w http.ResponseWriter, r *http.Request) {
return
}
// Read file data
data, err := io.ReadAll(file)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Forward to agent
agentPath := fmt.Sprintf("/v1/models/%s", name)
resp, code, err := h.agent.Do("PUT", dev.IP, dev.AgentPort, agentPath, data)
resp, code, err := h.agent.DoStream("PUT", dev.IP, dev.AgentPort, agentPath, file, "application/octet-stream", hdr.Size)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return

View File

@ -163,6 +163,72 @@ func OpenAPI(w http.ResponseWriter, r *http.Request) {
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
},
},
"/api/devices/{id}/v1/config": map[string]any{
"get": map[string]any{
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
"responses": map[string]any{"200": map[string]any{"description": "config", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
},
"put": map[string]any{
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
"requestBody": map[string]any{
"required": true,
"content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}},
},
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
},
},
"/api/devices/{id}/v1/config/ui/schema": map[string]any{
"get": map[string]any{
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
"responses": map[string]any{"200": map[string]any{"description": "schema", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
},
},
"/api/devices/{id}/v1/config/ui/state": map[string]any{
"get": map[string]any{
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
"responses": map[string]any{"200": map[string]any{"description": "state", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
},
},
"/api/devices/{id}/v1/config/ui/plan": map[string]any{
"post": map[string]any{
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
"requestBody": map[string]any{
"required": true,
"content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}},
},
"responses": map[string]any{"200": map[string]any{"description": "plan", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
},
},
"/api/devices/{id}/v1/config/ui/apply": map[string]any{
"post": map[string]any{
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
"requestBody": map[string]any{
"required": true,
"content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}},
},
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
},
},
"/api/devices/{id}/v1/face-gallery": map[string]any{
"get": map[string]any{
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
"responses": map[string]any{"200": map[string]any{"description": "face gallery info", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
},
"put": map[string]any{
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
"requestBody": map[string]any{
"required": true,
"content": map[string]any{"application/octet-stream": map[string]any{"schema": map[string]any{"type": "string", "format": "binary"}}},
},
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
},
},
"/api/devices/{id}/v1/face-gallery/reload": map[string]any{
"post": map[string]any{
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
},
},
"/api/templates": map[string]any{
"get": map[string]any{
"responses": map[string]any{"200": map[string]any{"description": "templates", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "array"}}}}},

View File

@ -2,6 +2,7 @@ package service
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
@ -14,42 +15,64 @@ import (
type AgentClient struct {
cfg *config.Config
client *http.Client
upload *http.Client
}
func NewAgentClient(cfg *config.Config) *AgentClient {
return &AgentClient{
cfg: cfg,
client: &http.Client{
Timeout: 3 * time.Second, // Overall timeout
Transport: &http.Transport{
DialContext: (&http.Transport{}).DialContext, // Default dialer
// Connect timeout is usually handled at dialer level but 1s is tight.
// For now using the simple client timeout.
},
Timeout: 3 * time.Second,
},
upload: &http.Client{Timeout: 120 * time.Second},
}
}
func (c *AgentClient) Do(method, ip string, port int, path string, body []byte) ([]byte, int, error) {
return c.DoStream(method, ip, port, path, bytes.NewReader(body), "", int64(len(body)))
}
func (c *AgentClient) DoStream(method, ip string, port int, path string, body io.Reader, contentType string, contentLength int64) ([]byte, int, error) {
url := fmt.Sprintf("http://%s:%d%s", ip, port, path)
req, err := http.NewRequest(method, url, bytes.NewBuffer(body))
var rc io.ReadCloser
if body == nil {
rc = http.NoBody
contentLength = 0
} else if r, ok := body.(io.ReadCloser); ok {
rc = r
} else {
rc = io.NopCloser(body)
}
ctx := context.Background()
req, err := http.NewRequestWithContext(ctx, method, url, rc)
if err != nil {
return nil, 0, err
}
if contentLength >= 0 {
req.ContentLength = contentLength
}
if c.cfg.AgentToken != "" {
req.Header.Set("X-RK-Token", c.cfg.AgentToken)
}
if len(body) > 0 {
contentType := "application/json"
if strings.HasPrefix(path, "/v1/models/") {
contentType = "application/octet-stream"
if req.Body != nil && req.Body != http.NoBody {
ct := strings.TrimSpace(contentType)
if ct == "" {
ct = defaultContentTypeForPath(path)
}
if ct != "" {
req.Header.Set("Content-Type", ct)
}
req.Header.Set("Content-Type", contentType)
}
resp, err := c.client.Do(req)
client := c.client
if isLongOp(method, path) {
client = c.upload
}
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
@ -62,3 +85,30 @@ func (c *AgentClient) Do(method, ip string, port int, path string, body []byte)
return respBody, resp.StatusCode, nil
}
func defaultContentTypeForPath(path string) string {
switch {
case strings.HasPrefix(path, "/v1/models/"):
return "application/octet-stream"
case path == "/v1/face-gallery":
return "application/octet-stream"
default:
return "application/json"
}
}
func isLongOp(method, path string) bool {
if method == http.MethodPut && path == "/v1/config" {
return true
}
if strings.HasPrefix(path, "/v1/config/ui/") {
return true
}
if strings.HasPrefix(path, "/v1/models/") {
return true
}
if path == "/v1/face-gallery" {
return true
}
return false
}

View File

@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"html/template"
"io"
"io/fs"
"net/http"
"strconv"
@ -46,10 +45,13 @@ type PageData struct {
Templates []service.Template
Template *service.Template
RawJSON string
RawText string
TaskID string
DeviceIDs string
RawJSON string
RawText string
SchemaJSON string
StateJSON string
FaceGalleryJSON string
TaskID string
DeviceIDs string
}
func NewUI(discovery *service.DiscoveryService, registry *service.RegistryService, agent *service.AgentClient, tasks *service.TaskService, templates *service.TemplateService) (*UI, error) {
@ -123,6 +125,12 @@ func (u *UI) Routes() (chi.Router, error) {
r.Get("/devices/{id}/logs", u.pageDeviceLogs)
r.Get("/devices/{id}/graphs", u.pageDeviceGraphs)
r.Post("/devices/{id}/config/apply", u.actionDeviceConfigApply)
r.Get("/devices/{id}/config-ui", u.pageDeviceConfigUI)
r.Get("/devices/{id}/config-friendly", u.pageDeviceConfigFriendly)
r.Post("/devices/{id}/config-ui/plan", u.actionDeviceConfigUIPlan)
r.Post("/devices/{id}/config-ui/apply", u.actionDeviceConfigUIApply)
r.Post("/devices/{id}/face-gallery/upload", u.actionDeviceFaceGalleryUpload)
r.Post("/devices/{id}/face-gallery/reload", u.actionDeviceFaceGalleryReload)
r.Post("/devices/{id}/models/upload", u.actionDeviceModelUpload)
r.Get("/tasks", u.pageTasks)
@ -324,7 +332,7 @@ func (u *UI) actionDeviceModelUpload(w http.ResponseWriter, r *http.Request) {
return
}
name := strings.TrimSpace(r.FormValue("name"))
file, _, err := r.FormFile("file")
file, hdr, err := r.FormFile("file")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@ -334,14 +342,8 @@ func (u *UI) actionDeviceModelUpload(w http.ResponseWriter, r *http.Request) {
http.Error(w, "name is required", http.StatusBadRequest)
return
}
data, err := io.ReadAll(file)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
path := fmt.Sprintf("/v1/models/%s", name)
resp, code, derr := u.agent.Do("PUT", dev.IP, dev.AgentPort, path, data)
resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, path, file, "application/octet-stream", hdr.Size)
out := PageData{Title: "设备详情", Device: dev, Message: fmt.Sprintf("PUT %s -> %d", path, code), RawText: string(resp)}
if derr != nil {
out.Error = derr.Error()
@ -430,3 +432,161 @@ func urlQueryEscape(s string) string {
r := strings.NewReplacer("%", "%25", " ", "%20", "+", "%2B", "&", "%26", "=", "%3D", "?", "%3F")
return r.Replace(s)
}
func prettyJSON(raw []byte) string {
var out bytes.Buffer
if err := json.Indent(&out, raw, "", " "); err != nil {
return string(raw)
}
return out.String()
}
func (u *UI) loadConfigUIData(dev *models.Device) PageData {
schemaBody, schemaCode, schemaErr := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/ui/schema", nil)
stateBody, stateCode, stateErr := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/ui/state", nil)
faceBody, faceCode, faceErr := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/face-gallery", nil)
data := PageData{
Title: "通道配置instances",
Device: dev,
SchemaJSON: fmt.Sprintf("GET /v1/config/ui/schema -> %d\n%s", schemaCode, prettyJSON(schemaBody)),
StateJSON: fmt.Sprintf("GET /v1/config/ui/state -> %d\n%s", stateCode, prettyJSON(stateBody)),
FaceGalleryJSON: fmt.Sprintf("GET /v1/face-gallery -> %d\n%s", faceCode, prettyJSON(faceBody)),
RawJSON: strings.TrimSpace(prettyJSON(stateBody)),
}
if schemaErr != nil {
data.Error = schemaErr.Error()
} else if stateErr != nil {
data.Error = stateErr.Error()
} else if faceErr != nil {
data.Error = faceErr.Error()
}
return data
}
func (u *UI) pageDeviceConfigUI(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
u.render(w, r, "config_ui", u.loadConfigUIData(dev))
}
func (u *UI) pageDeviceConfigFriendly(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
u.render(w, r, "config_friendly", PageData{Title: "友好配置", Device: dev})
}
func (u *UI) actionDeviceConfigUIPlan(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
_ = r.ParseForm()
raw := strings.TrimSpace(r.FormValue("json"))
if raw == "" {
raw = `{"instances":[]}`
}
if err := json.Unmarshal([]byte(raw), new(any)); err != nil {
data := u.loadConfigUIData(dev)
data.Error = "json 无效: " + err.Error()
data.RawJSON = raw
u.render(w, r, "config_ui", data)
return
}
body, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/config/ui/plan", []byte(raw))
data := u.loadConfigUIData(dev)
data.Message = fmt.Sprintf("POST /v1/config/ui/plan -> %d", code)
data.RawText = prettyJSON(body)
data.RawJSON = raw
if err != nil {
data.Error = err.Error()
}
u.render(w, r, "config_ui", data)
}
func (u *UI) actionDeviceConfigUIApply(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
_ = r.ParseForm()
raw := strings.TrimSpace(r.FormValue("json"))
if raw == "" {
raw = `{"instances":[]}`
}
if err := json.Unmarshal([]byte(raw), new(any)); err != nil {
data := u.loadConfigUIData(dev)
data.Error = "json 无效: " + err.Error()
data.RawJSON = raw
u.render(w, r, "config_ui", data)
return
}
body, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/config/ui/apply", []byte(raw))
data := u.loadConfigUIData(dev)
data.Message = fmt.Sprintf("POST /v1/config/ui/apply -> %d", code)
data.RawText = prettyJSON(body)
data.RawJSON = raw
if err != nil {
data.Error = err.Error()
}
u.render(w, r, "config_ui", data)
}
func (u *UI) actionDeviceFaceGalleryUpload(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
if err := r.ParseMultipartForm(500 << 20); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
file, hdr, err := r.FormFile("file")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer file.Close()
resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, "/v1/face-gallery", file, "application/octet-stream", hdr.Size)
data := u.loadConfigUIData(dev)
data.Message = fmt.Sprintf("PUT /v1/face-gallery -> %d", code)
data.RawText = prettyJSON(resp)
if derr != nil {
data.Error = derr.Error()
}
u.render(w, r, "config_ui", data)
}
func (u *UI) actionDeviceFaceGalleryReload(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
resp, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/face-gallery/reload", nil)
data := u.loadConfigUIData(dev)
data.Message = fmt.Sprintf("POST /v1/face-gallery/reload -> %d", code)
data.RawText = prettyJSON(resp)
if err != nil {
data.Error = err.Error()
}
u.render(w, r, "config_ui", data)
}

View File

@ -0,0 +1,500 @@
{{define "config_friendly"}}
<div class="card">
<h2>友好配置(实例化通道 / instances</h2>
<div class="muted small">设备:<a class="mono" href="/ui/devices/{{.Device.DeviceID}}">{{.Device.DeviceID}}</a>{{.Device.DeviceName}}),通过 agent <code class="mono">/v1/config/ui/*</code> 生成并应用配置。</div>
</div>
<div class="card" id="cfg-status">
<h2 id="cfg-status-title">加载中</h2>
<div class="muted small" id="cfg-status-msg">正在读取 schema/state…</div>
<div class="actions" id="cfg-help" style="display:none;margin-top:10px">
<a href="/ui/devices">去设备列表重新搜索</a>
<a href="/ui/devices/{{.Device.DeviceID}}/config-ui">打开 JSON 调试页</a>
</div>
</div>
<div class="card" id="cfg-editor" style="display:none">
<h2>通道列表</h2>
<div class="row" style="margin-top:10px; gap:16px">
<div style="flex:1">
<div class="muted small">新增 / 编辑通道</div>
<div class="row" style="margin-top:8px">
<div>
<div class="muted small">name</div>
<input id="inst-name" placeholder="cam1" />
</div>
<div>
<div class="muted small">template</div>
<select id="inst-template"></select>
</div>
</div>
<div style="margin-top:10px" id="inst-fields"></div>
<div class="actions" style="margin-top:10px">
<button id="btn-save">保存到列表</button>
<button id="btn-reset" type="button">清空表单</button>
</div>
<div class="muted small" style="margin-top:8px">提示:这里只生成 <code class="mono">instances[]</code>通道。global/queue 会沿用设备当前配置。</div>
</div>
<div style="flex:1">
<div class="muted small">当前通道(点击编辑)</div>
<div class="table-wrap" style="margin-top:8px">
<table id="inst-table" style="min-width:640px">
<thead>
<tr><th>name</th><th>template</th><th>操作</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="actions" style="margin-top:10px">
<button id="btn-plan" type="button">Plan预览 diff</button>
<button id="btn-apply" type="button">Apply写盘 + reload</button>
</div>
</div>
</div>
</div>
<div class="card" id="cfg-result" style="display:none">
<h2>结果</h2>
<pre id="result-pre"></pre>
</div>
<div class="card" id="cfg-json" style="display:none">
<h2>将要提交的 JSONconfig/ui/apply 请求体)</h2>
<pre id="payload-pre"></pre>
</div>
<script>
(() => {
const deviceId = {{printf "%q" .Device.DeviceID}};
const statusEl = document.getElementById('cfg-status');
const statusTitleEl = document.getElementById('cfg-status-title');
const statusMsgEl = document.getElementById('cfg-status-msg');
const helpEl = document.getElementById('cfg-help');
const editorEl = document.getElementById('cfg-editor');
const resultEl = document.getElementById('cfg-result');
const resultPre = document.getElementById('result-pre');
const payloadEl = document.getElementById('cfg-json');
const payloadPre = document.getElementById('payload-pre');
const nameEl = document.getElementById('inst-name');
const tplEl = document.getElementById('inst-template');
const fieldsEl = document.getElementById('inst-fields');
const tableBody = document.querySelector('#inst-table tbody');
const btnSave = document.getElementById('btn-save');
const btnReset = document.getElementById('btn-reset');
const btnPlan = document.getElementById('btn-plan');
const btnApply = document.getElementById('btn-apply');
const idFromPath = (() => {
try{
const parts = String(location.pathname || '').split('/');
// /ui/devices/{id}/config-friendly
if(parts.length > 3 && parts[1] === 'ui' && parts[2] === 'devices') return decodeURIComponent(parts[3] || '');
}catch(e){}
return '';
})();
const effectiveId = idFromPath || deviceId;
const apiBase = `/api/devices/${encodeURIComponent(effectiveId)}/v1`;
const PRD_FALLBACK = {
transcode_rtsp_hls: {
fields: [
{k:'url', t:'string', req:true, label:'RTSP URL'},
{k:'fps', t:'integer', def:30},
{k:'src_w', t:'integer', def:1280},
{k:'src_h', t:'integer', def:720},
{k:'gop', t:'integer', def:60},
{k:'bitrate_kbps', t:'integer', def:2000},
{k:'rtsp_port', t:'integer', def:8555},
{k:'hls_path', t:'string'}
]
},
yolo_rtsp_hls: {
fields: [
{k:'url', t:'string', req:true, label:'RTSP URL'},
{k:'model_path', t:'string', req:true},
{k:'fps', t:'integer', def:30},
{k:'src_w', t:'integer', def:1280},
{k:'src_h', t:'integer', def:720},
{k:'gop', t:'integer', def:60},
{k:'bitrate_kbps', t:'integer', def:2000},
{k:'rtsp_port', t:'integer', def:8555},
{k:'hls_path', t:'string'}
]
},
yolo_alarm_minio: {
fields: [
{k:'url', t:'string', req:true, label:'RTSP URL'},
{k:'model_path', t:'string', req:true},
{k:'minio_endpoint', t:'string', req:true},
{k:'minio_bucket', t:'string', req:true},
{k:'minio_ak', t:'string', req:true},
{k:'minio_sk', t:'string', req:true},
{k:'cooldown_ms', t:'integer', def:3000}
]
},
face_det_rtsp_hls: {
fields: [
{k:'url', t:'string', req:true, label:'RTSP URL'},
{k:'det_model_path', t:'string', req:true},
{k:'fps', t:'integer', def:30},
{k:'src_w', t:'integer', def:1280},
{k:'src_h', t:'integer', def:720},
{k:'gop', t:'integer', def:60},
{k:'bitrate_kbps', t:'integer', def:2000},
{k:'rtsp_port', t:'integer', def:8555},
{k:'hls_path', t:'string'}
]
},
face_det_recog_rtsp_hls: {
fields: [
{k:'url', t:'string', req:true, label:'RTSP URL'},
{k:'det_model_path', t:'string', req:true},
{k:'recog_model_path', t:'string', req:true},
{k:'gallery_path', t:'string', def:'./models/face_gallery.db'},
{k:'thr_accept', t:'number', def:0.45},
{k:'thr_margin', t:'number', def:0.05},
{k:'fps', t:'integer', def:30},
{k:'src_w', t:'integer', def:1280},
{k:'src_h', t:'integer', def:720},
{k:'gop', t:'integer', def:60},
{k:'bitrate_kbps', t:'integer', def:2000},
{k:'rtsp_port', t:'integer', def:8555},
{k:'hls_path', t:'string'}
]
}
};
let baseState = { global: undefined, queue: undefined, instances: [] };
let templateSchemas = {}; // name -> {properties, required}
const esc = (s) => String(s ?? '');
const pretty = (obj) => {
try { return JSON.stringify(obj, null, 2); } catch(e) { return String(obj); }
};
function setStatus(title, msg, showHelp){
statusEl.style.display = '';
statusTitleEl.textContent = esc(title);
statusMsgEl.textContent = esc(msg);
helpEl.style.display = showHelp ? '' : 'none';
}
function normalizeSchema(schema){
// Expect something like: {templates:[{name, schema:{type:'object', properties:{...}, required:[...]}}]}
// Fallback: {templates:["..."]}
const out = { templates: [], schemas: {} };
if(!schema || typeof schema !== 'object') return out;
const tpls = schema.templates;
if(Array.isArray(tpls)){
for(const t of tpls){
if(typeof t === 'string'){
out.templates.push(t);
continue;
}
if(t && typeof t === 'object'){
const name = t.name || t.template || t.id;
if(typeof name === 'string' && name){
out.templates.push(name);
const sch = t.schema || t.params_schema || t.paramsSchema;
if(sch && typeof sch === 'object') out.schemas[name] = sch;
}
}
}
}
// Sometimes schema might be a map: {template_schemas:{...}}
const m = schema.template_schemas || schema.templateSchemas || schema.schemas;
if(m && typeof m === 'object'){
for(const [k,v] of Object.entries(m)){
if(!out.templates.includes(k)) out.templates.push(k);
if(v && typeof v === 'object') out.schemas[k] = v;
}
}
if(!out.templates.length){
out.templates = Object.keys(PRD_FALLBACK);
}
return out;
}
function buildFieldList(tplName){
const sch = templateSchemas[tplName];
if(sch && sch.type === 'object' && sch.properties && typeof sch.properties === 'object'){
const req = Array.isArray(sch.required) ? new Set(sch.required) : new Set();
const fields = [];
for(const [k,prop] of Object.entries(sch.properties)){
const t = (prop && typeof prop.type === 'string') ? prop.type : 'string';
fields.push({k, t, req: req.has(k), def: prop.default, enum: prop.enum, desc: prop.description || prop.title});
}
return fields;
}
const fb = PRD_FALLBACK[tplName];
return fb ? fb.fields : [];
}
function inputForField(f, value){
const id = `f_${f.k}`;
const label = f.label || f.k;
const req = f.req ? '<span class="muted small">(必填)</span>' : '';
const desc = f.desc ? `<div class="muted small" style="margin-top:6px">${esc(f.desc)}</div>` : '';
if(Array.isArray(f.enum) && f.enum.length){
const opts = f.enum.map(v => `<option value=${JSON.stringify(String(v))}>${esc(v)}</option>`).join('');
return `<div class="card" style="margin:10px 0;padding:10px 12px">
<div class="muted small">${esc(label)} ${req}</div>
<select id="${id}">${opts}</select>
${desc}
</div>`;
}
const type = (f.t === 'integer' || f.t === 'number') ? 'number' : 'text';
const step = (f.t === 'number') ? 'any' : '1';
const v = (value !== undefined && value !== null) ? value : (f.def !== undefined ? f.def : '');
return `<div class="card" style="margin:10px 0;padding:10px 12px">
<div class="muted small">${esc(label)} ${req}</div>
<input id="${id}" type="${type}" step="${step}" value=${JSON.stringify(String(v))} />
${desc}
</div>`;
}
function collectParams(tplName){
const fields = buildFieldList(tplName);
const params = {};
for(const f of fields){
const el = document.getElementById(`f_${f.k}`);
if(!el) continue;
let v = el.value;
if(v === '' || v === null || v === undefined){
if(f.req) throw new Error(`缺少必填字段: ${f.k}`);
continue;
}
if(f.t === 'integer'){
const n = Number(v);
if(!Number.isFinite(n)) throw new Error(`字段 ${f.k} 不是有效整数`);
params[f.k] = Math.trunc(n);
}else if(f.t === 'number'){
const n = Number(v);
if(!Number.isFinite(n)) throw new Error(`字段 ${f.k} 不是有效数字`);
params[f.k] = n;
}else{
params[f.k] = String(v);
}
}
return params;
}
function renderFields(tplName, params){
const fields = buildFieldList(tplName);
if(!fields.length){
fieldsEl.innerHTML = '<div class="muted small">(该模板没有可渲染字段;如需支持,请补充 schema 或在 PRD_FALLBACK 中补字段)</div>';
return;
}
fieldsEl.innerHTML = fields.map(f => inputForField(f, params ? params[f.k] : undefined)).join('');
// Auto fill hls_path if present
const hls = document.getElementById('f_hls_path');
if(hls && !hls.value){
const nm = (nameEl.value || '').trim();
if(nm) hls.value = `./web/hls/${nm}/index.m3u8`;
}
}
function renderTable(){
tableBody.innerHTML = '';
for(const inst of baseState.instances){
const tr = document.createElement('tr');
tr.innerHTML = `<td class="mono">${esc(inst.name)}</td><td class="mono">${esc(inst.template)}</td><td></td>`;
const td = tr.querySelector('td:last-child');
const btnEdit = document.createElement('button');
btnEdit.textContent = '编辑';
btnEdit.type = 'button';
btnEdit.addEventListener('click', () => {
nameEl.value = inst.name || '';
tplEl.value = inst.template || '';
renderFields(tplEl.value, inst.params || {});
});
const btnDel = document.createElement('button');
btnDel.textContent = '删除';
btnDel.type = 'button';
btnDel.addEventListener('click', () => {
if(!confirm(`确认删除通道 ${inst.name} ?`)) return;
baseState.instances = baseState.instances.filter(x => x.name !== inst.name);
renderTable();
renderPayload();
});
td.appendChild(btnEdit);
td.appendChild(document.createTextNode(' '));
td.appendChild(btnDel);
tableBody.appendChild(tr);
}
}
function buildPayload(){
const p = { instances: baseState.instances };
if(baseState.global !== undefined) p.global = baseState.global;
if(baseState.queue !== undefined) p.queue = baseState.queue;
return p;
}
function renderPayload(){
payloadEl.style.display = '';
payloadPre.textContent = pretty(buildPayload());
}
async function postJson(path, obj){
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(obj)
});
const txt = await res.text();
let j;
try { j = JSON.parse(txt); } catch(e) {}
if(!res.ok){
const msg = j && j.error ? j.error : txt;
throw new Error(`HTTP ${res.status}: ${msg}`);
}
return j || txt;
}
async function load(){
setStatus('加载中', '正在读取 schema/state…');
let schema, state;
try{
const schemaUrl = `${apiBase}/config/ui/schema`;
const stateUrl = `${apiBase}/config/ui/state`;
const [s1, s2] = await Promise.all([fetch(schemaUrl), fetch(stateUrl)]);
const t1 = await s1.text();
const t2 = await s2.text();
schema = (() => { try { return JSON.parse(t1); } catch(e) { return null; } })();
state = (() => { try { return JSON.parse(t2); } catch(e) { return null; } })();
if(!s1.ok){
const extra = `\n\n请求GET ${schemaUrl}\n设备ID(页面): ${deviceId}\n设备ID(URL): ${idFromPath || '-'}`;
const hint = (String(t1).includes('device not found') || String(t1).includes('DEVICE_NOT_FOUND'))
? `\n\n提示managerd 的设备列表在内存里;如果刚重启 managerd需要先到“设备”页点一次“搜索UDP 广播)”。`
: '';
throw new Error(`schema HTTP ${s1.status}: ${t1}${extra}${hint}`);
}
if(!s2.ok){
const extra = `\n\n请求GET ${stateUrl}\n设备ID(页面): ${deviceId}\n设备ID(URL): ${idFromPath || '-'}`;
throw new Error(`state HTTP ${s2.status}: ${t2}${extra}`);
}
}catch(e){
setStatus('加载失败', String(e), true);
return;
}
if(schema && state){
const norm = normalizeSchema(schema);
templateSchemas = norm.schemas || {};
tplEl.innerHTML = '';
for(const n of norm.templates){
const opt = document.createElement('option');
opt.value = n;
opt.textContent = n;
tplEl.appendChild(opt);
}
baseState = {
global: state && typeof state.global === 'object' ? state.global : undefined,
queue: state && typeof state.queue === 'object' ? state.queue : undefined,
instances: Array.isArray(state && state.instances) ? state.instances.map(x => ({
name: x.name,
template: x.template,
params: (x.params && typeof x.params === 'object') ? x.params : {}
})) : []
};
statusEl.style.display = 'none';
editorEl.style.display = '';
renderTable();
tplEl.value = norm.templates[0] || '';
renderFields(tplEl.value, {});
renderPayload();
return;
}
}
tplEl.addEventListener('change', () => renderFields(tplEl.value, {}));
nameEl.addEventListener('input', () => {
const hls = document.getElementById('f_hls_path');
if(hls && (!hls.value || hls.value.includes('/hls/'))) {
const nm = (nameEl.value || '').trim();
if(nm) hls.value = `./web/hls/${nm}/index.m3u8`;
}
});
btnReset.addEventListener('click', () => {
nameEl.value = '';
renderFields(tplEl.value, {});
});
btnSave.addEventListener('click', (ev) => {
ev.preventDefault();
resultEl.style.display = 'none';
try{
const name = (nameEl.value || '').trim();
if(!name) throw new Error('name 不能为空');
const tplName = tplEl.value;
if(!tplName) throw new Error('template 不能为空');
const params = collectParams(tplName);
const next = { name, template: tplName, params };
const idx = baseState.instances.findIndex(x => x.name === name);
if(idx >= 0) baseState.instances[idx] = next;
else baseState.instances.push(next);
baseState.instances.sort((a,b) => String(a.name).localeCompare(String(b.name)));
renderTable();
renderPayload();
setStatus('已更新', `已保存到列表:${name}`);
statusEl.style.display = '';
setTimeout(() => { statusEl.style.display = 'none'; }, 1200);
}catch(e){
setStatus('校验失败', String(e));
statusEl.style.display = '';
}
});
btnPlan.addEventListener('click', async () => {
resultEl.style.display = 'none';
try{
const payload = buildPayload();
renderPayload();
const out = await postJson(`${apiBase}/config/ui/plan`, payload);
resultEl.style.display = '';
resultPre.textContent = pretty(out);
}catch(e){
resultEl.style.display = '';
resultPre.textContent = String(e);
}
});
btnApply.addEventListener('click', async () => {
resultEl.style.display = 'none';
if(!confirm('确认 Apply将写盘并触发 media-server reload失败会自动 rollback')) return;
try{
const payload = buildPayload();
renderPayload();
const out = await postJson(`${apiBase}/config/ui/apply`, payload);
resultEl.style.display = '';
resultPre.textContent = pretty(out);
}catch(e){
resultEl.style.display = '';
resultPre.textContent = String(e);
}
});
load();
})();
</script>
{{end}}

View File

@ -0,0 +1,58 @@
{{define "config_ui"}}
<div class="card">
<h2>通道配置instances</h2>
<div class="muted small">设备:<a class="mono" href="/ui/devices/{{.Device.DeviceID}}">{{.Device.DeviceID}}</a>{{.Device.DeviceName}}</div>
</div>
<div class="card">
<h2>Schema / Current State</h2>
<div class="muted small">来自 agent<code class="mono">GET /v1/config/ui/schema</code><code class="mono">GET /v1/config/ui/state</code></div>
<div class="row" style="margin-top:10px; gap:16px">
<div style="flex:1">
<div class="muted small">schema</div>
<pre>{{.SchemaJSON}}</pre>
</div>
<div style="flex:1">
<div class="muted small">state</div>
<pre>{{.StateJSON}}</pre>
</div>
</div>
</div>
<div class="card">
<h2>编辑 desired state</h2>
<div class="muted small">提交到 agent<code class="mono">POST /v1/config/ui/plan</code><code class="mono">POST /v1/config/ui/apply</code></div>
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/config-ui/plan">
<textarea name="json" spellcheck="false">{{if .RawJSON}}{{.RawJSON}}{{else}}{"instances":[]}{{end}}</textarea>
<div class="actions" style="margin-top:10px">
<button type="submit">Plandry-run</button>
<button type="submit" formaction="/ui/devices/{{.Device.DeviceID}}/config-ui/apply">Apply写盘+reload</button>
</div>
</form>
</div>
<div class="card">
<h2>人脸库face_gallery.db</h2>
<div class="muted small">上传:<code class="mono">PUT /v1/face-gallery</code>;热加载:<code class="mono">POST /v1/face-gallery/reload</code></div>
<pre>{{.FaceGalleryJSON}}</pre>
<div class="row" style="margin-top:10px; gap:16px">
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/face-gallery/upload" enctype="multipart/form-data" class="row">
<div>
<div class="muted small">file</div>
<input type="file" name="file" />
</div>
<div style="align-self:end"><button type="submit">上传 db</button></div>
</form>
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/face-gallery/reload" style="align-self:end">
<button type="submit">热加载</button>
</form>
</div>
</div>
{{if .RawText}}
<div class="card">
<h2>最近响应</h2>
<pre>{{.RawText}}</pre>
</div>
{{end}}
{{end}}

View File

@ -27,7 +27,9 @@
<div class="muted small">快速查看</div>
<div style="margin-top:6px">
<a href="/ui/devices/{{.Device.DeviceID}}/graphs">图表</a> |
<a href="/ui/devices/{{.Device.DeviceID}}/logs?limit=200">日志</a>
<a href="/ui/devices/{{.Device.DeviceID}}/logs?limit=200">日志</a> |
<a href="/ui/devices/{{.Device.DeviceID}}/config-friendly">友好配置</a> |
<a href="/ui/devices/{{.Device.DeviceID}}/config-ui">JSON 调试</a>
</div>
</div>
</div>