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:
@@ -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();
|
||||||
|
|||||||
@@ -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'] }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}` : ''}`,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user