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] 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)
节点标识状态进度失败原因
设备状态进度失败原因
{{$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}}