update,可以恢复的版本
This commit is contained in:
parent
6148498f0d
commit
3e4373ef25
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"}}}}},
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
500
internal/web/ui/templates/config_friendly.html
Normal file
500
internal/web/ui/templates/config_friendly.html
Normal 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>将要提交的 JSON(config/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}}
|
||||
58
internal/web/ui/templates/config_ui.html
Normal file
58
internal/web/ui/templates/config_ui.html
Normal 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">Plan(dry-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}}
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user