feat: remove OpenSearch, add ClickHouse admin page
Remove all OpenSearch code, dependencies, configuration, deployment manifests, and CI/CD references. Replace the OpenSearch admin page with a ClickHouse admin page showing cluster status, table sizes, performance metrics, and indexer pipeline stats. - Delete 11 OpenSearch Java files (config, search impl, admin controller, DTOs, tests) - Delete 3 OpenSearch frontend files (admin page, CSS, query hooks) - Delete deploy/opensearch.yaml K8s manifest - Remove opensearch Maven dependencies from pom.xml - Remove opensearch config from application.yml, Dockerfile, docker-compose - Remove opensearch from CI workflow (secrets, deploy, cleanup steps) - Simplify ThresholdConfig (remove OpenSearch thresholds, database-only) - Change default search backend from opensearch to clickhouse - Add ClickHouseAdminController with /status, /tables, /performance, /pipeline - Add ClickHouseAdminPage with StatCards, pipeline ProgressBar, tables DataTable - Update CLAUDE.md, HOWTO.md, and source comments Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
77
ui/src/api/queries/admin/clickhouse.ts
Normal file
77
ui/src/api/queries/admin/clickhouse.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { adminFetch } from './admin-api';
|
||||
import { useRefreshInterval } from '../use-refresh-interval';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ClickHouseStatus {
|
||||
reachable: boolean;
|
||||
version: string | null;
|
||||
uptime: string | null;
|
||||
host: string | null;
|
||||
}
|
||||
|
||||
export interface ClickHouseTableInfo {
|
||||
name: string;
|
||||
engine: string;
|
||||
rowCount: number;
|
||||
dataSize: string;
|
||||
dataSizeBytes: number;
|
||||
partitionCount: number;
|
||||
}
|
||||
|
||||
export interface ClickHousePerformance {
|
||||
queryCount: number;
|
||||
insertQueryCount: number;
|
||||
memoryUsage: string;
|
||||
insertedRows: number;
|
||||
readRows: number;
|
||||
}
|
||||
|
||||
export interface IndexerPipeline {
|
||||
queueDepth: number;
|
||||
maxQueueSize: number;
|
||||
failedCount: number;
|
||||
indexedCount: number;
|
||||
debounceMs: number;
|
||||
indexingRate: number;
|
||||
lastIndexedAt: string | null;
|
||||
}
|
||||
|
||||
// ── Query Hooks ────────────────────────────────────────────────────────
|
||||
|
||||
export function useClickHouseStatus() {
|
||||
const refetchInterval = useRefreshInterval(30_000);
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'clickhouse', 'status'],
|
||||
queryFn: () => adminFetch<ClickHouseStatus>('/clickhouse/status'),
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
export function useClickHouseTables() {
|
||||
const refetchInterval = useRefreshInterval(60_000);
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'clickhouse', 'tables'],
|
||||
queryFn: () => adminFetch<ClickHouseTableInfo[]>('/clickhouse/tables'),
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
export function useClickHousePerformance() {
|
||||
const refetchInterval = useRefreshInterval(30_000);
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'clickhouse', 'performance'],
|
||||
queryFn: () => adminFetch<ClickHousePerformance>('/clickhouse/performance'),
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
export function useIndexerPipeline() {
|
||||
const refetchInterval = useRefreshInterval(10_000);
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'clickhouse', 'pipeline'],
|
||||
queryFn: () => adminFetch<IndexerPipeline>('/clickhouse/pipeline'),
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { adminFetch } from './admin-api';
|
||||
import { useRefreshInterval } from '../use-refresh-interval';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface OpenSearchStatus {
|
||||
reachable: boolean;
|
||||
clusterHealth: string;
|
||||
version: string | null;
|
||||
nodeCount: number;
|
||||
host: string;
|
||||
}
|
||||
|
||||
export interface PipelineStats {
|
||||
queueDepth: number;
|
||||
maxQueueSize: number;
|
||||
failedCount: number;
|
||||
indexedCount: number;
|
||||
debounceMs: number;
|
||||
indexingRate: number;
|
||||
lastIndexedAt: string | null;
|
||||
}
|
||||
|
||||
export interface IndexInfo {
|
||||
name: string;
|
||||
docCount: number;
|
||||
size: string;
|
||||
sizeBytes: number;
|
||||
health: string;
|
||||
primaryShards: number;
|
||||
replicas: number;
|
||||
}
|
||||
|
||||
export interface IndicesPage {
|
||||
indices: IndexInfo[];
|
||||
totalIndices: number;
|
||||
totalDocs: number;
|
||||
totalSize: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface PerformanceStats {
|
||||
queryCacheHitRate: number;
|
||||
requestCacheHitRate: number;
|
||||
searchLatencyMs: number;
|
||||
indexingLatencyMs: number;
|
||||
heapUsedBytes: number;
|
||||
heapMaxBytes: number;
|
||||
}
|
||||
|
||||
// ── Query Hooks ────────────────────────────────────────────────────────
|
||||
|
||||
export function useOpenSearchStatus() {
|
||||
const refetchInterval = useRefreshInterval(30_000);
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'opensearch', 'status'],
|
||||
queryFn: () => adminFetch<OpenSearchStatus>('/opensearch/status'),
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePipelineStats() {
|
||||
const refetchInterval = useRefreshInterval(10_000);
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'opensearch', 'pipeline'],
|
||||
queryFn: () => adminFetch<PipelineStats>('/opensearch/pipeline'),
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOpenSearchIndices(page = 0, size = 20, search = '', prefix = 'executions') {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'opensearch', 'indices', prefix, page, size, search],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page));
|
||||
params.set('size', String(size));
|
||||
params.set('prefix', prefix);
|
||||
if (search) params.set('search', search);
|
||||
return adminFetch<IndicesPage>(`/opensearch/indices?${params}`);
|
||||
},
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOpenSearchPerformance() {
|
||||
const refetchInterval = useRefreshInterval(30_000);
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'opensearch', 'performance'],
|
||||
queryFn: () => adminFetch<PerformanceStats>('/opensearch/performance'),
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mutation Hooks ─────────────────────────────────────────────────────
|
||||
|
||||
export function useDeleteIndex() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (indexName: string) =>
|
||||
adminFetch<void>(`/opensearch/indices/${indexName}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'opensearch', 'indices'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -10,20 +10,8 @@ export interface DatabaseThresholds {
|
||||
queryDurationCritical: number;
|
||||
}
|
||||
|
||||
export interface OpenSearchThresholds {
|
||||
clusterHealthWarning: string;
|
||||
clusterHealthCritical: string;
|
||||
queueDepthWarning: number;
|
||||
queueDepthCritical: number;
|
||||
jvmHeapWarning: number;
|
||||
jvmHeapCritical: number;
|
||||
failedDocsWarning: number;
|
||||
failedDocsCritical: number;
|
||||
}
|
||||
|
||||
export interface ThresholdConfig {
|
||||
database: DatabaseThresholds;
|
||||
opensearch: OpenSearchThresholds;
|
||||
}
|
||||
|
||||
// ── Query Hooks ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -184,7 +184,7 @@ function LayoutContent() {
|
||||
audit: 'Audit Log',
|
||||
oidc: 'OIDC',
|
||||
database: 'Database',
|
||||
opensearch: 'OpenSearch',
|
||||
clickhouse: 'ClickHouse',
|
||||
appconfig: 'App Config',
|
||||
};
|
||||
const parts = location.pathname.split('/').filter(Boolean);
|
||||
|
||||
@@ -7,7 +7,7 @@ const ADMIN_TABS = [
|
||||
{ label: 'OIDC', value: '/admin/oidc' },
|
||||
{ label: 'App Config', value: '/admin/appconfig' },
|
||||
{ label: 'Database', value: '/admin/database' },
|
||||
{ label: 'OpenSearch', value: '/admin/opensearch' },
|
||||
{ label: 'ClickHouse', value: '/admin/clickhouse' },
|
||||
];
|
||||
|
||||
export default function AdminLayout() {
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.indexSection {
|
||||
.tableSection {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -42,7 +42,7 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.indexHeader {
|
||||
.tableHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -50,13 +50,13 @@
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.indexTitle {
|
||||
.tableTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.indexMeta {
|
||||
.tableMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
65
ui/src/pages/Admin/ClickHouseAdminPage.tsx
Normal file
65
ui/src/pages/Admin/ClickHouseAdminPage.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { StatCard, DataTable, ProgressBar } from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { useClickHouseStatus, useClickHouseTables, useClickHousePerformance, useIndexerPipeline } from '../../api/queries/admin/clickhouse';
|
||||
import styles from './ClickHouseAdminPage.module.css';
|
||||
|
||||
export default function ClickHouseAdminPage() {
|
||||
const { data: status, isError: statusError } = useClickHouseStatus();
|
||||
const { data: tables } = useClickHouseTables();
|
||||
const { data: perf } = useClickHousePerformance();
|
||||
const { data: pipeline } = useIndexerPipeline();
|
||||
const unreachable = statusError || (status && !status.reachable);
|
||||
|
||||
const tableColumns: Column<any>[] = [
|
||||
{ key: 'name', header: 'Table', sortable: true },
|
||||
{ key: 'engine', header: 'Engine' },
|
||||
{ key: 'rowCount', header: 'Rows', sortable: true, render: (v) => Number(v).toLocaleString() },
|
||||
{ key: 'dataSize', header: 'Size', sortable: true },
|
||||
{ key: 'partitionCount', header: 'Partitions', sortable: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.statStrip}>
|
||||
<StatCard label="Status" value={unreachable ? 'Disconnected' : status ? 'Connected' : '\u2014'} accent={unreachable ? 'error' : status ? 'success' : undefined} />
|
||||
<StatCard label="Version" value={status?.version ?? '\u2014'} />
|
||||
<StatCard label="Uptime" value={status?.uptime ?? '\u2014'} />
|
||||
</div>
|
||||
|
||||
{pipeline && (
|
||||
<div className={styles.pipelineCard}>
|
||||
<div className={styles.pipelineTitle}>Indexer Pipeline</div>
|
||||
<ProgressBar value={pipeline.maxQueueSize > 0 ? (pipeline.queueDepth / pipeline.maxQueueSize) * 100 : 0} />
|
||||
<div className={styles.pipelineMetrics}>
|
||||
<span>Queue: {pipeline.queueDepth}/{pipeline.maxQueueSize}</span>
|
||||
<span>Indexed: {pipeline.indexedCount.toLocaleString()}</span>
|
||||
<span>Failed: {pipeline.failedCount}</span>
|
||||
<span>Rate: {pipeline.indexingRate.toFixed(1)}/s</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{perf && (
|
||||
<div className={styles.statStrip}>
|
||||
<StatCard label="Select Queries" value={perf.queryCount.toLocaleString()} />
|
||||
<StatCard label="Insert Queries" value={perf.insertQueryCount.toLocaleString()} />
|
||||
<StatCard label="Memory" value={perf.memoryUsage} />
|
||||
<StatCard label="Rows Read" value={perf.readRows.toLocaleString()} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.tableSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Tables ({(tables || []).length})</span>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
data={(tables || []).map((t: any) => ({ ...t, id: t.name }))}
|
||||
sortable
|
||||
pageSize={20}
|
||||
flush
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { StatCard, DataTable, Badge, ProgressBar } from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { useOpenSearchStatus, usePipelineStats, useOpenSearchIndices, useOpenSearchPerformance, useDeleteIndex } from '../../api/queries/admin/opensearch';
|
||||
import styles from './OpenSearchAdminPage.module.css';
|
||||
|
||||
export default function OpenSearchAdminPage() {
|
||||
const { data: status, isError: statusError } = useOpenSearchStatus();
|
||||
const { data: pipeline } = usePipelineStats();
|
||||
const { data: perf } = useOpenSearchPerformance();
|
||||
const { data: execIndices } = useOpenSearchIndices(0, 50, '', 'executions');
|
||||
const { data: logIndices } = useOpenSearchIndices(0, 50, '', 'logs');
|
||||
const unreachable = statusError || (status && !status.reachable);
|
||||
const deleteIndex = useDeleteIndex();
|
||||
|
||||
const indexColumns: Column<any>[] = [
|
||||
{ key: 'name', header: 'Index' },
|
||||
{ key: 'health', header: 'Health', render: (v) => <Badge label={String(v)} color={v === 'green' ? 'success' : v === 'yellow' ? 'warning' : 'error'} /> },
|
||||
{ key: 'docCount', header: 'Documents', sortable: true, render: (v) => Number(v).toLocaleString() },
|
||||
{ key: 'size', header: 'Size' },
|
||||
{ key: 'primaryShards', header: 'Shards' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.statStrip}>
|
||||
<StatCard label="Status" value={unreachable ? 'Disconnected' : status ? 'Connected' : '\u2014'} accent={unreachable ? 'error' : status ? 'success' : undefined} />
|
||||
<StatCard label="Health" value={status?.clusterHealth ?? '\u2014'} accent={status?.clusterHealth === 'green' ? 'success' : 'warning'} />
|
||||
<StatCard label="Version" value={status?.version ?? '\u2014'} />
|
||||
<StatCard label="Nodes" value={status?.nodeCount ?? 0} />
|
||||
</div>
|
||||
|
||||
{pipeline && (
|
||||
<div className={styles.pipelineCard}>
|
||||
<div className={styles.pipelineTitle}>Indexing Pipeline</div>
|
||||
<ProgressBar value={(pipeline.queueDepth / pipeline.maxQueueSize) * 100} />
|
||||
<div className={styles.pipelineMetrics}>
|
||||
<span>Queue: {pipeline.queueDepth}/{pipeline.maxQueueSize}</span>
|
||||
<span>Indexed: {pipeline.indexedCount.toLocaleString()}</span>
|
||||
<span>Failed: {pipeline.failedCount}</span>
|
||||
<span>Rate: {pipeline.indexingRate}/s</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.indexSection}>
|
||||
<div className={styles.indexHeader}>
|
||||
<span className={styles.indexTitle}>Execution Indices ({execIndices?.totalIndices ?? 0})</span>
|
||||
<span className={styles.indexMeta}>
|
||||
{execIndices ? `${execIndices.totalDocs.toLocaleString()} docs \u00b7 ${execIndices.totalSize}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={indexColumns}
|
||||
data={(execIndices?.indices || []).map((i: any) => ({ ...i, id: i.name }))}
|
||||
sortable
|
||||
pageSize={20}
|
||||
flush
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.indexSection}>
|
||||
<div className={styles.indexHeader}>
|
||||
<span className={styles.indexTitle}>Log Indices ({logIndices?.totalIndices ?? 0})</span>
|
||||
<span className={styles.indexMeta}>
|
||||
{logIndices ? `${logIndices.totalDocs.toLocaleString()} docs \u00b7 ${logIndices.totalSize}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={indexColumns}
|
||||
data={(logIndices?.indices || []).map((i: any) => ({ ...i, id: i.name }))}
|
||||
sortable
|
||||
pageSize={20}
|
||||
flush
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -141,7 +141,7 @@ export default function AgentInstance() {
|
||||
[chartData],
|
||||
);
|
||||
|
||||
// Application logs from OpenSearch
|
||||
// Application logs
|
||||
const { data: rawLogs } = useApplicationLogs(appId, instanceId, { toOverride: logRefreshTo });
|
||||
const logEntries = useMemo<LogEntry[]>(() => {
|
||||
const mapped = (rawLogs || []).map((l) => ({
|
||||
|
||||
@@ -14,7 +14,7 @@ const RbacPage = lazy(() => import('./pages/Admin/RbacPage'));
|
||||
const AuditLogPage = lazy(() => import('./pages/Admin/AuditLogPage'));
|
||||
const OidcConfigPage = lazy(() => import('./pages/Admin/OidcConfigPage'));
|
||||
const DatabaseAdminPage = lazy(() => import('./pages/Admin/DatabaseAdminPage'));
|
||||
const OpenSearchAdminPage = lazy(() => import('./pages/Admin/OpenSearchAdminPage'));
|
||||
const ClickHouseAdminPage = lazy(() => import('./pages/Admin/ClickHouseAdminPage'));
|
||||
const AppConfigPage = lazy(() => import('./pages/Admin/AppConfigPage'));
|
||||
const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage'));
|
||||
|
||||
@@ -87,7 +87,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'oidc', element: <SuspenseWrapper><OidcConfigPage /></SuspenseWrapper> },
|
||||
{ path: 'appconfig', element: <SuspenseWrapper><AppConfigPage /></SuspenseWrapper> },
|
||||
{ path: 'database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> },
|
||||
{ path: 'opensearch', element: <SuspenseWrapper><OpenSearchAdminPage /></SuspenseWrapper> },
|
||||
{ path: 'clickhouse', element: <SuspenseWrapper><ClickHouseAdminPage /></SuspenseWrapper> },
|
||||
],
|
||||
},
|
||||
{ path: 'api-docs', element: <SuspenseWrapper><SwaggerPage /></SuspenseWrapper> },
|
||||
|
||||
Reference in New Issue
Block a user