feat: add pending task drilldown

This commit is contained in:
Codex
2026-05-04 16:22:01 +00:00
parent 3207b9ecb1
commit 7e4dce47cd
9 changed files with 253 additions and 29 deletions
+5 -1
View File
@@ -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,避免总览数字长期卡住。
+1
View File
@@ -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
+5 -1
View File
@@ -8,7 +8,11 @@ frontend 应用源码必须使用 TypeScript + React,禁止在 `src/components
## Layout
左侧边栏只切换主模块:运行总览、资源节点、任务调度、系统配置。顶部标签只切换当前主模块内的子功能;例如资源节点下的节点清单、资源标签、心跳状态只属于资源节点,和运行总览、任务调度、系统配置没有重复或共享语义。
左侧边栏只切换主模块:运行总览、资源节点、任务调度、系统配置。顶部标签只切换当前主模块内的子功能;例如资源节点下的节点清单、资源标签、心跳状态只属于资源节点,和运行总览、任务调度、系统配置没有重复或共享语义。移动端左侧边栏会转为顶部横向主模块条,但高度必须在不同主模块之间保持一致,并保持窄条、单行、不换行。
## Overview Task Drilldown
`态势总览` 中的 `待处理任务` 指标必须可点击进入任务调度的 `待处理任务` 子标签,展示具体 queued、dispatched、running 任务的状态、Provider、已等待时间、payload 摘要和显式 `查看原始JSON` 操作。总览不得只给出无法追溯的数字;当后台把超时未终态任务转为 failed 后,待处理指标应回落,历史记录仍可在任务历史和执行结果中查看。
## Resource Node Monitor View
+4
View File
@@ -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 终态回报的任务做超时处理。超时任务转为 failedresult 中保留 timeout、previousStatus 和 previousResult 摘要,避免 `态势总览` 的待处理数量长期卡住且无法解释。
+1
View File
@@ -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,
+20
View File
@@ -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) });
+87 -10
View File
@@ -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<void> {
}
}
async function markStaleTasksFailed(): Promise<void> {
if (!dbReady) return;
const timeoutMs = config.taskPendingTimeoutMs;
const rows = await sql<Array<{ id: string; provider_id: string; command: string; previous_status: string; updated_at: Date | string }>>`
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<ApiEvent[]> {
}));
}
async function getTasks(limit: number): Promise<ApiTask[]> {
const rows = await sql<Array<Record<string, unknown>>>`
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<ApiTask[]> {
await markStaleTasksFailed();
const rows = statusFilter === "pending"
? await sql<Array<Record<string, unknown>>>`
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<Array<Record<string, unknown>>>`
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<ApiTask[]> {
}));
}
async function countPendingTasks(): Promise<number> {
await markStaleTasksFailed();
const rows = await sql<Array<{ count: string | number }>>`
SELECT count(*)::int AS count
FROM unidesk_tasks
WHERE status IN ('queued', 'dispatched', 'running')
`;
return Number(rows[0]?.count ?? 0);
}
async function getOverview(): Promise<JsonValue> {
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<JsonValue> {
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<Response> {
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<WsData>({
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`,
+57 -7
View File
@@ -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; }
}
+73 -10
View File
@@ -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<void> {
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) }),
);