feat: harmonize admin pages to split-pane layout with shared CSS
Extract shared admin layout styles into AdminLayout.module.css and convert all admin pages to consistent patterns: Database/OpenSearch/ Audit Log use split-pane master/detail, OIDC uses full-width detail-only with unified panelHeader treatment across all pages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
299
ui/src/styles/AdminLayout.module.css
Normal file
299
ui/src/styles/AdminLayout.module.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user