@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,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Snapshots" are profiler reports exported for comparison. This module knows how
|
|
3
|
+
* to name them (the commit hash goes in the file name so they are recognisable
|
|
4
|
+
* once downloaded), check that an uploaded file really is one, and recover the
|
|
5
|
+
* commit from a file name when the report itself carries no git information.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** @returns {{ok: true} | {ok: false, reason: string}} */
|
|
9
|
+
export function validateReport(obj) {
|
|
10
|
+
if (obj && (Array.isArray(obj) || Array.isArray(obj.traceEvents))) {
|
|
11
|
+
return {
|
|
12
|
+
ok: false,
|
|
13
|
+
reason:
|
|
14
|
+
'This is a raw Chrome trace (.trace.json). Comparison needs the profiler report: use “Snapshot for comparison” on the report instead — the trace lacks the React and source-file data.',
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
const ok =
|
|
18
|
+
obj &&
|
|
19
|
+
typeof obj === 'object' &&
|
|
20
|
+
obj.meta &&
|
|
21
|
+
obj.vitals &&
|
|
22
|
+
obj.totals &&
|
|
23
|
+
obj.memory &&
|
|
24
|
+
obj.network &&
|
|
25
|
+
Array.isArray(obj.files) &&
|
|
26
|
+
Array.isArray(obj.findings);
|
|
27
|
+
return ok
|
|
28
|
+
? { ok: true }
|
|
29
|
+
: { ok: false, reason: 'This file is not a performance-profiler report.' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Commit hash from a file name such as `perf_3fa516c_load_20260930-1548.json`.
|
|
34
|
+
* The segment right after the `perf_` prefix wins; otherwise the first hex-looking
|
|
35
|
+
* token that contains a letter (so plain dates like 20260930 are never mistaken for a hash).
|
|
36
|
+
*/
|
|
37
|
+
export function hashFromFileName(name) {
|
|
38
|
+
const base = String(name || '').replace(/\.json$/i, '');
|
|
39
|
+
const prefixed = /^perf[_-]([0-9a-f]{7,40})(?:[_-]|$)/i.exec(base);
|
|
40
|
+
if (prefixed) return prefixed[1].toLowerCase();
|
|
41
|
+
const token = base
|
|
42
|
+
.split(/[^0-9a-zA-Z]+/)
|
|
43
|
+
.find(t => /^[0-9a-f]{7,40}$/i.test(t) && /[a-f]/i.test(t));
|
|
44
|
+
return token ? token.toLowerCase() : '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const slug = text =>
|
|
48
|
+
String(text || '')
|
|
49
|
+
.toLowerCase()
|
|
50
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
51
|
+
.replace(/^-+|-+$/g, '')
|
|
52
|
+
.slice(0, 40);
|
|
53
|
+
|
|
54
|
+
export function snapshotFileName(report, git, label) {
|
|
55
|
+
const hash = (git?.shortHash || git?.hash || '').slice(0, 10) || 'nocommit';
|
|
56
|
+
const stamp = String(report.createdAt || '')
|
|
57
|
+
.replace(/[-:]/g, '')
|
|
58
|
+
.replace(/\..+$/, '')
|
|
59
|
+
.replace('T', '-')
|
|
60
|
+
.slice(0, 15); // yyyymmdd-hhmmss: two runs a few seconds apart must not share a name
|
|
61
|
+
const parts = ['perf', hash, report.meta.mode, stamp, slug(label)].filter(
|
|
62
|
+
Boolean,
|
|
63
|
+
);
|
|
64
|
+
return `${parts.join('_')}.json`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The report with the commit and an optional label recorded in `meta`. */
|
|
68
|
+
export function withCommit(report, git, label) {
|
|
69
|
+
return {
|
|
70
|
+
...report,
|
|
71
|
+
meta: {
|
|
72
|
+
...report.meta,
|
|
73
|
+
git: git || report.meta.git || null,
|
|
74
|
+
label: label || report.meta.label || '',
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/App.jsx
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { api } from './api.js';
|
|
3
|
+
import AppearanceMenu from './components/AppearanceMenu.jsx';
|
|
4
|
+
import { CallHeatTree, FileHeatTree } from './components/HeatTrees.jsx';
|
|
5
|
+
import ComparePanel from './components/ComparePanel.jsx';
|
|
6
|
+
import ControlBar from './components/ControlBar.jsx';
|
|
7
|
+
import Culprits from './components/Culprits.jsx';
|
|
8
|
+
import EvaluationPanel from './components/EvaluationPanel.jsx';
|
|
9
|
+
import FilePanel from './components/FilePanel.jsx';
|
|
10
|
+
import LongTasks from './components/LongTasks.jsx';
|
|
11
|
+
import NetworkPanel from './components/NetworkPanel.jsx';
|
|
12
|
+
import PageMap from './components/PageMap.jsx';
|
|
13
|
+
import ReactPanel from './components/ReactPanel.jsx';
|
|
14
|
+
import SnapshotButton from './components/SnapshotButton.jsx';
|
|
15
|
+
import TraceButton from './components/TraceButton.jsx';
|
|
16
|
+
import { fmtMs } from './format.js';
|
|
17
|
+
import { useCompare } from './hooks/useCompare.js';
|
|
18
|
+
import { useSession } from './hooks/useSession.js';
|
|
19
|
+
import { useAppearance } from './theme.js';
|
|
20
|
+
|
|
21
|
+
const TABS = [
|
|
22
|
+
{ id: 'culprits', label: 'Culprits' },
|
|
23
|
+
{ id: 'page', label: 'Page map' },
|
|
24
|
+
{ id: 'files', label: 'File tree' },
|
|
25
|
+
{ id: 'calls', label: 'Call tree' },
|
|
26
|
+
{ id: 'react', label: 'React' },
|
|
27
|
+
{ id: 'tasks', label: 'Long tasks' },
|
|
28
|
+
{ id: 'network', label: 'Network & memory' },
|
|
29
|
+
{ id: 'evaluation', label: 'Golden paths' },
|
|
30
|
+
{ id: 'compare', label: 'Compare commits' },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** Tabs that don't require a loaded report to be usable. */
|
|
34
|
+
const REPORTLESS_TABS = new Set(['compare', 'evaluation']);
|
|
35
|
+
|
|
36
|
+
function ReportPicker({ reports, current, onPick, onDelete }) {
|
|
37
|
+
if (!reports.length)
|
|
38
|
+
return <span className="muted">No saved reports yet</span>;
|
|
39
|
+
return (
|
|
40
|
+
<span className="report-picker">
|
|
41
|
+
<select
|
|
42
|
+
aria-label="Saved report"
|
|
43
|
+
value={current || ''}
|
|
44
|
+
onChange={e => onPick(e.target.value)}
|
|
45
|
+
>
|
|
46
|
+
{!current && <option value="">Open a saved report…</option>}
|
|
47
|
+
{reports.map(r => (
|
|
48
|
+
<option key={r.id} value={r.id}>
|
|
49
|
+
{new Date(r.createdAt).toLocaleString()} · {r.mode} ·{' '}
|
|
50
|
+
{fmtMs(r.durationMs)} · {r.critical} critical
|
|
51
|
+
</option>
|
|
52
|
+
))}
|
|
53
|
+
</select>
|
|
54
|
+
{current && (
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
className="ghost destructive"
|
|
58
|
+
onClick={() => onDelete(current)}
|
|
59
|
+
aria-label="Delete this report"
|
|
60
|
+
>
|
|
61
|
+
Delete
|
|
62
|
+
</button>
|
|
63
|
+
)}
|
|
64
|
+
</span>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const STEPS = [
|
|
69
|
+
{
|
|
70
|
+
title: 'Start the frontend',
|
|
71
|
+
body: (
|
|
72
|
+
<>
|
|
73
|
+
Run <code>npm run dev-server</code> (usually on port 9000). A{' '}
|
|
74
|
+
<b>development</b> build gives per-file and per-component results.
|
|
75
|
+
</>
|
|
76
|
+
),
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
title: 'Launch Chrome',
|
|
80
|
+
body: (
|
|
81
|
+
<>
|
|
82
|
+
Click <b>Launch Chrome</b>, sign in to the app in the window that opens,
|
|
83
|
+
and navigate to the page you care about.
|
|
84
|
+
</>
|
|
85
|
+
),
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
title: 'Record',
|
|
89
|
+
body: (
|
|
90
|
+
<>
|
|
91
|
+
Press <b>Record interaction</b>, use the page, then <b>Stop</b> — or use{' '}
|
|
92
|
+
<b>Record page load</b> for startup performance.
|
|
93
|
+
</>
|
|
94
|
+
),
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
function Welcome({ onCompare }) {
|
|
99
|
+
return (
|
|
100
|
+
<div className="welcome">
|
|
101
|
+
<div>
|
|
102
|
+
<h2>Find what makes the frontend slow</h2>
|
|
103
|
+
<p className="welcome-lead">
|
|
104
|
+
Ranked culprits, heat trees, React re-renders, long tasks and network
|
|
105
|
+
findings from a single recording.
|
|
106
|
+
</p>
|
|
107
|
+
</div>
|
|
108
|
+
<ol className="steps">
|
|
109
|
+
{STEPS.map((step, i) => (
|
|
110
|
+
<li key={step.title}>
|
|
111
|
+
<span className="step-num">{i + 1}</span>
|
|
112
|
+
<h3>{step.title}</h3>
|
|
113
|
+
<p>{step.body}</p>
|
|
114
|
+
</li>
|
|
115
|
+
))}
|
|
116
|
+
</ol>
|
|
117
|
+
<p className="welcome-foot">
|
|
118
|
+
Reviewing a PR? Use{' '}
|
|
119
|
+
<button type="button" className="link" onClick={onCompare}>
|
|
120
|
+
Compare commits
|
|
121
|
+
</button>{' '}
|
|
122
|
+
to see what a commit changed.
|
|
123
|
+
</p>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export default function App() {
|
|
129
|
+
const [report, setReport] = useState(null);
|
|
130
|
+
const [tab, setTab] = useState('culprits');
|
|
131
|
+
const [selectedFile, setSelectedFile] = useState(null);
|
|
132
|
+
const [hideVendor, setHideVendor] = useState(false);
|
|
133
|
+
const [revealTick, setRevealTick] = useState(0);
|
|
134
|
+
const compare = useCompare();
|
|
135
|
+
const [appearance, setAppearance] = useAppearance();
|
|
136
|
+
const [evaluations, setEvaluations] = useState([]);
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
api
|
|
140
|
+
.evaluations()
|
|
141
|
+
.then(setEvaluations)
|
|
142
|
+
.catch(() => setEvaluations([]));
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
const openReport = useCallback(async id => {
|
|
146
|
+
const data = await api.report(id);
|
|
147
|
+
setReport(data);
|
|
148
|
+
setSelectedFile(null);
|
|
149
|
+
setTab('culprits');
|
|
150
|
+
}, []);
|
|
151
|
+
const { status, reports, error, clearError, run, refreshReports } =
|
|
152
|
+
useSession({ onReport: openReport });
|
|
153
|
+
|
|
154
|
+
// Open the newest saved report on first load so the page is never empty.
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (!report && reports.length) openReport(reports[0].id);
|
|
157
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
158
|
+
}, [reports.length]);
|
|
159
|
+
|
|
160
|
+
const openFile = useCallback(path => {
|
|
161
|
+
setSelectedFile(path);
|
|
162
|
+
setRevealTick(t => t + 1);
|
|
163
|
+
setTab(t =>
|
|
164
|
+
t === 'culprits' || t === 'network' || t === 'tasks' ? 'files' : t,
|
|
165
|
+
);
|
|
166
|
+
}, []);
|
|
167
|
+
|
|
168
|
+
const deleteReport = async id => {
|
|
169
|
+
await api.deleteReport(id);
|
|
170
|
+
setReport(null);
|
|
171
|
+
refreshReports();
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const showPanel =
|
|
175
|
+
selectedFile &&
|
|
176
|
+
report &&
|
|
177
|
+
['files', 'calls', 'react', 'culprits', 'tasks', 'page'].includes(tab);
|
|
178
|
+
const inCompare = tab === 'compare';
|
|
179
|
+
const inEvaluation = tab === 'evaluation';
|
|
180
|
+
return (
|
|
181
|
+
<div className="app">
|
|
182
|
+
<header className="topbar">
|
|
183
|
+
<div className="brand">
|
|
184
|
+
<span className="brand-mark" aria-hidden="true">
|
|
185
|
+
<svg viewBox="0 0 16 16" width="16" height="16">
|
|
186
|
+
<path
|
|
187
|
+
d="M1.5 8.5h3l2-5 3 9 2-4h3"
|
|
188
|
+
fill="none"
|
|
189
|
+
stroke="currentColor"
|
|
190
|
+
strokeWidth="1.7"
|
|
191
|
+
strokeLinecap="round"
|
|
192
|
+
strokeLinejoin="round"
|
|
193
|
+
/>
|
|
194
|
+
</svg>
|
|
195
|
+
</span>
|
|
196
|
+
<div>
|
|
197
|
+
<h1>Performance Profiler</h1>
|
|
198
|
+
<small>{status?.defaultName || 'target-frontend'}</small>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
<span className="spacer" />
|
|
202
|
+
<ReportPicker
|
|
203
|
+
reports={reports}
|
|
204
|
+
current={report?.id}
|
|
205
|
+
onPick={openReport}
|
|
206
|
+
onDelete={deleteReport}
|
|
207
|
+
/>
|
|
208
|
+
<AppearanceMenu appearance={appearance} onChange={setAppearance} />
|
|
209
|
+
</header>
|
|
210
|
+
<ControlBar status={status} run={run} isCompact={Boolean(report)} />
|
|
211
|
+
{error && (
|
|
212
|
+
<div className="error" role="alert">
|
|
213
|
+
<span>{error}</span>
|
|
214
|
+
<button type="button" onClick={clearError}>
|
|
215
|
+
Dismiss
|
|
216
|
+
</button>
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
<div className="workspace">
|
|
220
|
+
{report && !inCompare && !inEvaluation && (
|
|
221
|
+
<div className="report-bar">
|
|
222
|
+
<span className="report-title" title={report.meta.url}>
|
|
223
|
+
{report.meta.title || report.meta.url}
|
|
224
|
+
</span>
|
|
225
|
+
<span className="report-meta">
|
|
226
|
+
<span className="pill">
|
|
227
|
+
{report.meta.mode === 'load' ? 'Page load' : 'Interaction'}
|
|
228
|
+
</span>
|
|
229
|
+
<span className="pill">{fmtMs(report.meta.durationMs)}</span>
|
|
230
|
+
{report.meta.cpuThrottle > 1 && (
|
|
231
|
+
<span className="pill warn">
|
|
232
|
+
{report.meta.cpuThrottle}× CPU slowdown
|
|
233
|
+
</span>
|
|
234
|
+
)}
|
|
235
|
+
<span className="pill">
|
|
236
|
+
Attribution: {report.meta.attribution}
|
|
237
|
+
</span>
|
|
238
|
+
{report.meta.git && (
|
|
239
|
+
<span className="pill">
|
|
240
|
+
Commit <code>{report.meta.git.shortHash}</code>
|
|
241
|
+
{report.meta.git.dirty ? ' · uncommitted changes' : ''}
|
|
242
|
+
</span>
|
|
243
|
+
)}
|
|
244
|
+
</span>
|
|
245
|
+
<span className="spacer" />
|
|
246
|
+
<span className="btn-group">
|
|
247
|
+
<SnapshotButton key={`snap-${report.id}`} report={report} />
|
|
248
|
+
<TraceButton key={report.id} reportId={report.id} />
|
|
249
|
+
</span>
|
|
250
|
+
</div>
|
|
251
|
+
)}
|
|
252
|
+
<div className="tabs main-tabs">
|
|
253
|
+
<div role="tablist" className="tab-list" aria-label="Report views">
|
|
254
|
+
{TABS.map(t => (
|
|
255
|
+
<button
|
|
256
|
+
key={t.id}
|
|
257
|
+
type="button"
|
|
258
|
+
role="tab"
|
|
259
|
+
aria-selected={
|
|
260
|
+
tab === t.id && (Boolean(report) || REPORTLESS_TABS.has(t.id))
|
|
261
|
+
}
|
|
262
|
+
disabled={!report && !REPORTLESS_TABS.has(t.id)}
|
|
263
|
+
onClick={() => setTab(t.id)}
|
|
264
|
+
>
|
|
265
|
+
{t.label}
|
|
266
|
+
{t.id === 'culprits' && report && (
|
|
267
|
+
<span className="count">{report.findings.length}</span>
|
|
268
|
+
)}
|
|
269
|
+
</button>
|
|
270
|
+
))}
|
|
271
|
+
</div>
|
|
272
|
+
<span className="spacer" />
|
|
273
|
+
<label className="switch">
|
|
274
|
+
<input
|
|
275
|
+
type="checkbox"
|
|
276
|
+
role="switch"
|
|
277
|
+
checked={hideVendor}
|
|
278
|
+
onChange={e => setHideVendor(e.target.checked)}
|
|
279
|
+
/>
|
|
280
|
+
Hide node_modules
|
|
281
|
+
</label>
|
|
282
|
+
</div>
|
|
283
|
+
{inCompare && (
|
|
284
|
+
<main className="main">
|
|
285
|
+
<div className="content">
|
|
286
|
+
<ComparePanel
|
|
287
|
+
compare={compare}
|
|
288
|
+
savedReports={reports}
|
|
289
|
+
hideVendor={hideVendor}
|
|
290
|
+
/>
|
|
291
|
+
</div>
|
|
292
|
+
</main>
|
|
293
|
+
)}
|
|
294
|
+
{inEvaluation && (
|
|
295
|
+
<main className="main">
|
|
296
|
+
<div className="content">
|
|
297
|
+
<EvaluationPanel
|
|
298
|
+
evaluations={evaluations}
|
|
299
|
+
onOpenReport={openReport}
|
|
300
|
+
/>
|
|
301
|
+
</div>
|
|
302
|
+
</main>
|
|
303
|
+
)}
|
|
304
|
+
{!inCompare && !inEvaluation && report && (
|
|
305
|
+
<main className="main">
|
|
306
|
+
<div className="content">
|
|
307
|
+
{tab === 'culprits' && (
|
|
308
|
+
<Culprits
|
|
309
|
+
key={report.id}
|
|
310
|
+
report={report}
|
|
311
|
+
hideVendor={hideVendor}
|
|
312
|
+
onOpenFile={openFile}
|
|
313
|
+
/>
|
|
314
|
+
)}
|
|
315
|
+
{tab === 'page' && (
|
|
316
|
+
<PageMap
|
|
317
|
+
report={report}
|
|
318
|
+
hideVendor={hideVendor}
|
|
319
|
+
onOpenFile={openFile}
|
|
320
|
+
/>
|
|
321
|
+
)}
|
|
322
|
+
{tab === 'files' && (
|
|
323
|
+
<FileHeatTree
|
|
324
|
+
report={report}
|
|
325
|
+
hideVendor={hideVendor}
|
|
326
|
+
selectedPath={selectedFile}
|
|
327
|
+
revealTick={revealTick}
|
|
328
|
+
onSelectFile={setSelectedFile}
|
|
329
|
+
/>
|
|
330
|
+
)}
|
|
331
|
+
{tab === 'calls' && (
|
|
332
|
+
<CallHeatTree report={report} onSelectFile={setSelectedFile} />
|
|
333
|
+
)}
|
|
334
|
+
{tab === 'react' && (
|
|
335
|
+
<ReactPanel
|
|
336
|
+
report={report}
|
|
337
|
+
hideVendor={hideVendor}
|
|
338
|
+
onOpenFile={openFile}
|
|
339
|
+
/>
|
|
340
|
+
)}
|
|
341
|
+
{tab === 'tasks' && (
|
|
342
|
+
<LongTasks report={report} onOpenFile={openFile} />
|
|
343
|
+
)}
|
|
344
|
+
{tab === 'network' && <NetworkPanel report={report} />}
|
|
345
|
+
</div>
|
|
346
|
+
{showPanel && (
|
|
347
|
+
<FilePanel
|
|
348
|
+
report={report}
|
|
349
|
+
path={selectedFile}
|
|
350
|
+
onClose={() => setSelectedFile(null)}
|
|
351
|
+
/>
|
|
352
|
+
)}
|
|
353
|
+
</main>
|
|
354
|
+
)}
|
|
355
|
+
{!inCompare && !inEvaluation && !report && (
|
|
356
|
+
<Welcome onCompare={() => setTab('compare')} />
|
|
357
|
+
)}
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
);
|
|
361
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
async function request(method, url, body) {
|
|
2
|
+
const res = await fetch(url, {
|
|
3
|
+
method,
|
|
4
|
+
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
|
5
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
6
|
+
});
|
|
7
|
+
const data = await res.json().catch(() => ({}));
|
|
8
|
+
if (!res.ok) throw new Error(data.error || `${method} ${url}: ${res.status}`);
|
|
9
|
+
return data;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const api = {
|
|
13
|
+
status: () => request('GET', '/api/status'),
|
|
14
|
+
connect: options => request('POST', '/api/connect', options),
|
|
15
|
+
disconnect: () => request('POST', '/api/disconnect', {}),
|
|
16
|
+
selectPage: id => request('POST', '/api/select-page', { id }),
|
|
17
|
+
navigate: (url, signIn) => request('POST', '/api/navigate', { url, signIn }),
|
|
18
|
+
reload: () => request('POST', '/api/reload', {}),
|
|
19
|
+
start: options => request('POST', '/api/record/start', options),
|
|
20
|
+
stop: () => request('POST', '/api/record/stop', {}),
|
|
21
|
+
reports: () => request('GET', '/api/reports'),
|
|
22
|
+
report: id => request('GET', `/api/reports/${encodeURIComponent(id)}`),
|
|
23
|
+
evaluations: () => request('GET', '/api/evaluations'),
|
|
24
|
+
evaluation: id =>
|
|
25
|
+
request('GET', `/api/evaluations/${encodeURIComponent(id)}`),
|
|
26
|
+
saveTrace: id =>
|
|
27
|
+
request('POST', `/api/reports/${encodeURIComponent(id)}/trace`, {}),
|
|
28
|
+
gitCommit: rev =>
|
|
29
|
+
request('GET', `/api/git/commit?rev=${encodeURIComponent(rev)}`),
|
|
30
|
+
/** @returns {Promise<{blob: Blob, name: string}>} */
|
|
31
|
+
comparePdf: async (comparison, kind) => {
|
|
32
|
+
const res = await fetch('/api/compare/pdf', {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: { 'Content-Type': 'application/json' },
|
|
35
|
+
body: JSON.stringify({ comparison, kind }),
|
|
36
|
+
});
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
const data = await res.json().catch(() => ({}));
|
|
39
|
+
throw new Error(data.error || `PDF export failed (${res.status})`);
|
|
40
|
+
}
|
|
41
|
+
const disposition = res.headers.get('content-disposition') || '';
|
|
42
|
+
return {
|
|
43
|
+
blob: await res.blob(),
|
|
44
|
+
name: /filename="([^"]+)"/.exec(disposition)?.[1] || 'comparison.pdf',
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
deleteReport: id =>
|
|
48
|
+
request('DELETE', `/api/reports/${encodeURIComponent(id)}`),
|
|
49
|
+
source: path =>
|
|
50
|
+
request('GET', `/api/source?path=${encodeURIComponent(path)}`),
|
|
51
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { MODES, STYLES } from '../theme.js';
|
|
2
|
+
import Popover from './Popover.jsx';
|
|
3
|
+
|
|
4
|
+
const PaletteIcon = () => (
|
|
5
|
+
<svg
|
|
6
|
+
className="icon"
|
|
7
|
+
viewBox="0 0 16 16"
|
|
8
|
+
width="14"
|
|
9
|
+
height="14"
|
|
10
|
+
aria-hidden="true"
|
|
11
|
+
>
|
|
12
|
+
<path
|
|
13
|
+
fill="currentColor"
|
|
14
|
+
d="M8 1a7 7 0 1 0 0 14c1 0 1.5-.7 1.5-1.4 0-.9-.6-1.1-.6-1.9 0-.7.6-1.2 1.3-1.2H12a3 3 0 0 0 3-3C15 3.6 11.9 1 8 1Zm-4 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm2-3a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm2 3a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"
|
|
15
|
+
/>
|
|
16
|
+
</svg>
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
function ChoiceGroup({ name, legend, options, value, onChange, variant }) {
|
|
20
|
+
return (
|
|
21
|
+
<fieldset className="choice-group">
|
|
22
|
+
<legend>{legend}</legend>
|
|
23
|
+
<div className={variant}>
|
|
24
|
+
{options.map(option => (
|
|
25
|
+
<label
|
|
26
|
+
key={option.id}
|
|
27
|
+
className={value === option.id ? 'choice selected' : 'choice'}
|
|
28
|
+
>
|
|
29
|
+
<input
|
|
30
|
+
type="radio"
|
|
31
|
+
name={name}
|
|
32
|
+
value={option.id}
|
|
33
|
+
checked={value === option.id}
|
|
34
|
+
onChange={() => onChange(option.id)}
|
|
35
|
+
/>
|
|
36
|
+
{variant === 'theme-cards' && (
|
|
37
|
+
<span className={`swatch swatch-${option.id}`} aria-hidden="true">
|
|
38
|
+
<i />
|
|
39
|
+
<i />
|
|
40
|
+
</span>
|
|
41
|
+
)}
|
|
42
|
+
<span className="choice-label">{option.label}</span>
|
|
43
|
+
{option.description && (
|
|
44
|
+
<small className="muted">{option.description}</small>
|
|
45
|
+
)}
|
|
46
|
+
</label>
|
|
47
|
+
))}
|
|
48
|
+
</div>
|
|
49
|
+
</fieldset>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default function AppearanceMenu({ appearance, onChange }) {
|
|
54
|
+
return (
|
|
55
|
+
<Popover label="Appearance" icon={<PaletteIcon />}>
|
|
56
|
+
<div className="appearance">
|
|
57
|
+
<ChoiceGroup
|
|
58
|
+
name="theme-style"
|
|
59
|
+
legend="Theme"
|
|
60
|
+
options={STYLES}
|
|
61
|
+
value={appearance.style}
|
|
62
|
+
onChange={style => onChange({ style })}
|
|
63
|
+
variant="theme-cards"
|
|
64
|
+
/>
|
|
65
|
+
<ChoiceGroup
|
|
66
|
+
name="theme-mode"
|
|
67
|
+
legend="Mode"
|
|
68
|
+
options={MODES}
|
|
69
|
+
value={appearance.mode}
|
|
70
|
+
onChange={mode => onChange({ mode })}
|
|
71
|
+
variant="segmented-control"
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
</Popover>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { splitPath } from '../format.js';
|
|
2
|
+
|
|
3
|
+
/** `file:line` as a link that opens the file in the tool, plus an “open in editor” link. */
|
|
4
|
+
export default function CodeRef({ file, line, lineKind, abs, onOpenFile }) {
|
|
5
|
+
if (!file) return <span className="muted">unknown file</span>;
|
|
6
|
+
const { dir, name } = splitPath(file);
|
|
7
|
+
const label = `${line ? `${lineKind === 'located' ? '≈' : ''}${line}` : ''}`;
|
|
8
|
+
return (
|
|
9
|
+
<span className="code-ref">
|
|
10
|
+
{onOpenFile ? (
|
|
11
|
+
<button
|
|
12
|
+
type="button"
|
|
13
|
+
className="link mono"
|
|
14
|
+
onClick={() => onOpenFile(file)}
|
|
15
|
+
title={file}
|
|
16
|
+
>
|
|
17
|
+
<span className="muted">{dir}</span>
|
|
18
|
+
<b>{name}</b>
|
|
19
|
+
{label && <span>:{label}</span>}
|
|
20
|
+
</button>
|
|
21
|
+
) : (
|
|
22
|
+
<span className="mono" title={file}>
|
|
23
|
+
<span className="muted">{dir}</span>
|
|
24
|
+
<b>{name}</b>
|
|
25
|
+
{label && <span>:{label}</span>}
|
|
26
|
+
</span>
|
|
27
|
+
)}
|
|
28
|
+
{abs && (
|
|
29
|
+
<a
|
|
30
|
+
className="editor-link"
|
|
31
|
+
href={`vscode://file/${abs}${line ? `:${line}` : ''}`}
|
|
32
|
+
title="Open in VS Code"
|
|
33
|
+
>
|
|
34
|
+
↗ editor
|
|
35
|
+
</a>
|
|
36
|
+
)}
|
|
37
|
+
{lineKind === 'located' && (
|
|
38
|
+
<span
|
|
39
|
+
className="muted"
|
|
40
|
+
title="Webpack dev bundles have no source map, so the line was found by searching the file for this function's definition."
|
|
41
|
+
>
|
|
42
|
+
{' '}
|
|
43
|
+
(line found by name)
|
|
44
|
+
</span>
|
|
45
|
+
)}
|
|
46
|
+
</span>
|
|
47
|
+
);
|
|
48
|
+
}
|