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 @@ +
{{.ConfigStatusText}}
+