fix: improve ClickHouse admin page, fix AgentHealth type error
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:
@@ -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,
|
||||||
|
round(sum(bytes_on_disk) / sum(data_uncompressed_bytes), 3), 0) AS compression_ratio,
|
||||||
|
sum(rows) AS total_rows,
|
||||||
|
count() AS part_count
|
||||||
|
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'",
|
"SELECT formatReadableSize(value) FROM system.metrics WHERE metric = 'MemoryTracking'",
|
||||||
String.class);
|
String.class);
|
||||||
return new ClickHousePerformanceResponse(selectQueries, insertQueries,
|
} catch (Exception ignored) {}
|
||||||
memoryUsage != null ? memoryUsage : "0 B", insertedRows, readRows);
|
|
||||||
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user