glam/frontend/src/pages/Database.tsx
kempersc 41aace785f feat: Add SyncPanel component for database synchronization
- Add SyncPanel component with bilingual (NL/EN) support
- Add relative URL handling for production (bronhouder.nl)
- Integrate SyncPanel into Database page
- Show sync status for all 4 databases (DuckLake, PostgreSQL, Oxigraph, Qdrant)
- Support dry-run mode and file limit options
2025-12-12 23:42:22 +01:00

449 lines
16 KiB
TypeScript

/**
* Database Management Page
* Multi-database comparison UI for DuckLake, PostgreSQL, TypeDB, Oxigraph, and Qdrant
*
* DuckLake is the primary analytics database (server-side DuckDB with lakehouse features)
* Falls back to DuckDB-WASM for in-browser queries when server is unavailable
*
* Qdrant is the vector database for semantic search and embedding visualization
*/
import { useState } from 'react';
import { useLanguage } from '../contexts/LanguageContext';
import { DuckLakePanel } from '../components/database/DuckLakePanel';
import { PostgreSQLPanel } from '../components/database/PostgreSQLPanel';
import { TypeDBPanel } from '../components/database/TypeDBPanel';
import { OxigraphPanel } from '../components/database/OxigraphPanel';
import { QdrantPanel } from '../components/database/QdrantPanel';
import { SyncPanel } from '../components/database/SyncPanel';
import { useDuckDB } from '../hooks/useDuckDB';
import { useDuckLake } from '../hooks/useDuckLake';
import { usePostgreSQL } from '../hooks/usePostgreSQL';
import { useTypeDB } from '../hooks/useTypeDB';
import { useOxigraph } from '../hooks/useOxigraph';
import { useQdrant } from '../hooks/useQdrant';
import './Database.css';
// Database types - DuckLake is primary (with DuckDB-WASM fallback)
type DatabaseType = 'duckdb' | 'postgres' | 'typedb' | 'oxigraph' | 'qdrant';
interface DatabaseInfo {
id: DatabaseType;
name: string;
description: { nl: string; en: string };
icon: string;
color: string;
}
// Stats interface for status cards
interface DatabaseStats {
isConnected: boolean;
isLoading: boolean;
primaryStat: number;
primaryLabel: string;
secondaryStat: number;
secondaryLabel: string;
tertiaryStat?: number;
tertiaryLabel?: string;
responseTimeMs?: number;
}
const DATABASES: DatabaseInfo[] = [
{
id: 'duckdb',
name: 'DuckLake',
description: {
nl: 'Lakehouse met tijdreizen, ACID-transacties en schema-evolutie',
en: 'Lakehouse with time travel, ACID transactions, and schema evolution',
},
icon: '🦆',
color: '#FFC107',
},
{
id: 'postgres',
name: 'PostgreSQL',
description: {
nl: 'Relationele database voor gestructureerde erfgoeddata',
en: 'Relational database for structured heritage data',
},
icon: '🐘',
color: '#336791',
},
{
id: 'typedb',
name: 'TypeDB',
description: {
nl: 'Kennisgraaf voor complexe erfgoedrelaties',
en: 'Knowledge graph for complex heritage relationships',
},
icon: '🔷',
color: '#6B5CE7',
},
{
id: 'oxigraph',
name: 'Oxigraph',
description: {
nl: 'SPARQL-triplestore voor Linked Data',
en: 'SPARQL triplestore for Linked Data',
},
icon: '🔗',
color: '#00A86B',
},
{
id: 'qdrant',
name: 'Qdrant',
description: {
nl: 'Vector database voor semantisch zoeken en embeddings',
en: 'Vector database for semantic search and embeddings',
},
icon: '⚡',
color: '#DC2F5C',
},
];
// Bilingual text object for translations
const TEXT = {
// Page header
pageTitle: { nl: 'Database Vergelijking', en: 'Database Comparison' },
pageSubtitle: {
nl: 'Vergelijk en beheer meerdere database-backends voor erfgoeddata',
en: 'Compare and manage multiple database backends for heritage data',
},
// Database tabs
allDatabases: { nl: 'Alle databases', en: 'All Databases' },
// Status indicators
connected: { nl: 'Verbonden', en: 'Connected' },
disconnected: { nl: 'Niet verbonden', en: 'Disconnected' },
loading: { nl: 'Laden...', en: 'Loading...' },
// Quick comparison
quickComparison: { nl: 'Gegevensbanken', en: 'Databases' },
database: { nl: 'Database', en: 'Database' },
status: { nl: 'Status', en: 'Status' },
dataCount: { nl: 'Data', en: 'Data' },
responseTime: { nl: 'Responstijd', en: 'Response Time' },
lastChecked: { nl: 'Laatst gecontroleerd', en: 'Last Checked' },
// Data upload
uploadData: { nl: 'Data uploaden', en: 'Upload Data' },
uploadDescription: {
nl: 'Upload verrijkte NDE-bestanden naar geselecteerde databases',
en: 'Upload enriched NDE files to selected databases',
},
// Info
databaseTypes: { nl: 'Database types', en: 'Database Types' },
ducklakeDescription: {
nl: 'Lakehouse - ACID transacties, tijdreizen, schema-evolutie. Server-side DuckDB met DuckLake extensie.',
en: 'Lakehouse - ACID transactions, time travel, schema evolution. Server-side DuckDB with DuckLake extension.',
},
relationalDescription: {
nl: 'Relational - Gestructureerde data met ACID-garanties',
en: 'Relational - Structured data with ACID guarantees',
},
graphDescription: {
nl: 'Knowledge Graph - Complexe relaties en type-inferentie',
en: 'Knowledge Graph - Complex relationships and type inference',
},
rdfDescription: {
nl: 'RDF Triplestore - Linked Data en SPARQL-queries',
en: 'RDF Triplestore - Linked Data and SPARQL queries',
},
vectorDescription: {
nl: 'Vector Database - Semantisch zoeken en embedding-visualisatie',
en: 'Vector Database - Semantic search and embedding visualization',
},
};
export function Database() {
const { language } = useLanguage();
const t = (key: keyof typeof TEXT) => TEXT[key][language];
const [activeDatabase, setActiveDatabase] = useState<DatabaseType | 'all'>('all');
return (
<div className="database-page">
<header className="database-header">
<h1>{t('pageTitle')}</h1>
<p>{t('pageSubtitle')}</p>
</header>
{/* Content based on active tab */}
<div className="database-content">
{activeDatabase === 'all' && <AllDatabasesView language={language} onSelectDatabase={setActiveDatabase} />}
{activeDatabase === 'duckdb' && (
<>
<button className="back-button" onClick={() => setActiveDatabase('all')}>
{language === 'nl' ? 'Terug naar overzicht' : 'Back to overview'}
</button>
<DuckLakePanel />
</>
)}
{activeDatabase === 'postgres' && (
<>
<button className="back-button" onClick={() => setActiveDatabase('all')}>
{language === 'nl' ? 'Terug naar overzicht' : 'Back to overview'}
</button>
<PostgreSQLPanel />
</>
)}
{activeDatabase === 'typedb' && (
<>
<button className="back-button" onClick={() => setActiveDatabase('all')}>
{language === 'nl' ? 'Terug naar overzicht' : 'Back to overview'}
</button>
<TypeDBPanel />
</>
)}
{activeDatabase === 'oxigraph' && (
<>
<button className="back-button" onClick={() => setActiveDatabase('all')}>
{language === 'nl' ? 'Terug naar overzicht' : 'Back to overview'}
</button>
<OxigraphPanel />
</>
)}
{activeDatabase === 'qdrant' && (
<>
<button className="back-button" onClick={() => setActiveDatabase('all')}>
{language === 'nl' ? 'Terug naar overzicht' : 'Back to overview'}
</button>
<QdrantPanel />
</>
)}
</div>
{/* Info Section - only show on overview */}
{activeDatabase === 'all' && (
<section className="info-card">
<h2>{t('databaseTypes')}</h2>
<div className="db-types-grid">
<div className="db-type-card" style={{ borderLeftColor: '#FFC107' }}>
<span className="db-type-icon">🦆</span>
<div>
<strong>DuckLake</strong>
<p>{t('ducklakeDescription')}</p>
</div>
</div>
<div className="db-type-card" style={{ borderLeftColor: '#336791' }}>
<span className="db-type-icon">🐘</span>
<div>
<strong>PostgreSQL</strong>
<p>{t('relationalDescription')}</p>
</div>
</div>
<div className="db-type-card" style={{ borderLeftColor: '#6B5CE7' }}>
<span className="db-type-icon">🔷</span>
<div>
<strong>TypeDB</strong>
<p>{t('graphDescription')}</p>
</div>
</div>
<div className="db-type-card" style={{ borderLeftColor: '#00A86B' }}>
<span className="db-type-icon">🔗</span>
<div>
<strong>Oxigraph</strong>
<p>{t('rdfDescription')}</p>
</div>
</div>
<div className="db-type-card" style={{ borderLeftColor: '#DC2F5C' }}>
<span className="db-type-icon"></span>
<div>
<strong>Qdrant</strong>
<p>{t('vectorDescription')}</p>
</div>
</div>
</div>
</section>
)}
</div>
);
}
/**
* All Databases Overview View
*/
function AllDatabasesView({ language, onSelectDatabase }: { language: 'nl' | 'en'; onSelectDatabase: (db: DatabaseType) => void }) {
const t = (key: keyof typeof TEXT) => TEXT[key][language];
// Initialize all database hooks
const duckdb = useDuckDB();
const ducklake = useDuckLake(); // Server-side DuckDB with lakehouse features
const postgres = usePostgreSQL();
const typedb = useTypeDB();
const oxigraph = useOxigraph();
const qdrant = useQdrant();
// Unified DuckDB stats: prefer DuckLake (server) if connected, fallback to WASM
const duckdbConnected = ducklake.status.isConnected || duckdb.status.isConnected;
const duckdbLoading = ducklake.isLoading || duckdb.isLoading;
// Use DuckLake stats if available (production data), otherwise use WASM stats
const duckdbTables = ducklake.status.isConnected
? (ducklake.stats?.totalTables ?? 0)
: (duckdb.stats?.totalTables ?? 0);
const duckdbRows = ducklake.status.isConnected
? (ducklake.stats?.totalRows ?? 0)
: (duckdb.stats?.totalRows ?? 0);
const duckdbSnapshots = ducklake.stats?.totalSnapshots ?? 0;
const duckdbResponseTime = ducklake.status.isConnected
? ducklake.status.responseTimeMs
: duckdb.status.responseTimeMs;
// Create a map of database stats
const dbStats: Record<DatabaseType, DatabaseStats> = {
duckdb: {
isConnected: duckdbConnected,
isLoading: duckdbLoading,
primaryStat: duckdbTables,
primaryLabel: language === 'nl' ? 'tabellen' : 'tables',
secondaryStat: duckdbRows,
secondaryLabel: language === 'nl' ? 'rijen' : 'rows',
// Show snapshots if DuckLake is connected (lakehouse feature)
tertiaryStat: ducklake.status.isConnected ? duckdbSnapshots : undefined,
tertiaryLabel: ducklake.status.isConnected ? 'snapshots' : undefined,
responseTimeMs: duckdbResponseTime,
},
postgres: {
isConnected: postgres.status.isConnected,
isLoading: postgres.isLoading,
primaryStat: postgres.stats?.totalTables ?? 0,
primaryLabel: language === 'nl' ? 'tabellen' : 'tables',
secondaryStat: postgres.stats?.totalRows ?? 0,
secondaryLabel: language === 'nl' ? 'rijen' : 'rows',
responseTimeMs: postgres.status.responseTimeMs,
},
typedb: {
isConnected: typedb.status.isConnected,
isLoading: typedb.isLoading,
primaryStat: typedb.stats?.totalEntities ?? 0,
primaryLabel: language === 'nl' ? 'entiteiten' : 'entities',
secondaryStat: typedb.stats?.totalRelations ?? 0,
secondaryLabel: language === 'nl' ? 'relaties' : 'relations',
responseTimeMs: typedb.status.responseTimeMs,
},
oxigraph: {
isConnected: oxigraph.status.isConnected,
isLoading: oxigraph.isLoading,
primaryStat: oxigraph.stats?.totalTriples ?? 0,
primaryLabel: language === 'nl' ? 'triples' : 'triples',
secondaryStat: oxigraph.stats?.totalGraphs ?? 0,
secondaryLabel: language === 'nl' ? 'grafen' : 'graphs',
responseTimeMs: oxigraph.status.responseTimeMs,
},
qdrant: {
isConnected: qdrant.status.isConnected,
isLoading: qdrant.isLoading,
primaryStat: qdrant.stats?.totalCollections ?? 0,
primaryLabel: language === 'nl' ? 'collecties' : 'collections',
secondaryStat: qdrant.stats?.totalVectors ?? 0,
secondaryLabel: language === 'nl' ? 'vectoren' : 'vectors',
responseTimeMs: qdrant.status.responseTimeMs,
},
};
return (
<div className="all-databases-view">
{/* Sync Panel - Synchronize YAML files to all databases */}
<SyncPanel language={language} />
{/* Database Cards Grid */}
<section className="comparison-section">
<h2>{t('quickComparison')}</h2>
<div className="comparison-grid">
{DATABASES.map((db) => (
<DatabaseStatusCard
key={db.id}
db={db}
language={language}
stats={dbStats[db.id]}
onClick={() => onSelectDatabase(db.id)}
/>
))}
</div>
</section>
</div>
);
}
/**
* Database Status Card for comparison view
*/
function DatabaseStatusCard({
db,
language,
stats,
onClick,
}: {
db: DatabaseInfo;
language: 'nl' | 'en';
stats: DatabaseStats;
onClick: () => void;
}) {
// Format large numbers
const formatNumber = (n: number) => {
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
return n.toLocaleString();
};
return (
<div
className="db-status-card clickable"
style={{ '--db-color': db.color } as React.CSSProperties}
onClick={onClick}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && onClick()}
>
<div className="db-status-header">
<span className="db-icon-large">{db.icon}</span>
<div>
<h3>{db.name}</h3>
<p>{db.description[language]}</p>
</div>
</div>
<div className="db-status-body">
{stats.isLoading ? (
<div className="status-placeholder">
<span className="status-dot loading" />
<span>{language === 'nl' ? 'Laden...' : 'Loading...'}</span>
</div>
) : stats.isConnected ? (
<div className="db-stats-grid">
<div className="db-stat-item">
<span className="status-dot connected" />
<span className="stat-label">{language === 'nl' ? 'Verbonden' : 'Connected'}</span>
{stats.responseTimeMs && (
<span className="stat-response-time">{stats.responseTimeMs}ms</span>
)}
</div>
<div className="db-stat-numbers">
<div className="stat-number">
<span className="stat-value">{formatNumber(stats.primaryStat)}</span>
<span className="stat-unit">{stats.primaryLabel}</span>
</div>
<div className="stat-number">
<span className="stat-value">{formatNumber(stats.secondaryStat)}</span>
<span className="stat-unit">{stats.secondaryLabel}</span>
</div>
{stats.tertiaryStat !== undefined && stats.tertiaryLabel && (
<div className="stat-number">
<span className="stat-value">{formatNumber(stats.tertiaryStat)}</span>
<span className="stat-unit">{stats.tertiaryLabel}</span>
</div>
)}
</div>
</div>
) : (
<div className="status-placeholder">
<span className="status-dot disconnected" />
<span>{language === 'nl' ? 'Niet verbonden' : 'Disconnected'}</span>
</div>
)}
</div>
</div>
);
}