From 3e4373ef25159bece7f823f04b79f276fe5d1d9c Mon Sep 17 00:00:00 2001 From: sladro Date: Sat, 10 Jan 2026 22:56:31 +0800 Subject: [PATCH] =?UTF-8?q?update=EF=BC=8C=E5=8F=AF=E4=BB=A5=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E7=9A=84=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/managerd/main.go | 5 +- internal/api/handlers.go | 47 +- internal/api/openapi.go | 66 +++ internal/service/agent_client.go | 76 ++- internal/web/ui.go | 186 ++++++- .../web/ui/templates/config_friendly.html | 500 ++++++++++++++++++ internal/web/ui/templates/config_ui.html | 58 ++ internal/web/ui/templates/device.html | 4 +- 8 files changed, 902 insertions(+), 40 deletions(-) create mode 100644 internal/web/ui/templates/config_friendly.html create mode 100644 internal/web/ui/templates/config_ui.html diff --git a/cmd/managerd/main.go b/cmd/managerd/main.go index 41868ad..01c6c95 100644 --- a/cmd/managerd/main.go +++ b/cmd/managerd/main.go @@ -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) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 94de8f3..05f9bb3 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -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 diff --git a/internal/api/openapi.go b/internal/api/openapi.go index 0305464..b915197 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -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"}}}}}, diff --git a/internal/service/agent_client.go b/internal/service/agent_client.go index b0ca639..2f4b64d 100644 --- a/internal/service/agent_client.go +++ b/internal/service/agent_client.go @@ -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 +} diff --git a/internal/web/ui.go b/internal/web/ui.go index b310b1f..3f2902e 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -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) +} diff --git a/internal/web/ui/templates/config_friendly.html b/internal/web/ui/templates/config_friendly.html new file mode 100644 index 0000000..f70ea34 --- /dev/null +++ b/internal/web/ui/templates/config_friendly.html @@ -0,0 +1,500 @@ +{{define "config_friendly"}} +
+

友好配置(实例化通道 / instances)

+
设备:{{.Device.DeviceID}}({{.Device.DeviceName}}),通过 agent /v1/config/ui/* 生成并应用配置。
+
+ +
+

加载中

+
正在读取 schema/state…
+ +
+ + + + + + + + +{{end}} diff --git a/internal/web/ui/templates/config_ui.html b/internal/web/ui/templates/config_ui.html new file mode 100644 index 0000000..4e1fe76 --- /dev/null +++ b/internal/web/ui/templates/config_ui.html @@ -0,0 +1,58 @@ +{{define "config_ui"}} +
+

通道配置(instances)

+
设备:{{.Device.DeviceID}}({{.Device.DeviceName}})
+
+ +
+

Schema / Current State

+
来自 agent:GET /v1/config/ui/schemaGET /v1/config/ui/state
+
+
+
schema
+
{{.SchemaJSON}}
+
+
+
state
+
{{.StateJSON}}
+
+
+
+ +
+

编辑 desired state

+
提交到 agent:POST /v1/config/ui/planPOST /v1/config/ui/apply
+
+ +
+ + +
+
+
+ +
+

人脸库(face_gallery.db)

+
上传:PUT /v1/face-gallery;热加载:POST /v1/face-gallery/reload
+
{{.FaceGalleryJSON}}
+
+
+
+
file
+ +
+
+
+
+ +
+
+
+ +{{if .RawText}} +
+

最近响应

+
{{.RawText}}
+
+{{end}} +{{end}} diff --git a/internal/web/ui/templates/device.html b/internal/web/ui/templates/device.html index f18bd25..4e3829c 100644 --- a/internal/web/ui/templates/device.html +++ b/internal/web/ui/templates/device.html @@ -27,7 +27,9 @@
快速查看
图表 | - 日志 + 日志 | + 友好配置 | + JSON 调试