@crvouga/atlas 0.10.2 → 0.12.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 (114) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +7 -0
  3. package/dist/reports/src/catalog-health.d.ts +142 -0
  4. package/dist/reports/src/catalog-health.js +257 -0
  5. package/dist/reports/src/test-catalog.d.ts +8 -0
  6. package/dist/reports/src/test-catalog.js +5 -1
  7. package/dist/schema/src/api.d.ts +79 -0
  8. package/dist/schema/src/api.js +50 -0
  9. package/dist/server/src/app.js +9 -0
  10. package/dist/server/src/llms.js +1 -0
  11. package/dist/server/src/openapi.js +18 -2
  12. package/dist/server/src/settings-page.d.ts +4 -4
  13. package/dist/server/src/settings-page.js +17 -78
  14. package/docs/reference/http-api.md +34 -0
  15. package/docs/reference/reports.md +224 -0
  16. package/docs/reference/schema.md +10 -0
  17. package/docs/reference/server.md +4 -4
  18. package/docs/server.md +10 -0
  19. package/docs/ui.md +142 -0
  20. package/docs/workspaces.md +130 -0
  21. package/llms-full.txt +322 -0
  22. package/llms.txt +2 -0
  23. package/package.json +17 -10
  24. package/packages/schema/schemas/catalog-health.schema.json +400 -0
  25. package/packages/server/openapi.json +596 -0
  26. package/packages/visualizer/src/admin/SettingsPage.tsx +456 -0
  27. package/packages/visualizer/src/admin/main.tsx +11 -0
  28. package/packages/visualizer/src/app/AppShell.tsx +62 -38
  29. package/packages/visualizer/src/app/gazetteer.tsx +90 -83
  30. package/packages/visualizer/src/app/router.tsx +33 -15
  31. package/packages/visualizer/src/components/ActivityIndicator.tsx +4 -5
  32. package/packages/visualizer/src/components/DataIssues.tsx +36 -54
  33. package/packages/visualizer/src/components/Header.tsx +197 -125
  34. package/packages/visualizer/src/components/Legend.tsx +13 -41
  35. package/packages/visualizer/src/components/ProjectPicker.tsx +61 -26
  36. package/packages/visualizer/src/components/Screen.tsx +80 -71
  37. package/packages/visualizer/src/components/SpecLogo.tsx +15 -0
  38. package/packages/visualizer/src/components/Status.tsx +20 -34
  39. package/packages/visualizer/src/components/labels.tsx +8 -3
  40. package/packages/visualizer/src/components/spec-logos/APACHE-2.0.txt +206 -0
  41. package/packages/visualizer/src/components/spec-logos/CAMUNDA-MIT.txt +21 -0
  42. package/packages/visualizer/src/components/spec-logos/FILE-ICONS-ISC.txt +13 -0
  43. package/packages/visualizer/src/components/spec-logos/README.md +23 -0
  44. package/packages/visualizer/src/components/spec-logos/SIMPLE-ICONS-CC0.txt +30 -0
  45. package/packages/visualizer/src/components/spec-logos/alloy.svg +1 -0
  46. package/packages/visualizer/src/components/spec-logos/dmn.svg +3 -0
  47. package/packages/visualizer/src/components/spec-logos/gherkin.svg +1 -0
  48. package/packages/visualizer/src/components/spec-logos/openslo.svg +63 -0
  49. package/packages/visualizer/src/components/spec-logos/quint.svg +1 -0
  50. package/packages/visualizer/src/evidence/artifacts.tsx +263 -110
  51. package/packages/visualizer/src/evidence/chart-source.tsx +18 -0
  52. package/packages/visualizer/src/evidence/check-detail.tsx +283 -139
  53. package/packages/visualizer/src/evidence/decision-table.tsx +35 -26
  54. package/packages/visualizer/src/evidence/document-page.tsx +51 -29
  55. package/packages/visualizer/src/evidence/feature-page.tsx +121 -53
  56. package/packages/visualizer/src/evidence/marks.tsx +112 -79
  57. package/packages/visualizer/src/evidence/model-page.tsx +77 -46
  58. package/packages/visualizer/src/evidence/model-trace.tsx +55 -35
  59. package/packages/visualizer/src/evidence/overview.tsx +137 -69
  60. package/packages/visualizer/src/evidence/recording-page.tsx +70 -31
  61. package/packages/visualizer/src/evidence/recording.tsx +135 -68
  62. package/packages/visualizer/src/evidence/snapshot.tsx +99 -43
  63. package/packages/visualizer/src/evidence/source-text.tsx +33 -0
  64. package/packages/visualizer/src/evidence/sources-panel.tsx +138 -144
  65. package/packages/visualizer/src/evidence/spec-header.tsx +52 -36
  66. package/packages/visualizer/src/evidence/spec-index.tsx +61 -32
  67. package/packages/visualizer/src/evidence/spec-page.tsx +56 -25
  68. package/packages/visualizer/src/evidence/test-filter.tsx +38 -14
  69. package/packages/visualizer/src/explorer/Explorer.tsx +688 -261
  70. package/packages/visualizer/src/explorer/ExplorerIndex.tsx +136 -68
  71. package/packages/visualizer/src/explorer/RunsView.tsx +307 -94
  72. package/packages/visualizer/src/explorer/TestDetail.tsx +393 -118
  73. package/packages/visualizer/src/explorer/parts.tsx +35 -13
  74. package/packages/visualizer/src/find/Finder.tsx +106 -90
  75. package/packages/visualizer/src/find/open.ts +17 -20
  76. package/packages/visualizer/src/live/test-progress.tsx +60 -22
  77. package/packages/visualizer/src/looks/Stage.tsx +223 -110
  78. package/packages/visualizer/src/main.tsx +9 -11
  79. package/packages/visualizer/src/map/ChartMap.tsx +125 -69
  80. package/packages/visualizer/src/map/EventEdge.tsx +59 -37
  81. package/packages/visualizer/src/map/map.module.css +12 -546
  82. package/packages/visualizer/src/map/nodes.tsx +124 -70
  83. package/packages/visualizer/src/reachable.test.ts +3 -2
  84. package/packages/visualizer/src/spec-kind.ts +2 -2
  85. package/packages/visualizer/src/ui/grammars/ALLOY-MIT.txt +21 -0
  86. package/packages/visualizer/src/ui/grammars/QUINT-APACHE-2.0.txt +206 -0
  87. package/packages/visualizer/src/ui/grammars/README.md +14 -0
  88. package/packages/visualizer/src/ui/grammars/alloy.json +71 -0
  89. package/packages/visualizer/src/ui/grammars/quint.json +152 -0
  90. package/packages/visualizer/src/ui/highlight.ts +50 -0
  91. package/packages/visualizer/src/ui/theme.tsx +109 -0
  92. package/packages/visualizer/src/views/ChartPage.tsx +46 -26
  93. package/packages/visualizer/src/views/ChartTests.tsx +59 -22
  94. package/packages/visualizer/src/views/ProductPage.tsx +16 -15
  95. package/packages/visualizer/src/views/SidePanel.tsx +241 -131
  96. package/packages/visualizer/src/app/gazetteer.module.css +0 -107
  97. package/packages/visualizer/src/components/ActivityIndicator.module.css +0 -4
  98. package/packages/visualizer/src/components/DataIssues.module.css +0 -136
  99. package/packages/visualizer/src/components/Header.module.css +0 -114
  100. package/packages/visualizer/src/components/Legend.module.css +0 -84
  101. package/packages/visualizer/src/components/Screen.module.css +0 -312
  102. package/packages/visualizer/src/components/Status.module.css +0 -116
  103. package/packages/visualizer/src/components/icons.tsx +0 -156
  104. package/packages/visualizer/src/evidence/artifacts.module.css +0 -213
  105. package/packages/visualizer/src/evidence/evidence.module.css +0 -907
  106. package/packages/visualizer/src/evidence/recording.module.css +0 -460
  107. package/packages/visualizer/src/evidence/sources.module.css +0 -168
  108. package/packages/visualizer/src/evidence/spec-snapshot.tsx +0 -26
  109. package/packages/visualizer/src/explorer/explorer.module.css +0 -1238
  110. package/packages/visualizer/src/find/Finder.module.css +0 -168
  111. package/packages/visualizer/src/looks/stage.module.css +0 -300
  112. package/packages/visualizer/src/styles/global.css +0 -105
  113. package/packages/visualizer/src/styles/tokens.css +0 -80
  114. package/packages/visualizer/src/views/views.module.css +0 -546
@@ -0,0 +1,456 @@
1
+ import { IconArrowLeft } from '@tabler/icons-react';
2
+ import { ServerEditableSettingsSchema } from '@crvouga/atlas/schema';
3
+ /** @module Server settings interface
4
+ * Administrator credentials live only in this component; the server validates every request.
5
+ */
6
+ import {
7
+ Accordion,
8
+ Alert,
9
+ Anchor,
10
+ AppShell,
11
+ Badge,
12
+ Box,
13
+ Button,
14
+ Code,
15
+ Container,
16
+ Fieldset,
17
+ Group,
18
+ JsonInput,
19
+ NativeSelect,
20
+ NumberInput,
21
+ PasswordInput,
22
+ SimpleGrid,
23
+ Stack,
24
+ Text,
25
+ TextInput,
26
+ Title
27
+ } from '@mantine/core';
28
+ import { useState } from 'react';
29
+
30
+ type Settings = Record<string, unknown>;
31
+ type Backend = { id: string; label?: string; url: string; write?: boolean };
32
+ type Field = {
33
+ id: string;
34
+ label: string;
35
+ path: string[];
36
+ description?: string;
37
+ min?: number;
38
+ max?: number;
39
+ type?: 'number' | 'boolean' | 'branches';
40
+ };
41
+ 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
+ live: [
46
+ {
47
+ id: 'runTimeoutSeconds',
48
+ label: 'Missing heartbeat timeout (seconds)',
49
+ path: ['runTimeoutSeconds'],
50
+ type: 'number',
51
+ min: 10,
52
+ max: 3600,
53
+ description: 'Interrupt a run when its producer stops checking in. Default: 30 seconds.'
54
+ },
55
+ {
56
+ id: 'runStallSeconds',
57
+ label: 'No-progress timeout (seconds)',
58
+ path: ['runStallSeconds'],
59
+ type: 'number',
60
+ min: 0,
61
+ max: 86400,
62
+ description: 'Interrupt stalled runs. Use 0 to disable. Default: 900 seconds.'
63
+ },
64
+ {
65
+ id: 'refreshSeconds',
66
+ label: 'Storage refresh interval (seconds)',
67
+ path: ['refreshSeconds'],
68
+ type: 'number',
69
+ min: 1,
70
+ description: 'Check for reports written outside this node. Default: 10 seconds.'
71
+ },
72
+ {
73
+ id: 'heartbeatSeconds',
74
+ label: 'Client keepalive interval (seconds)',
75
+ path: ['heartbeatSeconds'],
76
+ type: 'number',
77
+ min: 1,
78
+ description: 'Keep browser connections open between updates. Default: 15 seconds.'
79
+ }
80
+ ],
81
+ rules: [
82
+ {
83
+ id: 'outlineBranch',
84
+ label: 'Preferred specification branch',
85
+ path: ['outlineBranch'],
86
+ description: 'Leave blank to prefer main, then master, then the newest available specs.'
87
+ },
88
+ {
89
+ id: 'branches',
90
+ label: 'Include branches',
91
+ path: ['include', 'branches'],
92
+ type: 'branches',
93
+ description: 'Comma-separated branch names. Leave blank to include every branch.'
94
+ },
95
+ { id: 'ignoreModified', label: 'Results from modified checkouts', path: ['include', 'ignoreModified'], type: 'boolean' }
96
+ ],
97
+ advanced: [
98
+ { id: 'maxObjectMiB', label: 'Maximum upload size (MiB)', path: ['limits', 'maxObjectMiB'], type: 'number', min: 0.001 },
99
+ { id: 'maxJsonMiB', label: 'Maximum JSON size (MiB)', path: ['limits', 'maxJsonMiB'], type: 'number', min: 0.001 },
100
+ { id: 'maxStreams', label: 'Maximum live connections', path: ['limits', 'maxStreams'], type: 'number', min: 1 },
101
+ { id: 'log', label: 'Request logging', path: ['log'], type: 'boolean' }
102
+ ]
103
+ };
104
+ function at(value: Settings, path: string[]): unknown {
105
+ return path.reduce<unknown>((held, key) => (held && typeof held === 'object' ? (held as Settings)[key] : undefined), value);
106
+ }
107
+ function updated(draft: Settings, path: string[], value: unknown): Settings {
108
+ const next = structuredClone(draft);
109
+ let held = next;
110
+ for (const key of path.slice(0, -1)) {
111
+ held[key] = held[key] && typeof held[key] === 'object' ? held[key] : {};
112
+ held = held[key] as Settings;
113
+ }
114
+ if (value === undefined) delete held[path.at(-1)!];
115
+ else held[path.at(-1)!] = value;
116
+ for (const key of ['include', 'limits']) if (next[key] && !Object.keys(next[key] as object).length) delete next[key];
117
+ return next;
118
+ }
119
+ /** Complete settings editor with revision conflicts, raw JSON, default values, and storage selection. */
120
+ export function SettingsPage() {
121
+ const [token, setToken] = useState('');
122
+ const [revision, setRevision] = useState<string>();
123
+ const [draft, setDraft] = useState<Settings>({});
124
+ const [active, setActive] = useState<Settings>({});
125
+ const [json, setJson] = useState('{}');
126
+ const [jsonError, setJsonError] = useState('');
127
+ const [busy, setBusy] = useState(false);
128
+ const [stale, setStale] = useState(false);
129
+ const [message, setMessage] = useState<{ text: string; error?: boolean; success?: boolean }>();
130
+ const params = new URLSearchParams(window.location.search);
131
+ const project = params.get('project');
132
+ const back =
133
+ project && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(project) && project.length <= 64
134
+ ? `/?project=${encodeURIComponent(project)}#/`
135
+ : '/';
136
+ const change = (next: Settings) => {
137
+ setDraft(next);
138
+ setJson(JSON.stringify(next, null, 2));
139
+ setJsonError('');
140
+ };
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)) });
144
+ const request = async (method: 'GET' | 'PUT') => {
145
+ if (busy || (method === 'PUT' && (!revision || stale))) return;
146
+ setBusy(true);
147
+ if (method === 'GET') setRevision(undefined);
148
+ try {
149
+ const headers: Record<string, string> = token ? { authorization: `Bearer ${token}` } : {};
150
+ let body: string | undefined;
151
+ if (method === 'PUT') {
152
+ const parsed = JSON.parse(json);
153
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('Settings must be a JSON object.');
154
+ headers['content-type'] = 'application/json';
155
+ headers['if-match'] = `"${revision}"`;
156
+ body = JSON.stringify(parsed);
157
+ }
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);
165
+ setStale(false);
166
+ setActive(data.active);
167
+ change(data.settings);
168
+ setMessage({
169
+ text:
170
+ method === 'PUT' || data.restartRequired
171
+ ? 'Saved. Restart Atlas to apply.'
172
+ : 'Settings loaded. Blank fields use defaults after restart.',
173
+ success: method === 'PUT' || data.restartRequired
174
+ });
175
+ } 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
+ });
185
+ } finally {
186
+ setBusy(false);
187
+ }
188
+ };
189
+ const input = (field: Field) => {
190
+ const saved = at(draft, field.path),
191
+ current = at(active, field.path);
192
+ const description = (
193
+ <>
194
+ {field.description}
195
+ {current !== undefined && (
196
+ <Text component="span" display="block" size="xs">
197
+ Current: {Array.isArray(current) ? current.join(', ') || 'All branches' : String(current)}
198
+ </Text>
199
+ )}
200
+ </>
201
+ );
202
+ const props = { id: field.id, label: field.label, description };
203
+ if (field.type === 'number')
204
+ return (
205
+ <NumberInput
206
+ key={field.id}
207
+ {...props}
208
+ value={typeof saved === 'number' ? saved : ''}
209
+ min={field.min}
210
+ max={field.max}
211
+ allowDecimal={field.id !== 'maxStreams'}
212
+ onChange={(value) => change(updated(draft, field.path, value === '' ? undefined : Number(value)))}
213
+ />
214
+ );
215
+ if (field.type === 'boolean')
216
+ return (
217
+ <NativeSelect
218
+ key={field.id}
219
+ {...props}
220
+ value={saved === undefined ? '' : String(saved)}
221
+ onChange={(event) =>
222
+ change(updated(draft, field.path, event.currentTarget.value === '' ? undefined : event.currentTarget.value === 'true'))
223
+ }
224
+ data={
225
+ field.id === 'ignoreModified'
226
+ ? [
227
+ { value: '', label: 'Use default (include)' },
228
+ { value: 'false', label: 'Include' },
229
+ { value: 'true', label: 'Exclude' }
230
+ ]
231
+ : [
232
+ { value: '', label: 'Use default (enabled)' },
233
+ { value: 'true', label: 'Enabled' },
234
+ { value: 'false', label: 'Disabled' }
235
+ ]
236
+ }
237
+ />
238
+ );
239
+ return (
240
+ <TextInput
241
+ key={field.id}
242
+ {...props}
243
+ value={Array.isArray(saved) ? saved.join(', ') : String(saved ?? '')}
244
+ onChange={(event) => {
245
+ const text = event.currentTarget.value;
246
+ change(
247
+ updated(
248
+ draft,
249
+ field.path,
250
+ text === ''
251
+ ? undefined
252
+ : field.type === 'branches'
253
+ ? text
254
+ .split(',')
255
+ .map((item) => item.trim())
256
+ .filter(Boolean)
257
+ : text
258
+ )
259
+ );
260
+ }}
261
+ />
262
+ );
263
+ };
264
+ return (
265
+ <AppShell header={{ height: 64 }}>
266
+ <AppShell.Header>
267
+ <Group h="100%" px="lg" justify="space-between">
268
+ <Group>
269
+ <Anchor href={back} size="lg" fw={800}>
270
+ Atlas
271
+ </Anchor>
272
+ <Text c="dimmed">Settings</Text>
273
+ </Group>
274
+ <Anchor href={back}>
275
+ <Group gap="xs">
276
+ <IconArrowLeft size={16} aria-hidden="true" />
277
+ Back to reports
278
+ </Group>
279
+ </Anchor>
280
+ </Group>
281
+ </AppShell.Header>
282
+ <AppShell.Main>
283
+ <Container size="md" py="xl">
284
+ <Stack gap="xl">
285
+ <Box>
286
+ <Title order={1}>Atlas server settings</Title>
287
+ <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.
289
+ </Text>
290
+ </Box>
291
+ <Fieldset legend={<Group gap="xs">Administrator access {revision && <Badge color="green">Connected</Badge>}</Group>}>
292
+ <Stack>
293
+ <Text c="dimmed">
294
+ Enter an admin token to view and edit settings. For a local server without authentication, leave it blank.
295
+ </Text>
296
+ <PasswordInput
297
+ id="token"
298
+ label="Admin token"
299
+ autoComplete="off"
300
+ value={token}
301
+ onChange={(event) => setToken(event.currentTarget.value)}
302
+ description="Your token stays in this page and is never saved in browser storage."
303
+ />
304
+ <Button id="load" variant="filled" loading={busy} onClick={() => void request('GET')}>
305
+ {revision ? 'Reload settings' : 'Load settings'}
306
+ </Button>
307
+ </Stack>
308
+ </Fieldset>
309
+ {message && (
310
+ <Alert
311
+ id="status"
312
+ color={message.error ? 'red' : message.success ? 'green' : 'blue'}
313
+ role={message.error ? 'alert' : 'status'}
314
+ >
315
+ {message.text}
316
+ </Alert>
317
+ )}
318
+ {revision && (
319
+ <Box id="configuration">
320
+ <Stack gap="lg">
321
+ <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.
323
+ </Alert>
324
+ <Box
325
+ component="form"
326
+ onSubmit={(event) => {
327
+ event.preventDefault();
328
+ void request('PUT');
329
+ }}
330
+ >
331
+ <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.
362
+ </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
+ </Stack>
394
+ </Fieldset>
395
+ <Fieldset legend="Live updates" disabled={busy}>
396
+ <SimpleGrid cols={{ base: 1, sm: 2 }}>{fields.live!.map(input)}</SimpleGrid>
397
+ </Fieldset>
398
+ <Fieldset legend="Report rules" disabled={busy}>
399
+ <Stack>{fields.rules!.map(input)}</Stack>
400
+ </Fieldset>
401
+ <Fieldset legend="Advanced" disabled={busy}>
402
+ <Stack>
403
+ <SimpleGrid cols={{ base: 1, sm: 2 }}>{fields.advanced!.map(input)}</SimpleGrid>
404
+ <Accordion variant="separated">
405
+ <Accordion.Item value="json">
406
+ <Accordion.Control>Edit configuration as JSON</Accordion.Control>
407
+ <Accordion.Panel>
408
+ <JsonInput
409
+ id="settings"
410
+ label="Storage and report settings (JSON)"
411
+ autosize
412
+ minRows={10}
413
+ formatOnBlur
414
+ value={json}
415
+ error={jsonError || undefined}
416
+ onChange={(value) => {
417
+ setJson(value);
418
+ try {
419
+ const parsed = ServerEditableSettingsSchema.parse(JSON.parse(value));
420
+ setDraft(parsed);
421
+ setJsonError('');
422
+ } catch {
423
+ setJsonError('Settings must be a valid JSON object.');
424
+ }
425
+ }}
426
+ />
427
+ </Accordion.Panel>
428
+ </Accordion.Item>
429
+ <Accordion.Item value="active">
430
+ <Accordion.Control>View currently active settings</Accordion.Control>
431
+ <Accordion.Panel>
432
+ <Code block>{JSON.stringify(active, null, 2)}</Code>
433
+ </Accordion.Panel>
434
+ </Accordion.Item>
435
+ </Accordion>
436
+ </Stack>
437
+ </Fieldset>
438
+ <Group>
439
+ <Button id="save" type="submit" variant="filled" loading={busy} disabled={stale || !!jsonError}>
440
+ Save changes
441
+ </Button>
442
+ <Text size="xs" c="dimmed">
443
+ Saved to <Code>atlas.server.json</Code>. Restart Atlas to apply.
444
+ </Text>
445
+ </Group>
446
+ </Stack>
447
+ </Box>
448
+ </Stack>
449
+ </Box>
450
+ )}
451
+ </Stack>
452
+ </Container>
453
+ </AppShell.Main>
454
+ </AppShell>
455
+ );
456
+ }
@@ -0,0 +1,11 @@
1
+ import '@mantine/core/styles.css';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { AtlasThemeProvider } from '../ui/theme';
4
+ import { SettingsPage } from './SettingsPage';
5
+ const root = document.getElementById('root');
6
+ if (root)
7
+ createRoot(root).render(
8
+ <AtlasThemeProvider>
9
+ <SettingsPage />
10
+ </AtlasThemeProvider>
11
+ );
@@ -1,52 +1,88 @@
1
+ import { isStatechartFile } from '@crvouga/atlas/schema';
2
+ import { Alert, Box, Burger, Button, Container, Group, AppShell as MantineAppShell, ScrollArea, Stack, Text } from '@mantine/core';
1
3
  import { Outlet, useLocation, useSearch } from '@tanstack/react-router';
2
- import { lazy, Suspense } from 'react';
3
-
4
- import { LiveRunsProvider } from '../live/live-runs';
4
+ import { lazy, Suspense, useEffect, useState } from 'react';
5
5
  import { Header } from '../components/Header';
6
- import { EvidenceProvider } from '../evidence/evidence-context';
6
+ import { EvidenceProvider, useEvidence } from '../evidence/evidence-context';
7
7
  import { SourcesPanel } from '../evidence/sources-panel';
8
8
  import { SpecIndex } from '../evidence/spec-index';
9
- import { kindOfAddress } from '../spec-kind';
10
- import styles from '../views/views.module.css';
11
- import { AtlasProvider, useAtlas } from './atlas-context';
12
- import { Gazetteer } from './gazetteer';
13
- import { useEvidence } from '../evidence/evidence-context';
14
- import { isStatechartFile } from '@crvouga/atlas/schema';
15
9
  import { Explorer } from '../explorer/Explorer';
16
10
  import { ProjectIndex } from '../explorer/ExplorerIndex';
17
11
  import { TestCatalogProvider } from '../explorer/catalog';
18
-
19
- // The finder brings every spec parser with it. It loads as soon as the page has drawn, long before anyone presses ⌘K.
12
+ import { LiveRunsProvider } from '../live/live-runs';
13
+ import { kindOfAddress } from '../spec-kind';
14
+ import { AtlasProvider, useAtlas } from './atlas-context';
15
+ import { Gazetteer } from './gazetteer';
20
16
  const loadFinder = () => import('../find/Finder').then((m) => ({ default: m.Finder }));
21
17
  const Finder = lazy(loadFinder);
22
18
  if (typeof window !== 'undefined') setTimeout(() => void loadFinder(), 0);
23
-
24
19
  function Index() {
25
20
  const evidence = useEvidence();
26
21
  const { pathname } = useLocation();
27
22
  const kind = kindOfAddress(pathname);
28
- return kind ? <SpecIndex kind={kind} /> : pathname.startsWith('/specs/') ? <nav aria-label="Specs" /> : evidence.ready && !evidence.files.some(isStatechartFile) ? <ProjectIndex /> : <Gazetteer />;
23
+ return kind ? (
24
+ <SpecIndex kind={kind} />
25
+ ) : pathname.startsWith('/specs/') ? (
26
+ <Box component="nav" aria-label="Specs" />
27
+ ) : evidence.ready && !evidence.files.some(isStatechartFile) ? (
28
+ <ProjectIndex />
29
+ ) : (
30
+ <Gazetteer />
31
+ );
29
32
  }
30
-
31
33
  function Body() {
32
34
  const evidence = useEvidence();
33
35
  const { view, spec } = useAtlas();
34
36
  const { pathname } = useLocation();
35
37
  if (pathname === '/' && evidence.ready && !evidence.filesPending && !evidence.files.some(isStatechartFile)) return <Explorer />;
36
- if (!pathname.startsWith('/specs/') && !view && spec.error) {
38
+ if (!pathname.startsWith('/specs/') && !view && spec.error)
37
39
  return (
38
- <div className={styles.message} role="alert">
39
- <h1>The spec couldn’t be loaded</h1>
40
- <p>{spec.error}</p>
41
- <button type="button" className={styles.button} onClick={spec.retry}>
42
- Try again
43
- </button>
44
- </div>
40
+ <Container py="xl">
41
+ <Alert color="red" title="The spec couldn’t be loaded">
42
+ <Stack gap="sm">
43
+ <Text>{spec.error}</Text>
44
+ <Button onClick={spec.retry}>Try again</Button>
45
+ </Stack>
46
+ </Alert>
47
+ </Container>
45
48
  );
46
- }
47
49
  return <Outlet />;
48
50
  }
49
-
51
+ function Layout() {
52
+ const [opened, setOpened] = useState(false);
53
+ const location = useLocation();
54
+ useEffect(() => setOpened(false), [location.href]);
55
+ return (
56
+ <MantineAppShell header={{ height: 64 }} navbar={{ width: 288, breakpoint: 'md', collapsed: { mobile: !opened } }} padding={0}>
57
+ <MantineAppShell.Header>
58
+ <Group h="100%" px="md" gap="sm" wrap="nowrap">
59
+ <Burger
60
+ aria-expanded={opened}
61
+ aria-controls="atlas-navigation"
62
+ opened={opened}
63
+ onClick={() => setOpened((v) => !v)}
64
+ hiddenFrom="md"
65
+ size="sm"
66
+ aria-label="Toggle navigation"
67
+ />
68
+ <Header />
69
+ </Group>
70
+ </MantineAppShell.Header>
71
+ <MantineAppShell.Navbar id="atlas-navigation" component="div">
72
+ <ScrollArea h="100%">
73
+ <Index />
74
+ </ScrollArea>
75
+ </MantineAppShell.Navbar>
76
+ <MantineAppShell.Main>
77
+ <Body />
78
+ </MantineAppShell.Main>
79
+ <SourcesPanel />
80
+ <Suspense fallback={null}>
81
+ <Finder />
82
+ </Suspense>
83
+ </MantineAppShell>
84
+ );
85
+ }
50
86
  export function AppShell() {
51
87
  const { run } = useSearch({ strict: false });
52
88
  return (
@@ -54,19 +90,7 @@ export function AppShell() {
54
90
  <AtlasProvider run={run}>
55
91
  <EvidenceProvider>
56
92
  <TestCatalogProvider>
57
- <div className={styles.app}>
58
- <Header />
59
- <div className={styles.frame}>
60
- <Index />
61
- <main className={styles.main}>
62
- <Body />
63
- </main>
64
- </div>
65
- <SourcesPanel />
66
- <Suspense fallback={null}>
67
- <Finder />
68
- </Suspense>
69
- </div>
93
+ <Layout />
70
94
  </TestCatalogProvider>
71
95
  </EvidenceProvider>
72
96
  </AtlasProvider>