← vibesdk
src/routes/chat/components/database-viewer.tsx
12.0 KB
Download file
/**
* DatabaseViewer — read-only inspector for the space-generated app's
* Durable Object SQLite storage.
*
* The LLM exports `class App extends DurableObject`. SpaceDO hosts it as
* a Facet; the inspector wrapper injected into the dynamic worker adds
* `__vibeInspect*` methods to a subclass of App without forcing the LLM
* to write any boilerplate. This component just hits the host inspector
* endpoints (see `worker/api/controllers/appDatabase`).
*/
import { useState } from 'react';
import {
Database as DatabaseIcon,
RefreshCw,
ChevronLeft,
ChevronRight,
Trash2,
} from 'lucide-react';
import { cn } from '@cloudflare/kumo';
import { useDatabaseViewer } from '../hooks/use-database-viewer';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
interface DatabaseViewerProps {
agentId: string;
enabled: boolean;
}
const PAGE_SIZE_OPTIONS = [25, 50, 100, 200];
const MAX_INLINE_CELL_LEN = 80;
export function DatabaseViewer({ agentId, enabled }: DatabaseViewerProps) {
const state = useDatabaseViewer(agentId, { enabled });
const {
tables,
queryResult,
loadingTables,
loadingQuery,
error,
selectedTable,
page,
pageSize,
orderBy,
orderDir,
refreshTables,
refreshQuery,
selectTable,
setPage,
setPageSize,
setOrder,
wipe,
} = state;
const [expandedCell, setExpandedCell] = useState<{ column: string; value: unknown } | null>(null);
const [confirmingWipe, setConfirmingWipe] = useState(false);
const totalPages = queryResult ? Math.max(1, Math.ceil(queryResult.totalCount / pageSize)) : 1;
const refreshAll = async () => {
await refreshTables();
if (selectedTable) await refreshQuery();
};
const onColumnHeaderClick = (column: string) => {
if (orderBy === column) {
if (orderDir === 'asc') setOrder(column, 'desc');
else if (orderDir === 'desc') setOrder(undefined, undefined);
else setOrder(column, 'asc');
} else {
setOrder(column, 'asc');
}
};
return (
<div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden bg-kumo-base text-text-primary">
<div className="flex shrink-0 items-center gap-2 px-3 py-2 border-b border-bg-2 bg-bg-4/40">
<DatabaseIcon className="size-4 text-text-50/70" />
<span className="text-xs font-mono text-text-50/70">App database</span>
<span className="text-xs text-text-50/40 ml-2">read-only</span>
<div className="ml-auto flex items-center gap-1">
<button
className="p-1 hover:bg-kumo-elevated rounded transition-colors text-text-50/70"
onClick={() => void refreshAll()}
title="Refresh"
>
<RefreshCw
className={cn('size-4', (loadingTables || loadingQuery) && 'animate-spin')}
/>
</button>
<button
className="p-1 hover:bg-kumo-elevated rounded transition-colors text-red-500/80 hover:text-red-500"
onClick={() => setConfirmingWipe(true)}
title="Reset database"
>
<Trash2 className="size-4" />
</button>
</div>
</div>
{error && (
<div className="shrink-0 px-3 py-2 text-xs text-red-400 bg-red-500/10 border-b border-red-500/20">
{error}
</div>
)}
<div className="flex-1 flex min-h-0 min-w-0 overflow-hidden">
<div className="w-56 shrink-0 border-r border-bg-2 overflow-y-auto bg-bg-4/20 flex flex-col min-h-0">
<div className="px-3 py-2 text-[10px] uppercase tracking-wider text-text-50/40 font-mono">
Tables
</div>
{loadingTables && (
<div className="px-3 py-2 text-xs text-text-50/50">Loading…</div>
)}
{!loadingTables && tables && tables.tables.length === 0 && (
<div className="px-3 py-4 text-xs text-text-50/50 leading-relaxed">
No tables yet.
<br />
Your app hasn't written anything to its database yet.
</div>
)}
{tables?.tables.map((t) => (
<button
key={t.name}
onClick={() => selectTable(t.name)}
className={cn(
'flex items-center justify-between px-3 py-1.5 text-xs font-mono text-left hover:bg-kumo-elevated transition-colors min-w-0',
selectedTable === t.name && 'bg-kumo-elevated text-text-primary',
)}
>
<span className="truncate">{t.name}</span>
<span className="text-text-50/40 ml-2 shrink-0">{t.rowCount}</span>
</button>
))}
</div>
<div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden">
{!selectedTable && (
<div className="flex-1 flex items-center justify-center text-text-50/50 text-sm">
Select a table to view rows
</div>
)}
{selectedTable && (
<>
<div className="relative flex-1 min-h-0 min-w-0">
<div className="absolute inset-0 overflow-auto">
{loadingQuery && !queryResult && (
<div className="p-4 text-xs text-text-50/50">Loading rows…</div>
)}
{queryResult && queryResult.rows.length === 0 && (
<div className="p-4 text-xs text-text-50/50">No rows.</div>
)}
{queryResult && queryResult.rows.length > 0 && (
<table className="w-max min-w-full text-xs font-mono border-collapse">
<thead className="sticky top-0 z-10 bg-bg-4 border-b border-bg-2">
<tr>
{queryResult.columns.map((col) => (
<th
key={col}
onClick={() => onColumnHeaderClick(col)}
className="px-3 py-1.5 text-left font-medium text-text-50/70 cursor-pointer select-none hover:bg-kumo-elevated whitespace-nowrap max-w-64"
>
<span className="block truncate max-w-64">
{col}
{orderBy === col && (
<span className="ml-1 text-text-primary">
{orderDir === 'asc' ? '↑' : '↓'}
</span>
)}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{queryResult.rows.map((row, i) => (
<tr
key={i}
className="border-b border-bg-2/50 hover:bg-kumo-elevated/30"
>
{queryResult.columns.map((col) => (
<td
key={col}
className="px-3 py-1.5 align-top max-w-64 overflow-hidden whitespace-nowrap"
>
<CellValue
value={row[col]}
onExpand={(v) =>
setExpandedCell({ column: col, value: v })
}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{queryResult && (
<div className="flex items-center gap-3 px-3 py-2 border-t border-bg-2 bg-bg-4/40 text-xs shrink-0 min-w-0">
<span className="text-text-50/60 shrink-0">
{queryResult.totalCount.toLocaleString()} rows
</span>
<span className="text-text-50/30">·</span>
<span className="text-text-50/60 shrink-0">
Page {page + 1} / {totalPages}
</span>
<button
className="p-1 rounded hover:bg-kumo-elevated text-text-50/60 hover:text-text-primary disabled:opacity-30 disabled:cursor-not-allowed"
onClick={() => setPage(Math.max(0, page - 1))}
disabled={page === 0}
>
<ChevronLeft className="size-3.5" />
</button>
<button
className="p-1 rounded hover:bg-kumo-elevated text-text-50/60 hover:text-text-primary disabled:opacity-30 disabled:cursor-not-allowed"
onClick={() => setPage(Math.min(totalPages - 1, page + 1))}
disabled={page >= totalPages - 1}
>
<ChevronRight className="size-3.5" />
</button>
<div className="ml-auto flex items-center gap-1 shrink-0">
<span className="text-text-50/50">per page</span>
<select
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
className="bg-kumo-elevated border border-bg-1 rounded px-1 py-0.5 text-text-primary"
>
{PAGE_SIZE_OPTIONS.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</div>
</div>
)}
</>
)}
</div>
</div>
<Dialog open={!!expandedCell} onOpenChange={(open) => !open && setExpandedCell(null)}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="font-mono text-sm">{expandedCell?.column}</DialogTitle>
</DialogHeader>
{expandedCell && (
<div className="overflow-auto max-h-[60vh]">
<ExpandedCellView value={expandedCell.value} />
</div>
)}
</DialogContent>
</Dialog>
<Dialog open={confirmingWipe} onOpenChange={setConfirmingWipe}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Reset app database?</DialogTitle>
</DialogHeader>
<p className="text-sm text-text-50/80">
This drops every table in your app's Durable Object. The next request the app
handles will recreate whatever schema its <code>CREATE TABLE IF NOT EXISTS</code>{' '}
calls define. This cannot be undone.
</p>
<div className="flex justify-end gap-2 mt-4">
<Button variant="outline" onClick={() => setConfirmingWipe(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={async () => {
await wipe();
setConfirmingWipe(false);
}}
>
Reset
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
// ─── Cell rendering ─────────────────────────────────────────────────────────
function CellValue({
value,
onExpand,
}: {
value: unknown;
onExpand: (v: unknown) => void;
}) {
if (value === null || value === undefined) {
return <span className="text-text-50/30 italic">null</span>;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return <span>{String(value)}</span>;
}
if (typeof value === 'string') {
if (value.length <= MAX_INLINE_CELL_LEN) {
return <span className="truncate max-w-64 inline-block align-bottom" title={value}>{value}</span>;
}
return (
<button
className="text-left text-text-primary/80 hover:text-text-primary underline-offset-2 hover:underline truncate max-w-64 inline-block align-bottom"
onClick={() => onExpand(value)}
title="Click to view full value"
>
{value.slice(0, MAX_INLINE_CELL_LEN)}…
</button>
);
}
const repr = typeof value === 'object' ? JSON.stringify(value) : String(value);
if (repr.length <= MAX_INLINE_CELL_LEN) {
return <span className="truncate max-w-64 inline-block align-bottom" title={repr}>{repr}</span>;
}
return (
<button
className="text-left text-text-primary/80 hover:text-text-primary underline-offset-2 hover:underline truncate max-w-64 inline-block align-bottom"
onClick={() => onExpand(value)}
title="Click to view full value"
>
{repr.slice(0, MAX_INLINE_CELL_LEN)}…
</button>
);
}
function ExpandedCellView({ value }: { value: unknown }) {
if (value === null || value === undefined) {
return <pre className="text-xs">null</pre>;
}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return (
<pre className="text-xs font-mono whitespace-pre-wrap break-all">
{JSON.stringify(parsed, null, 2)}
</pre>
);
} catch {
return (
<pre className="text-xs font-mono whitespace-pre-wrap break-all">{value}</pre>
);
}
}
if (typeof value === 'object') {
return (
<pre className="text-xs font-mono whitespace-pre-wrap break-all">
{JSON.stringify(value, null, 2)}
</pre>
);
}
return <pre className="text-xs font-mono">{String(value)}</pre>;
}