@progen-labs/spectra 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +99 -2
- package/bin/spectra.js +215 -0
- package/dist/assets/index-BvBsPok5.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 +551 -0
- package/server/analysis/locate.js +99 -0
- package/server/analysis/report.js +750 -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 +689 -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 +118 -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 +200 -0
- package/src/components/SidePicker.jsx +207 -0
- package/src/components/SnapshotButton.jsx +140 -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,381 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { SEVERITY, SEVERITY_RANK, groupFindings } from '../culprits.js';
|
|
3
|
+
import { fmtMs, splitPath } from '../format.js';
|
|
4
|
+
import CodeRef from './CodeRef.jsx';
|
|
5
|
+
import { ChevronIcon } from './Icons.jsx';
|
|
6
|
+
|
|
7
|
+
const FILTERS = [
|
|
8
|
+
{ id: 'all', label: 'All' },
|
|
9
|
+
{ id: 'critical', label: 'Critical' },
|
|
10
|
+
{ id: 'warning', label: 'Warnings' },
|
|
11
|
+
{ id: 'info', label: 'Notes' },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function CulpritTable({ culprits, onOpenFile }) {
|
|
15
|
+
const maxMs = Math.max(1, ...culprits.map(c => c.ms || 0));
|
|
16
|
+
return (
|
|
17
|
+
<table className="data culprit-table">
|
|
18
|
+
<thead>
|
|
19
|
+
<tr>
|
|
20
|
+
<th>Function</th>
|
|
21
|
+
<th>Where</th>
|
|
22
|
+
<th className="num">Time</th>
|
|
23
|
+
</tr>
|
|
24
|
+
</thead>
|
|
25
|
+
<tbody>
|
|
26
|
+
{culprits.map(c => (
|
|
27
|
+
<tr key={`${c.fn}|${c.file}|${c.line}`}>
|
|
28
|
+
<td>
|
|
29
|
+
<code className="fn">{c.fn || '(unknown)'}()</code>
|
|
30
|
+
{c.note && <div className="muted small">{c.note}</div>}
|
|
31
|
+
</td>
|
|
32
|
+
<td>
|
|
33
|
+
<CodeRef
|
|
34
|
+
file={c.file}
|
|
35
|
+
line={c.line}
|
|
36
|
+
lineKind={c.lineKind}
|
|
37
|
+
abs={c.abs}
|
|
38
|
+
onOpenFile={onOpenFile}
|
|
39
|
+
/>
|
|
40
|
+
</td>
|
|
41
|
+
<td className="num">
|
|
42
|
+
{c.ms > 0 && (
|
|
43
|
+
<>
|
|
44
|
+
{fmtMs(c.ms)}
|
|
45
|
+
<div className="bar">
|
|
46
|
+
<span style={{ width: `${(c.ms / maxMs) * 100}%` }} />
|
|
47
|
+
</div>
|
|
48
|
+
</>
|
|
49
|
+
)}
|
|
50
|
+
</td>
|
|
51
|
+
</tr>
|
|
52
|
+
))}
|
|
53
|
+
</tbody>
|
|
54
|
+
</table>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function FixCallout({ fix }) {
|
|
59
|
+
if (!fix) return null;
|
|
60
|
+
return (
|
|
61
|
+
<div className="fix-callout">
|
|
62
|
+
<span className="fix-label">What to do</span>
|
|
63
|
+
<p>{fix}</p>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function FindingBody({ finding, onOpenFile }) {
|
|
69
|
+
return (
|
|
70
|
+
<div className="fbody">
|
|
71
|
+
<FixCallout fix={finding.fix} />
|
|
72
|
+
{finding.detail && (
|
|
73
|
+
<p className="fdetail">
|
|
74
|
+
<b>Why it matters</b>
|
|
75
|
+
{finding.detail}
|
|
76
|
+
</p>
|
|
77
|
+
)}
|
|
78
|
+
{finding.culprits?.length > 0 && (
|
|
79
|
+
<CulpritTable culprits={finding.culprits} onOpenFile={onOpenFile} />
|
|
80
|
+
)}
|
|
81
|
+
{!finding.culprits?.length && finding.file && (
|
|
82
|
+
<button type="button" onClick={() => onOpenFile(finding.file)}>
|
|
83
|
+
Open {finding.file}
|
|
84
|
+
</button>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function RowHead({
|
|
91
|
+
severity,
|
|
92
|
+
title,
|
|
93
|
+
chips,
|
|
94
|
+
impactMs,
|
|
95
|
+
maxImpact,
|
|
96
|
+
isOpen,
|
|
97
|
+
onToggle,
|
|
98
|
+
subtitle,
|
|
99
|
+
}) {
|
|
100
|
+
const sev = SEVERITY[severity];
|
|
101
|
+
return (
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
className="frow-head"
|
|
105
|
+
aria-expanded={isOpen}
|
|
106
|
+
onClick={onToggle}
|
|
107
|
+
>
|
|
108
|
+
<span className="sev-badge" aria-label={sev.label} title={sev.label}>
|
|
109
|
+
{sev.icon}
|
|
110
|
+
</span>
|
|
111
|
+
<span className="frow-main">
|
|
112
|
+
<span className="frow-title">{title}</span>
|
|
113
|
+
{subtitle && <span className="frow-sub muted">{subtitle}</span>}
|
|
114
|
+
</span>
|
|
115
|
+
<span className="frow-chips">
|
|
116
|
+
{chips.map(c => (
|
|
117
|
+
<span key={c} className="chip">
|
|
118
|
+
{c}
|
|
119
|
+
</span>
|
|
120
|
+
))}
|
|
121
|
+
</span>
|
|
122
|
+
<span className="impact">
|
|
123
|
+
{impactMs > 0 ? (
|
|
124
|
+
<>
|
|
125
|
+
<span className="impact-bar">
|
|
126
|
+
<span
|
|
127
|
+
style={{
|
|
128
|
+
width: `${Math.max(4, (impactMs / maxImpact) * 100)}%`,
|
|
129
|
+
}}
|
|
130
|
+
/>
|
|
131
|
+
</span>
|
|
132
|
+
<span className="impact-ms">{fmtMs(impactMs)}</span>
|
|
133
|
+
</>
|
|
134
|
+
) : (
|
|
135
|
+
<span className="muted">–</span>
|
|
136
|
+
)}
|
|
137
|
+
</span>
|
|
138
|
+
<ChevronIcon isUp={isOpen} />
|
|
139
|
+
</button>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function SingleRow({
|
|
144
|
+
finding,
|
|
145
|
+
maxImpact,
|
|
146
|
+
isOpen,
|
|
147
|
+
onToggle,
|
|
148
|
+
onOpenFile,
|
|
149
|
+
rowRef,
|
|
150
|
+
}) {
|
|
151
|
+
return (
|
|
152
|
+
<li
|
|
153
|
+
ref={rowRef}
|
|
154
|
+
className={`frow sev-${finding.severity} ${isOpen ? 'open' : ''}`}
|
|
155
|
+
>
|
|
156
|
+
<RowHead
|
|
157
|
+
severity={finding.severity}
|
|
158
|
+
title={finding.title}
|
|
159
|
+
subtitle={finding.file ? splitPath(finding.file).name : ''}
|
|
160
|
+
chips={[finding.category]}
|
|
161
|
+
impactMs={finding.impactMs}
|
|
162
|
+
maxImpact={maxImpact}
|
|
163
|
+
isOpen={isOpen}
|
|
164
|
+
onToggle={onToggle}
|
|
165
|
+
/>
|
|
166
|
+
{isOpen && <FindingBody finding={finding} onOpenFile={onOpenFile} />}
|
|
167
|
+
</li>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function GroupRow({
|
|
172
|
+
group,
|
|
173
|
+
maxImpact,
|
|
174
|
+
openIds,
|
|
175
|
+
onToggle,
|
|
176
|
+
onOpenFile,
|
|
177
|
+
rowRefs,
|
|
178
|
+
}) {
|
|
179
|
+
const isOpen = openIds.has(group.id);
|
|
180
|
+
const count = group.members.length;
|
|
181
|
+
const name = `${group.culprit.fn || '(unknown)'}()`;
|
|
182
|
+
return (
|
|
183
|
+
<li
|
|
184
|
+
ref={el => rowRefs.current.set(group.id, el)}
|
|
185
|
+
className={`frow group sev-${group.severity} ${isOpen ? 'open' : ''}`}
|
|
186
|
+
>
|
|
187
|
+
<RowHead
|
|
188
|
+
severity={group.severity}
|
|
189
|
+
title={`${name} is behind ${count} findings`}
|
|
190
|
+
subtitle={splitPath(group.culprit.file).name}
|
|
191
|
+
chips={[...new Set(group.members.map(m => m.category))]}
|
|
192
|
+
impactMs={group.impactMs}
|
|
193
|
+
maxImpact={maxImpact}
|
|
194
|
+
isOpen={isOpen}
|
|
195
|
+
onToggle={() => onToggle(group.id)}
|
|
196
|
+
/>
|
|
197
|
+
{isOpen && (
|
|
198
|
+
<div className="fbody">
|
|
199
|
+
<FixCallout fix={group.lead.fix} />
|
|
200
|
+
<CulpritTable culprits={[group.culprit]} onOpenFile={onOpenFile} />
|
|
201
|
+
<h4 className="members-title">Findings it causes</h4>
|
|
202
|
+
<ul className="members">
|
|
203
|
+
{group.members.map(m => (
|
|
204
|
+
<SingleRow
|
|
205
|
+
key={m.id}
|
|
206
|
+
finding={m}
|
|
207
|
+
maxImpact={group.impactMs}
|
|
208
|
+
isOpen={openIds.has(m.id)}
|
|
209
|
+
onToggle={() => onToggle(m.id)}
|
|
210
|
+
onOpenFile={onOpenFile}
|
|
211
|
+
rowRef={el => rowRefs.current.set(m.id, el)}
|
|
212
|
+
/>
|
|
213
|
+
))}
|
|
214
|
+
</ul>
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
</li>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Findings as a scannable list: root causes grouped, one line each, details
|
|
223
|
+
* (what to do first, then why, then the exact code) only when expanded.
|
|
224
|
+
*/
|
|
225
|
+
export default function FindingsList({ findings, onOpenFile, focus }) {
|
|
226
|
+
const [severity, setSeverity] = useState('all');
|
|
227
|
+
const [category, setCategory] = useState('all');
|
|
228
|
+
const items = useMemo(
|
|
229
|
+
() =>
|
|
230
|
+
groupFindings(
|
|
231
|
+
findings.filter(
|
|
232
|
+
f =>
|
|
233
|
+
(severity === 'all' || f.severity === severity) &&
|
|
234
|
+
(category === 'all' || f.category === category),
|
|
235
|
+
),
|
|
236
|
+
),
|
|
237
|
+
[findings, severity, category],
|
|
238
|
+
);
|
|
239
|
+
const [openIds, setOpenIds] = useState(() => {
|
|
240
|
+
const first = groupFindings(findings)[0];
|
|
241
|
+
return new Set(first ? [first.id] : []);
|
|
242
|
+
});
|
|
243
|
+
const rowRefs = useRef(new Map());
|
|
244
|
+
const pendingScroll = useRef(null);
|
|
245
|
+
|
|
246
|
+
const categories = useMemo(
|
|
247
|
+
() => [...new Set(findings.map(f => f.category))].sort(),
|
|
248
|
+
[findings],
|
|
249
|
+
);
|
|
250
|
+
const counts = useMemo(
|
|
251
|
+
() => ({
|
|
252
|
+
all: findings.length,
|
|
253
|
+
...Object.fromEntries(
|
|
254
|
+
Object.keys(SEVERITY_RANK).map(s => [
|
|
255
|
+
s,
|
|
256
|
+
findings.filter(f => f.severity === s).length,
|
|
257
|
+
]),
|
|
258
|
+
),
|
|
259
|
+
}),
|
|
260
|
+
[findings],
|
|
261
|
+
);
|
|
262
|
+
const maxImpact = Math.max(1, ...items.map(i => i.impactMs));
|
|
263
|
+
|
|
264
|
+
const toggle = id =>
|
|
265
|
+
setOpenIds(prev => {
|
|
266
|
+
const next = new Set(prev);
|
|
267
|
+
if (!next.delete(id)) next.add(id);
|
|
268
|
+
return next;
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
if (!focus) return;
|
|
273
|
+
const target = groupFindings(findings).find(
|
|
274
|
+
i => i.id === focus.id || i.members?.some(m => m.id === focus.id),
|
|
275
|
+
);
|
|
276
|
+
if (!target) return;
|
|
277
|
+
setSeverity('all');
|
|
278
|
+
setCategory('all');
|
|
279
|
+
setOpenIds(prev => new Set([...prev, target.id, focus.id]));
|
|
280
|
+
pendingScroll.current = focus.id;
|
|
281
|
+
// Only a new jump request should re-run this.
|
|
282
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
283
|
+
}, [focus?.tick]);
|
|
284
|
+
|
|
285
|
+
useEffect(() => {
|
|
286
|
+
const id = pendingScroll.current;
|
|
287
|
+
if (!id) return;
|
|
288
|
+
const el = rowRefs.current.get(id);
|
|
289
|
+
if (el) {
|
|
290
|
+
pendingScroll.current = null;
|
|
291
|
+
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<div className="findings-list">
|
|
297
|
+
<div className="findings-toolbar">
|
|
298
|
+
<div className="segmented" role="group" aria-label="Filter by severity">
|
|
299
|
+
{FILTERS.map(f => (
|
|
300
|
+
<button
|
|
301
|
+
key={f.id}
|
|
302
|
+
type="button"
|
|
303
|
+
aria-pressed={severity === f.id}
|
|
304
|
+
onClick={() => setSeverity(f.id)}
|
|
305
|
+
>
|
|
306
|
+
{f.label} <span className="muted">{counts[f.id]}</span>
|
|
307
|
+
</button>
|
|
308
|
+
))}
|
|
309
|
+
</div>
|
|
310
|
+
{categories.length > 1 && (
|
|
311
|
+
<select
|
|
312
|
+
aria-label="Filter by category"
|
|
313
|
+
value={category}
|
|
314
|
+
onChange={e => setCategory(e.target.value)}
|
|
315
|
+
>
|
|
316
|
+
<option value="all">All categories</option>
|
|
317
|
+
{categories.map(c => (
|
|
318
|
+
<option key={c} value={c}>
|
|
319
|
+
{c}
|
|
320
|
+
</option>
|
|
321
|
+
))}
|
|
322
|
+
</select>
|
|
323
|
+
)}
|
|
324
|
+
<span className="spacer" />
|
|
325
|
+
<button
|
|
326
|
+
type="button"
|
|
327
|
+
className="ghost"
|
|
328
|
+
onClick={() =>
|
|
329
|
+
setOpenIds(
|
|
330
|
+
new Set(
|
|
331
|
+
items.flatMap(i =>
|
|
332
|
+
i.kind === 'group'
|
|
333
|
+
? [i.id, ...i.members.map(m => m.id)]
|
|
334
|
+
: [i.id],
|
|
335
|
+
),
|
|
336
|
+
),
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
>
|
|
340
|
+
Expand all
|
|
341
|
+
</button>
|
|
342
|
+
<button
|
|
343
|
+
type="button"
|
|
344
|
+
className="ghost"
|
|
345
|
+
onClick={() => setOpenIds(new Set())}
|
|
346
|
+
>
|
|
347
|
+
Collapse all
|
|
348
|
+
</button>
|
|
349
|
+
</div>
|
|
350
|
+
{items.length === 0 ? (
|
|
351
|
+
<p className="muted pad">Nothing matches these filters.</p>
|
|
352
|
+
) : (
|
|
353
|
+
<ul className="frows">
|
|
354
|
+
{items.map(item =>
|
|
355
|
+
item.kind === 'group' ? (
|
|
356
|
+
<GroupRow
|
|
357
|
+
key={item.id}
|
|
358
|
+
group={item}
|
|
359
|
+
maxImpact={maxImpact}
|
|
360
|
+
openIds={openIds}
|
|
361
|
+
onToggle={toggle}
|
|
362
|
+
onOpenFile={onOpenFile}
|
|
363
|
+
rowRefs={rowRefs}
|
|
364
|
+
/>
|
|
365
|
+
) : (
|
|
366
|
+
<SingleRow
|
|
367
|
+
key={item.id}
|
|
368
|
+
finding={item.finding}
|
|
369
|
+
maxImpact={maxImpact}
|
|
370
|
+
isOpen={openIds.has(item.id)}
|
|
371
|
+
onToggle={() => toggle(item.id)}
|
|
372
|
+
onOpenFile={onOpenFile}
|
|
373
|
+
rowRef={el => rowRefs.current.set(item.id, el)}
|
|
374
|
+
/>
|
|
375
|
+
),
|
|
376
|
+
)}
|
|
377
|
+
</ul>
|
|
378
|
+
)}
|
|
379
|
+
</div>
|
|
380
|
+
);
|
|
381
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
FILE_METRICS,
|
|
4
|
+
ancestorIds,
|
|
5
|
+
buildFileTree,
|
|
6
|
+
decorateCallNode,
|
|
7
|
+
decorateFileNode,
|
|
8
|
+
defaultCallExpanded,
|
|
9
|
+
defaultExpanded,
|
|
10
|
+
formatMetric,
|
|
11
|
+
} from '../heat.js';
|
|
12
|
+
import { fmtMs } from '../format.js';
|
|
13
|
+
import { layoutTree } from '../layout.js';
|
|
14
|
+
import TreeView from './TreeView.jsx';
|
|
15
|
+
|
|
16
|
+
function Legend({ extra }) {
|
|
17
|
+
return (
|
|
18
|
+
<div className="legend">
|
|
19
|
+
<span className="ramp" aria-hidden="true" /> low → high heat (share of the
|
|
20
|
+
total)
|
|
21
|
+
<span>
|
|
22
|
+
<i className="ring" /> library code (node_modules)
|
|
23
|
+
</span>
|
|
24
|
+
{extra}
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Directory tree of profiled files, heat-coloured by the chosen metric. */
|
|
30
|
+
export function FileHeatTree({
|
|
31
|
+
report,
|
|
32
|
+
hideVendor,
|
|
33
|
+
selectedPath,
|
|
34
|
+
revealTick,
|
|
35
|
+
onSelectFile,
|
|
36
|
+
}) {
|
|
37
|
+
const [metricId, setMetricId] = useState('selfMs');
|
|
38
|
+
const [expanded, setExpanded] = useState(null);
|
|
39
|
+
const [centerTick, setCenterTick] = useState(0);
|
|
40
|
+
const metric = FILE_METRICS.find(m => m.id === metricId);
|
|
41
|
+
|
|
42
|
+
const tree = useMemo(
|
|
43
|
+
() =>
|
|
44
|
+
buildFileTree(report.files, metric, {
|
|
45
|
+
hideVendor,
|
|
46
|
+
rootName: report?.meta?.title || report?.meta?.url || 'target-frontend',
|
|
47
|
+
}),
|
|
48
|
+
[report, metric, hideVendor],
|
|
49
|
+
);
|
|
50
|
+
// A new tree (report, metric, filter) restarts from its hottest paths.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
setExpanded(null);
|
|
53
|
+
setCenterTick(t => t + 1);
|
|
54
|
+
}, [tree]);
|
|
55
|
+
const expandedSet = useMemo(
|
|
56
|
+
() => expanded || defaultExpanded(tree),
|
|
57
|
+
[expanded, tree],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// Reveal a file chosen elsewhere (findings, React table, search).
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!selectedPath || !tree.byId.has(selectedPath)) return;
|
|
63
|
+
setExpanded(
|
|
64
|
+
prev =>
|
|
65
|
+
new Set([
|
|
66
|
+
...(prev || defaultExpanded(tree)),
|
|
67
|
+
...ancestorIds(tree, selectedPath),
|
|
68
|
+
]),
|
|
69
|
+
);
|
|
70
|
+
setCenterTick(t => t + 1);
|
|
71
|
+
}, [revealTick, selectedPath, tree]);
|
|
72
|
+
|
|
73
|
+
const layout = useMemo(
|
|
74
|
+
() =>
|
|
75
|
+
layoutTree(
|
|
76
|
+
tree.root,
|
|
77
|
+
n => (n.isDir && expandedSet.has(n.id) ? n.children : null),
|
|
78
|
+
n => `${n.name}${n.isDir ? '/' : ''} ${formatMetric(metric, n.value)}`,
|
|
79
|
+
n => decorateFileNode(n, tree, metric),
|
|
80
|
+
),
|
|
81
|
+
[tree, expandedSet, metric],
|
|
82
|
+
);
|
|
83
|
+
const toggle = useCallback(
|
|
84
|
+
node => {
|
|
85
|
+
setExpanded(prev => {
|
|
86
|
+
const next = new Set(prev || expandedSet);
|
|
87
|
+
if (next.has(node.data.id)) next.delete(node.data.id);
|
|
88
|
+
else next.add(node.data.id);
|
|
89
|
+
return next;
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
[expandedSet],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const activate = useCallback(
|
|
96
|
+
node => {
|
|
97
|
+
if (node.data.isDir) toggle(node);
|
|
98
|
+
else onSelectFile(node.data.id);
|
|
99
|
+
},
|
|
100
|
+
[toggle, onSelectFile],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div className="tree-pane">
|
|
105
|
+
<div className="tree-toolbar">
|
|
106
|
+
<label>
|
|
107
|
+
Colour and size by
|
|
108
|
+
<select value={metricId} onChange={e => setMetricId(e.target.value)}>
|
|
109
|
+
{FILE_METRICS.map(m => (
|
|
110
|
+
<option key={m.id} value={m.id}>
|
|
111
|
+
{m.label}
|
|
112
|
+
</option>
|
|
113
|
+
))}
|
|
114
|
+
</select>
|
|
115
|
+
</label>
|
|
116
|
+
<span className="muted">{metric.hint}</span>
|
|
117
|
+
<span className="spacer" />
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
onClick={() => setExpanded(new Set([tree.root.id]))}
|
|
121
|
+
>
|
|
122
|
+
Collapse all
|
|
123
|
+
</button>
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
onClick={() => setExpanded(new Set(tree.byId.keys()))}
|
|
127
|
+
>
|
|
128
|
+
Expand all
|
|
129
|
+
</button>
|
|
130
|
+
</div>
|
|
131
|
+
{tree.total === 0 ? (
|
|
132
|
+
<div className="empty">
|
|
133
|
+
No files have any “{metric.label}” in this recording.
|
|
134
|
+
</div>
|
|
135
|
+
) : (
|
|
136
|
+
<TreeView
|
|
137
|
+
layout={layout}
|
|
138
|
+
selectedKey={selectedPath}
|
|
139
|
+
centerKey={selectedPath}
|
|
140
|
+
centerTick={centerTick}
|
|
141
|
+
onActivate={activate}
|
|
142
|
+
onToggle={toggle}
|
|
143
|
+
/>
|
|
144
|
+
)}
|
|
145
|
+
<Legend />
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Top-down call tree: hot paths are expanded, colour = self time. */
|
|
151
|
+
export function CallHeatTree({ report, onSelectFile }) {
|
|
152
|
+
const root = report.callTree;
|
|
153
|
+
const [expanded, setExpanded] = useState(() =>
|
|
154
|
+
root ? defaultCallExpanded(root) : new Set(),
|
|
155
|
+
);
|
|
156
|
+
const [centerTick, setCenterTick] = useState(0);
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
setExpanded(root ? defaultCallExpanded(root) : new Set());
|
|
159
|
+
setCenterTick(t => t + 1);
|
|
160
|
+
}, [root]);
|
|
161
|
+
|
|
162
|
+
const layout = useMemo(
|
|
163
|
+
() =>
|
|
164
|
+
root &&
|
|
165
|
+
layoutTree(
|
|
166
|
+
root,
|
|
167
|
+
n => (expanded.has(n.id) || n === root ? n.children : null),
|
|
168
|
+
n => `${n.name} ${fmtMs(n.totalMs)} · self ${fmtMs(n.selfMs)}`,
|
|
169
|
+
n => decorateCallNode(n, root.totalMs),
|
|
170
|
+
),
|
|
171
|
+
[root, expanded],
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
// Open the view on the function with the most self time among the visible nodes.
|
|
175
|
+
const hottestKey = useMemo(() => {
|
|
176
|
+
if (!layout) return undefined;
|
|
177
|
+
return layout.nodes.reduce(
|
|
178
|
+
(best, n) => (n.data.selfMs > (best?.data.selfMs ?? -1) ? n : best),
|
|
179
|
+
null,
|
|
180
|
+
)?.data.id;
|
|
181
|
+
}, [layout]);
|
|
182
|
+
|
|
183
|
+
const toggle = useCallback(node => {
|
|
184
|
+
setExpanded(prev => {
|
|
185
|
+
const next = new Set(prev);
|
|
186
|
+
if (next.has(node.data.id)) next.delete(node.data.id);
|
|
187
|
+
else next.add(node.data.id);
|
|
188
|
+
return next;
|
|
189
|
+
});
|
|
190
|
+
}, []);
|
|
191
|
+
const activate = useCallback(
|
|
192
|
+
node => {
|
|
193
|
+
const { file } = node.data;
|
|
194
|
+
if (file && !file.startsWith('(')) onSelectFile(file);
|
|
195
|
+
else if (node.view.expandable) toggle(node);
|
|
196
|
+
},
|
|
197
|
+
[toggle, onSelectFile],
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
if (!layout)
|
|
201
|
+
return <div className="empty">No CPU samples were captured.</div>;
|
|
202
|
+
return (
|
|
203
|
+
<div className="tree-pane">
|
|
204
|
+
<div className="tree-toolbar">
|
|
205
|
+
<span className="muted">
|
|
206
|
+
Each node is a function call path: total time, then self time. Click a
|
|
207
|
+
circle to expand/collapse; click a label to open its file.
|
|
208
|
+
</span>
|
|
209
|
+
</div>
|
|
210
|
+
<TreeView
|
|
211
|
+
layout={layout}
|
|
212
|
+
selectedKey={null}
|
|
213
|
+
centerKey={hottestKey}
|
|
214
|
+
centerTick={centerTick}
|
|
215
|
+
anchor={0.55}
|
|
216
|
+
onActivate={activate}
|
|
217
|
+
onToggle={toggle}
|
|
218
|
+
/>
|
|
219
|
+
<Legend extra={<span>colour = self time · size = total time</span>} />
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const Svg = ({ children, size = 14 }) => (
|
|
2
|
+
<svg
|
|
3
|
+
className="icon"
|
|
4
|
+
viewBox="0 0 16 16"
|
|
5
|
+
width={size}
|
|
6
|
+
height={size}
|
|
7
|
+
aria-hidden="true"
|
|
8
|
+
fill="none"
|
|
9
|
+
stroke="currentColor"
|
|
10
|
+
strokeWidth="1.6"
|
|
11
|
+
strokeLinecap="round"
|
|
12
|
+
strokeLinejoin="round"
|
|
13
|
+
>
|
|
14
|
+
{children}
|
|
15
|
+
</svg>
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
export const ChevronIcon = ({ isUp = false }) => (
|
|
19
|
+
<Svg size={12}>
|
|
20
|
+
<path d={isUp ? 'M3.5 10 8 5.5l4.5 4.5' : 'M3.5 6 8 10.5 12.5 6'} />
|
|
21
|
+
</Svg>
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
export const RecordIcon = () => (
|
|
25
|
+
<svg
|
|
26
|
+
className="icon"
|
|
27
|
+
viewBox="0 0 16 16"
|
|
28
|
+
width="12"
|
|
29
|
+
height="12"
|
|
30
|
+
aria-hidden="true"
|
|
31
|
+
>
|
|
32
|
+
<circle cx="8" cy="8" r="5.5" fill="currentColor" />
|
|
33
|
+
</svg>
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export const StopIcon = () => (
|
|
37
|
+
<svg
|
|
38
|
+
className="icon"
|
|
39
|
+
viewBox="0 0 16 16"
|
|
40
|
+
width="12"
|
|
41
|
+
height="12"
|
|
42
|
+
aria-hidden="true"
|
|
43
|
+
>
|
|
44
|
+
<rect x="3" y="3" width="10" height="10" rx="2" fill="currentColor" />
|
|
45
|
+
</svg>
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
export const PageLoadIcon = () => (
|
|
49
|
+
<Svg>
|
|
50
|
+
<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2.5v3h-3" />
|
|
51
|
+
</Svg>
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
export const DownloadIcon = () => (
|
|
55
|
+
<Svg>
|
|
56
|
+
<path d="M8 2.5v8M4.5 7.5 8 11l3.5-3.5M3 13.5h10" />
|
|
57
|
+
</Svg>
|
|
58
|
+
);
|