Spaces:
Running
Running
| /** | |
| * 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 "{confirmClear}"? | |
| </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; | |