From 6d650cdf3481bbb3cc334ba34ba7ab368ae9344e Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:30:38 +0100 Subject: [PATCH] feat: harmonize admin pages to split-pane layout with shared CSS Extract shared admin layout styles into AdminLayout.module.css and convert all admin pages to consistent patterns: Database/OpenSearch/ Audit Log use split-pane master/detail, OIDC uses full-width detail-only with unified panelHeader treatment across all pages. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/pages/admin/AuditLogPage.module.css | 215 +++------- ui/src/pages/admin/AuditLogPage.tsx | 370 +++++++++++------- .../pages/admin/DatabaseAdminPage.module.css | 70 +--- ui/src/pages/admin/DatabaseAdminPage.tsx | 155 +++++--- ui/src/pages/admin/OidcAdminPage.module.css | 96 +---- ui/src/pages/admin/OidcAdminPage.tsx | 265 +++++++------ .../admin/OpenSearchAdminPage.module.css | 81 +--- ui/src/pages/admin/OpenSearchAdminPage.tsx | 140 ++++--- ui/src/styles/AdminLayout.module.css | 299 ++++++++++++++ 9 files changed, 944 insertions(+), 747 deletions(-) create mode 100644 ui/src/styles/AdminLayout.module.css diff --git a/ui/src/pages/admin/AuditLogPage.module.css b/ui/src/pages/admin/AuditLogPage.module.css index 2d24e5c8..cc2a5efd 100644 --- a/ui/src/pages/admin/AuditLogPage.module.css +++ b/ui/src/pages/admin/AuditLogPage.module.css @@ -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; } diff --git a/ui/src/pages/admin/AuditLogPage.tsx b/ui/src/pages/admin/AuditLogPage.tsx index 1aa1f3ad..d704f73a 100644 --- a/ui/src/pages/admin/AuditLogPage.tsx +++ b/ui/src/pages/admin/AuditLogPage.tsx @@ -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 ( -
-
- Access Denied — this page requires the ADMIN role. +
+
+ Access Denied -- this page requires the ADMIN role.
); @@ -36,7 +37,8 @@ function AuditLogContent() { const [category, setCategory] = useState(''); const [search, setSearch] = useState(''); const [page, setPage] = useState(0); - const [expandedRow, setExpandedRow] = useState(null); + const [selectedEventId, setSelectedEventId] = useState(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 ( -
-
-

Audit Log

- {data && ( - {data.totalCount.toLocaleString()} events - )} -
- -
-
- - { setFrom(e.target.value); setPage(0); }} - /> -
-
- - { setTo(e.target.value); setPage(0); }} - /> -
-
- - { setUsername(e.target.value); setPage(0); }} - /> -
-
- - -
-
- - { setSearch(e.target.value); setPage(0); }} - /> +
+
+
+
Audit Log
+ {data && ( +
+ {data.totalCount.toLocaleString()} events +
+ )}
- {audit.isLoading ? ( -
Loading...
- ) : !data || data.items.length === 0 ? ( -
No audit events found for the selected filters.
- ) : ( - <> -
- - - - - - - - - - - - - {data.items.map((event) => ( - <> - - setExpandedRow((prev) => (prev === event.id ? null : event.id)) - } +
+ {/* Left pane — event list */} +
+ {/* Collapsible filter bar */} +
+ +
+
+ + { setFrom(e.target.value); setPage(0); }} + /> +
+
+ + { setTo(e.target.value); setPage(0); }} + /> +
+
+ + { setUsername(e.target.value); setPage(0); }} + /> +
+
+ + +
+
+ + { setSearch(e.target.value); setPage(0); }} + /> +
+
+
+ + {/* Event list */} +
+ {audit.isLoading ? ( +
Loading...
+ ) : !data || data.items.length === 0 ? ( +
No events found.
+ ) : ( + data.items.map((event) => ( +
setSelectedEventId(event.id)} + > +
+
+ {formatTimestamp(event.timestamp)} +
+
+ {event.username} + {event.action} +
+
+ + {event.result} + +
+
+
+ )) + )} +
+ + {/* Pagination */} + {data && data.totalCount > 0 && ( +
+ + + {showingFrom}-{showingTo} of {data.totalCount.toLocaleString()} + + +
+ )} +
+ + {/* Right pane — detail view */} +
+ {!selectedEvent ? ( +
+ Select an event to view details +
+ ) : ( + <> +
+
Event Info
+
+ Timestamp + + {formatTimestamp(selectedEvent.timestamp)} + +
+
+ User + {selectedEvent.username} +
+
+ Category + + {selectedEvent.category} + +
+
+ Action + {selectedEvent.action} +
+
+ Target + + {selectedEvent.target} + +
+
+ Result + + -
- - - - - - - {expandedRow === event.id && ( - - - - )} - - ))} - -
TimestampUserCategoryActionTargetResult
- {formatTimestamp(event.timestamp)} - {event.username} - {event.category} - {event.action}{event.target} - - {event.result} - -
-
-                            {JSON.stringify(event.detail, null, 2)}
-                          
-
-
+ {selectedEvent.result} + + +
+
+ IP Address + + {selectedEvent.ipAddress} + +
+
+ User Agent + {selectedEvent.userAgent} +
+
-
- - - Showing {showingFrom}-{showingTo} of {data.totalCount.toLocaleString()} - - -
- - )} +
+
Detail Payload
+
+                  {JSON.stringify(selectedEvent.detail, null, 2)}
+                
+
+ + )} +
+
); } diff --git a/ui/src/pages/admin/DatabaseAdminPage.module.css b/ui/src/pages/admin/DatabaseAdminPage.module.css index 944e0246..91ec954a 100644 --- a/ui/src/pages/admin/DatabaseAdminPage.module.css +++ b/ui/src/pages/admin/DatabaseAdminPage.module.css @@ -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; - } } diff --git a/ui/src/pages/admin/DatabaseAdminPage.tsx b/ui/src/pages/admin/DatabaseAdminPage.tsx index a91d7540..805a0c73 100644 --- a/ui/src/pages/admin/DatabaseAdminPage.tsx +++ b/ui/src/pages/admin/DatabaseAdminPage.tsx @@ -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 ( -
-
- Access Denied — this page requires the ADMIN role. +
+
+ Access Denied -- this page requires the ADMIN role.
); @@ -30,6 +46,8 @@ export function DatabaseAdminPage() { } function DatabaseAdminContent() { + const [selectedSection, setSelectedSection] = useState
('pool'); + const status = useDatabaseStatus(); const pool = useDatabasePool(); const tables = useDatabaseTables(); @@ -38,21 +56,39 @@ function DatabaseAdminContent() { if (status.isLoading) { return ( -
-

Database Administration

-
Loading...
+
+
Loading...
); } 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 ( -
-
-
-

Database Administration

-
+
+
+
+
Database
+
- - - - - +
+
+
+ {SECTIONS.map((sec) => ( +
setSelectedSection(sec.id)} + > +
{sec.icon}
+
+
{sec.label}
+
+
{getMiniStatus(sec.id)}
+
+ ))} +
+
+ +
+ {selectedSection === 'pool' && ( + + )} + {selectedSection === 'tables' && } + {selectedSection === 'queries' && ( + + )} + {selectedSection === 'maintenance' && } + {selectedSection === 'thresholds' && ( + + )} +
+
); } @@ -113,12 +177,8 @@ function PoolSection({ : '#22c55e'; return ( - pool.refetch()} - isRefreshing={pool.isFetching} - autoRefresh - > + <> +
Connection Pool
{data.activeConnections} / {data.maxPoolSize} connections @@ -149,7 +209,7 @@ function PoolSection({ Max Wait
-
+ ); } @@ -157,13 +217,10 @@ function TablesSection({ tables }: { tables: ReturnType tables.refetch()} - isRefreshing={tables.isFetching} - > + <> +
Table Sizes
{!data ? ( -
Loading...
+
Loading...
) : (
@@ -188,7 +245,7 @@ function TablesSection({ tables }: { tables: ReturnType )} - + ); } @@ -206,12 +263,8 @@ function QueriesSection({ const warningSec = warningSeconds ?? 30; return ( - queries.refetch()} - isRefreshing={queries.isFetching} - autoRefresh - > + <> +
Active Queries
{!data || data.length === 0 ? (
No active queries
) : ( @@ -265,13 +318,14 @@ function QueriesSection({ resourceName={String(killTarget ?? '')} resourceType="query (PID)" /> -
+ ); } function MaintenanceSection() { return ( - + <> +
Maintenance
-
+ ); } @@ -315,7 +369,8 @@ function ThresholdsSection({ thresholds }: { thresholds?: ThresholdConfig }) { } return ( - + <> +
Thresholds
@@ -369,7 +424,7 @@ function ThresholdsSection({ thresholds }: { thresholds?: ThresholdConfig }) { )}
- + ); } diff --git a/ui/src/pages/admin/OidcAdminPage.module.css b/ui/src/pages/admin/OidcAdminPage.module.css index ce9f406a..2c6a8fcd 100644 --- a/ui/src/pages/admin/OidcAdminPage.module.css +++ b/ui/src/pages/admin/OidcAdminPage.module.css @@ -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; } diff --git a/ui/src/pages/admin/OidcAdminPage.tsx b/ui/src/pages/admin/OidcAdminPage.tsx index 675a3207..43ab523e 100644 --- a/ui/src/pages/admin/OidcAdminPage.tsx +++ b/ui/src/pages/admin/OidcAdminPage.tsx @@ -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 ( -
-
- Access Denied — this page requires the ADMIN role. +
+
+ Access Denied -- this page requires the ADMIN role.
); @@ -137,10 +138,14 @@ function OidcAdminForm() { if (isLoading) { return ( -
-

OIDC Configuration

-

Configure external identity provider

-
+
+
+
+
OIDC Configuration
+
Configure external identity provider
+
+
+
@@ -154,108 +159,151 @@ function OidcAdminForm() { const isConfigured = data?.configured ?? false; return ( -
-

OIDC Configuration

-

Configure external identity provider

- -
-
-
-
Enabled
-
- Allow users to sign in with the configured OIDC identity provider -
-
+
+
+
+
OIDC Configuration
+
Configure external identity provider
+
+
- -
-
-
Auto Sign-Up
-
- Automatically create accounts for new OIDC users. When disabled, an admin must - pre-create the user before they can sign in. -
-
+ className={`${layout.btnAction} ${styles.btnPrimary}`} + onClick={handleSave} + disabled={saveMutation.isPending} + > + {saveMutation.isPending ? 'Saving...' : 'Save'} + +
+
-
- - updateField('issuerUri', e.target.value)} - placeholder="https://auth.example.com/realms/main/.well-known/openid-configuration" - /> -
+
+
+
Behavior
-
- - updateField('clientId', e.target.value)} - placeholder="cameleer3" - /> -
+
+
+
Enabled
+
+ Allow users to sign in with the configured OIDC identity provider +
+
+
-
- - { - updateField('clientSecret', e.target.value); - setSecretTouched(true); - }} - placeholder={data?.clientSecretSet ? 'Secret is configured' : 'Enter client secret'} - /> -
- -
- - updateField('rolesClaim', e.target.value)} - placeholder="realm_access.roles" - /> -
- Dot-separated path to roles array in the ID token +
+
+
Auto Sign-Up
+
+ Automatically create accounts for new OIDC users. When disabled, an admin must + pre-create the user before they can sign in. +
+
+
-
- - updateField('displayNameClaim', e.target.value)} - placeholder="name" - /> -
- Dot-separated path to the user's display name in the ID token (e.g. name, preferred_username, profile.display_name) +
+
Provider Settings
+ +
+ + updateField('issuerUri', e.target.value)} + placeholder="https://auth.example.com/realms/main/.well-known/openid-configuration" + /> +
+ +
+ + updateField('clientId', e.target.value)} + placeholder="cameleer3" + /> +
+ +
+ + { + updateField('clientSecret', e.target.value); + setSecretTouched(true); + }} + placeholder={data?.clientSecretSet ? 'Secret is configured' : 'Enter client secret'} + />
-
- +
+
Claim Mapping
+ +
+ + updateField('rolesClaim', e.target.value)} + placeholder="realm_access.roles" + /> +
+ Dot-separated path to roles array in the ID token +
+
+ +
+ + updateField('displayNameClaim', e.target.value)} + placeholder="name" + /> +
+ Dot-separated path to the user's display name in the ID token (e.g. name, preferred_username, profile.display_name) +
+
+
+ +
+
Default Roles
+
{form.defaultRoles.map((role) => ( @@ -291,33 +339,6 @@ function OidcAdminForm() {
-
- - - -
- {showDeleteConfirm && (
Delete OIDC configuration? This cannot be undone. diff --git a/ui/src/pages/admin/OpenSearchAdminPage.module.css b/ui/src/pages/admin/OpenSearchAdminPage.module.css index cca61734..e897d748 100644 --- a/ui/src/pages/admin/OpenSearchAdminPage.module.css +++ b/ui/src/pages/admin/OpenSearchAdminPage.module.css @@ -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; } diff --git a/ui/src/pages/admin/OpenSearchAdminPage.tsx b/ui/src/pages/admin/OpenSearchAdminPage.tsx index f069acc8..3608ce7f 100644 --- a/ui/src/pages/admin/OpenSearchAdminPage.tsx +++ b/ui/src/pages/admin/OpenSearchAdminPage.tsx @@ -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 ( -
-
- Access Denied — this page requires the ADMIN role. +
+
+ Access Denied -- this page requires the ADMIN role.
); @@ -40,6 +50,8 @@ export function OpenSearchAdminPage() { } function OpenSearchAdminContent() { + const [selectedSection, setSelectedSection] = useState
('pipeline'); + const status = useOpenSearchStatus(); const pipeline = usePipelineStats(); const performance = usePerformanceStats(); @@ -47,35 +59,48 @@ function OpenSearchAdminContent() { if (status.isLoading) { return ( -
-

OpenSearch Administration

-
Loading...
+
+
Loading...
); } 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 ( -
-
-
-

OpenSearch Administration

-
+
+
+
+
OpenSearch
+
- {os?.version && v{os.version}} - {os?.nodeCount !== undefined && ( - {os.nodeCount} node(s) - )} - {os?.host && {os.host}} + {os?.version && v{os.version}} + {os?.nodeCount !== undefined && {os.nodeCount} node(s)}
- - - - - +
+
+
+ {SECTIONS.map((s) => ( +
setSelectedSection(s.key)} + > +
{s.icon}
+
+
{s.label}
+
+
{getMiniStatus(s.key)}
+
+ ))} +
+
+ +
+ {selectedSection === 'pipeline' && ( + + )} + {selectedSection === 'indices' && } + {selectedSection === 'performance' && ( + + )} + {selectedSection === 'operations' && } + {selectedSection === 'thresholds' && ( + + )} +
+
); } @@ -114,12 +167,8 @@ function PipelineSection({ : '#22c55e'; return ( - pipeline.refetch()} - isRefreshing={pipeline.isFetching} - autoRefresh - > + <> +
Indexing Pipeline
Queue: {data.queueDepth} / {data.maxQueueSize} @@ -146,7 +195,7 @@ function PipelineSection({ Indexing Rate
-
+ ); } @@ -169,11 +218,8 @@ function IndicesSection() { const totalPages = data?.totalPages ?? 0; return ( - indices.refetch()} - isRefreshing={indices.isFetching} - > + <> +
Indices
{!data ? ( -
Loading...
+
Loading...
) : ( <>
@@ -270,7 +316,7 @@ function IndicesSection() { resourceName={deleteTarget ?? ''} resourceType="index" /> - + ); } @@ -293,12 +339,8 @@ function PerformanceSection({ : '#22c55e'; return ( - performance.refetch()} - isRefreshing={performance.isFetching} - autoRefresh - > + <> +
Performance
{(data.queryCacheHitRate * 100).toFixed(1)}% @@ -329,13 +371,14 @@ function PerformanceSection({ />
-
+ ); } function OperationsSection() { return ( - + <> +
Operations
-
+ ); } @@ -378,7 +421,8 @@ function OsThresholdsSection({ thresholds }: { thresholds?: ThresholdConfig }) { } return ( - + <> +
Thresholds
@@ -432,7 +476,7 @@ function OsThresholdsSection({ thresholds }: { thresholds?: ThresholdConfig }) { )}
- + ); } diff --git a/ui/src/styles/AdminLayout.module.css b/ui/src/styles/AdminLayout.module.css new file mode 100644 index 00000000..940bae5b --- /dev/null +++ b/ui/src/styles/AdminLayout.module.css @@ -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; +}