diff --git a/gui/src/components/NumberStepper.tsx b/gui/src/components/NumberStepper.tsx new file mode 100644 index 000000000..1b9f85528 --- /dev/null +++ b/gui/src/components/NumberStepper.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/gui/src/components/storage-workspace/StorageWorkspace.tsx b/gui/src/components/storage-workspace/StorageWorkspace.tsx new file mode 100644 index 000000000..c4485d3fa --- /dev/null +++ b/gui/src/components/storage-workspace/StorageWorkspace.tsx @@ -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 = { + 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(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 = []; + 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 ( +
+ + +
+ {selected ? ( +
+ +

{bucketLabel(selected, t)}

+
+
+
{t("storage.col.size")}
+
{formatBytes(selected.bytes, locale)}
+
+
+
{t("storage.col.files")}
+
{selected.fileCount.toLocaleString(locale)}
+
+
+
{t("storage.col.oldest")}
+
{formatDate(selected.oldest, locale)}
+
+
+
{t("storage.col.newest")}
+
{formatDate(selected.newest, locale)}
+
+
+
{t("storage.col.rows")}
+
{rowsDisplay(selected, locale, t)}
+
+
+ + {(selected.largest?.length ?? 0) > 0 && ( +
+

{t("storage.section.largest")}

+ {selected.largest!.map(entry => ( +
+ {entry.path} + {formatBytes(entry.bytes, locale)} +
+ ))} +
+ )} +
+ ) : ( + <> +
+
+
{t("storage.card.total")}
+
{formatBytes(report.total.bytes, locale)}
+
+
+
{t("storage.card.files")}
+
{report.total.fileCount.toLocaleString(locale)}
+
+
+
{t("storage.card.home")}
+
{report.codexHome}
+
+
+ + {largestAcross.length > 0 ? ( +
+

{t("storage.section.largest")}

+ {largestAcross.map(entry => { + const owner = bucketByKey.get(entry.bucketKey); + return ( +
+ {entry.path} + {owner && {bucketLabel(owner, t)}} + {formatBytes(entry.bytes, locale)} +
+ ); + })} +
+ ) : ( +

+

+ )} + + )} +
+
+ ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 11167fc3b..f2b8048e9 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -906,11 +906,14 @@ export const de: Record = { "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", @@ -928,7 +931,7 @@ export const de: Record = { "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", @@ -999,6 +1002,12 @@ export const de: Record = { "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", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index edb244f48..8633ec4d6 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -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", @@ -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", @@ -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", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 3a6981701..bf682bbca 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -599,11 +599,14 @@ export const ja: Record = { "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": "ファイル", @@ -621,7 +624,7 @@ export const ja: Record = { "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": "アーカイブクリーンアップの確認", @@ -692,6 +695,12 @@ export const ja: Record = { "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": "プロキシ起動時", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 5e206ab31..0e2821c2a 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -926,11 +926,14 @@ export const ko: Record = { "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": "파일", @@ -948,7 +951,7 @@ export const ko: Record = { "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": "보관 정리 확인", @@ -1019,6 +1022,12 @@ export const ko: Record = { "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": "프록시 시작 시", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 97e1d68da..1773772b1 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -631,11 +631,14 @@ export const ru: Record = { "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": "Файлы", @@ -653,7 +656,7 @@ export const ru: Record = { "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": "Подтвердить очистку архива", @@ -724,6 +727,12 @@ export const ru: Record = { "storage.policy.target": "Цель очистки", "storage.policy.targetPercent": "Удалить процент самых старых архивов", "storage.policy.targetReduce": "Уменьшить архив до (ГиБ)", + "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": "При запуске прокси", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index ee16b561c..d7bed7261 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -926,11 +926,14 @@ export const zh: Record = { "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": "文件", @@ -948,7 +951,7 @@ export const zh: Record = { "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": "确认归档清理", @@ -1019,6 +1022,12 @@ export const zh: Record = { "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": "代理启动时", diff --git a/gui/src/pages/Storage.tsx b/gui/src/pages/Storage.tsx index 0fc3e9e9b..f55032022 100644 --- a/gui/src/pages/Storage.tsx +++ b/gui/src/pages/Storage.tsx @@ -1,32 +1,13 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { useI18n, type TFn, type TKey, type Locale } from "../i18n/shared"; +import { useI18n, type TFn, type Locale } from "../i18n/shared"; import { EmptyState } from "../ui"; import { IconRefresh } from "../icons"; import { formatBytes } from "../format-bytes"; +import { NumberStepper } from "../components/NumberStepper"; +import StorageWorkspace, { + type StorageReport, +} from "../components/storage-workspace/StorageWorkspace"; -interface StorageLargestEntry { - path: string; - bytes: number; -} - -interface StorageBucket { - key: string; - label: string; - bytes: number; - fileCount: number; - oldest?: number; - newest?: number; - largest?: StorageLargestEntry[]; - rows?: number | null; -} - -interface StorageReport { - codexHome: string; - generatedAt: number; - total: { bytes: number; fileCount: number }; - buckets: StorageBucket[]; - error?: string; -} interface CleanupPreview { percent: number; @@ -96,18 +77,16 @@ interface CleanupPolicy { }; } -const BUCKET_TKEYS: Record = { - 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", -}; - const PRESETS = [10, 25, 50] as const; +function clampDraft(raw: string, delta: number, min: number, max: number, step = 1): string { + const parsed = Number(raw); + const base = Number.isFinite(parsed) ? parsed : min; + const next = Math.min(max, Math.max(min, base + delta)); + // Keep one decimal for GiB-style steps; integers for percent. + return step < 1 ? String(Math.round(next * 10) / 10) : String(Math.round(next)); +} + const localizedCatch = (e: unknown, fallback: string): string => { if (!(e instanceof Error)) return fallback; const msg = e.message; @@ -123,78 +102,6 @@ const localizedCatch = (e: unknown, fallback: string): string => { return msg || fallback; }; -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 BucketsTable({ buckets, locale, t }: { buckets: StorageBucket[]; locale: Locale; t: TFn }) { - return ( -
-

{t("storage.section.buckets")}

-
- - - - - - - - - - - - - {buckets.map(bucket => ( - - - - - - - - - ))} - -
{t("storage.col.bucket")}{t("storage.col.size")}{t("storage.col.files")}{t("storage.col.oldest")}{t("storage.col.newest")}{t("storage.col.rows")}
{bucketLabel(bucket, t)}{formatBytes(bucket.bytes, locale)}{bucket.fileCount}{formatDate(bucket.oldest, locale)}{formatDate(bucket.newest, locale)} - {bucket.rows === undefined ? "—" : bucket.rows === null ? t("storage.rows.unknown") : bucket.rows.toLocaleString(locale)} -
-
-
- ); -} - -function LargestFilesPanel({ buckets, locale, t }: { buckets: StorageBucket[]; locale: Locale; t: TFn }) { - const withLargest = buckets.filter(bucket => (bucket.largest?.length ?? 0) > 0); - if (withLargest.length === 0) return null; - return ( -
-

{t("storage.section.largest")}

- {withLargest.map(bucket => ( -
- {bucketLabel(bucket, t)} -
- - - {bucket.largest!.map(entry => ( - - - - - ))} - -
{entry.path}{formatBytes(entry.bytes, locale)}
-
-
- ))} -
- ); -} - function ArchivedCleanupPanel({ apiBase, locale, @@ -339,7 +246,9 @@ function ArchivedCleanupPanel({