3588AdminBackend/internal/web/ui_test.go

4440 lines
179 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_LayoutProvidesThemeSwitcherAndCompactTopbar(t *testing.T) {
ui := newTestUI(t)
body := renderPage(t, ui, "/ui/devices")
for _, want := range []string{
`data-theme="blue-dark"`,
`class="topbar-actions"`,
`data-theme-option="blue-dark"`,
`data-theme-option="blue-light"`,
`data-theme-option="graphite-gold"`,
`const themeLabels = {`,
`"blue-light": "蓝灰浅色"`,
`"当前主题:" + themeLabels[nextTheme]`,
`aria-label="主题"`,
`aria-label="日志审计"`,
`aria-label="系统"`,
`localStorage.getItem("3588-admin-theme")`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected layout to contain %q, got:\n%s", want, body)
}
}
for _, forbidden := range []string{
`class="crumb"`,
"多设备视觉识别运维平台",
"Fleet Operations Console",
} {
if strings.Contains(body, forbidden) {
t.Fatalf("expected compact topbar to omit %q, got:\n%s", forbidden, 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{
`body[data-theme="blue-light"]`,
`body[data-theme="graphite-gold"]`,
"--radius:4px",
".nav-section{padding:14px 10px 6px;font-size:11px",
".side-nav a{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:var(--radius);color:var(--sidebar-text);font-size:13px;font-weight:500}",
".topbar h1{margin:0;font-size:18px;font-weight:600",
".card h2{margin:0 0 8px;font-size:15px;font-weight:600;color:var(--text)}",
".card h3{margin:0 0 6px;font-size:13px;font-weight:600;color:var(--text)}",
".btn.ghost.icon-only{background:transparent;border-color:transparent;color:var(--muted)}",
".topbar{height:60px;background:var(--topbar);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 28px;position:sticky;top:0;z-index:100}",
".topbar-icon-btn{position:relative",
".theme-menu-panel{position:fixed;right:28px;top:54px;",
"z-index:9999",
".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:var(--radius)",
} {
if !strings.Contains(text, want) {
t.Fatalf("expected stylesheet to contain %q", want)
}
}
}
func TestUI_BlueDarkThemeKeepsWorkspaceDarkAndTablesReadable(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{
"--bg:#090909",
"--surface:#121212",
"--surface-soft:#1a1a1a",
"--table-text:#c8c8c8",
"--table-link:#f4f4f4",
"--primary:#dddddd",
"--selected-row:#303030",
"--border:#303030",
"--border-strong:#565656",
"--button-soft:#1d1d1d",
"--button-soft-hover:#292929",
"--input-bg:#101010",
"--green:#66c98f",
"--amber:#d8a657",
"--red:#e46f72",
".card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;margin:14px 0;box-shadow:var(--shadow);color:var(--text)}",
".form-hint{color:var(--muted);font-size:12px;line-height:1.35}",
"th,td{padding:8px 12px;border-bottom:1px solid var(--border);text-align:left;vertical-align:middle;line-height:1.25;color:var(--table-text)}",
".table-wrap td a{color:var(--table-link)}",
".device-name{font-size:13px;font-weight:400;color:var(--table-link)}",
".asset-link>span:first-child{color:var(--table-link)}",
} {
if !strings.Contains(text, want) {
t.Fatalf("expected blue dark theme 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) {
t.Skip("legacy batch scene-config flow replaced by device-assignment batch apply")
ui := newTestUI(t)
ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigMediaRepo(t)}, repo)
mustImportAssetsForUI(t, ui.preview)
mustImportAssetsForUI(t, ui.preview)
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厂区视觉识别",
"std_workshop_face_recognition_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) {
t.Skip("legacy batch scene-config flow replaced by device-assignment batch apply")
ui := newTestUI(t)
ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigMediaRepo(t)}, repo)
mustImportAssetsForUI(t, ui.preview)
mustImportAssetsForUI(t, ui.preview)
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"] != "std_workshop_face_recognition_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) {
t.Skip("legacy batch scene-config flow replaced by device-assignment batch apply")
ui := newTestUI(t)
ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigBrokenMediaRepo(t)}, repo)
mustImportAssetsForUI(t, ui.preview)
mustImportAssetsForUI(t, ui.preview)
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.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) {
t.Skip("legacy scene-config editor replaced by scene-template and recognition-unit pages")
ui := newTestUI(t)
root := createProfileEditorMediaRepo(t)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{
"name": "std_workshop_face_recognition_shoe_alarm",
"source": "standard",
"slots": {
"inputs": [
{"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}
],
"services": [
{"name": "object_storage_main", "type": "object_storage", "required": false, "description": "对象存储"},
{"name": "token_service_main", "type": "token_service", "required": false, "description": "认证服务"},
{"name": "alarm_service_main", "type": "alarm_service", "required": false, "description": "告警服务"}
],
"outputs": [
{"name": "stream_output_main", "type": "stream_publish", "required": true, "description": "主视频输出"}
]
},
"template": {"nodes": [], "edges": []}
}`)
mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`)
mustSaveVideoSource(t, repo, "line_cam_02", `{"name":"line_cam_02","source_type":"rtsp","area":"西门入口","config":{"url":"rtsp://10.0.0.2/live"}}`)
mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
mustSaveIntegrationService(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","description":"认证","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`)
mustSaveIntegrationService(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","description":"告警","config":{"put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
mustImportAssetsForUI(t, ui.preview)
req := httptest.NewRequest(http.MethodGet, "/ui/assets/profiles/local_3588_test?edit=1", 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",
"主视频输入",
"主视频输出",
"新建场景",
"编辑",
"A厂区视觉识别",
"东门入口",
"编辑模式",
"正在编辑场景配置",
`id="active-instance-input"`,
`name="profile_name" value="local_3588_test"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected profile editor page to contain %q, got:\n%s", want, body)
}
}
for _, forbidden := range []string{"生成预览", "上传为候选配置", "目标设备", "发布入口", "请在设备页中预览并下发", "下发方式", "Profile 编辑页签", "设备编号", `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)
}
}
for _, forbidden := range []string{"队列大小", "队列策略"} {
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) {
t.Skip("legacy scene-config editor replaced by scene-template and recognition-unit pages")
root := createProfileEditorMediaRepo(t)
ui := newTestUI(t)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.2/live"}}`)
mustSaveVideoSource(t, repo, "line_cam_02", `{"name":"line_cam_02","source_type":"rtsp","area":"西门入口","config":{"url":"rtsp://10.0.0.3/live"}}`)
mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
mustSaveIntegrationService(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","description":"认证","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`)
mustSaveIntegrationService(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","description":"告警","config":{"put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
mustImportAssetsForUI(t, ui.preview)
form := url.Values{}
form.Set("profile_name", "local_3588_test")
form.Set("business_name", "B厂区视觉识别")
form.Set("description", "updated profile")
form.Set("overlay_name", "face_debug")
form.Set("site_name", "B厂区")
form.Set("instances[0].name", "cam1")
form.Set("instances[0].template", "std_workshop_face_recognition_shoe_alarm")
form.Set("instances[0].display_name", "西门入口")
form.Set("instances[0].input_bindings.video_input_main.video_source_ref", "gate_cam_01")
form.Set("instances[0].service_bindings.object_storage_main.service_ref", "minio_main")
form.Set("instances[0].service_bindings.token_service_main.service_ref", "token_main")
form.Set("instances[0].service_bindings.alarm_service_main.service_ref", "alarm_main")
form.Set("instances[0].output_bindings.stream_output_main.channel_no", "cam1")
form.Set("instances[0].output_bindings.stream_output_main.publish_hls_path", "./web/hls/cam1/index.m3u8")
form.Set("instances[0].output_bindings.stream_output_main.publish_rtsp_port", "8556")
form.Set("instances[0].output_bindings.stream_output_main.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", "std_workshop_face_recognition_shoe_alarm")
form.Set("instances[1].display_name", "视觉识别终端-C厂区")
form.Set("instances[1].input_bindings.video_input_main.video_source_ref", "line_cam_02")
form.Set("instances[1].output_bindings.stream_output_main.channel_no", "cam2")
form.Set("instances[1].output_bindings.stream_output_main.publish_hls_path", "./web/hls/cam2/index.m3u8")
form.Set("instances[1].output_bindings.stream_output_main.publish_rtsp_port", "8557")
form.Set("instances[1].output_bindings.stream_output_main.publish_rtsp_path", "/live/cam2")
form.Set("instances[1].delete", "1")
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.StatusFound {
t.Fatalf("expected 302, got %d: %s", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/plans?") || !strings.Contains(got, "name=local_3588_test") {
t.Fatalf("expected redirect back to plans, got %q", got)
}
saved, err := repo.GetProfile("local_3588_test")
if err != nil {
t.Fatalf("GetProfile: %v", err)
}
if saved == nil {
t.Fatal("expected saved profile in repo")
}
var doc map[string]any
if err := json.Unmarshal([]byte(saved.BodyJSON), &doc); err != nil {
t.Fatalf("unmarshal saved profile: %v", err)
}
if doc["description"] != "updated profile" {
t.Fatalf("unexpected description: %#v", doc)
}
if overlays, _ := doc["overlays"].([]any); len(overlays) != 1 || overlays[0] != "face_debug" {
t.Fatalf("expected selected overlay to be saved, got %#v", doc["overlays"])
}
queue, _ := doc["queue"].(map[string]any)
if queue["size"] != float64(8) || queue["strategy"] != "drop_oldest" {
t.Fatalf("expected default queue config, 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)
}
inputBindings, _ := instance["input_bindings"].(map[string]any)
videoInput, _ := inputBindings["video_input_main"].(map[string]any)
if videoInput["video_source_ref"] != "gate_cam_01" {
t.Fatalf("expected slot-driven video source ref, got %#v", inputBindings)
}
serviceBindings, _ := instance["service_bindings"].(map[string]any)
objectStorage, _ := serviceBindings["object_storage_main"].(map[string]any)
if objectStorage["service_ref"] != "minio_main" {
t.Fatalf("expected object storage binding, got %#v", serviceBindings)
}
outputBindings, _ := instance["output_bindings"].(map[string]any)
streamOutput, _ := outputBindings["stream_output_main"].(map[string]any)
if streamOutput["publish_rtsp_port"] != float64(8556) {
t.Fatalf("expected numeric slot publish_rtsp_port, got %#v", streamOutput["publish_rtsp_port"])
}
sceneMeta, _ := instance["scene_meta"].(map[string]any)
if sceneMeta["display_name"] != "西门入口" || sceneMeta["site_name"] != "B厂区" {
t.Fatalf("expected scene meta on saved profile, got %#v", sceneMeta)
}
if params["queue_debug"] != true {
t.Fatalf("expected advanced params to survive save, got %#v", params)
}
if _, exists := params["video_source_ref"]; exists {
t.Fatalf("expected new scene document to avoid legacy param duplication, got %#v", params)
}
}
func createBatchConfigMediaRepo(t *testing.T) string {
t.Helper()
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{
"name":"std_workshop_face_recognition_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":"std_workshop_face_recognition_shoe_alarm",
"scene_meta":{"display_name":"东门入口","site_name":"A厂区"},
"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},
"output_bindings":{"stream_output_main":{"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()
with open(args.profile, "r", encoding="utf-8") as fh:
profile_doc = json.load(fh)
doc = {
"metadata": {
"config_id": args.config_id,
"config_version": args.config_version,
"template": os.path.splitext(os.path.basename(args.template))[0],
"profile": profile_doc.get("name") or 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", "std_workshop_face_recognition_shoe_alarm.json"), `{
"name": "std_workshop_face_recognition_shoe_alarm",
"slots": {
"inputs": [
{"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}
],
"services": [
{"name": "object_storage_main", "type": "object_storage", "required": false, "description": "对象存储"},
{"name": "token_service_main", "type": "token_service", "required": false, "description": "认证服务"},
{"name": "alarm_service_main", "type": "alarm_service", "required": false, "description": "告警服务"}
],
"outputs": [
{"name": "stream_output_main", "type": "stream_publish", "required": true, "description": "主视频输出"}
]
},
"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": "std_workshop_face_recognition_shoe_alarm",
"params": {"queue_debug": true},
"scene_meta": {"display_name": "东门入口", "device_code": "rk3588-a-001", "site_name": "A厂区"},
"input_bindings": {"video_input_main": {"video_source_ref": "gate_cam_01"}},
"service_bindings": {
"object_storage_main": {"service_ref": "minio_main"},
"token_service_main": {"service_ref": "token_main"},
"alarm_service_main": {"service_ref": "alarm_main"}
},
"output_bindings": {"stream_output_main": {"publish_hls_path": "./web/hls/cam1/index.m3u8", "publish_rtsp_port": 8555, "publish_rtsp_path": "/live/cam1", "channel_no": "cam1"}}
},
{
"name": "cam2",
"template": "std_workshop_face_recognition_shoe_alarm",
"scene_meta": {"display_name": "西门入口", "site_name": "A厂区"},
"input_bindings": {"video_input_main": {"video_source_ref": "line_cam_02"}},
"output_bindings": {"stream_output_main": {"publish_hls_path": "./web/hls/cam2/index.m3u8", "publish_rtsp_port": 8555, "publish_rtsp_path": "/live/cam2", "channel_no": "cam2"}}
},
{
"name": "cam3",
"template": "std_workshop_face_recognition_shoe_alarm",
"scene_meta": {"display_name": "1号产线", "site_name": "A厂区"},
"input_bindings": {"video_input_main": {"video_source_ref": "line_cam_03"}},
"output_bindings": {"stream_output_main": {"publish_hls_path": "./web/hls/cam3/index.m3u8", "publish_rtsp_port": 8555, "publish_rtsp_path": "/live/cam3", "channel_no": "cam3"}}
},
{
"name": "cam4",
"template": "std_workshop_face_recognition_shoe_alarm",
"scene_meta": {"display_name": "2号产线", "site_name": "A厂区"},
"input_bindings": {"video_input_main": {"video_source_ref": "line_cam_04"}},
"output_bindings": {"stream_output_main": {"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", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_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":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"东门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]
}`)
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 mustSaveIntegrationService(t *testing.T, repo *storage.AssetsRepo, name string, serviceType string, body string) {
t.Helper()
if err := repo.SaveIntegrationService(name, serviceType, name, true, body); err != nil {
t.Fatalf("SaveIntegrationService(%s): %v", name, err)
}
}
func mustSaveTemplate(t *testing.T, repo *storage.AssetsRepo, name string, description string, body string) {
t.Helper()
if err := repo.SaveTemplate(name, description, body); err != nil {
t.Fatalf("SaveTemplate(%s): %v", name, err)
}
}
func mustImportAssetsForUI(t *testing.T, preview *service.ConfigPreviewService) {
t.Helper()
if _, err := preview.ImportAssetsFromMediaRepo(); err != nil {
t.Fatalf("ImportAssetsFromMediaRepo: %v", err)
}
}
func mustSaveVideoSource(t *testing.T, repo *storage.AssetsRepo, name string, body string) {
t.Helper()
if err := repo.SaveVideoSource(name, "rtsp", "", name, body); err != nil {
t.Fatalf("SaveVideoSource(%s): %v", name, err)
}
}
func TestUI_TaskPageRendersBatchSummaryAndDeviceResults(t *testing.T) {
t.Skip("task summary expectations need to be rewritten for device-assignment apply metadata")
ui := newTestUI(t)
ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigMediaRepo(t)}, repo)
mustImportAssetsForUI(t, ui.preview)
form := url.Values{}
form.Add("device_id", "edge-01")
form.Add("device_id", "edge-02")
form.Set("template", "std_workshop_face_recognition_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": "std_workshop_face_recognition_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) {
t.Skip("device detail expectations need rewrite for device-assignment terminology")
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": "std_workshop_face_recognition_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",
"std_workshop_face_recognition_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)
}
}
for _, forbidden := range []string{"当前运行场景", "当前模板", "最近下发任务"} {
if strings.Contains(body, forbidden) {
t.Fatalf("expected scene config panel to avoid duplicate runtime text %q, got:\n%s", forbidden, body)
}
}
}
func TestUI_DeviceDetailDoesNotUseChannelDisplayNameAsDeviceName(t *testing.T) {
t.Skip("device detail expectations need rewrite for device-assignment terminology")
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) {
t.Skip("legacy config-preview form replaced by device-assignment preview")
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",
"生成预览",
} {
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) {
t.Skip("legacy candidate-config preview flow removed from migrated device-assignment preview page")
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": "std_workshop_face_recognition_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) {
t.Skip("legacy candidate-config flow is no longer exposed in migrated UI")
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":"std_workshop_face_recognition_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) {
t.Skip("legacy config-preview form replaced by device-assignment preview")
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: "std_workshop_face_recognition_shoe_alarm"}}, Profiles: []service.ConfigSource{{Name: "local_3588_test"}}, Overlays: []service.ConfigSource{{Name: "face_debug"}, {Name: "face_test_sensitive"}}},
SelectedTemplate: "std_workshop_face_recognition_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":"std_workshop_face_recognition_shoe_alarm","profile":"local_3588_test","overlays":["face_test_sensitive"]}}`,
Metadata: map[string]any{
"config_id": "preview_edge-01",
"config_version": "v1",
"template": "std_workshop_face_recognition_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: "std_workshop_face_recognition_shoe_alarm"}}, Profiles: []service.ConfigSource{{Name: "local_3588_test"}}, Overlays: []service.ConfigSource{{Name: "face_debug"}}},
SelectedTemplate: "std_workshop_face_recognition_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) {
t.Skip("legacy candidate-config apply summary removed from migrated device-assignment preview page")
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: "std_workshop_face_recognition_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: "std_workshop_face_recognition_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": "std_workshop_face_recognition_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) {
t.Skip("legacy candidate-config apply flow is no longer exposed in migrated UI")
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":"std_workshop_face_recognition_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",
"已清空",
"运行中",
} {
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_ModelsPageShowsStandardModelsAndDeviceStatus(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{"模型管理", "标准模型", "设备模型状态", "完整设备数", "缺失设备数", "版本不一致设备数", "更新全部模型"} {
if !strings.Contains(body, want) {
t.Fatalf("expected model management HTML to contain %q", want)
}
}
for _, forbidden := range []string{"统一模型目录", "人脸库", "查看设备模型"} {
if strings.Contains(body, forbidden) {
t.Fatalf("model management page should not contain legacy text %q", forbidden)
}
}
}
func TestUI_ActionModelSyncCreatesTask(t *testing.T) {
ui := newTestUI(t)
form := url.Values{}
form.Set("action", "model_sync_all")
form.Add("device_id", "edge-01")
req := httptest.NewRequest(http.MethodPost, "/ui/models/sync", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
ui.actionModelSync(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
tasks := ui.tasks.ListTasks()
if len(tasks) != 1 || tasks[0].Type != "model_sync_all" {
t.Fatalf("unexpected tasks: %#v", tasks)
}
}
func TestUI_ResourcesPageShowsUnifiedResourceStatus(t *testing.T) {
ui := newTestUI(t)
html := renderPage(t, ui, "/ui/resources")
for _, want := range []string{"资源管理", "人脸库版本", "设备资源状态", "入口识别节点"} {
if !strings.Contains(html, want) {
t.Fatalf("expected resources HTML to contain %q", want)
}
}
for _, forbidden := range []string{"上传模型", "单台设备"} {
if strings.Contains(html, forbidden) {
t.Fatalf("resources page should not contain legacy text %q", forbidden)
}
}
}
func TestUI_LogAuditPageRendersLogAndMetricLinks(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)
}
}
if strings.Contains(body, "进入系统状态") {
t.Fatalf("log audit page should not contain system status section")
}
}
func TestUI_SidebarMatchesInformationArchitecture(t *testing.T) {
t.Skip("sidebar expectations updated by new IA; replace with dedicated scene-template/unit/assignment assertions")
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/plans"`,
`href="/ui/assets"`,
`href="/ui/tasks"`,
`href="/ui/models"`,
`href="/ui/resources"`,
`href="/ui/diagnostics"`,
`href="/ui/system"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected sidebar to contain %q", want)
}
}
for _, old := range []string{"配置管理", "操作审计", `<a href="/ui/diagnostics"><span class="nav-icon">`} {
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) {
t.Skip("legacy candidate-config apply flow is no longer exposed in migrated UI")
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", "运行中"} {
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", "profiles", "local_3588_test.json"), `{"name":"local_3588_test","business_name":"A厂区视觉识别","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"东门入口"}}]}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "template", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","description":"template","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],"services":[{"name":"object_storage_main","type":"object_storage","required":false,"description":"对象存储"}],"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]},"template":{"nodes":[{},{}],"edges":[[]]}}`)
mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
mustImportAssetsForUI(t, ui.preview)
req := httptest.NewRequest(http.MethodGet, "/ui/assets", nil)
rr := httptest.NewRecorder()
ui.pageAssets(rr, req)
body := rr.Body.String()
for _, want := range []string{"基础配置", "总览", "视频源", "识别模板", "第三方服务", "调试参数", "std_workshop_face_recognition_shoe_alarm", "face_debug"} {
if !strings.Contains(body, want) {
t.Fatalf("expected assets HTML to contain %q", want)
}
}
if !strings.Contains(body, "minio_main") {
t.Fatalf("expected assets overview to contain integration service, got:\n%s", body)
}
}
func TestUI_AssetIntegrationsPageShowsStructuredServices(t *testing.T) {
ui := newTestUI(t)
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_main"}}}]}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
mustSaveIntegrationService(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","description":"认证","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
mustImportAssetsForUI(t, ui.preview)
req := httptest.NewRequest(http.MethodGet, "/ui/assets/integrations", nil)
rr := httptest.NewRecorder()
ui.pageAssetIntegrations(rr, req)
body := rr.Body.String()
for _, want := range []string{
"第三方服务列表",
"第三方服务详情",
"新增服务",
"编辑",
"删除",
"minio_main",
"token_main",
"对象存储",
"认证服务",
"http://10.0.0.49:9000 / myminio",
"1",
"查看模式",
`<strong class="mono">minio_main</strong>`,
`data-nav-row`,
`data-nav-href="/ui/assets/integrations?name=minio_main"`,
`class="selected"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected integrations page to contain %q, got:\n%s", want, body)
}
}
if strings.Contains(body, ">保存</span>") {
t.Fatalf("expected readonly details to hide save button, got:\n%s", body)
}
}
func TestUI_AssetIntegrationsPageNewModeClearsDetailForm(t *testing.T) {
ui := newTestUI(t)
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
mustImportAssetsForUI(t, ui.preview)
req := httptest.NewRequest(http.MethodGet, "/ui/assets/integrations?new=1", nil)
rr := httptest.NewRecorder()
ui.pageAssetIntegrations(rr, req)
body := rr.Body.String()
if strings.Contains(body, `value="minio_main"`) {
t.Fatalf("expected new mode to clear selected service detail, got:\n%s", body)
}
if !strings.Contains(body, `name="name" value="" autofocus`) {
t.Fatalf("expected new mode to autofocus empty name field, got:\n%s", body)
}
}
func TestUI_AssetIntegrationsPageEditModeEnablesFormAndAutofocusesName(t *testing.T) {
ui := newTestUI(t)
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
mustImportAssetsForUI(t, ui.preview)
req := httptest.NewRequest(http.MethodGet, "/ui/assets/integrations?name=minio_main&edit=1", nil)
rr := httptest.NewRecorder()
ui.pageAssetIntegrations(rr, req)
body := rr.Body.String()
if !strings.Contains(body, `name="name" value="minio_main" autofocus`) {
t.Fatalf("expected edit mode to autofocus selected name field, got:\n%s", body)
}
if !strings.Contains(body, ">保存</span>") {
t.Fatalf("expected edit mode to show save button, got:\n%s", body)
}
}
func TestUI_ActionAssetIntegrationSaveCreatesService(t *testing.T) {
ui := newTestUI(t)
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
mustImportAssetsForUI(t, ui.preview)
form := url.Values{}
form.Set("name", "minio_main")
form.Set("type", "object_storage")
form.Set("description", "主对象存储")
form.Set("enabled", "1")
form.Set("endpoint", "http://10.0.0.49:9000")
form.Set("bucket", "myminio")
form.Set("access_key", "admin")
form.Set("secret_key", "password")
req := httptest.NewRequest(http.MethodPost, "/ui/assets/integrations", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
ui.actionAssetIntegrationSave(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
record, err := repo.GetIntegrationService("minio_main")
if err != nil {
t.Fatalf("GetIntegrationService: %v", err)
}
if record == nil {
t.Fatal("expected service record")
}
if record.ServiceType != "object_storage" {
t.Fatalf("unexpected service type: %#v", record)
}
}
func TestUI_ActionAssetIntegrationDeleteBlocksReferencedService(t *testing.T) {
ui := newTestUI(t)
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_main"}}}]}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_main"}}}]}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
mustImportAssetsForUI(t, ui.preview)
req := httptest.NewRequest(http.MethodPost, "/ui/assets/integrations/minio_main/delete", nil)
req = withChiURLParam(req, "name", "minio_main")
rr := httptest.NewRecorder()
ui.actionAssetIntegrationDelete(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("Location"); !strings.Contains(got, "error=") {
t.Fatalf("expected error redirect, got %q", got)
}
}
func TestUI_AssetVideoSourcesPageShowsStructuredSources(t *testing.T) {
ui := newTestUI(t)
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","description":"东门主入口摄像头","config":{"url":"rtsp://10.0.0.1/live","resolution":"1080p","fps":"25"}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
mustImportAssetsForUI(t, ui.preview)
req := httptest.NewRequest(http.MethodGet, "/ui/assets/video-sources", nil)
rr := httptest.NewRecorder()
ui.pageAssetVideoSources(rr, req)
body := rr.Body.String()
for _, want := range []string{
"视频源列表",
"视频源详情",
"新增视频源",
"编辑",
"删除",
"gate_cam_01",
"RTSP",
"东门入口",
"1080p",
"查看模式",
`<strong class="mono">gate_cam_01</strong>`,
`data-nav-row`,
`data-nav-href="/ui/assets/video-sources?name=gate_cam_01"`,
`class="selected"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected video sources page to contain %q, got:\n%s", want, body)
}
}
}
func TestUI_AssetVideoSourcesPageNewModeClearsDetailForm(t *testing.T) {
ui := newTestUI(t)
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
mustImportAssetsForUI(t, ui.preview)
req := httptest.NewRequest(http.MethodGet, "/ui/assets/video-sources?new=1", nil)
rr := httptest.NewRecorder()
ui.pageAssetVideoSources(rr, req)
body := rr.Body.String()
if strings.Contains(body, `value="gate_cam_01"`) {
t.Fatalf("expected new mode to clear selected source detail, got:\n%s", body)
}
if !strings.Contains(body, `name="name" value="" autofocus`) {
t.Fatalf("expected new mode to autofocus empty name field, got:\n%s", body)
}
}
func TestUI_PlanPageShowsVideoSourceSelector(t *testing.T) {
t.Skip("legacy scene-config channel editor replaced by recognition-unit page")
ui := newTestUI(t)
root := createProfileEditorMediaRepo(t)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{
"name": "std_workshop_face_recognition_shoe_alarm",
"source": "standard",
"slots": {
"inputs": [
{"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}
],
"services": [
{"name": "object_storage_main", "type": "object_storage", "required": false, "description": "对象存储"},
{"name": "token_service_main", "type": "token_service", "required": false, "description": "认证服务"},
{"name": "alarm_service_main", "type": "alarm_service", "required": false, "description": "告警服务"}
],
"outputs": [
{"name": "stream_output_main", "type": "stream_publish", "required": true, "description": "主视频输出"}
]
},
"template": {"nodes": [], "edges": []}
}`)
mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live","resolution":"1080p"}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
mustImportAssetsForUI(t, ui.preview)
req := httptest.NewRequest(http.MethodGet, "/ui/plans?name=local_3588_test&edit=1", nil)
rr := httptest.NewRecorder()
ui.pagePlans(rr, req)
body := rr.Body.String()
for _, want := range []string{"主视频输入", `name="instances[0].input_bindings.video_input_main.video_source_ref"`, "gate_cam_01 - 东门入口", "编辑模式", "正在编辑场景配置"} {
if !strings.Contains(body, want) {
t.Fatalf("expected plan page to contain %q, got:\n%s", want, body)
}
}
}
func TestUI_ActionAssetVideoSourceSavePreservesFormOnError(t *testing.T) {
ui := newTestUI(t)
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, storage.NewAssetsRepo(store.DB()))
form := url.Values{}
form.Set("name", "东门主入口")
form.Set("source_type", "rtsp")
form.Set("area", "东门")
form.Set("description", "入口相机")
req := httptest.NewRequest(http.MethodPost, "/ui/assets/video-sources", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
ui.actionAssetVideoSourceSave(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected inline render on error, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{"视频源地址不能为空", `name="name" value="东门主入口" autofocus`, `name="area" value="东门"`, `name="description" value="入口相机"`} {
if !strings.Contains(body, want) {
t.Fatalf("expected preserved error form to contain %q, got:\n%s", want, body)
}
}
}
func TestUI_ProfileAssetPageShowsInstanceSummary(t *testing.T) {
t.Skip("legacy profile asset page replaced by scene-template and recognition-unit pages")
ui := newTestUI(t)
root := t.TempDir()
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]},"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":"std_workshop_face_recognition_shoe_alarm",
"params":{"queue_debug":true},
"scene_meta":{"display_name":"东门入口","device_code":"rk3588-a-001","site_name":"A厂区"},
"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},
"output_bindings":{"stream_output_main":{"publish_hls_path":"./web/hls/cam1/index.m3u8","publish_rtsp_port":8555,"publish_rtsp_path":"/live/cam1","channel_no":"cam1"}}
}]
}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
mustImportAssetsForUI(t, ui.preview)
req := httptest.NewRequest(http.MethodGet, "/ui/assets/profiles/local_3588_test?edit=1", 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厂区", "东门入口", "主视频输入", "主视频输出"} {
if !strings.Contains(body, want) {
t.Fatalf("expected profile asset HTML to contain %q, got:\n%s", want, body)
}
}
for _, forbidden := range []string{"高级设置 JSON", `name="instances[0].advanced_params"`} {
if strings.Contains(body, forbidden) {
t.Fatalf("profile asset HTML should omit %q, got:\n%s", forbidden, 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_ProfileAssetPageEditActionOpensInstanceEditor(t *testing.T) {
t.Skip("legacy profile asset page replaced by scene-template and recognition-unit pages")
ui := newTestUI(t)
root := t.TempDir()
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]},"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":"std_workshop_face_recognition_shoe_alarm",
"scene_meta":{"display_name":"东门入口"},
"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},
"output_bindings":{"stream_output_main":{"publish_hls_path":"./web/hls/cam1/index.m3u8"}}
}]
}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
mustImportAssetsForUI(t, ui.preview)
req := httptest.NewRequest(http.MethodGet, "/ui/assets/profiles/local_3588_test?edit=1", 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{
`id="active-instance-input"`,
`class="btn ghost js-open-instance-editor"`,
`data-instance-index="0"`,
`data-instance-editor="0"`,
`保存场景配置`,
`通道详情`,
`场景名称<span class="required-mark">*</span>`,
`通道名<span class="required-mark">*</span>`,
`主视频输入<span class="required-mark">*</span>`,
`const instanceEditorToggle = event.target.closest(".js-open-instance-editor");`,
`activeInput.value = index;`,
`panel.hidden = panel.getAttribute("data-instance-editor") !== index;`,
`input:not([type=hidden]):not([readonly]), textarea, select`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected profile asset HTML to contain %q, got:\n%s", want, body)
}
}
for _, forbidden := range []string{
`href="#profile-instance-0"`,
`class="card collapsible profile-instance-editor"`,
`<span>保存</span></button>`,
} {
if strings.Contains(body, forbidden) {
t.Fatalf("expected legacy profile editor markup to omit %q, got:\n%s", forbidden, body)
}
}
}
func TestUI_PlanPageHighlightsSelectedSceneAndShowsCrudActions(t *testing.T) {
t.Skip("legacy scene-config page replaced by scene-template page")
ui := newTestUI(t)
root := createProfileEditorMediaRepo(t)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{
"name": "std_workshop_face_recognition_shoe_alarm",
"source": "standard",
"slots": {
"inputs": [
{"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}
],
"outputs": [
{"name": "stream_output_main", "type": "stream_publish", "required": true, "description": "主视频输出"}
]
},
"template": {"nodes": [], "edges": []}
}`)
mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`)
if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "产线A", "A 线", `{"name":"line_a","business_name":"产线A","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
t.Fatalf("SaveProfile line_a: %v", err)
}
if err := repo.SaveProfile("line_b", "std_workshop_face_recognition_shoe_alarm", "产线B", "B 线", `{"name":"line_b","business_name":"产线B","instances":[{"name":"cam2","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
t.Fatalf("SaveProfile line_b: %v", err)
}
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
req := httptest.NewRequest(http.MethodGet, "/ui/plans?name=line_b", nil)
rr := httptest.NewRecorder()
ui.pagePlans(rr, req)
body := rr.Body.String()
for _, want := range []string{
`href="/ui/plans?new=1"`,
`href="/ui/plans?name=line_b&edit=1"`,
`action="/ui/plans/line_b/delete"`,
`href="/ui/plans?name=line_b"`,
`<strong class="mono">line_b</strong>`,
`查看模式`,
`data-profile-row`,
`data-profile-href="/ui/plans?name=line_b"`,
`class="selected"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected plan page to contain %q, got:\n%s", want, body)
}
}
}
func TestUI_ActionPlanDeleteBlocksProfileUsedByDevice(t *testing.T) {
t.Skip("legacy scene-config page replaced by scene-template/device-assignment flow")
ui := newTestUI(t)
root := createProfileEditorMediaRepo(t)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{
"name": "std_workshop_face_recognition_shoe_alarm",
"source": "standard",
"slots": {
"inputs": [
{"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}
]
},
"template": {"nodes": [], "edges": []}
}`)
mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`)
if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "产线A", "A 线", `{"name":"line_a","business_name":"产线A","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
stateRepo := storage.NewDeviceConfigStateRepo(store.DB())
if err := stateRepo.Upsert(storage.DeviceConfigStateRecord{
DeviceID: "edge-01",
TemplateName: "std_workshop_face_recognition_shoe_alarm",
ProfileName: "line_a",
}); err != nil {
t.Fatalf("Upsert: %v", err)
}
ui.stateRepo = stateRepo
req := httptest.NewRequest(http.MethodPost, "/ui/plans/line_a/delete", nil)
req = withChiURLParam(req, "name", "line_a")
rr := httptest.NewRecorder()
ui.actionPlanDelete(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.Contains(loc, "/ui/plans?") || !strings.Contains(loc, "error=") || !strings.Contains(loc, "edge-01") {
t.Fatalf("expected redirect with usage error, got %q", loc)
}
}
func TestUI_ActionPlanCreateRedirectsToNewScene(t *testing.T) {
t.Skip("legacy scene-config page replaced by scene-template page")
ui := newTestUI(t)
root := createProfileEditorMediaRepo(t)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{
"name": "std_workshop_face_recognition_shoe_alarm",
"source": "standard",
"slots": {
"inputs": [
{"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}
],
"outputs": [
{"name": "stream_output_main", "type": "stream_publish", "required": true, "description": "主视频输出"}
]
},
"template": {"nodes": [], "edges": []}
}`)
mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
form := url.Values{}
form.Set("profile_name", "line_c")
form.Set("business_name", "产线C")
form.Set("description", "C 线")
form.Set("instances[0].name", "cam1")
form.Set("instances[0].template", "std_workshop_face_recognition_shoe_alarm")
form.Set("instances[0].input_bindings.video_input_main.video_source_ref", "gate_cam_01")
form.Set("instances[0].output_bindings.stream_output_main.publish_rtsp_port", "8555")
req := httptest.NewRequest(http.MethodPost, "/ui/plans/create", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
ui.actionPlanCreate(rr, req)
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/plans?msg=%e5%9c%ba%e6%99%af%e9%85%8d%e7%bd%ae%e5%b7%b2%e4%bf%9d%e5%ad%98&name=line_c" {
t.Fatalf("expected redirect to new scene, got %q", got)
}
}
func TestUI_PlanPageUsesExportLabelAndSingleEditEntry(t *testing.T) {
t.Skip("legacy scene-config page replaced by scene-template page")
ui := newTestUI(t)
root := createProfileEditorMediaRepo(t)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{
"name": "std_workshop_face_recognition_shoe_alarm",
"source": "standard",
"slots": {
"inputs": [
{"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}
]
},
"template": {"nodes": [], "edges": []}
}`)
mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`)
if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "产线A", "A 线", `{"name":"line_a","business_name":"产线A","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
req := httptest.NewRequest(http.MethodGet, "/ui/plans?name=line_a", nil)
rr := httptest.NewRecorder()
ui.pagePlans(rr, req)
body := rr.Body.String()
if strings.Contains(body, "另存为 JSON") {
t.Fatalf("expected export label update, got:\n%s", body)
}
if !strings.Contains(body, "导出为 JSON") {
t.Fatalf("expected export label, got:\n%s", body)
}
if got := strings.Count(body, `href="/ui/plans?name=line_a&edit=1"`); got != 1 {
t.Fatalf("expected only list header edit action to remain, got %d", got)
}
if got := strings.Count(body, ">编辑</"); got != 2 {
t.Fatalf("expected list header edit plus row editor action, got %d", got)
}
}
func TestUI_PlanPageReadOnlyUsesStaticDetailView(t *testing.T) {
t.Skip("legacy scene-config page replaced by scene-template page")
ui := newTestUI(t)
root := createProfileEditorMediaRepo(t)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{
"name": "std_workshop_face_recognition_shoe_alarm",
"source": "standard",
"slots": {
"inputs": [{"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}],
"outputs": [{"name": "stream_output_main", "type": "stream_publish", "required": true, "description": "主视频输出"}]
},
"template": {"nodes": [], "edges": []}
}`)
mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`)
if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "产线A", "A 线", `{"name":"line_a","business_name":"产线A","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"东门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"output_bindings":{"stream_output_main":{"publish_rtsp_port":8555}}}]}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
req := httptest.NewRequest(http.MethodGet, "/ui/plans?name=line_a", nil)
rr := httptest.NewRecorder()
ui.pagePlans(rr, req)
body := rr.Body.String()
for _, want := range []string{
`class="detail-sheet"`,
`class="detail-grid profile-instance-grid detail-sheet"`,
`href="/ui/plans?name=line_a&edit=1"`,
`导出为 JSON`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected readonly plan view to contain %q, got:\n%s", want, body)
}
}
for _, forbidden := range []string{
`name="business_name"`,
`name="overlay_name"`,
`name="instances[0].name"`,
`正在编辑场景配置`,
} {
if strings.Contains(body, forbidden) {
t.Fatalf("expected readonly plan view to omit %q, got:\n%s", forbidden, body)
}
}
}
func TestUI_PlanPageEditModeShowsFormControls(t *testing.T) {
t.Skip("legacy scene-config page replaced by scene-template page")
ui := newTestUI(t)
root := createProfileEditorMediaRepo(t)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{
"name": "std_workshop_face_recognition_shoe_alarm",
"source": "standard",
"slots": {
"inputs": [{"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}],
"outputs": [{"name": "stream_output_main", "type": "stream_publish", "required": true, "description": "主视频输出"}]
},
"template": {"nodes": [], "edges": []}
}`)
mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`)
if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "产线A", "A 线", `{"name":"line_a","business_name":"产线A","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"output_bindings":{"stream_output_main":{"publish_rtsp_port":8555}}}]}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
req := httptest.NewRequest(http.MethodGet, "/ui/plans?name=line_a&edit=1", nil)
rr := httptest.NewRecorder()
ui.pagePlans(rr, req)
body := rr.Body.String()
for _, want := range []string{
`正在编辑场景配置`,
`name="business_name"`,
`name="overlay_name"`,
`name="instances[0].name"`,
`保存场景配置`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected edit plan view to contain %q, got:\n%s", want, body)
}
}
}
func TestUI_ActionDevicePlanApplyCreatesSingleDeviceTask(t *testing.T) {
t.Skip("device apply expectations need rewrite for device-assignment flow")
ui := newTestUI(t)
root := t.TempDir()
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]},"template":{"nodes":[{}],"edges":[]}}`)
mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`)
if err := repo.SaveProfile("local_3588_test", "std_workshop_face_recognition_shoe_alarm", "A厂区视觉识别", "profile", `{"name":"local_3588_test","business_name":"A厂区视觉识别","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"东门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
form := url.Values{}
form.Set("profile", "local_3588_test")
req := httptest.NewRequest(http.MethodPost, "/ui/devices/edge-01/plan-apply", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req = withChiURLParam(req, "id", "edge-01")
rr := httptest.NewRecorder()
ui.actionDevicePlanApply(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) != 1 || task.DeviceIDs[0] != "edge-01" {
t.Fatalf("expected single target edge-01, got %#v", task.DeviceIDs)
}
}
func TestUI_RequiredMarkUsesThemeColorClass(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{
`.field-grid label>span .required-mark{display:inline;color:var(--red);font-weight:600;margin-left:4px}`,
`--red:#e46f72`,
`--red:#a43f3f`,
`--red:#d66a63`,
} {
if !strings.Contains(text, want) {
t.Fatalf("expected stylesheet to contain %q", want)
}
}
}
func TestUI_ActionAssetProfileSaveAddInstanceDoesNotValidateBlankDraft(t *testing.T) {
t.Skip("legacy add-instance flow removed in scene-template migration")
ui := newTestUI(t)
root := t.TempDir()
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]},"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":"std_workshop_face_recognition_shoe_alarm",
"scene_meta":{"display_name":"东门入口"},
"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}
}]
}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
mustImportAssetsForUI(t, ui.preview)
form := url.Values{}
form.Set("profile_name", "local_3588_test")
form.Set("business_name", "A厂区视觉识别")
form.Set("active_instance", "0")
form.Set("instances[0].name", "cam1")
form.Set("instances[0].template", "std_workshop_face_recognition_shoe_alarm")
form.Set("instances[0].display_name", "东门入口")
form.Set("instances[0].input_bindings.video_input_main.video_source_ref", "gate_cam_01")
form.Set("add_instance", "1")
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()
for _, forbidden := range []string{"instance name is required", "video source is required"} {
if strings.Contains(body, forbidden) {
t.Fatalf("expected add instance draft to avoid validation error %q, got:\n%s", forbidden, body)
}
}
for _, want := range []string{
`value="1"`,
`id="profile-instance-1"`,
`data-instance-editor="1"`,
`name="instances[1].name" value="cam2"`,
`name="instances[1].output_bindings.stream_output_main.publish_rtsp_port" value="8555"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected add instance response to contain %q, got:\n%s", want, body)
}
}
for _, forbidden := range []string{
`name="instances[1].output_bindings.stream_output_main.publish_hls_path"`,
`name="instances[1].output_bindings.stream_output_main.publish_rtsp_path"`,
`name="instances[1].output_bindings.stream_output_main.channel_no"`,
} {
if strings.Contains(body, forbidden) {
t.Fatalf("expected add instance response to omit %q, got:\n%s", forbidden, body)
}
}
}
func TestUI_TemplateAssetsPageShowsListAndSelectedDetail(t *testing.T) {
ui := newTestUI(t)
root := t.TempDir()
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "template", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","description":"template","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]},"template":{"nodes":[{},{}],"edges":[[]]}}`)
mustSaveTemplate(t, repo, "helmet", "helmet template", `{"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":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"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}, repo)
mustImportAssetsForUI(t, ui.preview)
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{
"识别模板列表",
"std_workshop_face_recognition_shoe_alarm",
"helmet",
"模板详情",
"helmet template",
`data-nav-row`,
`data-nav-href="/ui/assets/templates?name=helmet"`,
`class="selected"`,
} {
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", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[{}],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"local_3588_test","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"东门入口"}}]}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"启用人脸识别和陌生候选调试日志,用于联调和测试。","instance_overrides":{"*":{"override":{}}}}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "night_relaxed.json"), `{"description":"夜间场景下放宽识别约束,减少低照环境漏检。","instance_overrides":{"cam2":{"override":{}}}}`)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","template":{"nodes":[{}],"edges":[]}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
mustImportAssetsForUI(t, ui.preview)
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",
"夜间场景下放宽识别约束,减少低照环境漏检。",
"cam2",
`data-nav-row`,
`data-nav-href="/ui/assets/overlays?name=night_relaxed"`,
`class="selected"`,
} {
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) {
t.Skip("legacy profile assets page replaced by scene-template page")
ui := newTestUI(t)
root := t.TempDir()
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":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"东门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]
}`)
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":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"西门"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_09"}}}]
}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}]},"template":{"nodes":[{}],"edges":[]}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
mustImportAssetsForUI(t, ui.preview)
mustImportAssetsForUI(t, ui.preview)
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", "西门", "gate_cam_09"} {
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_DeviceAssignmentsPageShowsBoard(t *testing.T) {
ui := newTestUI(t)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{
SceneTemplateName: "scene_a",
Name: "cam1",
DisplayName: "东门入口",
VideoSourceRef: "vs1",
OutputChannel: "cam1",
RTSPPort: "8555",
BodyJSON: `{"name":"cam1","scene_meta":{"display_name":"东门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`,
}); err != nil {
t.Fatalf("SaveRecognitionUnit cam1: %v", err)
}
if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{
SceneTemplateName: "scene_a",
Name: "cam2",
DisplayName: "西门入口",
VideoSourceRef: "vs2",
OutputChannel: "cam2",
RTSPPort: "8555",
BodyJSON: `{"name":"cam2","scene_meta":{"display_name":"西门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"vs2"}}}`,
}); err != nil {
t.Fatalf("SaveRecognitionUnit cam2: %v", err)
}
if err := repo.SaveDeviceAssignment("edge-01", "scene_a", "", `{"device_id":"edge-01","profile_name":"scene_a","recognition_units":["scene_a::cam1"]}`); err != nil {
t.Fatalf("SaveDeviceAssignment: %v", err)
}
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
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/device-assignments", nil)
rr := httptest.NewRecorder()
ui.pageDeviceAssignments(rr, req)
body := rr.Body.String()
for _, want := range []string{"设备分配", "识别单元", "未分配", "每台最多", "自动平均分配", "保存设备分配", "东门入口", "西门入口"} {
if !strings.Contains(body, want) {
t.Fatalf("expected device assignment board to contain %q, got:\n%s", want, body)
}
}
for _, want := range []string{"测试设备 03", "测试设备 08"} {
if !strings.Contains(body, want) {
t.Fatalf("expected device assignment board to include preview device %q, got:\n%s", want, body)
}
}
}
func TestUI_DeviceAssignmentsPageHidesAddControlsForFullDevice(t *testing.T) {
ui := newTestUI(t)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{
SceneTemplateName: "scene_a",
Name: "cam1",
DisplayName: "东门入口",
VideoSourceRef: "vs1",
OutputChannel: "cam1",
RTSPPort: "8555",
BodyJSON: `{"name":"cam1","scene_meta":{"display_name":"东门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`,
}); err != nil {
t.Fatalf("SaveRecognitionUnit cam1: %v", err)
}
if err := repo.SaveDeviceAssignment("edge-01", "scene_a", "", `{"device_id":"edge-01","profile_name":"scene_a","recognition_units":["scene_a::cam1"]}`); err != nil {
t.Fatalf("SaveDeviceAssignment: %v", err)
}
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
req := httptest.NewRequest(http.MethodGet, "/ui/device-assignments?max_units_per_device=1", nil)
rr := httptest.NewRecorder()
ui.pageDeviceAssignments(rr, req)
body := rr.Body.String()
start := strings.Index(body, `data-device-card="edge-01"`)
if start < 0 {
t.Fatalf("expected edge-01 card in html: %s", body)
}
end := strings.Index(body[start:], `</section>`)
if end < 0 {
t.Fatalf("expected end of edge-01 card in html: %s", body)
}
cardHTML := body[start : start+end]
if strings.Contains(cardHTML, "添加识别单元") {
t.Fatalf("expected full device card to hide add controls, got:\n%s", cardHTML)
}
}
func TestUI_ActionDeviceAssignmentSavePersistsBoardState(t *testing.T) {
ui := newTestUI(t)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{
SceneTemplateName: "scene_a",
Name: "cam1",
VideoSourceRef: "vs1",
OutputChannel: "cam1",
RTSPPort: "8555",
BodyJSON: `{"name":"cam1","input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`,
}); err != nil {
t.Fatalf("SaveRecognitionUnit cam1: %v", err)
}
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
form := url.Values{}
form.Set("max_units_per_device", "4")
form.Set("board_state_json", `{"devices":{"edge-01":["scene_a::cam1"]}}`)
req := httptest.NewRequest(http.MethodPost, "/ui/device-assignments", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
ui.actionDeviceAssignmentSave(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
item, err := ui.preview.GetDeviceAssignment("edge-01")
if err != nil {
t.Fatalf("GetDeviceAssignment: %v", err)
}
if item == nil || item.ProfileName != "scene_a" || len(item.RecognitionUnits) != 1 || item.RecognitionUnits[0] != "scene_a::cam1" {
t.Fatalf("unexpected saved assignment: %#v", item)
}
}
func TestUI_ActionDeviceAssignmentSaveIgnoresPreviewDevices(t *testing.T) {
ui := newTestUI(t)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{
SceneTemplateName: "scene_a",
Name: "cam1",
VideoSourceRef: "vs1",
OutputChannel: "cam1",
RTSPPort: "8555",
BodyJSON: `{"name":"cam1","input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`,
}); err != nil {
t.Fatalf("SaveRecognitionUnit cam1: %v", err)
}
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
form := url.Values{}
form.Set("max_units_per_device", "4")
form.Set("board_state_json", `{"devices":{"demo-edge-02":["scene_a::cam1"]}}`)
req := httptest.NewRequest(http.MethodPost, "/ui/device-assignments", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
ui.actionDeviceAssignmentSave(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
item, err := ui.preview.GetDeviceAssignment("demo-edge-02")
if err == nil && item != nil {
t.Fatalf("expected preview device assignment to be ignored, got %#v", item)
}
}
func TestUI_DeviceAssignmentsBoardUsesDenseGridLayout(t *testing.T) {
css, err := os.ReadFile(filepath.Join("ui", "assets", "style.css"))
if err != nil {
t.Fatalf("ReadFile style.css: %v", err)
}
if !strings.Contains(string(css), ".assignment-board-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));") {
t.Fatalf("expected dense four-column assignment board grid, got:\n%s", string(css))
}
}
func TestUI_DeviceAssignmentsBoardUsesBrightOrangeLoadStates(t *testing.T) {
css, err := os.ReadFile(filepath.Join("ui", "assets", "style.css"))
if err != nil {
t.Fatalf("ReadFile style.css: %v", err)
}
text := string(css)
for _, want := range []string{
"--assignment-low-bg:#24190d;",
"--assignment-busy-bg:#3a2812;",
"--assignment-full-bg:#4b3110;",
"--assignment-low-bg:#f4ead8;",
"--assignment-busy-bg:#f0dcc0;",
"--assignment-full-bg:#eccb96;",
"--assignment-low-bg:#332514;",
"--assignment-busy-bg:#3f3015;",
"--assignment-full-bg:#57401a;",
} {
if !strings.Contains(text, want) {
t.Fatalf("expected brighter orange assignment state token %q in css", want)
}
}
}
func TestUI_SidebarMatchesApprovedIoTArchitecture(t *testing.T) {
t.Skip("sidebar expectations updated by new IA; replace with dedicated scene-template/unit/assignment assertions")
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{"配置管理", "操作审计", `<a href="/ui/diagnostics"><span class="nav-icon">`} {
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_DashboardOmitsRedundantExplanatoryChrome(t *testing.T) {
ui := newTestUI(t)
html := renderPage(t, ui, "/ui/dashboard")
for _, text := range []string{
"只看 fleet 级运行态势、最近任务和需要关注的异常设备。",
"当前版本基于节点在线状态和视频分析服务查询入口汇总。",
"按常见现场操作顺序进入对应页面。",
"视频分析概览",
"运维工作流",
"快速入口",
"任务健康",
"失败任务",
"成功任务",
`<a class="btn ghost" href="/ui/devices">设备列表</a>`,
`<a class="btn ghost" href="/ui/tasks">任务</a>`,
} {
if strings.Contains(html, text) {
t.Fatalf("dashboard should omit redundant chrome %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_LogAuditPageOwnsLogsAndAudit(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)
}
}
if strings.Contains(html, "进入系统状态") {
t.Fatalf("log audit page should not include system status: %s", 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_SystemManagementPagesUseSystemManagementCrumb(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_AssetsOverviewOmitsLegacyImportAction(t *testing.T) {
ui := newTestUI(t)
html := renderPage(t, ui, "/ui/assets")
for _, forbidden := range []string{"资产操作", "导入现有 JSON", `action="/ui/assets/import"`} {
if strings.Contains(html, forbidden) {
t.Fatalf("expected assets overview to omit legacy import action %q, got: %s", forbidden, 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_AssetTemplatesPageShowsDirectStandardCloneAction(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("std_face_recognition_stream", "标准人脸流模板", `{"name":"std_face_recognition_stream","description":"标准人脸流模板","source":"standard","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"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=std_face_recognition_stream")
for _, want := range []string{"标准模板应作为基线", "复制标准模板", "模板详情"} {
if !strings.Contains(html, want) {
t.Fatalf("expected template page to contain %q, got: %s", want, html)
}
}
for _, forbidden := range []string{"新增模板", `name="clone_source" value="std_face_recognition_stream"`, "复制并编辑"} {
if strings.Contains(html, forbidden) {
t.Fatalf("expected template page to omit %q, got: %s", forbidden, html)
}
}
}
func TestUI_AssetTemplateGraphPageRendersEditorShell(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()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveTemplate("helmet", "helmet template", `{
"name": "helmet",
"description": "helmet template",
"template": {
"nodes": [{"id":"in","type":"input_rtsp","role":"source","enable":true}],
"edges": []
}
}`); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
html := renderPage(t, ui, "/ui/assets/templates/helmet/graph")
for _, want := range []string{
"模板可视化编辑",
"graph-editor",
"graph_editor.css",
"graph_editor.js",
"graph-template-json",
`"id":"in"`,
`"type":"input_rtsp"`,
"graph-node-form",
`name="id"`,
`name="role"`,
`name="enable"`,
"graph-connect-target",
"graph-typed-param-fields",
"graph-auto-layout",
"graph-param-editor",
"graph-edge-form",
"graph-node-palette-list",
`data-catalog-url="/ui/api/graph-node-types"`,
`name="from"`,
`name="to"`,
"常用参数",
"高级 JSON",
"自动布局",
} {
if !strings.Contains(html, want) {
t.Fatalf("expected graph editor page to contain %q, got: %s", want, html)
}
}
}
func TestUI_AssetTemplatePageShowsEditAndDeleteForUserTemplate(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()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
html := renderPage(t, ui, "/ui/assets/templates?name=helmet")
for _, want := range []string{`href="/ui/assets/templates?name=helmet&edit=1"`, `action="/ui/assets/templates/helmet/delete"`, "模板详情", "查看模式"} {
if !strings.Contains(html, want) {
t.Fatalf("expected template detail to contain %q, got: %s", want, html)
}
}
}
func TestUI_ActionAssetTemplateCloneCreatesDefaultCopyAndRedirectsToEdit(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()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveTemplate("std_face_recognition_stream", "helmet template", `{"name":"std_face_recognition_stream","description":"helmet template","source":"standard","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[["in","det"]]},"extra":{"mode":"std"}}`); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
req := httptest.NewRequest(http.MethodPost, "/assets/templates/std_face_recognition_stream/clone", nil)
rr := httptest.NewRecorder()
router, err := ui.Routes()
if err != nil {
t.Fatalf("Routes: %v", err)
}
router.ServeHTTP(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/assets/templates?name=face_recognition_stream_copy&edit=1") {
t.Fatalf("expected edit redirect, got %q", got)
}
saved, err := repo.GetTemplate("face_recognition_stream_copy")
if err != nil {
t.Fatalf("GetTemplate: %v", err)
}
for _, want := range []string{`"name": "face_recognition_stream_copy"`, `"description": "helmet template"`, `"type": "input_rtsp"`, `"mode": "std"`} {
if saved == nil || !strings.Contains(saved.BodyJSON, want) {
t.Fatalf("expected cloned template to contain %q, got %#v", want, saved)
}
}
}
func TestUI_GraphNodeTypesAPIFallbackListsCatalog(t *testing.T) {
ui := newTestUI(t)
router, err := ui.Routes()
if err != nil {
t.Fatalf("Routes: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/graph-node-types", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var payload struct {
Items []graphNodeTypeInfo `json:"items"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil {
t.Fatalf("invalid json: %v", err)
}
got := map[string]graphNodeTypeInfo{}
for _, item := range payload.Items {
got[item.Type] = item
}
for _, want := range []string{"input_rtsp", "ai_shoe_det", "event_fusion", "zlm_http"} {
item, ok := got[want]
if !ok {
t.Fatalf("expected graph node type %q in %#v", want, payload.Items)
}
if item.Label == "" || item.Icon == "" || item.Description == "" {
t.Fatalf("expected %q to include label, icon and description: %#v", want, item)
}
if item.Defaults["type"] != want {
t.Fatalf("expected %q defaults.type, got %#v", want, item.Defaults)
}
}
}
func TestUI_AssetTemplateGraphSavePersistsNodeAndEdgeParameters(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()
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.preview = service.NewConfigPreviewService(&config.Config{}, repo)
form := url.Values{}
form.Set("json", `{"name":"helmet","description":"helmet template","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source","enable":true,"url":"${slot:video_input_main.url}"},{"id":"det","type":"ai_yolo","role":"filter","enable":true,"conf":0.42}],"edges":[{"from":"in","to":"det","stream":"video"}]}}`)
req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
router, err := ui.Routes()
if err != nil {
t.Fatalf("Routes: %v", err)
}
router.ServeHTTP(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
saved, err := repo.GetTemplate("helmet")
if err != nil {
t.Fatalf("GetTemplate: %v", err)
}
for _, want := range []string{`"url": "${slot:video_input_main.url}"`, `"conf": 0.42`, `"stream": "video"`} {
if saved == nil || !strings.Contains(saved.BodyJSON, want) {
t.Fatalf("expected saved graph parameter %q, got %#v", want, saved)
}
}
}
func TestUI_AssetTemplateGraphSavePersistsLayout(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()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
form := url.Values{}
form.Set("json", `{"name":"helmet","description":"helmet template","ui":{"layout":{"version":1,"nodes":{"in":{"x":123,"y":456}}}},"template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`)
req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
router, err := ui.Routes()
if err != nil {
t.Fatalf("Routes: %v", err)
}
router.ServeHTTP(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
saved, err := repo.GetTemplate("helmet")
if err != nil {
t.Fatalf("GetTemplate: %v", err)
}
if saved == nil || !strings.Contains(saved.BodyJSON, `"x": 123`) || !strings.Contains(saved.BodyJSON, `"y": 456`) {
t.Fatalf("expected saved layout, got %#v", saved)
}
}
func TestUI_AssetTemplateGraphSaveCanRenameTemplate(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()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
form := url.Values{}
form.Set("name", "helmet_v2")
form.Set("description", "helmet v2")
form.Set("json", `{"name":"helmet","description":"helmet template","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`)
req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
router, err := ui.Routes()
if err != nil {
t.Fatalf("Routes: %v", err)
}
router.ServeHTTP(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/assets/templates/helmet_v2/graph") {
t.Fatalf("expected renamed graph redirect, got %q", got)
}
oldRecord, err := repo.GetTemplate("helmet")
if err != nil {
t.Fatalf("GetTemplate old: %v", err)
}
if oldRecord != nil {
t.Fatalf("expected old template removed, got %#v", oldRecord)
}
newRecord, err := repo.GetTemplate("helmet_v2")
if err != nil {
t.Fatalf("GetTemplate new: %v", err)
}
if newRecord == nil || !strings.Contains(newRecord.BodyJSON, `"name": "helmet_v2"`) {
t.Fatalf("expected renamed template saved, got %#v", newRecord)
}
profile, err := repo.GetProfile("gate_a")
if err != nil {
t.Fatalf("GetProfile: %v", err)
}
if profile == nil || profile.TemplateName != "helmet_v2" {
t.Fatalf("expected profile ref updated, got %#v", profile)
}
}
func TestUI_ActionAssetTemplateRenameFromDetailPage(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()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
form := url.Values{}
form.Set("name", "helmet_v2")
form.Set("description", "helmet v2")
req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/rename", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
router, err := ui.Routes()
if err != nil {
t.Fatalf("Routes: %v", err)
}
router.ServeHTTP(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/assets/templates?name=helmet_v2") {
t.Fatalf("expected detail redirect, got %q", got)
}
record, err := repo.GetTemplate("helmet_v2")
if err != nil {
t.Fatalf("GetTemplate: %v", err)
}
if record == nil || record.Description != "helmet_v2" && record.Description != "helmet v2" {
t.Fatalf("expected renamed template, got %#v", record)
}
}
func TestUI_ActionAssetTemplateDeleteFromDetailPage(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()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/delete", nil)
rr := httptest.NewRecorder()
router, err := ui.Routes()
if err != nil {
t.Fatalf("Routes: %v", err)
}
router.ServeHTTP(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/assets/templates?msg=") {
t.Fatalf("expected list redirect with message, got %q", got)
}
record, err := repo.GetTemplate("helmet")
if err != nil {
t.Fatalf("GetTemplate: %v", err)
}
if record != nil {
t.Fatalf("expected deleted template, got %#v", record)
}
}
func TestUI_AssetTemplateGraphSaveRejectsUnknownEdgeEndpoint(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()
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.preview = service.NewConfigPreviewService(&config.Config{}, repo)
form := url.Values{}
form.Set("json", `{"name":"helmet","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[["in","missing"]]}}`)
req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
router, err := ui.Routes()
if err != nil {
t.Fatalf("Routes: %v", err)
}
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "edge references unknown node") {
t.Fatalf("expected edge validation error, got: %s", rr.Body.String())
}
}
func TestUI_AssetTemplatePageMarksBuiltinTemplateReadonly(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()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveTemplate("std_face_recognition_stream", "builtin helmet", `{"name":"std_face_recognition_stream","description":"builtin helmet","source":"standard","template":{"nodes":[{"id":"input_rtsp_main","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
html := renderPage(t, ui, "/ui/assets/templates?name=std_face_recognition_stream")
for _, want := range []string{"标准模板(只读)", "复制标准模板", "可视化预览", "builtin helmet"} {
if !strings.Contains(html, want) {
t.Fatalf("expected builtin template page to contain %q, got: %s", want, html)
}
}
}
func TestUI_AssetTemplateGraphSaveRejectsBuiltinTemplate(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()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveTemplate("std_face_recognition_stream", "builtin helmet", `{"name":"std_face_recognition_stream","description":"builtin helmet","source":"standard","template":{"nodes":[{"id":"input_rtsp_main","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
form := url.Values{}
form.Set("json", `{"name":"std_face_recognition_stream","description":"builtin helmet","template":{"nodes":[{"id":"input_rtsp_main","type":"input_rtsp","role":"source"}],"edges":[]}}`)
req := httptest.NewRequest(http.MethodPost, "/assets/templates/std_face_recognition_stream/graph", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
router, err := ui.Routes()
if err != nil {
t.Fatalf("Routes: %v", err)
}
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "read-only") {
t.Fatalf("expected readonly rejection, got: %s", rr.Body.String())
}
}
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)
}
}