@crvouga/atlas 1.19.0 → 1.20.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 +7 -1
- package/dist/schema/src/api.d.ts +55 -0
- package/dist/schema/src/api.js +33 -0
- package/dist/server/src/app.js +3 -1
- package/dist/server/src/openapi.d.ts +1 -1
- package/dist/server/src/openapi.js +24 -2
- package/dist/server/src/recording-verification.d.ts +34 -0
- package/dist/server/src/recording-verification.js +118 -0
- package/docs/reference/http-api.md +52 -0
- package/docs/reference/schema.md +41 -0
- package/docs/reference/server.md +39 -0
- package/docs/rich-evidence.md +12 -0
- package/docs/server.md +12 -0
- package/llms-full.txt +76 -0
- package/package.json +1 -1
- package/packages/server/openapi.json +527 -1
- package/packages/visualizer/src/explorer/RunsView.tsx +32 -6
- package/packages/visualizer/src/explorer/catalog.tsx +24 -1
- package/packages/visualizer/src/explorer/recording-verification.ts +58 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { IconAlertTriangle, IconArrowLeft, IconPlayerPlay, IconVideo } from '@tabler/icons-react';
|
|
1
|
+
import { IconAlertTriangle, IconArrowLeft, IconCheck, IconPlayerPlay, IconVideo } from '@tabler/icons-react';
|
|
2
2
|
import { Alert, Anchor, Badge, Box, Button, Code, Flex, Group, List, NativeSelect, Stack, Table, Text, TextInput, Title, VisuallyHidden } from '@mantine/core';
|
|
3
3
|
/**
|
|
4
4
|
* @module Runs view
|
|
@@ -23,7 +23,8 @@ import { hasFilters, passesFilters, type ParsedSearch, type SearchFilters } from
|
|
|
23
23
|
import type { FindDoc, Ranked } from '../find/rank';
|
|
24
24
|
import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidence/format';
|
|
25
25
|
import { Meter, StatusMark } from '../evidence/marks';
|
|
26
|
-
import { useRunDetail, useRunList, useTestCatalog } from './catalog';
|
|
26
|
+
import { useRecordingVerification, useRunDetail, useRunList, useTestCatalog } from './catalog';
|
|
27
|
+
import { allStoredVideosGone, verificationOfRun, type RunVerification } from './recording-verification';
|
|
27
28
|
import { RunArtifacts } from './RunArtifacts';
|
|
28
29
|
import { entriesFromRun } from './playlist-entries';
|
|
29
30
|
import { WatchPlaylist } from './playlist';
|
|
@@ -126,9 +127,30 @@ function RunRecordings({ run }: { run: RunSummary }) {
|
|
|
126
127
|
</Badge>
|
|
127
128
|
);
|
|
128
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* What the server's read-back says about a run's stored videos, apart from its test outcomes: some are gone or damaged
|
|
132
|
+
* (an orange mark that names the problems), or all were read back and are as published. Nothing when they were never
|
|
133
|
+
* read back: an unverified run is not shown as verified.
|
|
134
|
+
*/
|
|
135
|
+
function StoredVideos({ verification }: { verification: RunVerification | null }) {
|
|
136
|
+
if (!verification) return null;
|
|
137
|
+
const at = `Read back from storage ${exactTime(verification.atMs)}. This checks the stored files, not playback in a browser. The test outcomes are unaffected.`;
|
|
138
|
+
if (verification.gaps === 0)
|
|
139
|
+
return (
|
|
140
|
+
<Text component="div" size="xs" c="dimmed" title={at} data-stored-videos="verified">
|
|
141
|
+
<IconCheck size={16} aria-hidden="true" /> {verification.checked > 0 ? `${plural(verification.ok, 'stored video', 'stored videos')} verified` : 'No stored videos to verify'}
|
|
142
|
+
</Text>
|
|
143
|
+
);
|
|
144
|
+
return (
|
|
145
|
+
<Text component="div" size="xs" c="orange.9" title={at} data-stored-videos="unavailable">
|
|
146
|
+
<IconAlertTriangle size={16} aria-hidden="true" /> Recording unavailable: {verification.gaps} of {plural(verification.checked, 'stored video', 'stored videos')} failed the read-back ({verification.problems.join(', ')}).
|
|
147
|
+
</Text>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
129
150
|
/** A run's videos in its row: a link to the recordings, how many references they come from, and what cannot be played. */
|
|
130
|
-
function RunVideos({ run }: { run: RunSummary }) {
|
|
151
|
+
function RunVideos({ run, verification }: { run: RunSummary; verification: RunVerification | null }) {
|
|
131
152
|
const videos = run.videos;
|
|
153
|
+
const gone = videos !== undefined && allStoredVideosGone(verification, videos.recordings);
|
|
132
154
|
const files = run.attachments ? (
|
|
133
155
|
<Anchor
|
|
134
156
|
aria-label={`Open the ${plural(run.attachments, 'file', 'files')} of run ${runName(run.key)}`}
|
|
@@ -151,9 +173,9 @@ function RunVideos({ run }: { run: RunSummary }) {
|
|
|
151
173
|
</Stack>
|
|
152
174
|
);
|
|
153
175
|
return (
|
|
154
|
-
<Stack gap={0} data-video={videos.recordings > 0 ? 'available' : 'unavailable'}>
|
|
176
|
+
<Stack gap={0} data-video={videos.recordings > 0 && !gone ? 'available' : 'unavailable'}>
|
|
155
177
|
{files}
|
|
156
|
-
{videos.recordings > 0 && (
|
|
178
|
+
{videos.recordings > 0 && !gone && (
|
|
157
179
|
<Anchor
|
|
158
180
|
aria-label={`Watch ${plural(videos.recordings, 'recording', 'recordings')} in run ${runName(run.key)}`}
|
|
159
181
|
renderRoot={(props) => (
|
|
@@ -169,6 +191,7 @@ function RunVideos({ run }: { run: RunSummary }) {
|
|
|
169
191
|
</Text>
|
|
170
192
|
)}
|
|
171
193
|
<Unavailable videos={videos} />
|
|
194
|
+
<StoredVideos verification={verification} />
|
|
172
195
|
</Stack>
|
|
173
196
|
);
|
|
174
197
|
}
|
|
@@ -258,6 +281,7 @@ function RunTable() {
|
|
|
258
281
|
}, [search]);
|
|
259
282
|
const narrow = filtered(search);
|
|
260
283
|
const runs = useRunList(true, filter, searching);
|
|
284
|
+
const stored = useRecordingVerification();
|
|
261
285
|
const now = useNow();
|
|
262
286
|
const [shown, setShown] = useState(RUN_ROWS);
|
|
263
287
|
const docs = useMemo(() => (runs.data?.runs ?? []).map((run) => runDoc(run, runLabel(run))), [runs.data]);
|
|
@@ -406,7 +430,7 @@ function RunTable() {
|
|
|
406
430
|
</Table.Td>
|
|
407
431
|
<Table.Td>{formatMs(run.spanMs)}</Table.Td>
|
|
408
432
|
<Table.Td>
|
|
409
|
-
<RunVideos run={run} />
|
|
433
|
+
<RunVideos run={run} verification={verificationOfRun(stored.data, run.key)} />
|
|
410
434
|
<RunRecordings run={run} />
|
|
411
435
|
</Table.Td>
|
|
412
436
|
<Table.Td data-tags>{run.artifactTags?.map((tag) => `#${tag}`).join(' ') || '—'}</Table.Td>
|
|
@@ -461,6 +485,7 @@ function RunDetailView({ runKey }: { runKey: string }) {
|
|
|
461
485
|
const go = useExplorerNavigate();
|
|
462
486
|
const video = search.video === true;
|
|
463
487
|
const detail = useRunDetail(runKey);
|
|
488
|
+
const stored = useRecordingVerification();
|
|
464
489
|
const now = useNow();
|
|
465
490
|
const [status, setStatus] = useState('');
|
|
466
491
|
const [shown, setShown] = useState(RESULT_ROWS);
|
|
@@ -598,6 +623,7 @@ function RunDetailView({ runKey }: { runKey: string }) {
|
|
|
598
623
|
<Unavailable videos={run.videos} />
|
|
599
624
|
</Text>
|
|
600
625
|
)}
|
|
626
|
+
<StoredVideos verification={verificationOfRun(stored.data, run.key)} />
|
|
601
627
|
<RunArtifacts detail={detail.data} />
|
|
602
628
|
<Flex gap="xs" align="center" wrap="wrap">
|
|
603
629
|
<Anchor
|
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
type TestHistoryDocument,
|
|
45
45
|
type TestSummary
|
|
46
46
|
} from '@crvouga/atlas/reports/test-catalog';
|
|
47
|
-
import { isStatechartFile } from '@crvouga/atlas/schema';
|
|
47
|
+
import { isStatechartFile, type RecordingVerification } from '@crvouga/atlas/schema';
|
|
48
48
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
|
49
49
|
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
50
50
|
|
|
@@ -373,6 +373,29 @@ export function useRunDetail(key: string | undefined) {
|
|
|
373
373
|
});
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
+
/**
|
|
377
|
+
* What the server last read back of the stored videos, per layer (`GET /recordings/verification`; see
|
|
378
|
+
* `recording-verification.ts`). The question is only asked of a server, never reads a blob, and an older server (no route)
|
|
379
|
+
* or an unanswered request means nothing is known: `null`, which is never shown as verified.
|
|
380
|
+
*/
|
|
381
|
+
export function useRecordingVerification() {
|
|
382
|
+
const catalog = useTestCatalog();
|
|
383
|
+
return useQuery({
|
|
384
|
+
queryKey: ['recording-verification', catalog.version],
|
|
385
|
+
queryFn: async ({ signal }): Promise<RecordingVerification | null> => {
|
|
386
|
+
try {
|
|
387
|
+
return await reportJson<RecordingVerification>('/recordings/verification', signal);
|
|
388
|
+
} catch (error) {
|
|
389
|
+
if (signal.aborted) throw error;
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
enabled: catalog.source === 'server',
|
|
394
|
+
staleTime: 30_000,
|
|
395
|
+
retry: false
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
376
399
|
/** What the Catalog health tab reads: the diagnostics, or that this server is too old to give them. */
|
|
377
400
|
export type HealthAnswer = { readonly unsupported: false; readonly health: CatalogHealth } | { readonly unsupported: true };
|
|
378
401
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Recording verification of a run
|
|
3
|
+
*
|
|
4
|
+
* What the server's read-back of stored videos says about one run, for the Runs tab: how many of its stored videos were
|
|
5
|
+
* read back, how many passed, and which problems the others have. It is evidence about storage, kept apart from the run's
|
|
6
|
+
* test outcomes: it never changes how a run is counted, and "never read back" is `null`, not "verified".
|
|
7
|
+
*/
|
|
8
|
+
import type { RecordingVerification } from '@crvouga/atlas/schema';
|
|
9
|
+
|
|
10
|
+
/** The words for each way a stored recording can be wrong; an unknown problem is still a gap and is named as it came. */
|
|
11
|
+
const PROBLEMS: Readonly<Record<string, string>> = {
|
|
12
|
+
'blob-missing': 'file gone from storage',
|
|
13
|
+
'not-in-bundle': 'file missing from the run’s bundle',
|
|
14
|
+
unreadable: 'storage could not be read',
|
|
15
|
+
'size-mismatch': 'size differs from what was published',
|
|
16
|
+
'checksum-mismatch': 'content differs from what was published',
|
|
17
|
+
undecodable: 'not the video format it claims to be'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** What was read back of one run's stored recordings. */
|
|
21
|
+
export type RunVerification = {
|
|
22
|
+
/** Videos read back: those that name a stored file. */
|
|
23
|
+
readonly checked: number;
|
|
24
|
+
readonly ok: number;
|
|
25
|
+
/** Each problem in words, with how many videos have it, most common first. */
|
|
26
|
+
readonly problems: readonly string[];
|
|
27
|
+
readonly gaps: number;
|
|
28
|
+
/** When the server last read the run's layers back, epoch milliseconds (the oldest of them). */
|
|
29
|
+
readonly atMs: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What the read-back found for the run with this key, or `null` when none of its layers was read back (the page then says
|
|
34
|
+
* nothing about its stored files). A run is as unavailable as its worst layer: gaps add up across its layers.
|
|
35
|
+
*/
|
|
36
|
+
export function verificationOfRun(document: RecordingVerification | null | undefined, runKey: string): RunVerification | null {
|
|
37
|
+
const layers = document?.layers.filter((layer) => layer.run === runKey) ?? [];
|
|
38
|
+
if (!layers.length) return null;
|
|
39
|
+
const counts = new Map<string, number>();
|
|
40
|
+
let checked = 0, ok = 0, gaps = 0, atMs = Infinity;
|
|
41
|
+
for (const layer of layers) {
|
|
42
|
+
checked += layer.checked;
|
|
43
|
+
ok += layer.ok;
|
|
44
|
+
atMs = Math.min(atMs, layer.verifiedAtMs);
|
|
45
|
+
for (const gap of layer.gaps) {
|
|
46
|
+
gaps += 1;
|
|
47
|
+
const word = PROBLEMS[gap.problem] ?? gap.problem;
|
|
48
|
+
counts.set(word, (counts.get(word) ?? 0) + 1);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const problems = [...counts].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).map(([word, count]) => `${count} ${word}`);
|
|
52
|
+
return { checked, ok, problems, gaps, atMs };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Whether a run's stored videos were read back and none of them can be played: its Watch action would lead nowhere. */
|
|
56
|
+
export function allStoredVideosGone(verification: RunVerification | null, playable: number): boolean {
|
|
57
|
+
return verification !== null && verification.gaps > 0 && verification.ok === 0 && verification.checked >= playable;
|
|
58
|
+
}
|