diff --git a/AGENTS.md b/AGENTS.md index 913ffa43..c842bc70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun`:TypeScript 运行时固定使用 Bun,组件入口和 CLI 都直接运行 `.ts` 文件,约束见 `docs/reference/config.md`。 - `docker-compose.yml`:主 server 统一编排 core、frontend、database 和本机 provider gateway,且只公开 frontend/provider ingress,服务拓扑见 `docs/reference/deployment.md`。 - `src/components/frontend`:前端源码固定使用 TypeScript + React,采用高信息密度工业控制台设计,资源节点含任务管理器风格资源监控与 Docker Desktop 风格状态页,界面规则见 `docs/reference/frontend.md`。 -- `src/components/provider-gateway`:当前主 server 也作为 provider gateway 接入 UniDesk,并周期性上报系统资源指标和 Docker daemon 状态,支持 `provider.upgrade` 预检/调度,节点接入规则和公网 provider ingress 见 `docs/reference/provider-gateway.md`。 +- `src/components/provider-gateway`:当前主 server `74.48.78.17` 也作为 provider gateway 接入 UniDesk,外部节点通过 `ws://74.48.78.17:18082/ws/provider` 接入,部署与 Playwright 公网前端验证方法见 `docs/reference/provider-gateway.md`。 - `docs/reference/e2e.md`:交付前必须执行的自测门禁、Playwright 登录与 JSON 展示断言、数据库命名卷持久化要求。 ## Architecture Docs diff --git a/TEST.md b/TEST.md index c856832c..94e4571e 100644 --- a/TEST.md +++ b/TEST.md @@ -34,7 +34,7 @@ ## T8 Playwright 公网前端 E2E -阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认 `config.json` 的 `network.publicHost` 是主 server 公网地址,运行 `bun scripts/cli.ts e2e run`,要求 JSON 中 `network:only-frontend-provider-ports`、`network:core-public-blocked`、`network:database-public-blocked`、`core:internal-overview`、`provider:self-node-online`、`provider:system-status`、`provider:docker-status`、`provider:upgrade-plan`、`provider-ingress:public-health`、`database:named-volume-write`、`frontend:login-provider-visible`、`frontend:no-naked-json-before-click`、`frontend:system-monitor-visible`、`frontend:upgrade-plan-dispatch`、`frontend:docker-status-visible` 全部 passed;打开输出的 screenshotPath,确认页面上能看到 `main-server`、`Main Server Provider` 和结构化控件。 +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认 `config.json` 的 `network.publicHost` 是主 server 公网地址,运行 `bun scripts/cli.ts e2e run`,要求 JSON 中 `network:only-frontend-provider-ports`、`network:core-public-blocked`、`network:database-public-blocked`、`core:internal-overview`、`provider:self-node-online`、`provider:system-status`、`provider:docker-status`、`provider:upgrade-plan`、`provider-ingress:public-health`、`database:named-volume-write`、`frontend:login-provider-visible`、`frontend:public-provider-info-visible`、`frontend:no-naked-json-before-click`、`frontend:system-monitor-visible`、`frontend:upgrade-plan-dispatch`、`frontend:docker-status-visible` 全部 passed;打开输出的 screenshotPath,确认 Playwright 访问的是公网 frontend,页面上能看到 `main-server`、`Main Server Provider` 和结构化控件。 ## T9 Database 命名卷持久化 @@ -63,3 +63,7 @@ ## T15 待处理任务可追溯 阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts e2e run`,确认 `frontend:pending-task-drilldown`、`frontend:mobile-nav-fixed-height` 和 `frontend:mobile-content-top-aligned` passed;再登录 frontend,点击 `态势总览` 的 `待处理任务` 指标,确认会进入 `任务调度 / 待处理任务` 子标签,并能看到每个 queued、dispatched、running 任务的 Provider、已等待时间、payload 摘要和 `查看原始JSON` 按钮。backend-core 超过 `TASK_PENDING_TIMEOUT_MS` 的待处理任务必须自动转为 failed,避免总览数字长期卡住。 + +## T16 任务历史诊断信息 + +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts e2e run`,确认 `frontend:task-history-diagnostics` passed;再登录 frontend,进入 `任务调度 / 任务历史`,确认每个任务行都能看到状态、任务命令和 id、Provider、任务耗时、载荷摘要、诊断信息、更新时间和 `查看原始JSON` 按钮。失败任务必须在默认表格中显示失败原因以及 exit code、timeout、previous status 等关键字段,完整 result 只能点击 `查看原始JSON` 查看。 diff --git a/docs/reference/e2e.md b/docs/reference/e2e.md index 47306a76..5282cc0d 100644 --- a/docs/reference/e2e.md +++ b/docs/reference/e2e.md @@ -17,7 +17,7 @@ UniDesk delivery is not complete until the public frontend, public provider ingr - Provider self-connection: internal `GET /api/nodes` must contain `main-server` with `status: online`; internal `GET /api/nodes/system-status` must contain CPU/memory/disk samples; internal `GET /api/nodes/docker-status` must contain a Docker snapshot for `main-server`; public provider ingress `/health` must return ok. - Provider remote control: internal `/api/dispatch` must successfully complete a real `provider.upgrade` task in `mode: "plan"` so the upgrade path is validated without recreating the running gateway during E2E. - Database: the command writes an `unidesk_e2e_markers` row through `docker exec unidesk-database psql` and confirms provider state is stored in PostgreSQL. -- Frontend: Playwright opens the public frontend URL, logs in with the configured account, waits for `核心在线`, asserts that `main-server` and `Main Server Provider` are visible, confirms no raw JSON is visible before clicking `查看原始JSON`, opens resource nodes `资源监控` to verify CPU/Memory/Disk curves and provider upgrade precheck dispatch, then opens `Docker 状态` and verifies the Docker Desktop-style container view including the database named volume `unidesk_pgdata_10gb`. +- Frontend: Playwright must open the public frontend URL derived from `network.publicHost`, not localhost or a Docker-internal URL; it logs in with the configured account, waits for `核心在线`, asserts that `main-server` and `Main Server Provider` are visible, clicks `查看原始JSON` to verify Provider data from the frontend, confirms no raw JSON is visible before that click, opens task history to verify duration and failure diagnostics, opens resource nodes `资源监控` to verify CPU/Memory/Disk curves and provider upgrade precheck dispatch, then opens `Docker 状态` and verifies the Docker Desktop-style container view including the database named volume `unidesk_pgdata_10gb`. ## Frontend JSON Rule diff --git a/docs/reference/frontend.md b/docs/reference/frontend.md index 031c7bd8..a0e79779 100644 --- a/docs/reference/frontend.md +++ b/docs/reference/frontend.md @@ -14,6 +14,10 @@ frontend 应用源码必须使用 TypeScript + React,禁止在 `src/components `态势总览` 中的 `待处理任务` 指标必须可点击进入任务调度的 `待处理任务` 子标签,展示具体 queued、dispatched、running 任务的状态、Provider、已等待时间、payload 摘要和显式 `查看原始JSON` 操作。总览不得只给出无法追溯的数字;当后台把超时未终态任务转为 failed 后,待处理指标应回落,历史记录仍可在任务历史和执行结果中查看。 +## Task History Diagnostics + +`任务调度 / 任务历史` 必须把任务生命周期渲染为可诊断表格,不得只显示更新时间和原始 payload 摘要。每行至少展示状态、任务命令和 id、Provider、任务耗时、载荷摘要、诊断信息、更新时间和显式 `查看原始JSON` 操作;终态任务的耗时按 `updatedAt - createdAt` 计算,待处理任务按当前时间减 `createdAt` 计算。失败任务必须在默认视图中提取 `result.error`、`result.message`、`result.stderr`、`result.reason` 或等价字段作为失败原因,并将 exit code、timeout、previous status 等关键诊断字段渲染为控件;完整 result 只能通过 `查看原始JSON` 展开。 + ## Resource Node Monitor View 资源节点模块必须提供 `资源监控` 子标签,用类似 Windows 任务管理器的性能页展示每个 provider 节点的 CPU、内存和硬盘用量历史曲线。该页面应包含节点切换、当前用量摘要、CPU/Memory/Disk 三条曲线、采样说明和 `Provider Gateway 升级` 控制区;曲线数据来自 backend-core 的 `/api/nodes/system-status`,不得在页面默认展示原始 JSON。内存曲线必须使用实际内存口径,不把 Linux page cache / buffer 计入占用。 diff --git a/docs/reference/provider-gateway.md b/docs/reference/provider-gateway.md index 4ec71dae..29c00e2a 100644 --- a/docs/reference/provider-gateway.md +++ b/docs/reference/provider-gateway.md @@ -6,6 +6,18 @@ Provider Gateway 是计算节点侧容器。它只主动连出到主 server 暴 当前主 server 也运行一个 provider-gateway,`providerId` 固定来自 `config.json` 的 `providerGateway.id`。这让单机环境也能验证完整的分布式调度闭环:frontend 发起任务,core 写数据库并通过 provider ingress WebSocket 下发,provider gateway 执行后回传状态。 +## Deployment Method + +当前主 server 公网 IP 是 `74.48.78.17`,`config.json` 中的 `network.publicHost` 必须保持为该地址;公网 frontend 入口是 `http://74.48.78.17:18081/`,provider gateway 对外接入入口是 `ws://74.48.78.17:18082/ws/provider`,provider ingress 健康检查是 `http://74.48.78.17:18082/health`。主 server 本机 provider 由根目录 `docker-compose.yml` 的 `provider-gateway` 服务启动,容器内使用 Docker 内网地址 `ws://backend-core:8081/ws/provider` 自接入;外部计算节点部署 provider-gateway 时必须改用公网 provider ingress URL,并复用 `config.json` / `.state/docker-compose.env` 中的 provider token、心跳间隔和重连参数。 + +计算节点部署 provider-gateway 的最小方法是:准备可运行 `unidesk_provider-gateway` 镜像的 Docker 环境,为节点分配唯一 `PROVIDER_ID` 与可读 `PROVIDER_NAME`,设置 `PROVIDER_SERVER_URL=ws://74.48.78.17:18082/ws/provider`、`PROVIDER_TOKEN`、`PROVIDER_LABELS_JSON`、`HEARTBEAT_INTERVAL_MS`、`RECONNECT_BASE_MS` 和 `RECONNECT_MAX_MS`,并挂载 `/var/run/docker.sock:/var/run/docker.sock` 作为 Docker 状态采集、任务执行和远程升级的唯一自动化通道。需要支持 `provider.upgrade` 的节点还必须设置 `PROVIDER_UPGRADE_*` 环境变量,把节点上的 UniDesk 仓库只读挂载到 `PROVIDER_UPGRADE_WORKSPACE_PATH`,并确保升级命令只重建 `provider-gateway` service,不影响 database、backend-core、frontend。 + +## Deployment Verification + +provider-gateway 部署是否成功必须以 UniDesk frontend 中可见的 Provider 信息为准,不能只看节点容器 `running`。验证时访问 `http://74.48.78.17:18081/`,使用配置中的账号密码登录,进入 `资源节点 / 节点清单`,确认目标 `PROVIDER_ID`、`PROVIDER_NAME`、`online` 状态、`lastHeartbeat` 和 labels 可见;点击该节点的 `查看原始JSON`,确认 raw payload 中的 `providerId`、`name`、`status`、`labels` 与部署环境变量一致。随后进入 `资源节点 / 资源监控`,确认该 Provider 有 CPU、实际内存和硬盘采样曲线;进入 `资源节点 / Docker 状态`,确认 Docker daemon、containers、images、volumes、networks 已渲染出来,且 `dockerSocketPresent` 或 Docker ready 状态与预期一致。只有这些前端信息都能通过 UniDesk 正常读取,才说明 provider-gateway 已经真正挂载到主 server。 + +自动化验证必须使用 Playwright 访问公网 frontend,而不是在容器内直接调 core API 代替浏览器验收。标准命令是 `bun scripts/cli.ts e2e run`;该命令会让 Playwright 打开公网 `http://74.48.78.17:18081/`、登录、抓取页面中的 Provider 信息和 `查看原始JSON` 内容,并检查 Provider 自接入、资源指标、Docker 状态和 `provider.upgrade` 预检。外部新增节点的人工验收应复用同一套前端路径:先确认 Provider 信息出现在节点清单,再确认资源监控和 Docker 状态页面有该节点的数据,最后通过任务调度向该 Provider 下发 `echo` 或 `docker.ps` 并在任务历史中查看耗时、状态和失败原因。 + ## Provider Ingress provider ingress 是唯一允许公网暴露的 provider 连接接口,当前由 backend-core 容器的独立端口提供 `/ws/provider` 和 `/health`。backend-core REST API 仍只在 Docker 内网开放,外部计算节点只应连接 provider ingress。 diff --git a/scripts/src/e2e.ts b/scripts/src/e2e.ts index 8b2f1218..59c8a9eb 100644 --- a/scripts/src/e2e.ts +++ b/scripts/src/e2e.ts @@ -231,6 +231,7 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2 function databaseChecks(config: UniDeskConfig, checks: E2ECheck[]): string { const markerId = `e2e_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`; + const failedTaskId = `task_${markerId}_failed_diagnostic`; const markerSql = ` CREATE TABLE IF NOT EXISTS unidesk_e2e_markers ( id TEXT PRIMARY KEY, @@ -238,8 +239,20 @@ function databaseChecks(config: UniDeskConfig, checks: E2ECheck[]): string { created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); INSERT INTO unidesk_e2e_markers (id, source) VALUES ('${markerId}', 'cli-e2e'); + INSERT INTO unidesk_tasks (id, provider_id, command, status, payload, result, created_at, updated_at) + VALUES ( + '${failedTaskId}', + '${config.providerGateway.id}', + 'echo', + 'failed', + '{"source":"cli-e2e","case":"history-diagnostics"}'::jsonb, + '{"error":"e2e forced failure for diagnostics","exitCode":23,"stderr":"simulated provider failure"}'::jsonb, + now() - interval '83 seconds', + now() + ); SELECT 'marker=' || id FROM unidesk_e2e_markers WHERE id = '${markerId}'; SELECT 'marker_count=' || count(*) FROM unidesk_e2e_markers; + SELECT 'failed_task=' || id FROM unidesk_tasks WHERE id = '${failedTaskId}' AND status = 'failed'; SELECT 'online_main_server=' || count(*) FROM unidesk_nodes WHERE provider_id = '${config.providerGateway.id}' AND status = 'online'; `; const marker = runPsql(config, markerSql); @@ -267,6 +280,10 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 await page.click('button[type="submit"]'); await page.waitForSelector('[data-testid="app-shell"]', { timeout: 10000 }); await page.waitForFunction(() => document.querySelector('[data-testid="conn-text"]')?.textContent?.includes("核心在线"), undefined, { timeout: 15000 }); + const landedUrl = page.url(); + const publicOrigin = new URL(urls.frontendUrl).origin; + const landed = new URL(landedUrl); + const publicFrontendReached = landed.origin === publicOrigin && !["127.0.0.1", "localhost", "::1"].includes(landed.hostname); await page.waitForSelector(`text=${config.providerGateway.id}`, { timeout: 10000 }); await page.waitForSelector(`text=${config.providerGateway.name}`, { timeout: 10000 }); await page.setViewportSize({ width: 390, height: 860 }); @@ -301,6 +318,9 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 await page.getByTestId("pending-task-card").click(); await page.waitForSelector('[data-testid="pending-task-page"]', { timeout: 5000 }); const pendingTaskText = await page.locator('[data-testid="pending-task-page"]').innerText({ timeout: 5000 }); + await page.getByRole("button", { name: /任务历史/ }).click(); + await page.waitForSelector('[data-testid="task-history-page"]', { timeout: 5000 }); + const taskHistoryText = await page.locator('[data-testid="task-history-page"]').innerText({ timeout: 5000 }); await page.getByRole("button", { name: /运行总览/ }).click(); await page.getByRole("button", { name: /态势总览/ }).click(); await page.screenshot({ path: screenshotPath, fullPage: true }); @@ -332,9 +352,11 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 }, undefined, { timeout: 10000 }); const dockerText = await page.locator('[data-testid="docker-status-page"]').innerText({ timeout: 5000 }); addCheck(checks, "frontend:login-provider-visible", bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name) && bodyText.includes("核心在线"), { screenshotPath }); + addCheck(checks, "frontend:public-provider-info-visible", publicFrontendReached && bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name) && rawText.includes('"status": "online"') && rawText.includes(`"providerId": "${config.providerGateway.id}"`), { frontendUrl: urls.frontendUrl, landedUrl, providerId: config.providerGateway.id, rawTextPreview: rawText.slice(0, 400) }); addCheck(checks, "frontend:mobile-nav-fixed-height", mobileRailMax - mobileRailMin <= 1 && mobileRailMax <= 44, { mobileRailHeights }); addCheck(checks, "frontend:mobile-content-top-aligned", mobileContentMetrics.pageTop <= 190 && mobileContentMetrics.emptyTextOffset <= 14, { mobileContentMetrics }); addCheck(checks, "frontend:pending-task-drilldown", pendingTaskText.includes("待处理任务") && (pendingTaskText.includes("当前无待处理任务") || (pendingTaskText.includes("Provider") && pendingTaskText.includes("已等待"))), { pendingTaskPreview: pendingTaskText.slice(0, 600) }); + addCheck(checks, "frontend:task-history-diagnostics", taskHistoryText.includes("任务耗时") && taskHistoryText.includes("诊断信息") && taskHistoryText.includes("失败原因") && taskHistoryText.includes("e2e forced failure for diagnostics"), { taskHistoryPreview: taskHistoryText.slice(0, 900) }); addCheck(checks, "frontend:no-naked-json-before-click", rawBlocksBefore === 0 && !nakedJsonText, { rawBlocksBefore, nakedJsonText }); addCheck(checks, "frontend:raw-json-explicit-button", rawText.includes('"providerId"') && rawText.includes(config.providerGateway.id), { rawTextPreview: rawText.slice(0, 400) }); addCheck(checks, "frontend:system-monitor-visible", monitorText.includes("任务管理器视图") && monitorText.includes("CPU") && monitorText.includes("Memory") && monitorText.includes("Disk") && monitorText.includes("不含缓存"), { monitorTextPreview: monitorText.slice(0, 800) }); diff --git a/src/components/frontend/public/style.css b/src/components/frontend/public/style.css index b8bbff1d..0f17b629 100644 --- a/src/components/frontend/public/style.css +++ b/src/components/frontend/public/style.css @@ -671,6 +671,7 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; } .table-wrap { overflow: auto; max-height: calc(100vh - 174px); } table { width: 100%; border-collapse: collapse; min-width: 760px; } +.task-history-table { min-width: 1080px; } th, td { padding: 7px 9px; border-bottom: 1px solid var(--line-soft); @@ -689,6 +690,44 @@ th { } td { color: var(--text); } +.task-duration { + display: grid; + gap: 2px; + min-width: 118px; +} +.task-duration strong { + color: var(--accent-2); + font-family: "Cascadia Mono", "IBM Plex Mono", "Liberation Mono", monospace; + font-size: 13px; +} +.task-duration span { + color: var(--muted); + font-size: 11px; +} +.task-diagnostic { + display: grid; + gap: 4px; + min-width: 190px; + max-width: 360px; +} +.task-diagnostic b { + font-size: 11px; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.task-diagnostic.ok b { color: var(--ok); } +.task-diagnostic.warn b { color: var(--warn); } +.task-diagnostic.failed b { color: var(--danger); } +.diagnostic-reason { + color: #ffd7cf; + overflow-wrap: anywhere; +} +.diagnostic-meta { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + .compact-row, .heartbeat-row, .log-row, .endpoint-list article, .policy-grid article { display: grid; grid-template-columns: auto minmax(180px, 1fr) auto auto; diff --git a/src/components/frontend/src/app.tsx b/src/components/frontend/src/app.tsx index cb4545cf..65559cc6 100644 --- a/src/components/frontend/src/app.tsx +++ b/src/components/frontend/src/app.tsx @@ -108,6 +108,34 @@ function fmtRelativeAge(value: any): string { return fmtDuration(Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000))); } +function timeMs(value: any): number | null { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.getTime(); +} + +function taskElapsedSeconds(task: any): number | null { + const started = timeMs(task?.createdAt); + if (started === null) return null; + const terminal = ["succeeded", "failed"].includes(String(task?.status || "").toLowerCase()); + const ended = terminal ? timeMs(task?.updatedAt) : Date.now(); + if (ended === null) return null; + return Math.max(0, Math.floor((ended - started) / 1000)); +} + +function taskFailureReason(task: any): string { + if (String(task?.status || "").toLowerCase() !== "failed") return ""; + const result = task?.result; + if (typeof result === "string") return result; + if (result && typeof result === "object" && !Array.isArray(result)) { + const record = result as AnyRecord; + for (const key of ["error", "reason", "message", "stderr", "detail"]) { + if (typeof record[key] === "string" && record[key].length > 0) return record[key]; + } + } + return "任务失败但 provider 未返回明确原因"; +} + function summarizeValue(value: any): string { if (value === null || value === undefined) return "--"; if (typeof value === "boolean") return value ? "是" : "否"; @@ -841,11 +869,48 @@ function TaskCompactRow({ task, onRaw }: AnyRecord) { return h("article", { className: "compact-row" }, h(StatusBadge, { status: task.status }), h("div", null, h("strong", null, task.command), h("code", null, task.id)), - h("span", null, isPendingTask(task) ? `已等待 ${fmtRelativeAge(task.updatedAt)}` : fmtDate(task.updatedAt)), + h("span", null, isPendingTask(task) ? `已等待 ${fmtRelativeAge(task.updatedAt)}` : `耗时 ${fmtDuration(taskElapsedSeconds(task) ?? 0)}`), h(RawButton, { title: `Task ${task.id}`, data: task, onOpen: onRaw }), ); } +function TaskDurationCell({ task }: AnyRecord) { + const elapsed = taskElapsedSeconds(task); + const pending = isPendingTask(task); + return h("div", { className: "task-duration" }, + h("strong", null, elapsed === null ? "--" : fmtDuration(elapsed)), + h("span", null, pending ? `已运行 / 创建 ${fmtDate(task.createdAt)}` : `创建 ${fmtDate(task.createdAt)}`), + ); +} + +function TaskDiagnosticCell({ task }: AnyRecord) { + const status = String(task?.status || "").toLowerCase(); + const result = task?.result; + const resultRecord = result && typeof result === "object" && !Array.isArray(result) ? result as AnyRecord : {}; + const metaKeys = ["exitCode", "code", "signal", "timeoutMs", "previousStatus", "mode"]; + const metas = metaKeys.filter((key) => resultRecord[key] !== undefined && resultRecord[key] !== null); + if (status === "failed") { + const reason = taskFailureReason(task); + return h("div", { className: "task-diagnostic failed" }, + h("b", null, "失败原因"), + h("span", { className: "diagnostic-reason" }, summarizeValue(reason)), + metas.length > 0 ? h("div", { className: "diagnostic-meta" }, metas.map((key) => + h("span", { key, className: "data-chip" }, h("b", null, key), h("span", null, summarizeValue(resultRecord[key]))), + )) : null, + ); + } + if (isPendingTask(task)) { + return h("div", { className: "task-diagnostic warn" }, + h("b", null, "等待终态"), + h("span", null, `最后更新 ${fmtRelativeAge(task.updatedAt)} 前`), + ); + } + return h("div", { className: "task-diagnostic ok" }, + h("b", null, "完成摘要"), + h(DataSummary, { data: result, empty: "无执行输出" }), + ); +} + function TaskPendingPage({ tasks, onRaw }: AnyRecord) { const pending = tasks.filter(isPendingTask); return h("div", { "data-testid": "pending-task-page" }, @@ -867,19 +932,23 @@ function TaskPendingPage({ tasks, onRaw }: AnyRecord) { } function TaskHistoryPage({ tasks, onRaw }: AnyRecord) { - return h(Panel, { title: "任务历史", eyebrow: `${tasks.length} Tasks` }, + return h("div", { "data-testid": "task-history-page" }, + h(Panel, { title: "任务历史", eyebrow: `${tasks.length} Tasks` }, tasks.length === 0 ? h(EmptyState, { title: "暂无任务", text: "下发任务后会在这里看到生命周期" }) : - h("div", { className: "table-wrap" }, h("table", null, - h("thead", null, h("tr", null, h("th", null, "状态"), h("th", null, "任务"), h("th", null, "Provider"), h("th", null, "载荷摘要"), h("th", null, "更新时间"), h("th", null, "操作"))), - h("tbody", null, tasks.map((task: any) => h("tr", { key: task.id }, + h("div", { className: "table-wrap" }, h("table", { className: "task-history-table" }, + h("thead", null, h("tr", null, h("th", null, "状态"), h("th", null, "任务"), h("th", null, "Provider"), h("th", null, "任务耗时"), h("th", null, "载荷摘要"), h("th", null, "诊断信息"), h("th", null, "更新时间"), h("th", null, "操作"))), + h("tbody", null, tasks.map((task: any) => h("tr", { key: task.id, "data-testid": `task-row-${safeId(task.id)}` }, h("td", null, h(StatusBadge, { status: task.status })), h("td", null, h("strong", null, task.command), h("code", null, task.id)), h("td", null, h("code", null, task.providerId)), + h("td", null, h(TaskDurationCell, { task })), h("td", null, h(DataSummary, { data: task.payload })), + h("td", null, h(TaskDiagnosticCell, { task })), h("td", null, fmtDate(task.updatedAt)), h("td", null, h(RawButton, { title: `Task ${task.id}`, data: task, onOpen: onRaw })), ))), )), + ), ); }