diff --git a/TEST.md b/TEST.md index 9b8d3c6b..05127d94 100644 --- a/TEST.md +++ b/TEST.md @@ -14,7 +14,7 @@ ## T4 前端控制台连通 -阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:先用 `bun scripts/cli.ts server status` 获取 frontend URL,再用浏览器访问该 URL,使用默认账号 `admin` 和默认密码 `Liang6516.` 登录,确认左侧主模块、顶部当前模块子标签、核心指标、Provider 控件和事件流可见;页面布局应紧凑、信息密度高、字体不过大,且移动端宽度下左侧栏转为横向模块条。 +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:先用 `bun scripts/cli.ts server status` 获取 frontend URL,再用浏览器访问该 URL,使用默认账号 `admin` 和默认密码 `Liang6516.` 登录,确认左侧主模块、顶部当前模块子标签、核心指标、Provider 控件和事件流可见;页面布局应紧凑、信息密度高、字体不过大,且移动端宽度下左侧栏转为高度一致、较窄、单行不换行的横向模块条。 ## T5 真实任务下发链路 @@ -59,3 +59,7 @@ ## T14 Provider Gateway 远程升级预检 阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts debug dispatch main-server provider.upgrade`,随后查看任务历史或 `bun scripts/cli.ts debug health`,确认 `provider.upgrade` 通过真实 WebSocket 下发并以 `mode: plan` 成功返回升级计划;正式执行升级只能通过前端 `资源监控` 的 `执行升级` 或等价的显式调度完成,不能使用 Host SSH 维护桥作为自动升级通道。 + +## 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` passed;再登录 frontend,点击 `态势总览` 的 `待处理任务` 指标,确认会进入 `任务调度 / 待处理任务` 子标签,并能看到每个 queued、dispatched、running 任务的 Provider、已等待时间、payload 摘要和 `查看原始JSON` 按钮。backend-core 超过 `TASK_PENDING_TIMEOUT_MS` 的待处理任务必须自动转为 failed,避免总览数字长期卡住。 diff --git a/docker-compose.yml b/docker-compose.yml index 8f3b6cbc..7adb53ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,6 +54,7 @@ services: DATABASE_URL: "postgres://${UNIDESK_DATABASE_USER}:${UNIDESK_DATABASE_PASSWORD}@database:5432/${UNIDESK_DATABASE_NAME}" PROVIDER_TOKEN: "${UNIDESK_PROVIDER_TOKEN}" HEARTBEAT_TIMEOUT_MS: "${UNIDESK_HEARTBEAT_TIMEOUT_MS}" + TASK_PENDING_TIMEOUT_MS: "${UNIDESK_TASK_PENDING_TIMEOUT_MS:-600000}" LOG_FILE: "/var/log/unidesk/${UNIDESK_LOG_PREFIX}_backend-core.jsonl" volumes: - ${UNIDESK_LOG_DIR}:/var/log/unidesk diff --git a/docs/reference/frontend.md b/docs/reference/frontend.md index 4cdd5592..d8e86577 100644 --- a/docs/reference/frontend.md +++ b/docs/reference/frontend.md @@ -8,7 +8,11 @@ frontend 应用源码必须使用 TypeScript + React,禁止在 `src/components ## Layout -左侧边栏只切换主模块:运行总览、资源节点、任务调度、系统配置。顶部标签只切换当前主模块内的子功能;例如资源节点下的节点清单、资源标签、心跳状态只属于资源节点,和运行总览、任务调度、系统配置没有重复或共享语义。 +左侧边栏只切换主模块:运行总览、资源节点、任务调度、系统配置。顶部标签只切换当前主模块内的子功能;例如资源节点下的节点清单、资源标签、心跳状态只属于资源节点,和运行总览、任务调度、系统配置没有重复或共享语义。移动端左侧边栏会转为顶部横向主模块条,但高度必须在不同主模块之间保持一致,并保持窄条、单行、不换行。 + +## Overview Task Drilldown + +`态势总览` 中的 `待处理任务` 指标必须可点击进入任务调度的 `待处理任务` 子标签,展示具体 queued、dispatched、running 任务的状态、Provider、已等待时间、payload 摘要和显式 `查看原始JSON` 操作。总览不得只给出无法追溯的数字;当后台把超时未终态任务转为 failed 后,待处理指标应回落,历史记录仍可在任务历史和执行结果中查看。 ## Resource Node Monitor View diff --git a/docs/reference/observability.md b/docs/reference/observability.md index c397b3bd..41ab7853 100644 --- a/docs/reference/observability.md +++ b/docs/reference/observability.md @@ -13,3 +13,7 @@ UniDesk 的可观测性优先级高于静默成功。CLI、服务日志、Docker ## Log Access `bun scripts/cli.ts server logs` 同时读取文件日志和 Docker logs 尾部。文件日志是服务崩溃时的第一现场,Docker logs 是容器启动失败和 stdout/stderr 的辅助来源。 + +## Task Liveness + +backend-core 必须把 queued、dispatched、running 视为待处理任务,并通过 `TASK_PENDING_TIMEOUT_MS` 对长时间没有 provider 终态回报的任务做超时处理。超时任务转为 failed,result 中保留 timeout、previousStatus 和 previousResult 摘要,避免 `态势总览` 的待处理数量长期卡住且无法解释。 diff --git a/scripts/src/docker.ts b/scripts/src/docker.ts index 0a85b9dd..3402a5a4 100644 --- a/scripts/src/docker.ts +++ b/scripts/src/docker.ts @@ -81,6 +81,7 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean): UNIDESK_SESSION_TTL_SECONDS: String(config.auth.sessionTtlSeconds), UNIDESK_HEARTBEAT_INTERVAL_MS: String(config.providerGateway.heartbeatIntervalMs), UNIDESK_HEARTBEAT_TIMEOUT_MS: "90000", + UNIDESK_TASK_PENDING_TIMEOUT_MS: "600000", UNIDESK_RECONNECT_BASE_MS: String(config.providerGateway.reconnectBaseMs), UNIDESK_RECONNECT_MAX_MS: String(config.providerGateway.reconnectMaxMs), UNIDESK_MONITOR_DISK_PATH: config.providerGateway.metrics.diskPath, diff --git a/scripts/src/e2e.ts b/scripts/src/e2e.ts index 833c03ab..28e0af10 100644 --- a/scripts/src/e2e.ts +++ b/scripts/src/e2e.ts @@ -269,9 +269,27 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 await page.waitForFunction(() => document.querySelector('[data-testid="conn-text"]')?.textContent?.includes("核心在线"), undefined, { timeout: 15000 }); 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 }); + const mobileRailHeights: number[] = []; + for (const moduleLabel of ["运行总览", "资源节点", "任务调度", "系统配置"]) { + await page.getByRole("button", { name: new RegExp(moduleLabel) }).click(); + await page.waitForTimeout(80); + const height = await page.locator(".rail").evaluate((element) => Math.round(element.getBoundingClientRect().height)); + mobileRailHeights.push(height); + } + const mobileRailMax = Math.max(...mobileRailHeights); + const mobileRailMin = Math.min(...mobileRailHeights); + await page.setViewportSize({ width: 1440, height: 920 }); + await page.getByRole("button", { name: /运行总览/ }).click(); + await page.getByRole("button", { name: /态势总览/ }).click(); const bodyText = await page.locator("body").innerText({ timeout: 5000 }); const rawBlocksBefore = await page.locator("pre.raw-json").count(); const nakedJsonText = bodyText.includes('{"') || bodyText.includes('"providerId"') || bodyText.includes('"labels"'); + 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.getByRole("button", { name: /态势总览/ }).click(); await page.screenshot({ path: screenshotPath, fullPage: true }); await page.getByTestId(`raw-node-${config.providerGateway.id.replace(/[^a-zA-Z0-9_-]/g, "_")}`).click(); await page.waitForSelector('[data-testid="raw-json"]', { timeout: 5000 }); @@ -301,6 +319,8 @@ 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:mobile-nav-fixed-height", mobileRailMax - mobileRailMin <= 1 && mobileRailMax <= 44, { mobileRailHeights }); + addCheck(checks, "frontend:pending-task-drilldown", pendingTaskText.includes("待处理任务") && (pendingTaskText.includes("当前无待处理任务") || (pendingTaskText.includes("Provider") && pendingTaskText.includes("已等待"))), { pendingTaskPreview: pendingTaskText.slice(0, 600) }); 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/backend-core/src/index.ts b/src/components/backend-core/src/index.ts index 503b2927..6524255b 100644 --- a/src/components/backend-core/src/index.ts +++ b/src/components/backend-core/src/index.ts @@ -21,6 +21,7 @@ interface RuntimeConfig { databaseUrl: string; providerToken: string; heartbeatTimeoutMs: number; + taskPendingTimeoutMs: number; logFile: string; } @@ -62,6 +63,16 @@ function readNumberEnv(name: string): number { return parsed; } +function readOptionalNumberEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw.length === 0) return fallback; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`Environment variable ${name} must be a positive number, got ${raw}`); + } + return parsed; +} + function readConfig(): RuntimeConfig { return { port: readNumberEnv("PORT"), @@ -69,6 +80,7 @@ function readConfig(): RuntimeConfig { databaseUrl: requiredEnv("DATABASE_URL"), providerToken: requiredEnv("PROVIDER_TOKEN"), heartbeatTimeoutMs: readNumberEnv("HEARTBEAT_TIMEOUT_MS"), + taskPendingTimeoutMs: readOptionalNumberEnv("TASK_PENDING_TIMEOUT_MS", 10 * 60 * 1000), logFile: requiredEnv("LOG_FILE"), }; } @@ -353,6 +365,47 @@ async function markStaleProvidersOffline(): Promise { } } +async function markStaleTasksFailed(): Promise { + if (!dbReady) return; + const timeoutMs = config.taskPendingTimeoutMs; + const rows = await sql>` + WITH stale AS ( + SELECT id, provider_id, command, status AS previous_status, updated_at + FROM unidesk_tasks + WHERE status IN ('queued', 'dispatched', 'running') + AND updated_at < now() - (${timeoutMs}::bigint * interval '1 millisecond') + FOR UPDATE + ), + updated AS ( + UPDATE unidesk_tasks task + SET + status = 'failed', + result = jsonb_build_object( + 'error', 'task timed out without terminal provider status', + 'timeoutMs', ${timeoutMs}::bigint, + 'previousStatus', stale.previous_status, + 'previousResult', task.result, + 'timedOutAt', now() + ), + updated_at = now() + FROM stale + WHERE task.id = stale.id + RETURNING task.id, task.provider_id, task.command, stale.previous_status, stale.updated_at + ) + SELECT * FROM updated + `; + for (const row of rows) { + await recordEvent("task_timeout", row.provider_id, { + taskId: row.id, + providerId: row.provider_id, + command: row.command, + previousStatus: row.previous_status, + previousUpdatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at), + timeoutMs, + }); + } +} + function parseMessage(raw: string | Buffer): ProviderToCoreMessage { const text = typeof raw === "string" ? raw : raw.toString("utf8"); const parsed = JSON.parse(text) as unknown; @@ -532,13 +585,22 @@ async function getEvents(limit: number): Promise { })); } -async function getTasks(limit: number): Promise { - const rows = await sql>>` - SELECT id, provider_id, command, status, payload, result, created_at, updated_at - FROM unidesk_tasks - ORDER BY updated_at DESC - LIMIT ${limit} - `; +async function getTasks(limit: number, statusFilter = "all"): Promise { + await markStaleTasksFailed(); + const rows = statusFilter === "pending" + ? await sql>>` + SELECT id, provider_id, command, status, payload, result, created_at, updated_at + FROM unidesk_tasks + WHERE status IN ('queued', 'dispatched', 'running') + ORDER BY updated_at DESC + LIMIT ${limit} + ` + : await sql>>` + SELECT id, provider_id, command, status, payload, result, created_at, updated_at + FROM unidesk_tasks + ORDER BY updated_at DESC + LIMIT ${limit} + `; return rows.map((row) => ({ id: String(row.id), providerId: String(row.provider_id), @@ -551,13 +613,22 @@ async function getTasks(limit: number): Promise { })); } +async function countPendingTasks(): Promise { + await markStaleTasksFailed(); + const rows = await sql>` + SELECT count(*)::int AS count + FROM unidesk_tasks + WHERE status IN ('queued', 'dispatched', 'running') + `; + return Number(rows[0]?.count ?? 0); +} + async function getOverview(): Promise { const nodes = await getNodes(); - const tasks = await getTasks(50); + const pendingTasks = await countPendingTasks(); const dockerStatuses = await getNodeDockerStatuses(); const systemStatuses = await getNodeSystemStatuses(1); const online = nodes.filter((node) => node.status === "online").length; - const pendingTasks = tasks.filter((task) => task.status === "queued" || task.status === "dispatched" || task.status === "running").length; return { service: "unidesk-core", ok: true, @@ -568,6 +639,7 @@ async function getOverview(): Promise { dockerStatusNodeCount: dockerStatuses.filter((item) => item.dockerStatus !== null).length, systemStatusNodeCount: systemStatuses.filter((item) => item.current !== null).length, pendingTaskCount: pendingTasks, + taskPendingTimeoutMs: config.taskPendingTimeoutMs, activeSocketCount: activeProviders.size, heartbeatTimeoutMs: config.heartbeatTimeoutMs, }; @@ -618,7 +690,7 @@ async function route(req: Request): Promise { if (url.pathname === "/api/nodes/system-status") return jsonResponse({ ok: true, systemStatuses: await getNodeSystemStatuses(readLimit(url, 120)) }); if (url.pathname === "/api/nodes/docker-status") return jsonResponse({ ok: true, dockerStatuses: await getNodeDockerStatuses() }); if (url.pathname === "/api/events") return jsonResponse({ ok: true, events: await getEvents(readLimit(url, 100)) }); - if (url.pathname === "/api/tasks") return jsonResponse({ ok: true, tasks: await getTasks(readLimit(url, 100)) }); + if (url.pathname === "/api/tasks") return jsonResponse({ ok: true, tasks: await getTasks(readLimit(url, 100), url.searchParams.get("status") ?? "all") }); if (url.pathname === "/api/dispatch" && req.method === "POST") return dispatchTask(req); if (url.pathname === "/logs") return jsonResponse({ ok: true, logs: recentLogs.slice(-readLimit(url, 100)) }); if (url.pathname === "/favicon.ico") return textResponse("", 204); @@ -655,6 +727,7 @@ function readLimit(url: URL, defaultLimit: number): number { } await initDatabaseWithRetry(); +markStaleTasksFailed().catch((error) => logger("error", "task_timeout_sweep_failed", { error: errorToJson(error) })); const apiServer = Bun.serve({ port: config.port, @@ -690,6 +763,10 @@ setInterval(() => { markStaleProvidersOffline().catch((error) => logger("error", "heartbeat_sweep_failed", { error: errorToJson(error) })); }, 10_000); +setInterval(() => { + markStaleTasksFailed().catch((error) => logger("error", "task_timeout_sweep_failed", { error: errorToJson(error) })); +}, Math.min(config.taskPendingTimeoutMs, 60_000)); + logger("info", "server_listening", { apiUrl: `http://0.0.0.0:${apiServer.port}`, providerIngressUrl: `ws://0.0.0.0:${providerServer.port}/ws/provider`, diff --git a/src/components/frontend/public/style.css b/src/components/frontend/public/style.css index cbb32e47..839fd86b 100644 --- a/src/components/frontend/public/style.css +++ b/src/components/frontend/public/style.css @@ -203,7 +203,7 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; } align-items: start; } -.overview-grid .panel:nth-child(3), .dispatch-grid .panel:first-child, .topology-grid .panel:nth-child(3) { grid-column: 1 / -1; } +.overview-grid .panel:nth-child(n+3), .dispatch-grid .panel:first-child, .topology-grid .panel:nth-child(3) { grid-column: 1 / -1; } .panel { min-width: 0; @@ -238,6 +238,16 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; } border: 1px solid var(--line-soft); background: var(--panel-2); } +.metric-card.clickable { + cursor: pointer; + outline: none; +} +.metric-card.clickable:hover, .metric-card.clickable:focus-visible { + border-color: var(--accent); + background: + linear-gradient(90deg, rgba(215, 161, 58, 0.11), transparent 70%), + var(--panel-2); +} .metric-card.ok { border-color: rgba(113, 191, 120, 0.42); } .metric-card.warn { border-color: rgba(215, 161, 58, 0.45); } .metric-label { @@ -836,7 +846,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } .dispatch-form { grid-template-columns: 1fr 1fr; } .dispatch-actions { align-items: center; } .page-grid, .docker-layout, .monitor-layout { grid-template-columns: 1fr; } - .overview-grid .panel:nth-child(3), .dispatch-grid .panel:first-child, .topology-grid .panel:nth-child(3) { grid-column: 1; } + .overview-grid .panel:nth-child(n+3), .dispatch-grid .panel:first-child, .topology-grid .panel:nth-child(3) { grid-column: 1; } } @media (max-width: 760px) { @@ -844,21 +854,61 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } .shell { grid-template-columns: 1fr; } .rail { position: static; - height: auto; + height: 42px; display: flex; - gap: 6px; + align-items: center; + gap: 4px; + padding: 4px 6px; overflow-x: auto; + overflow-y: hidden; border-right: 0; border-bottom: 1px solid var(--line); } - .brand { border-bottom: 0; margin-bottom: 0; padding-bottom: 0; flex: 0 0 auto; } - .module { width: auto; min-width: 120px; grid-template-columns: 1fr; border-left: 0; border-bottom: 2px solid transparent; } + .brand { + flex: 0 0 auto; + height: 32px; + margin: 0 3px 0 0; + padding: 0 8px 0 0; + border-bottom: 0; + border-right: 1px solid var(--line-soft); + } + .brand-mark { + width: 30px; + height: 24px; + font-size: 11px; + } + .brand-text { display: none; } + .module { + width: auto; + min-width: 88px; + min-height: 30px; + grid-template-columns: auto 1fr; + gap: 5px; + padding: 4px 8px; + margin: 0; + border-left: 0; + border-bottom: 2px solid transparent; + white-space: nowrap; + } + .module-code { font-size: 9px; letter-spacing: 0.08em; } .module.active, .module:hover { border-bottom-color: var(--accent); } .workspace { padding: 10px; } .topbar { align-items: flex-start; flex-direction: column; } .status-strip { flex-wrap: wrap; white-space: normal; } + .tabs { + height: 38px; + align-items: center; + padding: 5px 0; + gap: 4px; + overflow-y: hidden; + } + .tab { + min-width: auto; + min-height: 28px; + padding: 4px 9px; + white-space: nowrap; + } .metric-grid, .policy-grid, .security-board, .dispatch-form, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid { grid-template-columns: 1fr; } .compact-row, .heartbeat-row, .log-row, .endpoint-list article, .volume-route { grid-template-columns: 1fr; align-items: start; } .docker-hero, .monitor-hero { flex-direction: column; } - .tab { min-width: 104px; } } diff --git a/src/components/frontend/src/app.tsx b/src/components/frontend/src/app.tsx index bc82a75c..00f7638b 100644 --- a/src/components/frontend/src/app.tsx +++ b/src/components/frontend/src/app.tsx @@ -45,6 +45,7 @@ const MODULES = [ ] }, { id: "tasks", label: "任务调度", code: "TASK", tabs: [ { id: "dispatch", label: "下发任务" }, + { id: "pending", label: "待处理任务" }, { id: "history", label: "任务历史" }, { id: "results", label: "执行结果" }, ] }, @@ -96,6 +97,17 @@ function asNumber(value: any, fallback = 0): number { return Number.isFinite(number) ? number : fallback; } +function isPendingTask(task: any): boolean { + return ["queued", "dispatched", "running"].includes(String(task?.status || "").toLowerCase()); +} + +function fmtRelativeAge(value: any): string { + if (!value) return "--"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "--"; + return fmtDuration(Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000))); +} + function summarizeValue(value: any): string { if (value === null || value === undefined) return "--"; if (typeof value === "boolean") return value ? "是" : "否"; @@ -140,8 +152,21 @@ function StatusBadge({ status, children }: AnyRecord) { return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown"); } -function MetricCard({ label, value, hint, tone }: AnyRecord) { - return h("article", { className: `metric-card ${tone || ""}` }, +function MetricCard({ label, value, hint, tone, onClick, testId }: AnyRecord) { + const interactive = typeof onClick === "function"; + return h("article", { + className: `metric-card ${tone || ""} ${interactive ? "clickable" : ""}`, + role: interactive ? "button" : undefined, + tabIndex: interactive ? 0 : undefined, + "data-testid": testId, + onClick, + onKeyDown: interactive ? (event: any) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onClick(); + } + } : undefined, + }, h("div", { className: "metric-label" }, label), h("div", { className: "metric-value" }, value), h("div", { className: "metric-hint" }, hint), @@ -281,9 +306,11 @@ function TabBar({ module, activeTab, onChange }: AnyRecord) { ); } -function OverviewPage({ data, onRaw }: AnyRecord) { +function OverviewPage({ data, onRaw, onNavigate }: AnyRecord) { const overview = data.overview || {}; const onlineNodes = data.nodes.filter((node: any) => node.status === "online"); + const pendingTasks = data.pendingTasks || data.tasks.filter(isPendingTask); + const pendingCount = overview.pendingTaskCount ?? pendingTasks.length; const recentTasks = data.tasks.slice(0, 5); return h("div", { className: "page-grid overview-grid" }, h(Panel, { title: "核心指标", eyebrow: "Control" }, @@ -291,13 +318,21 @@ function OverviewPage({ data, onRaw }: AnyRecord) { h(MetricCard, { label: "数据库", value: overview.dbReady ? "READY" : "WAIT", hint: "PostgreSQL internal network", tone: overview.dbReady ? "ok" : "warn" }), h(MetricCard, { label: "在线节点", value: overview.onlineNodeCount ?? 0, hint: `${overview.nodeCount ?? 0} registered`, tone: "ok" }), h(MetricCard, { label: "WebSocket", value: overview.activeSocketCount ?? 0, hint: "Provider ingress sockets" }), - h(MetricCard, { label: "待处理任务", value: overview.pendingTaskCount ?? 0, hint: `uptime ${fmtDuration(overview.uptimeSeconds ?? 0)}` }), + h(MetricCard, { label: "待处理任务", value: pendingCount, hint: pendingCount > 0 ? "点击查看具体任务" : `timeout ${fmtDuration(Math.floor((overview.taskPendingTimeoutMs ?? 0) / 1000))}`, tone: pendingCount > 0 ? "warn" : "ok", onClick: () => onNavigate("tasks", "pending"), testId: "pending-task-card" }), ), ), h(Panel, { title: "本机 Provider", eyebrow: "Self Connected" }, onlineNodes.length === 0 ? h(EmptyState, { title: "暂无在线节点", text: "provider-gateway 未完成自接入" }) : h("div", { className: "node-card-list" }, onlineNodes.slice(0, 4).map((node: any) => h(NodeCard, { key: node.providerId, node, onRaw }))), ), + h(Panel, { + title: "待处理任务明细", + eyebrow: `${pendingCount} Pending`, + actions: h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("tasks", "pending"), "data-testid": "pending-task-detail-link" }, "进入任务调度"), + }, + pendingTasks.length === 0 ? h(EmptyState, { title: "当前无待处理", text: "queued / dispatched / running 超时后会自动转为 failed,避免总览长期卡住" }) : + h("div", { className: "compact-list" }, pendingTasks.slice(0, 5).map((task: any) => h(TaskCompactRow, { key: task.id, task, onRaw }))), + ), h(Panel, { title: "最近任务", eyebrow: "Dispatch" }, recentTasks.length === 0 ? h(EmptyState, { title: "暂无任务", text: "可以在任务调度模块发起 docker.ps 或 echo" }) : h("div", { className: "compact-list" }, recentTasks.map((task: any) => h(TaskCompactRow, { key: task.id, task, onRaw }))), @@ -806,11 +841,31 @@ 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, fmtDate(task.updatedAt)), + h("span", null, isPendingTask(task) ? `已等待 ${fmtRelativeAge(task.updatedAt)}` : fmtDate(task.updatedAt)), h(RawButton, { title: `Task ${task.id}`, data: task, onOpen: onRaw }), ); } +function TaskPendingPage({ tasks, onRaw }: AnyRecord) { + const pending = tasks.filter(isPendingTask); + return h("div", { "data-testid": "pending-task-page" }, + h(Panel, { title: "待处理任务", eyebrow: `${pending.length} Pending` }, + pending.length === 0 ? h(EmptyState, { title: "当前无待处理任务", text: "queued / dispatched / running 会在超时后自动转为 failed;历史记录仍可在任务历史中查看" }) : + h("div", { className: "table-wrap", "data-testid": "pending-task-table" }, 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, pending.map((task: any) => h("tr", { key: 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, fmtRelativeAge(task.updatedAt)), + h("td", null, h(DataSummary, { data: task.payload })), + h("td", null, h(RawButton, { title: `Pending Task ${task.id}`, data: task, onOpen: onRaw })), + ))), + )), + ), + ); +} + function TaskHistoryPage({ tasks, onRaw }: AnyRecord) { return h(Panel, { title: "任务历史", eyebrow: `${tasks.length} Tasks` }, tasks.length === 0 ? h(EmptyState, { title: "暂无任务", text: "下发任务后会在这里看到生命周期" }) : @@ -887,8 +942,8 @@ function SecurityPage() { ); } -function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw }: AnyRecord) { - if (activeModule === "ops" && activeTab === "status") return h(OverviewPage, { data, onRaw }); +function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNavigate }: AnyRecord) { + if (activeModule === "ops" && activeTab === "status") return h(OverviewPage, { data, onRaw, onNavigate }); if (activeModule === "ops" && activeTab === "events") return h(EventsPage, { events: data.events, onRaw }); if (activeModule === "ops" && activeTab === "logs") return h(LogsPage, { logs: data.logs, onRaw }); if (activeModule === "nodes" && activeTab === "list") return h(NodeListPage, { nodes: data.nodes, onRaw }); @@ -897,6 +952,7 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw }: An if (activeModule === "nodes" && activeTab === "labels") return h(LabelsPage, { nodes: data.nodes }); if (activeModule === "nodes" && activeTab === "heartbeats") return h(HeartbeatPage, { nodes: data.nodes }); if (activeModule === "tasks" && activeTab === "dispatch") return h(DispatchPage, { nodes: data.nodes, onDispatched: refresh, onRaw }); + if (activeModule === "tasks" && activeTab === "pending") return h(TaskPendingPage, { tasks: data.pendingTasks, onRaw }); if (activeModule === "tasks" && activeTab === "history") return h(TaskHistoryPage, { tasks: data.tasks, onRaw }); if (activeModule === "tasks" && activeTab === "results") return h(TaskResultsPage, { tasks: data.tasks, onRaw }); if (activeModule === "config" && activeTab === "topology") return h(TopologyPage, { data }); @@ -908,7 +964,7 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw }: An function Shell({ session, onLogout }: AnyRecord) { const [activeModule, setActiveModule] = useState("ops"); const [activeTabs, setActiveTabs] = useState({ ops: "status", nodes: "list", tasks: "dispatch", config: "topology" }); - const [data, setData] = useState({ overview: null, nodes: [], systemStatuses: [], dockerStatuses: [], events: [], tasks: [], logs: [] }); + const [data, setData] = useState({ overview: null, nodes: [], systemStatuses: [], dockerStatuses: [], events: [], tasks: [], pendingTasks: [], logs: [] }); const [connection, setConnection] = useState({ ok: false, text: "连接中" }); const [lastRefresh, setLastRefresh] = useState(null); const [clock, setClock] = useState(new Date()); @@ -919,13 +975,14 @@ function Shell({ session, onLogout }: AnyRecord) { async function refresh(): Promise { try { - const [overview, nodes, systemStatuses, dockerStatuses, events, tasks, logs] = await Promise.all([ + const [overview, nodes, systemStatuses, dockerStatuses, events, tasks, pendingTasks, logs] = await Promise.all([ requestJson(`${cfg.apiBaseUrl}/overview`), requestJson(`${cfg.apiBaseUrl}/nodes`), requestJson(`${cfg.apiBaseUrl}/nodes/system-status?limit=120`), requestJson(`${cfg.apiBaseUrl}/nodes/docker-status`), requestJson(`${cfg.apiBaseUrl}/events?limit=100`), requestJson(`${cfg.apiBaseUrl}/tasks?limit=100`), + requestJson(`${cfg.apiBaseUrl}/tasks?status=pending&limit=100`), requestJson("/logs?limit=100"), ]); setData({ @@ -935,6 +992,7 @@ function Shell({ session, onLogout }: AnyRecord) { dockerStatuses: dockerStatuses.dockerStatuses || [], events: events.events || [], tasks: tasks.tasks || [], + pendingTasks: pendingTasks.tasks || [], logs: logs.logs || [], }); setConnection({ ok: true, text: "核心在线" }); @@ -960,6 +1018,11 @@ function Shell({ session, onLogout }: AnyRecord) { setActiveTabs((prev: any) => ({ ...prev, [activeModule]: tab })); } + function navigate(moduleId: string, tabId: string): void { + setActiveModule(moduleId); + setActiveTabs((prev: any) => ({ ...prev, [moduleId]: tabId })); + } + function openRaw(title: string, rawData: any): void { setRaw({ title, data: rawData }); } @@ -969,7 +1032,7 @@ function Shell({ session, onLogout }: AnyRecord) { h("main", { className: "workspace" }, h(TopBar, { connection, lastRefresh, onRefresh: refresh, onLogout: () => onLogout(true), session, clock }), h(TabBar, { module, activeTab, onChange: setTab }), - h(WorkArea, { activeModule, activeTab, data, session, refresh, onRaw: openRaw }), + h(WorkArea, { activeModule, activeTab, data, session, refresh, onRaw: openRaw, onNavigate: navigate }), ), h(RawDialog, { raw, onClose: () => setRaw(null) }), );