feat: add pending task drilldown
This commit is contained in:
@@ -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,避免总览数字长期卡住。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,11 @@ frontend 应用源码必须使用 TypeScript + React,禁止在 `src/components
|
||||
|
||||
## Layout
|
||||
|
||||
左侧边栏只切换主模块:运行总览、资源节点、任务调度、系统配置。顶部标签只切换当前主模块内的子功能;例如资源节点下的节点清单、资源标签、心跳状态只属于资源节点,和运行总览、任务调度、系统配置没有重复或共享语义。
|
||||
左侧边栏只切换主模块:运行总览、资源节点、任务调度、系统配置。顶部标签只切换当前主模块内的子功能;例如资源节点下的节点清单、资源标签、心跳状态只属于资源节点,和运行总览、任务调度、系统配置没有重复或共享语义。移动端左侧边栏会转为顶部横向主模块条,但高度必须在不同主模块之间保持一致,并保持窄条、单行、不换行。
|
||||
|
||||
## Overview Task Drilldown
|
||||
|
||||
`态势总览` 中的 `待处理任务` 指标必须可点击进入任务调度的 `待处理任务` 子标签,展示具体 queued、dispatched、running 任务的状态、Provider、已等待时间、payload 摘要和显式 `查看原始JSON` 操作。总览不得只给出无法追溯的数字;当后台把超时未终态任务转为 failed 后,待处理指标应回落,历史记录仍可在任务历史和执行结果中查看。
|
||||
|
||||
## Resource Node Monitor View
|
||||
|
||||
|
||||
@@ -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 摘要,避免 `态势总览` 的待处理数量长期卡住且无法解释。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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) }),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user