fix: 收敛 observability CLI 默认输出

This commit is contained in:
Codex
2026-07-11 22:25:56 +02:00
parent 061a0d97dd
commit 4647d3cab3
5 changed files with 326 additions and 21 deletions
+6
View File
@@ -991,6 +991,12 @@ export async function staticNamespaceHelp(args: string[]): Promise<unknown | nul
platformInfraHelpSummary(),
);
}
if (top === "platform-infra" && sub === "observability") {
return loadHelp(
async () => (await import("./platform-infra-observability")).runPlatformObservabilityCommand({} as never, args.slice(2)),
platformInfraHelpSummary(),
);
}
if (top === "platform-infra") return loadHelp(async () => (await import("./platform-infra")).platformInfraHelp(), platformInfraHelpSummary());
if (top === "platform-db") return platformDbHelp();
if (top === "secrets") return secretsHelp();
@@ -0,0 +1,88 @@
import { describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { rootPath } from "./config";
import { isRenderedCliResult } from "./output";
import { runPlatformObservabilityCommand } from "./platform-infra-observability";
import { renderObservabilityPlanFailure } from "./platform-infra-observability/plan-render";
describe("platform-infra observability progressive disclosure", () => {
test("default plan is a bounded table while full and raw preserve the complete plan", async () => {
const compact = await runPlatformObservabilityCommand({} as never, ["plan", "--target", "NC01"]);
expect(isRenderedCliResult(compact)).toBe(true);
if (!isRenderedCliResult(compact)) throw new Error("expected rendered plan");
expect(Buffer.byteLength(compact.renderedText, "utf8")).toBeLessThan(10_240);
expect(compact.renderedText).toContain("serviceConnections=8");
expect(compact.renderedText).toContain("configRefs=6/6 missing=0");
expect(compact.renderedText).toContain("--full");
expect(compact.renderedText).toContain("--raw");
for (const mode of ["--full", "--raw"]) {
const expanded = await runPlatformObservabilityCommand({} as never, ["plan", "--target", "NC01", mode]);
expect(isRenderedCliResult(expanded)).toBe(false);
expect(expanded).toMatchObject({
action: "platform-infra-observability-plan",
mutation: false,
renderPlan: { target: { id: "NC01", route: "NC01:k3s", namespace: "platform-infra" } },
});
expect((expanded as Record<string, any>).renderPlan.instrumentation).toHaveLength(8);
}
});
test("real outer CLI keeps plan below the stdout dump threshold", () => {
const result = cli(["platform-infra", "observability", "plan", "--target", "NC01"]);
expect(result.status).toBe(0);
expect(Buffer.byteLength(result.stdout, "utf8")).toBeLessThan(10_240);
expect(result.stdout).toContain("platform-infra observability plan (ok)");
expect(result.stdout).toContain("configRefs=6/6 missing=0");
expect(result.stdout).not.toContain("outputTruncated");
expect(result.stdout).not.toContain("/tmp/unidesk-cli-output");
});
test("search, trace and diagnose-code-agent expose only scoped help", () => {
for (const operation of ["search", "trace", "diagnose-code-agent"]) {
const result = cli(["platform-infra", "observability", operation, "--help"]);
expect(result.status).toBe(0);
const payload = JSON.parse(result.stdout) as Record<string, any>;
expect(payload.data.scope).toBe(operation);
expect(payload.data.command).toBe(`platform-infra observability ${operation}`);
expect(payload.data.usage.every((line: string) => line.includes(`observability ${operation}`))).toBe(true);
expect(result.stdout).not.toContain("platform-infra sub2api");
expect(result.stdout).not.toContain("platform-infra langbot");
expect(result.stdout).not.toContain("outputTruncated");
}
});
test("top observability help is only an operation index", () => {
const result = cli(["platform-infra", "observability", "--help"]);
expect(result.status).toBe(0);
const payload = JSON.parse(result.stdout) as Record<string, any>;
expect(payload.data.operations.map((item: Record<string, unknown>) => item.name)).toEqual([
"plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent",
]);
expect(payload.data.scopedHelp).toContain("<operation> --help");
expect(payload.data.usage).toBeUndefined();
expect(result.stdout).not.toContain("platform-infra sub2api");
});
test("configRef failures name the source and a bounded next command", () => {
const rendered = renderObservabilityPlanFailure(
new Error("config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01.runtime.namespace is missing segment namespace"),
"NC01",
);
expect(rendered.ok).toBe(false);
expect(rendered.renderedText).toContain("config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01.runtime.namespace");
expect(rendered.renderedText).toContain("rg -n lanes config/hwlab-node-lanes.yaml");
expect(rendered.renderedText).not.toContain("/tmp/unidesk-cli-output");
});
});
function cli(args: string[]): { status: number | null; stdout: string; stderr: string } {
const result = spawnSync("bun", ["scripts/cli.ts", ...args], {
cwd: rootPath(),
encoding: "utf8",
timeout: 30_000,
env: { ...process.env, NO_COLOR: "1" },
});
expect(result.stdout).not.toBe("");
return { status: result.status, stdout: result.stdout, stderr: result.stderr };
}
@@ -10,3 +10,4 @@ export * from "./trace-script";
export * from "./search-script";
export * from "./diagnose-code-agent-script";
export * from "./summary";
export * from "./plan-render";
@@ -22,37 +22,40 @@ import {
import type { ApplyOptions, CommonOptions, DiagnoseCodeAgentOptions, SearchOptions, TraceOptions } from "./types";
import { apply, plan, status, validate } from "./actions";
import { diagnoseCodeAgent, search, trace } from "./render";
import { renderObservabilityPlan, renderObservabilityPlanFailure } from "./plan-render";
export function observabilityHelp(): Record<string, unknown> {
const observabilityOperations = ["plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent"] as const;
export function observabilityHelp(scope: string | null = null): Record<string, unknown> {
const scoped = scope === null ? null : observabilityOperationHelp(scope);
if (scoped !== null) return scoped;
return {
command: "platform-infra observability plan|apply|status|validate|trace|search|diagnose-code-agent",
output: "text/table by default; trace --full expands bounded diagnostic tables, --raw returns backend JSON",
output: "默认输出为有界摘要;--full/--raw 显式披露完整结构",
configTruth: "config/platform-infra/observability.yaml",
spec: "PJ2026-01060501 OTel追踪 draft-2026-06-19-p0",
usage: [
"bun scripts/cli.ts platform-infra observability plan --target D601",
"bun scripts/cli.ts platform-infra observability plan --target D518",
"bun scripts/cli.ts platform-infra observability apply --target D601 --dry-run",
"bun scripts/cli.ts platform-infra observability apply --target D518 --confirm",
"bun scripts/cli.ts platform-infra observability status --target D601 [--full|--raw]",
"bun scripts/cli.ts platform-infra observability status --target D518 [--full|--raw]",
"bun scripts/cli.ts platform-infra observability validate --target D518 [--full|--raw]",
"bun scripts/cli.ts platform-infra observability trace <traceId> --target D518 [--grep provider-stream-disconnected] [--limit 40] [--full|--raw]",
"bun scripts/cli.ts platform-infra observability search --target D518 --grep 'no rollout found' [--lookback-minutes 360] [--candidate-limit 80] [--limit 20] [--full|--raw]",
"bun scripts/cli.ts platform-infra observability search --target JD01 --grep opencode.proxy.stream.start --lookback-minutes 30 --limit 20",
"bun scripts/cli.ts platform-infra observability search --target JD01 --grep opencode.proxy.sse.directory_rewrite_enabled --lookback-minutes 30 --limit 20",
"bun scripts/cli.ts platform-infra observability search --target JD01 --grep opencode-provider-proxy --lookback-minutes 30 --limit 20",
"bun scripts/cli.ts platform-infra observability search --target D518 --path /v1/workbench/sessions --status 502 [--lookback-minutes 120] [--full|--raw]",
"bun scripts/cli.ts platform-infra observability diagnose-code-agent --target D518 --business-trace-id <trc_...> [--full|--raw]",
"bun scripts/cli.ts platform-infra observability diagnose-code-agent --target D518 --run-id <run_...> [--command-id <cmd_...>] [--session-id <ses_...>] [--runner-job-id <rjob_...>] [--full|--raw]",
],
boundary: "Prometheus remains the metrics source; this command owns only platform-infra OTel Collector, trace backend readiness, and trace lookup.",
operations: observabilityOperations.map((name) => ({
name,
help: `bun scripts/cli.ts platform-infra observability ${name} --help`,
})),
scopedHelp: "bun scripts/cli.ts platform-infra observability <operation> --help",
boundary: "Prometheus 仍是指标来源;本命令只负责 platform-infra OTel Collector、Tempo readiness 与 trace 查询。",
};
}
export async function runPlatformObservabilityCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
const [action = "plan"] = args;
if (action === "plan") return plan(parseCommonOptions(args.slice(1)));
if (args.some(isHelpToken)) return observabilityHelp(args.find((item) => !isHelpToken(item)) ?? null);
if (action === "plan") {
const options = parseCommonOptions(args.slice(1));
try {
const result = plan(options);
return options.full || options.raw ? result : renderObservabilityPlan(result);
} catch (error) {
if (options.full || options.raw) throw error;
return renderObservabilityPlanFailure(error, options.targetId);
}
}
if (action === "apply") return await apply(config, parseApplyOptions(args.slice(1)));
if (action === "status") return await status(config, parseCommonOptions(args.slice(1)));
if (action === "validate") return await validate(config, parseCommonOptions(args.slice(1)));
@@ -62,6 +65,67 @@ export async function runPlatformObservabilityCommand(config: UniDeskConfig, arg
return { ok: false, error: "unsupported-platform-infra-observability-command", args, help: observabilityHelp() };
}
function isHelpToken(value: string): boolean {
return value === "help" || value === "--help" || value === "-h";
}
function observabilityOperationHelp(scope: string): Record<string, unknown> | null {
const common = {
scope,
configTruth: "config/platform-infra/observability.yaml",
disclosure: "默认有界;使用 --full 查看完整计划或诊断结构,--raw 仅用于后端/API 解析排障。",
};
if (scope === "plan") return {
...common,
command: "platform-infra observability plan",
usage: ["bun scripts/cli.ts platform-infra observability plan --target <NODE> [--full|--raw]"],
options: ["--target <NODE>", "--full", "--raw"],
};
if (scope === "apply") return {
...common,
command: "platform-infra observability apply",
usage: [
"bun scripts/cli.ts platform-infra observability apply --target <NODE> --dry-run",
"bun scripts/cli.ts platform-infra observability apply --target <NODE> --confirm [--wait]",
],
options: ["--target <NODE>", "--dry-run", "--confirm", "--wait", "--full", "--raw"],
};
if (scope === "status" || scope === "validate") return {
...common,
command: `platform-infra observability ${scope}`,
usage: [`bun scripts/cli.ts platform-infra observability ${scope} --target <NODE> [--full|--raw]`],
options: ["--target <NODE>", "--full", "--raw"],
};
if (scope === "trace") return {
...common,
command: "platform-infra observability trace",
usage: [
"bun scripts/cli.ts platform-infra observability trace <otel-trace-id> --target <NODE> [--grep <text>] [--limit 40] [--full|--raw]",
"bun scripts/cli.ts platform-infra observability trace --trace-id <otel-trace-id> --target <NODE> --full",
],
options: ["--trace-id <32-hex>", "--target <NODE>", "--grep <text>", "--limit <1..500>", "--full", "--raw"],
};
if (scope === "search") return {
...common,
command: "platform-infra observability search",
usage: [
"bun scripts/cli.ts platform-infra observability search --target <NODE> --grep <text> [--lookback-minutes 360] [--candidate-limit 80] [--limit 20]",
"bun scripts/cli.ts platform-infra observability search --target <NODE> --path </route> --status <code> [--full|--raw]",
],
options: ["--grep <text>|--query <TraceQL>|--path </route>|--status <code>", "--target <NODE>", "--lookback-minutes <N>", "--end-at <ISO>", "--candidate-limit <N>", "--limit <N>", "--full", "--raw"],
};
if (scope === "diagnose-code-agent") return {
...common,
command: "platform-infra observability diagnose-code-agent",
usage: [
"bun scripts/cli.ts platform-infra observability diagnose-code-agent --target <NODE> --business-trace-id <trc_...> [--full|--raw]",
"bun scripts/cli.ts platform-infra observability diagnose-code-agent --target <NODE> --run-id <run_...> [--command-id <cmd_...>] [--session-id <ses_...>] [--runner-job-id <rjob_...>]",
],
options: ["--business-trace-id <trc_...>|--trace-id <32-hex>|--run-id <run_...>|--command-id <cmd_...>|--runner-job-id <rjob_...>", "--session-id <ses_...>", "--target <NODE>", "--lookback-minutes <N>", "--candidate-limit <N>", "--limit <N>", "--full", "--raw"],
};
return null;
}
export function parseCommonOptions(args: string[]): CommonOptions {
let targetId: string | null = null;
let full = false;
@@ -0,0 +1,146 @@
import type { RenderedCliResult } from "../output";
import { formatTable, shortenEnd, textValue } from "./manifest";
import { configLabel } from "./types";
export function renderObservabilityPlan(result: Record<string, unknown>): RenderedCliResult {
const config = record(result.config);
const target = record(config.target);
const images = record(config.images);
const collector = record(config.collector);
const traceBackend = record(config.traceBackend);
const renderPlan = record(result.renderPlan);
const otlp = record(renderPlan.otlp);
const connections = records(config.serviceConnections);
const objects = records(renderPlan.objects);
const policy = records(result.policy);
const configRefs = connections.flatMap((connection) => records(connection.configRefs));
const presentConfigRefs = configRefs.filter((item) => item.present === true);
const missingConfigRefs = configRefs.filter((item) => item.present !== true);
const failedPolicy = policy.filter((item) => item.ok === false);
const serviceNames = new Set(connections.map((item) => textValue(item.serviceName)).filter((item) => item !== "-"));
const nodes = new Set(connections.map((item) => textValue(record(item.resolved).targetNode)).filter((item) => item !== "-"));
const connectionRows = connections.map((item) => {
const resolved = record(item.resolved);
const refs = records(item.configRefs);
const present = refs.filter((ref) => ref.present === true).length;
return [
shortenEnd(textValue(item.serviceName), 28),
textValue(resolved.targetNode),
textValue(resolved.lane),
textValue(resolved.namespace),
refs.length === 0 ? "inline" : `${present}/${refs.length}`,
String(values(item.requiredSpans).length),
];
});
const objectRows = objects.map((item) => [textValue(item.kind), textValue(item.name)]);
const lines = [
`platform-infra observability plan (${result.ok === false ? "not-ok" : "ok"})`,
"",
`target=${textValue(target.id)} route=${textValue(target.route)} namespace=${textValue(target.namespace)} role=${textValue(target.role)}`,
`collector=${textValue(collector.serviceName)} replicas=${textValue(collector.replicas)} image=${textValue(images.collector)}`,
`tempo=${textValue(traceBackend.serviceName)} replicas=${textValue(traceBackend.replicas)} retention=${textValue(record(traceBackend.storage).retention)} image=${textValue(images.traceBackend)}`,
`serviceConnections=${connections.length} services=${serviceNames.size} nodes=${nodes.size} configRefs=${presentConfigRefs.length}/${configRefs.length} missing=${missingConfigRefs.length} objects=${objects.length}`,
"",
"Service connections:",
formatTable(
["SERVICE", "NODE", "LANE", "NAMESPACE", "CONFIG_REFS", "SPANS"],
connectionRows.length > 0 ? connectionRows : [["-", "-", "-", "-", "-", "-"]],
),
"",
"Render objects:",
formatTable(["KIND", "NAME"], objectRows.length > 0 ? objectRows : [["-", "-"]]),
];
if (missingConfigRefs.length > 0) {
lines.push(
"",
"Missing configRefs:",
formatTable(
["ID", "FILE", "PATH"],
missingConfigRefs.map((item) => [
shortenEnd(textValue(item.id), 48),
shortenEnd(textValue(item.file), 48),
shortenEnd(textValue(item.path), 64),
]),
),
);
}
if (failedPolicy.length > 0) {
lines.push(
"",
"Failed policy:",
formatTable(["NAME", "DETAIL"], failedPolicy.map((item) => [textValue(item.name), shortenEnd(textValue(item.detail), 100)])),
);
}
lines.push(
"",
"Endpoints:",
` collector-grpc: ${textValue(otlp.collectorGrpcEndpoint)}`,
` collector-http: ${textValue(otlp.collectorHttpEndpoint)}`,
` tempo-grpc: ${textValue(otlp.backendGrpcEndpoint)}`,
"",
"Next:",
` bun scripts/cli.ts platform-infra observability plan --target ${textValue(target.id)} --full`,
` bun scripts/cli.ts platform-infra observability plan --target ${textValue(target.id)} --raw`,
` bun scripts/cli.ts platform-infra observability status --target ${textValue(target.id)}`,
"",
"Disclosure:",
" 默认视图只展示计划摘要;--full/--raw 显式披露完整 render plan。",
);
return {
ok: result.ok !== false,
command: "platform-infra observability plan",
contentType: "text/plain",
renderedText: lines.join("\n"),
};
}
export function renderObservabilityPlanFailure(error: unknown, targetId: string | null): RenderedCliResult {
const message = error instanceof Error ? error.message : String(error);
const configRef = extractConfigRef(message);
const sourcePath = configRef?.split("#")[0] ?? configLabel;
const inspectPattern = configRef?.split("#")[1]?.split(/[.[]/u)[0] ?? "instrumentation";
const target = targetId ?? "YAML-default";
const targetArg = targetId === null ? "" : ` --target ${targetId}`;
const lines = [
"platform-infra observability plan (not-ok)",
"",
`target=${target}`,
`config=${configLabel}`,
`configRef=${configRef ?? "unresolved"}`,
`reason=${shortenEnd(message.replace(/\s+/gu, " ").trim(), 500)}`,
"",
"Next:",
` rg -n ${quoteArg(inspectPattern)} ${quoteArg(sourcePath)}`,
` bun scripts/cli.ts platform-infra observability plan${targetArg} --full`,
"",
"Disclosure:",
" 配置或 configRef 失败会直接显示来源与检查入口,不依赖 stdout dump。",
];
return {
ok: false,
command: "platform-infra observability plan",
contentType: "text/plain",
renderedText: lines.join("\n"),
};
}
function extractConfigRef(message: string): string | null {
const match = message.match(/config\/[A-Za-z0-9_./-]+[.]yaml(?:#[A-Za-z0-9_.\-[\]]+)?/u);
return match?.[0] ?? null;
}
function quoteArg(value: string): string {
return /^[A-Za-z0-9_./:[\]-]+$/u.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
}
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function records(value: unknown): Record<string, unknown>[] {
return Array.isArray(value) ? value.map(record) : [];
}
function values(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}