From 513062f08e6b3217f690ea25298f1bf4078c7f2d Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Mon, 20 Apr 2026 00:31:40 +0800 Subject: [PATCH 1/4] Add device overview batch selection mode --- internal/web/ui.go | 271 +++++++++++++++---------- internal/web/ui/templates/devices.html | 180 +++++++++------- internal/web/ui_test.go | 64 +++++- 3 files changed, 337 insertions(+), 178 deletions(-) diff --git a/internal/web/ui.go b/internal/web/ui.go index 00dca6e..3336ccc 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -40,26 +40,28 @@ type PageData struct { OfflineCount int FoundCount int - Devices []*models.Device - DeviceRows []DeviceOverviewRow - AttentionDevices []*models.Device - Found []*models.Device - Device *models.Device - ConfigStatus *ConfigStatusView - ConfigStatusText string - ConfigStatusErr string - ConfigSources service.ConfigPreviewSources - ConfigPreview *service.ConfigPreviewResult - ResultTitle string - SelectedTemplate string - SelectedProfile string - SelectedOverlays []string - SelectedConfigID string - SelectedVersion string - Tasks []models.Task - Task *models.Task - Templates []service.Template - Template *service.Template + Devices []*models.Device + DeviceRows []DeviceOverviewRow + AttentionDevices []*models.Device + Found []*models.Device + Device *models.Device + ConfigStatus *ConfigStatusView + ConfigStatusText string + ConfigStatusErr string + ConfigSources service.ConfigPreviewSources + ConfigPreview *service.ConfigPreviewResult + ResultTitle string + SelectedTemplate string + SelectedProfile string + SelectedOverlays []string + SelectedConfigID string + SelectedVersion string + Tasks []models.Task + Task *models.Task + Templates []service.Template + Template *service.Template + SelectedDeviceIDs []string + SelectedQuery string RawJSON string RawText string @@ -177,29 +179,29 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic func tablerIconSVG(name string) string { icons := map[string]string{ - "devices": ``, - "assets": ``, - "audit": ``, - "system": ``, - "online": ``, - "detail": ``, - "control": ``, - "device": ``, - "status": ``, - "config": ``, - "overview": ``, - "tech": ``, - "preview": ``, - "apply": ``, - "service": ``, - "result": ``, - "meta": ``, - "template": ``, - "profile": ``, - "overlay": ``, - "release": ``, + "devices": ``, + "assets": ``, + "audit": ``, + "system": ``, + "online": ``, + "detail": ``, + "control": ``, + "device": ``, + "status": ``, + "config": ``, + "overview": ``, + "tech": ``, + "preview": ``, + "apply": ``, + "service": ``, + "result": ``, + "meta": ``, + "template": ``, + "profile": ``, + "overlay": ``, + "release": ``, "discovery": ``, - "shield": ``, + "shield": ``, "heartbeat": ``, } if svg, ok := icons[name]; ok { @@ -329,46 +331,7 @@ func (u *UI) pageDashboard(w http.ResponseWriter, r *http.Request) { } func (u *UI) pageDevices(w http.ResponseWriter, r *http.Request) { - u.ensureDevicesLoaded() - devices := u.registry.GetDevices() - rows := make([]DeviceOverviewRow, 0, len(devices)) - for _, dev := range devices { - row := DeviceOverviewRow{Device: dev} - status, _, err := u.loadConfigStatus(dev) - row.ConfigStatus = status - if err != nil { - row.ConfigStatusErr = err.Error() - } - rows = append(rows, row) - } - online := 0 - attention := 0 - for _, d := range devices { - if d.Online { - online++ - } else { - attention++ - } - } - failedTasks := 0 - if u.tasks != nil { - for _, t := range u.tasks.ListTasks() { - if t.Status == models.TaskFailed { - failedTasks++ - } - } - } - u.render(w, r, "devices", PageData{ - Title: "设备", - Devices: devices, - DeviceRows: rows, - DeviceCount: len(devices), - OnlineCount: online, - OfflineCount: len(devices) - online, - RunningTaskCount: 0, - FailedTaskCount: failedTasks, - FoundCount: attention, - }) + u.render(w, r, "devices", u.deviceOverviewPageData(r, nil, "")) } func (u *UI) pageDeviceAdd(w http.ResponseWriter, r *http.Request) { @@ -438,14 +401,7 @@ func (u *UI) actionDevicesBatchAction(w http.ResponseWriter, r *http.Request) { action := strings.TrimSpace(r.FormValue("action")) deviceIDs := r.Form["device_id"] if len(deviceIDs) == 0 { - devices := u.registry.GetDevices() - online := 0 - for _, d := range devices { - if d.Online { - online++ - } - } - u.render(w, r, "devices", PageData{Title: "设备", Devices: devices, DeviceCount: len(devices), OnlineCount: online, OfflineCount: len(devices) - online, Error: "请先选择设备"}) + u.render(w, r, "devices", u.deviceOverviewPageData(r, nil, "请先选择设备")) return } @@ -454,14 +410,7 @@ func (u *UI) actionDevicesBatchAction(w http.ResponseWriter, r *http.Request) { case "media_start", "media_restart", "media_stop", "reload", "rollback": typeStr = action default: - devices := u.registry.GetDevices() - online := 0 - for _, d := range devices { - if d.Online { - online++ - } - } - u.render(w, r, "devices", PageData{Title: "设备", Devices: devices, DeviceCount: len(devices), OnlineCount: online, OfflineCount: len(devices) - online, Error: "不支持的操作: " + action}) + u.render(w, r, "devices", u.deviceOverviewPageData(r, deviceIDs, "不支持的操作: "+action)) return } @@ -480,14 +429,7 @@ func (u *UI) actionDevicesBatchAction(w http.ResponseWriter, r *http.Request) { task, err := u.tasks.CreateTask(typeStr, deviceIDs, payload) if err != nil { - devices := u.registry.GetDevices() - online := 0 - for _, d := range devices { - if d.Online { - online++ - } - } - u.render(w, r, "devices", PageData{Title: "设备", Devices: devices, DeviceCount: len(devices), OnlineCount: online, OfflineCount: len(devices) - online, Error: err.Error()}) + u.render(w, r, "devices", u.deviceOverviewPageData(r, deviceIDs, err.Error())) return } @@ -1139,6 +1081,123 @@ func cleanFormList(values []string) []string { return out } +func selectedIDsFromQuery(values []string) []string { + values = cleanFormList(values) + if len(values) == 0 { + return nil + } + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +func filterSelectedDeviceIDs(devices []*models.Device, candidates []string) []string { + if len(candidates) == 0 || len(devices) == 0 { + return nil + } + known := make(map[string]struct{}, len(devices)) + for _, dev := range devices { + if dev == nil { + continue + } + id := strings.TrimSpace(dev.DeviceID) + if id != "" { + known[id] = struct{}{} + } + } + seen := make(map[string]struct{}, len(candidates)) + out := make([]string, 0, len(candidates)) + for _, id := range candidates { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if _, ok := known[id]; !ok { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + if len(out) == 0 { + return nil + } + return out +} + +func selectedQueryString(ids []string) string { + if len(ids) == 0 { + return "" + } + values := url.Values{} + for _, id := range ids { + values.Add("selected", id) + } + return values.Encode() +} + +func (u *UI) deviceOverviewPageData(r *http.Request, selectedIDs []string, errMsg string) PageData { + u.ensureDevicesLoaded() + devices := u.registry.GetDevices() + rows := make([]DeviceOverviewRow, 0, len(devices)) + for _, dev := range devices { + row := DeviceOverviewRow{Device: dev} + status, _, err := u.loadConfigStatus(dev) + row.ConfigStatus = status + if err != nil { + row.ConfigStatusErr = err.Error() + } + rows = append(rows, row) + } + online := 0 + attention := 0 + for _, d := range devices { + if d.Online { + online++ + } else { + attention++ + } + } + failedTasks := 0 + if u.tasks != nil { + for _, t := range u.tasks.ListTasks() { + if t.Status == models.TaskFailed { + failedTasks++ + } + } + } + if selectedIDs == nil { + selectedIDs = selectedIDsFromQuery(r.URL.Query()["selected"]) + } + selectedIDs = filterSelectedDeviceIDs(devices, selectedIDs) + data := PageData{ + Title: "设备", + Devices: devices, + DeviceRows: rows, + DeviceCount: len(devices), + OnlineCount: online, + OfflineCount: len(devices) - online, + RunningTaskCount: 0, + FailedTaskCount: failedTasks, + FoundCount: attention, + SelectedDeviceIDs: selectedIDs, + SelectedQuery: selectedQueryString(selectedIDs), + } + if errMsg != "" { + data.Error = errMsg + } + return data +} + func previewResultFromJSON(raw string) *service.ConfigPreviewResult { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/internal/web/ui/templates/devices.html b/internal/web/ui/templates/devices.html index f06246d..f180cee 100644 --- a/internal/web/ui/templates/devices.html +++ b/internal/web/ui/templates/devices.html @@ -41,88 +41,112 @@ -
- - - - - - - - - - - {{range .DeviceRows}} - - + + + {{else}} + + + + {{end}} + +
设备状态当前配置操作
-
-
{{icon "device"}}
-
-
{{if .Device.DeviceName}}{{.Device.DeviceName}}{{else}}{{.Device.DeviceID}}{{end}}
-
- {{if .Device.Hostname}}{{.Device.Hostname}}{{end}} - {{.Device.IP}} - {{if .Device.Version}}{{.Device.Version}}{{end}} - {{if .Device.GitSha}}#{{shortHash .Device.GitSha}}{{end}} +
+ {{if .SelectedDeviceIDs}} +
+
+
已选 {{len .SelectedDeviceIDs}} 台
+
选择后可以对这批设备统一执行服务操作,批量配置入口稍后开放。
+
+
+ + + + + 批量配置 + 清空选择 +
+
+ {{end}} + +
+ + + + + + + + + + + + {{range .DeviceRows}} + + + - + + - - - - {{else}} - - - - {{end}} - -
选中设备状态当前配置操作
+ + +
+
{{icon "device"}}
+
+
{{if .Device.DeviceName}}{{.Device.DeviceName}}{{else}}{{.Device.DeviceID}}{{end}}
+
+ {{if .Device.Hostname}}{{.Device.Hostname}}{{end}} + {{.Device.IP}} + {{if .Device.Version}}{{.Device.Version}}{{end}} + {{if .Device.GitSha}}#{{shortHash .Device.GitSha}}{{end}} +
- -
-
-
- {{if .Device.Online}}在线{{else}}离线{{end}} - {{if .ConfigStatus}} - {{if .ConfigStatus.MediaServer.Running}}运行中{{else}}未运行{{end}} - {{else if .Device.Online}} - 待确认 +
+
+
+ {{if .Device.Online}}在线{{else}}离线{{end}} + {{if .ConfigStatus}} + {{if .ConfigStatus.MediaServer.Running}}运行中{{else}}未运行{{end}} + {{else if .Device.Online}} + 待确认 + {{else}} + 未知 + {{end}} +
+
心跳 {{ago .Device.LastSeenMs}}
+
+
+
+ {{if and .ConfigStatus .ConfigStatus.Metadata.ConfigID}} +
{{.ConfigStatus.Metadata.ConfigID}}
+
{{.ConfigStatus.Metadata.ConfigVersion}}
+ {{if .ConfigStatus.Metadata.Overlays}}
{{range $i, $overlay := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$overlay}}{{end}}
{{end}} + {{else if .ConfigStatusErr}} +
未取到配置摘要
{{else}} - 未知 +
暂无配置摘要
{{end}}
-
心跳 {{ago .Device.LastSeenMs}}
- -
-
- {{if and .ConfigStatus .ConfigStatus.Metadata.ConfigID}} -
{{.ConfigStatus.Metadata.ConfigID}}
-
{{.ConfigStatus.Metadata.ConfigVersion}}
- {{if .ConfigStatus.Metadata.Overlays}}
{{range $i, $overlay := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$overlay}}{{end}}
{{end}} - {{else if .ConfigStatusErr}} -
未取到配置摘要
- {{else}} -
暂无配置摘要
- {{end}} -
-
{{shortHash .Device.DeviceID}}
-
- -
-
-
还没有设备
-
当前后台还没有发现或录入任何设备。
-
-
-
+
{{shortHash .Device.DeviceID}}
+
+ +
+
+
还没有设备
+
当前后台还没有发现或录入任何设备。
+
+
+
+ {{end}} diff --git a/internal/web/ui_test.go b/internal/web/ui_test.go index fbfacb6..84b1b19 100644 --- a/internal/web/ui_test.go +++ b/internal/web/ui_test.go @@ -117,6 +117,68 @@ func newTestUI(t *testing.T) *UI { return ui } +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) + } + } +} + +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 台"} { + if !strings.Contains(body, want) { + t.Fatalf("expected error render to contain %q, got:\n%s", want, body) + } + } +} + func TestUI_DeviceOverviewRendersFleetOverview(t *testing.T) { ui := newTestUI(t) req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil) @@ -666,7 +728,7 @@ func TestUI_ConfigPreviewShowsApplySummaryAfterApplyResult(t *testing.T) { "overlays": []any{"face_test_sensitive", "production_quiet"}, }, Sha256: "eecdf8d422705f3affa0f892199604f037f60ea8fe578fe2a65527e1800044c5", - Size: 64, + Size: 64, }, ConfigStatus: &ConfigStatusView{ OK: true, From 17240ac7bdc46874b14435bb485dcf47fbd10a34 Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Mon, 20 Apr 2026 00:44:54 +0800 Subject: [PATCH 2/4] Promote device batch service actions in overview --- internal/web/ui.go | 49 +++++++++++- internal/web/ui/assets/style.css | 11 +++ internal/web/ui/templates/devices.html | 4 +- internal/web/ui/templates/task.html | 96 +++++++++++++++++------- internal/web/ui_test.go | 100 ++++++++++++++++++++++++- 5 files changed, 230 insertions(+), 30 deletions(-) diff --git a/internal/web/ui.go b/internal/web/ui.go index 3336ccc..fcf8ce2 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -58,6 +58,7 @@ type PageData struct { SelectedVersion string Tasks []models.Task Task *models.Task + TaskDeviceRows []TaskDeviceRow Templates []service.Template Template *service.Template SelectedDeviceIDs []string @@ -81,6 +82,13 @@ type DeviceOverviewRow struct { ConfigStatusErr string } +type TaskDeviceRow struct { + Device *models.Device + Status models.TaskStatus + Progress float64 + Error string +} + type ConfigStatusView struct { OK bool `json:"ok"` ConfigPath string `json:"config_path"` @@ -399,7 +407,7 @@ func (u *UI) actionDiscoverySearch(w http.ResponseWriter, r *http.Request) { func (u *UI) actionDevicesBatchAction(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() action := strings.TrimSpace(r.FormValue("action")) - deviceIDs := r.Form["device_id"] + deviceIDs := filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"]) if len(deviceIDs) == 0 { u.render(w, r, "devices", u.deviceOverviewPageData(r, nil, "请先选择设备")) return @@ -701,6 +709,41 @@ func (u *UI) pageTasks(w http.ResponseWriter, r *http.Request) { u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices()}) } +func (u *UI) taskPageData(task *models.Task) PageData { + data := PageData{Title: "任务详情", Task: task} + if task == nil { + return data + } + + devices := make(map[string]*models.Device) + if u.registry != nil { + for _, dev := range u.registry.GetDevices() { + if dev == nil { + continue + } + devices[dev.DeviceID] = dev + } + } + + rows := make([]TaskDeviceRow, 0, len(task.DeviceIDs)) + for _, did := range task.DeviceIDs { + row := TaskDeviceRow{} + if dev := devices[did]; dev != nil { + row.Device = dev + } else { + row.Device = &models.Device{DeviceID: did} + } + if ds := task.Devices[did]; ds != nil { + row.Status = ds.Status + row.Progress = ds.Progress + row.Error = ds.Error + } + rows = append(rows, row) + } + data.TaskDeviceRows = rows + return data +} + func (u *UI) actionCreateTask(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() typeStr := strings.TrimSpace(r.FormValue("type")) @@ -748,7 +791,9 @@ func (u *UI) pageTask(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - u.render(w, r, "task", PageData{Title: "任务详情", Task: task, TaskID: id}) + data := u.taskPageData(task) + data.TaskID = id + u.render(w, r, "task", data) } func (u *UI) pageTemplates(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/ui/assets/style.css b/internal/web/ui/assets/style.css index c2f00ea..b64f6ee 100644 --- a/internal/web/ui/assets/style.css +++ b/internal/web/ui/assets/style.css @@ -104,9 +104,12 @@ tbody tr:hover{background:#f9fafb} .pill{display:inline-flex;align-items:center;padding:3px 8px;border-radius:999px;border:1px solid var(--border);background:#f3f4f6;color:#374151;font-size:11px;font-weight:600} .pill.ok{background:#ecfdf5;border-color:#bbf7d0;color:#166534} .pill.bad{background:#fef2f2;border-color:#fecaca;color:#991b1b} +.pill.run{background:#eff6ff;border-color:#bfdbfe;color:#1d4ed8} .pill.warn{background:#fffbeb;border-color:#fde68a;color:#92400e} .actions{display:flex;flex-wrap:wrap;gap:8px} +.actions.compact{gap:6px} +.actions.compact .btn,.actions.compact button{padding:5px 9px;font-size:11px} .btn .ui-icon{width:14px;height:14px} .stack{flex-direction:column;align-items:flex-start} .device-context-head{display:flex;align-items:center;gap:12px} @@ -151,6 +154,13 @@ pre{margin-top:12px;padding:12px;border-radius:8px;border:1px solid #1f2937;back .subnav a{display:inline-flex;align-items:center;padding:7px 10px;border:1px solid var(--border);border-radius:999px;background:#fff;color:#374151;font-size:12px;font-weight:500} .ui-icon{display:block;flex:0 0 auto} +.batch-toolbar{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;padding:14px 16px;border:1px solid var(--border);border-radius:8px;background:var(--surface-soft);margin:0 0 12px} +.batch-toolbar-count{font-size:13px;font-weight:600} +.batch-toolbar .actions{justify-content:flex-end} +.batch-toolbar .actions .btn,.batch-toolbar .actions button{white-space:nowrap} +.select-cell{width:52px;text-align:center;vertical-align:middle} +.select-cell input[type=checkbox]{width:16px;height:16px;margin:0;accent-color:var(--primary)} + @media (max-width:1024px){ .app-shell{grid-template-columns:1fr} .sidebar{position:relative;height:auto} @@ -158,4 +168,5 @@ pre{margin-top:12px;padding:12px;border-radius:8px;border:1px solid #1f2937;back main{padding:18px} .stats,.detail-grid,.quad-grid,.control-grid,.summary-strip,.info-list,.field-grid{grid-template-columns:1fr} .hero-band{flex-direction:column;align-items:flex-start} + .batch-toolbar{flex-direction:column} } diff --git a/internal/web/ui/templates/devices.html b/internal/web/ui/templates/devices.html index f180cee..be9349f 100644 --- a/internal/web/ui/templates/devices.html +++ b/internal/web/ui/templates/devices.html @@ -48,7 +48,7 @@
已选 {{len .SelectedDeviceIDs}} 台
选择后可以对这批设备统一执行服务操作,批量配置入口稍后开放。
-
+
@@ -73,7 +73,7 @@ {{range .DeviceRows}} - + diff --git a/internal/web/ui/templates/task.html b/internal/web/ui/templates/task.html index 8183729..5b9d985 100644 --- a/internal/web/ui/templates/task.html +++ b/internal/web/ui/templates/task.html @@ -1,18 +1,20 @@ {{define "task"}}
-

任务执行详情

-
-
-
任务标识
-
{{.Task.ID}}
+

任务详情

+
+
+
任务标识
+
{{.Task.ID}}
-
-
操作
-
+
+
操作类型
+
{{if eq .Task.Type "config_apply"}}下发识别配置 {{else if eq .Task.Type "reload"}}重载识别服务 {{else if eq .Task.Type "rollback"}}回滚识别配置 @@ -22,9 +24,13 @@ {{else}}{{.Task.Type}}{{end}}
-
-
状态
-
+
+
设备数量
+
{{len .Task.DeviceIDs}}
+
+
+
当前状态
+
{{if eq .Task.Status "success"}}成功 {{else if eq .Task.Status "failed"}}失败 {{else if eq .Task.Status "running"}}执行中 @@ -35,24 +41,35 @@
-

节点执行情况

+

设备结果表

- + - {{range $id, $st := .Task.Devices}} - - + {{range .TaskDeviceRows}} + + - - + + {{end}} @@ -62,8 +79,8 @@

实时进度

-
任务执行过程中会持续推送每台节点的状态变化。
-
+
任务执行过程中会持续推送每台设备的状态变化。
+
未连接 @@ -73,12 +90,38 @@ {{end}} diff --git a/internal/web/ui_test.go b/internal/web/ui_test.go index 84b1b19..ace2e89 100644 --- a/internal/web/ui_test.go +++ b/internal/web/ui_test.go @@ -50,6 +50,61 @@ func TestUI_ActionDevicesBatchAction_RedirectsToTask(t *testing.T) { } } +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) @@ -172,13 +227,56 @@ func TestUI_ActionDevicesBatchActionKeepsDevicesOnError(t *testing.T) { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } body := rr.Body.String() - for _, want := range []string{"不支持的操作: nope", "入口识别节点", "辅助节点", "已选 2 台"} { + 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_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}) + + form := url.Values{} + form.Set("action", "reload") + 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.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{"任务详情", "返回操作审计", "设备结果表", "设备数量", "入口识别节点", "辅助节点", "edge-01", "edge-02", `id="task-status-value"`, "syncTaskStatus()"} { + if !strings.Contains(body, want) { + t.Fatalf("expected task page to contain %q, got:\n%s", want, 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) From 2eca56e59a1b12199253c3d3dd32a8098aef3ea0 Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Mon, 20 Apr 2026 01:01:44 +0800 Subject: [PATCH 3/4] Add batch config rollout workflow --- internal/web/ui.go | 108 ++++++++++ .../web/ui/templates/device_batch_config.html | 110 ++++++++++ internal/web/ui/templates/devices.html | 4 +- internal/web/ui_test.go | 199 ++++++++++++++++++ 4 files changed, 419 insertions(+), 2 deletions(-) create mode 100644 internal/web/ui/templates/device_batch_config.html diff --git a/internal/web/ui.go b/internal/web/ui.go index fcf8ce2..4c47935 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -62,6 +62,7 @@ type PageData struct { Templates []service.Template Template *service.Template SelectedDeviceIDs []string + SelectedDevices []*models.Device SelectedQuery string RawJSON string @@ -253,6 +254,8 @@ func (u *UI) Routes() (chi.Router, error) { r.Get("/devices-add", u.pageDeviceAdd) r.Post("/devices-add", u.actionDeviceAdd) r.Post("/devices/batch-action", u.actionDevicesBatchAction) + r.Get("/devices/batch-config", u.pageDeviceBatchConfig) + r.Post("/devices/batch-config", u.actionDeviceBatchConfig) r.Post("/discovery/search", u.actionDiscoverySearch) r.Get("/devices/{id}", u.pageDevice) r.Post("/devices/{id}/action", u.actionDeviceAction) @@ -444,6 +447,76 @@ func (u *UI) actionDevicesBatchAction(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound) } +func (u *UI) pageDeviceBatchConfig(w http.ResponseWriter, r *http.Request) { + data := u.deviceBatchConfigPageData(r, selectedIDsFromQuery(r.URL.Query()["selected"])) + u.render(w, r, "device_batch_config", data) +} + +func (u *UI) actionDeviceBatchConfig(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + selectedIDs := filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"]) + req := service.ConfigPreviewRequest{ + Template: strings.TrimSpace(r.FormValue("template")), + Profile: strings.TrimSpace(r.FormValue("profile")), + Overlays: cleanFormList(r.Form["overlay"]), + ConfigID: strings.TrimSpace(r.FormValue("config_id")), + ConfigVersion: strings.TrimSpace(r.FormValue("config_version")), + } + data := u.deviceBatchConfigPageData(r, selectedIDs) + if req.Template != "" { + data.SelectedTemplate = req.Template + } + if req.Profile != "" { + data.SelectedProfile = req.Profile + } + data.SelectedOverlays = append([]string(nil), req.Overlays...) + data.SelectedConfigID = req.ConfigID + if req.ConfigVersion != "" { + data.SelectedVersion = req.ConfigVersion + } + + if len(selectedIDs) == 0 { + data.Error = "请先选择需要下发配置的设备" + u.render(w, r, "device_batch_config", data) + return + } + if req.Template == "" { + req.Template = data.SelectedTemplate + } + if req.Profile == "" { + req.Profile = data.SelectedProfile + } + if u.tasks == nil { + data.Error = "task service not initialized" + u.render(w, r, "device_batch_config", data) + return + } + + preview, err := u.preview.Render(req) + data.ConfigPreview = preview + if err != nil { + data.Error = err.Error() + u.render(w, r, "device_batch_config", data) + return + } + + var configDoc any + if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil { + data.Error = "生成配置 JSON 无效: " + err.Error() + u.render(w, r, "device_batch_config", data) + return + } + + task, err := u.tasks.CreateTask("config_apply", selectedIDs, map[string]any{"config": configDoc}) + if err != nil { + data.Error = err.Error() + u.render(w, r, "device_batch_config", data) + return + } + + http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound) +} + func (u *UI) pageDevice(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) @@ -1243,6 +1316,41 @@ func (u *UI) deviceOverviewPageData(r *http.Request, selectedIDs []string, errMs return data } +func (u *UI) deviceBatchConfigPageData(r *http.Request, selectedIDs []string) PageData { + data := u.deviceOverviewPageData(r, selectedIDs, "") + sources, err := u.preview.ListSources() + data.Title = "批量配置" + data.ConfigSources = sources + data.SelectedDevices = selectedDevicesFromIDs(data.Devices, data.SelectedDeviceIDs) + data.SelectedTemplate = "workshop_face_shoe_alarm" + data.SelectedProfile = "local_3588_test" + data.SelectedOverlays = []string{"face_debug"} + if err != nil { + data.Error = err.Error() + } + return data +} + +func selectedDevicesFromIDs(devices []*models.Device, ids []string) []*models.Device { + if len(devices) == 0 || len(ids) == 0 { + return nil + } + byID := make(map[string]*models.Device, len(devices)) + for _, dev := range devices { + if dev == nil { + continue + } + byID[strings.TrimSpace(dev.DeviceID)] = dev + } + selected := make([]*models.Device, 0, len(ids)) + for _, id := range ids { + if dev := byID[strings.TrimSpace(id)]; dev != nil { + selected = append(selected, dev) + } + } + return selected +} + func previewResultFromJSON(raw string) *service.ConfigPreviewResult { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/internal/web/ui/templates/device_batch_config.html b/internal/web/ui/templates/device_batch_config.html new file mode 100644 index 0000000..ffb785b --- /dev/null +++ b/internal/web/ui/templates/device_batch_config.html @@ -0,0 +1,110 @@ +{{define "device_batch_config"}} +
+
+
批量配置
+

用模板化配置驱动一批设备

+
先确认目标设备,再选择模板、Profile 和 Overlay,生成后直接进入批量下发任务。
+
+
+ +
+
+
+

{{icon "devices"}}已选设备

+
已选 {{len .SelectedDeviceIDs}} 台设备,将按当前选择顺序创建任务。
+
+ +
+
+ {{range .SelectedDevices}} +
+ {{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}} + {{.DeviceID}} +
+ {{else}} +
+ 目标设备 + 还没有选中设备 +
+ {{end}} +
+
+ +
+
+
+

{{icon "config"}}批量配置

+
保持模板化配置路线,不在这里直接维护完整 JSON。
+
+ {{if .ConfigSources.Root}}
{{.ConfigSources.Root}}
{{end}} +
+ +
+ {{range .SelectedDeviceIDs}}{{end}} +
+ + + + +
+ Overlay +
+ {{range .ConfigSources.Overlays}} + + {{end}} +
+
+
+ +
+ +
+ +
+ +
+
+
+

{{icon "preview"}}预览摘要

+
{{if .ConfigPreview}}默认只展示配置生成关键信息。完整 JSON 在下方折叠区。{{else}}先选择模板化参数并提交,页面会在这里展示配置生成关键信息。{{end}}
+
+
+
+
模板{{if .ConfigPreview}}{{index .ConfigPreview.Metadata "template"}}{{else}}{{.SelectedTemplate}}{{end}}
+
Profile{{if .ConfigPreview}}{{index .ConfigPreview.Metadata "profile"}}{{else}}{{.SelectedProfile}}{{end}}
+
Overlay{{if .ConfigPreview}}{{if index .ConfigPreview.Metadata "overlays"}}{{range $i, $name := index .ConfigPreview.Metadata "overlays"}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}{{else}}{{if .SelectedOverlays}}{{range $i, $name := .SelectedOverlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}{{end}}
+
目标设备{{len .SelectedDeviceIDs}} 台
+
config_id{{if .ConfigPreview}}{{index .ConfigPreview.Metadata "config_id"}}{{else}}{{if .SelectedConfigID}}{{.SelectedConfigID}}{{else}}自动生成{{end}}{{end}}
+
config_version{{if .ConfigPreview}}{{index .ConfigPreview.Metadata "config_version"}}{{else}}{{if .SelectedVersion}}{{.SelectedVersion}}{{else}}自动生成{{end}}{{end}}
+ {{if .ConfigPreview}} +
大小{{.ConfigPreview.Size}} bytes
+
SHA256{{.ConfigPreview.Sha256}}
+ {{end}} +
+
+ +{{if .ConfigPreview}} +
+ {{icon "tech"}}完整 JSON +
{{.ConfigPreview.JSON}}
+
+{{end}} +{{end}} diff --git a/internal/web/ui/templates/devices.html b/internal/web/ui/templates/devices.html index be9349f..efba81c 100644 --- a/internal/web/ui/templates/devices.html +++ b/internal/web/ui/templates/devices.html @@ -46,14 +46,14 @@
已选 {{len .SelectedDeviceIDs}} 台
-
选择后可以对这批设备统一执行服务操作,批量配置入口稍后开放。
+
选择后可以对这批设备统一执行服务操作,或进入模板化批量配置。
- 批量配置 + 批量配置 清空选择
diff --git a/internal/web/ui_test.go b/internal/web/ui_test.go index ace2e89..21fb795 100644 --- a/internal/web/ui_test.go +++ b/internal/web/ui_test.go @@ -10,6 +10,7 @@ import ( "net/http/httptest" "net/url" "os" + "path/filepath" "strconv" "strings" "testing" @@ -209,6 +210,147 @@ func TestUI_DeviceOverviewShowsBatchBarWhenDevicesSelected(t *testing.T) { } } +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{ + "批量配置", + "模板", + "Profile", + "Overlay", + "已选设备", + "已选 2 台设备", + "入口识别节点", + "辅助节点", + "预览摘要", + "workshop_face_shoe_alarm", + "local_3588_test", + "face_debug", + } { + if !strings.Contains(body, want) { + t.Fatalf("expected batch config page to contain %q, got:\n%s", want, 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("template", "workshop_face_shoe_alarm") + form.Set("profile", "local_3588_test") + form.Add("overlay", "face_debug") + 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) + } + + 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="config_id" value=""`, + } { + if !strings.Contains(body, want) { + t.Fatalf("expected failure refill HTML to contain %q, got:\n%s", want, body) + } + } + if strings.Contains(body, `name="overlay" value="face_debug" checked`) { + t.Fatalf("expected empty overlay selection to stay empty, got:\n%s", body) + } + if strings.Contains(body, "完整 JSON 放在折叠区") { + t.Fatalf("expected no JSON foldout hint on render failure, got:\n%s", 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}) @@ -234,6 +376,63 @@ func TestUI_ActionDevicesBatchActionKeepsDevicesOnError(t *testing.T) { } } +func createBatchConfigMediaRepo(t *testing.T) string { + t.Helper() + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{"name":"template"}`) + writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"profile"}`) + 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 createBatchConfigBrokenMediaRepo(t *testing.T) string { + t.Helper() + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{"name":"template"}`) + writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"profile"}`) + 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}) From 2a994b7220e7bdbdcc4d6d4a1ca4d5a742ef68b6 Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Mon, 20 Apr 2026 01:15:55 +0800 Subject: [PATCH 4/4] Align batch operations with audit workflow --- internal/web/ui.go | 72 ++++++++++++++++++++++- internal/web/ui/templates/audit.html | 17 ++++-- internal/web/ui/templates/task.html | 27 +++------ internal/web/ui/templates/tasks.html | 26 ++++----- internal/web/ui_test.go | 86 ++++++++++++++++++++++++---- 5 files changed, 172 insertions(+), 56 deletions(-) diff --git a/internal/web/ui.go b/internal/web/ui.go index 4c47935..ea7270e 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -147,6 +147,72 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic } return v }, + "taskGroupLabel": func(v any) string { + switch fmt.Sprint(v) { + case "config_apply": + return "批量配置" + case "media_start", "media_restart", "media_stop": + return "批量服务" + case "reload", "rollback": + return "设备操作" + default: + return "其他任务" + } + }, + "taskActionLabel": func(v any) string { + switch fmt.Sprint(v) { + case "config_apply": + return "下发识别配置" + case "reload": + return "重载识别服务" + case "rollback": + return "回滚识别配置" + case "media_start": + return "启动视频分析服务" + case "media_restart": + return "重启视频分析服务" + case "media_stop": + return "停止视频分析服务" + default: + return fmt.Sprint(v) + } + }, + "taskGroupClass": func(v any) string { + switch fmt.Sprint(v) { + case "config_apply": + return "pill run" + case "media_start", "media_restart", "media_stop": + return "pill ok" + case "reload", "rollback": + return "pill warn" + default: + return "pill" + } + }, + "taskStatusLabel": func(v any) string { + switch fmt.Sprint(v) { + case "success": + return "成功" + case "failed": + return "失败" + case "running": + return "执行中" + default: + return "待执行" + } + }, + "taskStatusClass": func(v any) string { + switch fmt.Sprint(v) { + case "success": + return "pill ok" + case "failed": + return "pill bad" + case "running": + return "pill run" + default: + return "pill" + } + }, "ago": func(ms int64) string { if ms <= 0 { return "-" @@ -779,7 +845,7 @@ func (u *UI) actionDeviceMediaServerConfigUploadBatch(w http.ResponseWriter, r * } func (u *UI) pageTasks(w http.ResponseWriter, r *http.Request) { - u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices()}) + u.render(w, r, "tasks", PageData{Title: "任务", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices()}) } func (u *UI) taskPageData(task *models.Task) PageData { @@ -837,13 +903,13 @@ func (u *UI) actionCreateTask(w http.ResponseWriter, r *http.Request) { } var payload any if err := json.Unmarshal([]byte(raw), &payload); err != nil { - u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: "高级参数 JSON 无效: " + err.Error(), RawJSON: raw, DeviceIDs: ids}) + u.render(w, r, "tasks", PageData{Title: "任务", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: "高级参数 JSON 无效: " + err.Error(), RawJSON: raw, DeviceIDs: ids}) return } task, err := u.tasks.CreateTask(typeStr, deviceIDs, payload) if err != nil { - u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: err.Error(), RawJSON: raw, DeviceIDs: ids}) + u.render(w, r, "tasks", PageData{Title: "任务", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: err.Error(), RawJSON: raw, DeviceIDs: ids}) return } http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound) diff --git a/internal/web/ui/templates/audit.html b/internal/web/ui/templates/audit.html index 50585de..51e65fd 100644 --- a/internal/web/ui/templates/audit.html +++ b/internal/web/ui/templates/audit.html @@ -18,9 +18,9 @@
节点标识状态进度失败原因
设备状态进度失败原因
{{$id}}
+
+
{{icon "device"}}
+
+
{{if .Device.DeviceName}}{{.Device.DeviceName}}{{else}}{{.Device.DeviceID}}{{end}}
+
+ {{.Device.DeviceID}} + {{if .Device.IP}}{{.Device.IP}}{{end}} +
+
+
+
- {{if eq $st.Status "success"}}成功 - {{else if eq $st.Status "failed"}}失败 - {{else if eq $st.Status "running"}}执行中 + {{if eq .Status "success"}}成功 + {{else if eq .Status "failed"}}失败 + {{else if eq .Status "running"}}执行中 {{else}}待执行{{end}} {{$st.Progress}}{{$st.Error}}{{.Progress}}{{.Error}}
+ - - + @@ -28,10 +28,15 @@ {{range .Tasks}} - - - - + + + + {{else}} diff --git a/internal/web/ui/templates/task.html b/internal/web/ui/templates/task.html index 5b9d985..c573a8c 100644 --- a/internal/web/ui/templates/task.html +++ b/internal/web/ui/templates/task.html @@ -1,7 +1,7 @@ {{define "task"}} @@ -13,28 +13,20 @@
{{.Task.ID}}
-
操作类型
+
任务类型
- {{if eq .Task.Type "config_apply"}}下发识别配置 - {{else if eq .Task.Type "reload"}}重载识别服务 - {{else if eq .Task.Type "rollback"}}回滚识别配置 - {{else if eq .Task.Type "media_start"}}启动视频分析服务 - {{else if eq .Task.Type "media_restart"}}重启视频分析服务 - {{else if eq .Task.Type "media_stop"}}停止视频分析服务 - {{else}}{{.Task.Type}}{{end}} + {{taskGroupLabel .Task.Type}} +
{{taskActionLabel .Task.Type}}
-
设备数量
-
{{len .Task.DeviceIDs}}
+
目标设备数
+
{{len .Task.DeviceIDs}} 台
当前状态
- {{if eq .Task.Status "success"}}成功 - {{else if eq .Task.Status "failed"}}失败 - {{else if eq .Task.Status "running"}}执行中 - {{else}}待执行{{end}} + {{taskStatusLabel .Task.Status}}
@@ -63,10 +55,7 @@ diff --git a/internal/web/ui/templates/tasks.html b/internal/web/ui/templates/tasks.html index 315e6f9..1c46e09 100644 --- a/internal/web/ui/templates/tasks.html +++ b/internal/web/ui/templates/tasks.html @@ -1,6 +1,6 @@ {{define "tasks"}}
-

创建批量运维任务

+

创建任务

用于向多台边缘节点下发识别配置、重载服务、回滚配置或控制视频分析服务。
@@ -33,32 +33,26 @@
-

任务中心

+

任务列表

类型 任务动作目标设备目标设备数 结果 说明
{{.ID}}{{.Type}}{{range $i, $id := .DeviceIDs}}{{if $i}}, {{end}}{{$id}}{{end}}{{.Status}} + {{taskGroupLabel .Type}} +
{{taskActionLabel .Type}}
+
+
{{.ID}}
+
{{len .DeviceIDs}} 台{{taskStatusLabel .Status}} {{if .Payload}}已记录任务参数{{else}}无附加参数{{end}}
- {{if eq .Status "success"}}成功 - {{else if eq .Status "failed"}}失败 - {{else if eq .Status "running"}}执行中 - {{else}}待执行{{end}} + {{taskStatusLabel .Status}} {{.Progress}} {{.Error}}
- + {{range .Tasks}} - + + - {{end}} diff --git a/internal/web/ui_test.go b/internal/web/ui_test.go index 21fb795..cd7ae8c 100644 --- a/internal/web/ui_test.go +++ b/internal/web/ui_test.go @@ -436,16 +436,20 @@ func writeTestFile(t *testing.T, path string, body string) { 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.Set("action", "reload") 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())) + 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.actionDevicesBatchAction(rr, req) + ui.actionDeviceBatchConfig(rr, req) if rr.Code != http.StatusFound { t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) } @@ -466,11 +470,19 @@ func TestUI_TaskPageRendersBatchSummaryAndDeviceResults(t *testing.T) { t.Fatalf("expected task page 200, got %d: %s", rrTask.Code, rrTask.Body.String()) } body := rrTask.Body.String() - for _, want := range []string{"任务详情", "返回操作审计", "设备结果表", "设备数量", "入口识别节点", "辅助节点", "edge-01", "edge-02", `id="task-status-value"`, "syncTaskStatus()"} { + 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) } @@ -1521,17 +1533,32 @@ 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", 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", GitSha: "8eaf213"}) tasks := service.NewTaskService(cfg, nil, reg) - task, err := tasks.CreateTask("reload", []string{"edge-01"}, nil) + 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) } - task.Mu.Lock() - task.Status = models.TaskSuccess - if ds, ok := task.Devices["edge-01"]; ok && ds != nil { - ds.Status = models.TaskSuccess + taskService, err := tasks.CreateTask("media_restart", []string{"edge-01", "edge-02"}, nil) + if err != nil { + t.Fatalf("CreateTask: %v", err) } - task.Mu.Unlock() + 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) @@ -1539,16 +1566,51 @@ func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) { rrAudit := httptest.NewRecorder() ui.pageAudit(rrAudit, httptest.NewRequest(http.MethodGet, "/ui/audit", nil)) - for _, want := range []string{"操作审计", "审计记录", "谁做了什么、对哪台设备做的、结果如何", "reload", "edge-01", task.ID} { + 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`} { + 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{"任务中心", "节点执行情况"} { + 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))
任务标识操作状态节点数
任务类型目标设备数状态
{{.ID}} - {{if eq .Type "config_apply"}}下发识别配置 - {{else if eq .Type "reload"}}重载识别服务 - {{else if eq .Type "rollback"}}回滚识别配置 - {{else if eq .Type "media_start"}}启动视频分析服务 - {{else if eq .Type "media_restart"}}重启视频分析服务 - {{else if eq .Type "media_stop"}}停止视频分析服务 - {{else}}{{.Type}}{{end}} + +
{{taskActionLabel .Type}}
- {{if eq .Status "success"}}成功 - {{else if eq .Status "failed"}}失败 - {{else if eq .Status "running"}}执行中 - {{else}}待执行{{end}} + {{taskGroupLabel .Type}} + {{len .DeviceIDs}} 台 + {{taskStatusLabel .Status}} {{len .DeviceIDs}}