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