AUDIT / src /components /DBBrowserPanel.tsx
Arypulka98's picture
feat(audit): deploy full backend cluster node
aea470f verified
Raw
History Blame Contribute Delete
15.6 kB
/**
* DBBrowserPanel.tsx — S746: Integrated DB browser in-app (Gap 6 IDE Analysis)
*
* Esplora le table Dexie (IndexedDB) dal device:
* - Conteggio record per table + preview ultimi 5
* - Export tabella come JSON (download)
* - Clear tabella (con conferma)
* - Quota storage totale (navigator.storage.estimate)
*
* Sprint: S746 — Gap 6 IDE Analysis (Integrated DB — database browser in-app)
*/
import { useState, useEffect, useCallback, memo } from "react";
import type * as React from "react";
import { X, Database, RefreshCw, Download, Trash2, ChevronDown, ChevronRight, AlertCircle } from "lucide-react";
import { Z_INDEX } from "@/lib/zindex";
import { vfsDb } from "@/lib/vfsDb";
import type { Table } from "dexie";
interface DBBrowserPanelProps { onClose: () => void; }
interface TableInfo {
name: string;
label: string;
count: number | null;
preview: unknown[];
loading: boolean;
open: boolean;
error?: string;
}
// ─── Table definitions ────────────────────────────────────────────────────────
const TABLES: Array<{ name: string; label: string; key: keyof typeof vfsDb }> = [
{ name: "files", label: "📁 File VFS", key: "files" },
{ name: "messages", label: "💬 Messaggi", key: "messages" },
{ name: "conversations",label: "🗂 Conversazioni", key: "conversations" },
{ name: "tasks", label: "⚙️ Task agente", key: "tasks" },
{ name: "snapshots", label: "📸 Snapshot", key: "snapshots" },
{ name: "agentMemory", label: "🧠 Memoria agente", key: "agentMemory" },
{ name: "agentContext", label: "📋 Contesto agente", key: "agentContext" },
{ name: "skillUsage", label: "🔧 Skill usage", key: "skillUsage" },
{ name: "telemetry", label: "📊 Telemetria", key: "telemetry" },
{ name: "diagnosticsLog",label: "🔴 Diagnostics log", key: "diagnosticsLog" },
{ name: "runtimeEvents",label: "⚡ Runtime events", key: "runtimeEvents" },
{ name: "providerUsage",label: "🔑 Provider usage", key: "providerUsage" },
{ name: "sessions", label: "📂 Sessioni", key: "sessions" },
{ name: "fileBackups", label: "💾 File backups", key: "fileBackups" },
];
// ─── Typed table accessor (avoids `as any` on dynamic Dexie table lookup) ────
type VfsTable = Table<unknown, unknown>;
function getTable(name: string): VfsTable | undefined {
const entry = TABLES.find(t => t.name === name);
return entry ? (vfsDb[entry.key] as unknown as VfsTable) : undefined;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatBytes(b: number): string {
if (b < 1024) return `${b} B`;
if (b < 1_048_576) return `${(b / 1024).toFixed(1)} KB`;
return `${(b / 1_048_576).toFixed(2)} MB`;
}
function previewValue(v: unknown, depth = 0): string {
if (v === null || v === undefined) return String(v);
if (typeof v === "string") return v.length > 80 ? v.slice(0, 80) + "…" : v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
if (Array.isArray(v)) return `[${v.length} items]`;
if (typeof v === "object" && depth < 2) {
const entries = Object.entries(v as Record<string, unknown>).slice(0, 3);
return "{" + entries.map(([k, val]) => `${k}: ${previewValue(val, depth + 1)}`).join(", ") + (Object.keys(v as object).length > 3 ? ", …" : "") + "}";
}
return "[object]";
}
async function exportTable(tableName: string): Promise<void> {
const table = getTable(tableName);
if (!table) return;
const data = await table.toArray();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${tableName}-${new Date().toISOString().slice(0,10)}.json`;
a.click();
URL.revokeObjectURL(url);
}
// ─── Stili ────────────────────────────────────────────────────────────────────
const iconBtn = (color = "#6366f1"): React.CSSProperties => ({
all: "unset" as const, cursor: "pointer", color, padding: "4px 6px",
borderRadius: 8, display: "flex", alignItems: "center", touchAction: "manipulation",
transition: "background 0.15s",
});
// ─── Componente principale ─────────────────────────────────────────────────────
const DBBrowserPanel = memo(function DBBrowserPanel({ onClose }: DBBrowserPanelProps) {
const [tables, setTables] = useState<TableInfo[]>(
TABLES.map(t => ({ name: t.name, label: t.label, count: null, preview: [], loading: false, open: false }))
);
const [totalCounts, setTotalCounts] = useState<Record<string, number>>({});
const [quota, setQuota] = useState<{ usedMB: number; quotaMB: number; pct: number } | null>(null);
const [loadingAll, setLoadingAll] = useState(false);
const [confirmClear, setConfirmClear] = useState<string | null>(null);
// ── Carica conteggi ──────────────────────────────────────────────────────
const loadCounts = useCallback(async () => {
setLoadingAll(true);
const counts: Record<string, number> = {};
await Promise.all(TABLES.map(async t => {
try {
const table = getTable(t.name);
if (table) counts[t.name] = await table.count();
} catch { counts[t.name] = -1; }
}));
setTotalCounts(counts);
setTables(prev => prev.map(t => ({ ...t, count: counts[t.name] ?? null })));
setLoadingAll(false);
// Quota storage
if (navigator.storage?.estimate) {
try {
const { usage = 0, quota: q = 0 } = await navigator.storage.estimate();
setQuota({ usedMB: usage / 1_048_576, quotaMB: q / 1_048_576, pct: q > 0 ? (usage / q) * 100 : 0 });
} catch { /**/ }
}
}, []);
useEffect(() => { void loadCounts(); }, [loadCounts]);
// ── Toggle table expansion ─────────────────────────────────────────────
const toggleTable = useCallback(async (tableName: string) => {
setTables(prev => prev.map(t => {
if (t.name !== tableName) return t;
if (t.open) return { ...t, open: false };
return { ...t, open: true, loading: true };
}));
try {
const table = getTable(tableName);
const preview = table ? await table.orderBy(":id").reverse().limit(5).toArray() : [];
setTables(prev => prev.map(t => t.name === tableName ? { ...t, loading: false, preview } : t));
} catch (e) {
setTables(prev => prev.map(t => t.name === tableName ? { ...t, loading: false, error: (e as Error).message } : t));
}
}, []);
// ── Clear table ──────────────────────────────────────────────────────────
const clearTable = useCallback(async (tableName: string) => {
try {
const table = getTable(tableName);
if (table) await table.clear();
setConfirmClear(null);
await loadCounts();
} catch (e) {
console.error("Clear failed:", e);
}
}, [loadCounts]);
const totalRecords = Object.values(totalCounts).reduce((s, n) => s + (n > 0 ? n : 0), 0);
return (
<div role="dialog" aria-modal="true" aria-label="Database"
style={{ position: "fixed", inset: 0, zIndex: Z_INDEX.MODAL, display: "flex", justifyContent: "flex-end" }}>
<div onClick={onClose} style={{ flex: 1, background: "rgba(0,0,0,0.45)" }} />
<div style={{
width: "min(100vw, 420px)", height: "100%", overflowY: "auto",
background: "rgba(6,6,18,0.97)", borderLeft: "1px solid rgba(99,102,241,0.18)",
display: "flex", flexDirection: "column",
paddingBottom: "env(safe-area-inset-bottom, 0px)",
}}>
{/* Header */}
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "14px 16px", borderBottom: "1px solid rgba(99,102,241,0.12)", flexShrink: 0 }}>
<Database size={18} color="#6366f1" />
<div style={{ flex: 1 }}>
<span style={{ fontWeight: 700, fontSize: "0.95rem", color: "#d0d0f0" }}>Database (IndexedDB)</span>
<span style={{ marginLeft: 8, fontSize: "0.7rem", color: "#4a4a78" }}>{totalRecords.toLocaleString()} record</span>
</div>
<button onClick={() => void loadCounts()} disabled={loadingAll}
style={{ ...iconBtn(), opacity: loadingAll ? 0.5 : 1 }}>
<RefreshCw size={15} style={{ animation: loadingAll ? "spin 1s linear infinite" : "none" }} />
</button>
<button onClick={onClose} style={iconBtn("#4a4a78")}><X size={18} /></button>
</div>
{/* Storage quota */}
{quota && (
<div style={{ padding: "10px 16px", borderBottom: "1px solid rgba(99,102,241,0.08)", display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ flex: 1 }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 4 }}>
<span style={{ fontSize: "0.7rem", color: "#4a4a78" }}>Storage quota</span>
<span style={{ fontSize: "0.7rem", color: quota.pct > 80 ? "#f87171" : "#4a4a78" }}>
{formatBytes(quota.usedMB * 1_048_576)} / {formatBytes(quota.quotaMB * 1_048_576)} ({quota.pct.toFixed(1)}%)
</span>
</div>
<div style={{ height: 4, background: "rgba(255,255,255,0.06)", borderRadius: 2, overflow: "hidden" }}>
<div style={{ height: "100%", width: `${Math.min(100, quota.pct)}%`, background: quota.pct > 80 ? "#f87171" : quota.pct > 60 ? "#fbbf24" : "#6366f1", borderRadius: 2, transition: "width 0.3s" }} />
</div>
</div>
</div>
)}
{/* Confirm clear modal */}
{confirmClear && (
<div style={{ margin: "10px 16px 0", padding: "12px 14px", background: "rgba(239,68,68,0.10)", border: "1px solid rgba(239,68,68,0.28)", borderRadius: 10 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 10 }}>
<AlertCircle size={15} color="#f87171" />
<span style={{ fontSize: "0.8rem", color: "#f87171", fontWeight: 600 }}>
Eliminare tutti i record di &quot;{confirmClear}&quot;?
</span>
</div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={() => setConfirmClear(null)} style={{ all: "unset", cursor: "pointer", padding: "6px 14px", borderRadius: 7, background: "rgba(255,255,255,0.06)", color: "#a0a0c0", fontSize: "0.78rem", touchAction: "manipulation" }}>Annulla</button>
<button onClick={() => void clearTable(confirmClear)} style={{ all: "unset", cursor: "pointer", padding: "6px 14px", borderRadius: 7, background: "rgba(239,68,68,0.20)", color: "#f87171", fontSize: "0.78rem", fontWeight: 700, touchAction: "manipulation" }}>Elimina</button>
</div>
</div>
)}
{/* Tables list */}
<div style={{ flex: 1, overflowY: "auto", padding: "10px 16px", display: "flex", flexDirection: "column", gap: 4 }}>
{tables.map(t => (
<div key={t.name} style={{ border: "1px solid rgba(99,102,241,0.12)", borderRadius: 10, overflow: "hidden" }}>
{/* Table header */}
<div
style={{ display: "flex", alignItems: "center", gap: 8, padding: "10px 12px", cursor: "pointer", background: t.open ? "rgba(99,102,241,0.06)" : "transparent", transition: "background 0.15s", userSelect: "none", WebkitUserSelect: "none" as React.CSSProperties["WebkitUserSelect"] }}
onClick={() => void toggleTable(t.name)}
>
{t.open ? <ChevronDown size={13} color="#6366f1" /> : <ChevronRight size={13} color="#4a4a78" />}
<span style={{ flex: 1, fontSize: "0.82rem", color: "#c0c0e0" }}>{t.label}</span>
<span style={{ fontSize: "0.7rem", color: t.count === 0 ? "#3a3a6a" : "#6366f1", background: "rgba(99,102,241,0.10)", padding: "1px 8px", borderRadius: 10, fontWeight: 600 }}>
{t.count === null ? "…" : t.count === -1 ? "err" : t.count.toLocaleString()}
</span>
<button onClick={e => { e.stopPropagation(); void exportTable(t.name); }} style={{ ...iconBtn("#4a4a78"), padding: "2px 4px" }} title="Esporta JSON">
<Download size={12} />
</button>
<button onClick={e => { e.stopPropagation(); setConfirmClear(t.name); }} style={{ ...iconBtn("#f87171"), padding: "2px 4px" }} title="Svuota tabella">
<Trash2 size={12} />
</button>
</div>
{/* Expanded records */}
{t.open && (
<div style={{ borderTop: "1px solid rgba(99,102,241,0.08)", padding: "8px 12px" }}>
{t.loading && <p style={{ margin: 0, fontSize: "0.75rem", color: "#4a4a78" }}>⏳ Caricamento…</p>}
{t.error && <p style={{ margin: 0, fontSize: "0.75rem", color: "#f87171" }}>{t.error}</p>}
{!t.loading && t.preview.length === 0 && <p style={{ margin: 0, fontSize: "0.75rem", color: "#3a3a6a" }}>Tabella vuota.</p>}
{t.preview.map((row, i) => (
<div key={i} style={{ padding: "6px 8px", marginBottom: 4, background: "rgba(255,255,255,0.02)", borderRadius: 8, borderLeft: "2px solid rgba(99,102,241,0.25)" }}>
{typeof row === "object" && row !== null
? Object.entries(row as Record<string, unknown>).slice(0, 5).map(([k, v]) => (
<div key={k} style={{ display: "flex", gap: 6, marginBottom: 2 }}>
<code style={{ fontSize: "0.65rem", color: "#6366f1", flexShrink: 0, minWidth: 60, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{k}</code>
<span style={{ fontSize: "0.65rem", color: "#7a7ab0", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1 }}>{previewValue(v)}</span>
</div>
))
: <code style={{ fontSize: "0.65rem", color: "#7a7ab0" }}>{previewValue(row)}</code>
}
</div>
))}
{t.count !== null && t.count > 5 && (
<p style={{ margin: "4px 0 0", fontSize: "0.65rem", color: "#3a3a6a", textAlign: "right" }}>
+ {(t.count - 5).toLocaleString()} altri record — usa Export per vedere tutto
</p>
)}
</div>
)}
</div>
))}
</div>
</div>
</div>
);
});
export default DBBrowserPanel;