3588AdminBackend/internal/web/ui_test.go

829 lines
29 KiB
Go

package web
import (
"3588AdminBackend/internal/config"
"3588AdminBackend/internal/models"
"3588AdminBackend/internal/service"
"context"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strconv"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
func TestUI_ActionDevicesBatchAction_RedirectsToTask(t *testing.T) {
cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
reg := service.NewRegistryService(cfg, nil)
reg.UpdateDevice(&models.Device{DeviceID: "dev1", IP: "127.0.0.1", AgentPort: 9100, Online: true})
reg.UpdateDevice(&models.Device{DeviceID: "dev2", IP: "127.0.0.1", AgentPort: 9100, Online: true})
tasks := service.NewTaskService(cfg, nil, reg)
ui, err := NewUI(nil, reg, nil, tasks, nil)
if err != nil {
t.Fatalf("NewUI: %v", err)
}
form := url.Values{}
form.Set("action", "media_start")
form.Add("device_id", "dev1")
form.Add("device_id", "dev2")
form.Set("config", "cam1")
req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-action", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
ui.actionDevicesBatchAction(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected 302, got %d: %s", rr.Code, rr.Body.String())
}
loc := rr.Header().Get("Location")
if !strings.HasPrefix(loc, "/ui/tasks/") {
t.Fatalf("expected redirect to /ui/tasks/*, got %q", loc)
}
}
func TestUI_DevicePageUsesEdgeVisionConsoleShell(t *testing.T) {
cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
reg := service.NewRegistryService(cfg, nil)
reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true})
tasks := service.NewTaskService(cfg, nil, reg)
ui, err := NewUI(nil, reg, nil, tasks, nil)
if err != nil {
t.Fatalf("NewUI: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil)
rr := httptest.NewRecorder()
ui.pageDevices(rr, req)
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{
"视觉识别边缘控制台",
"设备列表",
"识别配置",
"模型管理",
"高级调试",
"<h1>设备列表</h1>",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected rendered HTML to contain %q, got:\n%s", want, body)
}
}
}
func TestUI_ConsoleTypographyStaysModerate(t *testing.T) {
css, err := os.ReadFile("ui/assets/style.css")
if err != nil {
t.Fatalf("read stylesheet: %v", err)
}
text := string(css)
for _, want := range []string{
".brand-title{font-weight:600",
".nav-section{font-size:11px",
".side-nav a{color:#dce6f1;display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:8px;font-size:13px;font-weight:500",
".topbar h1{margin:3px 0 0;font-size:18px;letter-spacing:0;font-weight:600",
".crumb{font-size:11px",
"input,select,textarea,button,.btn{background:#fff;color:var(--text);border:1px solid var(--border2);border-radius:7px;padding:8px 10px;outline:none;font:inherit;font-size:13px",
"button,.btn{cursor:pointer;font-size:13px;font-weight:600",
"th{color:#40546a;font-size:13px;font-weight:600",
} {
if !strings.Contains(text, want) {
t.Fatalf("expected stylesheet to contain %q", want)
}
}
}
func newTestUI(t *testing.T) *UI {
t.Helper()
cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
reg := service.NewRegistryService(cfg, nil)
reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true, Version: "1.0.0"})
tasks := service.NewTaskService(cfg, nil, reg)
ui, err := NewUI(nil, reg, nil, tasks, nil)
if err != nil {
t.Fatalf("NewUI: %v", err)
}
return ui
}
func TestUI_DashboardRendersFleetOverview(t *testing.T) {
ui := newTestUI(t)
req := httptest.NewRequest(http.MethodGet, "/ui/dashboard", nil)
rr := httptest.NewRecorder()
ui.pageDashboard(rr, req)
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{"总览", "视觉节点态势", "视频分析概览", "最近任务", "快速入口", "执行中任务", "失败任务", "需要关注的节点", "运维工作流"} {
if !strings.Contains(body, want) {
t.Fatalf("expected dashboard HTML to contain %q, got:\n%s", want, body)
}
}
}
func TestUI_DevicePageIncludesDrawerAndBatchConfirm(t *testing.T) {
ui := newTestUI(t)
req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil)
rr := httptest.NewRecorder()
ui.pageDevices(rr, req)
body := rr.Body.String()
for _, want := range []string{"device-drawer", "batch-confirm-panel", "openDeviceDrawer", "confirmBatchAction"} {
if !strings.Contains(body, want) {
t.Fatalf("expected device page HTML to contain %q", want)
}
}
}
func TestUI_DeviceDetailIncludesTabs(t *testing.T) {
ui := newTestUI(t)
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))
body := rr.Body.String()
for _, want := range []string{"device-tabs", "返回设备列表", "节点详情", "概览", "识别配置", "模型管理", "诊断日志", "运行指标", "技术信息"} {
if !strings.Contains(body, want) {
t.Fatalf("expected device detail HTML to contain %q", want)
}
}
}
func TestUI_DeviceDetailIsReadOnly(t *testing.T) {
ui := newTestUI(t)
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))
body := rr.Body.String()
for _, want := range []string{"视觉识别节点详情", "只读详情", "配置管理", "技术信息", "诊断日志", "运行指标"} {
if !strings.Contains(body, want) {
t.Fatalf("expected read-only device detail HTML to contain %q", want)
}
}
for _, forbidden := range []string{
`method="post"`,
`type="file"`,
"部署到设备",
"启动视频分析",
"重启视频分析",
"停止视频分析",
"重载识别服务",
"回滚识别配置",
"上传视频分析配置",
} {
if strings.Contains(body, forbidden) {
t.Fatalf("device detail should be read-only and not contain %q", forbidden)
}
}
}
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}
for _, content := range []string{"device_logs", "device_graphs", "config_ui"} {
req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01", nil)
rr := httptest.NewRecorder()
ui.render(rr, req, content, PageData{Title: "节点子页面", Device: dev})
body := rr.Body.String()
for _, want := range []string{"device-tabs", "返回设备列表", "节点详情", "识别配置", "诊断日志", "运行指标"} {
if !strings.Contains(body, want) {
t.Fatalf("expected %s HTML to contain %q", content, want)
}
}
}
}
func TestUI_ConfigFriendlyIncludesWizard(t *testing.T) {
ui := newTestUI(t)
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/config-friendly", nil))
body := rr.Body.String()
for _, want := range []string{"config-wizard", "选择节点", "编辑视频通道", "预览变更", "部署结果", "data-wizard-section=\"channels\"", "下一步:编辑视频通道", "前往预览变更", "查看部署结果"} {
if !strings.Contains(body, want) {
t.Fatalf("expected config page HTML to contain %q", want)
}
}
}
func TestUI_ConfigPreviewPageShowsTemplateProfileOverlayForm(t *testing.T) {
ui := newTestUI(t)
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/config-preview", 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{
"配置预览",
"模板",
"Profile",
"Overlay",
"config_id",
"config_version",
"生成预览",
"workshop_face_shoe_alarm",
"local_3588_test",
"face_debug",
"上传为候选配置",
"应用候选配置",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected config preview page to contain %q, got:\n%s", want, body)
}
}
}
func TestUI_ConfigPreviewPageKeepsApplyActionAfterUploadResult(t *testing.T) {
ui := newTestUI(t)
req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01/config-preview", nil)
rr := httptest.NewRecorder()
ui.render(rr, req, "config_preview", PageData{
Title: "配置预览",
Device: &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100},
ConfigStatus: &ConfigStatusView{
Candidate: &ConfigStatusLastGoodFile{Exists: true, Path: "/opt/rk3588-media-server/etc/media-server.json.candidate.json"},
},
ConfigPreview: &service.ConfigPreviewResult{
JSON: `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v1"}}`,
Metadata: map[string]any{
"config_id": "preview_edge-01",
"config_version": "v1",
"template": "workshop_face_shoe_alarm",
"profile": "local_3588_test",
},
Size: 123,
},
RawText: `{"ok":true}`,
ResultTitle: "应用候选配置结果",
})
body := rr.Body.String()
for _, want := range []string{
"应用候选配置结果",
"上传为候选配置",
"应用候选配置",
`formaction="/ui/devices/edge-01/config-candidate/apply"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected config preview upload result HTML to contain %q, got:\n%s", want, body)
}
}
}
func TestUI_ActionDeviceConfigCandidateKeepsPreviewApplyAction(t *testing.T) {
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/v1/config/status" {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"candidate":{"exists":true,"path":"/opt/rk3588-media-server/etc/media-server.json.candidate.json"}}`))
return
}
if r.Method != http.MethodPut || r.URL.Path != "/v1/config/candidate" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
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})
tasks := service.NewTaskService(cfg, agent, reg)
ui, err := NewUI(nil, reg, agent, tasks, nil)
if err != nil {
t.Fatalf("NewUI: %v", err)
}
form := url.Values{}
form.Set("json", `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v1","template":"workshop_face_shoe_alarm","profile":"local_3588_test"}}`)
req := httptest.NewRequest(http.MethodPost, "/ui/devices/edge-01/config-candidate", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetPathValue("id", "edge-01")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "edge-01")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rr := httptest.NewRecorder()
ui.actionDeviceConfigCandidate(rr, req)
body := rr.Body.String()
for _, want := range []string{
"候选配置结果",
"应用候选配置",
`formaction="/ui/devices/edge-01/config-candidate/apply"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected actionDeviceConfigCandidate HTML to contain %q, got:\n%s", want, body)
}
}
}
func TestUI_ConfigPreviewKeepsSelectedOverlayAfterPreview(t *testing.T) {
ui := newTestUI(t)
req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01/config-preview", nil)
rr := httptest.NewRecorder()
ui.render(rr, req, "config_preview", PageData{
Title: "配置预览",
Device: &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100},
ConfigSources: service.ConfigPreviewSources{Templates: []service.ConfigSource{{Name: "workshop_face_shoe_alarm"}}, Profiles: []service.ConfigSource{{Name: "local_3588_test"}}, Overlays: []service.ConfigSource{{Name: "face_debug"}, {Name: "face_test_sensitive"}}},
SelectedTemplate: "workshop_face_shoe_alarm",
SelectedProfile: "local_3588_test",
SelectedOverlays: []string{"face_test_sensitive"},
SelectedConfigID: "preview_edge-01",
ConfigPreview: &service.ConfigPreviewResult{
JSON: `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v1","template":"workshop_face_shoe_alarm","profile":"local_3588_test","overlays":["face_test_sensitive"]}}`,
Metadata: map[string]any{
"config_id": "preview_edge-01",
"config_version": "v1",
"template": "workshop_face_shoe_alarm",
"profile": "local_3588_test",
"overlays": []any{"face_test_sensitive"},
},
Size: 123,
},
})
body := rr.Body.String()
if !strings.Contains(body, `value="face_test_sensitive" checked`) {
t.Fatalf("expected selected overlay to remain checked, got:\n%s", body)
}
if strings.Contains(body, `value="face_debug" checked`) {
t.Fatalf("did not expect default overlay to stay checked, got:\n%s", body)
}
}
func TestUI_ConfigPreviewCollapsesJSONAfterActionResult(t *testing.T) {
ui := newTestUI(t)
req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01/config-preview", nil)
rr := httptest.NewRecorder()
ui.render(rr, req, "config_preview", PageData{
Title: "配置预览",
Device: &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100},
ConfigSources: service.ConfigPreviewSources{Templates: []service.ConfigSource{{Name: "workshop_face_shoe_alarm"}}, Profiles: []service.ConfigSource{{Name: "local_3588_test"}}, Overlays: []service.ConfigSource{{Name: "face_debug"}}},
SelectedTemplate: "workshop_face_shoe_alarm",
SelectedProfile: "local_3588_test",
SelectedOverlays: []string{"face_debug"},
SelectedConfigID: "preview_edge-01",
ConfigPreview: &service.ConfigPreviewResult{
JSON: `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[]}`,
Metadata: map[string]any{"config_id": "preview_edge-01"},
Size: 64,
},
ResultTitle: "候选配置结果",
RawText: `{"ok":true}`,
})
body := rr.Body.String()
if !strings.Contains(body, "<details") {
t.Fatalf("expected json panel to use details, got:\n%s", body)
}
if strings.Contains(body, "<details open>") {
t.Fatalf("expected json panel to be collapsed after action result, got:\n%s", body)
}
}
func TestUI_ConfigPreviewShowsApplySummaryAfterApplyResult(t *testing.T) {
ui := newTestUI(t)
req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01/config-preview", nil)
rr := httptest.NewRecorder()
ui.render(rr, req, "config_preview", PageData{
Title: "配置预览",
Device: &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100},
SelectedTemplate: "workshop_face_shoe_alarm",
SelectedProfile: "local_3588_test",
SelectedOverlays: []string{"face_debug"},
SelectedConfigID: "preview_edge-01",
ConfigPreview: &service.ConfigPreviewResult{
JSON: `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v2"}}`,
Metadata: map[string]any{
"config_id": "preview_edge-01",
"config_version": "v2",
"overlays": []any{"face_test_sensitive", "production_quiet"},
},
Sha256: "eecdf8d422705f3affa0f892199604f037f60ea8fe578fe2a65527e1800044c5",
Size: 64,
},
ConfigStatus: &ConfigStatusView{
OK: true,
Metadata: ConfigStatusMetadata{
ConfigID: "preview_edge-01",
ConfigVersion: "v2",
Overlays: []string{"face_test_sensitive", "production_quiet"},
},
Candidate: &ConfigStatusLastGoodFile{
Exists: false,
Path: "/opt/rk3588-media-server/etc/media-server.json.candidate.json",
},
LastGood: &ConfigStatusLastGoodFile{
Exists: true,
Path: "/opt/rk3588-media-server/etc/media-server.json.last_good.json",
Sha256: "07a37fabd73e98c1c131d31ddfa7b6c0e0949be854225bf2a6e990a09e60ddd3",
Metadata: ConfigStatusMetadata{
ConfigID: "local_3588_face_debug",
ConfigVersion: "20260419.120246",
Overlays: []string{"face_debug"},
},
},
MediaServer: ConfigStatusMediaServer{
Running: true,
PID: 1810489,
},
},
ResultTitle: "应用候选配置结果",
RawText: `{"ok":true}`,
})
body := rr.Body.String()
for _, want := range []string{
"应用结果摘要",
"当前运行",
"preview_edge-01 / v2",
"last_good",
"local_3588_face_debug / 20260419.120246",
"face_test_sensitive, production_quiet",
"face_debug",
"eecdf8d4",
"07a37fab",
"candidate",
"已清空",
"media-server",
"运行中",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected apply summary HTML to contain %q, got:\n%s", want, body)
}
}
}
func TestUI_ConfigPreviewExplainsConfigIdentityFields(t *testing.T) {
ui := newTestUI(t)
req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01/config-preview", nil)
rr := httptest.NewRecorder()
ui.render(rr, req, "config_preview", PageData{
Title: "配置预览",
Device: &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100},
SelectedTemplate: "workshop_face_shoe_alarm",
SelectedProfile: "local_3588_test",
SelectedOverlays: []string{"face_debug"},
SelectedConfigID: "preview_edge-01",
ConfigPreview: &service.ConfigPreviewResult{
JSON: `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v2","overlays":["face_debug"]}}`,
Metadata: map[string]any{
"config_id": "preview_edge-01",
"config_version": "v2",
"template": "workshop_face_shoe_alarm",
"profile": "local_3588_test",
"overlays": []any{"face_debug"},
},
Sha256: "eecdf8d422705f3affa0f892199604f037f60ea8fe578fe2a65527e1800044c5",
Size: 64,
},
})
body := rr.Body.String()
for _, want := range []string{
"config_id</span> 是配置名,默认会带上 <span class=\"mono\">device_id",
"config_version</span> 表示本次生成版本",
"device_id",
"edge-01",
"SHA256</span> 是最终文件内容指纹",
"Overlay",
"face_debug",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected preview explanation HTML to contain %q, got:\n%s", want, body)
}
}
}
func TestUI_ActionDeviceConfigCandidateApplyReloadsStatusAfterApply(t *testing.T) {
statusCalls := 0
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v1/config/status":
statusCalls++
w.Header().Set("Content-Type", "application/json")
if statusCalls == 1 {
_, _ = w.Write([]byte(`{
"ok": true,
"metadata": {"config_id": "preview_edge-01", "config_version": "v2"},
"candidate": {"exists": true, "path": "/opt/rk3588-media-server/etc/media-server.json.candidate.json"},
"last_good": {"exists": true, "path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json", "metadata": {"config_id": "local_3588_face_debug", "config_version": "20260419.120246"}},
"media_server": {"running": true, "pid": 1810489}
}`))
return
}
_, _ = w.Write([]byte(`{
"ok": true,
"metadata": {"config_id": "preview_edge-01", "config_version": "v2"},
"candidate": {"exists": false, "path": "/opt/rk3588-media-server/etc/media-server.json.candidate.json"},
"last_good": {"exists": true, "path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json", "metadata": {"config_id": "local_3588_face_debug", "config_version": "20260419.120246"}},
"media_server": {"running": true, "pid": 1810489}
}`))
return
case r.Method == http.MethodPost && r.URL.Path == "/v1/config/candidate/apply":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
return
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
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})
tasks := service.NewTaskService(cfg, agent, reg)
ui, err := NewUI(nil, reg, agent, tasks, nil)
if err != nil {
t.Fatalf("NewUI: %v", err)
}
form := url.Values{}
form.Set("json", `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v2","template":"workshop_face_shoe_alarm","profile":"local_3588_test"}}`)
req := httptest.NewRequest(http.MethodPost, "/ui/devices/edge-01/config-candidate/apply", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetPathValue("id", "edge-01")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "edge-01")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rr := httptest.NewRecorder()
ui.actionDeviceConfigCandidateApply(rr, req)
body := rr.Body.String()
if statusCalls != 2 {
t.Fatalf("expected status to be loaded twice, got %d", statusCalls)
}
for _, want := range []string{
"应用结果摘要",
"preview_edge-01 / v2",
"local_3588_face_debug / 20260419.120246",
"已清空",
"运行中",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected apply result HTML to contain %q, got:\n%s", want, body)
}
}
if strings.Contains(body, "仍存在") {
t.Fatalf("expected refreshed status after apply, got stale candidate state:\n%s", body)
}
}
func TestUI_ModelDeploymentPageRendersDeviceActions(t *testing.T) {
ui := newTestUI(t)
req := httptest.NewRequest(http.MethodGet, "/ui/models", nil)
rr := httptest.NewRecorder()
ui.pageModels(rr, req)
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{"模型管理", "模型管理工作流", "model-summary", "summary-value", "入口识别节点", "上传模型", "查看设备模型", "人脸库"} {
if !strings.Contains(body, want) {
t.Fatalf("expected model deployment HTML to contain %q", want)
}
}
if strings.Contains(body, `<div class="stats">
<div class="stat"><div class="k">目标节点`) {
t.Fatalf("model summary should not use large stat cards")
}
}
func TestUI_DiagnosticsPageRendersLogAndMetricLinks(t *testing.T) {
ui := newTestUI(t)
req := httptest.NewRequest(http.MethodGet, "/ui/diagnostics", nil)
rr := httptest.NewRecorder()
ui.pageDiagnostics(rr, req)
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{"日志分析", "诊断工作台", "入口识别节点", "诊断日志", "运行指标", "高级调试"} {
if !strings.Contains(body, want) {
t.Fatalf("expected diagnostics HTML to contain %q", want)
}
}
}
func TestUI_SidebarMatchesInformationArchitecture(t *testing.T) {
ui := newTestUI(t)
req := httptest.NewRequest(http.MethodGet, "/ui/dashboard", nil)
rr := httptest.NewRecorder()
ui.pageDashboard(rr, req)
body := rr.Body.String()
for _, want := range []string{
"总览",
"设备列表",
"配置管理",
"新增设备",
"模型管理",
"识别配置",
"日志分析",
"高级调试",
`href="/ui/device-config"`,
`href="/ui/recognition"`,
`href="/ui/logs"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected sidebar to contain %q", want)
}
}
for _, old := range []string{"设备节点", "添加节点", "模型部署", "日志指标", "任务中心"} {
if strings.Contains(body, old) {
t.Fatalf("sidebar should not contain old label %q", old)
}
}
}
func TestUI_DeviceConfigPageDefinesOpsConfigScope(t *testing.T) {
ui := newTestUI(t)
req := httptest.NewRequest(http.MethodGet, "/ui/device-config", nil)
rr := httptest.NewRecorder()
ui.pageDeviceConfig(rr, req)
body := rr.Body.String()
for _, want := range []string{"配置管理", "设备运行配置", "服务状态", "服务管理", "配置查看", "配置上传", "入口识别节点", "启动视频分析", "重启视频分析", "停止视频分析", "重载识别服务", "回滚识别配置"} {
if !strings.Contains(body, want) {
t.Fatalf("expected device config HTML to contain %q", want)
}
}
if !strings.Contains(body, `method="post"`) {
t.Fatalf("expected device config page to contain service operation forms")
}
}
func TestUI_RecognitionPageDefinesAIScope(t *testing.T) {
ui := newTestUI(t)
req := httptest.NewRequest(http.MethodGet, "/ui/recognition", nil)
rr := httptest.NewRecorder()
ui.pageRecognition(rr, req)
body := rr.Body.String()
for _, want := range []string{"识别配置", "识别方案", "视频通道", "模板参数", "预览变更", "入口识别节点"} {
if !strings.Contains(body, want) {
t.Fatalf("expected recognition HTML to contain %q", want)
}
}
}
func TestUI_LogsPageDefinesDiagnosticsScope(t *testing.T) {
ui := newTestUI(t)
req := httptest.NewRequest(http.MethodGet, "/ui/logs", nil)
rr := httptest.NewRecorder()
ui.pageLogs(rr, req)
body := rr.Body.String()
for _, want := range []string{"日志分析", "日志筛选", "诊断日志", "运行指标", "高级调试", "入口识别节点"} {
if !strings.Contains(body, want) {
t.Fatalf("expected logs HTML to contain %q", want)
}
}
}