feat: add Database admin page with pool, tables, queries, and thresholds UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-17 16:10:56 +01:00
parent b61c32729b
commit 0edbdea2eb
2 changed files with 696 additions and 0 deletions

View File

@@ -0,0 +1,317 @@
.page {
max-width: 960px;
margin: 0 auto;
padding: 32px 16px;
}
.pageTitle {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 24px;
}
.headerInfo {
display: flex;
flex-direction: column;
gap: 8px;
}
.headerMeta {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.metaItem {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.globalRefresh {
padding: 8px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.globalRefresh:hover {
border-color: var(--amber-dim);
color: var(--text-primary);
}
.loading {
text-align: center;
padding: 32px;
color: var(--text-muted);
font-size: 14px;
}
.accessDenied {
text-align: center;
padding: 64px 16px;
color: var(--text-muted);
font-size: 14px;
}
/* ─── Progress Bar ─── */
.progressContainer {
margin-bottom: 16px;
}
.progressLabel {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.progressPct {
font-weight: 600;
font-family: var(--font-mono);
}
.progressBar {
height: 8px;
background: var(--bg-raised);
border-radius: 4px;
overflow: hidden;
}
.progressFill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
/* ─── Metrics Grid ─── */
.metricsGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.metric {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px;
background: var(--bg-raised);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
}
.metricValue {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
font-family: var(--font-mono);
}
.metricLabel {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 4px;
}
/* ─── Tables ─── */
.tableWrapper {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.table th {
text-align: left;
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
border-bottom: 1px solid var(--border-subtle);
white-space: nowrap;
}
.table td {
padding: 8px 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-subtle);
}
.table tbody tr:hover {
background: var(--bg-hover);
}
.mono {
font-family: var(--font-mono);
font-size: 12px;
}
.queryCell {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: 11px;
}
.rowWarning {
background: rgba(234, 179, 8, 0.06);
}
.killBtn {
padding: 4px 10px;
border-radius: var(--radius-sm);
background: transparent;
border: 1px solid var(--rose-dim);
color: var(--rose);
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
}
.killBtn:hover {
background: var(--rose-glow);
}
.emptyState {
text-align: center;
padding: 24px;
color: var(--text-muted);
font-size: 13px;
}
/* ─── Maintenance ─── */
.maintenanceGrid {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.maintenanceBtn {
padding: 8px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text-muted);
font-size: 13px;
cursor: not-allowed;
opacity: 0.5;
}
/* ─── Thresholds ─── */
.thresholdGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.thresholdField {
display: flex;
flex-direction: column;
gap: 4px;
}
.thresholdLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.thresholdInput {
width: 100%;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.thresholdInput:focus {
border-color: var(--amber-dim);
box-shadow: 0 0 0 3px var(--amber-glow);
}
.thresholdActions {
display: flex;
align-items: center;
gap: 12px;
}
.btnPrimary {
padding: 8px 20px;
border-radius: var(--radius-sm);
border: 1px solid var(--amber);
background: var(--amber);
color: #0a0e17;
font-family: var(--font-body);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.btnPrimary:hover {
background: var(--amber-hover);
border-color: var(--amber-hover);
}
.btnPrimary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.successMsg {
font-size: 12px;
color: var(--green);
}
.errorMsg {
font-size: 12px;
color: var(--rose);
}
@media (max-width: 640px) {
.metricsGrid {
grid-template-columns: repeat(2, 1fr);
}
.thresholdGrid {
grid-template-columns: 1fr;
}
.header {
flex-direction: column;
gap: 12px;
}
}

View File

@@ -0,0 +1,379 @@
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';
import { useThresholds, useSaveThresholds, type Thresholds } from '../../api/queries/admin/thresholds';
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}
warningPct={thresholds.data?.poolWarningPercent}
criticalPct={thresholds.data?.poolCriticalPercent}
/>
<TablesSection tables={tables} />
<QueriesSection
queries={queries}
warningSeconds={thresholds.data?.queryDurationWarningSeconds}
/>
<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;
const usagePct = data.maxConnections > 0
? Math.round((data.activeConnections / data.maxConnections) * 100)
: 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}>
{data.activeConnections} / {data.maxConnections} 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.pendingConnections}</span>
<span className={styles.metricLabel}>Pending</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.maxWaitMillis}ms</span>
<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>
<td>{t.rowEstimate.toLocaleString()}</td>
<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;
const warningMs = (warningSeconds ?? 30) * 1000;
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}
className={q.durationMs > warningMs ? styles.rowWarning : undefined}
>
<td className={styles.mono}>{q.pid}</td>
<td>{formatDuration(q.durationMs)}</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)"
/>
</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>
);
}
function ThresholdsSection({ thresholds }: { thresholds?: Thresholds }) {
const [form, setForm] = useState<Thresholds | 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 update(key: keyof Thresholds, value: number) {
setForm((prev) => ({ ...(prev ?? thresholds!), [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 (
<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}
value={current.poolWarningPercent}
onChange={(e) => update('poolWarningPercent', Number(e.target.value))}
/>
</div>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Pool Critical %</label>
<input
type="number"
className={styles.thresholdInput}
value={current.poolCriticalPercent}
onChange={(e) => update('poolCriticalPercent', 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.queryDurationWarningSeconds}
onChange={(e) => update('queryDurationWarningSeconds', 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.queryDurationCriticalSeconds}
onChange={(e) => update('queryDurationCriticalSeconds', 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>
</RefreshableCard>
);
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
return `${m}m ${s % 60}s`;
}