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