@crvouga/atlas 0.3.0 → 0.4.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 (76) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +15 -2
  3. package/dist/reports/src/evidence-live.d.ts +6 -0
  4. package/dist/reports/src/evidence-live.js +35 -3
  5. package/dist/reports/src/evidence-types.d.ts +2 -0
  6. package/dist/reports/src/evidence-view.d.ts +15 -2
  7. package/dist/reports/src/evidence-view.js +31 -3
  8. package/dist/reports/src/layer-axes.d.ts +18 -0
  9. package/dist/reports/src/layer-axes.js +51 -0
  10. package/dist/reports/src/layer-catalog.js +1 -1
  11. package/dist/reports/src/layer-merge.d.ts +1 -1
  12. package/dist/reports/src/layer-merge.js +9 -4
  13. package/dist/reports/src/layer-source.js +3 -0
  14. package/dist/reports/src/layer-types.d.ts +2 -0
  15. package/dist/reports/src/layers-route.js +2 -5
  16. package/dist/reports/src/protocol.d.ts +4 -0
  17. package/dist/reports/src/publish.d.ts +2 -0
  18. package/dist/reports/src/publish.js +4 -0
  19. package/dist/reports/src/report-parts.js +3 -0
  20. package/package.json +2 -2
  21. package/packages/reports/README.md +4 -2
  22. package/packages/reports/STORAGE.md +1 -1
  23. package/packages/visualizer/README.md +6 -5
  24. package/packages/visualizer/example/specs/coffee/ordering/cart.qnt +7 -0
  25. package/packages/visualizer/example/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  26. package/packages/visualizer/example/specs/coffee/ordering/drink-price.dmn +24 -0
  27. package/packages/visualizer/example/specs/coffee/ordering/loyalty.feature +7 -0
  28. package/packages/visualizer/example/specs/coffee/ordering/ordering.feature +36 -0
  29. package/packages/visualizer/example/specs/coffee/ordering/payment.als +4 -0
  30. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/cart.qnt +7 -0
  31. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  32. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/drink-price.dmn +24 -0
  33. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/loyalty.feature +7 -0
  34. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/ordering.feature +36 -0
  35. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/payment.als +4 -0
  36. package/packages/visualizer/fixtures/full/specs/coffee/ordering/cart.qnt +7 -0
  37. package/packages/visualizer/fixtures/full/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  38. package/packages/visualizer/fixtures/full/specs/coffee/ordering/drink-price.dmn +24 -0
  39. package/packages/visualizer/fixtures/full/specs/coffee/ordering/loyalty.feature +7 -0
  40. package/packages/visualizer/fixtures/full/specs/coffee/ordering/ordering.feature +36 -0
  41. package/packages/visualizer/fixtures/full/specs/coffee/ordering/payment.als +4 -0
  42. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/cart.qnt +7 -0
  43. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  44. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/drink-price.dmn +24 -0
  45. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/loyalty.feature +7 -0
  46. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/ordering.feature +36 -0
  47. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/payment.als +4 -0
  48. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/cart.qnt +7 -0
  49. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  50. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/drink-price.dmn +24 -0
  51. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/loyalty.feature +7 -0
  52. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/ordering.feature +36 -0
  53. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/payment.als +4 -0
  54. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/cart.qnt +7 -0
  55. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  56. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/drink-price.dmn +24 -0
  57. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/loyalty.feature +7 -0
  58. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/ordering.feature +36 -0
  59. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/payment.als +4 -0
  60. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/cart.qnt +7 -0
  61. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  62. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/drink-price.dmn +24 -0
  63. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/loyalty.feature +7 -0
  64. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/ordering.feature +36 -0
  65. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/payment.als +4 -0
  66. package/packages/visualizer/scripts/make-fixtures.ts +2 -0
  67. package/packages/visualizer/scripts/publish-data.ts +1 -2
  68. package/packages/visualizer/src/components/Header.tsx +1 -1
  69. package/packages/visualizer/src/evidence/check-detail.tsx +35 -3
  70. package/packages/visualizer/src/evidence/evidence.module.css +20 -0
  71. package/packages/visualizer/src/evidence/marks.tsx +2 -0
  72. package/packages/visualizer/src/evidence/overview.tsx +3 -3
  73. package/packages/visualizer/src/evidence/sources-panel.tsx +88 -34
  74. package/packages/visualizer/src/state/ui-store.ts +6 -2
  75. package/packages/visualizer/vite/example-reports.ts +475 -0
  76. package/packages/visualizer/vite.config.ts +9 -1
@@ -5,8 +5,7 @@ import { publishData } from '../vite/publish';
5
5
 
6
6
  /**
7
7
  * `pnpm publish-data`: the spec and the newest runs into `public/data` (or `ATLAS_OUT`), where
8
- * `pnpm build` picks them up. Roots come from `ATLAS_SPECS_ROOT` / `ATLAS_RUNS_ROOT` or
9
- * `ATLAS_FIXTURE`, as for the dev server.
8
+ * `pnpm build` picks them up. Roots come from `ATLAS_SPECS_ROOT` / `ATLAS_RUNS_ROOT`, as for the dev server.
10
9
  */
11
10
  try {
12
11
  const roots = resolveRoots();
@@ -77,7 +77,7 @@ function SourcesButton() {
77
77
  return (
78
78
  <button type="button" className={styles.sources} data-filtered={off > 0 || undefined} onClick={() => setOpen(true)}>
79
79
  Sources
80
- <span>{unavailable ? 'unavailable' : off > 0 ? `${off} off` : plural(layers.length, 'run', 'runs')}</span>
80
+ <span>{unavailable ? 'unavailable' : off > 0 ? `${off} off` : plural(layers.length, 'layer', 'layers')}</span>
81
81
  </button>
82
82
  );
83
83
  }
@@ -1,5 +1,7 @@
1
1
  import { recordingPaths } from '@crvouga/atlas/reports/evidence-types';
2
2
  import { checkHistory, type CheckSlot, type HistoryEntry } from '@crvouga/atlas/reports/evidence-view';
3
+ import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
4
+ import type { EvidenceResult } from '@crvouga/atlas/reports/layer-merge';
3
5
  import { flattenFrames } from '@crvouga/atlas/reports/frames';
4
6
  import type { ReportStep, RunReport } from '@crvouga/atlas/reports/report-types';
5
7
  import { useQuery } from '@tanstack/react-query';
@@ -34,15 +36,45 @@ export function useRecordedReport(entry: HistoryEntry | null, enabled = true) {
34
36
  }
35
37
 
36
38
  /** One check in a list: its outcome at a glance, and everything recorded for it when opened. */
39
+ const ENV_RANK = { passed: 0, 'not-run': 1, running: 2, failed: 3 } as const;
40
+
41
+ /** One mark per client when the same check was recorded on more than one. */
42
+ function environmentMarks(slot: CheckSlot) {
43
+ const seen = new Map<string, CheckSlot['status']>();
44
+ for (const outcome of slot.outcomes) {
45
+ const id = outcome.layer.environment ?? '';
46
+ const prior = seen.get(id);
47
+ if (prior === undefined || ENV_RANK[outcome.row.status] > ENV_RANK[prior]) seen.set(id, outcome.row.status);
48
+ }
49
+ if (seen.size < 2) return [];
50
+ return [...seen].map(([id, status]) => ({ id, status }));
51
+ }
52
+
53
+ function outcomeName(outcome: EvidenceResult) {
54
+ const environment = outcome.layer.environment ? environmentLabel(outcome.layer.environment) : '';
55
+ const parameters = Object.entries(outcome.parameters).map(([name, value]) => `${name}: ${value}`).join(', ');
56
+ return [environment, parameters].filter(Boolean).join(' · ') || outcome.layer.producer;
57
+ }
58
+
37
59
  export function ResultRow({ label, slot, open, onToggle, children }: { label: string; slot: CheckSlot | null; open: boolean; onToggle: () => void; children: ReactNode }) {
38
60
  const now = useNow();
39
61
  const newest = slot?.outcomes[0];
62
+ const marks = slot ? environmentMarks(slot) : [];
40
63
  const failure = slot?.status === 'failed' ? slot.outcomes.find((outcome) => outcome.row.status === 'failed')?.row.detail.split('\n')[0] : '';
41
64
  return (
42
65
  <li className={styles.result} data-status={slot?.status ?? 'none'} data-open={open || undefined}>
43
66
  <button type="button" className={styles.resultHead} aria-expanded={open} onClick={onToggle}>
44
67
  <StatusMark status={slot?.status ?? 'none'} />
45
- <span className={styles.resultName}>{label}</span>
68
+ <span className={styles.resultName}>
69
+ {label}
70
+ {marks.length > 0 && (
71
+ <span className={styles.envMarks}>
72
+ {marks.map((mark) => (
73
+ <span key={mark.id || 'unspecified'} data-status={mark.status}>{environmentLabel(mark.id || null)}</span>
74
+ ))}
75
+ </span>
76
+ )}
77
+ </span>
46
78
  {slot?.live && <span className={styles.liveTag}>Running now</span>}
47
79
  <span className={styles.resultAside}>
48
80
  {newest ? [duration(newest.row.durationMs), slot.live ? '' : ago(slot.latestAtMs, now)].filter(Boolean).join(' · ') : 'No result'}
@@ -185,11 +217,11 @@ export function CheckDetail({ slot, layerId, select, reads, written }: Props) {
185
217
  return (
186
218
  <div className={styles.detail}>
187
219
  {slot.outcomes.length > 1 && !earlier && (
188
- <div className={styles.variants} role="group" aria-label="Parameter sets">
220
+ <div className={styles.variants} role="group" aria-label="Environments">
189
221
  {slot.outcomes.map((outcome, index) => (
190
222
  <button key={outcome.key} type="button" aria-pressed={index === variant} onClick={() => setVariant(index)}>
191
223
  <StatusMark status={outcome.row.status} size={12} />
192
- {Object.entries(outcome.layer.parameters).map(([name, value]) => `${name}: ${value}`).join(', ') || outcome.layer.producer}
224
+ {outcomeName(outcome)}
193
225
  </button>
194
226
  ))}
195
227
  </div>
@@ -455,11 +455,31 @@
455
455
  }
456
456
 
457
457
  .resultName {
458
+ display: flex;
458
459
  flex: 1;
460
+ flex-direction: column;
459
461
  min-width: 0;
460
462
  font-weight: 500;
461
463
  }
462
464
 
465
+ .envMarks {
466
+ display: flex;
467
+ flex-wrap: wrap;
468
+ gap: 8px;
469
+ margin-top: 2px;
470
+ color: var(--ink-3);
471
+ font-size: 0.75rem;
472
+ font-weight: 400;
473
+ }
474
+
475
+ .envMarks span[data-status='passed'] {
476
+ color: var(--passed-ink);
477
+ }
478
+
479
+ .envMarks span[data-status='failed'] {
480
+ color: var(--failed-ink);
481
+ }
482
+
463
483
  .result[data-status='none'] .resultName {
464
484
  color: var(--ink-3);
465
485
  font-weight: 400;
@@ -1,5 +1,6 @@
1
1
  import type { BackendInfo, LayerHeader, StatusCounts } from '@crvouga/atlas/reports/evidence-types';
2
2
  import type { SpecStatus, SpecSummary } from '@crvouga/atlas/reports/evidence-view';
3
+ import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
3
4
  import { useState, type ReactNode } from 'react';
4
5
 
5
6
  import styles from './evidence.module.css';
@@ -89,6 +90,7 @@ export function Origin({ layer, backends, now }: { layer: LayerHeader; backends:
89
90
  {layer.live && <span className={styles.liveTag}>Running now</span>}
90
91
  <span>{backendLabel(backends, layer.backend)}</span>
91
92
  {layer.branch && <span>{layer.branch}</span>}
93
+ {layer.environment && <span>{environmentLabel(layer.environment)}</span>}
92
94
  {sha && <code>{sha}</code>}
93
95
  {layer.freshness === 'modified' && <span>with local changes</span>}
94
96
  {layer.freshness === 'unknown' && <span>origin not captured</span>}
@@ -67,7 +67,7 @@ export function Overview({ kind }: { kind: SpecKind }) {
67
67
  </p>
68
68
  ))}
69
69
  {!evidence.ready && !evidence.unavailable && <p className={styles.aside} role="status">Loading recorded results…</p>}
70
- {evidence.ready && evidence.pending > 0 && <p className={styles.aside} role="status">Reading {plural(evidence.pending, 'more run', 'more runs')}…</p>}
70
+ {evidence.ready && evidence.pending > 0 && <p className={styles.aside} role="status">Reading {plural(evidence.pending, 'more layer', 'more layers')}…</p>}
71
71
 
72
72
  {evidence.active.length > 0 && (
73
73
  <section className={styles.block}>
@@ -111,13 +111,13 @@ export function Overview({ kind }: { kind: SpecKind }) {
111
111
  <p className={styles.aside}>
112
112
  {evidence.layers.length === 0
113
113
  ? `Nothing has been recorded yet. Results appear here as a spec run finishes each test.`
114
- : `None of the ${plural(evidence.layers.length, 'run', 'runs')} in the combined report checked a ${kind.one}.`}
114
+ : `None of the ${plural(evidence.layers.length, 'layer', 'layers')} in the combined report checked a ${kind.one}.`}
115
115
  </p>
116
116
  )}
117
117
 
118
118
  {evidence.ready && (
119
119
  <p className={styles.footLine}>
120
- {plural(evidence.layers.length, 'run', 'runs')} from {evidence.backends.filter((backend) => backend.available).map((backend) => backendLabel(evidence.backends, backend.id)).join(' and ') || 'no report store'}.{' '}
120
+ {plural(evidence.layers.length, 'layer', 'layers')} from {evidence.backends.filter((backend) => backend.available).map((backend) => backendLabel(evidence.backends, backend.id)).join(' and ') || 'no report store'}.{' '}
121
121
  <button type="button" className={styles.link} onClick={() => setSourcesOpen(true)}>Choose sources</button>
122
122
  {empty > 0 && (
123
123
  <>
@@ -1,5 +1,6 @@
1
1
  import type { LayerHeader } from '@crvouga/atlas/reports/evidence-types';
2
- import { branchOf, contributes, sourceFacets, type SourceSelection } from '@crvouga/atlas/reports/evidence-view';
2
+ import { ALL_SOURCES, branchOf, contributes, sourceFacets, sourceSelection, type SourceFacet, type SourceSelection } from '@crvouga/atlas/reports/evidence-view';
3
+ import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
3
4
  import * as Dialog from '@radix-ui/react-dialog';
4
5
  import { useMemo } from 'react';
5
6
 
@@ -20,16 +21,23 @@ function counts(layer: LayerHeader) {
20
21
  }
21
22
 
22
23
  /** How many sources are switched off, for the button that opens the panel. */
23
- export function excludedCount(excluded: SourceSelection) {
24
- return excluded.backends.length + excluded.branches.length + excluded.layers.length;
24
+ 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;
27
+ }
28
+
29
+ function versionLabel(id: string) {
30
+ return id === 'unknown' ? 'Unknown version' : id.slice(0, 7);
25
31
  }
26
32
 
27
33
  /**
28
- * Where the combined report comes from. Every backend, branch and run contributes unless it is
29
- * switched off here, which is also how an earlier result is brought back into view.
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.
30
36
  */
31
37
  export function SourcesPanel() {
32
- const { layers, backends, excluded, setExcluded, notices, retry } = useEvidence();
38
+ const evidence = useEvidence();
39
+ const { layers, backends, setExcluded, notices, retry } = evidence;
40
+ const excluded = sourceSelection(evidence.excluded);
33
41
  const open = useUiStore((s) => s.sourcesOpen);
34
42
  const setOpen = useUiStore((s) => s.setSourcesOpen);
35
43
  const now = useNow();
@@ -48,10 +56,10 @@ export function SourcesPanel() {
48
56
  </Dialog.Close>
49
57
  </div>
50
58
  <p className={styles.lead}>
51
- {contributing} of {plural(layers.length, 'run', 'runs')} contribute. For each check the newest completed result is shown. Switch a source off to see what earlier runs recorded.
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.
52
60
  </p>
53
61
  {excludedCount(excluded) > 0 && (
54
- <button type="button" className={styles.reset} onClick={() => setExcluded({ backends: [], branches: [], layers: [] })}>
62
+ <button type="button" className={styles.reset} onClick={() => setExcluded(ALL_SOURCES)}>
55
63
  Use every source
56
64
  </button>
57
65
  )}
@@ -71,42 +79,52 @@ export function SourcesPanel() {
71
79
  <label className={styles.option}>
72
80
  <input type="checkbox" checked={!excluded.backends.includes(backend.id)} onChange={() => setExcluded({ ...excluded, backends: toggled(excluded.backends, backend.id) })} />
73
81
  <span className={styles.optionName}>{backend.label}</span>
74
- <span className={styles.optionMeta}>{backend.available ? plural(layerCount(backend.id), 'run', 'runs') : 'Unavailable'}</span>
82
+ <span className={styles.optionMeta}>{backend.available ? plural(layerCount(backend.id), 'layer', 'layers') : 'Unavailable'}</span>
75
83
  </label>
76
84
  </li>
77
85
  ))}
78
86
  </ul>
79
87
  </section>
80
88
 
81
- <section className={styles.group}>
82
- <h3>Branches</h3>
83
- {facets.branches.length === 0 && <p className={styles.empty}>No runs yet.</p>}
84
- <ul>
85
- {facets.branches.map((branch) => (
86
- <li key={branch.id}>
87
- <label className={styles.option}>
88
- <input type="checkbox" checked={!excluded.branches.includes(branch.id)} onChange={() => setExcluded({ ...excluded, branches: toggled(excluded.branches, branch.id) })} />
89
- <span className={styles.optionName}>{branch.id}</span>
90
- <span className={styles.optionMeta}>
91
- {plural(branch.layers, 'run', 'runs')} · {ago(branch.latestAtMs, now)}
92
- </span>
93
- </label>
94
- {facets.branches.length > 1 && (
95
- <button type="button" className={styles.only} onClick={() => setExcluded({ ...excluded, branches: facets.branches.map((facet) => facet.id).filter((id) => id !== branch.id) })}>
96
- Only
97
- </button>
98
- )}
99
- </li>
100
- ))}
101
- </ul>
102
- </section>
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
+ />
103
121
 
104
122
  <section className={styles.group}>
105
- <h3>Runs</h3>
123
+ <h3>Layers</h3>
106
124
  <ul>
107
- <Bounded items={layers} size={40} more="Earlier runs">
125
+ <Bounded items={layers} size={40} more="Earlier layers">
108
126
  {(layer) => {
109
- const off = excluded.backends.includes(layer.backend) || excluded.branches.includes(branchOf(layer));
127
+ const off = !contributes(layer, { ...excluded, layers: [] });
110
128
  return (
111
129
  <li key={`${layer.backend}|${layer.id}`}>
112
130
  <label className={styles.option} data-off={off || undefined}>
@@ -115,6 +133,7 @@ export function SourcesPanel() {
115
133
  {layer.packageDir ?? layer.producer}
116
134
  <span className={styles.run}>
117
135
  {layer.live ? 'Running now' : <time title={exactTime(layer.recordedAtMs)}>{ago(layer.recordedAtMs, now)}</time>} · {branchOf(layer)}
136
+ {layer.environment && <> · {environmentLabel(layer.environment)}</>}
118
137
  {shortSha(layer.sha) && <> · <code>{shortSha(layer.sha)}</code></>}
119
138
  {layer.freshness === 'modified' && ' · local changes'}
120
139
  </span>
@@ -132,3 +151,38 @@ export function SourcesPanel() {
132
151
  </Dialog.Root>
133
152
  );
134
153
  }
154
+
155
+ function FacetGroup({ title, facets, excluded, label, empty, now, onToggle, onOnly }: {
156
+ title: string;
157
+ facets: readonly SourceFacet[];
158
+ excluded: readonly string[];
159
+ label: (id: string) => string;
160
+ empty: string | null;
161
+ now: number;
162
+ onToggle: (id: string) => void;
163
+ onOnly: (id: string) => void;
164
+ }) {
165
+ if (!facets.length && empty === null) return null;
166
+ 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>
187
+ );
188
+ }
@@ -11,7 +11,7 @@ type UiState = {
11
11
  issuesOpen: boolean;
12
12
  sourcesOpen: boolean;
13
13
  resultFilter: ResultFilter;
14
- /** Backends, branches and layers left out of the combined report. */
14
+ /** Backends, branches, versions, environments and layers left out of the combined report. */
15
15
  excluded: SourceSelection;
16
16
  saveViewport: (chartId: string, viewport: Viewport) => void;
17
17
  setIssuesOpen: (open: boolean) => void;
@@ -38,7 +38,11 @@ export const useUiStore = create<UiState>()(
38
38
  {
39
39
  name: 'atlas-ui',
40
40
  storage: createJSONStorage(() => sessionStorage),
41
- partialize: (s) => ({ viewports: s.viewports, excluded: s.excluded, resultFilter: s.resultFilter })
41
+ partialize: (s) => ({ viewports: s.viewports, excluded: s.excluded, resultFilter: s.resultFilter }),
42
+ merge: (persisted, current) => {
43
+ const saved = (persisted ?? {}) as Partial<UiState>;
44
+ return { ...current, ...saved, excluded: { ...ALL_SOURCES, ...saved.excluded } };
45
+ }
42
46
  }
43
47
  )
44
48
  );