diff --git a/internal/web/ui.go b/internal/web/ui.go
index 00dca6e..ea7270e 100644
--- a/internal/web/ui.go
+++ b/internal/web/ui.go
@@ -40,26 +40,30 @@ 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
+ TaskDeviceRows []TaskDeviceRow
+ Templates []service.Template
+ Template *service.Template
+ SelectedDeviceIDs []string
+ SelectedDevices []*models.Device
+ SelectedQuery string
RawJSON string
RawText string
@@ -79,6 +83,13 @@ type DeviceOverviewRow struct {
ConfigStatusErr string
}
+type TaskDeviceRow struct {
+ Device *models.Device
+ Status models.TaskStatus
+ Progress float64
+ Error string
+}
+
type ConfigStatusView struct {
OK bool `json:"ok"`
ConfigPath string `json:"config_path"`
@@ -136,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 "-"
@@ -177,29 +254,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 {
@@ -243,6 +320,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)
@@ -329,46 +408,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) {
@@ -436,16 +476,9 @@ func (u *UI) actionDiscoverySearch(w http.ResponseWriter, r *http.Request) {
func (u *UI) actionDevicesBatchAction(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
action := strings.TrimSpace(r.FormValue("action"))
- deviceIDs := r.Form["device_id"]
+ deviceIDs := filterSelectedDeviceIDs(u.registry.GetDevices(), 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 +487,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 +506,77 @@ 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
+ }
+
+ 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
}
@@ -756,7 +845,42 @@ 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 {
+ data := PageData{Title: "任务详情", Task: task}
+ if task == nil {
+ return data
+ }
+
+ devices := make(map[string]*models.Device)
+ if u.registry != nil {
+ for _, dev := range u.registry.GetDevices() {
+ if dev == nil {
+ continue
+ }
+ devices[dev.DeviceID] = dev
+ }
+ }
+
+ rows := make([]TaskDeviceRow, 0, len(task.DeviceIDs))
+ for _, did := range task.DeviceIDs {
+ row := TaskDeviceRow{}
+ if dev := devices[did]; dev != nil {
+ row.Device = dev
+ } else {
+ row.Device = &models.Device{DeviceID: did}
+ }
+ if ds := task.Devices[did]; ds != nil {
+ row.Status = ds.Status
+ row.Progress = ds.Progress
+ row.Error = ds.Error
+ }
+ rows = append(rows, row)
+ }
+ data.TaskDeviceRows = rows
+ return data
}
func (u *UI) actionCreateTask(w http.ResponseWriter, r *http.Request) {
@@ -779,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)
@@ -806,7 +930,9 @@ func (u *UI) pageTask(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
- u.render(w, r, "task", PageData{Title: "任务详情", Task: task, TaskID: id})
+ data := u.taskPageData(task)
+ data.TaskID = id
+ u.render(w, r, "task", data)
}
func (u *UI) pageTemplates(w http.ResponseWriter, r *http.Request) {
@@ -1139,6 +1265,158 @@ 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 (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/assets/style.css b/internal/web/ui/assets/style.css
index c2f00ea..b64f6ee 100644
--- a/internal/web/ui/assets/style.css
+++ b/internal/web/ui/assets/style.css
@@ -104,9 +104,12 @@ tbody tr:hover{background:#f9fafb}
.pill{display:inline-flex;align-items:center;padding:3px 8px;border-radius:999px;border:1px solid var(--border);background:#f3f4f6;color:#374151;font-size:11px;font-weight:600}
.pill.ok{background:#ecfdf5;border-color:#bbf7d0;color:#166534}
.pill.bad{background:#fef2f2;border-color:#fecaca;color:#991b1b}
+.pill.run{background:#eff6ff;border-color:#bfdbfe;color:#1d4ed8}
.pill.warn{background:#fffbeb;border-color:#fde68a;color:#92400e}
.actions{display:flex;flex-wrap:wrap;gap:8px}
+.actions.compact{gap:6px}
+.actions.compact .btn,.actions.compact button{padding:5px 9px;font-size:11px}
.btn .ui-icon{width:14px;height:14px}
.stack{flex-direction:column;align-items:flex-start}
.device-context-head{display:flex;align-items:center;gap:12px}
@@ -151,6 +154,13 @@ pre{margin-top:12px;padding:12px;border-radius:8px;border:1px solid #1f2937;back
.subnav a{display:inline-flex;align-items:center;padding:7px 10px;border:1px solid var(--border);border-radius:999px;background:#fff;color:#374151;font-size:12px;font-weight:500}
.ui-icon{display:block;flex:0 0 auto}
+.batch-toolbar{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;padding:14px 16px;border:1px solid var(--border);border-radius:8px;background:var(--surface-soft);margin:0 0 12px}
+.batch-toolbar-count{font-size:13px;font-weight:600}
+.batch-toolbar .actions{justify-content:flex-end}
+.batch-toolbar .actions .btn,.batch-toolbar .actions button{white-space:nowrap}
+.select-cell{width:52px;text-align:center;vertical-align:middle}
+.select-cell input[type=checkbox]{width:16px;height:16px;margin:0;accent-color:var(--primary)}
+
@media (max-width:1024px){
.app-shell{grid-template-columns:1fr}
.sidebar{position:relative;height:auto}
@@ -158,4 +168,5 @@ pre{margin-top:12px;padding:12px;border-radius:8px;border:1px solid #1f2937;back
main{padding:18px}
.stats,.detail-grid,.quad-grid,.control-grid,.summary-strip,.info-list,.field-grid{grid-template-columns:1fr}
.hero-band{flex-direction:column;align-items:flex-start}
+ .batch-toolbar{flex-direction:column}
}
diff --git a/internal/web/ui/templates/audit.html b/internal/web/ui/templates/audit.html
index 50585de..51e65fd 100644
--- a/internal/web/ui/templates/audit.html
+++ b/internal/web/ui/templates/audit.html
@@ -18,9 +18,9 @@
+ | 类型 |
任务 |
- 动作 |
- 目标设备 |
+ 目标设备数 |
结果 |
说明 |
@@ -28,10 +28,15 @@
{{range .Tasks}}
- | {{.ID}} |
- {{.Type}} |
- {{range $i, $id := .DeviceIDs}}{{if $i}}, {{end}}{{$id}}{{end}} |
- {{.Status}} |
+
+ {{taskGroupLabel .Type}}
+ {{taskActionLabel .Type}}
+ |
+
+ {{.ID}}
+ |
+ {{len .DeviceIDs}} 台 |
+ {{taskStatusLabel .Status}} |
{{if .Payload}}已记录任务参数{{else}}无附加参数{{end}} |
{{else}}
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 f06246d..efba81c 100644
--- a/internal/web/ui/templates/devices.html
+++ b/internal/web/ui/templates/devices.html
@@ -41,88 +41,112 @@
-
-
-
-
- | 设备 |
- 状态 |
- 当前配置 |
- 操作 |
-
-
-
- {{range .DeviceRows}}
-
-
-
- {{icon "device"}}
-
- {{if .Device.DeviceName}}{{.Device.DeviceName}}{{else}}{{.Device.DeviceID}}{{end}}
- |
+
+
+ |
+
+ {{else}}
+
+
+
+ 还没有设备
+ 当前后台还没有发现或录入任何设备。
+
+ |
+
+ {{end}}
+
+
+
+
{{end}}
diff --git a/internal/web/ui/templates/task.html b/internal/web/ui/templates/task.html
index 8183729..c573a8c 100644
--- a/internal/web/ui/templates/task.html
+++ b/internal/web/ui/templates/task.html
@@ -1,58 +1,64 @@
{{define "task"}}
-
任务执行详情
-
-
-
任务标识
-
{{.Task.ID}}
+
任务详情
+
+
-
-
操作
-
- {{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}}
{{.Task.Type}}{{end}}
+
+
任务类型
+
+
{{taskGroupLabel .Task.Type}}
+
{{taskActionLabel .Task.Type}}
-
-
状态
-
- {{if eq .Task.Status "success"}}
成功
- {{else if eq .Task.Status "failed"}}
失败
- {{else if eq .Task.Status "running"}}
执行中
- {{else}}
待执行{{end}}
+
+
目标设备数
+
{{len .Task.DeviceIDs}} 台
+
+
+
当前状态
+
+ {{taskStatusLabel .Task.Status}}
-
节点执行情况
+
设备结果表
- | 节点标识 | 状态 | 进度 | 失败原因 |
+ | 设备 | 状态 | 进度 | 失败原因 |
- {{range $id, $st := .Task.Devices}}
-
- | {{$id}} |
-
- {{if eq $st.Status "success"}}成功
- {{else if eq $st.Status "failed"}}失败
- {{else if eq $st.Status "running"}}执行中
- {{else}}待执行{{end}}
+ {{range .TaskDeviceRows}}
+ |
+
+
+ {{icon "device"}}
+
+ {{if .Device.DeviceName}}{{.Device.DeviceName}}{{else}}{{.Device.DeviceID}}{{end}}
+
+ {{.Device.DeviceID}}
+ {{if .Device.IP}}{{.Device.IP}}{{end}}
+
+
+
|
- {{$st.Progress}} |
- {{$st.Error}} |
+
+ {{taskStatusLabel .Status}}
+ |
+ {{.Progress}} |
+ {{.Error}} |
{{end}}
@@ -62,8 +68,8 @@
实时进度
-
任务执行过程中会持续推送每台节点的状态变化。
-
+
任务执行过程中会持续推送每台设备的状态变化。
+
未连接
@@ -73,12 +79,38 @@
{{end}}
diff --git a/internal/web/ui/templates/tasks.html b/internal/web/ui/templates/tasks.html
index 315e6f9..1c46e09 100644
--- a/internal/web/ui/templates/tasks.html
+++ b/internal/web/ui/templates/tasks.html
@@ -1,6 +1,6 @@
{{define "tasks"}}
-
创建批量运维任务
+
创建任务
用于向多台边缘节点下发识别配置、重载服务、回滚配置或控制视频分析服务。
@@ -33,32 +33,26 @@
-
任务中心
+
任务列表
- | 任务标识 | 操作 | 状态 | 节点数 |
+ | 任务 | 类型 | 目标设备数 | 状态 |
{{range .Tasks}}
- | {{.ID}} |
- {{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}}{{.Type}}{{end}}
+
+ {{taskActionLabel .Type}}
|
- {{if eq .Status "success"}}成功
- {{else if eq .Status "failed"}}失败
- {{else if eq .Status "running"}}执行中
- {{else}}待执行{{end}}
+ {{taskGroupLabel .Type}}
+ |
+ {{len .DeviceIDs}} 台 |
+
+ {{taskStatusLabel .Status}}
|
- {{len .DeviceIDs}} |
{{end}}
diff --git a/internal/web/ui_test.go b/internal/web/ui_test.go
index fbfacb6..cd7ae8c 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"
@@ -50,6 +51,61 @@ func TestUI_ActionDevicesBatchAction_RedirectsToTask(t *testing.T) {
}
}
+func TestUI_ActionDevicesBatchActionDeduplicatesKnownDevices(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", "reload")
+ form.Add("device_id", "edge-01")
+ form.Add("device_id", "edge-01")
+ form.Add("device_id", "edge-02")
+ form.Add("device_id", "missing")
+ 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.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 got := len(task.DeviceIDs); got != 2 {
+ t.Fatalf("expected deduplicated device count 2, got %d: %#v", got, task.DeviceIDs)
+ }
+ if task.DeviceIDs[0] != "edge-01" || task.DeviceIDs[1] != "edge-02" {
+ t.Fatalf("expected selection order preserved, got %#v", task.DeviceIDs)
+ }
+}
+
+func TestUI_SelectedDeviceQueryHelpersStayStable(t *testing.T) {
+ ids := selectedIDsFromQuery([]string{" edge-02 ", "edge-01", "edge-02", ""})
+ if len(ids) != 2 || ids[0] != "edge-02" || ids[1] != "edge-01" {
+ t.Fatalf("selectedIDsFromQuery normalized to %#v", ids)
+ }
+ if got := selectedQueryString(ids); got != "selected=edge-02&selected=edge-01" {
+ t.Fatalf("selectedQueryString returned %q", got)
+ }
+}
+
func TestUI_DevicePageUsesEdgeVisionConsoleShell(t *testing.T) {
cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
reg := service.NewRegistryService(cfg, nil)
@@ -117,6 +173,321 @@ 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_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})
+
+ 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 台", `value="edge-01" checked`, `value="edge-02" checked`} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("expected error render to contain %q, got:\n%s", want, body)
+ }
+ }
+}
+
+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})
+ 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.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)
+ }
+
+ rrTask := httptest.NewRecorder()
+ reqTask := httptest.NewRequest(http.MethodGet, loc, nil)
+ rctx := chi.NewRouteContext()
+ rctx.URLParams.Add("id", strings.TrimPrefix(loc, "/ui/tasks/"))
+ reqTask = reqTask.WithContext(context.WithValue(reqTask.Context(), chi.RouteCtxKey, rctx))
+ ui.pageTask(rrTask, reqTask)
+
+ if rrTask.Code != http.StatusOK {
+ t.Fatalf("expected task page 200, got %d: %s", rrTask.Code, rrTask.Body.String())
+ }
+ body := rrTask.Body.String()
+ 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)
+ }
+}
+
func TestUI_DeviceOverviewRendersFleetOverview(t *testing.T) {
ui := newTestUI(t)
req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil)
@@ -666,7 +1037,7 @@ func TestUI_ConfigPreviewShowsApplySummaryAfterApplyResult(t *testing.T) {
"overlays": []any{"face_test_sensitive", "production_quiet"},
},
Sha256: "eecdf8d422705f3affa0f892199604f037f60ea8fe578fe2a65527e1800044c5",
- Size: 64,
+ Size: 64,
},
ConfigStatus: &ConfigStatusView{
OK: true,
@@ -1162,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)
@@ -1180,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))