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,59 +1,40 @@
.page { /* ─── Filter Toggle ─── */
max-width: 1100px; .filterToggle {
margin: 0 auto; width: 100%;
padding: 32px 16px; padding: 8px 20px;
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
background: transparent;
border: none;
border-bottom: 1px solid var(--border);
cursor: pointer;
text-align: left;
font-family: var(--font-body);
} }
.pageTitle { .filterToggle:hover {
font-size: 20px;
font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
margin: 0; background: var(--bg-hover);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.totalCount {
font-size: 13px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.accessDenied {
text-align: center;
padding: 64px 16px;
color: var(--text-muted);
font-size: 14px;
} }
/* ─── Filters ─── */ /* ─── Filters ─── */
.filters { .filters {
display: flex; display: flex;
gap: 12px; flex-direction: column;
flex-wrap: wrap; gap: 8px;
margin-bottom: 20px; padding: 10px 20px;
padding: 16px; border-bottom: 1px solid var(--border);
background: var(--bg-surface); }
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); .filtersCollapsed {
display: none;
} }
.filterGroup { .filterGroup {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 2px;
min-width: 120px;
}
.filterGroup:nth-child(3),
.filterGroup:nth-child(5) {
flex: 1;
min-width: 150px;
} }
.filterLabel { .filterLabel {
@@ -68,11 +49,12 @@
background: var(--bg-base); background: var(--bg-base);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 7px 10px; padding: 5px 8px;
color: var(--text-primary); color: var(--text-primary);
font-size: 12px; font-size: 11px;
outline: none; outline: none;
transition: border-color 0.2s; transition: border-color 0.2s;
font-family: var(--font-body);
} }
.filterInput:focus { .filterInput:focus {
@@ -87,64 +69,37 @@
background: var(--bg-base); background: var(--bg-base);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 7px 10px; padding: 5px 8px;
color: var(--text-primary); color: var(--text-primary);
font-size: 12px; font-size: 11px;
outline: none; outline: none;
cursor: pointer; cursor: pointer;
font-family: var(--font-body);
} }
/* ─── Table ─── */ /* ─── Event Row Styles ─── */
.tableWrapper { .eventTimestamp {
overflow-x: auto;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.table th {
text-align: left;
padding: 10px 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);
}
.eventRow {
cursor: pointer;
transition: background 0.1s;
}
.eventRow:hover {
background: var(--bg-hover);
}
.eventRowExpanded {
background: var(--bg-hover);
}
.mono {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 10px;
color: var(--text-muted);
white-space: nowrap; white-space: nowrap;
} }
.eventAction {
color: var(--text-muted);
font-size: 11px;
}
.eventCompact {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
margin-top: 1px;
}
/* ─── Badges ─── */
.categoryBadge { .categoryBadge {
display: inline-block; display: inline-block;
padding: 2px 8px; padding: 2px 8px;
@@ -177,12 +132,7 @@
color: #ef4444; color: #ef4444;
} }
/* ─── Detail Row ─── */ /* ─── Detail JSON ─── */
.detailRow td {
padding: 0 12px 12px;
background: var(--bg-hover);
}
.detailJson { .detailJson {
margin: 0; margin: 0;
padding: 12px; padding: 12px;
@@ -197,64 +147,9 @@
word-break: break-word; word-break: break-word;
} }
/* ─── Pagination ─── */ /* ─── Mono ─── */
.pagination { .mono {
display: flex; font-family: var(--font-mono);
align-items: center; font-size: 11px;
justify-content: center; white-space: nowrap;
gap: 12px;
margin-top: 16px;
}
.pageBtn {
padding: 6px 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.pageBtn:hover:not(:disabled) {
border-color: var(--amber-dim);
color: var(--text-primary);
}
.pageBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pageInfo {
font-size: 12px;
color: var(--text-muted);
}
.loading {
text-align: center;
padding: 32px;
color: var(--text-muted);
font-size: 14px;
}
.emptyState {
text-align: center;
padding: 48px 16px;
color: var(--text-muted);
font-size: 13px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
}
@media (max-width: 768px) {
.filters {
flex-direction: column;
}
.filterGroup {
min-width: unset;
}
} }

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useAuthStore } from '../../auth/auth-store'; import { useAuthStore } from '../../auth/auth-store';
import { useAuditLog, type AuditLogParams } from '../../api/queries/admin/audit'; import { useAuditLog, type AuditLogParams } from '../../api/queries/admin/audit';
import layout from '../../styles/AdminLayout.module.css';
import styles from './AuditLogPage.module.css'; import styles from './AuditLogPage.module.css';
function defaultFrom(): string { function defaultFrom(): string {
@@ -18,9 +19,9 @@ export function AuditLogPage() {
if (!roles.includes('ADMIN')) { if (!roles.includes('ADMIN')) {
return ( return (
<div className={styles.page}> <div className={layout.page}>
<div className={styles.accessDenied}> <div className={layout.accessDenied}>
Access Denied this page requires the ADMIN role. Access Denied -- this page requires the ADMIN role.
</div> </div>
</div> </div>
); );
@@ -36,7 +37,8 @@ function AuditLogContent() {
const [category, setCategory] = useState(''); const [category, setCategory] = useState('');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [expandedRow, setExpandedRow] = useState<number | null>(null); const [selectedEventId, setSelectedEventId] = useState<number | null>(null);
const [filtersVisible, setFiltersVisible] = useState(true);
const pageSize = 25; const pageSize = 25;
const params: AuditLogParams = { const params: AuditLogParams = {
@@ -55,16 +57,36 @@ function AuditLogContent() {
const showingFrom = data && data.totalCount > 0 ? page * pageSize + 1 : 0; const showingFrom = data && data.totalCount > 0 ? page * pageSize + 1 : 0;
const showingTo = data ? Math.min((page + 1) * pageSize, data.totalCount) : 0; const showingTo = data ? Math.min((page + 1) * pageSize, data.totalCount) : 0;
const selectedEvent = data?.items.find((e) => e.id === selectedEventId) ?? null;
return ( return (
<div className={styles.page}> <div className={layout.page}>
<div className={styles.header}> <div className={layout.panelHeader}>
<h1 className={styles.pageTitle}>Audit Log</h1> <div>
<div className={layout.panelTitle}>Audit Log</div>
{data && ( {data && (
<span className={styles.totalCount}>{data.totalCount.toLocaleString()} events</span> <div className={layout.panelSubtitle}>
{data.totalCount.toLocaleString()} events
</div>
)} )}
</div> </div>
</div>
<div className={styles.filters}> <div className={layout.split}>
{/* Left pane — event list */}
<div className={layout.listPane}>
{/* Collapsible filter bar */}
<div style={{ flexShrink: 0 }}>
<button
type="button"
className={styles.filterToggle}
onClick={() => setFiltersVisible((v) => !v)}
>
{filtersVisible ? 'Hide Filters' : 'Show Filters'}
</button>
<div
className={`${styles.filters} ${!filtersVisible ? styles.filtersCollapsed : ''}`}
>
<div className={styles.filterGroup}> <div className={styles.filterGroup}>
<label className={styles.filterLabel}>From</label> <label className={styles.filterLabel}>From</label>
<input <input
@@ -118,93 +140,147 @@ function AuditLogContent() {
/> />
</div> </div>
</div> </div>
</div>
{/* Event list */}
<div className={layout.entityList}>
{audit.isLoading ? ( {audit.isLoading ? (
<div className={styles.loading}>Loading...</div> <div className={layout.loading}>Loading...</div>
) : !data || data.items.length === 0 ? ( ) : !data || data.items.length === 0 ? (
<div className={styles.emptyState}>No audit events found for the selected filters.</div> <div className={layout.loading}>No events found.</div>
) : ( ) : (
<> data.items.map((event) => (
<div className={styles.tableWrapper}> <div
<table className={styles.table}>
<thead>
<tr>
<th>Timestamp</th>
<th>User</th>
<th>Category</th>
<th>Action</th>
<th>Target</th>
<th>Result</th>
</tr>
</thead>
<tbody>
{data.items.map((event) => (
<>
<tr
key={event.id} key={event.id}
className={`${styles.eventRow} ${expandedRow === event.id ? styles.eventRowExpanded : ''}`} className={`${layout.entityItem} ${
onClick={() => selectedEventId === event.id ? layout.entityItemSelected : ''
setExpandedRow((prev) => (prev === event.id ? null : event.id)) }`}
} onClick={() => setSelectedEventId(event.id)}
> >
<td className={styles.mono}> <div className={layout.entityInfo}>
<div className={styles.eventTimestamp}>
{formatTimestamp(event.timestamp)} {formatTimestamp(event.timestamp)}
</td> </div>
<td>{event.username}</td> <div className={styles.eventCompact}>
<td> <span>{event.username}</span>
<span className={styles.categoryBadge}>{event.category}</span> <span className={styles.eventAction}>{event.action}</span>
</td> </div>
<td>{event.action}</td> <div className={styles.eventCompact}>
<td className={styles.mono}>{event.target}</td>
<td>
<span <span
className={`${styles.resultBadge} ${ className={`${styles.resultBadge} ${
event.result === 'SUCCESS' ? styles.resultSuccess : styles.resultFailure event.result === 'SUCCESS'
? styles.resultSuccess
: styles.resultFailure
}`} }`}
> >
{event.result} {event.result}
</span> </span>
</td> </div>
</tr> </div>
{expandedRow === event.id && ( </div>
<tr key={`${event.id}-detail`} className={styles.detailRow}> ))
<td colSpan={6}>
<pre className={styles.detailJson}>
{JSON.stringify(event.detail, null, 2)}
</pre>
</td>
</tr>
)} )}
</>
))}
</tbody>
</table>
</div> </div>
<div className={styles.pagination}> {/* Pagination */}
{data && data.totalCount > 0 && (
<div className={layout.pagination}>
<button <button
type="button" type="button"
className={styles.pageBtn} className={layout.pageBtn}
disabled={page === 0} disabled={page === 0}
onClick={() => setPage((p) => p - 1)} onClick={() => setPage((p) => p - 1)}
> >
Previous Previous
</button> </button>
<span className={styles.pageInfo}> <span className={layout.pageInfo}>
Showing {showingFrom}-{showingTo} of {data.totalCount.toLocaleString()} {showingFrom}-{showingTo} of {data.totalCount.toLocaleString()}
</span> </span>
<button <button
type="button" type="button"
className={styles.pageBtn} className={layout.pageBtn}
disabled={page >= totalPages - 1} disabled={page >= totalPages - 1}
onClick={() => setPage((p) => p + 1)} onClick={() => setPage((p) => p + 1)}
> >
Next Next
</button> </button>
</div> </div>
)}
</div>
{/* Right pane — detail view */}
<div className={layout.detailPane}>
{!selectedEvent ? (
<div className={layout.detailEmpty}>
Select an event to view details
</div>
) : (
<>
<div className={layout.detailSection}>
<div className={layout.detailSectionTitle}>Event Info</div>
<div className={layout.fieldRow}>
<span className={layout.fieldLabel}>Timestamp</span>
<span className={`${layout.fieldVal} ${styles.mono}`}>
{formatTimestamp(selectedEvent.timestamp)}
</span>
</div>
<div className={layout.fieldRow}>
<span className={layout.fieldLabel}>User</span>
<span className={layout.fieldVal}>{selectedEvent.username}</span>
</div>
<div className={layout.fieldRow}>
<span className={layout.fieldLabel}>Category</span>
<span className={layout.fieldVal}>
<span className={styles.categoryBadge}>{selectedEvent.category}</span>
</span>
</div>
<div className={layout.fieldRow}>
<span className={layout.fieldLabel}>Action</span>
<span className={layout.fieldVal}>{selectedEvent.action}</span>
</div>
<div className={layout.fieldRow}>
<span className={layout.fieldLabel}>Target</span>
<span className={`${layout.fieldVal} ${styles.mono}`}>
{selectedEvent.target}
</span>
</div>
<div className={layout.fieldRow}>
<span className={layout.fieldLabel}>Result</span>
<span className={layout.fieldVal}>
<span
className={`${styles.resultBadge} ${
selectedEvent.result === 'SUCCESS'
? styles.resultSuccess
: styles.resultFailure
}`}
>
{selectedEvent.result}
</span>
</span>
</div>
<div className={layout.fieldRow}>
<span className={layout.fieldLabel}>IP Address</span>
<span className={`${layout.fieldVal} ${styles.mono}`}>
{selectedEvent.ipAddress}
</span>
</div>
<div className={layout.fieldRow}>
<span className={layout.fieldLabel}>User Agent</span>
<span className={layout.fieldVal}>{selectedEvent.userAgent}</span>
</div>
</div>
<div className={layout.detailSection}>
<div className={layout.detailSectionTitle}>Detail Payload</div>
<pre className={styles.detailJson}>
{JSON.stringify(selectedEvent.detail, null, 2)}
</pre>
</div>
</> </>
)} )}
</div> </div>
</div>
</div>
); );
} }

View File

@@ -1,73 +1,10 @@
.page { /* ─── Meta ─── */
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 { .metaItem {
font-size: 12px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
font-family: var(--font-mono); 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 ─── */ /* ─── Progress Bar ─── */
.progressContainer { .progressContainer {
margin-bottom: 16px; margin-bottom: 16px;
@@ -309,9 +246,4 @@
.thresholdGrid { .thresholdGrid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.header {
flex-direction: column;
gap: 12px;
}
} }

View File

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

View File

@@ -1,29 +1,4 @@
.page { /* ─── Toggle ─── */
max-width: 640px;
margin: 0 auto;
padding: 32px 16px;
}
.pageTitle {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.subtitle {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 24px;
}
.card {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 32px;
}
.toggleRow { .toggleRow {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -84,6 +59,7 @@
background: #0a0e17; background: #0a0e17;
} }
/* ─── Form Fields ─── */
.field { .field {
margin-top: 16px; margin-top: 16px;
} }
@@ -123,6 +99,7 @@
box-shadow: 0 0 0 3px var(--amber-glow); box-shadow: 0 0 0 3px var(--amber-glow);
} }
/* ─── Tags ─── */
.tags { .tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -182,31 +159,17 @@
color: var(--text-primary); color: var(--text-primary);
} }
.actions { /* ─── Header Action Button Variants ─── */
display: flex;
align-items: center;
gap: 12px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--border-subtle);
}
.btnPrimary { .btnPrimary {
padding: 10px 24px; border-color: var(--amber) !important;
border-radius: var(--radius-sm); background: var(--amber) !important;
border: 1px solid var(--amber); color: #0a0e17 !important;
background: var(--amber);
color: #0a0e17;
font-family: var(--font-body);
font-size: 14px;
font-weight: 600; font-weight: 600;
cursor: pointer;
transition: all 0.15s;
} }
.btnPrimary:hover { .btnPrimary:hover:not(:disabled) {
background: var(--amber-hover); background: var(--amber-hover) !important;
border-color: var(--amber-hover); border-color: var(--amber-hover) !important;
} }
.btnPrimary:disabled { .btnPrimary:disabled {
@@ -215,19 +178,12 @@
} }
.btnOutline { .btnOutline {
padding: 10px 24px;
border-radius: var(--radius-sm);
background: transparent; background: transparent;
border: 1px solid var(--border); border-color: var(--border);
color: var(--text-secondary); color: var(--text-secondary);
font-family: var(--font-body);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
} }
.btnOutline:hover { .btnOutline:hover:not(:disabled) {
border-color: var(--amber-dim); border-color: var(--amber-dim);
color: var(--text-primary); color: var(--text-primary);
} }
@@ -238,21 +194,13 @@
} }
.btnDanger { .btnDanger {
margin-left: auto; border-color: var(--rose-dim) !important;
padding: 10px 24px; color: var(--rose) !important;
border-radius: var(--radius-sm); background: transparent !important;
background: transparent;
border: 1px solid var(--rose-dim);
color: var(--rose);
font-family: var(--font-body);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
} }
.btnDanger:hover { .btnDanger:hover:not(:disabled) {
background: var(--rose-glow); background: var(--rose-glow) !important;
} }
.btnDanger:disabled { .btnDanger:disabled {
@@ -260,6 +208,7 @@
cursor: not-allowed; cursor: not-allowed;
} }
/* ─── Confirm Bar ─── */
.confirmBar { .confirmBar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -283,6 +232,7 @@
gap: 8px; gap: 8px;
} }
/* ─── Status Messages ─── */
.successMsg { .successMsg {
margin-top: 16px; margin-top: 16px;
padding: 10px 12px; padding: 10px 12px;
@@ -303,6 +253,7 @@
color: var(--rose); color: var(--rose);
} }
/* ─── Skeleton Loading ─── */
.skeleton { .skeleton {
animation: pulse 1.5s ease-in-out infinite; animation: pulse 1.5s ease-in-out infinite;
background: var(--bg-raised); background: var(--bg-raised);
@@ -322,13 +273,6 @@
width: 60%; width: 60%;
} }
.accessDenied {
text-align: center;
padding: 64px 16px;
color: var(--text-muted);
font-size: 14px;
}
@keyframes pulse { @keyframes pulse {
0%, 100% { opacity: 0.4; } 0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; } 50% { opacity: 0.8; }

View File

@@ -7,6 +7,7 @@ import {
useDeleteOidcConfig, useDeleteOidcConfig,
} from '../../api/queries/oidc-admin'; } from '../../api/queries/oidc-admin';
import type { OidcAdminConfigRequest } from '../../api/types'; import type { OidcAdminConfigRequest } from '../../api/types';
import layout from '../../styles/AdminLayout.module.css';
import styles from './OidcAdminPage.module.css'; import styles from './OidcAdminPage.module.css';
interface FormData { interface FormData {
@@ -36,9 +37,9 @@ export function OidcAdminPage() {
if (!roles.includes('ADMIN')) { if (!roles.includes('ADMIN')) {
return ( return (
<div className={styles.page}> <div className={layout.page}>
<div className={styles.accessDenied}> <div className={layout.accessDenied}>
Access Denied this page requires the ADMIN role. Access Denied -- this page requires the ADMIN role.
</div> </div>
</div> </div>
); );
@@ -137,10 +138,14 @@ function OidcAdminForm() {
if (isLoading) { if (isLoading) {
return ( return (
<div className={styles.page}> <div className={layout.page}>
<h1 className={styles.pageTitle}>OIDC Configuration</h1> <div className={layout.panelHeader}>
<p className={styles.subtitle}>Configure external identity provider</p> <div>
<div className={styles.card}> <div className={layout.panelTitle}>OIDC Configuration</div>
<div className={layout.panelSubtitle}>Configure external identity provider</div>
</div>
</div>
<div className={layout.detailOnly}>
<div className={styles.skeletonWide} /> <div className={styles.skeletonWide} />
<div className={styles.skeletonMedium} /> <div className={styles.skeletonMedium} />
<div className={styles.skeletonWide} /> <div className={styles.skeletonWide} />
@@ -154,11 +159,44 @@ function OidcAdminForm() {
const isConfigured = data?.configured ?? false; const isConfigured = data?.configured ?? false;
return ( return (
<div className={styles.page}> <div className={layout.page}>
<h1 className={styles.pageTitle}>OIDC Configuration</h1> <div className={layout.panelHeader}>
<p className={styles.subtitle}>Configure external identity provider</p> <div>
<div className={layout.panelTitle}>OIDC Configuration</div>
<div className={layout.panelSubtitle}>Configure external identity provider</div>
</div>
<div className={layout.headerActions}>
<button
type="button"
className={`${layout.btnAction} ${styles.btnPrimary}`}
onClick={handleSave}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? 'Saving...' : 'Save'}
</button>
<button
type="button"
className={`${layout.btnAction} ${styles.btnOutline}`}
onClick={handleTest}
disabled={!isConfigured || testMutation.isPending}
>
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
</button>
<button
type="button"
className={`${layout.btnAction} ${styles.btnDanger}`}
onClick={() => setShowDeleteConfirm(true)}
disabled={!isConfigured || deleteMutation.isPending}
>
Delete
</button>
</div>
</div>
<div className={layout.detailOnly}>
<div className={layout.detailSection}>
<div className={layout.detailSectionTitle}>Behavior</div>
<div className={styles.card}>
<div className={styles.toggleRow}> <div className={styles.toggleRow}>
<div className={styles.toggleInfo}> <div className={styles.toggleInfo}>
<div className={styles.toggleLabel}>Enabled</div> <div className={styles.toggleLabel}>Enabled</div>
@@ -189,6 +227,10 @@ function OidcAdminForm() {
aria-label="Toggle auto sign-up" aria-label="Toggle auto sign-up"
/> />
</div> </div>
</div>
<div className={layout.detailSection}>
<div className={layout.detailSectionTitle}>Provider Settings</div>
<div className={styles.field}> <div className={styles.field}>
<label className={styles.label}>Issuer URI</label> <label className={styles.label}>Issuer URI</label>
@@ -225,6 +267,10 @@ function OidcAdminForm() {
placeholder={data?.clientSecretSet ? 'Secret is configured' : 'Enter client secret'} placeholder={data?.clientSecretSet ? 'Secret is configured' : 'Enter client secret'}
/> />
</div> </div>
</div>
<div className={layout.detailSection}>
<div className={layout.detailSectionTitle}>Claim Mapping</div>
<div className={styles.field}> <div className={styles.field}>
<label className={styles.label}>Roles Claim</label> <label className={styles.label}>Roles Claim</label>
@@ -253,9 +299,11 @@ function OidcAdminForm() {
Dot-separated path to the user's display name in the ID token (e.g. name, preferred_username, profile.display_name) Dot-separated path to the user's display name in the ID token (e.g. name, preferred_username, profile.display_name)
</div> </div>
</div> </div>
</div>
<div className={layout.detailSection}>
<div className={layout.detailSectionTitle}>Default Roles</div>
<div className={styles.field}>
<label className={styles.label}>Default Roles</label>
<div className={styles.tags}> <div className={styles.tags}>
{form.defaultRoles.map((role) => ( {form.defaultRoles.map((role) => (
<span key={role} className={styles.tag}> <span key={role} className={styles.tag}>
@@ -291,33 +339,6 @@ function OidcAdminForm() {
</div> </div>
</div> </div>
<div className={styles.actions}>
<button
type="button"
className={styles.btnPrimary}
onClick={handleSave}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? 'Saving...' : 'Save'}
</button>
<button
type="button"
className={styles.btnOutline}
onClick={handleTest}
disabled={!isConfigured || testMutation.isPending}
>
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
</button>
<button
type="button"
className={styles.btnDanger}
onClick={() => setShowDeleteConfirm(true)}
disabled={!isConfigured || deleteMutation.isPending}
>
Delete
</button>
</div>
{showDeleteConfirm && ( {showDeleteConfirm && (
<div className={styles.confirmBar}> <div className={styles.confirmBar}>
<span>Delete OIDC configuration? This cannot be undone.</span> <span>Delete OIDC configuration? This cannot be undone.</span>

View File

@@ -1,73 +1,3 @@
.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 ─── */ /* ─── Progress Bar ─── */
.progressContainer { .progressContainer {
margin-bottom: 16px; margin-bottom: 16px;
@@ -405,6 +335,12 @@
color: var(--rose); color: var(--rose);
} }
.metaItem {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}
@media (max-width: 640px) { @media (max-width: 640px) {
.metricsGrid { .metricsGrid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
@@ -414,11 +350,6 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.header {
flex-direction: column;
gap: 12px;
}
.filterRow { .filterRow {
flex-direction: column; flex-direction: column;
} }

View File

@@ -1,7 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useAuthStore } from '../../auth/auth-store'; import { useAuthStore } from '../../auth/auth-store';
import { StatusBadge, type Status } from '../../components/admin/StatusBadge'; import { StatusBadge, type Status } from '../../components/admin/StatusBadge';
import { RefreshableCard } from '../../components/admin/RefreshableCard';
import { ConfirmDeleteDialog } from '../../components/admin/ConfirmDeleteDialog'; import { ConfirmDeleteDialog } from '../../components/admin/ConfirmDeleteDialog';
import { import {
useOpenSearchStatus, useOpenSearchStatus,
@@ -12,8 +11,11 @@ import {
type IndicesParams, type IndicesParams,
} from '../../api/queries/admin/opensearch'; } from '../../api/queries/admin/opensearch';
import { useThresholds, useSaveThresholds, type ThresholdConfig } from '../../api/queries/admin/thresholds'; import { useThresholds, useSaveThresholds, type ThresholdConfig } from '../../api/queries/admin/thresholds';
import layout from '../../styles/AdminLayout.module.css';
import styles from './OpenSearchAdminPage.module.css'; import styles from './OpenSearchAdminPage.module.css';
type Section = 'pipeline' | 'indices' | 'performance' | 'operations' | 'thresholds';
function clusterHealthToStatus(health: string | undefined): Status { function clusterHealthToStatus(health: string | undefined): Status {
switch (health?.toLowerCase()) { switch (health?.toLowerCase()) {
case 'green': return 'healthy'; case 'green': return 'healthy';
@@ -23,14 +25,22 @@ function clusterHealthToStatus(health: string | undefined): Status {
} }
} }
const SECTIONS: { key: Section; label: string; icon: string }[] = [
{ key: 'pipeline', label: 'Indexing Pipeline', icon: '>' },
{ key: 'indices', label: 'Indices', icon: '#' },
{ key: 'performance', label: 'Performance', icon: '~' },
{ key: 'operations', label: 'Operations', icon: '*' },
{ key: 'thresholds', label: 'Thresholds', icon: '=' },
];
export function OpenSearchAdminPage() { export function OpenSearchAdminPage() {
const roles = useAuthStore((s) => s.roles); const roles = useAuthStore((s) => s.roles);
if (!roles.includes('ADMIN')) { if (!roles.includes('ADMIN')) {
return ( return (
<div className={styles.page}> <div className={layout.page}>
<div className={styles.accessDenied}> <div className={layout.accessDenied}>
Access Denied this page requires the ADMIN role. Access Denied -- this page requires the ADMIN role.
</div> </div>
</div> </div>
); );
@@ -40,6 +50,8 @@ export function OpenSearchAdminPage() {
} }
function OpenSearchAdminContent() { function OpenSearchAdminContent() {
const [selectedSection, setSelectedSection] = useState<Section>('pipeline');
const status = useOpenSearchStatus(); const status = useOpenSearchStatus();
const pipeline = usePipelineStats(); const pipeline = usePipelineStats();
const performance = usePerformanceStats(); const performance = usePerformanceStats();
@@ -47,35 +59,48 @@ function OpenSearchAdminContent() {
if (status.isLoading) { if (status.isLoading) {
return ( return (
<div className={styles.page}> <div className={layout.page}>
<h1 className={styles.pageTitle}>OpenSearch Administration</h1> <div className={layout.loading}>Loading...</div>
<div className={styles.loading}>Loading...</div>
</div> </div>
); );
} }
const os = status.data; const os = status.data;
function getMiniStatus(key: Section): string {
switch (key) {
case 'pipeline':
return pipeline.data ? `Queue: ${pipeline.data.queueDepth}` : '--';
case 'indices':
return '--';
case 'performance':
return performance.data
? `${(performance.data.queryCacheHitRate * 100).toFixed(0)}% hit`
: '--';
case 'operations':
return 'Coming soon';
case 'thresholds':
return 'Configured';
}
}
return ( return (
<div className={styles.page}> <div className={layout.page}>
<div className={styles.header}> <div className={layout.panelHeader}>
<div className={styles.headerInfo}> <div>
<h1 className={styles.pageTitle}>OpenSearch Administration</h1> <div className={layout.panelTitle}>OpenSearch</div>
<div className={styles.headerMeta}> <div className={layout.panelSubtitle}>
<StatusBadge <StatusBadge
status={clusterHealthToStatus(os?.clusterHealth)} status={clusterHealthToStatus(os?.clusterHealth)}
label={os?.clusterHealth ?? 'Unknown'} label={os?.clusterHealth ?? 'Unknown'}
/> />
{os?.version && <span className={styles.metaItem}>v{os.version}</span>} {os?.version && <span>v{os.version}</span>}
{os?.nodeCount !== undefined && ( {os?.nodeCount !== undefined && <span>{os.nodeCount} node(s)</span>}
<span className={styles.metaItem}>{os.nodeCount} node(s)</span>
)}
{os?.host && <span className={styles.metaItem}>{os.host}</span>}
</div> </div>
</div> </div>
<button <button
type="button" type="button"
className={styles.globalRefresh} className={layout.btnAction}
onClick={() => { onClick={() => {
status.refetch(); status.refetch();
pipeline.refetch(); pipeline.refetch();
@@ -86,11 +111,39 @@ function OpenSearchAdminContent() {
</button> </button>
</div> </div>
<div className={layout.split}>
<div className={layout.listPane}>
<div className={layout.entityList}>
{SECTIONS.map((s) => (
<div
key={s.key}
className={`${layout.entityItem} ${selectedSection === s.key ? layout.entityItemSelected : ''}`}
onClick={() => setSelectedSection(s.key)}
>
<div className={layout.sectionIcon}>{s.icon}</div>
<div className={layout.entityInfo}>
<div className={layout.entityName}>{s.label}</div>
</div>
<div className={layout.miniStatus}>{getMiniStatus(s.key)}</div>
</div>
))}
</div>
</div>
<div className={layout.detailPane}>
{selectedSection === 'pipeline' && (
<PipelineSection pipeline={pipeline} thresholds={thresholds.data} /> <PipelineSection pipeline={pipeline} thresholds={thresholds.data} />
<IndicesSection /> )}
{selectedSection === 'indices' && <IndicesSection />}
{selectedSection === 'performance' && (
<PerformanceSection performance={performance} thresholds={thresholds.data} /> <PerformanceSection performance={performance} thresholds={thresholds.data} />
<OperationsSection /> )}
{selectedSection === 'operations' && <OperationsSection />}
{selectedSection === 'thresholds' && (
<OsThresholdsSection thresholds={thresholds.data} /> <OsThresholdsSection thresholds={thresholds.data} />
)}
</div>
</div>
</div> </div>
); );
} }
@@ -114,12 +167,8 @@ function PipelineSection({
: '#22c55e'; : '#22c55e';
return ( return (
<RefreshableCard <>
title="Indexing Pipeline" <div className={layout.detailSectionTitle}>Indexing Pipeline</div>
onRefresh={() => pipeline.refetch()}
isRefreshing={pipeline.isFetching}
autoRefresh
>
<div className={styles.progressContainer}> <div className={styles.progressContainer}>
<div className={styles.progressLabel}> <div className={styles.progressLabel}>
Queue: {data.queueDepth} / {data.maxQueueSize} Queue: {data.queueDepth} / {data.maxQueueSize}
@@ -146,7 +195,7 @@ function PipelineSection({
<span className={styles.metricLabel}>Indexing Rate</span> <span className={styles.metricLabel}>Indexing Rate</span>
</div> </div>
</div> </div>
</RefreshableCard> </>
); );
} }
@@ -169,11 +218,8 @@ function IndicesSection() {
const totalPages = data?.totalPages ?? 0; const totalPages = data?.totalPages ?? 0;
return ( return (
<RefreshableCard <>
title="Indices" <div className={layout.detailSectionTitle}>Indices</div>
onRefresh={() => indices.refetch()}
isRefreshing={indices.isFetching}
>
<div className={styles.filterRow}> <div className={styles.filterRow}>
<input <input
className={styles.filterInput} className={styles.filterInput}
@@ -185,7 +231,7 @@ function IndicesSection() {
</div> </div>
{!data ? ( {!data ? (
<div className={styles.loading}>Loading...</div> <div className={layout.loading}>Loading...</div>
) : ( ) : (
<> <>
<div className={styles.tableWrapper}> <div className={styles.tableWrapper}>
@@ -270,7 +316,7 @@ function IndicesSection() {
resourceName={deleteTarget ?? ''} resourceName={deleteTarget ?? ''}
resourceType="index" resourceType="index"
/> />
</RefreshableCard> </>
); );
} }
@@ -293,12 +339,8 @@ function PerformanceSection({
: '#22c55e'; : '#22c55e';
return ( return (
<RefreshableCard <>
title="Performance" <div className={layout.detailSectionTitle}>Performance</div>
onRefresh={() => performance.refetch()}
isRefreshing={performance.isFetching}
autoRefresh
>
<div className={styles.metricsGrid}> <div className={styles.metricsGrid}>
<div className={styles.metric}> <div className={styles.metric}>
<span className={styles.metricValue}>{(data.queryCacheHitRate * 100).toFixed(1)}%</span> <span className={styles.metricValue}>{(data.queryCacheHitRate * 100).toFixed(1)}%</span>
@@ -329,13 +371,14 @@ function PerformanceSection({
/> />
</div> </div>
</div> </div>
</RefreshableCard> </>
); );
} }
function OperationsSection() { function OperationsSection() {
return ( return (
<RefreshableCard title="Operations"> <>
<div className={layout.detailSectionTitle}>Operations</div>
<div className={styles.operationsGrid}> <div className={styles.operationsGrid}>
<button type="button" className={styles.operationBtn} disabled title="Coming soon"> <button type="button" className={styles.operationBtn} disabled title="Coming soon">
Force Merge Force Merge
@@ -347,7 +390,7 @@ function OperationsSection() {
Clear Cache Clear Cache
</button> </button>
</div> </div>
</RefreshableCard> </>
); );
} }
@@ -378,7 +421,8 @@ function OsThresholdsSection({ thresholds }: { thresholds?: ThresholdConfig }) {
} }
return ( return (
<RefreshableCard title="Thresholds" collapsible defaultCollapsed> <>
<div className={layout.detailSectionTitle}>Thresholds</div>
<div className={styles.thresholdGrid}> <div className={styles.thresholdGrid}>
<div className={styles.thresholdField}> <div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Queue Warning</label> <label className={styles.thresholdLabel}>Queue Warning</label>
@@ -432,7 +476,7 @@ function OsThresholdsSection({ thresholds }: { thresholds?: ThresholdConfig }) {
</span> </span>
)} )}
</div> </div>
</RefreshableCard> </>
); );
} }

View File

@@ -0,0 +1,299 @@
/* ─── Shared Admin Layout ─── */
.page {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.accessDenied {
text-align: center;
padding: 64px 16px;
color: var(--text-muted);
font-size: 14px;
}
.loading {
text-align: center;
padding: 32px;
color: var(--text-muted);
font-size: 14px;
}
/* ─── Panel Header ─── */
.panelHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 12px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.panelTitle {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
}
.panelSubtitle {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
display: flex;
align-items: center;
gap: 10px;
}
.btnAction {
font-size: 12px;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: transparent;
color: var(--text-primary);
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
font-family: var(--font-body);
}
.btnAction:hover {
background: var(--bg-hover);
}
/* ─── Split Layout ─── */
.split {
display: flex;
flex: 1;
overflow: hidden;
}
.listPane {
width: 280px;
min-width: 220px;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.detailPane {
flex: 1;
overflow-y: auto;
padding: 20px;
}
/* ─── Search Bar ─── */
.searchBar {
padding: 10px 20px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.searchInput {
width: 100%;
padding: 7px 10px;
font-size: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-base);
color: var(--text-primary);
outline: none;
font-family: var(--font-body);
transition: border-color 0.15s;
}
.searchInput:focus {
border-color: var(--amber-dim);
}
.searchInput::placeholder {
color: var(--text-muted);
}
/* ─── Entity List (section nav / item list) ─── */
.entityList {
flex: 1;
overflow-y: auto;
}
.entityItem {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
border-bottom: 1px solid var(--border-subtle);
cursor: pointer;
transition: background 0.1s;
}
.entityItem:hover {
background: var(--bg-hover);
}
.entityItemSelected {
background: var(--bg-raised);
}
.entityInfo {
flex: 1;
min-width: 0;
}
.entityName {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.entityMeta {
font-size: 11px;
color: var(--text-muted);
margin-top: 1px;
}
/* ─── Detail Pane ─── */
.detailEmpty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 13px;
gap: 8px;
}
.detailSection {
margin-bottom: 20px;
}
.detailSectionTitle {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-muted);
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
}
.detailSectionTitle span {
font-size: 10px;
color: var(--text-muted);
text-transform: none;
letter-spacing: 0;
}
.divider {
border: none;
border-top: 1px solid var(--border-subtle);
margin: 12px 0;
}
/* ─── Field Rows ─── */
.fieldRow {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.fieldLabel {
font-size: 11px;
color: var(--text-muted);
width: 70px;
flex-shrink: 0;
}
.fieldVal {
font-size: 12px;
color: var(--text-primary);
}
.fieldMono {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
}
/* ─── Section Icon ─── */
.sectionIcon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
background: var(--bg-raised);
border: 1px solid var(--border-subtle);
}
/* ─── Status Indicators ─── */
.miniStatus {
font-size: 10px;
font-family: var(--font-mono);
color: var(--text-muted);
white-space: nowrap;
}
/* ─── Pagination ─── */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 10px 20px;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.pageBtn {
padding: 5px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text-secondary);
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
}
.pageBtn:hover:not(:disabled) {
border-color: var(--amber-dim);
color: var(--text-primary);
}
.pageBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pageInfo {
font-size: 11px;
color: var(--text-muted);
}
/* ─── Header Actions Row ─── */
.headerActions {
display: flex;
align-items: center;
gap: 8px;
}
/* ─── Detail-only layout (no split, e.g. OIDC) ─── */
.detailOnly {
flex: 1;
overflow-y: auto;
padding: 20px;
}