fix: align frontend interfaces with backend DTO field names

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-17 16:36:11 +01:00
parent 329e4b0b16
commit 038b663b8c
7 changed files with 153 additions and 168 deletions

View File

@@ -2,14 +2,16 @@ import { useQuery } from '@tanstack/react-query';
import { adminFetch } from './admin-api'; import { adminFetch } from './admin-api';
export interface AuditEvent { export interface AuditEvent {
id: string; id: number;
timestamp: string; timestamp: string;
username: string; username: string;
category: string;
action: string; action: string;
category: string;
target: string; target: string;
result: string;
detail: Record<string, unknown>; detail: Record<string, unknown>;
result: string;
ipAddress: string;
userAgent: string;
} }
export interface AuditLogParams { export interface AuditLogParams {
@@ -18,13 +20,18 @@ export interface AuditLogParams {
username?: string; username?: string;
category?: string; category?: string;
search?: string; search?: string;
sort?: string;
order?: string;
page?: number; page?: number;
size?: number; size?: number;
} }
export interface AuditLogResponse { export interface AuditLogResponse {
events: AuditEvent[]; items: AuditEvent[];
total: number; totalCount: number;
page: number;
pageSize: number;
totalPages: number;
} }
export function useAuditLog(params: AuditLogParams) { export function useAuditLog(params: AuditLogParams) {
@@ -34,6 +41,8 @@ export function useAuditLog(params: AuditLogParams) {
if (params.username) query.set('username', params.username); if (params.username) query.set('username', params.username);
if (params.category) query.set('category', params.category); if (params.category) query.set('category', params.category);
if (params.search) query.set('search', params.search); if (params.search) query.set('search', params.search);
if (params.sort) query.set('sort', params.sort);
if (params.order) query.set('order', params.order);
if (params.page !== undefined) query.set('page', String(params.page)); if (params.page !== undefined) query.set('page', String(params.page));
if (params.size !== undefined) query.set('size', String(params.size)); if (params.size !== undefined) query.set('size', String(params.size));
const qs = query.toString(); const qs = query.toString();

View File

@@ -6,26 +6,29 @@ export interface DatabaseStatus {
version: string; version: string;
host: string; host: string;
schema: string; schema: string;
timescaleDb: boolean;
} }
export interface PoolStats { export interface PoolStats {
activeConnections: number; activeConnections: number;
idleConnections: number; idleConnections: number;
pendingConnections: number; pendingThreads: number;
maxConnections: number; maxPoolSize: number;
maxWaitMillis: number; maxWaitMs: number;
} }
export interface TableInfo { export interface TableInfo {
tableName: string; tableName: string;
rowEstimate: number; rowCount: number;
dataSize: string; dataSize: string;
indexSize: string; indexSize: string;
dataSizeBytes: number;
indexSizeBytes: number;
} }
export interface ActiveQuery { export interface ActiveQuery {
pid: number; pid: number;
durationMs: number; durationSeconds: number;
state: string; state: string;
query: string; query: string;
} }
@@ -64,7 +67,7 @@ export function useKillQuery() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (pid: number) => { mutationFn: async (pid: number) => {
await adminFetch<void>(`/database/queries/${pid}`, { method: 'DELETE' }); await adminFetch<void>(`/database/queries/${pid}/kill`, { method: 'POST' });
}, },
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'database', 'queries'] }), onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'database', 'queries'] }),
}); });

View File

@@ -2,47 +2,54 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { adminFetch } from './admin-api'; import { adminFetch } from './admin-api';
export interface OpenSearchStatus { export interface OpenSearchStatus {
connected: boolean; reachable: boolean;
clusterName: string;
clusterHealth: string; clusterHealth: string;
version: string; version: string;
numberOfNodes: number; nodeCount: number;
host: string; host: string;
} }
export interface PipelineStats { export interface PipelineStats {
queueDepth: number; queueDepth: number;
maxQueueSize: number; maxQueueSize: number;
totalIndexed: number; indexedCount: number;
totalFailed: number; failedCount: number;
avgLatencyMs: number; debounceMs: number;
indexingRate: number;
lastIndexedAt: string | null;
} }
export interface IndexInfo { export interface IndexInfo {
name: string; name: string;
health: string; health: string;
status: string; docCount: number;
docsCount: number; size: string;
storeSize: string; sizeBytes: number;
primaryShards: number; primaryShards: number;
replicas: number; replicaShards: number;
}
export interface IndicesPageResponse {
indices: IndexInfo[];
totalIndices: number;
totalDocs: number;
totalSize: string;
page: number;
pageSize: number;
totalPages: number;
} }
export interface PerformanceStats { export interface PerformanceStats {
queryCacheHitRate: number; queryCacheHitRate: number;
requestCacheHitRate: number; requestCacheHitRate: number;
avgQueryLatencyMs: number; searchLatencyMs: number;
avgIndexLatencyMs: number; indexingLatencyMs: number;
jvmHeapUsedPercent: number;
jvmHeapUsedBytes: number; jvmHeapUsedBytes: number;
jvmHeapMaxBytes: number; jvmHeapMaxBytes: number;
} }
export interface IndicesParams { export interface IndicesParams {
search?: string; search?: string;
health?: string;
sortBy?: string;
sortDir?: 'asc' | 'desc';
page?: number; page?: number;
size?: number; size?: number;
} }
@@ -65,9 +72,6 @@ export function usePipelineStats() {
export function useIndices(params: IndicesParams) { export function useIndices(params: IndicesParams) {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params.search) query.set('search', params.search); if (params.search) query.set('search', params.search);
if (params.health) query.set('health', params.health);
if (params.sortBy) query.set('sortBy', params.sortBy);
if (params.sortDir) query.set('sortDir', params.sortDir);
if (params.page !== undefined) query.set('page', String(params.page)); if (params.page !== undefined) query.set('page', String(params.page));
if (params.size !== undefined) query.set('size', String(params.size)); if (params.size !== undefined) query.set('size', String(params.size));
const qs = query.toString(); const qs = query.toString();
@@ -75,7 +79,7 @@ export function useIndices(params: IndicesParams) {
return useQuery({ return useQuery({
queryKey: ['admin', 'opensearch', 'indices', params], queryKey: ['admin', 'opensearch', 'indices', params],
queryFn: () => queryFn: () =>
adminFetch<{ indices: IndexInfo[]; total: number }>( adminFetch<IndicesPageResponse>(
`/opensearch/indices${qs ? `?${qs}` : ''}`, `/opensearch/indices${qs ? `?${qs}` : ''}`,
), ),
}); });

View File

@@ -1,29 +1,41 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { adminFetch } from './admin-api'; import { adminFetch } from './admin-api';
export interface Thresholds { export interface DatabaseThresholds {
poolWarningPercent: number; connectionPoolWarning: number;
poolCriticalPercent: number; connectionPoolCritical: number;
queryDurationWarningSeconds: number; queryDurationWarning: number;
queryDurationCriticalSeconds: number; queryDurationCritical: number;
osQueueWarningPercent: number; }
osQueueCriticalPercent: number;
osHeapWarningPercent: number; export interface OpenSearchThresholds {
osHeapCriticalPercent: number; clusterHealthWarning: string;
clusterHealthCritical: string;
queueDepthWarning: number;
queueDepthCritical: number;
jvmHeapWarning: number;
jvmHeapCritical: number;
failedDocsWarning: number;
failedDocsCritical: number;
}
export interface ThresholdConfig {
database: DatabaseThresholds;
opensearch: OpenSearchThresholds;
} }
export function useThresholds() { export function useThresholds() {
return useQuery({ return useQuery({
queryKey: ['admin', 'thresholds'], queryKey: ['admin', 'thresholds'],
queryFn: () => adminFetch<Thresholds>('/thresholds'), queryFn: () => adminFetch<ThresholdConfig>('/thresholds'),
}); });
} }
export function useSaveThresholds() { export function useSaveThresholds() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (body: Thresholds) => { mutationFn: async (body: ThresholdConfig) => {
await adminFetch<Thresholds>('/thresholds', { await adminFetch<ThresholdConfig>('/thresholds', {
method: 'PUT', method: 'PUT',
body: JSON.stringify(body), body: JSON.stringify(body),
}); });

View File

@@ -36,7 +36,7 @@ function AuditLogContent() {
const [category, setCategory] = useState(''); const [category, setCategory] = useState('');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [expandedRow, setExpandedRow] = useState<string | null>(null); const [expandedRow, setExpandedRow] = useState<number | null>(null);
const pageSize = 25; const pageSize = 25;
const params: AuditLogParams = { const params: AuditLogParams = {
@@ -51,16 +51,16 @@ function AuditLogContent() {
const audit = useAuditLog(params); const audit = useAuditLog(params);
const data = audit.data; const data = audit.data;
const totalPages = data ? Math.ceil(data.total / pageSize) : 0; const totalPages = data?.totalPages ?? 0;
const showingFrom = data && data.total > 0 ? page * pageSize + 1 : 0; const showingFrom = data && data.totalCount > 0 ? page * pageSize + 1 : 0;
const showingTo = data ? Math.min((page + 1) * pageSize, data.total) : 0; const showingTo = data ? Math.min((page + 1) * pageSize, data.totalCount) : 0;
return ( return (
<div className={styles.page}> <div className={styles.page}>
<div className={styles.header}> <div className={styles.header}>
<h1 className={styles.pageTitle}>Audit Log</h1> <h1 className={styles.pageTitle}>Audit Log</h1>
{data && ( {data && (
<span className={styles.totalCount}>{data.total.toLocaleString()} events</span> <span className={styles.totalCount}>{data.totalCount.toLocaleString()} events</span>
)} )}
</div> </div>
@@ -121,7 +121,7 @@ function AuditLogContent() {
{audit.isLoading ? ( {audit.isLoading ? (
<div className={styles.loading}>Loading...</div> <div className={styles.loading}>Loading...</div>
) : !data || data.events.length === 0 ? ( ) : !data || data.items.length === 0 ? (
<div className={styles.emptyState}>No audit events found for the selected filters.</div> <div className={styles.emptyState}>No audit events found for the selected filters.</div>
) : ( ) : (
<> <>
@@ -138,7 +138,7 @@ function AuditLogContent() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.events.map((event) => ( {data.items.map((event) => (
<> <>
<tr <tr
key={event.id} key={event.id}
@@ -191,7 +191,7 @@ function AuditLogContent() {
Previous Previous
</button> </button>
<span className={styles.pageInfo}> <span className={styles.pageInfo}>
Showing {showingFrom}-{showingTo} of {data.total.toLocaleString()} Showing {showingFrom}-{showingTo} of {data.totalCount.toLocaleString()}
</span> </span>
<button <button
type="button" type="button"

View File

@@ -10,7 +10,7 @@ import {
useDatabaseQueries, useDatabaseQueries,
useKillQuery, useKillQuery,
} from '../../api/queries/admin/database'; } from '../../api/queries/admin/database';
import { useThresholds, useSaveThresholds, type Thresholds } from '../../api/queries/admin/thresholds'; import { useThresholds, useSaveThresholds, type ThresholdConfig } from '../../api/queries/admin/thresholds';
import styles from './DatabaseAdminPage.module.css'; import styles from './DatabaseAdminPage.module.css';
export function DatabaseAdminPage() { export function DatabaseAdminPage() {
@@ -78,13 +78,13 @@ function DatabaseAdminContent() {
<PoolSection <PoolSection
pool={pool} pool={pool}
warningPct={thresholds.data?.poolWarningPercent} warningPct={thresholds.data?.database?.connectionPoolWarning}
criticalPct={thresholds.data?.poolCriticalPercent} criticalPct={thresholds.data?.database?.connectionPoolCritical}
/> />
<TablesSection tables={tables} /> <TablesSection tables={tables} />
<QueriesSection <QueriesSection
queries={queries} queries={queries}
warningSeconds={thresholds.data?.queryDurationWarningSeconds} warningSeconds={thresholds.data?.database?.queryDurationWarning}
/> />
<MaintenanceSection /> <MaintenanceSection />
<ThresholdsSection thresholds={thresholds.data} /> <ThresholdsSection thresholds={thresholds.data} />
@@ -104,8 +104,8 @@ function PoolSection({
const data = pool.data; const data = pool.data;
if (!data) return null; if (!data) return null;
const usagePct = data.maxConnections > 0 const usagePct = data.maxPoolSize > 0
? Math.round((data.activeConnections / data.maxConnections) * 100) ? Math.round((data.activeConnections / data.maxPoolSize) * 100)
: 0; : 0;
const barColor = const barColor =
criticalPct && usagePct >= criticalPct ? '#ef4444' criticalPct && usagePct >= criticalPct ? '#ef4444'
@@ -121,7 +121,7 @@ function PoolSection({
> >
<div className={styles.progressContainer}> <div className={styles.progressContainer}>
<div className={styles.progressLabel}> <div className={styles.progressLabel}>
{data.activeConnections} / {data.maxConnections} connections {data.activeConnections} / {data.maxPoolSize} connections
<span className={styles.progressPct}>{usagePct}%</span> <span className={styles.progressPct}>{usagePct}%</span>
</div> </div>
<div className={styles.progressBar}> <div className={styles.progressBar}>
@@ -141,11 +141,11 @@ function PoolSection({
<span className={styles.metricLabel}>Idle</span> <span className={styles.metricLabel}>Idle</span>
</div> </div>
<div className={styles.metric}> <div className={styles.metric}>
<span className={styles.metricValue}>{data.pendingConnections}</span> <span className={styles.metricValue}>{data.pendingThreads}</span>
<span className={styles.metricLabel}>Pending</span> <span className={styles.metricLabel}>Pending</span>
</div> </div>
<div className={styles.metric}> <div className={styles.metric}>
<span className={styles.metricValue}>{data.maxWaitMillis}ms</span> <span className={styles.metricValue}>{data.maxWaitMs}ms</span>
<span className={styles.metricLabel}>Max Wait</span> <span className={styles.metricLabel}>Max Wait</span>
</div> </div>
</div> </div>
@@ -179,7 +179,7 @@ function TablesSection({ tables }: { tables: ReturnType<typeof useDatabaseTables
{data.map((t) => ( {data.map((t) => (
<tr key={t.tableName}> <tr key={t.tableName}>
<td className={styles.mono}>{t.tableName}</td> <td className={styles.mono}>{t.tableName}</td>
<td>{t.rowEstimate.toLocaleString()}</td> <td>{t.rowCount.toLocaleString()}</td>
<td>{t.dataSize}</td> <td>{t.dataSize}</td>
<td>{t.indexSize}</td> <td>{t.indexSize}</td>
</tr> </tr>
@@ -203,7 +203,7 @@ function QueriesSection({
const killMutation = useKillQuery(); const killMutation = useKillQuery();
const data = queries.data; const data = queries.data;
const warningMs = (warningSeconds ?? 30) * 1000; const warningSec = warningSeconds ?? 30;
return ( return (
<RefreshableCard <RefreshableCard
@@ -230,10 +230,10 @@ function QueriesSection({
{data.map((q) => ( {data.map((q) => (
<tr <tr
key={q.pid} key={q.pid}
className={q.durationMs > warningMs ? styles.rowWarning : undefined} className={q.durationSeconds > warningSec ? styles.rowWarning : undefined}
> >
<td className={styles.mono}>{q.pid}</td> <td className={styles.mono}>{q.pid}</td>
<td>{formatDuration(q.durationMs)}</td> <td>{formatDuration(q.durationSeconds)}</td>
<td>{q.state}</td> <td>{q.state}</td>
<td className={styles.queryCell} title={q.query}> <td className={styles.queryCell} title={q.query}>
{q.query.length > 100 ? `${q.query.slice(0, 100)}...` : q.query} {q.query.length > 100 ? `${q.query.slice(0, 100)}...` : q.query}
@@ -287,16 +287,19 @@ function MaintenanceSection() {
); );
} }
function ThresholdsSection({ thresholds }: { thresholds?: Thresholds }) { function ThresholdsSection({ thresholds }: { thresholds?: ThresholdConfig }) {
const [form, setForm] = useState<Thresholds | null>(null); const [form, setForm] = useState<ThresholdConfig | null>(null);
const saveMutation = useSaveThresholds(); const saveMutation = useSaveThresholds();
const [status, setStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null); const [status, setStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null);
const current = form ?? thresholds; const current = form ?? thresholds;
if (!current) return null; if (!current) return null;
function update(key: keyof Thresholds, value: number) { function updateDb(key: keyof ThresholdConfig['database'], value: number) {
setForm((prev) => ({ ...(prev ?? thresholds!), [key]: value })); setForm((prev) => {
const base = prev ?? thresholds!;
return { ...base, database: { ...base.database, [key]: value } };
});
} }
async function handleSave() { async function handleSave() {
@@ -319,8 +322,8 @@ function ThresholdsSection({ thresholds }: { thresholds?: Thresholds }) {
<input <input
type="number" type="number"
className={styles.thresholdInput} className={styles.thresholdInput}
value={current.poolWarningPercent} value={current.database.connectionPoolWarning}
onChange={(e) => update('poolWarningPercent', Number(e.target.value))} onChange={(e) => updateDb('connectionPoolWarning', Number(e.target.value))}
/> />
</div> </div>
<div className={styles.thresholdField}> <div className={styles.thresholdField}>
@@ -328,8 +331,8 @@ function ThresholdsSection({ thresholds }: { thresholds?: Thresholds }) {
<input <input
type="number" type="number"
className={styles.thresholdInput} className={styles.thresholdInput}
value={current.poolCriticalPercent} value={current.database.connectionPoolCritical}
onChange={(e) => update('poolCriticalPercent', Number(e.target.value))} onChange={(e) => updateDb('connectionPoolCritical', Number(e.target.value))}
/> />
</div> </div>
<div className={styles.thresholdField}> <div className={styles.thresholdField}>
@@ -337,8 +340,8 @@ function ThresholdsSection({ thresholds }: { thresholds?: Thresholds }) {
<input <input
type="number" type="number"
className={styles.thresholdInput} className={styles.thresholdInput}
value={current.queryDurationWarningSeconds} value={current.database.queryDurationWarning}
onChange={(e) => update('queryDurationWarningSeconds', Number(e.target.value))} onChange={(e) => updateDb('queryDurationWarning', Number(e.target.value))}
/> />
</div> </div>
<div className={styles.thresholdField}> <div className={styles.thresholdField}>
@@ -346,8 +349,8 @@ function ThresholdsSection({ thresholds }: { thresholds?: Thresholds }) {
<input <input
type="number" type="number"
className={styles.thresholdInput} className={styles.thresholdInput}
value={current.queryDurationCriticalSeconds} value={current.database.queryDurationCritical}
onChange={(e) => update('queryDurationCriticalSeconds', Number(e.target.value))} onChange={(e) => updateDb('queryDurationCritical', Number(e.target.value))}
/> />
</div> </div>
</div> </div>
@@ -370,9 +373,9 @@ function ThresholdsSection({ thresholds }: { thresholds?: Thresholds }) {
); );
} }
function formatDuration(ms: number): string { function formatDuration(seconds: number): string {
if (ms < 1000) return `${ms}ms`; if (seconds < 1) return `${Math.round(seconds * 1000)}ms`;
const s = Math.floor(ms / 1000); const s = Math.floor(seconds);
if (s < 60) return `${s}s`; if (s < 60) return `${s}s`;
const m = Math.floor(s / 60); const m = Math.floor(s / 60);
return `${m}m ${s % 60}s`; return `${m}m ${s % 60}s`;

View File

@@ -11,7 +11,7 @@ import {
useDeleteIndex, useDeleteIndex,
type IndicesParams, type IndicesParams,
} from '../../api/queries/admin/opensearch'; } from '../../api/queries/admin/opensearch';
import { useThresholds, useSaveThresholds, type Thresholds } from '../../api/queries/admin/thresholds'; import { useThresholds, useSaveThresholds, type ThresholdConfig } from '../../api/queries/admin/thresholds';
import styles from './OpenSearchAdminPage.module.css'; import styles from './OpenSearchAdminPage.module.css';
function clusterHealthToStatus(health: string | undefined): Status { function clusterHealthToStatus(health: string | undefined): Status {
@@ -67,8 +67,8 @@ function OpenSearchAdminContent() {
label={os?.clusterHealth ?? 'Unknown'} label={os?.clusterHealth ?? 'Unknown'}
/> />
{os?.version && <span className={styles.metaItem}>v{os.version}</span>} {os?.version && <span className={styles.metaItem}>v{os.version}</span>}
{os?.numberOfNodes !== undefined && ( {os?.nodeCount !== undefined && (
<span className={styles.metaItem}>{os.numberOfNodes} node(s)</span> <span className={styles.metaItem}>{os.nodeCount} node(s)</span>
)} )}
{os?.host && <span className={styles.metaItem}>{os.host}</span>} {os?.host && <span className={styles.metaItem}>{os.host}</span>}
</div> </div>
@@ -100,7 +100,7 @@ function PipelineSection({
thresholds, thresholds,
}: { }: {
pipeline: ReturnType<typeof usePipelineStats>; pipeline: ReturnType<typeof usePipelineStats>;
thresholds?: Thresholds; thresholds?: ThresholdConfig;
}) { }) {
const data = pipeline.data; const data = pipeline.data;
if (!data) return null; if (!data) return null;
@@ -109,8 +109,8 @@ function PipelineSection({
? Math.round((data.queueDepth / data.maxQueueSize) * 100) ? Math.round((data.queueDepth / data.maxQueueSize) * 100)
: 0; : 0;
const barColor = const barColor =
thresholds?.osQueueCriticalPercent && queuePct >= thresholds.osQueueCriticalPercent ? '#ef4444' thresholds?.opensearch?.queueDepthCritical && data.queueDepth >= thresholds.opensearch.queueDepthCritical ? '#ef4444'
: thresholds?.osQueueWarningPercent && queuePct >= thresholds.osQueueWarningPercent ? '#eab308' : thresholds?.opensearch?.queueDepthWarning && data.queueDepth >= thresholds.opensearch.queueDepthWarning ? '#eab308'
: '#22c55e'; : '#22c55e';
return ( return (
@@ -134,16 +134,16 @@ function PipelineSection({
</div> </div>
<div className={styles.metricsGrid}> <div className={styles.metricsGrid}>
<div className={styles.metric}> <div className={styles.metric}>
<span className={styles.metricValue}>{data.totalIndexed.toLocaleString()}</span> <span className={styles.metricValue}>{data.indexedCount.toLocaleString()}</span>
<span className={styles.metricLabel}>Total Indexed</span> <span className={styles.metricLabel}>Total Indexed</span>
</div> </div>
<div className={styles.metric}> <div className={styles.metric}>
<span className={styles.metricValue}>{data.totalFailed.toLocaleString()}</span> <span className={styles.metricValue}>{data.failedCount.toLocaleString()}</span>
<span className={styles.metricLabel}>Total Failed</span> <span className={styles.metricLabel}>Total Failed</span>
</div> </div>
<div className={styles.metric}> <div className={styles.metric}>
<span className={styles.metricValue}>{data.avgLatencyMs}ms</span> <span className={styles.metricValue}>{data.indexingRate.toFixed(1)}/s</span>
<span className={styles.metricLabel}>Avg Latency</span> <span className={styles.metricLabel}>Indexing Rate</span>
</div> </div>
</div> </div>
</RefreshableCard> </RefreshableCard>
@@ -152,18 +152,12 @@ function PipelineSection({
function IndicesSection() { function IndicesSection() {
const [search, setSearch] = useState(''); 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 [page, setPage] = useState(0);
const pageSize = 10; const pageSize = 10;
const [deleteTarget, setDeleteTarget] = useState<string | null>(null); const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const params: IndicesParams = { const params: IndicesParams = {
search: search || undefined, search: search || undefined,
health: healthFilter || undefined,
sortBy,
sortDir,
page, page,
size: pageSize, size: pageSize,
}; };
@@ -171,18 +165,8 @@ function IndicesSection() {
const indices = useIndices(params); const indices = useIndices(params);
const deleteMutation = useDeleteIndex(); 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 data = indices.data;
const totalPages = data ? Math.ceil(data.total / pageSize) : 0; const totalPages = data?.totalPages ?? 0;
return ( return (
<RefreshableCard <RefreshableCard
@@ -198,16 +182,6 @@ function IndicesSection() {
value={search} value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }} 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> </div>
{!data ? ( {!data ? (
@@ -218,10 +192,10 @@ function IndicesSection() {
<table className={styles.table}> <table className={styles.table}>
<thead> <thead>
<tr> <tr>
<SortHeader label="Name" col="name" current={sortBy} dir={sortDir} onSort={toggleSort} /> <th>Name</th>
<SortHeader label="Health" col="health" current={sortBy} dir={sortDir} onSort={toggleSort} /> <th>Health</th>
<SortHeader label="Docs" col="docsCount" current={sortBy} dir={sortDir} onSort={toggleSort} /> <th>Docs</th>
<SortHeader label="Size" col="storeSize" current={sortBy} dir={sortDir} onSort={toggleSort} /> <th>Size</th>
<th>Shards</th> <th>Shards</th>
<th></th> <th></th>
</tr> </tr>
@@ -235,9 +209,9 @@ function IndicesSection() {
{idx.health} {idx.health}
</span> </span>
</td> </td>
<td>{idx.docsCount.toLocaleString()}</td> <td>{idx.docCount.toLocaleString()}</td>
<td>{idx.storeSize}</td> <td>{idx.size}</td>
<td>{idx.primaryShards}p / {idx.replicas}r</td> <td>{idx.primaryShards}p / {idx.replicaShards}r</td>
<td> <td>
<button <button
type="button" type="button"
@@ -300,45 +274,22 @@ function IndicesSection() {
); );
} }
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({ function PerformanceSection({
performance, performance,
thresholds, thresholds,
}: { }: {
performance: ReturnType<typeof usePerformanceStats>; performance: ReturnType<typeof usePerformanceStats>;
thresholds?: Thresholds; thresholds?: ThresholdConfig;
}) { }) {
const data = performance.data; const data = performance.data;
if (!data) return null; if (!data) return null;
const heapPct = data.jvmHeapUsedPercent; const heapPct = data.jvmHeapMaxBytes > 0
? Math.round((data.jvmHeapUsedBytes / data.jvmHeapMaxBytes) * 100)
: 0;
const heapColor = const heapColor =
thresholds?.osHeapCriticalPercent && heapPct >= thresholds.osHeapCriticalPercent ? '#ef4444' thresholds?.opensearch?.jvmHeapCritical && heapPct >= thresholds.opensearch.jvmHeapCritical ? '#ef4444'
: thresholds?.osHeapWarningPercent && heapPct >= thresholds.osHeapWarningPercent ? '#eab308' : thresholds?.opensearch?.jvmHeapWarning && heapPct >= thresholds.opensearch.jvmHeapWarning ? '#eab308'
: '#22c55e'; : '#22c55e';
return ( return (
@@ -358,11 +309,11 @@ function PerformanceSection({
<span className={styles.metricLabel}>Request Cache Hit</span> <span className={styles.metricLabel}>Request Cache Hit</span>
</div> </div>
<div className={styles.metric}> <div className={styles.metric}>
<span className={styles.metricValue}>{data.avgQueryLatencyMs}ms</span> <span className={styles.metricValue}>{data.searchLatencyMs.toFixed(1)}ms</span>
<span className={styles.metricLabel}>Query Latency</span> <span className={styles.metricLabel}>Query Latency</span>
</div> </div>
<div className={styles.metric}> <div className={styles.metric}>
<span className={styles.metricValue}>{data.avgIndexLatencyMs}ms</span> <span className={styles.metricValue}>{data.indexingLatencyMs.toFixed(1)}ms</span>
<span className={styles.metricLabel}>Index Latency</span> <span className={styles.metricLabel}>Index Latency</span>
</div> </div>
</div> </div>
@@ -400,16 +351,19 @@ function OperationsSection() {
); );
} }
function OsThresholdsSection({ thresholds }: { thresholds?: Thresholds }) { function OsThresholdsSection({ thresholds }: { thresholds?: ThresholdConfig }) {
const [form, setForm] = useState<Thresholds | null>(null); const [form, setForm] = useState<ThresholdConfig | null>(null);
const saveMutation = useSaveThresholds(); const saveMutation = useSaveThresholds();
const [status, setStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null); const [status, setStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null);
const current = form ?? thresholds; const current = form ?? thresholds;
if (!current) return null; if (!current) return null;
function update(key: keyof Thresholds, value: number) { function updateOs(key: keyof ThresholdConfig['opensearch'], value: number | string) {
setForm((prev) => ({ ...(prev ?? thresholds!), [key]: value })); setForm((prev) => {
const base = prev ?? thresholds!;
return { ...base, opensearch: { ...base.opensearch, [key]: value } };
});
} }
async function handleSave() { async function handleSave() {
@@ -427,21 +381,21 @@ function OsThresholdsSection({ thresholds }: { thresholds?: Thresholds }) {
<RefreshableCard title="Thresholds" collapsible defaultCollapsed> <RefreshableCard title="Thresholds" collapsible defaultCollapsed>
<div className={styles.thresholdGrid}> <div className={styles.thresholdGrid}>
<div className={styles.thresholdField}> <div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Queue Warning %</label> <label className={styles.thresholdLabel}>Queue Warning</label>
<input <input
type="number" type="number"
className={styles.thresholdInput} className={styles.thresholdInput}
value={current.osQueueWarningPercent} value={current.opensearch.queueDepthWarning}
onChange={(e) => update('osQueueWarningPercent', Number(e.target.value))} onChange={(e) => updateOs('queueDepthWarning', Number(e.target.value))}
/> />
</div> </div>
<div className={styles.thresholdField}> <div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Queue Critical %</label> <label className={styles.thresholdLabel}>Queue Critical</label>
<input <input
type="number" type="number"
className={styles.thresholdInput} className={styles.thresholdInput}
value={current.osQueueCriticalPercent} value={current.opensearch.queueDepthCritical}
onChange={(e) => update('osQueueCriticalPercent', Number(e.target.value))} onChange={(e) => updateOs('queueDepthCritical', Number(e.target.value))}
/> />
</div> </div>
<div className={styles.thresholdField}> <div className={styles.thresholdField}>
@@ -449,8 +403,8 @@ function OsThresholdsSection({ thresholds }: { thresholds?: Thresholds }) {
<input <input
type="number" type="number"
className={styles.thresholdInput} className={styles.thresholdInput}
value={current.osHeapWarningPercent} value={current.opensearch.jvmHeapWarning}
onChange={(e) => update('osHeapWarningPercent', Number(e.target.value))} onChange={(e) => updateOs('jvmHeapWarning', Number(e.target.value))}
/> />
</div> </div>
<div className={styles.thresholdField}> <div className={styles.thresholdField}>
@@ -458,8 +412,8 @@ function OsThresholdsSection({ thresholds }: { thresholds?: Thresholds }) {
<input <input
type="number" type="number"
className={styles.thresholdInput} className={styles.thresholdInput}
value={current.osHeapCriticalPercent} value={current.opensearch.jvmHeapCritical}
onChange={(e) => update('osHeapCriticalPercent', Number(e.target.value))} onChange={(e) => updateOs('jvmHeapCritical', Number(e.target.value))}
/> />
</div> </div>
</div> </div>