- 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
1695 lines
68 KiB
TypeScript
1695 lines
68 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|