Add batch config rollout workflow
This commit is contained in:
parent
17240ac7bd
commit
2eca56e59a
@ -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 == "" {
|
||||
|
||||
110
internal/web/ui/templates/device_batch_config.html
Normal file
110
internal/web/ui/templates/device_batch_config.html
Normal file
@ -0,0 +1,110 @@
|
||||
{{define "device_batch_config"}}
|
||||
<section class="hero-band">
|
||||
<div>
|
||||
<div class="eyebrow">批量配置</div>
|
||||
<h2>用模板化配置驱动一批设备</h2>
|
||||
<div class="muted">先确认目标设备,再选择模板、Profile 和 Overlay,生成后直接进入批量下发任务。</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "devices"}}<span>已选设备</span></h2>
|
||||
<div class="muted small">已选 {{len .SelectedDeviceIDs}} 台设备,将按当前选择顺序创建任务。</div>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<a class="btn ghost" href="/ui/devices?{{.SelectedQuery}}#batch-config">返回设备列表</a>
|
||||
<a class="btn ghost" href="/ui/devices">重新选择</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-list">
|
||||
{{range .SelectedDevices}}
|
||||
<div>
|
||||
<span>{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</span>
|
||||
<strong class="mono">{{.DeviceID}}</strong>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="full">
|
||||
<span>目标设备</span>
|
||||
<strong>还没有选中设备</strong>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "config"}}<span>批量配置</span></h2>
|
||||
<div class="muted small">保持模板化配置路线,不在这里直接维护完整 JSON。</div>
|
||||
</div>
|
||||
{{if .ConfigSources.Root}}<div class="muted small mono">{{.ConfigSources.Root}}</div>{{end}}
|
||||
</div>
|
||||
|
||||
<form method="post" action="/ui/devices/batch-config">
|
||||
{{range .SelectedDeviceIDs}}<input type="hidden" name="device_id" value="{{.}}" />{{end}}
|
||||
<div class="field-grid">
|
||||
<label><span>模板</span>
|
||||
<select name="template">
|
||||
{{range .ConfigSources.Templates}}
|
||||
<option value="{{.Name}}" {{if eq .Name $.SelectedTemplate}}selected{{end}}>{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label><span>Profile</span>
|
||||
<select name="profile">
|
||||
{{range .ConfigSources.Profiles}}
|
||||
<option value="{{.Name}}" {{if eq .Name $.SelectedProfile}}selected{{end}}>{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label><span>config_id</span><input name="config_id" value="{{.SelectedConfigID}}" placeholder="留空自动生成" /></label>
|
||||
<label><span>config_version</span><input name="config_version" value="{{.SelectedVersion}}" placeholder="留空自动生成" /></label>
|
||||
<div class="full">
|
||||
<span class="muted small">Overlay</span>
|
||||
<div class="actions" style="margin-top:6px">
|
||||
{{range .ConfigSources.Overlays}}
|
||||
<label class="btn ghost">
|
||||
<input type="checkbox" name="overlay" value="{{.Name}}" {{if hasString $.SelectedOverlays .Name}}checked{{end}} />
|
||||
{{.Name}}
|
||||
</label>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">创建批量下发任务</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "preview"}}<span>预览摘要</span></h2>
|
||||
<div class="muted small">{{if .ConfigPreview}}默认只展示配置生成关键信息。完整 JSON 在下方折叠区。{{else}}先选择模板化参数并提交,页面会在这里展示配置生成关键信息。{{end}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-list">
|
||||
<div><span>模板</span><strong>{{if .ConfigPreview}}{{index .ConfigPreview.Metadata "template"}}{{else}}{{.SelectedTemplate}}{{end}}</strong></div>
|
||||
<div><span>Profile</span><strong>{{if .ConfigPreview}}{{index .ConfigPreview.Metadata "profile"}}{{else}}{{.SelectedProfile}}{{end}}</strong></div>
|
||||
<div><span>Overlay</span><strong class="mono">{{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}}</strong></div>
|
||||
<div><span>目标设备</span><strong>{{len .SelectedDeviceIDs}} 台</strong></div>
|
||||
<div><span>config_id</span><strong class="mono">{{if .ConfigPreview}}{{index .ConfigPreview.Metadata "config_id"}}{{else}}{{if .SelectedConfigID}}{{.SelectedConfigID}}{{else}}自动生成{{end}}{{end}}</strong></div>
|
||||
<div><span>config_version</span><strong class="mono">{{if .ConfigPreview}}{{index .ConfigPreview.Metadata "config_version"}}{{else}}{{if .SelectedVersion}}{{.SelectedVersion}}{{else}}自动生成{{end}}{{end}}</strong></div>
|
||||
{{if .ConfigPreview}}
|
||||
<div><span>大小</span><strong class="mono">{{.ConfigPreview.Size}} bytes</strong></div>
|
||||
<div class="full"><span>SHA256</span><strong class="mono">{{.ConfigPreview.Sha256}}</strong></div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .ConfigPreview}}
|
||||
<details class="card collapsible">
|
||||
<summary class="title-with-icon">{{icon "tech"}}<span>完整 JSON</span></summary>
|
||||
<pre>{{.ConfigPreview.JSON}}</pre>
|
||||
</details>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@ -46,14 +46,14 @@
|
||||
<div class="batch-toolbar" id="batch-config">
|
||||
<div>
|
||||
<div class="batch-toolbar-count">已选 {{len .SelectedDeviceIDs}} 台</div>
|
||||
<div class="muted small">选择后可以对这批设备统一执行服务操作,批量配置入口稍后开放。</div>
|
||||
<div class="muted small">选择后可以对这批设备统一执行服务操作,或进入模板化批量配置。</div>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<button type="submit" name="action" value="media_restart">重启服务</button>
|
||||
<button type="submit" name="action" value="media_start">启动服务</button>
|
||||
<button type="submit" name="action" value="media_stop">停止服务</button>
|
||||
<button type="submit" name="action" value="reload">重载服务</button>
|
||||
<a class="btn ghost" href="/ui/devices?{{.SelectedQuery}}#batch-config">批量配置</a>
|
||||
<a class="btn ghost" href="/ui/devices/batch-config?{{.SelectedQuery}}">批量配置</a>
|
||||
<a class="btn ghost" href="/ui/devices">清空选择</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user