feat: add resource monitoring and provider upgrade

This commit is contained in:
Codex
2026-05-04 13:04:39 +00:00
parent 8726611b6f
commit 2a03f6343c
27 changed files with 1807 additions and 130 deletions
+3 -3
View File
@@ -23,8 +23,8 @@ function help(): unknown {
{ command: "server logs [--tail-bytes N]", description: "Return bounded tails from file logs and docker logs." },
{ command: "job list", description: "List async jobs from .state/jobs." },
{ command: "job status <jobId|latest> [--tail-bytes N]", description: "Show job state with bounded stdout/stderr tails." },
{ command: "debug health", description: "Probe internal core, overview, nodes, frontend, provider ingress, and public boundary." },
{ command: "debug dispatch [providerId] [docker.ps|echo]", description: "Submit a real internal-core dispatch request for CLI debugging." },
{ command: "debug health", description: "Probe internal core, nodes, system/Docker status, frontend, provider ingress, and public boundary." },
{ command: "debug dispatch [providerId] [docker.ps|provider.upgrade|echo]", description: "Submit a real internal-core dispatch request for CLI debugging." },
{ command: "e2e run", description: "Run public frontend/provider, internal core/database, and Playwright login E2E checks." },
],
};
@@ -110,7 +110,7 @@ async function main(): Promise<void> {
}
if (sub === "dispatch") {
const providerId = third ?? config.providerGateway.id;
const dispatchCommand = fourth === "docker.ps" || fourth === "echo" ? fourth : "docker.ps";
const dispatchCommand = fourth === "docker.ps" || fourth === "provider.upgrade" || fourth === "echo" ? fourth : "docker.ps";
emitJson(commandName, await debugDispatch(config, providerId, dispatchCommand));
return;
}
+32
View File
@@ -23,6 +23,17 @@ export interface UniDeskConfig {
heartbeatIntervalMs: number;
reconnectBaseMs: number;
reconnectMaxMs: number;
metrics: { diskPath: string };
upgrade: {
enabled: boolean;
hostProjectRoot: string;
workspacePath: string;
composeFile: string;
composeEnvFile: string;
composeProject: string;
service: string;
runnerImage: string;
};
};
docker: { composeFile: string; projectName: string };
paths: { stateDir: string; logsDir: string; docsReferenceDir: string };
@@ -55,6 +66,12 @@ function numberField(obj: Record<string, unknown>, key: string, path: string): n
return value;
}
function booleanField(obj: Record<string, unknown>, key: string, path: string): boolean {
const value = obj[key];
if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
return value;
}
function portPair(obj: Record<string, unknown>, key: string): { port: number; containerPort: number } {
const value = asRecord(obj[key], `network.${key}`);
return { port: numberField(value, "port", `network.${key}`), containerPort: numberField(value, "containerPort", `network.${key}`) };
@@ -75,6 +92,8 @@ export function readConfig(): UniDeskConfig {
const paths = asRecord(parsed.paths, "paths");
const sshForwarding = asRecord(parsed.sshForwarding, "sshForwarding");
const labels = asRecord(providerGateway.labels, "providerGateway.labels");
const providerMetrics = asRecord(providerGateway.metrics, "providerGateway.metrics");
const providerUpgrade = asRecord(providerGateway.upgrade, "providerGateway.upgrade");
const typescript = stringField(runtime, "typescript", "runtime");
if (typescript !== "bun") throw new Error("runtime.typescript must be bun");
return {
@@ -103,6 +122,19 @@ export function readConfig(): UniDeskConfig {
heartbeatIntervalMs: numberField(providerGateway, "heartbeatIntervalMs", "providerGateway"),
reconnectBaseMs: numberField(providerGateway, "reconnectBaseMs", "providerGateway"),
reconnectMaxMs: numberField(providerGateway, "reconnectMaxMs", "providerGateway"),
metrics: {
diskPath: stringField(providerMetrics, "diskPath", "providerGateway.metrics"),
},
upgrade: {
enabled: booleanField(providerUpgrade, "enabled", "providerGateway.upgrade"),
hostProjectRoot: stringField(providerUpgrade, "hostProjectRoot", "providerGateway.upgrade"),
workspacePath: stringField(providerUpgrade, "workspacePath", "providerGateway.upgrade"),
composeFile: stringField(providerUpgrade, "composeFile", "providerGateway.upgrade"),
composeEnvFile: stringField(providerUpgrade, "composeEnvFile", "providerGateway.upgrade"),
composeProject: stringField(providerUpgrade, "composeProject", "providerGateway.upgrade"),
service: stringField(providerUpgrade, "service", "providerGateway.upgrade"),
runnerImage: stringField(providerUpgrade, "runnerImage", "providerGateway.upgrade"),
},
},
docker: { composeFile: stringField(docker, "composeFile", "docker"), projectName: stringField(docker, "projectName", "docker") },
paths: {
+84 -2
View File
@@ -38,11 +38,93 @@ function coreInternalFetch(path: string, init?: { method?: string; body?: unknow
}
}
function coreDockerStatusSummary(): unknown {
const code = `
const res = await fetch('http://127.0.0.1:8080/api/nodes/docker-status');
const text = await res.text();
let body = null;
try { body = text ? JSON.parse(text) : null; } catch { body = { text }; }
const dockerStatuses = (body?.dockerStatuses || []).map((item) => {
const status = item.dockerStatus || {};
return {
providerId: item.providerId,
name: item.name,
nodeStatus: item.nodeStatus,
updatedAt: item.updatedAt,
dockerStatus: {
ok: status.ok,
socketPresent: status.socketPresent,
collectedAt: status.collectedAt,
counts: status.counts,
daemon: status.daemon,
containersPreview: (status.containers || []).slice(0, 8).map((container) => ({
id: container.id,
name: container.name,
image: container.image,
state: container.state,
status: container.status,
ports: container.ports,
})),
},
};
});
console.log(JSON.stringify({ ok: res.ok, status: res.status, body: { ok: body?.ok === true, dockerStatuses } }));
`;
const result = runCommand(["docker", "exec", "unidesk-backend-core", "bun", "-e", code], repoRoot);
if (result.exitCode !== 0) {
return { ok: false, exitCode: result.exitCode, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200) };
}
try {
return JSON.parse(result.stdout.trim()) as unknown;
} catch {
return { ok: true, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200) };
}
}
function coreSystemStatusSummary(): unknown {
const code = `
const res = await fetch('http://127.0.0.1:8080/api/nodes/system-status?limit=24');
const text = await res.text();
let body = null;
try { body = text ? JSON.parse(text) : null; } catch { body = { text }; }
const systemStatuses = (body?.systemStatuses || []).map((item) => {
const current = item.current || {};
return {
providerId: item.providerId,
name: item.name,
nodeStatus: item.nodeStatus,
updatedAt: item.updatedAt,
current: item.current ? {
ok: current.ok,
collectedAt: current.collectedAt,
cpu: current.cpu,
memory: current.memory,
disk: current.disk,
} : null,
historyPreview: (item.history || []).slice(-8),
historyCount: (item.history || []).length,
};
});
console.log(JSON.stringify({ ok: res.ok, status: res.status, body: { ok: body?.ok === true, systemStatuses } }));
`;
const result = runCommand(["docker", "exec", "unidesk-backend-core", "bun", "-e", code], repoRoot);
if (result.exitCode !== 0) {
return { ok: false, exitCode: result.exitCode, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200) };
}
try {
return JSON.parse(result.stdout.trim()) as unknown;
} catch {
return { ok: true, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200) };
}
}
export async function debugHealth(config: UniDeskConfig): Promise<unknown> {
return {
coreInternal: await coreInternalFetch("/health"),
overviewInternal: await coreInternalFetch("/api/overview"),
nodesInternal: await coreInternalFetch("/api/nodes"),
systemStatusInternal: coreSystemStatusSummary(),
dockerStatusInternal: coreDockerStatusSummary(),
frontendPublic: await readJson(`http://127.0.0.1:${config.network.frontend.port}/health`),
providerIngressPublic: await readJson(`http://127.0.0.1:${config.network.providerIngress.port}/health`),
publicExposureBoundary: {
@@ -52,9 +134,9 @@ export async function debugHealth(config: UniDeskConfig): Promise<unknown> {
};
}
export async function debugDispatch(config: UniDeskConfig, providerId: string, command: "docker.ps" | "echo"): Promise<unknown> {
export async function debugDispatch(config: UniDeskConfig, providerId: string, command: "docker.ps" | "provider.upgrade" | "echo"): Promise<unknown> {
return coreInternalFetch("/api/dispatch", {
method: "POST",
body: { providerId, command, payload: { source: "cli-debug" } },
body: { providerId, command, payload: command === "provider.upgrade" ? { source: "cli-debug", mode: "plan" } : { source: "cli-debug" } },
});
}
+9
View File
@@ -83,6 +83,15 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean):
UNIDESK_HEARTBEAT_TIMEOUT_MS: "90000",
UNIDESK_RECONNECT_BASE_MS: String(config.providerGateway.reconnectBaseMs),
UNIDESK_RECONNECT_MAX_MS: String(config.providerGateway.reconnectMaxMs),
UNIDESK_MONITOR_DISK_PATH: config.providerGateway.metrics.diskPath,
UNIDESK_PROVIDER_UPGRADE_ENABLED: String(config.providerGateway.upgrade.enabled),
UNIDESK_PROVIDER_UPGRADE_HOST_PROJECT_ROOT: config.providerGateway.upgrade.hostProjectRoot,
UNIDESK_PROVIDER_UPGRADE_WORKSPACE_PATH: config.providerGateway.upgrade.workspacePath,
UNIDESK_PROVIDER_UPGRADE_COMPOSE_FILE: config.providerGateway.upgrade.composeFile,
UNIDESK_PROVIDER_UPGRADE_ENV_FILE: config.providerGateway.upgrade.composeEnvFile,
UNIDESK_PROVIDER_UPGRADE_COMPOSE_PROJECT: config.providerGateway.upgrade.composeProject,
UNIDESK_PROVIDER_UPGRADE_SERVICE: config.providerGateway.upgrade.service,
UNIDESK_PROVIDER_UPGRADE_RUNNER_IMAGE: config.providerGateway.upgrade.runnerImage,
UNIDESK_LOG_DIR: logDir,
UNIDESK_LOG_PREFIX: logPrefix,
UNIDESK_HOST_SSH_HOST: config.sshForwarding.host,
+110 -2
View File
@@ -71,9 +71,13 @@ function runPsql(config: UniDeskConfig, sql: string): { ok: boolean; stdout: str
return { ok: result.exitCode === 0, stdout: result.stdout.trim(), stderr: result.stderr.trim(), exitCode: result.exitCode };
}
function dockerCoreJson(path: string): unknown {
function dockerCoreJson(path: string, init?: { method?: string; body?: unknown }): unknown {
const code = `
const res = await fetch(${JSON.stringify(`http://127.0.0.1:8080${path}`)});
const res = await fetch(${JSON.stringify(`http://127.0.0.1:8080${path}`)}, ${JSON.stringify({
method: init?.method ?? "GET",
headers: init?.body === undefined ? undefined : { "content-type": "application/json" },
body: init?.body === undefined ? undefined : JSON.stringify(init.body),
})});
const text = await res.text();
let body = null;
try { body = text ? JSON.parse(text) : null; } catch { body = { text }; }
@@ -88,6 +92,20 @@ function dockerCoreJson(path: string): unknown {
}
}
async function waitForTaskStatus(taskId: string, expected: string, timeoutMs = 10_000): Promise<unknown> {
const started = Date.now();
let latest: unknown = null;
while (Date.now() - started < timeoutMs) {
latest = dockerCoreJson("/api/tasks?limit=40");
const tasks = (latest as { body?: { tasks?: Array<{ id?: string; status?: string; result?: unknown }> } }).body?.tasks ?? [];
const task = tasks.find((item) => item.id === taskId);
if (task?.status === expected) return { ok: true, task };
if (task?.status === "failed") return { ok: false, task };
await Bun.sleep(500);
}
return { ok: false, timeoutMs, latest };
}
function dockerPortSummary(): unknown {
const result = runCommand([
"docker",
@@ -108,6 +126,54 @@ function dockerPortSummary(): unknown {
};
}
function dockerStatusCheckDetail(dockerStatus: unknown, providerId: string): unknown {
const response = dockerStatus as { ok?: boolean; status?: number; body?: { dockerStatuses?: Array<{ providerId?: string; name?: string; nodeStatus?: string; updatedAt?: string; dockerStatus?: { ok?: boolean; socketPresent?: boolean; collectedAt?: string; counts?: unknown; daemon?: unknown; containers?: Array<{ id?: string; name?: string; image?: string; state?: string; status?: string; ports?: string }> } }> } };
const item = response.body?.dockerStatuses?.find((entry) => entry.providerId === providerId);
return {
ok: response.ok,
status: response.status,
providerId,
nodeStatus: item?.nodeStatus,
updatedAt: item?.updatedAt,
dockerStatus: item?.dockerStatus === undefined ? null : {
ok: item.dockerStatus.ok,
socketPresent: item.dockerStatus.socketPresent,
collectedAt: item.dockerStatus.collectedAt,
counts: item.dockerStatus.counts,
daemon: item.dockerStatus.daemon,
containersPreview: (item.dockerStatus.containers ?? []).slice(0, 8).map((container) => ({
id: container.id,
name: container.name,
image: container.image,
state: container.state,
status: container.status,
ports: container.ports,
})),
},
};
}
function systemStatusCheckDetail(systemStatus: unknown, providerId: string): unknown {
const response = systemStatus as { ok?: boolean; status?: number; body?: { systemStatuses?: Array<{ providerId?: string; name?: string; nodeStatus?: string; updatedAt?: string; current?: { ok?: boolean; collectedAt?: string; cpu?: unknown; memory?: unknown; disk?: unknown }; history?: unknown[] }> } };
const item = response.body?.systemStatuses?.find((entry) => entry.providerId === providerId);
return {
ok: response.ok,
status: response.status,
providerId,
nodeStatus: item?.nodeStatus,
updatedAt: item?.updatedAt,
current: item?.current === undefined ? null : {
ok: item.current.ok,
collectedAt: item.current.collectedAt,
cpu: item.current.cpu,
memory: item.current.memory,
disk: item.current.disk,
},
historyCount: item?.history?.length ?? 0,
historyPreview: (item?.history ?? []).slice(-8),
};
}
async function exposureChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise<void> {
const portSummary = dockerPortSummary() as { rows?: Array<{ name: string; ports: string }> };
const portsText = (portSummary.rows ?? []).map((row) => `${row.name} ${row.ports}`).join("\n");
@@ -137,11 +203,27 @@ async function exposureChecks(config: UniDeskConfig, urls: PublicUrls, checks: E
async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise<void> {
const coreOverview = dockerCoreJson("/api/overview");
const coreNodes = dockerCoreJson("/api/nodes");
const systemStatus = dockerCoreJson("/api/nodes/system-status?limit=24");
const dockerStatus = dockerCoreJson("/api/nodes/docker-status");
const providerIngress = await fetchProbe(urls.providerIngressHealthUrl);
const overviewBody = (coreOverview as { body?: { ok?: boolean; dbReady?: boolean; onlineNodeCount?: number } }).body;
const nodeList = (coreNodes as { body?: { nodes?: Array<{ providerId?: string; status?: string }> } }).body?.nodes ?? [];
const systemStatuses = (systemStatus as { body?: { systemStatuses?: Array<{ providerId?: string; current?: { cpu?: { percent?: number }; memory?: { percent?: number }; disk?: { percent?: number } }; history?: unknown[] }> } }).body?.systemStatuses ?? [];
const mainSystem = systemStatuses.find((item) => item.providerId === config.providerGateway.id);
const dockerStatuses = (dockerStatus as { body?: { dockerStatuses?: Array<{ providerId?: string; dockerStatus?: { counts?: { containers?: number }; containers?: unknown[] } }> } }).body?.dockerStatuses ?? [];
const mainDocker = dockerStatuses.find((item) => item.providerId === config.providerGateway.id);
addCheck(checks, "core:internal-overview", (coreOverview as { ok?: boolean }).ok === true && overviewBody?.ok === true && overviewBody.dbReady === true && (overviewBody.onlineNodeCount ?? 0) >= 1, coreOverview);
addCheck(checks, "provider:self-node-online", nodeList.some((node) => node.providerId === config.providerGateway.id && node.status === "online"), coreNodes);
addCheck(checks, "provider:system-status", (systemStatus as { ok?: boolean }).ok === true && mainSystem?.current !== undefined && Number.isFinite(mainSystem.current.cpu?.percent) && Number.isFinite(mainSystem.current.memory?.percent) && Number.isFinite(mainSystem.current.disk?.percent) && (mainSystem.history?.length ?? 0) > 0, systemStatusCheckDetail(systemStatus, config.providerGateway.id));
addCheck(checks, "provider:docker-status", (dockerStatus as { ok?: boolean }).ok === true && mainDocker?.dockerStatus !== undefined && ((mainDocker.dockerStatus.counts?.containers ?? 0) > 0 || (mainDocker.dockerStatus.containers?.length ?? 0) > 0), dockerStatusCheckDetail(dockerStatus, config.providerGateway.id));
const upgradeDispatch = dockerCoreJson("/api/dispatch", {
method: "POST",
body: { providerId: config.providerGateway.id, command: "provider.upgrade", payload: { source: "cli-e2e", mode: "plan" } },
});
const upgradeTaskId = (upgradeDispatch as { body?: { taskId?: string } }).body?.taskId ?? "";
const upgradeTask = upgradeTaskId ? await waitForTaskStatus(upgradeTaskId, "succeeded") : { ok: false, error: "missing taskId", upgradeDispatch };
const taskResult = (upgradeTask as { task?: { result?: { plan?: unknown; mode?: string } }; ok?: boolean }).task?.result;
addCheck(checks, "provider:upgrade-plan", (upgradeDispatch as { ok?: boolean }).ok === true && (upgradeTask as { ok?: boolean }).ok === true && taskResult?.mode === "plan" && taskResult.plan !== undefined, { upgradeDispatch, upgradeTask });
addCheck(checks, "provider-ingress:public-health", (providerIngress as { ok?: boolean; body?: { ok?: boolean } }).ok === true && (providerIngress as { body?: { ok?: boolean } }).body?.ok === true, providerIngress);
}
@@ -192,9 +274,35 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
await page.getByTestId(`raw-node-${config.providerGateway.id.replace(/[^a-zA-Z0-9_-]/g, "_")}`).click();
await page.waitForSelector('[data-testid="raw-json"]', { timeout: 5000 });
const rawText = await page.locator('[data-testid="raw-json"]').innerText({ timeout: 5000 });
await page.getByRole("button", { name: "关闭" }).click();
await page.getByRole("button", { name: /资源节点/ }).click();
await page.getByRole("button", { name: /资源监控/ }).click();
await page.waitForSelector('[data-testid="node-monitor-page"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="metric-chart-cpu"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="metric-chart-memory"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="metric-chart-disk"]', { timeout: 10000 });
await page.waitForFunction(() => {
const text = document.body.innerText.toLowerCase();
return text.includes("任务管理器视图") && text.includes("cpu") && text.includes("memory") && text.includes("disk");
}, undefined, { timeout: 10000 });
const monitorText = await page.locator('[data-testid="node-monitor-page"]').innerText({ timeout: 5000 });
await page.getByTestId("upgrade-plan-button").click();
await page.waitForFunction(() => document.body.innerText.includes("预检升级 已下发"), undefined, { timeout: 10000 });
const upgradeControlText = await page.locator('[data-testid="provider-upgrade-control"]').innerText({ timeout: 5000 });
await page.getByRole("button", { name: /Docker 状态/ }).click();
await page.waitForSelector('[data-testid="docker-status-page"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="docker-container-table"]', { timeout: 10000 });
await page.waitForFunction(() => {
const text = document.body.innerText.toLowerCase();
return text.includes("docker desktop 视图") && text.includes("containers");
}, 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: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"), { monitorTextPreview: monitorText.slice(0, 800) });
addCheck(checks, "frontend:upgrade-plan-dispatch", upgradeControlText.includes("预检升级 已下发"), { providerId: config.providerGateway.id, upgradeControlPreview: upgradeControlText.slice(0, 500) });
addCheck(checks, "frontend:docker-status-visible", dockerText.toLowerCase().includes("docker desktop 视图") && dockerText.toLowerCase().includes("containers") && (dockerText.includes("unidesk-frontend") || dockerText.includes("unidesk-backend-core")), { dockerTextPreview: dockerText.slice(0, 800) });
addCheck(checks, "frontend:no-console-errors", consoleErrors.length === 0, { consoleErrors });
return { screenshotPath, bodyText, consoleErrors };
} finally {