Files
cameleer-server/ui/src/pages/admin/DatabaseAdminPage.tsx

438 lines
14 KiB
TypeScript
Raw Normal View History

import { useState } from 'react';
import { useAuthStore } from '../../auth/auth-store';
import { StatusBadge } from '../../components/admin/StatusBadge';
import { ConfirmDeleteDialog } from '../../components/admin/ConfirmDeleteDialog';
import {
useDatabaseStatus,
useDatabasePool,
useDatabaseTables,
useDatabaseQueries,
useKillQuery,
} from '../../api/queries/admin/database';
import { useThresholds, useSaveThresholds, type ThresholdConfig } from '../../api/queries/admin/thresholds';
import layout from '../../styles/AdminLayout.module.css';
import styles from './DatabaseAdminPage.module.css';
type Section = 'pool' | 'tables' | 'queries' | 'maintenance' | 'thresholds';
interface SectionDef {
id: Section;
label: string;
icon: string;
}
const SECTIONS: SectionDef[] = [
{ id: 'pool', label: 'Connection Pool', icon: 'CP' },
{ id: 'tables', label: 'Table Sizes', icon: 'TS' },
{ id: 'queries', label: 'Active Queries', icon: 'AQ' },
{ id: 'maintenance', label: 'Maintenance', icon: 'MN' },
{ id: 'thresholds', label: 'Thresholds', icon: 'TH' },
];
export function DatabaseAdminPage() {
const roles = useAuthStore((s) => s.roles);
if (!roles.includes('ADMIN')) {
return (
<div className={layout.page}>
<div className={layout.accessDenied}>
Access Denied -- this page requires the ADMIN role.
</div>
</div>
);
}
return <DatabaseAdminContent />;
}
function DatabaseAdminContent() {
const [selectedSection, setSelectedSection] = useState<Section>('pool');
const status = useDatabaseStatus();
const pool = useDatabasePool();
const tables = useDatabaseTables();
const queries = useDatabaseQueries();
const thresholds = useThresholds();
if (status.isLoading) {
return (
<div className={layout.page}>
<div className={layout.loading}>Loading...</div>
</div>
);
}
const db = status.data;
function getMiniStatus(section: Section): string {
switch (section) {
case 'pool': {
const d = pool.data;
if (!d) return '--';
const pct = d.maxPoolSize > 0 ? Math.round((d.activeConnections / d.maxPoolSize) * 100) : 0;
return `${pct}%`;
}
case 'tables':
return tables.data ? `${tables.data.length}` : '--';
case 'queries':
return queries.data ? `${queries.data.length}` : '--';
case 'maintenance':
return 'Coming soon';
case 'thresholds':
return thresholds.data ? 'Configured' : '--';
}
}
return (
<div className={layout.page}>
<div className={layout.panelHeader}>
<div>
<div className={layout.panelTitle}>Database</div>
<div className={layout.panelSubtitle}>
<StatusBadge
status={db?.connected ? 'healthy' : 'critical'}
label={db?.connected ? 'Connected' : 'Disconnected'}
/>
{db?.version && <span className={styles.metaItem}>{db.version}</span>}
{db?.host && <span className={styles.metaItem}>{db.host}</span>}
{db?.schema && <span className={styles.metaItem}>Schema: {db.schema}</span>}
</div>
</div>
<button
type="button"
className={layout.btnAction}
onClick={() => {
status.refetch();
pool.refetch();
tables.refetch();
queries.refetch();
}}
>
Refresh All
</button>
</div>
<div className={layout.split}>
<div className={layout.listPane}>
<div className={layout.entityList}>
{SECTIONS.map((sec) => (
<div
key={sec.id}
className={`${layout.entityItem} ${selectedSection === sec.id ? layout.entityItemSelected : ''}`}
onClick={() => setSelectedSection(sec.id)}
>
<div className={layout.sectionIcon}>{sec.icon}</div>
<div className={layout.entityInfo}>
<div className={layout.entityName}>{sec.label}</div>
</div>
<div className={layout.miniStatus}>{getMiniStatus(sec.id)}</div>
</div>
))}
</div>
</div>
<div className={layout.detailPane}>
{selectedSection === 'pool' && (
<PoolSection
pool={pool}
warningPct={thresholds.data?.database?.connectionPoolWarning}
criticalPct={thresholds.data?.database?.connectionPoolCritical}
/>
)}
{selectedSection === 'tables' && <TablesSection tables={tables} />}
{selectedSection === 'queries' && (
<QueriesSection
queries={queries}
warningSeconds={thresholds.data?.database?.queryDurationWarning}
/>
)}
{selectedSection === 'maintenance' && <MaintenanceSection />}
{selectedSection === 'thresholds' && (
<ThresholdsSection thresholds={thresholds.data} />
)}
</div>
</div>
</div>
);
}
function PoolSection({
pool,
warningPct,
criticalPct,
}: {
pool: ReturnType<typeof useDatabasePool>;
warningPct?: number;
criticalPct?: number;
}) {
const data = pool.data;
if (!data) return null;
const usagePct = data.maxPoolSize > 0
? Math.round((data.activeConnections / data.maxPoolSize) * 100)
: 0;
const barColor =
criticalPct && usagePct >= criticalPct ? '#ef4444'
: warningPct && usagePct >= warningPct ? '#eab308'
: '#22c55e';
return (
<>
<div className={layout.detailSectionTitle}>Connection Pool</div>
<div className={styles.progressContainer}>
<div className={styles.progressLabel}>
{data.activeConnections} / {data.maxPoolSize} connections
<span className={styles.progressPct}>{usagePct}%</span>
</div>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${usagePct}%`, background: barColor }}
/>
</div>
</div>
<div className={styles.metricsGrid}>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.activeConnections}</span>
<span className={styles.metricLabel}>Active</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.idleConnections}</span>
<span className={styles.metricLabel}>Idle</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.pendingThreads}</span>
<span className={styles.metricLabel}>Pending</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.maxWaitMs}ms</span>
<span className={styles.metricLabel}>Max Wait</span>
</div>
</div>
</>
);
}
function TablesSection({ tables }: { tables: ReturnType<typeof useDatabaseTables> }) {
const data = tables.data;
return (
<>
<div className={layout.detailSectionTitle}>Table Sizes</div>
{!data ? (
<div className={layout.loading}>Loading...</div>
) : (
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>Table</th>
<th>Rows</th>
<th>Data Size</th>
<th>Index Size</th>
</tr>
</thead>
<tbody>
{data.map((t) => (
<tr key={t.tableName}>
<td className={styles.mono}>{t.tableName}</td>
<td>{t.rowCount.toLocaleString()}</td>
<td>{t.dataSize}</td>
<td>{t.indexSize}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
);
}
function QueriesSection({
queries,
warningSeconds,
}: {
queries: ReturnType<typeof useDatabaseQueries>;
warningSeconds?: number;
}) {
const [killTarget, setKillTarget] = useState<number | null>(null);
const killMutation = useKillQuery();
const data = queries.data;
const warningSec = warningSeconds ?? 30;
return (
<>
<div className={layout.detailSectionTitle}>Active Queries</div>
{!data || data.length === 0 ? (
<div className={styles.emptyState}>No active queries</div>
) : (
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>PID</th>
<th>Duration</th>
<th>State</th>
<th>Query</th>
<th></th>
</tr>
</thead>
<tbody>
{data.map((q) => (
<tr
key={q.pid}
className={q.durationSeconds > warningSec ? styles.rowWarning : undefined}
>
<td className={styles.mono}>{q.pid}</td>
<td>{formatDuration(q.durationSeconds)}</td>
<td>{q.state}</td>
<td className={styles.queryCell} title={q.query}>
{q.query.length > 100 ? `${q.query.slice(0, 100)}...` : q.query}
</td>
<td>
<button
type="button"
className={styles.killBtn}
onClick={() => setKillTarget(q.pid)}
>
Kill
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<ConfirmDeleteDialog
isOpen={killTarget !== null}
onClose={() => setKillTarget(null)}
onConfirm={() => {
if (killTarget !== null) {
killMutation.mutate(killTarget);
setKillTarget(null);
}
}}
resourceName={String(killTarget ?? '')}
resourceType="query (PID)"
/>
</>
);
}
function MaintenanceSection() {
return (
<>
<div className={layout.detailSectionTitle}>Maintenance</div>
<div className={styles.maintenanceGrid}>
<button type="button" className={styles.maintenanceBtn} disabled title="Coming soon">
VACUUM ANALYZE
</button>
<button type="button" className={styles.maintenanceBtn} disabled title="Coming soon">
REINDEX
</button>
<button type="button" className={styles.maintenanceBtn} disabled title="Coming soon">
Refresh Aggregates
</button>
</div>
</>
);
}
function ThresholdsSection({ thresholds }: { thresholds?: ThresholdConfig }) {
const [form, setForm] = useState<ThresholdConfig | null>(null);
const saveMutation = useSaveThresholds();
const [status, setStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null);
const current = form ?? thresholds;
if (!current) return null;
function updateDb(key: keyof ThresholdConfig['database'], value: number) {
setForm((prev) => {
const base = prev ?? thresholds!;
return { ...base, database: { ...base.database, [key]: value } };
});
}
async function handleSave() {
if (!form && !thresholds) return;
const data = form ?? thresholds!;
try {
await saveMutation.mutateAsync(data);
setStatus({ type: 'success', msg: 'Thresholds saved.' });
setTimeout(() => setStatus(null), 3000);
} catch {
setStatus({ type: 'error', msg: 'Failed to save thresholds.' });
}
}
return (
<>
<div className={layout.detailSectionTitle}>Thresholds</div>
<div className={styles.thresholdGrid}>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Pool Warning %</label>
<input
type="number"
className={styles.thresholdInput}
value={current.database.connectionPoolWarning}
onChange={(e) => updateDb('connectionPoolWarning', Number(e.target.value))}
/>
</div>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Pool Critical %</label>
<input
type="number"
className={styles.thresholdInput}
value={current.database.connectionPoolCritical}
onChange={(e) => updateDb('connectionPoolCritical', Number(e.target.value))}
/>
</div>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Query Warning (s)</label>
<input
type="number"
className={styles.thresholdInput}
value={current.database.queryDurationWarning}
onChange={(e) => updateDb('queryDurationWarning', Number(e.target.value))}
/>
</div>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Query Critical (s)</label>
<input
type="number"
className={styles.thresholdInput}
value={current.database.queryDurationCritical}
onChange={(e) => updateDb('queryDurationCritical', Number(e.target.value))}
/>
</div>
</div>
<div className={styles.thresholdActions}>
<button
type="button"
className={styles.btnPrimary}
onClick={handleSave}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? 'Saving...' : 'Save Thresholds'}
</button>
{status && (
<span className={status.type === 'success' ? styles.successMsg : styles.errorMsg}>
{status.msg}
</span>
)}
</div>
</>
);
}
function formatDuration(seconds: number): string {
if (seconds < 1) return `${Math.round(seconds * 1000)}ms`;
const s = Math.floor(seconds);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
return `${m}m ${s % 60}s`;
}