@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,16 +1,13 @@
1
1
  import { PHONE_H, PHONE_W } from '@crvouga/atlas/reports/phone';
2
2
  import type { ReportSnapshotBlob, ReportStepHit } from '@crvouga/atlas/reports/report-types';
3
3
  import { createSnapshotHost, paintSnapshotHost, type HitResult } from '@crvouga/atlas/reports/snapshot-surface';
4
+ import { Accordion, Anchor, Box, Button, Code, Image, Stack, Text } from '@mantine/core';
4
5
  import { useQuery } from '@tanstack/react-query';
5
6
  import { useEffect, useRef, useState } from 'react';
6
-
7
7
  import { reportAssetUrl, reportResponse } from './api';
8
- import styles from './recording.module.css';
9
- import { traceStates } from './quint';
10
8
  import { ModelTrace } from './model-trace';
11
-
9
+ import { traceStates } from './quint';
12
10
  const SCALE = 0.72;
13
-
14
11
  async function readBlob(url: string, blob: ReportSnapshotBlob, signal: AbortSignal) {
15
12
  const response = await reportResponse(url, signal);
16
13
  if (blob.media !== 'png' && blob.media !== 'bytes') return response.text();
@@ -19,12 +16,23 @@ async function readBlob(url: string, blob: ReportSnapshotBlob, signal: AbortSign
19
16
  for (let i = 0; i < bytes.length; i += 32768) binary += String.fromCharCode(...bytes.subarray(i, i + 32768));
20
17
  return `data:${response.headers.get('content-type') ?? 'application/octet-stream'};base64,${btoa(binary)}`;
21
18
  }
22
-
23
19
  /**
24
20
  * A captured page, placed in this page as it was recorded. It sits in a shadow tree so its
25
21
  * stylesheet and the viewer's never restyle each other; there is no frame to load.
26
22
  */
27
- export function SnapshotPicture({ file, html, css, hit, who }: { file: string; html: string; css: string; hit: ReportStepHit | null; who: string }) {
23
+ export function SnapshotPicture({
24
+ file,
25
+ html,
26
+ css,
27
+ hit,
28
+ who
29
+ }: {
30
+ file: string;
31
+ html: string;
32
+ css: string;
33
+ hit: ReportStepHit | null;
34
+ who: string;
35
+ }) {
28
36
  const frame = useRef<HTMLDivElement>(null);
29
37
  const [found, setFound] = useState<HitResult>('none');
30
38
  useEffect(() => {
@@ -37,73 +45,121 @@ export function SnapshotPicture({ file, html, css, hit, who }: { file: string; h
37
45
  }, [file, html, css, hit]);
38
46
  return (
39
47
  <>
40
- <div ref={frame} className={styles.phone} role="img" aria-label={`Recorded ${who}`} style={{ width: PHONE_W * SCALE, height: PHONE_H * SCALE }} />
41
- {found === 'missing' && <p className={styles.quietLine}>The recorded control cannot be found in this picture.</p>}
48
+ <Stack ref={frame} role="img" aria-label={`Recorded ${who}`} w={PHONE_W * SCALE} h={PHONE_H * SCALE} maw="100%" gap="sm" />
49
+ {found === 'missing' && (
50
+ <Text component="div" c="dimmed">
51
+ The recorded control cannot be found in this picture.
52
+ </Text>
53
+ )}
42
54
  </>
43
55
  );
44
56
  }
45
-
46
57
  /** One captured picture or value, exactly as recorded. */
47
- export function Snapshot({ url, blob, css, hit, earlier = false }: { url: string; blob: ReportSnapshotBlob; css: string; hit: ReportStepHit | null; earlier?: boolean }) {
48
- const content = useQuery({ queryKey: ['evidence-blob', url], queryFn: ({ signal }) => readBlob(url, blob, signal), enabled: blob.media !== 'video', retry: false });
58
+ export function Snapshot({
59
+ url,
60
+ blob,
61
+ css,
62
+ hit,
63
+ earlier = false
64
+ }: {
65
+ url: string;
66
+ blob: ReportSnapshotBlob;
67
+ css: string;
68
+ hit: ReportStepHit | null;
69
+ earlier?: boolean;
70
+ }) {
71
+ const content = useQuery({
72
+ queryKey: ['evidence-blob', url],
73
+ queryFn: ({ signal }) => readBlob(url, blob, signal),
74
+ enabled: blob.media !== 'video',
75
+ retry: false
76
+ });
49
77
  const who = blob.nodeId ?? blob.name;
50
78
  const picture = blob.media === 'html' || blob.media === 'png' || blob.media === 'video';
51
79
  return (
52
- <figure className={styles.snapshot} data-picture={picture || undefined}>
53
- <figcaption>
54
- <strong>{who}</strong>
55
- {blob.nodeId && blob.name !== blob.nodeId && <span>{blob.name}</span>}
56
- {earlier && <span className={styles.earlier}>Earlier picture</span>}
57
- </figcaption>
58
- {blob.media === 'video' && <video className={styles.image} src={reportAssetUrl(url)} controls playsInline aria-label={`Recorded ${who}`} />}
59
- {blob.media !== 'video' && content.isPending && <p className={styles.quietLine} role="status">Loading…</p>}
80
+ <Box data-picture={picture || undefined} component="figure" m={0}>
81
+ <Box component="figcaption">
82
+ <Text span inherit fw={600}>
83
+ {who}
84
+ </Text>
85
+ {blob.nodeId && blob.name !== blob.nodeId && (
86
+ <Text span inherit>
87
+ {blob.name}
88
+ </Text>
89
+ )}
90
+ {earlier && (
91
+ <Text span inherit>
92
+ Earlier picture
93
+ </Text>
94
+ )}
95
+ </Box>
96
+ {blob.media === 'video' && (
97
+ <Box src={reportAssetUrl(url)} controls playsInline aria-label={`Recorded ${who}`} component="video" maw="100%" w="100%" />
98
+ )}
99
+ {blob.media !== 'video' && content.isPending && (
100
+ <Text role="status" component="div" c="dimmed">
101
+ Loading…
102
+ </Text>
103
+ )}
60
104
  {content.isError && (
61
- <p role="alert" className={styles.quietLine}>
105
+ <Text role="alert" component="div" c="dimmed">
62
106
  {content.error.message}{' '}
63
- <button type="button" className={styles.link} onClick={() => void content.refetch()}>Try again</button>
64
- </p>
107
+ <Button type="button" onClick={() => void content.refetch()} variant="subtle">
108
+ Try again
109
+ </Button>
110
+ </Text>
111
+ )}
112
+ {content.data !== undefined && blob.media === 'html' && (
113
+ <SnapshotPicture file={blob.file} html={content.data} css={css} hit={hit} who={who} />
114
+ )}
115
+ {content.data !== undefined && blob.media === 'png' && <Image alt={`Recorded ${who}`} src={content.data} fit="contain" maw="100%" />}
116
+ {content.data !== undefined && blob.media === 'bytes' && (
117
+ <Anchor download={blob.file.split('/').at(-1)} href={content.data}>
118
+ Download captured data
119
+ </Anchor>
65
120
  )}
66
- {content.data !== undefined && blob.media === 'html' && <SnapshotPicture file={blob.file} html={content.data} css={css} hit={hit} who={who} />}
67
- {content.data !== undefined && blob.media === 'png' && <img className={styles.image} alt={`Recorded ${who}`} src={content.data} />}
68
- {content.data !== undefined && blob.media === 'bytes' && <a download={blob.file.split('/').at(-1)} href={content.data}>Download captured data</a>}
69
121
  {content.data !== undefined && (blob.media === 'json' || blob.media === 'text') && <SnapshotData text={content.data} />}
70
- </figure>
122
+ </Box>
71
123
  );
72
124
  }
73
-
74
125
  /** A recorded value: a model trace when it is one, otherwise its fields. */
75
126
  export function SnapshotData({ text }: { text: string }) {
76
127
  let value: unknown;
77
128
  try {
78
129
  value = JSON.parse(text);
79
130
  } catch {
80
- return <pre className={styles.pre}>{text}</pre>;
131
+ return <Code block>{text}</Code>;
81
132
  }
82
133
  const states = traceStates(value);
83
134
  return states ? <ModelTrace states={states} /> : <DataValue value={value} />;
84
135
  }
85
-
86
136
  function DataValue({ value }: { value: unknown }) {
87
- if (value === null || typeof value !== 'object') return <code>{JSON.stringify(value)}</code>;
137
+ if (value === null || typeof value !== 'object') return <Code>{JSON.stringify(value)}</Code>;
88
138
  const entries = Object.entries(value);
89
- if (!entries.length) return <code>{Array.isArray(value) ? '[]' : '{}'}</code>;
139
+ if (!entries.length) return <Code>{Array.isArray(value) ? '[]' : '{}'}</Code>;
90
140
  return (
91
- <dl className={styles.data}>
141
+ <Box component="dl">
92
142
  {entries.map(([key, item]) => (
93
- <div key={key}>
94
- <dt>{key}</dt>
95
- <dd>
143
+ <Stack key={key} gap="sm">
144
+ <Box component="dt">{key}</Box>
145
+ <Box component="dd" m={0}>
96
146
  {item !== null && typeof item === 'object' ? (
97
- <details>
98
- <summary>{Array.isArray(item) ? `${item.length} items` : `${Object.keys(item).length} fields`}</summary>
99
- <DataValue value={item} />
100
- </details>
147
+ <Accordion variant="separated" my="sm">
148
+ <Accordion.Item value="content">
149
+ <Accordion.Control>
150
+ {Array.isArray(item) ? `${item.length} items` : `${Object.keys(item).length} fields`}
151
+ </Accordion.Control>
152
+ <Accordion.Panel>
153
+ <DataValue value={item} />
154
+ </Accordion.Panel>
155
+ </Accordion.Item>
156
+ </Accordion>
101
157
  ) : (
102
158
  <DataValue value={item} />
103
159
  )}
104
- </dd>
105
- </div>
160
+ </Box>
161
+ </Stack>
106
162
  ))}
107
- </dl>
163
+ </Box>
108
164
  );
109
165
  }
@@ -0,0 +1,33 @@
1
+ import { CodeHighlightAdapterProvider, CodeHighlightTabs } from '@mantine/code-highlight';
2
+ import { Box, Button, Group, Text, Title } from '@mantine/core';
3
+ import { specHighlightAdapter, specLanguage } from '../ui/highlight';
4
+
5
+ /** The written specification is primary: complete, expanded and highlighted, with Mantine code controls. */
6
+ export function SourceText({ text, file, withResults = false }: { text: string; file: string; withResults?: boolean }) {
7
+ return (
8
+ <Box component="section" data-source aria-label={`Source of ${file}`} mb="lg" miw={0}>
9
+ <Group justify="space-between" mb="sm">
10
+ <Title order={2}>Source</Title>
11
+ {withResults && (
12
+ <Button
13
+ variant="subtle"
14
+ onClick={() => {
15
+ const results = document.getElementById('spec-results');
16
+ results?.scrollIntoView({ block: 'start' });
17
+ results?.focus({ preventScroll: true });
18
+ }}
19
+ >
20
+ Recorded results
21
+ </Button>
22
+ )}
23
+ </Group>
24
+ {text ? (
25
+ <CodeHighlightAdapterProvider adapter={specHighlightAdapter}>
26
+ <CodeHighlightTabs code={[{ fileName: file, code: text, language: specLanguage(file) }]} copyLabel={`Copy ${file}`} />
27
+ </CodeHighlightAdapterProvider>
28
+ ) : (
29
+ <Text c="dimmed">This source file is empty.</Text>
30
+ )}
31
+ </Box>
32
+ );
33
+ }
@@ -1,39 +1,39 @@
1
1
  import type { LayerHeader } from '@crvouga/atlas/reports/evidence-types';
2
- import { ALL_SOURCES, branchOf, contributes, sourceFacets, sourceSelection, type SourceFacet, type SourceSelection } from '@crvouga/atlas/reports/evidence-view';
2
+ import {
3
+ ALL_SOURCES,
4
+ branchOf,
5
+ contributes,
6
+ sourceFacets,
7
+ sourceSelection,
8
+ type SourceFacet,
9
+ type SourceSelection
10
+ } from '@crvouga/atlas/reports/evidence-view';
3
11
  import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
4
- import * as Dialog from '@radix-ui/react-dialog';
12
+ import { Alert, Button, Checkbox, Drawer, Group, List, Stack, Text, Title } from '@mantine/core';
5
13
  import { useMemo } from 'react';
6
-
7
- import { CloseIcon } from '../components/icons';
8
14
  import { useUiStore } from '../state/ui-store';
9
15
  import { useEvidence } from './evidence-context';
10
- import { ago, exactTime, plural, shortSha, useNow } from './format';
16
+ import { ago, plural, shortSha, useNow } from './format';
11
17
  import { Bounded } from './marks';
12
- import styles from './sources.module.css';
13
-
14
18
  function toggled(list: readonly string[], id: string) {
15
19
  return list.includes(id) ? list.filter((entry) => entry !== id) : [...list, id];
16
20
  }
17
-
18
21
  function counts(layer: LayerHeader) {
19
22
  const { passed, failed, running } = layer.counts;
20
- return [failed ? `${failed} failed` : '', passed ? `${passed} passed` : '', running ? `${running} running` : ''].filter(Boolean).join(', ') || 'nothing completed';
23
+ return (
24
+ [failed ? `${failed} failed` : '', passed ? `${passed} passed` : '', running ? `${running} running` : ''].filter(Boolean).join(', ') ||
25
+ 'nothing completed'
26
+ );
21
27
  }
22
-
23
- /** How many sources are switched off, for the button that opens the panel. */
28
+ /** Number of disabled source facets, including individually excluded layers. */
24
29
  export function excludedCount(excluded: Partial<SourceSelection>) {
25
- const selection = sourceSelection(excluded);
26
- return selection.backends.length + selection.branches.length + selection.versions.length + selection.environments.length + selection.layers.length;
30
+ const s = sourceSelection(excluded);
31
+ return s.backends.length + s.branches.length + s.versions.length + s.environments.length + s.layers.length;
27
32
  }
28
-
29
33
  function versionLabel(id: string) {
30
34
  return id === 'unknown' ? 'Unknown version' : id.slice(0, 7);
31
35
  }
32
-
33
- /**
34
- * Where the combined report comes from. Every backend, environment, branch, version and layer
35
- * contributes unless it is switched off here, which is also how an earlier result is brought back.
36
- */
36
+ /** Every backend, environment, branch, version and layer contributes unless switched off here. */
37
37
  export function SourcesPanel() {
38
38
  const evidence = useEvidence();
39
39
  const { layers, backends, setExcluded, notices, retry } = evidence;
@@ -45,114 +45,109 @@ export function SourcesPanel() {
45
45
  const contributing = layers.filter((layer) => contributes(layer, excluded)).length;
46
46
  const layerCount = (id: string) => facets.backends.find((facet) => facet.id === id)?.layers ?? 0;
47
47
  return (
48
- <Dialog.Root open={open} onOpenChange={setOpen}>
49
- <Dialog.Portal>
50
- <Dialog.Overlay className={styles.overlay} />
51
- <Dialog.Content className={styles.sheet} aria-describedby={undefined}>
52
- <div className={styles.head}>
53
- <Dialog.Title className={styles.title}>Sources</Dialog.Title>
54
- <Dialog.Close className={styles.close} aria-label="Close">
55
- <CloseIcon size={16} />
56
- </Dialog.Close>
57
- </div>
58
- <p className={styles.lead}>
59
- {contributing} of {plural(layers.length, 'layer', 'layers')} contribute. For each check and environment, the newest completed result is shown. A layer fills only the checks it ran, so the rest of the report stays in place. Switch a source off to see an earlier result.
60
- </p>
61
- {excludedCount(excluded) > 0 && (
62
- <button type="button" className={styles.reset} onClick={() => setExcluded(ALL_SOURCES)}>
63
- Use every source
64
- </button>
65
- )}
66
- {notices.map((notice) => (
67
- <p key={notice} className={styles.notice} role="status">
68
- {notice}{' '}
69
- <button type="button" className={styles.link} onClick={retry}>Try again</button>
70
- </p>
48
+ <Drawer opened={open} onClose={() => setOpen(false)} title="Sources" closeButtonProps={{ 'aria-label': 'Close sources' }}>
49
+ <Stack gap="lg">
50
+ <Text c="dimmed">
51
+ {contributing} of {plural(layers.length, 'layer', 'layers')} contribute. For each check and environment, the newest completed
52
+ result is shown. A layer fills only the checks it ran, so the rest of the report stays in place. Switch a source off to see an
53
+ earlier result.
54
+ </Text>
55
+ {excludedCount(excluded) > 0 && <Button onClick={() => setExcluded(ALL_SOURCES)}>Use every source</Button>}
56
+ {notices.map((notice) => (
57
+ <Alert key={notice} role="status">
58
+ {notice}{' '}
59
+ <Button variant="subtle" onClick={retry}>
60
+ Try again
61
+ </Button>
62
+ </Alert>
63
+ ))}
64
+ <Stack gap="sm">
65
+ <Title order={3}>Backends</Title>
66
+ {!backends.length && <Text c="dimmed">No report store is configured.</Text>}
67
+ {backends.map((backend) => (
68
+ <Checkbox
69
+ key={backend.id}
70
+ label={backend.label}
71
+ description={backend.available ? plural(layerCount(backend.id), 'layer', 'layers') : 'Unavailable'}
72
+ checked={!excluded.backends.includes(backend.id)}
73
+ onChange={() => setExcluded({ ...excluded, backends: toggled(excluded.backends, backend.id) })}
74
+ />
71
75
  ))}
72
-
73
- <section className={styles.group}>
74
- <h3>Backends</h3>
75
- {backends.length === 0 && <p className={styles.empty}>No report store is configured.</p>}
76
- <ul>
77
- {backends.map((backend) => (
78
- <li key={backend.id}>
79
- <label className={styles.option}>
80
- <input type="checkbox" checked={!excluded.backends.includes(backend.id)} onChange={() => setExcluded({ ...excluded, backends: toggled(excluded.backends, backend.id) })} />
81
- <span className={styles.optionName}>{backend.label}</span>
82
- <span className={styles.optionMeta}>{backend.available ? plural(layerCount(backend.id), 'layer', 'layers') : 'Unavailable'}</span>
83
- </label>
84
- </li>
85
- ))}
86
- </ul>
87
- </section>
88
-
89
- <FacetGroup
90
- title="Environments"
91
- facets={facets.environments.filter((facet) => facets.environments.length > 1 || facet.id !== 'unspecified')}
92
- excluded={excluded.environments}
93
- label={(id) => environmentLabel(id === 'unspecified' ? null : id)}
94
- empty={null}
95
- now={now}
96
- onToggle={(id) => setExcluded({ ...excluded, environments: toggled(excluded.environments, id) })}
97
- onOnly={(id) => setExcluded({ ...excluded, environments: facets.environments.map((facet) => facet.id).filter((facet) => facet !== id) })}
98
- />
99
-
100
- <FacetGroup
101
- title="Branches"
102
- facets={facets.branches}
103
- excluded={excluded.branches}
104
- label={(id) => id}
105
- empty="No layers yet."
106
- now={now}
107
- onToggle={(id) => setExcluded({ ...excluded, branches: toggled(excluded.branches, id) })}
108
- onOnly={(id) => setExcluded({ ...excluded, branches: facets.branches.map((facet) => facet.id).filter((facet) => facet !== id) })}
109
- />
110
-
111
- <FacetGroup
112
- title="Versions"
113
- facets={facets.versions}
114
- excluded={excluded.versions}
115
- label={versionLabel}
116
- empty={null}
117
- now={now}
118
- onToggle={(id) => setExcluded({ ...excluded, versions: toggled(excluded.versions, id) })}
119
- onOnly={(id) => setExcluded({ ...excluded, versions: facets.versions.map((facet) => facet.id).filter((facet) => facet !== id) })}
120
- />
121
-
122
- <section className={styles.group}>
123
- <h3>Layers</h3>
124
- <ul>
125
- <Bounded items={layers} size={40} more="Earlier layers">
126
- {(layer) => {
127
- const off = !contributes(layer, { ...excluded, layers: [] });
128
- return (
129
- <li key={`${layer.backend}|${layer.id}`}>
130
- <label className={styles.option} data-off={off || undefined}>
131
- <input type="checkbox" disabled={off} checked={!off && !excluded.layers.includes(layer.id)} onChange={() => setExcluded({ ...excluded, layers: toggled(excluded.layers, layer.id) })} />
132
- <span className={styles.optionName}>
133
- {layer.packageDir ?? layer.producer}
134
- <span className={styles.run}>
135
- {layer.live ? 'Running now' : <time title={exactTime(layer.recordedAtMs)}>{ago(layer.recordedAtMs, now)}</time>} · {branchOf(layer)}
136
- {layer.environment && <> · {environmentLabel(layer.environment)}</>}
137
- {shortSha(layer.sha) && <> · <code>{shortSha(layer.sha)}</code></>}
138
- {layer.freshness === 'modified' && ' · local changes'}
139
- </span>
140
- </span>
141
- <span className={styles.optionMeta} data-failed={layer.counts.failed > 0 || undefined}>{counts(layer)}</span>
142
- </label>
143
- </li>
144
- );
145
- }}
146
- </Bounded>
147
- </ul>
148
- </section>
149
- </Dialog.Content>
150
- </Dialog.Portal>
151
- </Dialog.Root>
76
+ </Stack>
77
+ <FacetGroup
78
+ title="Environments"
79
+ facets={facets.environments.filter((facet) => facets.environments.length > 1 || facet.id !== 'unspecified')}
80
+ excluded={excluded.environments}
81
+ label={(id) => environmentLabel(id === 'unspecified' ? null : id)}
82
+ empty={null}
83
+ now={now}
84
+ onToggle={(id) => setExcluded({ ...excluded, environments: toggled(excluded.environments, id) })}
85
+ onOnly={(id) =>
86
+ setExcluded({ ...excluded, environments: facets.environments.map((facet) => facet.id).filter((facet) => facet !== id) })
87
+ }
88
+ />
89
+ <FacetGroup
90
+ title="Branches"
91
+ facets={facets.branches}
92
+ excluded={excluded.branches}
93
+ label={(id) => id}
94
+ empty="No layers yet."
95
+ now={now}
96
+ onToggle={(id) => setExcluded({ ...excluded, branches: toggled(excluded.branches, id) })}
97
+ onOnly={(id) => setExcluded({ ...excluded, branches: facets.branches.map((facet) => facet.id).filter((facet) => facet !== id) })}
98
+ />
99
+ <FacetGroup
100
+ title="Versions"
101
+ facets={facets.versions}
102
+ excluded={excluded.versions}
103
+ label={versionLabel}
104
+ empty={null}
105
+ now={now}
106
+ onToggle={(id) => setExcluded({ ...excluded, versions: toggled(excluded.versions, id) })}
107
+ onOnly={(id) => setExcluded({ ...excluded, versions: facets.versions.map((facet) => facet.id).filter((facet) => facet !== id) })}
108
+ />
109
+ <Stack gap="sm">
110
+ <Title order={3}>Layers</Title>
111
+ <List spacing="md" listStyleType="none">
112
+ <Bounded items={layers} size={40} more="Earlier layers">
113
+ {(layer) => {
114
+ const off = !contributes(layer, { ...excluded, layers: [] });
115
+ return (
116
+ <List.Item key={`${layer.backend}|${layer.id}`}>
117
+ <Checkbox
118
+ disabled={off}
119
+ checked={!off && !excluded.layers.includes(layer.id)}
120
+ onChange={() => setExcluded({ ...excluded, layers: toggled(excluded.layers, layer.id) })}
121
+ label={layer.packageDir ?? layer.producer}
122
+ description={
123
+ <>
124
+ {layer.live ? 'Running now' : ago(layer.recordedAtMs, now)} · {branchOf(layer)}
125
+ {layer.environment && ` · ${environmentLabel(layer.environment)}`}
126
+ {shortSha(layer.sha) && ` · ${shortSha(layer.sha)}`}
127
+ {layer.freshness === 'modified' && ' · local changes'} · {counts(layer)}
128
+ </>
129
+ }
130
+ />
131
+ </List.Item>
132
+ );
133
+ }}
134
+ </Bounded>
135
+ </List>
136
+ </Stack>
137
+ </Stack>
138
+ </Drawer>
152
139
  );
153
140
  }
154
-
155
- function FacetGroup({ title, facets, excluded, label, empty, now, onToggle, onOnly }: {
141
+ function FacetGroup({
142
+ title,
143
+ facets,
144
+ excluded,
145
+ label,
146
+ empty,
147
+ now,
148
+ onToggle,
149
+ onOnly
150
+ }: {
156
151
  title: string;
157
152
  facets: readonly SourceFacet[];
158
153
  excluded: readonly string[];
@@ -164,25 +159,24 @@ function FacetGroup({ title, facets, excluded, label, empty, now, onToggle, onOn
164
159
  }) {
165
160
  if (!facets.length && empty === null) return null;
166
161
  return (
167
- <section className={styles.group}>
168
- <h3>{title}</h3>
169
- {facets.length === 0 && <p className={styles.empty}>{empty}</p>}
170
- <ul>
171
- {facets.map((facet) => (
172
- <li key={facet.id}>
173
- <label className={styles.option}>
174
- <input type="checkbox" checked={!excluded.includes(facet.id)} onChange={() => onToggle(facet.id)} />
175
- <span className={styles.optionName} title={facet.id}>{label(facet.id)}</span>
176
- <span className={styles.optionMeta}>
177
- {plural(facet.layers, 'layer', 'layers')} · {ago(facet.latestAtMs, now)}
178
- </span>
179
- </label>
180
- {facets.length > 1 && (
181
- <button type="button" className={styles.only} onClick={() => onOnly(facet.id)}>Only</button>
182
- )}
183
- </li>
184
- ))}
185
- </ul>
186
- </section>
162
+ <Stack gap="sm">
163
+ <Title order={3}>{title}</Title>
164
+ {!facets.length && <Text c="dimmed">{empty}</Text>}
165
+ {facets.map((facet) => (
166
+ <Group key={facet.id} justify="space-between" wrap="nowrap">
167
+ <Checkbox
168
+ label={label(facet.id)}
169
+ description={`${plural(facet.layers, 'layer', 'layers')} · ${ago(facet.latestAtMs, now)}`}
170
+ checked={!excluded.includes(facet.id)}
171
+ onChange={() => onToggle(facet.id)}
172
+ />
173
+ {facets.length > 1 && (
174
+ <Button variant="subtle" onClick={() => onOnly(facet.id)}>
175
+ Only
176
+ </Button>
177
+ )}
178
+ </Group>
179
+ ))}
180
+ </Stack>
187
181
  );
188
182
  }