Promote device batch service actions in overview

This commit is contained in:
tian 2026-04-20 00:44:54 +08:00
parent 513062f08e
commit 17240ac7bd
5 changed files with 230 additions and 30 deletions

View File

@ -58,6 +58,7 @@ type PageData struct {
SelectedVersion string
Tasks []models.Task
Task *models.Task
TaskDeviceRows []TaskDeviceRow
Templates []service.Template
Template *service.Template
SelectedDeviceIDs []string
@ -81,6 +82,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"`
@ -399,7 +407,7 @@ 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 {
u.render(w, r, "devices", u.deviceOverviewPageData(r, nil, "请先选择设备"))
return
@ -701,6 +709,41 @@ 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()})
}
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) {
_ = r.ParseForm()
typeStr := strings.TrimSpace(r.FormValue("type"))
@ -748,7 +791,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) {

View File

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

View File

@ -48,7 +48,7 @@
<div class="batch-toolbar-count">已选 {{len .SelectedDeviceIDs}} 台</div>
<div class="muted small">选择后可以对这批设备统一执行服务操作,批量配置入口稍后开放。</div>
</div>
<div class="actions">
<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>
@ -73,7 +73,7 @@
<tbody>
{{range .DeviceRows}}
<tr>
<td style="text-align:center">
<td class="select-cell">
<input type="checkbox" name="device_id" value="{{.Device.DeviceID}}" {{if hasString $.SelectedDeviceIDs .Device.DeviceID}}checked{{end}} />
</td>
<td>

View File

@ -1,18 +1,20 @@
{{define "task"}}
<div class="card">
<div><a href="/ui/tasks">返回任务中心</a></div>
<div class="actions">
<a class="btn ghost" href="/ui/audit">{{icon "audit"}}<span>返回操作审计</span></a>
</div>
</div>
<div class="card">
<h2>任务执行详情</h2>
<div class="row" style="margin-top:10px">
<div>
<div class="muted small">任务标识</div>
<div class="mono"><b>{{.Task.ID}}</b></div>
<h2>任务详情</h2>
<div class="summary-strip control-summary" style="margin-top:10px">
<div class="summary-chip">
<div class="summary-chip-label">任务标识</div>
<div class="summary-chip-value mono">{{.Task.ID}}</div>
</div>
<div>
<div class="muted small">操作</div>
<div>
<div class="summary-chip">
<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"}}回滚识别配置
@ -22,9 +24,13 @@
{{else}}<span class="mono">{{.Task.Type}}</span>{{end}}
</div>
</div>
<div>
<div class="muted small">状态</div>
<div>
<div class="summary-chip">
<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>
@ -35,24 +41,35 @@
</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 id="devices-body">
{{range $id, $st := .Task.Devices}}
<tr data-device-id="{{$id}}">
<td class="mono">{{$id}}</td>
{{range .TaskDeviceRows}}
<tr data-device-id="{{.Device.DeviceID}}" data-status="{{.Status}}">
<td>
<div class="device-cell">
<div class="device-avatar">{{icon "device"}}</div>
<div>
<div class="device-name">{{if .Device.DeviceName}}{{.Device.DeviceName}}{{else}}{{.Device.DeviceID}}{{end}}</div>
<div class="device-meta-line">
<span class="mono">{{.Device.DeviceID}}</span>
{{if .Device.IP}}<span class="mono">{{.Device.IP}}</span>{{end}}
</div>
</div>
</div>
</td>
<td class="st">
{{if eq $st.Status "success"}}<span class="pill ok">成功</span>
{{else if eq $st.Status "failed"}}<span class="pill bad">失败</span>
{{else if eq $st.Status "running"}}<span class="pill run">执行中</span>
{{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}}
</td>
<td class="pg mono">{{$st.Progress}}</td>
<td class="er">{{$st.Error}}</td>
<td class="pg mono">{{.Progress}}</td>
<td class="er">{{.Error}}</td>
</tr>
{{end}}
</tbody>
@ -62,8 +79,8 @@
<div class="card">
<h2>实时进度</h2>
<div class="muted small">任务执行过程中会持续推送每台节点的状态变化。</div>
<div class="actions" style="margin-top:8px">
<div class="muted small">任务执行过程中会持续推送每台设备的状态变化。</div>
<div class="actions compact" style="margin-top:8px">
<button type="button" onclick="startSSE()">连接</button>
<button type="button" onclick="stopSSE()">断开</button>
<span class="muted small" id="sse-status">未连接</span>
@ -73,12 +90,38 @@
<script>
let es;
function pill(status){
function statusSummary(status){
if(status === 'success') return '<span class="pill ok">成功</span>';
if(status === 'failed') return '<span class="pill bad">失败</span>';
if(status === 'running') return '<span class="pill run">执行中</span>';
return '<span class="pill">待执行</span>';
}
function syncTaskStatus(){
const rows = Array.from(document.querySelectorAll('#devices-body tr[data-device-id]'));
if(!rows.length) return;
const statuses = rows.map((row) => row.getAttribute('data-status') || 'pending');
const initial = document.getElementById('task-status-value')?.getAttribute('data-task-status') || 'pending';
let next = 'pending';
if (statuses.some((s) => s === 'failed')) {
next = 'failed';
} else if (statuses.every((s) => s === 'success')) {
next = 'success';
} else if (statuses.some((s) => s === 'running')) {
next = 'running';
} else if (statuses.some((s) => s === 'success')) {
next = initial === 'running' ? 'running' : 'pending';
} else {
next = initial;
}
const target = document.getElementById('task-status-value');
if (target) {
target.setAttribute('data-task-status', next);
target.innerHTML = statusSummary(next);
}
}
function pill(status){
return statusSummary(status);
}
function startSSE(){
stopSSE();
const url = `/api/tasks/{{.TaskID}}/events`;
@ -95,9 +138,11 @@ function startSSE(){
const u = JSON.parse(line);
const tr = document.querySelector(`tr[data-device-id="${cssEscape(u.device_id)}"]`);
if(tr){
tr.setAttribute('data-status', u.status || 'pending');
tr.querySelector('.st').innerHTML = pill(u.status);
tr.querySelector('.pg').textContent = u.progress;
tr.querySelector('.er').textContent = u.error || '';
syncTaskStatus();
}
} catch(e){}
});
@ -109,5 +154,6 @@ function stopSSE(){
function cssEscape(s){
return String(s).replace(/[^a-zA-Z0-9_-]/g, (c) => "\\"+c);
}
syncTaskStatus();
</script>
{{end}}

View File

@ -50,6 +50,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)
@ -172,13 +227,56 @@ func TestUI_ActionDevicesBatchActionKeepsDevicesOnError(t *testing.T) {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{"不支持的操作: nope", "入口识别节点", "辅助节点", "已选 2 台"} {
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 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})
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()))
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)
}
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{"任务详情", "返回操作审计", "设备结果表", "设备数量", "入口识别节点", "辅助节点", "edge-01", "edge-02", `id="task-status-value"`, "syncTaskStatus()"} {
if !strings.Contains(body, want) {
t.Fatalf("expected task page to contain %q, got:\n%s", want, 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)