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}}
+
+
+
+
+
+
+
+
+
{{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 @@
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})