2026-03-17 16:10:56 +01:00
|
|
|
import { useState } from 'react';
|
|
|
|
|
import { useAuthStore } from '../../auth/auth-store';
|
|
|
|
|
import { StatusBadge } from '../../components/admin/StatusBadge';
|
|
|
|
|
import { RefreshableCard } from '../../components/admin/RefreshableCard';
|
|
|
|
|
import { ConfirmDeleteDialog } from '../../components/admin/ConfirmDeleteDialog';
|
|
|
|
|
import {
|
|
|
|
|
useDatabaseStatus,
|
|
|
|
|
useDatabasePool,
|
|
|
|
|
useDatabaseTables,
|
|
|
|
|
useDatabaseQueries,
|
|
|
|
|
useKillQuery,
|
|
|
|
|
} from '../../api/queries/admin/database';
|
2026-03-17 16:36:11 +01:00
|
|
|
import { useThresholds, useSaveThresholds, type ThresholdConfig } from '../../api/queries/admin/thresholds';
|
2026-03-17 16:10:56 +01:00
|
|
|
import styles from './DatabaseAdminPage.module.css';
|
|
|
|
|
|
|
|
|
|
export function DatabaseAdminPage() {
|
|
|
|
|
const roles = useAuthStore((s) => s.roles);
|
|
|
|
|
|
|
|
|
|
if (!roles.includes('ADMIN')) {
|
|
|
|
|
return (
|
|
|
|
|
<div className={styles.page}>
|
|
|
|
|
<div className={styles.accessDenied}>
|
|
|
|
|
Access Denied — this page requires the ADMIN role.
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return <DatabaseAdminContent />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function DatabaseAdminContent() {
|
|
|
|
|
const status = useDatabaseStatus();
|
|
|
|
|
const pool = useDatabasePool();
|
|
|
|
|
const tables = useDatabaseTables();
|
|
|
|
|
const queries = useDatabaseQueries();
|
|
|
|
|
const thresholds = useThresholds();
|
|
|
|
|
|
|
|
|
|
if (status.isLoading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className={styles.page}>
|
|
|
|
|
<h1 className={styles.pageTitle}>Database Administration</h1>
|
|
|
|
|
<div className={styles.loading}>Loading...</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const db = status.data;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className={styles.page}>
|
|
|
|
|
<div className={styles.header}>
|
|
|
|
|
<div className={styles.headerInfo}>
|
|
|
|
|
<h1 className={styles.pageTitle}>Database Administration</h1>
|
|
|
|
|
<div className={styles.headerMeta}>
|
|
|
|
|
<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={styles.globalRefresh}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
status.refetch();
|
|
|
|
|
pool.refetch();
|
|
|
|
|
tables.refetch();
|
|
|
|
|
queries.refetch();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Refresh All
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<PoolSection
|
|
|
|
|
pool={pool}
|
2026-03-17 16:36:11 +01:00
|
|
|
warningPct={thresholds.data?.database?.connectionPoolWarning}
|
|
|
|
|
criticalPct={thresholds.data?.database?.connectionPoolCritical}
|
2026-03-17 16:10:56 +01:00
|
|
|
/>
|
|
|
|
|
<TablesSection tables={tables} />
|
|
|
|
|
<QueriesSection
|
|
|
|
|
queries={queries}
|
2026-03-17 16:36:11 +01:00
|
|
|
warningSeconds={thresholds.data?.database?.queryDurationWarning}
|
2026-03-17 16:10:56 +01:00
|
|
|
/>
|
|
|
|
|
<MaintenanceSection />
|
|
|
|
|
<ThresholdsSection thresholds={thresholds.data} />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function PoolSection({
|
|
|
|
|
pool,
|
|
|
|
|
warningPct,
|
|
|
|
|
criticalPct,
|
|
|
|
|
}: {
|
|
|
|
|
pool: ReturnType<typeof useDatabasePool>;
|
|
|
|
|
warningPct?: number;
|
|
|
|
|
criticalPct?: number;
|
|
|
|
|
}) {
|
|
|
|
|
const data = pool.data;
|
|
|
|
|
if (!data) return null;
|
|
|
|
|
|
2026-03-17 16:36:11 +01:00
|
|
|
const usagePct = data.maxPoolSize > 0
|
|
|
|
|
? Math.round((data.activeConnections / data.maxPoolSize) * 100)
|
2026-03-17 16:10:56 +01:00
|
|
|
: 0;
|
|
|
|
|
const barColor =
|
|
|
|
|
criticalPct && usagePct >= criticalPct ? '#ef4444'
|
|
|
|
|
: warningPct && usagePct >= warningPct ? '#eab308'
|
|
|
|
|
: '#22c55e';
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<RefreshableCard
|
|
|
|
|
title="Connection Pool"
|
|
|
|
|
onRefresh={() => pool.refetch()}
|
|
|
|
|
isRefreshing={pool.isFetching}
|
|
|
|
|
autoRefresh
|
|
|
|
|
>
|
|
|
|
|
<div className={styles.progressContainer}>
|
|
|
|
|
<div className={styles.progressLabel}>
|
2026-03-17 16:36:11 +01:00
|
|
|
{data.activeConnections} / {data.maxPoolSize} connections
|
2026-03-17 16:10:56 +01:00
|
|
|
<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}>
|
2026-03-17 16:36:11 +01:00
|
|
|
<span className={styles.metricValue}>{data.pendingThreads}</span>
|
2026-03-17 16:10:56 +01:00
|
|
|
<span className={styles.metricLabel}>Pending</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.metric}>
|
2026-03-17 16:36:11 +01:00
|
|
|
<span className={styles.metricValue}>{data.maxWaitMs}ms</span>
|
2026-03-17 16:10:56 +01:00
|
|
|
<span className={styles.metricLabel}>Max Wait</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</RefreshableCard>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function TablesSection({ tables }: { tables: ReturnType<typeof useDatabaseTables> }) {
|
|
|
|
|
const data = tables.data;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<RefreshableCard
|
|
|
|
|
title="Table Sizes"
|
|
|
|
|
onRefresh={() => tables.refetch()}
|
|
|
|
|
isRefreshing={tables.isFetching}
|
|
|
|
|
>
|
|
|
|
|
{!data ? (
|
|
|
|
|
<div className={styles.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>
|
2026-03-17 16:36:11 +01:00
|
|
|
<td>{t.rowCount.toLocaleString()}</td>
|
2026-03-17 16:10:56 +01:00
|
|
|
<td>{t.dataSize}</td>
|
|
|
|
|
<td>{t.indexSize}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</RefreshableCard>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function QueriesSection({
|
|
|
|
|
queries,
|
|
|
|
|
warningSeconds,
|
|
|
|
|
}: {
|
|
|
|
|
queries: ReturnType<typeof useDatabaseQueries>;
|
|
|
|
|
warningSeconds?: number;
|
|
|
|
|
}) {
|
|
|
|
|
const [killTarget, setKillTarget] = useState<number | null>(null);
|
|
|
|
|
const killMutation = useKillQuery();
|
|
|
|
|
const data = queries.data;
|
|
|
|
|
|
2026-03-17 16:36:11 +01:00
|
|
|
const warningSec = warningSeconds ?? 30;
|
2026-03-17 16:10:56 +01:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<RefreshableCard
|
|
|
|
|
title="Active Queries"
|
|
|
|
|
onRefresh={() => queries.refetch()}
|
|
|
|
|
isRefreshing={queries.isFetching}
|
|
|
|
|
autoRefresh
|
|
|
|
|
>
|
|
|
|
|
{!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}
|
2026-03-17 16:36:11 +01:00
|
|
|
className={q.durationSeconds > warningSec ? styles.rowWarning : undefined}
|
2026-03-17 16:10:56 +01:00
|
|
|
>
|
|
|
|
|
<td className={styles.mono}>{q.pid}</td>
|
2026-03-17 16:36:11 +01:00
|
|
|
<td>{formatDuration(q.durationSeconds)}</td>
|
2026-03-17 16:10:56 +01:00
|
|
|
<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)"
|
|
|
|
|
/>
|
|
|
|
|
</RefreshableCard>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function MaintenanceSection() {
|
|
|
|
|
return (
|
|
|
|
|
<RefreshableCard title="Maintenance">
|
|
|
|
|
<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>
|
|
|
|
|
</RefreshableCard>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 16:36:11 +01:00
|
|
|
function ThresholdsSection({ thresholds }: { thresholds?: ThresholdConfig }) {
|
|
|
|
|
const [form, setForm] = useState<ThresholdConfig | null>(null);
|
2026-03-17 16:10:56 +01:00
|
|
|
const saveMutation = useSaveThresholds();
|
|
|
|
|
const [status, setStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null);
|
|
|
|
|
|
|
|
|
|
const current = form ?? thresholds;
|
|
|
|
|
if (!current) return null;
|
|
|
|
|
|
2026-03-17 16:36:11 +01:00
|
|
|
function updateDb(key: keyof ThresholdConfig['database'], value: number) {
|
|
|
|
|
setForm((prev) => {
|
|
|
|
|
const base = prev ?? thresholds!;
|
|
|
|
|
return { ...base, database: { ...base.database, [key]: value } };
|
|
|
|
|
});
|
2026-03-17 16:10:56 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
<RefreshableCard title="Thresholds" collapsible defaultCollapsed>
|
|
|
|
|
<div className={styles.thresholdGrid}>
|
|
|
|
|
<div className={styles.thresholdField}>
|
|
|
|
|
<label className={styles.thresholdLabel}>Pool Warning %</label>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
className={styles.thresholdInput}
|
2026-03-17 16:36:11 +01:00
|
|
|
value={current.database.connectionPoolWarning}
|
|
|
|
|
onChange={(e) => updateDb('connectionPoolWarning', Number(e.target.value))}
|
2026-03-17 16:10:56 +01:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.thresholdField}>
|
|
|
|
|
<label className={styles.thresholdLabel}>Pool Critical %</label>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
className={styles.thresholdInput}
|
2026-03-17 16:36:11 +01:00
|
|
|
value={current.database.connectionPoolCritical}
|
|
|
|
|
onChange={(e) => updateDb('connectionPoolCritical', Number(e.target.value))}
|
2026-03-17 16:10:56 +01:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.thresholdField}>
|
|
|
|
|
<label className={styles.thresholdLabel}>Query Warning (s)</label>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
className={styles.thresholdInput}
|
2026-03-17 16:36:11 +01:00
|
|
|
value={current.database.queryDurationWarning}
|
|
|
|
|
onChange={(e) => updateDb('queryDurationWarning', Number(e.target.value))}
|
2026-03-17 16:10:56 +01:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.thresholdField}>
|
|
|
|
|
<label className={styles.thresholdLabel}>Query Critical (s)</label>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
className={styles.thresholdInput}
|
2026-03-17 16:36:11 +01:00
|
|
|
value={current.database.queryDurationCritical}
|
|
|
|
|
onChange={(e) => updateDb('queryDurationCritical', Number(e.target.value))}
|
2026-03-17 16:10:56 +01:00
|
|
|
/>
|
|
|
|
|
</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>
|
|
|
|
|
</RefreshableCard>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 16:36:11 +01:00
|
|
|
function formatDuration(seconds: number): string {
|
|
|
|
|
if (seconds < 1) return `${Math.round(seconds * 1000)}ms`;
|
|
|
|
|
const s = Math.floor(seconds);
|
2026-03-17 16:10:56 +01:00
|
|
|
if (s < 60) return `${s}s`;
|
|
|
|
|
const m = Math.floor(s / 60);
|
|
|
|
|
return `${m}m ${s % 60}s`;
|
|
|
|
|
}
|