@progen-labs/spectra 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +100 -2
- package/bin/spectra.js +215 -0
- package/dist/assets/index-D0Lxrmdt.js +46 -0
- package/dist/assets/index-Da3Le5Px.css +1 -0
- package/dist/index.html +14 -0
- package/index.html +13 -0
- package/package.json +76 -4
- package/server/analysis/cpu.js +369 -0
- package/server/analysis/findings.js +570 -0
- package/server/analysis/locate.js +99 -0
- package/server/analysis/report.js +775 -0
- package/server/analysis/sources.js +249 -0
- package/server/analysis/trace.js +314 -0
- package/server/api.js +292 -0
- package/server/chrome.js +81 -0
- package/server/cli.js +94 -0
- package/server/config.js +59 -0
- package/server/dashboard.js +154 -0
- package/server/explore/boot.js +150 -0
- package/server/explore/classify.js +169 -0
- package/server/explore/discover.js +162 -0
- package/server/explore/evaluate-cli.js +101 -0
- package/server/explore/evaluate.js +165 -0
- package/server/explore/paths.js +105 -0
- package/server/explore/runner.js +94 -0
- package/server/explore/test-explore.js +139 -0
- package/server/explore/test-paths.js +75 -0
- package/server/framework.js +67 -0
- package/server/git.js +59 -0
- package/server/pdf/render.js +60 -0
- package/server/pdf/template.js +419 -0
- package/server/plugin.js +19 -0
- package/server/probe.browser.js +828 -0
- package/server/recorder.js +610 -0
- package/server/signin.js +27 -0
- package/server/test-analysis.js +424 -0
- package/server/test-compare.js +473 -0
- package/server/test-sources.js +136 -0
- package/shared/compare.js +977 -0
- package/shared/snapshot.js +77 -0
- package/src/App.jsx +361 -0
- package/src/api.js +51 -0
- package/src/components/AppearanceMenu.jsx +76 -0
- package/src/components/CodeRef.jsx +48 -0
- package/src/components/ComparePanel.jsx +143 -0
- package/src/components/CompareResults.jsx +473 -0
- package/src/components/ControlBar.jsx +336 -0
- package/src/components/CulpritSphere.jsx +218 -0
- package/src/components/Culprits.jsx +120 -0
- package/src/components/CulpritsHero.jsx +109 -0
- package/src/components/EvaluationPanel.jsx +160 -0
- package/src/components/FilePanel.jsx +225 -0
- package/src/components/FindingsList.jsx +381 -0
- package/src/components/HeatTrees.jsx +222 -0
- package/src/components/Icons.jsx +58 -0
- package/src/components/LongTasks.jsx +199 -0
- package/src/components/NetworkPanel.jsx +98 -0
- package/src/components/PageMap.jsx +329 -0
- package/src/components/Popover.jsx +51 -0
- package/src/components/ReactPanel.jsx +206 -0
- package/src/components/SidePicker.jsx +207 -0
- package/src/components/SnapshotButton.jsx +140 -0
- package/src/components/SpectraCulprits.jsx +39 -0
- package/src/components/TimeBreakdown.jsx +45 -0
- package/src/components/TraceButton.jsx +76 -0
- package/src/components/TreeView.jsx +173 -0
- package/src/components/Vitals.jsx +163 -0
- package/src/culprits.js +127 -0
- package/src/download.js +18 -0
- package/src/format.js +26 -0
- package/src/heat.js +177 -0
- package/src/highlight.js +45 -0
- package/src/hooks/useCompare.js +187 -0
- package/src/hooks/useSession.js +73 -0
- package/src/layout.js +55 -0
- package/src/main.jsx +15 -0
- package/src/styles.css +2478 -0
- package/src/theme.js +75 -0
- package/src/themes.css +286 -0
- package/vite.config.js +8 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { api } from '../api.js';
|
|
3
|
+
import { downloadBlob } from '../download.js';
|
|
4
|
+
import { DownloadIcon } from './Icons.jsx';
|
|
5
|
+
import CompareResults from './CompareResults.jsx';
|
|
6
|
+
import SidePicker from './SidePicker.jsx';
|
|
7
|
+
|
|
8
|
+
function PdfButtons({ comparison }) {
|
|
9
|
+
const [busy, setBusy] = useState(null);
|
|
10
|
+
const [error, setError] = useState('');
|
|
11
|
+
const save = async kind => {
|
|
12
|
+
setBusy(kind);
|
|
13
|
+
setError('');
|
|
14
|
+
try {
|
|
15
|
+
const { blob, name } = await api.comparePdf(comparison, kind);
|
|
16
|
+
downloadBlob(blob, name);
|
|
17
|
+
} catch (err) {
|
|
18
|
+
setError(err.message);
|
|
19
|
+
}
|
|
20
|
+
setBusy(null);
|
|
21
|
+
};
|
|
22
|
+
return (
|
|
23
|
+
<span className="pdf-buttons">
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
className="primary"
|
|
27
|
+
disabled={Boolean(busy)}
|
|
28
|
+
onClick={() => save('summary')}
|
|
29
|
+
>
|
|
30
|
+
{busy === 'summary' ? (
|
|
31
|
+
'Preparing…'
|
|
32
|
+
) : (
|
|
33
|
+
<>
|
|
34
|
+
<DownloadIcon /> PDF summary
|
|
35
|
+
</>
|
|
36
|
+
)}
|
|
37
|
+
</button>
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
disabled={Boolean(busy)}
|
|
41
|
+
onClick={() => save('detailed')}
|
|
42
|
+
>
|
|
43
|
+
{busy === 'detailed' ? (
|
|
44
|
+
'Preparing…'
|
|
45
|
+
) : (
|
|
46
|
+
<>
|
|
47
|
+
<DownloadIcon /> PDF detailed report
|
|
48
|
+
</>
|
|
49
|
+
)}
|
|
50
|
+
</button>
|
|
51
|
+
{error && (
|
|
52
|
+
<span className="warn" role="alert">
|
|
53
|
+
{error}
|
|
54
|
+
</span>
|
|
55
|
+
)}
|
|
56
|
+
</span>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Compare tab: pick the recordings taken before and after a commit and see what
|
|
62
|
+
* improved, degraded or stayed the same. All numbers come from shared/compare.js.
|
|
63
|
+
*/
|
|
64
|
+
export default function ComparePanel({ compare, savedReports, hideVendor }) {
|
|
65
|
+
const {
|
|
66
|
+
sides,
|
|
67
|
+
comparison,
|
|
68
|
+
addFiles,
|
|
69
|
+
addSaved,
|
|
70
|
+
removeRun,
|
|
71
|
+
setLabel,
|
|
72
|
+
lookupCommit,
|
|
73
|
+
swap,
|
|
74
|
+
reset,
|
|
75
|
+
} = compare;
|
|
76
|
+
const picker = (name, title, hint) => (
|
|
77
|
+
<SidePicker
|
|
78
|
+
title={title}
|
|
79
|
+
hint={hint}
|
|
80
|
+
side={sides[name]}
|
|
81
|
+
saved={savedReports}
|
|
82
|
+
onFiles={files => addFiles(name, files)}
|
|
83
|
+
onSaved={id => addSaved(name, id)}
|
|
84
|
+
onRemove={id => removeRun(name, id)}
|
|
85
|
+
onLabel={label => setLabel(name, label)}
|
|
86
|
+
onLookup={hash => lookupCommit(name, hash)}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
return (
|
|
90
|
+
<div className="compare-panel">
|
|
91
|
+
<div className="compare-intro">
|
|
92
|
+
<h2>Compare two commits</h2>
|
|
93
|
+
<p className="muted">
|
|
94
|
+
Record the same scenario on the base commit and on the commit under
|
|
95
|
+
review (<b>3 times each</b> is best), use{' '}
|
|
96
|
+
<b>Snapshot for comparison</b> to save each recording, then upload
|
|
97
|
+
them here. A change counts only if it is bigger than the noise between
|
|
98
|
+
runs.
|
|
99
|
+
</p>
|
|
100
|
+
</div>
|
|
101
|
+
<div className="sides">
|
|
102
|
+
{picker('before', 'Before', '· base commit')}
|
|
103
|
+
<div className="swap">
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
onClick={swap}
|
|
107
|
+
title="Swap before and after"
|
|
108
|
+
aria-label="Swap before and after"
|
|
109
|
+
>
|
|
110
|
+
⇄
|
|
111
|
+
</button>
|
|
112
|
+
</div>
|
|
113
|
+
{picker('after', 'After', '· commit under review')}
|
|
114
|
+
</div>
|
|
115
|
+
{comparison ? (
|
|
116
|
+
<>
|
|
117
|
+
<div className="compare-toolbar">
|
|
118
|
+
<span className="commit-pair">
|
|
119
|
+
<code>{comparison.before.git?.shortHash || 'before'}</code>
|
|
120
|
+
{comparison.before.git?.author && (
|
|
121
|
+
<span className="muted"> ({comparison.before.git.author})</span>
|
|
122
|
+
)}{' '}
|
|
123
|
+
→ <code>{comparison.after.git?.shortHash || 'after'}</code>
|
|
124
|
+
{comparison.after.git?.author && (
|
|
125
|
+
<span className="muted"> ({comparison.after.git.author})</span>
|
|
126
|
+
)}
|
|
127
|
+
</span>
|
|
128
|
+
<span className="spacer" />
|
|
129
|
+
<PdfButtons comparison={comparison} />
|
|
130
|
+
<button type="button" onClick={reset}>
|
|
131
|
+
Clear
|
|
132
|
+
</button>
|
|
133
|
+
</div>
|
|
134
|
+
<CompareResults comparison={comparison} hideVendor={hideVendor} />
|
|
135
|
+
</>
|
|
136
|
+
) : (
|
|
137
|
+
<p className="empty">
|
|
138
|
+
Add at least one recording on each side to see the comparison.
|
|
139
|
+
</p>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { formatDelta, formatPct, formatValue } from '../../shared/compare.js';
|
|
3
|
+
import CodeRef from './CodeRef.jsx';
|
|
4
|
+
|
|
5
|
+
const VERDICT = {
|
|
6
|
+
improved: { icon: '✓', word: 'Improved', cls: 'good' },
|
|
7
|
+
degraded: { icon: '✕', word: 'Degraded', cls: 'bad' },
|
|
8
|
+
same: { icon: '=', word: 'Unchanged', cls: 'same' },
|
|
9
|
+
'n/a': { icon: '–', word: 'Not measured', cls: 'na' },
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const OVERALL = {
|
|
13
|
+
improved: {
|
|
14
|
+
title: 'Improved',
|
|
15
|
+
cls: 'good',
|
|
16
|
+
sentence: 'Key metrics got better and none got significantly worse.',
|
|
17
|
+
},
|
|
18
|
+
regressed: {
|
|
19
|
+
title: 'Regressed',
|
|
20
|
+
cls: 'bad',
|
|
21
|
+
sentence: 'Key metrics got worse and none got significantly better.',
|
|
22
|
+
},
|
|
23
|
+
mixed: {
|
|
24
|
+
title: 'Mixed result',
|
|
25
|
+
cls: 'warn',
|
|
26
|
+
sentence: 'Some key metrics improved while others degraded.',
|
|
27
|
+
},
|
|
28
|
+
neutral: {
|
|
29
|
+
title: 'No significant change',
|
|
30
|
+
cls: 'same',
|
|
31
|
+
sentence: 'No key metric moved beyond the noise thresholds.',
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const CONFIDENCE = {
|
|
36
|
+
high: 'High confidence: comparable recordings, repeated runs.',
|
|
37
|
+
medium: 'Medium confidence: see the comparability notes.',
|
|
38
|
+
low: 'Low confidence: the recordings are not cleanly comparable.',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const CHECK_ICON = {
|
|
42
|
+
ok: ['✓', 'good'],
|
|
43
|
+
warn: ['!', 'warn'],
|
|
44
|
+
bad: ['✕', 'bad'],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function Chip({ verdict }) {
|
|
48
|
+
const v = VERDICT[verdict];
|
|
49
|
+
return (
|
|
50
|
+
<span className={`verdict ${v.cls}`}>
|
|
51
|
+
<span aria-hidden="true">{v.icon}</span> {v.word}
|
|
52
|
+
</span>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Diverging bar: left of the axis = lower (better), right = higher (worse); capped at ±100 %. */
|
|
57
|
+
function ChangeBar({ metric }) {
|
|
58
|
+
if (metric.verdict === 'n/a') return null;
|
|
59
|
+
const half = 60;
|
|
60
|
+
const frac = metric.pct === null ? 1 : Math.min(Math.abs(metric.pct), 1);
|
|
61
|
+
const len = Math.max(frac * (half - 2), metric.verdict === 'same' ? 1 : 3);
|
|
62
|
+
const x = metric.delta < 0 ? half - len : half;
|
|
63
|
+
return (
|
|
64
|
+
<svg
|
|
65
|
+
className={`change-bar ${VERDICT[metric.verdict].cls}`}
|
|
66
|
+
width={half * 2}
|
|
67
|
+
height="12"
|
|
68
|
+
role="img"
|
|
69
|
+
aria-label={formatPct(metric.pct)}
|
|
70
|
+
>
|
|
71
|
+
<line x1={half} x2={half} y1="0" y2="12" className="axis" />
|
|
72
|
+
<rect x={x} y="3" width={len} height="6" rx="2" />
|
|
73
|
+
</svg>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function Verdict({ comparison }) {
|
|
78
|
+
const o = OVERALL[comparison.verdict];
|
|
79
|
+
const { improved, degraded, same, unavailable } = comparison.counts;
|
|
80
|
+
return (
|
|
81
|
+
<div className={`verdict-banner ${o.cls}`} role="status">
|
|
82
|
+
<div className="big">{o.title}</div>
|
|
83
|
+
<p>
|
|
84
|
+
{o.sentence} {CONFIDENCE[comparison.confidence]}
|
|
85
|
+
</p>
|
|
86
|
+
<p className="counts">
|
|
87
|
+
<span className="good">✓ {improved} improved</span>
|
|
88
|
+
<span className="bad">✕ {degraded} degraded</span>
|
|
89
|
+
<span className="same">= {same} unchanged</span>
|
|
90
|
+
{unavailable > 0 && (
|
|
91
|
+
<span className="muted">{unavailable} not measured</span>
|
|
92
|
+
)}
|
|
93
|
+
</p>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function Checks({ checks }) {
|
|
99
|
+
return (
|
|
100
|
+
<ul className="checks">
|
|
101
|
+
{checks.map(c => (
|
|
102
|
+
<li key={c.id} className={CHECK_ICON[c.level][1]}>
|
|
103
|
+
<span className="i" aria-hidden="true">
|
|
104
|
+
{CHECK_ICON[c.level][0]}
|
|
105
|
+
</span>
|
|
106
|
+
<span>
|
|
107
|
+
<b>{c.title}.</b> {c.detail}
|
|
108
|
+
</span>
|
|
109
|
+
</li>
|
|
110
|
+
))}
|
|
111
|
+
</ul>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function MetricsTable({ metrics }) {
|
|
116
|
+
const [filter, setFilter] = useState('all');
|
|
117
|
+
const shown = metrics.filter(m => filter === 'all' || m.verdict === filter);
|
|
118
|
+
const counts = v => metrics.filter(m => m.verdict === v).length;
|
|
119
|
+
const filters = [
|
|
120
|
+
['all', `All (${metrics.length})`],
|
|
121
|
+
['improved', `✓ Improved (${counts('improved')})`],
|
|
122
|
+
['degraded', `✕ Degraded (${counts('degraded')})`],
|
|
123
|
+
['same', `= Unchanged (${counts('same')})`],
|
|
124
|
+
];
|
|
125
|
+
let group = null;
|
|
126
|
+
return (
|
|
127
|
+
<section>
|
|
128
|
+
<h2>Metrics</h2>
|
|
129
|
+
<div className="segmented" role="group" aria-label="Filter metrics">
|
|
130
|
+
{filters.map(([id, label]) => (
|
|
131
|
+
<button
|
|
132
|
+
key={id}
|
|
133
|
+
type="button"
|
|
134
|
+
aria-pressed={filter === id}
|
|
135
|
+
onClick={() => setFilter(id)}
|
|
136
|
+
>
|
|
137
|
+
{label}
|
|
138
|
+
</button>
|
|
139
|
+
))}
|
|
140
|
+
</div>
|
|
141
|
+
<table className="data compare-table">
|
|
142
|
+
<thead>
|
|
143
|
+
<tr>
|
|
144
|
+
<th>Metric</th>
|
|
145
|
+
<th className="num">Before</th>
|
|
146
|
+
<th className="num">After</th>
|
|
147
|
+
<th className="num">Change</th>
|
|
148
|
+
<th aria-label="Change as a bar" />
|
|
149
|
+
<th>Result</th>
|
|
150
|
+
</tr>
|
|
151
|
+
</thead>
|
|
152
|
+
<tbody>
|
|
153
|
+
{shown.map(m => {
|
|
154
|
+
const header =
|
|
155
|
+
m.group !== group
|
|
156
|
+
? ((group = m.group),
|
|
157
|
+
(
|
|
158
|
+
<tr key={`g-${m.group}`} className="group-row">
|
|
159
|
+
<td colSpan={6}>{m.group}</td>
|
|
160
|
+
</tr>
|
|
161
|
+
))
|
|
162
|
+
: null;
|
|
163
|
+
return [
|
|
164
|
+
header,
|
|
165
|
+
<tr
|
|
166
|
+
key={m.id}
|
|
167
|
+
className={
|
|
168
|
+
m.verdict === 'same' || m.verdict === 'n/a' ? 'dim' : ''
|
|
169
|
+
}
|
|
170
|
+
>
|
|
171
|
+
<td>
|
|
172
|
+
{m.label}{' '}
|
|
173
|
+
{m.key && (
|
|
174
|
+
<span
|
|
175
|
+
className="chip"
|
|
176
|
+
title="Counts toward the overall verdict"
|
|
177
|
+
>
|
|
178
|
+
key
|
|
179
|
+
</span>
|
|
180
|
+
)}
|
|
181
|
+
{(m.before.n > 1 || m.after.n > 1) && (
|
|
182
|
+
<div className="muted small">
|
|
183
|
+
range {formatValue(m.unit, m.before.min)}–
|
|
184
|
+
{formatValue(m.unit, m.before.max)} →{' '}
|
|
185
|
+
{formatValue(m.unit, m.after.min)}–
|
|
186
|
+
{formatValue(m.unit, m.after.max)}
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
</td>
|
|
190
|
+
<td className="num">{formatValue(m.unit, m.before.value)}</td>
|
|
191
|
+
<td className="num">{formatValue(m.unit, m.after.value)}</td>
|
|
192
|
+
<td className="num">
|
|
193
|
+
{m.verdict === 'n/a'
|
|
194
|
+
? '–'
|
|
195
|
+
: `${formatDelta(m.unit, m.delta)} (${formatPct(m.pct)})`}
|
|
196
|
+
</td>
|
|
197
|
+
<td>
|
|
198
|
+
<ChangeBar metric={m} />
|
|
199
|
+
</td>
|
|
200
|
+
<td>
|
|
201
|
+
<Chip verdict={m.verdict} />
|
|
202
|
+
</td>
|
|
203
|
+
</tr>,
|
|
204
|
+
];
|
|
205
|
+
})}
|
|
206
|
+
</tbody>
|
|
207
|
+
</table>
|
|
208
|
+
{!shown.length && <p className="muted">Nothing in this category.</p>}
|
|
209
|
+
</section>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function EntityList({ title, tone, list, total, hideVendor, defaultOpen }) {
|
|
214
|
+
const rows = hideVendor ? list.filter(e => !e.vendor) : list;
|
|
215
|
+
if (!rows.length) return null;
|
|
216
|
+
return (
|
|
217
|
+
<details className={`entity-group ${tone}`} open={defaultOpen}>
|
|
218
|
+
<summary>
|
|
219
|
+
{title}{' '}
|
|
220
|
+
<span className="muted">({hideVendor ? rows.length : total})</span>
|
|
221
|
+
</summary>
|
|
222
|
+
<table className="data">
|
|
223
|
+
<tbody>
|
|
224
|
+
{rows.map(e => (
|
|
225
|
+
<tr key={e.key}>
|
|
226
|
+
<td className="entity-name">
|
|
227
|
+
<b>
|
|
228
|
+
{e.name === e.file ? (
|
|
229
|
+
<span className="mono">{e.name}</span>
|
|
230
|
+
) : (
|
|
231
|
+
e.name
|
|
232
|
+
)}
|
|
233
|
+
</b>
|
|
234
|
+
{e.vendor && <span className="chip">library</span>}
|
|
235
|
+
{e.name !== e.file && e.file && (
|
|
236
|
+
<CodeRef
|
|
237
|
+
file={e.file}
|
|
238
|
+
line={e.line}
|
|
239
|
+
lineKind={e.lineKind}
|
|
240
|
+
abs={e.abs}
|
|
241
|
+
/>
|
|
242
|
+
)}
|
|
243
|
+
</td>
|
|
244
|
+
<td>
|
|
245
|
+
{e.changes.map(c => (
|
|
246
|
+
<div
|
|
247
|
+
key={c.id}
|
|
248
|
+
className={
|
|
249
|
+
c.verdict === 'same' ? 'muted' : VERDICT[c.verdict].cls
|
|
250
|
+
}
|
|
251
|
+
>
|
|
252
|
+
{c.verdict !== 'same' && (
|
|
253
|
+
<b aria-hidden="true">{VERDICT[c.verdict].icon} </b>
|
|
254
|
+
)}
|
|
255
|
+
{c.label}: {formatValue(c.unit, c.before)} →{' '}
|
|
256
|
+
{formatValue(c.unit, c.after)}
|
|
257
|
+
{c.verdict !== 'same' && ` (${formatPct(c.pct)})`}
|
|
258
|
+
</div>
|
|
259
|
+
))}
|
|
260
|
+
</td>
|
|
261
|
+
</tr>
|
|
262
|
+
))}
|
|
263
|
+
</tbody>
|
|
264
|
+
</table>
|
|
265
|
+
</details>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function Entities({ data, hideVendor }) {
|
|
270
|
+
const group = (title, bucket, tone, open) => (
|
|
271
|
+
<EntityList
|
|
272
|
+
key={bucket}
|
|
273
|
+
title={title}
|
|
274
|
+
tone={tone}
|
|
275
|
+
list={data[bucket]}
|
|
276
|
+
total={data[`${bucket}Total`]}
|
|
277
|
+
hideVendor={hideVendor}
|
|
278
|
+
defaultOpen={open}
|
|
279
|
+
/>
|
|
280
|
+
);
|
|
281
|
+
return (
|
|
282
|
+
<>
|
|
283
|
+
<p className="muted">
|
|
284
|
+
{data.compared} compared · {data.unchanged} unchanged
|
|
285
|
+
</p>
|
|
286
|
+
{group('Degraded', 'degraded', 'bad', true)}
|
|
287
|
+
{group('Mixed (some metrics better, some worse)', 'mixed', 'warn', true)}
|
|
288
|
+
{group('Improved', 'improved', 'good', true)}
|
|
289
|
+
{group('Newly active (new cost)', 'added', 'bad', false)}
|
|
290
|
+
{group('No longer active (cost gone)', 'removed', 'good', false)}
|
|
291
|
+
{!data.degradedTotal &&
|
|
292
|
+
!data.improvedTotal &&
|
|
293
|
+
!data.mixedTotal &&
|
|
294
|
+
!data.addedTotal &&
|
|
295
|
+
!data.removedTotal && (
|
|
296
|
+
<p className="muted">Nothing changed beyond the thresholds.</p>
|
|
297
|
+
)}
|
|
298
|
+
</>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function FindingCard({ finding, tone }) {
|
|
303
|
+
const sev = { critical: 'bad', warning: 'warn', info: 'info' }[
|
|
304
|
+
finding.severity
|
|
305
|
+
];
|
|
306
|
+
return (
|
|
307
|
+
<li className={`finding-card ${tone || sev}`}>
|
|
308
|
+
<div>
|
|
309
|
+
<span className={`sev ${sev}`}>{finding.severity.toUpperCase()}</span>{' '}
|
|
310
|
+
<span className="chip">{finding.category}</span>
|
|
311
|
+
</div>
|
|
312
|
+
<h4>{finding.title}</h4>
|
|
313
|
+
{finding.culprits?.map(c => (
|
|
314
|
+
<div key={`${c.fn}${c.file}`} className="mono">
|
|
315
|
+
{c.fn}() in{' '}
|
|
316
|
+
<CodeRef
|
|
317
|
+
file={c.file}
|
|
318
|
+
line={c.line}
|
|
319
|
+
lineKind={c.lineKind}
|
|
320
|
+
abs={c.abs}
|
|
321
|
+
/>
|
|
322
|
+
</div>
|
|
323
|
+
))}
|
|
324
|
+
<p className="fix">
|
|
325
|
+
<b>Fix:</b> {finding.fix}
|
|
326
|
+
</p>
|
|
327
|
+
</li>
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function Findings({ findings }) {
|
|
332
|
+
const block = (title, list, tone, empty) => (
|
|
333
|
+
<section>
|
|
334
|
+
<h3>
|
|
335
|
+
{title} <span className="muted">({list.length})</span>
|
|
336
|
+
</h3>
|
|
337
|
+
{list.length ? (
|
|
338
|
+
<ul className="finding-cards">
|
|
339
|
+
{list.map(f => (
|
|
340
|
+
<FindingCard key={f.id} finding={f} tone={tone} />
|
|
341
|
+
))}
|
|
342
|
+
</ul>
|
|
343
|
+
) : (
|
|
344
|
+
<p className="muted">{empty}</p>
|
|
345
|
+
)}
|
|
346
|
+
</section>
|
|
347
|
+
);
|
|
348
|
+
return (
|
|
349
|
+
<>
|
|
350
|
+
{block(
|
|
351
|
+
'New problems introduced',
|
|
352
|
+
findings.added,
|
|
353
|
+
'bad',
|
|
354
|
+
'No new problems.',
|
|
355
|
+
)}
|
|
356
|
+
{block(
|
|
357
|
+
'Problems resolved',
|
|
358
|
+
findings.resolved,
|
|
359
|
+
'good',
|
|
360
|
+
'No problems were resolved.',
|
|
361
|
+
)}
|
|
362
|
+
<section>
|
|
363
|
+
<h3>
|
|
364
|
+
Problems that persist{' '}
|
|
365
|
+
<span className="muted">({findings.persisting.length})</span>
|
|
366
|
+
</h3>
|
|
367
|
+
{findings.persisting.length ? (
|
|
368
|
+
<table className="data">
|
|
369
|
+
<thead>
|
|
370
|
+
<tr>
|
|
371
|
+
<th>Issue</th>
|
|
372
|
+
<th className="num">Impact before</th>
|
|
373
|
+
<th className="num">Impact after</th>
|
|
374
|
+
</tr>
|
|
375
|
+
</thead>
|
|
376
|
+
<tbody>
|
|
377
|
+
{findings.persisting.map(f => (
|
|
378
|
+
<tr key={f.id}>
|
|
379
|
+
<td>
|
|
380
|
+
<span
|
|
381
|
+
className={`sev ${{ critical: 'bad', warning: 'warn', info: 'info' }[f.severity]}`}
|
|
382
|
+
>
|
|
383
|
+
{f.severity}
|
|
384
|
+
</span>{' '}
|
|
385
|
+
· {f.title}
|
|
386
|
+
</td>
|
|
387
|
+
<td className="num">{formatValue('ms', f.beforeImpactMs)}</td>
|
|
388
|
+
<td className="num">{formatValue('ms', f.impactMs)}</td>
|
|
389
|
+
</tr>
|
|
390
|
+
))}
|
|
391
|
+
</tbody>
|
|
392
|
+
</table>
|
|
393
|
+
) : (
|
|
394
|
+
<p className="muted">None.</p>
|
|
395
|
+
)}
|
|
396
|
+
</section>
|
|
397
|
+
</>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const DETAIL_TABS = [
|
|
402
|
+
['components', 'React components'],
|
|
403
|
+
['files', 'Files'],
|
|
404
|
+
['functions', 'Functions'],
|
|
405
|
+
['findings', 'Problems'],
|
|
406
|
+
];
|
|
407
|
+
|
|
408
|
+
export default function CompareResults({ comparison, hideVendor }) {
|
|
409
|
+
const [detail, setDetail] = useState('components');
|
|
410
|
+
const badge = id => {
|
|
411
|
+
if (id === 'findings')
|
|
412
|
+
return (
|
|
413
|
+
comparison.findings.added.length + comparison.findings.resolved.length
|
|
414
|
+
);
|
|
415
|
+
const d = comparison[id];
|
|
416
|
+
return (
|
|
417
|
+
d.degradedTotal +
|
|
418
|
+
d.improvedTotal +
|
|
419
|
+
d.mixedTotal +
|
|
420
|
+
d.addedTotal +
|
|
421
|
+
d.removedTotal
|
|
422
|
+
);
|
|
423
|
+
};
|
|
424
|
+
return (
|
|
425
|
+
<div className="compare-results">
|
|
426
|
+
<Verdict comparison={comparison} />
|
|
427
|
+
{comparison.headlines.length > 0 && (
|
|
428
|
+
<section>
|
|
429
|
+
<h2>Highlights</h2>
|
|
430
|
+
<ul className="icons">
|
|
431
|
+
{comparison.headlines.map(h => (
|
|
432
|
+
<li key={h.text} className={VERDICT[h.kind].cls}>
|
|
433
|
+
<span className="i" aria-hidden="true">
|
|
434
|
+
{VERDICT[h.kind].icon}
|
|
435
|
+
</span>
|
|
436
|
+
{h.text}
|
|
437
|
+
</li>
|
|
438
|
+
))}
|
|
439
|
+
</ul>
|
|
440
|
+
</section>
|
|
441
|
+
)}
|
|
442
|
+
<section>
|
|
443
|
+
<h2>Can this comparison be trusted?</h2>
|
|
444
|
+
<Checks checks={comparison.checks} />
|
|
445
|
+
</section>
|
|
446
|
+
<MetricsTable metrics={comparison.metrics} />
|
|
447
|
+
<section>
|
|
448
|
+
<h2>What changed, in detail</h2>
|
|
449
|
+
<div role="tablist" className="tabs">
|
|
450
|
+
{DETAIL_TABS.map(([id, label]) => (
|
|
451
|
+
<button
|
|
452
|
+
key={id}
|
|
453
|
+
type="button"
|
|
454
|
+
role="tab"
|
|
455
|
+
aria-selected={detail === id}
|
|
456
|
+
onClick={() => setDetail(id)}
|
|
457
|
+
>
|
|
458
|
+
{label}
|
|
459
|
+
<span className="count">{badge(id)}</span>
|
|
460
|
+
</button>
|
|
461
|
+
))}
|
|
462
|
+
</div>
|
|
463
|
+
<div className="detail-pane">
|
|
464
|
+
{detail === 'findings' ? (
|
|
465
|
+
<Findings findings={comparison.findings} />
|
|
466
|
+
) : (
|
|
467
|
+
<Entities data={comparison[detail]} hideVendor={hideVendor} />
|
|
468
|
+
)}
|
|
469
|
+
</div>
|
|
470
|
+
</section>
|
|
471
|
+
</div>
|
|
472
|
+
);
|
|
473
|
+
}
|