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:
425
ui/src/pages/admin/OpenSearchAdminPage.module.css
Normal file
425
ui/src/pages/admin/OpenSearchAdminPage.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
490
ui/src/pages/admin/OpenSearchAdminPage.tsx
Normal file
490
ui/src/pages/admin/OpenSearchAdminPage.tsx
Normal 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]}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user