3588AdminBackend/internal/web/ui_test.go

2554 lines
94 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package web
import (
"3588AdminBackend/internal/config"
"3588AdminBackend/internal/models"
"3588AdminBackend/internal/service"
"3588AdminBackend/internal/storage"
"bytes"
"context"
"encoding/json"
"mime/multipart"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"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_ActionDevicesBatchActionDeduplicatesKnownDevices(t *testing.T) {
ui := newTestUI(t)
ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
form := url.Values{}
form.Set("action", "reload")
form.Add("device_id", "edge-01")
form.Add("device_id", "edge-01")
form.Add("device_id", "edge-02")
form.Add("device_id", "missing")
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 redirect, got %d: %s", rr.Code, rr.Body.String())
}
loc := rr.Header().Get("Location")
if !strings.HasPrefix(loc, "/ui/tasks/") {
t.Fatalf("expected redirect to task page, got %q", loc)
}
taskID := strings.TrimPrefix(loc, "/ui/tasks/")
items := ui.tasks.ListTasks()
var task *models.Task
for i := range items {
if items[i].ID == taskID {
t := items[i]
task = &t
break
}
}
if task == nil {
t.Fatalf("expected task %s to exist", taskID)
}
if got := len(task.DeviceIDs); got != 2 {
t.Fatalf("expected deduplicated device count 2, got %d: %#v", got, task.DeviceIDs)
}
if task.DeviceIDs[0] != "edge-01" || task.DeviceIDs[1] != "edge-02" {
t.Fatalf("expected selection order preserved, got %#v", task.DeviceIDs)
}
}
func TestUI_SelectedDeviceQueryHelpersStayStable(t *testing.T) {
ids := selectedIDsFromQuery([]string{" edge-02 ", "edge-01", "edge-02", ""})
if len(ids) != 2 || ids[0] != "edge-02" || ids[1] != "edge-01" {
t.Fatalf("selectedIDsFromQuery normalized to %#v", ids)
}
if got := selectedQueryString(ids); got != "selected=edge-02&selected=edge-01" {
t.Fatalf("selectedQueryString returned %q", got)
}
}
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-size:14px;font-weight:600",
".nav-section{padding:14px 10px 6px;font-size:11px",
".topbar h1{margin:4px 0 0;font-size:18px;font-weight:600",
".crumb,.eyebrow{font-size:11px",
".btn,button{display:inline-flex",
".stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}",
".card{background:var(--surface);border:1px solid var(--border);border-radius:8px",
} {
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}
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
reg := service.NewRegistryService(cfg, nil, storage.NewDevicesRepo(store.DB()))
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 renderPage(t *testing.T, ui *UI, path string) string {
t.Helper()
router, err := ui.Routes()
if err != nil {
t.Fatalf("build routes: %v", err)
}
if strings.HasPrefix(path, "/ui/") {
path = strings.TrimPrefix(path, "/ui")
}
req := httptest.NewRequest(http.MethodGet, path, nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 for %s, got %d: %s", path, rr.Code, rr.Body.String())
}
return rr.Body.String()
}
func TestUI_DeviceOverviewHidesBatchBarWithoutSelection(t *testing.T) {
ui := newTestUI(t)
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 _, forbidden := range []string{"batch-toolbar", "已选", "重启服务", "启动服务", "停止服务", "重载配置", "回滚配置", "下发业务配置", "清空选择"} {
if strings.Contains(body, forbidden) {
t.Fatalf("device overview should not show batch controls without selection, found %q in:\n%s", forbidden, body)
}
}
}
func TestUI_DeviceOverviewShowsBatchBarWhenDevicesSelected(t *testing.T) {
ui := newTestUI(t)
ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
req := httptest.NewRequest(http.MethodGet, "/ui/devices?selected=edge-01&selected=edge-02", 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{"batch-toolbar", "已选 2 台", "重启服务", "启动服务", "停止服务", "重载配置", "回滚配置", "下发业务配置", "清空选择", "将重载当前业务配置", "将回滚到上一版业务配置"} {
if !strings.Contains(body, want) {
t.Fatalf("expected batch controls HTML to contain %q, got:\n%s", want, body)
}
}
for _, forbidden := range []string{"批量配置", "重载服务"} {
if strings.Contains(body, forbidden) {
t.Fatalf("device overview batch controls should not contain %q, got:\n%s", forbidden, body)
}
}
if !strings.Contains(body, `href="/ui/devices/batch-config?selected=edge-01&amp;selected=edge-02"`) {
t.Fatalf("device overview batch config link should preserve selected query params, got:\n%s", body)
}
}
func TestUI_DeviceBatchConfigPageShowsSelectedSummaryAndSources(t *testing.T) {
ui := newTestUI(t)
ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigMediaRepo(t)})
req := httptest.NewRequest(http.MethodGet, "/ui/devices/batch-config?selected=edge-01&selected=edge-02", nil)
rr := httptest.NewRecorder()
ui.pageDeviceBatchConfig(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{
"下发业务配置",
"业务配置",
"已选设备",
"入口识别节点",
"辅助节点",
"业务配置摘要",
"local_3588_test",
"A厂区视觉识别",
"workshop_face_shoe_alarm",
"东门入口",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected batch config page to contain %q, got:\n%s", want, body)
}
}
for _, forbidden := range []string{"config_id", "config_version", "完整 JSON"} {
if strings.Contains(body, forbidden) {
t.Fatalf("batch config page should not expose internal config fields %q, got:\n%s", forbidden, body)
}
}
}
func TestUI_ActionDeviceBatchConfigCreatesTaskAndRedirects(t *testing.T) {
ui := newTestUI(t)
ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigMediaRepo(t)})
form := url.Values{}
form.Add("device_id", "edge-01")
form.Add("device_id", "edge-02")
form.Set("profile", "local_3588_test")
req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-config", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
ui.actionDeviceBatchConfig(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
loc := rr.Header().Get("Location")
if !strings.HasPrefix(loc, "/ui/tasks/") {
t.Fatalf("expected redirect to task page, got %q", loc)
}
taskID := strings.TrimPrefix(loc, "/ui/tasks/")
items := ui.tasks.ListTasks()
var task *models.Task
for i := range items {
if items[i].ID == taskID {
t := items[i]
task = &t
break
}
}
if task == nil {
t.Fatalf("expected task %s to exist", taskID)
}
if task.Type != "config_apply" {
t.Fatalf("expected task type config_apply, got %q", task.Type)
}
if len(task.DeviceIDs) != 2 || task.DeviceIDs[0] != "edge-01" || task.DeviceIDs[1] != "edge-02" {
t.Fatalf("expected selected devices preserved, got %#v", task.DeviceIDs)
}
payload, ok := task.Payload.(map[string]any)
if !ok {
t.Fatalf("expected payload map, got %#v", task.Payload)
}
configDoc, ok := payload["config"].(map[string]any)
if !ok {
t.Fatalf("expected payload.config object, got %#v", payload["config"])
}
metadata, ok := configDoc["metadata"].(map[string]any)
if !ok {
t.Fatalf("expected metadata object, got %#v", configDoc["metadata"])
}
if metadata["template"] != "workshop_face_shoe_alarm" {
t.Fatalf("expected template metadata, got %#v", metadata["template"])
}
if metadata["profile"] != "local_3588_test" {
t.Fatalf("expected profile metadata, got %#v", metadata["profile"])
}
}
func TestUI_ActionDeviceBatchConfigRenderFailurePreservesUserInput(t *testing.T) {
ui := newTestUI(t)
ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigBrokenMediaRepo(t)})
form := url.Values{}
form.Add("device_id", "edge-01")
form.Add("device_id", "edge-02")
form.Set("template", "workshop_face_shoe_alarm")
form.Set("profile", "local_3588_test")
form.Set("config_id", "")
form.Set("config_version", "")
req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-config", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
ui.actionDeviceBatchConfig(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{
`name="device_id" value="edge-01"`,
`name="device_id" value="edge-02"`,
"入口识别节点",
"辅助节点",
`name="profile"`,
`value="local_3588_test" selected`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected failure refill HTML to contain %q, got:\n%s", want, body)
}
}
for _, forbidden := range []string{`name="config_id"`, `name="overlay"`, "完整 JSON"} {
if strings.Contains(body, forbidden) {
t.Fatalf("expected failure UI to avoid internal config field %q, got:\n%s", forbidden, body)
}
}
}
func TestUI_ActionDevicesBatchActionKeepsDevicesOnError(t *testing.T) {
ui := newTestUI(t)
ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
form := url.Values{}
form.Set("action", "nope")
form.Add("device_id", "edge-01")
form.Add("device_id", "edge-02")
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.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{"不支持的操作: nope", "入口识别节点", "辅助节点", "已选 2 台", `value="edge-01" checked`, `value="edge-02" checked`} {
if !strings.Contains(body, want) {
t.Fatalf("expected error render to contain %q, got:\n%s", want, body)
}
}
}
func TestUI_AssetProfilePageShowsEditorTabs(t *testing.T) {
ui := newTestUI(t)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createProfileEditorMediaRepo(t)})
req := httptest.NewRequest(http.MethodGet, "/ui/assets/profiles/local_3588_test", nil)
req = withChiURLParam(req, "name", "local_3588_test")
rr := httptest.NewRecorder()
ui.pageAssetProfile(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{
"业务配置",
"业务配置名称",
"业务名称",
"站点名",
"视频通道",
"cam1",
"cam2",
"cam3",
"cam4",
"RTSP 输入",
"HLS 输出",
"RTSP 输出",
"高级设置",
"新增通道",
"删除",
"保存",
"A厂区视觉识别",
"东门入口",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected profile editor page to contain %q, got:\n%s", want, body)
}
}
for _, forbidden := range []string{"生成预览", "上传为候选配置", "目标设备", "发布入口", "请在设备页中预览并下发", "下发方式", "Profile 编辑页签", ">模板</span><input", "设备编号", `instances[0].device_code`, `instances[0].site_name`} {
if strings.Contains(body, forbidden) {
t.Fatalf("expected profile editor page to omit %q, got:\n%s", forbidden, body)
}
}
}
func TestUI_ActionAssetProfileSaveWritesProfileFile(t *testing.T) {
root := createProfileEditorMediaRepo(t)
ui := newTestUI(t)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root})
form := url.Values{}
form.Set("profile_name", "local_3588_test")
form.Set("business_name", "B厂区视觉识别")
form.Set("description", "updated profile")
form.Set("site_name", "B厂区")
form.Set("instances[0].name", "cam1")
form.Set("instances[0].template", "workshop_face_shoe_alarm")
form.Set("instances[0].display_name", "西门入口")
form.Set("instances[0].channel_no", "cam1")
form.Set("instances[0].rtsp_url", "rtsp://10.0.0.2/live")
form.Set("instances[0].publish_hls_path", "./web/hls/cam1/index.m3u8")
form.Set("instances[0].publish_rtsp_port", "8556")
form.Set("instances[0].publish_rtsp_path", "/live/cam1")
form.Set("instances[0].advanced_params", `{"queue_debug":true}`)
form.Set("instances[1].name", "cam2")
form.Set("instances[1].template", "workshop_face_shoe_alarm")
form.Set("instances[1].display_name", "视觉识别终端-C厂区")
form.Set("instances[1].channel_no", "cam2")
form.Set("instances[1].rtsp_url", "rtsp://10.0.0.3/live")
form.Set("instances[1].publish_hls_path", "./web/hls/cam2/index.m3u8")
form.Set("instances[1].publish_rtsp_port", "8557")
form.Set("instances[1].publish_rtsp_path", "/live/cam2")
form.Set("instances[1].delete", "1")
form.Set("queue_size", "9")
form.Set("queue_strategy", "drop_oldest")
req := httptest.NewRequest(http.MethodPost, "/ui/assets/profiles/local_3588_test", strings.NewReader(form.Encode()))
req = withChiURLParam(req, "name", "local_3588_test")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
ui.actionAssetProfileSave(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
if !strings.Contains(body, "业务配置已保存") {
t.Fatalf("expected save success message, got:\n%s", body)
}
raw, err := os.ReadFile(filepath.Join(root, "configs", "profiles", "local_3588_test.json"))
if err != nil {
t.Fatalf("read saved profile: %v", err)
}
var doc map[string]any
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("unmarshal saved profile: %v", err)
}
if doc["description"] != "updated profile" {
t.Fatalf("unexpected description: %#v", doc)
}
queue, _ := doc["queue"].(map[string]any)
if queue["size"] != float64(9) {
t.Fatalf("expected queue size 9, got %#v", queue)
}
instances, _ := doc["instances"].([]any)
if len(instances) != 1 {
t.Fatalf("expected deleted second instance to be omitted, got %#v", instances)
}
instance, _ := instances[0].(map[string]any)
params, _ := instance["params"].(map[string]any)
if doc["business_name"] != "B厂区视觉识别" {
t.Fatalf("expected updated business name, got %#v", doc)
}
if params["display_name"] != "西门入口" {
t.Fatalf("expected updated display name, got %#v", params)
}
if params["publish_rtsp_port"] != float64(8556) {
t.Fatalf("expected numeric publish_rtsp_port, got %#v", params["publish_rtsp_port"])
}
if params["site_name"] != "B厂区" {
t.Fatalf("expected profile site name to be saved into params, got %#v", params)
}
if params["device_code"] != "rk3588-a-001" {
t.Fatalf("expected hidden legacy device_code to be preserved, got %#v", params)
}
}
func createBatchConfigMediaRepo(t *testing.T) string {
t.Helper()
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{
"name":"workshop_face_shoe_alarm",
"description":"helmet and shoe alarm",
"template":{"nodes":[],"edges":[]}
}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
"name":"local_3588_test",
"business_name":"A厂区视觉识别",
"description":"默认班次识别配置",
"instances":[
{
"name":"cam1",
"template":"workshop_face_shoe_alarm",
"params":{
"display_name":"东门入口",
"site_name":"A厂区",
"rtsp_url":"rtsp://10.0.0.1/live",
"channel_no":"cam1"
}
}
]
}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"name":"overlay"}`)
writeTestFile(t, filepath.Join(root, "tools", "render_config.py"), `import argparse
import json
import os
parser = argparse.ArgumentParser()
parser.add_argument("--template", required=True)
parser.add_argument("--profile", required=True)
parser.add_argument("--out", required=True)
parser.add_argument("--config-id", required=True)
parser.add_argument("--config-version", required=True)
parser.add_argument("--rendered-at", required=True)
parser.add_argument("--overlay", action="append", default=[])
args = parser.parse_args()
doc = {
"metadata": {
"config_id": args.config_id,
"config_version": args.config_version,
"template": os.path.splitext(os.path.basename(args.template))[0],
"profile": os.path.splitext(os.path.basename(args.profile))[0],
"overlays": [os.path.splitext(os.path.basename(item))[0] for item in args.overlay],
"rendered_at": args.rendered_at,
},
"pipelines": [],
}
with open(args.out, "w", encoding="utf-8") as fh:
json.dump(doc, fh, ensure_ascii=False, indent=2)
`)
return root
}
func createProfileEditorMediaRepo(t *testing.T) string {
t.Helper()
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{
"name": "workshop_face_shoe_alarm",
"template": {"nodes": [], "edges": []}
}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
"name": "local_3588_test",
"description": "test profile",
"business_name": "A厂区视觉识别",
"queue": {"size": 8, "strategy": "drop_oldest"},
"instances": [
{
"name": "cam1",
"template": "workshop_face_shoe_alarm",
"params": {
"display_name": "东门入口",
"device_code": "rk3588-a-001",
"site_name": "A厂区",
"rtsp_url": "rtsp://10.0.0.1/live",
"publish_hls_path": "./web/hls/cam1/index.m3u8",
"publish_rtsp_port": 8555,
"publish_rtsp_path": "/live/cam1",
"channel_no": "cam1",
"queue_debug": true
}
},
{
"name": "cam2",
"template": "workshop_face_shoe_alarm",
"params": {
"display_name": "西门入口",
"rtsp_url": "rtsp://10.0.0.2/live",
"publish_hls_path": "./web/hls/cam2/index.m3u8",
"publish_rtsp_port": 8555,
"publish_rtsp_path": "/live/cam2",
"channel_no": "cam2"
}
},
{
"name": "cam3",
"template": "workshop_face_shoe_alarm",
"params": {
"display_name": "1号产线",
"rtsp_url": "rtsp://10.0.0.3/live",
"publish_hls_path": "./web/hls/cam3/index.m3u8",
"publish_rtsp_port": 8555,
"publish_rtsp_path": "/live/cam3",
"channel_no": "cam3"
}
},
{
"name": "cam4",
"template": "workshop_face_shoe_alarm",
"params": {
"display_name": "2号产线",
"rtsp_url": "rtsp://10.0.0.4/live",
"publish_hls_path": "./web/hls/cam4/index.m3u8",
"publish_rtsp_port": 8555,
"publish_rtsp_path": "/live/cam4",
"channel_no": "cam4"
}
}
]
}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
return root
}
func withChiURLParam(req *http.Request, key string, value string) *http.Request {
rctx := chi.NewRouteContext()
rctx.URLParams.Add(key, value)
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
}
func createBatchConfigBrokenMediaRepo(t *testing.T) string {
t.Helper()
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{"name":"workshop_face_shoe_alarm","template":{"nodes":[],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
"name":"local_3588_test",
"business_name":"A厂区视觉识别",
"instances":[{"name":"cam1","template":"workshop_face_shoe_alarm","params":{"display_name":"东门入口"}}]
}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"name":"overlay"}`)
return root
}
func writeTestFile(t *testing.T, path string, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func TestUI_TaskPageRendersBatchSummaryAndDeviceResults(t *testing.T) {
ui := newTestUI(t)
ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigMediaRepo(t)})
form := url.Values{}
form.Add("device_id", "edge-01")
form.Add("device_id", "edge-02")
form.Set("template", "workshop_face_shoe_alarm")
form.Set("profile", "local_3588_test")
form.Set("config_id", "batch_edge")
form.Set("config_version", "20260420.090000")
req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-config", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
ui.actionDeviceBatchConfig(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
loc := rr.Header().Get("Location")
if !strings.HasPrefix(loc, "/ui/tasks/") {
t.Fatalf("expected redirect to task page, got %q", loc)
}
rrTask := httptest.NewRecorder()
reqTask := httptest.NewRequest(http.MethodGet, loc, nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", strings.TrimPrefix(loc, "/ui/tasks/"))
reqTask = reqTask.WithContext(context.WithValue(reqTask.Context(), chi.RouteCtxKey, rctx))
ui.pageTask(rrTask, reqTask)
if rrTask.Code != http.StatusOK {
t.Fatalf("expected task page 200, got %d: %s", rrTask.Code, rrTask.Body.String())
}
body := rrTask.Body.String()
for _, want := range []string{"任务概览", "返回任务列表", "设备结果", "执行进度", "任务类型", "目标设备数", "批量配置", "下发识别配置", "2 台", "入口识别节点", "辅助节点", "edge-01", "edge-02", `id="task-status-value"`, "syncTaskStatus()", `href="/ui/tasks"`} {
if !strings.Contains(body, want) {
t.Fatalf("expected task page to contain %q, got:\n%s", want, body)
}
}
for _, forbidden := range []string{"任务中心", "节点执行情况"} {
if strings.Contains(body, forbidden) {
t.Fatalf("task page should not contain %q, got:\n%s", forbidden, body)
}
}
if strings.Contains(body, "返回操作审计") {
t.Fatalf("task page should not point back to audit, got:\n%s", body)
}
if strings.Index(body, "edge-01") > strings.Index(body, "edge-02") {
t.Fatalf("expected task devices to keep selection order, got:\n%s", body)
}
}
func TestUI_DeviceOverviewRendersFleetOverview(t *testing.T) {
ui := newTestUI(t)
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{"设备", "设备总数", "在线设备", "离线设备", "失败操作", "设备列表"} {
if !strings.Contains(body, want) {
t.Fatalf("expected dashboard HTML to contain %q, got:\n%s", want, body)
}
}
for _, forbidden := range []string{"设备总览", "多台视觉识别设备的统一运维视图", "先看状态,再决定操作。默认只展示值班和维护最需要的信息。"} {
if strings.Contains(body, forbidden) {
t.Fatalf("device page should not contain explanatory copy %q", forbidden)
}
}
}
func TestUI_DevicePageIncludesFilterAndControlEntry(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-filter", "详情", "控制"} {
if !strings.Contains(body, want) {
t.Fatalf("expected device page HTML to contain %q", want)
}
}
for _, forbidden := range []string{"查看配置资产", "查看操作审计"} {
if strings.Contains(body, forbidden) {
t.Fatalf("device overview should not contain redundant link %q", forbidden)
}
}
}
func TestUI_DeviceOverviewShowsLiveStatusAndConfigSummary(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", "production_quiet"]
},
"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: false, Version: "1.0.0", BuildID: "20260419.151628", GitSha: "5c04681"})
tasks := service.NewTaskService(cfg, agent, reg)
ui, err := NewUI(nil, reg, agent, 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{"在线", "运行中", "local_3588_face_debug"} {
if !strings.Contains(body, want) {
t.Fatalf("expected device overview HTML to contain %q, got:\n%s", want, body)
}
}
if strings.Contains(body, "待在详情页查看") {
t.Fatalf("device overview should show live config summary instead of placeholder text")
}
for _, forbidden := range []string{"20260419.104457", "face_debug, production_quiet"} {
if strings.Contains(body, forbidden) {
t.Fatalf("device overview should not expose extra config details %q in main list", forbidden)
}
}
if strings.Contains(body, "心跳") {
t.Fatalf("device overview should not show heartbeat text in status cell")
}
}
func TestUI_DeviceOverviewUsesCompactColumns(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,
"metadata": {
"config_id": "local_3588_face_debug",
"config_version": "20260419.104457",
"overlays": ["face_debug"]
},
"media_server": {"running": 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: "d12a4719c91641df91b76ab271280797", DeviceName: "rk3588_orangepi5plus", Hostname: "orangepi5plus", IP: host, AgentPort: port, MediaPort: 9000, Online: true, Version: "0.1.0", BuildID: "20260419.151628", GitSha: "5c04681"})
tasks := service.NewTaskService(cfg, agent, reg)
ui, err := NewUI(nil, reg, agent, 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)
body := rr.Body.String()
for _, want := range []string{"设备", "状态", "当前配置", "操作", "orangepi5plus", "0.1.0", "#2026041"} {
if !strings.Contains(body, want) {
t.Fatalf("expected compact overview HTML to contain %q, got:\n%s", want, body)
}
}
for _, forbidden := range []string{"#5c04681", "Git SHA"} {
if strings.Contains(body, forbidden) {
t.Fatalf("device overview should not expose git sha marker %q", forbidden)
}
}
for _, forbidden := range []string{"最后心跳", "版本</th>", "在线</th>", "服务</th>"} {
if strings.Contains(body, forbidden) {
t.Fatalf("device overview should not keep verbose column header %q", forbidden)
}
}
if strings.Contains(body, "心跳") {
t.Fatalf("device overview compact view should not render heartbeat text")
}
}
func TestUI_DeviceDetailIncludesWorkspaceSections(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 device detail HTML to contain %q", want)
}
}
for _, forbidden := range []string{"device-tabs", "返回设备总览", "进入管理"} {
if strings.Contains(body, forbidden) {
t.Fatalf("device detail should not contain redundant nav %q", forbidden)
}
}
}
func TestUI_DeviceDetailUsesUnifiedWorkspace(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 unified device workspace HTML to contain %q", want)
}
}
for _, forbidden := range []string{"只读查看页", "权威摘要位置", "当前框架版"} {
if strings.Contains(body, forbidden) {
t.Fatalf("device detail should not contain placeholder copy %q", forbidden)
}
}
for _, forbidden := range []string{
`action="/ui/devices/edge-01/alias"`,
} {
if strings.Contains(body, forbidden) {
t.Fatalf("device detail workspace should not contain obsolete entry %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",
"8d935c9d",
"/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_DeviceDetailDoesNotUseChannelDisplayNameAsDeviceName(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,
"metadata": {
"business_name": "A厂区视觉识别",
"instance_name": "cam1",
"instance_names": ["cam1"],
"instance_display_names": ["东门入口"]
},
"media_server": {"running": 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)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
reg := service.NewRegistryService(cfg, agent, storage.NewDevicesRepo(store.DB()))
if err := reg.SetDeviceAlias("edge-01", "备用盒子-01"); err != nil {
t.Fatalf("SetDeviceAlias: %v", err)
}
reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "rk3588_orangepi5plus", Hostname: "orangepi5plus", IP: host, AgentPort: port, MediaPort: 9000, Online: true, Version: "1.0.0"})
ui, err := NewUI(nil, reg, agent, service.NewTaskService(cfg, agent, reg), 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))
body := rr.Body.String()
for _, want := range []string{"备用盒子-01", "当前业务配置", "A厂区视觉识别", "通道名", "cam1"} {
if !strings.Contains(body, want) {
t.Fatalf("expected device detail to contain %q, got:\n%s", want, body)
}
}
for _, forbidden := range []string{
"<span>设备名称</span><strong>东门入口</strong>",
"<span>当前业务配置</span><strong>东门入口</strong>",
} {
if strings.Contains(body, forbidden) {
t.Fatalf("channel display name should not be used as device or business name, got:\n%s", 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{"当前设备"} {
if !strings.Contains(body, want) {
t.Fatalf("expected %s HTML to contain %q", content, want)
}
}
if strings.Contains(body, "device-tabs") {
t.Fatalf("expected %s HTML to avoid device cross-page tabs", content)
}
}
}
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{
"配置预览",
"模板",
"业务配置",
"配置叠加项",
"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)
}
}
for _, forbidden := range []string{"返回节点详情", "完整 JSON</h2>"} {
if strings.Contains(body, forbidden) {
t.Fatalf("config preview page should not contain redundant element %q", forbidden)
}
}
for _, forbidden := range []string{"上传为候选配置", "应用候选配置"} {
if strings.Contains(body, forbidden) {
t.Fatalf("config preview page should not show action %q before preview is generated", forbidden)
}
}
}
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)
}
}
if strings.Contains(body, "<h2>执行结果</h2>") {
t.Fatalf("config preview page should not render duplicate result card, got:\n%s", 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",
},
PreviousConfig: &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",
"上一份配置",
"local_3588_face_debug / 20260419.120246",
"face_test_sensitive, production_quiet",
"face_debug",
"eecdf8d4",
"07a37fab",
"候选配置",
"已清空",
"视觉服务",
"运行中",
} {
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",
"config_version",
"SHA256",
"配置叠加项",
"face_debug",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected config preview HTML to contain %q, got:\n%s", want, body)
}
}
for _, forbidden := range []string{
"是配置名,默认会带上",
"表示本次生成版本",
"是最终文件内容指纹",
} {
if strings.Contains(body, forbidden) {
t.Fatalf("config preview should not contain explanatory copy %q", forbidden)
}
}
}
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"},
"previous_config": {"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"},
"previous_config": {"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_LoadConfigStatusPrefersPreviousConfigFields(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" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"ok": true,
"metadata": {"config_id": "preview-edge", "config_version": "v2"},
"previous_config_path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json",
"previous_config": {
"exists": true,
"path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json",
"sha256": "abc12345",
"metadata": {"config_id": "prev-edge", "config_version": "v1", "overlays": ["face_debug"]}
},
"media_server": {"running": true, "pid": 1234}
}`))
}))
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)
}
status, _, err := ui.loadConfigStatus(&models.Device{DeviceID: "edge-01", IP: host, AgentPort: port})
if err != nil {
t.Fatalf("loadConfigStatus: %v", err)
}
if status == nil || status.PreviousConfig == nil {
t.Fatalf("expected previous config to be present, got %#v", status)
}
if status.PreviousConfig.Metadata.ConfigID != "prev-edge" {
t.Fatalf("previous config metadata = %#v", status.PreviousConfig.Metadata)
}
}
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/devices", nil)
rr := httptest.NewRecorder()
ui.pageDevices(rr, req)
body := rr.Body.String()
for _, want := range []string{
"总览",
"设备",
"识别配置",
"任务",
"诊断",
`href="/ui/dashboard"`,
`href="/ui/devices"`,
`href="/ui/assets"`,
`href="/ui/tasks"`,
`href="/ui/diagnostics"`,
} {
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_DeviceConfigPageShowsDeviceSelector(t *testing.T) {
ui := newTestUI(t)
req := httptest.NewRequest(http.MethodGet, "/ui/device-config", nil)
rr := httptest.NewRecorder()
ui.pageDeviceConfig(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{"单设备配置已并入设备详情", "设备入口", "edge-01", "入口识别节点", `href="/ui/devices/edge-01#device-config"`} {
if !strings.Contains(body, want) {
t.Fatalf("expected config selector page to contain %q, got:\n%s", want, body)
}
}
for _, forbidden := range []string{"模板", "业务配置", "叠加项", "配置资产"} {
if strings.Contains(body, forbidden) {
t.Fatalf("expected config selector page to avoid asset-library copy %q", forbidden)
}
}
}
func TestUI_DeviceControlPageShowsLiveActions(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, "/device-config/edge-01", nil))
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("Location"); got != "/ui/devices/edge-01#device-config" {
t.Fatalf("expected device-config detail redirect to device workspace, got %q", got)
}
}
func TestUI_ActionDeviceActionCanRenderControlPage(t *testing.T) {
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/v1/media-server/reload":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
case r.Method == http.MethodGet && r.URL.Path == "/v1/config/status":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"ok": true,
"metadata": {"config_id": "preview_edge-01", "config_version": "v2"},
"previous_config": {"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}
}`))
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("action", "reload")
form.Set("return_to", "config")
req := httptest.NewRequest(http.MethodPost, "/ui/devices/edge-01/action", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "edge-01")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rr := httptest.NewRecorder()
ui.actionDeviceAction(rr, req)
body := rr.Body.String()
for _, want := range []string{"设备工作台", "运行与服务", "POST /v1/media-server/reload", "执行结果摘要"} {
if !strings.Contains(body, want) {
t.Fatalf("expected control page result HTML to contain %q, got:\n%s", want, body)
}
}
}
func TestUI_ActionDeviceConfigCandidateApplyCanRenderControlPage(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")
_, _ = 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"},
"previous_config": {"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}
}`))
case r.Method == http.MethodPost && r.URL.Path == "/v1/config/candidate/apply":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
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("return_to", "config")
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")
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)
if statusCalls < 2 {
t.Fatalf("expected status to be refreshed for control page, got %d calls", statusCalls)
}
body := rr.Body.String()
for _, want := range []string{"设备工作台", "应用候选配置结果", "preview_edge-01", "local_3588_face_debug"} {
if !strings.Contains(body, want) {
t.Fatalf("expected control page apply result HTML to contain %q, got:\n%s", want, body)
}
}
}
func TestUI_AssetsPageDefinesConfigAssetScope(t *testing.T) {
ui := newTestUI(t)
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{"name":"workshop_face_shoe_alarm","description":"template","params":{"minio_endpoint":"http://10.0.0.49:9000"},"template":{"nodes":[{},{}],"edges":[[]]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"local_3588_test","business_name":"A厂区视觉识别","instances":[{"name":"cam1","template":"workshop_face_shoe_alarm","params":{"display_name":"东门入口"}}]}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root})
req := httptest.NewRequest(http.MethodGet, "/ui/assets", nil)
rr := httptest.NewRecorder()
ui.pageAssets(rr, req)
body := rr.Body.String()
for _, want := range []string{"识别配置", "总览", "模板", "业务配置", "叠加项", "local_3588_test", "workshop_face_shoe_alarm", "face_debug"} {
if !strings.Contains(body, want) {
t.Fatalf("expected assets HTML to contain %q", want)
}
}
}
func TestUI_ProfileAssetPageShowsInstanceSummary(t *testing.T) {
ui := newTestUI(t)
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{"name":"workshop_face_shoe_alarm","params":{"minio_endpoint":"http://10.0.0.49:9000","external_get_token_url":"http://10.0.0.49:8080/api/getToken"},"template":{"nodes":[{}],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
"name":"local_3588_test",
"business_name":"A厂区视觉识别",
"description":"test profile",
"queue":{"size":8,"strategy":"drop_oldest"},
"instances":[{
"name":"cam1",
"template":"workshop_face_shoe_alarm",
"params":{
"display_name":"东门入口",
"device_code":"rk3588-a-001",
"site_name":"A厂区",
"rtsp_url":"rtsp://10.0.0.1/live",
"publish_hls_path":"./web/hls/cam1/index.m3u8",
"publish_rtsp_port":8555,
"publish_rtsp_path":"/live/cam1",
"channel_no":"cam1",
"queue_debug":true
}
}]
}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root})
req := httptest.NewRequest(http.MethodGet, "/ui/assets/profiles/local_3588_test", nil)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, func() *chi.Context {
rctx := chi.NewRouteContext()
rctx.URLParams.Add("name", "local_3588_test")
return rctx
}()))
rr := httptest.NewRecorder()
ui.pageAssetProfile(rr, req)
body := rr.Body.String()
for _, want := range []string{"业务配置", "local_3588_test", "业务名称", "A厂区视觉识别", "站点名", "A厂区", "东门入口", "rtsp://10.0.0.1/live", "高级设置", "queue_debug"} {
if !strings.Contains(body, want) {
t.Fatalf("expected profile asset HTML to contain %q, got:\n%s", want, body)
}
}
for _, forbidden := range []string{"MinIO", "取 token 接口", "告警上报接口", "设备编号"} {
if strings.Contains(body, forbidden) {
t.Fatalf("profile asset HTML should no longer contain shared template field %q, got:\n%s", forbidden, body)
}
}
}
func TestUI_TemplateAssetsPageShowsListAndSelectedDetail(t *testing.T) {
ui := newTestUI(t)
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{"name":"workshop_face_shoe_alarm","description":"template","params":{"minio_endpoint":"http://10.0.0.49:9000"},"template":{"nodes":[{},{}],"edges":[[]]}}`)
writeTestFile(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{"name":"helmet","description":"helmet template","template":{"nodes":[{}],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"local_3588_test","business_name":"A厂区视觉识别","instances":[{"name":"cam1","template":"workshop_face_shoe_alarm","params":{"display_name":"东门入口"}}]}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root})
req := httptest.NewRequest(http.MethodGet, "/ui/assets/templates?name=helmet", nil)
rr := httptest.NewRecorder()
ui.pageAssetTemplates(rr, req)
body := rr.Body.String()
for _, want := range []string{"模板列表", "workshop_face_shoe_alarm", "helmet", "模板详情", "helmet template"} {
if !strings.Contains(body, want) {
t.Fatalf("expected template assets page to contain %q, got:\n%s", want, body)
}
}
}
func TestUI_OverlayAssetsPageShowsListAndSelectedDetail(t *testing.T) {
ui := newTestUI(t)
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{"name":"workshop_face_shoe_alarm","template":{"nodes":[{}],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"local_3588_test","instances":[{"name":"cam1","template":"workshop_face_shoe_alarm","params":{"display_name":"东门入口"}}]}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "night_relaxed.json"), `{"description":"relaxed","instance_overrides":{"cam2":{"override":{}}}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root})
req := httptest.NewRequest(http.MethodGet, "/ui/assets/overlays?name=night_relaxed", nil)
rr := httptest.NewRecorder()
ui.pageAssetOverlays(rr, req)
body := rr.Body.String()
for _, want := range []string{"配置叠加项列表", "face_debug", "night_relaxed", "relaxed", "cam2"} {
if !strings.Contains(body, want) {
t.Fatalf("expected overlay assets page to contain %q, got:\n%s", want, body)
}
}
}
func TestUI_ProfileAssetsPageShowsListAndSelectedEditor(t *testing.T) {
ui := newTestUI(t)
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{"name":"workshop_face_shoe_alarm","template":{"nodes":[{}],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
"name":"local_3588_test",
"business_name":"A厂区视觉识别",
"description":"test profile",
"queue":{"size":8,"strategy":"drop_oldest"},
"instances":[{"name":"cam1","template":"workshop_face_shoe_alarm","params":{"display_name":"东门入口","rtsp_url":"rtsp://10.0.0.1/live"}}]
}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "night_shift.json"), `{
"name":"night_shift",
"business_name":"夜班巡检",
"description":"night profile",
"queue":{"size":4,"strategy":"drop_oldest"},
"instances":[{"name":"cam9","template":"workshop_face_shoe_alarm","params":{"display_name":"西门","rtsp_url":"rtsp://10.0.0.9/live"}}]
}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root})
req := httptest.NewRequest(http.MethodGet, "/ui/assets/profiles?name=night_shift", nil)
rr := httptest.NewRecorder()
ui.pageAssetProfiles(rr, req)
body := rr.Body.String()
for _, want := range []string{"业务配置列表", "local_3588_test", "night_shift", "业务配置", "夜班巡检", "night profile", "西门", "rtsp://10.0.0.9/live"} {
if !strings.Contains(body, want) {
t.Fatalf("expected profile assets page to contain %q, got:\n%s", want, body)
}
}
}
func TestUI_AuditAndSystemPagesDefineNewScopes(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, Version: "1.0.0", BuildID: "20260419.151628", GitSha: "5c04681"})
reg.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true, Version: "1.0.1", BuildID: "20260419.151900", GitSha: "8eaf213"})
tasks := service.NewTaskService(cfg, nil, reg)
taskConfig, err := tasks.CreateTask("config_apply", []string{"edge-01", "edge-02"}, map[string]any{"config": map[string]any{}})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
taskService, err := tasks.CreateTask("media_restart", []string{"edge-01", "edge-02"}, nil)
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
taskConfig.Mu.Lock()
taskConfig.Status = models.TaskSuccess
for _, did := range taskConfig.DeviceIDs {
if ds, ok := taskConfig.Devices[did]; ok && ds != nil {
ds.Status = models.TaskSuccess
}
}
taskConfig.Mu.Unlock()
taskService.Mu.Lock()
taskService.Status = models.TaskSuccess
for _, did := range taskService.DeviceIDs {
if ds, ok := taskService.Devices[did]; ok && ds != nil {
ds.Status = models.TaskSuccess
}
}
taskService.Mu.Unlock()
ui, err := NewUI(nil, reg, nil, tasks, nil)
if err != nil {
t.Fatalf("NewUI: %v", err)
}
rrAudit := httptest.NewRecorder()
ui.pageAudit(rrAudit, httptest.NewRequest(http.MethodGet, "/ui/audit", nil))
for _, want := range []string{"诊断 / 审计记录", "审计记录", "批量配置", "批量服务", "目标设备数", "2 台", "下发识别配置", "重启视频分析服务"} {
if !strings.Contains(rrAudit.Body.String(), want) {
t.Fatalf("expected audit HTML to contain %q", want)
}
}
for _, forbidden := range []string{"框架版", "后续", "任务中心", "节点执行情况", `disabled`} {
if strings.Contains(rrAudit.Body.String(), forbidden) {
t.Fatalf("audit HTML should not contain placeholder marker %q", forbidden)
}
}
for _, forbidden := range []string{"success", "failed", "running"} {
if strings.Contains(rrAudit.Body.String(), forbidden) {
t.Fatalf("audit HTML should not leak raw status enum %q", forbidden)
}
}
rrTasks := httptest.NewRecorder()
ui.pageTasks(rrTasks, httptest.NewRequest(http.MethodGet, "/ui/tasks", nil))
for _, want := range []string{"执行历史", "目标设备数"} {
if !strings.Contains(rrTasks.Body.String(), want) {
t.Fatalf("expected tasks HTML to contain %q", want)
}
}
for _, forbidden := range []string{"任务中心", "节点执行情况", "创建任务", "<h2>目标设备</h2>", "高级参数JSON", `name="device_ids"`, `name="payload_json"`} {
if strings.Contains(rrTasks.Body.String(), forbidden) {
t.Fatalf("tasks HTML should not contain placeholder marker %q", forbidden)
}
}
rrTaskConfig := httptest.NewRecorder()
reqTask := httptest.NewRequest(http.MethodGet, "/ui/tasks/"+taskConfig.ID, nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", taskConfig.ID)
reqTask = reqTask.WithContext(context.WithValue(reqTask.Context(), chi.RouteCtxKey, rctx))
ui.pageTask(rrTaskConfig, reqTask)
for _, want := range []string{"任务概览", "下发识别配置", "返回任务列表"} {
if !strings.Contains(rrTaskConfig.Body.String(), want) {
t.Fatalf("expected task detail HTML to contain %q", want)
}
}
for _, forbidden := range []string{"任务中心", "返回操作审计"} {
if strings.Contains(rrTaskConfig.Body.String(), forbidden) {
t.Fatalf("task detail HTML should not contain %q", forbidden)
}
}
rrSystem := httptest.NewRecorder()
ui.pageSystem(rrSystem, httptest.NewRequest(http.MethodGet, "/ui/system", nil))
for _, want := range []string{"诊断 / 系统状态", "设备发现与注册", "Agent 访问策略", "后台健康", "当前设备数", "UDP 广播发现", "/health", "/openapi.json"} {
if !strings.Contains(rrSystem.Body.String(), want) {
t.Fatalf("expected system HTML to contain %q", want)
}
}
for _, forbidden := range []string{"框架版", "后续", "平台状态与访问入口"} {
if strings.Contains(rrSystem.Body.String(), forbidden) {
t.Fatalf("system HTML should not contain placeholder marker %q", forbidden)
}
}
}
func TestUI_SidebarMatchesApprovedIoTArchitecture(t *testing.T) {
ui := newTestUI(t)
html := renderPage(t, ui, "/ui/devices")
for _, label := range []string{"总览", "设备", "识别配置", "任务", "诊断"} {
if !strings.Contains(html, label) {
t.Fatalf("expected sidebar label %q in html: %s", label, html)
}
}
for _, removed := range []string{"配置管理", "系统状态", "操作审计"} {
if strings.Contains(html, removed) {
t.Fatalf("did not expect legacy top-level label %q in html: %s", removed, html)
}
}
}
func TestUI_DashboardShowsGlobalOperationsSummary(t *testing.T) {
ui := newTestUI(t)
html := renderPage(t, ui, "/ui/dashboard")
for _, text := range []string{"全局 KPI", "在线率", "最近任务", "异常设备"} {
if !strings.Contains(html, text) {
t.Fatalf("expected dashboard text %q in html: %s", text, html)
}
}
}
func TestUI_TasksPageOwnsBatchExecutionDomain(t *testing.T) {
ui := newTestUI(t)
html := renderPage(t, ui, "/ui/tasks")
for _, text := range []string{"执行历史", "目标设备数"} {
if !strings.Contains(html, text) {
t.Fatalf("expected tasks text %q in html: %s", text, html)
}
}
}
func TestUI_DiagnosticsPageOwnsLogsSystemAndAudit(t *testing.T) {
ui := newTestUI(t)
html := renderPage(t, ui, "/ui/diagnostics")
for _, text := range []string{"日志", "系统状态", "审计"} {
if !strings.Contains(html, text) {
t.Fatalf("expected diagnostics text %q in html: %s", text, html)
}
}
}
func TestUI_DeviceDetailActsAsSingleDeviceWorkspace(t *testing.T) {
ui := newTestUI(t)
html := renderPage(t, ui, "/ui/devices/edge-01")
for _, text := range []string{"概览", "运行与服务", "设备配置", "日志与指标"} {
if !strings.Contains(html, text) {
t.Fatalf("expected device workspace text %q in html: %s", text, html)
}
}
if strings.Contains(html, "进入配置管理") {
t.Fatalf("device detail should not contain cross-module config shortcut: %s", html)
}
}
func TestUI_DeviceWorkspaceContainsSingleDeviceOperations(t *testing.T) {
ui := newTestUI(t)
html := renderPage(t, ui, "/ui/devices/edge-01")
for _, text := range []string{"概览", "运行与服务", "设备配置", "模型与资源", "日志与指标"} {
if !strings.Contains(html, text) {
t.Fatalf("expected device workspace section %q in html: %s", text, html)
}
}
for _, removed := range []string{"返回设备选择", "配置管理面向单台设备"} {
if strings.Contains(html, removed) {
t.Fatalf("did not expect legacy standalone config flow text %q in html: %s", removed, html)
}
}
}
func TestUI_TaskDetailPresentsExecutionSections(t *testing.T) {
ui := newTestUI(t)
task, err := ui.tasks.CreateTask("config_apply", []string{"edge-01"}, map[string]any{"config": map[string]any{}})
if err != nil {
t.Fatalf("create task: %v", err)
}
html := renderPage(t, ui, "/ui/tasks/"+task.ID)
for _, text := range []string{"任务概览", "执行进度", "设备结果"} {
if !strings.Contains(html, text) {
t.Fatalf("expected task detail section %q in html: %s", text, html)
}
}
}
func TestUI_DiagnosticsSecondaryPagesUseDiagnosticsCrumb(t *testing.T) {
ui := newTestUI(t)
for _, item := range []struct {
path string
want string
}{
{path: "/ui/system", want: "诊断 / 系统状态"},
{path: "/ui/audit", want: "诊断 / 审计记录"},
{path: "/ui/api", want: "诊断 / 高级调试"},
} {
html := renderPage(t, ui, item.path)
if !strings.Contains(html, item.want) {
t.Fatalf("expected %s to contain crumb %q, got: %s", item.path, item.want, html)
}
}
}
func TestUI_TasksPageDoesNotExposeTaskCreationForm(t *testing.T) {
ui := newTestUI(t)
ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
html := renderPage(t, ui, "/ui/tasks?selected=edge-02")
for _, forbidden := range []string{"<h2>目标设备</h2>", `name="device_id"`, `name="device_ids"`, `name="payload_json"`, "创建任务", "<option value=\"config_apply\">下发识别配置</option>"} {
if strings.Contains(html, forbidden) {
t.Fatalf("tasks page should not expose task-creation UI %q, got: %s", forbidden, html)
}
}
}
func TestUI_TasksPageDoesNotShowTransitionQuickActions(t *testing.T) {
ui := newTestUI(t)
html := renderPage(t, ui, "/ui/tasks")
for _, forbidden := range []string{"常用动作", "<span class=\"pill\">批量下发</span>", "<span class=\"pill\">批量重启</span>", "<span class=\"pill\">批量回滚</span>"} {
if strings.Contains(html, forbidden) {
t.Fatalf("tasks page should not contain transition quick action block %q, got: %s", forbidden, html)
}
}
}
func TestUI_ActionCreateTaskUsesSelectedDeviceCheckboxes(t *testing.T) {
ui := newTestUI(t)
ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
form := url.Values{}
form.Set("type", "media_restart")
form.Add("device_id", "edge-01")
form.Add("device_id", "edge-02")
form.Set("payload_json", `{}`)
req := httptest.NewRequest(http.MethodPost, "/ui/tasks", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
ui.actionCreateTask(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
loc := rr.Header().Get("Location")
taskID := strings.TrimPrefix(loc, "/ui/tasks/")
var created *models.Task
for _, item := range ui.tasks.ListTasks() {
if item.ID == taskID {
task := item
created = &task
break
}
}
if created == nil {
t.Fatalf("expected created task %q to exist", taskID)
}
if got := strings.Join(created.DeviceIDs, ","); got != "edge-01,edge-02" {
t.Fatalf("expected selected devices preserved in order, got %q", got)
}
}
func TestUI_TasksPageShowsPersistedHistory(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})
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewTasksRepo(store.DB())
task := models.NewTask("task-persisted", "reload", []string{"edge-01"}, nil)
task.Status = models.TaskSuccess
task.Devices["edge-01"].Status = models.TaskSuccess
task.Devices["edge-01"].Progress = 1
if err := repo.Save(task); err != nil {
t.Fatalf("Save: %v", err)
}
tasks := service.NewTaskService(cfg, nil, reg, repo)
if err := tasks.LoadPersistedTasks(); err != nil {
t.Fatalf("LoadPersistedTasks: %v", err)
}
ui, err := NewUI(nil, reg, nil, tasks, nil)
if err != nil {
t.Fatalf("NewUI: %v", err)
}
html := renderPage(t, ui, "/ui/tasks")
for _, want := range []string{"task-persisted", "重载识别服务", "1 台"} {
if !strings.Contains(html, want) {
t.Fatalf("expected persisted task history to contain %q, got: %s", want, html)
}
}
}
func TestUI_AssetsOverviewShowsImportAction(t *testing.T) {
ui := newTestUI(t)
html := renderPage(t, ui, "/ui/assets")
if !strings.Contains(html, "导入现有 JSON") {
t.Fatalf("expected assets overview to contain import action, got: %s", html)
}
}
func TestUI_AssetTemplateExportsJSON(t *testing.T) {
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
const raw = "{\n \"name\": \"helmet\",\n \"template\": {\n \"nodes\": [],\n \"edges\": []\n }\n}\n"
if err := repo.SaveTemplate("helmet", "helmet template", raw); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
ui := newTestUI(t)
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
req := httptest.NewRequest(http.MethodGet, "/ui/assets/templates/helmet/export", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("name", "helmet")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rr := httptest.NewRecorder()
ui.pageAssetTemplateExport(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("Content-Disposition"); !strings.Contains(got, "helmet.json") {
t.Fatalf("expected attachment filename, got %q", got)
}
if rr.Body.String() != raw {
t.Fatalf("unexpected export body: %s", rr.Body.String())
}
}
func TestUI_AssetTemplateShowsSaveAsExportButton(t *testing.T) {
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
ui := newTestUI(t)
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
html := renderPage(t, ui, "/ui/assets/templates?name=helmet")
for _, want := range []string{"另存为 JSON", `data-export-url="/ui/assets/templates/helmet/export"`, `data-default-filename="helmet.json"`, "showSaveFilePicker", "当前浏览器不支持选择保存目录和文件名"} {
if !strings.Contains(html, want) {
t.Fatalf("expected asset template html to contain %q, got: %s", want, html)
}
}
}
func TestUI_AuditPagePrefersPersistedAuditLogs(t *testing.T) {
ui := newTestUI(t)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
auditRepo := storage.NewAuditLogsRepo(store.DB())
if err := auditRepo.Append(storage.AuditLogRecord{
Actor: "system",
Action: "config_apply",
TargetType: "device",
TargetID: "edge-01",
DetailsJSON: `{"task_id":"task-99","profile":"gate_a","status":"success"}`,
}); err != nil {
t.Fatalf("Append: %v", err)
}
ui.auditRepo = auditRepo
html := renderPage(t, ui, "/ui/audit")
for _, want := range []string{"task-99", "gate_a", "下发业务配置", "成功", "edge-01"} {
if !strings.Contains(html, want) {
t.Fatalf("expected audit page to contain %q, got: %s", want, html)
}
}
if strings.Contains(html, "暂无审计记录") {
t.Fatalf("expected persisted audit logs to replace empty state, got: %s", html)
}
if strings.Contains(html, "config_apply") || strings.Contains(html, "success") {
t.Fatalf("expected audit page to avoid raw enums, got: %s", html)
}
}
func TestUI_DeviceDetailFallsBackToPersistedConfigState(t *testing.T) {
ui := newTestUI(t)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
stateRepo := storage.NewDeviceConfigStateRepo(store.DB())
if err := stateRepo.Upsert(storage.DeviceConfigStateRecord{
DeviceID: "edge-01",
TemplateName: "helmet",
ProfileName: "gate_a",
OverlaysJSON: `["night_relaxed"]`,
ConfigID: "cfg-001",
ConfigVersion: "20260427.1",
LastAppliedTaskID: "task-1",
}); err != nil {
t.Fatalf("Upsert: %v", err)
}
ui.stateRepo = stateRepo
html := renderPage(t, ui, "/ui/devices/edge-01")
for _, want := range []string{"cfg-001", "20260427.1", "helmet", "gate_a", "night_relaxed"} {
if !strings.Contains(html, want) {
t.Fatalf("expected device detail to contain %q, got: %s", want, html)
}
}
}
func TestUI_SystemPageShowsDatabaseBackupAction(t *testing.T) {
ui := newTestUI(t)
ui.dbPath = filepath.Join(t.TempDir(), "app.db")
html := renderPage(t, ui, "/ui/system")
for _, want := range []string{
"数据备份 / 恢复",
"恢复数据库",
`class="btn ghost js-export-db"`,
`data-export-url="/ui/system/db-backup"`,
`data-default-filename="app.db"`,
"showSaveFilePicker",
"当前浏览器不支持选择保存目录和文件名",
} {
if !strings.Contains(html, want) {
t.Fatalf("expected system page to contain %q, got: %s", want, html)
}
}
}
func TestUI_SystemPageShowsFlashMessageFromQuery(t *testing.T) {
ui := newTestUI(t)
ui.dbPath = filepath.Join(t.TempDir(), "app.db")
html := renderPage(t, ui, "/ui/system?msg=%E6%95%B0%E6%8D%AE%E5%BA%93%E6%81%A2%E5%A4%8D%E5%AE%8C%E6%88%90")
if !strings.Contains(html, "数据库恢复完成") {
t.Fatalf("expected system page to show success message, got: %s", html)
}
}
func TestUI_SystemDBBackupUsesTimestampedFilename(t *testing.T) {
ui := newTestUI(t)
dir := t.TempDir()
ui.dbPath = filepath.Join(dir, "app.db")
if err := os.WriteFile(ui.dbPath, []byte("sqlite-bytes"), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/ui/system/db-backup", nil)
rr := httptest.NewRecorder()
ui.pageSystemDBBackup(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
got := rr.Header().Get("Content-Disposition")
if !strings.Contains(got, "attachment;") || !strings.Contains(got, "app-") || !strings.Contains(got, ".db") {
t.Fatalf("expected timestamped filename, got %q", got)
}
}
func TestUI_SystemDBRestoreReplacesDatabaseFile(t *testing.T) {
ui := newTestUI(t)
dir := t.TempDir()
ui.dbPath = filepath.Join(dir, "app.db")
if err := os.WriteFile(ui.dbPath, []byte("old-db"), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "restore.db")
if err != nil {
t.Fatalf("CreateFormFile: %v", err)
}
if _, err := part.Write([]byte("new-db")); err != nil {
t.Fatalf("Write part: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("Close writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/ui/system/db-restore", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
rr := httptest.NewRecorder()
ui.actionSystemDBRestore(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
restored, err := os.ReadFile(ui.dbPath)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(restored) != "new-db" {
t.Fatalf("expected restored db contents, got %q", string(restored))
}
}
func TestUI_SystemDBRestoreRequiresSelectedFile(t *testing.T) {
ui := newTestUI(t)
dir := t.TempDir()
ui.dbPath = filepath.Join(dir, "app.db")
if err := os.WriteFile(ui.dbPath, []byte("old-db"), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if err := writer.Close(); err != nil {
t.Fatalf("Close writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/ui/system/db-restore", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
rr := httptest.NewRecorder()
ui.actionSystemDBRestore(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String())
}
html := rr.Body.String()
if !strings.Contains(html, "请先选择数据库备份文件") {
t.Fatalf("expected friendly error message, got: %s", html)
}
if strings.Contains(html, "http: no such file") {
t.Fatalf("expected raw form-file error to stay hidden, got: %s", html)
}
}