feat: harmonize admin pages to split-pane layout with shared CSS
All checks were successful
CI / build (push) Successful in 1m12s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 52s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Successful in 35s

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>
This commit is contained in:
hsiegeln
2026-03-17 19:30:38 +01:00
parent 6f5b5b8655
commit 6d650cdf34
9 changed files with 944 additions and 747 deletions

View File

@@ -1,7 +1,6 @@
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,
@@ -11,16 +10,33 @@ import {
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={styles.page}>
<div className={styles.accessDenied}>
Access Denied this page requires the ADMIN role.
<div className={layout.page}>
<div className={layout.accessDenied}>
Access Denied -- this page requires the ADMIN role.
</div>
</div>
);
@@ -30,6 +46,8 @@ export function DatabaseAdminPage() {
}
function DatabaseAdminContent() {
const [selectedSection, setSelectedSection] = useState<Section>('pool');
const status = useDatabaseStatus();
const pool = useDatabasePool();
const tables = useDatabaseTables();
@@ -38,21 +56,39 @@ function DatabaseAdminContent() {
if (status.isLoading) {
return (
<div className={styles.page}>
<h1 className={styles.pageTitle}>Database Administration</h1>
<div className={styles.loading}>Loading...</div>
<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={styles.page}>
<div className={styles.header}>
<div className={styles.headerInfo}>
<h1 className={styles.pageTitle}>Database Administration</h1>
<div className={styles.headerMeta}>
<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'}
@@ -64,7 +100,7 @@ function DatabaseAdminContent() {
</div>
<button
type="button"
className={styles.globalRefresh}
className={layout.btnAction}
onClick={() => {
status.refetch();
pool.refetch();
@@ -76,18 +112,46 @@ function DatabaseAdminContent() {
</button>
</div>
<PoolSection
pool={pool}
warningPct={thresholds.data?.database?.connectionPoolWarning}
criticalPct={thresholds.data?.database?.connectionPoolCritical}
/>
<TablesSection tables={tables} />
<QueriesSection
queries={queries}
warningSeconds={thresholds.data?.database?.queryDurationWarning}
/>
<MaintenanceSection />
<ThresholdsSection thresholds={thresholds.data} />
<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>
);
}
@@ -113,12 +177,8 @@ function PoolSection({
: '#22c55e';
return (
<RefreshableCard
title="Connection Pool"
onRefresh={() => pool.refetch()}
isRefreshing={pool.isFetching}
autoRefresh
>
<>
<div className={layout.detailSectionTitle}>Connection Pool</div>
<div className={styles.progressContainer}>
<div className={styles.progressLabel}>
{data.activeConnections} / {data.maxPoolSize} connections
@@ -149,7 +209,7 @@ function PoolSection({
<span className={styles.metricLabel}>Max Wait</span>
</div>
</div>
</RefreshableCard>
</>
);
}
@@ -157,13 +217,10 @@ function TablesSection({ tables }: { tables: ReturnType<typeof useDatabaseTables
const data = tables.data;
return (
<RefreshableCard
title="Table Sizes"
onRefresh={() => tables.refetch()}
isRefreshing={tables.isFetching}
>
<>
<div className={layout.detailSectionTitle}>Table Sizes</div>
{!data ? (
<div className={styles.loading}>Loading...</div>
<div className={layout.loading}>Loading...</div>
) : (
<div className={styles.tableWrapper}>
<table className={styles.table}>
@@ -188,7 +245,7 @@ function TablesSection({ tables }: { tables: ReturnType<typeof useDatabaseTables
</table>
</div>
)}
</RefreshableCard>
</>
);
}
@@ -206,12 +263,8 @@ function QueriesSection({
const warningSec = warningSeconds ?? 30;
return (
<RefreshableCard
title="Active Queries"
onRefresh={() => queries.refetch()}
isRefreshing={queries.isFetching}
autoRefresh
>
<>
<div className={layout.detailSectionTitle}>Active Queries</div>
{!data || data.length === 0 ? (
<div className={styles.emptyState}>No active queries</div>
) : (
@@ -265,13 +318,14 @@ function QueriesSection({
resourceName={String(killTarget ?? '')}
resourceType="query (PID)"
/>
</RefreshableCard>
</>
);
}
function MaintenanceSection() {
return (
<RefreshableCard title="Maintenance">
<>
<div className={layout.detailSectionTitle}>Maintenance</div>
<div className={styles.maintenanceGrid}>
<button type="button" className={styles.maintenanceBtn} disabled title="Coming soon">
VACUUM ANALYZE
@@ -283,7 +337,7 @@ function MaintenanceSection() {
Refresh Aggregates
</button>
</div>
</RefreshableCard>
</>
);
}
@@ -315,7 +369,8 @@ function ThresholdsSection({ thresholds }: { thresholds?: ThresholdConfig }) {
}
return (
<RefreshableCard title="Thresholds" collapsible defaultCollapsed>
<>
<div className={layout.detailSectionTitle}>Thresholds</div>
<div className={styles.thresholdGrid}>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Pool Warning %</label>
@@ -369,7 +424,7 @@ function ThresholdsSection({ thresholds }: { thresholds?: ThresholdConfig }) {
</span>
)}
</div>
</RefreshableCard>
</>
);
}