feat: add task history diagnostics

This commit is contained in:
Codex
2026-05-04 17:41:10 +00:00
parent 3adce947cf
commit 308e7c858e
8 changed files with 158 additions and 8 deletions
+1 -1
View File
@@ -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
+5 -1
View File
@@ -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` 查看。
+1 -1
View File
@@ -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
+4
View File
@@ -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 计入占用。
+12
View File
@@ -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。
+22
View File
@@ -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) });
+39
View File
@@ -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;
+74 -5
View File
@@ -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 })),
))),
)),
),
);
}