Show device config status in admin UI

This commit is contained in:
tian 2026-04-19 10:53:07 +08:00
parent 8c80f3a1a5
commit c2729d90c9
6 changed files with 252 additions and 6 deletions

View File

@ -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)

View File

@ -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"

View File

@ -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())
}
}

View File

@ -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)

View File

@ -35,6 +35,68 @@
</div>
</div>
<div class="card">
<div class="section-title">
<div>
<h2>当前运行配置</h2>
<div class="muted small">来自 agent 的 /v1/config/status用于确认设备当前加载的配置版本。</div>
</div>
{{if .ConfigStatus}}
{{if .ConfigStatus.OK}}<span class="pill ok">已读取</span>{{else}}<span class="pill bad">状态异常</span>{{end}}
{{else if .ConfigStatusErr}}
<span class="pill bad">读取失败</span>
{{else}}
<span class="pill">未连接</span>
{{end}}
</div>
{{if .ConfigStatus}}
<div class="row" style="margin-top:10px">
<div>
<div class="muted small">配置 ID</div>
<div class="mono">{{if .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else}}未标记{{end}}</div>
<div class="muted small" style="margin-top:6px">版本:<span class="mono">{{if .ConfigStatus.Metadata.ConfigVersion}}{{.ConfigStatus.Metadata.ConfigVersion}}{{else}}未标记{{end}}</span></div>
</div>
<div>
<div class="muted small">模板 / Profile</div>
<div class="mono">{{if .ConfigStatus.Metadata.Template}}{{.ConfigStatus.Metadata.Template}}{{else}}-{{end}}</div>
<div class="muted small mono" style="margin-top:6px">{{if .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}</div>
</div>
<div>
<div class="muted small">Overlay</div>
<div class="mono">{{if .ConfigStatus.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</div>
<div class="muted small mono" style="margin-top:6px">{{.ConfigStatus.Metadata.RenderedBy}}</div>
</div>
<div>
<div class="muted small">配置路径</div>
<div class="mono">{{.ConfigStatus.ConfigPath}}</div>
<div class="muted small mono" style="margin-top:6px">{{if .ConfigStatus.Exists}}文件存在{{else}}文件不存在{{end}}</div>
</div>
<div>
<div class="muted small">视频服务</div>
<div>{{if .ConfigStatus.MediaServer.Running}}<span class="pill ok">运行中</span>{{else}}<span class="pill bad">未运行</span>{{end}}</div>
{{if .ConfigStatus.MediaServer.PID}}<div class="muted small mono" style="margin-top:6px">pid: {{.ConfigStatus.MediaServer.PID}}</div>{{end}}
</div>
</div>
<div style="margin-top:10px">
<div class="muted small">SHA256</div>
<div class="mono small">{{.ConfigStatus.Sha256}}</div>
</div>
{{if .ConfigStatus.Metadata.RenderedAt}}
<div class="muted small" style="margin-top:8px">生成时间:<span class="mono">{{.ConfigStatus.Metadata.RenderedAt}}</span></div>
{{end}}
{{else if .ConfigStatusErr}}
<div class="muted small" style="margin-top:10px">暂时无法读取设备配置状态:{{.ConfigStatusErr}}</div>
{{else}}
<div class="muted small" style="margin-top:10px">未配置 agent 客户端时不会请求设备。</div>
{{end}}
{{if .ConfigStatusText}}
<details style="margin-top:10px">
<summary class="muted small">原始响应</summary>
<pre>{{.ConfigStatusText}}</pre>
</details>
{{end}}
</div>
<div class="card">
<h2 id="overview">只读详情</h2>
<div class="muted small">此页面只用于查看设备身份、在线状态、地址、版本和诊断入口。修改配置或控制服务请进入配置管理。</div>

View File

@ -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}