From 8594a08c7d3d630d368479354452571b1245fdfd Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 11:10:29 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20Sub2API=20?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E8=BF=90=E7=BB=B4=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/unidesk-sub2api/SKILL.md | 5 + scripts/src/help.ts | 2 + .../src/platform-infra-sub2api-ops/index.ts | 75 ++++ .../platform-infra-sub2api-ops/projection.ts | 337 ++++++++++++++++++ .../src/platform-infra-sub2api-ops/query.ts | 267 ++++++++++++++ .../src/platform-infra-sub2api-ops/render.ts | 184 ++++++++++ .../src/platform-infra-sub2api-ops/types.ts | 26 ++ scripts/src/platform-infra/entry.ts | 2 + scripts/src/platform-infra/options.ts | 4 + 9 files changed, 902 insertions(+) create mode 100644 scripts/src/platform-infra-sub2api-ops/index.ts create mode 100644 scripts/src/platform-infra-sub2api-ops/projection.ts create mode 100644 scripts/src/platform-infra-sub2api-ops/query.ts create mode 100644 scripts/src/platform-infra-sub2api-ops/render.ts create mode 100644 scripts/src/platform-infra-sub2api-ops/types.ts diff --git a/.agents/skills/unidesk-sub2api/SKILL.md b/.agents/skills/unidesk-sub2api/SKILL.md index 3b596eb1..6c2413ac 100644 --- a/.agents/skills/unidesk-sub2api/SKILL.md +++ b/.agents/skills/unidesk-sub2api/SKILL.md @@ -13,6 +13,8 @@ UniDesk 通过 `platform-infra sub2api` 运维 YAML 选中的 Sub2API target; bun scripts/cli.ts platform-infra sub2api report bun scripts/cli.ts platform-infra sub2api status --target PK01 bun scripts/cli.ts platform-infra sub2api validate --target PK01 +bun scripts/cli.ts platform-infra sub2api ops diagnosis --target PK01 +bun scripts/cli.ts platform-infra sub2api ops channels --target PK01 --window 7d bun scripts/cli.ts platform-infra sub2api plan --target PK01 bun scripts/cli.ts platform-infra sub2api image-prepull --target PK01 --confirm ``` @@ -24,6 +26,9 @@ bun scripts/cli.ts platform-infra sub2api image-prepull --target PK01 --confirm - YAML 是 source of truth;target、public exposure、Secret sourceRef、Codex pool 和 sentinel 配置都从 YAML 进入 CLI。 - Secret 只输出对象名、key 名、presence、fingerprint 或 redacted prefix;禁止打印完整 token/key。 - 默认 active target 以 YAML `defaults.targetId` 和 target role 为准;当前 `api.pikapython.com` 对应 PK01 host-Docker target。 +- 原生运维快照统一走 `ops diagnosis|channels`:默认输出紧凑文本,显式 `--json` 输出结构化结果;命令只读且不在前台模拟自动刷新。 +- `ops diagnosis` 的指标来自原生 admin/ops API;v0.1.155 没有独立 diagnosis/advice endpoint,CLI 必须把官方前端投影建议与 API 已验证事实分开呈现。 +- `ops channels` 使用原生渠道监控的 `7d`、`15d`、`30d` 窗口,支持平台、渠道和模型下钻;渠道历史固定分页并用 `--page-token ` 向更早记录翻页,`--record ` 精确下钻,不提供手工 `--limit`;原生响应未提供刷新倒计时时必须显示 unsupported。 - Codex pool、统一 API key、master `~/.codex` 配置、FRP/Caddy 暴露、账号增删都必须走本技能的受控 CLI。 - `api.pikapython.com` 异常先按 YAML target 区分 PK01 local edge/app、k3s FRP target 和账号池调度;用 `status`、`validate`、受控 apply/sync 以及最小 `/v1/responses` smoke 做分层恢复。完整步骤见 [references/troubleshooting.md](references/troubleshooting.md) 和 [references/public-exposure.md](references/public-exposure.md)。 diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 4b5d658e..d7af69c8 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -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 ] [--group ] [--id ] [--json]", + "bun scripts/cli.ts platform-infra sub2api ops channels [--platform ] [--channel ] [--window 7d|15d|30d] [--page-token |--record ] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool validate", "bun scripts/cli.ts platform-infra sub2api codex-pool trace --request-id ", "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status", diff --git a/scripts/src/platform-infra-sub2api-ops/index.ts b/scripts/src/platform-infra-sub2api-ops/index.ts new file mode 100644 index 00000000..3658604b --- /dev/null +++ b/scripts/src/platform-infra-sub2api-ops/index.ts @@ -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 | 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 ] [--platform ] [--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`); +} diff --git a/scripts/src/platform-infra-sub2api-ops/projection.ts b/scripts/src/platform-infra-sub2api-ops/projection.ts new file mode 100644 index 00000000..c1c22fed --- /dev/null +++ b/scripts/src/platform-infra-sub2api-ops/projection.ts @@ -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, options: Sub2ApiOpsOptions): Record { + 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, data: Record, options: Sub2ApiOpsOptions): Record { + 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): Record[] { + const items: Record[] = []; + 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): Record { + 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): Record { + 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): Record | 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, data: Record, options: Sub2ApiOpsOptions): Record { + 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[], options: Sub2ApiOpsOptions): Record { + 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, window: "7d" | "15d" | "30d"): Record { + 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, window: "7d" | "15d" | "30d"): unknown { + return model[`availability_${window}`] ?? model.availability ?? null; +} + +function sourceSummary(action: "diagnosis" | "channels", status: string): Record { + 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 { + 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 { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function arrayRecords(value: unknown): Record[] { + 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; +} diff --git a/scripts/src/platform-infra-sub2api-ops/query.ts b/scripts/src/platform-infra-sub2api-ops/query.ts new file mode 100644 index 00000000..a71e12ef --- /dev/null +++ b/scripts/src/platform-infra-sub2api-ops/query.ts @@ -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> { + 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 +`; +} diff --git a/scripts/src/platform-infra-sub2api-ops/render.ts b/scripts/src/platform-infra-sub2api-ops/render.ts new file mode 100644 index 00000000..bf583153 --- /dev/null +++ b/scripts/src/platform-infra-sub2api-ops/render.ts @@ -0,0 +1,184 @@ +import type { RenderedCliResult } from "../output"; + +export function renderSub2ApiOps(result: Record): 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[] { + 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 ", + " 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[] { + 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 ", + " 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 --page-token ", + " record: bun scripts/cli.ts platform-infra sub2api ops channels --channel --record ", + " 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 { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function arrayRecords(value: unknown): Record[] { + 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; +} diff --git a/scripts/src/platform-infra-sub2api-ops/types.ts b/scripts/src/platform-infra-sub2api-ops/types.ts new file mode 100644 index 00000000..1bd792cb --- /dev/null +++ b/scripts/src/platform-infra-sub2api-ops/types.ts @@ -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; +} diff --git a/scripts/src/platform-infra/entry.ts b/scripts/src/platform-infra/entry.ts index d9116132..f674afdd 100644 --- a/scripts/src/platform-infra/entry.ts +++ b/scripts/src/platform-infra/entry.ts @@ -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 ] [--group ] [--time-range 5m|30m|1h|6h|24h] [--id ] [--json]", + "bun scripts/cli.ts platform-infra sub2api ops channels [--target PK01] [--platform ] [--channel ] [--model ] [--window 7d|15d|30d] [--page-token |--record ] [--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", diff --git a/scripts/src/platform-infra/options.ts b/scripts/src/platform-infra/options.ts index 24482b49..c4c049ce 100644 --- a/scripts/src/platform-infra/options.ts +++ b/scripts/src/platform-infra/options.ts @@ -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));