@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.
- package/CHANGELOG.md +12 -0
- package/README.md +52 -8
- package/dist/core/src/cli.d.ts +1 -1
- package/dist/core/src/cli.js +1 -1
- package/dist/reports/src/garbage.d.ts +39 -0
- package/dist/reports/src/garbage.js +82 -0
- package/dist/reports/src/report-index.d.ts +6 -0
- package/dist/reports/src/report-index.js +14 -0
- package/dist/schema/src/index.d.ts +1 -0
- package/dist/schema/src/index.js +1 -0
- package/dist/schema/src/server-config.d.ts +7 -49
- package/dist/schema/src/server-config.js +8 -21
- package/dist/schema/src/user-state.d.ts +115 -0
- package/dist/schema/src/user-state.js +54 -0
- package/dist/server/src/app.d.ts +16 -2
- package/dist/server/src/app.js +62 -0
- package/dist/server/src/config.d.ts +16 -21
- package/dist/server/src/config.js +48 -57
- package/dist/server/src/dev.d.ts +1 -1
- package/dist/server/src/dev.js +20 -7
- package/dist/server/src/index.d.ts +5 -1
- package/dist/server/src/index.js +5 -1
- package/dist/server/src/main.d.ts +23 -7
- package/dist/server/src/main.js +72 -26
- package/dist/server/src/openapi.js +24 -5
- package/dist/server/src/projects.js +89 -14
- package/dist/server/src/settings.d.ts +23 -7
- package/dist/server/src/settings.js +50 -60
- package/dist/server/src/storage.d.ts +68 -0
- package/dist/server/src/storage.js +125 -0
- package/dist/server/src/user-state.d.ts +31 -0
- package/dist/server/src/user-state.js +74 -0
- package/docs/agents.md +1 -1
- package/docs/deploying.md +38 -39
- package/docs/reference/cli.md +12 -7
- package/docs/reference/http-api.md +30 -2
- package/docs/reference/reports.md +71 -0
- package/docs/reference/schema.md +102 -16
- package/docs/reference/server.md +263 -41
- package/docs/report-backends.md +0 -144
- package/docs/report-contract.md +1 -1
- package/docs/runners.md +1 -1
- package/docs/server.md +141 -36
- package/docs/statecharts.md +1 -1
- package/docs/ui.md +8 -1
- package/docs/workspaces.md +5 -2
- package/llms-full.txt +238 -234
- package/llms.txt +1 -1
- package/package.json +2 -2
- package/packages/reports/STORAGE.md +1 -1
- package/packages/schema/schemas/gc-policy.schema.json +36 -0
- package/packages/schema/schemas/gc-run-result.schema.json +106 -0
- package/packages/schema/schemas/gc-snapshot.schema.json +122 -0
- package/packages/schema/schemas/server-config.schema.json +0 -41
- package/packages/schema/schemas/server-editable-settings.schema.json +0 -41
- package/packages/schema/schemas/server-settings-snapshot.schema.json +0 -82
- package/packages/schema/schemas/user-state.schema.json +140 -0
- package/packages/server/README.md +11 -6
- package/packages/server/openapi.json +510 -126
- package/packages/visualizer/src/admin/SettingsPage.tsx +183 -88
- package/packages/visualizer/src/evidence/check-detail.tsx +6 -1
- package/packages/visualizer/src/evidence/evidence-context.tsx +9 -2
- package/packages/visualizer/src/evidence/load-state.tsx +65 -0
- package/packages/visualizer/src/evidence/overview.tsx +4 -6
- package/packages/visualizer/src/evidence/recording-page.tsx +17 -7
- package/packages/visualizer/src/evidence/spec-header.tsx +8 -6
- package/packages/visualizer/src/evidence/spec-page.tsx +98 -15
- package/packages/visualizer/src/find/Finder.tsx +45 -7
- package/packages/visualizer/src/spec-kind.test.ts +14 -1
- 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
|
|
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
|
|
142
|
-
const
|
|
143
|
-
|
|
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> =
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
333
|
-
<Stack>
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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'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="
|
|
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>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
{
|
|
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} />
|