@crvouga/atlas 1.1.0 → 1.2.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 +6 -0
- package/dist/reports/src/artifact-query.d.ts +123 -0
- package/dist/reports/src/artifact-query.js +234 -0
- package/dist/reports/src/evidence-detail.js +2 -1
- package/dist/reports/src/evidence-types.d.ts +2 -0
- package/dist/reports/src/test-catalog.d.ts +101 -5
- package/dist/reports/src/test-catalog.js +140 -5
- package/dist/schema/src/api.d.ts +225 -0
- package/dist/schema/src/api.js +41 -2
- package/dist/server/src/app.js +16 -5
- package/dist/server/src/openapi.js +41 -3
- package/docs/reference/http-api.md +84 -2
- package/docs/reference/reports.md +371 -7
- package/docs/reference/schema.md +32 -0
- package/docs/rich-evidence.md +31 -0
- package/docs/server.md +4 -1
- package/docs/ui.md +1 -1
- package/llms-full.txt +120 -4
- package/package.json +1 -1
- package/packages/schema/schemas/effective-delta.schema.json +14 -0
- package/packages/schema/schemas/effective-evidence.schema.json +14 -0
- package/packages/schema/schemas/evidence-page.schema.json +7 -0
- package/packages/schema/schemas/layer-rows.schema.json +7 -0
- package/packages/schema/schemas/report-run.schema.json +35 -0
- package/packages/schema/schemas/report-runs.schema.json +221 -0
- package/packages/schema/schemas/test-catalog.schema.json +7 -0
- package/packages/schema/schemas/test-history.schema.json +14 -0
- package/packages/server/openapi.json +2473 -1035
- package/packages/visualizer/src/explorer/ArtifactsView.tsx +162 -0
- package/packages/visualizer/src/explorer/Explorer.tsx +28 -3
- package/packages/visualizer/src/explorer/RunsView.tsx +43 -19
- package/packages/visualizer/src/explorer/catalog.tsx +97 -12
- package/packages/visualizer/src/explorer/filters.tsx +279 -0
- package/packages/visualizer/src/explorer/search.ts +81 -6
- package/packages/visualizer/src/explorer/views.ts +75 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Artifacts view
|
|
3
|
+
*
|
|
4
|
+
* Every recorded result that carries evidence (recordings, screenshots, traces) across the project's history, newest first,
|
|
5
|
+
* found by artifact tag, media type, label, feature, environment, branch, outcome and time. A result is listed with the
|
|
6
|
+
* run, commit and status it was recorded with, so a recording tagged `demo` is found even when a later attempt is
|
|
7
|
+
* untagged. Each row opens that exact result in the test detail. An empty answer for a filter says nothing matches and
|
|
8
|
+
* offers to clear it; it never says the project has nothing recorded.
|
|
9
|
+
*/
|
|
10
|
+
import { IconVideo } from '@tabler/icons-react';
|
|
11
|
+
import { Alert, Anchor, Box, Button, Code, Stack, Table, Text } from '@mantine/core';
|
|
12
|
+
import { videoCounts } from '@crvouga/atlas/reports/evidence-detail';
|
|
13
|
+
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
14
|
+
import { Link } from '@tanstack/react-router';
|
|
15
|
+
import { useMemo, useState } from 'react';
|
|
16
|
+
|
|
17
|
+
import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidence/format';
|
|
18
|
+
import { StatusMark } from '../evidence/marks';
|
|
19
|
+
import { useArtifactList } from './catalog';
|
|
20
|
+
import { FilterBar } from './filters';
|
|
21
|
+
import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
|
|
22
|
+
import { runName } from './parts';
|
|
23
|
+
import { filtered, filterOf, NO_FILTERS } from './search';
|
|
24
|
+
|
|
25
|
+
const ARTIFACT_ROWS = 50;
|
|
26
|
+
|
|
27
|
+
/** The Artifacts tab: recorded results that carry evidence, filtered by the address. */
|
|
28
|
+
export function ArtifactsView() {
|
|
29
|
+
const search = useExplorerSearch();
|
|
30
|
+
const go = useExplorerNavigate();
|
|
31
|
+
const filter = useMemo(() => filterOf(search), [search]);
|
|
32
|
+
const narrow = filtered(search);
|
|
33
|
+
const answer = useArtifactList(filter);
|
|
34
|
+
const now = useNow();
|
|
35
|
+
const [shown, setShown] = useState(ARTIFACT_ROWS);
|
|
36
|
+
if (answer.isPending)
|
|
37
|
+
return (
|
|
38
|
+
<Text role="status" component="div" c="dimmed">
|
|
39
|
+
Reading the artifacts…
|
|
40
|
+
</Text>
|
|
41
|
+
);
|
|
42
|
+
if (answer.isError)
|
|
43
|
+
return (
|
|
44
|
+
<Alert role="alert" color="red" my="sm">
|
|
45
|
+
The artifacts could not be read. {answer.error.message}
|
|
46
|
+
</Alert>
|
|
47
|
+
);
|
|
48
|
+
if (answer.data.unsupported)
|
|
49
|
+
return (
|
|
50
|
+
<Alert role="status" color="blue" my="sm" data-unsupported>
|
|
51
|
+
This server cannot search artifacts, so nothing is shown here rather than a list that looks narrowed. Update the server to find recordings by tag, type and label; the Tests tab still filters by artifact tag.
|
|
52
|
+
</Alert>
|
|
53
|
+
);
|
|
54
|
+
const { list } = answer.data;
|
|
55
|
+
return (
|
|
56
|
+
<Box aria-label="Artifacts" component="section" mb="lg">
|
|
57
|
+
{(narrow || list.results.length > 0) && <FilterBar facets={list.facets} search={search} tab="artifacts" />}
|
|
58
|
+
{!list.results.length && !narrow && (
|
|
59
|
+
<Text component="div" c="dimmed">
|
|
60
|
+
No recorded result carries evidence yet. Recordings, screenshots and traces published with a result are listed here.
|
|
61
|
+
</Text>
|
|
62
|
+
)}
|
|
63
|
+
{list.results.length > 0 && (
|
|
64
|
+
<Text role="status" component="div">
|
|
65
|
+
{list.total > list.results.length
|
|
66
|
+
? `The newest ${list.results.length} of ${plural(list.total, 'result', 'results')}${narrow ? ' matching' : ''}`
|
|
67
|
+
: `${plural(list.results.length, 'result', 'results')}${narrow ? ' matching' : ''}`}
|
|
68
|
+
</Text>
|
|
69
|
+
)}
|
|
70
|
+
{!list.results.length && narrow && (
|
|
71
|
+
<Stack gap="xs" align="flex-start" role="status" data-no-match>
|
|
72
|
+
<Text component="div" c="dimmed">
|
|
73
|
+
Nothing matches these filters.
|
|
74
|
+
</Text>
|
|
75
|
+
<Button type="button" variant="light" onClick={() => go(NO_FILTERS)}>
|
|
76
|
+
Clear filters
|
|
77
|
+
</Button>
|
|
78
|
+
</Stack>
|
|
79
|
+
)}
|
|
80
|
+
{list.results.length > 0 && (
|
|
81
|
+
<Table.ScrollContainer minWidth={700}>
|
|
82
|
+
<Table data-artifacts>
|
|
83
|
+
<Table.Thead>
|
|
84
|
+
<Table.Tr>
|
|
85
|
+
<Table.Th scope="col">Recorded</Table.Th>
|
|
86
|
+
<Table.Th scope="col">Test</Table.Th>
|
|
87
|
+
<Table.Th scope="col">Outcome</Table.Th>
|
|
88
|
+
<Table.Th scope="col">Environment</Table.Th>
|
|
89
|
+
<Table.Th scope="col">Branch</Table.Th>
|
|
90
|
+
<Table.Th scope="col">Commit</Table.Th>
|
|
91
|
+
<Table.Th scope="col">Run</Table.Th>
|
|
92
|
+
<Table.Th scope="col">Evidence</Table.Th>
|
|
93
|
+
<Table.Th scope="col">Tags</Table.Th>
|
|
94
|
+
</Table.Tr>
|
|
95
|
+
</Table.Thead>
|
|
96
|
+
<Table.Tbody>
|
|
97
|
+
{list.results.slice(0, shown).map((result) => {
|
|
98
|
+
const videos = videoCounts(result.videos);
|
|
99
|
+
return (
|
|
100
|
+
<Table.Tr key={`${result.layer}|${result.key}|${result.kind}|${result.producer}|${result.environment}`} data-status={result.status}>
|
|
101
|
+
<Table.Td title={exactTime(result.at)}>{ago(result.at, now)}</Table.Td>
|
|
102
|
+
<Table.Td>
|
|
103
|
+
<Anchor
|
|
104
|
+
aria-label={`Open ${result.title} as recorded in run ${runName(result.run)}`}
|
|
105
|
+
renderRoot={(props) => (
|
|
106
|
+
<Link
|
|
107
|
+
{...props}
|
|
108
|
+
to="/"
|
|
109
|
+
search={patched({ tab: undefined, openRun: undefined, test: result.key, layer: result.layer })}
|
|
110
|
+
resetScroll={false}
|
|
111
|
+
activeOptions={{ exact: true }}
|
|
112
|
+
/>
|
|
113
|
+
)}
|
|
114
|
+
>
|
|
115
|
+
{result.title}
|
|
116
|
+
</Anchor>
|
|
117
|
+
<Text c="dimmed" fz="xs">
|
|
118
|
+
{result.producer}
|
|
119
|
+
</Text>
|
|
120
|
+
</Table.Td>
|
|
121
|
+
<Table.Td>
|
|
122
|
+
<StatusMark status={result.status} size={14} /> {statusWord(result.status)}
|
|
123
|
+
</Table.Td>
|
|
124
|
+
<Table.Td>{environmentLabel(result.environment)}</Table.Td>
|
|
125
|
+
<Table.Td c="dimmed">{result.branch ?? '—'}</Table.Td>
|
|
126
|
+
<Table.Td>
|
|
127
|
+
<Code>{shortSha(result.sha) ?? '—'}</Code>
|
|
128
|
+
</Table.Td>
|
|
129
|
+
<Table.Td>
|
|
130
|
+
<Anchor
|
|
131
|
+
aria-label={`Open run ${runName(result.run)}`}
|
|
132
|
+
renderRoot={(props) => (
|
|
133
|
+
<Link {...props} to="/" search={patched({ tab: 'runs', openRun: result.run, test: undefined, layer: undefined, video: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
|
|
134
|
+
)}
|
|
135
|
+
>
|
|
136
|
+
{runName(result.run)}
|
|
137
|
+
</Anchor>
|
|
138
|
+
</Table.Td>
|
|
139
|
+
<Table.Td>
|
|
140
|
+
{result.types.join(', ') || '—'}
|
|
141
|
+
{videos && videos.recordings > 0 && (
|
|
142
|
+
<Text component="div" size="xs" c="dimmed" data-video="available">
|
|
143
|
+
<IconVideo size={16} aria-hidden="true" /> {plural(videos.recordings, 'video', 'videos')}
|
|
144
|
+
</Text>
|
|
145
|
+
)}
|
|
146
|
+
</Table.Td>
|
|
147
|
+
<Table.Td data-tags>{result.artifactTags.map((tag) => `#${tag}`).join(' ') || '—'}</Table.Td>
|
|
148
|
+
</Table.Tr>
|
|
149
|
+
);
|
|
150
|
+
})}
|
|
151
|
+
</Table.Tbody>
|
|
152
|
+
</Table>
|
|
153
|
+
</Table.ScrollContainer>
|
|
154
|
+
)}
|
|
155
|
+
{list.results.length > shown && (
|
|
156
|
+
<Button type="button" onClick={() => setShown(shown + ARTIFACT_ROWS)} variant="subtle">
|
|
157
|
+
Show more ({list.results.length - shown} more)
|
|
158
|
+
</Button>
|
|
159
|
+
)}
|
|
160
|
+
</Box>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
Flex,
|
|
13
13
|
Group,
|
|
14
14
|
List,
|
|
15
|
+
MultiSelect,
|
|
15
16
|
NativeSelect,
|
|
16
17
|
Paper,
|
|
17
18
|
SimpleGrid,
|
|
@@ -53,8 +54,9 @@ import { useRunDetail, useTestCatalog } from './catalog';
|
|
|
53
54
|
import { groupTests, tally, type TestGroupView } from './groups';
|
|
54
55
|
import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
|
|
55
56
|
import { Badges, EnvironmentChips, projectTitle, runName } from './parts';
|
|
57
|
+
import { ArtifactsView } from './ArtifactsView';
|
|
56
58
|
import { RunsView } from './RunsView';
|
|
57
|
-
import { narrowed, queryOf, TEST_GROUPS, toggled, values, type ExplorerSearch, type TestGroup } from './search';
|
|
59
|
+
import { joined, narrowed, queryOf, TEST_GROUPS, toggled, values, type ExplorerSearch, type TestGroup } from './search';
|
|
58
60
|
import { TestDetail } from './TestDetail';
|
|
59
61
|
/** Groups start closed when there are more of them than this, unless the list is short. */
|
|
60
62
|
const OPEN_GROUPS = 40;
|
|
@@ -132,7 +134,7 @@ export function Explorer() {
|
|
|
132
134
|
const model = catalog.model;
|
|
133
135
|
const selected = search.test ? (model?.byKey.get(search.test) ?? null) : null;
|
|
134
136
|
const empty = model !== null && model.tests.length === 0;
|
|
135
|
-
const split = !!search.test && search.tab !== 'runs';
|
|
137
|
+
const split = !!search.test && search.tab !== 'runs' && search.tab !== 'artifacts';
|
|
136
138
|
return (
|
|
137
139
|
<Container size="xl" py="xl">
|
|
138
140
|
<Box aria-label="Test explorer" component="article" miw={0}>
|
|
@@ -143,7 +145,7 @@ export function Explorer() {
|
|
|
143
145
|
{model && model.tests.length > 0 && (
|
|
144
146
|
<Tabs
|
|
145
147
|
value={search.tab ?? 'tests'}
|
|
146
|
-
onChange={(value) => go({ tab: value === 'runs'
|
|
148
|
+
onChange={(value) => go({ tab: value === 'runs' || value === 'artifacts' ? value : undefined, test: undefined, openRun: undefined, layer: undefined })}
|
|
147
149
|
>
|
|
148
150
|
<Tabs.List aria-label="Explorer view">
|
|
149
151
|
<Tabs.Tab
|
|
@@ -166,6 +168,7 @@ export function Explorer() {
|
|
|
166
168
|
>
|
|
167
169
|
Runs
|
|
168
170
|
</Tabs.Tab>
|
|
171
|
+
<Tabs.Tab value="artifacts">Artifacts</Tabs.Tab>
|
|
169
172
|
</Tabs.List>
|
|
170
173
|
</Tabs>
|
|
171
174
|
)}
|
|
@@ -224,6 +227,8 @@ export function Explorer() {
|
|
|
224
227
|
listing &&
|
|
225
228
|
(search.tab === 'runs' ? (
|
|
226
229
|
<RunsView />
|
|
230
|
+
) : search.tab === 'artifacts' ? (
|
|
231
|
+
<ArtifactsView />
|
|
227
232
|
) : (
|
|
228
233
|
<>
|
|
229
234
|
<InsightStrip model={model} scope={listing.scope} search={search} />
|
|
@@ -506,6 +511,7 @@ function Toolbar({
|
|
|
506
511
|
const chips = [
|
|
507
512
|
...values(search.spec).map((path) => ({ label: `Spec: ${specTitle(path)}`, clear: { spec: toggled(search.spec, path) } })),
|
|
508
513
|
...values(search.tag).map((tag) => ({ label: `Tag: ${tag}`, clear: { tag: toggled(search.tag, tag) } })),
|
|
514
|
+
...values(search.artifactTag).map((tag) => ({ label: `Artifact tag: #${tag}`, clear: { artifactTag: toggled(search.artifactTag, tag) } })),
|
|
509
515
|
...(search.inRun ? [{ label: `Run: ${runName(search.inRun)}`, clear: { inRun: undefined } }] : [])
|
|
510
516
|
];
|
|
511
517
|
return (
|
|
@@ -614,6 +620,23 @@ function Toolbar({
|
|
|
614
620
|
</NativeSelect>
|
|
615
621
|
</Box>
|
|
616
622
|
)}
|
|
623
|
+
{((model.facets.artifactTags?.length ?? 0) > 0 || values(search.artifactTag).length > 0) && (
|
|
624
|
+
<MultiSelect
|
|
625
|
+
aria-label="Artifact tag"
|
|
626
|
+
placeholder={values(search.artifactTag).length ? undefined : 'Any artifact tag'}
|
|
627
|
+
data={[
|
|
628
|
+
...(model.facets.artifactTags ?? []).map(({ value, count }) => ({ value, label: `#${value} (${count})` })),
|
|
629
|
+
...values(search.artifactTag)
|
|
630
|
+
.filter((tag) => !model.facets.artifactTags?.some((facet) => facet.value === tag))
|
|
631
|
+
.map((tag) => ({ value: tag, label: `#${tag} (0)` }))
|
|
632
|
+
]}
|
|
633
|
+
value={values(search.artifactTag)}
|
|
634
|
+
searchable
|
|
635
|
+
clearable
|
|
636
|
+
nothingFoundMessage="Nothing found"
|
|
637
|
+
onChange={(next) => go({ artifactTag: joined(next) })}
|
|
638
|
+
/>
|
|
639
|
+
)}
|
|
617
640
|
{model.facets.domains.length > 1 && (
|
|
618
641
|
<Box component="label">
|
|
619
642
|
<VisuallyHidden>Domain</VisuallyHidden>
|
|
@@ -659,6 +682,8 @@ function Toolbar({
|
|
|
659
682
|
domain: undefined,
|
|
660
683
|
spec: undefined,
|
|
661
684
|
tag: undefined,
|
|
685
|
+
artifactTag: undefined,
|
|
686
|
+
inherited: undefined,
|
|
662
687
|
inRun: undefined
|
|
663
688
|
});
|
|
664
689
|
}}
|
|
@@ -12,14 +12,17 @@ import { Alert, Anchor, Box, Button, Code, Flex, Group, List, Stack, Table, Text
|
|
|
12
12
|
import { videoCounts } from '@crvouga/atlas/reports/evidence-detail';
|
|
13
13
|
import type { VideoCounts, VideoRef } from '@crvouga/atlas/reports/evidence-types';
|
|
14
14
|
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
15
|
+
import type { FilterName } from '@crvouga/atlas/reports/artifact-query';
|
|
15
16
|
import { formatMs, type RunResult, type RunSummary } from '@crvouga/atlas/reports/test-catalog';
|
|
16
17
|
import { Link } from '@tanstack/react-router';
|
|
17
18
|
import { useMemo, useState } from 'react';
|
|
18
19
|
import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidence/format';
|
|
19
20
|
import { Meter, StatusMark } from '../evidence/marks';
|
|
20
21
|
import { useRunDetail, useRunList } from './catalog';
|
|
22
|
+
import { FilterBar } from './filters';
|
|
21
23
|
import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
|
|
22
24
|
import { runName } from './parts';
|
|
25
|
+
import { filtered, filterOf, NO_FILTERS } from './search';
|
|
23
26
|
const RUN_ROWS = 50;
|
|
24
27
|
const RESULT_ROWS = 200;
|
|
25
28
|
/** The Runs tab: the run list, or the run opened in it. */
|
|
@@ -118,11 +121,25 @@ function HasVideo({ count, pressed, noun, onToggle }: { count: number; pressed:
|
|
|
118
121
|
</Button>
|
|
119
122
|
);
|
|
120
123
|
}
|
|
124
|
+
/** What a filter is called when a page says a server could not apply it. */
|
|
125
|
+
const FILTER_WORDS: Partial<Record<FilterName, string>> = { artifactTag: 'tag', type: 'media type', label: 'label', domain: 'domain', spec: 'spec' };
|
|
126
|
+
/** Says which filters the list is not narrowed by, because the server that answered ignored them. */
|
|
127
|
+
export function NotApplied({ names, noun }: { names: readonly FilterName[]; noun: string }) {
|
|
128
|
+
if (!names.length) return null;
|
|
129
|
+
return (
|
|
130
|
+
<Alert role="status" color="blue" my="sm" data-not-applied>
|
|
131
|
+
This server cannot filter {noun} by {names.map((name) => FILTER_WORDS[name] ?? name).join(', ')}, so the list below is not narrowed by {names.length > 1 ? 'them' : 'it'}. Update the server to use{' '}
|
|
132
|
+
{names.length > 1 ? 'these filters' : 'this filter'}.
|
|
133
|
+
</Alert>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
121
136
|
function RunTable() {
|
|
122
137
|
const search = useExplorerSearch();
|
|
123
138
|
const go = useExplorerNavigate();
|
|
124
139
|
const video = search.video === true;
|
|
125
|
-
const
|
|
140
|
+
const filter = useMemo(() => filterOf(search), [search]);
|
|
141
|
+
const narrow = filtered(search);
|
|
142
|
+
const runs = useRunList(true, filter);
|
|
126
143
|
const now = useNow();
|
|
127
144
|
const [shown, setShown] = useState(RUN_ROWS);
|
|
128
145
|
if (runs.isPending)
|
|
@@ -139,29 +156,34 @@ function RunTable() {
|
|
|
139
156
|
);
|
|
140
157
|
const list = runs.data.runs;
|
|
141
158
|
const withVideo = runs.data.withVideo;
|
|
142
|
-
|
|
143
|
-
return (
|
|
144
|
-
<Text component="div" c="dimmed">
|
|
145
|
-
No runs recorded yet.
|
|
146
|
-
</Text>
|
|
147
|
-
);
|
|
148
|
-
const noun = video ? 'with a video' : '';
|
|
159
|
+
const noun = narrow ? 'matching' : '';
|
|
149
160
|
return (
|
|
150
161
|
<Box aria-label="Runs" component="section" mb="lg">
|
|
151
|
-
<
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
: `${plural(list.length, 'run', 'runs')} ${noun}`.trim()}
|
|
155
|
-
{(withVideo !== undefined || video) && (
|
|
156
|
-
<HasVideo count={withVideo ?? list.length} pressed={video} noun={['run', 'runs']} onToggle={() => go({ video: video ? undefined : true })} />
|
|
157
|
-
)}
|
|
158
|
-
</Flex>
|
|
159
|
-
{!list.length && (
|
|
162
|
+
{(narrow || list.length > 0) && <FilterBar facets={runs.data.facets} search={search} tab="runs" extra={withVideo !== undefined || video ? <HasVideo count={withVideo ?? list.length} pressed={video} noun={['run', 'runs']} onToggle={() => go({ video: video ? undefined : true })} /> : undefined} />}
|
|
163
|
+
<NotApplied names={runs.data.notApplied} noun="runs" />
|
|
164
|
+
{!list.length && !narrow && (
|
|
160
165
|
<Text component="div" c="dimmed">
|
|
161
|
-
No
|
|
162
|
-
|
|
166
|
+
No runs recorded yet.
|
|
167
|
+
</Text>
|
|
168
|
+
)}
|
|
169
|
+
{list.length > 0 && (
|
|
170
|
+
<Text role="status" component="div">
|
|
171
|
+
{runs.data.total > list.length
|
|
172
|
+
? `The newest ${list.length} of ${plural(runs.data.total, 'run', 'runs')} ${noun}`.trim()
|
|
173
|
+
: `${plural(list.length, 'run', 'runs')} ${noun}`.trim()}
|
|
163
174
|
</Text>
|
|
164
175
|
)}
|
|
176
|
+
{!list.length && narrow && (
|
|
177
|
+
<Stack gap="xs" align="flex-start" role="status" data-no-match>
|
|
178
|
+
<Text component="div" c="dimmed">
|
|
179
|
+
No run matches these filters.
|
|
180
|
+
{video && withVideo === undefined && ' This server does not report which runs have recordings.'}
|
|
181
|
+
</Text>
|
|
182
|
+
<Button type="button" variant="light" onClick={() => go(NO_FILTERS)}>
|
|
183
|
+
Clear filters
|
|
184
|
+
</Button>
|
|
185
|
+
</Stack>
|
|
186
|
+
)}
|
|
165
187
|
<Table.ScrollContainer minWidth={600}>
|
|
166
188
|
<Table data-runs>
|
|
167
189
|
<Table.Thead>
|
|
@@ -174,6 +196,7 @@ function RunTable() {
|
|
|
174
196
|
<Table.Th scope="col">Outcomes</Table.Th>
|
|
175
197
|
<Table.Th scope="col">Span</Table.Th>
|
|
176
198
|
<Table.Th scope="col">Video</Table.Th>
|
|
199
|
+
<Table.Th scope="col">Tags</Table.Th>
|
|
177
200
|
<Table.Th scope="col">
|
|
178
201
|
<VisuallyHidden>CI run</VisuallyHidden>
|
|
179
202
|
</Table.Th>
|
|
@@ -215,6 +238,7 @@ function RunTable() {
|
|
|
215
238
|
<Table.Td>
|
|
216
239
|
<RunVideos run={run} />
|
|
217
240
|
</Table.Td>
|
|
241
|
+
<Table.Td data-tags>{run.artifactTags?.map((tag) => `#${tag}`).join(' ') || '—'}</Table.Td>
|
|
218
242
|
<Table.Td>
|
|
219
243
|
{run.runUrl ? (
|
|
220
244
|
<Anchor href={run.runUrl} target="_blank" rel="noreferrer">
|
|
@@ -17,13 +17,25 @@ import type { EvidenceStatus } from '@crvouga/atlas/reports/explorer-types';
|
|
|
17
17
|
import type { LayerEvidence, LayerRows, StatusCounts } from '@crvouga/atlas/reports/evidence-types';
|
|
18
18
|
import type { CheckSlot, EvidenceView } from '@crvouga/atlas/reports/evidence-view';
|
|
19
19
|
import {
|
|
20
|
+
facetsOf,
|
|
21
|
+
filterParams,
|
|
22
|
+
forRuns,
|
|
23
|
+
matchesFilter,
|
|
24
|
+
usedFilters,
|
|
25
|
+
type ArtifactFilter,
|
|
26
|
+
type FilterName,
|
|
27
|
+
type QueryFacets
|
|
28
|
+
} from '@crvouga/atlas/reports/artifact-query';
|
|
29
|
+
import {
|
|
30
|
+
artifactList,
|
|
20
31
|
catalogModel,
|
|
32
|
+
fieldsOfRun,
|
|
21
33
|
haystackOf,
|
|
22
|
-
hasVideo,
|
|
23
34
|
runDetail,
|
|
24
35
|
runList,
|
|
25
36
|
testHistory,
|
|
26
37
|
TestHistory,
|
|
38
|
+
type ArtifactList,
|
|
27
39
|
type CatalogModel,
|
|
28
40
|
type RunDetail,
|
|
29
41
|
type RunList,
|
|
@@ -40,6 +52,8 @@ import { useEvidence } from '../evidence/evidence-context';
|
|
|
40
52
|
|
|
41
53
|
/** How many runs the Runs tab lists. */
|
|
42
54
|
export const RUNS_SHOWN = 500;
|
|
55
|
+
/** How many recorded results the Artifacts tab lists. */
|
|
56
|
+
export const ARTIFACTS_SHOWN = 200;
|
|
43
57
|
/** The server's catalog is read again at most this often while results keep arriving. */
|
|
44
58
|
const SERVER_REREAD_MS = 5_000;
|
|
45
59
|
/** Layers read one after another are folded into the page's catalog at most this often. */
|
|
@@ -234,24 +248,95 @@ export function useTestHistory(key: string | undefined) {
|
|
|
234
248
|
});
|
|
235
249
|
}
|
|
236
250
|
|
|
251
|
+
/** The runs a query matched, and what this server could not do about it. */
|
|
252
|
+
export type RunsAnswer = RunList & {
|
|
253
|
+
/** What the viewer's query asks for that the answer does not reflect: filters an older server ignores and the viewer cannot apply itself (tags, types, labels need data it does not send). Say so; never present the list as narrowed by them. */
|
|
254
|
+
readonly notApplied: readonly FilterName[];
|
|
255
|
+
/** The values each dimension offers, from the server or worked out from the runs it sent. */
|
|
256
|
+
readonly facets: QueryFacets;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/** What an older server's run summary can answer by itself: the viewer filters its complete list for these. */
|
|
260
|
+
const ANSWERABLE_HERE: ReadonlySet<FilterName> = new Set(['q', 'environment', 'producer', 'branch', 'sha', 'outcome', 'since', 'until', 'video']);
|
|
261
|
+
|
|
237
262
|
/**
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
263
|
+
* Makes a server's answer honest about the filters it did not apply (an older server ignores parameters it does not know
|
|
264
|
+
* and answers with every run). Those the run summary can answer are applied here, to the complete list; the rest are
|
|
265
|
+
* reported in `notApplied` so the page can say the list is not narrowed by them.
|
|
241
266
|
*/
|
|
242
|
-
export function
|
|
267
|
+
export function runsAnswer(list: RunList, filter: ArtifactFilter): RunsAnswer {
|
|
268
|
+
const asked = usedFilters(forRuns(filter)).filter((name) => name !== 'inherited');
|
|
269
|
+
// A server that predates `filters` but has `withVideo` already filtered by `video`.
|
|
270
|
+
const applied = new Set<FilterName>(list.filters ?? (list.withVideo !== undefined ? ['video'] : []));
|
|
271
|
+
const missing = asked.filter((name) => !applied.has(name));
|
|
272
|
+
if (!missing.length && list.facets) return { ...list, facets: list.facets, notApplied: [] };
|
|
273
|
+
const here = missing.filter((name) => ANSWERABLE_HERE.has(name));
|
|
274
|
+
const sub = Object.fromEntries(here.map((name) => [name, filter[name]])) as ArtifactFilter;
|
|
275
|
+
const runs = here.length ? list.runs.filter((run) => matchesFilter(fieldsOfRun(run), sub)) : list.runs;
|
|
276
|
+
return {
|
|
277
|
+
...list,
|
|
278
|
+
total: here.length ? runs.length : list.total,
|
|
279
|
+
runs,
|
|
280
|
+
facets: list.facets ?? facetsOf(list.runs.map(fieldsOfRun), sub),
|
|
281
|
+
notApplied: missing.filter((name) => !ANSWERABLE_HERE.has(name))
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* CI runs, newest first, narrowed by a query (see `artifact-query.ts`). The server filters every run before it pages
|
|
287
|
+
* them, so counts are the whole dataset's; a server that does not know a filter is handled by {@link runsAnswer}.
|
|
288
|
+
*/
|
|
289
|
+
export function useRunList(enabled = true, filter: ArtifactFilter = {}) {
|
|
243
290
|
const catalog = useTestCatalog();
|
|
291
|
+
const query = forRuns(filter);
|
|
292
|
+
const key = filterParams(query).toString();
|
|
244
293
|
return useQuery({
|
|
245
|
-
queryKey: ['test-runs', catalog.version,
|
|
246
|
-
queryFn: async ({ signal }): Promise<
|
|
294
|
+
queryKey: ['test-runs', catalog.version, key],
|
|
295
|
+
queryFn: async ({ signal }): Promise<RunsAnswer> => {
|
|
247
296
|
if (catalog.source === 'server') {
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
return { ...list, total: runs.length, runs };
|
|
297
|
+
const params = filterParams(query);
|
|
298
|
+
params.set('limit', String(RUNS_SHOWN));
|
|
299
|
+
return runsAnswer(await reportJson<RunList>(`/runs?${params}`, signal), query);
|
|
252
300
|
}
|
|
253
301
|
const history = catalog.model?.history;
|
|
254
|
-
|
|
302
|
+
const list = history ? runList(history, catalog.version, RUNS_SHOWN, query) : { schemaVersion: 1 as const, version: catalog.version, total: 0, runs: [] };
|
|
303
|
+
return runsAnswer(list, query);
|
|
304
|
+
},
|
|
305
|
+
enabled: enabled && catalog.model !== null,
|
|
306
|
+
placeholderData: keepPreviousData,
|
|
307
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
308
|
+
retry: false
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The recorded results that carry evidence and match a query, or `unsupported` when the server has no artifact search. */
|
|
313
|
+
export type ArtifactsAnswer = { readonly unsupported: false; readonly list: ArtifactList } | { readonly unsupported: true };
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Recorded results that carry evidence (recordings, screenshots, traces) across the whole history, newest first. A
|
|
317
|
+
* server without `/artifacts` (an older one) answers `unsupported`, and the page says so instead of showing an empty list.
|
|
318
|
+
*/
|
|
319
|
+
export function useArtifactList(filter: ArtifactFilter, enabled = true) {
|
|
320
|
+
const catalog = useTestCatalog();
|
|
321
|
+
const key = filterParams(filter).toString();
|
|
322
|
+
return useQuery({
|
|
323
|
+
queryKey: ['test-artifacts', catalog.version, key],
|
|
324
|
+
queryFn: async ({ signal }): Promise<ArtifactsAnswer> => {
|
|
325
|
+
if (catalog.source === 'server') {
|
|
326
|
+
const params = filterParams(filter);
|
|
327
|
+
params.set('limit', String(ARTIFACTS_SHOWN));
|
|
328
|
+
try {
|
|
329
|
+
return { unsupported: false, list: await reportJson<ArtifactList>(`/artifacts?${params}`, signal) };
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (notFound(error)) return { unsupported: true };
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const model = catalog.model!;
|
|
336
|
+
const domains = new Map(model.facets.specs.map((spec) => [spec.path, spec.domain]));
|
|
337
|
+
const history = model.history;
|
|
338
|
+
if (!history) return { unsupported: true };
|
|
339
|
+
return { unsupported: false, list: artifactList(history, catalog.version, filter, { limit: ARTIFACTS_SHOWN, domainOf: (path) => domains.get(path) }) };
|
|
255
340
|
},
|
|
256
341
|
enabled: enabled && catalog.model !== null,
|
|
257
342
|
placeholderData: keepPreviousData,
|