Align batch operations with audit workflow

This commit is contained in:
tian 2026-04-20 01:15:55 +08:00
parent 2eca56e59a
commit 2a994b7220
5 changed files with 172 additions and 56 deletions

View File

@ -147,6 +147,72 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic
}
return v
},
"taskGroupLabel": func(v any) string {
switch fmt.Sprint(v) {
case "config_apply":
return "批量配置"
case "media_start", "media_restart", "media_stop":
return "批量服务"
case "reload", "rollback":
return "设备操作"
default:
return "其他任务"
}
},
"taskActionLabel": func(v any) string {
switch fmt.Sprint(v) {
case "config_apply":
return "下发识别配置"
case "reload":
return "重载识别服务"
case "rollback":
return "回滚识别配置"
case "media_start":
return "启动视频分析服务"
case "media_restart":
return "重启视频分析服务"
case "media_stop":
return "停止视频分析服务"
default:
return fmt.Sprint(v)
}
},
"taskGroupClass": func(v any) string {
switch fmt.Sprint(v) {
case "config_apply":
return "pill run"
case "media_start", "media_restart", "media_stop":
return "pill ok"
case "reload", "rollback":
return "pill warn"
default:
return "pill"
}
},
"taskStatusLabel": func(v any) string {
switch fmt.Sprint(v) {
case "success":
return "成功"
case "failed":
return "失败"
case "running":
return "执行中"
default:
return "待执行"
}
},
"taskStatusClass": func(v any) string {
switch fmt.Sprint(v) {
case "success":
return "pill ok"
case "failed":
return "pill bad"
case "running":
return "pill run"
default:
return "pill"
}
},
"ago": func(ms int64) string {
if ms <= 0 {
return "-"
@ -779,7 +845,7 @@ func (u *UI) actionDeviceMediaServerConfigUploadBatch(w http.ResponseWriter, r *
}
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()})
u.render(w, r, "tasks", PageData{Title: "任务", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices()})
}
func (u *UI) taskPageData(task *models.Task) PageData {
@ -837,13 +903,13 @@ func (u *UI) actionCreateTask(w http.ResponseWriter, r *http.Request) {
}
var payload any
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: "高级参数 JSON 无效: " + err.Error(), RawJSON: raw, DeviceIDs: ids})
u.render(w, r, "tasks", PageData{Title: "任务", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: "高级参数 JSON 无效: " + err.Error(), RawJSON: raw, DeviceIDs: ids})
return
}
task, err := u.tasks.CreateTask(typeStr, deviceIDs, payload)
if err != nil {
u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: err.Error(), RawJSON: raw, DeviceIDs: ids})
u.render(w, r, "tasks", PageData{Title: "任务", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: err.Error(), RawJSON: raw, DeviceIDs: ids})
return
}
http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound)

View File

@ -18,9 +18,9 @@
<table>
<thead>
<tr>
<th>类型</th>
<th>任务</th>
<th>动作</th>
<th>目标设备</th>
<th>目标设备数</th>
<th>结果</th>
<th>说明</th>
</tr>
@ -28,10 +28,15 @@
<tbody>
{{range .Tasks}}
<tr>
<td class="mono">{{.ID}}</td>
<td>{{.Type}}</td>
<td class="mono">{{range $i, $id := .DeviceIDs}}{{if $i}}, {{end}}{{$id}}{{end}}</td>
<td><span class="pill">{{.Status}}</span></td>
<td>
<span class="{{taskGroupClass .Type}}">{{taskGroupLabel .Type}}</span>
<div class="muted small" style="margin-top:4px">{{taskActionLabel .Type}}</div>
</td>
<td>
<div class="mono">{{.ID}}</div>
</td>
<td>{{len .DeviceIDs}} 台</td>
<td><span class="{{taskStatusClass .Status}}">{{taskStatusLabel .Status}}</span></td>
<td class="muted small">{{if .Payload}}已记录任务参数{{else}}无附加参数{{end}}</td>
</tr>
{{else}}

View File

@ -1,7 +1,7 @@
{{define "task"}}
<div class="card">
<div class="actions">
<a class="btn ghost" href="/ui/audit">{{icon "audit"}}<span>返回操作审计</span></a>
<a class="btn ghost" href="/ui/tasks">{{icon "devices"}}<span>返回任务列表</span></a>
</div>
</div>
@ -13,28 +13,20 @@
<div class="summary-chip-value mono">{{.Task.ID}}</div>
</div>
<div class="summary-chip">
<div class="summary-chip-label">操作类型</div>
<div class="summary-chip-label">任务类型</div>
<div class="summary-chip-value">
{{if eq .Task.Type "config_apply"}}下发识别配置
{{else if eq .Task.Type "reload"}}重载识别服务
{{else if eq .Task.Type "rollback"}}回滚识别配置
{{else if eq .Task.Type "media_start"}}启动视频分析服务
{{else if eq .Task.Type "media_restart"}}重启视频分析服务
{{else if eq .Task.Type "media_stop"}}停止视频分析服务
{{else}}<span class="mono">{{.Task.Type}}</span>{{end}}
<span class="{{taskGroupClass .Task.Type}}">{{taskGroupLabel .Task.Type}}</span>
<div class="muted small" style="margin-top:4px">{{taskActionLabel .Task.Type}}</div>
</div>
</div>
<div class="summary-chip">
<div class="summary-chip-label">设备数</div>
<div class="summary-chip-value">{{len .Task.DeviceIDs}}</div>
<div class="summary-chip-label">目标设备数</div>
<div class="summary-chip-value">{{len .Task.DeviceIDs}} 台</div>
</div>
<div class="summary-chip">
<div class="summary-chip-label">当前状态</div>
<div class="summary-chip-value" id="task-status-value" data-task-status="{{.Task.Status}}">
{{if eq .Task.Status "success"}}<span class="pill ok">成功</span>
{{else if eq .Task.Status "failed"}}<span class="pill bad">失败</span>
{{else if eq .Task.Status "running"}}<span class="pill run">执行中</span>
{{else}}<span class="pill">待执行</span>{{end}}
<span class="{{taskStatusClass .Task.Status}}">{{taskStatusLabel .Task.Status}}</span>
</div>
</div>
</div>
@ -63,10 +55,7 @@
</div>
</td>
<td class="st">
{{if eq .Status "success"}}<span class="pill ok">成功</span>
{{else if eq .Status "failed"}}<span class="pill bad">失败</span>
{{else if eq .Status "running"}}<span class="pill run">执行中</span>
{{else}}<span class="pill">待执行</span>{{end}}
<span class="{{taskStatusClass .Status}}">{{taskStatusLabel .Status}}</span>
</td>
<td class="pg mono">{{.Progress}}</td>
<td class="er">{{.Error}}</td>

View File

@ -1,6 +1,6 @@
{{define "tasks"}}
<div class="card">
<h2>创建批量运维任务</h2>
<h2>创建任务</h2>
<div class="muted small">
用于向多台边缘节点下发识别配置、重载服务、回滚配置或控制视频分析服务。
</div>
@ -33,32 +33,26 @@
</div>
<div class="card">
<h2>任务中心</h2>
<h2>任务列表</h2>
<div class="table-wrap" style="margin-top:10px">
<table>
<thead>
<tr><th>任务标识</th><th>操作</th><th>状态</th><th>节点数</th></tr>
<tr><th>任务</th><th>类型</th><th>目标设备数</th><th>状态</th></tr>
</thead>
<tbody>
{{range .Tasks}}
<tr>
<td><a class="mono" href="/ui/tasks/{{.ID}}">{{.ID}}</a></td>
<td>
{{if eq .Type "config_apply"}}下发识别配置
{{else if eq .Type "reload"}}重载识别服务
{{else if eq .Type "rollback"}}回滚识别配置
{{else if eq .Type "media_start"}}启动视频分析服务
{{else if eq .Type "media_restart"}}重启视频分析服务
{{else if eq .Type "media_stop"}}停止视频分析服务
{{else}}<span class="mono">{{.Type}}</span>{{end}}
<div><a class="mono" href="/ui/tasks/{{.ID}}">{{.ID}}</a></div>
<div class="muted small">{{taskActionLabel .Type}}</div>
</td>
<td>
{{if eq .Status "success"}}<span class="pill ok">成功</span>
{{else if eq .Status "failed"}}<span class="pill bad">失败</span>
{{else if eq .Status "running"}}<span class="pill run">执行中</span>
{{else}}<span class="pill">待执行</span>{{end}}
<span class="{{taskGroupClass .Type}}">{{taskGroupLabel .Type}}</span>
</td>
<td>{{len .DeviceIDs}} 台</td>
<td>
<span class="{{taskStatusClass .Status}}">{{taskStatusLabel .Status}}</span>
</td>
<td>{{len .DeviceIDs}}</td>
</tr>
{{end}}
</tbody>

View File

@ -436,16 +436,20 @@ func writeTestFile(t *testing.T, path string, body string) {
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})
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigMediaRepo(t)})
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()))
form.Set("template", "workshop_face_shoe_alarm")
form.Set("profile", "local_3588_test")
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.actionDevicesBatchAction(rr, req)
ui.actionDeviceBatchConfig(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
@ -466,11 +470,19 @@ func TestUI_TaskPageRendersBatchSummaryAndDeviceResults(t *testing.T) {
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()"} {
for _, want := range []string{"任务详情", "返回任务列表", "设备结果表", "任务类型", "目标设备数", "批配置", "下发识别配置", "2 台", "入口识别节点", "辅助节点", "edge-01", "edge-02", `id="task-status-value"`, "syncTaskStatus()", `href="/ui/tasks"`} {
if !strings.Contains(body, want) {
t.Fatalf("expected task page to contain %q, got:\n%s", want, body)
}
}
for _, forbidden := range []string{"任务中心", "节点执行情况"} {
if strings.Contains(body, forbidden) {
t.Fatalf("task page should not contain %q, got:\n%s", forbidden, body)
}
}
if strings.Contains(body, "返回操作审计") {
t.Fatalf("task page should not point back to audit, got:\n%s", 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)
}
@ -1521,17 +1533,32 @@ func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) {
cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
reg := service.NewRegistryService(cfg, nil)
reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true, Version: "1.0.0", GitSha: "5c04681"})
reg.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true, Version: "1.0.1", GitSha: "8eaf213"})
tasks := service.NewTaskService(cfg, nil, reg)
task, err := tasks.CreateTask("reload", []string{"edge-01"}, nil)
taskConfig, err := tasks.CreateTask("config_apply", []string{"edge-01", "edge-02"}, map[string]any{"config": map[string]any{}})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
task.Mu.Lock()
task.Status = models.TaskSuccess
if ds, ok := task.Devices["edge-01"]; ok && ds != nil {
ds.Status = models.TaskSuccess
taskService, err := tasks.CreateTask("media_restart", []string{"edge-01", "edge-02"}, nil)
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
task.Mu.Unlock()
taskConfig.Mu.Lock()
taskConfig.Status = models.TaskSuccess
for _, did := range taskConfig.DeviceIDs {
if ds, ok := taskConfig.Devices[did]; ok && ds != nil {
ds.Status = models.TaskSuccess
}
}
taskConfig.Mu.Unlock()
taskService.Mu.Lock()
taskService.Status = models.TaskSuccess
for _, did := range taskService.DeviceIDs {
if ds, ok := taskService.Devices[did]; ok && ds != nil {
ds.Status = models.TaskSuccess
}
}
taskService.Mu.Unlock()
ui, err := NewUI(nil, reg, nil, tasks, nil)
if err != nil {
t.Fatalf("NewUI: %v", err)
@ -1539,16 +1566,51 @@ func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) {
rrAudit := httptest.NewRecorder()
ui.pageAudit(rrAudit, httptest.NewRequest(http.MethodGet, "/ui/audit", nil))
for _, want := range []string{"操作审计", "审计记录", "谁做了什么、对哪台设备做的、结果如何", "reload", "edge-01", task.ID} {
for _, want := range []string{"操作审计", "审计记录", "批量配置", "批量服务", "目标设备数", "2 台", "下发识别配置", "重启视频分析服务"} {
if !strings.Contains(rrAudit.Body.String(), want) {
t.Fatalf("expected audit HTML to contain %q", want)
}
}
for _, forbidden := range []string{"框架版", "后续", `disabled`} {
for _, forbidden := range []string{"框架版", "后续", "任务中心", "节点执行情况", `disabled`} {
if strings.Contains(rrAudit.Body.String(), forbidden) {
t.Fatalf("audit HTML should not contain placeholder marker %q", forbidden)
}
}
for _, forbidden := range []string{"success", "failed", "running"} {
if strings.Contains(rrAudit.Body.String(), forbidden) {
t.Fatalf("audit HTML should not leak raw status enum %q", forbidden)
}
}
rrTasks := httptest.NewRecorder()
ui.pageTasks(rrTasks, httptest.NewRequest(http.MethodGet, "/ui/tasks", nil))
for _, want := range []string{"任务列表", "批量配置", "批量服务", "目标设备数"} {
if !strings.Contains(rrTasks.Body.String(), want) {
t.Fatalf("expected tasks HTML to contain %q", want)
}
}
for _, forbidden := range []string{"任务中心", "节点执行情况"} {
if strings.Contains(rrTasks.Body.String(), forbidden) {
t.Fatalf("tasks HTML should not contain placeholder marker %q", forbidden)
}
}
rrTaskConfig := httptest.NewRecorder()
reqTask := httptest.NewRequest(http.MethodGet, "/ui/tasks/"+taskConfig.ID, nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", taskConfig.ID)
reqTask = reqTask.WithContext(context.WithValue(reqTask.Context(), chi.RouteCtxKey, rctx))
ui.pageTask(rrTaskConfig, reqTask)
for _, want := range []string{"批量配置", "下发识别配置", "返回任务列表"} {
if !strings.Contains(rrTaskConfig.Body.String(), want) {
t.Fatalf("expected task detail HTML to contain %q", want)
}
}
for _, forbidden := range []string{"任务中心", "返回操作审计"} {
if strings.Contains(rrTaskConfig.Body.String(), forbidden) {
t.Fatalf("task detail HTML should not contain %q", forbidden)
}
}
rrSystem := httptest.NewRecorder()
ui.pageSystem(rrSystem, httptest.NewRequest(http.MethodGet, "/ui/system", nil))