- 视图切换:最近一轮(既有 DOM 逐字节未变,7/7 SSR 对照 IDENTICAL)| 时间轴(新) - 时间轴:竖轴=时间(默认最新在上)、每回合一个可折叠块、块内复用 LaneTracks 泳道、 块间不画任何连线(不臆造因果);before 游标分页;truncated ⇒ 显式「更早的轮次已过保留期」 - 降级诚实:只拿到最近一轮(老后端)⇒ 明确提示「当前只支持最近一轮」 - 4 个变异自检(排序方向/块间插线/truncated 静默/游标方向);老视图等价性实测
233 lines
12 KiB
TypeScript
233 lines
12 KiB
TypeScript
import type {
|
||
AdminLogLine,
|
||
AdminLogQuery,
|
||
AdminNotification,
|
||
AdminNotificationQuery,
|
||
AdminObservabilityMetrics,
|
||
AdminStorageStatus,
|
||
AdminTraceSpan,
|
||
AuditRecord,
|
||
AdminConfigFacts,
|
||
AdminTidyDryRunResult,
|
||
BackthinkItem,
|
||
FlowGraph,
|
||
SystemBackupResp,
|
||
SystemRestoreResp,
|
||
SystemRevokeKeysResp,
|
||
SystemRotateJwtResp,
|
||
} from "../types";
|
||
import { limitQuery, request } from "./core";
|
||
|
||
/** GET /api/v1/admin/observability/metrics — gateway request metrics. */
|
||
export function adminObservabilityMetrics(): Promise<AdminObservabilityMetrics> {
|
||
return request<AdminObservabilityMetrics>("/admin/observability/metrics");
|
||
}
|
||
/** GET /api/v1/admin/observability/trace?limit=N — most recent traced spans. */
|
||
export function adminObservabilityTrace(limit?: number): Promise<AdminTraceSpan[]> {
|
||
return request<AdminTraceSpan[]>(`/admin/observability/trace${limitQuery(limit)}`);
|
||
}
|
||
/** GET /api/v1/admin/observability/audit?limit=N — most recent audit entries. */
|
||
export function adminObservabilityAudit(limit?: number): Promise<AuditRecord[]> {
|
||
return request<AuditRecord[]>(`/admin/observability/audit${limitQuery(limit)}`);
|
||
}
|
||
/** GET /api/v1/admin/observability/logs?level=&limit= — the gateway's structured
|
||
* log tail, newest first (owner/admin only). An omitted `level` means “all”; the
|
||
* backend defaults `limit` to 200 and caps it at 1000. */
|
||
export function adminObservabilityLogs(
|
||
params: AdminLogQuery = {}
|
||
): Promise<AdminLogLine[]> {
|
||
const qs = new URLSearchParams();
|
||
if (params.level) qs.set("level", params.level);
|
||
if (params.limit !== undefined) qs.set("limit", String(params.limit));
|
||
const q = qs.toString();
|
||
return request<AdminLogLine[]>(`/admin/observability/logs${q ? `?${q}` : ""}`);
|
||
}
|
||
|
||
/** GET /api/v1/admin/backthink — persisted background-think records (newest first). */
|
||
export function adminBackthink(limit?: number): Promise<BackthinkItem[]> {
|
||
return request<BackthinkItem[]>(`/admin/backthink${limitQuery(limit)}`);
|
||
}
|
||
|
||
/** 一条**已落盘轮次**的摘要(契约 v0.73 §4.11 的 `turns[]`)。面板用它渲染历史轮
|
||
* 选择器:把 `turnId` 回带给 `adminFlowGraph` 即可精确取回那一轮。 */
|
||
export interface AdminFlowTurn {
|
||
turnId: string;
|
||
createdAt: string;
|
||
}
|
||
|
||
/** 链路图端点的后端响应(契约 v0.71 起;**v0.73 新增 `turnId`/`source`/`turns`**)。
|
||
*
|
||
* `graph` **恒在**(`null` 而不是缺席):`null` = 该轮**确实**没有图,客户端必须据此
|
||
* 渲染空态,绝不允许补画任何节点或假图。
|
||
*
|
||
* `source` 告诉客户端这张图是哪来的:`live` = 内存里最近一轮(实时图);`persisted` =
|
||
* 从本地库读回的历史轮;`none` = 没有图。`turns` 是该会话已落盘的轮次摘要(最新在前),
|
||
* 没有时为 `[]`(后端保证非 null 数组)。 */
|
||
export interface AdminFlowGraphResponse {
|
||
sessionId: string;
|
||
turnId: string;
|
||
source: "live" | "persisted" | "none";
|
||
graph: FlowGraph | null;
|
||
/** 最近一轮**每一路**的图(契约 v0.77,纯增量;真并行 `chat.parallel` 时不止一条)。
|
||
* `graph` 只是其中最新的一条(向后兼容),`lanes` 才是全貌 —— 面板据此把两路并列
|
||
* 画出来,而不是让后起的那一路把前一路顶掉。没有图时是 `[]`(后端保证非 null)。 */
|
||
lanes: AdminFlowLane[];
|
||
turns: AdminFlowTurn[];
|
||
}
|
||
|
||
/** `lanes[]` 里的一条路:`turnId` 是该路的权威回合标识(design/15 §2.5.9),
|
||
* `graph` 与响应的 `graph` / 帧的 data 逐字同形。 */
|
||
export interface AdminFlowLane {
|
||
turnId: string;
|
||
graph: FlowGraph | null;
|
||
}
|
||
|
||
/** GET /api/v1/admin/flow/graph?sessionId=&turnId= — **链路图快照 + 多轮历史回放**(契约
|
||
* v0.71 起,v0.73 增量;只读、不审计,owner/admin 档)。
|
||
*
|
||
* 为什么需要它:`graph_update` 是**纯实时**帧、从不重放。v0.71 补了「打开页面拉最近一轮
|
||
* 快照」;v0.73 把图**每轮落盘**,于是可以:
|
||
* - **省略 `turnId`** ⇒ 最近一轮(后端**先查内存、miss 才落盘** ⇒ 最新一轮永远最新);
|
||
* - **给了 `turnId`** ⇒ 精确取该会话已落盘的某一轮(历史回放)。
|
||
*
|
||
* `turns`(同一响应里)就是给选择器用的历史轮列表。⚠️ 白名单之外的会话(群聊/陌生人)
|
||
* 从不建图、也从不落盘 ⇒ 它们**永远**是空态,这不是缺陷。 */
|
||
export function adminFlowGraph(sessionId: string, turnId?: string): Promise<AdminFlowGraphResponse> {
|
||
const qs = new URLSearchParams();
|
||
if (sessionId) qs.set("sessionId", sessionId);
|
||
if (turnId) qs.set("turnId", turnId);
|
||
const q = qs.toString();
|
||
return request<AdminFlowGraphResponse>(`/admin/flow/graph${q ? `?${q}` : ""}`);
|
||
}
|
||
|
||
/** 时间轴里的**一个回合**(`design/19 §7`;形状取自设计规格 —— W1 的出口)。 */
|
||
export interface AdminFlowTimelineRound {
|
||
/** 该回合的权威标识。🔴 **一律用后端给的值**:面板既不自己拼 `{会话}-{序号}`,
|
||
* 也不从 `graph.id` 反解(契约 v0.79 起 `graph.id` 含 `#` 且单路也有后缀)。 */
|
||
turnId: string;
|
||
/** 会话轮次号。**可能缺席 / `null`**(旧行、或后端尚未落这一列)⇒ 面板显示
|
||
* 「轮次未知」,**绝不用 0 冒充**。 */
|
||
round?: number | null;
|
||
/** 该回合的开始时间(RFC3339)。它是**时间轴排序与 `before` 游标的唯一依据**。 */
|
||
startedAt: string;
|
||
/** 收尾时间。缺席 / `null` = 该回合**未收尾**(不许读成「已完成」)。 */
|
||
endedAt?: string | null;
|
||
/** 该回合**每一路**的图(真并行时不止一条;`graph` 为 `null` = 那一路没有留下图)。 */
|
||
lanes: AdminFlowLane[];
|
||
}
|
||
|
||
/** 链路**时间轴**端点的响应(`design/19 §7.1`;W1 新增的只读出口)。 */
|
||
export interface AdminFlowTimelineResponse {
|
||
/** 解析后的会话(回显)。取数只认调用时给的 `sessionId`,不依赖这一键。 */
|
||
sessionId?: string;
|
||
/** 保留期内的回合。后端按**时间正序**(最旧在前)返回;面板渲染前自己归一排序。 */
|
||
rounds: AdminFlowTimelineRound[];
|
||
/** `flow_graphs` 的保留期(小时)。`≤0` = 后端配置为不回收(见 `FLOW_GRAPH_RETENTION`)。 */
|
||
retentionHours: number;
|
||
/** **更早的回合已过保留期**(`design/19 §7.1`)⇒ 面板必须**如实标注**,不许静默少块。 */
|
||
truncated: boolean;
|
||
}
|
||
|
||
/** GET /api/v1/admin/flow/timeline?sessionId=&limit=&before= — **多轮时间轴**(`design/19 §7`;
|
||
* 只读、不审计,owner/admin 档)。它是老出口 `adminFlowGraph`(最近一轮 + 历史轮列表)的
|
||
* **增量兄弟**:一次给**多轮**,每轮连同它**每一路**的图,供面板画「所有消息按时间顺序串
|
||
* 起来」的时间轴。
|
||
*
|
||
* 分页:`limit` 缺省 50(后端硬上限 200);`before` 是**开始时间游标** —— 传「当前手上
|
||
* 最旧那一回合的 `startedAt`(原样回传后端给的值)」即继续取更早的。⚠️ 本函数**不解释**
|
||
* `before`(不做字符串/毫秒换算),只原样转交:面板的游标就是后端给的时间字符串。
|
||
*
|
||
* 老后端(该出口未接线)⇒ `404`;调用方按「当前只支持最近一轮」诚实降级,别把 404 读成
|
||
* 「这段时间没有回合」。 */
|
||
export function adminFlowTimeline(
|
||
sessionId: string,
|
||
opts: { limit?: number; before?: string } = {}
|
||
): Promise<AdminFlowTimelineResponse> {
|
||
const qs = new URLSearchParams();
|
||
if (sessionId) qs.set("sessionId", sessionId);
|
||
if (opts.limit && opts.limit > 0) qs.set("limit", String(opts.limit));
|
||
if (opts.before) qs.set("before", opts.before);
|
||
const q = qs.toString();
|
||
return request<AdminFlowTimelineResponse>(`/admin/flow/timeline${q ? `?${q}` : ""}`);
|
||
}
|
||
|
||
/** GET /api/v1/admin/config-facts — **只读**配置事实面(契约 v0.68 §4.10(a)):
|
||
* 记忆整理 7 键 + 安静时段两键的**进程生效值** + 声明默认值,外加安静时段窗口的
|
||
* 解释(跨午夜 / 已关闭)。owner/admin 档;**不含任何凭据值**(后端有哨兵测试钉死),
|
||
* 网关也**不读 `.env`** —— 值就是运行实例真正在用的数。 */
|
||
export function adminConfigFacts(): Promise<AdminConfigFacts> {
|
||
return request<AdminConfigFacts>("/admin/config-facts");
|
||
}
|
||
|
||
/** POST /api/v1/admin/memories/tidy/dry-run — 记忆整理「试算」(契约 v0.75 §4.12)。
|
||
*
|
||
* 它跑**同一套**整理规则(与低峰定时轮共用代码路径)并把候选直接回在响应里,
|
||
* 但**一行都不写**(不写候选表/运行表、不改任何记忆)—— 因此可以在任何时刻、
|
||
* 在整理器第一次开跑之前,先看清「它会建议合并哪些」。
|
||
*
|
||
* ⚠️ 它不是看板数据源:试算是一次真实的规则计算(扫描 + 两两召回 + 包含判定),
|
||
* **只在用户点击时调用,不要轮询**。
|
||
* ⚠️ 失败走 `error.code`(tidy_unavailable / tidy_disabled / tidy_recall_unavailable /
|
||
* tidy_timeout),**不会**回 `candidates: []` —— 空数组只表示「算过了,没有建议」。 */
|
||
export function adminMemoryTidyDryRun(): Promise<AdminTidyDryRunResult> {
|
||
return request<AdminTidyDryRunResult>("/admin/memories/tidy/dry-run", { method: "POST" });
|
||
}
|
||
|
||
/** GET /api/v1/admin/notifications?type=&before=&limit= — the unified notification
|
||
* feed (design/10 §3 #16: audit + channel + backthink + system aggregated).
|
||
* `type` omitted = all types, `before` = createdAt(ms) cursor for older pages,
|
||
* `limit` defaults to 50 (backend caps at 200). Returned newest first. */
|
||
export function adminListNotifications(
|
||
params: AdminNotificationQuery = {}
|
||
): Promise<AdminNotification[]> {
|
||
const qs = new URLSearchParams();
|
||
if (params.type) qs.set("type", params.type);
|
||
if (params.before !== undefined) qs.set("before", String(params.before));
|
||
if (params.limit !== undefined) qs.set("limit", String(params.limit));
|
||
const q = qs.toString();
|
||
return request<AdminNotification[]>(`/admin/notifications${q ? `?${q}` : ""}`);
|
||
}
|
||
|
||
/* ----------------------------------------------------------------------------
|
||
* System management admin writes (owner-only, audited). Reference:
|
||
* internal/services/gateway/admin_system.go + Docs/api-client.md §系统设置契约.
|
||
* All four are implemented (not 501) — they return real data.
|
||
* -------------------------------------------------------------------------- */
|
||
|
||
/** GET /api/v1/admin/system/storage — the **read-only** storage snapshot behind the
|
||
* dashboard's 存储 card (v0.60): SQLite file/WAL/page stats, the documents table's
|
||
* count+bytes, and whether the optional pgvector enhancement is configured and
|
||
* actually live. The `vector` block never carries credentials (no DSN, no password).
|
||
* owner-only; the backend answers `500 storage_unavailable` rather than a fake
|
||
* all-zero snapshot when the live DB handle is not wired. */
|
||
export function adminSystemStorage(): Promise<AdminStorageStatus> {
|
||
return request<AdminStorageStatus>("/admin/system/storage");
|
||
}
|
||
|
||
/** POST /api/v1/admin/system/revoke-keys — revokes every registered API key. */
|
||
export function adminSystemRevokeKeys(): Promise<SystemRevokeKeysResp> {
|
||
return request<SystemRevokeKeysResp>("/admin/system/revoke-keys", { method: "POST" });
|
||
}
|
||
/** POST /api/v1/admin/system/rotate-jwt — swaps the JWT signing secret (≥32 chars).
|
||
* The secret is provided by the owner and never echoed back; all existing tokens
|
||
* become invalid, so the client must re-login afterward. */
|
||
export function adminSystemRotateJWT(newSecret: string): Promise<SystemRotateJwtResp> {
|
||
return request<SystemRotateJwtResp>("/admin/system/rotate-jwt", {
|
||
method: "POST",
|
||
body: JSON.stringify({ newSecret }),
|
||
});
|
||
}
|
||
/** POST /api/v1/admin/system/backup — snapshots the SQLite DB (VACUUM INTO, incl. WAL). */
|
||
export function adminSystemBackup(): Promise<SystemBackupResp> {
|
||
return request<SystemBackupResp>("/admin/system/backup", { method: "POST" });
|
||
}
|
||
/** POST /api/v1/admin/system/restore — restores a data-dir backup; path must live
|
||
* inside the data directory (path-traversal guard). The backend pre-backups the
|
||
* current DB and reports that the swap needs a restart. */
|
||
export function adminSystemRestore(path: string): Promise<SystemRestoreResp> {
|
||
return request<SystemRestoreResp>("/admin/system/restore", {
|
||
method: "POST",
|
||
body: JSON.stringify({ path }),
|
||
});
|
||
}
|