Extract shared admin layout styles into AdminLayout.module.css and convert all admin pages to consistent patterns: Database/OpenSearch/ Audit Log use split-pane master/detail, OIDC uses full-width detail-only with unified panelHeader treatment across all pages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
438 lines
14 KiB
TypeScript
438 lines
14 KiB
TypeScript
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`;
|
|
}
|