Promote device batch service actions in overview
This commit is contained in:
parent
513062f08e
commit
17240ac7bd
@ -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) {
|
||||
|
||||
@ -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}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user