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})