Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions gui/src/components/NumberStepper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { IconChevron } from "../icons";

export interface NumberStepperProps {
disabled?: boolean;
/** Increase the bound value (parent owns parsing / clamping). */
onIncrement(): void;
/** Decrease the bound value. */
onDecrement(): void;
incrementLabel: string;
decrementLabel: string;
}

/** Compact up/down chevron pair matching dashboard control chrome. */
export function NumberStepper({
disabled = false,
onIncrement,
onDecrement,
incrementLabel,
decrementLabel,
}: NumberStepperProps) {
return (
<div className="ocx-stepper" role="group">
<button
type="button"
className="ocx-stepper__btn"
disabled={disabled}
aria-label={incrementLabel}
onClick={onIncrement}
>
<IconChevron width={10} height={10} aria-hidden="true" style={{ transform: "rotate(-90deg)" }} />
</button>
<button
type="button"
className="ocx-stepper__btn"
disabled={disabled}
aria-label={decrementLabel}
onClick={onDecrement}
>
<IconChevron width={10} height={10} aria-hidden="true" style={{ transform: "rotate(90deg)" }} />
</button>
</div>
);
}
208 changes: 208 additions & 0 deletions gui/src/components/storage-workspace/StorageWorkspace.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* StorageWorkspace — rail + main workspace for the Storage tab, mirroring the
* Providers workspace DNA. Left rail lists buckets sorted by size; the main pane
* shows either the overview (totals + largest files across buckets) or a
* per-bucket detail view.
*/
/* eslint-disable react-refresh/only-export-components -- bucket label helper co-locates with the rail rows */
import { useMemo, useState } from "react";
import { IconChevron, IconHardDrive } from "../../icons";
import { useT, type TFn, type TKey, type Locale } from "../../i18n/shared";
import { formatBytes } from "../../format-bytes";

export interface StorageLargestEntry {
path: string;
bytes: number;
}

export interface StorageBucket {
key: string;
label: string;
bytes: number;
fileCount: number;
oldest?: number;
newest?: number;
largest?: StorageLargestEntry[];
rows?: number | null;
}

export interface StorageReport {
codexHome: string;
generatedAt: number;
total: { bytes: number; fileCount: number };
buckets: StorageBucket[];
error?: string;
}

// Known scanner bucket keys → localized labels; unknown future keys fall back to the API label.
const BUCKET_TKEYS: Record<string, TKey> = {
sessions: "storage.bucket.sessions",
archived_sessions: "storage.bucket.archived_sessions",
logs_db: "storage.bucket.logs_db",
state_db: "storage.bucket.state_db",
attachments: "storage.bucket.attachments",
deletion_manifests: "storage.bucket.deletion_manifests",
other: "storage.bucket.other",
};

export function bucketLabel(bucket: StorageBucket, t: TFn): string {
const tkey = BUCKET_TKEYS[bucket.key];
return tkey ? t(tkey) : bucket.label;
}

function formatDate(ms: number | undefined, locale: Locale): string {
return ms === undefined ? "—" : new Date(ms).toLocaleDateString(locale);
}

function rowsDisplay(bucket: StorageBucket, locale: Locale, t: TFn): string {
if (bucket.rows === undefined) return "—";
if (bucket.rows === null) return t("storage.rows.unknown");
return bucket.rows.toLocaleString(locale);
}

export interface StorageWorkspaceProps {
report: StorageReport;
locale: Locale;
}

export default function StorageWorkspace({ report, locale }: StorageWorkspaceProps) {
const t = useT();
const [selectedKey, setSelectedKey] = useState<string | null>(null);

const sortedBuckets = useMemo(
() => [...report.buckets].sort((a, b) => b.bytes - a.bytes),
[report.buckets],
);
const selected = sortedBuckets.find(b => b.key === selectedKey) ?? null;

const largestAcross = useMemo(() => {
const rows: Array<StorageLargestEntry & { bucketKey: string }> = [];
for (const bucket of report.buckets) {
for (const entry of bucket.largest ?? []) rows.push({ ...entry, bucketKey: bucket.key });
}
return rows.sort((a, b) => b.bytes - a.bytes).slice(0, 10);
}, [report.buckets]);

const bucketByKey = useMemo(
() => new Map(report.buckets.map(b => [b.key, b])),
[report.buckets],
);

return (
<div className="storage-workspace-root">
<aside className="storage-workspace-rail" aria-label={t("storage.section.buckets")}>
<div className="storage-workspace-rail-header">
<span className="storage-workspace-rail-title">{t("storage.section.buckets")}</span>
<span className="storage-workspace-rail-count">{sortedBuckets.length}</span>
</div>
<div className="storage-workspace-rail-list">
{sortedBuckets.length === 0 ? (
<span className="storage-workspace-rail-empty">{t("storage.empty")}</span>
) : (
sortedBuckets.map(bucket => (
<button
key={bucket.key}
type="button"
className={`storage-workspace-rail-row${selectedKey === bucket.key ? " storage-workspace-rail-row--selected" : ""}`}
onClick={() => setSelectedKey(prev => (prev === bucket.key ? null : bucket.key))}
aria-current={selectedKey === bucket.key ? "true" : undefined}
>
<span className="storage-workspace-rail-primary">
<span className="storage-workspace-rail-name">{bucketLabel(bucket, t)}</span>
<span className="storage-workspace-rail-size">{formatBytes(bucket.bytes, locale)}</span>
</span>
<span className="storage-workspace-rail-meta">
{bucket.fileCount.toLocaleString(locale)} {t("storage.col.files").toLowerCase()}
</span>
</button>
))
)}
</div>
</aside>

<main className="storage-workspace-main">
{selected ? (
<div className="stw-detail">
<button type="button" className="stw-detail-back" onClick={() => setSelectedKey(null)}>
<IconChevron className="stw-detail-back-chevron" />
{t("modal.back")}
</button>
<h2 className="stw-detail-title">{bucketLabel(selected, t)}</h2>
<dl className="stw-kv">
<div className="stw-kv-row">
<dt>{t("storage.col.size")}</dt>
<dd className="stw-kv-mono">{formatBytes(selected.bytes, locale)}</dd>
</div>
<div className="stw-kv-row">
<dt>{t("storage.col.files")}</dt>
<dd className="stw-kv-mono">{selected.fileCount.toLocaleString(locale)}</dd>
</div>
<div className="stw-kv-row">
<dt>{t("storage.col.oldest")}</dt>
<dd>{formatDate(selected.oldest, locale)}</dd>
</div>
<div className="stw-kv-row">
<dt>{t("storage.col.newest")}</dt>
<dd>{formatDate(selected.newest, locale)}</dd>
</div>
<div className="stw-kv-row">
<dt>{t("storage.col.rows")}</dt>
<dd className="stw-kv-mono">{rowsDisplay(selected, locale, t)}</dd>
</div>
</dl>

{(selected.largest?.length ?? 0) > 0 && (
<div className="stw-section">
<h3 className="stw-section-title">{t("storage.section.largest")}</h3>
{selected.largest!.map(entry => (
<div key={entry.path} className="stw-file-row">
<span className="stw-file-path" title={entry.path}>{entry.path}</span>
<span className="stw-file-size">{formatBytes(entry.bytes, locale)}</span>
</div>
))}
</div>
)}
</div>
) : (
<>
<div className="usage-cards">
<div className="stat">
<div className="muted">{t("storage.card.total")}</div>
<div className="stat-value">{formatBytes(report.total.bytes, locale)}</div>
</div>
<div className="stat">
<div className="muted">{t("storage.card.files")}</div>
<div className="stat-value">{report.total.fileCount.toLocaleString(locale)}</div>
</div>
<div className="stat">
<div className="muted">{t("storage.card.home")}</div>
<div className="stat-value mono" style={{ fontSize: "var(--text-body)", wordBreak: "break-all" }}>{report.codexHome}</div>
</div>
</div>

{largestAcross.length > 0 ? (
<div className="stw-section">
<h3 className="stw-section-title">{t("storage.section.largest")}</h3>
{largestAcross.map(entry => {
const owner = bucketByKey.get(entry.bucketKey);
return (
<div key={`${entry.bucketKey}:${entry.path}`} className="stw-file-row">
<span className="stw-file-path" title={entry.path}>{entry.path}</span>
{owner && <span className="stw-file-bucket">{bucketLabel(owner, t)}</span>}
<span className="stw-file-size">{formatBytes(entry.bytes, locale)}</span>
</div>
);
})}
</div>
) : (
<p className="stw-hint">
<IconHardDrive style={{ width: 14, height: 14, verticalAlign: "text-bottom", marginRight: 6 }} aria-hidden="true" />
{t("storage.workspace.selectBucket")}
</p>
)}
</>
)}
</main>
</div>
);
}
11 changes: 10 additions & 1 deletion gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -906,11 +906,14 @@ export const de: Record<TKey, string> = {
"storage.empty": "CODEX_HOME ist leer oder fehlt — nichts zu berichten.",
"storage.error": "Speicher-Scan fehlgeschlagen. Prüfe, ob CODEX_HOME auf ein gültiges Verzeichnis zeigt.",
"storage.refresh": "Neu scannen",
"storage.rescanned": "Scan abgeschlossen.",
"storage.card.total": "Gesamtgröße",
"storage.card.files": "Dateien",
"storage.card.home": "CODEX_HOME",
"storage.section.buckets": "Bereiche",
"storage.section.largest": "Größte Dateien",
"storage.workspace.overview": "Übersicht",
"storage.workspace.selectBucket": "Wähle einen Bucket aus der Liste, um die Aufschlüsselung zu sehen.",
"storage.col.bucket": "Bereich",
"storage.col.size": "Größe",
"storage.col.files": "Dateien",
Expand All @@ -928,7 +931,7 @@ export const de: Record<TKey, string> = {
"storage.cleanup.title": "Archivbereinigung",
"storage.cleanup.help": "Entfernt die ältesten archivierten Sitzungen nach Prozentsatz. Aktive Sitzungen werden nie angefasst. Standard ist Quarantäne — Dateien wandern nach CODEX_HOME/.trash.",
"storage.cleanup.slider": "Ältester Archivanteil",
"storage.cleanup.percent": "Älteste {percent}%",
"storage.cleanup.percent": "{percent}%",
"storage.cleanup.preset": "{percent}",
"storage.cleanup.preview": "Vorschau",
"storage.cleanup.confirmTitle": "Archivbereinigung bestätigen",
Expand Down Expand Up @@ -999,6 +1002,12 @@ export const de: Record<TKey, string> = {
"storage.policy.target": "Bereinigungsziel",
"storage.policy.targetPercent": "Ältesten Archivanteil entfernen",
"storage.policy.targetReduce": "Archivgröße reduzieren auf (GiB)",
"storage.policy.thresholdInc": "Schwellwert erhöhen",
"storage.policy.thresholdDec": "Schwellwert verringern",
"storage.policy.percentInc": "Prozent erhöhen",
"storage.policy.percentDec": "Prozent verringern",
"storage.policy.reduceInc": "Zielgröße erhöhen",
"storage.policy.reduceDec": "Zielgröße verringern",
"storage.policy.schedule": "Zeitplan",
"storage.policy.schedule.manual": "Nur manuell",
"storage.policy.schedule.startup": "Beim Proxy-Start",
Expand Down
11 changes: 10 additions & 1 deletion gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,11 +632,14 @@ export const en = {
"storage.empty": "CODEX_HOME is empty or missing — nothing to report.",
"storage.error": "Storage scan failed. Check that CODEX_HOME points at a valid directory.",
"storage.refresh": "Rescan",
"storage.rescanned": "Scan complete.",
"storage.card.total": "Total size",
"storage.card.files": "Files",
"storage.card.home": "CODEX_HOME",
"storage.section.buckets": "Buckets",
"storage.section.largest": "Largest files",
"storage.workspace.overview": "Overview",
"storage.workspace.selectBucket": "Select a bucket from the list to see its breakdown.",
"storage.col.bucket": "Bucket",
"storage.col.size": "Size",
"storage.col.files": "Files",
Expand All @@ -654,7 +657,7 @@ export const en = {
"storage.cleanup.title": "Archived cleanup",
"storage.cleanup.help": "Remove the oldest archived sessions by percentage. Active sessions are never touched. Quarantine is the default — files move to CODEX_HOME/.trash.",
"storage.cleanup.slider": "Oldest archived percent",
"storage.cleanup.percent": "Oldest {percent}%",
"storage.cleanup.percent": "{percent}%",
"storage.cleanup.preset": "{percent}",
"storage.cleanup.preview": "Preview",
"storage.cleanup.confirmTitle": "Confirm archived cleanup",
Expand Down Expand Up @@ -725,6 +728,12 @@ export const en = {
"storage.policy.target": "Cleanup target",
"storage.policy.targetPercent": "Remove oldest archived percent",
"storage.policy.targetReduce": "Reduce archived size to (GiB)",
"storage.policy.thresholdInc": "Increase threshold",
"storage.policy.thresholdDec": "Decrease threshold",
"storage.policy.percentInc": "Increase percent",
"storage.policy.percentDec": "Decrease percent",
"storage.policy.reduceInc": "Increase reduce-to size",
"storage.policy.reduceDec": "Decrease reduce-to size",
"storage.policy.schedule": "Schedule",
"storage.policy.schedule.manual": "Manual only",
"storage.policy.schedule.startup": "On proxy startup",
Expand Down
11 changes: 10 additions & 1 deletion gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -599,11 +599,14 @@ export const ja: Record<TKey, string> = {
"storage.empty": "CODEX_HOME が空か存在しません — 報告するものはありません。",
"storage.error": "ストレージのスキャンに失敗しました。CODEX_HOME が有効なディレクトリを指しているか確認してください。",
"storage.refresh": "再スキャン",
"storage.rescanned": "スキャンが完了しました。",
"storage.card.total": "合計サイズ",
"storage.card.files": "ファイル",
"storage.card.home": "CODEX_HOME",
"storage.section.buckets": "バケット",
"storage.section.largest": "最大ファイル",
"storage.workspace.overview": "概要",
"storage.workspace.selectBucket": "一覧からバケットを選ぶと内訳が表示されます。",
"storage.col.bucket": "バケット",
"storage.col.size": "サイズ",
"storage.col.files": "ファイル",
Expand All @@ -621,7 +624,7 @@ export const ja: Record<TKey, string> = {
"storage.cleanup.title": "アーカイブのクリーンアップ",
"storage.cleanup.help": "古いアーカイブセッションを割合で削除します。アクティブセッションには触れません。既定は隔離で、ファイルは CODEX_HOME/.trash へ移動します。",
"storage.cleanup.slider": "古いアーカイブの割合",
"storage.cleanup.percent": "古い {percent}%",
"storage.cleanup.percent": "{percent}%",
"storage.cleanup.preset": "{percent}",
"storage.cleanup.preview": "プレビュー",
"storage.cleanup.confirmTitle": "アーカイブクリーンアップの確認",
Expand Down Expand Up @@ -692,6 +695,12 @@ export const ja: Record<TKey, string> = {
"storage.policy.target": "クリーンアップ目標",
"storage.policy.targetPercent": "古いアーカイブの割合を削除",
"storage.policy.targetReduce": "アーカイブを次のサイズまで縮小(GiB)",
"storage.policy.thresholdInc": "しきい値を上げる",
"storage.policy.thresholdDec": "しきい値を下げる",
"storage.policy.percentInc": "パーセントを上げる",
"storage.policy.percentDec": "パーセントを下げる",
"storage.policy.reduceInc": "削減目標を上げる",
"storage.policy.reduceDec": "削減目標を下げる",
"storage.policy.schedule": "スケジュール",
"storage.policy.schedule.manual": "手動のみ",
"storage.policy.schedule.startup": "プロキシ起動時",
Expand Down
Loading
Loading