diff --git a/ui/src/pages/admin/OpenSearchAdminPage.module.css b/ui/src/pages/admin/OpenSearchAdminPage.module.css new file mode 100644 index 00000000..cca61734 --- /dev/null +++ b/ui/src/pages/admin/OpenSearchAdminPage.module.css @@ -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; + } +} diff --git a/ui/src/pages/admin/OpenSearchAdminPage.tsx b/ui/src/pages/admin/OpenSearchAdminPage.tsx new file mode 100644 index 00000000..45653e05 --- /dev/null +++ b/ui/src/pages/admin/OpenSearchAdminPage.tsx @@ -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 ( +
+
+ Access Denied — this page requires the ADMIN role. +
+
+ ); + } + + return ; +} + +function OpenSearchAdminContent() { + const status = useOpenSearchStatus(); + const pipeline = usePipelineStats(); + const performance = usePerformanceStats(); + const thresholds = useThresholds(); + + if (status.isLoading) { + return ( +
+

OpenSearch Administration

+
Loading...
+
+ ); + } + + const os = status.data; + + return ( +
+
+
+

OpenSearch Administration

+
+ + {os?.version && v{os.version}} + {os?.numberOfNodes !== undefined && ( + {os.numberOfNodes} node(s) + )} + {os?.host && {os.host}} +
+
+ +
+ + + + + + +
+ ); +} + +function PipelineSection({ + pipeline, + thresholds, +}: { + pipeline: ReturnType; + 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 ( + pipeline.refetch()} + isRefreshing={pipeline.isFetching} + autoRefresh + > +
+
+ Queue: {data.queueDepth} / {data.maxQueueSize} + {queuePct}% +
+
+
+
+
+
+
+ {data.totalIndexed.toLocaleString()} + Total Indexed +
+
+ {data.totalFailed.toLocaleString()} + Total Failed +
+
+ {data.avgLatencyMs}ms + Avg Latency +
+
+ + ); +} + +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(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 ( + indices.refetch()} + isRefreshing={indices.isFetching} + > +
+ { setSearch(e.target.value); setPage(0); }} + /> + +
+ + {!data ? ( +
Loading...
+ ) : ( + <> +
+ + + + + + + + + + + + + {data.indices.map((idx) => ( + + + + + + + + + ))} + {data.indices.length === 0 && ( + + + + )} + +
Shards
{idx.name} + + {idx.health} + + {idx.docsCount.toLocaleString()}{idx.storeSize}{idx.primaryShards}p / {idx.replicas}r + +
No indices found
+
+ + {totalPages > 1 && ( +
+ + + Page {page + 1} of {totalPages} + + +
+ )} + + )} + + setDeleteTarget(null)} + onConfirm={() => { + if (deleteTarget) { + deleteMutation.mutate(deleteTarget); + setDeleteTarget(null); + } + }} + resourceName={deleteTarget ?? ''} + resourceType="index" + /> +
+ ); +} + +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 ( + onSort(col)} + > + {label} + {isActive && {dir === 'asc' ? ' \u25B2' : ' \u25BC'}} + + ); +} + +function PerformanceSection({ + performance, + thresholds, +}: { + performance: ReturnType; + 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 ( + performance.refetch()} + isRefreshing={performance.isFetching} + autoRefresh + > +
+
+ {(data.queryCacheHitRate * 100).toFixed(1)}% + Query Cache Hit +
+
+ {(data.requestCacheHitRate * 100).toFixed(1)}% + Request Cache Hit +
+
+ {data.avgQueryLatencyMs}ms + Query Latency +
+
+ {data.avgIndexLatencyMs}ms + Index Latency +
+
+
+
+ JVM Heap: {formatBytes(data.jvmHeapUsedBytes)} / {formatBytes(data.jvmHeapMaxBytes)} + {heapPct}% +
+
+
+
+
+ + ); +} + +function OperationsSection() { + return ( + +
+ + + +
+
+ ); +} + +function OsThresholdsSection({ thresholds }: { thresholds?: Thresholds }) { + const [form, setForm] = useState(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 ( + +
+
+ + update('osQueueWarningPercent', Number(e.target.value))} + /> +
+
+ + update('osQueueCriticalPercent', Number(e.target.value))} + /> +
+
+ + update('osHeapWarningPercent', Number(e.target.value))} + /> +
+
+ + update('osHeapCriticalPercent', Number(e.target.value))} + /> +
+
+
+ + {status && ( + + {status.msg} + + )} +
+
+ ); +} + +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]}`; +}