@crvouga/atlas 0.2.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.
- package/CHANGELOG.md +13 -1
- package/README.md +21 -2
- package/dist/reports/src/evidence-live.d.ts +6 -0
- package/dist/reports/src/evidence-live.js +35 -3
- package/dist/reports/src/evidence-types.d.ts +2 -0
- package/dist/reports/src/evidence-view.d.ts +15 -2
- package/dist/reports/src/evidence-view.js +31 -3
- package/dist/reports/src/export-evidence.js +2 -10
- package/dist/reports/src/frames.d.ts +3 -1
- package/dist/reports/src/frames.js +21 -0
- package/dist/reports/src/layer-axes.d.ts +18 -0
- package/dist/reports/src/layer-axes.js +51 -0
- package/dist/reports/src/layer-catalog.js +1 -1
- package/dist/reports/src/layer-merge.d.ts +1 -1
- package/dist/reports/src/layer-merge.js +9 -4
- package/dist/reports/src/layer-source.js +3 -0
- package/dist/reports/src/layer-types.d.ts +2 -0
- package/dist/reports/src/layers-route.js +2 -5
- package/dist/reports/src/protocol.d.ts +4 -0
- package/dist/reports/src/publish.d.ts +2 -0
- package/dist/reports/src/publish.js +6 -11
- package/dist/reports/src/recording-keys.js +4 -0
- package/dist/reports/src/report-parts.js +3 -0
- package/dist/reports/src/report-types.d.ts +19 -2
- package/dist/reports/src/source-entries.js +2 -12
- package/package.json +5 -4
- package/packages/reports/README.md +4 -2
- package/packages/reports/STORAGE.md +1 -1
- package/packages/visualizer/README.md +9 -6
- package/packages/visualizer/example/specs/coffee/ordering/cart.qnt +7 -0
- package/packages/visualizer/example/specs/coffee/ordering/checkout.openslo.yaml +10 -0
- package/packages/visualizer/example/specs/coffee/ordering/drink-price.dmn +24 -0
- package/packages/visualizer/example/specs/coffee/ordering/loyalty.feature +7 -0
- package/packages/visualizer/example/specs/coffee/ordering/ordering.feature +36 -0
- package/packages/visualizer/example/specs/coffee/ordering/payment.als +4 -0
- package/packages/visualizer/fixtures/failures/specs/coffee/ordering/cart.qnt +7 -0
- package/packages/visualizer/fixtures/failures/specs/coffee/ordering/checkout.openslo.yaml +10 -0
- package/packages/visualizer/fixtures/failures/specs/coffee/ordering/drink-price.dmn +24 -0
- package/packages/visualizer/fixtures/failures/specs/coffee/ordering/loyalty.feature +7 -0
- package/packages/visualizer/fixtures/failures/specs/coffee/ordering/ordering.feature +36 -0
- package/packages/visualizer/fixtures/failures/specs/coffee/ordering/payment.als +4 -0
- package/packages/visualizer/fixtures/full/specs/coffee/ordering/cart.qnt +7 -0
- package/packages/visualizer/fixtures/full/specs/coffee/ordering/checkout.openslo.yaml +10 -0
- package/packages/visualizer/fixtures/full/specs/coffee/ordering/drink-price.dmn +24 -0
- package/packages/visualizer/fixtures/full/specs/coffee/ordering/loyalty.feature +7 -0
- package/packages/visualizer/fixtures/full/specs/coffee/ordering/ordering.feature +36 -0
- package/packages/visualizer/fixtures/full/specs/coffee/ordering/payment.als +4 -0
- package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/cart.qnt +7 -0
- package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/checkout.openslo.yaml +10 -0
- package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/drink-price.dmn +24 -0
- package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/loyalty.feature +7 -0
- package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/ordering.feature +36 -0
- package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/payment.als +4 -0
- package/packages/visualizer/fixtures/partial/specs/coffee/ordering/cart.qnt +7 -0
- package/packages/visualizer/fixtures/partial/specs/coffee/ordering/checkout.openslo.yaml +10 -0
- package/packages/visualizer/fixtures/partial/specs/coffee/ordering/drink-price.dmn +24 -0
- package/packages/visualizer/fixtures/partial/specs/coffee/ordering/loyalty.feature +7 -0
- package/packages/visualizer/fixtures/partial/specs/coffee/ordering/ordering.feature +36 -0
- package/packages/visualizer/fixtures/partial/specs/coffee/ordering/payment.als +4 -0
- package/packages/visualizer/fixtures/removed/specs/coffee/ordering/cart.qnt +7 -0
- package/packages/visualizer/fixtures/removed/specs/coffee/ordering/checkout.openslo.yaml +10 -0
- package/packages/visualizer/fixtures/removed/specs/coffee/ordering/drink-price.dmn +24 -0
- package/packages/visualizer/fixtures/removed/specs/coffee/ordering/loyalty.feature +7 -0
- package/packages/visualizer/fixtures/removed/specs/coffee/ordering/ordering.feature +36 -0
- package/packages/visualizer/fixtures/removed/specs/coffee/ordering/payment.als +4 -0
- package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/cart.qnt +7 -0
- package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/checkout.openslo.yaml +10 -0
- package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/drink-price.dmn +24 -0
- package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/loyalty.feature +7 -0
- package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/ordering.feature +36 -0
- package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/payment.als +4 -0
- package/packages/visualizer/scripts/make-fixtures.ts +2 -0
- package/packages/visualizer/scripts/publish-data.ts +1 -2
- package/packages/visualizer/src/app/AppShell.tsx +11 -12
- package/packages/visualizer/src/app/gazetteer.module.css +12 -2
- package/packages/visualizer/src/app/gazetteer.tsx +38 -26
- package/packages/visualizer/src/components/Header.module.css +20 -4
- package/packages/visualizer/src/components/Header.tsx +7 -4
- package/packages/visualizer/src/data/model/build.ts +17 -5
- package/packages/visualizer/src/data/model/types.ts +10 -1
- package/packages/visualizer/src/evidence/api.ts +6 -1
- package/packages/visualizer/src/evidence/check-detail.tsx +35 -3
- package/packages/visualizer/src/evidence/evidence.module.css +20 -0
- package/packages/visualizer/src/evidence/marks.tsx +2 -0
- package/packages/visualizer/src/evidence/overview.tsx +3 -3
- package/packages/visualizer/src/evidence/recording-page.tsx +19 -2
- package/packages/visualizer/src/evidence/recording.module.css +4 -8
- package/packages/visualizer/src/evidence/recording.tsx +74 -83
- package/packages/visualizer/src/evidence/snapshot.tsx +5 -4
- package/packages/visualizer/src/evidence/sources-panel.tsx +88 -34
- package/packages/visualizer/src/evidence/spec-index.tsx +3 -11
- package/packages/visualizer/src/find/Finder.module.css +168 -0
- package/packages/visualizer/src/find/Finder.tsx +303 -0
- package/packages/visualizer/src/find/corpus.test.ts +77 -0
- package/packages/visualizer/src/find/corpus.ts +368 -0
- package/packages/visualizer/src/find/open.ts +51 -0
- package/packages/visualizer/src/find/rank.test.ts +237 -0
- package/packages/visualizer/src/find/rank.ts +405 -0
- package/packages/visualizer/src/find/semantic.test.ts +62 -0
- package/packages/visualizer/src/find/semantic.ts +93 -0
- package/packages/visualizer/src/find/semantic.worker.ts +40 -0
- package/packages/visualizer/src/looks/Stage.tsx +322 -0
- package/packages/visualizer/src/looks/collect.test.ts +158 -0
- package/packages/visualizer/src/looks/collect.ts +245 -0
- package/packages/visualizer/src/looks/stage.module.css +300 -0
- package/packages/visualizer/src/reachable.test.ts +1 -1
- package/packages/visualizer/src/state/ui-store.ts +6 -2
- package/packages/visualizer/src/views/SidePanel.tsx +39 -49
- package/packages/visualizer/vite/example-reports.ts +475 -0
- package/packages/visualizer/vite.config.ts +9 -1
- package/packages/visualizer/src/app/search-query.tsx +0 -14
|
@@ -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}>
|
|
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="
|
|
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
|
-
{
|
|
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
|
|
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, '
|
|
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, '
|
|
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,4 +1,5 @@
|
|
|
1
1
|
import { checkHistory, type CheckSlot, type HistoryEntry } from '@crvouga/atlas/reports/evidence-view';
|
|
2
|
+
import type { RunReport } from '@crvouga/atlas/reports/report-types';
|
|
2
3
|
import { useQuery } from '@tanstack/react-query';
|
|
3
4
|
import { useMemo } from 'react';
|
|
4
5
|
|
|
@@ -21,6 +22,21 @@ type Props = {
|
|
|
21
22
|
onClose: () => void;
|
|
22
23
|
};
|
|
23
24
|
|
|
25
|
+
/** HTML snapshots are the only pictures that paint with the run's stylesheet. */
|
|
26
|
+
function needsStylesheet(report: RunReport | undefined): boolean {
|
|
27
|
+
if (!report) return false;
|
|
28
|
+
const visit = (steps: RunReport['steps']): boolean => {
|
|
29
|
+
for (const step of steps) {
|
|
30
|
+
for (const frame of step.frames) {
|
|
31
|
+
if (frame.snapshots.some((blob) => blob.media === 'html')) return true;
|
|
32
|
+
}
|
|
33
|
+
if (visit(step.steps)) return true;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
};
|
|
37
|
+
return visit(report.steps) || (report.artifacts ?? []).some((artifact) => artifact.media === 'html');
|
|
38
|
+
}
|
|
39
|
+
|
|
24
40
|
/** One recorded test, opened in place of its spec. */
|
|
25
41
|
export function RecordingPage({ slot, layerId, label, back, moment, onMoment, onClose }: Props) {
|
|
26
42
|
const { evidence, backends, pending, ready, unavailable } = useEvidence();
|
|
@@ -29,7 +45,8 @@ export function RecordingPage({ slot, layerId, label, back, moment, onMoment, on
|
|
|
29
45
|
const winner = slot?.outcomes[0];
|
|
30
46
|
const chosen: HistoryEntry | null = (layerId ? history.find((entry) => entry.layer.id === layerId) : undefined) ?? (winner ? { layer: winner.layer, row: winner.row } : null);
|
|
31
47
|
const { paths, report } = useRecordedReport(chosen);
|
|
32
|
-
const
|
|
48
|
+
const wantsCss = needsStylesheet(report.data);
|
|
49
|
+
const css = useQuery({ queryKey: ['evidence-css', paths?.css ?? ''], queryFn: ({ signal }) => reportText(paths!.css, signal), enabled: paths !== null && wantsCss, staleTime: Number.POSITIVE_INFINITY, retry: false });
|
|
33
50
|
const waiting = !chosen && !unavailable && (!ready || pending > 0);
|
|
34
51
|
return (
|
|
35
52
|
<article className={styles.page} data-wide="true" aria-label="Recorded test">
|
|
@@ -57,7 +74,7 @@ export function RecordingPage({ slot, layerId, label, back, moment, onMoment, on
|
|
|
57
74
|
<button type="button" className={styles.link} onClick={() => void report.refetch()}>Try again</button>
|
|
58
75
|
</p>
|
|
59
76
|
)}
|
|
60
|
-
{css.isError && (
|
|
77
|
+
{wantsCss && css.isError && (
|
|
61
78
|
<p className={styles.notice} role="alert">
|
|
62
79
|
The recorded stylesheet could not be read, so pictures appear unstyled.{' '}
|
|
63
80
|
<button type="button" className={styles.link} onClick={() => void css.refetch()}>Try again</button>
|
|
@@ -214,10 +214,8 @@
|
|
|
214
214
|
display: flex;
|
|
215
215
|
flex-wrap: wrap;
|
|
216
216
|
align-content: flex-start;
|
|
217
|
+
justify-content: center;
|
|
217
218
|
gap: 20px;
|
|
218
|
-
height: 29rem;
|
|
219
|
-
overflow: auto;
|
|
220
|
-
scrollbar-gutter: stable;
|
|
221
219
|
}
|
|
222
220
|
|
|
223
221
|
.snapshot {
|
|
@@ -265,17 +263,15 @@
|
|
|
265
263
|
.image {
|
|
266
264
|
display: block;
|
|
267
265
|
width: 100%;
|
|
268
|
-
height:
|
|
266
|
+
height: auto;
|
|
267
|
+
max-height: min(68vh, 640px);
|
|
269
268
|
object-fit: contain;
|
|
270
|
-
|
|
271
|
-
background: #fff;
|
|
272
|
-
max-width: 100%;
|
|
269
|
+
background: var(--bg-2);
|
|
273
270
|
border: 1px solid var(--line-2);
|
|
274
271
|
border-radius: 12px;
|
|
275
272
|
}
|
|
276
273
|
|
|
277
274
|
@media (max-width: 800px) {
|
|
278
|
-
.pictures { height: 24rem; }
|
|
279
275
|
.snapshot { flex-basis: min(270px, 100%); width: min(270px, 100%); }
|
|
280
276
|
}
|
|
281
277
|
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { flattenSteps, neighborFailure, orderedUiBlobs, selectedFrame, testFrames } from '@crvouga/atlas/reports/frames';
|
|
2
2
|
import { performanceFrameIndex, performanceRows, type PerformanceRow } from '@crvouga/atlas/reports/performance';
|
|
3
3
|
import type { ReportStep, RunReport } from '@crvouga/atlas/reports/report-types';
|
|
4
|
-
import {
|
|
4
|
+
import { useMemo, useState } from 'react';
|
|
5
5
|
|
|
6
|
+
import { reportLooks } from '../looks/collect';
|
|
7
|
+
import { Stage, type LookMode } from '../looks/Stage';
|
|
8
|
+
import { reportAssetUrl } from './api';
|
|
6
9
|
import { duration } from './format';
|
|
7
10
|
import { StatusMark } from './marks';
|
|
8
11
|
import styles from './recording.module.css';
|
|
@@ -20,63 +23,35 @@ type Props = {
|
|
|
20
23
|
|
|
21
24
|
/** A recorded test, one captured moment at a time. */
|
|
22
25
|
export function Timeline({ report, fileUrl, css, moment, onMoment }: Props) {
|
|
23
|
-
const [playing, setPlaying] = useState(false);
|
|
24
26
|
const [actor, setActor] = useState('all');
|
|
25
27
|
const [tab, setTab] = useState<'moments' | 'timing'>('moments');
|
|
28
|
+
const [mode, setMode] = useState<LookMode | null>(null);
|
|
26
29
|
const test = useMemo(() => ({ report, blobs: {} }), [report]);
|
|
27
30
|
const frames = useMemo(() => testFrames(test), [test]);
|
|
31
|
+
const looks = useMemo(() => reportLooks(report, (file) => reportAssetUrl(fileUrl(file))), [report, fileUrl]);
|
|
32
|
+
const extras = (report.artifacts ?? []).filter((artifact) => artifact.media !== 'png' && artifact.media !== 'video');
|
|
28
33
|
const frame = selectedFrame(frames, { view: 'test', testId: report.meta.testId, params: { frame: moment === null ? '' : String(moment) } });
|
|
29
34
|
const index = frame?.index ?? 0;
|
|
30
35
|
const last = frames.length - 1;
|
|
31
36
|
const go = (target: number) => {
|
|
32
37
|
if (frames.length) onMoment(Math.max(0, Math.min(last, target)) + 1, true);
|
|
33
38
|
};
|
|
34
|
-
// Timers and key handlers outlive a render; they always move from the moment on screen.
|
|
35
|
-
const goNow = useRef(go);
|
|
36
|
-
goNow.current = go;
|
|
37
39
|
const steps = useMemo(() => flattenSteps(report.steps).filter((entry) => entry.step.kind !== 'quint'), [report.steps]);
|
|
38
40
|
const rows = useMemo(() => performanceRows(report.steps, report.meta.performance?.slowStepMs ?? 50), [report]);
|
|
39
41
|
const byStep = useMemo(() => performanceFrameIndex(rows, frames), [rows, frames]);
|
|
40
42
|
const jump = (step: ReportStep) => {
|
|
41
43
|
const target = byStep.get(step);
|
|
42
44
|
if (!target) return;
|
|
43
|
-
|
|
45
|
+
setMode('timeline');
|
|
44
46
|
setTab('moments');
|
|
45
47
|
go(target.index);
|
|
46
48
|
};
|
|
47
|
-
|
|
48
|
-
const pause = frame?.frame.durationMs;
|
|
49
|
-
useEffect(() => {
|
|
50
|
-
if (!playing) return;
|
|
51
|
-
if (index >= last) {
|
|
52
|
-
setPlaying(false);
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
const timer = setTimeout(() => goNow.current(index + 1), Math.max(100, Math.min(pause ?? 500, 2000)));
|
|
56
|
-
return () => clearTimeout(timer);
|
|
57
|
-
}, [playing, index, last, pause]);
|
|
58
|
-
|
|
59
|
-
useEffect(() => {
|
|
60
|
-
const onKey = (event: KeyboardEvent) => {
|
|
61
|
-
if (event.altKey || event.ctrlKey || event.metaKey || !frames.length) return;
|
|
62
|
-
if (event.target instanceof HTMLElement && (event.target.isContentEditable || event.target.closest('input, textarea, select, button'))) return;
|
|
63
|
-
const target = event.key === 'ArrowRight' ? index + 1 : event.key === 'ArrowLeft' ? index - 1 : event.key === 'Home' ? 0 : event.key === 'End' ? last : null;
|
|
64
|
-
if (target === null) return;
|
|
65
|
-
event.preventDefault();
|
|
66
|
-
setPlaying(false);
|
|
67
|
-
goNow.current(target);
|
|
68
|
-
};
|
|
69
|
-
window.addEventListener('keydown', onKey);
|
|
70
|
-
return () => window.removeEventListener('keydown', onKey);
|
|
71
|
-
}, [index, last, frames.length]);
|
|
72
|
-
|
|
73
|
-
const ui = frame ? orderedUiBlobs(frame, test) : [];
|
|
74
|
-
const pictures = ui.filter((entry) => entry.blob && (actor === 'all' || entry.nodeId === actor));
|
|
75
|
-
const absent = ui.filter((entry) => !entry.blob && (actor === 'all' || entry.nodeId === actor));
|
|
76
|
-
const observations = frame?.frame.snapshots.filter((blob) => blob.media !== 'html' && blob.media !== 'png' && (actor === 'all' || !blob.nodeId || blob.nodeId === actor)) ?? [];
|
|
77
49
|
const nextFailure = () => {
|
|
78
50
|
const failure = neighborFailure(frames, index, 1);
|
|
79
|
-
if (failure)
|
|
51
|
+
if (failure) {
|
|
52
|
+
setMode('timeline');
|
|
53
|
+
go(failure.index);
|
|
54
|
+
}
|
|
80
55
|
};
|
|
81
56
|
|
|
82
57
|
return (
|
|
@@ -115,53 +90,69 @@ export function Timeline({ report, fileUrl, css, moment, onMoment }: Props) {
|
|
|
115
90
|
})}
|
|
116
91
|
</ol>
|
|
117
92
|
<section className={styles.stage} aria-label="Recorded moment">
|
|
118
|
-
|
|
119
|
-
<
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
<span className={styles.position}>{frames.length ? `Moment ${index + 1} of ${frames.length}` : 'No recorded moments'}</span>
|
|
126
|
-
<button type="button" className={styles.control} disabled={!frames.some((entry) => entry.step.status === 'failed')} onClick={nextFailure}>Next failure</button>
|
|
127
|
-
{report.meta.actorIds.length > 1 && (
|
|
128
|
-
<label className={styles.actor}>
|
|
129
|
-
<span className="sr-only">Person</span>
|
|
130
|
-
<select value={actor} onChange={(event) => setActor(event.target.value)}>
|
|
131
|
-
<option value="all">Everyone</option>
|
|
132
|
-
{report.meta.actorIds.map((id) => (
|
|
133
|
-
<option key={id}>{id}</option>
|
|
134
|
-
))}
|
|
135
|
-
</select>
|
|
136
|
-
</label>
|
|
137
|
-
)}
|
|
138
|
-
</div>
|
|
139
|
-
{!frame && <p className={styles.quietLine}>This test recorded no moments. Its steps and outcome are on the left.</p>}
|
|
140
|
-
{frame && (
|
|
141
|
-
<>
|
|
142
|
-
<h3 className={styles.momentTitle} data-status={frame.step.status}>{frame.step.title}</h3>
|
|
143
|
-
<p className={styles.quietLine}>
|
|
144
|
-
{frame.step.status === 'failed' ? 'Failed' : 'Passed'} · at {duration(Math.max(0, frame.frame.capturedAtMs - report.meta.startedAtMs)) || '0 ms'}
|
|
145
|
-
{frame.frame.hit && <> · acted on {frame.frame.hit.path.join(' › ')}</>}
|
|
146
|
-
</p>
|
|
147
|
-
{frame.step.error && <pre className={styles.error} role="alert">{frame.step.error}</pre>}
|
|
148
|
-
{frame.step.detail && (
|
|
149
|
-
<div className={styles.observed}>
|
|
150
|
-
<SnapshotData text={frame.step.detail} />
|
|
151
|
-
</div>
|
|
152
|
-
)}
|
|
153
|
-
<div className={styles.pictures} aria-label="Recorded snapshots">
|
|
154
|
-
{pictures.map((entry) => (
|
|
155
|
-
<Snapshot key={entry.blob!.file} url={fileUrl(entry.blob!.file)} blob={entry.blob!} css={css} hit={entry.stale ? null : frame.frame.hit ?? null} earlier={entry.stale} />
|
|
156
|
-
))}
|
|
157
|
-
{absent.map((entry) => (
|
|
158
|
-
<p key={entry.nodeId} className={styles.quietLine}>No picture of {entry.nodeId} had been recorded by this moment.</p>
|
|
93
|
+
{report.meta.actorIds.length > 1 && (
|
|
94
|
+
<label className={styles.actor}>
|
|
95
|
+
<span className="sr-only">Person</span>
|
|
96
|
+
<select value={actor} onChange={(event) => setActor(event.target.value)}>
|
|
97
|
+
<option value="all">Everyone</option>
|
|
98
|
+
{report.meta.actorIds.map((id) => (
|
|
99
|
+
<option key={id}>{id}</option>
|
|
159
100
|
))}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
101
|
+
</select>
|
|
102
|
+
</label>
|
|
103
|
+
)}
|
|
104
|
+
<Stage
|
|
105
|
+
looks={looks}
|
|
106
|
+
prefer="timeline"
|
|
107
|
+
mode={mode}
|
|
108
|
+
onMode={setMode}
|
|
109
|
+
moment={frames.length ? index : undefined}
|
|
110
|
+
onMoment={go}
|
|
111
|
+
failure={frames.some((entry) => entry.step.status === 'failed')}
|
|
112
|
+
onFailure={nextFailure}
|
|
113
|
+
empty={extras.length > 0
|
|
114
|
+
? <p className={styles.quietLine}>This runner attached files instead of a step-by-step timeline. They are below.</p>
|
|
115
|
+
: <p className={styles.quietLine}>This test recorded no picture, video, or timeline. Its steps and outcome are on the left.</p>}
|
|
116
|
+
renderMoment={(at) => {
|
|
117
|
+
const current = frames[at];
|
|
118
|
+
if (!current) return null;
|
|
119
|
+
const ui = orderedUiBlobs(current, test);
|
|
120
|
+
const pictures = ui.filter((entry) => entry.blob && entry.blob.media !== 'video' && (actor === 'all' || entry.nodeId === actor));
|
|
121
|
+
const absent = ui.filter((entry) => !entry.blob && (actor === 'all' || !entry.nodeId || entry.nodeId === actor));
|
|
122
|
+
const observations = current.frame.snapshots.filter((blob) => blob.media !== 'html' && blob.media !== 'png' && blob.media !== 'video' && (actor === 'all' || !blob.nodeId || blob.nodeId === actor));
|
|
123
|
+
return (
|
|
124
|
+
<>
|
|
125
|
+
<p className={styles.quietLine}>
|
|
126
|
+
{current.step.status === 'failed' ? 'Failed' : 'Passed'} · at {duration(Math.max(0, current.frame.capturedAtMs - report.meta.startedAtMs)) || '0 ms'}
|
|
127
|
+
{current.frame.hit && <> · acted on {current.frame.hit.path.join(' › ')}</>}
|
|
128
|
+
</p>
|
|
129
|
+
{current.step.error && <pre className={styles.error} role="alert">{current.step.error}</pre>}
|
|
130
|
+
{current.step.detail && (
|
|
131
|
+
<div className={styles.observed}>
|
|
132
|
+
<SnapshotData text={current.step.detail} />
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
<div className={styles.pictures} aria-label="Recorded snapshots">
|
|
136
|
+
{pictures.map((entry) => (
|
|
137
|
+
<Snapshot key={entry.blob!.file} url={fileUrl(entry.blob!.file)} blob={entry.blob!} css={css} hit={entry.stale ? null : current.frame.hit ?? null} earlier={entry.stale} />
|
|
138
|
+
))}
|
|
139
|
+
{absent.map((entry) => (
|
|
140
|
+
<p key={entry.nodeId} className={styles.quietLine}>No picture of {entry.nodeId} had been recorded by this moment.</p>
|
|
141
|
+
))}
|
|
142
|
+
{observations.map((blob) => (
|
|
143
|
+
<Snapshot key={blob.file} url={fileUrl(blob.file)} blob={blob} css={css} hit={null} />
|
|
144
|
+
))}
|
|
145
|
+
</div>
|
|
146
|
+
</>
|
|
147
|
+
);
|
|
148
|
+
}}
|
|
149
|
+
/>
|
|
150
|
+
{extras.length > 0 && (
|
|
151
|
+
<div className={styles.pictures} aria-label="Other attachments">
|
|
152
|
+
{extras.map((artifact) => (
|
|
153
|
+
<Snapshot key={artifact.file} url={fileUrl(artifact.file)} blob={{ name: artifact.name, media: artifact.media, file: artifact.file }} css={css} hit={null} />
|
|
154
|
+
))}
|
|
155
|
+
</div>
|
|
165
156
|
)}
|
|
166
157
|
</section>
|
|
167
158
|
</div>
|
|
@@ -4,7 +4,7 @@ import { createSnapshotHost, paintSnapshotHost, type HitResult } from '@crvouga/
|
|
|
4
4
|
import { useQuery } from '@tanstack/react-query';
|
|
5
5
|
import { useEffect, useRef, useState } from 'react';
|
|
6
6
|
|
|
7
|
-
import { reportResponse } from './api';
|
|
7
|
+
import { reportAssetUrl, reportResponse } from './api';
|
|
8
8
|
import styles from './recording.module.css';
|
|
9
9
|
import { traceStates } from './quint';
|
|
10
10
|
import { ModelTrace } from './model-trace';
|
|
@@ -45,9 +45,9 @@ export function SnapshotPicture({ file, html, css, hit, who }: { file: string; h
|
|
|
45
45
|
|
|
46
46
|
/** One captured picture or value, exactly as recorded. */
|
|
47
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), retry: false });
|
|
48
|
+
const content = useQuery({ queryKey: ['evidence-blob', url], queryFn: ({ signal }) => readBlob(url, blob, signal), enabled: blob.media !== 'video', retry: false });
|
|
49
49
|
const who = blob.nodeId ?? blob.name;
|
|
50
|
-
const picture = blob.media === 'html' || blob.media === 'png';
|
|
50
|
+
const picture = blob.media === 'html' || blob.media === 'png' || blob.media === 'video';
|
|
51
51
|
return (
|
|
52
52
|
<figure className={styles.snapshot} data-picture={picture || undefined}>
|
|
53
53
|
<figcaption>
|
|
@@ -55,7 +55,8 @@ export function Snapshot({ url, blob, css, hit, earlier = false }: { url: string
|
|
|
55
55
|
{blob.nodeId && blob.name !== blob.nodeId && <span>{blob.name}</span>}
|
|
56
56
|
{earlier && <span className={styles.earlier}>Earlier picture</span>}
|
|
57
57
|
</figcaption>
|
|
58
|
-
{
|
|
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>}
|
|
59
60
|
{content.isError && (
|
|
60
61
|
<p role="alert" className={styles.quietLine}>
|
|
61
62
|
{content.error.message}{' '}
|
|
@@ -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
|
-
|
|
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
|
|
29
|
-
* switched off here, which is also how an earlier result is brought back
|
|
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
|
|
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, '
|
|
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(
|
|
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), '
|
|
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
|
-
<
|
|
82
|
-
|
|
83
|
-
{facets.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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>
|
|
123
|
+
<h3>Layers</h3>
|
|
106
124
|
<ul>
|
|
107
|
-
<Bounded items={layers} size={40} more="Earlier
|
|
125
|
+
<Bounded items={layers} size={40} more="Earlier layers">
|
|
108
126
|
{(layer) => {
|
|
109
|
-
const off =
|
|
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
|
+
}
|