@crvouga/atlas 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -1
- package/dist/reports/src/evidence-detail.js +6 -1
- package/dist/reports/src/evidence-types.d.ts +2 -0
- package/dist/reports/src/explorer-types.d.ts +19 -0
- package/dist/reports/src/latency.d.ts +204 -0
- package/dist/reports/src/latency.js +219 -0
- package/dist/reports/src/reliability.d.ts +181 -0
- package/dist/reports/src/reliability.js +294 -0
- package/dist/reports/src/report-index.js +1 -1
- package/dist/reports/src/reports-explorer-route.d.ts +19 -6
- package/dist/reports/src/reports-route.d.ts +19 -6
- package/dist/reports/src/test-catalog.d.ts +11 -3
- package/dist/reports/src/test-catalog.js +12 -5
- package/dist/schema/src/api.d.ts +377 -0
- package/dist/schema/src/api.js +55 -3
- package/dist/schema/src/layer.d.ts +90 -0
- package/dist/schema/src/layer.js +20 -1
- package/dist/server/src/app.js +15 -0
- package/dist/server/src/llms.js +1 -0
- package/dist/server/src/openapi.js +25 -2
- package/docs/extending.md +1 -1
- package/docs/reference/http-api.md +48 -0
- package/docs/reference/reports.md +619 -1
- package/docs/reference/schema.md +39 -0
- package/docs/rich-evidence.md +43 -0
- package/docs/server.md +18 -0
- package/docs/ui.md +20 -1
- package/llms-full.txt +130 -2
- package/package.json +1 -1
- package/packages/schema/schemas/check-detail.schema.json +56 -1
- package/packages/schema/schemas/effective-delta.schema.json +86 -2
- package/packages/schema/schemas/effective-evidence.schema.json +86 -2
- package/packages/schema/schemas/evidence-page.schema.json +13 -0
- package/packages/schema/schemas/layer-rows.schema.json +73 -2
- package/packages/schema/schemas/layer.schema.json +112 -2
- package/packages/schema/schemas/publish-checks.schema.json +56 -1
- package/packages/schema/schemas/test-catalog.schema.json +102 -0
- package/packages/schema/schemas/test-history.schema.json +204 -0
- package/packages/schema/schemas/test-reliability.schema.json +629 -0
- package/packages/server/openapi.json +2354 -661
- package/packages/visualizer/src/evidence/check-detail.tsx +9 -0
- package/packages/visualizer/src/explorer/ProductLatency.tsx +214 -0
- package/packages/visualizer/src/explorer/Reliability.tsx +337 -0
- package/packages/visualizer/src/explorer/TestDetail.tsx +12 -2
- package/packages/visualizer/src/explorer/catalog.tsx +1 -1
- package/packages/visualizer/src/explorer/search.ts +36 -0
|
@@ -2,6 +2,7 @@ import { recordingPaths } from '@crvouga/atlas/reports/evidence-types';
|
|
|
2
2
|
import { checkHistory, type CheckSlot, type HistoryEntry } from '@crvouga/atlas/reports/evidence-view';
|
|
3
3
|
import { flattenFrames } from '@crvouga/atlas/reports/frames';
|
|
4
4
|
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
5
|
+
import { boundaryOf, isProductLatency } from '@crvouga/atlas/reports/latency';
|
|
5
6
|
import type { EvidenceResult } from '@crvouga/atlas/reports/layer-merge';
|
|
6
7
|
import type { ReportStep, RunReport } from '@crvouga/atlas/reports/report-types';
|
|
7
8
|
import { Accordion, Alert, Anchor, Box, Button, Code, Flex, Group, List, Stack, Table, Text, Title } from '@mantine/core';
|
|
@@ -435,6 +436,14 @@ function RecordedCheckDetail({ slot, layerId, select, reads, written, pinned, pi
|
|
|
435
436
|
against an allowance of {measurement.limit} {measurement.unit}
|
|
436
437
|
</Text>
|
|
437
438
|
)}
|
|
439
|
+
{isProductLatency(measurement) && (
|
|
440
|
+
<Text span inherit c="dimmed">
|
|
441
|
+
{' '}
|
|
442
|
+
· product latency, {boundaryOf(measurement).label}
|
|
443
|
+
{boundaryOf(measurement).state === 'partial' && ` (partial: not ${boundaryOf(measurement).intended})`}
|
|
444
|
+
{measurement.outcome !== undefined && measurement.outcome !== 'observed' && ` · ${measurement.outcome}: waited at least this long`}
|
|
445
|
+
</Text>
|
|
446
|
+
)}
|
|
438
447
|
</Text>
|
|
439
448
|
);
|
|
440
449
|
})
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Product latency view
|
|
3
|
+
*
|
|
4
|
+
* How quickly the product responded to a user, apart from how long the test took. It reads only the measurements a
|
|
5
|
+
* producer named as product latency (see `@crvouga/atlas/reports/latency`) and shows, for each, its boundaries, unit,
|
|
6
|
+
* source, group (device, build or day), sample count, budget and breaches, p50 and p95 when enough observations
|
|
7
|
+
* support them, and a comparison with a chosen baseline that names every difference in instrumentation or
|
|
8
|
+
* conditions. What the evidence cannot support is withheld with the reason. A test with no named measurement says its
|
|
9
|
+
* product latency is unknown; its execution time is never shown here.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
compareLatency, formatLatency, latencyReview, LATENCY_GROUPINGS, type LatencyChange, type LatencyGrouping, type LatencyReview, type LatencySummary
|
|
13
|
+
} from '@crvouga/atlas/reports/latency';
|
|
14
|
+
import type { TestResultEntry } from '@crvouga/atlas/reports/test-catalog';
|
|
15
|
+
import { Badge, Box, Code, Flex, List, NativeSelect, Stack, Table, Text, Title } from '@mantine/core';
|
|
16
|
+
import { useMemo, useState } from 'react';
|
|
17
|
+
|
|
18
|
+
import { plural } from '../evidence/format';
|
|
19
|
+
|
|
20
|
+
const GROUPING_LABEL: Record<LatencyGrouping, string> = { none: 'Not grouped', device: 'Device', build: 'Build', window: 'Day recorded' };
|
|
21
|
+
|
|
22
|
+
const sign = (value: number) => (value > 0 ? '+' : value < 0 ? '−' : '');
|
|
23
|
+
|
|
24
|
+
/** A change as words: `+100 ms (+25%) in p50`. */
|
|
25
|
+
function changeText(change: LatencyChange, unit: string): string {
|
|
26
|
+
const delta = change.p50 ?? change.p95 ?? change.last;
|
|
27
|
+
if (!change.comparable || !delta) return 'Not compared';
|
|
28
|
+
const relative = delta.relative === null ? 'baseline was 0' : `${sign(delta.relative)}${Math.abs(Math.round(delta.relative * 1000) / 10)}%`;
|
|
29
|
+
const basis = change.basis === 'last' ? 'in the newest observation, not a percentile' : `in ${change.basis}`;
|
|
30
|
+
return `${sign(delta.absolute)}${formatLatency(Math.abs(delta.absolute), unit)} (${relative}) ${basis}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function Figure({ value, unit, summary }: { value: number | undefined; unit: string; summary: LatencySummary }) {
|
|
34
|
+
return value === undefined ? (
|
|
35
|
+
<Text span inherit c="dimmed">
|
|
36
|
+
Withheld{summary.claim ? '' : ' (no boundaries)'}
|
|
37
|
+
</Text>
|
|
38
|
+
) : (
|
|
39
|
+
<>
|
|
40
|
+
{summary.lowerBound ? '≥ ' : ''}
|
|
41
|
+
{formatLatency(value, unit)}
|
|
42
|
+
</>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function MetricTable({ summaries, baseline }: { summaries: readonly LatencySummary[]; baseline: LatencySummary | undefined }) {
|
|
47
|
+
const unit = summaries[0]!.metric.unit;
|
|
48
|
+
const changes = new Map(summaries.map((summary) => [summary.group, baseline && summary !== baseline ? compareLatency(baseline, summary) : undefined]));
|
|
49
|
+
return (
|
|
50
|
+
<Table.ScrollContainer minWidth={560}>
|
|
51
|
+
<Table>
|
|
52
|
+
<Table.Thead>
|
|
53
|
+
<Table.Tr>
|
|
54
|
+
<Table.Th scope="col">Group</Table.Th>
|
|
55
|
+
<Table.Th scope="col">Observations</Table.Th>
|
|
56
|
+
<Table.Th scope="col">p50</Table.Th>
|
|
57
|
+
<Table.Th scope="col">p95</Table.Th>
|
|
58
|
+
<Table.Th scope="col">Budget</Table.Th>
|
|
59
|
+
<Table.Th scope="col">Over budget</Table.Th>
|
|
60
|
+
{baseline && <Table.Th scope="col">Against baseline</Table.Th>}
|
|
61
|
+
</Table.Tr>
|
|
62
|
+
</Table.Thead>
|
|
63
|
+
<Table.Tbody>
|
|
64
|
+
{summaries.map((summary) => {
|
|
65
|
+
const change = changes.get(summary.group);
|
|
66
|
+
return (
|
|
67
|
+
<Table.Tr key={summary.group}>
|
|
68
|
+
<Table.Th scope="row">{summary.group}</Table.Th>
|
|
69
|
+
<Table.Td>
|
|
70
|
+
{summary.samples}
|
|
71
|
+
{summary.timeouts + summary.failures > 0 && (
|
|
72
|
+
<Text span inherit c="dimmed">
|
|
73
|
+
{' '}
|
|
74
|
+
({summary.timeouts + summary.failures} did not complete)
|
|
75
|
+
</Text>
|
|
76
|
+
)}
|
|
77
|
+
</Table.Td>
|
|
78
|
+
<Table.Td>
|
|
79
|
+
<Figure value={summary.p50} unit={unit} summary={summary} />
|
|
80
|
+
</Table.Td>
|
|
81
|
+
<Table.Td>
|
|
82
|
+
<Figure value={summary.p95} unit={unit} summary={summary} />
|
|
83
|
+
</Table.Td>
|
|
84
|
+
<Table.Td>{summary.budget === undefined ? <Text span inherit c="dimmed">None set</Text> : formatLatency(summary.budget, unit)}</Table.Td>
|
|
85
|
+
<Table.Td>
|
|
86
|
+
{summary.budget === undefined ? (
|
|
87
|
+
'—'
|
|
88
|
+
) : summary.breaches > 0 ? (
|
|
89
|
+
<Badge color="red" variant="light">
|
|
90
|
+
{summary.breaches} of {summary.samples} over
|
|
91
|
+
</Badge>
|
|
92
|
+
) : (
|
|
93
|
+
`0 of ${summary.samples}`
|
|
94
|
+
)}
|
|
95
|
+
</Table.Td>
|
|
96
|
+
{baseline && <Table.Td>{summary === baseline ? 'Baseline' : change ? changeText(change, unit) : '—'}</Table.Td>}
|
|
97
|
+
</Table.Tr>
|
|
98
|
+
);
|
|
99
|
+
})}
|
|
100
|
+
</Table.Tbody>
|
|
101
|
+
</Table>
|
|
102
|
+
</Table.ScrollContainer>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** What a reader must not assume, per group and per comparison, as one list. */
|
|
107
|
+
function Notes({ summaries, baseline }: { summaries: readonly LatencySummary[]; baseline: LatencySummary | undefined }) {
|
|
108
|
+
const lines: string[] = [];
|
|
109
|
+
for (const summary of summaries) {
|
|
110
|
+
const name = summaries.length > 1 ? `${summary.group}: ` : '';
|
|
111
|
+
for (const line of [...summary.gaps, ...summary.withheld]) lines.push(`${name}${line}`);
|
|
112
|
+
if (baseline && summary !== baseline) {
|
|
113
|
+
const change = compareLatency(baseline, summary);
|
|
114
|
+
for (const reason of change.reasons) lines.push(`${summary.group} against ${baseline.group}: ${reason}`);
|
|
115
|
+
for (const disclosure of change.disclosures) lines.push(`${summary.group} against ${baseline.group}: ${disclosure}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!lines.length) return null;
|
|
119
|
+
return (
|
|
120
|
+
<List aria-label="What these figures do not show" size="sm" c="dimmed">
|
|
121
|
+
{lines.map((line, index) => (
|
|
122
|
+
<List.Item key={index}>{line}</List.Item>
|
|
123
|
+
))}
|
|
124
|
+
</List>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function Metric({ metric, groups, baselineGroup }: { metric: LatencyReview['metrics'][number]['metric']; groups: readonly LatencySummary[]; baselineGroup: string | undefined }) {
|
|
129
|
+
const baseline = groups.length > 1 ? (groups.find((summary) => summary.group === baselineGroup) ?? groups[1]) : undefined;
|
|
130
|
+
const first = groups[0]!;
|
|
131
|
+
const source = [...new Set(groups.map((summary) => summary.metric.boundary.label))];
|
|
132
|
+
return (
|
|
133
|
+
<Stack gap="xs" aria-label={metric.name} role="group">
|
|
134
|
+
<Flex gap="xs" align="center" wrap="wrap">
|
|
135
|
+
<Text span inherit fw={600}>
|
|
136
|
+
{metric.name}
|
|
137
|
+
</Text>
|
|
138
|
+
<Text span inherit c="dimmed">
|
|
139
|
+
{source.join(', ')} · {metric.unit}
|
|
140
|
+
</Text>
|
|
141
|
+
{metric.boundary.state === 'partial' && <Badge color="orange" variant="light">Partial interval</Badge>}
|
|
142
|
+
{metric.boundary.state === 'undeclared' && <Badge color="gray" variant="light">Boundaries not declared</Badge>}
|
|
143
|
+
{first.lowerBound && <Badge color="orange" variant="light">Includes timeouts or failures</Badge>}
|
|
144
|
+
</Flex>
|
|
145
|
+
<MetricTable summaries={groups} baseline={baseline} />
|
|
146
|
+
<Notes summaries={groups} baseline={baseline} />
|
|
147
|
+
</Stack>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The product latency of one test, from its recorded results (newest first, as the history lists them). Grouping and
|
|
153
|
+
* the baseline are the viewer's own choices and are not saved. With nothing recorded it says product latency is
|
|
154
|
+
* unknown and how a producer records it.
|
|
155
|
+
*/
|
|
156
|
+
export function ProductLatency({ entries }: { entries: readonly TestResultEntry[] }) {
|
|
157
|
+
const [by, setBy] = useState<LatencyGrouping>('none');
|
|
158
|
+
const [baseline, setBaseline] = useState<string | undefined>(undefined);
|
|
159
|
+
const review = useMemo(() => latencyReview(entries, by), [entries, by]);
|
|
160
|
+
const groups = [...new Set(review.metrics.flatMap((item) => item.groups.map((summary) => summary.group)))];
|
|
161
|
+
return (
|
|
162
|
+
<Box aria-label="Product latency" component="section" mb="lg">
|
|
163
|
+
<Title order={3} mb="sm" c="dimmed">
|
|
164
|
+
Product latency
|
|
165
|
+
</Title>
|
|
166
|
+
<Text component="div" c="dimmed" mb="sm">
|
|
167
|
+
How quickly the product responded to a user, between two events the producer named. It is not the test's duration, which includes setup,
|
|
168
|
+
waits, retries and any demo pacing.
|
|
169
|
+
</Text>
|
|
170
|
+
{review.metrics.length === 0 ? (
|
|
171
|
+
<Text component="div" role="status">
|
|
172
|
+
No product latency was recorded for this test, so it is unknown, not fast. A producer records one as a measurement with{' '}
|
|
173
|
+
<Code>kind: "product-latency"</Code>, a <Code>from</Code> and a <Code>to</Code>, and optionally a <Code>limit</Code> as the budget.
|
|
174
|
+
</Text>
|
|
175
|
+
) : (
|
|
176
|
+
<Stack gap="md">
|
|
177
|
+
<Flex gap="md" wrap="wrap" align="flex-end">
|
|
178
|
+
<NativeSelect
|
|
179
|
+
label="Group latency by"
|
|
180
|
+
value={by}
|
|
181
|
+
onChange={(event) => {
|
|
182
|
+
setBy(event.target.value as LatencyGrouping);
|
|
183
|
+
setBaseline(undefined);
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
{LATENCY_GROUPINGS.map((option) => (
|
|
187
|
+
<option key={option} value={option}>
|
|
188
|
+
{GROUPING_LABEL[option]}
|
|
189
|
+
</option>
|
|
190
|
+
))}
|
|
191
|
+
</NativeSelect>
|
|
192
|
+
{groups.length > 1 && (
|
|
193
|
+
<NativeSelect label="Baseline to compare against" value={baseline ?? ''} onChange={(event) => setBaseline(event.target.value || undefined)}>
|
|
194
|
+
<option value="">Second newest group</option>
|
|
195
|
+
{groups.map((group) => (
|
|
196
|
+
<option key={group} value={group}>
|
|
197
|
+
{group}
|
|
198
|
+
</option>
|
|
199
|
+
))}
|
|
200
|
+
</NativeSelect>
|
|
201
|
+
)}
|
|
202
|
+
</Flex>
|
|
203
|
+
{review.metrics.map(({ metric, groups: summaries }) => (
|
|
204
|
+
<Metric key={metric.key} metric={metric} groups={summaries} baselineGroup={baseline} />
|
|
205
|
+
))}
|
|
206
|
+
<Text component="div" c="dimmed">
|
|
207
|
+
{plural(review.measured, 'recorded result carries', 'recorded results carry')} product latency
|
|
208
|
+
{review.unmeasured > 0 ? `; ${plural(review.unmeasured, 'result records', 'results record')} none, which is unknown, not fast` : ''}.
|
|
209
|
+
</Text>
|
|
210
|
+
</Stack>
|
|
211
|
+
)}
|
|
212
|
+
</Box>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Reliability of a test
|
|
3
|
+
*
|
|
4
|
+
* The open test's reliability, qualified: how many independent executions it rests on (not snapshots, not tries), for
|
|
5
|
+
* which environment, branch, spec revision and time window, how often it passed first time and in the end, and why the
|
|
6
|
+
* figure may not be what it looks like (a small sample, pooled environments, failed cases published again, bounded
|
|
7
|
+
* history). The scope is in the address and the executions behind the figure (its denominator) open in place, so a link
|
|
8
|
+
* shows the same figure and the same executions. It is worked out from the history the page already holds
|
|
9
|
+
* (`reliability` in the reports library), so it needs no further request.
|
|
10
|
+
*/
|
|
11
|
+
import { Accordion, Alert, Anchor, Badge, Box, Button, Checkbox, Code, CopyButton, Flex, List, MultiSelect, SimpleGrid, Stack, Table, Text, TextInput, Title, VisuallyHidden } from '@mantine/core';
|
|
12
|
+
import { IconLink } from '@tabler/icons-react';
|
|
13
|
+
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
14
|
+
import { reliability, type ReliabilityExecution } from '@crvouga/atlas/reports/reliability';
|
|
15
|
+
import type { TestResultEntry } from '@crvouga/atlas/reports/test-catalog';
|
|
16
|
+
import { Link } from '@tanstack/react-router';
|
|
17
|
+
import { useMemo } from 'react';
|
|
18
|
+
import { exactTime, plural, shortSha, STATUS_WORD } from '../evidence/format';
|
|
19
|
+
import { StatusMark } from '../evidence/marks';
|
|
20
|
+
import { joined, reliabilityScopeOf, values } from './search';
|
|
21
|
+
import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
|
|
22
|
+
|
|
23
|
+
/** A recorded status as a mark and a word; one this viewer does not know is shown by its own name and as unknown, never as a pass. */
|
|
24
|
+
const known = (status: string) => (Object.hasOwn(STATUS_WORD, status) && status !== 'none' ? (status as keyof typeof STATUS_WORD) : 'none');
|
|
25
|
+
const word = (status: string) => (known(status) === 'none' ? status : STATUS_WORD[known(status)]);
|
|
26
|
+
const percent = (rate: number | null) => (rate === null ? 'no rate' : `${Math.round(rate * 1000) / 10}%`);
|
|
27
|
+
const outOf = (passed: number, executions: number) => `${passed} of ${plural(executions, 'execution', 'executions')} passed`;
|
|
28
|
+
const REASONS: Record<string, string> = {
|
|
29
|
+
'not-run': 'Did not run',
|
|
30
|
+
unfinished: 'Not finished',
|
|
31
|
+
'unknown-status': 'Status not understood',
|
|
32
|
+
'historical-revision': 'Earlier spec revision',
|
|
33
|
+
'revision-not-recorded': 'Spec revision not recorded'
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function Choice({ label, param, options, chosen }: { label: string; param: 'relEnv' | 'relProducer' | 'relBranch' | 'relParam'; options: readonly { value: string; label: string }[]; chosen: string[] }) {
|
|
37
|
+
const go = useExplorerNavigate();
|
|
38
|
+
if (options.length < 2 && !chosen.length) return null;
|
|
39
|
+
return (
|
|
40
|
+
<MultiSelect
|
|
41
|
+
label={label}
|
|
42
|
+
data={options as { value: string; label: string }[]}
|
|
43
|
+
value={chosen}
|
|
44
|
+
searchable
|
|
45
|
+
clearable
|
|
46
|
+
placeholder={chosen.length ? undefined : `Any ${label.toLowerCase()}`}
|
|
47
|
+
nothingFoundMessage="Nothing found"
|
|
48
|
+
onChange={(next) => go({ [param]: joined(next) } as never, { replace: true })}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function Denominator({ executions, layer }: { executions: readonly ReliabilityExecution[]; layer: string | undefined }) {
|
|
54
|
+
return (
|
|
55
|
+
<Table.ScrollContainer minWidth={760}>
|
|
56
|
+
<Table>
|
|
57
|
+
<Table.Caption>Independent executions of this test in the scope, newest first. Snapshots of one execution are one row.</Table.Caption>
|
|
58
|
+
<Table.Thead>
|
|
59
|
+
<Table.Tr>
|
|
60
|
+
<Table.Th scope="col">Recorded</Table.Th>
|
|
61
|
+
<Table.Th scope="col">Environment</Table.Th>
|
|
62
|
+
<Table.Th scope="col">Status</Table.Th>
|
|
63
|
+
<Table.Th scope="col">Tries</Table.Th>
|
|
64
|
+
<Table.Th scope="col">Snapshots</Table.Th>
|
|
65
|
+
<Table.Th scope="col">Branch</Table.Th>
|
|
66
|
+
<Table.Th scope="col">Commit</Table.Th>
|
|
67
|
+
<Table.Th scope="col">Counted</Table.Th>
|
|
68
|
+
<Table.Th scope="col">
|
|
69
|
+
<VisuallyHidden>Evidence</VisuallyHidden>
|
|
70
|
+
</Table.Th>
|
|
71
|
+
</Table.Tr>
|
|
72
|
+
</Table.Thead>
|
|
73
|
+
<Table.Tbody>
|
|
74
|
+
{executions.map((execution) => (
|
|
75
|
+
<Table.Tr key={`${execution.layer}|${execution.kind}|${execution.producer}|${execution.environment}|${JSON.stringify(execution.parameters)}`} data-status={execution.status} data-counted={execution.counted}>
|
|
76
|
+
<Table.Td title={exactTime(execution.at)}>{exactTime(execution.at)}</Table.Td>
|
|
77
|
+
<Table.Td>
|
|
78
|
+
{environmentLabel(execution.environment)}
|
|
79
|
+
{Object.entries(execution.parameters).map(([name, value]) => (
|
|
80
|
+
<Text key={name} span inherit c="dimmed">
|
|
81
|
+
{' '}
|
|
82
|
+
{name}: {value}
|
|
83
|
+
</Text>
|
|
84
|
+
))}
|
|
85
|
+
</Table.Td>
|
|
86
|
+
<Table.Td>
|
|
87
|
+
<Flex component="span" display="inline-flex" gap="xs" align="center" wrap="wrap">
|
|
88
|
+
<StatusMark status={known(execution.status)} size={12} />
|
|
89
|
+
{word(execution.status)}
|
|
90
|
+
</Flex>
|
|
91
|
+
</Table.Td>
|
|
92
|
+
<Table.Td>{execution.attempts ? execution.attempts.map(word).join(' → ') : 'Not recorded'}</Table.Td>
|
|
93
|
+
<Table.Td>
|
|
94
|
+
{execution.snapshots}
|
|
95
|
+
{execution.republished && (
|
|
96
|
+
<Text span inherit c="dimmed">
|
|
97
|
+
{' '}
|
|
98
|
+
(published again after a failure)
|
|
99
|
+
</Text>
|
|
100
|
+
)}
|
|
101
|
+
</Table.Td>
|
|
102
|
+
<Table.Td c="dimmed">{execution.branch ?? '—'}</Table.Td>
|
|
103
|
+
<Table.Td>
|
|
104
|
+
<Code>{shortSha(execution.sha) ?? '—'}</Code>
|
|
105
|
+
</Table.Td>
|
|
106
|
+
<Table.Td>{execution.counted ? 'Yes' : `No: ${REASONS[execution.excluded ?? ''] ?? execution.excluded}`}</Table.Td>
|
|
107
|
+
<Table.Td>
|
|
108
|
+
<Anchor
|
|
109
|
+
aria-label={`Open the ${word(execution.status)} result recorded ${exactTime(execution.at)} in ${environmentLabel(execution.environment)}`}
|
|
110
|
+
aria-current={execution.layer === layer ? 'true' : undefined}
|
|
111
|
+
renderRoot={(props) => <Link {...props} to="/" search={patched({ layer: execution.layer })} resetScroll={false} activeOptions={{ exact: true }} />}
|
|
112
|
+
>
|
|
113
|
+
Open this result
|
|
114
|
+
</Anchor>
|
|
115
|
+
</Table.Td>
|
|
116
|
+
</Table.Tr>
|
|
117
|
+
))}
|
|
118
|
+
</Table.Tbody>
|
|
119
|
+
</Table>
|
|
120
|
+
</Table.ScrollContainer>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The reliability section of an open test: scope choices, the qualified figures, why they may mislead, and the
|
|
126
|
+
* executions they count. `entries` is every recorded result of the test the page holds, newest first; while it is
|
|
127
|
+
* `undefined` the history is still being read.
|
|
128
|
+
*/
|
|
129
|
+
export function Reliability({ testKey, entries }: { testKey: string; entries: readonly TestResultEntry[] | undefined }) {
|
|
130
|
+
const search = useExplorerSearch();
|
|
131
|
+
const go = useExplorerNavigate();
|
|
132
|
+
const scope = useMemo(() => reliabilityScopeOf(search), [search]);
|
|
133
|
+
const document = useMemo(() => (entries ? reliability(testKey, entries, scope) : null), [testKey, entries, scope]);
|
|
134
|
+
const parameters = useMemo(() => {
|
|
135
|
+
const counts = new Map<string, number>();
|
|
136
|
+
for (const entry of entries ?? []) for (const [name, value] of Object.entries(entry.parameters)) counts.set(`${name}=${value}`, (counts.get(`${name}=${value}`) ?? 0) + 1);
|
|
137
|
+
return [...counts].map(([value, count]) => ({ value, label: `${value} (${count})` }));
|
|
138
|
+
}, [entries]);
|
|
139
|
+
if (!document)
|
|
140
|
+
return (
|
|
141
|
+
<Box aria-label="Reliability" component="section" mb="lg">
|
|
142
|
+
<Title order={3} mb="sm" c="dimmed">
|
|
143
|
+
Reliability
|
|
144
|
+
</Title>
|
|
145
|
+
<Text role="status" component="div" c="dimmed">
|
|
146
|
+
Reading the history…
|
|
147
|
+
</Text>
|
|
148
|
+
</Box>
|
|
149
|
+
);
|
|
150
|
+
const { eventual, firstAttempt } = document;
|
|
151
|
+
const label = (list: readonly { value: string; count: number }[], format: (value: string) => string = (value) => value) => list.map(({ value, count }) => ({ value, label: `${format(value)} (${count})` }));
|
|
152
|
+
const warnings = document.notes.filter((note) => note.severity === 'warning');
|
|
153
|
+
const infos = document.notes.filter((note) => note.severity === 'info');
|
|
154
|
+
const scoped = Object.keys(scope).length > 0;
|
|
155
|
+
return (
|
|
156
|
+
<Box aria-label="Reliability" component="section" mb="lg">
|
|
157
|
+
<Title order={3} mb="sm" c="dimmed">
|
|
158
|
+
Reliability
|
|
159
|
+
</Title>
|
|
160
|
+
<Text component="div" c="dimmed" mb="sm">
|
|
161
|
+
Counted over independent executions: the snapshots one run recorded for one environment are one execution, and a retry is a try of it, not another
|
|
162
|
+
execution. This describes the checks Atlas holds, not how often the product fails in production.
|
|
163
|
+
</Text>
|
|
164
|
+
<Stack gap="sm" mb="md">
|
|
165
|
+
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
|
|
166
|
+
<Choice label="Environment" param="relEnv" options={label(document.available.environments, environmentLabel)} chosen={values(search.relEnv)} />
|
|
167
|
+
<Choice label="Producer" param="relProducer" options={label(document.available.producers)} chosen={values(search.relProducer)} />
|
|
168
|
+
<Choice label="Branch" param="relBranch" options={label(document.available.branches)} chosen={values(search.relBranch)} />
|
|
169
|
+
<Choice label="Parameters" param="relParam" options={parameters} chosen={values(search.relParam)} />
|
|
170
|
+
<TextInput type="date" label="Executions from" value={search.relSince ?? ''} onChange={(event) => go({ relSince: event.target.value || undefined }, { replace: true })} />
|
|
171
|
+
<TextInput type="date" label="Executions until" value={search.relUntil ?? ''} onChange={(event) => go({ relUntil: event.target.value || undefined }, { replace: true })} />
|
|
172
|
+
</SimpleGrid>
|
|
173
|
+
{document.available.specRevisions.length > 0 && (
|
|
174
|
+
<Checkbox
|
|
175
|
+
label="Include earlier revisions of the spec"
|
|
176
|
+
description={document.currentRevision ? 'Off: only executions of the newest recorded spec revision count; earlier ones are shown as context.' : 'On: every revision is pooled, though the behavior may have changed between them.'}
|
|
177
|
+
checked={search.relRevision === 'any'}
|
|
178
|
+
onChange={(event) => go({ relRevision: event.currentTarget.checked ? 'any' : undefined }, { replace: true })}
|
|
179
|
+
/>
|
|
180
|
+
)}
|
|
181
|
+
</Stack>
|
|
182
|
+
|
|
183
|
+
<Flex gap="xs" align="center" wrap="wrap" mb="sm" role="status" aria-label="Reliability sample">
|
|
184
|
+
<Badge color={document.sample === 'sufficient' ? 'gray' : 'orange'} variant="light">
|
|
185
|
+
{document.sample === 'none' ? 'No independent executions' : document.sample === 'insufficient' ? 'Insufficient evidence' : 'Sample large enough to read as a rate'}
|
|
186
|
+
</Badge>
|
|
187
|
+
<Text span inherit fw={600}>
|
|
188
|
+
{plural(eventual.executions, 'independent execution', 'independent executions')}
|
|
189
|
+
</Text>
|
|
190
|
+
<Text span inherit c="dimmed">
|
|
191
|
+
{scoped ? 'in this scope' : 'in all recorded history'}
|
|
192
|
+
{document.sample === 'insufficient' ? `; at least ${document.minimumExecutions} are needed before a rate means much` : ''}
|
|
193
|
+
</Text>
|
|
194
|
+
</Flex>
|
|
195
|
+
|
|
196
|
+
<SimpleGrid component="dl" cols={{ base: 1, sm: 2, md: 3 }}>
|
|
197
|
+
<Stack gap="sm">
|
|
198
|
+
<Box component="dt">Eventual success</Box>
|
|
199
|
+
<Box component="dd" m={0}>
|
|
200
|
+
{eventual.executions ? `${outOf(eventual.passed, eventual.executions)} (${percent(eventual.rate)})` : 'No rate: nothing completed in this scope'}
|
|
201
|
+
</Box>
|
|
202
|
+
</Stack>
|
|
203
|
+
<Stack gap="sm">
|
|
204
|
+
<Box component="dt">First-try success</Box>
|
|
205
|
+
<Box component="dd" m={0}>
|
|
206
|
+
{firstAttempt.executions ? `${outOf(firstAttempt.passed, firstAttempt.executions)} first time (${percent(firstAttempt.rate)})` : 'Unknown: no tries recorded'}
|
|
207
|
+
{document.unrecordedAttempts > 0 && firstAttempt.executions > 0 && (
|
|
208
|
+
<Text span inherit c="dimmed">
|
|
209
|
+
{' '}
|
|
210
|
+
· {document.unrecordedAttempts} recorded no tries
|
|
211
|
+
</Text>
|
|
212
|
+
)}
|
|
213
|
+
</Box>
|
|
214
|
+
</Stack>
|
|
215
|
+
<Stack gap="sm">
|
|
216
|
+
<Box component="dt">Tries</Box>
|
|
217
|
+
<Box component="dd" m={0}>
|
|
218
|
+
{document.attempts.total
|
|
219
|
+
? `${document.attempts.total} recorded; ${plural(document.attempts.retried, 'execution', 'executions')} retried, ${document.attempts.recovered} failed first and passed after`
|
|
220
|
+
: 'None recorded'}
|
|
221
|
+
</Box>
|
|
222
|
+
</Stack>
|
|
223
|
+
<Stack gap="sm">
|
|
224
|
+
<Box component="dt">Interval (95%)</Box>
|
|
225
|
+
<Box component="dd" m={0} title={document.interval?.assumptions}>
|
|
226
|
+
{document.interval ? `${percent(document.interval.lower)} to ${percent(document.interval.upper)} (Wilson)` : '—'}
|
|
227
|
+
</Box>
|
|
228
|
+
</Stack>
|
|
229
|
+
<Stack gap="sm">
|
|
230
|
+
<Box component="dt">Recorded snapshots</Box>
|
|
231
|
+
<Box component="dd" m={0}>
|
|
232
|
+
{document.snapshots} behind the counted {plural(eventual.executions, 'execution', 'executions')}
|
|
233
|
+
</Box>
|
|
234
|
+
</Stack>
|
|
235
|
+
<Stack gap="sm">
|
|
236
|
+
<Box component="dt">Not counted</Box>
|
|
237
|
+
<Box component="dd" m={0}>
|
|
238
|
+
{document.outsideScope} outside the scope
|
|
239
|
+
{Object.entries(document.excluded)
|
|
240
|
+
.filter(([, count]) => count > 0)
|
|
241
|
+
.map(([reason, count]) => `, ${count} ${(REASONS[reason] ?? reason).toLowerCase()}`)
|
|
242
|
+
.join('')}
|
|
243
|
+
</Box>
|
|
244
|
+
</Stack>
|
|
245
|
+
</SimpleGrid>
|
|
246
|
+
|
|
247
|
+
{warnings.length > 0 && (
|
|
248
|
+
<Alert color="orange" title="Read this figure with care" mt="sm">
|
|
249
|
+
<List>
|
|
250
|
+
{warnings.map((note) => (
|
|
251
|
+
<List.Item key={note.code}>{note.message}</List.Item>
|
|
252
|
+
))}
|
|
253
|
+
</List>
|
|
254
|
+
</Alert>
|
|
255
|
+
)}
|
|
256
|
+
{infos.length > 0 && (
|
|
257
|
+
<List mt="sm" c="dimmed">
|
|
258
|
+
{infos.map((note) => (
|
|
259
|
+
<List.Item key={note.code}>{note.message}</List.Item>
|
|
260
|
+
))}
|
|
261
|
+
</List>
|
|
262
|
+
)}
|
|
263
|
+
{document.interval && (
|
|
264
|
+
<Text component="div" c="dimmed" mt="sm">
|
|
265
|
+
Interval assumes: {document.interval.assumptions}
|
|
266
|
+
</Text>
|
|
267
|
+
)}
|
|
268
|
+
{document.historical.executions > 0 && (
|
|
269
|
+
<Text component="div" c="dimmed" mt="sm">
|
|
270
|
+
Earlier spec revisions, shown as context and not counted: {outOf(document.historical.passed, document.historical.executions)}.
|
|
271
|
+
</Text>
|
|
272
|
+
)}
|
|
273
|
+
|
|
274
|
+
{document.cohorts.length > 1 && (
|
|
275
|
+
<Table.ScrollContainer minWidth={500} mt="sm">
|
|
276
|
+
<Table>
|
|
277
|
+
<Table.Caption>Each environment, producer and parameter set on its own</Table.Caption>
|
|
278
|
+
<Table.Thead>
|
|
279
|
+
<Table.Tr>
|
|
280
|
+
<Table.Th scope="col">Environment</Table.Th>
|
|
281
|
+
<Table.Th scope="col">Producer</Table.Th>
|
|
282
|
+
<Table.Th scope="col">Executions</Table.Th>
|
|
283
|
+
<Table.Th scope="col">Passed</Table.Th>
|
|
284
|
+
</Table.Tr>
|
|
285
|
+
</Table.Thead>
|
|
286
|
+
<Table.Tbody>
|
|
287
|
+
{document.cohorts.map((cohort) => (
|
|
288
|
+
<Table.Tr key={JSON.stringify([cohort.environment, cohort.producer, cohort.parameters])}>
|
|
289
|
+
<Table.Td>
|
|
290
|
+
{environmentLabel(cohort.environment)}
|
|
291
|
+
{Object.entries(cohort.parameters).map(([name, value]) => (
|
|
292
|
+
<Text key={name} span inherit c="dimmed">
|
|
293
|
+
{' '}
|
|
294
|
+
{name}: {value}
|
|
295
|
+
</Text>
|
|
296
|
+
))}
|
|
297
|
+
</Table.Td>
|
|
298
|
+
<Table.Td>{cohort.producer}</Table.Td>
|
|
299
|
+
<Table.Td>{cohort.executions}</Table.Td>
|
|
300
|
+
<Table.Td>
|
|
301
|
+
{cohort.passed} ({percent(cohort.rate)})
|
|
302
|
+
</Table.Td>
|
|
303
|
+
</Table.Tr>
|
|
304
|
+
))}
|
|
305
|
+
</Table.Tbody>
|
|
306
|
+
</Table>
|
|
307
|
+
</Table.ScrollContainer>
|
|
308
|
+
)}
|
|
309
|
+
|
|
310
|
+
<Accordion variant="separated" mt="sm" value={search.relOpen ? 'denominator' : null} onChange={(value) => go({ relOpen: value ? true : undefined }, { replace: true })}>
|
|
311
|
+
<Accordion.Item value="denominator">
|
|
312
|
+
<Accordion.Control>
|
|
313
|
+
Executions behind this figure: {document.executions.filter((execution) => execution.counted).length} counted
|
|
314
|
+
{document.executions.some((execution) => !execution.counted) ? `, ${document.executions.filter((execution) => !execution.counted).length} not counted` : ''}
|
|
315
|
+
</Accordion.Control>
|
|
316
|
+
<Accordion.Panel>
|
|
317
|
+
<Stack gap="sm">
|
|
318
|
+
<Flex gap="xs" align="center" wrap="wrap">
|
|
319
|
+
<CopyButton value={typeof window === 'undefined' ? '' : window.location.href}>
|
|
320
|
+
{({ copied, copy }) => (
|
|
321
|
+
<Button type="button" variant="default" onClick={copy} leftSection={<IconLink size={16} aria-hidden="true" />}>
|
|
322
|
+
{copied ? 'Link copied' : 'Copy link to this scope'}
|
|
323
|
+
</Button>
|
|
324
|
+
)}
|
|
325
|
+
</CopyButton>
|
|
326
|
+
<Text span inherit c="dimmed">
|
|
327
|
+
The link opens this scope and these executions.
|
|
328
|
+
</Text>
|
|
329
|
+
</Flex>
|
|
330
|
+
{document.executions.length > 0 ? <Denominator executions={document.executions} layer={search.layer} /> : <Text c="dimmed">No execution of this test matches the scope.</Text>}
|
|
331
|
+
</Stack>
|
|
332
|
+
</Accordion.Panel>
|
|
333
|
+
</Accordion.Item>
|
|
334
|
+
</Accordion>
|
|
335
|
+
</Box>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
@@ -21,6 +21,8 @@ import { outcomeHeadline, selectedResult } from '../evidence/outcome';
|
|
|
21
21
|
import { useTestHistory } from './catalog';
|
|
22
22
|
import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
|
|
23
23
|
import { Badges, runName } from './parts';
|
|
24
|
+
import { Reliability } from './Reliability';
|
|
25
|
+
import { ProductLatency } from './ProductLatency';
|
|
24
26
|
import { usePinnedResult, type PinnedResult } from './pinned';
|
|
25
27
|
const RecordingPage = lazy(() => import('../evidence/recording-page').then((module) => ({ default: module.RecordingPage })));
|
|
26
28
|
/** Results the history table shows before "Show more". */
|
|
@@ -310,6 +312,10 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
|
|
|
310
312
|
<Title order={3} mb="sm" c="dimmed">
|
|
311
313
|
How it has gone
|
|
312
314
|
</Title>
|
|
315
|
+
<Text component="div" c="dimmed" mb="sm">
|
|
316
|
+
Durations here are test execution time: setup, waits, retries and any demo pacing included. They do not say how quickly the product
|
|
317
|
+
responded; see Product latency.
|
|
318
|
+
</Text>
|
|
313
319
|
<SimpleGrid component="dl" cols={{ base: 2, sm: 3 }}>
|
|
314
320
|
<Stack gap="sm">
|
|
315
321
|
<Box component="dt">Pass rate</Box>
|
|
@@ -394,12 +400,16 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
|
|
|
394
400
|
)}
|
|
395
401
|
{!d && (
|
|
396
402
|
<Text component="div" c="dimmed">
|
|
397
|
-
No duration recorded. Producers can send <Code>durationMs</Code> with each check.
|
|
403
|
+
No test duration recorded. Producers can send <Code>durationMs</Code> with each check.
|
|
398
404
|
</Text>
|
|
399
405
|
)}
|
|
400
406
|
{entries.length > 1 && <Trend entries={entries} />}
|
|
401
407
|
</Box>
|
|
402
408
|
|
|
409
|
+
{!history.isError && <Reliability testKey={test.key} entries={history.data === null ? [] : history.data?.history} />}
|
|
410
|
+
|
|
411
|
+
<ProductLatency entries={entries} />
|
|
412
|
+
|
|
403
413
|
{(test.tags.length > 0 || Object.keys(test.labels).length > 0) && (
|
|
404
414
|
<Box aria-label="Tags and labels" component="section" mb="lg">
|
|
405
415
|
<Title order={3} mb="sm" c="dimmed">
|
|
@@ -577,7 +587,7 @@ function Trend({ entries }: { entries: readonly TestResultEntry[] }) {
|
|
|
577
587
|
{timed.length > 1 && (
|
|
578
588
|
<Box component="figure" m={0}>
|
|
579
589
|
<Box component="figcaption">
|
|
580
|
-
|
|
590
|
+
Test duration over the last {plural(timed.length, 'result', 'results')} · max {formatMs(max)}
|
|
581
591
|
</Box>
|
|
582
592
|
<svg
|
|
583
593
|
viewBox={`0 0 ${width} ${height}`}
|
|
@@ -180,7 +180,7 @@ function usePageHistory(layers: readonly LayerEvidence[] | null): PageHistory |
|
|
|
180
180
|
const key = `${entry.layer.backend}|${entry.layer.id}`;
|
|
181
181
|
if (current.rows.has(key)) continue;
|
|
182
182
|
current.rows.set(key, entry.rows);
|
|
183
|
-
current.history.add(entry.layer, entry.rows, [], entry.layer.backend);
|
|
183
|
+
current.history.add(entry.layer, entry.rows, [], entry.layer.backend, entry.fingerprints);
|
|
184
184
|
}
|
|
185
185
|
return { history: current.history, stamp: `page:${current.gen}:${current.history.rev}` };
|
|
186
186
|
}, [layers]);
|