@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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -2
  3. package/bin/spectra.js +215 -0
  4. package/dist/assets/index-BvBsPok5.js +46 -0
  5. package/dist/assets/index-Da3Le5Px.css +1 -0
  6. package/dist/index.html +14 -0
  7. package/index.html +13 -0
  8. package/package.json +76 -4
  9. package/server/analysis/cpu.js +369 -0
  10. package/server/analysis/findings.js +551 -0
  11. package/server/analysis/locate.js +99 -0
  12. package/server/analysis/report.js +750 -0
  13. package/server/analysis/sources.js +249 -0
  14. package/server/analysis/trace.js +314 -0
  15. package/server/api.js +292 -0
  16. package/server/chrome.js +81 -0
  17. package/server/cli.js +94 -0
  18. package/server/config.js +59 -0
  19. package/server/dashboard.js +154 -0
  20. package/server/explore/boot.js +150 -0
  21. package/server/explore/classify.js +169 -0
  22. package/server/explore/discover.js +162 -0
  23. package/server/explore/evaluate-cli.js +101 -0
  24. package/server/explore/evaluate.js +165 -0
  25. package/server/explore/paths.js +105 -0
  26. package/server/explore/runner.js +94 -0
  27. package/server/explore/test-explore.js +139 -0
  28. package/server/explore/test-paths.js +75 -0
  29. package/server/framework.js +67 -0
  30. package/server/git.js +59 -0
  31. package/server/pdf/render.js +60 -0
  32. package/server/pdf/template.js +419 -0
  33. package/server/plugin.js +19 -0
  34. package/server/probe.browser.js +689 -0
  35. package/server/recorder.js +610 -0
  36. package/server/signin.js +27 -0
  37. package/server/test-analysis.js +424 -0
  38. package/server/test-compare.js +473 -0
  39. package/server/test-sources.js +136 -0
  40. package/shared/compare.js +977 -0
  41. package/shared/snapshot.js +77 -0
  42. package/src/App.jsx +361 -0
  43. package/src/api.js +51 -0
  44. package/src/components/AppearanceMenu.jsx +76 -0
  45. package/src/components/CodeRef.jsx +48 -0
  46. package/src/components/ComparePanel.jsx +143 -0
  47. package/src/components/CompareResults.jsx +473 -0
  48. package/src/components/ControlBar.jsx +336 -0
  49. package/src/components/CulpritSphere.jsx +218 -0
  50. package/src/components/Culprits.jsx +118 -0
  51. package/src/components/CulpritsHero.jsx +109 -0
  52. package/src/components/EvaluationPanel.jsx +160 -0
  53. package/src/components/FilePanel.jsx +225 -0
  54. package/src/components/FindingsList.jsx +381 -0
  55. package/src/components/HeatTrees.jsx +222 -0
  56. package/src/components/Icons.jsx +58 -0
  57. package/src/components/LongTasks.jsx +199 -0
  58. package/src/components/NetworkPanel.jsx +98 -0
  59. package/src/components/PageMap.jsx +329 -0
  60. package/src/components/Popover.jsx +51 -0
  61. package/src/components/ReactPanel.jsx +200 -0
  62. package/src/components/SidePicker.jsx +207 -0
  63. package/src/components/SnapshotButton.jsx +140 -0
  64. package/src/components/TimeBreakdown.jsx +45 -0
  65. package/src/components/TraceButton.jsx +76 -0
  66. package/src/components/TreeView.jsx +173 -0
  67. package/src/components/Vitals.jsx +163 -0
  68. package/src/culprits.js +127 -0
  69. package/src/download.js +18 -0
  70. package/src/format.js +26 -0
  71. package/src/heat.js +177 -0
  72. package/src/highlight.js +45 -0
  73. package/src/hooks/useCompare.js +187 -0
  74. package/src/hooks/useSession.js +73 -0
  75. package/src/layout.js +55 -0
  76. package/src/main.jsx +15 -0
  77. package/src/styles.css +2478 -0
  78. package/src/theme.js +75 -0
  79. package/src/themes.css +286 -0
  80. 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_&lt;commit&gt;_…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
+ }