feat(admin): 面板「存储」卡片接真数据(含 PG 增强状态)+ 契约 v0.60
- 现状: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:
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 };
|
||||
}
|
||||
Reference in new issue
Block a user