feat: harmonize admin pages to split-pane layout with shared CSS
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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user