@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,76 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { api } from '../api.js';
|
|
3
|
+
import { fmtBytes } from '../format.js';
|
|
4
|
+
import { DownloadIcon } from './Icons.jsx';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Writes the recording's raw trace to reports/<id>.trace.json so it can be opened
|
|
8
|
+
* in Chrome DevTools → Performance → Load profile. (The report JSON itself is this
|
|
9
|
+
* tool's own format and cannot be loaded there.)
|
|
10
|
+
*/
|
|
11
|
+
export default function TraceButton({ reportId }) {
|
|
12
|
+
const [state, setState] = useState({ status: 'idle' });
|
|
13
|
+
|
|
14
|
+
const save = async () => {
|
|
15
|
+
setState({ status: 'working' });
|
|
16
|
+
try {
|
|
17
|
+
const result = await api.saveTrace(reportId);
|
|
18
|
+
setState({ status: 'done', ...result });
|
|
19
|
+
} catch (err) {
|
|
20
|
+
setState({ status: 'error', message: err.message });
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<span className="trace-button">
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
onClick={save}
|
|
29
|
+
disabled={state.status === 'working'}
|
|
30
|
+
>
|
|
31
|
+
{state.status === 'working' ? (
|
|
32
|
+
'Preparing…'
|
|
33
|
+
) : (
|
|
34
|
+
<>
|
|
35
|
+
<DownloadIcon /> Trace for Chrome DevTools
|
|
36
|
+
</>
|
|
37
|
+
)}
|
|
38
|
+
</button>
|
|
39
|
+
{state.status !== 'idle' && state.status !== 'working' && (
|
|
40
|
+
<div className={`trace-note ${state.status}`} role="status">
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
className="close"
|
|
44
|
+
aria-label="Dismiss"
|
|
45
|
+
onClick={() => setState({ status: 'idle' })}
|
|
46
|
+
>
|
|
47
|
+
×
|
|
48
|
+
</button>
|
|
49
|
+
{state.status === 'error' ? (
|
|
50
|
+
state.message
|
|
51
|
+
) : (
|
|
52
|
+
<>
|
|
53
|
+
<b>Saved</b> <code>{state.path}</code> ({fmtBytes(state.size)}).
|
|
54
|
+
<ol>
|
|
55
|
+
<li>
|
|
56
|
+
In Chrome open DevTools (<kbd>F12</kbd>) → <b>Performance</b>.
|
|
57
|
+
</li>
|
|
58
|
+
<li>
|
|
59
|
+
Click <b>Load profile…</b> (the ↑ icon) and choose that file —
|
|
60
|
+
or{' '}
|
|
61
|
+
<a
|
|
62
|
+
href={`/api/reports/${encodeURIComponent(reportId)}/trace`}
|
|
63
|
+
download
|
|
64
|
+
>
|
|
65
|
+
download it
|
|
66
|
+
</a>{' '}
|
|
67
|
+
first.
|
|
68
|
+
</li>
|
|
69
|
+
</ol>
|
|
70
|
+
</>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
</span>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { select, zoom, zoomIdentity } from 'd3';
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { ROW_HEIGHT } from '../layout.js';
|
|
4
|
+
|
|
5
|
+
const CULL_MARGIN = 200;
|
|
6
|
+
|
|
7
|
+
function linkPath(link) {
|
|
8
|
+
const sx = link.source.px + link.source.labelWidth - 10;
|
|
9
|
+
const sy = link.source.py;
|
|
10
|
+
const tx = link.target.px;
|
|
11
|
+
const ty = link.target.py;
|
|
12
|
+
const mid = (sx + tx) / 2;
|
|
13
|
+
return `M${sx},${sy}C${mid},${sy} ${mid},${ty} ${tx},${ty}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function TreeNode({ node, isSelected, onActivate, onToggle }) {
|
|
17
|
+
const view = node.view;
|
|
18
|
+
const hasChildren = view.expandable;
|
|
19
|
+
const heat = Math.max(0, Math.min(1, view.heat || 0));
|
|
20
|
+
const cls = [
|
|
21
|
+
'node',
|
|
22
|
+
view.isDir ? 'dir' : 'file',
|
|
23
|
+
view.vendor ? 'vendor' : '',
|
|
24
|
+
isSelected ? 'selected' : '',
|
|
25
|
+
]
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.join(' ');
|
|
28
|
+
const r = view.r || 5;
|
|
29
|
+
return (
|
|
30
|
+
<g className={cls} transform={`translate(${node.px},${node.py})`}>
|
|
31
|
+
<title>{view.tooltip || node.label}</title>
|
|
32
|
+
<rect
|
|
33
|
+
className="node-hit"
|
|
34
|
+
x={-r}
|
|
35
|
+
y={-ROW_HEIGHT / 2 + 1}
|
|
36
|
+
width={node.labelWidth}
|
|
37
|
+
height={ROW_HEIGHT - 2}
|
|
38
|
+
rx={4}
|
|
39
|
+
/>
|
|
40
|
+
<circle
|
|
41
|
+
className="node-dot"
|
|
42
|
+
r={r}
|
|
43
|
+
style={{ '--heat-pct': `${Math.round(12 + heat * 88)}%` }}
|
|
44
|
+
onClick={() => (hasChildren ? onToggle(node) : onActivate(node))}
|
|
45
|
+
/>
|
|
46
|
+
{hasChildren && (
|
|
47
|
+
<text className="node-glyph" y={3.5} textAnchor="middle">
|
|
48
|
+
{node.children ? '−' : '+'}
|
|
49
|
+
</text>
|
|
50
|
+
)}
|
|
51
|
+
<text
|
|
52
|
+
className="node-label"
|
|
53
|
+
x={r + 6}
|
|
54
|
+
y={4}
|
|
55
|
+
role="button"
|
|
56
|
+
tabIndex={0}
|
|
57
|
+
onClick={() => onActivate(node)}
|
|
58
|
+
onKeyDown={e => {
|
|
59
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
60
|
+
e.preventDefault();
|
|
61
|
+
onActivate(node);
|
|
62
|
+
}
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
{node.label}
|
|
66
|
+
</text>
|
|
67
|
+
</g>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Pan/zoomable node-link tree. Wheel scrolls, ctrl/cmd + wheel zooms. */
|
|
72
|
+
export default function TreeView({
|
|
73
|
+
layout,
|
|
74
|
+
selectedKey,
|
|
75
|
+
centerKey,
|
|
76
|
+
centerTick,
|
|
77
|
+
anchor = 0.12,
|
|
78
|
+
onActivate,
|
|
79
|
+
onToggle,
|
|
80
|
+
}) {
|
|
81
|
+
const svgRef = useRef(null);
|
|
82
|
+
const zoomRef = useRef(null);
|
|
83
|
+
const sizeRef = useRef({ width: 800, height: 600 });
|
|
84
|
+
const [size, setSize] = useState(sizeRef.current);
|
|
85
|
+
const [transform, setTransform] = useState(zoomIdentity);
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const svg = svgRef.current;
|
|
89
|
+
const behavior = zoom()
|
|
90
|
+
.scaleExtent([0.25, 2.5])
|
|
91
|
+
.filter(e => (e.type === 'wheel' ? e.ctrlKey || e.metaKey : !e.button))
|
|
92
|
+
.on('zoom', e => setTransform(e.transform));
|
|
93
|
+
zoomRef.current = behavior;
|
|
94
|
+
select(svg).call(behavior).on('dblclick.zoom', null);
|
|
95
|
+
const onWheel = e => {
|
|
96
|
+
if (e.ctrlKey || e.metaKey) return;
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
select(svg).call(behavior.translateBy, -e.deltaX, -e.deltaY);
|
|
99
|
+
};
|
|
100
|
+
svg.addEventListener('wheel', onWheel, { passive: false });
|
|
101
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
102
|
+
sizeRef.current = {
|
|
103
|
+
width: entry.contentRect.width,
|
|
104
|
+
height: entry.contentRect.height,
|
|
105
|
+
};
|
|
106
|
+
setSize(sizeRef.current);
|
|
107
|
+
});
|
|
108
|
+
observer.observe(svg);
|
|
109
|
+
return () => {
|
|
110
|
+
svg.removeEventListener('wheel', onWheel);
|
|
111
|
+
observer.disconnect();
|
|
112
|
+
select(svg).on('.zoom', null);
|
|
113
|
+
};
|
|
114
|
+
}, []);
|
|
115
|
+
|
|
116
|
+
// Re-centre on an explicit request (new report, search, list click) only.
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
const node = layout.byKey.get(centerKey) || layout.root;
|
|
119
|
+
const { width, height } = sizeRef.current;
|
|
120
|
+
const target = zoomIdentity.translate(
|
|
121
|
+
Math.max(60, width * anchor) - node.px,
|
|
122
|
+
height / 2 - node.py,
|
|
123
|
+
);
|
|
124
|
+
select(svgRef.current)
|
|
125
|
+
.transition()
|
|
126
|
+
.duration(centerTick > 0 ? 300 : 0)
|
|
127
|
+
.call(zoomRef.current.transform, target);
|
|
128
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
129
|
+
}, [centerTick]);
|
|
130
|
+
|
|
131
|
+
const visible = useMemo(() => {
|
|
132
|
+
const top = (-transform.y - CULL_MARGIN) / transform.k;
|
|
133
|
+
const bottom = (size.height - transform.y + CULL_MARGIN) / transform.k;
|
|
134
|
+
const inView = n => n.py >= top && n.py <= bottom;
|
|
135
|
+
return {
|
|
136
|
+
nodes: layout.nodes.filter(inView),
|
|
137
|
+
links: layout.links.filter(l => inView(l.source) || inView(l.target)),
|
|
138
|
+
};
|
|
139
|
+
}, [layout, transform, size.height]);
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<svg
|
|
143
|
+
ref={svgRef}
|
|
144
|
+
className="tree-svg"
|
|
145
|
+
role="tree"
|
|
146
|
+
aria-label="Performance heat tree"
|
|
147
|
+
>
|
|
148
|
+
<g
|
|
149
|
+
transform={`translate(${transform.x},${transform.y}) scale(${transform.k})`}
|
|
150
|
+
>
|
|
151
|
+
<g className="tree-links">
|
|
152
|
+
{visible.links.map(l => (
|
|
153
|
+
<path
|
|
154
|
+
key={`${l.source.data.id}>${l.target.data.id}`}
|
|
155
|
+
d={linkPath(l)}
|
|
156
|
+
/>
|
|
157
|
+
))}
|
|
158
|
+
</g>
|
|
159
|
+
<g>
|
|
160
|
+
{visible.nodes.map(n => (
|
|
161
|
+
<TreeNode
|
|
162
|
+
key={n.data.id}
|
|
163
|
+
node={n}
|
|
164
|
+
isSelected={n.data.id === selectedKey}
|
|
165
|
+
onActivate={onActivate}
|
|
166
|
+
onToggle={onToggle}
|
|
167
|
+
/>
|
|
168
|
+
))}
|
|
169
|
+
</g>
|
|
170
|
+
</g>
|
|
171
|
+
</svg>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { fmtMs, fmtNumber } from '../format.js';
|
|
2
|
+
|
|
3
|
+
// Web-vitals style thresholds: [good, poor]. `hero` ones get a full card.
|
|
4
|
+
const RATINGS = [
|
|
5
|
+
{
|
|
6
|
+
key: 'tbt',
|
|
7
|
+
label: 'Total blocking time',
|
|
8
|
+
get: r => r.vitals.tbtMs,
|
|
9
|
+
limits: [200, 600],
|
|
10
|
+
fmt: fmtMs,
|
|
11
|
+
hero: true,
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
key: 'long',
|
|
15
|
+
label: 'Longest task',
|
|
16
|
+
get: r => r.vitals.maxLongTaskMs,
|
|
17
|
+
limits: [50, 200],
|
|
18
|
+
fmt: fmtMs,
|
|
19
|
+
hero: true,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
key: 'inp',
|
|
23
|
+
label: 'Slowest interaction',
|
|
24
|
+
get: r => r.vitals.inpMs,
|
|
25
|
+
limits: [200, 500],
|
|
26
|
+
fmt: fmtMs,
|
|
27
|
+
hero: true,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
key: 'wasted',
|
|
31
|
+
label: 'Wasted React renders',
|
|
32
|
+
get: r =>
|
|
33
|
+
r.react.detected
|
|
34
|
+
? (r.react && r.react.components ? r.react.components.reduce((s, c) => s + c.wasted, 0) : 0)
|
|
35
|
+
: null,
|
|
36
|
+
limits: [20, 200],
|
|
37
|
+
fmt: fmtNumber,
|
|
38
|
+
hero: true,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
key: 'lcp',
|
|
42
|
+
label: 'Largest paint',
|
|
43
|
+
get: r => r.vitals.lcpMs,
|
|
44
|
+
limits: [2500, 4000],
|
|
45
|
+
fmt: fmtMs,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
key: 'fcp',
|
|
49
|
+
label: 'First paint',
|
|
50
|
+
get: r => r.vitals.fcpMs,
|
|
51
|
+
limits: [1800, 3000],
|
|
52
|
+
fmt: fmtMs,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
key: 'cls',
|
|
56
|
+
label: 'Layout shift',
|
|
57
|
+
get: r => r.vitals.cls,
|
|
58
|
+
limits: [0.1, 0.25],
|
|
59
|
+
fmt: v => v.toFixed(3),
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
key: 'dom',
|
|
63
|
+
label: 'DOM nodes',
|
|
64
|
+
get: r => r.memory.domNodes,
|
|
65
|
+
limits: [1500, 3000],
|
|
66
|
+
fmt: fmtNumber,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
key: 'heap',
|
|
70
|
+
label: 'Heap growth',
|
|
71
|
+
get: r => r.memory.heapGrowthMb,
|
|
72
|
+
limits: [30, 100],
|
|
73
|
+
fmt: v => `${v.toFixed(1)} MB`,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
key: 'forced',
|
|
77
|
+
label: 'Forced reflows',
|
|
78
|
+
get: r => r.totals.forcedLayoutCount + r.totals.forcedStyleCount,
|
|
79
|
+
limits: [10, 100],
|
|
80
|
+
fmt: fmtNumber,
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const LABELS = { good: 'Good', warn: 'Needs work', poor: 'Poor', none: 'n/a' };
|
|
85
|
+
const ICONS = { good: '✓', warn: '!', poor: '✕', none: '–' };
|
|
86
|
+
const GAUGE_HEADROOM = 1.5;
|
|
87
|
+
|
|
88
|
+
function rate(value, [good, poor]) {
|
|
89
|
+
if (value == null) return 'none';
|
|
90
|
+
if (value <= good) return 'good';
|
|
91
|
+
return value >= poor ? 'poor' : 'warn';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function Gauge({ value, limits }) {
|
|
95
|
+
const max = limits[1] * GAUGE_HEADROOM;
|
|
96
|
+
const pct = n => `${Math.min(100, (n / max) * 100)}%`;
|
|
97
|
+
return (
|
|
98
|
+
<div
|
|
99
|
+
className="gauge"
|
|
100
|
+
aria-hidden="true"
|
|
101
|
+
style={{ '--g': pct(limits[0]), '--p': pct(limits[1]) }}
|
|
102
|
+
>
|
|
103
|
+
<span className="gauge-needle" style={{ left: pct(value) }} />
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function HeroCard({ vital, value, rating }) {
|
|
109
|
+
const [good, poor] = vital.limits;
|
|
110
|
+
return (
|
|
111
|
+
<div className={`vital-hero rating-${rating}`}>
|
|
112
|
+
<div className="vital-label">{vital.label}</div>
|
|
113
|
+
<div className="vital-value">
|
|
114
|
+
{value == null ? '–' : vital.fmt(value)}
|
|
115
|
+
</div>
|
|
116
|
+
<div className="vital-rating">
|
|
117
|
+
<span aria-hidden="true">{ICONS[rating]}</span> {LABELS[rating]}
|
|
118
|
+
</div>
|
|
119
|
+
{value != null && <Gauge value={value} limits={vital.limits} />}
|
|
120
|
+
<div className="vital-target muted">
|
|
121
|
+
Good ≤ {vital.fmt(good)} · Poor ≥ {vital.fmt(poor)}
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function Chip({ vital, value, rating }) {
|
|
128
|
+
return (
|
|
129
|
+
<div className={`vital-chip rating-${rating}`}>
|
|
130
|
+
<span className="vital-chip-icon" aria-hidden="true">
|
|
131
|
+
{ICONS[rating]}
|
|
132
|
+
</span>
|
|
133
|
+
<span className="vital-chip-label">{vital.label}</span>
|
|
134
|
+
<b>{value == null ? '–' : vital.fmt(value)}</b>
|
|
135
|
+
<span className="visually-hidden">{LABELS[rating]}</span>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export default function Vitals({ report }) {
|
|
141
|
+
const rows = RATINGS.map(v => {
|
|
142
|
+
const value = v.get(report);
|
|
143
|
+
return { vital: v, value, rating: rate(value, v.limits) };
|
|
144
|
+
});
|
|
145
|
+
return (
|
|
146
|
+
<div className="vitals-block">
|
|
147
|
+
<div className="vitals-hero">
|
|
148
|
+
{rows
|
|
149
|
+
.filter(r => r.vital.hero)
|
|
150
|
+
.map(r => (
|
|
151
|
+
<HeroCard key={r.vital.key} {...r} />
|
|
152
|
+
))}
|
|
153
|
+
</div>
|
|
154
|
+
<div className="vitals-secondary">
|
|
155
|
+
{rows
|
|
156
|
+
.filter(r => !r.vital.hero)
|
|
157
|
+
.map(r => (
|
|
158
|
+
<Chip key={r.vital.key} {...r} />
|
|
159
|
+
))}
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
package/src/culprits.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
export const SEVERITY_RANK = { critical: 3, warning: 2, info: 1 };
|
|
2
|
+
|
|
3
|
+
export const SEVERITY = {
|
|
4
|
+
critical: { label: 'Critical', icon: '✕' },
|
|
5
|
+
warning: { label: 'Warning', icon: '!' },
|
|
6
|
+
info: { label: 'Note', icon: 'i' },
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const isVendorFile = file => file.includes('node_modules/');
|
|
10
|
+
const higher = (a, b) => (SEVERITY_RANK[b] > SEVERITY_RANK[a] ? b : a);
|
|
11
|
+
|
|
12
|
+
export const culpritKey = c => `${c.fn || c.name}|${c.file}`;
|
|
13
|
+
|
|
14
|
+
/** The finding to quote a fix from: most severe, then biggest impact. */
|
|
15
|
+
function leadFinding(findings) {
|
|
16
|
+
return [...findings].sort(
|
|
17
|
+
(a, b) =>
|
|
18
|
+
SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] ||
|
|
19
|
+
b.impactMs - a.impactMs,
|
|
20
|
+
)[0];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The functions behind the report, merged across findings: one entry per
|
|
25
|
+
* function with its worst severity, the time it cost and the findings it
|
|
26
|
+
* appears in. Time is the largest figure seen (not a sum) because the same
|
|
27
|
+
* work is counted again by the total-blocking-time finding.
|
|
28
|
+
*/
|
|
29
|
+
export function topCulprits(report, hideVendor, limit = 12) {
|
|
30
|
+
const map = new Map();
|
|
31
|
+
const entryFor = (key, base) => {
|
|
32
|
+
let e = map.get(key);
|
|
33
|
+
if (!e) {
|
|
34
|
+
e = {
|
|
35
|
+
...base,
|
|
36
|
+
key,
|
|
37
|
+
ms: 0,
|
|
38
|
+
severity: 'info',
|
|
39
|
+
findings: [],
|
|
40
|
+
categories: new Set(),
|
|
41
|
+
};
|
|
42
|
+
map.set(key, e);
|
|
43
|
+
}
|
|
44
|
+
return e;
|
|
45
|
+
};
|
|
46
|
+
for (const f of report.findings) {
|
|
47
|
+
for (const c of f.culprits || []) {
|
|
48
|
+
if (!c.file || (hideVendor && isVendorFile(c.file))) continue;
|
|
49
|
+
const e = entryFor(culpritKey(c), {
|
|
50
|
+
name: c.fn || '(unknown)',
|
|
51
|
+
file: c.file,
|
|
52
|
+
line: c.line,
|
|
53
|
+
lineKind: c.lineKind,
|
|
54
|
+
abs: c.abs,
|
|
55
|
+
});
|
|
56
|
+
e.ms = Math.max(e.ms, c.ms || 0);
|
|
57
|
+
e.severity = higher(e.severity, f.severity);
|
|
58
|
+
e.findings.push(f);
|
|
59
|
+
e.categories.add(f.category);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const h of report.hotFunctions || []) {
|
|
63
|
+
if (!h.file || (hideVendor && h.vendor)) continue;
|
|
64
|
+
const e = entryFor(culpritKey({ fn: h.name, file: h.file }), {
|
|
65
|
+
name: h.name,
|
|
66
|
+
file: h.file,
|
|
67
|
+
line: h.line,
|
|
68
|
+
lineKind: h.lineKind,
|
|
69
|
+
abs: h.abs,
|
|
70
|
+
});
|
|
71
|
+
e.ms = Math.max(e.ms, h.selfMs || 0);
|
|
72
|
+
if (!e.findings.length && h.share >= 10) e.severity = 'warning';
|
|
73
|
+
}
|
|
74
|
+
return [...map.values()]
|
|
75
|
+
.sort((a, b) => b.ms - a.ms)
|
|
76
|
+
.slice(0, limit)
|
|
77
|
+
.map(e => ({
|
|
78
|
+
...e,
|
|
79
|
+
categories: [...e.categories],
|
|
80
|
+
lead: e.findings.length ? leadFinding(e.findings) : null,
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Collapses findings that blame the same function (typically a dozen long
|
|
86
|
+
* tasks all caused by one library call) into one group, so the list shows
|
|
87
|
+
* root causes instead of repeating them.
|
|
88
|
+
*/
|
|
89
|
+
export function groupFindings(findings) {
|
|
90
|
+
const buckets = new Map();
|
|
91
|
+
const order = [];
|
|
92
|
+
for (const f of findings) {
|
|
93
|
+
const first = f.culprits?.[0];
|
|
94
|
+
const key = first?.file ? culpritKey(first) : `solo:${f.id}`;
|
|
95
|
+
if (!buckets.has(key)) {
|
|
96
|
+
buckets.set(key, []);
|
|
97
|
+
order.push(key);
|
|
98
|
+
}
|
|
99
|
+
buckets.get(key).push(f);
|
|
100
|
+
}
|
|
101
|
+
const items = order.map(key => {
|
|
102
|
+
const members = buckets.get(key);
|
|
103
|
+
if (members.length === 1)
|
|
104
|
+
return {
|
|
105
|
+
kind: 'single',
|
|
106
|
+
id: members[0].id,
|
|
107
|
+
severity: members[0].severity,
|
|
108
|
+
impactMs: members[0].impactMs,
|
|
109
|
+
finding: members[0],
|
|
110
|
+
};
|
|
111
|
+
const lead = leadFinding(members);
|
|
112
|
+
return {
|
|
113
|
+
kind: 'group',
|
|
114
|
+
id: `group:${key}`,
|
|
115
|
+
severity: members.reduce((s, m) => higher(s, m.severity), 'info'),
|
|
116
|
+
impactMs: Math.max(...members.map(m => m.impactMs)),
|
|
117
|
+
culprit: lead.culprits[0],
|
|
118
|
+
lead,
|
|
119
|
+
members: [...members].sort((a, b) => b.impactMs - a.impactMs),
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
return items.sort(
|
|
123
|
+
(a, b) =>
|
|
124
|
+
SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] ||
|
|
125
|
+
b.impactMs - a.impactMs,
|
|
126
|
+
);
|
|
127
|
+
}
|
package/src/download.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Saves a Blob through the browser's download mechanism. */
|
|
2
|
+
export function downloadBlob(blob, filename) {
|
|
3
|
+
const url = URL.createObjectURL(blob);
|
|
4
|
+
const link = document.createElement('a');
|
|
5
|
+
link.href = url;
|
|
6
|
+
link.download = filename;
|
|
7
|
+
document.body.appendChild(link);
|
|
8
|
+
link.click();
|
|
9
|
+
link.remove();
|
|
10
|
+
// The download starts asynchronously; releasing the URL immediately can cancel it.
|
|
11
|
+
setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const downloadJson = (value, filename) =>
|
|
15
|
+
downloadBlob(
|
|
16
|
+
new Blob([JSON.stringify(value)], { type: 'application/json' }),
|
|
17
|
+
filename,
|
|
18
|
+
);
|
package/src/format.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function fmtMs(ms) {
|
|
2
|
+
if (ms == null) return '–';
|
|
3
|
+
if (ms >= 1000) return `${(ms / 1000).toFixed(2)} s`;
|
|
4
|
+
if (ms >= 100) return `${Math.round(ms)} ms`;
|
|
5
|
+
if (ms >= 1) return `${ms.toFixed(1)} ms`;
|
|
6
|
+
return `${ms.toFixed(2)} ms`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function fmtBytes(n) {
|
|
10
|
+
if (n == null) return '–';
|
|
11
|
+
if (n < 1024) return `${n} B`;
|
|
12
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
13
|
+
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function fmtNumber(n) {
|
|
17
|
+
return n == null ? '–' : Number(n).toLocaleString();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function splitPath(path) {
|
|
21
|
+
const slash = path.lastIndexOf('/');
|
|
22
|
+
return {
|
|
23
|
+
dir: slash < 0 ? '' : path.slice(0, slash + 1),
|
|
24
|
+
name: path.slice(slash + 1),
|
|
25
|
+
};
|
|
26
|
+
}
|