Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/src/commands/checks/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
88 changes: 88 additions & 0 deletions packages/cli/src/formatters/__tests__/batch-stats.spec.ts
Original file line number Diff line number Diff line change
@@ -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<StatsRow['analytics']> = {},
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('—')
})
})
20 changes: 20 additions & 0 deletions packages/cli/src/formatters/batch-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '—'

/**
Expand Down Expand Up @@ -63,6 +66,7 @@ function metricOrDash (
function buildColumns (rows: StatsRow[], format: OutputFormat): ColumnDef<StatsRow>[] {
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<StatsRow>[] = [
Expand Down Expand Up @@ -99,6 +103,13 @@ function buildColumns (rows: StatsRow[], format: OutputFormat): ColumnDef<StatsR
value: (r, fmt) => 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
}

Expand Down Expand Up @@ -162,6 +173,15 @@ function buildColumns (rows: StatsRow[], format: OutputFormat): ColumnDef<StatsR
})
}

if (hasTraceroute) {
cols.push({
header: 'Hops',
width: metricWidth,
align: 'right',
value: (r, fmt) => metricOrDash('hopCount_avg', r.analytics?.hopCount_avg ?? null, fmt, r.checkType === TRACEROUTE_TYPE),
})
}

return cols
}

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/rest/batch-analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down