From 1304d591c4fc7ab190e30c2d2b4f7e9c622dc88e Mon Sep 17 00:00:00 2001 From: Jordan Harrison Date: Tue, 30 Jun 2026 10:21:23 +0100 Subject: [PATCH] feat(desktop): implement storage management in the settings ui --- packages/cli/src/commands.ts | 20 + .../src/main/__tests__/integration.test.ts | 3 + .../src/main/__tests__/toolFactories.test.ts | 8 +- packages/desktop/src/main/bootstrap.ts | 5 +- .../src/main/services/StorageService.ts | 172 +++++++- packages/desktop/src/main/tools/deps.ts | 4 + .../desktop/src/main/tools/storageTools.ts | 95 ++++- packages/desktop/src/renderer/src/bridge.ts | 94 +++++ .../renderer/src/components/SettingsView.tsx | 371 +++++++++++++++++- 9 files changed, 755 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index d138633..4fdbbcb 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -470,6 +470,26 @@ export const commands: Record = { positionals: [], summary: "Export persisted state and storage metadata", }, + "storage-support-bundle": { + tool: "storage_export_support_bundle", + positionals: [], + summary: "Export storage support bundle artifact", + }, + "storage-clear": { + tool: "storage_clear_collection", + positionals: ["name"], + summary: "Clear logs, audit, or artifacts (--confirm)", + }, + "storage-delete-screenshots": { + tool: "storage_delete_old_screenshots", + positionals: [], + summary: "Delete screenshot artifacts older than --olderThanDays (--confirm)", + }, + "storage-prune-sessions": { + tool: "storage_prune_stale_agent_sessions", + positionals: [], + summary: "Prune agent sessions older than --olderThanDays (--confirm)", + }, plugins: { tool: "list_plugins", positionals: [], diff --git a/packages/desktop/src/main/__tests__/integration.test.ts b/packages/desktop/src/main/__tests__/integration.test.ts index f4e285e..b82acfd 100644 --- a/packages/desktop/src/main/__tests__/integration.test.ts +++ b/packages/desktop/src/main/__tests__/integration.test.ts @@ -103,6 +103,9 @@ describe("socket integration", () => { expect(names).toContain("state"); expect(names).toContain("logs"); expect(names).toContain("audit"); + expect(names).toContain("artifacts"); + expect(names).toContain("agent_sessions"); + expect(names).toContain("agent_transcripts"); }); it("denies privileged socket calls without an explicit session grant", async () => { diff --git a/packages/desktop/src/main/__tests__/toolFactories.test.ts b/packages/desktop/src/main/__tests__/toolFactories.test.ts index 21caccd..2e71df4 100644 --- a/packages/desktop/src/main/__tests__/toolFactories.test.ts +++ b/packages/desktop/src/main/__tests__/toolFactories.test.ts @@ -115,6 +115,10 @@ const factoryCases = [ "storage_list_collections", "storage_read_collection", "storage_export_state", + "storage_export_support_bundle", + "storage_clear_collection", + "storage_delete_old_screenshots", + "storage_prune_stale_agent_sessions", ], }, { @@ -235,9 +239,7 @@ describe("tool factory contracts", () => { const violations: string[] = []; for (const tool of all) { - const isMutating = MUTATING_PREFIXES.some((prefix) => - tool.name.startsWith(prefix), - ); + const isMutating = MUTATING_PREFIXES.some((prefix) => tool.name.startsWith(prefix)); if (!isMutating) continue; const caps = tool.capabilities ?? []; diff --git a/packages/desktop/src/main/bootstrap.ts b/packages/desktop/src/main/bootstrap.ts index 8cd394e..ac72edb 100644 --- a/packages/desktop/src/main/bootstrap.ts +++ b/packages/desktop/src/main/bootstrap.ts @@ -46,6 +46,7 @@ import { WorkspaceFileService } from "./services/WorkspaceFileService.js"; import { ArtifactStore } from "./storage/ArtifactStore.js"; import { createAppTools } from "./tools/appTools.js"; import { createBrowserTools } from "./tools/browserTools.js"; +import type { ToolDeps } from "./tools/deps.js"; import { createFileTools } from "./tools/fileTools.js"; import { createGitTools } from "./tools/gitTools.js"; import { createPluginTools } from "./tools/pluginTools.js"; @@ -357,7 +358,7 @@ export async function bootstrap( managedPluginsDir: join(userDataPath, "plugins"), }); pluginRef.current = plugins; - const deps = { + const deps: ToolDeps = { appState, browserTabs, spaces, @@ -369,6 +370,7 @@ export async function bootstrap( storage, plugins, permissions, + artifacts, }; registry.registerAll(createBrowserTools(deps)); registry.registerAll(createSpaceTools(deps)); @@ -413,6 +415,7 @@ export async function bootstrap( }); agents.hydrate(); agents.startIdleGc(); + deps.agents = agents; registry.registerAll( createAppTools(deps, { diff --git a/packages/desktop/src/main/services/StorageService.ts b/packages/desktop/src/main/services/StorageService.ts index fd22347..cf375a1 100644 --- a/packages/desktop/src/main/services/StorageService.ts +++ b/packages/desktop/src/main/services/StorageService.ts @@ -1,9 +1,10 @@ -import { existsSync, statSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs"; +import { basename, extname, join } from "node:path"; import { JsonlStore } from "../storage/JsonlStore.js"; import { CURRENT_STATE_VERSION } from "../storage/migrations.js"; import type { AppStateService } from "./AppStateService.js"; -export type CollectionKind = "json" | "jsonl"; +export type CollectionKind = "json" | "jsonl" | "directory"; export interface CollectionInfo { name: string; @@ -18,6 +19,30 @@ interface CollectionDef extends Omit { read: (limit?: number) => unknown; } +export interface StorageMaintenanceResult { + collection?: string; + deletedFiles: number; + deletedBytes: number; +} + +interface FileInfo { + path: string; + sizeBytes: number; + createdAt: number; + modifiedAt: number; +} + +export interface SupportBundle { + schema: "meith-support-bundle/v1"; + exportedAt: number; + dataDirectory: string; + stateVersion: number; + collections: CollectionInfo[]; + state: unknown; + recentLogs: unknown; + recentAudit: unknown; +} + export interface StorageServiceOptions { dataDir: string; appState: AppStateService; @@ -64,6 +89,29 @@ export class StorageService { description: "Append-only redacted tool-call audit history.", read: (limit) => auditStore.tail(limit ?? 200), }); + this.define({ + name: "artifacts", + kind: "directory", + path: this.artifactsDirectory, + description: "Binary and JSON artifacts, including screenshots and bug reports.", + read: (limit) => this.listFiles(this.artifactsDirectory).slice(0, limit ?? 500), + }); + this.define({ + name: "agent_sessions", + kind: "json", + path: this.agentSessionsPath, + description: + "Agent session metadata index; transcripts are stored as per-session JSONL.", + read: () => this.readCollectionFile(this.agentSessionsPath), + }); + this.define({ + name: "agent_transcripts", + kind: "directory", + path: this.agentTranscriptsDirectory, + description: "Append-only JSONL agent transcripts, one file per session.", + read: (limit) => + this.listFiles(this.agentTranscriptsDirectory).slice(0, limit ?? 500), + }); } private define(def: CollectionDef): void { @@ -75,17 +123,22 @@ export class StorageService { return this.opts.dataDir; } + get artifactsDirectory(): string { + return join(this.opts.dataDir, "artifacts"); + } + + get agentSessionsPath(): string { + return join(this.opts.dataDir, "agent", "sessions.json"); + } + + get agentTranscriptsDirectory(): string { + return join(this.opts.dataDir, "agent", "sessions"); + } + listCollections(): CollectionInfo[] { return [...this.collections.values()].map((def) => { - let sizeBytes = 0; const exists = existsSync(def.path); - if (exists) { - try { - sizeBytes = statSync(def.path).size; - } catch { - sizeBytes = 0; - } - } + const sizeBytes = exists ? this.pathSize(def.path) : 0; return { name: def.name, kind: def.kind, @@ -115,4 +168,103 @@ export class StorageService { state: this.opts.appState.getState(), }; } + + exportSupportBundle(logsLimit = 500): SupportBundle { + return { + schema: "meith-support-bundle/v1", + exportedAt: Date.now(), + dataDirectory: this.opts.dataDir, + stateVersion: CURRENT_STATE_VERSION, + collections: this.listCollections(), + state: this.opts.appState.getState(), + recentLogs: this.readCollection("logs", logsLimit), + recentAudit: this.readCollection("audit", logsLimit), + }; + } + + clearCollection(name: string): StorageMaintenanceResult { + const allowed = new Set(["logs", "audit", "artifacts"]); + if (!allowed.has(name)) { + throw new Error(`Collection cannot be cleared from storage management: ${name}`); + } + const def = this.collections.get(name); + if (!def) throw new Error(`Unknown collection: ${name}`); + const before = this.pathSize(def.path); + const deletedFiles = this.pathExists(def.path) + ? statSync(def.path).isDirectory() + ? this.listFiles(def.path).length + : 1 + : 0; + rmSync(def.path, { recursive: true, force: true }); + return { collection: name, deletedFiles, deletedBytes: before }; + } + + deleteOldScreenshots(olderThanDays: number): StorageMaintenanceResult { + const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000; + let deletedFiles = 0; + let deletedBytes = 0; + for (const file of this.listFiles(this.artifactsDirectory)) { + const name = basename(file.path); + const isScreenshot = + extname(name).toLowerCase() === ".png" && + (name.startsWith("screenshot-") || name.startsWith("app-")); + if (!isScreenshot || file.modifiedAt >= cutoff) continue; + rmSync(file.path, { force: true }); + deletedFiles += 1; + deletedBytes += file.sizeBytes; + } + return { collection: "artifacts", deletedFiles, deletedBytes }; + } + + private readCollectionFile(path: string): unknown { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch { + return null; + } + } + + private pathExists(path: string): boolean { + try { + statSync(path); + return true; + } catch { + return false; + } + } + + private pathSize(path: string): number { + try { + const st = statSync(path); + if (!st.isDirectory()) return st.size; + return this.listFiles(path).reduce((sum, file) => sum + file.sizeBytes, 0); + } catch { + return 0; + } + } + + private listFiles(dir: string): FileInfo[] { + if (!existsSync(dir)) return []; + const out: FileInfo[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + try { + if (entry.isDirectory()) { + out.push(...this.listFiles(path)); + continue; + } + if (!entry.isFile()) continue; + const st = statSync(path); + out.push({ + path, + sizeBytes: st.size, + createdAt: st.birthtimeMs || st.mtimeMs, + modifiedAt: st.mtimeMs, + }); + } catch { + // Ignore files that disappear while storage accounting is running. + } + } + return out.sort((a, b) => b.modifiedAt - a.modifiedAt); + } } diff --git a/packages/desktop/src/main/tools/deps.ts b/packages/desktop/src/main/tools/deps.ts index 38bdd2b..3ca605a 100644 --- a/packages/desktop/src/main/tools/deps.ts +++ b/packages/desktop/src/main/tools/deps.ts @@ -1,3 +1,4 @@ +import type { AgentService } from "../services/AgentService.js"; import type { AppStateService } from "../services/AppStateService.js"; import type { BrowserTabService } from "../services/BrowserTabService.js"; import type { DevServerService } from "../services/DevServerService.js"; @@ -9,6 +10,7 @@ import type { SpaceService } from "../services/SpaceService.js"; import type { StorageService } from "../services/StorageService.js"; import type { TerminalService } from "../services/TerminalService.js"; import type { WorkspaceFileService } from "../services/WorkspaceFileService.js"; +import type { ArtifactStore } from "../storage/ArtifactStore.js"; /** * Dependencies injected into tool factories. Keeping this explicit (rather than @@ -27,4 +29,6 @@ export interface ToolDeps { storage: StorageService; plugins: PluginHostService; permissions: PermissionService; + agents?: AgentService; + artifacts?: ArtifactStore; } diff --git a/packages/desktop/src/main/tools/storageTools.ts b/packages/desktop/src/main/tools/storageTools.ts index 05c62f2..8ce8d75 100644 --- a/packages/desktop/src/main/tools/storageTools.ts +++ b/packages/desktop/src/main/tools/storageTools.ts @@ -50,5 +50,98 @@ export function createStorageTools(deps: ToolDeps): ToolDefinition[] { execute: () => deps.storage.exportState(), }); - return [listCollections, readCollection, exportState]; + const exportSupportBundle = defineTool({ + name: "storage_export_support_bundle", + description: + "Export a support bundle with storage metadata, state, recent logs, and recent audit entries.", + capabilities: ["read-only"], + inputSchema: z.object({ + logsLimit: z.number().int().positive().max(2000).default(500), + }), + execute: (_ctx, input) => { + const bundle = deps.storage.exportSupportBundle(input.logsLimit); + const data = Buffer.from(JSON.stringify(bundle, null, 2), "utf8"); + const artifact = deps.artifacts?.write( + `support-bundle-${Date.now()}`, + "json", + data, + ); + return { + path: artifact?.path, + bytes: data.byteLength, + bundle, + }; + }, + }); + + const clearCollection = defineTool({ + name: "storage_clear_collection", + description: "Clear a managed storage collection: logs, audit, or artifacts.", + capabilities: ["destructive"], + inputSchema: z.object({ + name: z.enum(["logs", "audit", "artifacts"]), + confirm: z.literal(true).describe("Must be true to confirm destructive clearing."), + }), + execute: (_ctx, input) => { + try { + return deps.storage.clearCollection(input.name); + } catch (err) { + return errorResult( + "VALIDATION_ERROR", + err instanceof Error ? err.message : String(err), + { name: input.name }, + ); + } + }, + }); + + const deleteOldScreenshots = defineTool({ + name: "storage_delete_old_screenshots", + description: "Delete screenshot PNG artifacts older than the requested age.", + capabilities: ["destructive"], + inputSchema: z.object({ + olderThanDays: z.number().int().min(1).max(3650).default(30), + confirm: z.literal(true).describe("Must be true to confirm screenshot deletion."), + }), + execute: (_ctx, input) => deps.storage.deleteOldScreenshots(input.olderThanDays), + }); + + const pruneStaleAgentSessions = defineTool({ + name: "storage_prune_stale_agent_sessions", + description: + "Delete non-running agent sessions whose metadata has not changed within the requested age.", + capabilities: ["destructive"], + inputSchema: z.object({ + olderThanDays: z.number().int().min(1).max(3650).default(30), + confirm: z.literal(true).describe("Must be true to confirm agent session pruning."), + }), + execute: (_ctx, input) => { + if (!deps.agents) { + return errorResult("TOOL_FAILED", "Agent service is not available."); + } + const cutoff = Date.now() - input.olderThanDays * 24 * 60 * 60 * 1000; + const candidates = deps.agents + .listSessions() + .filter((session) => session.updatedAt < cutoff && session.status !== "running"); + let deletedSessions = 0; + for (const session of candidates) { + if (deps.agents.deleteSession(session.id)) deletedSessions += 1; + } + return { + collection: "agent_sessions", + deletedSessions, + cutoff, + }; + }, + }); + + return [ + listCollections, + readCollection, + exportState, + exportSupportBundle, + clearCollection, + deleteOldScreenshots, + pruneStaleAgentSessions, + ]; } diff --git a/packages/desktop/src/renderer/src/bridge.ts b/packages/desktop/src/renderer/src/bridge.ts index b1758bc..8916e1a 100644 --- a/packages/desktop/src/renderer/src/bridge.ts +++ b/packages/desktop/src/renderer/src/bridge.ts @@ -334,6 +334,20 @@ function createMockBridge(): MeithBridge { ]), desc("get_app_settings", "Read global app settings.", ["read-only"]), desc("set_app_settings", "Patch global app settings.", ["writes-files"]), + desc("storage_list_collections", "List durable storage collections.", ["read-only"]), + desc("storage_export_state", "Export persisted state and storage metadata.", [ + "read-only", + ]), + desc("storage_export_support_bundle", "Export a support bundle.", ["read-only"]), + desc("storage_clear_collection", "Clear a managed storage collection.", [ + "destructive", + ]), + desc("storage_delete_old_screenshots", "Delete old screenshot artifacts.", [ + "destructive", + ]), + desc("storage_prune_stale_agent_sessions", "Prune stale agent sessions.", [ + "destructive", + ]), desc("git_identity_detect", "Detect local Git commit identity suggestions.", [ "read-only", ]), @@ -428,6 +442,86 @@ function createMockBridge(): MeithBridge { return okResult(structuredClone(state)); case "app_get_logs": return okResult([...logs]); + case "storage_list_collections": + return okResult({ + dataDirectory: "/tmp/meith-preview", + collections: [ + { + name: "state", + kind: "json", + path: "/tmp/meith-preview/state.json", + description: "Small, bounded application state (spaces, tabs).", + exists: true, + sizeBytes: JSON.stringify(state).length, + }, + { + name: "logs", + kind: "jsonl", + path: "/tmp/meith-preview/logs.jsonl", + description: "Append-only structured log history.", + exists: true, + sizeBytes: JSON.stringify(logs).length, + }, + { + name: "audit", + kind: "jsonl", + path: "/tmp/meith-preview/audit.jsonl", + description: "Append-only redacted tool-call audit history.", + exists: false, + sizeBytes: 0, + }, + { + name: "artifacts", + kind: "directory", + path: "/tmp/meith-preview/artifacts", + description: "Binary and JSON artifacts, including screenshots.", + exists: false, + sizeBytes: 0, + }, + { + name: "agent_sessions", + kind: "json", + path: "/tmp/meith-preview/agent/sessions.json", + description: "Agent session metadata index.", + exists: true, + sizeBytes: agentSessions.size * 512, + }, + { + name: "agent_transcripts", + kind: "directory", + path: "/tmp/meith-preview/agent/sessions", + description: "Append-only JSONL agent transcripts.", + exists: true, + sizeBytes: agentSessions.size * 2048, + }, + ], + }); + case "storage_export_state": + return okResult({ + exportedAt: Date.now(), + dataDirectory: "/tmp/meith-preview", + state, + }); + case "storage_export_support_bundle": + return okResult({ bytes: 1024, bundle: { state, logs } }); + case "storage_clear_collection": + return okResult({ + collection: String(args.name ?? ""), + deletedFiles: 0, + deletedBytes: 0, + }); + case "storage_delete_old_screenshots": + return okResult({ + collection: "artifacts", + deletedFiles: 0, + deletedBytes: 0, + }); + case "storage_prune_stale_agent_sessions": + return okResult({ + collection: "agent_sessions", + deletedSessions: 0, + cutoff: Date.now(), + }); case "create_space": { const space = { diff --git a/packages/desktop/src/renderer/src/components/SettingsView.tsx b/packages/desktop/src/renderer/src/components/SettingsView.tsx index 7d482c4..c61913b 100644 --- a/packages/desktop/src/renderer/src/components/SettingsView.tsx +++ b/packages/desktop/src/renderer/src/components/SettingsView.tsx @@ -19,16 +19,19 @@ import { import { AlertTriangleIcon, CheckCircle2Icon, + DownloadIcon, GitBranchIcon, + HardDriveIcon, Loader2Icon, PlusIcon, + RefreshCwIcon, SettingsIcon, TerminalIcon, Trash2Icon, UserRoundIcon, XIcon, } from "lucide-react"; -import { useEffect, useId, useState } from "react"; +import { useCallback, useEffect, useId, useState } from "react"; import { toast } from "sonner"; import type { MeithBridge } from "../../../bridge"; import { cn } from "../lib/utils"; @@ -40,7 +43,14 @@ import { ScrollArea } from "./ui/scroll-area"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; -export type SettingsTab = "general" | "run" | "git" | "agent" | "plugins" | "about"; +export type SettingsTab = + | "general" + | "run" + | "git" + | "agent" + | "plugins" + | "storage" + | "about"; type Run = (name: string, args?: Record) => Promise; @@ -163,6 +173,9 @@ export function SettingsView({ Plugins + + Storage + About @@ -190,6 +203,9 @@ export function SettingsView({ + + + @@ -1125,6 +1141,357 @@ function AgentTab({ bridge, open }: { bridge: MeithBridge; open: boolean }) { ); } +// --- Storage --------------------------------------------------------------- + +interface StorageCollectionInfo { + name: string; + kind: "json" | "jsonl" | "directory"; + path: string; + description: string; + exists: boolean; + sizeBytes: number; +} + +interface StorageListContent { + dataDirectory: string; + collections: StorageCollectionInfo[]; +} + +type StorageAction = + | "export" + | "clear-logs" + | "clear-audit" + | "clear-artifacts" + | "screenshots" + | "sessions" + | "refresh"; + +function StorageTab({ run, open }: { run: Run; open: boolean }) { + const [storage, setStorage] = useState(null); + const [busy, setBusy] = useState(null); + const [screenshotDays, setScreenshotDays] = useState(30); + const [sessionDays, setSessionDays] = useState(30); + + const refresh = useCallback( + async (action: StorageAction = "refresh") => { + setBusy(action); + try { + const result = await run("storage_list_collections", {}); + if (!result.ok) + throw new Error(result.error?.message ?? "Failed to load storage"); + setStorage(result.content as StorageListContent); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(null); + } + }, + [run], + ); + + useEffect(() => { + if (open) void refresh(); + }, [open, refresh]); + + const runStorageAction = async ( + action: StorageAction, + tool: string, + args: Record, + success: (result: ToolResult) => string, + ) => { + setBusy(action); + try { + const result = await run(tool, args); + if (!result.ok) throw new Error(result.error?.message ?? "Storage action failed"); + toast.success(success(result)); + await refresh(action); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + setBusy(null); + } + }; + + const clearCollection = (name: "logs" | "audit" | "artifacts") => { + if (!window.confirm(`Clear ${name}? This cannot be undone.`)) return; + void runStorageAction( + `clear-${name}` as StorageAction, + "storage_clear_collection", + { name, confirm: true }, + (result) => { + const content = result.content as { + deletedBytes?: number; + deletedFiles?: number; + }; + return `Cleared ${formatBytes(content.deletedBytes ?? 0)} from ${name}`; + }, + ); + }; + + const exportBundle = () => { + void runStorageAction( + "export", + "storage_export_support_bundle", + { logsLimit: 500 }, + (result) => { + const content = result.content as { path?: string; bytes?: number }; + return content.path + ? `Support bundle exported (${formatBytes(content.bytes ?? 0)})` + : "Support bundle prepared"; + }, + ); + }; + + const deleteScreenshots = () => { + if ( + !window.confirm( + `Delete screenshot artifacts older than ${screenshotDays} days? This cannot be undone.`, + ) + ) { + return; + } + void runStorageAction( + "screenshots", + "storage_delete_old_screenshots", + { olderThanDays: screenshotDays, confirm: true }, + (result) => { + const content = result.content as { deletedFiles?: number }; + return `Deleted ${content.deletedFiles ?? 0} old screenshots`; + }, + ); + }; + + const pruneSessions = () => { + if ( + !window.confirm( + `Prune agent sessions idle for ${sessionDays} days? This cannot be undone.`, + ) + ) { + return; + } + void runStorageAction( + "sessions", + "storage_prune_stale_agent_sessions", + { olderThanDays: sessionDays, confirm: true }, + (result) => { + const content = result.content as { deletedSessions?: number }; + return `Pruned ${content.deletedSessions ?? 0} stale sessions`; + }, + ); + }; + + const collections = storage?.collections ?? []; + const totalBytes = collections.reduce( + (sum, collection) => sum + collection.sizeBytes, + 0, + ); + + return ( +
+
+
+ + Storage usage +
+
+ + +
+
+ +
+
+ + {storage?.dataDirectory ?? "Loading storage directory…"} + + {formatBytes(totalBytes)} +
+
    + {collections.map((collection) => ( +
  • +
    +
    + + {formatCollectionName(collection.name)} + + {collection.kind} + {!collection.exists && missing} +
    +

    + {collection.description} +

    +
    + + {formatBytes(collection.sizeBytes)} + +
  • + ))} + {collections.length === 0 && ( +
  • + {busy ? "Loading storage collections…" : "No storage collections found."} +
  • + )} +
+
+ +
+ Maintenance + clearCollection("logs")} + > + + Clear + + } + /> + clearCollection("audit")} + > + + Clear + + } + /> + clearCollection("artifacts")} + > + + Clear + + } + /> + + +
+
+ ); +} + +function StorageActionRow({ + label, + description, + action, +}: { + label: string; + description: string; + action: React.ReactNode; +}) { + return ( +
+
+

{label}

+

{description}

+
+
{action}
+
+ ); +} + +function StorageNumberAction({ + label, + description, + value, + onValueChange, + disabled, + buttonLabel, + onClick, +}: { + label: string; + description: string; + value: number; + onValueChange: (value: number) => void; + disabled: boolean; + buttonLabel: string; + onClick: () => void; +}) { + return ( + + onValueChange(Number(e.target.value))} + /> + + + } + /> + ); +} + +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +function formatCollectionName(name: string): string { + return name + .split("_") + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join(" "); +} + // --- About ----------------------------------------------------------------- function AboutTab({ isMock }: { isMock: boolean }) {