@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
@@ -1,3 +1,5 @@
1
+ import { IconX, IconCircleFilled } from '@tabler/icons-react';
2
+ import { Alert, Anchor, Box, Button, Code, Flex, SimpleGrid, Stack, Table, Text, Title, VisuallyHidden } from '@mantine/core';
1
3
  /**
2
4
  * @module Test detail
3
5
  *
@@ -11,24 +13,18 @@ import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
11
13
  import { formatMs, type TestResultEntry, type TestSummary } from '@crvouga/atlas/reports/test-catalog';
12
14
  import { Link } from '@tanstack/react-router';
13
15
  import { lazy, Suspense, useState } from 'react';
14
-
15
16
  import { CheckDetail, type CheckSelection } from '../evidence/check-detail';
16
17
  import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidence/format';
17
18
  import { StatusMark } from '../evidence/marks';
18
19
  import { useTestHistory } from './catalog';
19
- import styles from './explorer.module.css';
20
- import { Badges } from './parts';
21
20
  import { patched } from './navigate';
22
-
21
+ import { Badges } from './parts';
23
22
  const RecordingPage = lazy(() => import('../evidence/recording-page').then((module) => ({ default: module.RecordingPage })));
24
-
25
23
  /** Results the history table shows before "Show more". */
26
24
  const HISTORY_ROWS = 20;
27
-
28
25
  function percent(rate: number | null): string {
29
26
  return rate === null ? '—' : `${Math.round(rate * 100)}%`;
30
27
  }
31
-
32
28
  /** The open test. `slot` is the report's check for it, when the page holds one, for its evidence. */
33
29
  export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot | undefined }) {
34
30
  const now = useNow();
@@ -40,96 +36,286 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
40
36
  const entries = history.data?.history ?? [];
41
37
  const stats = test.stats;
42
38
  const d = stats.durations;
43
-
44
39
  if (recording && slot) {
45
40
  return (
46
- <Suspense fallback={<p className={styles.quiet} role="status">Loading the recording…</p>}>
47
- <RecordingPage slot={slot} layerId={selection.layer} label={test.title} back="Test detail" moment={moment} onMoment={setMoment} onClose={() => setRecording(false)} />
41
+ <Suspense
42
+ fallback={
43
+ <Text role="status" c="dimmed">
44
+ Loading the recording…
45
+ </Text>
46
+ }
47
+ >
48
+ <RecordingPage
49
+ slot={slot}
50
+ layerId={selection.layer}
51
+ label={test.title}
52
+ back="Test detail"
53
+ moment={moment}
54
+ onMoment={setMoment}
55
+ onClose={() => setRecording(false)}
56
+ />
48
57
  </Suspense>
49
58
  );
50
59
  }
51
-
52
60
  return (
53
- <article className={styles.detail} aria-label={test.title}>
54
- <div className={styles.detailTop}>
55
- <Link to="/" search={patched({ test: undefined })} resetScroll={false} activeOptions={{ exact: true }} className={styles.close} aria-label="Close the test">×</Link>
56
- </div>
57
- <header className={styles.detailHead}>
61
+ <Box aria-label={test.title} component="article">
62
+ <Flex gap="xs" align="center" wrap="wrap" justify="flex-end">
63
+ <Anchor
64
+ aria-label="Close the test"
65
+ renderRoot={(props) => (
66
+ <Link {...props} to="/" search={patched({ test: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
67
+ )}
68
+ >
69
+ <IconX size={20} aria-hidden="true" />
70
+ </Anchor>
71
+ </Flex>
72
+ <Box component="header" mb="lg">
58
73
  {test.spec && (
59
- <p className={styles.detailSpec}>
60
- {test.spec.domain && <Link to="/" search={patched({ domain: test.spec.domain, spec: undefined })} resetScroll={false} activeOptions={{ exact: true }}>{test.spec.domain}</Link>}
61
- <Link to="/" search={patched({ spec: test.spec.path, domain: undefined })} resetScroll={false} activeOptions={{ exact: true }}>{test.spec.title}</Link>
62
- </p>
74
+ <Text component="div" c="dimmed">
75
+ {test.spec!.domain && (
76
+ <Anchor
77
+ renderRoot={(props) => (
78
+ <Link
79
+ {...props}
80
+ to="/"
81
+ search={patched({ domain: test.spec!.domain, spec: undefined })}
82
+ resetScroll={false}
83
+ activeOptions={{ exact: true }}
84
+ />
85
+ )}
86
+ >
87
+ {test.spec!.domain}
88
+ </Anchor>
89
+ )}
90
+ <Anchor
91
+ renderRoot={(props) => (
92
+ <Link
93
+ {...props}
94
+ to="/"
95
+ search={patched({ spec: test.spec!.path, domain: undefined })}
96
+ resetScroll={false}
97
+ activeOptions={{ exact: true }}
98
+ />
99
+ )}
100
+ >
101
+ {test.spec!.title}
102
+ </Anchor>
103
+ </Text>
63
104
  )}
64
- <h2 className={styles.detailTitle}>{test.title}</h2>
65
- <p className={styles.detailStatus}>
105
+ <Title order={2} mb="sm">
106
+ {test.title}
107
+ </Title>
108
+ <Flex gap="xs" align="center" wrap="wrap">
66
109
  <StatusMark status={test.status} />
67
- <strong data-status={test.status}>{statusWord(test.status)}</strong>
68
- {stats.flaky && <span className={styles.flaky} title={stats.flakyReason}>Flaky: {stats.flakyReason}</span>}
110
+ <Text data-status={test.status} span inherit fw={600}>
111
+ {statusWord(test.status)}
112
+ </Text>
113
+ {stats.flaky && (
114
+ <Text title={stats.flakyReason} span inherit>
115
+ Flaky: {stats.flakyReason}
116
+ </Text>
117
+ )}
69
118
  <Badges test={test} />
70
- </p>
71
- {test.spec && <code className={styles.mono}>{test.spec.path}</code>}
72
- <code className={styles.mono} title="Check id">{test.id}</code>
73
- </header>
119
+ </Flex>
120
+ {test.spec && <Code c="dimmed">{test.spec!.path}</Code>}
121
+ <Code title="Check id" c="dimmed">
122
+ {test.id}
123
+ </Code>
124
+ </Box>
74
125
 
75
- <section className={styles.detailBlock} aria-label="Current results">
76
- <h3 className={styles.label}>Current result per environment</h3>
77
- {test.outcomes.length === 0 ? <p className={styles.quiet}>No completed result recorded yet.</p> : (
78
- <div className={styles.tableScroll}>
79
- <table className={styles.table}>
80
- <thead><tr><th scope="col">Environment</th><th scope="col">Producer</th><th scope="col">Status</th><th scope="col">Duration</th><th scope="col">Branch</th><th scope="col">Commit</th><th scope="col">Recorded</th><th scope="col"><span className="sr-only">Run</span></th></tr></thead>
81
- <tbody>
126
+ <Box aria-label="Current results" component="section" mb="lg">
127
+ <Title order={3} mb="sm" c="dimmed">
128
+ Current result per environment
129
+ </Title>
130
+ {test.outcomes.length === 0 ? (
131
+ <Text component="div" c="dimmed">
132
+ No completed result recorded yet.
133
+ </Text>
134
+ ) : (
135
+ <Table.ScrollContainer minWidth={600}>
136
+ <Table>
137
+ <Table.Thead>
138
+ <Table.Tr>
139
+ <Table.Th scope="col">Environment</Table.Th>
140
+ <Table.Th scope="col">Producer</Table.Th>
141
+ <Table.Th scope="col">Status</Table.Th>
142
+ <Table.Th scope="col">Duration</Table.Th>
143
+ <Table.Th scope="col">Branch</Table.Th>
144
+ <Table.Th scope="col">Commit</Table.Th>
145
+ <Table.Th scope="col">Recorded</Table.Th>
146
+ <Table.Th scope="col">
147
+ <VisuallyHidden>Run</VisuallyHidden>
148
+ </Table.Th>
149
+ </Table.Tr>
150
+ </Table.Thead>
151
+ <Table.Tbody>
82
152
  {test.outcomes.map((outcome) => (
83
- <tr key={`${outcome.layer}|${outcome.producer}|${outcome.environment}|${JSON.stringify(outcome.parameters)}`} data-status={outcome.status}>
84
- <td>{environmentLabel(outcome.environment)}{Object.entries(outcome.parameters).map(([name, value]) => <span key={name} className={styles.param}> {name}: {value}</span>)}</td>
85
- <td>{outcome.producer}</td>
86
- <td><span className={styles.cellStatus}><StatusMark status={outcome.status} size={12} />{statusWord(outcome.status)}</span></td>
87
- <td className={styles.num}>{formatMs(outcome.durationMs)}</td>
88
- <td className={styles.ellipsis} title={outcome.branch ?? ''}>{outcome.branch ?? '—'}</td>
89
- <td><code>{shortSha(outcome.sha) ?? '—'}</code></td>
90
- <td title={exactTime(outcome.at)}>{ago(outcome.at, now)}</td>
91
- <td>{outcome.runUrl ? <a href={outcome.runUrl} target="_blank" rel="noreferrer">CI run</a> : null}</td>
92
- </tr>
153
+ <Table.Tr
154
+ key={`${outcome.layer}|${outcome.producer}|${outcome.environment}|${JSON.stringify(outcome.parameters)}`}
155
+ data-status={outcome.status}
156
+ >
157
+ <Table.Td>
158
+ {environmentLabel(outcome.environment)}
159
+ {Object.entries(outcome.parameters).map(([name, value]) => (
160
+ <Text key={name} span inherit c="dimmed">
161
+ {' '}
162
+ {name}: {value}
163
+ </Text>
164
+ ))}
165
+ </Table.Td>
166
+ <Table.Td>{outcome.producer}</Table.Td>
167
+ <Table.Td>
168
+ <Flex component="span" display="inline-flex" gap="xs" align="center" wrap="wrap">
169
+ <StatusMark status={outcome.status} size={12} />
170
+ {statusWord(outcome.status)}
171
+ </Flex>
172
+ </Table.Td>
173
+ <Table.Td>{formatMs(outcome.durationMs)}</Table.Td>
174
+ <Table.Td title={outcome.branch ?? ''} c="dimmed">
175
+ {outcome.branch ?? '—'}
176
+ </Table.Td>
177
+ <Table.Td>
178
+ <Code>{shortSha(outcome.sha) ?? '—'}</Code>
179
+ </Table.Td>
180
+ <Table.Td title={exactTime(outcome.at)}>{ago(outcome.at, now)}</Table.Td>
181
+ <Table.Td>
182
+ {outcome.runUrl ? (
183
+ <Anchor href={outcome.runUrl} target="_blank" rel="noreferrer">
184
+ CI run
185
+ </Anchor>
186
+ ) : null}
187
+ </Table.Td>
188
+ </Table.Tr>
93
189
  ))}
94
- </tbody>
95
- </table>
96
- </div>
190
+ </Table.Tbody>
191
+ </Table>
192
+ </Table.ScrollContainer>
97
193
  )}
98
- </section>
194
+ </Box>
99
195
 
100
- <section className={styles.detailBlock} aria-label="Statistics">
101
- <h3 className={styles.label}>How it has gone</h3>
102
- <dl className={styles.stats}>
103
- <div><dt>Pass rate</dt><dd>{percent(stats.passRate)}</dd></div>
104
- <div><dt>Results</dt><dd>{stats.results} <span>({stats.passed} passed{stats.failed ? `, ${stats.failed} failed` : ''})</span></dd></div>
105
- <div><dt>Runs</dt><dd>{stats.runs}</dd></div>
106
- <div><dt>Flips</dt><dd>{stats.flips}</dd></div>
107
- <div><dt>Average</dt><dd>{formatMs(d?.avgMs)}</dd></div>
108
- <div><dt>p50</dt><dd>{formatMs(d?.p50Ms)}</dd></div>
109
- <div><dt>p95</dt><dd>{formatMs(d?.p95Ms)}</dd></div>
110
- <div><dt>Min – max</dt><dd>{d ? `${formatMs(d.minMs)} – ${formatMs(d.maxMs)}` : '—'}</dd></div>
111
- <div><dt>Last</dt><dd>{formatMs(d?.lastMs)}</dd></div>
112
- <div><dt>Last passed</dt><dd>{stats.lastPassedAtMs ? ago(stats.lastPassedAtMs, now) : '—'}</dd></div>
113
- <div><dt>Last failed</dt><dd>{stats.lastFailedAtMs ? ago(stats.lastFailedAtMs, now) : '—'}</dd></div>
114
- <div><dt>First seen</dt><dd>{stats.firstSeenMs ? ago(stats.firstSeenMs, now) : '—'}</dd></div>
115
- </dl>
116
- {stats.branches.length > 0 && <p className={styles.quiet}>Branches: {stats.branches.join(', ')}</p>}
117
- {!d && <p className={styles.quiet}>No duration recorded. Producers can send <code>durationMs</code> with each check.</p>}
196
+ <Box aria-label="Statistics" component="section" mb="lg">
197
+ <Title order={3} mb="sm" c="dimmed">
198
+ How it has gone
199
+ </Title>
200
+ <SimpleGrid component="dl" cols={{ base: 2, sm: 3 }}>
201
+ <Stack gap="sm">
202
+ <Box component="dt">Pass rate</Box>
203
+ <Box component="dd" m={0}>
204
+ {percent(stats.passRate)}
205
+ </Box>
206
+ </Stack>
207
+ <Stack gap="sm">
208
+ <Box component="dt">Results</Box>
209
+ <Box component="dd" m={0}>
210
+ {stats.results}{' '}
211
+ <Text span inherit>
212
+ ({stats.passed} passed{stats.failed ? `, ${stats.failed} failed` : ''})
213
+ </Text>
214
+ </Box>
215
+ </Stack>
216
+ <Stack gap="sm">
217
+ <Box component="dt">Runs</Box>
218
+ <Box component="dd" m={0}>
219
+ {stats.runs}
220
+ </Box>
221
+ </Stack>
222
+ <Stack gap="sm">
223
+ <Box component="dt">Flips</Box>
224
+ <Box component="dd" m={0}>
225
+ {stats.flips}
226
+ </Box>
227
+ </Stack>
228
+ <Stack gap="sm">
229
+ <Box component="dt">Average</Box>
230
+ <Box component="dd" m={0}>
231
+ {formatMs(d?.avgMs)}
232
+ </Box>
233
+ </Stack>
234
+ <Stack gap="sm">
235
+ <Box component="dt">p50</Box>
236
+ <Box component="dd" m={0}>
237
+ {formatMs(d?.p50Ms)}
238
+ </Box>
239
+ </Stack>
240
+ <Stack gap="sm">
241
+ <Box component="dt">p95</Box>
242
+ <Box component="dd" m={0}>
243
+ {formatMs(d?.p95Ms)}
244
+ </Box>
245
+ </Stack>
246
+ <Stack gap="sm">
247
+ <Box component="dt">Min – max</Box>
248
+ <Box component="dd" m={0}>
249
+ {d ? `${formatMs(d.minMs)} – ${formatMs(d.maxMs)}` : '—'}
250
+ </Box>
251
+ </Stack>
252
+ <Stack gap="sm">
253
+ <Box component="dt">Last</Box>
254
+ <Box component="dd" m={0}>
255
+ {formatMs(d?.lastMs)}
256
+ </Box>
257
+ </Stack>
258
+ <Stack gap="sm">
259
+ <Box component="dt">Last passed</Box>
260
+ <Box component="dd" m={0}>
261
+ {stats.lastPassedAtMs ? ago(stats.lastPassedAtMs, now) : '—'}
262
+ </Box>
263
+ </Stack>
264
+ <Stack gap="sm">
265
+ <Box component="dt">Last failed</Box>
266
+ <Box component="dd" m={0}>
267
+ {stats.lastFailedAtMs ? ago(stats.lastFailedAtMs, now) : '—'}
268
+ </Box>
269
+ </Stack>
270
+ <Stack gap="sm">
271
+ <Box component="dt">First seen</Box>
272
+ <Box component="dd" m={0}>
273
+ {stats.firstSeenMs ? ago(stats.firstSeenMs, now) : '—'}
274
+ </Box>
275
+ </Stack>
276
+ </SimpleGrid>
277
+ {stats.branches.length > 0 && (
278
+ <Text component="div" c="dimmed">
279
+ Branches: {stats.branches.join(', ')}
280
+ </Text>
281
+ )}
282
+ {!d && (
283
+ <Text component="div" c="dimmed">
284
+ No duration recorded. Producers can send <Code>durationMs</Code> with each check.
285
+ </Text>
286
+ )}
118
287
  {entries.length > 1 && <Trend entries={entries} />}
119
- </section>
288
+ </Box>
120
289
 
121
290
  {(test.tags.length > 0 || Object.keys(test.labels).length > 0) && (
122
- <section className={styles.detailBlock} aria-label="Tags and labels">
123
- <h3 className={styles.label}>Tags and labels</h3>
124
- <p className={styles.tags}>
125
- {test.tags.map((tag) => <Link key={tag} to="/" search={patched({ tag })} resetScroll={false} activeOptions={{ exact: true }}>{tag}</Link>)}
126
- {Object.entries(test.labels).map(([name, value]) => <span key={name}>{name}: {value}</span>)}
127
- </p>
128
- </section>
291
+ <Box aria-label="Tags and labels" component="section" mb="lg">
292
+ <Title order={3} mb="sm" c="dimmed">
293
+ Tags and labels
294
+ </Title>
295
+ <Flex gap="xs" align="center" wrap="wrap">
296
+ {test.tags.map((tag) => (
297
+ <Anchor
298
+ key={tag}
299
+ renderRoot={(props) => (
300
+ <Link {...props} to="/" search={patched({ tag })} resetScroll={false} activeOptions={{ exact: true }} />
301
+ )}
302
+ >
303
+ {tag}
304
+ </Anchor>
305
+ ))}
306
+ {Object.entries(test.labels).map(([name, value]) => (
307
+ <Text key={name} span inherit>
308
+ {name}: {value}
309
+ </Text>
310
+ ))}
311
+ </Flex>
312
+ </Box>
129
313
  )}
130
314
 
131
- <section className={styles.detailBlock} aria-label="Evidence">
132
- <h3 className={styles.label}>What the current result carries</h3>
315
+ <Box aria-label="Evidence" component="section" mb="lg">
316
+ <Title order={3} mb="sm" c="dimmed">
317
+ What the current result carries
318
+ </Title>
133
319
  {slot ? (
134
320
  <CheckDetail
135
321
  slot={slot}
@@ -137,78 +323,167 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
137
323
  reads="steps"
138
324
  select={(patch) => {
139
325
  setSelection({ check: patch.check ?? slot.id, layer: patch.layer ?? undefined });
140
- if (patch.view === 'recording') { setRecording(true); setMoment(null); }
326
+ if (patch.view === 'recording') {
327
+ setRecording(true);
328
+ setMoment(null);
329
+ }
141
330
  }}
142
331
  />
143
- ) : <p className={styles.quiet}>The evidence of this result is not loaded in this page.</p>}
144
- </section>
332
+ ) : (
333
+ <Text component="div" c="dimmed">
334
+ The evidence of this result is not loaded in this page.
335
+ </Text>
336
+ )}
337
+ </Box>
145
338
 
146
- <section className={styles.detailBlock} aria-label="History">
147
- <h3 className={styles.label}>History{entries.length ? ` · ${plural(entries.length, 'result', 'results')}` : ''}</h3>
148
- {history.isPending && <p className={styles.quiet} role="status">Reading the history…</p>}
149
- {history.isError && <p className={styles.notice} role="alert">The history could not be read. {history.error.message}</p>}
339
+ <Box aria-label="History" component="section" mb="lg">
340
+ <Title order={3} mb="sm" c="dimmed">
341
+ History{entries.length ? ` · ${plural(entries.length, 'result', 'results')}` : ''}
342
+ </Title>
343
+ {history.isPending && (
344
+ <Text role="status" component="div" c="dimmed">
345
+ Reading the history…
346
+ </Text>
347
+ )}
348
+ {history.isError && (
349
+ <Alert role="alert" color={'red'} my="sm">
350
+ The history could not be read. {history.error.message}
351
+ </Alert>
352
+ )}
150
353
  {entries.length > 0 && (
151
- <div className={styles.tableScroll}>
152
- <table className={styles.table}>
153
- <thead><tr><th scope="col">Recorded</th><th scope="col">Environment</th><th scope="col">Status</th><th scope="col">Duration</th><th scope="col">Branch</th><th scope="col">Commit</th><th scope="col">Detail</th></tr></thead>
154
- <tbody>
354
+ <Table.ScrollContainer minWidth={600}>
355
+ <Table>
356
+ <Table.Thead>
357
+ <Table.Tr>
358
+ <Table.Th scope="col">Recorded</Table.Th>
359
+ <Table.Th scope="col">Environment</Table.Th>
360
+ <Table.Th scope="col">Status</Table.Th>
361
+ <Table.Th scope="col">Duration</Table.Th>
362
+ <Table.Th scope="col">Branch</Table.Th>
363
+ <Table.Th scope="col">Commit</Table.Th>
364
+ <Table.Th scope="col">Detail</Table.Th>
365
+ </Table.Tr>
366
+ </Table.Thead>
367
+ <Table.Tbody>
155
368
  {entries.slice(0, rows).map((entry) => (
156
- <tr key={`${entry.layer}|${entry.kind}|${entry.producer}|${entry.environment}|${JSON.stringify(entry.parameters)}`} data-status={entry.status}>
157
- <td title={exactTime(entry.at)}>{entry.runUrl ? <a href={entry.runUrl} target="_blank" rel="noreferrer">{ago(entry.at, now)}</a> : ago(entry.at, now)}</td>
158
- <td>{environmentLabel(entry.environment)}</td>
159
- <td><span className={styles.cellStatus}><StatusMark status={entry.status} size={12} />{statusWord(entry.status)}</span></td>
160
- <td className={styles.num}>{formatMs(entry.durationMs)}</td>
161
- <td className={styles.ellipsis} title={entry.branch ?? ''}>{entry.branch ?? '—'}</td>
162
- <td><code>{shortSha(entry.sha) ?? '—'}</code></td>
163
- <td className={styles.detailCell} title={entry.detail}>{entry.detail.split('\n')[0]}</td>
164
- </tr>
369
+ <Table.Tr
370
+ key={`${entry.layer}|${entry.kind}|${entry.producer}|${entry.environment}|${JSON.stringify(entry.parameters)}`}
371
+ data-status={entry.status}
372
+ >
373
+ <Table.Td title={exactTime(entry.at)}>
374
+ {entry.runUrl ? (
375
+ <Anchor href={entry.runUrl} target="_blank" rel="noreferrer">
376
+ {ago(entry.at, now)}
377
+ </Anchor>
378
+ ) : (
379
+ ago(entry.at, now)
380
+ )}
381
+ </Table.Td>
382
+ <Table.Td>{environmentLabel(entry.environment)}</Table.Td>
383
+ <Table.Td>
384
+ <Flex component="span" display="inline-flex" gap="xs" align="center" wrap="wrap">
385
+ <StatusMark status={entry.status} size={12} />
386
+ {statusWord(entry.status)}
387
+ </Flex>
388
+ </Table.Td>
389
+ <Table.Td>{formatMs(entry.durationMs)}</Table.Td>
390
+ <Table.Td title={entry.branch ?? ''} c="dimmed">
391
+ {entry.branch ?? '—'}
392
+ </Table.Td>
393
+ <Table.Td>
394
+ <Code>{shortSha(entry.sha) ?? '—'}</Code>
395
+ </Table.Td>
396
+ <Table.Td title={entry.detail} c="dimmed">
397
+ {entry.detail.split('\n')[0]}
398
+ </Table.Td>
399
+ </Table.Tr>
165
400
  ))}
166
- </tbody>
167
- </table>
168
- </div>
401
+ </Table.Tbody>
402
+ </Table>
403
+ </Table.ScrollContainer>
169
404
  )}
170
- {entries.length > rows && <button type="button" className={styles.link} onClick={() => setRows(rows + HISTORY_ROWS)}>Show more ({entries.length - rows} more)</button>}
171
- </section>
172
- </article>
405
+ {entries.length > rows && (
406
+ <Button type="button" onClick={() => setRows(rows + HISTORY_ROWS)} variant="subtle">
407
+ Show more ({entries.length - rows} more)
408
+ </Button>
409
+ )}
410
+ </Box>
411
+ </Box>
173
412
  );
174
413
  }
175
-
176
414
  /** The duration of each completed result over time, and a strip of every outcome, oldest on the left. */
177
415
  function Trend({ entries }: { entries: readonly TestResultEntry[] }) {
178
416
  const oldest = [...entries].reverse();
179
417
  const timed = oldest.filter((entry) => entry.durationMs !== null && (entry.status === 'passed' || entry.status === 'failed'));
180
- const width = 320, height = 56, pad = 4;
418
+ const width = 320,
419
+ height = 56,
420
+ pad = 4;
181
421
  const max = Math.max(1, ...timed.map((entry) => entry.durationMs!));
182
422
  const x = (index: number) => (timed.length === 1 ? width / 2 : pad + (index * (width - 2 * pad)) / (timed.length - 1));
183
423
  const y = (ms: number) => height - pad - (ms / max) * (height - 2 * pad);
184
424
  const points = timed.map((entry, index) => `${x(index).toFixed(1)},${y(entry.durationMs!).toFixed(1)}`).join(' ');
185
425
  return (
186
- <div className={styles.trend}>
426
+ <Stack gap="sm">
187
427
  {timed.length > 1 && (
188
- <figure className={styles.spark}>
189
- <figcaption>Duration over the last {plural(timed.length, 'result', 'results')} · max {formatMs(max)}</figcaption>
190
- <svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label={`Durations from ${formatMs(timed[0]!.durationMs)} to ${formatMs(timed.at(-1)!.durationMs)}`} preserveAspectRatio="none">
191
- <line x1={pad} x2={width - pad} y1={height - pad} y2={height - pad} className={styles.sparkBase} />
192
- <polyline points={points} className={styles.sparkLine} />
428
+ <Box component="figure" m={0}>
429
+ <Box component="figcaption">
430
+ Duration over the last {plural(timed.length, 'result', 'results')} · max {formatMs(max)}
431
+ </Box>
432
+ <svg
433
+ viewBox={`0 0 ${width} ${height}`}
434
+ role="img"
435
+ aria-label={`Durations from ${formatMs(timed[0]!.durationMs)} to ${formatMs(timed.at(-1)!.durationMs)}`}
436
+ preserveAspectRatio="none"
437
+ >
438
+ <line x1={pad} x2={width - pad} y1={height - pad} y2={height - pad} stroke="var(--mantine-color-gray-3)" />
439
+ <polyline points={points} fill="none" stroke="var(--mantine-color-blue-7)" strokeWidth={2} />
193
440
  {timed.map((entry, index) => (
194
- <circle key={index} cx={x(index)} cy={y(entry.durationMs!)} r={entry.status === 'failed' ? 3 : index === timed.length - 1 ? 2.5 : 0} data-status={entry.status}>
441
+ <circle
442
+ key={index}
443
+ cx={x(index)}
444
+ cy={y(entry.durationMs!)}
445
+ r={entry.status === 'failed' ? 3 : index === timed.length - 1 ? 2.5 : 0}
446
+ data-status={entry.status}
447
+ >
195
448
  <title>{`${exactTime(entry.at)} · ${environmentLabel(entry.environment)} · ${statusWord(entry.status)} · ${formatMs(entry.durationMs)}`}</title>
196
449
  </circle>
197
450
  ))}
198
451
  {/* Wide invisible targets, so a point is easy to hover. */}
199
452
  {timed.map((entry, index) => (
200
- <rect key={`hit${index}`} x={x(index) - (width / timed.length) / 2} y={0} width={width / timed.length} height={height} className={styles.sparkHit}>
453
+ <rect
454
+ key={`hit${index}`}
455
+ x={x(index) - width / timed.length / 2}
456
+ y={0}
457
+ width={width / timed.length}
458
+ height={height}
459
+ fill="transparent"
460
+ >
201
461
  <title>{`${exactTime(entry.at)} · ${environmentLabel(entry.environment)} · ${statusWord(entry.status)} · ${formatMs(entry.durationMs)}`}</title>
202
462
  </rect>
203
463
  ))}
204
464
  </svg>
205
- </figure>
465
+ </Box>
206
466
  )}
207
- <div className={styles.strip} role="img" aria-label={`Outcomes, oldest first: ${oldest.map((entry) => statusWord(entry.status)).join(', ')}`}>
467
+ <Flex
468
+ role="img"
469
+ aria-label={`Outcomes, oldest first: ${oldest.map((entry) => statusWord(entry.status)).join(', ')}`}
470
+ gap="xs"
471
+ align="center"
472
+ wrap="wrap"
473
+ >
208
474
  {oldest.map((entry, index) => (
209
- <span key={index} data-status={entry.status} title={`${exactTime(entry.at)} · ${environmentLabel(entry.environment)} · ${statusWord(entry.status)}${entry.durationMs !== null ? ` · ${formatMs(entry.durationMs)}` : ''}`} />
475
+ <Text
476
+ key={index}
477
+ c={entry.status === 'passed' ? 'green' : entry.status === 'failed' ? 'red' : 'gray'}
478
+ data-status={entry.status}
479
+ title={`${exactTime(entry.at)} · ${environmentLabel(entry.environment)} · ${statusWord(entry.status)}${entry.durationMs !== null ? ` · ${formatMs(entry.durationMs)}` : ''}`}
480
+ span
481
+ inherit
482
+ >
483
+ <IconCircleFilled size={12} aria-hidden="true" />
484
+ </Text>
210
485
  ))}
211
- </div>
212
- </div>
486
+ </Flex>
487
+ </Stack>
213
488
  );
214
489
  }