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] 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,