@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,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Run and artifact filters
|
|
3
|
+
*
|
|
4
|
+
* The filter bar of the Runs and Artifacts tabs: words, artifact tag, media type, label, environment, branch, outcome,
|
|
5
|
+
* feature domain and a time range, each offering the values the report has with counts in the scope of the list. What is
|
|
6
|
+
* chosen shows as removable chips and is the address, so copying it or reloading restores the same view; named views keep
|
|
7
|
+
* a query to open again. Choices combine with AND across the controls and OR within one.
|
|
8
|
+
*/
|
|
9
|
+
import { IconBookmark, IconLink, IconTrash, IconX } from '@tabler/icons-react';
|
|
10
|
+
import { Anchor, Badge, Box, Button, Checkbox, CopyButton, Flex, Group, MultiSelect, Popover, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
|
11
|
+
import type { QueryFacets, QueryFacetValue } from '@crvouga/atlas/reports/artifact-query';
|
|
12
|
+
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
13
|
+
import { Link } from '@tanstack/react-router';
|
|
14
|
+
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
15
|
+
|
|
16
|
+
import { currentProject } from '../runtime';
|
|
17
|
+
import { patched, useExplorerNavigate } from './navigate';
|
|
18
|
+
import { filtered, joined, NO_FILTERS, toggled, values, type ExplorerSearch } from './search';
|
|
19
|
+
import { filtersOf, isView, readViews, writeViews, type SavedView } from './views';
|
|
20
|
+
|
|
21
|
+
type Param = 'artifactTag' | 'type' | 'label' | 'env' | 'branch' | 'outcome' | 'domain' | 'producer';
|
|
22
|
+
|
|
23
|
+
const NOUN: Record<Param, string> = {
|
|
24
|
+
artifactTag: 'Tag',
|
|
25
|
+
type: 'Type',
|
|
26
|
+
label: 'Label',
|
|
27
|
+
env: 'Environment',
|
|
28
|
+
branch: 'Branch',
|
|
29
|
+
outcome: 'Outcome',
|
|
30
|
+
domain: 'Domain',
|
|
31
|
+
producer: 'Producer'
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** How a chosen value reads: tags with their hash, environments by name. */
|
|
35
|
+
function shown(param: Param, value: string): string {
|
|
36
|
+
if (param === 'artifactTag') return `#${value}`;
|
|
37
|
+
if (param === 'env') return environmentLabel(value);
|
|
38
|
+
if (param === 'domain' && value === '(no spec)') return 'No spec';
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function Facet({ param, facets, search }: { param: Param; facets: readonly QueryFacetValue[]; search: ExplorerSearch }) {
|
|
43
|
+
const go = useExplorerNavigate();
|
|
44
|
+
const chosen = values(search[param]);
|
|
45
|
+
const data = useMemo(() => facets.map((facet) => ({ value: facet.value, label: `${shown(param, facet.value)} (${facet.count})` })), [facets, param]);
|
|
46
|
+
if (!data.length && !chosen.length) return null;
|
|
47
|
+
return (
|
|
48
|
+
<MultiSelect
|
|
49
|
+
label={NOUN[param]}
|
|
50
|
+
data={data}
|
|
51
|
+
value={chosen}
|
|
52
|
+
searchable
|
|
53
|
+
clearable
|
|
54
|
+
placeholder={chosen.length ? undefined : `Any ${NOUN[param].toLowerCase()}`}
|
|
55
|
+
nothingFoundMessage="Nothing found"
|
|
56
|
+
onChange={(next) => go({ [param]: joined(next) } as never)}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Saved views: name the current filters, open one again, delete it. They are live queries, said so beside the list. */
|
|
62
|
+
function Views({
|
|
63
|
+
search,
|
|
64
|
+
tab,
|
|
65
|
+
views,
|
|
66
|
+
onChange,
|
|
67
|
+
failed
|
|
68
|
+
}: {
|
|
69
|
+
search: ExplorerSearch;
|
|
70
|
+
tab: 'runs' | 'artifacts';
|
|
71
|
+
views: readonly SavedView[];
|
|
72
|
+
onChange: (next: SavedView[]) => void;
|
|
73
|
+
failed: boolean;
|
|
74
|
+
}) {
|
|
75
|
+
const project = currentProject() ?? '';
|
|
76
|
+
const [name, setName] = useState('');
|
|
77
|
+
const mine = views.filter((view) => view.project === project && view.tab === tab);
|
|
78
|
+
const keep = onChange;
|
|
79
|
+
const narrowed = filtered(search);
|
|
80
|
+
return (
|
|
81
|
+
<Popover width={360} position="bottom-start" withArrow shadow="md" trapFocus>
|
|
82
|
+
<Popover.Target>
|
|
83
|
+
<Button type="button" variant="default" leftSection={<IconBookmark size={16} aria-hidden="true" />}>
|
|
84
|
+
Views{mine.length ? ` (${mine.length})` : ''}
|
|
85
|
+
</Button>
|
|
86
|
+
</Popover.Target>
|
|
87
|
+
<Popover.Dropdown>
|
|
88
|
+
<Stack gap="sm" aria-label="Saved views">
|
|
89
|
+
<Text size="sm" c="dimmed">
|
|
90
|
+
A saved view is a live query: opening it asks again, so it shows what matches the newest report now. It is not a snapshot of the results you see today.
|
|
91
|
+
</Text>
|
|
92
|
+
{mine.length === 0 && (
|
|
93
|
+
<Text size="sm" c="dimmed">
|
|
94
|
+
No saved views for this tab yet.
|
|
95
|
+
</Text>
|
|
96
|
+
)}
|
|
97
|
+
{mine.map((view) => (
|
|
98
|
+
<Group key={view.id} justify="space-between" wrap="nowrap">
|
|
99
|
+
<Anchor
|
|
100
|
+
aria-label={`Open view ${view.name}`}
|
|
101
|
+
renderRoot={(props) => (
|
|
102
|
+
<Link
|
|
103
|
+
{...props}
|
|
104
|
+
to="/"
|
|
105
|
+
search={patched({ ...NO_FILTERS, ...view.filters, tab: view.tab, openRun: undefined, test: undefined, layer: undefined } as never)}
|
|
106
|
+
resetScroll={false}
|
|
107
|
+
activeOptions={{ exact: true }}
|
|
108
|
+
/>
|
|
109
|
+
)}
|
|
110
|
+
>
|
|
111
|
+
{view.name}
|
|
112
|
+
</Anchor>
|
|
113
|
+
<Badge variant="outline" color="gray">
|
|
114
|
+
Live query
|
|
115
|
+
</Badge>
|
|
116
|
+
<Button
|
|
117
|
+
type="button"
|
|
118
|
+
variant="subtle"
|
|
119
|
+
color="red"
|
|
120
|
+
aria-label={`Delete view ${view.name}`}
|
|
121
|
+
onClick={() => keep(views.filter((entry) => entry.id !== view.id))}
|
|
122
|
+
>
|
|
123
|
+
<IconTrash size={16} aria-hidden="true" />
|
|
124
|
+
</Button>
|
|
125
|
+
</Group>
|
|
126
|
+
))}
|
|
127
|
+
<TextInput label="View name" value={name} onChange={(event) => setName(event.target.value)} placeholder="Demo review" />
|
|
128
|
+
<Button
|
|
129
|
+
type="button"
|
|
130
|
+
disabled={!name.trim() || !narrowed}
|
|
131
|
+
onClick={() => {
|
|
132
|
+
const saved: SavedView = {
|
|
133
|
+
id: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
|
|
134
|
+
name: name.trim(),
|
|
135
|
+
tab,
|
|
136
|
+
project,
|
|
137
|
+
filters: filtersOf(search),
|
|
138
|
+
savedAtMs: Date.now()
|
|
139
|
+
};
|
|
140
|
+
keep([...views.filter((view) => !(view.project === project && view.tab === tab && view.name === saved.name)), saved]);
|
|
141
|
+
setName('');
|
|
142
|
+
}}
|
|
143
|
+
>
|
|
144
|
+
Save the current filters
|
|
145
|
+
</Button>
|
|
146
|
+
{!narrowed && (
|
|
147
|
+
<Text size="sm" c="dimmed">
|
|
148
|
+
Choose at least one filter to save a view.
|
|
149
|
+
</Text>
|
|
150
|
+
)}
|
|
151
|
+
{failed && (
|
|
152
|
+
<Text size="sm" c="red.7" role="alert">
|
|
153
|
+
This browser would not keep the view, so it will be gone when the page closes.
|
|
154
|
+
</Text>
|
|
155
|
+
)}
|
|
156
|
+
</Stack>
|
|
157
|
+
</Popover.Dropdown>
|
|
158
|
+
</Popover>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The filter bar for a tab. `facets` are the values on offer with their counts; `extra` is shown beside the controls (the
|
|
164
|
+
* Runs tab's Has video button); `domains` offers the feature domain (artifacts only).
|
|
165
|
+
*/
|
|
166
|
+
export function FilterBar({
|
|
167
|
+
facets,
|
|
168
|
+
search,
|
|
169
|
+
tab,
|
|
170
|
+
extra
|
|
171
|
+
}: {
|
|
172
|
+
facets: QueryFacets;
|
|
173
|
+
search: ExplorerSearch;
|
|
174
|
+
tab: 'runs' | 'artifacts';
|
|
175
|
+
extra?: ReactNode;
|
|
176
|
+
}) {
|
|
177
|
+
const go = useExplorerNavigate();
|
|
178
|
+
const [draft, setDraft] = useState(search.q ?? '');
|
|
179
|
+
// The address may change from elsewhere (a link, a saved view, back): follow it.
|
|
180
|
+
useEffect(() => setDraft((current) => (current.trim() === (search.q ?? '').trim() ? current : (search.q ?? ''))), [search.q]);
|
|
181
|
+
const params: Param[] = ['artifactTag', 'type', 'label', 'env', 'branch', 'outcome', ...(tab === 'artifacts' ? (['domain'] as const) : []), 'producer'];
|
|
182
|
+
const chips = [
|
|
183
|
+
...params.flatMap((param) =>
|
|
184
|
+
values(search[param]).map((value) => ({ label: `${NOUN[param]}: ${shown(param, value)}`, clear: { [param]: toggled(search[param], value) } as never }))
|
|
185
|
+
),
|
|
186
|
+
...(search.since ? [{ label: `From ${search.since}`, clear: { since: undefined } as never }] : []),
|
|
187
|
+
...(search.until ? [{ label: `Until ${search.until}`, clear: { until: undefined } as never }] : []),
|
|
188
|
+
...(search.q ? [{ label: `Words: ${search.q}`, clear: { q: undefined } as never }] : []),
|
|
189
|
+
...(search.video ? [{ label: 'Has video', clear: { video: undefined } as never }] : [])
|
|
190
|
+
];
|
|
191
|
+
const [views, setViews] = useState<SavedView[]>(() => readViews());
|
|
192
|
+
const [failed, setFailed] = useState(false);
|
|
193
|
+
const saved = views.find((view) => view.project === (currentProject() ?? '') && isView(view, search));
|
|
194
|
+
return (
|
|
195
|
+
<Box aria-label="Filters" component="section" mb="md">
|
|
196
|
+
<Stack gap="sm">
|
|
197
|
+
<Flex gap="sm" align="flex-end" wrap="wrap">
|
|
198
|
+
<TextInput
|
|
199
|
+
flex={1}
|
|
200
|
+
miw={{ base: '100%', sm: '15rem' }}
|
|
201
|
+
type="search"
|
|
202
|
+
aria-label={`Search ${tab}`}
|
|
203
|
+
placeholder={tab === 'runs' ? 'Search runs by title, branch, commit, tag…' : 'Search by test, spec, tag, label…'}
|
|
204
|
+
value={draft}
|
|
205
|
+
onChange={(event) => {
|
|
206
|
+
setDraft(event.target.value);
|
|
207
|
+
go({ q: event.target.value.trim() || undefined }, { replace: true });
|
|
208
|
+
}}
|
|
209
|
+
/>
|
|
210
|
+
<Views
|
|
211
|
+
search={search}
|
|
212
|
+
tab={tab}
|
|
213
|
+
views={views}
|
|
214
|
+
failed={failed}
|
|
215
|
+
onChange={(next) => {
|
|
216
|
+
setViews(next);
|
|
217
|
+
setFailed(!writeViews(next));
|
|
218
|
+
}}
|
|
219
|
+
/>
|
|
220
|
+
<CopyButton value={typeof window === 'undefined' ? '' : window.location.href}>
|
|
221
|
+
{({ copied, copy }) => (
|
|
222
|
+
<Button type="button" variant="default" onClick={copy} leftSection={<IconLink size={16} aria-hidden="true" />}>
|
|
223
|
+
{copied ? 'Link copied' : 'Copy link'}
|
|
224
|
+
</Button>
|
|
225
|
+
)}
|
|
226
|
+
</CopyButton>
|
|
227
|
+
{extra}
|
|
228
|
+
</Flex>
|
|
229
|
+
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
|
|
230
|
+
<Facet param="artifactTag" facets={facets.artifactTags} search={search} />
|
|
231
|
+
<Facet param="type" facets={facets.types} search={search} />
|
|
232
|
+
<Facet param="label" facets={facets.labels} search={search} />
|
|
233
|
+
<Facet param="env" facets={facets.environments} search={search} />
|
|
234
|
+
<Facet param="branch" facets={facets.branches} search={search} />
|
|
235
|
+
<Facet param="outcome" facets={facets.outcomes} search={search} />
|
|
236
|
+
{tab === 'artifacts' && facets.domains && <Facet param="domain" facets={facets.domains} search={search} />}
|
|
237
|
+
{facets.producers.length > 1 && <Facet param="producer" facets={facets.producers} search={search} />}
|
|
238
|
+
<TextInput type="date" label="From" value={search.since ?? ''} onChange={(event) => go({ since: event.target.value || undefined })} />
|
|
239
|
+
<TextInput type="date" label="Until" value={search.until ?? ''} onChange={(event) => go({ until: event.target.value || undefined })} />
|
|
240
|
+
</SimpleGrid>
|
|
241
|
+
{values(search.artifactTag).length > 0 && (
|
|
242
|
+
<Checkbox
|
|
243
|
+
label="Also match tags inherited from the check and run"
|
|
244
|
+
description="Off: only tags written on the recording itself."
|
|
245
|
+
checked={search.inherited === true}
|
|
246
|
+
onChange={(event) => go({ inherited: event.currentTarget.checked ? true : undefined })}
|
|
247
|
+
/>
|
|
248
|
+
)}
|
|
249
|
+
{(chips.length > 0 || saved) && (
|
|
250
|
+
<Flex gap="xs" align="center" wrap="wrap" aria-label="Active filters">
|
|
251
|
+
{saved && (
|
|
252
|
+
<Badge variant="light" aria-label={`Live query ${saved.name}`}>
|
|
253
|
+
Live query: {saved.name}
|
|
254
|
+
</Badge>
|
|
255
|
+
)}
|
|
256
|
+
{chips.map((chip) => (
|
|
257
|
+
<Button key={chip.label} type="button" onClick={() => go(chip.clear)} aria-label={`Remove ${chip.label}`} variant="light">
|
|
258
|
+
{chip.label}{' '}
|
|
259
|
+
<Text aria-hidden="true" span inherit>
|
|
260
|
+
<IconX size={16} aria-hidden="true" />
|
|
261
|
+
</Text>
|
|
262
|
+
</Button>
|
|
263
|
+
))}
|
|
264
|
+
{filtered(search) && (
|
|
265
|
+
<Button type="button" variant="subtle" onClick={() => go(NO_FILTERS)}>
|
|
266
|
+
Clear filters
|
|
267
|
+
</Button>
|
|
268
|
+
)}
|
|
269
|
+
{saved && (
|
|
270
|
+
<Text size="sm" c="dimmed">
|
|
271
|
+
A live query, not a snapshot: it follows the newest report.
|
|
272
|
+
</Text>
|
|
273
|
+
)}
|
|
274
|
+
</Flex>
|
|
275
|
+
)}
|
|
276
|
+
</Stack>
|
|
277
|
+
</Box>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
@@ -3,8 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Everything the test explorer shows is in the address of the `/` route, so a link reproduces the view: the open test,
|
|
5
5
|
* the words searched, the filters, the grouping, the order, the Runs tab with the run opened in it, and the exact layer a test's detail is pinned to. Filters that
|
|
6
|
-
* take several values are comma-separated (`status=failed,flaky`)
|
|
6
|
+
* take several values are comma-separated (`status=failed,flaky`); a comma or backslash inside a value is written `\,`
|
|
7
|
+
* or `\\`, and the router encodes `#` and spaces. Anything unreadable is dropped, never an error.
|
|
8
|
+
*
|
|
9
|
+
* The Runs and Artifacts tabs share the tests' `q`, `env`, `producer`, `domain` and `spec` and add `artifactTag`,
|
|
10
|
+
* `type`, `label`, `branch`, `outcome`, `since` and `until`, so a link to one tab's filters opens the same query on another.
|
|
7
11
|
*/
|
|
12
|
+
import { joinList, splitList, type ArtifactFilter } from '@crvouga/atlas/reports/artifact-query';
|
|
8
13
|
import { TEST_SORTS, type TestQuery, type TestSort } from '@crvouga/atlas/reports/test-catalog';
|
|
9
14
|
import { z } from 'zod/v4';
|
|
10
15
|
|
|
@@ -30,11 +35,27 @@ export const ExplorerSearchFields = {
|
|
|
30
35
|
domain: text,
|
|
31
36
|
spec: text,
|
|
32
37
|
tag: text,
|
|
38
|
+
/** Artifact tags (`demo`, shown `#demo`): tests with an attachment carrying one; runs and artifacts likewise. */
|
|
39
|
+
artifactTag: text,
|
|
40
|
+
/** With `artifactTag`: also the tags attachments inherit from their check and run. */
|
|
41
|
+
inherited: z.preprocess((value) => (value === 'true' ? true : value), z.literal(true)).optional().catch(undefined),
|
|
42
|
+
/** Media types of attachments (`video`, `image`…): runs and artifacts. */
|
|
43
|
+
type: text,
|
|
44
|
+
/** `key=value` labels: runs and artifacts. */
|
|
45
|
+
label: text,
|
|
46
|
+
/** Branches: runs and artifacts. */
|
|
47
|
+
branch: text,
|
|
48
|
+
/** Outcomes of a run or recorded result (`passed`, `failed`…). */
|
|
49
|
+
outcome: text,
|
|
50
|
+
/** Only records from this day (`YYYY-MM-DD`, the viewer's local time). */
|
|
51
|
+
since: text,
|
|
52
|
+
/** Only records until the end of this day (`YYYY-MM-DD`, local time). */
|
|
53
|
+
until: text,
|
|
33
54
|
/** Only the tests a CI run recorded (a `RunSummary.key`). Not `run`, which already names a recorded statechart run. */
|
|
34
55
|
inRun: text,
|
|
35
56
|
group: z.enum(TEST_GROUPS).optional().catch(undefined),
|
|
36
57
|
sort: z.enum(TEST_SORTS).optional().catch(undefined),
|
|
37
|
-
tab: z.enum(['runs']).optional().catch(undefined),
|
|
58
|
+
tab: z.enum(['runs', 'artifacts']).optional().catch(undefined),
|
|
38
59
|
/** The run opened in the Runs tab. */
|
|
39
60
|
openRun: text,
|
|
40
61
|
/** Pins the open test's detail to one exact layer (a run's record of it), instead of the latest combined evidence. */
|
|
@@ -53,10 +74,18 @@ export type ExplorerSearch = {
|
|
|
53
74
|
readonly domain?: string;
|
|
54
75
|
readonly spec?: string;
|
|
55
76
|
readonly tag?: string;
|
|
77
|
+
readonly artifactTag?: string;
|
|
78
|
+
readonly inherited?: true;
|
|
79
|
+
readonly type?: string;
|
|
80
|
+
readonly label?: string;
|
|
81
|
+
readonly branch?: string;
|
|
82
|
+
readonly outcome?: string;
|
|
83
|
+
readonly since?: string;
|
|
84
|
+
readonly until?: string;
|
|
56
85
|
readonly inRun?: string;
|
|
57
86
|
readonly group?: TestGroup;
|
|
58
87
|
readonly sort?: TestSort;
|
|
59
|
-
readonly tab?: 'runs';
|
|
88
|
+
readonly tab?: 'runs' | 'artifacts';
|
|
60
89
|
readonly openRun?: string;
|
|
61
90
|
readonly layer?: string;
|
|
62
91
|
readonly video?: true;
|
|
@@ -64,12 +93,12 @@ export type ExplorerSearch = {
|
|
|
64
93
|
|
|
65
94
|
/** The values of a comma-separated filter, empty when it is not set. */
|
|
66
95
|
export function values(value: string | undefined): string[] {
|
|
67
|
-
return value ? value
|
|
96
|
+
return value ? splitList(value) : [];
|
|
68
97
|
}
|
|
69
98
|
|
|
70
99
|
/** A list of values as one comma-separated parameter, or undefined (left out of the address) when empty. */
|
|
71
100
|
export function joined(list: readonly string[]): string | undefined {
|
|
72
|
-
return list
|
|
101
|
+
return joinList(list);
|
|
73
102
|
}
|
|
74
103
|
|
|
75
104
|
/** Adds a value to a comma-separated filter, or removes it when it is there. */
|
|
@@ -88,6 +117,8 @@ export function queryOf(search: ExplorerSearch): TestQuery {
|
|
|
88
117
|
domain: values(search.domain),
|
|
89
118
|
spec: values(search.spec),
|
|
90
119
|
tag: values(search.tag),
|
|
120
|
+
artifactTag: values(search.artifactTag),
|
|
121
|
+
...(search.inherited ? { inherited: true } : {}),
|
|
91
122
|
sort: search.sort ?? 'spec'
|
|
92
123
|
};
|
|
93
124
|
}
|
|
@@ -99,5 +130,49 @@ export function explorerSearch(search: Record<string, unknown>): ExplorerSearch
|
|
|
99
130
|
|
|
100
131
|
/** True when any filter or search narrows the list. */
|
|
101
132
|
export function narrowed(search: ExplorerSearch): boolean {
|
|
102
|
-
return !!(search.q || search.status || search.env || search.producer || search.domain || search.spec || search.tag || search.inRun);
|
|
133
|
+
return !!(search.q || search.status || search.env || search.producer || search.domain || search.spec || search.tag || search.artifactTag || search.inRun);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The filters of the Runs and Artifacts tabs, in the address. */
|
|
137
|
+
export const FILTER_FIELDS = ['q', 'artifactTag', 'inherited', 'type', 'label', 'env', 'producer', 'branch', 'outcome', 'domain', 'spec', 'since', 'until', 'video'] as const;
|
|
138
|
+
|
|
139
|
+
/** A patch that removes every filter of the Runs and Artifacts tabs. */
|
|
140
|
+
export const NO_FILTERS = Object.fromEntries(FILTER_FIELDS.map((field) => [field, undefined])) as { [K in (typeof FILTER_FIELDS)[number]]?: undefined };
|
|
141
|
+
|
|
142
|
+
/** True when an address narrows the Runs and Artifacts tabs. */
|
|
143
|
+
export function filtered(search: ExplorerSearch): boolean {
|
|
144
|
+
return FILTER_FIELDS.some((field) => field !== 'inherited' && search[field] !== undefined);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Midnight at the start (or the last millisecond) of a `YYYY-MM-DD` day in the viewer's local time, or undefined. */
|
|
148
|
+
function dayMs(day: string | undefined, end: boolean): number | undefined {
|
|
149
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day ?? '');
|
|
150
|
+
if (!match) return undefined;
|
|
151
|
+
const [year, month, date] = [Number(match[1]), Number(match[2]) - 1, Number(match[3])];
|
|
152
|
+
const value = new Date(year, month, date + (end ? 1 : 0)).getTime() - (end ? 1 : 0);
|
|
153
|
+
return Number.isFinite(value) ? value : undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The query the Runs and Artifacts tabs ask for: the address's filters in the shape the report API and `matchesFilter` read. */
|
|
157
|
+
export function filterOf(search: ExplorerSearch): ArtifactFilter {
|
|
158
|
+
const list = (value: string | undefined) => (values(value).length ? values(value) : undefined);
|
|
159
|
+
const since = dayMs(search.since, false);
|
|
160
|
+
const until = dayMs(search.until, true);
|
|
161
|
+
const parts = {
|
|
162
|
+
q: search.q,
|
|
163
|
+
artifactTag: list(search.artifactTag),
|
|
164
|
+
inherited: search.inherited,
|
|
165
|
+
type: list(search.type),
|
|
166
|
+
label: list(search.label),
|
|
167
|
+
environment: list(search.env),
|
|
168
|
+
producer: list(search.producer),
|
|
169
|
+
branch: list(search.branch),
|
|
170
|
+
outcome: list(search.outcome),
|
|
171
|
+
domain: list(search.domain),
|
|
172
|
+
spec: list(search.spec),
|
|
173
|
+
since,
|
|
174
|
+
until,
|
|
175
|
+
video: search.video
|
|
176
|
+
};
|
|
177
|
+
return Object.fromEntries(Object.entries(parts).filter(([, value]) => value !== undefined)) as ArtifactFilter;
|
|
103
178
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Saved views
|
|
3
|
+
*
|
|
4
|
+
* A saved view is a name for a set of filters on the Runs or Artifacts tab of one project, kept in this browser. It is a
|
|
5
|
+
* live query, not a snapshot: opening it asks again and shows what matches the newest report, so its results change as
|
|
6
|
+
* runs arrive. (An immutable collection of reviewed results is a different thing, and is not what this stores.) Unreadable
|
|
7
|
+
* storage is treated as no saved views, never as an error.
|
|
8
|
+
*/
|
|
9
|
+
import { FILTER_FIELDS, type ExplorerSearch } from './search';
|
|
10
|
+
|
|
11
|
+
/** The storage key of the saved views of this browser, all projects. */
|
|
12
|
+
export const VIEWS_KEY = 'atlas.savedViews.v1';
|
|
13
|
+
|
|
14
|
+
/** A saved live query. `filters` holds only the filter fields of the address, as the address writes them. */
|
|
15
|
+
export type SavedView = {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly name: string;
|
|
18
|
+
/** The tab it opens on. */
|
|
19
|
+
readonly tab: 'runs' | 'artifacts';
|
|
20
|
+
/** The project it belongs to (the page's `?project=`), or an empty string. */
|
|
21
|
+
readonly project: string;
|
|
22
|
+
readonly filters: Readonly<Record<string, string | true>>;
|
|
23
|
+
readonly savedAtMs: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** The filters of an address, ready to save: only the fields that narrow, with their values. */
|
|
27
|
+
export function filtersOf(search: ExplorerSearch): Record<string, string | true> {
|
|
28
|
+
const filters: Record<string, string | true> = {};
|
|
29
|
+
for (const field of FILTER_FIELDS) {
|
|
30
|
+
const value = search[field];
|
|
31
|
+
if (value !== undefined) filters[field] = value;
|
|
32
|
+
}
|
|
33
|
+
return filters;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Whether an address is exactly a saved view's query on its tab (ignoring anything that is not a filter). */
|
|
37
|
+
export function isView(view: SavedView, search: ExplorerSearch): boolean {
|
|
38
|
+
if ((search.tab ?? 'tests') !== view.tab) return false;
|
|
39
|
+
const now = filtersOf(search);
|
|
40
|
+
const keys = Object.keys(now);
|
|
41
|
+
return keys.length === Object.keys(view.filters).length && keys.every((key) => now[key] === view.filters[key]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The saved views of this browser, oldest first; empty when none or when storage cannot be read. */
|
|
45
|
+
export function readViews(storage: Pick<Storage, 'getItem'> | undefined = safeStorage()): SavedView[] {
|
|
46
|
+
try {
|
|
47
|
+
const parsed: unknown = JSON.parse(storage?.getItem(VIEWS_KEY) ?? '[]');
|
|
48
|
+
if (!Array.isArray(parsed)) return [];
|
|
49
|
+
return parsed.filter(
|
|
50
|
+
(item): item is SavedView =>
|
|
51
|
+
!!item && typeof item.id === 'string' && typeof item.name === 'string' && (item.tab === 'runs' || item.tab === 'artifacts') && typeof item.project === 'string' && !!item.filters && typeof item.filters === 'object'
|
|
52
|
+
);
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Keeps the saved views. Returns false when storage refused them (full, private or unavailable), so the page can say so. */
|
|
59
|
+
export function writeViews(views: readonly SavedView[], storage: Pick<Storage, 'setItem'> | undefined = safeStorage()): boolean {
|
|
60
|
+
try {
|
|
61
|
+
if (!storage) return false;
|
|
62
|
+
storage.setItem(VIEWS_KEY, JSON.stringify(views));
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function safeStorage(): Storage | undefined {
|
|
70
|
+
try {
|
|
71
|
+
return typeof localStorage === 'undefined' ? undefined : localStorage;
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|