@crvouga/atlas 0.17.0 → 1.0.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/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 +1 -1
- package/docs/workspaces.md +5 -2
- package/llms-full.txt +231 -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
|
@@ -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>
|