Merge pull request #1800 from pikasTech/fix/1619-observability-bounded-help
fix: 收敛 observability CLI 默认输出与 scoped help
This commit is contained in:
@@ -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 : [];
|
||||
}
|
||||
Reference in New Issue
Block a user