@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,200 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
import { fmtMs, fmtNumber, splitPath } from '../format.js';
|
|
3
|
+
|
|
4
|
+
const COLUMNS = [
|
|
5
|
+
{ id: 'name', label: 'Component', get: c => c.name, text: true },
|
|
6
|
+
{ id: 'renders', label: 'Renders', get: c => c.renders },
|
|
7
|
+
{ id: 'wasted', label: 'Wasted', get: c => c.wasted },
|
|
8
|
+
{
|
|
9
|
+
id: 'wastedPct',
|
|
10
|
+
label: 'Wasted %',
|
|
11
|
+
get: c => (c.renders ? (c.wasted / c.renders) * 100 : 0),
|
|
12
|
+
},
|
|
13
|
+
{ id: 'selfMs', label: 'Self time', get: c => c.selfMs },
|
|
14
|
+
{
|
|
15
|
+
id: 'avgMs',
|
|
16
|
+
label: 'Avg / render',
|
|
17
|
+
get: c => (c.renders ? c.selfMs / c.renders : 0),
|
|
18
|
+
},
|
|
19
|
+
{ id: 'maxMs', label: 'Slowest', get: c => c.maxMs },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const REASON_LABELS = {
|
|
23
|
+
props: 'props changed',
|
|
24
|
+
state: 'state',
|
|
25
|
+
hooks: 'hook state',
|
|
26
|
+
context: 'context',
|
|
27
|
+
parent: 'parent re-render (nothing changed)',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function Detail({ c }) {
|
|
31
|
+
const reasons = Object.entries(c.reasons).filter(([, n]) => n);
|
|
32
|
+
return (
|
|
33
|
+
<tr className="detail">
|
|
34
|
+
<td colSpan={COLUMNS.length + 1}>
|
|
35
|
+
<div className="detail-grid">
|
|
36
|
+
<div>
|
|
37
|
+
<h4>Why it rendered</h4>
|
|
38
|
+
<ul>
|
|
39
|
+
{c.mounts > 0 && <li>{c.mounts}× mounted</li>}
|
|
40
|
+
{reasons.map(([k, n]) => (
|
|
41
|
+
<li key={k}>
|
|
42
|
+
{n}× {REASON_LABELS[k]}
|
|
43
|
+
</li>
|
|
44
|
+
))}
|
|
45
|
+
{c.topParent && (
|
|
46
|
+
<li className="muted">
|
|
47
|
+
usually triggered by parent <b>{c.topParent}</b>
|
|
48
|
+
</li>
|
|
49
|
+
)}
|
|
50
|
+
</ul>
|
|
51
|
+
</div>
|
|
52
|
+
<div>
|
|
53
|
+
<h4>Props that changed</h4>
|
|
54
|
+
{c.changedProps.length === 0 ? (
|
|
55
|
+
<p className="muted">none</p>
|
|
56
|
+
) : (
|
|
57
|
+
<ul>
|
|
58
|
+
{c.changedProps.map(p => {
|
|
59
|
+
const unstable = c.unstableProps.find(u => u.prop === p.prop);
|
|
60
|
+
return (
|
|
61
|
+
<li key={p.prop}>
|
|
62
|
+
<code>{p.prop}</code> {p.count}×
|
|
63
|
+
{unstable && (
|
|
64
|
+
<span className="warn">
|
|
65
|
+
{' '}
|
|
66
|
+
— new{' '}
|
|
67
|
+
{unstable.kind === 'function'
|
|
68
|
+
? 'function with identical code'
|
|
69
|
+
: 'object with identical contents'}{' '}
|
|
70
|
+
each time{' '}
|
|
71
|
+
{unstable.kind === 'function'
|
|
72
|
+
? '(useCallback)'
|
|
73
|
+
: '(useMemo)'}
|
|
74
|
+
</span>
|
|
75
|
+
)}
|
|
76
|
+
</li>
|
|
77
|
+
);
|
|
78
|
+
})}
|
|
79
|
+
</ul>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</td>
|
|
84
|
+
</tr>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export default function ReactPanel({ report, hideVendor, onOpenFile }) {
|
|
89
|
+
const [sort, setSort] = useState({ id: 'selfMs', dir: -1 });
|
|
90
|
+
const [open, setOpen] = useState(null);
|
|
91
|
+
const react = report.react || { detected: false, components: [] };
|
|
92
|
+
const rows = useMemo(() => {
|
|
93
|
+
const col = COLUMNS.find(c => c.id === sort.id);
|
|
94
|
+
return react.components
|
|
95
|
+
.filter(c => !(hideVendor && c.vendor))
|
|
96
|
+
.sort(
|
|
97
|
+
(a, b) =>
|
|
98
|
+
(col.text
|
|
99
|
+
? col.get(a).localeCompare(col.get(b))
|
|
100
|
+
: col.get(a) - col.get(b)) * sort.dir,
|
|
101
|
+
);
|
|
102
|
+
}, [react.components, sort, hideVendor]);
|
|
103
|
+
|
|
104
|
+
if (!react.detected) {
|
|
105
|
+
return (
|
|
106
|
+
<div className="empty">
|
|
107
|
+
<h3>No React renderer was detected</h3>
|
|
108
|
+
<p>
|
|
109
|
+
The probe has to be installed before React loads. Use{' '}
|
|
110
|
+
<b>Record page load</b>, or reload the tab from this tool and record
|
|
111
|
+
again.
|
|
112
|
+
</p>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return (
|
|
117
|
+
<div className="react-panel">
|
|
118
|
+
<div className="summary-row">
|
|
119
|
+
<span>React {react.version}</span>
|
|
120
|
+
<span>{fmtNumber(react.commits.count)} commits</span>
|
|
121
|
+
<span>total render time {fmtMs(react.commits.totalMs)}</span>
|
|
122
|
+
<span>slowest commit {fmtMs(react.commits.maxMs)}</span>
|
|
123
|
+
{!react.hasTimings && (
|
|
124
|
+
<span className="warn">production build: no timings</span>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
<table className="data">
|
|
128
|
+
<thead>
|
|
129
|
+
<tr>
|
|
130
|
+
{COLUMNS.map(c => (
|
|
131
|
+
<th key={c.id}>
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
onClick={() =>
|
|
135
|
+
setSort({
|
|
136
|
+
id: c.id,
|
|
137
|
+
dir: sort.id === c.id ? -sort.dir : -1,
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
>
|
|
141
|
+
{c.label} {sort.id === c.id ? (sort.dir < 0 ? '↓' : '↑') : ''}
|
|
142
|
+
</button>
|
|
143
|
+
</th>
|
|
144
|
+
))}
|
|
145
|
+
<th>File</th>
|
|
146
|
+
</tr>
|
|
147
|
+
</thead>
|
|
148
|
+
<tbody>
|
|
149
|
+
{rows.slice(0, 300).map(c => {
|
|
150
|
+
const key = `${c.name}|${c.file}|${c.line}`;
|
|
151
|
+
return [
|
|
152
|
+
<tr key={key} className={open === key ? 'open' : ''}>
|
|
153
|
+
<td>
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
className="link"
|
|
157
|
+
onClick={() => setOpen(open === key ? null : key)}
|
|
158
|
+
>
|
|
159
|
+
{open === key ? '▾' : '▸'} {c.name}
|
|
160
|
+
</button>
|
|
161
|
+
</td>
|
|
162
|
+
<td className="num">{fmtNumber(c.renders)}</td>
|
|
163
|
+
<td className={`num ${c.wasted ? 'warn' : ''}`}>
|
|
164
|
+
{fmtNumber(c.wasted)}
|
|
165
|
+
</td>
|
|
166
|
+
<td className="num">
|
|
167
|
+
{c.renders ? Math.round((c.wasted / c.renders) * 100) : 0}%
|
|
168
|
+
</td>
|
|
169
|
+
<td className="num">{fmtMs(c.selfMs)}</td>
|
|
170
|
+
<td className="num">
|
|
171
|
+
{fmtMs(c.renders ? c.selfMs / c.renders : 0)}
|
|
172
|
+
</td>
|
|
173
|
+
<td className="num">{fmtMs(c.maxMs)}</td>
|
|
174
|
+
<td>
|
|
175
|
+
{c.file ? (
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
className="link mono"
|
|
179
|
+
onClick={() => onOpenFile(c.file)}
|
|
180
|
+
title={c.file}
|
|
181
|
+
>
|
|
182
|
+
<span className="muted">
|
|
183
|
+
{splitPath(c.file).dir.slice(-28)}
|
|
184
|
+
</span>
|
|
185
|
+
{splitPath(c.file).name}
|
|
186
|
+
{c.line ? `:${c.line}` : ''}
|
|
187
|
+
</button>
|
|
188
|
+
) : (
|
|
189
|
+
<span className="muted">unknown</span>
|
|
190
|
+
)}
|
|
191
|
+
</td>
|
|
192
|
+
</tr>,
|
|
193
|
+
open === key && <Detail key={`${key}-detail`} c={c} />,
|
|
194
|
+
];
|
|
195
|
+
})}
|
|
196
|
+
</tbody>
|
|
197
|
+
</table>
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import { fmtMs } from '../format.js';
|
|
3
|
+
|
|
4
|
+
function CommitFacts({ git, runsCommit }) {
|
|
5
|
+
if (!git)
|
|
6
|
+
return (
|
|
7
|
+
<p className="muted">
|
|
8
|
+
No commit information yet: enter a hash and press Look up, or upload
|
|
9
|
+
snapshots that carry one.
|
|
10
|
+
</p>
|
|
11
|
+
);
|
|
12
|
+
const date = git.date
|
|
13
|
+
? new Date(git.date).toLocaleString(undefined, {
|
|
14
|
+
dateStyle: 'medium',
|
|
15
|
+
timeStyle: 'short',
|
|
16
|
+
})
|
|
17
|
+
: '';
|
|
18
|
+
return (
|
|
19
|
+
<dl className="git-facts">
|
|
20
|
+
<dt>Commit</dt>
|
|
21
|
+
<dd className="mono">{git.shortHash || git.hash?.slice(0, 10)}</dd>
|
|
22
|
+
<dt>Author</dt>
|
|
23
|
+
<dd>{git.author || <span className="muted">unknown</span>}</dd>
|
|
24
|
+
{date && (
|
|
25
|
+
<>
|
|
26
|
+
<dt>Date</dt>
|
|
27
|
+
<dd>{date}</dd>
|
|
28
|
+
</>
|
|
29
|
+
)}
|
|
30
|
+
{git.subject && (
|
|
31
|
+
<>
|
|
32
|
+
<dt>Message</dt>
|
|
33
|
+
<dd>{git.subject}</dd>
|
|
34
|
+
</>
|
|
35
|
+
)}
|
|
36
|
+
{git.branch && (
|
|
37
|
+
<>
|
|
38
|
+
<dt>Branch</dt>
|
|
39
|
+
<dd>{git.branch}</dd>
|
|
40
|
+
</>
|
|
41
|
+
)}
|
|
42
|
+
{runsCommit && runsCommit !== git.shortHash && (
|
|
43
|
+
<>
|
|
44
|
+
<dt>Runs say</dt>
|
|
45
|
+
<dd className="mono">{runsCommit}</dd>
|
|
46
|
+
</>
|
|
47
|
+
)}
|
|
48
|
+
</dl>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One side of the comparison: commit details plus the recordings (snapshots) taken on that commit. */
|
|
53
|
+
export default function SidePicker({
|
|
54
|
+
title,
|
|
55
|
+
hint,
|
|
56
|
+
side,
|
|
57
|
+
saved,
|
|
58
|
+
onFiles,
|
|
59
|
+
onSaved,
|
|
60
|
+
onRemove,
|
|
61
|
+
onLabel,
|
|
62
|
+
onLookup,
|
|
63
|
+
}) {
|
|
64
|
+
const input = useRef(null);
|
|
65
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
66
|
+
const [hash, setHash] = useState(null);
|
|
67
|
+
const git = side.git || side.runs[0]?.report.meta.git || null;
|
|
68
|
+
const hashValue = hash ?? (side.hashInput || git?.shortHash || '');
|
|
69
|
+
const addable = saved.filter(r => !side.runs.some(x => x.report.id === r.id));
|
|
70
|
+
|
|
71
|
+
const drop = e => {
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
setIsDragging(false);
|
|
74
|
+
if (e.dataTransfer.files.length) onFiles(e.dataTransfer.files);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<section className="side-picker" aria-label={title}>
|
|
79
|
+
<h3>
|
|
80
|
+
{title} <span className="muted">{hint}</span>
|
|
81
|
+
</h3>
|
|
82
|
+
|
|
83
|
+
<label className="field">
|
|
84
|
+
<span>Commit hash</span>
|
|
85
|
+
<span className="field-row">
|
|
86
|
+
<input
|
|
87
|
+
value={hashValue}
|
|
88
|
+
onChange={e => setHash(e.target.value)}
|
|
89
|
+
onKeyDown={e => e.key === 'Enter' && onLookup(hashValue)}
|
|
90
|
+
placeholder="e.g. 3fa516c"
|
|
91
|
+
spellCheck={false}
|
|
92
|
+
/>
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
onClick={() => {
|
|
96
|
+
onLookup(hashValue);
|
|
97
|
+
setHash(null);
|
|
98
|
+
}}
|
|
99
|
+
disabled={side.lookup.status === 'working'}
|
|
100
|
+
>
|
|
101
|
+
{side.lookup.status === 'working' ? 'Looking up…' : 'Look up'}
|
|
102
|
+
</button>
|
|
103
|
+
</span>
|
|
104
|
+
</label>
|
|
105
|
+
<CommitFacts
|
|
106
|
+
git={git}
|
|
107
|
+
runsCommit={side.runs[0]?.report.meta.git?.shortHash}
|
|
108
|
+
/>
|
|
109
|
+
{git?.dirty && (
|
|
110
|
+
<p className="warn">
|
|
111
|
+
This snapshot was recorded with uncommitted changes.
|
|
112
|
+
</p>
|
|
113
|
+
)}
|
|
114
|
+
{side.lookup.status === 'error' && (
|
|
115
|
+
<p className="warn">{side.lookup.message}</p>
|
|
116
|
+
)}
|
|
117
|
+
|
|
118
|
+
<label className="field">
|
|
119
|
+
<span>
|
|
120
|
+
Scenario <span className="muted">(optional)</span>
|
|
121
|
+
</span>
|
|
122
|
+
<input
|
|
123
|
+
value={side.label}
|
|
124
|
+
onChange={e => onLabel(e.target.value)}
|
|
125
|
+
placeholder={
|
|
126
|
+
side.runs[0]?.report.meta.title || 'e.g. dashboard 579, page load'
|
|
127
|
+
}
|
|
128
|
+
/>
|
|
129
|
+
</label>
|
|
130
|
+
|
|
131
|
+
<div
|
|
132
|
+
className={`dropzone ${isDragging ? 'dragging' : ''}`}
|
|
133
|
+
onDragOver={e => {
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
setIsDragging(true);
|
|
136
|
+
}}
|
|
137
|
+
onDragLeave={() => setIsDragging(false)}
|
|
138
|
+
onDrop={drop}
|
|
139
|
+
>
|
|
140
|
+
<input
|
|
141
|
+
ref={input}
|
|
142
|
+
type="file"
|
|
143
|
+
accept=".json,application/json"
|
|
144
|
+
multiple
|
|
145
|
+
hidden
|
|
146
|
+
onChange={e => {
|
|
147
|
+
onFiles(e.target.files);
|
|
148
|
+
e.target.value = '';
|
|
149
|
+
}}
|
|
150
|
+
/>
|
|
151
|
+
<button
|
|
152
|
+
type="button"
|
|
153
|
+
className="primary"
|
|
154
|
+
onClick={() => input.current.click()}
|
|
155
|
+
>
|
|
156
|
+
Upload snapshot(s)
|
|
157
|
+
</button>
|
|
158
|
+
<span className="muted">
|
|
159
|
+
or drop <code>perf_*.json</code> files here. Several runs of the same
|
|
160
|
+
commit are welcome: medians are used.
|
|
161
|
+
</span>
|
|
162
|
+
</div>
|
|
163
|
+
{addable.length > 0 && (
|
|
164
|
+
<select
|
|
165
|
+
aria-label={`Add a saved report to ${title}`}
|
|
166
|
+
value=""
|
|
167
|
+
onChange={e => e.target.value && onSaved(e.target.value)}
|
|
168
|
+
>
|
|
169
|
+
<option value="">…or add a report recorded on this machine</option>
|
|
170
|
+
{addable.map(r => (
|
|
171
|
+
<option key={r.id} value={r.id}>
|
|
172
|
+
{new Date(r.createdAt).toLocaleString()} · {r.mode} ·{' '}
|
|
173
|
+
{fmtMs(r.durationMs)}
|
|
174
|
+
</option>
|
|
175
|
+
))}
|
|
176
|
+
</select>
|
|
177
|
+
)}
|
|
178
|
+
|
|
179
|
+
{side.problems.map(p => (
|
|
180
|
+
<p key={p} className="warn" role="alert">
|
|
181
|
+
{p}
|
|
182
|
+
</p>
|
|
183
|
+
))}
|
|
184
|
+
<ul className="run-chips">
|
|
185
|
+
{side.runs.map(({ report, fileName }) => (
|
|
186
|
+
<li key={report.id}>
|
|
187
|
+
<span className="mono" title={fileName}>
|
|
188
|
+
{fileName.replace(/^reports\//, '')}
|
|
189
|
+
</span>
|
|
190
|
+
<span className="muted">
|
|
191
|
+
{report.meta.mode === 'load' ? 'load' : 'interaction'} ·{' '}
|
|
192
|
+
{fmtMs(report.meta.durationMs)}
|
|
193
|
+
</span>
|
|
194
|
+
<button
|
|
195
|
+
type="button"
|
|
196
|
+
aria-label={`Remove ${fileName}`}
|
|
197
|
+
onClick={() => onRemove(report.id)}
|
|
198
|
+
>
|
|
199
|
+
×
|
|
200
|
+
</button>
|
|
201
|
+
</li>
|
|
202
|
+
))}
|
|
203
|
+
{!side.runs.length && <li className="muted">No recordings yet.</li>}
|
|
204
|
+
</ul>
|
|
205
|
+
</section>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { snapshotFileName, withCommit } from '../../shared/snapshot.js';
|
|
3
|
+
import { api } from '../api.js';
|
|
4
|
+
import { downloadJson } from '../download.js';
|
|
5
|
+
import { DownloadIcon } from './Icons.jsx';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Exports the open report as a snapshot named after its commit, for the Compare tab:
|
|
9
|
+
* `perf_<commit>_<mode>_<time>[_<label>].json`. The commit is whatever git had
|
|
10
|
+
* checked out when the recording was made, and can be corrected here.
|
|
11
|
+
*/
|
|
12
|
+
export default function SnapshotButton({ report }) {
|
|
13
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
14
|
+
const [hash, setHash] = useState(report.meta.git?.hash || '');
|
|
15
|
+
const [git, setGit] = useState(report.meta.git || null);
|
|
16
|
+
const [label, setLabel] = useState(report.meta.label || '');
|
|
17
|
+
const [state, setState] = useState({ status: 'idle', message: '' });
|
|
18
|
+
|
|
19
|
+
const lookup = async () => {
|
|
20
|
+
setState({ status: 'working', message: '' });
|
|
21
|
+
try {
|
|
22
|
+
const info = await api.gitCommit(hash.trim() || 'HEAD');
|
|
23
|
+
setGit(info);
|
|
24
|
+
setHash(info.hash);
|
|
25
|
+
setState({ status: 'ok', message: '' });
|
|
26
|
+
return info;
|
|
27
|
+
} catch (err) {
|
|
28
|
+
setState({ status: 'error', message: err.message });
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const download = async () => {
|
|
34
|
+
let commit = git;
|
|
35
|
+
// A hash typed but never looked up: resolve it so the author is included.
|
|
36
|
+
if (hash.trim() && hash.trim() !== git?.hash) commit = await lookup();
|
|
37
|
+
if (!commit && hash.trim())
|
|
38
|
+
commit = {
|
|
39
|
+
hash: hash.trim(),
|
|
40
|
+
shortHash: hash.trim().slice(0, 10),
|
|
41
|
+
author: '',
|
|
42
|
+
};
|
|
43
|
+
downloadJson(
|
|
44
|
+
withCommit(report, commit, label),
|
|
45
|
+
snapshotFileName(report, commit, label),
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<span className="trace-button">
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
onClick={() => setIsOpen(open => !open)}
|
|
54
|
+
aria-expanded={isOpen}
|
|
55
|
+
>
|
|
56
|
+
<DownloadIcon /> Snapshot for comparison
|
|
57
|
+
</button>
|
|
58
|
+
{isOpen && (
|
|
59
|
+
<div
|
|
60
|
+
className="trace-note snapshot"
|
|
61
|
+
role="dialog"
|
|
62
|
+
aria-label="Snapshot for comparison"
|
|
63
|
+
>
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
className="close"
|
|
67
|
+
aria-label="Close"
|
|
68
|
+
onClick={() => setIsOpen(false)}
|
|
69
|
+
>
|
|
70
|
+
×
|
|
71
|
+
</button>
|
|
72
|
+
<p>
|
|
73
|
+
Saves this report as <code>perf_<commit>_…json</code>. Upload
|
|
74
|
+
two of these in the <b>Compare</b> tab to see what a commit changed.
|
|
75
|
+
</p>
|
|
76
|
+
<label className="field">
|
|
77
|
+
<span>Commit</span>
|
|
78
|
+
<span className="field-row">
|
|
79
|
+
<input
|
|
80
|
+
value={hash}
|
|
81
|
+
onChange={e => setHash(e.target.value)}
|
|
82
|
+
placeholder="commit hash (blank = current HEAD)"
|
|
83
|
+
spellCheck={false}
|
|
84
|
+
/>
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
onClick={lookup}
|
|
88
|
+
disabled={state.status === 'working'}
|
|
89
|
+
>
|
|
90
|
+
Look up
|
|
91
|
+
</button>
|
|
92
|
+
</span>
|
|
93
|
+
</label>
|
|
94
|
+
{git && (
|
|
95
|
+
<dl className="git-facts">
|
|
96
|
+
<dt>Commit</dt>
|
|
97
|
+
<dd className="mono">{git.shortHash}</dd>
|
|
98
|
+
<dt>Author</dt>
|
|
99
|
+
<dd>{git.author || 'unknown'}</dd>
|
|
100
|
+
<dt>Message</dt>
|
|
101
|
+
<dd>{git.subject || '–'}</dd>
|
|
102
|
+
{git.branch && (
|
|
103
|
+
<>
|
|
104
|
+
<dt>Branch</dt>
|
|
105
|
+
<dd>{git.branch}</dd>
|
|
106
|
+
</>
|
|
107
|
+
)}
|
|
108
|
+
</dl>
|
|
109
|
+
)}
|
|
110
|
+
{git?.dirty && (
|
|
111
|
+
<p className="warn">
|
|
112
|
+
The working tree had uncommitted changes when this was recorded,
|
|
113
|
+
so the commit may not describe exactly what was measured.
|
|
114
|
+
</p>
|
|
115
|
+
)}
|
|
116
|
+
{!git && (
|
|
117
|
+
<p className="muted">
|
|
118
|
+
No commit was recorded with this report (it predates commit
|
|
119
|
+
tracking). Enter the hash it was taken at.
|
|
120
|
+
</p>
|
|
121
|
+
)}
|
|
122
|
+
{state.status === 'error' && <p className="warn">{state.message}</p>}
|
|
123
|
+
<label className="field">
|
|
124
|
+
<span>
|
|
125
|
+
Scenario label <span className="muted">(optional)</span>
|
|
126
|
+
</span>
|
|
127
|
+
<input
|
|
128
|
+
value={label}
|
|
129
|
+
onChange={e => setLabel(e.target.value)}
|
|
130
|
+
placeholder="e.g. dashboard 579, page load"
|
|
131
|
+
/>
|
|
132
|
+
</label>
|
|
133
|
+
<button type="button" className="primary" onClick={download}>
|
|
134
|
+
Download snapshot
|
|
135
|
+
</button>
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
138
|
+
</span>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { fmtMs } from '../format.js';
|
|
2
|
+
|
|
3
|
+
const PARTS = [
|
|
4
|
+
{ key: 'scriptingMs', label: 'Scripting', color: 'var(--cat-1)' },
|
|
5
|
+
{ key: 'layoutMs', label: 'Layout', color: 'var(--cat-2)' },
|
|
6
|
+
{ key: 'styleMs', label: 'Style', color: 'var(--cat-3)' },
|
|
7
|
+
{ key: 'paintMs', label: 'Paint', color: 'var(--cat-4)' },
|
|
8
|
+
{ key: 'gcMs', label: 'Garbage collection', color: 'var(--cat-5)' },
|
|
9
|
+
{ key: 'compileMs', label: 'Compile/parse', color: 'var(--cat-6)' },
|
|
10
|
+
{ key: 'otherMs', label: 'Other', color: 'var(--cat-other)' },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
/** Where main-thread time went, as one stacked bar with a labelled legend. */
|
|
14
|
+
export default function TimeBreakdown({ totals }) {
|
|
15
|
+
const parts = PARTS.map(p => ({ ...p, value: totals[p.key] || 0 })).filter(
|
|
16
|
+
p => p.value > 0,
|
|
17
|
+
);
|
|
18
|
+
const sum = parts.reduce((s, p) => s + p.value, 0) || 1;
|
|
19
|
+
return (
|
|
20
|
+
<div className="breakdown">
|
|
21
|
+
<div
|
|
22
|
+
className="breakdown-bar"
|
|
23
|
+
role="img"
|
|
24
|
+
aria-label="Main-thread time breakdown"
|
|
25
|
+
>
|
|
26
|
+
{parts.map(p => (
|
|
27
|
+
<span
|
|
28
|
+
key={p.key}
|
|
29
|
+
style={{ width: `${(p.value / sum) * 100}%`, background: p.color }}
|
|
30
|
+
title={`${p.label}: ${fmtMs(p.value)}`}
|
|
31
|
+
/>
|
|
32
|
+
))}
|
|
33
|
+
</div>
|
|
34
|
+
<ul className="breakdown-legend">
|
|
35
|
+
{parts.map(p => (
|
|
36
|
+
<li key={p.key}>
|
|
37
|
+
<i style={{ background: p.color }} />
|
|
38
|
+
{p.label} <b>{fmtMs(p.value)}</b>{' '}
|
|
39
|
+
<span className="muted">{Math.round((p.value / sum) * 100)}%</span>
|
|
40
|
+
</li>
|
|
41
|
+
))}
|
|
42
|
+
</ul>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -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
|
+
}
|