diff --git a/docs/reference/webterm.md b/docs/reference/webterm.md index 1ea24179..7c0e6870 100644 --- a/docs/reference/webterm.md +++ b/docs/reference/webterm.md @@ -7,6 +7,12 @@ - 预览使用 `bun scripts/cli.ts platform-infra webterm plan --target `; - 部署使用 `bun scripts/cli.ts platform-infra webterm apply --target --confirm`; - 收口使用同一入口的 `status` 与 `validate`。 + - 日志使用 `bun scripts/cli.ts platform-infra webterm logs --target --tail `; + - 日志默认返回最近 100 行,`--tail` 允许范围为 1 到 1000 行,且总输出保持有界。 +- 匿名健康与 provider 判定: + - Webterm `/healthz` 无需登录,返回 `ok`、`providers` 与 `sessions`; + - `apply`、`status` 和 `validate` 必须读取 `/healthz`,且只在 `ok=true`、`providers>=1` 时判定本地运行面健康; + - `/login` 可访问不能替代 provider 健康证据。 - 浏览器历史策略: - `browserTerminalHistory.desktopScrollbackLines` 控制桌面端 xterm 历史行数; - `browserTerminalHistory.mobileScrollbackLines` 控制移动端 xterm 历史行数; diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 04f50f14..08595127 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -766,6 +766,7 @@ function platformInfraHelpSummary(): unknown { "bun scripts/cli.ts platform-infra webterm apply --confirm", "bun scripts/cli.ts platform-infra webterm status", "bun scripts/cli.ts platform-infra webterm validate", + "bun scripts/cli.ts platform-infra webterm logs --target local-7682 --tail 100", "bun scripts/cli.ts platform-infra wechat-archive plan", "bun scripts/cli.ts platform-infra wechat-archive apply --confirm", "bun scripts/cli.ts platform-infra wechat-archive validate --full", diff --git a/scripts/src/platform-infra-webterm.ts b/scripts/src/platform-infra-webterm.ts index 48c2110f..dd4c36c6 100644 --- a/scripts/src/platform-infra-webterm.ts +++ b/scripts/src/platform-infra-webterm.ts @@ -111,6 +111,11 @@ export async function runWebtermCommand(config: UniDeskConfig, args: string[]): const result = validate(options.targetId); return options.full || options.raw ? result : renderValidate(result); } + if (action === "logs") { + const options = parseOpsCommonOptions(rest, { stringOptions: ["--tail"] }); + const result = logs(options.targetId, parseLogTail(options.tail)); + return options.full || options.raw ? result : renderLogs(result); + } return { ok: false, error: "unsupported-webterm-command", @@ -121,13 +126,14 @@ export async function runWebtermCommand(config: UniDeskConfig, args: string[]): export function webtermHelp(): Record { return { - command: "platform-infra webterm plan|apply|status|validate", + command: "platform-infra webterm plan|apply|status|validate|logs", examples: [ "bun scripts/cli.ts platform-infra webterm plan", "bun scripts/cli.ts platform-infra webterm apply --dry-run", "bun scripts/cli.ts platform-infra webterm apply --confirm", "bun scripts/cli.ts platform-infra webterm status", "bun scripts/cli.ts platform-infra webterm validate", + "bun scripts/cli.ts platform-infra webterm logs --target local-7682 --tail 100", ], configPath, description: "Deploy target-scoped host-Docker Web Terminal instances from YAML and optionally expose them through PK01 Caddy HTTPS.", @@ -195,7 +201,7 @@ async function apply(config: UniDeskConfig, targetId: string | null, confirm: bo cwd: target.runtime.workDir, encoding: "utf8", }); - const localProbe = localHttpProbeWithRetry(target.runtime.hostPort, "/login", 10, 500); + const localProbe = providerHealthProbeWithRetry(target.runtime.hostPort, 10, 500); const caddy = target.publicExposure.enabled ? await applyPk01CaddySiteBlock( config, @@ -231,7 +237,7 @@ function status(targetId: string | null): Record { const target = resolveTarget(targetId); const inspect = spawnSync("docker", ["inspect", target.runtime.containerName], { encoding: "utf8" }); const ps = spawnSync("docker", ["ps", "--filter", `name=^/${target.runtime.containerName}$`, "--format", "{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"], { encoding: "utf8" }); - const localProbe = localHttpProbe(target.runtime.hostPort, "/login"); + const localProbe = providerHealthProbe(target.runtime.hostPort); const publicProbe = target.publicExposure.enabled ? publicHttpProbe(target.publicExposure.publicBaseUrl, "/login") : { ok: true, skipped: true }; return { ok: ps.status === 0 && ps.stdout.trim().length > 0 && localProbe.ok === true && publicProbe.ok === true, @@ -254,7 +260,7 @@ function status(targetId: string | null): Record { function validate(targetId: string | null): Record { const target = resolveTarget(targetId); const dns = dnsProbe(target.publicExposure.dns.hostname); - const localProbe = localHttpProbe(target.runtime.hostPort, "/login"); + const localProbe = providerHealthProbe(target.runtime.hostPort); const publicProbe = target.publicExposure.enabled ? publicHttpProbe(target.publicExposure.publicBaseUrl, "/login") : { ok: true, skipped: true }; return { ok: localProbe.ok === true && publicProbe.ok === true && dns.addresses.includes(target.publicExposure.dns.expectedA), @@ -266,6 +272,28 @@ function validate(targetId: string | null): Record { }; } +function logs(targetId: string | null, tail: number): Record { + const target = resolveTarget(targetId); + const result = spawnSync("docker", ["logs", "--tail", String(tail), target.runtime.containerName], { encoding: "utf8" }); + const output = `${result.stdout}${result.stderr}`.slice(-20000); + return { + ok: result.status === 0, + target: targetSummary(target), + tail, + logs: output, + truncated: result.stdout.length + result.stderr.length > output.length, + exitCode: result.status, + valuesPrinted: false, + }; +} + +function parseLogTail(value: unknown): number { + if (value === null || value === undefined) return 100; + const tail = Number(value); + if (!Number.isInteger(tail) || tail < 1 || tail > 1000) throw new Error("--tail must be an integer from 1 to 1000"); + return tail; +} + function readWebtermConfig(): WebtermConfig { const yaml = readYamlRecord(configPath, "platform-infra-webterm"); const defaults = recordField(yaml, "defaults", "webterm"); @@ -511,6 +539,33 @@ function localHttpProbeWithRetry(port: number, path: string, attempts: number, d return httpProbeWithRetry(() => localHttpProbe(port, path), attempts, delayMs); } +function providerHealthProbe(port: number): Record { + const url = `http://127.0.0.1:${port}/healthz`; + const result = spawnSync("curl", ["-fsS", "--connect-timeout", "5", "--max-time", "15", url], { encoding: "utf8" }); + let payload: Record = {}; + try { + payload = JSON.parse(result.stdout) as Record; + } catch { + payload = {}; + } + const providers = Number(payload.providers); + const sessions = Number(payload.sessions); + return { + ok: result.status === 0 && payload.ok === true && Number.isInteger(providers) && providers >= 1, + url, + authenticated: false, + providers: Number.isInteger(providers) ? providers : null, + sessions: Number.isInteger(sessions) ? sessions : null, + exitCode: result.status, + error: result.status === 0 && providers < 1 ? "no-provider" : undefined, + stderrTail: result.status === 0 ? "" : result.stderr.slice(-1000), + }; +} + +function providerHealthProbeWithRetry(port: number, attempts: number, delayMs: number): Record { + return httpProbeWithRetry(() => providerHealthProbe(port), attempts, delayMs); +} + function publicHttpProbeWithRetry(baseUrl: string, path: string, attempts: number, delayMs: number): Record { return httpProbeWithRetry(() => publicHttpProbe(baseUrl, path), attempts, delayMs); } @@ -730,6 +785,7 @@ function renderApply(result: Record): RenderedCliResult { "CHECKS", ...table(["CHECK", "OK", "DETAIL"], [ ["local", bool(localProbe.ok), str(localProbe.url, "-")], + ["provider", str(localProbe.providers, "-"), `sessions=${str(localProbe.sessions, "-")}`], ["pk01Caddy", bool(caddy.ok), str(caddy.action, str(caddy.hostname, "-"))], ["public", bool(publicProbe.ok), str(publicProbe.url, "-")], ]), @@ -756,6 +812,7 @@ function renderStatus(result: Record): RenderedCliResult { "CHECKS", ...table(["CHECK", "OK", "DETAIL"], [ ["local", bool(localProbe.ok), str(localProbe.url, "-")], + ["provider", str(localProbe.providers, "-"), `sessions=${str(localProbe.sessions, "-")}`], ["public", bool(publicProbe.ok), str(publicProbe.url, "-")], ]), ]; @@ -771,12 +828,24 @@ function renderValidate(result: Record): RenderedCliResult { ...table(["CHECK", "OK", "DETAIL"], [ ["dns", bool(dns.ok), values(dns.addresses).map(String).join(",") || "-"], ["local", bool(localProbe.ok), str(localProbe.url, "-")], + ["provider", str(localProbe.providers, "-"), `sessions=${str(localProbe.sessions, "-")}`], ["public", bool(publicProbe.ok), str(publicProbe.url, "-")], ]), ]; return rendered(result, "platform-infra webterm validate", lines); } +function renderLogs(result: Record): RenderedCliResult { + const target = rec(result.target); + const lines = [ + "PLATFORM-INFRA WEBTERM LOGS", + ...table(["TARGET", "PORT", "TAIL", "STATUS"], [[str(target.id), str(target.hostPort), str(result.tail), result.ok === false ? "failed" : "ok"]]), + "", + str(result.logs, "-"), + ]; + return rendered(result, "platform-infra webterm logs", lines); +} + function rendered(result: Record, command: string, lines: string[]): RenderedCliResult { return { ok: result.ok !== false, command, renderedText: lines.join("\n"), contentType: "text/plain" }; }