From b6f33f73e64c7fb481c0c9b6de74ad17d742b5eb Mon Sep 17 00:00:00 2001 From: Daniel Paulus Date: Sun, 5 Jul 2026 13:37:12 +0200 Subject: [PATCH] feat(cli): add TRACEROUTE Hops column to checks stats [SIM-278] Adds a bespoke `Hops` column for TRACEROUTE checks in `checkly checks stats`, mirroring the ICMP Latency/Pkt Loss bespoke-column pattern. TRACEROUTE keeps its final-hop-latency Resp columns (stays in TIMING_TYPES) and additionally shows hop count from the new hopCount_avg field. Follow-up to #1362 (stacked on feat/cli-pertype-result-rendering). hopCount_avg is populated by the SIM-278 backend change; until it deploys the column renders a dash. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/commands/checks/stats.ts | 1 + .../formatters/__tests__/batch-stats.spec.ts | 88 +++++++++++++++++++ packages/cli/src/formatters/batch-stats.ts | 20 +++++ packages/cli/src/rest/batch-analytics.ts | 1 + 4 files changed, 110 insertions(+) create mode 100644 packages/cli/src/formatters/__tests__/batch-stats.spec.ts diff --git a/packages/cli/src/commands/checks/stats.ts b/packages/cli/src/commands/checks/stats.ts index 2d0c6e2a0..2500d997c 100644 --- a/packages/cli/src/commands/checks/stats.ts +++ b/packages/cli/src/commands/checks/stats.ts @@ -144,6 +144,7 @@ export default class ChecksStats extends AuthCommand { responseTime_p95: r.analytics?.responseTime_p95 ?? null, latency_avg: r.analytics?.latency_avg ?? null, packetLoss_avg: r.analytics?.packetLoss_avg ?? null, + hopCount_avg: r.analytics?.hopCount_avg ?? null, })), pagination: { page, limit, total: totalChecks, totalPages }, range, diff --git a/packages/cli/src/formatters/__tests__/batch-stats.spec.ts b/packages/cli/src/formatters/__tests__/batch-stats.spec.ts new file mode 100644 index 000000000..218ab321d --- /dev/null +++ b/packages/cli/src/formatters/__tests__/batch-stats.spec.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest' +import { formatBatchStats, type StatsRow } from '../batch-stats.js' + +// Build a minimal StatsRow. Only the fields read by the batch-stats formatter +// (name, checkType, activated, status, analytics) matter here. +function row ( + checkType: string, + analytics: Partial = {}, + name = `${checkType} check`, +): StatsRow { + return { + name, + checkType, + activated: true, + status: { hasFailures: false, hasErrors: false, isDegraded: false }, + analytics: { availability: 99.9, ...analytics }, + } as unknown as StatsRow +} + +describe('formatBatchStats — TRACEROUTE columns', () => { + it('renders a Hops column with the average hop count for TRACEROUTE rows', () => { + const out = formatBatchStats([row('TRACEROUTE', { hopCount_avg: 12.5 })], 'last24Hours', 'md') + + expect(out).toContain('Hops (avg)') + expect(out).toContain('12.50') + }) + + it('keeps the final-hop-latency responseTime columns for TRACEROUTE (no regression)', () => { + // responseTime_avg for TRACEROUTE is the final-hop latency (ms). + const out = formatBatchStats( + [row('TRACEROUTE', { hopCount_avg: 9, responseTime_avg: 42000 })], + 'last24Hours', + 'md', + ) + + expect(out).toContain('Resp (avg)') + expect(out).toContain('42.000s') + expect(out).toContain('Hops (avg)') + }) + + it('shows a dash in the Hops column for non-TRACEROUTE rows in a mixed batch', () => { + const out = formatBatchStats( + [ + row('API', { responseTime_avg: 1000 }), + row('TRACEROUTE', { hopCount_avg: 15 }), + ], + 'last24Hours', + 'md', + ) + + expect(out).toContain('Hops (avg)') + // The API row has no hop count; the Hops cell falls back to the dash. + expect(out).toContain('—') + expect(out).toContain('15.00') + }) + + it('omits the Hops column entirely when no TRACEROUTE checks are present', () => { + const out = formatBatchStats([row('API', { responseTime_avg: 1000 })], 'last24Hours', 'md') + + expect(out).not.toContain('Hops') + }) + + it('renders a dash when a TRACEROUTE row has no hop-count data yet', () => { + // Until the backend batch-analytics endpoint returns hopCount_avg, the column + // renders a dash rather than a bogus value. + const out = formatBatchStats([row('TRACEROUTE', { hopCount_avg: null })], 'last24Hours', 'md') + + expect(out).toContain('Hops (avg)') + expect(out).toContain('—') + }) + + it('renders the Hops column in terminal format (count for traceroute, dash otherwise)', () => { + const out = formatBatchStats( + [ + row('API', { responseTime_avg: 1000 }), + row('TRACEROUTE', { hopCount_avg: 15 }), + ], + 'last24Hours', + 'terminal', + ) + + // Terminal renders headers upper-cased ("HOPS"); the value and the + // non-traceroute dash both survive chalk styling. + expect(out).toContain('HOPS') + expect(out).toContain('15.00') + expect(out).toContain('—') + }) +}) diff --git a/packages/cli/src/formatters/batch-stats.ts b/packages/cli/src/formatters/batch-stats.ts index 2a64053bd..29de38b1f 100644 --- a/packages/cli/src/formatters/batch-stats.ts +++ b/packages/cli/src/formatters/batch-stats.ts @@ -8,8 +8,11 @@ import type { QuickRange } from '../rest/analytics.js' export type StatsRow = CheckWithStatus & { analytics?: BatchAnalyticsResult } +// TRACEROUTE stays in TIMING_TYPES so it keeps its final-hop-latency responseTime +// columns; it additionally gets a bespoke Hops column (see hasTraceroute below). const TIMING_TYPES = new Set(['API', 'BROWSER', 'PLAYWRIGHT', 'MULTI_STEP', 'URL', 'TCP', 'DNS', 'GRPC', 'SSL', 'TRACEROUTE']) const ICMP_TYPE = 'ICMP' +const TRACEROUTE_TYPE = 'TRACEROUTE' const DASH = '—' /** @@ -63,6 +66,7 @@ function metricOrDash ( function buildColumns (rows: StatsRow[], format: OutputFormat): ColumnDef[] { const hasTiming = rows.some(r => TIMING_TYPES.has(r.checkType)) const hasIcmp = rows.some(r => r.checkType === ICMP_TYPE) + const hasTraceroute = rows.some(r => r.checkType === TRACEROUTE_TYPE) if (format === 'md') { const cols: ColumnDef[] = [ @@ -99,6 +103,13 @@ function buildColumns (rows: StatsRow[], format: OutputFormat): ColumnDef metricOrDash('packetLoss_avg', r.analytics?.packetLoss_avg ?? null, fmt, r.checkType === ICMP_TYPE), }) } + if (hasTraceroute) { + cols.push({ + header: 'Hops (avg)', + align: 'right', + value: (r, fmt) => metricOrDash('hopCount_avg', r.analytics?.hopCount_avg ?? null, fmt, r.checkType === TRACEROUTE_TYPE), + }) + } return cols } @@ -162,6 +173,15 @@ function buildColumns (rows: StatsRow[], format: OutputFormat): ColumnDef metricOrDash('hopCount_avg', r.analytics?.hopCount_avg ?? null, fmt, r.checkType === TRACEROUTE_TYPE), + }) + } + return cols } diff --git a/packages/cli/src/rest/batch-analytics.ts b/packages/cli/src/rest/batch-analytics.ts index 1dd4f51e7..d2fc74ca3 100644 --- a/packages/cli/src/rest/batch-analytics.ts +++ b/packages/cli/src/rest/batch-analytics.ts @@ -15,6 +15,7 @@ export interface BatchAnalyticsResult { packetLoss_avg: number | null packetLoss_p95: number | null packetLoss_p99: number | null + hopCount_avg: number | null } // Subset of QuickRange — excludes last30Days and thisMonth because their rolling windows