@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,336 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { api } from '../api.js';
3
+ import { ChevronIcon, PageLoadIcon, RecordIcon, StopIcon } from './Icons.jsx';
4
+ import Popover from './Popover.jsx';
5
+
6
+ const URL_KEY = 'perf-profiler:url';
7
+ const SIGNIN_KEY = 'perf-profiler:sign-in';
8
+ const readSignIn = () => localStorage.getItem(SIGNIN_KEY) || '';
9
+
10
+ function Elapsed({ startedAt }) {
11
+ const [now, setNow] = useState(Date.now());
12
+ useEffect(() => {
13
+ // Ticks the on-screen recording timer; cleared on unmount.
14
+ const id = setInterval(() => setNow(Date.now()), 250);
15
+ return () => clearInterval(id);
16
+ }, []);
17
+ return (
18
+ <span className="elapsed">{((now - startedAt) / 1000).toFixed(1)} s</span>
19
+ );
20
+ }
21
+
22
+ function ProbePill({ probe }) {
23
+ if (!probe) return null;
24
+ if (probe.react) {
25
+ return (
26
+ <span
27
+ className="pill ok"
28
+ title="React was detected; per-component render data will be recorded."
29
+ >
30
+ ✓ React {probe.version}
31
+ {probe.timings ? '' : ' · no timings (production build)'}
32
+ </span>
33
+ );
34
+ }
35
+ // If a non-React framework was detected, show that instead of the reload hint
36
+ if (probe.framework && probe.framework.name) {
37
+ return (
38
+ <span className="pill ok" title={`Detected ${probe.framework.name}`}>
39
+ ✓ {probe.framework.name} {probe.framework.version || ''}
40
+ </span>
41
+ );
42
+ }
43
+ return (
44
+ <span
45
+ className="pill warn"
46
+ title="The probe must load before React. Use Reload, or “Record page load”."
47
+ >
48
+ ! No React seen — reload
49
+ </span>
50
+ );
51
+ }
52
+
53
+ function Connect({ status, run, isCompact }) {
54
+ const [url, setUrl] = useState(
55
+ () => localStorage.getItem(URL_KEY) || status.defaultUrl || '',
56
+ );
57
+ const [headless, setHeadless] = useState(false);
58
+ const [browserURL, setBrowserURL] = useState('http://127.0.0.1:9222');
59
+ const [busy, setBusy] = useState(false);
60
+ const [signIn, setSignIn] = useState(readSignIn);
61
+ const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
62
+ const go = async options => {
63
+ if (options.url) localStorage.setItem(URL_KEY, options.url);
64
+ localStorage.setItem(SIGNIN_KEY, signIn);
65
+ setBusy(true);
66
+ await run(() => api.connect(options));
67
+ setBusy(false);
68
+ };
69
+ return (
70
+ <div className="connect">
71
+ {!isCompact && (
72
+ <div className="connect-head">
73
+ <h2>Connect to a browser</h2>
74
+ <p className="muted">
75
+ Launch a fresh Chrome window, sign in, and open the page you want to
76
+ profile.
77
+ </p>
78
+ </div>
79
+ )}
80
+ <div className="connect-main">
81
+ <label className="field">
82
+ Page to profile
83
+ <input
84
+ type="url"
85
+ value={url}
86
+ onChange={e => setUrl(e.target.value)}
87
+ placeholder="http://localhost:3000/"
88
+ />
89
+ </label>
90
+ <button
91
+ type="button"
92
+ className="primary"
93
+ disabled={busy}
94
+ onClick={() => go({ mode: 'launch', url, headless, signIn })}
95
+ >
96
+ {busy ? 'Starting Chrome…' : 'Launch Chrome'}
97
+ </button>
98
+ <button
99
+ type="button"
100
+ className="ghost"
101
+ aria-expanded={isAdvancedOpen}
102
+ onClick={() => setIsAdvancedOpen(open => !open)}
103
+ >
104
+ Advanced <ChevronIcon isUp={isAdvancedOpen} />
105
+ </button>
106
+ </div>
107
+ {isAdvancedOpen && (
108
+ <div className="advanced">
109
+ <div className="advanced-section">
110
+ <div className="advanced-row">
111
+ <label
112
+ className="field"
113
+ title="Used automatically when the page fails to load or lands on a login screen. {url} is replaced with the page above. Do not put tokens in it."
114
+ >
115
+ Sign-in URL <span className="muted">(optional)</span>
116
+ <input
117
+ value={signIn}
118
+ onChange={e => setSignIn(e.target.value)}
119
+ placeholder="http://localhost:3000/login/?redirect={url}"
120
+ />
121
+ </label>
122
+ <label className="switch">
123
+ <input
124
+ type="checkbox"
125
+ role="switch"
126
+ checked={headless}
127
+ onChange={e => setHeadless(e.target.checked)}
128
+ />
129
+ Headless
130
+ </label>
131
+ </div>
132
+ <span className="muted">
133
+ The Chrome window starts signed out every time, so give it your
134
+ app’s login URL once; {'{url}'} is where the page to profile goes.
135
+ </span>
136
+ </div>
137
+ <div className="advanced-section">
138
+ <div className="advanced-row">
139
+ <label className="field">
140
+ Attach to a running Chrome
141
+ <input
142
+ value={browserURL}
143
+ onChange={e => setBrowserURL(e.target.value)}
144
+ />
145
+ </label>
146
+ <button
147
+ type="button"
148
+ disabled={busy}
149
+ onClick={() => go({ mode: 'attach', browserURL })}
150
+ >
151
+ Attach
152
+ </button>
153
+ </div>
154
+ <span className="muted">
155
+ Start it with <code>--remote-debugging-port=9222</code>
156
+ </span>
157
+ </div>
158
+ </div>
159
+ )}
160
+ </div>
161
+ );
162
+ }
163
+
164
+ function RecordingOptions({ throttle, setThrottle, saveTrace, setSaveTrace }) {
165
+ return (
166
+ <Popover label="Options" align="left">
167
+ <div className="options-list">
168
+ <label className="field">
169
+ <span className="field-label">CPU throttling</span>
170
+ <select
171
+ value={throttle}
172
+ onChange={e => setThrottle(Number(e.target.value))}
173
+ >
174
+ <option value={1}>No throttling</option>
175
+ <option value={4}>4× slowdown</option>
176
+ <option value={6}>6× slowdown</option>
177
+ </select>
178
+ </label>
179
+ <label className="switch">
180
+ <input
181
+ type="checkbox"
182
+ role="switch"
183
+ checked={saveTrace}
184
+ onChange={e => setSaveTrace(e.target.checked)}
185
+ />
186
+ Save raw trace for DevTools
187
+ </label>
188
+ </div>
189
+ </Popover>
190
+ );
191
+ }
192
+
193
+ function RecordRow({ status, run }) {
194
+ const [throttle, setThrottle] = useState(1);
195
+ const [saveTrace, setSaveTrace] = useState(false);
196
+ const { state } = status;
197
+ const options = mode => ({ mode, cpuThrottle: throttle, saveTrace });
198
+ if (state === 'recording')
199
+ return (
200
+ <div className="rec-strip recording">
201
+ <span className="rec">
202
+ <i /> Recording
203
+ {status.recording?.mode === 'load' ? ' page load' : ''}{' '}
204
+ <Elapsed startedAt={status.recording.startedAt} />
205
+ </span>
206
+ <span className="muted">
207
+ {status.recording?.mode === 'load'
208
+ ? 'Stops on its own a few seconds after the page finishes loading.'
209
+ : 'Use the page now — click, scroll, type — then stop.'}
210
+ </span>
211
+ <span className="spacer" />
212
+ <button type="button" className="danger" onClick={() => run(api.stop)}>
213
+ <StopIcon /> Stop &amp; analyse
214
+ </button>
215
+ </div>
216
+ );
217
+ if (state === 'analyzing')
218
+ return (
219
+ <div className="rec-strip">
220
+ <span className="rec">
221
+ <i className="spin" /> Analysing trace, CPU samples and source maps…
222
+ </span>
223
+ </div>
224
+ );
225
+ return (
226
+ <div className="rec-strip">
227
+ <button
228
+ type="button"
229
+ className="primary"
230
+ onClick={() => run(() => api.start(options('live')))}
231
+ >
232
+ <RecordIcon /> Record interaction
233
+ </button>
234
+ <button
235
+ type="button"
236
+ onClick={() => run(() => api.start(options('load')))}
237
+ >
238
+ <PageLoadIcon /> Record page load
239
+ </button>
240
+ <RecordingOptions
241
+ throttle={throttle}
242
+ setThrottle={setThrottle}
243
+ saveTrace={saveTrace}
244
+ setSaveTrace={setSaveTrace}
245
+ />
246
+ {throttle > 1 && (
247
+ <span className="pill warn">{throttle}× CPU slowdown</span>
248
+ )}
249
+ </div>
250
+ );
251
+ }
252
+
253
+ export default function ControlBar({ status, run, isCompact }) {
254
+ const [address, setAddress] = useState('');
255
+ useEffect(() => {
256
+ if (status?.pageUrl) setAddress(status.pageUrl);
257
+ }, [status?.pageUrl]);
258
+
259
+ if (!status)
260
+ return (
261
+ <section className="session">
262
+ <span className="muted">Connecting to the profiler server…</span>
263
+ </section>
264
+ );
265
+ if (!status.connected)
266
+ return (
267
+ <section className="session">
268
+ <Connect status={status} run={run} isCompact={isCompact} />
269
+ </section>
270
+ );
271
+
272
+ const failed =
273
+ status.navigation && status.navigation.status >= 400
274
+ ? status.navigation
275
+ : null;
276
+ const idle = status.state === 'idle';
277
+ return (
278
+ <section className="session" aria-label="Browser session">
279
+ {failed && (
280
+ <div className="error" role="alert">
281
+ <span>
282
+ The page answered <b>HTTP {failed.status}</b> ({failed.url}). You
283
+ are probably not signed in. Press Go to retry (the saved Sign-in URL
284
+ is applied automatically), or sign in by hand in the Chrome window.
285
+ If no Sign-in URL was saved, close Chrome and launch again with one.
286
+ </span>
287
+ </div>
288
+ )}
289
+ <div className="session-row">
290
+ <select
291
+ aria-label="Browser tab"
292
+ value={status.pages.find(p => p.selected)?.id ?? ''}
293
+ disabled={!idle}
294
+ onChange={e => run(() => api.selectPage(Number(e.target.value)))}
295
+ >
296
+ {status.pages.map(p => (
297
+ <option key={p.id} value={p.id}>
298
+ {p.title || p.url}
299
+ </option>
300
+ ))}
301
+ </select>
302
+ <input
303
+ className="address"
304
+ aria-label="Page URL"
305
+ value={address}
306
+ disabled={!idle}
307
+ onChange={e => setAddress(e.target.value)}
308
+ onKeyDown={e =>
309
+ e.key === 'Enter' && run(() => api.navigate(address, readSignIn()))
310
+ }
311
+ />
312
+ <button
313
+ type="button"
314
+ disabled={!idle}
315
+ onClick={() => run(() => api.navigate(address, readSignIn()))}
316
+ >
317
+ Go
318
+ </button>
319
+ <button type="button" disabled={!idle} onClick={() => run(api.reload)}>
320
+ Reload
321
+ </button>
322
+ <ProbePill probe={status.probe} />
323
+ <button
324
+ type="button"
325
+ className="ghost destructive"
326
+ onClick={() => run(api.disconnect)}
327
+ >
328
+ {status.launched ? 'Close Chrome' : 'Detach'}
329
+ </button>
330
+ </div>
331
+ <div className="session-row">
332
+ <RecordRow status={status} run={run} />
333
+ </div>
334
+ </section>
335
+ );
336
+ }
@@ -0,0 +1,218 @@
1
+ import { useEffect, useMemo, useRef } from 'react';
2
+ import { fmtMs } from '../format.js';
3
+
4
+ const FOV = 3;
5
+ const RADIUS = 0.8;
6
+ const AUTO_SPIN = 0.00035;
7
+ const DRAG_SPEED = 0.008;
8
+ const MAX_TILT = 1.2;
9
+ const LAT_DEG = [-60, -30, 0, 30, 60];
10
+ const MERIDIANS = 6;
11
+ const RING_POINTS = 48;
12
+ const MAX_NAME = 20;
13
+
14
+ // Evenly spread points on a unit sphere (Fibonacci lattice).
15
+ function latticePoints(n) {
16
+ const golden = Math.PI * (3 - Math.sqrt(5));
17
+ return Array.from({ length: n }, (_, i) => {
18
+ const y = n === 1 ? 0 : 1 - (2 * (i + 0.5)) / n;
19
+ const r = Math.sqrt(Math.max(0, 1 - y * y));
20
+ return [Math.cos(i * golden) * r, y, Math.sin(i * golden) * r];
21
+ });
22
+ }
23
+
24
+ function rotate([x, y, z], ax, ay) {
25
+ const cy = Math.cos(ay);
26
+ const sy = Math.sin(ay);
27
+ const x1 = x * cy + z * sy;
28
+ const z1 = -x * sy + z * cy;
29
+ const cx = Math.cos(ax);
30
+ const sx = Math.sin(ax);
31
+ return [x1, y * cx - z1 * sx, y * sx + z1 * cx];
32
+ }
33
+
34
+ // Perspective: nearer points are bigger and further from the centre.
35
+ function project(p, ax, ay) {
36
+ const [x, y, z] = rotate(p, ax, ay);
37
+ const k = FOV / (FOV - z);
38
+ return { x: x * k * RADIUS, y: y * k * RADIUS, z, k };
39
+ }
40
+
41
+ function ringPoints(fn) {
42
+ return Array.from({ length: RING_POINTS + 1 }, (_, i) =>
43
+ fn((i / RING_POINTS) * Math.PI * 2),
44
+ );
45
+ }
46
+
47
+ const WIREFRAME = [
48
+ ...LAT_DEG.map(deg => {
49
+ const lat = (deg * Math.PI) / 180;
50
+ return ringPoints(t => [
51
+ Math.cos(lat) * Math.cos(t),
52
+ Math.sin(lat),
53
+ Math.cos(lat) * Math.sin(t),
54
+ ]);
55
+ }),
56
+ ...Array.from({ length: MERIDIANS }, (_, m) => {
57
+ const lon = (m / MERIDIANS) * Math.PI;
58
+ return ringPoints(t => [
59
+ Math.cos(t) * Math.cos(lon),
60
+ Math.sin(t),
61
+ Math.cos(t) * Math.sin(lon),
62
+ ]);
63
+ }),
64
+ ];
65
+
66
+ const shortName = name =>
67
+ name.length > MAX_NAME ? `${name.slice(0, MAX_NAME - 1)}…` : name;
68
+
69
+ /**
70
+ * Culprits as labels on a slowly rotating sphere. Drag to spin it; hovering a
71
+ * label pauses it. The motion is driven straight onto the DOM nodes (no React
72
+ * render per frame) and is off entirely for people who ask for reduced motion.
73
+ */
74
+ export default function CulpritSphere({
75
+ items,
76
+ activeKey,
77
+ highlightKey,
78
+ onActivate,
79
+ onSelect,
80
+ }) {
81
+ const points = useMemo(() => latticePoints(items.length), [items.length]);
82
+ const maxMs = Math.max(1, ...items.map(i => i.ms));
83
+ const labelRefs = useRef([]);
84
+ const pathRefs = useRef([]);
85
+ const motion = useRef({
86
+ ax: -0.35,
87
+ ay: 0,
88
+ vy: 0,
89
+ vx: 0,
90
+ isDragging: false,
91
+ isPaused: false,
92
+ });
93
+ const drag = useRef({ x: 0, y: 0 });
94
+
95
+ useEffect(() => {
96
+ motion.current.isPaused = Boolean(activeKey);
97
+ }, [activeKey]);
98
+
99
+ useEffect(() => {
100
+ const reduced = window.matchMedia(
101
+ '(prefers-reduced-motion: reduce)',
102
+ ).matches;
103
+ const paint = () => {
104
+ const { ax, ay } = motion.current;
105
+ points.forEach((p, i) => {
106
+ const el = labelRefs.current[i];
107
+ if (!el) return;
108
+ const q = project(p, ax, ay);
109
+ const depth = (q.z + 1) / 2;
110
+ el.style.left = `${50 + q.x * 50}%`;
111
+ el.style.top = `${50 + q.y * 50}%`;
112
+ el.style.transform = `translate(-50%, -50%) scale(${0.7 + depth * 0.45})`;
113
+ el.style.opacity = String(0.16 + depth * 0.84);
114
+ el.style.zIndex = String(Math.round(depth * 100));
115
+ el.style.pointerEvents = q.z > -0.35 ? 'auto' : 'none';
116
+ });
117
+ WIREFRAME.forEach((ring, i) => {
118
+ const path = pathRefs.current[i];
119
+ if (!path) return;
120
+ path.setAttribute(
121
+ 'd',
122
+ ring
123
+ .map((p, j) => {
124
+ const q = project(p, ax, ay);
125
+ return `${j ? 'L' : 'M'}${q.x.toFixed(3)} ${q.y.toFixed(3)}`;
126
+ })
127
+ .join(''),
128
+ );
129
+ });
130
+ };
131
+ paint();
132
+ // requestAnimationFrame: this is a continuous animation with no event to hook into.
133
+ let frame = 0;
134
+ let last = performance.now();
135
+ const tick = now => {
136
+ const m = motion.current;
137
+ const dt = Math.min(64, now - last);
138
+ last = now;
139
+ const isSpinning = !reduced && !m.isPaused && !m.isDragging;
140
+ const isCoasting = Math.abs(m.vy) > 1e-4 || Math.abs(m.vx) > 1e-4;
141
+ if (isSpinning || isCoasting) {
142
+ m.ay += (isSpinning ? AUTO_SPIN * dt : 0) + m.vy;
143
+ m.ax = Math.max(-MAX_TILT, Math.min(MAX_TILT, m.ax + m.vx));
144
+ if (!m.isDragging) {
145
+ m.vy *= 0.94;
146
+ m.vx *= 0.94;
147
+ }
148
+ paint();
149
+ }
150
+ frame = requestAnimationFrame(tick);
151
+ };
152
+ frame = requestAnimationFrame(tick);
153
+ return () => cancelAnimationFrame(frame);
154
+ }, [points]);
155
+
156
+ const handlePointerDown = e => {
157
+ if (e.target.closest('button')) return;
158
+ e.currentTarget.setPointerCapture(e.pointerId);
159
+ motion.current.isDragging = true;
160
+ drag.current = { x: e.clientX, y: e.clientY };
161
+ };
162
+ const handlePointerMove = e => {
163
+ const m = motion.current;
164
+ if (!m.isDragging) return;
165
+ m.vy = (e.clientX - drag.current.x) * DRAG_SPEED;
166
+ m.vx = -(e.clientY - drag.current.y) * DRAG_SPEED;
167
+ drag.current = { x: e.clientX, y: e.clientY };
168
+ m.ay += m.vy;
169
+ m.ax = Math.max(-MAX_TILT, Math.min(MAX_TILT, m.ax + m.vx));
170
+ };
171
+ const handlePointerUp = () => {
172
+ motion.current.isDragging = false;
173
+ };
174
+
175
+ return (
176
+ <div
177
+ className="sphere"
178
+ role="group"
179
+ aria-label="Top culprits on a rotating sphere. Drag to rotate."
180
+ onPointerDown={handlePointerDown}
181
+ onPointerMove={handlePointerMove}
182
+ onPointerUp={handlePointerUp}
183
+ onPointerCancel={handlePointerUp}
184
+ >
185
+ <div className="sphere-glow" aria-hidden="true" />
186
+ <svg className="sphere-wire" viewBox="-1 -1 2 2" aria-hidden="true">
187
+ {WIREFRAME.map((_, i) => (
188
+ <path
189
+ key={i}
190
+ ref={el => {
191
+ pathRefs.current[i] = el;
192
+ }}
193
+ />
194
+ ))}
195
+ </svg>
196
+ {items.map((item, i) => (
197
+ <button
198
+ key={item.key}
199
+ ref={el => {
200
+ labelRefs.current[i] = el;
201
+ }}
202
+ type="button"
203
+ className={`orb sev-${item.severity} ${highlightKey === item.key ? 'active' : ''}`}
204
+ style={{ fontSize: `${0.7 + 0.28 * Math.sqrt(item.ms / maxMs)}rem` }}
205
+ title={item.name}
206
+ onMouseEnter={() => onActivate(item.key)}
207
+ onMouseLeave={() => onActivate(null)}
208
+ onFocus={() => onActivate(item.key)}
209
+ onBlur={() => onActivate(null)}
210
+ onClick={() => onSelect(item.key)}
211
+ >
212
+ <span className="orb-name">{shortName(item.name)}</span>
213
+ <span className="orb-ms">{fmtMs(item.ms)}</span>
214
+ </button>
215
+ ))}
216
+ </div>
217
+ );
218
+ }
@@ -0,0 +1,118 @@
1
+ import { useState } from 'react';
2
+ import { fmtMs } from '../format.js';
3
+ import CodeRef from './CodeRef.jsx';
4
+ import CulpritsHero from './CulpritsHero.jsx';
5
+ import FindingsList from './FindingsList.jsx';
6
+ import TimeBreakdown from './TimeBreakdown.jsx';
7
+ import Vitals from './Vitals.jsx';
8
+
9
+ function HotFunctions({ report, hideVendor, onOpenFile }) {
10
+ const rows = (report.hotFunctions || [])
11
+ .filter(f => !(hideVendor && f.vendor))
12
+ .slice(0, 15);
13
+ if (!rows.length) return null;
14
+ return (
15
+ <section>
16
+ <h2>
17
+ Slowest functions{' '}
18
+ <span className="muted">(by time spent in the function itself)</span>
19
+ </h2>
20
+ <table className="data hot-functions">
21
+ <thead>
22
+ <tr>
23
+ <th>#</th>
24
+ <th>Function</th>
25
+ <th>File</th>
26
+ <th>Self</th>
27
+ <th>With callees</th>
28
+ <th>% of CPU</th>
29
+ </tr>
30
+ </thead>
31
+ <tbody>
32
+ {rows.map((f, i) => (
33
+ <tr
34
+ key={`${f.file}|${f.name}|${f.bundleLine}`}
35
+ className={f.vendor ? 'vendor-row' : ''}
36
+ >
37
+ <td className="num">{i + 1}</td>
38
+ <td>
39
+ <code className="fn">{f.name}()</code>
40
+ </td>
41
+ <td>
42
+ <CodeRef
43
+ file={f.file}
44
+ line={f.line}
45
+ lineKind={f.lineKind}
46
+ abs={f.abs}
47
+ onOpenFile={onOpenFile}
48
+ />
49
+ </td>
50
+ <td className="num">{fmtMs(f.selfMs)}</td>
51
+ <td className="num">{fmtMs(f.totalMs)}</td>
52
+ <td className="num">{f.share}%</td>
53
+ </tr>
54
+ ))}
55
+ </tbody>
56
+ </table>
57
+ </section>
58
+ );
59
+ }
60
+
61
+ export default function Culprits({ report, hideVendor, onOpenFile }) {
62
+ const [focus, setFocus] = useState(null);
63
+ const counts = report.findings.reduce(
64
+ (acc, f) => ({ ...acc, [f.severity]: (acc[f.severity] || 0) + 1 }),
65
+ {},
66
+ );
67
+ return (
68
+ <div className="culprits">
69
+ <CulpritsHero
70
+ report={report}
71
+ hideVendor={hideVendor}
72
+ onOpenFile={onOpenFile}
73
+ onJump={id => setFocus(prev => ({ id, tick: (prev?.tick || 0) + 1 }))}
74
+ />
75
+ <Vitals report={report} />
76
+ <section>
77
+ <h2>Where main-thread time went</h2>
78
+ <TimeBreakdown totals={report.totals} />
79
+ </section>
80
+ <HotFunctions
81
+ report={report}
82
+ hideVendor={hideVendor}
83
+ onOpenFile={onOpenFile}
84
+ />
85
+ <section>
86
+ <h2>
87
+ Findings{' '}
88
+ <span className="muted">
89
+ {counts.critical || 0} critical · {counts.warning || 0} warnings ·{' '}
90
+ {counts.info || 0} notes
91
+ </span>
92
+ </h2>
93
+ {report.findings.length === 0 ? (
94
+ <p className="muted">
95
+ Nothing stood out in this recording. Try a longer or more demanding
96
+ interaction.
97
+ </p>
98
+ ) : (
99
+ <FindingsList
100
+ findings={report.findings}
101
+ onOpenFile={onOpenFile}
102
+ focus={focus}
103
+ />
104
+ )}
105
+ </section>
106
+ {report.notes.length > 0 && (
107
+ <section>
108
+ <h2>Notes</h2>
109
+ <ul>
110
+ {report.notes.map(n => (
111
+ <li key={n}>{n}</li>
112
+ ))}
113
+ </ul>
114
+ </section>
115
+ )}
116
+ </div>
117
+ );
118
+ }