@crvouga/atlas 0.17.0 → 1.1.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +52 -8
  3. package/dist/core/src/cli.d.ts +1 -1
  4. package/dist/core/src/cli.js +1 -1
  5. package/dist/reports/src/garbage.d.ts +39 -0
  6. package/dist/reports/src/garbage.js +82 -0
  7. package/dist/reports/src/report-index.d.ts +6 -0
  8. package/dist/reports/src/report-index.js +14 -0
  9. package/dist/schema/src/index.d.ts +1 -0
  10. package/dist/schema/src/index.js +1 -0
  11. package/dist/schema/src/server-config.d.ts +7 -49
  12. package/dist/schema/src/server-config.js +8 -21
  13. package/dist/schema/src/user-state.d.ts +115 -0
  14. package/dist/schema/src/user-state.js +54 -0
  15. package/dist/server/src/app.d.ts +16 -2
  16. package/dist/server/src/app.js +62 -0
  17. package/dist/server/src/config.d.ts +16 -21
  18. package/dist/server/src/config.js +48 -57
  19. package/dist/server/src/dev.d.ts +1 -1
  20. package/dist/server/src/dev.js +20 -7
  21. package/dist/server/src/index.d.ts +5 -1
  22. package/dist/server/src/index.js +5 -1
  23. package/dist/server/src/main.d.ts +23 -7
  24. package/dist/server/src/main.js +72 -26
  25. package/dist/server/src/openapi.js +24 -5
  26. package/dist/server/src/projects.js +89 -14
  27. package/dist/server/src/settings.d.ts +23 -7
  28. package/dist/server/src/settings.js +50 -60
  29. package/dist/server/src/storage.d.ts +68 -0
  30. package/dist/server/src/storage.js +125 -0
  31. package/dist/server/src/user-state.d.ts +31 -0
  32. package/dist/server/src/user-state.js +74 -0
  33. package/docs/agents.md +1 -1
  34. package/docs/deploying.md +38 -39
  35. package/docs/reference/cli.md +12 -7
  36. package/docs/reference/http-api.md +30 -2
  37. package/docs/reference/reports.md +71 -0
  38. package/docs/reference/schema.md +102 -16
  39. package/docs/reference/server.md +263 -41
  40. package/docs/report-backends.md +0 -144
  41. package/docs/report-contract.md +1 -1
  42. package/docs/runners.md +1 -1
  43. package/docs/server.md +141 -36
  44. package/docs/statecharts.md +1 -1
  45. package/docs/ui.md +8 -1
  46. package/docs/workspaces.md +5 -2
  47. package/llms-full.txt +238 -234
  48. package/llms.txt +1 -1
  49. package/package.json +2 -2
  50. package/packages/reports/STORAGE.md +1 -1
  51. package/packages/schema/schemas/gc-policy.schema.json +36 -0
  52. package/packages/schema/schemas/gc-run-result.schema.json +106 -0
  53. package/packages/schema/schemas/gc-snapshot.schema.json +122 -0
  54. package/packages/schema/schemas/server-config.schema.json +0 -41
  55. package/packages/schema/schemas/server-editable-settings.schema.json +0 -41
  56. package/packages/schema/schemas/server-settings-snapshot.schema.json +0 -82
  57. package/packages/schema/schemas/user-state.schema.json +140 -0
  58. package/packages/server/README.md +11 -6
  59. package/packages/server/openapi.json +510 -126
  60. package/packages/visualizer/src/admin/SettingsPage.tsx +183 -88
  61. package/packages/visualizer/src/evidence/check-detail.tsx +6 -1
  62. package/packages/visualizer/src/evidence/evidence-context.tsx +9 -2
  63. package/packages/visualizer/src/evidence/load-state.tsx +65 -0
  64. package/packages/visualizer/src/evidence/overview.tsx +4 -6
  65. package/packages/visualizer/src/evidence/recording-page.tsx +17 -7
  66. package/packages/visualizer/src/evidence/spec-header.tsx +8 -6
  67. package/packages/visualizer/src/evidence/spec-page.tsx +98 -15
  68. package/packages/visualizer/src/find/Finder.tsx +45 -7
  69. package/packages/visualizer/src/spec-kind.test.ts +14 -1
  70. package/packages/visualizer/src/spec-kind.ts +28 -2
@@ -21,6 +21,8 @@ import {
21
21
  PasswordInput,
22
22
  SimpleGrid,
23
23
  Stack,
24
+ Switch,
25
+ Table,
24
26
  Text,
25
27
  TextInput,
26
28
  Title
@@ -28,7 +30,10 @@ import {
28
30
  import { useState } from 'react';
29
31
 
30
32
  type Settings = Record<string, unknown>;
31
- type Backend = { id: string; label?: string; url: string; write?: boolean };
33
+ type GcPolicy = { enabled: boolean; maxAgeDays: number; keepLatest: number; intervalHours: number };
34
+ type GcRun = { project: string; dryRun: boolean; at: string; candidates: number; deletedLayers: number; deletedBlobs: number; keptLayers: number; error?: string };
35
+ /** What to suggest when no policy has been saved yet. Nothing is deleted until the person saves it with "Enabled" on. */
36
+ const SUGGESTED_POLICY: GcPolicy = { enabled: false, maxAgeDays: 90, keepLatest: 20, intervalHours: 24 };
32
37
  type Field = {
33
38
  id: string;
34
39
  label: string;
@@ -39,9 +44,6 @@ type Field = {
39
44
  type?: 'number' | 'boolean' | 'branches';
40
45
  };
41
46
  const fields: Record<string, Field[]> = {
42
- storage: [
43
- { id: 'data', label: 'Local data folder', path: ['data'], description: 'Relative paths start from the server configuration directory.' }
44
- ],
45
47
  live: [
46
48
  {
47
49
  id: 'runTimeoutSeconds',
@@ -138,15 +140,76 @@ export function SettingsPage() {
138
140
  setJson(JSON.stringify(next, null, 2));
139
141
  setJsonError('');
140
142
  };
141
- const backends = (draft.backends as Backend[] | undefined) ?? [];
142
- const changeBackend = (index: number, patch: Partial<Backend>) =>
143
- change({ ...draft, backends: backends.map((b, i) => (i === index ? { ...b, ...patch } : b)) });
143
+ const [policy, setPolicy] = useState<GcPolicy>(SUGGESTED_POLICY);
144
+ const [policySaved, setPolicySaved] = useState(false);
145
+ const [runs, setRuns] = useState<GcRun[]>([]);
146
+ const [preview, setPreview] = useState<GcRun[]>();
147
+ const authorization = (): Record<string, string> => (token ? { authorization: `Bearer ${token}` } : {});
148
+ const failure = (error: unknown) =>
149
+ setMessage({
150
+ text:
151
+ error instanceof SyntaxError
152
+ ? 'The JSON configuration is invalid. Fix it before saving.'
153
+ : error instanceof Error
154
+ ? error.message
155
+ : String(error),
156
+ error: true
157
+ });
158
+ const readJson = async (response: Response) => {
159
+ const data = await response.json();
160
+ if (!response.ok) {
161
+ if (response.status === 409 || response.status === 428) setStale(true);
162
+ throw new Error(data.detail || data.error || `Request failed (${response.status})`);
163
+ }
164
+ return data;
165
+ };
166
+ const applyGc = (data: { revision: string; policy: GcPolicy | null; saved: boolean; lastRuns: GcRun[] }) => {
167
+ setPolicy(data.policy ?? SUGGESTED_POLICY);
168
+ setPolicySaved(data.saved);
169
+ setRuns(data.lastRuns);
170
+ };
171
+ /** The garbage collection policy lives in the user state in S3: this reads and writes it through the server, never the browser. */
172
+ const gcRequest = async (action: 'save' | 'preview') => {
173
+ if (busy || !revision || (action === 'save' && stale)) return;
174
+ setBusy(true);
175
+ try {
176
+ if (action === 'save') {
177
+ const data = await readJson(
178
+ await fetch('/api/settings/gc', {
179
+ method: 'PUT',
180
+ headers: { ...authorization(), 'content-type': 'application/json', 'if-match': `"${revision}"` },
181
+ body: JSON.stringify(policy),
182
+ cache: 'no-store'
183
+ })
184
+ );
185
+ setRevision(data.revision);
186
+ setStale(false);
187
+ applyGc(data);
188
+ setMessage({
189
+ text: data.policy.enabled
190
+ ? 'Garbage collection policy saved to S3. It applies on the next scheduled pass.'
191
+ : 'Garbage collection policy saved to S3 but disabled: nothing will be deleted.',
192
+ success: true
193
+ });
194
+ } else {
195
+ const data = await readJson(
196
+ await fetch('/api/settings/gc/run?dryRun=1', { method: 'POST', headers: authorization(), cache: 'no-store' })
197
+ );
198
+ setPreview(data.runs);
199
+ setMessage({ text: 'Preview finished. Nothing was deleted.' });
200
+ }
201
+ } catch (error) {
202
+ failure(error);
203
+ } finally {
204
+ setBusy(false);
205
+ }
206
+ };
144
207
  const request = async (method: 'GET' | 'PUT') => {
145
208
  if (busy || (method === 'PUT' && (!revision || stale))) return;
146
209
  setBusy(true);
147
210
  if (method === 'GET') setRevision(undefined);
148
211
  try {
149
- const headers: Record<string, string> = token ? { authorization: `Bearer ${token}` } : {};
212
+ const headers: Record<string, string> = authorization();
150
213
  let body: string | undefined;
151
214
  if (method === 'PUT') {
152
215
  const parsed = JSON.parse(json);
@@ -155,16 +218,14 @@ export function SettingsPage() {
155
218
  headers['if-match'] = `"${revision}"`;
156
219
  body = JSON.stringify(parsed);
157
220
  }
158
- const response = await fetch('/api/settings', { method, headers, body, cache: 'no-store' });
159
- const data = await response.json();
160
- if (!response.ok) {
161
- if (response.status === 409 || response.status === 428) setStale(true);
162
- throw new Error(data.detail || data.error || `Request failed (${response.status})`);
163
- }
164
- setRevision(data.revision);
221
+ const data = await readJson(await fetch('/api/settings', { method, headers, body, cache: 'no-store' }));
222
+ // After a load, read the policy too. After a save, the revision (of the whole user state) changed under it.
223
+ const gc = method === 'GET' ? await readJson(await fetch('/api/settings/gc', { headers: authorization(), cache: 'no-store' })) : null;
224
+ setRevision(gc ? gc.revision : data.revision);
165
225
  setStale(false);
166
226
  setActive(data.active);
167
227
  change(data.settings);
228
+ if (gc) { applyGc(gc); setPreview(undefined); }
168
229
  setMessage({
169
230
  text:
170
231
  method === 'PUT' || data.restartRequired
@@ -173,15 +234,7 @@ export function SettingsPage() {
173
234
  success: method === 'PUT' || data.restartRequired
174
235
  });
175
236
  } catch (error) {
176
- setMessage({
177
- text:
178
- error instanceof SyntaxError
179
- ? 'The JSON configuration is invalid. Fix it before saving.'
180
- : error instanceof Error
181
- ? error.message
182
- : String(error),
183
- error: true
184
- });
237
+ failure(error);
185
238
  } finally {
186
239
  setBusy(false);
187
240
  }
@@ -285,7 +338,7 @@ export function SettingsPage() {
285
338
  <Box>
286
339
  <Title order={1}>Atlas server settings</Title>
287
340
  <Text c="dimmed" mt="xs">
288
- Configure how this Atlas node stores reports and tracks test runs. These settings apply to every project on this node.
341
+ Define the garbage collection policy and configure how this Atlas node tracks test runs. Everything here is saved to S3 and applies to every project on this node.
289
342
  </Text>
290
343
  </Box>
291
344
  <Fieldset legend={<Group gap="xs">Administrator access {revision && <Badge color="green">Connected</Badge>}</Group>}>
@@ -319,8 +372,101 @@ export function SettingsPage() {
319
372
  <Box id="configuration">
320
373
  <Stack gap="lg">
321
374
  <Alert title="Changes apply after a server restart.">
322
- Environment variables and command-line flags can override saved settings. Current values are shown under each field.
375
+ The configuration file, environment variables and command-line flags can override saved settings. Current values are shown under each field.
323
376
  </Alert>
377
+ <Box
378
+ id="garbage-collection"
379
+ component="form"
380
+ onSubmit={(event) => {
381
+ event.preventDefault();
382
+ void gcRequest('save');
383
+ }}
384
+ >
385
+ <Fieldset legend="Garbage collection" disabled={busy}>
386
+ <Stack>
387
+ {policySaved ? (
388
+ <Text size="sm" c="dimmed">
389
+ This policy is saved in S3 (<Code>server/state.json</Code>) and the server reads it from there each time it collects.
390
+ </Text>
391
+ ) : (
392
+ <Alert id="gc-empty" color="yellow" title="No policy has been saved yet">
393
+ Garbage collection is off and nothing is deleted. The values below are suggestions; they take effect only after you
394
+ turn collection on and save.
395
+ </Alert>
396
+ )}
397
+ <Text size="sm" c="dimmed">
398
+ Only report layers that no longer decide any result (newer layers replaced all their checks) are ever deleted, and never
399
+ the newest layers you choose to keep. Everything else, including specs and runs, is kept.
400
+ </Text>
401
+ <Switch
402
+ id="gc-enabled"
403
+ label="Delete eligible layers on a schedule"
404
+ checked={policy.enabled}
405
+ onChange={(event) => setPolicy({ ...policy, enabled: event.currentTarget.checked })}
406
+ />
407
+ <SimpleGrid cols={{ base: 1, sm: 3 }}>
408
+ <NumberInput
409
+ id="gc-maxAgeDays"
410
+ label="Delete layers older than (days)"
411
+ min={1}
412
+ max={3650}
413
+ allowDecimal={false}
414
+ value={policy.maxAgeDays}
415
+ onChange={(value) => setPolicy({ ...policy, maxAgeDays: Number(value) })}
416
+ />
417
+ <NumberInput
418
+ id="gc-keepLatest"
419
+ label="Always keep the newest layers"
420
+ min={0}
421
+ allowDecimal={false}
422
+ value={policy.keepLatest}
423
+ onChange={(value) => setPolicy({ ...policy, keepLatest: Number(value) })}
424
+ />
425
+ <NumberInput
426
+ id="gc-intervalHours"
427
+ label="Run every (hours)"
428
+ min={1}
429
+ max={720}
430
+ value={policy.intervalHours}
431
+ onChange={(value) => setPolicy({ ...policy, intervalHours: Number(value) })}
432
+ />
433
+ </SimpleGrid>
434
+ <Group>
435
+ <Button id="gc-save" type="submit" variant="filled" loading={busy} disabled={stale}>
436
+ Save garbage collection policy
437
+ </Button>
438
+ <Button id="gc-preview" disabled={!policySaved} loading={busy} onClick={() => void gcRequest('preview')}>
439
+ Preview deletions
440
+ </Button>
441
+ </Group>
442
+ {(preview ?? runs).length > 0 && (
443
+ <Table.ScrollContainer minWidth={480}>
444
+ <Table id="gc-runs">
445
+ <Table.Caption>{preview ? 'Preview (nothing deleted)' : 'Last collection pass per project'}</Table.Caption>
446
+ <Table.Thead>
447
+ <Table.Tr>
448
+ <Table.Th>Project</Table.Th>
449
+ <Table.Th>{preview ? 'Would delete' : 'Deleted layers'}</Table.Th>
450
+ <Table.Th>Kept layers</Table.Th>
451
+ <Table.Th>Result</Table.Th>
452
+ </Table.Tr>
453
+ </Table.Thead>
454
+ <Table.Tbody>
455
+ {(preview ?? runs).map((run) => (
456
+ <Table.Tr key={`${run.project}-${run.at}`}>
457
+ <Table.Td>{run.project}</Table.Td>
458
+ <Table.Td>{preview ? run.candidates : run.deletedLayers}</Table.Td>
459
+ <Table.Td>{run.keptLayers}</Table.Td>
460
+ <Table.Td>{run.error ? `Failed: ${run.error}` : `OK at ${run.at}`}</Table.Td>
461
+ </Table.Tr>
462
+ ))}
463
+ </Table.Tbody>
464
+ </Table>
465
+ </Table.ScrollContainer>
466
+ )}
467
+ </Stack>
468
+ </Fieldset>
469
+ </Box>
324
470
  <Box
325
471
  component="form"
326
472
  onSubmit={(event) => {
@@ -329,67 +475,16 @@ export function SettingsPage() {
329
475
  }}
330
476
  >
331
477
  <Stack gap="lg">
332
- <Fieldset legend="Storage" disabled={busy}>
333
- <Stack>
334
- {fields.storage!.map(input)}
335
- <NativeSelect
336
- id="write"
337
- label="Upload destination"
338
- description={`Current: ${active.write ?? 'data'}. Reports are read from all configured storage locations.`}
339
- value={String(draft.write ?? '')}
340
- onChange={(event) => {
341
- const next = updated(draft, ['write'], event.currentTarget.value || undefined);
342
- change({
343
- ...next,
344
- ...(draft.backends !== undefined ? { backends: backends.map(({ write, ...backend }) => backend) } : {})
345
- });
346
- }}
347
- >
348
- <option value="">Use default destination</option>
349
- <option value="data">Local data folder</option>
350
- {backends.map((backend, i) => (
351
- <option key={i} value={backend.id}>
352
- {backend.label || backend.id}
353
- </option>
354
- ))}
355
- {typeof draft.write === 'string' && draft.write !== 'data' && !backends.some((b) => b.id === draft.write) && (
356
- <option value={draft.write}>{draft.write}</option>
357
- )}
358
- </NativeSelect>
359
- <Title order={3}>Additional storage</Title>
360
- <Text c="dimmed">
361
- Add a folder, S3-compatible bucket, or another Atlas server. Keep credentials in the server environment.
478
+ <Fieldset legend="Storage">
479
+ <Stack gap="xs">
480
+ <Text>
481
+ All reports and all user state, including the garbage collection policy below, are stored in S3-compatible
482
+ object storage and nowhere else. The bucket and credentials are set by the server&apos;s required{' '}
483
+ <Code>ATLAS_S3_*</Code> environment variables and cannot be changed here.
484
+ </Text>
485
+ <Text size="sm" c="dimmed">
486
+ The settings on this page are saved as JSON in the same bucket, in <Code>server/state.json</Code>.
362
487
  </Text>
363
- {!backends.length && <Text c="dimmed">No additional storage configured. Atlas uses its local data folder.</Text>}
364
- {backends.map((backend, i) => (
365
- <Fieldset key={i} legend="Storage location">
366
- <Stack gap="sm">
367
- <TextInput
368
- label="Storage ID"
369
- required
370
- pattern="[a-z0-9-]+"
371
- value={backend.id}
372
- onChange={(event) => changeBackend(i, { id: event.currentTarget.value })}
373
- />
374
- <TextInput
375
- label="Display name (optional)"
376
- value={backend.label ?? ''}
377
- onChange={(event) => changeBackend(i, { label: event.currentTarget.value || undefined })}
378
- />
379
- <TextInput
380
- label="Location"
381
- required
382
- value={backend.url}
383
- placeholder="s3://reports/atlas"
384
- onChange={(event) => changeBackend(i, { url: event.currentTarget.value })}
385
- />
386
- <Button color="red" onClick={() => change({ ...draft, backends: backends.filter((_, at) => at !== i) })}>
387
- Remove
388
- </Button>
389
- </Stack>
390
- </Fieldset>
391
- ))}
392
- <Button onClick={() => change({ ...draft, backends: [...backends, { id: '', url: '' }] })}>Add storage</Button>
393
488
  </Stack>
394
489
  </Fieldset>
395
490
  <Fieldset legend="Live updates" disabled={busy}>
@@ -407,7 +502,7 @@ export function SettingsPage() {
407
502
  <Accordion.Panel>
408
503
  <JsonInput
409
504
  id="settings"
410
- label="Storage and report settings (JSON)"
505
+ label="Report and live-run settings (JSON)"
411
506
  autosize
412
507
  minRows={10}
413
508
  formatOnBlur
@@ -440,7 +535,7 @@ export function SettingsPage() {
440
535
  Save changes
441
536
  </Button>
442
537
  <Text size="xs" c="dimmed">
443
- Saved to <Code>atlas.server.json</Code>. Restart Atlas to apply.
538
+ Saved to S3 (<Code>server/state.json</Code>). Restart Atlas to apply.
444
539
  </Text>
445
540
  </Group>
446
541
  </Stack>
@@ -303,7 +303,7 @@ export function CheckDetail(props: Props) {
303
303
  );
304
304
  }
305
305
  function RecordedCheckDetail({ slot, layerId, select, reads, written, pinned, pin }: Props) {
306
- const { evidence, backends } = useEvidence();
306
+ const { evidence, backends, ready, pending } = useEvidence();
307
307
  const now = useNow();
308
308
  const [variant, setVariant] = useState(0);
309
309
  const history = useMemo(() => checkHistory(evidence, slot.specPath, slot.id), [evidence, slot.specPath, slot.id]);
@@ -350,6 +350,11 @@ function RecordedCheckDetail({ slot, layerId, select, reads, written, pinned, pi
350
350
  {note}
351
351
  </Alert>
352
352
  )}
353
+ {layerId && !earlier && ready && pending === 0 && (
354
+ <Alert role="status" color={'blue'} my="sm">
355
+ The layer this link names is not among the results held for this check. Showing the current result instead.
356
+ </Alert>
357
+ )}
353
358
  {failure && (
354
359
  <Code block c="red.7">
355
360
  {failure}
@@ -36,6 +36,8 @@ export type EvidenceState = {
36
36
  readonly retry: () => void;
37
37
  /** Layers whose checks are still arriving. */
38
38
  readonly pending: number;
39
+ /** Layers whose checks could not be read. The report shown leaves them out, so it may be incomplete. */
40
+ readonly failed: readonly LayerHeader[];
39
41
  /** Every distinct layer, whether or not it contributes. */
40
42
  readonly layers: readonly LayerHeader[];
41
43
  /** The layers that contribute, with their checks, once read. */
@@ -175,7 +177,7 @@ export function EvidenceProvider({ children }: { children: ReactNode }) {
175
177
  staleTime: Number.POSITIVE_INFINITY,
176
178
  retry: 1
177
179
  })),
178
- combine: (results) => ({ data: results.map((result) => result.data), stamp: results.map((result) => result.dataUpdatedAt).join(',') })
180
+ combine: (results) => ({ data: results.map((result) => result.data), failed: results.map((result) => result.isError && result.data === undefined), stamp: results.map((result) => result.dataUpdatedAt).join(',') })
179
181
  });
180
182
 
181
183
  // A running suite's layer is re-read as tests finish. Its last read stays on screen meanwhile.
@@ -195,6 +197,8 @@ export function EvidenceProvider({ children }: { children: ReactNode }) {
195
197
  // `stamp` changes exactly when a layer's rows do.
196
198
  }, [wanted, rows.stamp]);
197
199
 
200
+ const failed = useMemo(() => wanted.filter((_, i) => rows.failed[i]), [wanted, rows.failed]);
201
+
198
202
  const shown = effective ? overview.data?.layers ?? NONE : evidence;
199
203
  const { tests: liveTests } = useLiveTests();
200
204
  const view = useMemo(() => withLiveProgress(evidenceView(combineEvidence(outline, shown)), liveTests), [outline, shown, liveTests]);
@@ -206,11 +210,14 @@ export function EvidenceProvider({ children }: { children: ReactNode }) {
206
210
  unavailable: effective ? (overview.data === undefined && overview.error ? overview.error.message : null) : pages === undefined && index.error ? index.error.message : null,
207
211
  retry: () => {
208
212
  if (effective) { void Promise.all([overview.refetch(), serverOutline.refetch()]); return; }
213
+ // A layer whose rows could not be read is asked for again alone, so the layers already read stay on screen.
214
+ void client.refetchQueries({ queryKey: ['evidence-rows'], predicate: (query) => query.state.status === 'error' });
209
215
  void reportJson<EvidencePage>('/evidence?retry=1')
210
216
  .catch(() => null)
211
217
  .then(() => Promise.all([index.refetch(), serverOutline.refetch()]));
212
218
  },
213
- pending: wanted.length - evidence.length,
219
+ pending: Math.max(0, wanted.length - evidence.length - failed.length),
220
+ failed,
214
221
  layers: effective ? effectiveHeaders : layers,
215
222
  evidence: shown,
216
223
  backends: effective ? overview.data?.backends ?? [] : first?.backends ?? [],
@@ -0,0 +1,65 @@
1
+ import { Alert, Button, Group, Text } from '@mantine/core';
2
+ import { useEffect, useState } from 'react';
3
+ import { LoadError } from '../data/adapters';
4
+ import { useEvidence } from './evidence-context';
5
+ import { plural } from './format';
6
+ import { ReportsError } from './api';
7
+
8
+ /**
9
+ * How long a read may take before the page says so. Waiting is never silent past this: the page names what is
10
+ * affected, keeps whatever else is readable, and offers another attempt. A test shortens it.
11
+ */
12
+ export const loadingPolicy = { stallMs: 10_000 };
13
+
14
+ /** True once `active` has stayed true for the loading policy's bound. It starts over when `active` or `scope` changes. */
15
+ export function useStalled(active: boolean, scope = ''): boolean {
16
+ const [stalled, setStalled] = useState(false);
17
+ useEffect(() => {
18
+ setStalled(false);
19
+ if (!active) return;
20
+ const timer = setTimeout(() => setStalled(true), loadingPolicy.stallMs);
21
+ return () => clearTimeout(timer);
22
+ }, [active, scope]);
23
+ return active && stalled;
24
+ }
25
+
26
+ /** Is this error the server saying the thing is not there, as opposed to it failing to answer? */
27
+ export function isMissing(error: unknown): boolean {
28
+ return (error instanceof LoadError || error instanceof ReportsError) && error.status === 404;
29
+ }
30
+
31
+ /** Recorded results are still arriving. After the loading policy's bound it says so and offers another attempt. */
32
+ export function EvidenceWaiting({ label = 'Loading recorded results…', usable }: { label?: string; usable?: string }) {
33
+ const { retry } = useEvidence();
34
+ const stalled = useStalled(true);
35
+ return stalled ? (
36
+ <Alert role="status" color="orange" my="sm" title="Recorded results are taking longer than expected">
37
+ <Group gap="sm">
38
+ <Text span inherit>
39
+ {usable ? `${usable} ` : ''}Results will appear here when they arrive.
40
+ </Text>
41
+ <Button type="button" onClick={retry} variant="subtle">
42
+ Try again
43
+ </Button>
44
+ </Group>
45
+ </Alert>
46
+ ) : (
47
+ <Text role="status" component="div" c="dimmed">
48
+ {label}
49
+ </Text>
50
+ );
51
+ }
52
+
53
+ /** Says when some layers could not be read, so counts and results are not taken for the whole report. */
54
+ export function EvidenceIncomplete() {
55
+ const { failed, retry } = useEvidence();
56
+ if (failed.length === 0) return null;
57
+ return (
58
+ <Alert role="status" color="orange" my="sm">
59
+ {plural(failed.length, 'layer', 'layers')} could not be read, so the results shown here may be incomplete.{' '}
60
+ <Button type="button" onClick={retry} variant="subtle">
61
+ Try again
62
+ </Button>
63
+ </Alert>
64
+ );
65
+ }
@@ -7,6 +7,7 @@ import { folderOf, outlinePath, type SpecKind } from '../spec-kind';
7
7
  import { useUiStore } from '../state/ui-store';
8
8
  import { useEvidence } from './evidence-context';
9
9
  import { ago, plural, useNow } from './format';
10
+ import { EvidenceIncomplete, EvidenceWaiting } from './load-state';
10
11
  import { backendLabel, Bounded, CountLine, Meter, StatusMark, Tally } from './marks';
11
12
  /** A kind's specs at a glance: how many have results, which are failing, and what just ran. */
12
13
  export function Overview({ kind }: { kind: SpecKind }) {
@@ -104,11 +105,8 @@ export function Overview({ kind }: { kind: SpecKind }) {
104
105
  </Button>
105
106
  </Alert>
106
107
  ))}
107
- {!evidence.ready && !evidence.unavailable && (
108
- <Text role="status" component="div" c="dimmed">
109
- Loading recorded results…
110
- </Text>
111
- )}
108
+ {!evidence.ready && !evidence.unavailable && <EvidenceWaiting usable={`The list of ${kind.many} is readable.`} />}
109
+ {evidence.ready && <EvidenceIncomplete />}
112
110
  {evidence.ready && evidence.pending > 0 && (
113
111
  <Text role="status" component="div" c="dimmed">
114
112
  Reading {plural(evidence.pending, 'more layer', 'more layers')}…
@@ -168,7 +166,7 @@ export function Overview({ kind }: { kind: SpecKind }) {
168
166
  </Box>
169
167
  )}
170
168
 
171
- {evidence.ready && evidence.pending === 0 && withResults.length === 0 && !evidence.unavailable && (
169
+ {evidence.ready && evidence.pending === 0 && evidence.failed.length === 0 && withResults.length === 0 && !evidence.unavailable && (
172
170
  <Text component="div" c="dimmed">
173
171
  {evidence.layers.length === 0
174
172
  ? `Nothing has been recorded yet. Results appear here as a spec run finishes each test.`
@@ -10,6 +10,7 @@ import { useRecordedReport } from './check-detail';
10
10
  import { useEvidence } from './evidence-context';
11
11
  import { duration, statusWord, useNow } from './format';
12
12
  import { Origin, StatusMark } from './marks';
13
+ import { EvidenceIncomplete, EvidenceWaiting } from './load-state';
13
14
  import { Timeline } from './recording';
14
15
  type Props = {
15
16
  /** The check whose recording is open, once the combined report holds it. */
@@ -40,7 +41,7 @@ function needsStylesheet(report: RunReport | undefined): boolean {
40
41
  /** One recorded test, opened in place of its spec. */
41
42
  export function RecordingPage({ slot, layerId, entry, label, back, moment, onMoment, onClose }: Props) {
42
43
  const progress = useTestProgress(slot ?? null);
43
- const { evidence, backends, pending, ready, unavailable } = useEvidence();
44
+ const { evidence, backends, pending, ready, unavailable, failed, retry } = useEvidence();
44
45
  const now = useNow();
45
46
  const history = useMemo(() => (slot ? checkHistory(evidence, slot.specPath, slot.id) : []), [evidence, slot]);
46
47
  const winner = slot?.outcomes[0];
@@ -86,17 +87,26 @@ export function RecordingPage({ slot, layerId, entry, label, back, moment, onMom
86
87
  )}
87
88
  </Box>
88
89
  <TestProgress {...progress} />
89
- {waiting && (
90
- <Text role="status" component="div" c="dimmed">
91
- Loading the recording…
92
- </Text>
93
- )}
90
+ {waiting && <EvidenceWaiting label="Loading the recording…" usable="The way back to the spec is available." />}
94
91
  {!chosen && !waiting && !progress.tests.length && (
95
92
  <Alert color={'blue'} my="sm">
96
93
  {unavailable ??
97
- 'This result is not in the combined report any more. A newer run may have replaced it, or its source is switched off.'}
94
+ (failed.length > 0
95
+ ? 'This result was not found in the layers that could be read, and some layers could not be read.'
96
+ : 'This result is not in the combined report any more. A newer run may have replaced it, or its source is switched off.')}{' '}
97
+ {(unavailable || failed.length > 0) && (
98
+ <Button type="button" onClick={retry} variant="subtle">
99
+ Try again
100
+ </Button>
101
+ )}
102
+ </Alert>
103
+ )}
104
+ {chosen && layerId && !entry && chosen.layer.id !== layerId && ready && pending === 0 && (
105
+ <Alert role="status" color={'blue'} my="sm">
106
+ The layer this link names is not among the results held for this test. Showing the current result instead.
98
107
  </Alert>
99
108
  )}
109
+ {ready && !chosen && <EvidenceIncomplete />}
100
110
  {chosen && !paths && (
101
111
  <Alert color={'blue'} my="sm">
102
112
  This result has an outcome and no recorded timeline.
@@ -6,6 +6,7 @@ import type { ReactNode } from 'react';
6
6
  import type { SpecKind } from '../spec-kind';
7
7
  import { useEvidence } from './evidence-context';
8
8
  import { useNow } from './format';
9
+ import { EvidenceIncomplete, EvidenceWaiting } from './load-state';
9
10
  import { CountLine, Meter, Origin } from './marks';
10
11
  /** A spec's name, where it lives, and what the combined report holds for it. */
11
12
  export function SpecHeader({
@@ -23,7 +24,7 @@ export function SpecHeader({
23
24
  planned?: number;
24
25
  children?: ReactNode;
25
26
  }) {
26
- const { backends, ready, unavailable, pending } = useEvidence();
27
+ const { backends, ready, unavailable, pending, failed } = useEvidence();
27
28
  const now = useNow();
28
29
  const summary = entry?.summary ?? NO_EVIDENCE;
29
30
  const newest = entry?.slots
@@ -50,13 +51,13 @@ export function SpecHeader({
50
51
  <Text span inherit>
51
52
  Recorded results are unavailable.
52
53
  </Text>
53
- ) : !ready ? (
54
- <Text role="status" span inherit>
55
- Loading recorded results…
56
- </Text>
54
+ ) : !ready || (summary.status === 'none' && pending > 0) ? (
55
+ <EvidenceWaiting usable={`The ${kind.one} below is readable.`} />
57
56
  ) : summary.status === 'none' ? (
58
57
  <Text span inherit>
59
- {pending > 0 ? 'Loading recorded results…' : `No completed test report for this ${kind.one} yet.`}
58
+ {failed.length > 0
59
+ ? `No completed test report for this ${kind.one} in the layers that could be read.`
60
+ : `No completed test report for this ${kind.one} yet.`}
60
61
  </Text>
61
62
  ) : (
62
63
  <>
@@ -69,6 +70,7 @@ export function SpecHeader({
69
70
  </>
70
71
  )}
71
72
  </Flex>
73
+ {ready && <EvidenceIncomplete />}
72
74
  {newest && (
73
75
  <Flex gap="xs" align="center" wrap="wrap" c="dimmed">
74
76
  Latest from <Origin layer={newest.layer} backends={backends} now={now} />