glam/frontend/src/components/database/DuckLakePanel.tsx
kempersc 810022d524 feat(frontend): add search filter and claim page filter to DuckLakePanel
- Add search bar to filter table data across all columns
- Filter web archive claims by selected page
- Include source_page in claim queries for filtering
- Fix TypeScript unused parameter warning
2025-12-07 19:20:40 +01:00

1695 lines
68 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<string, unknown>[];
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<string, unknown>) => {
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<string, unknown>);
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<Record<string, number>>(() => {
// Initialize with smart defaults based on column names and actual content
const widths: Record<string, number> = {};
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 (
<div className="nested-table-wrapper" style={{ marginLeft: depth * 8, overflowX: 'auto' }}>
<table className="nested-table kv-table resizable-nested" style={{ tableLayout: 'fixed', width: totalWidth }}>
<colgroup>
<col style={{ width: keyWidth }} />
<col style={{ width: valueWidth }} />
</colgroup>
<tbody>
{rows.map((row, idx) => {
const key = Object.keys(row)[0];
const val = row[key];
return (
<tr key={idx}>
<th
className="kv-key"
style={{ width: keyWidth, maxWidth: keyWidth }}
>
<span className="kv-key-text">{key}</span>
<div
className="col-resize-handle nested-resize"
onMouseDown={(e) => handleResizeStart(e, 'key')}
/>
</th>
<td
className="kv-value"
style={{ width: valueWidth, maxWidth: valueWidth, position: 'relative' }}
>
<div className="nested-cell-content">{renderCell(val, depth + 1)}</div>
<div
className="col-resize-handle nested-resize"
onMouseDown={(e) => handleResizeStart(e, 'value')}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// Array of objects table
// Calculate total width for the table
const totalWidth = columns.reduce((sum, col) => sum + (colWidths[col] || 100), 0);
return (
<div className="nested-table-wrapper" style={{ marginLeft: depth * 8, overflowX: 'auto' }}>
<table className="nested-table resizable-nested" style={{ tableLayout: 'fixed', width: totalWidth }}>
<thead>
<tr>
{columns.map(col => (
<th
key={col}
style={{ width: colWidths[col] || 100, minWidth: 40 }}
className="resizable-nested-header"
>
<span className="nested-header-text">{col}</span>
<div
className="col-resize-handle nested-resize"
onMouseDown={(e) => handleResizeStart(e, col)}
/>
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, idx) => (
<tr key={idx}>
{columns.map(col => (
<td key={col} style={{ width: colWidths[col] || 100, maxWidth: colWidths[col] || 100 }}>
<div className="nested-cell-content">
{renderCell(row[col], depth + 1)}
</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
// ============================================
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<string | null>(null);
const [isQueryRunning, setIsQueryRunning] = useState(false);
const [selectedSnapshot, setSelectedSnapshot] = useState<number | undefined>(undefined);
const [uploadTableName, setUploadTableName] = useState('');
const [uploadMode, setUploadMode] = useState<'append' | 'replace' | 'create'>('append');
const fileInputRef = useRef<HTMLInputElement>(null);
// Data Explorer state
const [selectedTable, setSelectedTable] = useState<string | null>(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<string, unknown> } | null>(null);
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
const [rowHeights, setRowHeights] = useState<Record<number, number>>({});
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<string | null>(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<HTMLTableElement>(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<string, number> = {};
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<HTMLInputElement>) => {
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<string, unknown> => {
const obj: Record<string, unknown> = {};
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, unknown>): 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, unknown>): 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 <span className="null-value"></span>;
const parsed = parseJsonValue(value);
// Simple values
if (typeof parsed !== 'object') {
const strVal = String(parsed);
if (strVal.startsWith('http://') || strVal.startsWith('https://')) {
return <a href={strVal} target="_blank" rel="noopener noreferrer" className="link-value">{strVal}</a>;
}
return <span className="simple-value">{strVal}</span>;
}
// Arrays
if (Array.isArray(parsed)) {
if (parsed.length === 0) return <span className="empty-value">[ ]</span>;
// Array of objects - render as resizable sub-table
if (typeof parsed[0] === 'object' && parsed[0] !== null) {
const keys = Object.keys(parsed[0]);
return (
<div className="nested-array">
<ResizableNestedTable
columns={keys}
rows={parsed as Record<string, unknown>[]}
renderCell={renderValue}
depth={depth}
tableType="array"
/>
</div>
);
}
// Array of primitives
return (
<ul className="array-list">
{parsed.map((item, idx) => (
<li key={idx}>{renderValue(item, depth + 1)}</li>
))}
</ul>
);
}
// Objects - render as resizable key-value table
const entries = Object.entries(parsed as Record<string, unknown>);
if (entries.length === 0) return <span className="empty-value">{ }</span>;
// Convert entries to rows format for ResizableNestedTable
const kvRows = entries.map(([key, val]) => ({ [key]: val }));
return (
<div className="nested-object">
<ResizableNestedTable
columns={['key', 'value']}
rows={kvRows}
renderCell={renderValue}
depth={depth}
tableType="kv"
/>
</div>
);
};
// Compact view for comparison grid
if (compact) {
// Show informative disconnected state
if (!status.isConnected && !isLoading) {
return (
<div className="db-panel compact ducklake-panel disconnected-state">
<div className="panel-header">
<span className="panel-icon">🦆</span>
<h3>DuckLake</h3>
<span className="status-badge disconnected">
{t('backendRequired')}
</span>
</div>
<div className="panel-info-compact">
<p className="info-text">
{t('comingSoonExplanation')}
</p>
<div className="feature-list-compact">
<span className="feature-chip"> Time Travel</span>
<span className="feature-chip">🔒 ACID</span>
<span className="feature-chip">📊 Schema Evolution</span>
</div>
</div>
</div>
);
}
return (
<div className="db-panel compact ducklake-panel">
<div className="panel-header">
<span className="panel-icon">🦆</span>
<h3>DuckLake</h3>
<span className={`status-badge ${status.isConnected ? 'connected' : 'disconnected'}`}>
{isLoading ? t('loading') : status.isConnected ? t('connected') : t('disconnected')}
</span>
</div>
<div className="panel-stats">
<div className="stat">
<span className="stat-value">{stats?.totalTables ?? 0}</span>
<span className="stat-label">{t('tables')}</span>
</div>
<div className="stat">
<span className="stat-value">{stats?.totalRows?.toLocaleString() ?? 0}</span>
<span className="stat-label">{t('rows')}</span>
</div>
<div className="stat">
<span className="stat-value">{stats?.totalSnapshots ?? 0}</span>
<span className="stat-label">{t('snapshots')}</span>
</div>
{status.responseTimeMs && (
<div className="stat">
<span className="stat-value">{status.responseTimeMs}ms</span>
<span className="stat-label">{t('responseTime')}</span>
</div>
)}
</div>
<div className="panel-features">
<span className={`feature-badge ${status.ducklakeAvailable ? 'active' : 'inactive'}`}>
{status.ducklakeAvailable ? '⏱️ Time Travel' : '⚠️ Standard Mode'}
</span>
</div>
{error && <div className="panel-error">{error.message}</div>}
</div>
);
}
// Full view
// Show informative disconnected state for full view
if (!status.isConnected && !isLoading) {
return (
<div className="db-panel full ducklake-panel disconnected-state">
{/* Header */}
<div className="panel-header-full">
<div className="header-info">
<span className="panel-icon-large">🦆</span>
<div>
<h2>{t('title')}</h2>
<p>{t('description')}</p>
</div>
</div>
<div className="header-actions">
<span className="status-badge large disconnected">
{t('backendRequired')}
</span>
<button onClick={refresh} disabled={isLoading} className="refresh-button">
{t('refresh')}
</button>
</div>
</div>
{/* Disconnected Content */}
<div className="disconnected-content">
{/* Why Backend Section */}
<div className="info-card">
<h3>🔧 {t('whyBackendTitle')}</h3>
<p>{t('whyBackendExplanation')}</p>
</div>
{/* Coming Soon Section */}
<div className="info-card highlight">
<h3>🚀 {t('comingSoon')}</h3>
<p>{t('comingSoonExplanation')}</p>
</div>
{/* Features Section */}
<div className="info-card features">
<h3> {t('featuresTitle')}</h3>
<ul className="feature-list">
<li>
<span className="feature-icon"></span>
<span>{t('featureTimeTravel')}</span>
</li>
<li>
<span className="feature-icon">🔒</span>
<span>{t('featureACID')}</span>
</li>
<li>
<span className="feature-icon">📊</span>
<span>{t('featureSchema')}</span>
</li>
<li>
<span className="feature-icon">📁</span>
<span>{t('featureOpen')}</span>
</li>
</ul>
</div>
{/* Alternative Action */}
<div className="alternative-action">
<p>
<a href="/database" className="action-link">
🦆 {t('useDuckDBWasm')}
</a>
{' '} {language === 'nl'
? 'In-browser SQL-query\'s op heritage-datasets'
: 'In-browser SQL queries on heritage datasets'}
</p>
</div>
</div>
</div>
);
}
return (
<div className="db-panel full ducklake-panel">
{/* Header */}
<div className="panel-header-full">
<div className="header-info">
<span className="panel-icon-large">🦆</span>
<div>
<h2>{t('title')}</h2>
<p>{t('description')}</p>
</div>
</div>
<div className="header-actions">
<span className={`status-badge large ${status.isConnected ? 'connected' : 'disconnected'}`}>
{isLoading ? t('loading') : status.isConnected ? t('connected') : t('disconnected')}
</span>
<span className={`feature-badge ${status.ducklakeAvailable ? 'active' : 'inactive'}`}>
{status.ducklakeAvailable ? t('ducklakeAvailable') : t('ducklakeUnavailable')}
</span>
<button onClick={refresh} disabled={isLoading} className="refresh-button">
{t('refresh')}
</button>
</div>
</div>
{/* Stats Overview */}
<div className="stats-row">
<div className="stat-card">
<span className="stat-value">{stats?.totalTables ?? 0}</span>
<span className="stat-label">{t('tables')}</span>
</div>
<div className="stat-card">
<span className="stat-value">{stats?.totalRows?.toLocaleString() ?? 0}</span>
<span className="stat-label">{t('rows')}</span>
</div>
<div className="stat-card">
<span className="stat-value">{stats?.totalSnapshots ?? 0}</span>
<span className="stat-label">{t('snapshots')}</span>
</div>
<div className="stat-card">
<span className="stat-value">{status.duckdbVersion || 'N/A'}</span>
<span className="stat-label">{t('version')}</span>
</div>
<div className="stat-card">
<span className="stat-value">{status.catalogType || 'N/A'}</span>
<span className="stat-label">{t('catalogType')}</span>
</div>
{status.uptimeSeconds && (
<div className="stat-card">
<span className="stat-value">{formatUptime(status.uptimeSeconds)}</span>
<span className="stat-label">{t('uptime')}</span>
</div>
)}
{status.responseTimeMs && (
<div className="stat-card">
<span className="stat-value">{status.responseTimeMs}ms</span>
<span className="stat-label">{t('responseTime')}</span>
</div>
)}
</div>
{error && (
<div className="error-banner">
<strong>Error:</strong> {error.message}
</div>
)}
{/* Tabs */}
<div className="panel-tabs">
<button
className={`tab-btn ${activeTab === 'explore' ? 'active' : ''}`}
onClick={() => { setActiveTab('explore'); setSelectedTable(null); }}
>
🔍 {t('explore')}
</button>
<button
className={`tab-btn ${activeTab === 'query' ? 'active' : ''}`}
onClick={() => setActiveTab('query')}
>
💻 {t('runQuery')}
</button>
<button
className={`tab-btn ${activeTab === 'timetravel' ? 'active' : ''}`}
onClick={() => setActiveTab('timetravel')}
disabled={!status.ducklakeAvailable}
>
{t('timeTravel')}
</button>
<button
className={`tab-btn ${activeTab === 'schema' ? 'active' : ''}`}
onClick={() => setActiveTab('schema')}
disabled={!status.ducklakeAvailable}
>
📊 {t('schemaEvolution')}
</button>
<button
className={`tab-btn ${activeTab === 'upload' ? 'active' : ''}`}
onClick={() => setActiveTab('upload')}
>
{t('loadData')}
</button>
</div>
{/* Tab Content */}
<div className="panel-content">
{/* Data Explorer Tab */}
{activeTab === 'explore' && (
<div className="data-explorer">
{!selectedTable ? (
/* Table Selection Grid */
<div className="table-grid">
{!stats?.tables.length ? (
<p className="empty-message">{t('noTables')}</p>
) : (
<>
<p className="explorer-hint">{t('selectTable')}</p>
<div className="table-cards">
{stats.tables.map((table) => (
<div
key={table.name}
className="table-card"
onClick={() => loadTableData(table.name)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && loadTableData(table.name)}
>
<div className="table-card-icon">{getTableIcon(table.name)}</div>
<div className="table-card-info">
<h4>{table.name}</h4>
<div className="table-card-stats">
<span className="table-stat">
<strong>{table.rowCount.toLocaleString()}</strong> {t('rows')}
</span>
<span className="table-stat">
<strong>{table.columnCount}</strong> {t('columns')}
</span>
</div>
</div>
<div className="table-card-arrow"></div>
</div>
))}
</div>
</>
)}
</div>
) : (
/* Table Data Viewer */
<div className="table-viewer">
<div className="viewer-header">
<button
className="back-link"
onClick={() => { setSelectedTable(null); setTableData(null); }}
>
{t('backToTables')}
</button>
<div className="viewer-title">
<span className="viewer-icon">{getTableIcon(selectedTable)}</span>
<h3>{selectedTable}</h3>
{stats?.tables.find(t => t.name === selectedTable) && (
<span className="viewer-meta">
{stats.tables.find(t => t.name === selectedTable)?.rowCount.toLocaleString()} {t('rows')}
</span>
)}
</div>
<div className="viewer-actions">
<button
className={`view-toggle ${explorerView === 'data' ? 'active' : ''}`}
onClick={() => setExplorerView('data')}
>
📋 {t('data')}
</button>
<button
className={`view-toggle ${explorerView === 'schema' ? 'active' : ''}`}
onClick={() => setExplorerView('schema')}
>
🏗 {t('schema')}
</button>
<button
className="small-button"
onClick={() => handleExport(selectedTable, 'csv')}
>
📥 CSV
</button>
<button
className="small-button"
onClick={() => handleExport(selectedTable, 'json')}
>
📥 JSON
</button>
</div>
</div>
{/* Search Bar */}
{explorerView === 'data' && !expandedRow && (
<div className="table-search-bar">
<div className="search-input-wrapper">
<span className="search-icon">🔍</span>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('searchPlaceholder')}
className="search-input"
/>
{searchQuery && (
<button
className="clear-search-btn"
onClick={() => setSearchQuery('')}
title={t('clearSearch')}
>
</button>
)}
</div>
{searchQuery && tableData && (
<span className="search-results-count">
{filterRowsBySearch(tableData.rows, tableData.columns, searchQuery).length} / {tableData.rows.length} {t('resultsFiltered')}
</span>
)}
</div>
)}
{explorerView === 'schema' ? (
/* Schema View */
<div className="schema-view">
<div className="schema-columns">
{stats?.tables.find(t => t.name === selectedTable)?.columns.map((col, idx) => (
<div key={col.name} className="schema-column">
<span className="column-index">{idx + 1}</span>
<span className="column-name">{col.name}</span>
<span className="column-type">{col.type}</span>
</div>
))}
</div>
</div>
) : (
/* Data View */
<div className="data-view">
{isLoadingData ? (
<div className="loading-data">
<span className="spinner"></span>
{t('loading')}
</div>
) : expandedRow ? (
/* Expanded Row Detail View */
<div className={`row-detail-view ${isFullWidth ? 'full-width' : ''}`}>
<div className="row-detail-header">
<button
className="back-link"
onClick={() => setExpandedRow(null)}
>
Back to table
</button>
<h4 className="row-detail-title">
<span className="row-icon">📋</span>
{getRowDisplayName(expandedRow.data)}
</h4>
<span className="row-detail-meta">Row {expandedRow.rowIndex}</span>
<button
className={`expand-toggle ${isFullWidth ? 'active' : ''}`}
onClick={() => setIsFullWidth(!isFullWidth)}
title={isFullWidth ? 'Exit full width' : 'Expand to full width'}
>
{isFullWidth ? '⊟' : '⊞'}
</button>
{/* Web Archive Button */}
{getRowGhcid(expandedRow.data) && (
<button
className={`web-archive-btn ${showWebArchive ? 'active' : ''}`}
onClick={() => {
if (showWebArchive) {
setShowWebArchive(false);
} else {
const ghcid = getRowGhcid(expandedRow.data);
if (ghcid) loadWebArchive(ghcid);
}
}}
title={showWebArchive ? 'Close Web Archive' : 'View Web Archive'}
>
🌐
</button>
)}
</div>
{/* Web Archive Viewer */}
{showWebArchive && webArchiveData && (
<div className="web-archive-viewer">
<div className="web-archive-header">
<h5>
<span className="archive-icon">🗄</span>
Web Archive: {webArchiveData.ghcid}
</h5>
<a href={webArchiveData.url} target="_blank" rel="noopener noreferrer" className="original-url">
{webArchiveData.url}
</a>
</div>
<div className="web-archive-content">
{/* Archived Pages - Wayback style */}
<div className="archive-section pages-section">
<div className="section-header-row">
<h6>📄 Archived Pages ({webArchiveData.pages.length})</h6>
{selectedWebPage && (
<button
className="clear-filter-btn"
onClick={() => setSelectedWebPage(null)}
title="Show all pages"
>
Clear
</button>
)}
</div>
<div className="pages-list">
{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 (
<div
key={idx}
className={`page-item ${selectedWebPage === pageFile ? 'selected' : ''}`}
onClick={() => setSelectedWebPage(
selectedWebPage === pageFile ? null : pageFile
)}
>
<span className="page-title">{page.title || 'Untitled'}</span>
<span className="page-path">{page.path}</span>
</div>
);
})}
{webArchiveData.pages.length === 0 && (
<div className="no-data">No archived pages found</div>
)}
</div>
</div>
{/* Extracted Claims */}
<div className="archive-section claims-section">
{(() => {
// Filter claims by selected page
const filteredClaims = selectedWebPage
? webArchiveData.claims.filter(c => c.source_page === selectedWebPage)
: webArchiveData.claims;
return (
<>
<div className="section-header-row">
<h6>
🏷 Extracted Claims ({filteredClaims.length}
{selectedWebPage && ` / ${webArchiveData.claims.length} total`})
{selectedWebPage && (
<span className="filter-indicator"> filtered by page</span>
)}
</h6>
</div>
<div className="claims-note">
{selectedWebPage
? `Showing claims from: ${selectedWebPage}. Click "Clear" above to see all.`
: 'Click a page to filter claims from that page only.'
}
</div>
<div className="claims-list">
{filteredClaims.map((claim, idx) => (
<div key={idx} className="claim-item">
<div className="claim-header">
<span className={`claim-type ${claim.claim_type.toLowerCase()}`}>
{claim.claim_type}
</span>
{claim.hypernym && (
<span className="claim-hypernym">{claim.hypernym}</span>
)}
</div>
<div className="claim-content">{claim.text_content}</div>
{claim.xpath && (
<div className="claim-xpath" title={claim.xpath}>
📍 {claim.xpath.substring(0, 60)}...
</div>
)}
</div>
))}
{filteredClaims.length === 0 && (
<div className="no-data">
{selectedWebPage
? 'No claims extracted from this page'
: 'No claims extracted'
}
</div>
)}
</div>
</>
);
})()}
</div>
</div>
</div>
)}
<div className="row-detail-content">
{Object.entries(expandedRow.data).map(([key, value]) => {
const hasNestedData = isExpandable(value);
return (
<div key={key} className={`field-section ${hasNestedData ? 'has-nested' : ''}`}>
<div className="field-header">
<span className="field-name">{key}</span>
{hasNestedData && <span className="field-type-badge">JSON</span>}
</div>
<div className="field-value">
{renderValue(value)}
</div>
</div>
);
})}
</div>
</div>
) : tableData ? (
<>
<div className="data-table-wrapper">
<table className="data-table explorer-table resizable-table" ref={tableRef}>
<thead>
<tr>
<th className="row-expand-col" style={{ width: 40 }}></th>
{tableData.columns.map((col) => (
<th
key={col}
style={{ width: columnWidths[col] || 150, minWidth: 60 }}
className="resizable-header"
>
<span className="header-text">{col}</span>
<div
className="col-resize-handle"
onMouseDown={(e) => handleColumnResizeStart(e, col)}
/>
</th>
))}
</tr>
</thead>
<tbody>
{(() => {
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 (
<tr
key={rowIdx}
className="clickable-row"
style={rowHeight ? { height: rowHeight } : undefined}
onClick={() => setExpandedRow({
rowIndex: dataPage * PAGE_SIZE + originalIdx + 1,
data: rowObj
})}
>
<td className="row-expand-cell">
<span className="expand-arrow"></span>
<div
className="row-resize-handle"
onMouseDown={(e) => handleRowResizeStart(e, rowIdx, rowHeight || 40)}
onClick={(e) => e.stopPropagation()}
/>
</td>
{row.map((cell, cellIdx) => (
<td
key={cellIdx}
title={String(cell)}
style={{ maxWidth: columnWidths[tableData.columns[cellIdx]] || 150 }}
>
<div className="cell-content">
{formatCellValue(cell)}
</div>
</td>
))}
</tr>
);
});
})()}
</tbody>
</table>
</div>
<div className="pagination">
<button
className="page-btn"
disabled={dataPage === 0}
onClick={() => loadPage(dataPage - 1)}
>
{t('previous')}
</button>
<span className="page-info">
{searchQuery
? `${filterRowsBySearch(tableData.rows, tableData.columns, searchQuery).length} ${t('resultsFiltered')}`
: `${t('showingRows')} ${dataPage * PAGE_SIZE + 1} - ${dataPage * PAGE_SIZE + tableData.rows.length}`
}
</span>
<button
className="page-btn"
disabled={tableData.rows.length < PAGE_SIZE}
onClick={() => loadPage(dataPage + 1)}
>
{t('next')}
</button>
</div>
</>
) : (
<p className="empty-message">No data available</p>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Query Tab */}
{activeTab === 'query' && (
<div className="query-section">
{status.ducklakeAvailable && stats?.snapshots && stats.snapshots.length > 0 && (
<div className="snapshot-selector">
<label>{t('queryAtSnapshot')}:</label>
<select
value={selectedSnapshot ?? ''}
onChange={(e) => setSelectedSnapshot(e.target.value ? parseInt(e.target.value) : undefined)}
className="snapshot-select"
>
<option value="">{t('currentData')}</option>
{stats.snapshots.map((snap: SnapshotInfo) => (
<option key={snap.snapshotId} value={snap.snapshotId}>
Snapshot #{snap.snapshotId} - {new Date(snap.createdAt).toLocaleString()}
</option>
))}
</select>
</div>
)}
<textarea
value={query}
onChange={(e) => setQuery(e.target.value)}
className="query-input"
rows={8}
placeholder={t('enterQuery')}
/>
<div className="query-actions">
<button
onClick={handleRunQuery}
disabled={isQueryRunning}
className="primary-button"
>
{isQueryRunning ? t('running') : t('runQuery')}
</button>
{selectedSnapshot && (
<span className="snapshot-indicator">
Querying at Snapshot #{selectedSnapshot}
</span>
)}
</div>
{queryResult && <pre className="query-result">{queryResult}</pre>}
</div>
)}
{/* Time Travel Tab */}
{activeTab === 'timetravel' && (
<div className="timetravel-section">
<h3> {t('timeTravel')}</h3>
<p className="section-description">
Query historical versions of your data using snapshots. Each write operation creates a new snapshot.
</p>
{!stats?.snapshots?.length ? (
<p className="empty-message">{t('noSnapshots')}</p>
) : (
<table className="data-table">
<thead>
<tr>
<th>{t('snapshotId')}</th>
<th>{t('createdAt')}</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{stats.snapshots.map((snap: SnapshotInfo) => (
<tr key={snap.snapshotId}>
<td><code>#{snap.snapshotId}</code></td>
<td>{new Date(snap.createdAt).toLocaleString()}</td>
<td>
<button
className="small-button"
onClick={() => {
setSelectedSnapshot(snap.snapshotId);
setActiveTab('query');
}}
>
Query this snapshot
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{/* Schema Evolution Tab */}
{activeTab === 'schema' && (
<div className="schema-section">
<h3>📊 {t('schemaEvolution')}</h3>
<p className="section-description">
Track all schema changes over time. DuckLake automatically records column additions, type changes, and more.
</p>
{!stats?.schemaChanges?.length ? (
<p className="empty-message">No schema changes recorded yet.</p>
) : (
<table className="data-table">
<thead>
<tr>
<th>{t('tableName')}</th>
<th>{t('changeType')}</th>
<th>{t('columnName')}</th>
<th>{t('oldType')}</th>
<th>{t('newType')}</th>
<th>{t('changedAt')}</th>
</tr>
</thead>
<tbody>
{stats.schemaChanges.map((change: SchemaChange) => (
<tr key={change.changeId}>
<td><code>{change.tableName}</code></td>
<td>
<span className={`change-badge ${change.changeType.toLowerCase()}`}>
{change.changeType}
</span>
</td>
<td><code>{change.columnName || '-'}</code></td>
<td><code>{change.oldType || '-'}</code></td>
<td><code>{change.newType || '-'}</code></td>
<td>{new Date(change.changedAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{/* Upload Tab */}
{activeTab === 'upload' && (
<div className="upload-section">
<h3>{t('loadData')}</h3>
<p className="section-description">
Upload JSON, CSV, or Parquet files. Each upload creates a new snapshot for time travel.
</p>
<div className="form-group">
<label>{t('tableName')}:</label>
<input
type="text"
value={uploadTableName}
onChange={(e) => setUploadTableName(e.target.value)}
placeholder="my_table"
className="text-input"
/>
</div>
<div className="form-group">
<label>{t('uploadMode')}:</label>
<select
value={uploadMode}
onChange={(e) => setUploadMode(e.target.value as 'append' | 'replace' | 'create')}
className="mode-select"
>
<option value="append">{t('modeAppend')} - Add rows to existing table</option>
<option value="replace">{t('modeReplace')} - Drop and recreate table</option>
<option value="create">{t('modeCreate')} - Create new table (error if exists)</option>
</select>
</div>
<div className="form-group">
<label>{t('uploadFile')}:</label>
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
accept=".json,.csv,.parquet"
disabled={!uploadTableName}
className="file-input"
/>
<small>Supported formats: JSON, CSV, Parquet</small>
</div>
</div>
)}
</div>
</div>
);
}