@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.
- package/CHANGELOG.md +12 -0
- package/README.md +7 -0
- package/dist/reports/src/catalog-health.d.ts +142 -0
- package/dist/reports/src/catalog-health.js +257 -0
- package/dist/reports/src/test-catalog.d.ts +8 -0
- package/dist/reports/src/test-catalog.js +5 -1
- package/dist/schema/src/api.d.ts +79 -0
- package/dist/schema/src/api.js +50 -0
- package/dist/server/src/app.js +9 -0
- package/dist/server/src/llms.js +1 -0
- package/dist/server/src/openapi.js +18 -2
- package/dist/server/src/settings-page.d.ts +4 -4
- package/dist/server/src/settings-page.js +17 -78
- package/docs/reference/http-api.md +34 -0
- package/docs/reference/reports.md +224 -0
- package/docs/reference/schema.md +10 -0
- package/docs/reference/server.md +4 -4
- package/docs/server.md +10 -0
- package/docs/ui.md +142 -0
- package/docs/workspaces.md +130 -0
- package/llms-full.txt +322 -0
- package/llms.txt +2 -0
- package/package.json +17 -10
- package/packages/schema/schemas/catalog-health.schema.json +400 -0
- package/packages/server/openapi.json +596 -0
- package/packages/visualizer/src/admin/SettingsPage.tsx +456 -0
- package/packages/visualizer/src/admin/main.tsx +11 -0
- package/packages/visualizer/src/app/AppShell.tsx +62 -38
- package/packages/visualizer/src/app/gazetteer.tsx +90 -83
- package/packages/visualizer/src/app/router.tsx +33 -15
- package/packages/visualizer/src/components/ActivityIndicator.tsx +4 -5
- package/packages/visualizer/src/components/DataIssues.tsx +36 -54
- package/packages/visualizer/src/components/Header.tsx +197 -125
- package/packages/visualizer/src/components/Legend.tsx +13 -41
- package/packages/visualizer/src/components/ProjectPicker.tsx +61 -26
- package/packages/visualizer/src/components/Screen.tsx +80 -71
- package/packages/visualizer/src/components/SpecLogo.tsx +15 -0
- package/packages/visualizer/src/components/Status.tsx +20 -34
- package/packages/visualizer/src/components/labels.tsx +8 -3
- package/packages/visualizer/src/components/spec-logos/APACHE-2.0.txt +206 -0
- package/packages/visualizer/src/components/spec-logos/CAMUNDA-MIT.txt +21 -0
- package/packages/visualizer/src/components/spec-logos/FILE-ICONS-ISC.txt +13 -0
- package/packages/visualizer/src/components/spec-logos/README.md +23 -0
- package/packages/visualizer/src/components/spec-logos/SIMPLE-ICONS-CC0.txt +30 -0
- package/packages/visualizer/src/components/spec-logos/alloy.svg +1 -0
- package/packages/visualizer/src/components/spec-logos/dmn.svg +3 -0
- package/packages/visualizer/src/components/spec-logos/gherkin.svg +1 -0
- package/packages/visualizer/src/components/spec-logos/openslo.svg +63 -0
- package/packages/visualizer/src/components/spec-logos/quint.svg +1 -0
- package/packages/visualizer/src/evidence/artifacts.tsx +263 -110
- package/packages/visualizer/src/evidence/chart-source.tsx +18 -0
- package/packages/visualizer/src/evidence/check-detail.tsx +283 -139
- package/packages/visualizer/src/evidence/decision-table.tsx +35 -26
- package/packages/visualizer/src/evidence/document-page.tsx +51 -29
- package/packages/visualizer/src/evidence/feature-page.tsx +121 -53
- package/packages/visualizer/src/evidence/marks.tsx +112 -79
- package/packages/visualizer/src/evidence/model-page.tsx +77 -46
- package/packages/visualizer/src/evidence/model-trace.tsx +55 -35
- package/packages/visualizer/src/evidence/overview.tsx +137 -69
- package/packages/visualizer/src/evidence/recording-page.tsx +70 -31
- package/packages/visualizer/src/evidence/recording.tsx +135 -68
- package/packages/visualizer/src/evidence/snapshot.tsx +99 -43
- package/packages/visualizer/src/evidence/source-text.tsx +33 -0
- package/packages/visualizer/src/evidence/sources-panel.tsx +138 -144
- package/packages/visualizer/src/evidence/spec-header.tsx +52 -36
- package/packages/visualizer/src/evidence/spec-index.tsx +61 -32
- package/packages/visualizer/src/evidence/spec-page.tsx +56 -25
- package/packages/visualizer/src/evidence/test-filter.tsx +38 -14
- package/packages/visualizer/src/explorer/Explorer.tsx +688 -261
- package/packages/visualizer/src/explorer/ExplorerIndex.tsx +136 -68
- package/packages/visualizer/src/explorer/RunsView.tsx +307 -94
- package/packages/visualizer/src/explorer/TestDetail.tsx +393 -118
- package/packages/visualizer/src/explorer/parts.tsx +35 -13
- package/packages/visualizer/src/find/Finder.tsx +106 -90
- package/packages/visualizer/src/find/open.ts +17 -20
- package/packages/visualizer/src/live/test-progress.tsx +60 -22
- package/packages/visualizer/src/looks/Stage.tsx +223 -110
- package/packages/visualizer/src/main.tsx +9 -11
- package/packages/visualizer/src/map/ChartMap.tsx +125 -69
- package/packages/visualizer/src/map/EventEdge.tsx +59 -37
- package/packages/visualizer/src/map/map.module.css +12 -546
- package/packages/visualizer/src/map/nodes.tsx +124 -70
- package/packages/visualizer/src/reachable.test.ts +3 -2
- package/packages/visualizer/src/spec-kind.ts +2 -2
- package/packages/visualizer/src/ui/grammars/ALLOY-MIT.txt +21 -0
- package/packages/visualizer/src/ui/grammars/QUINT-APACHE-2.0.txt +206 -0
- package/packages/visualizer/src/ui/grammars/README.md +14 -0
- package/packages/visualizer/src/ui/grammars/alloy.json +71 -0
- package/packages/visualizer/src/ui/grammars/quint.json +152 -0
- package/packages/visualizer/src/ui/highlight.ts +50 -0
- package/packages/visualizer/src/ui/theme.tsx +109 -0
- package/packages/visualizer/src/views/ChartPage.tsx +46 -26
- package/packages/visualizer/src/views/ChartTests.tsx +59 -22
- package/packages/visualizer/src/views/ProductPage.tsx +16 -15
- package/packages/visualizer/src/views/SidePanel.tsx +241 -131
- package/packages/visualizer/src/app/gazetteer.module.css +0 -107
- package/packages/visualizer/src/components/ActivityIndicator.module.css +0 -4
- package/packages/visualizer/src/components/DataIssues.module.css +0 -136
- package/packages/visualizer/src/components/Header.module.css +0 -114
- package/packages/visualizer/src/components/Legend.module.css +0 -84
- package/packages/visualizer/src/components/Screen.module.css +0 -312
- package/packages/visualizer/src/components/Status.module.css +0 -116
- package/packages/visualizer/src/components/icons.tsx +0 -156
- package/packages/visualizer/src/evidence/artifacts.module.css +0 -213
- package/packages/visualizer/src/evidence/evidence.module.css +0 -907
- package/packages/visualizer/src/evidence/recording.module.css +0 -460
- package/packages/visualizer/src/evidence/sources.module.css +0 -168
- package/packages/visualizer/src/evidence/spec-snapshot.tsx +0 -26
- package/packages/visualizer/src/explorer/explorer.module.css +0 -1238
- package/packages/visualizer/src/find/Finder.module.css +0 -168
- package/packages/visualizer/src/looks/stage.module.css +0 -300
- package/packages/visualizer/src/styles/global.css +0 -105
- package/packages/visualizer/src/styles/tokens.css +0 -80
- 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
|
-
|
|
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 ?
|
|
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
|
-
<
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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>
|