@crvouga/atlas 0.10.2 → 0.11.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 +7 -0
- package/dist/server/src/settings-page.d.ts +4 -4
- package/dist/server/src/settings-page.js +17 -78
- package/docs/reference/server.md +4 -4
- package/docs/ui.md +142 -0
- package/docs/workspaces.md +130 -0
- package/llms-full.txt +278 -0
- package/llms.txt +2 -0
- package/package.json +17 -10
- 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
|
@@ -1,105 +1,112 @@
|
|
|
1
|
+
import { SpecLogo } from './SpecLogo';
|
|
2
|
+
import { IconDots } from '@tabler/icons-react';
|
|
3
|
+
import { isStatechartFile } from '@crvouga/atlas/schema';
|
|
4
|
+
import { ActionIcon, Anchor, Badge, Box, Button, Group, Menu, NativeSelect, Popover, Stack, Tooltip } from '@mantine/core';
|
|
1
5
|
import { Link, useLocation, useNavigate, useSearch } from '@tanstack/react-router';
|
|
2
|
-
import { useEffect, useMemo, useRef,
|
|
3
|
-
|
|
6
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
4
7
|
import { useAtlas } from '../app/atlas-context';
|
|
5
|
-
import { setFinderOpen } from '../find/open';
|
|
6
8
|
import type { AtlasView } from '../data/model';
|
|
7
9
|
import { LATEST } from '../data/queries';
|
|
8
10
|
import { useEvidence } from '../evidence/evidence-context';
|
|
9
11
|
import { plural } from '../evidence/format';
|
|
10
12
|
import { excludedCount } from '../evidence/sources-panel';
|
|
13
|
+
import { setFinderOpen } from '../find/open';
|
|
11
14
|
import { useLiveStatus } from '../live/live-stream';
|
|
15
|
+
import { currentProject, runtimeConfig } from '../runtime';
|
|
12
16
|
import { kindOfAddress, outlinePath, SPEC_KINDS } from '../spec-kind';
|
|
13
|
-
import { runtimeConfig } from '../runtime';
|
|
14
17
|
import { useUiStore } from '../state/ui-store';
|
|
15
|
-
import { DataIssues } from './DataIssues';
|
|
16
18
|
import { ActivityIndicator } from './ActivityIndicator';
|
|
17
|
-
import
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import { SearchIcon, ChevronDownIcon } from './icons';
|
|
21
|
-
|
|
19
|
+
import { DataIssues } from './DataIssues';
|
|
20
|
+
import { IconChevronDown as ChevronDownIcon, IconSearch as SearchIcon } from '@tabler/icons-react';
|
|
21
|
+
import { ProjectNotice, ProjectPicker } from './ProjectPicker';
|
|
22
22
|
const dateTime = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
|
23
|
-
|
|
24
23
|
export function formatRunTime(iso: string) {
|
|
25
24
|
const d = new Date(iso);
|
|
26
25
|
return Number.isNaN(d.getTime()) ? 'Unknown time' : dateTime.format(d);
|
|
27
26
|
}
|
|
28
|
-
|
|
29
|
-
/** The product's own name, without the subtitle a spec title may carry: "Gamezilla — the whole product" is Gamezilla. */
|
|
27
|
+
/** The product's own name, without the subtitle a spec title may carry. */
|
|
30
28
|
export function productName(title: string | undefined) {
|
|
31
|
-
|
|
32
|
-
return name || 'Atlas';
|
|
29
|
+
return title?.split(/\s+[—–|-]\s+|:\s+/)[0]?.trim() || 'Atlas';
|
|
33
30
|
}
|
|
34
|
-
|
|
35
31
|
function RunPicker({ view }: { view: AtlasView }) {
|
|
36
32
|
const search = useSearch({ strict: false });
|
|
37
33
|
const navigate = useNavigate();
|
|
38
34
|
const { latestRunId } = useAtlas();
|
|
39
|
-
if (view.runs.length
|
|
35
|
+
if (!view.runs.length) return null;
|
|
40
36
|
const latest = view.runs.find((run) => run.id === latestRunId);
|
|
41
37
|
return (
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
</
|
|
38
|
+
<NativeSelect
|
|
39
|
+
label="Run"
|
|
40
|
+
value={search.run ?? LATEST}
|
|
41
|
+
onChange={(event) =>
|
|
42
|
+
void navigate({ to: '.', search: (prev) => ({ ...prev, run: event.target.value === LATEST ? undefined : event.target.value }) })
|
|
43
|
+
}
|
|
44
|
+
>
|
|
45
|
+
<option value={LATEST}>Latest{latest ? ` · ${formatRunTime(latest.startedAt)}` : ''}</option>
|
|
46
|
+
{view.runs.map((run) => (
|
|
47
|
+
<option key={run.id} value={run.id} disabled={run.progress === 'running'}>
|
|
48
|
+
{formatRunTime(run.startedAt)} ·{' '}
|
|
49
|
+
{run.progress === 'running' ? 'still running' : run.mode === 'showcase' ? 'with videos' : 'quick check'}
|
|
50
|
+
</option>
|
|
51
|
+
))}
|
|
52
|
+
</NativeSelect>
|
|
57
53
|
);
|
|
58
54
|
}
|
|
59
|
-
|
|
60
55
|
function runOnly(prev: { run?: unknown }) {
|
|
61
56
|
return typeof prev.run === 'string' ? { run: prev.run } : {};
|
|
62
57
|
}
|
|
63
|
-
|
|
64
|
-
/** Spec runs writing results right now. Their results are already in the lists below. */
|
|
65
58
|
function LiveRuns() {
|
|
66
59
|
const { active } = useEvidence();
|
|
67
60
|
if (!active.length) return null;
|
|
68
|
-
const results = active.reduce((sum, run) => sum + run.completed, 0);
|
|
69
61
|
return (
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
62
|
+
<Badge color="blue" variant="light" role="status">
|
|
63
|
+
Running {active.length === 1 ? active[0]!.packageDir.split('/').at(-1) : plural(active.length, 'suite', 'suites')} ·{' '}
|
|
64
|
+
{plural(
|
|
65
|
+
active.reduce((n, run) => n + run.completed, 0),
|
|
66
|
+
'result',
|
|
67
|
+
'results'
|
|
68
|
+
)}
|
|
69
|
+
</Badge>
|
|
74
70
|
);
|
|
75
71
|
}
|
|
76
|
-
|
|
77
|
-
/** A fixed-size connection indicator; incoming results never change the header's width. */
|
|
78
72
|
function ServerConnection() {
|
|
79
73
|
const status = useLiveStatus();
|
|
80
74
|
if (status === 'off') return null;
|
|
81
|
-
const label =
|
|
75
|
+
const label =
|
|
76
|
+
status === 'live'
|
|
77
|
+
? 'Live updates connected'
|
|
78
|
+
: status === 'connecting'
|
|
79
|
+
? 'Connecting to live updates'
|
|
80
|
+
: 'Live updates disconnected; reconnecting';
|
|
82
81
|
return (
|
|
83
|
-
<
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
<Tooltip label={label}>
|
|
83
|
+
<Badge size="sm" color={status === 'live' ? 'green' : 'gray'} role="status" aria-label={label}>
|
|
84
|
+
{status === 'live' ? 'Live' : 'Connecting'}
|
|
85
|
+
</Badge>
|
|
86
|
+
</Tooltip>
|
|
87
87
|
);
|
|
88
88
|
}
|
|
89
|
-
|
|
90
|
-
function SourcesButton() {
|
|
89
|
+
function SourcesButton({ onOpen }: { onOpen: () => void }) {
|
|
91
90
|
const { layers, ready, unavailable, excluded } = useEvidence();
|
|
92
91
|
const setOpen = useUiStore((s) => s.setSourcesOpen);
|
|
93
92
|
if (!ready && !unavailable) return null;
|
|
94
93
|
const off = excludedCount(excluded);
|
|
95
94
|
return (
|
|
96
|
-
<
|
|
95
|
+
<Button
|
|
96
|
+
onClick={() => {
|
|
97
|
+
onOpen();
|
|
98
|
+
setOpen(true);
|
|
99
|
+
}}
|
|
100
|
+
rightSection={
|
|
101
|
+
<Badge color={off ? 'orange' : 'gray'}>
|
|
102
|
+
{unavailable ? 'unavailable' : off ? `${off} off` : plural(layers.length, 'layer', 'layers')}
|
|
103
|
+
</Badge>
|
|
104
|
+
}
|
|
105
|
+
>
|
|
97
106
|
Sources
|
|
98
|
-
|
|
99
|
-
</button>
|
|
107
|
+
</Button>
|
|
100
108
|
);
|
|
101
109
|
}
|
|
102
|
-
|
|
103
110
|
export function Header() {
|
|
104
111
|
const { view } = useAtlas();
|
|
105
112
|
const evidence = useEvidence();
|
|
@@ -107,6 +114,12 @@ export function Header() {
|
|
|
107
114
|
const kind = kindOfAddress(pathname);
|
|
108
115
|
const product = productName(view?.title);
|
|
109
116
|
const hasCharts = evidence.files.some(isStatechartFile);
|
|
117
|
+
const sourcesOpen = useUiStore((s) => s.sourcesOpen);
|
|
118
|
+
const [optionsOpen, setOptionsOpen] = useState(false);
|
|
119
|
+
const optionsTarget = useRef<HTMLButtonElement>(null);
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (sourcesOpen) setOptionsOpen(false);
|
|
122
|
+
}, [sourcesOpen]);
|
|
110
123
|
useEffect(() => {
|
|
111
124
|
document.title = product;
|
|
112
125
|
}, [product]);
|
|
@@ -114,73 +127,110 @@ export function Header() {
|
|
|
114
127
|
() =>
|
|
115
128
|
SPEC_KINDS.map((entry) => {
|
|
116
129
|
const files = evidence.files.filter(entry.accepts);
|
|
117
|
-
return {
|
|
130
|
+
return {
|
|
131
|
+
kind: entry,
|
|
132
|
+
files: files.length,
|
|
133
|
+
running: files.some((file) => (evidence.view.specs.get(outlinePath(file))?.summary.counts.running ?? 0) > 0),
|
|
134
|
+
failing: files.filter((file) => (evidence.view.specs.get(outlinePath(file))?.summary.counts.failed ?? 0) > 0).length
|
|
135
|
+
};
|
|
118
136
|
}).filter((tab) => tab.files > 0 || tab.kind === kind),
|
|
119
137
|
[evidence.files, evidence.view, kind]
|
|
120
138
|
);
|
|
139
|
+
const project = currentProject();
|
|
121
140
|
return (
|
|
122
|
-
<
|
|
123
|
-
<
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
</
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
141
|
+
<Group flex={1} miw={0} justify="space-between" wrap="nowrap" gap="xs">
|
|
142
|
+
<Group gap="xs" wrap="nowrap" miw={0}>
|
|
143
|
+
<Anchor fw={800} size="lg" underline="never" renderRoot={(props) => <Link {...props} to="/" search={(prev) => runOnly(prev)} />}>
|
|
144
|
+
Atlas
|
|
145
|
+
</Anchor>
|
|
146
|
+
<Menu keepMounted position="bottom-start">
|
|
147
|
+
<Menu.Target>
|
|
148
|
+
<Button
|
|
149
|
+
variant="subtle"
|
|
150
|
+
aria-label="Spec type"
|
|
151
|
+
leftSection={kind ? <SpecLogo kind={kind} /> : undefined}
|
|
152
|
+
rightSection={<ChevronDownIcon aria-hidden="true" size={12} />}
|
|
153
|
+
>
|
|
154
|
+
{kind?.label ?? (hasCharts ? 'Statecharts' : evidence.ready && !tabs.length ? 'Tests' : 'Specs')}
|
|
155
|
+
</Button>
|
|
156
|
+
</Menu.Target>
|
|
157
|
+
<Menu.Dropdown>
|
|
158
|
+
<Box component="nav" aria-label="Spec type">
|
|
159
|
+
<Menu.Item
|
|
160
|
+
aria-current={pathname.startsWith('/specs/') ? undefined : 'page'}
|
|
161
|
+
renderRoot={(props) => <Link {...props} to="/" search={(prev) => runOnly(prev)} />}
|
|
162
|
+
>
|
|
163
|
+
{hasCharts || !evidence.ready ? 'Statecharts' : 'Tests'}
|
|
164
|
+
</Menu.Item>
|
|
165
|
+
{tabs.map((tab) => (
|
|
166
|
+
<Menu.Item
|
|
167
|
+
key={tab.kind.id}
|
|
168
|
+
leftSection={<SpecLogo kind={tab.kind} />}
|
|
169
|
+
aria-current={tab.kind === kind ? 'page' : undefined}
|
|
170
|
+
rightSection={
|
|
171
|
+
tab.failing ? (
|
|
172
|
+
<Badge color="red">{tab.failing}</Badge>
|
|
173
|
+
) : tab.running ? (
|
|
174
|
+
<ActivityIndicator label={`${tab.kind.label} tests running`} />
|
|
175
|
+
) : undefined
|
|
176
|
+
}
|
|
177
|
+
renderRoot={(props) => <Link {...props} to="/specs/$kind" params={{ kind: tab.kind.id }} search={{}} />}
|
|
178
|
+
>
|
|
179
|
+
{tab.kind.label}
|
|
180
|
+
</Menu.Item>
|
|
181
|
+
))}
|
|
182
|
+
</Box>
|
|
183
|
+
</Menu.Dropdown>
|
|
184
|
+
</Menu>
|
|
185
|
+
<Box visibleFrom="sm" miw={0}>
|
|
186
|
+
<SpecBreadcrumb />
|
|
187
|
+
</Box>
|
|
188
|
+
</Group>
|
|
189
|
+
<Group gap="xs" wrap="nowrap">
|
|
190
|
+
<Box visibleFrom="sm">
|
|
191
|
+
<ServerConnection />
|
|
192
|
+
</Box>
|
|
193
|
+
<Tooltip label="Search (⌘K / Ctrl+K)">
|
|
194
|
+
<ActionIcon aria-label="Find a flow" aria-keyshortcuts="Meta+K Control+K" onClick={() => setFinderOpen(true)}>
|
|
195
|
+
<SearchIcon aria-hidden="true" size={18} />
|
|
196
|
+
</ActionIcon>
|
|
197
|
+
</Tooltip>
|
|
198
|
+
<Popover
|
|
199
|
+
opened={optionsOpen && !sourcesOpen}
|
|
200
|
+
onChange={setOptionsOpen}
|
|
201
|
+
disabled={sourcesOpen}
|
|
202
|
+
keepMounted
|
|
203
|
+
position="bottom-end"
|
|
204
|
+
width={320}
|
|
205
|
+
trapFocus
|
|
206
|
+
>
|
|
207
|
+
<Popover.Target>
|
|
208
|
+
<ActionIcon ref={optionsTarget} aria-label="View options" onClick={() => setOptionsOpen((open) => !open)}>
|
|
209
|
+
<IconDots size={20} aria-hidden="true" />
|
|
210
|
+
</ActionIcon>
|
|
211
|
+
</Popover.Target>
|
|
212
|
+
<Popover.Dropdown>
|
|
213
|
+
<Stack gap="sm">
|
|
214
|
+
{!kind && view && <RunPicker view={view} />}
|
|
215
|
+
<LiveRuns />
|
|
216
|
+
<SourcesButton
|
|
217
|
+
onOpen={() => {
|
|
218
|
+
setOptionsOpen(false);
|
|
219
|
+
// The drawer returns focus to this persistent trigger, not its disappearing Sources button.
|
|
220
|
+
optionsTarget.current?.focus();
|
|
221
|
+
}}
|
|
222
|
+
/>
|
|
223
|
+
{!kind && view && <DataIssues issues={view.issues} />}
|
|
224
|
+
{runtimeConfig()?.api && <Anchor href={`/admin${project ? `?project=${encodeURIComponent(project)}` : ''}`}>Settings</Anchor>}
|
|
225
|
+
<ProjectNotice />
|
|
226
|
+
</Stack>
|
|
227
|
+
</Popover.Dropdown>
|
|
228
|
+
</Popover>
|
|
158
229
|
<ProjectPicker />
|
|
159
|
-
</
|
|
160
|
-
</
|
|
230
|
+
</Group>
|
|
231
|
+
</Group>
|
|
161
232
|
);
|
|
162
233
|
}
|
|
163
|
-
|
|
164
|
-
function HeaderMenu({ label, caption, tools = false, working = false, children }: { label: string; caption: string; tools?: boolean; working?: boolean; children: ReactNode }) {
|
|
165
|
-
const ref = useRef<HTMLDetailsElement>(null);
|
|
166
|
-
useEffect(() => {
|
|
167
|
-
const close = (event: Event) => {
|
|
168
|
-
if (event instanceof KeyboardEvent) {
|
|
169
|
-
if (event.key === 'Escape' && ref.current?.open) { ref.current.open = false; ref.current.querySelector('summary')?.focus(); }
|
|
170
|
-
} else if (event.target instanceof Node && !ref.current?.contains(event.target) && ref.current) ref.current.open = false;
|
|
171
|
-
};
|
|
172
|
-
document.addEventListener('pointerdown', close);
|
|
173
|
-
document.addEventListener('keydown', close);
|
|
174
|
-
return () => { document.removeEventListener('pointerdown', close); document.removeEventListener('keydown', close); };
|
|
175
|
-
}, []);
|
|
176
|
-
return <details ref={ref} className={styles.menu} data-tools={tools || undefined} onToggle={event => {
|
|
177
|
-
if (event.currentTarget.open) for (const other of document.querySelectorAll<HTMLDetailsElement>('header details[open]')) if (other !== event.currentTarget) other.open = false;
|
|
178
|
-
}}>
|
|
179
|
-
<summary role="button" aria-label={label}>{working && <ActivityIndicator label="Specs running" />}{caption}{!tools && <ChevronDownIcon size={12} />}</summary>
|
|
180
|
-
<div className={styles.dropdown}>{children}</div>
|
|
181
|
-
</details>;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
234
|
function SpecBreadcrumb() {
|
|
185
235
|
const evidence = useEvidence();
|
|
186
236
|
const { view } = useAtlas();
|
|
@@ -189,17 +239,39 @@ function SpecBreadcrumb() {
|
|
|
189
239
|
const navigate = useNavigate();
|
|
190
240
|
const kind = kindOfAddress(pathname);
|
|
191
241
|
const chart = pathname.startsWith('/chart/') ? decodeURIComponent(pathname.split('/')[2] ?? '') : null;
|
|
192
|
-
const options = kind
|
|
193
|
-
|
|
242
|
+
const options = kind
|
|
243
|
+
? evidence.files.filter(kind.accepts).map((file) => ({
|
|
244
|
+
id: file,
|
|
245
|
+
name: evidence.titleOf(file),
|
|
246
|
+
running: (evidence.view.specs.get(outlinePath(file))?.summary.counts.running ?? 0) > 0
|
|
247
|
+
}))
|
|
248
|
+
: [...(view?.charts.values() ?? [])].map((item) => ({
|
|
249
|
+
id: item.id,
|
|
250
|
+
name: item.name,
|
|
251
|
+
running: (evidence.view.specs.get(outlinePath(item.file))?.summary.counts.running ?? 0) > 0
|
|
252
|
+
}));
|
|
194
253
|
if (!options.length) return null;
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
254
|
+
return (
|
|
255
|
+
<NativeSelect
|
|
256
|
+
data-running={options.some((option) => option.running) || undefined}
|
|
257
|
+
leftSection={options.some((option) => option.running) ? <ActivityIndicator label="Specs running" /> : undefined}
|
|
258
|
+
aria-label="Spec"
|
|
259
|
+
maw="20rem"
|
|
260
|
+
value={kind ? (search.spec ?? '') : (chart ?? '')}
|
|
261
|
+
onChange={(event) => {
|
|
262
|
+
const value = event.target.value;
|
|
263
|
+
if (kind) void navigate({ to: '/specs/$kind', params: { kind: kind.id }, search: value ? { spec: value } : {} });
|
|
264
|
+
else if (value) void navigate({ to: '/chart/$chartId', params: { chartId: value }, search: {} });
|
|
265
|
+
else void navigate({ to: '/', search: {} });
|
|
266
|
+
}}
|
|
267
|
+
>
|
|
268
|
+
<option value="">All specs</option>
|
|
269
|
+
{options.map((option) => (
|
|
270
|
+
<option key={option.id} value={option.id}>
|
|
271
|
+
{option.name}
|
|
272
|
+
{option.running ? ' · Running' : ''}
|
|
273
|
+
</option>
|
|
274
|
+
))}
|
|
275
|
+
</NativeSelect>
|
|
276
|
+
);
|
|
205
277
|
}
|
|
@@ -1,50 +1,22 @@
|
|
|
1
|
+
import { Badge, Group } from '@mantine/core';
|
|
1
2
|
import { KindIcon } from './labels';
|
|
2
|
-
import styles from './Legend.module.css';
|
|
3
|
-
|
|
4
3
|
export function Legend({ showTime }: { showTime: boolean }) {
|
|
5
4
|
return (
|
|
6
|
-
<
|
|
7
|
-
<
|
|
8
|
-
|
|
9
|
-
<KindIcon kind="user" size={11} />
|
|
10
|
-
</span>
|
|
11
|
-
User action
|
|
12
|
-
</span>
|
|
13
|
-
<span className={styles.item}>
|
|
14
|
-
<span className={styles.chip} data-kind="system">
|
|
15
|
-
<KindIcon kind="system" size={11} />
|
|
16
|
-
</span>
|
|
5
|
+
<Group gap="xs" role="group" aria-label="Legend">
|
|
6
|
+
<Badge leftSection={<KindIcon kind="user" size={12} />}>User action</Badge>
|
|
7
|
+
<Badge color="gray" leftSection={<KindIcon kind="system" size={12} />}>
|
|
17
8
|
System event
|
|
18
|
-
</
|
|
9
|
+
</Badge>
|
|
19
10
|
{showTime && (
|
|
20
|
-
<
|
|
21
|
-
<span className={styles.chip} data-kind="time">
|
|
22
|
-
<KindIcon kind="time" size={11} />
|
|
23
|
-
</span>
|
|
11
|
+
<Badge color="gray" leftSection={<KindIcon kind="time" size={12} />}>
|
|
24
12
|
Time passes
|
|
25
|
-
</
|
|
13
|
+
</Badge>
|
|
26
14
|
)}
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
</
|
|
31
|
-
<
|
|
32
|
-
|
|
33
|
-
<span className={styles.frame} data-status="passed" />
|
|
34
|
-
Working
|
|
35
|
-
</span>
|
|
36
|
-
<span className={styles.item}>
|
|
37
|
-
<span className={styles.frame} data-status="flaky" />
|
|
38
|
-
Unreliable
|
|
39
|
-
</span>
|
|
40
|
-
<span className={styles.item}>
|
|
41
|
-
<span className={styles.frame} data-status="failed" />
|
|
42
|
-
Broken
|
|
43
|
-
</span>
|
|
44
|
-
<span className={styles.item}>
|
|
45
|
-
<span className={styles.frame} data-status="none" />
|
|
46
|
-
Not checked
|
|
47
|
-
</span>
|
|
48
|
-
</div>
|
|
15
|
+
<Badge color="gray">Not reached or not run</Badge>
|
|
16
|
+
<Badge color="green">Working</Badge>
|
|
17
|
+
<Badge color="orange">Unreliable</Badge>
|
|
18
|
+
<Badge color="red">Broken</Badge>
|
|
19
|
+
<Badge color="gray">Not checked</Badge>
|
|
20
|
+
</Group>
|
|
49
21
|
);
|
|
50
22
|
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { useEffect } from 'react';
|
|
2
|
-
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
3
1
|
import { ProjectsSchema, type Project } from '@crvouga/atlas/schema';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
2
|
+
import { Alert, Box, Button, NativeSelect, Text, VisuallyHidden } from '@mantine/core';
|
|
3
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
4
|
+
import { useEffect } from 'react';
|
|
6
5
|
import { liveInvalidator } from '../live/live-query';
|
|
7
|
-
import
|
|
8
|
-
|
|
6
|
+
import { eventsAddress, listenLive, useLiveStatus } from '../live/live-stream';
|
|
7
|
+
import { currentProject, runtimeConfig, runtimeUrl } from '../runtime';
|
|
9
8
|
/** Project changes navigate the page so queries, recordings and event streams cannot carry across projects. */
|
|
10
9
|
export function selectProject(slug: string) {
|
|
11
10
|
const url = new URL(window.location.href);
|
|
@@ -13,7 +12,6 @@ export function selectProject(slug: string) {
|
|
|
13
12
|
url.hash = '#/';
|
|
14
13
|
window.location.assign(url.href);
|
|
15
14
|
}
|
|
16
|
-
|
|
17
15
|
function useProjects() {
|
|
18
16
|
const address = runtimeUrl(runtimeConfig()?.projectsUrl);
|
|
19
17
|
const client = useQueryClient();
|
|
@@ -22,43 +20,80 @@ function useProjects() {
|
|
|
22
20
|
const events = eventsAddress();
|
|
23
21
|
if (!address || !events) return;
|
|
24
22
|
const refresh = liveInvalidator(client, ['projects', address], 250);
|
|
25
|
-
const stop = listenLive(events, event => {
|
|
26
|
-
|
|
23
|
+
const stop = listenLive(events, (event) => {
|
|
24
|
+
if (event.type === 'open' || event.type === 'projects' || event.type === 'report') refresh();
|
|
25
|
+
});
|
|
26
|
+
return () => {
|
|
27
|
+
stop();
|
|
28
|
+
refresh.cancel();
|
|
29
|
+
};
|
|
27
30
|
}, [address, client]);
|
|
28
|
-
return useQuery<Project[]>({
|
|
29
|
-
|
|
31
|
+
return useQuery<Project[]>({
|
|
32
|
+
queryKey: ['projects', address],
|
|
33
|
+
enabled: !!address,
|
|
34
|
+
staleTime: 10000,
|
|
35
|
+
refetchInterval: (query) => (query.state.error?.message.includes('(404)') ? false : streaming ? 60000 : 5000),
|
|
36
|
+
refetchIntervalInBackground: true,
|
|
37
|
+
retry: false,
|
|
30
38
|
queryFn: async ({ signal }) => {
|
|
31
39
|
const response = await fetch(address!, { signal, cache: 'no-store' });
|
|
32
40
|
if (!response.ok) throw new Error(`Projects unavailable (${response.status})`);
|
|
33
41
|
return ProjectsSchema.parse(await response.json()).projects;
|
|
34
|
-
}
|
|
42
|
+
}
|
|
43
|
+
});
|
|
35
44
|
}
|
|
36
|
-
|
|
37
45
|
export function ProjectPicker() {
|
|
38
46
|
const address = runtimeUrl(runtimeConfig()?.projectsUrl);
|
|
39
47
|
const selected = currentProject();
|
|
40
48
|
const query = useProjects();
|
|
41
49
|
useEffect(() => {
|
|
42
|
-
if (!selected && query.data?.length && !query.data.some(project => project.slug === 'default')) selectProject(query.data[0]!.slug);
|
|
50
|
+
if (!selected && query.data?.length && !query.data.some((project) => project.slug === 'default')) selectProject(query.data[0]!.slug);
|
|
43
51
|
}, [selected, query.data]);
|
|
44
52
|
if (!address) return null;
|
|
45
53
|
const projects = query.data ?? [];
|
|
46
54
|
const value = selected ?? 'default';
|
|
47
|
-
return
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
{
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
return (
|
|
56
|
+
<Box
|
|
57
|
+
data-project="true"
|
|
58
|
+
title={query.error ? 'Project switching unavailable. Open View options for details.' : 'Switch project'}
|
|
59
|
+
component="label"
|
|
60
|
+
>
|
|
61
|
+
<VisuallyHidden>Project</VisuallyHidden>
|
|
62
|
+
<NativeSelect
|
|
63
|
+
aria-label="Project"
|
|
64
|
+
maw={{ base: '8rem', sm: '14rem' }}
|
|
65
|
+
value={value}
|
|
66
|
+
disabled={!projects.length}
|
|
67
|
+
onChange={(event) => selectProject(event.target.value)}
|
|
68
|
+
aria-busy={query.isLoading}
|
|
69
|
+
>
|
|
70
|
+
{!projects.some((project) => project.slug === value) && (
|
|
71
|
+
<option value={value}>{selected ?? (query.isLoading ? 'Loading projects…' : 'Default project')}</option>
|
|
72
|
+
)}
|
|
73
|
+
{projects.map((project) => (
|
|
74
|
+
<option key={project.slug} value={project.slug}>
|
|
75
|
+
{project.name}
|
|
76
|
+
</option>
|
|
77
|
+
))}
|
|
78
|
+
</NativeSelect>
|
|
79
|
+
</Box>
|
|
80
|
+
);
|
|
54
81
|
}
|
|
55
|
-
|
|
56
82
|
/** One actionable error in the options menu, without duplicating alerts across the navigation. */
|
|
57
83
|
export function ProjectNotice() {
|
|
58
84
|
const query = useProjects();
|
|
59
85
|
if (!query.error) return null;
|
|
60
|
-
return
|
|
61
|
-
<
|
|
62
|
-
|
|
63
|
-
|
|
86
|
+
return (
|
|
87
|
+
<Alert role="alert" color={'red'} my="sm">
|
|
88
|
+
<Text component="div">
|
|
89
|
+
Project switching is unavailable.{' '}
|
|
90
|
+
{query.error.message.includes('(404)')
|
|
91
|
+
? 'Restart the Atlas server to load project support.'
|
|
92
|
+
: 'The last available project list is shown.'}
|
|
93
|
+
</Text>
|
|
94
|
+
<Button type="button" onClick={() => void query.refetch()} variant="light">
|
|
95
|
+
Retry projects
|
|
96
|
+
</Button>
|
|
97
|
+
</Alert>
|
|
98
|
+
);
|
|
64
99
|
}
|