@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,109 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
import { SEVERITY, topCulprits } from '../culprits.js';
|
|
3
|
+
import { fmtMs } from '../format.js';
|
|
4
|
+
import CodeRef from './CodeRef.jsx';
|
|
5
|
+
import CulpritSphere from './CulpritSphere.jsx';
|
|
6
|
+
|
|
7
|
+
const MAX_CULPRITS = 12;
|
|
8
|
+
|
|
9
|
+
function Insight({ item, onOpenFile, onJump }) {
|
|
10
|
+
const sev = SEVERITY[item.severity];
|
|
11
|
+
return (
|
|
12
|
+
<div className={`insight sev-${item.severity}`} aria-live="polite">
|
|
13
|
+
<div className="insight-head">
|
|
14
|
+
<span className="badge-sev">
|
|
15
|
+
<span aria-hidden="true">{sev.icon}</span> {sev.label}
|
|
16
|
+
</span>
|
|
17
|
+
<b className="insight-name">{item.name}()</b>
|
|
18
|
+
<span className="insight-ms">{fmtMs(item.ms)}</span>
|
|
19
|
+
</div>
|
|
20
|
+
<CodeRef
|
|
21
|
+
file={item.file}
|
|
22
|
+
line={item.line}
|
|
23
|
+
lineKind={item.lineKind}
|
|
24
|
+
abs={item.abs}
|
|
25
|
+
onOpenFile={onOpenFile}
|
|
26
|
+
/>
|
|
27
|
+
{item.categories.length > 0 && (
|
|
28
|
+
<p className="muted">
|
|
29
|
+
Appears in {item.findings.length} finding
|
|
30
|
+
{item.findings.length === 1 ? '' : 's'} · {item.categories.join(', ')}
|
|
31
|
+
</p>
|
|
32
|
+
)}
|
|
33
|
+
{item.lead && (
|
|
34
|
+
<p className="insight-fix">
|
|
35
|
+
<b>What to do:</b> {item.lead.fix}
|
|
36
|
+
</p>
|
|
37
|
+
)}
|
|
38
|
+
{item.lead && (
|
|
39
|
+
<button type="button" onClick={() => onJump(item.lead.id)}>
|
|
40
|
+
Show the finding
|
|
41
|
+
</button>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Top culprits: the sphere, a ranked list for exact reading, and one insight card. */
|
|
48
|
+
export default function CulpritsHero({
|
|
49
|
+
report,
|
|
50
|
+
hideVendor,
|
|
51
|
+
onOpenFile,
|
|
52
|
+
onJump,
|
|
53
|
+
}) {
|
|
54
|
+
const items = useMemo(
|
|
55
|
+
() => topCulprits(report, hideVendor, MAX_CULPRITS),
|
|
56
|
+
[report, hideVendor],
|
|
57
|
+
);
|
|
58
|
+
const [hoverKey, setHoverKey] = useState(null);
|
|
59
|
+
const [pickedKey, setPickedKey] = useState(null);
|
|
60
|
+
if (!items.length) return null;
|
|
61
|
+
const shown =
|
|
62
|
+
items.find(i => i.key === hoverKey) ||
|
|
63
|
+
items.find(i => i.key === pickedKey) ||
|
|
64
|
+
items[0];
|
|
65
|
+
const maxMs = Math.max(1, ...items.map(i => i.ms));
|
|
66
|
+
return (
|
|
67
|
+
<section className="culprits-hero" aria-label="Top culprits">
|
|
68
|
+
<div className="hero-sphere">
|
|
69
|
+
<h2>
|
|
70
|
+
Top culprits <span className="muted">· drag to rotate</span>
|
|
71
|
+
</h2>
|
|
72
|
+
<CulpritSphere
|
|
73
|
+
items={items}
|
|
74
|
+
activeKey={hoverKey}
|
|
75
|
+
highlightKey={shown.key}
|
|
76
|
+
onActivate={setHoverKey}
|
|
77
|
+
onSelect={setPickedKey}
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
<div className="hero-detail">
|
|
81
|
+
<Insight item={shown} onOpenFile={onOpenFile} onJump={onJump} />
|
|
82
|
+
<ol className="rank-list">
|
|
83
|
+
{items.map((item, i) => (
|
|
84
|
+
<li key={item.key}>
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
className={`rank-row sev-${item.severity} ${
|
|
88
|
+
shown.key === item.key ? 'active' : ''
|
|
89
|
+
}`}
|
|
90
|
+
onMouseEnter={() => setHoverKey(item.key)}
|
|
91
|
+
onMouseLeave={() => setHoverKey(null)}
|
|
92
|
+
onFocus={() => setHoverKey(item.key)}
|
|
93
|
+
onBlur={() => setHoverKey(null)}
|
|
94
|
+
onClick={() => setPickedKey(item.key)}
|
|
95
|
+
>
|
|
96
|
+
<span className="rank-n">{i + 1}</span>
|
|
97
|
+
<span className="rank-name">{item.name}()</span>
|
|
98
|
+
<span className="rank-bar">
|
|
99
|
+
<span style={{ width: `${(item.ms / maxMs) * 100}%` }} />
|
|
100
|
+
</span>
|
|
101
|
+
<span className="rank-ms">{fmtMs(item.ms)}</span>
|
|
102
|
+
</button>
|
|
103
|
+
</li>
|
|
104
|
+
))}
|
|
105
|
+
</ol>
|
|
106
|
+
</div>
|
|
107
|
+
</section>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { api } from '../api.js';
|
|
3
|
+
import { fmtMs } from '../format.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Golden-path evaluation view: lists the `spectra performance` runs, and for
|
|
7
|
+
* the selected one shows the performance matrix (baseline + each journey with
|
|
8
|
+
* its automation result and key metrics). Each row opens its full report.
|
|
9
|
+
*/
|
|
10
|
+
export default function EvaluationPanel({ evaluations, onOpenReport }) {
|
|
11
|
+
const [current, setCurrent] = useState(evaluations[0]?.id || '');
|
|
12
|
+
const [detail, setDetail] = useState(null);
|
|
13
|
+
const [error, setError] = useState('');
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!current) {
|
|
17
|
+
setDetail(null);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
let live = true;
|
|
21
|
+
setError('');
|
|
22
|
+
api
|
|
23
|
+
.evaluation(current)
|
|
24
|
+
.then(d => live && setDetail(d))
|
|
25
|
+
.catch(e => live && setError(e.message));
|
|
26
|
+
return () => {
|
|
27
|
+
live = false;
|
|
28
|
+
};
|
|
29
|
+
}, [current]);
|
|
30
|
+
|
|
31
|
+
if (!evaluations.length) {
|
|
32
|
+
return (
|
|
33
|
+
<div className="welcome">
|
|
34
|
+
<div>
|
|
35
|
+
<h2>No golden-path evaluations yet</h2>
|
|
36
|
+
<p className="welcome-lead">
|
|
37
|
+
Run <code>spectra performance</code> in your project. Spectra starts
|
|
38
|
+
your dev server, auto-discovers the actionable elements, drives a set
|
|
39
|
+
of golden-path journeys, and records each one. They’ll appear here as
|
|
40
|
+
a performance matrix — every path openable as a full report.
|
|
41
|
+
</p>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const rows = detail ? [detail.baseline, ...detail.paths] : [];
|
|
48
|
+
const passed = detail ? detail.paths.filter(p => p.ok).length : 0;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="eval-panel">
|
|
52
|
+
<div className="eval-head">
|
|
53
|
+
<select
|
|
54
|
+
aria-label="Evaluation run"
|
|
55
|
+
value={current}
|
|
56
|
+
onChange={e => setCurrent(e.target.value)}
|
|
57
|
+
>
|
|
58
|
+
{evaluations.map(e => (
|
|
59
|
+
<option key={e.id} value={e.id}>
|
|
60
|
+
{new Date(e.createdAt).toLocaleString()} · {e.passed}/{e.pathCount}{' '}
|
|
61
|
+
passed
|
|
62
|
+
</option>
|
|
63
|
+
))}
|
|
64
|
+
</select>
|
|
65
|
+
{detail && (
|
|
66
|
+
<span className="eval-summary">
|
|
67
|
+
<span className="pill">{detail.discovered} actionable elements</span>
|
|
68
|
+
<span className="pill">{detail.paths.length} golden paths</span>
|
|
69
|
+
<span className={`pill ${passed === detail.paths.length ? 'ok' : 'warn'}`}>
|
|
70
|
+
{passed}/{detail.paths.length} passed
|
|
71
|
+
</span>
|
|
72
|
+
{detail.skippedDestructive?.length > 0 && (
|
|
73
|
+
<span className="pill" title={detail.skippedDestructive.map(s => s.name).join(', ')}>
|
|
74
|
+
{detail.skippedDestructive.length} destructive skipped
|
|
75
|
+
</span>
|
|
76
|
+
)}
|
|
77
|
+
</span>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
{error && <div className="error" role="alert"><span>{error}</span></div>}
|
|
82
|
+
|
|
83
|
+
{detail && (
|
|
84
|
+
<>
|
|
85
|
+
<div className="eval-target" title={detail.url}>
|
|
86
|
+
{detail.target} — <span className="muted">{detail.url}</span>
|
|
87
|
+
</div>
|
|
88
|
+
<div className="eval-table-wrap">
|
|
89
|
+
<table className="eval-matrix">
|
|
90
|
+
<thead>
|
|
91
|
+
<tr>
|
|
92
|
+
<th>#</th>
|
|
93
|
+
<th>Golden path</th>
|
|
94
|
+
<th>Result</th>
|
|
95
|
+
<th className="num">TBT</th>
|
|
96
|
+
<th className="num">LCP</th>
|
|
97
|
+
<th className="num">Long tasks</th>
|
|
98
|
+
<th className="num">Findings</th>
|
|
99
|
+
<th>Top finding</th>
|
|
100
|
+
<th />
|
|
101
|
+
</tr>
|
|
102
|
+
</thead>
|
|
103
|
+
<tbody>
|
|
104
|
+
{rows.map((r, i) => {
|
|
105
|
+
const s = r.summary || {};
|
|
106
|
+
const top = s.topFinding;
|
|
107
|
+
return (
|
|
108
|
+
<tr key={r.id || i} className={r.ok ? '' : 'row-fail'}>
|
|
109
|
+
<td className="muted">{i === 0 ? '—' : i}</td>
|
|
110
|
+
<td>
|
|
111
|
+
<div className="eval-path-title">{r.title}</div>
|
|
112
|
+
{!r.ok && r.error && (
|
|
113
|
+
<div className="eval-path-error">{r.error}</div>
|
|
114
|
+
)}
|
|
115
|
+
</td>
|
|
116
|
+
<td>
|
|
117
|
+
<span className={`badge ${r.ok ? 'badge-pass' : 'badge-fail'}`}>
|
|
118
|
+
{r.ok ? 'PASS' : 'FAIL'}
|
|
119
|
+
</span>
|
|
120
|
+
</td>
|
|
121
|
+
<td className="num">{s.tbtMs == null ? '—' : fmtMs(s.tbtMs)}</td>
|
|
122
|
+
<td className="num">{s.lcpMs == null ? '—' : fmtMs(s.lcpMs)}</td>
|
|
123
|
+
<td className="num">{s.longTaskCount ?? '—'}</td>
|
|
124
|
+
<td className="num">{s.findingCount ?? '—'}</td>
|
|
125
|
+
<td>
|
|
126
|
+
{top ? (
|
|
127
|
+
<span className={`top-finding sev-${top.severity}`}>
|
|
128
|
+
[{top.severity}] {top.title}
|
|
129
|
+
</span>
|
|
130
|
+
) : (
|
|
131
|
+
<span className="muted">—</span>
|
|
132
|
+
)}
|
|
133
|
+
</td>
|
|
134
|
+
<td>
|
|
135
|
+
{r.id && (
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
className="link"
|
|
139
|
+
onClick={() => onOpenReport(r.id)}
|
|
140
|
+
>
|
|
141
|
+
Open report →
|
|
142
|
+
</button>
|
|
143
|
+
)}
|
|
144
|
+
</td>
|
|
145
|
+
</tr>
|
|
146
|
+
);
|
|
147
|
+
})}
|
|
148
|
+
</tbody>
|
|
149
|
+
</table>
|
|
150
|
+
</div>
|
|
151
|
+
<p className="muted eval-foot">
|
|
152
|
+
Automation: {passed}/{detail.paths.length} golden paths passed. Open any
|
|
153
|
+
row for its full report — culprits, React re-renders, long tasks, PDF
|
|
154
|
+
export and commit compare.
|
|
155
|
+
</p>
|
|
156
|
+
</>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { api } from '../api.js';
|
|
3
|
+
import { fmtMs, fmtNumber } from '../format.js';
|
|
4
|
+
import { highlightSource } from '../highlight.js';
|
|
5
|
+
|
|
6
|
+
function Metric({ label, value, warn }) {
|
|
7
|
+
return (
|
|
8
|
+
<div className={`metric ${warn ? 'warn' : ''}`}>
|
|
9
|
+
<div className="metric-value">{value}</div>
|
|
10
|
+
<div className="metric-label">{label}</div>
|
|
11
|
+
</div>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function Source({ path, functions }) {
|
|
16
|
+
const [state, setState] = useState({ status: 'loading', file: null });
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
let cancelled = false;
|
|
19
|
+
setState({ status: 'loading', file: null });
|
|
20
|
+
api
|
|
21
|
+
.source(path)
|
|
22
|
+
.then(file => !cancelled && setState({ status: 'ready', file }))
|
|
23
|
+
.catch(() => !cancelled && setState({ status: 'missing', file: null }));
|
|
24
|
+
return () => {
|
|
25
|
+
cancelled = true;
|
|
26
|
+
};
|
|
27
|
+
}, [path]);
|
|
28
|
+
|
|
29
|
+
// highlight.js escapes everything it emits and highlightSource escapes the
|
|
30
|
+
// fallback path, so the markup never contains raw file content.
|
|
31
|
+
const html = useMemo(
|
|
32
|
+
() => (state.file ? highlightSource(path, state.file.content) : ''),
|
|
33
|
+
[state.file, path],
|
|
34
|
+
);
|
|
35
|
+
const hot = useMemo(() => {
|
|
36
|
+
const map = new Map();
|
|
37
|
+
for (const fn of functions)
|
|
38
|
+
if (fn.line && fn.selfMs >= 0.5) map.set(fn.line, fn);
|
|
39
|
+
return map;
|
|
40
|
+
}, [functions]);
|
|
41
|
+
|
|
42
|
+
if (state.status === 'loading')
|
|
43
|
+
return <div className="empty">Loading source…</div>;
|
|
44
|
+
if (state.status === 'missing') {
|
|
45
|
+
return (
|
|
46
|
+
<div className="empty">
|
|
47
|
+
Source not found on disk for <code>{path}</code>. Bundled or generated
|
|
48
|
+
files have no source file.
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const lineCount = state.file.content.split('\n').length;
|
|
53
|
+
return (
|
|
54
|
+
<div className="code-scroll">
|
|
55
|
+
<div className="gutter">
|
|
56
|
+
{Array.from({ length: lineCount }, (_, i) => {
|
|
57
|
+
const fn = hot.get(i + 1);
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
key={i + 1}
|
|
61
|
+
className={`gutter-line ${fn ? 'gutter-hot' : ''}`}
|
|
62
|
+
title={fn ? `${fn.name}: ${fmtMs(fn.selfMs)} self` : undefined}
|
|
63
|
+
>
|
|
64
|
+
{fn ? `${fmtMs(fn.selfMs)} ` : ''}
|
|
65
|
+
{i + 1}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
})}
|
|
69
|
+
</div>
|
|
70
|
+
<pre className="code">
|
|
71
|
+
<code className="hljs" dangerouslySetInnerHTML={{ __html: html }} />
|
|
72
|
+
</pre>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default function FilePanel({ report, path, onClose }) {
|
|
78
|
+
const [tab, setTab] = useState('functions');
|
|
79
|
+
const file = report.files.find(f => f.path === path);
|
|
80
|
+
const relatedFindings = report.findings.filter(f => f.file === path);
|
|
81
|
+
const maxSelf = Math.max(
|
|
82
|
+
...(file?.functions.map(f => f.selfMs) || [0]),
|
|
83
|
+
0.001,
|
|
84
|
+
);
|
|
85
|
+
const canShowSource = !path.startsWith('bundle:') && !path.startsWith('(');
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<aside className="file-panel" aria-label="Selected file">
|
|
89
|
+
<div className="panel-head">
|
|
90
|
+
<h2 title={path}>{path}</h2>
|
|
91
|
+
<button type="button" onClick={onClose} aria-label="Close file panel">
|
|
92
|
+
×
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
{file ? (
|
|
96
|
+
<div className="metrics">
|
|
97
|
+
<Metric label="CPU self" value={fmtMs(file.selfMs)} />
|
|
98
|
+
<Metric label="CPU inclusive" value={fmtMs(file.totalMs)} />
|
|
99
|
+
<Metric
|
|
100
|
+
label="Blocking"
|
|
101
|
+
value={fmtMs(file.blockMs)}
|
|
102
|
+
warn={file.blockMs > 0}
|
|
103
|
+
/>
|
|
104
|
+
<Metric label="React renders" value={fmtNumber(file.renders)} />
|
|
105
|
+
<Metric
|
|
106
|
+
label="Wasted"
|
|
107
|
+
value={fmtNumber(file.wasted)}
|
|
108
|
+
warn={file.wasted > 0}
|
|
109
|
+
/>
|
|
110
|
+
<Metric
|
|
111
|
+
label="Forced reflows"
|
|
112
|
+
value={fmtNumber(file.forcedCount)}
|
|
113
|
+
warn={file.forcedCount > 0}
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
) : (
|
|
117
|
+
<p className="muted pad">
|
|
118
|
+
No profile samples were recorded for this file.
|
|
119
|
+
</p>
|
|
120
|
+
)}
|
|
121
|
+
{relatedFindings.length > 0 && (
|
|
122
|
+
<ul className="mini-findings">
|
|
123
|
+
{relatedFindings.map(f => (
|
|
124
|
+
<li key={f.id} className={`sev-${f.severity}`}>
|
|
125
|
+
{f.title}
|
|
126
|
+
<div className="muted">{f.fix}</div>
|
|
127
|
+
</li>
|
|
128
|
+
))}
|
|
129
|
+
</ul>
|
|
130
|
+
)}
|
|
131
|
+
<div role="tablist" className="tabs">
|
|
132
|
+
{[
|
|
133
|
+
['functions', 'Functions'],
|
|
134
|
+
['components', 'Components'],
|
|
135
|
+
['source', 'Source'],
|
|
136
|
+
].map(([id, label]) => (
|
|
137
|
+
<button
|
|
138
|
+
key={id}
|
|
139
|
+
type="button"
|
|
140
|
+
role="tab"
|
|
141
|
+
aria-selected={tab === id}
|
|
142
|
+
onClick={() => setTab(id)}
|
|
143
|
+
>
|
|
144
|
+
{label}
|
|
145
|
+
</button>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
<div className="panel-body">
|
|
149
|
+
{tab === 'functions' && (
|
|
150
|
+
<table className="data">
|
|
151
|
+
<thead>
|
|
152
|
+
<tr>
|
|
153
|
+
<th>Function</th>
|
|
154
|
+
<th>Self</th>
|
|
155
|
+
<th>Total</th>
|
|
156
|
+
</tr>
|
|
157
|
+
</thead>
|
|
158
|
+
<tbody>
|
|
159
|
+
{(file?.functions || []).map(fn => (
|
|
160
|
+
<tr key={`${fn.name}${fn.line}${fn.bundleLine}`}>
|
|
161
|
+
<td className="mono">
|
|
162
|
+
{fn.name}()
|
|
163
|
+
<span className="muted">
|
|
164
|
+
{' '}
|
|
165
|
+
{fn.line
|
|
166
|
+
? `:${fn.lineKind === 'located' ? '≈' : ''}${fn.line}`
|
|
167
|
+
: fn.bundleLine
|
|
168
|
+
? `(bundle line ${fn.bundleLine})`
|
|
169
|
+
: ''}
|
|
170
|
+
</span>
|
|
171
|
+
<div className="bar">
|
|
172
|
+
<span
|
|
173
|
+
style={{ width: `${(fn.selfMs / maxSelf) * 100}%` }}
|
|
174
|
+
/>
|
|
175
|
+
</div>
|
|
176
|
+
</td>
|
|
177
|
+
<td className="num">{fmtMs(fn.selfMs)}</td>
|
|
178
|
+
<td className="num">{fmtMs(fn.totalMs)}</td>
|
|
179
|
+
</tr>
|
|
180
|
+
))}
|
|
181
|
+
</tbody>
|
|
182
|
+
</table>
|
|
183
|
+
)}
|
|
184
|
+
{tab === 'components' &&
|
|
185
|
+
((file?.components.length || 0) === 0 ? (
|
|
186
|
+
<p className="muted pad">
|
|
187
|
+
No React components from this file rendered.
|
|
188
|
+
</p>
|
|
189
|
+
) : (
|
|
190
|
+
<table className="data">
|
|
191
|
+
<thead>
|
|
192
|
+
<tr>
|
|
193
|
+
<th>Component</th>
|
|
194
|
+
<th>Renders</th>
|
|
195
|
+
<th>Wasted</th>
|
|
196
|
+
<th>Self</th>
|
|
197
|
+
</tr>
|
|
198
|
+
</thead>
|
|
199
|
+
<tbody>
|
|
200
|
+
{file.components.map(c => (
|
|
201
|
+
<tr key={`${c.name}${c.line}`}>
|
|
202
|
+
<td className="mono">
|
|
203
|
+
{c.name}
|
|
204
|
+
{c.line ? <span className="muted">:{c.line}</span> : null}
|
|
205
|
+
</td>
|
|
206
|
+
<td className="num">{fmtNumber(c.renders)}</td>
|
|
207
|
+
<td className={`num ${c.wasted ? 'warn' : ''}`}>
|
|
208
|
+
{fmtNumber(c.wasted)}
|
|
209
|
+
</td>
|
|
210
|
+
<td className="num">{fmtMs(c.selfMs)}</td>
|
|
211
|
+
</tr>
|
|
212
|
+
))}
|
|
213
|
+
</tbody>
|
|
214
|
+
</table>
|
|
215
|
+
))}
|
|
216
|
+
{tab === 'source' &&
|
|
217
|
+
(canShowSource ? (
|
|
218
|
+
<Source path={path} functions={file?.functions || []} />
|
|
219
|
+
) : (
|
|
220
|
+
<div className="empty">This is a bundle, not a source file.</div>
|
|
221
|
+
))}
|
|
222
|
+
</div>
|
|
223
|
+
</aside>
|
|
224
|
+
);
|
|
225
|
+
}
|