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 {
max-width: 1100px;
margin: 0 auto;
padding: 32px 16px;
/* ─── Filter Toggle ─── */
.filterToggle {
width: 100%;
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 {
font-size: 20px;
font-weight: 600;
.filterToggle:hover {
color: var(--text-primary);
margin: 0;
}
.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;
background: var(--bg-hover);
}
/* ─── Filters ─── */
.filters {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 20px;
padding: 16px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
flex-direction: column;
gap: 8px;
padding: 10px 20px;
border-bottom: 1px solid var(--border);
}
.filtersCollapsed {
display: none;
}
.filterGroup {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 120px;
}
.filterGroup:nth-child(3),
.filterGroup:nth-child(5) {
flex: 1;
min-width: 150px;
gap: 2px;
}
.filterLabel {
@@ -68,11 +49,12 @@
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 7px 10px;
padding: 5px 8px;
color: var(--text-primary);
font-size: 12px;
font-size: 11px;
outline: none;
transition: border-color 0.2s;
font-family: var(--font-body);
}
.filterInput:focus {
@@ -87,64 +69,37 @@
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 7px 10px;
padding: 5px 8px;
color: var(--text-primary);
font-size: 12px;
font-size: 11px;
outline: none;
cursor: pointer;
font-family: var(--font-body);
}
/* ─── Table ─── */
.tableWrapper {
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 {
/* ─── Event Row Styles ─── */
.eventTimestamp {
font-family: var(--font-mono);
font-size: 11px;
font-size: 10px;
color: var(--text-muted);
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 {
display: inline-block;
padding: 2px 8px;
@@ -177,12 +132,7 @@
color: #ef4444;
}
/* ─── Detail Row ─── */
.detailRow td {
padding: 0 12px 12px;
background: var(--bg-hover);
}
/* ─── Detail JSON ─── */
.detailJson {
margin: 0;
padding: 12px;
@@ -197,64 +147,9 @@
word-break: break-word;
}
/* ─── Pagination ─── */
.pagination {
display: flex;
align-items: center;
justify-content: center;
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;
}
/* ─── Mono ─── */
.mono {
font-family: var(--font-mono);
font-size: 11px;
white-space: nowrap;
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useAuthStore } from '../../auth/auth-store';
import { useAuditLog, type AuditLogParams } from '../../api/queries/admin/audit';
import layout from '../../styles/AdminLayout.module.css';
import styles from './AuditLogPage.module.css';
function defaultFrom(): string {
@@ -18,9 +19,9 @@ export function AuditLogPage() {
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>
);
@@ -36,7 +37,8 @@ function AuditLogContent() {
const [category, setCategory] = useState('');
const [search, setSearch] = useState('');
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 params: AuditLogParams = {
@@ -55,155 +57,229 @@ function AuditLogContent() {
const showingFrom = data && data.totalCount > 0 ? page * pageSize + 1 : 0;
const showingTo = data ? Math.min((page + 1) * pageSize, data.totalCount) : 0;
const selectedEvent = data?.items.find((e) => e.id === selectedEventId) ?? null;
return (
<div className={styles.page}>
<div className={styles.header}>
<h1 className={styles.pageTitle}>Audit Log</h1>
{data && (
<span className={styles.totalCount}>{data.totalCount.toLocaleString()} events</span>
)}
</div>
<div className={styles.filters}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>From</label>
<input
type="date"
className={styles.filterInput}
value={from}
onChange={(e) => { setFrom(e.target.value); setPage(0); }}
/>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>To</label>
<input
type="date"
className={styles.filterInput}
value={to}
onChange={(e) => { setTo(e.target.value); setPage(0); }}
/>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>User</label>
<input
type="text"
className={styles.filterInput}
placeholder="Username..."
value={username}
onChange={(e) => { setUsername(e.target.value); setPage(0); }}
/>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Category</label>
<select
className={styles.filterSelect}
value={category}
onChange={(e) => { setCategory(e.target.value); setPage(0); }}
>
<option value="">All</option>
<option value="INFRA">INFRA</option>
<option value="AUTH">AUTH</option>
<option value="USER_MGMT">USER_MGMT</option>
<option value="CONFIG">CONFIG</option>
</select>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Search</label>
<input
type="text"
className={styles.filterInput}
placeholder="Search..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
/>
<div className={layout.page}>
<div className={layout.panelHeader}>
<div>
<div className={layout.panelTitle}>Audit Log</div>
{data && (
<div className={layout.panelSubtitle}>
{data.totalCount.toLocaleString()} events
</div>
)}
</div>
</div>
{audit.isLoading ? (
<div className={styles.loading}>Loading...</div>
) : !data || data.items.length === 0 ? (
<div className={styles.emptyState}>No audit events found for the selected filters.</div>
) : (
<>
<div className={styles.tableWrapper}>
<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}
className={`${styles.eventRow} ${expandedRow === event.id ? styles.eventRowExpanded : ''}`}
onClick={() =>
setExpandedRow((prev) => (prev === event.id ? null : event.id))
}
<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}>
<label className={styles.filterLabel}>From</label>
<input
type="date"
className={styles.filterInput}
value={from}
onChange={(e) => { setFrom(e.target.value); setPage(0); }}
/>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>To</label>
<input
type="date"
className={styles.filterInput}
value={to}
onChange={(e) => { setTo(e.target.value); setPage(0); }}
/>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>User</label>
<input
type="text"
className={styles.filterInput}
placeholder="Username..."
value={username}
onChange={(e) => { setUsername(e.target.value); setPage(0); }}
/>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Category</label>
<select
className={styles.filterSelect}
value={category}
onChange={(e) => { setCategory(e.target.value); setPage(0); }}
>
<option value="">All</option>
<option value="INFRA">INFRA</option>
<option value="AUTH">AUTH</option>
<option value="USER_MGMT">USER_MGMT</option>
<option value="CONFIG">CONFIG</option>
</select>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Search</label>
<input
type="text"
className={styles.filterInput}
placeholder="Search..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
/>
</div>
</div>
</div>
{/* Event list */}
<div className={layout.entityList}>
{audit.isLoading ? (
<div className={layout.loading}>Loading...</div>
) : !data || data.items.length === 0 ? (
<div className={layout.loading}>No events found.</div>
) : (
data.items.map((event) => (
<div
key={event.id}
className={`${layout.entityItem} ${
selectedEventId === event.id ? layout.entityItemSelected : ''
}`}
onClick={() => setSelectedEventId(event.id)}
>
<div className={layout.entityInfo}>
<div className={styles.eventTimestamp}>
{formatTimestamp(event.timestamp)}
</div>
<div className={styles.eventCompact}>
<span>{event.username}</span>
<span className={styles.eventAction}>{event.action}</span>
</div>
<div className={styles.eventCompact}>
<span
className={`${styles.resultBadge} ${
event.result === 'SUCCESS'
? styles.resultSuccess
: styles.resultFailure
}`}
>
{event.result}
</span>
</div>
</div>
</div>
))
)}
</div>
{/* Pagination */}
{data && data.totalCount > 0 && (
<div className={layout.pagination}>
<button
type="button"
className={layout.pageBtn}
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
Previous
</button>
<span className={layout.pageInfo}>
{showingFrom}-{showingTo} of {data.totalCount.toLocaleString()}
</span>
<button
type="button"
className={layout.pageBtn}
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</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
}`}
>
<td className={styles.mono}>
{formatTimestamp(event.timestamp)}
</td>
<td>{event.username}</td>
<td>
<span className={styles.categoryBadge}>{event.category}</span>
</td>
<td>{event.action}</td>
<td className={styles.mono}>{event.target}</td>
<td>
<span
className={`${styles.resultBadge} ${
event.result === 'SUCCESS' ? styles.resultSuccess : styles.resultFailure
}`}
>
{event.result}
</span>
</td>
</tr>
{expandedRow === event.id && (
<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>
{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={styles.pagination}>
<button
type="button"
className={styles.pageBtn}
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
Previous
</button>
<span className={styles.pageInfo}>
Showing {showingFrom}-{showingTo} of {data.totalCount.toLocaleString()}
</span>
<button
type="button"
className={styles.pageBtn}
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</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>
);
}

View File

@@ -1,73 +1,10 @@
.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;
}
/* ─── Meta ─── */
.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;
@@ -309,9 +246,4 @@
.thresholdGrid {
grid-template-columns: 1fr;
}
.header {
flex-direction: column;
gap: 12px;
}
}

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>
</>
);
}

View File

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

View File

@@ -7,6 +7,7 @@ import {
useDeleteOidcConfig,
} from '../../api/queries/oidc-admin';
import type { OidcAdminConfigRequest } from '../../api/types';
import layout from '../../styles/AdminLayout.module.css';
import styles from './OidcAdminPage.module.css';
interface FormData {
@@ -36,9 +37,9 @@ export function OidcAdminPage() {
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>
);
@@ -137,10 +138,14 @@ function OidcAdminForm() {
if (isLoading) {
return (
<div className={styles.page}>
<h1 className={styles.pageTitle}>OIDC Configuration</h1>
<p className={styles.subtitle}>Configure external identity provider</p>
<div className={styles.card}>
<div className={layout.page}>
<div className={layout.panelHeader}>
<div>
<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.skeletonMedium} />
<div className={styles.skeletonWide} />
@@ -154,108 +159,151 @@ function OidcAdminForm() {
const isConfigured = data?.configured ?? false;
return (
<div className={styles.page}>
<h1 className={styles.pageTitle}>OIDC Configuration</h1>
<p className={styles.subtitle}>Configure external identity provider</p>
<div className={styles.card}>
<div className={styles.toggleRow}>
<div className={styles.toggleInfo}>
<div className={styles.toggleLabel}>Enabled</div>
<div className={styles.toggleDesc}>
Allow users to sign in with the configured OIDC identity provider
</div>
</div>
<div className={layout.page}>
<div className={layout.panelHeader}>
<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={`${styles.toggle} ${form.enabled ? styles.toggleOn : ''}`}
onClick={() => updateField('enabled', !form.enabled)}
aria-label="Toggle OIDC enabled"
/>
</div>
<div className={styles.toggleRow}>
<div className={styles.toggleInfo}>
<div className={styles.toggleLabel}>Auto Sign-Up</div>
<div className={styles.toggleDesc}>
Automatically create accounts for new OIDC users. When disabled, an admin must
pre-create the user before they can sign in.
</div>
</div>
className={`${layout.btnAction} ${styles.btnPrimary}`}
onClick={handleSave}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? 'Saving...' : 'Save'}
</button>
<button
type="button"
className={`${styles.toggle} ${form.autoSignup ? styles.toggleOn : ''}`}
onClick={() => updateField('autoSignup', !form.autoSignup)}
aria-label="Toggle auto sign-up"
/>
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={styles.field}>
<label className={styles.label}>Issuer URI</label>
<input
className={styles.input}
type="url"
value={form.issuerUri}
onChange={(e) => updateField('issuerUri', e.target.value)}
placeholder="https://auth.example.com/realms/main/.well-known/openid-configuration"
/>
</div>
<div className={layout.detailOnly}>
<div className={layout.detailSection}>
<div className={layout.detailSectionTitle}>Behavior</div>
<div className={styles.field}>
<label className={styles.label}>Client ID</label>
<input
className={styles.input}
type="text"
value={form.clientId}
onChange={(e) => updateField('clientId', e.target.value)}
placeholder="cameleer3"
/>
</div>
<div className={styles.toggleRow}>
<div className={styles.toggleInfo}>
<div className={styles.toggleLabel}>Enabled</div>
<div className={styles.toggleDesc}>
Allow users to sign in with the configured OIDC identity provider
</div>
</div>
<button
type="button"
className={`${styles.toggle} ${form.enabled ? styles.toggleOn : ''}`}
onClick={() => updateField('enabled', !form.enabled)}
aria-label="Toggle OIDC enabled"
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Client Secret</label>
<input
className={styles.input}
type="password"
value={form.clientSecret}
onChange={(e) => {
updateField('clientSecret', e.target.value);
setSecretTouched(true);
}}
placeholder={data?.clientSecretSet ? 'Secret is configured' : 'Enter client secret'}
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Roles Claim</label>
<input
className={styles.input}
type="text"
value={form.rolesClaim}
onChange={(e) => updateField('rolesClaim', e.target.value)}
placeholder="realm_access.roles"
/>
<div className={styles.hint}>
Dot-separated path to roles array in the ID token
<div className={styles.toggleRow}>
<div className={styles.toggleInfo}>
<div className={styles.toggleLabel}>Auto Sign-Up</div>
<div className={styles.toggleDesc}>
Automatically create accounts for new OIDC users. When disabled, an admin must
pre-create the user before they can sign in.
</div>
</div>
<button
type="button"
className={`${styles.toggle} ${form.autoSignup ? styles.toggleOn : ''}`}
onClick={() => updateField('autoSignup', !form.autoSignup)}
aria-label="Toggle auto sign-up"
/>
</div>
</div>
<div className={styles.field}>
<label className={styles.label}>Display Name Claim</label>
<input
className={styles.input}
type="text"
value={form.displayNameClaim}
onChange={(e) => updateField('displayNameClaim', e.target.value)}
placeholder="name"
/>
<div className={styles.hint}>
Dot-separated path to the user's display name in the ID token (e.g. name, preferred_username, profile.display_name)
<div className={layout.detailSection}>
<div className={layout.detailSectionTitle}>Provider Settings</div>
<div className={styles.field}>
<label className={styles.label}>Issuer URI</label>
<input
className={styles.input}
type="url"
value={form.issuerUri}
onChange={(e) => updateField('issuerUri', e.target.value)}
placeholder="https://auth.example.com/realms/main/.well-known/openid-configuration"
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Client ID</label>
<input
className={styles.input}
type="text"
value={form.clientId}
onChange={(e) => updateField('clientId', e.target.value)}
placeholder="cameleer3"
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Client Secret</label>
<input
className={styles.input}
type="password"
value={form.clientSecret}
onChange={(e) => {
updateField('clientSecret', e.target.value);
setSecretTouched(true);
}}
placeholder={data?.clientSecretSet ? 'Secret is configured' : 'Enter client secret'}
/>
</div>
</div>
<div className={styles.field}>
<label className={styles.label}>Default Roles</label>
<div className={layout.detailSection}>
<div className={layout.detailSectionTitle}>Claim Mapping</div>
<div className={styles.field}>
<label className={styles.label}>Roles Claim</label>
<input
className={styles.input}
type="text"
value={form.rolesClaim}
onChange={(e) => updateField('rolesClaim', e.target.value)}
placeholder="realm_access.roles"
/>
<div className={styles.hint}>
Dot-separated path to roles array in the ID token
</div>
</div>
<div className={styles.field}>
<label className={styles.label}>Display Name Claim</label>
<input
className={styles.input}
type="text"
value={form.displayNameClaim}
onChange={(e) => updateField('displayNameClaim', e.target.value)}
placeholder="name"
/>
<div className={styles.hint}>
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 className={layout.detailSection}>
<div className={layout.detailSectionTitle}>Default Roles</div>
<div className={styles.tags}>
{form.defaultRoles.map((role) => (
<span key={role} className={styles.tag}>
@@ -291,33 +339,6 @@ function OidcAdminForm() {
</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 && (
<div className={styles.confirmBar}>
<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 ─── */
.progressContainer {
margin-bottom: 16px;
@@ -405,6 +335,12 @@
color: var(--rose);
}
.metaItem {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}
@media (max-width: 640px) {
.metricsGrid {
grid-template-columns: repeat(2, 1fr);
@@ -414,11 +350,6 @@
grid-template-columns: 1fr;
}
.header {
flex-direction: column;
gap: 12px;
}
.filterRow {
flex-direction: column;
}

View File

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