fix: improve ClickHouse admin page, fix AgentHealth type error
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m13s
CI / docker (push) Successful in 3m46s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 58s

Rewrite ClickHouse admin to show useful storage metrics instead of
often-empty system.events data. Add active queries section.

- Replace performance endpoint: query system.parts for disk size,
  uncompressed size, compression ratio, total rows, part count
- Add /queries endpoint querying system.processes for active queries
- Frontend: storage overview strip, tables with total size, active
  queries DataTable
- Fix AgentHealth.tsx type: agentId → instanceId in inline type cast

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-01 20:18:06 +02:00
parent 188810e54b
commit f82aa26371
7 changed files with 159 additions and 37 deletions

View File

@@ -1,6 +1,7 @@
package com.cameleer3.server.app.controller; package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.dto.ClickHousePerformanceResponse; import com.cameleer3.server.app.dto.ClickHousePerformanceResponse;
import com.cameleer3.server.app.dto.ClickHouseQueryInfo;
import com.cameleer3.server.app.dto.ClickHouseStatusResponse; import com.cameleer3.server.app.dto.ClickHouseStatusResponse;
import com.cameleer3.server.app.dto.ClickHouseTableInfo; import com.cameleer3.server.app.dto.ClickHouseTableInfo;
import com.cameleer3.server.app.dto.IndexerPipelineResponse; import com.cameleer3.server.app.dto.IndexerPipelineResponse;
@@ -80,20 +81,72 @@ public class ClickHouseAdminController {
} }
@GetMapping("/performance") @GetMapping("/performance")
@Operation(summary = "ClickHouse performance metrics") @Operation(summary = "ClickHouse storage and performance metrics")
public ClickHousePerformanceResponse getPerformance() { public ClickHousePerformanceResponse getPerformance() {
try { try {
long selectQueries = queryEvent("SelectQuery"); var row = clickHouseJdbc.queryForMap("""
long insertQueries = queryEvent("InsertQuery"); SELECT
long insertedRows = queryEvent("InsertedRows"); formatReadableSize(sum(bytes_on_disk)) AS disk_size,
long readRows = queryEvent("SelectedRows"); formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
String memoryUsage = clickHouseJdbc.queryForObject( if(sum(data_uncompressed_bytes) > 0,
"SELECT formatReadableSize(value) FROM system.metrics WHERE metric = 'MemoryTracking'", round(sum(bytes_on_disk) / sum(data_uncompressed_bytes), 3), 0) AS compression_ratio,
String.class); sum(rows) AS total_rows,
return new ClickHousePerformanceResponse(selectQueries, insertQueries, count() AS part_count
memoryUsage != null ? memoryUsage : "0 B", insertedRows, readRows); FROM system.parts
WHERE database = currentDatabase() AND active
""");
String memory = "N/A";
try {
memory = clickHouseJdbc.queryForObject(
"SELECT formatReadableSize(value) FROM system.metrics WHERE metric = 'MemoryTracking'",
String.class);
} catch (Exception ignored) {}
int currentQueries = 0;
try {
Integer q = clickHouseJdbc.queryForObject(
"SELECT toInt32(value) FROM system.metrics WHERE metric = 'Query'",
Integer.class);
if (q != null) currentQueries = q;
} catch (Exception ignored) {}
return new ClickHousePerformanceResponse(
(String) row.get("disk_size"),
(String) row.get("uncompressed_size"),
((Number) row.get("compression_ratio")).doubleValue(),
((Number) row.get("total_rows")).longValue(),
((Number) row.get("part_count")).intValue(),
memory != null ? memory : "N/A",
currentQueries);
} catch (Exception e) { } catch (Exception e) {
return new ClickHousePerformanceResponse(0, 0, "N/A", 0, 0); return new ClickHousePerformanceResponse("N/A", "N/A", 0, 0, 0, "N/A", 0);
}
}
@GetMapping("/queries")
@Operation(summary = "Active ClickHouse queries")
public List<ClickHouseQueryInfo> getQueries() {
try {
return clickHouseJdbc.query("""
SELECT
query_id,
round(elapsed, 2) AS elapsed_seconds,
formatReadableSize(memory_usage) AS memory,
read_rows,
substring(query, 1, 200) AS query
FROM system.processes
WHERE is_initial_query = 1
ORDER BY elapsed DESC
""",
(rs, rowNum) -> new ClickHouseQueryInfo(
rs.getString("query_id"),
rs.getDouble("elapsed_seconds"),
rs.getString("memory"),
rs.getLong("read_rows"),
rs.getString("query")));
} catch (Exception e) {
return List.of();
} }
} }
@@ -109,11 +162,4 @@ public class ClickHouseAdminController {
indexerStats.getIndexingRate(), indexerStats.getIndexingRate(),
indexerStats.getLastIndexedAt()); indexerStats.getLastIndexedAt());
} }
private long queryEvent(String eventName) {
Long val = clickHouseJdbc.queryForObject(
"SELECT value FROM system.events WHERE event = ?",
Long.class, eventName);
return val != null ? val : 0;
}
} }

View File

@@ -2,11 +2,13 @@ package com.cameleer3.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "ClickHouse performance metrics") @Schema(description = "ClickHouse storage and performance metrics")
public record ClickHousePerformanceResponse( public record ClickHousePerformanceResponse(
long queryCount, String diskSize,
long insertQueryCount, String uncompressedSize,
double compressionRatio,
long totalRows,
int partCount,
String memoryUsage, String memoryUsage,
long insertedRows, int currentQueries
long readRows
) {} ) {}

View File

@@ -0,0 +1,12 @@
package com.cameleer3.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "Active ClickHouse query information")
public record ClickHouseQueryInfo(
String queryId,
double elapsedSeconds,
String memory,
long readRows,
String query
) {}

View File

@@ -21,11 +21,21 @@ export interface ClickHouseTableInfo {
} }
export interface ClickHousePerformance { export interface ClickHousePerformance {
queryCount: number; diskSize: string;
insertQueryCount: number; uncompressedSize: string;
compressionRatio: number;
totalRows: number;
partCount: number;
memoryUsage: string; memoryUsage: string;
insertedRows: number; currentQueries: number;
}
export interface ClickHouseQuery {
queryId: string;
elapsedSeconds: number;
memory: string;
readRows: number; readRows: number;
query: string;
} }
export interface IndexerPipeline { export interface IndexerPipeline {
@@ -67,6 +77,15 @@ export function useClickHousePerformance() {
}); });
} }
export function useClickHouseQueries() {
const refetchInterval = useRefreshInterval(5_000);
return useQuery({
queryKey: ['admin', 'clickhouse', 'queries'],
queryFn: () => adminFetch<ClickHouseQuery[]>('/clickhouse/queries'),
refetchInterval,
});
}
export function useIndexerPipeline() { export function useIndexerPipeline() {
const refetchInterval = useRefreshInterval(10_000); const refetchInterval = useRefreshInterval(10_000);
return useQuery({ return useQuery({

View File

@@ -61,3 +61,8 @@
color: var(--text-muted); color: var(--text-muted);
font-family: var(--font-mono); font-family: var(--font-mono);
} }
.queryText {
font-size: 0.75rem;
font-family: var(--font-mono);
}

View File

@@ -1,15 +1,19 @@
import { StatCard, DataTable, ProgressBar } from '@cameleer/design-system'; import { StatCard, DataTable, ProgressBar } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system';
import { useClickHouseStatus, useClickHouseTables, useClickHousePerformance, useIndexerPipeline } from '../../api/queries/admin/clickhouse'; import { useClickHouseStatus, useClickHouseTables, useClickHousePerformance, useClickHouseQueries, useIndexerPipeline } from '../../api/queries/admin/clickhouse';
import styles from './ClickHouseAdminPage.module.css'; import styles from './ClickHouseAdminPage.module.css';
export default function ClickHouseAdminPage() { export default function ClickHouseAdminPage() {
const { data: status, isError: statusError } = useClickHouseStatus(); const { data: status, isError: statusError } = useClickHouseStatus();
const { data: tables } = useClickHouseTables(); const { data: tables } = useClickHouseTables();
const { data: perf } = useClickHousePerformance(); const { data: perf } = useClickHousePerformance();
const { data: queries } = useClickHouseQueries();
const { data: pipeline } = useIndexerPipeline(); const { data: pipeline } = useIndexerPipeline();
const unreachable = statusError || (status && !status.reachable); const unreachable = statusError || (status && !status.reachable);
const totalSize = (tables || []).reduce((sum, t) => sum + (t.dataSizeBytes || 0), 0);
const totalSizeLabel = totalSize > 0 ? formatBytes(totalSize) : '';
const tableColumns: Column<any>[] = [ const tableColumns: Column<any>[] = [
{ key: 'name', header: 'Table', sortable: true }, { key: 'name', header: 'Table', sortable: true },
{ key: 'engine', header: 'Engine' }, { key: 'engine', header: 'Engine' },
@@ -18,14 +22,35 @@ export default function ClickHouseAdminPage() {
{ key: 'partitionCount', header: 'Partitions', sortable: true }, { key: 'partitionCount', header: 'Partitions', sortable: true },
]; ];
const queryColumns: Column<any>[] = [
{ key: 'elapsedSeconds', header: 'Elapsed', render: (v) => `${v}s` },
{ key: 'memory', header: 'Memory' },
{ key: 'readRows', header: 'Rows Read', render: (v) => Number(v).toLocaleString() },
{ key: 'query', header: 'Query', render: (v) => <span className={styles.queryText}>{String(v).slice(0, 100)}</span> },
];
return ( return (
<div> <div>
{/* Status */}
<div className={styles.statStrip}> <div className={styles.statStrip}>
<StatCard label="Status" value={unreachable ? 'Disconnected' : status ? 'Connected' : '\u2014'} accent={unreachable ? 'error' : status ? 'success' : undefined} /> <StatCard label="Status" value={unreachable ? 'Disconnected' : status ? 'Connected' : '\u2014'} accent={unreachable ? 'error' : status ? 'success' : undefined} />
<StatCard label="Version" value={status?.version ?? '\u2014'} /> <StatCard label="Version" value={status?.version ?? '\u2014'} />
<StatCard label="Uptime" value={status?.uptime ?? '\u2014'} /> <StatCard label="Uptime" value={status?.uptime ?? '\u2014'} />
</div> </div>
{/* Storage overview */}
{perf && (
<div className={styles.statStrip}>
<StatCard label="Disk Size" value={perf.diskSize} />
<StatCard label="Uncompressed" value={perf.uncompressedSize} />
<StatCard label="Compression" value={`${perf.compressionRatio}x`} />
<StatCard label="Total Rows" value={perf.totalRows.toLocaleString()} />
<StatCard label="Parts" value={perf.partCount.toLocaleString()} />
<StatCard label="Memory" value={perf.memoryUsage} />
</div>
)}
{/* Pipeline */}
{pipeline && ( {pipeline && (
<div className={styles.pipelineCard}> <div className={styles.pipelineCard}>
<div className={styles.pipelineTitle}>Indexer Pipeline</div> <div className={styles.pipelineTitle}>Indexer Pipeline</div>
@@ -39,18 +64,11 @@ export default function ClickHouseAdminPage() {
</div> </div>
)} )}
{perf && ( {/* Tables */}
<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.tableSection}>
<div className={styles.tableHeader}> <div className={styles.tableHeader}>
<span className={styles.tableTitle}>Tables ({(tables || []).length})</span> <span className={styles.tableTitle}>Tables ({(tables || []).length})</span>
{totalSizeLabel && <span className={styles.tableMeta}>{totalSizeLabel} total</span>}
</div> </div>
<DataTable <DataTable
columns={tableColumns} columns={tableColumns}
@@ -60,6 +78,26 @@ export default function ClickHouseAdminPage() {
flush flush
/> />
</div> </div>
{/* Active Queries */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Active Queries ({(queries || []).length})</span>
</div>
<DataTable
columns={queryColumns}
data={(queries || []).map((q: any, i: number) => ({ ...q, id: q.queryId || String(i) }))}
flush
/>
</div>
</div> </div>
); );
} }
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const val = bytes / Math.pow(1024, i);
return `${val.toFixed(val < 10 ? 2 : 1)} ${units[i]}`;
}

View File

@@ -189,7 +189,7 @@ export default function AgentHealth() {
// Map events to FeedEvent // Map events to FeedEvent
const feedEvents: FeedEvent[] = useMemo(() => { const feedEvents: FeedEvent[] = useMemo(() => {
const mapped = (events ?? []).map((e: { id: number; agentId: string; eventType: string; detail: string; timestamp: string }) => ({ const mapped = (events ?? []).map((e: { id: number; instanceId: string; eventType: string; detail: string; timestamp: string }) => ({
id: String(e.id), id: String(e.id),
severity: severity:
e.eventType === 'WENT_DEAD' e.eventType === 'WENT_DEAD'