Files
kandev/apps/web/components/settings/system/database-stats-card.tsx
T

278 lines
9.2 KiB
TypeScript

"use client";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Button } from "@kandev/ui/button";
import { Spinner } from "@kandev/ui/spinner";
import {
IconDatabase,
IconBolt,
IconInfoCircle,
IconRefresh,
IconTrash,
} from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useDatabaseStats } from "@/hooks/domains/system/use-database-stats";
import { optimizeDatabase, vacuumDatabase } from "@/lib/api/domains/system-api";
import { formatBytes } from "@/lib/utils/format-bytes";
import { useActionFeedback, type ActionFeedbackState } from "@/hooks/use-action-feedback";
import { ActionButtonContent } from "./action-button-content";
import { JobProgressIndicator } from "./job-progress-indicator";
import { FactoryResetDialog } from "./factory-reset-dialog";
function formatTimestamp(iso: string | null | undefined): string {
if (!iso) return "Never";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString();
}
const WAL_HELP =
"Write-Ahead Log. SQLite writes new changes to this companion file first and merges them into the main database over time. A non-zero WAL size is normal - it temporarily grows under load and shrinks back during checkpoints. Running vacuum will reset it.";
type Row = { label: string; value: string; testid: string; info?: string };
function StatRow({ label, value, testid, info }: Row) {
return (
<div className="flex items-baseline justify-between gap-4 py-1.5 border-b last:border-b-0">
<span className="text-xs text-muted-foreground inline-flex items-center gap-1">
{label}
{info && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`What is ${label}?`}
className="cursor-pointer text-muted-foreground/70 hover:text-foreground transition-colors"
data-testid={`${testid}-info`}
>
<IconInfoCircle className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">{info}</TooltipContent>
</Tooltip>
)}
</span>
<span className="text-sm font-mono break-all text-right" data-testid={testid}>
{value}
</span>
</div>
);
}
type DBStats = {
path: string;
size_bytes: number;
wal_size_bytes: number;
schema_version: string;
last_backup_at: string | null;
};
function StatsTable({ database }: { database: DBStats }) {
return (
<div className="rounded-md border px-3 py-2">
<StatRow label="Path" value={database.path} testid="system-db-path" />
<StatRow label="Size" value={formatBytes(database.size_bytes)} testid="system-db-size" />
<StatRow
label="WAL"
value={formatBytes(database.wal_size_bytes)}
testid="system-db-wal"
info={WAL_HELP}
/>
<StatRow
label="Schema version"
value={database.schema_version || "-"}
testid="system-db-schema-version"
/>
<StatRow
label="Last backup"
value={formatTimestamp(database.last_backup_at)}
testid="system-db-last-backup"
/>
</div>
);
}
// Plain-language descriptions of each maintenance operation. Surfaced as a
// short paragraph next to the button + an on-hover tooltip so users without
// database background know what each action does and when to use it.
const VACUUM_HELP =
"Reclaims unused space inside the database file. The file can grow over time as you delete tasks or sessions; vacuum compacts it back down. Safe to run anytime - it does not change any data. Use it once in a while if the database size looks large.";
const OPTIMIZE_HELP =
"Asks the database to refresh its internal indexes so common queries stay fast. Run it after deleting a lot of data or if the app feels sluggish. Quick and safe - no data is changed.";
const FACTORY_RESET_HELP =
"Wipes ALL Kandev data: tasks, sessions, settings, repositories, and worktrees. There is no undo. A backup is taken first, but restoring it requires using the Backups page after the reset. Only use this if you want to start over from scratch.";
function OperationRow({
testid,
label,
description,
button,
}: {
testid: string;
label: string;
description: string;
button: React.ReactNode;
}) {
return (
<div
className="flex items-start justify-between gap-3 rounded-md border p-3"
data-testid={testid}
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-muted-foreground mt-1">{description}</p>
</div>
<div className="shrink-0">{button}</div>
</div>
);
}
function MaintenanceButtons({
vacuumState,
optimizeState,
onVacuum,
onOptimize,
onResetOpen,
}: {
vacuumState: ActionFeedbackState;
optimizeState: ActionFeedbackState;
onVacuum: () => void;
onOptimize: () => void;
onResetOpen: () => void;
}) {
return (
<div className="space-y-2">
<OperationRow
testid="system-vacuum-row"
label="Vacuum"
description={VACUUM_HELP}
button={
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={vacuumState === "pending"}
onClick={onVacuum}
className="cursor-pointer min-w-[7.5rem] justify-center"
data-testid="system-vacuum-button"
data-state={vacuumState}
>
<ActionButtonContent
state={vacuumState}
idleIcon={<IconBolt className="h-3.5 w-3.5 mr-1" />}
idleLabel="Run vacuum"
/>
</Button>
</TooltipTrigger>
<TooltipContent>{VACUUM_HELP}</TooltipContent>
</Tooltip>
}
/>
<OperationRow
testid="system-optimize-row"
label="Optimize"
description={OPTIMIZE_HELP}
button={
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={optimizeState === "pending"}
onClick={onOptimize}
className="cursor-pointer min-w-[7.5rem] justify-center"
data-testid="system-optimize-button"
data-state={optimizeState}
>
<ActionButtonContent
state={optimizeState}
idleIcon={<IconRefresh className="h-3.5 w-3.5 mr-1" />}
idleLabel="Run optimize"
/>
</Button>
</TooltipTrigger>
<TooltipContent>{OPTIMIZE_HELP}</TooltipContent>
</Tooltip>
}
/>
<OperationRow
testid="system-factory-reset-row"
label="Factory reset"
description={FACTORY_RESET_HELP}
button={
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="destructive"
size="sm"
onClick={onResetOpen}
className="cursor-pointer"
data-testid="system-factory-reset-button"
>
<IconTrash className="h-3.5 w-3.5 mr-1" /> Factory reset
</Button>
</TooltipTrigger>
<TooltipContent>{FACTORY_RESET_HELP}</TooltipContent>
</Tooltip>
}
/>
</div>
);
}
export function DatabaseStatsCard() {
const { database, isLoading, error, reload } = useDatabaseStats();
const vacuum = useActionFeedback();
const optimize = useActionFeedback();
const [resetOpen, setResetOpen] = useState(false);
const onVacuum = () =>
void vacuum.run(async () => {
await vacuumDatabase();
await reload();
});
const onOptimize = () =>
void optimize.run(async () => {
await optimizeDatabase();
});
return (
<Card data-testid="system-database-card">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<IconDatabase className="h-4 w-4" /> Database
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{error && (
<p className="text-xs text-red-500" data-testid="system-database-error">
{error}
</p>
)}
{!database && isLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner className="size-4" /> Loading database stats...
</div>
)}
{database && <StatsTable database={database} />}
<MaintenanceButtons
vacuumState={vacuum.state}
optimizeState={optimize.state}
onVacuum={onVacuum}
onOptimize={onOptimize}
onResetOpen={() => setResetOpen(true)}
/>
<div className="flex flex-col gap-1">
<JobProgressIndicator kind="vacuum" />
<JobProgressIndicator kind="optimize" />
<JobProgressIndicator kind="factory-reset" />
</div>
<FactoryResetDialog open={resetOpen} onOpenChange={setResetOpen} />
</CardContent>
</Card>
);
}