From c2729d90c9a4a853c2d02b3b8348f197e226e707 Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Sun, 19 Apr 2026 10:53:07 +0800 Subject: [PATCH] Show device config status in admin UI --- cmd/managerd/main.go | 1 + internal/api/handlers.go | 2 + internal/api/handlers_test.go | 56 ++++++++++++++++++-- internal/web/ui.go | 63 ++++++++++++++++++++++- internal/web/ui/templates/device.html | 62 ++++++++++++++++++++++ internal/web/ui_test.go | 74 +++++++++++++++++++++++++++ 6 files changed, 252 insertions(+), 6 deletions(-) diff --git a/cmd/managerd/main.go b/cmd/managerd/main.go index 91473c4..334ec64 100644 --- a/cmd/managerd/main.go +++ b/cmd/managerd/main.go @@ -72,6 +72,7 @@ func main() { // Proxy routes for device actions r.Get("/devices/{id}/info", h.ProxyAgent) + r.Get("/devices/{id}/config/status", h.ProxyAgent) r.Post("/devices/{id}/reload", h.ProxyAgent) r.Post("/devices/{id}/rollback", h.ProxyAgent) r.Get("/devices/{id}/graphs", h.ProxyAgent) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 91b4626..b78977f 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -127,6 +127,8 @@ func (h *Handler) ProxyAgent(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == fmt.Sprintf("/api/devices/%s/info", id): agentPath = "/v1/info" + case r.URL.Path == fmt.Sprintf("/api/devices/%s/config/status", id): + agentPath = "/v1/config/status" case r.URL.Path == fmt.Sprintf("/api/devices/%s/reload", id): agentPath = "/v1/media-server/reload" method = "POST" diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go index 45662d7..8025579 100644 --- a/internal/api/handlers_test.go +++ b/internal/api/handlers_test.go @@ -6,9 +6,14 @@ import ( "3588AdminBackend/internal/service" "bytes" "encoding/json" + "net" "net/http" "net/http/httptest" + "strconv" + "strings" "testing" + + "github.com/go-chi/chi/v5" ) func TestHandler_ListDevices(t *testing.T) { @@ -21,7 +26,7 @@ func TestHandler_ListDevices(t *testing.T) { req, _ := http.NewRequest("GET", "/api/devices", nil) rr := httptest.NewRecorder() - + h.ListDevices(rr, req) if rr.Code != http.StatusOK { @@ -41,19 +46,19 @@ func TestHandler_CreateTask(t *testing.T) { agent := service.NewAgentClient(cfg) reg := service.NewRegistryService(cfg, agent) tasks := service.NewTaskService(cfg, agent, reg) - + h := NewHandler(nil, reg, agent, tasks, nil) body := map[string]interface{}{ - "type": "config_apply", + "type": "config_apply", "device_ids": []string{"dev1"}, - "payload": map[string]string{"foo": "bar"}, + "payload": map[string]string{"foo": "bar"}, } b, _ := json.Marshal(body) req, _ := http.NewRequest("POST", "/api/tasks", bytes.NewBuffer(b)) rr := httptest.NewRecorder() - + h.CreateTask(rr, req) if rr.Code != http.StatusOK { @@ -97,3 +102,44 @@ func TestHandler_ListTasks(t *testing.T) { t.Errorf("expected at least 1 item, got %d", len(resp["items"])) } } + +func TestHandler_ProxyAgentMapsConfigStatus(t *testing.T) { + agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/v1/config/status" { + t.Fatalf("expected /v1/config/status, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"metadata":{"config_id":"local_3588_face_debug"}}`)) + })) + defer agentServer.Close() + + host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) + if err != nil { + t.Fatalf("parse test server address: %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } + + cfg := &config.Config{} + agent := service.NewAgentClient(cfg) + reg := service.NewRegistryService(cfg, agent) + reg.UpdateDevice(&models.Device{DeviceID: "edge-01", IP: host, AgentPort: port}) + h := NewHandler(nil, reg, agent, nil, nil) + + r := chi.NewRouter() + r.Get("/api/devices/{id}/config/status", h.ProxyAgent) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/devices/edge-01/config/status", nil)) + + if rr.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "local_3588_face_debug") { + t.Fatalf("expected config status response, got %s", rr.Body.String()) + } +} diff --git a/internal/web/ui.go b/internal/web/ui.go index b2f199c..3235de5 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -43,6 +43,9 @@ type PageData struct { AttentionDevices []*models.Device Found []*models.Device Device *models.Device + ConfigStatus *ConfigStatusView + ConfigStatusText string + ConfigStatusErr string Tasks []models.Task Task *models.Task Templates []service.Template @@ -60,6 +63,40 @@ type PageData struct { SuccessTaskCount int } +type ConfigStatusView struct { + OK bool `json:"ok"` + ConfigPath string `json:"config_path"` + Exists bool `json:"exists"` + Sha256 string `json:"sha256"` + Size int64 `json:"size"` + Metadata ConfigStatusMetadata `json:"metadata"` + MediaServer ConfigStatusMediaServer `json:"media_server"` + LastGood *ConfigStatusLastGoodFile `json:"last_good"` +} + +type ConfigStatusMetadata struct { + ConfigID string `json:"config_id"` + ConfigVersion string `json:"config_version"` + Template string `json:"template"` + Profile string `json:"profile"` + Overlays []string `json:"overlays"` + RenderedAt string `json:"rendered_at"` + RenderedBy string `json:"rendered_by"` +} + +type ConfigStatusMediaServer struct { + Running bool `json:"running"` + PID int `json:"pid"` + Version string `json:"version"` +} + +type ConfigStatusLastGoodFile struct { + Path string `json:"path"` + Exists bool `json:"exists"` + Sha256 string `json:"sha256"` + Metadata ConfigStatusMetadata `json:"metadata"` +} + func NewUI(discovery *service.DiscoveryService, registry *service.RegistryService, agent *service.AgentClient, tasks *service.TaskService, templates *service.TemplateService) (*UI, error) { tpl, err := template.New("layout").Funcs(template.FuncMap{ "json": func(v any) string { @@ -373,7 +410,12 @@ func (u *UI) pageDevice(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - u.render(w, r, "device", PageData{Title: "节点详情", Device: dev}) + status, raw, statusErr := u.loadConfigStatus(dev) + data := PageData{Title: "节点详情", Device: dev, ConfigStatus: status, ConfigStatusText: raw} + if statusErr != nil { + data.ConfigStatusErr = statusErr.Error() + } + u.render(w, r, "device", data) } func (u *UI) actionDeviceAction(w http.ResponseWriter, r *http.Request) { @@ -725,6 +767,25 @@ func prettyJSON(raw []byte) string { return out.String() } +func (u *UI) loadConfigStatus(dev *models.Device) (*ConfigStatusView, string, error) { + if u.agent == nil || dev == nil { + return nil, "", nil + } + body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/status", nil) + raw := fmt.Sprintf("GET /v1/config/status -> %d\n%s", code, prettyJSON(body)) + if err != nil { + return nil, raw, err + } + if code < 200 || code >= 300 { + return nil, raw, fmt.Errorf("GET /v1/config/status -> %d", code) + } + var status ConfigStatusView + if err := json.Unmarshal(body, &status); err != nil { + return nil, raw, err + } + return &status, raw, nil +} + 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) diff --git a/internal/web/ui/templates/device.html b/internal/web/ui/templates/device.html index dc7ef26..b356130 100644 --- a/internal/web/ui/templates/device.html +++ b/internal/web/ui/templates/device.html @@ -35,6 +35,68 @@ +
+
+
+

当前运行配置

+
来自 agent 的 /v1/config/status,用于确认设备当前加载的配置版本。
+
+ {{if .ConfigStatus}} + {{if .ConfigStatus.OK}}已读取{{else}}状态异常{{end}} + {{else if .ConfigStatusErr}} + 读取失败 + {{else}} + 未连接 + {{end}} +
+ {{if .ConfigStatus}} +
+
+
配置 ID
+
{{if .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else}}未标记{{end}}
+
版本:{{if .ConfigStatus.Metadata.ConfigVersion}}{{.ConfigStatus.Metadata.ConfigVersion}}{{else}}未标记{{end}}
+
+
+
模板 / Profile
+
{{if .ConfigStatus.Metadata.Template}}{{.ConfigStatus.Metadata.Template}}{{else}}-{{end}}
+
{{if .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}
+
+
+
Overlay
+
{{if .ConfigStatus.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}
+
{{.ConfigStatus.Metadata.RenderedBy}}
+
+
+
配置路径
+
{{.ConfigStatus.ConfigPath}}
+
{{if .ConfigStatus.Exists}}文件存在{{else}}文件不存在{{end}}
+
+
+
视频服务
+
{{if .ConfigStatus.MediaServer.Running}}运行中{{else}}未运行{{end}}
+ {{if .ConfigStatus.MediaServer.PID}}
pid: {{.ConfigStatus.MediaServer.PID}}
{{end}} +
+
+
+
SHA256
+
{{.ConfigStatus.Sha256}}
+
+ {{if .ConfigStatus.Metadata.RenderedAt}} +
生成时间:{{.ConfigStatus.Metadata.RenderedAt}}
+ {{end}} + {{else if .ConfigStatusErr}} +
暂时无法读取设备配置状态:{{.ConfigStatusErr}}
+ {{else}} +
未配置 agent 客户端时不会请求设备。
+ {{end}} + {{if .ConfigStatusText}} +
+ 原始响应 +
{{.ConfigStatusText}}
+
+ {{end}} +
+

只读详情

此页面只用于查看设备身份、在线状态、地址、版本和诊断入口。修改配置或控制服务请进入配置管理。
diff --git a/internal/web/ui_test.go b/internal/web/ui_test.go index 04777b6..a12eec2 100644 --- a/internal/web/ui_test.go +++ b/internal/web/ui_test.go @@ -4,10 +4,12 @@ import ( "3588AdminBackend/internal/config" "3588AdminBackend/internal/models" "3588AdminBackend/internal/service" + "net" "net/http" "net/http/httptest" "net/url" "os" + "strconv" "strings" "testing" ) @@ -198,6 +200,78 @@ func TestUI_DeviceDetailIsReadOnly(t *testing.T) { } } +func TestUI_DeviceDetailShowsRunningConfigMetadata(t *testing.T) { + agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/config/status" { + t.Fatalf("unexpected agent path %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "ok": true, + "config_path": "/opt/rk3588-media-server/etc/media-server.json", + "exists": true, + "sha256": "8d935c9d366637ff853c69d25e9c6644c2fbff3b8d3aa15ff99ef32847fb947c", + "metadata": { + "config_id": "local_3588_face_debug", + "config_version": "20260419.104457", + "template": "workshop_face_shoe_alarm", + "profile": "local_3588_test", + "overlays": ["face_debug"], + "rendered_by": "tools/render_config.py", + "rendered_at": "2026-04-19T10:44:58+08:00" + }, + "media_server": {"running": true, "pid": 1706009} + }`)) + })) + defer agentServer.Close() + + host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) + if err != nil { + t.Fatalf("parse test server address: %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } + + cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} + agent := service.NewAgentClient(cfg) + reg := service.NewRegistryService(cfg, agent) + reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true, Version: "1.0.0"}) + tasks := service.NewTaskService(cfg, agent, reg) + ui, err := NewUI(nil, reg, agent, tasks, nil) + if err != nil { + t.Fatalf("NewUI: %v", err) + } + routes, err := ui.Routes() + if err != nil { + t.Fatalf("Routes: %v", err) + } + rr := httptest.NewRecorder() + + routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01", nil)) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, want := range []string{ + "当前运行配置", + "local_3588_face_debug", + "20260419.104457", + "workshop_face_shoe_alarm", + "local_3588_test", + "face_debug", + "8d935c9d366637ff853c69d25e9c6644c2fbff3b8d3aa15ff99ef32847fb947c", + "/opt/rk3588-media-server/etc/media-server.json", + "运行中", + } { + if !strings.Contains(body, want) { + t.Fatalf("expected device detail HTML to contain %q, got:\n%s", want, body) + } + } +} + func TestUI_DeviceSubpagesIncludeContextNavigation(t *testing.T) { ui := newTestUI(t) dev := &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true}