Add comparison stats: failure rate %, vs-yesterday change, today total
Stats endpoint now returns current + previous period (24h shift) values plus today's total count. UI shows: - Total Matches: "of 12.3K today" - Avg Duration: arrow + % vs yesterday - Failure Rate: percentage of errors vs total, arrow + % vs yesterday - P99 Latency: arrow + % vs yesterday - In-Flight: unchanged (running executions) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -89,18 +89,48 @@ public class ClickHouseSearchEngine implements SearchEngine {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ExecutionStats stats(Instant from, Instant to) {
|
public ExecutionStats stats(Instant from, Instant to) {
|
||||||
return jdbcTemplate.queryForObject(
|
String aggregateSql = "SELECT count() AS total_count, " +
|
||||||
"SELECT countIf(status = 'FAILED') AS failed_count, " +
|
"countIf(status = 'FAILED') AS failed_count, " +
|
||||||
"toInt64(avg(duration_ms)) AS avg_duration_ms, " +
|
"toInt64(avg(duration_ms)) AS avg_duration_ms, " +
|
||||||
"toInt64(quantile(0.99)(duration_ms)) AS p99_duration_ms, " +
|
"toInt64(quantile(0.99)(duration_ms)) AS p99_duration_ms, " +
|
||||||
"countIf(status = 'RUNNING') AS active_count " +
|
"countIf(status = 'RUNNING') AS active_count " +
|
||||||
"FROM route_executions WHERE start_time >= ? AND start_time <= ?",
|
"FROM route_executions WHERE start_time >= ? AND start_time <= ?";
|
||||||
(rs, rowNum) -> new ExecutionStats(
|
|
||||||
|
// Current period
|
||||||
|
record PeriodStats(long totalCount, long failedCount, long avgDurationMs, long p99LatencyMs, long activeCount) {}
|
||||||
|
PeriodStats current = jdbcTemplate.queryForObject(aggregateSql,
|
||||||
|
(rs, rowNum) -> new PeriodStats(
|
||||||
|
rs.getLong("total_count"),
|
||||||
rs.getLong("failed_count"),
|
rs.getLong("failed_count"),
|
||||||
rs.getLong("avg_duration_ms"),
|
rs.getLong("avg_duration_ms"),
|
||||||
rs.getLong("p99_duration_ms"),
|
rs.getLong("p99_duration_ms"),
|
||||||
rs.getLong("active_count")),
|
rs.getLong("active_count")),
|
||||||
Timestamp.from(from), Timestamp.from(to));
|
Timestamp.from(from), Timestamp.from(to));
|
||||||
|
|
||||||
|
// Previous period (same window shifted back 24h)
|
||||||
|
Duration window = Duration.between(from, to);
|
||||||
|
Instant prevFrom = from.minus(Duration.ofHours(24));
|
||||||
|
Instant prevTo = prevFrom.plus(window);
|
||||||
|
PeriodStats prev = jdbcTemplate.queryForObject(aggregateSql,
|
||||||
|
(rs, rowNum) -> new PeriodStats(
|
||||||
|
rs.getLong("total_count"),
|
||||||
|
rs.getLong("failed_count"),
|
||||||
|
rs.getLong("avg_duration_ms"),
|
||||||
|
rs.getLong("p99_duration_ms"),
|
||||||
|
rs.getLong("active_count")),
|
||||||
|
Timestamp.from(prevFrom), Timestamp.from(prevTo));
|
||||||
|
|
||||||
|
// Today total (midnight UTC to now)
|
||||||
|
Instant todayStart = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.DAYS);
|
||||||
|
Long totalToday = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT count() FROM route_executions WHERE start_time >= ?",
|
||||||
|
Long.class, Timestamp.from(todayStart));
|
||||||
|
|
||||||
|
return new ExecutionStats(
|
||||||
|
current.totalCount, current.failedCount, current.avgDurationMs,
|
||||||
|
current.p99LatencyMs, current.activeCount,
|
||||||
|
totalToday != null ? totalToday : 0L,
|
||||||
|
prev.totalCount, prev.failedCount, prev.avgDurationMs, prev.p99LatencyMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
package com.cameleer3.server.core.search;
|
package com.cameleer3.server.core.search;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregate execution statistics within a time window.
|
* Aggregate execution statistics within a time window, with comparison to the
|
||||||
*
|
* equivalent previous period (shifted back 24 h) and a "today" total.
|
||||||
* @param failedCount number of failed executions
|
|
||||||
* @param avgDurationMs average duration in milliseconds
|
|
||||||
* @param p99LatencyMs 99th percentile duration in milliseconds
|
|
||||||
* @param activeCount number of currently running executions
|
|
||||||
*/
|
*/
|
||||||
public record ExecutionStats(long failedCount, long avgDurationMs, long p99LatencyMs, long activeCount) {}
|
public record ExecutionStats(
|
||||||
|
long totalCount,
|
||||||
|
long failedCount,
|
||||||
|
long avgDurationMs,
|
||||||
|
long p99LatencyMs,
|
||||||
|
long activeCount,
|
||||||
|
long totalToday,
|
||||||
|
long prevTotalCount,
|
||||||
|
long prevFailedCount,
|
||||||
|
long prevAvgDurationMs,
|
||||||
|
long prevP99LatencyMs) {}
|
||||||
|
|||||||
@@ -1049,6 +1049,10 @@
|
|||||||
"ExecutionStats": {
|
"ExecutionStats": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"totalCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
"failedCount": {
|
"failedCount": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int64"
|
"format": "int64"
|
||||||
@@ -1064,6 +1068,26 @@
|
|||||||
"activeCount": {
|
"activeCount": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int64"
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"totalToday": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"prevTotalCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"prevFailedCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"prevAvgDurationMs": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"prevP99LatencyMs": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
6
ui/src/api/schema.d.ts
vendored
6
ui/src/api/schema.d.ts
vendored
@@ -217,10 +217,16 @@ export interface ProcessorNode {
|
|||||||
export type ProcessorSnapshot = Record<string, string>;
|
export type ProcessorSnapshot = Record<string, string>;
|
||||||
|
|
||||||
export interface ExecutionStats {
|
export interface ExecutionStats {
|
||||||
|
totalCount: number;
|
||||||
failedCount: number;
|
failedCount: number;
|
||||||
avgDurationMs: number;
|
avgDurationMs: number;
|
||||||
p99LatencyMs: number;
|
p99LatencyMs: number;
|
||||||
activeCount: number;
|
activeCount: number;
|
||||||
|
totalToday: number;
|
||||||
|
prevTotalCount: number;
|
||||||
|
prevFailedCount: number;
|
||||||
|
prevAvgDurationMs: number;
|
||||||
|
prevP99LatencyMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatsTimeseries {
|
export interface StatsTimeseries {
|
||||||
|
|||||||
@@ -6,6 +6,20 @@ import { SearchFilters } from './SearchFilters';
|
|||||||
import { ResultsTable } from './ResultsTable';
|
import { ResultsTable } from './ResultsTable';
|
||||||
import styles from './ExecutionExplorer.module.css';
|
import styles from './ExecutionExplorer.module.css';
|
||||||
|
|
||||||
|
function formatCompact(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||||
|
return n.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pctChange(current: number, previous: number): { text: string; direction: 'up' | 'down' | 'neutral' } {
|
||||||
|
if (previous === 0) return { text: 'no prior data', direction: 'neutral' };
|
||||||
|
const pct = ((current - previous) / previous) * 100;
|
||||||
|
if (Math.abs(pct) < 0.5) return { text: '~0% vs yesterday', direction: 'neutral' };
|
||||||
|
const arrow = pct > 0 ? '\u2191' : '\u2193';
|
||||||
|
return { text: `${arrow} ${Math.abs(pct).toFixed(1)}% vs yesterday`, direction: pct > 0 ? 'up' : 'down' };
|
||||||
|
}
|
||||||
|
|
||||||
export function ExecutionExplorer() {
|
export function ExecutionExplorer() {
|
||||||
const { toSearchRequest, offset, limit, setOffset, live, toggleLive } = useExecutionSearch();
|
const { toSearchRequest, offset, limit, setOffset, live, toggleLive } = useExecutionSearch();
|
||||||
const searchRequest = toSearchRequest();
|
const searchRequest = toSearchRequest();
|
||||||
@@ -24,6 +38,17 @@ export function ExecutionExplorer() {
|
|||||||
const total = data?.total ?? 0;
|
const total = data?.total ?? 0;
|
||||||
const results = data?.data ?? [];
|
const results = data?.data ?? [];
|
||||||
|
|
||||||
|
// Failure rate as percentage
|
||||||
|
const failureRate = stats && stats.totalCount > 0
|
||||||
|
? (stats.failedCount / stats.totalCount) * 100 : 0;
|
||||||
|
const prevFailureRate = stats && stats.prevTotalCount > 0
|
||||||
|
? (stats.prevFailedCount / stats.prevTotalCount) * 100 : 0;
|
||||||
|
|
||||||
|
// Comparison vs yesterday
|
||||||
|
const avgChange = stats ? pctChange(stats.avgDurationMs, stats.prevAvgDurationMs) : null;
|
||||||
|
const failRateChange = stats ? pctChange(failureRate, prevFailureRate) : null;
|
||||||
|
const p99Change = stats ? pctChange(stats.p99LatencyMs, stats.prevP99LatencyMs) : null;
|
||||||
|
|
||||||
const showFrom = total > 0 ? offset + 1 : 0;
|
const showFrom = total > 0 ? offset + 1 : 0;
|
||||||
const showTo = Math.min(offset + limit, total);
|
const showTo = Math.min(offset + limit, total);
|
||||||
|
|
||||||
@@ -43,10 +68,10 @@ export function ExecutionExplorer() {
|
|||||||
|
|
||||||
{/* Stats Bar */}
|
{/* Stats Bar */}
|
||||||
<div className={styles.statsBar}>
|
<div className={styles.statsBar}>
|
||||||
<StatCard label="Total Matches" value={total.toLocaleString()} accent="amber" change={`from current search`} sparkData={sparkTotal} />
|
<StatCard label="Total Matches" value={total.toLocaleString()} accent="amber" change={stats ? `of ${formatCompact(stats.totalToday)} today` : 'from current search'} sparkData={sparkTotal} />
|
||||||
<StatCard label="Avg Duration" value={stats ? `${stats.avgDurationMs.toLocaleString()}ms` : '--'} accent="cyan" change="mean processing time" sparkData={sparkAvgDuration} />
|
<StatCard label="Avg Duration" value={stats ? `${stats.avgDurationMs.toLocaleString()}ms` : '--'} accent="cyan" change={avgChange?.text} changeDirection={avgChange?.direction} sparkData={sparkAvgDuration} />
|
||||||
<StatCard label="Failed" value={stats ? stats.failedCount.toLocaleString() : '--'} accent="rose" change="errored executions" sparkData={sparkFailed} />
|
<StatCard label="Failure Rate" value={stats ? `${failureRate.toFixed(1)}%` : '--'} accent="rose" change={failRateChange?.text} changeDirection={failRateChange?.direction} sparkData={sparkFailed} />
|
||||||
<StatCard label="P99 Latency" value={stats ? `${stats.p99LatencyMs.toLocaleString()}ms` : '--'} accent="green" change="99th percentile" sparkData={sparkP99} />
|
<StatCard label="P99 Latency" value={stats ? `${stats.p99LatencyMs.toLocaleString()}ms` : '--'} accent="green" change={p99Change?.text} changeDirection={p99Change?.direction} sparkData={sparkP99} />
|
||||||
<StatCard label="In-Flight" value={stats ? stats.activeCount.toLocaleString() : '--'} accent="blue" change="running executions" sparkData={sparkActive} />
|
<StatCard label="In-Flight" value={stats ? stats.activeCount.toLocaleString() : '--'} accent="blue" change="running executions" sparkData={sparkActive} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user