/** * DuckLake Panel Component * Lakehouse with time travel, ACID transactions, and schema evolution */ import React, { useState, useRef, useCallback, useEffect } from 'react'; import { useDuckLake } from '@/hooks/useDuckLake'; import type { SnapshotInfo, SchemaChange } from '@/hooks/useDuckLake'; import { useLanguage } from '@/contexts/LanguageContext'; interface DuckLakePanelProps { compact?: boolean; } // ============================================ // ResizableNestedTable Component // Self-contained component with its own resize state // ============================================ interface ResizableNestedTableProps { columns: string[]; rows: Record[]; renderCell: (value: unknown, depth: number) => React.ReactNode; depth?: number; tableType?: 'array' | 'kv'; // Array of objects or key-value pairs } // Helper function to estimate the width needed for nested content function estimateNestedWidth(value: unknown, depth: number = 0): number { if (value === null || value === undefined) return 50; // Limit recursion depth to prevent infinite loops if (depth > 3) return 300; if (typeof value !== 'object') { const str = String(value); const isUrl = str.startsWith('http'); const charWidth = isUrl ? 6 : (str.length > 30 ? 6.5 : 7.5); return Math.min(str.length, isUrl ? 60 : 50) * charWidth + 20; } if (Array.isArray(value)) { if (value.length === 0) return 50; // Array of objects - calculate table width if (typeof value[0] === 'object' && value[0] !== null) { const keys = Object.keys(value[0]); let totalWidth = 0; keys.forEach(key => { const headerWidth = key.length * 8 + 30; let maxColWidth = headerWidth; // Sample values in this column value.forEach((item: Record) => { const cellWidth = estimateNestedWidth(item[key], depth + 1); maxColWidth = Math.max(maxColWidth, cellWidth); }); totalWidth += Math.min(400, maxColWidth); }); return Math.max(200, totalWidth + 20); // Add some padding } // Array of primitives let maxWidth = 100; value.forEach(item => { maxWidth = Math.max(maxWidth, estimateNestedWidth(item, depth + 1)); }); return maxWidth; } // Object (key-value table) const entries = Object.entries(value as Record); if (entries.length === 0) return 50; let maxKeyWidth = 100; let maxValueWidth = 150; entries.forEach(([key, val]) => { const keyWidth = key.length * 8 + 30; maxKeyWidth = Math.max(maxKeyWidth, Math.min(200, keyWidth)); const valueWidth = estimateNestedWidth(val, depth + 1); maxValueWidth = Math.max(maxValueWidth, valueWidth); }); return Math.min(200, maxKeyWidth) + Math.min(600, maxValueWidth) + 20; } function ResizableNestedTable({ columns, rows, renderCell, depth = 0, tableType = 'array' }: ResizableNestedTableProps) { const [colWidths, setColWidths] = useState>(() => { // Initialize with smart defaults based on column names and actual content const widths: Record = {}; if (tableType === 'kv') { // For key-value tables, analyze actual key and value lengths let maxKeyLen = 0; let maxValueLen = 0; rows.forEach(row => { const key = Object.keys(row)[0]; const val = row[key]; // Key length (with some padding for the cell) const keyLen = key.length * 8 + 24; if (keyLen > maxKeyLen) maxKeyLen = keyLen; // Value length - calculate recursively for nested content if (val !== null && val !== undefined) { const estimatedWidth = estimateNestedWidth(val, 0); maxValueLen = Math.max(maxValueLen, estimatedWidth); } }); // Set reasonable bounds - allow wider values for nested content widths['key'] = Math.max(100, Math.min(220, maxKeyLen)); widths['value'] = Math.max(150, Math.min(800, maxValueLen)); } else { // For array tables, calculate per-column with recursive estimation columns.forEach(col => { // Estimate width based on column name length const headerLen = col.length * 8 + 30; // Sample ALL rows for better accuracy let maxContentLen = headerLen; rows.forEach(row => { const val = row[col]; if (val !== null && val !== undefined) { const estimatedWidth = estimateNestedWidth(val, 0); maxContentLen = Math.max(maxContentLen, estimatedWidth); } }); widths[col] = Math.max(80, Math.min(500, maxContentLen)); }); } return widths; }); const resizeRef = useRef<{ column: string; startX: number; startWidth: number; } | null>(null); const handleResizeStart = useCallback((e: React.MouseEvent, column: string) => { e.preventDefault(); e.stopPropagation(); const startWidth = colWidths[column] || 100; resizeRef.current = { column, startX: e.clientX, startWidth }; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; }, [colWidths]); useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!resizeRef.current) return; const delta = e.clientX - resizeRef.current.startX; const newWidth = Math.max(50, resizeRef.current.startWidth + delta); setColWidths(prev => ({ ...prev, [resizeRef.current!.column]: newWidth })); }; const handleMouseUp = () => { if (resizeRef.current) { resizeRef.current = null; document.body.style.cursor = ''; document.body.style.userSelect = ''; } }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, []); if (tableType === 'kv') { // Key-value table (for objects) const keyWidth = colWidths['key'] || 120; const valueWidth = colWidths['value'] || 200; const totalWidth = keyWidth + valueWidth; return (
{rows.map((row, idx) => { const key = Object.keys(row)[0]; const val = row[key]; return ( ); })}
{key}
handleResizeStart(e, 'key')} />
{renderCell(val, depth + 1)}
handleResizeStart(e, 'value')} />
); } // Array of objects table // Calculate total width for the table const totalWidth = columns.reduce((sum, col) => sum + (colWidths[col] || 100), 0); return (
{columns.map(col => ( ))} {rows.map((row, idx) => ( {columns.map(col => ( ))} ))}
{col}
handleResizeStart(e, col)} />
{renderCell(row[col], depth + 1)}
); } // ============================================ const TEXT = { title: { nl: 'DuckLake Lakehouse', en: 'DuckLake Lakehouse' }, description: { nl: 'Lakehouse met tijdreizen, ACID-transacties en schema-evolutie', en: 'Lakehouse with time travel, ACID transactions, and schema evolution', }, connected: { nl: 'Verbonden', en: 'Connected' }, disconnected: { nl: 'Niet verbonden', en: 'Disconnected' }, loading: { nl: 'Laden...', en: 'Loading...' }, refresh: { nl: 'Verversen', en: 'Refresh' }, tables: { nl: 'Tabellen', en: 'Tables' }, rows: { nl: 'Rijen', en: 'Rows' }, columns: { nl: 'Kolommen', en: 'Columns' }, snapshots: { nl: 'Snapshots', en: 'Snapshots' }, version: { nl: 'DuckDB Versie', en: 'DuckDB Version' }, responseTime: { nl: 'Responstijd', en: 'Response time' }, uptime: { nl: 'Uptime', en: 'Uptime' }, lastChecked: { nl: 'Laatst gecontroleerd', en: 'Last checked' }, noTables: { nl: 'Geen tabellen. Upload data om te beginnen.', en: 'No tables. Upload data to get started.' }, noSnapshots: { nl: 'Geen snapshots beschikbaar.', en: 'No snapshots available.' }, loadData: { nl: 'Data uploaden', en: 'Upload Data' }, runQuery: { nl: 'Query uitvoeren', en: 'Run Query' }, enterQuery: { nl: 'Voer SQL-query in...', en: 'Enter SQL query...' }, running: { nl: 'Uitvoeren...', en: 'Running...' }, timeTravel: { nl: 'Tijdreizen', en: 'Time Travel' }, schemaEvolution: { nl: 'Schema Evolutie', en: 'Schema Evolution' }, uploadFile: { nl: 'Bestand uploaden', en: 'Upload file' }, tableName: { nl: 'Tabelnaam', en: 'Table name' }, uploadMode: { nl: 'Upload modus', en: 'Upload mode' }, modeAppend: { nl: 'Toevoegen', en: 'Append' }, modeReplace: { nl: 'Vervangen', en: 'Replace' }, modeCreate: { nl: 'Nieuw', en: 'Create' }, dropTable: { nl: 'Verwijderen', en: 'Drop' }, export: { nl: 'Exporteren', en: 'Export' }, snapshotId: { nl: 'Snapshot ID', en: 'Snapshot ID' }, createdAt: { nl: 'Aangemaakt op', en: 'Created at' }, queryAtSnapshot: { nl: 'Query op snapshot', en: 'Query at snapshot' }, currentData: { nl: 'Huidige data', en: 'Current data' }, ducklakeAvailable: { nl: 'DuckLake Actief', en: 'DuckLake Active' }, ducklakeUnavailable: { nl: 'DuckLake Niet Beschikbaar', en: 'DuckLake Unavailable' }, catalogType: { nl: 'Catalogus', en: 'Catalog' }, changeType: { nl: 'Type wijziging', en: 'Change type' }, columnName: { nl: 'Kolom', en: 'Column' }, oldType: { nl: 'Oud type', en: 'Old type' }, newType: { nl: 'Nieuw type', en: 'New type' }, changedAt: { nl: 'Gewijzigd op', en: 'Changed at' }, // Data Explorer explore: { nl: 'Verkennen', en: 'Explore' }, dataExplorer: { nl: 'Data Verkenner', en: 'Data Explorer' }, selectTable: { nl: 'Selecteer een tabel om te verkennen', en: 'Select a table to explore' }, viewData: { nl: 'Bekijk data', en: 'View data' }, previewRows: { nl: 'Voorbeeld rijen', en: 'Preview rows' }, showingRows: { nl: 'Toon rijen', en: 'Showing rows' }, of: { nl: 'van', en: 'of' }, previous: { nl: 'Vorige', en: 'Previous' }, next: { nl: 'Volgende', en: 'Next' }, backToTables: { nl: '← Terug naar tabellen', en: '← Back to tables' }, schema: { nl: 'Schema', en: 'Schema' }, data: { nl: 'Data', en: 'Data' }, // Search search: { nl: 'Zoeken', en: 'Search' }, searchPlaceholder: { nl: 'Zoek in alle kolommen...', en: 'Search across all columns...' }, resultsFiltered: { nl: 'resultaten gefilterd', en: 'results filtered' }, clearSearch: { nl: 'Wissen', en: 'Clear' }, // Disconnected state messages backendRequired: { nl: 'Server-backend Vereist', en: 'Server Backend Required' }, whyBackendTitle: { nl: 'Waarom een backend?', en: 'Why a backend?' }, whyBackendExplanation: { nl: 'DuckLake vereist server-side DuckDB met bestandssysteemtoegang voor de catalogus (SQLite/PostgreSQL) en Parquet-databestanden. De DuckLake-extensie is niet beschikbaar voor DuckDB-WASM in de browser.', en: 'DuckLake requires server-side DuckDB with file system access for the catalog (SQLite/PostgreSQL) and Parquet data files. The DuckLake extension is not available for DuckDB-WASM in the browser.' }, comingSoon: { nl: 'Binnenkort Beschikbaar', en: 'Coming Soon' }, comingSoonExplanation: { nl: 'De DuckLake backend wordt binnenkort uitgerold. In de tussentijd kunt u DuckDB WASM gebruiken voor in-browser query\'s.', en: 'The DuckLake backend is being deployed soon. In the meantime, you can use DuckDB WASM for in-browser queries.' }, featuresTitle: { nl: 'DuckLake Functies', en: 'DuckLake Features' }, featureTimeTravel: { nl: 'Tijdreizen - Query historische snapshots van uw data', en: 'Time Travel - Query historical snapshots of your data' }, featureACID: { nl: 'ACID Transacties - Veilige multi-client schrijfoperaties', en: 'ACID Transactions - Safe multi-client write operations' }, featureSchema: { nl: 'Schema Evolutie - Track alle schemawijzigingen automatisch', en: 'Schema Evolution - Track all schema changes automatically' }, featureOpen: { nl: 'Open Formaat - Parquet bestanden + SQLite/PostgreSQL catalogus', en: 'Open Format - Parquet files + SQLite/PostgreSQL catalog' }, useDuckDBWasm: { nl: 'Gebruik DuckDB WASM', en: 'Use DuckDB WASM' }, }; export function DuckLakePanel({ compact = false }: DuckLakePanelProps) { const { language } = useLanguage(); const t = (key: keyof typeof TEXT) => TEXT[key][language]; const { status, stats, isLoading, error, refresh, executeQuery, uploadData, // dropTable - will add back when delete UI is implemented exportTable, } = useDuckLake(); const [activeTab, setActiveTab] = useState<'explore' | 'query' | 'timetravel' | 'schema' | 'upload'>('explore'); const [query, setQuery] = useState('SELECT * FROM information_schema.tables LIMIT 100'); const [queryResult, setQueryResult] = useState(null); const [isQueryRunning, setIsQueryRunning] = useState(false); const [selectedSnapshot, setSelectedSnapshot] = useState(undefined); const [uploadTableName, setUploadTableName] = useState(''); const [uploadMode, setUploadMode] = useState<'append' | 'replace' | 'create'>('append'); const fileInputRef = useRef(null); // Data Explorer state const [selectedTable, setSelectedTable] = useState(null); const [tableData, setTableData] = useState<{ columns: string[]; rows: unknown[][] } | null>(null); const [isLoadingData, setIsLoadingData] = useState(false); const [dataPage, setDataPage] = useState(0); const [explorerView, setExplorerView] = useState<'schema' | 'data'>('data'); const [expandedRow, setExpandedRow] = useState<{ rowIndex: number; data: Record } | null>(null); const [columnWidths, setColumnWidths] = useState>({}); const [rowHeights, setRowHeights] = useState>({}); const [isFullWidth, setIsFullWidth] = useState(false); // Full-width mode for row detail view const [showWebArchive, setShowWebArchive] = useState(false); // Web archive viewer mode const [searchQuery, setSearchQuery] = useState(''); // Search filter for table data const [webArchiveData, setWebArchiveData] = useState<{ ghcid: string; url: string; pages: { title: string; path: string; archived_file: string }[]; claims: { claim_id: string; claim_type: string; text_content: string; xpath: string; hypernym: string; source_page: string }[]; } | null>(null); const [selectedWebPage, setSelectedWebPage] = useState(null); const PAGE_SIZE = 50; // Refs for resize tracking const resizeRef = useRef<{ type: 'column' | 'row'; key: string | number; startPos: number; startSize: number; } | null>(null); const tableRef = useRef(null); // Calculate optimal column width based on content const calculateOptimalWidth = useCallback((columnName: string, values: unknown[]): number => { const headerLen = columnName.length * 9; // Approximate char width let maxContentLen = 0; for (const val of values.slice(0, 20)) { // Sample first 20 rows if (val === null || val === undefined) continue; const str = typeof val === 'object' ? JSON.stringify(val) : String(val); const len = Math.min(str.length, 100) * 8; // Cap at 100 chars if (len > maxContentLen) maxContentLen = len; } // Return width between 80 and 400px return Math.max(80, Math.min(400, Math.max(headerLen, maxContentLen) + 24)); }, []); // Initialize column widths when table data loads useEffect(() => { if (tableData && tableData.columns.length > 0) { const widths: Record = {}; tableData.columns.forEach((col, idx) => { const colValues = tableData.rows.map(row => row[idx]); widths[col] = calculateOptimalWidth(col, colValues); }); setColumnWidths(widths); setRowHeights({}); // Reset row heights } }, [tableData, calculateOptimalWidth]); // Handle mouse down on column resize handle const handleColumnResizeStart = useCallback((e: React.MouseEvent, columnName: string) => { e.preventDefault(); e.stopPropagation(); resizeRef.current = { type: 'column', key: columnName, startPos: e.clientX, startSize: columnWidths[columnName] || 150 }; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; }, [columnWidths]); // Handle mouse down on row resize handle const handleRowResizeStart = useCallback((e: React.MouseEvent, rowIndex: number, currentHeight: number) => { e.preventDefault(); e.stopPropagation(); resizeRef.current = { type: 'row', key: rowIndex, startPos: e.clientY, startSize: currentHeight }; document.body.style.cursor = 'row-resize'; document.body.style.userSelect = 'none'; }, []); // Handle mouse move during resize useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!resizeRef.current) return; const { type, key, startPos, startSize } = resizeRef.current; if (type === 'column') { const delta = e.clientX - startPos; const newWidth = Math.max(60, startSize + delta); setColumnWidths(prev => ({ ...prev, [key]: newWidth })); } else { const delta = e.clientY - startPos; const newHeight = Math.max(32, startSize + delta); setRowHeights(prev => ({ ...prev, [key as number]: newHeight })); } }; const handleMouseUp = () => { if (resizeRef.current) { resizeRef.current = null; document.body.style.cursor = ''; document.body.style.userSelect = ''; } }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, []); const handleRunQuery = async () => { setIsQueryRunning(true); setQueryResult(null); try { const result = await executeQuery(query, selectedSnapshot); const output = { columns: result.columns, rowCount: result.rowCount, executionTime: `${result.executionTimeMs}ms`, snapshotId: result.snapshotId ?? 'current', rows: result.rows.slice(0, 100), // Limit display }; setQueryResult(JSON.stringify(output, null, 2)); } catch (err) { setQueryResult(`Error: ${err instanceof Error ? err.message : 'Unknown error'}`); } finally { setIsQueryRunning(false); } }; const handleFileUpload = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file || !uploadTableName) return; try { const result = await uploadData(file, uploadTableName, uploadMode); alert(`${result.message}\nRows: ${result.rowsInserted}\nSnapshot ID: ${result.snapshotId}`); if (fileInputRef.current) { fileInputRef.current.value = ''; } setUploadTableName(''); } catch (err) { alert(`Failed to upload: ${err instanceof Error ? err.message : 'Unknown error'}`); } }; const handleExport = async (tableName: string, format: 'json' | 'csv' | 'parquet' = 'json') => { try { const blob = await exportTable(tableName, format, selectedSnapshot); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${tableName}${selectedSnapshot ? `_snapshot${selectedSnapshot}` : ''}.${format}`; a.click(); URL.revokeObjectURL(url); } catch (err) { alert(`Export failed: ${err instanceof Error ? err.message : 'Unknown error'}`); } }; // handleDropTable removed - will add back when delete UI is implemented const formatUptime = (seconds: number): string => { if (seconds < 60) return `${Math.round(seconds)}s`; if (seconds < 3600) return `${Math.round(seconds / 60)}m`; if (seconds < 86400) return `${Math.round(seconds / 3600)}h`; return `${Math.round(seconds / 86400)}d`; }; // Load table data for explorer const loadTableData = async (tableName: string) => { setIsLoadingData(true); setSelectedTable(tableName); setDataPage(0); setSearchQuery(''); // Reset search when loading new table try { const offset = 0; // Tables are in the 'heritage' schema within DuckLake const fullTableName = `heritage.${tableName}`; const result = await executeQuery( `SELECT * FROM ${fullTableName} LIMIT ${PAGE_SIZE} OFFSET ${offset}`, selectedSnapshot ); setTableData({ columns: result.columns, rows: result.rows }); } catch (err) { console.error('Failed to load table data:', err); setTableData(null); } finally { setIsLoadingData(false); } }; // Load more data for pagination const loadPage = async (page: number) => { if (!selectedTable) return; setIsLoadingData(true); setDataPage(page); try { const offset = page * PAGE_SIZE; // Tables are in the 'heritage' schema within DuckLake const fullTableName = `heritage.${selectedTable}`; const result = await executeQuery( `SELECT * FROM ${fullTableName} LIMIT ${PAGE_SIZE} OFFSET ${offset}`, selectedSnapshot ); setTableData({ columns: result.columns, rows: result.rows }); } catch (err) { console.error('Failed to load page:', err); } finally { setIsLoadingData(false); } }; // Get table icon based on name const getTableIcon = (tableName: string): string => { const name = tableName.toLowerCase(); if (name.includes('custodian') || name.includes('institution')) return '🏛️'; if (name.includes('collection')) return '📚'; if (name.includes('location') || name.includes('geo')) return '📍'; if (name.includes('person') || name.includes('staff')) return '👤'; if (name.includes('event')) return '📅'; if (name.includes('identifier') || name.includes('id')) return '🔖'; if (name.includes('raw')) return '📦'; return '📊'; }; // Format cell value for display const formatCellValue = (value: unknown): string => { if (value === null || value === undefined) return '—'; if (typeof value === 'object') { const json = JSON.stringify(value); if (json.length > 50) return json.slice(0, 47) + '...'; return json; } if (typeof value === 'string') { // Check if it's a JSON string if ((value.startsWith('{') || value.startsWith('[')) && value.length > 50) { return value.slice(0, 47) + '...'; } if (value.length > 80) return value.slice(0, 77) + '...'; } return String(value); }; // Check if a cell value is expandable (JSON object/array or long string) const isExpandable = (value: unknown): boolean => { if (value === null || value === undefined) return false; if (typeof value === 'object') return true; if (typeof value === 'string') { // JSON strings or long text if (value.startsWith('{') || value.startsWith('[')) return true; if (value.length > 80) return true; } return false; }; // Parse a value that might be JSON string into an object const parseJsonValue = (value: unknown): unknown => { if (value === null || value === undefined) return null; if (typeof value === 'object') return value; if (typeof value === 'string' && (value.startsWith('{') || value.startsWith('['))) { try { return JSON.parse(value); } catch { return value; } } return value; }; // Convert a row array to an object with column names as keys const rowToObject = (row: unknown[], columns: string[]): Record => { const obj: Record = {}; columns.forEach((col, idx) => { obj[col] = row[idx]; }); return obj; }; // Get a display name for a row (try common name fields) const getRowDisplayName = (rowObj: Record): string => { const nameFields = ['org_name', 'custodian_name', 'name', 'title', 'file_name']; for (const field of nameFields) { if (rowObj[field] && typeof rowObj[field] === 'string') { return rowObj[field] as string; } } return 'Record'; }; // Filter rows based on search query (case-insensitive across all columns) const filterRowsBySearch = useCallback((rows: unknown[][], _columns: string[], query: string): unknown[][] => { if (!query.trim()) return rows; const lowerQuery = query.toLowerCase(); return rows.filter(row => { return row.some(cell => { if (cell === null || cell === undefined) return false; const cellStr = typeof cell === 'object' ? JSON.stringify(cell).toLowerCase() : String(cell).toLowerCase(); return cellStr.includes(lowerQuery); }); }); }, []); // Get GHCID from row data (try common GHCID fields) const getRowGhcid = (rowObj: Record): string | null => { const ghcidFields = ['ghcid', 'ghcid_current', 'file_name']; for (const field of ghcidFields) { if (rowObj[field] && typeof rowObj[field] === 'string') { const val = rowObj[field] as string; // GHCID pattern: XX-XX-XXX-X-XXX if (val.match(/^[A-Z]{2}-[A-Z0-9]{2,3}-[A-Z]{3}-[A-Z]-/)) { return val.replace('.yaml', ''); } } } return null; }; // Load web archive data for a GHCID const loadWebArchive = async (ghcid: string) => { if (!ghcid) return; try { // Query the web_archives table for this GHCID const archiveResult = await executeQuery( `SELECT ghcid, url, domain, total_pages FROM heritage.web_archives WHERE ghcid = '${ghcid}'`, selectedSnapshot ); if (archiveResult.rows.length === 0) { setWebArchiveData(null); return; } const archive = archiveResult.rows[0]; const url = archive[1] as string; // Get pages const pagesResult = await executeQuery( `SELECT page_title, source_path, archived_file FROM heritage.web_pages WHERE ghcid = '${ghcid}' LIMIT 50`, selectedSnapshot ); // Get claims (including source_page for filtering) const claimsResult = await executeQuery( `SELECT claim_id, claim_type, text_content, xpath, hypernym, source_page FROM heritage.web_claims WHERE ghcid = '${ghcid}' LIMIT 200`, selectedSnapshot ); setWebArchiveData({ ghcid, url, pages: pagesResult.rows.map(r => ({ title: r[0] as string, path: r[1] as string, archived_file: r[2] as string })), claims: claimsResult.rows.map(r => ({ claim_id: r[0] as string, claim_type: r[1] as string, text_content: r[2] as string, xpath: r[3] as string, hypernym: r[4] as string, source_page: r[5] as string })) }); setShowWebArchive(true); } catch (err) { console.error('Failed to load web archive:', err); setWebArchiveData(null); } }; // Render a value - either simple or as nested table with resizable columns const renderValue = (value: unknown, depth = 0): React.ReactNode => { if (value === null || value === undefined) return ; const parsed = parseJsonValue(value); // Simple values if (typeof parsed !== 'object') { const strVal = String(parsed); if (strVal.startsWith('http://') || strVal.startsWith('https://')) { return {strVal}; } return {strVal}; } // Arrays if (Array.isArray(parsed)) { if (parsed.length === 0) return [ ]; // Array of objects - render as resizable sub-table if (typeof parsed[0] === 'object' && parsed[0] !== null) { const keys = Object.keys(parsed[0]); return (
[]} renderCell={renderValue} depth={depth} tableType="array" />
); } // Array of primitives return (
    {parsed.map((item, idx) => (
  • {renderValue(item, depth + 1)}
  • ))}
); } // Objects - render as resizable key-value table const entries = Object.entries(parsed as Record); if (entries.length === 0) return { }; // Convert entries to rows format for ResizableNestedTable const kvRows = entries.map(([key, val]) => ({ [key]: val })); return (
); }; // Compact view for comparison grid if (compact) { // Show informative disconnected state if (!status.isConnected && !isLoading) { return (
🦆

DuckLake

{t('backendRequired')}

{t('comingSoonExplanation')}

⏱️ Time Travel 🔒 ACID 📊 Schema Evolution
); } return (
🦆

DuckLake

{isLoading ? t('loading') : status.isConnected ? t('connected') : t('disconnected')}
{stats?.totalTables ?? 0} {t('tables')}
{stats?.totalRows?.toLocaleString() ?? 0} {t('rows')}
{stats?.totalSnapshots ?? 0} {t('snapshots')}
{status.responseTimeMs && (
{status.responseTimeMs}ms {t('responseTime')}
)}
{status.ducklakeAvailable ? '⏱️ Time Travel' : '⚠️ Standard Mode'}
{error &&
{error.message}
}
); } // Full view // Show informative disconnected state for full view if (!status.isConnected && !isLoading) { return (
{/* Header */}
🦆

{t('title')}

{t('description')}

{t('backendRequired')}
{/* Disconnected Content */}
{/* Why Backend Section */}

🔧 {t('whyBackendTitle')}

{t('whyBackendExplanation')}

{/* Coming Soon Section */}

🚀 {t('comingSoon')}

{t('comingSoonExplanation')}

{/* Features Section */}

✨ {t('featuresTitle')}

  • ⏱️ {t('featureTimeTravel')}
  • 🔒 {t('featureACID')}
  • 📊 {t('featureSchema')}
  • 📁 {t('featureOpen')}
{/* Alternative Action */}

🦆 {t('useDuckDBWasm')} {' '}— {language === 'nl' ? 'In-browser SQL-query\'s op heritage-datasets' : 'In-browser SQL queries on heritage datasets'}

); } return (
{/* Header */}
🦆

{t('title')}

{t('description')}

{isLoading ? t('loading') : status.isConnected ? t('connected') : t('disconnected')} {status.ducklakeAvailable ? t('ducklakeAvailable') : t('ducklakeUnavailable')}
{/* Stats Overview */}
{stats?.totalTables ?? 0} {t('tables')}
{stats?.totalRows?.toLocaleString() ?? 0} {t('rows')}
{stats?.totalSnapshots ?? 0} {t('snapshots')}
{status.duckdbVersion || 'N/A'} {t('version')}
{status.catalogType || 'N/A'} {t('catalogType')}
{status.uptimeSeconds && (
{formatUptime(status.uptimeSeconds)} {t('uptime')}
)} {status.responseTimeMs && (
{status.responseTimeMs}ms {t('responseTime')}
)}
{error && (
Error: {error.message}
)} {/* Tabs */}
{/* Tab Content */}
{/* Data Explorer Tab */} {activeTab === 'explore' && (
{!selectedTable ? ( /* Table Selection Grid */
{!stats?.tables.length ? (

{t('noTables')}

) : ( <>

{t('selectTable')}

{stats.tables.map((table) => (
loadTableData(table.name)} role="button" tabIndex={0} onKeyDown={(e) => e.key === 'Enter' && loadTableData(table.name)} >
{getTableIcon(table.name)}

{table.name}

{table.rowCount.toLocaleString()} {t('rows')} {table.columnCount} {t('columns')}
))}
)}
) : ( /* Table Data Viewer */
{getTableIcon(selectedTable)}

{selectedTable}

{stats?.tables.find(t => t.name === selectedTable) && ( {stats.tables.find(t => t.name === selectedTable)?.rowCount.toLocaleString()} {t('rows')} )}
{/* Search Bar */} {explorerView === 'data' && !expandedRow && (
🔍 setSearchQuery(e.target.value)} placeholder={t('searchPlaceholder')} className="search-input" /> {searchQuery && ( )}
{searchQuery && tableData && ( {filterRowsBySearch(tableData.rows, tableData.columns, searchQuery).length} / {tableData.rows.length} {t('resultsFiltered')} )}
)} {explorerView === 'schema' ? ( /* Schema View */
{stats?.tables.find(t => t.name === selectedTable)?.columns.map((col, idx) => (
{idx + 1} {col.name} {col.type}
))}
) : ( /* Data View */
{isLoadingData ? (
{t('loading')}
) : expandedRow ? ( /* Expanded Row Detail View */

📋 {getRowDisplayName(expandedRow.data)}

Row {expandedRow.rowIndex} {/* Web Archive Button */} {getRowGhcid(expandedRow.data) && ( )}
{/* Web Archive Viewer */} {showWebArchive && webArchiveData && (
🗄️ Web Archive: {webArchiveData.ghcid}
{webArchiveData.url} ↗
{/* Archived Pages - Wayback style */}
📄 Archived Pages ({webArchiveData.pages.length})
{selectedWebPage && ( )}
{webArchiveData.pages.map((page, idx) => { // Extract filename from archived_file (e.g., "pages/index.html" -> "index.html") const pageFile = page.archived_file?.split('/').pop() || ''; return (
setSelectedWebPage( selectedWebPage === pageFile ? null : pageFile )} > {page.title || 'Untitled'} {page.path}
); })} {webArchiveData.pages.length === 0 && (
No archived pages found
)}
{/* Extracted Claims */}
{(() => { // Filter claims by selected page const filteredClaims = selectedWebPage ? webArchiveData.claims.filter(c => c.source_page === selectedWebPage) : webArchiveData.claims; return ( <>
🏷️ Extracted Claims ({filteredClaims.length} {selectedWebPage && ` / ${webArchiveData.claims.length} total`}) {selectedWebPage && ( — filtered by page )}
{selectedWebPage ? `Showing claims from: ${selectedWebPage}. Click "Clear" above to see all.` : 'Click a page to filter claims from that page only.' }
{filteredClaims.map((claim, idx) => (
{claim.claim_type} {claim.hypernym && ( {claim.hypernym} )}
{claim.text_content}
{claim.xpath && (
📍 {claim.xpath.substring(0, 60)}...
)}
))} {filteredClaims.length === 0 && (
{selectedWebPage ? 'No claims extracted from this page' : 'No claims extracted' }
)}
); })()}
)}
{Object.entries(expandedRow.data).map(([key, value]) => { const hasNestedData = isExpandable(value); return (
{key} {hasNestedData && JSON}
{renderValue(value)}
); })}
) : tableData ? ( <>
{tableData.columns.map((col) => ( ))} {(() => { const filteredRows = filterRowsBySearch(tableData.rows, tableData.columns, searchQuery); return filteredRows.map((row, rowIdx) => { const rowObj = rowToObject(row, tableData.columns); const rowHeight = rowHeights[rowIdx]; // Find original index for accurate row numbering const originalIdx = tableData.rows.indexOf(row); return ( setExpandedRow({ rowIndex: dataPage * PAGE_SIZE + originalIdx + 1, data: rowObj })} > {row.map((cell, cellIdx) => ( ))} ); }); })()}
{col}
handleColumnResizeStart(e, col)} />
handleRowResizeStart(e, rowIdx, rowHeight || 40)} onClick={(e) => e.stopPropagation()} />
{formatCellValue(cell)}
{searchQuery ? `${filterRowsBySearch(tableData.rows, tableData.columns, searchQuery).length} ${t('resultsFiltered')}` : `${t('showingRows')} ${dataPage * PAGE_SIZE + 1} - ${dataPage * PAGE_SIZE + tableData.rows.length}` }
) : (

No data available

)}
)}
)}
)} {/* Query Tab */} {activeTab === 'query' && (
{status.ducklakeAvailable && stats?.snapshots && stats.snapshots.length > 0 && (
)}