feat: add OpenSearch admin page with pipeline, indices, performance, and thresholds UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-17 16:11:01 +01:00
parent 0edbdea2eb
commit 6b9988f43a
2 changed files with 915 additions and 0 deletions

View File

@@ -0,0 +1,425 @@
.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;
}
.progressLabel {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.progressPct {
font-weight: 600;
font-family: var(--font-mono);
}
.progressBar {
height: 8px;
background: var(--bg-raised);
border-radius: 4px;
overflow: hidden;
}
.progressFill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
/* ─── Metrics Grid ─── */
.metricsGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.metric {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px;
background: var(--bg-raised);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
}
.metricValue {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
font-family: var(--font-mono);
}
.metricLabel {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 4px;
}
/* ─── Filter Row ─── */
.filterRow {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.filterInput {
flex: 1;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
color: var(--text-primary);
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.filterInput:focus {
border-color: var(--amber-dim);
}
.filterInput::placeholder {
color: var(--text-muted);
}
.filterSelect {
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
color: var(--text-primary);
font-size: 13px;
outline: none;
cursor: pointer;
}
/* ─── Tables ─── */
.tableWrapper {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.table th {
text-align: left;
padding: 8px 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;
}
.sortableHeader {
cursor: pointer;
user-select: none;
}
.sortableHeader:hover {
color: var(--text-primary);
}
.sortArrow {
font-size: 9px;
}
.table td {
padding: 8px 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-subtle);
}
.table tbody tr:hover {
background: var(--bg-hover);
}
.mono {
font-family: var(--font-mono);
font-size: 12px;
}
.healthBadge {
display: inline-block;
padding: 2px 8px;
border-radius: 99px;
font-size: 11px;
font-weight: 500;
text-transform: capitalize;
}
.healthGreen {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.healthYellow {
background: rgba(234, 179, 8, 0.1);
color: #eab308;
}
.healthRed {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.deleteBtn {
padding: 4px 10px;
border-radius: var(--radius-sm);
background: transparent;
border: 1px solid var(--rose-dim);
color: var(--rose);
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
}
.deleteBtn:hover {
background: var(--rose-glow);
}
.emptyState {
text-align: center;
padding: 24px;
color: var(--text-muted);
font-size: 13px;
}
/* ─── Pagination ─── */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border-subtle);
}
.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);
}
/* ─── Heap Section ─── */
.heapSection {
margin-top: 16px;
}
/* ─── Operations ─── */
.operationsGrid {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.operationBtn {
padding: 8px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text-muted);
font-size: 13px;
cursor: not-allowed;
opacity: 0.5;
}
/* ─── Thresholds ─── */
.thresholdGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.thresholdField {
display: flex;
flex-direction: column;
gap: 4px;
}
.thresholdLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.thresholdInput {
width: 100%;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.thresholdInput:focus {
border-color: var(--amber-dim);
box-shadow: 0 0 0 3px var(--amber-glow);
}
.thresholdActions {
display: flex;
align-items: center;
gap: 12px;
}
.btnPrimary {
padding: 8px 20px;
border-radius: var(--radius-sm);
border: 1px solid var(--amber);
background: var(--amber);
color: #0a0e17;
font-family: var(--font-body);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.btnPrimary:hover {
background: var(--amber-hover);
border-color: var(--amber-hover);
}
.btnPrimary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.successMsg {
font-size: 12px;
color: var(--green);
}
.errorMsg {
font-size: 12px;
color: var(--rose);
}
@media (max-width: 640px) {
.metricsGrid {
grid-template-columns: repeat(2, 1fr);
}
.thresholdGrid {
grid-template-columns: 1fr;
}
.header {
flex-direction: column;
gap: 12px;
}
.filterRow {
flex-direction: column;
}
}

View File

@@ -0,0 +1,490 @@
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,
usePipelineStats,
useIndices,
usePerformanceStats,
useDeleteIndex,
type IndicesParams,
} from '../../api/queries/admin/opensearch';
import { useThresholds, useSaveThresholds, type Thresholds } from '../../api/queries/admin/thresholds';
import styles from './OpenSearchAdminPage.module.css';
function clusterHealthToStatus(health: string | undefined): Status {
switch (health?.toLowerCase()) {
case 'green': return 'healthy';
case 'yellow': return 'warning';
case 'red': return 'critical';
default: return 'unknown';
}
}
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>
</div>
);
}
return <OpenSearchAdminContent />;
}
function OpenSearchAdminContent() {
const status = useOpenSearchStatus();
const pipeline = usePipelineStats();
const performance = usePerformanceStats();
const thresholds = useThresholds();
if (status.isLoading) {
return (
<div className={styles.page}>
<h1 className={styles.pageTitle}>OpenSearch Administration</h1>
<div className={styles.loading}>Loading...</div>
</div>
);
}
const os = status.data;
return (
<div className={styles.page}>
<div className={styles.header}>
<div className={styles.headerInfo}>
<h1 className={styles.pageTitle}>OpenSearch Administration</h1>
<div className={styles.headerMeta}>
<StatusBadge
status={clusterHealthToStatus(os?.clusterHealth)}
label={os?.clusterHealth ?? 'Unknown'}
/>
{os?.version && <span className={styles.metaItem}>v{os.version}</span>}
{os?.numberOfNodes !== undefined && (
<span className={styles.metaItem}>{os.numberOfNodes} node(s)</span>
)}
{os?.host && <span className={styles.metaItem}>{os.host}</span>}
</div>
</div>
<button
type="button"
className={styles.globalRefresh}
onClick={() => {
status.refetch();
pipeline.refetch();
performance.refetch();
}}
>
Refresh All
</button>
</div>
<PipelineSection pipeline={pipeline} thresholds={thresholds.data} />
<IndicesSection />
<PerformanceSection performance={performance} thresholds={thresholds.data} />
<OperationsSection />
<OsThresholdsSection thresholds={thresholds.data} />
</div>
);
}
function PipelineSection({
pipeline,
thresholds,
}: {
pipeline: ReturnType<typeof usePipelineStats>;
thresholds?: Thresholds;
}) {
const data = pipeline.data;
if (!data) return null;
const queuePct = data.maxQueueSize > 0
? Math.round((data.queueDepth / data.maxQueueSize) * 100)
: 0;
const barColor =
thresholds?.osQueueCriticalPercent && queuePct >= thresholds.osQueueCriticalPercent ? '#ef4444'
: thresholds?.osQueueWarningPercent && queuePct >= thresholds.osQueueWarningPercent ? '#eab308'
: '#22c55e';
return (
<RefreshableCard
title="Indexing Pipeline"
onRefresh={() => pipeline.refetch()}
isRefreshing={pipeline.isFetching}
autoRefresh
>
<div className={styles.progressContainer}>
<div className={styles.progressLabel}>
Queue: {data.queueDepth} / {data.maxQueueSize}
<span className={styles.progressPct}>{queuePct}%</span>
</div>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${queuePct}%`, background: barColor }}
/>
</div>
</div>
<div className={styles.metricsGrid}>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.totalIndexed.toLocaleString()}</span>
<span className={styles.metricLabel}>Total Indexed</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.totalFailed.toLocaleString()}</span>
<span className={styles.metricLabel}>Total Failed</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.avgLatencyMs}ms</span>
<span className={styles.metricLabel}>Avg Latency</span>
</div>
</div>
</RefreshableCard>
);
}
function IndicesSection() {
const [search, setSearch] = useState('');
const [healthFilter, setHealthFilter] = useState('');
const [sortBy, setSortBy] = useState('name');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [page, setPage] = useState(0);
const pageSize = 10;
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const params: IndicesParams = {
search: search || undefined,
health: healthFilter || undefined,
sortBy,
sortDir,
page,
size: pageSize,
};
const indices = useIndices(params);
const deleteMutation = useDeleteIndex();
function toggleSort(col: string) {
if (sortBy === col) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortBy(col);
setSortDir('asc');
}
setPage(0);
}
const data = indices.data;
const totalPages = data ? Math.ceil(data.total / pageSize) : 0;
return (
<RefreshableCard
title="Indices"
onRefresh={() => indices.refetch()}
isRefreshing={indices.isFetching}
>
<div className={styles.filterRow}>
<input
className={styles.filterInput}
type="text"
placeholder="Search indices..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
/>
<select
className={styles.filterSelect}
value={healthFilter}
onChange={(e) => { setHealthFilter(e.target.value); setPage(0); }}
>
<option value="">All Health</option>
<option value="green">Green</option>
<option value="yellow">Yellow</option>
<option value="red">Red</option>
</select>
</div>
{!data ? (
<div className={styles.loading}>Loading...</div>
) : (
<>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<SortHeader label="Name" col="name" current={sortBy} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Health" col="health" current={sortBy} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Docs" col="docsCount" current={sortBy} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Size" col="storeSize" current={sortBy} dir={sortDir} onSort={toggleSort} />
<th>Shards</th>
<th></th>
</tr>
</thead>
<tbody>
{data.indices.map((idx) => (
<tr key={idx.name}>
<td className={styles.mono}>{idx.name}</td>
<td>
<span className={`${styles.healthBadge} ${styles[`health${idx.health.charAt(0).toUpperCase()}${idx.health.slice(1)}`]}`}>
{idx.health}
</span>
</td>
<td>{idx.docsCount.toLocaleString()}</td>
<td>{idx.storeSize}</td>
<td>{idx.primaryShards}p / {idx.replicas}r</td>
<td>
<button
type="button"
className={styles.deleteBtn}
onClick={() => setDeleteTarget(idx.name)}
>
Delete
</button>
</td>
</tr>
))}
{data.indices.length === 0 && (
<tr>
<td colSpan={6} className={styles.emptyState}>No indices found</td>
</tr>
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className={styles.pagination}>
<button
type="button"
className={styles.pageBtn}
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
Previous
</button>
<span className={styles.pageInfo}>
Page {page + 1} of {totalPages}
</span>
<button
type="button"
className={styles.pageBtn}
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
)}
</>
)}
<ConfirmDeleteDialog
isOpen={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={() => {
if (deleteTarget) {
deleteMutation.mutate(deleteTarget);
setDeleteTarget(null);
}
}}
resourceName={deleteTarget ?? ''}
resourceType="index"
/>
</RefreshableCard>
);
}
function SortHeader({
label,
col,
current,
dir,
onSort,
}: {
label: string;
col: string;
current: string;
dir: 'asc' | 'desc';
onSort: (col: string) => void;
}) {
const isActive = current === col;
return (
<th
className={styles.sortableHeader}
onClick={() => onSort(col)}
>
{label}
{isActive && <span className={styles.sortArrow}>{dir === 'asc' ? ' \u25B2' : ' \u25BC'}</span>}
</th>
);
}
function PerformanceSection({
performance,
thresholds,
}: {
performance: ReturnType<typeof usePerformanceStats>;
thresholds?: Thresholds;
}) {
const data = performance.data;
if (!data) return null;
const heapPct = data.jvmHeapUsedPercent;
const heapColor =
thresholds?.osHeapCriticalPercent && heapPct >= thresholds.osHeapCriticalPercent ? '#ef4444'
: thresholds?.osHeapWarningPercent && heapPct >= thresholds.osHeapWarningPercent ? '#eab308'
: '#22c55e';
return (
<RefreshableCard
title="Performance"
onRefresh={() => performance.refetch()}
isRefreshing={performance.isFetching}
autoRefresh
>
<div className={styles.metricsGrid}>
<div className={styles.metric}>
<span className={styles.metricValue}>{(data.queryCacheHitRate * 100).toFixed(1)}%</span>
<span className={styles.metricLabel}>Query Cache Hit</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{(data.requestCacheHitRate * 100).toFixed(1)}%</span>
<span className={styles.metricLabel}>Request Cache Hit</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.avgQueryLatencyMs}ms</span>
<span className={styles.metricLabel}>Query Latency</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.avgIndexLatencyMs}ms</span>
<span className={styles.metricLabel}>Index Latency</span>
</div>
</div>
<div className={styles.heapSection}>
<div className={styles.progressLabel}>
JVM Heap: {formatBytes(data.jvmHeapUsedBytes)} / {formatBytes(data.jvmHeapMaxBytes)}
<span className={styles.progressPct}>{heapPct}%</span>
</div>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${heapPct}%`, background: heapColor }}
/>
</div>
</div>
</RefreshableCard>
);
}
function OperationsSection() {
return (
<RefreshableCard title="Operations">
<div className={styles.operationsGrid}>
<button type="button" className={styles.operationBtn} disabled title="Coming soon">
Force Merge
</button>
<button type="button" className={styles.operationBtn} disabled title="Coming soon">
Flush
</button>
<button type="button" className={styles.operationBtn} disabled title="Coming soon">
Clear Cache
</button>
</div>
</RefreshableCard>
);
}
function OsThresholdsSection({ thresholds }: { thresholds?: Thresholds }) {
const [form, setForm] = useState<Thresholds | null>(null);
const saveMutation = useSaveThresholds();
const [status, setStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null);
const current = form ?? thresholds;
if (!current) return null;
function update(key: keyof Thresholds, value: number) {
setForm((prev) => ({ ...(prev ?? thresholds!), [key]: value }));
}
async function handleSave() {
const data = form ?? thresholds!;
try {
await saveMutation.mutateAsync(data);
setStatus({ type: 'success', msg: 'Thresholds saved.' });
setTimeout(() => setStatus(null), 3000);
} catch {
setStatus({ type: 'error', msg: 'Failed to save thresholds.' });
}
}
return (
<RefreshableCard title="Thresholds" collapsible defaultCollapsed>
<div className={styles.thresholdGrid}>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Queue Warning %</label>
<input
type="number"
className={styles.thresholdInput}
value={current.osQueueWarningPercent}
onChange={(e) => update('osQueueWarningPercent', Number(e.target.value))}
/>
</div>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Queue Critical %</label>
<input
type="number"
className={styles.thresholdInput}
value={current.osQueueCriticalPercent}
onChange={(e) => update('osQueueCriticalPercent', Number(e.target.value))}
/>
</div>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Heap Warning %</label>
<input
type="number"
className={styles.thresholdInput}
value={current.osHeapWarningPercent}
onChange={(e) => update('osHeapWarningPercent', Number(e.target.value))}
/>
</div>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Heap Critical %</label>
<input
type="number"
className={styles.thresholdInput}
value={current.osHeapCriticalPercent}
onChange={(e) => update('osHeapCriticalPercent', Number(e.target.value))}
/>
</div>
</div>
<div className={styles.thresholdActions}>
<button
type="button"
className={styles.btnPrimary}
onClick={handleSave}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? 'Saving...' : 'Save Thresholds'}
</button>
{status && (
<span className={status.type === 'success' ? styles.successMsg : styles.errorMsg}>
{status.msg}
</span>
)}
</div>
</RefreshableCard>
);
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}