Show device config status in admin UI
This commit is contained in:
parent
8c80f3a1a5
commit
c2729d90c9
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user