@progen-labs/spectra 0.0.0-stage → 0.1.1
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 +100 -2
- package/bin/spectra.js +215 -0
- package/dist/assets/index-D0Lxrmdt.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 +570 -0
- package/server/analysis/locate.js +99 -0
- package/server/analysis/report.js +775 -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 +828 -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 +120 -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 +206 -0
- package/src/components/SidePicker.jsx +207 -0
- package/src/components/SnapshotButton.jsx +140 -0
- package/src/components/SpectraCulprits.jsx +39 -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
package/src/heat.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { fmtMs, fmtNumber } from './format.js';
|
|
2
|
+
|
|
3
|
+
export const FILE_METRICS = [
|
|
4
|
+
{
|
|
5
|
+
id: 'selfMs',
|
|
6
|
+
label: 'CPU self time',
|
|
7
|
+
unit: 'ms',
|
|
8
|
+
hint: 'JavaScript executed by functions in this file (excluding callees).',
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
id: 'blockMs',
|
|
12
|
+
label: 'Main-thread blocking',
|
|
13
|
+
unit: 'ms',
|
|
14
|
+
hint: 'Self time inside long tasks (>50 ms): what made the page unresponsive.',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: 'reactMs',
|
|
18
|
+
label: 'React render time',
|
|
19
|
+
unit: 'ms',
|
|
20
|
+
hint: 'Time React spent rendering components defined in this file (self time).',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'wasted',
|
|
24
|
+
label: 'Wasted re-renders',
|
|
25
|
+
unit: '',
|
|
26
|
+
hint: 'Renders where props, state and context were identical.',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'renders',
|
|
30
|
+
label: 'React renders',
|
|
31
|
+
unit: '',
|
|
32
|
+
hint: 'How many times components in this file rendered.',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'forcedCount',
|
|
36
|
+
label: 'Forced reflows',
|
|
37
|
+
unit: '',
|
|
38
|
+
hint: 'Synchronous layouts/style recalcs triggered by code in this file.',
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
export const formatMetric = (metric, value) =>
|
|
43
|
+
metric.unit === 'ms' ? fmtMs(value) : fmtNumber(Math.round(value));
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Directory tree of the profiled files, aggregated by `metric` and sorted
|
|
47
|
+
* hottest first. Single-child directory chains are merged.
|
|
48
|
+
*/
|
|
49
|
+
export function buildFileTree(files, metric, { hideVendor, rootName = 'target-frontend' } = {}) {
|
|
50
|
+
const root = {
|
|
51
|
+
id: '',
|
|
52
|
+
name: rootName,
|
|
53
|
+
isDir: true,
|
|
54
|
+
children: [],
|
|
55
|
+
parent: null,
|
|
56
|
+
value: 0,
|
|
57
|
+
};
|
|
58
|
+
const dirs = new Map([['', root]]);
|
|
59
|
+
const dirFor = dirPath => {
|
|
60
|
+
let node = dirs.get(dirPath);
|
|
61
|
+
if (node) return node;
|
|
62
|
+
const slash = dirPath.lastIndexOf('/');
|
|
63
|
+
const parent = dirFor(slash < 0 ? '' : dirPath.slice(0, slash));
|
|
64
|
+
node = {
|
|
65
|
+
id: dirPath,
|
|
66
|
+
name: dirPath.slice(slash + 1),
|
|
67
|
+
isDir: true,
|
|
68
|
+
children: [],
|
|
69
|
+
parent,
|
|
70
|
+
value: 0,
|
|
71
|
+
};
|
|
72
|
+
parent.children.push(node);
|
|
73
|
+
dirs.set(dirPath, node);
|
|
74
|
+
return node;
|
|
75
|
+
};
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
if (hideVendor && file.vendor) continue;
|
|
78
|
+
const value = file[metric.id] || 0;
|
|
79
|
+
if (value <= 0) continue;
|
|
80
|
+
const slash = file.path.lastIndexOf('/');
|
|
81
|
+
const parent = dirFor(slash < 0 ? '' : file.path.slice(0, slash));
|
|
82
|
+
parent.children.push({
|
|
83
|
+
id: file.path,
|
|
84
|
+
name: file.path.slice(slash + 1),
|
|
85
|
+
isDir: false,
|
|
86
|
+
file,
|
|
87
|
+
vendor: file.vendor,
|
|
88
|
+
value,
|
|
89
|
+
parent,
|
|
90
|
+
children: [],
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const finish = node => {
|
|
94
|
+
if (!node.isDir) return node.value;
|
|
95
|
+
node.value = node.children.reduce((sum, c) => sum + finish(c), 0);
|
|
96
|
+
node.children.sort((a, b) => b.value - a.value);
|
|
97
|
+
while (
|
|
98
|
+
node.parent &&
|
|
99
|
+
node.children.length === 1 &&
|
|
100
|
+
node.children[0].isDir
|
|
101
|
+
) {
|
|
102
|
+
const only = node.children[0];
|
|
103
|
+
node.id = only.id;
|
|
104
|
+
node.name = `${node.name}/${only.name}`;
|
|
105
|
+
node.children = only.children;
|
|
106
|
+
node.children.forEach(c => {
|
|
107
|
+
c.parent = node;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return node.value;
|
|
111
|
+
};
|
|
112
|
+
finish(root);
|
|
113
|
+
const byId = new Map();
|
|
114
|
+
const index = node => {
|
|
115
|
+
byId.set(node.id, node);
|
|
116
|
+
node.children.forEach(index);
|
|
117
|
+
};
|
|
118
|
+
index(root);
|
|
119
|
+
return { root, byId, total: root.value };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Expand the root and the paths leading to the hottest leaves. */
|
|
123
|
+
export function defaultExpanded(tree, topLeaves = 6) {
|
|
124
|
+
const leaves = [...tree.byId.values()]
|
|
125
|
+
.filter(n => !n.isDir)
|
|
126
|
+
.sort((a, b) => b.value - a.value)
|
|
127
|
+
.slice(0, topLeaves);
|
|
128
|
+
const set = new Set([tree.root.id]);
|
|
129
|
+
for (const leaf of leaves)
|
|
130
|
+
for (let n = leaf.parent; n; n = n.parent) set.add(n.id);
|
|
131
|
+
return set;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function ancestorIds(tree, path) {
|
|
135
|
+
const ids = [];
|
|
136
|
+
for (let n = tree.byId.get(path)?.parent; n; n = n.parent) ids.push(n.id);
|
|
137
|
+
return ids;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Turn an aggregated node into the shape TreeView renders. */
|
|
141
|
+
export function decorateFileNode(node, tree, metric) {
|
|
142
|
+
const heat = tree.total ? Math.sqrt(node.value / tree.total) : 0;
|
|
143
|
+
return {
|
|
144
|
+
isDir: node.isDir,
|
|
145
|
+
vendor: node.vendor,
|
|
146
|
+
expandable: node.isDir && node.children.length > 0,
|
|
147
|
+
heat,
|
|
148
|
+
r: 4 + heat * 7,
|
|
149
|
+
tooltip: `${node.id || tree.root.name}\n${metric.label}: ${formatMetric(metric, node.value)} (${tree.total ? Math.round((node.value / tree.total) * 100) : 0}% of total)`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Heat + label data for the top-down call tree. */
|
|
154
|
+
export function decorateCallNode(node, total) {
|
|
155
|
+
const share = total ? node.totalMs / total : 0;
|
|
156
|
+
const selfShare = total ? node.selfMs / total : 0;
|
|
157
|
+
return {
|
|
158
|
+
isDir: false,
|
|
159
|
+
vendor: node.vendor,
|
|
160
|
+
expandable: node.children.length > 0,
|
|
161
|
+
heat: Math.min(1, Math.sqrt(selfShare) * 1.6),
|
|
162
|
+
r: 4 + Math.sqrt(share) * 6,
|
|
163
|
+
tooltip: `${node.name}\n${node.file}${node.line ? `:${node.line}` : ''}\nself ${fmtMs(node.selfMs)} · total ${fmtMs(node.totalMs)}`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function defaultCallExpanded(root) {
|
|
168
|
+
const set = new Set();
|
|
169
|
+
const limit = root.totalMs * 0.08;
|
|
170
|
+
const walk = node => {
|
|
171
|
+
if (node.totalMs < limit || !node.children.length) return;
|
|
172
|
+
set.add(node.id);
|
|
173
|
+
node.children.forEach(walk);
|
|
174
|
+
};
|
|
175
|
+
walk(root);
|
|
176
|
+
return set;
|
|
177
|
+
}
|
package/src/highlight.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import hljs from 'highlight.js/lib/core';
|
|
2
|
+
import css from 'highlight.js/lib/languages/css';
|
|
3
|
+
import javascript from 'highlight.js/lib/languages/javascript';
|
|
4
|
+
import json from 'highlight.js/lib/languages/json';
|
|
5
|
+
import less from 'highlight.js/lib/languages/less';
|
|
6
|
+
import scss from 'highlight.js/lib/languages/scss';
|
|
7
|
+
import typescript from 'highlight.js/lib/languages/typescript';
|
|
8
|
+
import xml from 'highlight.js/lib/languages/xml';
|
|
9
|
+
|
|
10
|
+
const LANGUAGES = { css, javascript, json, less, scss, typescript, xml };
|
|
11
|
+
Object.entries(LANGUAGES).forEach(([name, def]) =>
|
|
12
|
+
hljs.registerLanguage(name, def),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const EXT_TO_LANGUAGE = {
|
|
16
|
+
ts: 'typescript',
|
|
17
|
+
tsx: 'typescript',
|
|
18
|
+
mts: 'typescript',
|
|
19
|
+
js: 'javascript',
|
|
20
|
+
jsx: 'javascript',
|
|
21
|
+
mjs: 'javascript',
|
|
22
|
+
cjs: 'javascript',
|
|
23
|
+
json: 'json',
|
|
24
|
+
css: 'css',
|
|
25
|
+
less: 'less',
|
|
26
|
+
scss: 'scss',
|
|
27
|
+
html: 'xml',
|
|
28
|
+
svg: 'xml',
|
|
29
|
+
};
|
|
30
|
+
const MAX_HIGHLIGHT_CHARS = 400_000;
|
|
31
|
+
|
|
32
|
+
const escapeHtml = text =>
|
|
33
|
+
text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
34
|
+
|
|
35
|
+
/** Returns HTML that is always escaped (highlight.js escapes its output). */
|
|
36
|
+
export function highlightSource(path, content) {
|
|
37
|
+
const language = EXT_TO_LANGUAGE[path.split('.').pop().toLowerCase()];
|
|
38
|
+
if (!language || content.length > MAX_HIGHLIGHT_CHARS)
|
|
39
|
+
return escapeHtml(content);
|
|
40
|
+
try {
|
|
41
|
+
return hljs.highlight(content, { language, ignoreIllegals: true }).value;
|
|
42
|
+
} catch {
|
|
43
|
+
return escapeHtml(content);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
import { compareSides } from '../../shared/compare.js';
|
|
3
|
+
import { hashFromFileName, validateReport } from '../../shared/snapshot.js';
|
|
4
|
+
import { api } from '../api.js';
|
|
5
|
+
|
|
6
|
+
const MAX_FILE_BYTES = 60 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
const emptySide = () => ({
|
|
9
|
+
runs: [],
|
|
10
|
+
git: null,
|
|
11
|
+
hashInput: '',
|
|
12
|
+
label: '',
|
|
13
|
+
lookup: { status: 'idle', message: '' },
|
|
14
|
+
problems: [],
|
|
15
|
+
});
|
|
16
|
+
const initial = () => ({ before: emptySide(), after: emptySide() });
|
|
17
|
+
|
|
18
|
+
async function readSnapshot(file) {
|
|
19
|
+
if (/\.trace\.json$/i.test(file.name) || file.size > MAX_FILE_BYTES) {
|
|
20
|
+
return {
|
|
21
|
+
ok: false,
|
|
22
|
+
fileName: file.name,
|
|
23
|
+
reason:
|
|
24
|
+
'This looks like a raw Chrome trace (or is too large). Comparison needs the profiler report: use “Snapshot for comparison”.',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = JSON.parse(await file.text());
|
|
30
|
+
} catch {
|
|
31
|
+
return { ok: false, fileName: file.name, reason: 'Not valid JSON.' };
|
|
32
|
+
}
|
|
33
|
+
const check = validateReport(parsed);
|
|
34
|
+
return check.ok
|
|
35
|
+
? { ok: true, fileName: file.name, report: parsed }
|
|
36
|
+
: { ok: false, fileName: file.name, reason: check.reason };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Which two groups of snapshots are being compared ("before" = base commit,
|
|
41
|
+
* "after" = the commit under review), plus the derived comparison.
|
|
42
|
+
*/
|
|
43
|
+
export function useCompare() {
|
|
44
|
+
const [sides, setSides] = useState(initial);
|
|
45
|
+
|
|
46
|
+
const patch = useCallback(
|
|
47
|
+
(name, change) =>
|
|
48
|
+
setSides(prev => ({
|
|
49
|
+
...prev,
|
|
50
|
+
[name]: {
|
|
51
|
+
...prev[name],
|
|
52
|
+
...(typeof change === 'function' ? change(prev[name]) : change),
|
|
53
|
+
},
|
|
54
|
+
})),
|
|
55
|
+
[],
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const lookupCommit = useCallback(
|
|
59
|
+
async (name, hash) => {
|
|
60
|
+
const rev = hash.trim();
|
|
61
|
+
if (!rev)
|
|
62
|
+
return patch(name, {
|
|
63
|
+
git: null,
|
|
64
|
+
lookup: { status: 'idle', message: '' },
|
|
65
|
+
});
|
|
66
|
+
patch(name, {
|
|
67
|
+
hashInput: rev,
|
|
68
|
+
lookup: { status: 'working', message: '' },
|
|
69
|
+
});
|
|
70
|
+
try {
|
|
71
|
+
const git = await api.gitCommit(rev);
|
|
72
|
+
patch(name, {
|
|
73
|
+
git,
|
|
74
|
+
hashInput: git.shortHash,
|
|
75
|
+
lookup: { status: 'ok', message: '' },
|
|
76
|
+
});
|
|
77
|
+
} catch (err) {
|
|
78
|
+
// Keep the typed hash so the PDF still shows it, with the author unknown.
|
|
79
|
+
patch(name, {
|
|
80
|
+
git: {
|
|
81
|
+
hash: rev,
|
|
82
|
+
shortHash: rev.slice(0, 10),
|
|
83
|
+
author: '',
|
|
84
|
+
subject: '',
|
|
85
|
+
date: '',
|
|
86
|
+
},
|
|
87
|
+
lookup: { status: 'error', message: err.message },
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
[patch],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const addFiles = useCallback(
|
|
95
|
+
async (name, files) => {
|
|
96
|
+
const results = await Promise.all([...files].map(readSnapshot));
|
|
97
|
+
const good = results.filter(r => r.ok);
|
|
98
|
+
patch(name, side => {
|
|
99
|
+
const known = new Set(side.runs.map(r => r.report.id));
|
|
100
|
+
const fresh = good
|
|
101
|
+
.filter(r => !known.has(r.report.id))
|
|
102
|
+
.map(r => ({ fileName: r.fileName, report: r.report }));
|
|
103
|
+
return {
|
|
104
|
+
runs: [...side.runs, ...fresh],
|
|
105
|
+
problems: results
|
|
106
|
+
.filter(r => !r.ok)
|
|
107
|
+
.map(r => `${r.fileName}: ${r.reason}`),
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
// The file name carries the commit when the report itself does not.
|
|
111
|
+
const first = good[0];
|
|
112
|
+
if (first && !first.report.meta.git) {
|
|
113
|
+
const hash = hashFromFileName(first.fileName);
|
|
114
|
+
if (hash) await lookupCommit(name, hash);
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
[patch, lookupCommit],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const addSaved = useCallback(
|
|
121
|
+
async (name, id) => {
|
|
122
|
+
try {
|
|
123
|
+
const report = await api.report(id);
|
|
124
|
+
patch(name, side =>
|
|
125
|
+
side.runs.some(r => r.report.id === report.id)
|
|
126
|
+
? {}
|
|
127
|
+
: {
|
|
128
|
+
runs: [
|
|
129
|
+
...side.runs,
|
|
130
|
+
{ fileName: `reports/${id}.json`, report },
|
|
131
|
+
],
|
|
132
|
+
problems: [],
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
patch(name, { problems: [err.message] });
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
[patch],
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const removeRun = useCallback(
|
|
143
|
+
(name, id) =>
|
|
144
|
+
patch(name, side => ({
|
|
145
|
+
runs: side.runs.filter(r => r.report.id !== id),
|
|
146
|
+
})),
|
|
147
|
+
[patch],
|
|
148
|
+
);
|
|
149
|
+
const setLabel = useCallback(
|
|
150
|
+
(name, label) => patch(name, { label }),
|
|
151
|
+
[patch],
|
|
152
|
+
);
|
|
153
|
+
const swap = useCallback(
|
|
154
|
+
() => setSides(prev => ({ before: prev.after, after: prev.before })),
|
|
155
|
+
[],
|
|
156
|
+
);
|
|
157
|
+
const reset = useCallback(() => setSides(initial()), []);
|
|
158
|
+
|
|
159
|
+
const comparison = useMemo(
|
|
160
|
+
() =>
|
|
161
|
+
compareSides(
|
|
162
|
+
{
|
|
163
|
+
runs: sides.before.runs.map(r => r.report),
|
|
164
|
+
git: sides.before.git,
|
|
165
|
+
label: sides.before.label,
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
runs: sides.after.runs.map(r => r.report),
|
|
169
|
+
git: sides.after.git,
|
|
170
|
+
label: sides.after.label,
|
|
171
|
+
},
|
|
172
|
+
),
|
|
173
|
+
[sides],
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
sides,
|
|
178
|
+
comparison,
|
|
179
|
+
addFiles,
|
|
180
|
+
addSaved,
|
|
181
|
+
removeRun,
|
|
182
|
+
setLabel,
|
|
183
|
+
lookupCommit,
|
|
184
|
+
swap,
|
|
185
|
+
reset,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { api } from '../api.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Connection / recording state of the Chrome session, kept fresh by
|
|
6
|
+
* Server-Sent Events plus a slow poll (for the tab list and probe status).
|
|
7
|
+
*/
|
|
8
|
+
export function useSession({ onReport }) {
|
|
9
|
+
const [status, setStatus] = useState(null);
|
|
10
|
+
const [reports, setReports] = useState([]);
|
|
11
|
+
const [error, setError] = useState('');
|
|
12
|
+
const onReportRef = useRef(onReport);
|
|
13
|
+
onReportRef.current = onReport;
|
|
14
|
+
|
|
15
|
+
const refresh = useCallback(async () => {
|
|
16
|
+
try {
|
|
17
|
+
setStatus(await api.status());
|
|
18
|
+
} catch (err) {
|
|
19
|
+
setError(err.message);
|
|
20
|
+
}
|
|
21
|
+
}, []);
|
|
22
|
+
const refreshReports = useCallback(
|
|
23
|
+
async () => setReports(await api.reports().catch(() => [])),
|
|
24
|
+
[],
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
refresh();
|
|
29
|
+
refreshReports();
|
|
30
|
+
const source = new EventSource('/api/events');
|
|
31
|
+
source.addEventListener('state', e => {
|
|
32
|
+
const data = JSON.parse(e.data);
|
|
33
|
+
if (data.error) setError(data.error);
|
|
34
|
+
refresh();
|
|
35
|
+
});
|
|
36
|
+
source.addEventListener('report', e => {
|
|
37
|
+
refreshReports();
|
|
38
|
+
onReportRef.current(JSON.parse(e.data).id);
|
|
39
|
+
});
|
|
40
|
+
// Tabs and probe status change without any server event.
|
|
41
|
+
const poll = setInterval(refresh, 4000);
|
|
42
|
+
return () => {
|
|
43
|
+
source.close();
|
|
44
|
+
clearInterval(poll);
|
|
45
|
+
};
|
|
46
|
+
}, [refresh, refreshReports]);
|
|
47
|
+
|
|
48
|
+
const run = useCallback(
|
|
49
|
+
async action => {
|
|
50
|
+
setError('');
|
|
51
|
+
try {
|
|
52
|
+
const result = await action();
|
|
53
|
+
await refresh();
|
|
54
|
+
return result;
|
|
55
|
+
} catch (err) {
|
|
56
|
+
setError(err.message);
|
|
57
|
+
await refresh();
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
[refresh],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
status,
|
|
66
|
+
reports,
|
|
67
|
+
error,
|
|
68
|
+
clearError: () => setError(''),
|
|
69
|
+
run,
|
|
70
|
+
refresh,
|
|
71
|
+
refreshReports,
|
|
72
|
+
};
|
|
73
|
+
}
|
package/src/layout.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { hierarchy, tree } from 'd3';
|
|
2
|
+
|
|
3
|
+
export const ROW_HEIGHT = 24;
|
|
4
|
+
const CHAR_WIDTH = 6.7;
|
|
5
|
+
const LABEL_PAD = 30;
|
|
6
|
+
const COLUMN_GAP = 40;
|
|
7
|
+
const MAX_LABEL_CHARS = 64;
|
|
8
|
+
|
|
9
|
+
export function truncate(text) {
|
|
10
|
+
return text.length > MAX_LABEL_CHARS
|
|
11
|
+
? `${text.slice(0, MAX_LABEL_CHARS - 1)}…`
|
|
12
|
+
: text;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Left-to-right tree layout with one column per depth, each as wide as its
|
|
17
|
+
* longest label so text never overlaps. `decorate(data)` supplies what the view
|
|
18
|
+
* needs beyond the label: {heat, r, isDir, expandable, vendor, tooltip}.
|
|
19
|
+
*/
|
|
20
|
+
export function layoutTree(
|
|
21
|
+
rootData,
|
|
22
|
+
childrenOf,
|
|
23
|
+
labelOf,
|
|
24
|
+
decorate = () => ({}),
|
|
25
|
+
) {
|
|
26
|
+
const root = hierarchy(rootData, childrenOf);
|
|
27
|
+
tree()
|
|
28
|
+
.nodeSize([ROW_HEIGHT, 1])
|
|
29
|
+
.separation(() => 1)(root);
|
|
30
|
+
const nodes = root.descendants();
|
|
31
|
+
const columnWidth = [];
|
|
32
|
+
for (const node of nodes) {
|
|
33
|
+
node.label = truncate(labelOf(node.data));
|
|
34
|
+
node.view = decorate(node.data);
|
|
35
|
+
node.labelWidth = node.label.length * CHAR_WIDTH + LABEL_PAD;
|
|
36
|
+
columnWidth[node.depth] = Math.max(
|
|
37
|
+
columnWidth[node.depth] || 0,
|
|
38
|
+
node.labelWidth,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const columnX = [0];
|
|
42
|
+
for (let d = 1; d < columnWidth.length; d += 1) {
|
|
43
|
+
columnX[d] = columnX[d - 1] + columnWidth[d - 1] + COLUMN_GAP;
|
|
44
|
+
}
|
|
45
|
+
for (const node of nodes) {
|
|
46
|
+
node.px = columnX[node.depth];
|
|
47
|
+
node.py = node.x;
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
root,
|
|
51
|
+
nodes,
|
|
52
|
+
links: root.links(),
|
|
53
|
+
byKey: new Map(nodes.map(n => [n.data.id, n])),
|
|
54
|
+
};
|
|
55
|
+
}
|
package/src/main.jsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { StrictMode } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import App from './App.jsx';
|
|
4
|
+
import { applyAppearance, readAppearance } from './theme.js';
|
|
5
|
+
import './themes.css';
|
|
6
|
+
import './styles.css';
|
|
7
|
+
|
|
8
|
+
// Before first paint, so the page never flashes the wrong theme.
|
|
9
|
+
applyAppearance(readAppearance());
|
|
10
|
+
|
|
11
|
+
createRoot(document.getElementById('root')).render(
|
|
12
|
+
<StrictMode>
|
|
13
|
+
<App />
|
|
14
|
+
</StrictMode>,
|
|
15
|
+
);
|