feat: add pending task drilldown
This commit is contained in:
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
## T4 前端控制台连通
|
## 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 真实任务下发链路
|
## T5 真实任务下发链路
|
||||||
|
|
||||||
@@ -59,3 +59,7 @@
|
|||||||
## T14 Provider Gateway 远程升级预检
|
## 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 维护桥作为自动升级通道。
|
阅读 `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}"
|
DATABASE_URL: "postgres://${UNIDESK_DATABASE_USER}:${UNIDESK_DATABASE_PASSWORD}@database:5432/${UNIDESK_DATABASE_NAME}"
|
||||||
PROVIDER_TOKEN: "${UNIDESK_PROVIDER_TOKEN}"
|
PROVIDER_TOKEN: "${UNIDESK_PROVIDER_TOKEN}"
|
||||||
HEARTBEAT_TIMEOUT_MS: "${UNIDESK_HEARTBEAT_TIMEOUT_MS}"
|
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"
|
LOG_FILE: "/var/log/unidesk/${UNIDESK_LOG_PREFIX}_backend-core.jsonl"
|
||||||
volumes:
|
volumes:
|
||||||
- ${UNIDESK_LOG_DIR}:/var/log/unidesk
|
- ${UNIDESK_LOG_DIR}:/var/log/unidesk
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ frontend 应用源码必须使用 TypeScript + React,禁止在 `src/components
|
|||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
左侧边栏只切换主模块:运行总览、资源节点、任务调度、系统配置。顶部标签只切换当前主模块内的子功能;例如资源节点下的节点清单、资源标签、心跳状态只属于资源节点,和运行总览、任务调度、系统配置没有重复或共享语义。
|
左侧边栏只切换主模块:运行总览、资源节点、任务调度、系统配置。顶部标签只切换当前主模块内的子功能;例如资源节点下的节点清单、资源标签、心跳状态只属于资源节点,和运行总览、任务调度、系统配置没有重复或共享语义。移动端左侧边栏会转为顶部横向主模块条,但高度必须在不同主模块之间保持一致,并保持窄条、单行、不换行。
|
||||||
|
|
||||||
|
## Overview Task Drilldown
|
||||||
|
|
||||||
|
`态势总览` 中的 `待处理任务` 指标必须可点击进入任务调度的 `待处理任务` 子标签,展示具体 queued、dispatched、running 任务的状态、Provider、已等待时间、payload 摘要和显式 `查看原始JSON` 操作。总览不得只给出无法追溯的数字;当后台把超时未终态任务转为 failed 后,待处理指标应回落,历史记录仍可在任务历史和执行结果中查看。
|
||||||
|
|
||||||
## Resource Node Monitor View
|
## Resource Node Monitor View
|
||||||
|
|
||||||
|
|||||||
@@ -13,3 +13,7 @@ UniDesk 的可观测性优先级高于静默成功。CLI、服务日志、Docker
|
|||||||
## Log Access
|
## Log Access
|
||||||
|
|
||||||
`bun scripts/cli.ts server logs` 同时读取文件日志和 Docker logs 尾部。文件日志是服务崩溃时的第一现场,Docker logs 是容器启动失败和 stdout/stderr 的辅助来源。
|
`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_SESSION_TTL_SECONDS: String(config.auth.sessionTtlSeconds),
|
||||||
UNIDESK_HEARTBEAT_INTERVAL_MS: String(config.providerGateway.heartbeatIntervalMs),
|
UNIDESK_HEARTBEAT_INTERVAL_MS: String(config.providerGateway.heartbeatIntervalMs),
|
||||||
UNIDESK_HEARTBEAT_TIMEOUT_MS: "90000",
|
UNIDESK_HEARTBEAT_TIMEOUT_MS: "90000",
|
||||||
|
UNIDESK_TASK_PENDING_TIMEOUT_MS: "600000",
|
||||||
UNIDESK_RECONNECT_BASE_MS: String(config.providerGateway.reconnectBaseMs),
|
UNIDESK_RECONNECT_BASE_MS: String(config.providerGateway.reconnectBaseMs),
|
||||||
UNIDESK_RECONNECT_MAX_MS: String(config.providerGateway.reconnectMaxMs),
|
UNIDESK_RECONNECT_MAX_MS: String(config.providerGateway.reconnectMaxMs),
|
||||||
UNIDESK_MONITOR_DISK_PATH: config.providerGateway.metrics.diskPath,
|
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.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.id}`, { timeout: 10000 });
|
||||||
await page.waitForSelector(`text=${config.providerGateway.name}`, { 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 bodyText = await page.locator("body").innerText({ timeout: 5000 });
|
||||||
const rawBlocksBefore = await page.locator("pre.raw-json").count();
|
const rawBlocksBefore = await page.locator("pre.raw-json").count();
|
||||||
const nakedJsonText = bodyText.includes('{"') || bodyText.includes('"providerId"') || bodyText.includes('"labels"');
|
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.screenshot({ path: screenshotPath, fullPage: true });
|
||||||
await page.getByTestId(`raw-node-${config.providerGateway.id.replace(/[^a-zA-Z0-9_-]/g, "_")}`).click();
|
await page.getByTestId(`raw-node-${config.providerGateway.id.replace(/[^a-zA-Z0-9_-]/g, "_")}`).click();
|
||||||
await page.waitForSelector('[data-testid="raw-json"]', { timeout: 5000 });
|
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 });
|
}, undefined, { timeout: 10000 });
|
||||||
const dockerText = await page.locator('[data-testid="docker-status-page"]').innerText({ timeout: 5000 });
|
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: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: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: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) });
|
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;
|
databaseUrl: string;
|
||||||
providerToken: string;
|
providerToken: string;
|
||||||
heartbeatTimeoutMs: number;
|
heartbeatTimeoutMs: number;
|
||||||
|
taskPendingTimeoutMs: number;
|
||||||
logFile: string;
|
logFile: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +63,16 @@ function readNumberEnv(name: string): number {
|
|||||||
return parsed;
|
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 {
|
function readConfig(): RuntimeConfig {
|
||||||
return {
|
return {
|
||||||
port: readNumberEnv("PORT"),
|
port: readNumberEnv("PORT"),
|
||||||
@@ -69,6 +80,7 @@ function readConfig(): RuntimeConfig {
|
|||||||
databaseUrl: requiredEnv("DATABASE_URL"),
|
databaseUrl: requiredEnv("DATABASE_URL"),
|
||||||
providerToken: requiredEnv("PROVIDER_TOKEN"),
|
providerToken: requiredEnv("PROVIDER_TOKEN"),
|
||||||
heartbeatTimeoutMs: readNumberEnv("HEARTBEAT_TIMEOUT_MS"),
|
heartbeatTimeoutMs: readNumberEnv("HEARTBEAT_TIMEOUT_MS"),
|
||||||
|
taskPendingTimeoutMs: readOptionalNumberEnv("TASK_PENDING_TIMEOUT_MS", 10 * 60 * 1000),
|
||||||
logFile: requiredEnv("LOG_FILE"),
|
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 {
|
function parseMessage(raw: string | Buffer): ProviderToCoreMessage {
|
||||||
const text = typeof raw === "string" ? raw : raw.toString("utf8");
|
const text = typeof raw === "string" ? raw : raw.toString("utf8");
|
||||||
const parsed = JSON.parse(text) as unknown;
|
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[]> {
|
async function getTasks(limit: number, statusFilter = "all"): Promise<ApiTask[]> {
|
||||||
const rows = await sql<Array<Record<string, unknown>>>`
|
await markStaleTasksFailed();
|
||||||
SELECT id, provider_id, command, status, payload, result, created_at, updated_at
|
const rows = statusFilter === "pending"
|
||||||
FROM unidesk_tasks
|
? await sql<Array<Record<string, unknown>>>`
|
||||||
ORDER BY updated_at DESC
|
SELECT id, provider_id, command, status, payload, result, created_at, updated_at
|
||||||
LIMIT ${limit}
|
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) => ({
|
return rows.map((row) => ({
|
||||||
id: String(row.id),
|
id: String(row.id),
|
||||||
providerId: String(row.provider_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> {
|
async function getOverview(): Promise<JsonValue> {
|
||||||
const nodes = await getNodes();
|
const nodes = await getNodes();
|
||||||
const tasks = await getTasks(50);
|
const pendingTasks = await countPendingTasks();
|
||||||
const dockerStatuses = await getNodeDockerStatuses();
|
const dockerStatuses = await getNodeDockerStatuses();
|
||||||
const systemStatuses = await getNodeSystemStatuses(1);
|
const systemStatuses = await getNodeSystemStatuses(1);
|
||||||
const online = nodes.filter((node) => node.status === "online").length;
|
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 {
|
return {
|
||||||
service: "unidesk-core",
|
service: "unidesk-core",
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -568,6 +639,7 @@ async function getOverview(): Promise<JsonValue> {
|
|||||||
dockerStatusNodeCount: dockerStatuses.filter((item) => item.dockerStatus !== null).length,
|
dockerStatusNodeCount: dockerStatuses.filter((item) => item.dockerStatus !== null).length,
|
||||||
systemStatusNodeCount: systemStatuses.filter((item) => item.current !== null).length,
|
systemStatusNodeCount: systemStatuses.filter((item) => item.current !== null).length,
|
||||||
pendingTaskCount: pendingTasks,
|
pendingTaskCount: pendingTasks,
|
||||||
|
taskPendingTimeoutMs: config.taskPendingTimeoutMs,
|
||||||
activeSocketCount: activeProviders.size,
|
activeSocketCount: activeProviders.size,
|
||||||
heartbeatTimeoutMs: config.heartbeatTimeoutMs,
|
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/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/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/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 === "/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 === "/logs") return jsonResponse({ ok: true, logs: recentLogs.slice(-readLimit(url, 100)) });
|
||||||
if (url.pathname === "/favicon.ico") return textResponse("", 204);
|
if (url.pathname === "/favicon.ico") return textResponse("", 204);
|
||||||
@@ -655,6 +727,7 @@ function readLimit(url: URL, defaultLimit: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await initDatabaseWithRetry();
|
await initDatabaseWithRetry();
|
||||||
|
markStaleTasksFailed().catch((error) => logger("error", "task_timeout_sweep_failed", { error: errorToJson(error) }));
|
||||||
|
|
||||||
const apiServer = Bun.serve<WsData>({
|
const apiServer = Bun.serve<WsData>({
|
||||||
port: config.port,
|
port: config.port,
|
||||||
@@ -690,6 +763,10 @@ setInterval(() => {
|
|||||||
markStaleProvidersOffline().catch((error) => logger("error", "heartbeat_sweep_failed", { error: errorToJson(error) }));
|
markStaleProvidersOffline().catch((error) => logger("error", "heartbeat_sweep_failed", { error: errorToJson(error) }));
|
||||||
}, 10_000);
|
}, 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", {
|
logger("info", "server_listening", {
|
||||||
apiUrl: `http://0.0.0.0:${apiServer.port}`,
|
apiUrl: `http://0.0.0.0:${apiServer.port}`,
|
||||||
providerIngressUrl: `ws://0.0.0.0:${providerServer.port}/ws/provider`,
|
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;
|
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 {
|
.panel {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -238,6 +238,16 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; }
|
|||||||
border: 1px solid var(--line-soft);
|
border: 1px solid var(--line-soft);
|
||||||
background: var(--panel-2);
|
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.ok { border-color: rgba(113, 191, 120, 0.42); }
|
||||||
.metric-card.warn { border-color: rgba(215, 161, 58, 0.45); }
|
.metric-card.warn { border-color: rgba(215, 161, 58, 0.45); }
|
||||||
.metric-label {
|
.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-form { grid-template-columns: 1fr 1fr; }
|
||||||
.dispatch-actions { align-items: center; }
|
.dispatch-actions { align-items: center; }
|
||||||
.page-grid, .docker-layout, .monitor-layout { grid-template-columns: 1fr; }
|
.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) {
|
@media (max-width: 760px) {
|
||||||
@@ -844,21 +854,61 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
|||||||
.shell { grid-template-columns: 1fr; }
|
.shell { grid-template-columns: 1fr; }
|
||||||
.rail {
|
.rail {
|
||||||
position: static;
|
position: static;
|
||||||
height: auto;
|
height: 42px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 6px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
.brand { border-bottom: 0; margin-bottom: 0; padding-bottom: 0; flex: 0 0 auto; }
|
.brand {
|
||||||
.module { width: auto; min-width: 120px; grid-template-columns: 1fr; border-left: 0; border-bottom: 2px solid transparent; }
|
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); }
|
.module.active, .module:hover { border-bottom-color: var(--accent); }
|
||||||
.workspace { padding: 10px; }
|
.workspace { padding: 10px; }
|
||||||
.topbar { align-items: flex-start; flex-direction: column; }
|
.topbar { align-items: flex-start; flex-direction: column; }
|
||||||
.status-strip { flex-wrap: wrap; white-space: normal; }
|
.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; }
|
.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; }
|
.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; }
|
.docker-hero, .monitor-hero { flex-direction: column; }
|
||||||
.tab { min-width: 104px; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const MODULES = [
|
|||||||
] },
|
] },
|
||||||
{ id: "tasks", label: "任务调度", code: "TASK", tabs: [
|
{ id: "tasks", label: "任务调度", code: "TASK", tabs: [
|
||||||
{ id: "dispatch", label: "下发任务" },
|
{ id: "dispatch", label: "下发任务" },
|
||||||
|
{ id: "pending", label: "待处理任务" },
|
||||||
{ id: "history", label: "任务历史" },
|
{ id: "history", label: "任务历史" },
|
||||||
{ id: "results", label: "执行结果" },
|
{ id: "results", label: "执行结果" },
|
||||||
] },
|
] },
|
||||||
@@ -96,6 +97,17 @@ function asNumber(value: any, fallback = 0): number {
|
|||||||
return Number.isFinite(number) ? number : fallback;
|
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 {
|
function summarizeValue(value: any): string {
|
||||||
if (value === null || value === undefined) return "--";
|
if (value === null || value === undefined) return "--";
|
||||||
if (typeof value === "boolean") return value ? "是" : "否";
|
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");
|
return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown");
|
||||||
}
|
}
|
||||||
|
|
||||||
function MetricCard({ label, value, hint, tone }: AnyRecord) {
|
function MetricCard({ label, value, hint, tone, onClick, testId }: AnyRecord) {
|
||||||
return h("article", { className: `metric-card ${tone || ""}` },
|
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-label" }, label),
|
||||||
h("div", { className: "metric-value" }, value),
|
h("div", { className: "metric-value" }, value),
|
||||||
h("div", { className: "metric-hint" }, hint),
|
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 overview = data.overview || {};
|
||||||
const onlineNodes = data.nodes.filter((node: any) => node.status === "online");
|
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);
|
const recentTasks = data.tasks.slice(0, 5);
|
||||||
return h("div", { className: "page-grid overview-grid" },
|
return h("div", { className: "page-grid overview-grid" },
|
||||||
h(Panel, { title: "核心指标", eyebrow: "Control" },
|
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.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: "在线节点", 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: "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" },
|
h(Panel, { title: "本机 Provider", eyebrow: "Self Connected" },
|
||||||
onlineNodes.length === 0 ? h(EmptyState, { title: "暂无在线节点", text: "provider-gateway 未完成自接入" }) :
|
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("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" },
|
h(Panel, { title: "最近任务", eyebrow: "Dispatch" },
|
||||||
recentTasks.length === 0 ? h(EmptyState, { title: "暂无任务", text: "可以在任务调度模块发起 docker.ps 或 echo" }) :
|
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 }))),
|
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" },
|
return h("article", { className: "compact-row" },
|
||||||
h(StatusBadge, { status: task.status }),
|
h(StatusBadge, { status: task.status }),
|
||||||
h("div", null, h("strong", null, task.command), h("code", null, task.id)),
|
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 }),
|
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) {
|
function TaskHistoryPage({ tasks, onRaw }: AnyRecord) {
|
||||||
return h(Panel, { title: "任务历史", eyebrow: `${tasks.length} Tasks` },
|
return h(Panel, { title: "任务历史", eyebrow: `${tasks.length} Tasks` },
|
||||||
tasks.length === 0 ? h(EmptyState, { title: "暂无任务", text: "下发任务后会在这里看到生命周期" }) :
|
tasks.length === 0 ? h(EmptyState, { title: "暂无任务", text: "下发任务后会在这里看到生命周期" }) :
|
||||||
@@ -887,8 +942,8 @@ function SecurityPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw }: AnyRecord) {
|
function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNavigate }: AnyRecord) {
|
||||||
if (activeModule === "ops" && activeTab === "status") return h(OverviewPage, { data, onRaw });
|
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 === "events") return h(EventsPage, { events: data.events, onRaw });
|
||||||
if (activeModule === "ops" && activeTab === "logs") return h(LogsPage, { logs: data.logs, 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 });
|
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 === "labels") return h(LabelsPage, { nodes: data.nodes });
|
||||||
if (activeModule === "nodes" && activeTab === "heartbeats") return h(HeartbeatPage, { 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 === "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 === "history") return h(TaskHistoryPage, { tasks: data.tasks, onRaw });
|
||||||
if (activeModule === "tasks" && activeTab === "results") return h(TaskResultsPage, { 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 });
|
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) {
|
function Shell({ session, onLogout }: AnyRecord) {
|
||||||
const [activeModule, setActiveModule] = useState("ops");
|
const [activeModule, setActiveModule] = useState("ops");
|
||||||
const [activeTabs, setActiveTabs] = useState({ ops: "status", nodes: "list", tasks: "dispatch", config: "topology" });
|
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 [connection, setConnection] = useState({ ok: false, text: "连接中" });
|
||||||
const [lastRefresh, setLastRefresh] = useState(null);
|
const [lastRefresh, setLastRefresh] = useState(null);
|
||||||
const [clock, setClock] = useState(new Date());
|
const [clock, setClock] = useState(new Date());
|
||||||
@@ -919,13 +975,14 @@ function Shell({ session, onLogout }: AnyRecord) {
|
|||||||
|
|
||||||
async function refresh(): Promise<void> {
|
async function refresh(): Promise<void> {
|
||||||
try {
|
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}/overview`),
|
||||||
requestJson(`${cfg.apiBaseUrl}/nodes`),
|
requestJson(`${cfg.apiBaseUrl}/nodes`),
|
||||||
requestJson(`${cfg.apiBaseUrl}/nodes/system-status?limit=120`),
|
requestJson(`${cfg.apiBaseUrl}/nodes/system-status?limit=120`),
|
||||||
requestJson(`${cfg.apiBaseUrl}/nodes/docker-status`),
|
requestJson(`${cfg.apiBaseUrl}/nodes/docker-status`),
|
||||||
requestJson(`${cfg.apiBaseUrl}/events?limit=100`),
|
requestJson(`${cfg.apiBaseUrl}/events?limit=100`),
|
||||||
requestJson(`${cfg.apiBaseUrl}/tasks?limit=100`),
|
requestJson(`${cfg.apiBaseUrl}/tasks?limit=100`),
|
||||||
|
requestJson(`${cfg.apiBaseUrl}/tasks?status=pending&limit=100`),
|
||||||
requestJson("/logs?limit=100"),
|
requestJson("/logs?limit=100"),
|
||||||
]);
|
]);
|
||||||
setData({
|
setData({
|
||||||
@@ -935,6 +992,7 @@ function Shell({ session, onLogout }: AnyRecord) {
|
|||||||
dockerStatuses: dockerStatuses.dockerStatuses || [],
|
dockerStatuses: dockerStatuses.dockerStatuses || [],
|
||||||
events: events.events || [],
|
events: events.events || [],
|
||||||
tasks: tasks.tasks || [],
|
tasks: tasks.tasks || [],
|
||||||
|
pendingTasks: pendingTasks.tasks || [],
|
||||||
logs: logs.logs || [],
|
logs: logs.logs || [],
|
||||||
});
|
});
|
||||||
setConnection({ ok: true, text: "核心在线" });
|
setConnection({ ok: true, text: "核心在线" });
|
||||||
@@ -960,6 +1018,11 @@ function Shell({ session, onLogout }: AnyRecord) {
|
|||||||
setActiveTabs((prev: any) => ({ ...prev, [activeModule]: tab }));
|
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 {
|
function openRaw(title: string, rawData: any): void {
|
||||||
setRaw({ title, data: rawData });
|
setRaw({ title, data: rawData });
|
||||||
}
|
}
|
||||||
@@ -969,7 +1032,7 @@ function Shell({ session, onLogout }: AnyRecord) {
|
|||||||
h("main", { className: "workspace" },
|
h("main", { className: "workspace" },
|
||||||
h(TopBar, { connection, lastRefresh, onRefresh: refresh, onLogout: () => onLogout(true), session, clock }),
|
h(TopBar, { connection, lastRefresh, onRefresh: refresh, onLogout: () => onLogout(true), session, clock }),
|
||||||
h(TabBar, { module, activeTab, onChange: setTab }),
|
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) }),
|
h(RawDialog, { raw, onClose: () => setRaw(null) }),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user