feat: 增加 Sub2API 原生运维诊断
This commit is contained in:
@@ -871,6 +871,8 @@ function platformInfraHelpSummary(): unknown {
|
||||
usage: [
|
||||
"bun scripts/cli.ts platform-infra sub2api plan",
|
||||
"bun scripts/cli.ts platform-infra sub2api status [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra sub2api ops diagnosis [--platform <name>] [--group <id-or-name>] [--id <diagnosis-id>] [--json]",
|
||||
"bun scripts/cli.ts platform-infra sub2api ops channels [--platform <name>] [--channel <id-or-name>] [--window 7d|15d|30d] [--page-token <record-id>|--record <record-id>] [--json]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool validate",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool trace --request-id <requestId>",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { UniDeskConfig } from "../config";
|
||||
import type { RenderedCliResult } from "../output";
|
||||
import { defaultSub2ApiTargetId } from "../platform-infra/config";
|
||||
|
||||
import { projectSub2ApiOps } from "./projection";
|
||||
import { querySub2ApiOps } from "./query";
|
||||
import { renderSub2ApiOps } from "./render";
|
||||
import type { Sub2ApiOpsOptions } from "./types";
|
||||
|
||||
export async function runSub2ApiOpsCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
|
||||
const options = parseSub2ApiOpsOptions(args);
|
||||
const raw = await querySub2ApiOps(config, options);
|
||||
const projected = projectSub2ApiOps(raw, options);
|
||||
return options.json ? projected : renderSub2ApiOps(projected);
|
||||
}
|
||||
|
||||
export function parseSub2ApiOpsOptions(args: string[]): Sub2ApiOpsOptions {
|
||||
const [actionRaw, ...rest] = args;
|
||||
if (actionRaw !== "diagnosis" && actionRaw !== "channels") {
|
||||
throw new Error("sub2api ops usage: diagnosis|channels [--target <id>] [--platform <name>] [--json]");
|
||||
}
|
||||
let targetId = defaultSub2ApiTargetId();
|
||||
let json = false;
|
||||
let platform: string | null = null;
|
||||
let group: string | null = null;
|
||||
let diagnosisId: string | null = null;
|
||||
let timeRange = "1h" as Sub2ApiOpsOptions["timeRange"];
|
||||
let channel: string | null = null;
|
||||
let model: string | null = null;
|
||||
let window = "7d" as Sub2ApiOpsOptions["window"];
|
||||
let pageToken: string | null = null;
|
||||
let recordId: string | null = null;
|
||||
for (let index = 0; index < rest.length; index += 1) {
|
||||
const arg = rest[index]!;
|
||||
const readValue = (name: string): string => {
|
||||
const value = rest[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
||||
index += 1;
|
||||
return value.trim();
|
||||
};
|
||||
if (arg === "--json") json = true;
|
||||
else if (arg === "--target") targetId = readValue(arg);
|
||||
else if (arg === "--platform") platform = readValue(arg);
|
||||
else if (arg === "--group") group = readValue(arg);
|
||||
else if (arg === "--id") diagnosisId = readValue(arg);
|
||||
else if (arg === "--time-range") timeRange = parseTimeRange(readValue(arg));
|
||||
else if (arg === "--channel") channel = readValue(arg);
|
||||
else if (arg === "--model") model = readValue(arg);
|
||||
else if (arg === "--window") window = parseWindow(readValue(arg));
|
||||
else if (arg === "--page-token") pageToken = parseRecordId(readValue(arg), arg);
|
||||
else if (arg === "--record") recordId = parseRecordId(readValue(arg), arg);
|
||||
else throw new Error(`unknown sub2api ops option: ${arg}`);
|
||||
}
|
||||
if (actionRaw === "diagnosis" && (channel !== null || model !== null || window !== "7d" || pageToken !== null || recordId !== null)) throw new Error("ops diagnosis does not accept channel history options");
|
||||
if (actionRaw === "channels" && (group !== null || diagnosisId !== null || timeRange !== "1h")) throw new Error("ops channels does not accept --group, --id, or --time-range");
|
||||
if (actionRaw === "channels" && model !== null && channel === null) throw new Error("--model requires --channel");
|
||||
if (actionRaw === "channels" && (pageToken !== null || recordId !== null) && channel === null) throw new Error("--page-token and --record require --channel");
|
||||
if (pageToken !== null && recordId !== null) throw new Error("use only one of --page-token or --record");
|
||||
return { action: actionRaw, targetId, json, platform, group, diagnosisId, timeRange, channel, model, window, pageToken, recordId };
|
||||
}
|
||||
|
||||
function parseTimeRange(value: string): Sub2ApiOpsOptions["timeRange"] {
|
||||
if (value === "5m" || value === "30m" || value === "1h" || value === "6h" || value === "24h") return value;
|
||||
throw new Error("--time-range must be one of 5m, 30m, 1h, 6h, 24h");
|
||||
}
|
||||
|
||||
function parseWindow(value: string): Sub2ApiOpsOptions["window"] {
|
||||
if (value === "7d" || value === "15d" || value === "30d") return value;
|
||||
throw new Error("--window must be one of 7d, 15d, 30d");
|
||||
}
|
||||
|
||||
function parseRecordId(value: string, option: string): string {
|
||||
if (/^[1-9][0-9]*$/u.test(value)) return value;
|
||||
throw new Error(`${option} must be a positive native history record ID`);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import type { Sub2ApiOpsOptions } from "./types";
|
||||
|
||||
const upstreamVersion = "v0.1.155";
|
||||
const diagnosisRuleSource = "frontend/src/views/admin/ops/components/OpsDashboardHeader.vue";
|
||||
const diagnosisTextSource = "frontend/src/i18n/locales/zh/admin/ops.ts";
|
||||
const channelHistoryPageSize = 20;
|
||||
|
||||
export function projectSub2ApiOps(raw: Record<string, unknown>, options: Sub2ApiOpsOptions): Record<string, unknown> {
|
||||
const target = record(raw.target);
|
||||
if (raw.ok !== true) {
|
||||
return {
|
||||
ok: false,
|
||||
action: `platform-infra-sub2api-ops-${options.action}`,
|
||||
target,
|
||||
query: querySummary(options),
|
||||
source: sourceSummary(options.action, "unavailable"),
|
||||
error: stringValue(raw.error, "Sub2API native source unavailable"),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
const data = record(raw.data);
|
||||
return options.action === "diagnosis"
|
||||
? projectDiagnosis(target, data, options)
|
||||
: projectChannels(target, data, options);
|
||||
}
|
||||
|
||||
function projectDiagnosis(target: Record<string, unknown>, data: Record<string, unknown>, options: Sub2ApiOpsOptions): Record<string, unknown> {
|
||||
const groups = arrayRecords(data.groups).map((entry) => {
|
||||
const group = record(entry.group);
|
||||
const overview = record(entry.overview);
|
||||
const scope = group.id === null || group.id === undefined ? "all" : `g${String(group.id)}`;
|
||||
return {
|
||||
id: scope,
|
||||
groupId: group.id ?? null,
|
||||
groupName: stringValue(group.name, "all"),
|
||||
platform: stringValue(group.platform, "all"),
|
||||
window: {
|
||||
kind: "native-ops-time-range",
|
||||
value: options.timeRange,
|
||||
startAt: overview.start_time ?? null,
|
||||
endAt: overview.end_time ?? null,
|
||||
},
|
||||
sourceStatus: "available",
|
||||
diagnoses: diagnosisItems(scope, overview),
|
||||
metrics: compactOverview(overview),
|
||||
};
|
||||
});
|
||||
const selectedDiagnosis = options.diagnosisId === null
|
||||
? null
|
||||
: groups.flatMap((group) => arrayRecords(group.diagnoses)).find((item) => item.id === options.diagnosisId) ?? null;
|
||||
return {
|
||||
ok: options.diagnosisId === null || selectedDiagnosis !== null,
|
||||
action: "platform-infra-sub2api-ops-diagnosis",
|
||||
target,
|
||||
query: querySummary(options),
|
||||
source: sourceSummary("diagnosis", "available"),
|
||||
groups,
|
||||
selectedDiagnosis,
|
||||
evidence: options.diagnosisId === null ? null : projectEvidence(record(data.evidence)),
|
||||
error: options.diagnosisId !== null && selectedDiagnosis === null ? `diagnosis id not found: ${options.diagnosisId}` : null,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function diagnosisItems(scope: string, overview: Record<string, unknown>): Record<string, unknown>[] {
|
||||
const items: Record<string, unknown>[] = [];
|
||||
const qps = numberAt(record(overview.qps).current, 0);
|
||||
const errorRate = numberAt(overview.error_rate, 0);
|
||||
if (qps === 0 && errorRate === 0) {
|
||||
return [diagnosisItem(scope, "idle", "info", "system_activity", 0, "requests/s", "系统当前处于待机状态", "无活跃流量", null, overview)];
|
||||
}
|
||||
const systemMetrics = record(overview.system_metrics);
|
||||
if (systemMetrics.db_ok === false) items.push(diagnosisItem(scope, "db-down", "critical", "db_ok", false, "boolean", "数据库连接失败", "所有数据库操作将失败", "检查数据库服务状态、网络连接和连接配置", overview));
|
||||
if (systemMetrics.redis_ok === false) items.push(diagnosisItem(scope, "redis-down", "warning", "redis_ok", false, "boolean", "Redis连接失败", "缓存功能降级,性能可能下降", "检查Redis服务状态和网络连接", overview));
|
||||
const cpu = numberAt(systemMetrics.cpu_usage_percent, 0);
|
||||
if (cpu > 90) items.push(diagnosisItem(scope, "cpu-critical", "critical", "cpu_usage_percent", cpu, "%", `CPU使用率严重过高 (${cpu.toFixed(1)}%)`, "系统响应变慢,可能影响所有请求", "检查CPU密集型任务,考虑扩容或优化代码", overview));
|
||||
else if (cpu > 80) items.push(diagnosisItem(scope, "cpu-high", "warning", "cpu_usage_percent", cpu, "%", `CPU使用率偏高 (${cpu.toFixed(1)}%)`, "系统负载较高,需要关注", "监控CPU趋势,准备扩容方案", overview));
|
||||
const memory = numberAt(systemMetrics.memory_usage_percent, 0);
|
||||
if (memory > 90) items.push(diagnosisItem(scope, "memory-critical", "critical", "memory_usage_percent", memory, "%", `内存使用率严重过高 (${memory.toFixed(1)}%)`, "可能触发OOM,系统稳定性受威胁", "检查内存泄漏,考虑增加内存或优化内存使用", overview));
|
||||
else if (memory > 85) items.push(diagnosisItem(scope, "memory-high", "warning", "memory_usage_percent", memory, "%", `内存使用率偏高 (${memory.toFixed(1)}%)`, "内存压力较大,需要关注", "监控内存趋势,检查是否有内存泄漏", overview));
|
||||
const ttft = numberAt(record(overview.ttft).p99_ms, 0);
|
||||
if (ttft > 500) items.push(diagnosisItem(scope, "ttft-high", "warning", "ttft.p99_ms", ttft, "ms", `首 Token 时间偏高 (${ttft.toFixed(0)}ms)`, "用户感知时长增加", "优化请求处理流程,减少前置逻辑耗时", overview));
|
||||
const upstreamRate = numberAt(overview.upstream_error_rate, 0) * 100;
|
||||
if (upstreamRate > 5) items.push(diagnosisItem(scope, "upstream-critical", "critical", "upstream_error_rate", upstreamRate, "%", `上游错误率严重偏高 (${upstreamRate.toFixed(2)}%)`, "可能影响大量用户请求", "检查上游服务健康状态,启用降级策略", overview));
|
||||
else if (upstreamRate > 2) items.push(diagnosisItem(scope, "upstream-high", "warning", "upstream_error_rate", upstreamRate, "%", `上游错误率偏高 (${upstreamRate.toFixed(2)}%)`, "建议检查上游服务状态", "联系上游服务团队,准备降级方案", overview));
|
||||
const clientRate = errorRate * 100;
|
||||
if (clientRate > 3) items.push(diagnosisItem(scope, "error-high", "critical", "error_rate", clientRate, "%", `错误率过高 (${clientRate.toFixed(2)}%)`, "大量请求失败", "查看错误日志,定位错误根因,紧急修复", overview));
|
||||
else if (clientRate > 0.5) items.push(diagnosisItem(scope, "error-elevated", "warning", "error_rate", clientRate, "%", `错误率偏高 (${clientRate.toFixed(2)}%)`, "建议检查错误日志", "分析错误类型和分布,制定修复计划", overview));
|
||||
const sla = numberAt(overview.sla, 0) * 100;
|
||||
if (sla < 90) items.push(diagnosisItem(scope, "sla-critical", "critical", "sla", sla, "%", `SLA 严重低于目标 (${sla.toFixed(2)}%)`, "用户体验严重受损", "紧急排查错误原因,必要时采取限流保护", overview));
|
||||
else if (sla < 98) items.push(diagnosisItem(scope, "sla-low", "warning", "sla", sla, "%", `SLA 低于目标 (${sla.toFixed(2)}%)`, "需要关注服务质量", "分析SLA下降原因,优化系统性能", overview));
|
||||
const healthScore = finiteNumber(overview.health_score);
|
||||
if (healthScore !== null && healthScore < 60) items.push(diagnosisItem(scope, "health-critical", "critical", "health_score", healthScore, "score", `综合健康评分过低 (${healthScore})`, "多个指标可能同时异常,建议优先排查错误与资源使用情况", "全面检查系统状态,优先处理critical级别问题", overview));
|
||||
else if (healthScore !== null && healthScore < 90) items.push(diagnosisItem(scope, "health-low", "warning", "health_score", healthScore, "score", `综合健康评分偏低 (${healthScore})`, "可能存在轻度波动,建议关注 SLA 与错误率", "监控指标趋势,预防问题恶化", overview));
|
||||
if (items.length === 0) items.push(diagnosisItem(scope, "healthy", "info", "health", "normal", "state", "所有系统指标正常", "服务运行稳定", null, overview));
|
||||
return items;
|
||||
}
|
||||
|
||||
function diagnosisItem(scope: string, category: string, severity: string, metric: string, value: unknown, unit: string, message: string, impact: string, advice: string | null, overview: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
id: `${scope}:${category}`,
|
||||
category,
|
||||
severity,
|
||||
message,
|
||||
metric: { name: metric, value, unit },
|
||||
impact,
|
||||
advice,
|
||||
adviceKind: advice === null ? null : "native-frontend-advice",
|
||||
factRef: `${scope}.metrics`,
|
||||
inferenceStatus: "not-verified",
|
||||
};
|
||||
}
|
||||
|
||||
function compactOverview(overview: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
requestCount: overview.request_count_total ?? null,
|
||||
successCount: overview.success_count ?? null,
|
||||
errorCount: overview.error_count_total ?? null,
|
||||
errorRate: overview.error_rate ?? null,
|
||||
upstreamErrorRate: overview.upstream_error_rate ?? null,
|
||||
sla: overview.sla ?? null,
|
||||
healthScore: overview.health_score ?? null,
|
||||
qps: record(overview.qps).current ?? null,
|
||||
ttftP99Ms: record(overview.ttft).p99_ms ?? null,
|
||||
collectedAt: overview.end_time ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function projectEvidence(evidence: Record<string, unknown>): Record<string, unknown> | null {
|
||||
if (Object.keys(evidence).length === 0) return null;
|
||||
const records = arrayRecords(evidence.records).map((item) => ({
|
||||
requestId: item.request_id ?? null,
|
||||
createdAt: item.created_at ?? null,
|
||||
platform: item.platform ?? null,
|
||||
model: item.model ?? null,
|
||||
accountId: item.account_id ?? null,
|
||||
accountName: item.account_name ?? null,
|
||||
statusCode: item.status_code ?? item.upstream_status_code ?? null,
|
||||
durationMs: item.duration_ms ?? null,
|
||||
severity: item.severity ?? null,
|
||||
message: item.message ?? null,
|
||||
}));
|
||||
return { diagnosisId: evidence.diagnosisId ?? null, recordCount: records.length, records };
|
||||
}
|
||||
|
||||
function projectChannels(target: Record<string, unknown>, data: Record<string, unknown>, options: Sub2ApiOpsOptions): Record<string, unknown> {
|
||||
const channels = arrayRecords(data.channels).map((entry) => projectChannel(entry, options.window));
|
||||
const operational = channels.every((channel) => channel.status === "OPERATIONAL");
|
||||
const history = arrayRecords(data.history)
|
||||
.map((item) => ({
|
||||
id: item.id ?? null,
|
||||
model: item.model ?? null,
|
||||
status: item.status ?? null,
|
||||
latencyMs: item.latency_ms ?? null,
|
||||
pingLatencyMs: item.ping_latency_ms ?? null,
|
||||
message: item.message ?? null,
|
||||
checkedAt: item.checked_at ?? null,
|
||||
}))
|
||||
.sort((left, right) => String(left.checkedAt).localeCompare(String(right.checkedAt)));
|
||||
const historyProjection = options.channel === null ? null : paginateHistory(history, options);
|
||||
const historyError = historyProjection === null ? null : historyProjection.error;
|
||||
return {
|
||||
ok: historyError === null,
|
||||
action: "platform-infra-sub2api-ops-channels",
|
||||
target,
|
||||
query: querySummary(options),
|
||||
source: sourceSummary("channels", "available"),
|
||||
overallStatus: operational ? "OPERATIONAL" : "DEGRADED",
|
||||
channels,
|
||||
history: historyProjection,
|
||||
error: historyError,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function paginateHistory(history: Record<string, unknown>[], options: Sub2ApiOpsOptions): Record<string, unknown> {
|
||||
if (options.recordId !== null) {
|
||||
const selectedRecord = history.find((item) => String(item.id) === options.recordId) ?? null;
|
||||
return {
|
||||
order: "PAST_TO_NOW",
|
||||
pageSize: channelHistoryPageSize,
|
||||
sourceRecordCount: history.length,
|
||||
sourceCapReached: history.length === 1000,
|
||||
records: [],
|
||||
moreAvailable: false,
|
||||
nextPageToken: null,
|
||||
selectedRecord,
|
||||
error: selectedRecord === null ? `channel history record not found: ${options.recordId}` : null,
|
||||
};
|
||||
}
|
||||
let end = history.length;
|
||||
if (options.pageToken !== null) {
|
||||
const cursorIndex = history.findIndex((item) => String(item.id) === options.pageToken);
|
||||
if (cursorIndex < 0) {
|
||||
return {
|
||||
order: "PAST_TO_NOW",
|
||||
pageSize: channelHistoryPageSize,
|
||||
sourceRecordCount: history.length,
|
||||
sourceCapReached: history.length === 1000,
|
||||
records: [],
|
||||
moreAvailable: false,
|
||||
nextPageToken: null,
|
||||
selectedRecord: null,
|
||||
error: `channel history page token not found: ${options.pageToken}`,
|
||||
};
|
||||
}
|
||||
end = cursorIndex;
|
||||
}
|
||||
const start = Math.max(0, end - channelHistoryPageSize);
|
||||
const records = history.slice(start, end);
|
||||
return {
|
||||
order: "PAST_TO_NOW",
|
||||
pageSize: channelHistoryPageSize,
|
||||
sourceRecordCount: history.length,
|
||||
sourceCapReached: history.length === 1000,
|
||||
records,
|
||||
moreAvailable: start > 0,
|
||||
nextPageToken: start > 0 && records.length > 0 ? records[0]?.id ?? null : null,
|
||||
selectedRecord: null,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
function projectChannel(entry: Record<string, unknown>, window: "7d" | "15d" | "30d"): Record<string, unknown> {
|
||||
const summary = record(entry.summary);
|
||||
const detail = record(entry.detail);
|
||||
const models = arrayRecords(detail.models).map((model) => ({
|
||||
model: model.model ?? null,
|
||||
status: upperStatus(model.latest_status),
|
||||
latencyMs: model.latest_latency_ms ?? null,
|
||||
availability: availabilityFor(model, window),
|
||||
availabilityWindow: window,
|
||||
avgLatency7dMs: model.avg_latency_7d_ms ?? null,
|
||||
}));
|
||||
const timeline = arrayRecords(summary.timeline)
|
||||
.map((point) => ({
|
||||
status: upperStatus(point.status),
|
||||
latencyMs: point.latency_ms ?? null,
|
||||
pingLatencyMs: point.ping_latency_ms ?? null,
|
||||
checkedAt: point.checked_at ?? null,
|
||||
}))
|
||||
.sort((left, right) => String(left.checkedAt).localeCompare(String(right.checkedAt)));
|
||||
const status = [summary.primary_status, ...arrayRecords(summary.extra_models).map((item) => item.status)]
|
||||
.every((value) => String(value ?? "").toLowerCase() === "operational") ? "OPERATIONAL" : "DEGRADED";
|
||||
return {
|
||||
id: summary.id ?? detail.id ?? null,
|
||||
name: summary.name ?? detail.name ?? null,
|
||||
groupName: summary.group_name ?? detail.group_name ?? null,
|
||||
provider: summary.provider ?? detail.provider ?? null,
|
||||
status,
|
||||
primary: {
|
||||
model: summary.primary_model ?? null,
|
||||
status: upperStatus(summary.primary_status),
|
||||
latencyMs: summary.primary_latency_ms ?? null,
|
||||
pingLatencyMs: summary.primary_ping_latency_ms ?? null,
|
||||
availability: availabilityFor(models.find((item) => item.model === summary.primary_model) ?? {}, window) ?? summary.availability_7d ?? null,
|
||||
availabilityWindow: window,
|
||||
},
|
||||
models,
|
||||
recentRecordCount: timeline.length,
|
||||
collectedAt: timeline.length === 0 ? null : timeline[timeline.length - 1]?.checkedAt ?? null,
|
||||
refreshRemainingSeconds: null,
|
||||
refreshSourceStatus: "unsupported-by-native-response",
|
||||
timeline: { order: "PAST_TO_NOW", records: timeline },
|
||||
};
|
||||
}
|
||||
|
||||
function availabilityFor(model: Record<string, unknown>, window: "7d" | "15d" | "30d"): unknown {
|
||||
return model[`availability_${window}`] ?? model.availability ?? null;
|
||||
}
|
||||
|
||||
function sourceSummary(action: "diagnosis" | "channels", status: string): Record<string, unknown> {
|
||||
if (action === "diagnosis") {
|
||||
return {
|
||||
status,
|
||||
upstreamVersion,
|
||||
metricsEndpoint: "/api/v1/admin/ops/dashboard/overview",
|
||||
nativeDiagnosisEndpoint: { status: "unsupported", reason: "v0.1.155 has no diagnosis/advice response schema" },
|
||||
projectionKind: "native-frontend-projection",
|
||||
ruleSource: diagnosisRuleSource,
|
||||
textSource: diagnosisTextSource,
|
||||
evidenceBoundary: "metrics are native API facts; severity, impact and advice reproduce the official v0.1.155 frontend projection and are not server-returned root-cause proof",
|
||||
};
|
||||
}
|
||||
return {
|
||||
status,
|
||||
upstreamVersion,
|
||||
listEndpoint: "/api/v1/channel-monitors",
|
||||
detailEndpoint: "/api/v1/channel-monitors/:id/status",
|
||||
historyEndpoint: "/api/v1/admin/channel-monitors/:id/history",
|
||||
windows: ["7d", "15d", "30d"],
|
||||
overallStatusKind: "native-frontend-projection",
|
||||
refreshRemaining: { status: "unsupported", reason: "native response does not include a refresh countdown" },
|
||||
};
|
||||
}
|
||||
|
||||
function querySummary(options: Sub2ApiOpsOptions): Record<string, unknown> {
|
||||
return {
|
||||
target: options.targetId,
|
||||
platform: options.platform,
|
||||
group: options.group,
|
||||
diagnosisId: options.diagnosisId,
|
||||
timeRange: options.action === "diagnosis" ? options.timeRange : undefined,
|
||||
channel: options.channel,
|
||||
model: options.model,
|
||||
window: options.action === "channels" ? options.window : undefined,
|
||||
pageToken: options.pageToken,
|
||||
recordId: options.recordId,
|
||||
snapshot: true,
|
||||
foregroundRefresh: false,
|
||||
};
|
||||
}
|
||||
|
||||
function upperStatus(value: unknown): string {
|
||||
const text = String(value ?? "unknown").trim();
|
||||
return text.length === 0 ? "UNKNOWN" : text.toUpperCase();
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function arrayRecords(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.map(record) : [];
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback: string): string {
|
||||
return typeof value === "string" && value.length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function numberAt(value: unknown, fallback: number): number {
|
||||
return finiteNumber(value) ?? fallback;
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import type { UniDeskConfig } from "../config";
|
||||
import { parseJsonOutput } from "../platform-infra-ops-library";
|
||||
import { runSshCommandCapture } from "../ssh";
|
||||
import { readSub2ApiConfig } from "../platform-infra/config";
|
||||
import { resolveTarget } from "../platform-infra/manifest";
|
||||
|
||||
import type { Sub2ApiOpsOptions, Sub2ApiOpsTarget } from "./types";
|
||||
|
||||
export async function querySub2ApiOps(config: UniDeskConfig, options: Sub2ApiOpsOptions): Promise<Record<string, unknown>> {
|
||||
const target = resolveOpsTarget(options.targetId);
|
||||
const result = await runSshCommandCapture(config, target.route, ["sh"], remoteQueryScript(target, options));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
if (result.exitCode !== 0 || parsed === null) {
|
||||
const stderr = result.stderr.trim().slice(-1200);
|
||||
const stdout = result.stdout.trim().slice(-1200);
|
||||
return {
|
||||
ok: false,
|
||||
sourceStatus: "unavailable",
|
||||
error: stderr || stdout || "remote Sub2API ops query returned no JSON",
|
||||
remote: { exitCode: result.exitCode, stdoutTail: stdout, stderrTail: stderr },
|
||||
target,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
return { ...parsed, target, valuesPrinted: false };
|
||||
}
|
||||
|
||||
function resolveOpsTarget(targetId: string): Sub2ApiOpsTarget {
|
||||
const config = readSub2ApiConfig();
|
||||
const target = resolveTarget(config, targetId);
|
||||
return {
|
||||
id: target.id,
|
||||
route: target.route,
|
||||
namespace: target.namespace,
|
||||
runtimeMode: target.runtimeMode,
|
||||
hostDockerAppPort: target.hostDocker?.appPort ?? null,
|
||||
hostDockerEnvPath: target.hostDocker?.envPath ?? null,
|
||||
appSecretName: config.runtime.database.secretName,
|
||||
};
|
||||
}
|
||||
|
||||
function pyJson(value: unknown): string {
|
||||
return `json.loads(${JSON.stringify(JSON.stringify(value))})`;
|
||||
}
|
||||
|
||||
function remoteQueryScript(target: Sub2ApiOpsTarget, options: Sub2ApiOpsOptions): string {
|
||||
return `
|
||||
set -u
|
||||
python3 - <<'PY'
|
||||
import base64
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib.parse import urlencode
|
||||
|
||||
TARGET = ${pyJson(target)}
|
||||
OPTIONS = ${pyJson(options)}
|
||||
|
||||
def run(command, input_bytes=None):
|
||||
return subprocess.run(command, input=input_bytes, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
def tail_text(value, limit=800):
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode("utf-8", errors="replace")
|
||||
return str(value)[-limit:]
|
||||
|
||||
def kube_json(args, label):
|
||||
proc = run(["kubectl", *args, "-o", "json"])
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"{label} failed: {tail_text(proc.stderr)}")
|
||||
return json.loads(proc.stdout.decode("utf-8"))
|
||||
|
||||
def read_host_env():
|
||||
path = TARGET.get("hostDockerEnvPath")
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
lines = handle.read().splitlines()
|
||||
except PermissionError:
|
||||
proc = run(["sudo", "-n", "cat", path])
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError("read host env failed: " + tail_text(proc.stderr))
|
||||
lines = proc.stdout.decode("utf-8", errors="replace").splitlines()
|
||||
values = {}
|
||||
for line in lines:
|
||||
text = line.strip()
|
||||
if not text or text.startswith("#") or "=" not in text:
|
||||
continue
|
||||
key, value = text.split("=", 1)
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
||||
value = value[1:-1]
|
||||
values[key.strip()] = value
|
||||
return values
|
||||
|
||||
def select_app_pod():
|
||||
if TARGET["runtimeMode"] == "host-docker":
|
||||
return "sub2api-app"
|
||||
pods = kube_json(["-n", TARGET["namespace"], "get", "pods", "-l", "app.kubernetes.io/name=sub2api"], "sub2api pods").get("items") or []
|
||||
running = [item for item in pods if (item.get("status") or {}).get("phase") == "Running"]
|
||||
if not running:
|
||||
raise RuntimeError("sub2api app pod not found")
|
||||
return running[0]["metadata"]["name"]
|
||||
|
||||
APP_POD = select_app_pod()
|
||||
|
||||
def config_value(key):
|
||||
if TARGET["runtimeMode"] == "host-docker":
|
||||
return read_host_env().get(key)
|
||||
data = kube_json(["-n", TARGET["namespace"], "get", "configmap", "sub2api-config"], "configmap/sub2api-config").get("data") or {}
|
||||
return data.get(key)
|
||||
|
||||
def secret_value(key):
|
||||
if TARGET["runtimeMode"] == "host-docker":
|
||||
return read_host_env().get(key)
|
||||
data = kube_json(["-n", TARGET["namespace"], "get", "secret", TARGET["appSecretName"]], "sub2api secret").get("data") or {}
|
||||
return base64.b64decode(data[key]).decode("utf-8") if key in data else None
|
||||
|
||||
def parse_curl(proc):
|
||||
output = proc.stdout.decode("utf-8", errors="replace")
|
||||
marker = "\\n__HTTP_CODE__:"
|
||||
position = output.rfind(marker)
|
||||
if position < 0:
|
||||
return {"ok": False, "httpStatus": 0, "json": None, "body": output, "stderr": tail_text(proc.stderr)}
|
||||
body = output[:position]
|
||||
try:
|
||||
status = int(output[position + len(marker):].strip()[-3:])
|
||||
except ValueError:
|
||||
status = 0
|
||||
try:
|
||||
parsed = json.loads(body) if body.strip() else None
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
return {"ok": proc.returncode == 0 and 200 <= status < 300, "httpStatus": status, "json": parsed, "body": body, "stderr": tail_text(proc.stderr)}
|
||||
|
||||
def curl_api(method, path, bearer=None, payload=None):
|
||||
body = b"" if payload is None else json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
script = r'''
|
||||
set -eu
|
||||
method="$1"
|
||||
url="$2"
|
||||
token="\${3:-}"
|
||||
tmp="$(mktemp)"
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
cat > "$tmp"
|
||||
if [ -n "$token" ] && [ "$method" = GET ] && [ ! -s "$tmp" ]; then
|
||||
curl -sS --max-time 20 -w '\n__HTTP_CODE__:%{http_code}' -X "$method" -H "Authorization: Bearer $token" "$url"
|
||||
elif [ -n "$token" ]; then
|
||||
curl -sS --max-time 20 -w '\n__HTTP_CODE__:%{http_code}' -X "$method" -H 'Content-Type: application/json' -H "Authorization: Bearer $token" --data-binary @"$tmp" "$url"
|
||||
elif [ "$method" = GET ] && [ ! -s "$tmp" ]; then
|
||||
curl -sS --max-time 20 -w '\n__HTTP_CODE__:%{http_code}' -X "$method" "$url"
|
||||
else
|
||||
curl -sS --max-time 20 -w '\n__HTTP_CODE__:%{http_code}' -X "$method" -H 'Content-Type: application/json' --data-binary @"$tmp" "$url"
|
||||
fi
|
||||
'''
|
||||
if TARGET["runtimeMode"] == "host-docker":
|
||||
command = ["sh", "-c", script, "sh", method, f"http://127.0.0.1:{TARGET['hostDockerAppPort']}{path}", bearer or ""]
|
||||
else:
|
||||
command = ["kubectl", "-n", TARGET["namespace"], "exec", "-i", APP_POD, "--", "sh", "-c", script, "sh", method, f"http://127.0.0.1:8080{path}", bearer or ""]
|
||||
return parse_curl(run(command, body))
|
||||
|
||||
def data_of(response, label):
|
||||
parsed = response.get("json")
|
||||
code = parsed.get("code") if isinstance(parsed, dict) else None
|
||||
if response.get("ok") is not True or (code is not None and code != 0):
|
||||
message = parsed.get("message") if isinstance(parsed, dict) else tail_text(response.get("body"), 400)
|
||||
raise RuntimeError(f"{label} failed: http={response.get('httpStatus')} message={message}")
|
||||
return parsed.get("data") if isinstance(parsed, dict) and "data" in parsed else parsed
|
||||
|
||||
def items_of(data):
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if isinstance(data, dict) and isinstance(data.get("items"), list):
|
||||
return data["items"]
|
||||
return []
|
||||
|
||||
def get(token, path, params=None, label="GET"):
|
||||
suffix = "?" + urlencode(params) if params else ""
|
||||
return data_of(curl_api("GET", path + suffix, bearer=token), label)
|
||||
|
||||
def login():
|
||||
email = config_value("ADMIN_EMAIL")
|
||||
password = secret_value("ADMIN_PASSWORD")
|
||||
if not email or not password:
|
||||
raise RuntimeError("Sub2API admin credentials are unavailable")
|
||||
data = data_of(curl_api("POST", "/api/v1/auth/login", payload={"email": email, "password": password}), "admin login")
|
||||
token = data.get("access_token") or data.get("token") if isinstance(data, dict) else None
|
||||
if not token:
|
||||
raise RuntimeError("admin login response has no token")
|
||||
return token
|
||||
|
||||
def find_named(items, selector, label):
|
||||
if selector is None:
|
||||
return items
|
||||
text = str(selector).strip().lower()
|
||||
numeric = int(text) if text.isdigit() else None
|
||||
matches = [item for item in items if (numeric is not None and item.get("id") == numeric) or str(item.get("id", "")).strip().lower() == text or str(item.get("name", "")).strip().lower() == text]
|
||||
if not matches:
|
||||
available = [f"{item.get('id')}:{item.get('name')}" for item in items]
|
||||
raise RuntimeError(f"unknown {label} {selector}; available={available}")
|
||||
return matches
|
||||
|
||||
def diagnosis(token):
|
||||
group_params = {"platform": OPTIONS["platform"]} if OPTIONS.get("platform") else None
|
||||
groups = items_of(get(token, "/api/v1/admin/groups/all", group_params, "list groups"))
|
||||
groups = find_named(groups, OPTIONS.get("group"), "group")
|
||||
snapshots = []
|
||||
selected = groups if groups else [{"id": None, "name": "all", "platform": OPTIONS.get("platform") or "all"}]
|
||||
for group in selected:
|
||||
params = {"time_range": OPTIONS["timeRange"]}
|
||||
platform = OPTIONS.get("platform") or group.get("platform")
|
||||
if platform and platform != "all":
|
||||
params["platform"] = platform
|
||||
if group.get("id"):
|
||||
params["group_id"] = group["id"]
|
||||
overview = get(token, "/api/v1/admin/ops/dashboard/overview", params, "ops dashboard overview")
|
||||
snapshots.append({"group": {"id": group.get("id"), "name": group.get("name"), "platform": platform or "all"}, "overview": overview})
|
||||
evidence = None
|
||||
diagnosis_id = OPTIONS.get("diagnosisId")
|
||||
if diagnosis_id:
|
||||
scope, _, category = diagnosis_id.partition(":")
|
||||
group = next((item["group"] for item in snapshots if ("g" + str(item["group"].get("id"))) == scope or scope == "all"), None)
|
||||
if group is None:
|
||||
raise RuntimeError(f"diagnosis id scope not found: {diagnosis_id}")
|
||||
params = {"time_range": OPTIONS["timeRange"], "page": 1, "page_size": 20}
|
||||
if group.get("id"):
|
||||
params["group_id"] = group["id"]
|
||||
if group.get("platform") and group.get("platform") != "all":
|
||||
params["platform"] = group["platform"]
|
||||
if category.startswith("upstream-"):
|
||||
endpoint = "/api/v1/admin/ops/upstream-errors"
|
||||
elif category.startswith("error-") or category.startswith("sla-"):
|
||||
endpoint = "/api/v1/admin/ops/request-errors"
|
||||
else:
|
||||
endpoint = "/api/v1/admin/ops/requests"
|
||||
params["kind"] = "all"
|
||||
params["sort"] = "duration_desc"
|
||||
evidence = {"diagnosisId": diagnosis_id, "records": items_of(get(token, endpoint, params, "diagnosis evidence"))}
|
||||
return {"groups": snapshots, "evidence": evidence}
|
||||
|
||||
def channels(token):
|
||||
monitors = items_of(get(token, "/api/v1/channel-monitors", None, "list channel monitors"))
|
||||
if OPTIONS.get("platform"):
|
||||
monitors = [item for item in monitors if str(item.get("provider", "")).lower() == OPTIONS["platform"].lower()]
|
||||
monitors = find_named(monitors, OPTIONS.get("channel"), "channel")
|
||||
output = []
|
||||
for monitor in monitors:
|
||||
detail = get(token, f"/api/v1/channel-monitors/{monitor['id']}/status", None, "channel monitor status")
|
||||
output.append({"summary": monitor, "detail": detail})
|
||||
history = None
|
||||
if OPTIONS.get("channel") and output:
|
||||
monitor_id = output[0]["summary"]["id"]
|
||||
params = {"limit": 1000}
|
||||
if OPTIONS.get("model"):
|
||||
params["model"] = OPTIONS["model"]
|
||||
history = items_of(get(token, f"/api/v1/admin/channel-monitors/{monitor_id}/history", params, "channel monitor history"))
|
||||
return {"channels": output, "history": history}
|
||||
|
||||
try:
|
||||
token = login()
|
||||
data = diagnosis(token) if OPTIONS["action"] == "diagnosis" else channels(token)
|
||||
print(json.dumps({"ok": True, "sourceStatus": "available", "data": data}, ensure_ascii=False, separators=(",", ":")))
|
||||
except Exception as error:
|
||||
print(json.dumps({"ok": False, "sourceStatus": "unavailable", "error": str(error), "data": None}, ensure_ascii=False, separators=(",", ":")))
|
||||
sys.exit(1)
|
||||
PY
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { RenderedCliResult } from "../output";
|
||||
|
||||
export function renderSub2ApiOps(result: Record<string, unknown>): RenderedCliResult {
|
||||
const action = String(result.action ?? "platform-infra-sub2api-ops");
|
||||
const lines = action.endsWith("diagnosis") ? renderDiagnosis(result) : renderChannels(result);
|
||||
return {
|
||||
ok: result.ok !== false,
|
||||
command: action.replaceAll("-", " "),
|
||||
renderedText: lines.join("\n"),
|
||||
contentType: "text/plain",
|
||||
};
|
||||
}
|
||||
|
||||
function renderDiagnosis(result: Record<string, unknown>): string[] {
|
||||
const source = record(result.source);
|
||||
const groups = arrayRecords(result.groups);
|
||||
const rows = groups.flatMap((group) => arrayRecords(group.diagnoses).map((item) => {
|
||||
const metric = record(item.metric);
|
||||
return [
|
||||
stringValue(item.id),
|
||||
stringValue(item.severity).toUpperCase(),
|
||||
`${stringValue(metric.value)} ${stringValue(metric.unit)}`.trim(),
|
||||
stringValue(item.message),
|
||||
`${stringValue(group.groupName)} / ${stringValue(group.platform)}`,
|
||||
];
|
||||
}));
|
||||
const lines = [
|
||||
"PLATFORM-INFRA SUB2API OPS DIAGNOSIS",
|
||||
...table(["ID", "SEVERITY", "VALUE", "DIAGNOSIS", "SCOPE"], rows),
|
||||
"",
|
||||
"SOURCE",
|
||||
...table(["FIELD", "VALUE"], [
|
||||
["status", stringValue(source.status)],
|
||||
["upstreamVersion", stringValue(source.upstreamVersion)],
|
||||
["metrics", stringValue(source.metricsEndpoint)],
|
||||
["advice", stringValue(source.projectionKind)],
|
||||
["nativeDiagnosisEndpoint", stringValue(record(source.nativeDiagnosisEndpoint).status)],
|
||||
]),
|
||||
];
|
||||
const selected = record(result.selectedDiagnosis);
|
||||
if (Object.keys(selected).length > 0) {
|
||||
const metric = record(selected.metric);
|
||||
lines.push(
|
||||
"",
|
||||
"DETAIL",
|
||||
...table(["FIELD", "VALUE"], [
|
||||
["id", stringValue(selected.id)],
|
||||
["category", stringValue(selected.category)],
|
||||
["severity", stringValue(selected.severity)],
|
||||
["metric", `${stringValue(metric.name)}=${stringValue(metric.value)} ${stringValue(metric.unit)}`.trim()],
|
||||
["impact", stringValue(selected.impact)],
|
||||
["nativeAdvice", stringValue(selected.advice, "-")],
|
||||
["inference", stringValue(selected.inferenceStatus)],
|
||||
]),
|
||||
);
|
||||
const evidence = record(result.evidence);
|
||||
const evidenceRows = arrayRecords(evidence.records).map((item) => [
|
||||
stringValue(item.requestId, "-"),
|
||||
stringValue(item.createdAt, "-"),
|
||||
stringValue(item.accountName, stringValue(item.accountId, "-")),
|
||||
stringValue(item.statusCode, "-"),
|
||||
stringValue(item.message, "-").replace(/\s+/gu, " ").slice(0, 80),
|
||||
]);
|
||||
lines.push("", "RELATED EVIDENCE", ...(evidenceRows.length === 0 ? ["-"] : table(["REQUEST_ID", "AT", "ACCOUNT", "STATUS", "SUMMARY"], evidenceRows)));
|
||||
}
|
||||
lines.push(
|
||||
"",
|
||||
"NEXT",
|
||||
" detail: bun scripts/cli.ts platform-infra sub2api ops diagnosis --id <diagnosis-id>",
|
||||
" json: bun scripts/cli.ts platform-infra sub2api ops diagnosis --json",
|
||||
"",
|
||||
`Evidence boundary: ${stringValue(source.evidenceBoundary)}`,
|
||||
"Disclosure: one-shot read-only snapshot; no foreground refresh, Secret, token, or full log output.",
|
||||
);
|
||||
if (result.error) lines.push(`Error: ${stringValue(result.error)}`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function renderChannels(result: Record<string, unknown>): string[] {
|
||||
const source = record(result.source);
|
||||
const channels = arrayRecords(result.channels);
|
||||
const rows = channels.map((channel) => {
|
||||
const primary = record(channel.primary);
|
||||
return [
|
||||
stringValue(channel.id),
|
||||
stringValue(channel.name),
|
||||
stringValue(channel.provider),
|
||||
stringValue(primary.model),
|
||||
stringValue(channel.status),
|
||||
stringValue(primary.latencyMs, "-"),
|
||||
stringValue(primary.pingLatencyMs, "-"),
|
||||
formatAvailability(primary.availability),
|
||||
stringValue(channel.recentRecordCount, "0"),
|
||||
stringValue(channel.collectedAt, "-"),
|
||||
];
|
||||
});
|
||||
const lines = [
|
||||
"PLATFORM-INFRA SUB2API OPS CHANNELS",
|
||||
...table(["ID", "CHANNEL", "PLATFORM", "MODEL", "STATUS", "LATENCY_MS", "PING_MS", "AVAIL", "RECORDS", "COLLECTED_AT"], rows),
|
||||
"",
|
||||
"SUMMARY",
|
||||
...table(["FIELD", "VALUE"], [
|
||||
["overallStatus", stringValue(result.overallStatus)],
|
||||
["window", stringValue(record(result.query).window)],
|
||||
["sourceStatus", stringValue(source.status)],
|
||||
["refreshRemaining", stringValue(record(source.refreshRemaining).status)],
|
||||
]),
|
||||
];
|
||||
const history = record(result.history);
|
||||
const historyRows = arrayRecords(history.records).map((item) => [
|
||||
stringValue(item.id),
|
||||
stringValue(item.model),
|
||||
stringValue(item.status),
|
||||
stringValue(item.latencyMs, "-"),
|
||||
stringValue(item.pingLatencyMs, "-"),
|
||||
stringValue(item.checkedAt, "-"),
|
||||
]);
|
||||
if (historyRows.length > 0) {
|
||||
lines.push(
|
||||
"",
|
||||
"PAST → NOW",
|
||||
...table(["ID", "MODEL", "STATUS", "LATENCY_MS", "PING_MS", "CHECKED_AT"], historyRows),
|
||||
`pageSize=${stringValue(history.pageSize)} moreAvailable=${stringValue(history.moreAvailable)} nextPageToken=${stringValue(history.nextPageToken)}`,
|
||||
);
|
||||
}
|
||||
const selectedRecord = record(history.selectedRecord);
|
||||
if (Object.keys(selectedRecord).length > 0) {
|
||||
lines.push(
|
||||
"",
|
||||
"RECORD",
|
||||
...table(["FIELD", "VALUE"], [
|
||||
["id", stringValue(selectedRecord.id)],
|
||||
["model", stringValue(selectedRecord.model)],
|
||||
["status", stringValue(selectedRecord.status)],
|
||||
["latencyMs", stringValue(selectedRecord.latencyMs)],
|
||||
["pingLatencyMs", stringValue(selectedRecord.pingLatencyMs)],
|
||||
["checkedAt", stringValue(selectedRecord.checkedAt)],
|
||||
["message", stringValue(selectedRecord.message)],
|
||||
]),
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
"",
|
||||
"NEXT",
|
||||
" channel: bun scripts/cli.ts platform-infra sub2api ops channels --channel <id-or-name>",
|
||||
" window: bun scripts/cli.ts platform-infra sub2api ops channels --window 7d|15d|30d",
|
||||
" older page: bun scripts/cli.ts platform-infra sub2api ops channels --channel <id-or-name> --page-token <record-id>",
|
||||
" record: bun scripts/cli.ts platform-infra sub2api ops channels --channel <id-or-name> --record <record-id>",
|
||||
" json: bun scripts/cli.ts platform-infra sub2api ops channels --json",
|
||||
"",
|
||||
"Disclosure: one-shot read-only snapshot; native response has no refresh countdown, and the CLI does not simulate one.",
|
||||
);
|
||||
if (result.error) lines.push(`Error: ${stringValue(result.error)}`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function formatAvailability(value: unknown): string {
|
||||
return typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(2)}%` : stringValue(value, "-");
|
||||
}
|
||||
|
||||
function table(headers: string[], rows: string[][]): string[] {
|
||||
if (rows.length === 0) return ["-"];
|
||||
const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => visibleLength(row[index] ?? ""))));
|
||||
const format = (row: string[]): string => row.map((cell, index) => `${cell}${" ".repeat(Math.max(0, widths[index]! - visibleLength(cell)))}`).join(" ").trimEnd();
|
||||
return [format(headers), format(widths.map((width) => "-".repeat(width))), ...rows.map(format)];
|
||||
}
|
||||
|
||||
function visibleLength(value: string): number {
|
||||
return [...value].length;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function arrayRecords(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.map(record) : [];
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback = "-"): string {
|
||||
if (typeof value === "string") return value.length > 0 ? value : fallback;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export type Sub2ApiOpsAction = "diagnosis" | "channels";
|
||||
|
||||
export interface Sub2ApiOpsOptions {
|
||||
action: Sub2ApiOpsAction;
|
||||
targetId: string;
|
||||
json: boolean;
|
||||
platform: string | null;
|
||||
group: string | null;
|
||||
diagnosisId: string | null;
|
||||
timeRange: "5m" | "30m" | "1h" | "6h" | "24h";
|
||||
channel: string | null;
|
||||
model: string | null;
|
||||
window: "7d" | "15d" | "30d";
|
||||
pageToken: string | null;
|
||||
recordId: string | null;
|
||||
}
|
||||
|
||||
export interface Sub2ApiOpsTarget {
|
||||
id: string;
|
||||
route: string;
|
||||
namespace: string;
|
||||
runtimeMode: "k3s" | "host-docker";
|
||||
hostDockerAppPort: number | null;
|
||||
hostDockerEnvPath: string | null;
|
||||
appSecretName: string;
|
||||
}
|
||||
@@ -417,6 +417,8 @@ export function platformInfraHelp(): unknown {
|
||||
"bun scripts/cli.ts platform-infra sub2api apply [--target G14|D601] --confirm",
|
||||
"bun scripts/cli.ts platform-infra sub2api status [--target G14|D601] [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra sub2api validate [--target G14|D601] [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra sub2api ops diagnosis [--target PK01] [--platform <name>] [--group <id-or-name>] [--time-range 5m|30m|1h|6h|24h] [--id <diagnosis-id>] [--json]",
|
||||
"bun scripts/cli.ts platform-infra sub2api ops channels [--target PK01] [--platform <name>] [--channel <id-or-name>] [--model <name>] [--window 7d|15d|30d] [--page-token <record-id>|--record <record-id>] [--json]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool plan",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool validate",
|
||||
|
||||
@@ -108,6 +108,10 @@ export async function runPlatformInfraCommand(config: UniDeskConfig, args: strin
|
||||
return options.full || options.raw ? result : renderSub2ApiStatus(result);
|
||||
}
|
||||
if (action === "validate") return await validate(config, parseDisclosureOptions(args.slice(2)));
|
||||
if (action === "ops") {
|
||||
const { runSub2ApiOpsCommand } = await import("../platform-infra-sub2api-ops");
|
||||
return await runSub2ApiOpsCommand(config, args.slice(2));
|
||||
}
|
||||
if (action === "codex-pool") {
|
||||
const { runCodexPoolCommand } = await import("../platform-infra-sub2api-codex");
|
||||
return await runCodexPoolCommand(config, args.slice(2));
|
||||
|
||||
Reference in New Issue
Block a user