diff --git a/ui/src/pages/admin/DatabaseAdminPage.module.css b/ui/src/pages/admin/DatabaseAdminPage.module.css new file mode 100644 index 00000000..944e0246 --- /dev/null +++ b/ui/src/pages/admin/DatabaseAdminPage.module.css @@ -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; + } +} diff --git a/ui/src/pages/admin/DatabaseAdminPage.tsx b/ui/src/pages/admin/DatabaseAdminPage.tsx new file mode 100644 index 00000000..3edbff07 --- /dev/null +++ b/ui/src/pages/admin/DatabaseAdminPage.tsx @@ -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 ( +
+
+ Access Denied — this page requires the ADMIN role. +
+
+ ); + } + + return ; +} + +function DatabaseAdminContent() { + const status = useDatabaseStatus(); + const pool = useDatabasePool(); + const tables = useDatabaseTables(); + const queries = useDatabaseQueries(); + const thresholds = useThresholds(); + + if (status.isLoading) { + return ( +
+

Database Administration

+
Loading...
+
+ ); + } + + const db = status.data; + + return ( +
+
+
+

Database Administration

+
+ + {db?.version && {db.version}} + {db?.host && {db.host}} + {db?.schema && Schema: {db.schema}} +
+
+ +
+ + + + + + +
+ ); +} + +function PoolSection({ + pool, + warningPct, + criticalPct, +}: { + pool: ReturnType; + 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 ( + pool.refetch()} + isRefreshing={pool.isFetching} + autoRefresh + > +
+
+ {data.activeConnections} / {data.maxConnections} connections + {usagePct}% +
+
+
+
+
+
+
+ {data.activeConnections} + Active +
+
+ {data.idleConnections} + Idle +
+
+ {data.pendingConnections} + Pending +
+
+ {data.maxWaitMillis}ms + Max Wait +
+
+ + ); +} + +function TablesSection({ tables }: { tables: ReturnType }) { + const data = tables.data; + + return ( + tables.refetch()} + isRefreshing={tables.isFetching} + > + {!data ? ( +
Loading...
+ ) : ( +
+ + + + + + + + + + + {data.map((t) => ( + + + + + + + ))} + +
TableRowsData SizeIndex Size
{t.tableName}{t.rowEstimate.toLocaleString()}{t.dataSize}{t.indexSize}
+
+ )} +
+ ); +} + +function QueriesSection({ + queries, + warningSeconds, +}: { + queries: ReturnType; + warningSeconds?: number; +}) { + const [killTarget, setKillTarget] = useState(null); + const killMutation = useKillQuery(); + const data = queries.data; + + const warningMs = (warningSeconds ?? 30) * 1000; + + return ( + queries.refetch()} + isRefreshing={queries.isFetching} + autoRefresh + > + {!data || data.length === 0 ? ( +
No active queries
+ ) : ( +
+ + + + + + + + + + + + {data.map((q) => ( + warningMs ? styles.rowWarning : undefined} + > + + + + + + + ))} + +
PIDDurationStateQuery
{q.pid}{formatDuration(q.durationMs)}{q.state} + {q.query.length > 100 ? `${q.query.slice(0, 100)}...` : q.query} + + +
+
+ )} + setKillTarget(null)} + onConfirm={() => { + if (killTarget !== null) { + killMutation.mutate(killTarget); + setKillTarget(null); + } + }} + resourceName={String(killTarget ?? '')} + resourceType="query (PID)" + /> +
+ ); +} + +function MaintenanceSection() { + return ( + +
+ + + +
+
+ ); +} + +function ThresholdsSection({ thresholds }: { thresholds?: Thresholds }) { + const [form, setForm] = useState(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 ( + +
+
+ + update('poolWarningPercent', Number(e.target.value))} + /> +
+
+ + update('poolCriticalPercent', Number(e.target.value))} + /> +
+
+ + update('queryDurationWarningSeconds', Number(e.target.value))} + /> +
+
+ + update('queryDurationCriticalSeconds', Number(e.target.value))} + /> +
+
+
+ + {status && ( + + {status.msg} + + )} +
+
+ ); +} + +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`; +}