feat(admin): 面板「存储」卡片接真数据(含 PG 增强状态)+ 契约 v0.60
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 现状:overviewMetrics.ts:188 的「存储」卡是写死的 value:"SQLite",后端也没有对应读端点(/admin/system/* 只有 revoke-keys/rotate-jwt/backup/restore 四个动作端点)。

- 新增只读端点 GET /api/v1/admin/system/storage(ownerAuth):main{kind,path,fileBytes,walBytes,shmBytes,pageSize,pageCount,freelistPages,freeBytes} + documents{count,bytes} + vector{kind,enabled,ready};只读(SELECT/PRAGMA + os.Stat)、复用活连接不开新池、**绝不含凭据**(只回 kind,不回 host/DSN)、失败一律 500 storage_unavailable(不回全 0 假快照;表缺失也 500 而不是静默 0)。

- vector 真源 = 组合根 app.vectorState()(Configured = DB_PG_URL 非空;Live = 向量索引类型断言成功),零新探测 ⇒ 面板现在能区分「未启用(未配置 DB_PG_URL)/ 启用(pgvector·ready)/ 已配置但未就绪(连接失败,检索降级)」,正好解答主人对「面板只写 SQLite」的困惑。

- 前端:卡片显示真实体积 + WAL/页数/空闲页 + documents 件数与占用 + PG 那一行;取数走与已接线卡片同一条加载/错误态(失败显示 '—' + 端点报错,不静默显示 0)。

- 契约 v0.60(项目红线):api-client(版本段 + 端点表 + 存储状态契约小节 + changelog)与 api-reference(版本号 + 端点表 + 详情小节)同步;字段名三方(代码/Docs/前端)逐字对齐。

- 主会话独立验收:隔离 worktree(ST1 的 14 文件,排除 B1 在途的 chat_documents.go/documents_pdf_test.go)build/vet 绿、全树 59 包 0 FAIL、存储端点 7 用例 PASS、双平台交叉编译绿。

- ⚠️ 该切片自首一起事故:为通过 cmd/cyrene 类型检查跑了 go build ./cmd/cyrene/(单 main 包)⇒ 覆盖了仓库根 cyrene.exe(49,675,264 B / 19:32:19,脏树未 strip);上一个权威产物仍在 cyrene.exe~(39,507,968 B / 19:25:13)。运行中的 PID 477964 未受影响;主会话将在整树干净后重跑 ctrl.bat build 恢复权威产物。
This commit is contained in:
AskaEth committed 2026-10-04 19:44:53 +08:00
1 parent 137b3e9eaf
commit c4e09afd38
14 files changed
+751 -13

No files matched your search

@@ -13,6 +13,10 @@ export interface MiniMetricSpec {
label: string;
value: string;
hint: string;
/** 可选的补充详情行:逐行渲染在 `hint` 下方。**不传时卡片与既有形状
* 逐字等价**(其它卡片因此一字未改);存储卡用它把 PG 增强状态单独
* 摆一行,而不是挤进 `hint`。 */
extra?: string[];
state: MetricState;
/** Admin module this card drills down to; absent = not a link. */
section?: AdminSectionId;
@@ -25,7 +29,7 @@ const STATE_BADGE: Record<MetricState, { label: string; tone: TagTone }> = {
placeholder: { label: "占位", tone: "warn" },
};
export function MiniMetric({ icon, label, value, hint, state, section }: MiniMetricSpec): ReactNode {
export function MiniMetric({ icon, label, value, hint, extra, state, section }: MiniMetricSpec): ReactNode {
const badge = STATE_BADGE[state];
const body = (
<>
@@ -36,6 +40,11 @@ export function MiniMetric({ icon, label, value, hint, state, section }: MiniMet
</div>
<div className="mt-1.5 text-lg font-semibold leading-tight">{value}</div>
<div className="mt-0.5 break-words text-xs text-[var(--mu-text-lo)]">{hint}</div>
{extra?.map((line) => (
<div key={line} className="mt-0.5 break-words text-xs text-[var(--mu-text-lo)]">
{line}
</div>
))}
</>
);
const base =
@@ -10,6 +10,8 @@ import type {
AdminLlmConfig,
AdminObservabilityMetrics,
AdminRagResponse,
AdminStorageStatus,
AdminStorageVector,
AdminUserRecord,
InstalledPlugin,
} from "../../../lib/types";
@@ -21,6 +23,10 @@ import type { DashboardExtras, Loaded } from "./useDashboardExtras";
* 8 rounds before the agent must answer. Shown as a fact, not as a metric. */
const TOOL_LOOP_CAP = 8;
/** 字节单位阶梯(1 KiB = 1024 B)。 */
const BYTE_UNITS = ["B", "KiB", "MiB", "GiB", "TiB"] as const;
const BYTE_STEP = 1024;
export function buildOverviewMetrics(extras: DashboardExtras): MiniMetricSpec[] {
return [
llmMetric(extras.llm),
@@ -30,7 +36,7 @@ export function buildOverviewMetrics(extras: DashboardExtras): MiniMetricSpec[]
userMetric(extras.users),
gatewayMetric(extras.metrics),
agentCallMetric(extras.metrics),
storageMetric(),
storageMetric(extras.storage),
];
}
@@ -179,20 +185,51 @@ function agentCallMetric(m: Loaded<AdminObservabilityMetrics>): MiniMetricSpec {
};
}
/** Storage has no read-only endpoint in `lib/api.ts` (backup/restore are writes
* that would touch the filesystem), so this card states the configured default
* and names the missing endpoint instead of showing a made-up figure. */
function storageMetric(): MiniMetricSpec {
/** 存储卡(v0.60):取 `GET /admin/system/storage` 的真实快照 —— 主库体积
* (`value`)、WAL / 页数 / 空闲页、`documents` 件数与占用,以及 PG/pgvector
* 增强到底有没有生效(主人 2.4 的困惑点:只写「SQLite」会让人以为 PG 没接上)。
*
* 取数与其它已接线卡片同一条路:数据没到就 `pending(...)`(显示端点错误,
* **绝不**回退成 0 或假装成功)。 */
function storageMetric(storage: Loaded<AdminStorageStatus>): MiniMetricSpec {
if (storage.data == null) {
return pending("storage", "存储", "SQLite 主库 · WAL · documents · PG/pgvector 增强", storage, "system");
}
const { main, documents, vector } = storage.data;
return {
icon: "storage",
label: "存储",
value: "SQLite",
hint: "默认引擎 · PG/pgvector 可选增强;缺只读查询端点(未在 lib/api.ts 暴露)",
state: "placeholder",
value: `SQLite ${formatBytes(main.fileBytes)}`,
hint: `WAL ${formatBytes(main.walBytes)} · ${main.pageCount} 页(空闲 ${main.freelistPages} 页 / ${formatBytes(main.freeBytes)})`,
extra: [
`documents ${documents.count} 件 · ${formatBytes(documents.bytes)}`,
`PG 增强 ${vectorLabel(vector)}`,
],
state: "ok",
section: "system",
};
}
/** PG 增强一行:未配置 / 已就绪 / 已配置但未就绪(降级)三者必须可区分 ——
* 绝不把「配了」写成「生效了」。 */
function vectorLabel(vector: AdminStorageVector): string {
if (vector.kind === "none" || !vector.enabled) return "未启用(未配置 DB_PG_URL)";
if (vector.ready) return `启用(${vector.kind} · ready)`;
return `已配置但未就绪(${vector.kind} 连接失败,检索已降级)`;
}
/** 字节数 → 「12.3 MiB」这类人类可读量级;0 如实显示 `0 B`。 */
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
let value = bytes;
let unit = 0;
while (value >= BYTE_STEP && unit < BYTE_UNITS.length - 1) {
value /= BYTE_STEP;
unit += 1;
}
return `${value.toFixed(unit === 0 || value >= 100 ? 0 : 1)} ${BYTE_UNITS[unit]}`;
}
/** ISO → local "MM-DD HH:mm"; absent or unparseable values render as an em dash. */
function formatTime(iso?: string): string {
if (!iso) return "—";
@@ -5,6 +5,7 @@ import {
adminListRag,
adminListUsers,
adminObservabilityMetrics,
adminSystemStorage,
} from "../../../lib/api";
import { useAsyncData } from "../../../lib/useAsyncData";
import type {
@@ -12,6 +13,7 @@ import type {
AdminLlmConfig,
AdminObservabilityMetrics,
AdminRagResponse,
AdminStorageStatus,
AdminUserRecord,
InstalledPlugin,
} from "../../../lib/types";
@@ -32,6 +34,7 @@ export interface DashboardExtras {
automations: Loaded<AdminAutomationRecord[]>;
rag: Loaded<AdminRagResponse>;
users: Loaded<AdminUserRecord[]>;
storage: Loaded<AdminStorageStatus>;
}
/** The secondary dashboard sources, all from endpoints that already exist in
@@ -44,11 +47,15 @@ export function useDashboardExtras(): DashboardExtras {
const automations = useAsyncData(adminListAutomations, []);
const rag = useAsyncData(adminListRag, []);
const users = useAsyncData(adminListUsers, []);
// 存储快照(v0.60):只读端点,取数的加载/错误态与其它卡片同一条路。
const storage = useAsyncData(adminSystemStorage, []);
useAutoReload(llm.reload, 30000);
useAutoReload(metrics.reload, 15000);
useAutoReload(installed.reload, 30000);
useAutoReload(automations.reload, 30000);
useAutoReload(rag.reload, 30000);
useAutoReload(users.reload, 30000);
return { llm, metrics, installed, automations, rag, users };
// 库体积/页数变化很慢,比其它卡片再放慢一档(仍是同一个 useAutoReload)。
useAutoReload(storage.reload, 60000);
return { llm, metrics, installed, automations, rag, users, storage };
}