Skip to content

Commit 24d8bbb

Browse files
lidge-junChang-Jin-Leeclaude
committed
feat(gui): read-only Storage diagnostics page (Phase 1 of #42) (#173)
Adds src/storage/scanner.ts: walks CODEX_HOME and buckets sessions/, archived_sessions/, state_*.sqlite, logs_*.sqlite, attachments/, deletion_manifests/ into a StorageReport. DB row counts via short-timeout readonly open, degrading to null on lock/corruption. GET /api/storage endpoint. gui/src/pages/Storage.tsx: total/file-count cards, per-bucket table, largest-files disclosure. i18n keys in all four locales. 9+2 tests including read-only invariant. No deletion logic (Phase 1 only). Squash-merged from Chang-Jin-Lee:feat/storage-diagnostics-page (2041b03). Co-Authored-By: Chang-Jin-Lee <ckdwls525@gmail.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 42076ee commit 24d8bbb

12 files changed

Lines changed: 869 additions & 3 deletions

File tree

gui/src/App.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,21 @@ import Subagents from "./pages/Subagents";
77
import Logs from "./pages/Logs";
88
import Debug from "./pages/Debug";
99
import Usage from "./pages/Usage";
10+
import Storage from "./pages/Storage";
1011
import CodexAuth from "./pages/CodexAuth";
1112
import ApiKeys from "./pages/ApiKeys";
1213
import ClaudeCode from "./pages/ClaudeCode";
13-
import { IconGrid, IconServer, IconBoxes, IconShuffle, IconBot, IconList, IconTerminal, IconActivity, IconKey, IconGithub, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconSparkle, IconX } from "./icons";
14+
import { IconGrid, IconServer, IconBoxes, IconShuffle, IconBot, IconList, IconTerminal, IconActivity, IconHardDrive, IconKey, IconGithub, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconSparkle, IconX } from "./icons";
1415
import { useI18n, useT, LOCALES, type Locale, type TKey } from "./i18n";
1516
import { Select } from "./ui";
1617
import { installApiAuthFetch } from "./api";
1718

1819
installApiAuthFetch();
1920

20-
type Page = "dashboard" | "providers" | "models" | "combos" | "subagents" | "logs" | "debug" | "usage" | "codex-auth" | "api" | "claude";
21+
type Page = "dashboard" | "providers" | "models" | "combos" | "subagents" | "logs" | "debug" | "usage" | "storage" | "codex-auth" | "api" | "claude";
2122
type Theme = "light" | "dark" | "system";
2223

23-
const VALID_PAGES = new Set<Page>(["dashboard", "providers", "models", "combos", "subagents", "logs", "debug", "usage", "codex-auth", "api", "claude"]);
24+
const VALID_PAGES = new Set<Page>(["dashboard", "providers", "models", "combos", "subagents", "logs", "debug", "usage", "storage", "codex-auth", "api", "claude"]);
2425

2526
function readPageFromHash(): Page {
2627
const raw = location.hash.replace(/^#\/?/, "");
@@ -45,6 +46,7 @@ const NAV: { id: Page; tkey: TKey; Icon: typeof IconGrid }[] = [
4546
{ id: "logs", tkey: "nav.logs", Icon: IconList },
4647
{ id: "debug", tkey: "nav.debug", Icon: IconTerminal },
4748
{ id: "usage", tkey: "nav.usage", Icon: IconActivity },
49+
{ id: "storage", tkey: "nav.storage", Icon: IconHardDrive },
4850
{ id: "codex-auth", tkey: "nav.codexAuth", Icon: IconKey },
4951
{ id: "api", tkey: "nav.api", Icon: IconGlobe },
5052
{ id: "claude", tkey: "nav.claude", Icon: IconSparkle },
@@ -273,6 +275,7 @@ export default function App() {
273275
{page === "logs" && <Logs apiBase={API_BASE} />}
274276
{page === "debug" && <Debug apiBase={API_BASE} />}
275277
{page === "usage" && <Usage apiBase={API_BASE} />}
278+
{page === "storage" && <Storage apiBase={API_BASE} />}
276279
{page === "codex-auth" && <CodexAuth apiBase={API_BASE} />}
277280
{page === "api" && <ApiKeys apiBase={API_BASE} />}
278281
{page === "claude" && <ClaudeCode apiBase={API_BASE} />}

gui/src/format-bytes.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { Locale } from "./i18n/shared";
2+
3+
/** Human-readable byte size (1.5 MB, 320 KB). Unit symbols are locale-invariant like model ids. */
4+
export function formatBytes(bytes: number, locale: Locale): string {
5+
if (bytes < 1024) return `${bytes} B`;
6+
const units = ["KB", "MB", "GB", "TB"];
7+
let value = bytes;
8+
let unit = -1;
9+
do {
10+
value /= 1024;
11+
unit++;
12+
} while (value >= 1024 && unit < units.length - 1);
13+
return `${value.toLocaleString(locale, { maximumFractionDigits: 1 })} ${units[unit]}`;
14+
}

gui/src/i18n/de.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,33 @@ export const de = {
591591
"usage.dayFri": "Fr",
592592
"usage.heatmap.tooltipTokens": "{tokens} Tokens",
593593
"usage.heatmap.tooltipRequests": "{requests} Anfragen",
594+
595+
"nav.storage": "Speicher",
596+
"storage.title": "Speicher",
597+
"storage.subtitle": "Schreibgeschützte Übersicht, wohin der CODEX_HOME-Speicher geht. Nichts auf dieser Seite löscht etwas.",
598+
"storage.loading": "Speicher wird gescannt…",
599+
"storage.empty": "CODEX_HOME ist leer oder fehlt — nichts zu berichten.",
600+
"storage.error": "Speicher-Scan fehlgeschlagen. Prüfe, ob CODEX_HOME auf ein gültiges Verzeichnis zeigt.",
601+
"storage.refresh": "Neu scannen",
602+
"storage.card.total": "Gesamtgröße",
603+
"storage.card.files": "Dateien",
604+
"storage.card.home": "CODEX_HOME",
605+
"storage.section.buckets": "Bereiche",
606+
"storage.section.largest": "Größte Dateien",
607+
"storage.col.bucket": "Bereich",
608+
"storage.col.size": "Größe",
609+
"storage.col.files": "Dateien",
610+
"storage.col.oldest": "Älteste",
611+
"storage.col.newest": "Neueste",
612+
"storage.col.rows": "DB-Zeilen",
613+
"storage.rows.unknown": "unbekannt (gesperrt)",
614+
"storage.bucket.sessions": "Aktive Sitzungen",
615+
"storage.bucket.archived_sessions": "Archivierte Sitzungen",
616+
"storage.bucket.logs_db": "Log-Datenbank",
617+
"storage.bucket.state_db": "Status-Datenbank",
618+
"storage.bucket.attachments": "Anhänge",
619+
"storage.bucket.deletion_manifests": "Lösch-Manifeste",
620+
"storage.bucket.other": "Sonstiges",
594621
"modal.back": "Zurück",
595622
"modal.badge.oauth": "OAuth",
596623
"modal.customProvider": "Benutzerdefinierter Anbieter",

gui/src/i18n/en.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,33 @@ export const en = {
418418
"usage.heatmap.tooltipTokens": "{tokens} tokens",
419419
"usage.heatmap.tooltipRequests": "{requests} requests",
420420

421+
"nav.storage": "Storage",
422+
"storage.title": "Storage",
423+
"storage.subtitle": "Read-only view of where your CODEX_HOME disk goes. Nothing on this page deletes anything.",
424+
"storage.loading": "Scanning storage…",
425+
"storage.empty": "CODEX_HOME is empty or missing — nothing to report.",
426+
"storage.error": "Storage scan failed. Check that CODEX_HOME points at a valid directory.",
427+
"storage.refresh": "Rescan",
428+
"storage.card.total": "Total size",
429+
"storage.card.files": "Files",
430+
"storage.card.home": "CODEX_HOME",
431+
"storage.section.buckets": "Buckets",
432+
"storage.section.largest": "Largest files",
433+
"storage.col.bucket": "Bucket",
434+
"storage.col.size": "Size",
435+
"storage.col.files": "Files",
436+
"storage.col.oldest": "Oldest",
437+
"storage.col.newest": "Newest",
438+
"storage.col.rows": "DB rows",
439+
"storage.rows.unknown": "unknown (locked)",
440+
"storage.bucket.sessions": "Active sessions",
441+
"storage.bucket.archived_sessions": "Archived sessions",
442+
"storage.bucket.logs_db": "Logs database",
443+
"storage.bucket.state_db": "State database",
444+
"storage.bucket.attachments": "Attachments",
445+
"storage.bucket.deletion_manifests": "Deletion manifests",
446+
"storage.bucket.other": "Other",
447+
421448
// add-provider modal
422449
"modal.addNamed": "Add: {label}",
423450
"modal.add": "Add provider",

gui/src/i18n/ko.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,33 @@ export const ko: Record<TKey, string> = {
611611
"usage.dayFri": "금",
612612
"usage.heatmap.tooltipTokens": "{tokens} 토큰",
613613
"usage.heatmap.tooltipRequests": "{requests} 요청",
614+
615+
"nav.storage": "저장소",
616+
"storage.title": "저장소",
617+
"storage.subtitle": "CODEX_HOME 디스크 사용처를 읽기 전용으로 보여줍니다. 이 페이지에서는 아무것도 삭제되지 않습니다.",
618+
"storage.loading": "저장소 스캔 중…",
619+
"storage.empty": "CODEX_HOME이 비어 있거나 없습니다 — 표시할 내용이 없습니다.",
620+
"storage.error": "저장소 스캔에 실패했습니다. CODEX_HOME이 올바른 디렉터리를 가리키는지 확인하세요.",
621+
"storage.refresh": "다시 스캔",
622+
"storage.card.total": "전체 크기",
623+
"storage.card.files": "파일 수",
624+
"storage.card.home": "CODEX_HOME",
625+
"storage.section.buckets": "버킷",
626+
"storage.section.largest": "가장 큰 파일",
627+
"storage.col.bucket": "버킷",
628+
"storage.col.size": "크기",
629+
"storage.col.files": "파일",
630+
"storage.col.oldest": "가장 오래됨",
631+
"storage.col.newest": "가장 최근",
632+
"storage.col.rows": "DB 행 수",
633+
"storage.rows.unknown": "알 수 없음 (잠김)",
634+
"storage.bucket.sessions": "활성 세션",
635+
"storage.bucket.archived_sessions": "보관된 세션",
636+
"storage.bucket.logs_db": "로그 데이터베이스",
637+
"storage.bucket.state_db": "상태 데이터베이스",
638+
"storage.bucket.attachments": "첨부 파일",
639+
"storage.bucket.deletion_manifests": "삭제 매니페스트",
640+
"storage.bucket.other": "기타",
614641
"modal.back": "뒤로",
615642
"modal.badge.oauth": "OAuth",
616643
"modal.customProvider": "사용자 지정 프로바이더",

gui/src/i18n/zh.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,33 @@ export const zh: Record<TKey, string> = {
611611
"usage.dayFri": "五",
612612
"usage.heatmap.tooltipTokens": "{tokens} 令牌",
613613
"usage.heatmap.tooltipRequests": "{requests} 请求",
614+
615+
"nav.storage": "存储",
616+
"storage.title": "存储",
617+
"storage.subtitle": "只读视图,显示 CODEX_HOME 磁盘的使用情况。此页面不会删除任何内容。",
618+
"storage.loading": "正在扫描存储…",
619+
"storage.empty": "CODEX_HOME 为空或不存在——没有可显示的内容。",
620+
"storage.error": "存储扫描失败。请检查 CODEX_HOME 是否指向有效目录。",
621+
"storage.refresh": "重新扫描",
622+
"storage.card.total": "总大小",
623+
"storage.card.files": "文件数",
624+
"storage.card.home": "CODEX_HOME",
625+
"storage.section.buckets": "分类",
626+
"storage.section.largest": "最大文件",
627+
"storage.col.bucket": "分类",
628+
"storage.col.size": "大小",
629+
"storage.col.files": "文件",
630+
"storage.col.oldest": "最旧",
631+
"storage.col.newest": "最新",
632+
"storage.col.rows": "数据库行数",
633+
"storage.rows.unknown": "未知(已锁定)",
634+
"storage.bucket.sessions": "活动会话",
635+
"storage.bucket.archived_sessions": "已归档会话",
636+
"storage.bucket.logs_db": "日志数据库",
637+
"storage.bucket.state_db": "状态数据库",
638+
"storage.bucket.attachments": "附件",
639+
"storage.bucket.deletion_manifests": "删除清单",
640+
"storage.bucket.other": "其他",
614641
"modal.back": "返回",
615642
"modal.badge.oauth": "OAuth",
616643
"modal.customProvider": "自定义提供方",

gui/src/icons.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const IconList = (p: P) => (<svg {...S(p)}><path d="M8 6h13M8 12h13M8 18h
1515
export const IconMenu = (p: P) => (<svg {...S(p)}><path d="M4 6h16M4 12h16M4 18h16"/></svg>);
1616
export const IconTerminal = (p: P) => (<svg {...S(p)}><path d="m4 17 6-5-6-5"/><path d="M12 19h8"/></svg>);
1717
export const IconActivity = (p: P) => (<svg {...S(p)}><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>);
18+
export const IconHardDrive = (p: P) => (<svg {...S(p)}><path d="M22 12H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11Z"/><path d="M6 16h.01M10 16h.01"/></svg>);
1819

1920
export const IconCheck = (p: P) => (<svg {...S(p)}><path d="m20 6-11 11-5-5"/></svg>);
2021
export const IconX = (p: P) => (<svg {...S(p)}><path d="M18 6 6 18M6 6l12 12"/></svg>);

gui/src/pages/Storage.tsx

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { useCallback, useEffect, useState } from "react";
2+
import { useI18n, type TFn, type TKey, type Locale } from "../i18n/shared";
3+
import { EmptyState } from "../ui";
4+
import { IconRefresh } from "../icons";
5+
import { formatBytes } from "../format-bytes";
6+
7+
interface StorageLargestEntry {
8+
path: string;
9+
bytes: number;
10+
}
11+
12+
interface StorageBucket {
13+
key: string;
14+
label: string;
15+
bytes: number;
16+
fileCount: number;
17+
oldest?: number;
18+
newest?: number;
19+
largest?: StorageLargestEntry[];
20+
rows?: number | null;
21+
}
22+
23+
interface StorageReport {
24+
codexHome: string;
25+
generatedAt: number;
26+
total: { bytes: number; fileCount: number };
27+
buckets: StorageBucket[];
28+
error?: string;
29+
}
30+
31+
// Known scanner bucket keys → localized labels; unknown future keys fall back to the API label.
32+
const BUCKET_TKEYS: Record<string, TKey> = {
33+
sessions: "storage.bucket.sessions",
34+
archived_sessions: "storage.bucket.archived_sessions",
35+
logs_db: "storage.bucket.logs_db",
36+
state_db: "storage.bucket.state_db",
37+
attachments: "storage.bucket.attachments",
38+
deletion_manifests: "storage.bucket.deletion_manifests",
39+
other: "storage.bucket.other",
40+
};
41+
42+
function bucketLabel(bucket: StorageBucket, t: TFn): string {
43+
const tkey = BUCKET_TKEYS[bucket.key];
44+
return tkey ? t(tkey) : bucket.label;
45+
}
46+
47+
function formatDate(ms: number | undefined, locale: Locale): string {
48+
return ms === undefined ? "—" : new Date(ms).toLocaleDateString(locale);
49+
}
50+
51+
function BucketsTable({ buckets, locale, t }: { buckets: StorageBucket[]; locale: Locale; t: TFn }) {
52+
return (
53+
<section className="panel" style={{ marginTop: 16 }} aria-labelledby="storage-buckets-title">
54+
<h3 id="storage-buckets-title" className="panel-title">{t("storage.section.buckets")}</h3>
55+
<div className="tbl-wrap">
56+
<table className="tbl">
57+
<thead>
58+
<tr>
59+
<th>{t("storage.col.bucket")}</th>
60+
<th className="num">{t("storage.col.size")}</th>
61+
<th className="num">{t("storage.col.files")}</th>
62+
<th>{t("storage.col.oldest")}</th>
63+
<th>{t("storage.col.newest")}</th>
64+
<th className="num">{t("storage.col.rows")}</th>
65+
</tr>
66+
</thead>
67+
<tbody>
68+
{buckets.map(bucket => (
69+
<tr key={bucket.key}>
70+
<td>{bucketLabel(bucket, t)}</td>
71+
<td className="num mono">{formatBytes(bucket.bytes, locale)}</td>
72+
<td className="num">{bucket.fileCount}</td>
73+
<td className="muted">{formatDate(bucket.oldest, locale)}</td>
74+
<td className="muted">{formatDate(bucket.newest, locale)}</td>
75+
<td className="num mono">
76+
{bucket.rows === undefined ? "—" : bucket.rows === null ? t("storage.rows.unknown") : bucket.rows.toLocaleString(locale)}
77+
</td>
78+
</tr>
79+
))}
80+
</tbody>
81+
</table>
82+
</div>
83+
</section>
84+
);
85+
}
86+
87+
function LargestFilesPanel({ buckets, locale, t }: { buckets: StorageBucket[]; locale: Locale; t: TFn }) {
88+
const withLargest = buckets.filter(bucket => (bucket.largest?.length ?? 0) > 0);
89+
if (withLargest.length === 0) return null;
90+
return (
91+
<section className="panel" style={{ marginTop: 16 }} aria-labelledby="storage-largest-title">
92+
<h3 id="storage-largest-title" className="panel-title">{t("storage.section.largest")}</h3>
93+
{withLargest.map(bucket => (
94+
<details key={bucket.key} style={{ marginTop: 8 }}>
95+
<summary>{bucketLabel(bucket, t)}</summary>
96+
<div className="tbl-wrap" style={{ marginTop: 8 }}>
97+
<table className="tbl">
98+
<tbody>
99+
{bucket.largest!.map(entry => (
100+
<tr key={entry.path}>
101+
<td className="mono">{entry.path}</td>
102+
<td className="num mono">{formatBytes(entry.bytes, locale)}</td>
103+
</tr>
104+
))}
105+
</tbody>
106+
</table>
107+
</div>
108+
</details>
109+
))}
110+
</section>
111+
);
112+
}
113+
114+
export default function Storage({ apiBase }: { apiBase: string }) {
115+
const { t, locale } = useI18n();
116+
const [data, setData] = useState<StorageReport | null>(null);
117+
const [loading, setLoading] = useState(true);
118+
119+
const fetchStorage = useCallback(async (signal?: AbortSignal) => {
120+
setLoading(true);
121+
try {
122+
const res = await fetch(`${apiBase}/api/storage`, { signal });
123+
if (!res.ok) throw new Error("fetch failed");
124+
const json = await res.json() as StorageReport;
125+
if (signal?.aborted) return;
126+
setData(json);
127+
} catch {
128+
if (signal?.aborted) return;
129+
setData(null);
130+
} finally {
131+
if (!signal?.aborted) setLoading(false);
132+
}
133+
}, [apiBase]);
134+
135+
useEffect(() => {
136+
const controller = new AbortController();
137+
// Deferred a tick (same pattern as Usage.tsx) so the effect never sets state synchronously.
138+
const timeout = window.setTimeout(() => {
139+
void fetchStorage(controller.signal);
140+
}, 0);
141+
return () => {
142+
window.clearTimeout(timeout);
143+
controller.abort();
144+
};
145+
}, [fetchStorage]);
146+
147+
const failed = !loading && (!data || data.error !== undefined);
148+
const empty = !loading && !failed && data!.total.fileCount === 0;
149+
150+
return (
151+
<>
152+
<div className="page-head">
153+
<h2 id="storage-page-title">{t("storage.title")}</h2>
154+
<button type="button" className="btn btn-ghost btn-sm" disabled={loading} onClick={() => void fetchStorage()}>
155+
<IconRefresh /> {t("storage.refresh")}
156+
</button>
157+
</div>
158+
<p className="page-sub">{t("storage.subtitle")}</p>
159+
160+
{loading && !data ? (
161+
<EmptyState title={t("storage.loading")} />
162+
) : failed ? (
163+
<EmptyState title={t("storage.error")} />
164+
) : empty ? (
165+
<EmptyState title={t("storage.empty")} />
166+
) : (
167+
<>
168+
<div className="usage-cards">
169+
<div className="stat"><div className="muted">{t("storage.card.total")}</div><div className="stat-value">{formatBytes(data!.total.bytes, locale)}</div></div>
170+
<div className="stat"><div className="muted">{t("storage.card.files")}</div><div className="stat-value">{data!.total.fileCount.toLocaleString(locale)}</div></div>
171+
<div className="stat"><div className="muted">{t("storage.card.home")}</div><div className="stat-value mono" style={{ fontSize: "var(--text-body)", wordBreak: "break-all" }}>{data!.codexHome}</div></div>
172+
</div>
173+
<BucketsTable buckets={data!.buckets} locale={locale} t={t} />
174+
<LargestFilesPanel buckets={data!.buckets} locale={locale} t={t} />
175+
</>
176+
)}
177+
</>
178+
);
179+
}

0 commit comments

Comments
 (0)