@ogygia/devtools 0.12.0 → 0.13.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/dist/app.d.ts +3 -0
- package/dist/app.js +9 -0
- package/dist/devtools/BoundaryOverlay.svelte +10 -6
- package/dist/devtools/Devtools.svelte +24 -18
- package/dist/devtools/IslandDetail.svelte +7 -5
- package/dist/devtools/PageTab.svelte +4 -2
- package/dist/devtools/ProfilerTab.svelte +8 -2
- package/dist/devtools/SessionTab.svelte +21 -8
- package/dist/devtools/highlight.js +14 -7
- package/dist/devtools/ledger-dom.js +6 -9
- package/dist/devtools/names.js +18 -17
- package/dist/devtools/page.d.ts +2 -0
- package/dist/devtools/page.js +37 -17
- package/dist/devtools/profile-store.d.ts +4 -9
- package/dist/devtools/profile-store.js +26 -21
- package/dist/devtools/session.d.ts +2 -0
- package/dist/devtools/session.js +48 -42
- package/dist/devtools/testing-engine.js +10 -1
- package/dist/devtools/ui-app.d.ts +3 -1
- package/dist/devtools/ui-app.js +9 -3
- package/dist/devtools/ui.js +83 -40
- package/dist/messages/build.d.ts +21 -0
- package/dist/messages/build.js +56 -0
- package/dist/messages/index.d.ts +3 -0
- package/dist/messages/index.js +3 -0
- package/dist/messages/runtime.d.ts +63 -0
- package/dist/messages/runtime.js +122 -0
- package/dist/profiler/async-io.d.ts +12 -7
- package/dist/profiler/async-io.js +94 -82
- package/dist/profiler/beacon-standalone.js +15 -6
- package/dist/profiler/busy.d.ts +13 -0
- package/dist/profiler/busy.js +45 -0
- package/dist/profiler/client.js +5 -24
- package/dist/profiler/embedded-maps.js +9 -5
- package/dist/profiler/gc.d.ts +5 -0
- package/dist/profiler/gc.js +75 -11
- package/dist/profiler/index.d.ts +23 -2
- package/dist/profiler/index.js +648 -373
- package/dist/profiler/net.d.ts +14 -6
- package/dist/profiler/net.js +61 -45
- package/dist/profiler/patterns.js +29 -1
- package/dist/profiler/source-scan.js +24 -12
- package/dist/profiler/storage/index.d.ts +14 -3
- package/dist/profiler/storage/index.js +41 -41
- package/dist/profiler/storage/postgres.d.ts +18 -5
- package/dist/profiler/storage/postgres.js +124 -85
- package/dist/profiler/storage/redis.d.ts +18 -5
- package/dist/profiler/storage/redis.js +160 -131
- package/dist/profiler/storage/sqlite.d.ts +15 -2
- package/dist/profiler/storage/sqlite.js +54 -48
- package/dist/profiler/ui/ComponentsTable.svelte +2 -2
- package/dist/profiler/ui/Dashboard.svelte +2 -3
- package/dist/profiler/ui/DownloadHtml.svelte +1 -0
- package/dist/profiler/ui/ExportButton.svelte +1 -0
- package/dist/profiler/ui/Flame.svelte +10 -4
- package/dist/profiler/ui/Flame.svelte.d.ts +0 -5
- package/dist/profiler/ui/FrameDetails.svelte +13 -2
- package/dist/profiler/ui/FrameDetails.svelte.d.ts +0 -6
- package/dist/profiler/ui/FunctionsTable.svelte +2 -2
- package/dist/profiler/ui/IslandsTable.svelte +2 -2
- package/dist/profiler/ui/OnThisPage.svelte +12 -4
- package/dist/profiler/ui/RunView.svelte +31 -27
- package/dist/profiler/ui/ShareLink.svelte +6 -1
- package/dist/profiler/ui/Sidebar.svelte +14 -9
- package/dist/profiler/ui/Treemap.svelte +11 -4
- package/dist/profiler/ui/Treemap.svelte.d.ts +0 -6
- package/dist/profiler/ui/WaitingTable.svelte +2 -2
- package/dist/profiler/ui/fake-progress.svelte.d.ts +7 -12
- package/dist/profiler/ui/fake-progress.svelte.js +57 -36
- package/dist/profiler/ui/press.js +4 -2
- package/dist/profiler/ui/row-anchor.svelte.d.ts +0 -6
- package/dist/profiler/ui/row-anchor.svelte.js +13 -8
- package/dist/profiler/ui/sort.svelte.d.ts +9 -6
- package/dist/profiler/ui/sort.svelte.js +34 -28
- package/dist/profiler/visit.d.ts +0 -1
- package/dist/profiler/visit.js +1 -2
- package/dist/server.js +27 -10
- package/dist/vite.js +3 -2
- package/package.json +13 -4
|
@@ -10,8 +10,8 @@ import { createRequire } from "node:module";
|
|
|
10
10
|
*
|
|
11
11
|
* // src/hooks.server.ts
|
|
12
12
|
* import { setProfilerStore } from '@ogygia/devtools/profiler/storage';
|
|
13
|
-
* import {
|
|
14
|
-
* setProfilerStore(
|
|
13
|
+
* import { SqliteStore } from '@ogygia/devtools/profiler/storage/sqlite';
|
|
14
|
+
* setProfilerStore(new SqliteStore('.ogygia/profiles.db'));
|
|
15
15
|
*/
|
|
16
16
|
const require = createRequire(import.meta.url);
|
|
17
17
|
/** Open a synchronous sqlite database from whichever driver is installed. */
|
|
@@ -21,21 +21,59 @@ function open(filename) {
|
|
|
21
21
|
const dir = require("node:path").dirname(filename);
|
|
22
22
|
if (dir && dir !== ".") fs.mkdirSync(dir, { recursive: true });
|
|
23
23
|
} catch {}
|
|
24
|
+
const built_in = load_driver("node:sqlite");
|
|
25
|
+
if (built_in) return new built_in.DatabaseSync(filename);
|
|
26
|
+
const Database = load_driver("better-sqlite3");
|
|
27
|
+
if (Database) return new Database(filename);
|
|
28
|
+
throw new Error("ogygia/profiler: SqliteStore needs Node 22.5+ (built-in node:sqlite) or the 'better-sqlite3' package.");
|
|
29
|
+
}
|
|
30
|
+
/** A driver module, or null when it is not installed (this runtime has no such module). */
|
|
31
|
+
function load_driver(id) {
|
|
24
32
|
try {
|
|
25
|
-
|
|
26
|
-
return new DatabaseSync(filename);
|
|
27
|
-
} catch {}
|
|
28
|
-
try {
|
|
29
|
-
return new (require("better-sqlite3"))(filename);
|
|
33
|
+
return require(id);
|
|
30
34
|
} catch {
|
|
31
|
-
|
|
35
|
+
return null;
|
|
32
36
|
}
|
|
33
37
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
var SqliteStore = class {
|
|
39
|
+
kind = "sqlite";
|
|
40
|
+
#filename;
|
|
41
|
+
#db = null;
|
|
42
|
+
constructor(filename = ".ogygia/profiles.db") {
|
|
43
|
+
this.#filename = filename;
|
|
44
|
+
}
|
|
45
|
+
init() {
|
|
46
|
+
this.#need();
|
|
47
|
+
}
|
|
48
|
+
putReport(rec) {
|
|
49
|
+
this.#need().prepare("INSERT INTO reports (id,created,page,trigger,label,median,dump) VALUES (?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET created=excluded.created,page=excluded.page,trigger=excluded.trigger,label=excluded.label,median=excluded.median,dump=excluded.dump").run(rec.id, rec.created, rec.page, rec.trigger, rec.label, rec.median, rec.dump);
|
|
50
|
+
}
|
|
51
|
+
getReport(id) {
|
|
52
|
+
return this.#need().prepare("SELECT dump FROM reports WHERE id = ?").get(id)?.dump ?? null;
|
|
53
|
+
}
|
|
54
|
+
listReports(limit = 30) {
|
|
55
|
+
return this.#need().prepare("SELECT id,created,page,trigger,label,median FROM reports ORDER BY created DESC LIMIT ?").all(limit);
|
|
56
|
+
}
|
|
57
|
+
deleteReport(id) {
|
|
58
|
+
this.#need().prepare("DELETE FROM reports WHERE id = ?").run(id);
|
|
59
|
+
}
|
|
60
|
+
prune(keep) {
|
|
61
|
+
this.#need().prepare("DELETE FROM reports WHERE id NOT IN (SELECT id FROM reports ORDER BY created DESC LIMIT ?)").run(keep);
|
|
62
|
+
}
|
|
63
|
+
putVisit(rec) {
|
|
64
|
+
this.#need().prepare("INSERT INTO visits (key,page,at,visit) VALUES (?,?,?,?) ON CONFLICT(key) DO UPDATE SET at=excluded.at,visit=excluded.visit").run(rec.key, rec.page, rec.at, rec.visit);
|
|
65
|
+
this.#need().prepare("DELETE FROM visits WHERE key IN (SELECT key FROM visits WHERE page = ? ORDER BY at DESC LIMIT -1 OFFSET ?)").run(rec.page, MAX_VISITS_PER_KEY);
|
|
66
|
+
}
|
|
67
|
+
listVisits(page, limit = 10) {
|
|
68
|
+
return this.#need().prepare("SELECT key,page,at,visit FROM visits WHERE page = ? ORDER BY at DESC LIMIT ?").all(page, limit);
|
|
69
|
+
}
|
|
70
|
+
close() {
|
|
71
|
+
this.#db?.close();
|
|
72
|
+
this.#db = null;
|
|
73
|
+
}
|
|
74
|
+
#need() {
|
|
75
|
+
if (this.#db) return this.#db;
|
|
76
|
+
const db = this.#db = open(this.#filename);
|
|
39
77
|
db.exec(`
|
|
40
78
|
CREATE TABLE IF NOT EXISTS reports (
|
|
41
79
|
id TEXT PRIMARY KEY, created INTEGER NOT NULL, page TEXT, trigger TEXT,
|
|
@@ -48,38 +86,6 @@ function sqliteStore(filename = ".ogygia/profiles.db") {
|
|
|
48
86
|
CREATE INDEX IF NOT EXISTS visits_page ON visits (page, at);
|
|
49
87
|
`);
|
|
50
88
|
return db;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
init() {
|
|
55
|
-
need();
|
|
56
|
-
},
|
|
57
|
-
putReport(rec) {
|
|
58
|
-
need().prepare("INSERT INTO reports (id,created,page,trigger,label,median,dump) VALUES (?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET created=excluded.created,page=excluded.page,trigger=excluded.trigger,label=excluded.label,median=excluded.median,dump=excluded.dump").run(rec.id, rec.created, rec.page, rec.trigger, rec.label, rec.median, rec.dump);
|
|
59
|
-
},
|
|
60
|
-
getReport(id) {
|
|
61
|
-
return need().prepare("SELECT dump FROM reports WHERE id = ?").get(id)?.dump ?? null;
|
|
62
|
-
},
|
|
63
|
-
listReports(limit = 30) {
|
|
64
|
-
return need().prepare("SELECT id,created,page,trigger,label,median FROM reports ORDER BY created DESC LIMIT ?").all(limit);
|
|
65
|
-
},
|
|
66
|
-
deleteReport(id) {
|
|
67
|
-
need().prepare("DELETE FROM reports WHERE id = ?").run(id);
|
|
68
|
-
},
|
|
69
|
-
prune(keep) {
|
|
70
|
-
need().prepare("DELETE FROM reports WHERE id NOT IN (SELECT id FROM reports ORDER BY created DESC LIMIT ?)").run(keep);
|
|
71
|
-
},
|
|
72
|
-
putVisit(rec) {
|
|
73
|
-
need().prepare("INSERT INTO visits (key,page,at,visit) VALUES (?,?,?,?) ON CONFLICT(key) DO UPDATE SET at=excluded.at,visit=excluded.visit").run(rec.key, rec.page, rec.at, rec.visit);
|
|
74
|
-
need().prepare("DELETE FROM visits WHERE page = ? AND key NOT IN (SELECT key FROM visits WHERE page = ? ORDER BY at DESC LIMIT ?)").run(rec.page, rec.page, MAX_VISITS_PER_KEY);
|
|
75
|
-
},
|
|
76
|
-
listVisits(page, limit = 10) {
|
|
77
|
-
return need().prepare("SELECT key,page,at,visit FROM visits WHERE page = ? ORDER BY at DESC LIMIT ?").all(page, limit);
|
|
78
|
-
},
|
|
79
|
-
close() {
|
|
80
|
-
db?.close();
|
|
81
|
-
db = null;
|
|
82
|
-
}
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
export { sqliteStore };
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
export { SqliteStore };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script>import { fmt_ms, fmt_dur, fmt_pct, fmt_bytes, CATEGORY_COLOR } from "./format.js";
|
|
2
|
-
import {
|
|
2
|
+
import { Sortable } from "./sort.svelte.js";
|
|
3
3
|
import { pressable } from "./press.js";
|
|
4
4
|
import FrameDetails from "./FrameDetails.svelte";
|
|
5
5
|
import { row_id, follow_hash } from "./row-anchor.svelte.js";
|
|
@@ -10,7 +10,7 @@ const filtered = $derived.by(() => {
|
|
|
10
10
|
if (!q) return rows;
|
|
11
11
|
return rows.filter((r) => r.name.toLowerCase().includes(q) || r.url.toLowerCase().includes(q) || (r.path ?? "").toLowerCase().includes(q));
|
|
12
12
|
});
|
|
13
|
-
const s =
|
|
13
|
+
const s = new Sortable(() => filtered, "self_ms");
|
|
14
14
|
let open = $state(null);
|
|
15
15
|
const hasSplit = $derived(rows.some((r) => (r.markup_ms ?? 0) + (r.logic_ms ?? 0) > 0));
|
|
16
16
|
const hasParent = $derived(rows.some((r) => !!r.parent));
|
|
@@ -126,9 +126,8 @@ const delta_pct = (pts) => {
|
|
|
126
126
|
{#if trap}
|
|
127
127
|
<p class="hint">
|
|
128
128
|
<b>Catch the slow one:</b>
|
|
129
|
-
{trap.
|
|
130
|
-
|
|
131
|
-
)} s windows and keeps the window when a request crosses <b>{trap.over} ms</b>. Caught {trap.caught}
|
|
129
|
+
{trap.state} — a coarse sampler runs in {Math.round(trap.window_ms / 1000)} s windows and keeps
|
|
130
|
+
the window when a request crosses <b>{trap.over} ms</b>. Caught {trap.caught}
|
|
132
131
|
of {trap.keep}.{#if trap.caught}
|
|
133
132
|
The reports are in the list below, labelled "caught".{/if}
|
|
134
133
|
</p>
|
|
@@ -17,6 +17,7 @@ function save(blob) {
|
|
|
17
17
|
document.body.appendChild(a);
|
|
18
18
|
a.click();
|
|
19
19
|
a.remove();
|
|
20
|
+
// oxlint-disable-next-line ogygia/owned-effects -- the download's, not the button's: it lets go of the file even if the button goes
|
|
20
21
|
setTimeout(() => URL.revokeObjectURL(a.href), 1500);
|
|
21
22
|
}
|
|
22
23
|
async function download(e) {
|
|
@@ -16,6 +16,7 @@ function save(blob) {
|
|
|
16
16
|
document.body.appendChild(a);
|
|
17
17
|
a.click();
|
|
18
18
|
a.remove();
|
|
19
|
+
// oxlint-disable-next-line ogygia/owned-effects -- the download's, not the button's: it lets go of the file even if the button goes
|
|
19
20
|
setTimeout(() => URL.revokeObjectURL(a.href), 1500);
|
|
20
21
|
}
|
|
21
22
|
async function download() {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
<script
|
|
1
|
+
<script>/**
|
|
2
|
+
* Flame graph — a `wake:'load'` canvas island (was FLAME_JS). Width = time; click a bar to zoom,
|
|
3
|
+
* click the top bar to zoom back out; hover for detail. Zoom + tooltip are `$state`; the canvas
|
|
4
|
+
* redraws in an `$effect` on zoom/resize. `rects` (hit regions) is plain, rebuilt each draw.
|
|
5
|
+
*/
|
|
6
|
+
import { scope } from "@ogygia/machine";
|
|
7
|
+
import { CATEGORY_COLOR } from "./format.js";
|
|
2
8
|
let { flame } = $props();
|
|
3
9
|
const ROW = 20;
|
|
4
10
|
let canvas;
|
|
@@ -106,9 +112,9 @@ $effect(() => {
|
|
|
106
112
|
layout();
|
|
107
113
|
});
|
|
108
114
|
$effect(() => {
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
return () =>
|
|
115
|
+
const s = scope();
|
|
116
|
+
s.on(window, "resize", () => resizeTick++);
|
|
117
|
+
return () => s.close();
|
|
112
118
|
});
|
|
113
119
|
function hit(ev) {
|
|
114
120
|
const r = canvas.getBoundingClientRect();
|
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Flame graph — a `wake:'load'` canvas island (was FLAME_JS). Width = time; click a bar to zoom,
|
|
3
|
-
* click the top bar to zoom back out; hover for detail. Zoom + tooltip are `$state`; the canvas
|
|
4
|
-
* redraws in an `$effect` on zoom/resize. `rects` (hit regions) is plain, rebuilt each draw.
|
|
5
|
-
*/
|
|
6
1
|
import type { FlameNode } from '../analyze.js';
|
|
7
2
|
type $$ComponentProps = {
|
|
8
3
|
flame: FlameNode;
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
<script
|
|
1
|
+
<script>/**
|
|
2
|
+
* The expanded row under a function / component: WHERE it is (the full path, with line and
|
|
3
|
+
* column, openable in the editor when the path is absolute; on a dev server the source lines
|
|
4
|
+
* themselves) and HOW it was reached (its heaviest call stacks, nearest caller first).
|
|
5
|
+
* Rendered inside the table islands; plain markup.
|
|
6
|
+
*/
|
|
7
|
+
import { onDestroy } from "svelte";
|
|
8
|
+
import { scope } from "@ogygia/machine";
|
|
9
|
+
import { fmt_ms, CATEGORY_LABEL } from "./format.js";
|
|
2
10
|
let { f, colspan, base = "", dev = false } = $props();
|
|
3
11
|
const is_abs = (p) => p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p);
|
|
4
12
|
const location = $derived(f.path ? f.path + (f.line > 0 ? ":" + f.line + (f.col > 0 ? ":" + f.col : "") : "") : "");
|
|
@@ -12,11 +20,14 @@ const heat = $derived(new Map(lines.map((l) => [l.line, l.ms / lines_max])));
|
|
|
12
20
|
const last_hot = $derived(lines.length ? Math.max(...lines.map((l) => l.line)) : 0);
|
|
13
21
|
const ms_at = (n) => lines.find((l) => l.line === n)?.ms ?? 0;
|
|
14
22
|
let copied = $state(false);
|
|
23
|
+
/** The panel's own timer: let go when it unmounts. */
|
|
24
|
+
const owner = scope();
|
|
25
|
+
onDestroy(() => owner.close());
|
|
15
26
|
async function copy() {
|
|
16
27
|
try {
|
|
17
28
|
await navigator.clipboard.writeText(location);
|
|
18
29
|
copied = true;
|
|
19
|
-
|
|
30
|
+
owner.timeout(() => copied = false, 1200);
|
|
20
31
|
} catch {}
|
|
21
32
|
}
|
|
22
33
|
// SOURCE PEEK: the code around the hot lines. The report carries it (`f.src`, captured at report
|
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The expanded row under a function / component: WHERE it is (the full path, with line and
|
|
3
|
-
* column, openable in the editor when the path is absolute; on a dev server the source lines
|
|
4
|
-
* themselves) and HOW it was reached (its heaviest call stacks, nearest caller first).
|
|
5
|
-
* Rendered inside the table islands; plain markup.
|
|
6
|
-
*/
|
|
7
1
|
import type { FrameStat } from '../analyze.js';
|
|
8
2
|
type $$ComponentProps = {
|
|
9
3
|
f: FrameStat;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script>import { fmt_ms, fmt_dur, fmt_bytes, CATEGORY_COLOR, CATEGORY_LABEL, ink_on } from "./format.js";
|
|
2
|
-
import {
|
|
2
|
+
import { Sortable } from "./sort.svelte.js";
|
|
3
3
|
import { pressable } from "./press.js";
|
|
4
4
|
import FrameDetails from "./FrameDetails.svelte";
|
|
5
5
|
import { row_id, follow_hash } from "./row-anchor.svelte.js";
|
|
@@ -10,7 +10,7 @@ const filtered = $derived.by(() => {
|
|
|
10
10
|
if (!q) return rows;
|
|
11
11
|
return rows.filter((r) => r.name.toLowerCase().includes(q) || r.url.toLowerCase().includes(q) || (r.path ?? "").toLowerCase().includes(q) || (r.pkg ?? "").toLowerCase().includes(q));
|
|
12
12
|
});
|
|
13
|
-
const s =
|
|
13
|
+
const s = new Sortable(() => filtered, "self_ms");
|
|
14
14
|
const row_key = (f) => f.key ?? f.name + " " + f.url;
|
|
15
15
|
let open = $state(null);
|
|
16
16
|
const cols = $derived(hasAlloc ? 7 : 6);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script>import { fmt_ms, fmt_bytes, ink_on } from "./format.js";
|
|
2
|
-
import {
|
|
2
|
+
import { Sortable } from "./sort.svelte.js";
|
|
3
3
|
import { pressable } from "./press.js";
|
|
4
4
|
import { row_id, follow_hash } from "./row-anchor.svelte.js";
|
|
5
5
|
let { rows, hasJs, hasClient } = $props();
|
|
@@ -9,7 +9,7 @@ const filtered = $derived.by(() => {
|
|
|
9
9
|
if (!q) return rows;
|
|
10
10
|
return rows.filter((r) => r.name.toLowerCase().includes(q) || r.entry.toLowerCase().includes(q) || r.wake.includes(q));
|
|
11
11
|
});
|
|
12
|
-
const s =
|
|
12
|
+
const s = new Sortable(() => filtered, "props_bytes");
|
|
13
13
|
let open = $state(null);
|
|
14
14
|
const max_props = $derived(Math.max(...rows.map((r) => r.props_bytes), 1));
|
|
15
15
|
const max_js = $derived(Math.max(...rows.map((r) => r.js_bytes ?? 0), 1));
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
<script
|
|
1
|
+
<script>/**
|
|
2
|
+
* ON THIS PAGE — a fixed rail on the right listing the report's parts, the one in view lit, click
|
|
3
|
+
* to jump. A `wake:'load'` island: it reads the rendered `.part` bands from the DOM (no ids
|
|
4
|
+
* needed — it holds the element refs) and tracks the active one with an IntersectionObserver.
|
|
5
|
+
* Hidden on narrow screens.
|
|
6
|
+
*/
|
|
7
|
+
import { scope } from "@ogygia/machine";
|
|
8
|
+
let entries = $state([]);
|
|
2
9
|
let active = $state(0);
|
|
3
10
|
$effect(() => {
|
|
4
11
|
const els = [...document.querySelectorAll(".part, .app-main h2")];
|
|
@@ -19,7 +26,8 @@ $effect(() => {
|
|
|
19
26
|
};
|
|
20
27
|
});
|
|
21
28
|
if (!els.length) return;
|
|
22
|
-
const
|
|
29
|
+
const s = scope();
|
|
30
|
+
const io = s.watch(new IntersectionObserver((ents) => {
|
|
23
31
|
for (const e of ents) {
|
|
24
32
|
if (e.isIntersecting) {
|
|
25
33
|
const i = els.indexOf(e.target);
|
|
@@ -29,10 +37,10 @@ $effect(() => {
|
|
|
29
37
|
}, {
|
|
30
38
|
rootMargin: "-6% 0px -85% 0px",
|
|
31
39
|
threshold: 0
|
|
32
|
-
});
|
|
40
|
+
}));
|
|
33
41
|
for (const el of els) io.observe(el);
|
|
34
42
|
// the report reserves the rail's space server-side (Shell `toc`); nothing to toggle here
|
|
35
|
-
return () =>
|
|
43
|
+
return () => s.close();
|
|
36
44
|
});
|
|
37
45
|
const jump = (e) => e.el.scrollIntoView({
|
|
38
46
|
behavior: "smooth",
|
|
@@ -6,42 +6,38 @@
|
|
|
6
6
|
* parent frame, so the devtools tab can offer "open in new tab".
|
|
7
7
|
*/
|
|
8
8
|
import { onMount } from "svelte";
|
|
9
|
-
import {
|
|
9
|
+
import { scope, STALE } from "@ogygia/machine";
|
|
10
|
+
import { FakeProgress } from "./fake-progress.svelte.js";
|
|
11
|
+
import { fetch_when_free } from "../busy.js";
|
|
10
12
|
import { put_report } from "./store.js";
|
|
11
13
|
// ── regexes
|
|
12
14
|
const NON_WORD_G = /\W+/g;
|
|
13
15
|
let { base, path, runs, format, against = "" } = $props();
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
// server it comes back once the run ahead finishes. Give up after QUEUE_MAX_MS and point at Reset.
|
|
18
|
-
const QUEUE_POLL_MS = 2e3;
|
|
19
|
-
const QUEUE_MAX_MS = 12e4;
|
|
20
|
-
const p = fake_progress();
|
|
16
|
+
const p = new FakeProgress();
|
|
17
|
+
/** The view's own work (its request, the queue's waits): let go when it unmounts. */
|
|
18
|
+
const owner = scope();
|
|
21
19
|
let phase = $state("Starting the profiler…");
|
|
22
20
|
let error = $state("");
|
|
23
21
|
let done_ogp = $state(false);
|
|
24
22
|
let queued = $state(false);
|
|
25
23
|
let waited_s = $state(0);
|
|
26
|
-
/** Fetch /page,
|
|
27
|
-
* last 409
|
|
24
|
+
/** Fetch /page, waiting while any worker we reach is busy (`fetch_when_free`). Returns the first
|
|
25
|
+
* non-409 response, or the last 409 once the wait ran out (the caller surfaces that as an error,
|
|
26
|
+
* pointing at Reset); null once the view is gone (the request and the queue end with it). */
|
|
28
27
|
async function fetch_page(url) {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
phase = "In queue — a profile is already running. Waiting for a free worker…";
|
|
43
|
-
await new Promise((r) => setTimeout(r, delay));
|
|
44
|
-
}
|
|
28
|
+
const res = await fetch_when_free(owner, url, {}, {
|
|
29
|
+
asking: () => {
|
|
30
|
+
queued = false;
|
|
31
|
+
p.start();
|
|
32
|
+
phase = `Rendering ${path} ${runs}× and sampling the server…`;
|
|
33
|
+
},
|
|
34
|
+
busy: (waited) => {
|
|
35
|
+
queued = true;
|
|
36
|
+
waited_s = Math.round(waited / 1e3);
|
|
37
|
+
phase = "In queue — a profile is already running. Waiting for a free worker…";
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
return res === STALE ? null : res;
|
|
45
41
|
}
|
|
46
42
|
async function run() {
|
|
47
43
|
try {
|
|
@@ -49,6 +45,7 @@ async function run() {
|
|
|
49
45
|
// navigating — on an ephemeral host the instance that rendered it may be gone by then
|
|
50
46
|
const suffix = format === "ogp" ? "&format=ogp" : "&format=keep";
|
|
51
47
|
const res = await fetch_page(`${base}/page?p=${encodeURIComponent(path)}&runs=${runs}${suffix}`);
|
|
48
|
+
if (!res) return;
|
|
52
49
|
if (!res.ok) throw new Error(String(res.status));
|
|
53
50
|
if (format === "ogp") {
|
|
54
51
|
const blob = await res.blob();
|
|
@@ -91,7 +88,14 @@ async function run() {
|
|
|
91
88
|
error = `Profiling failed (${code}). If the page is slow, the host's request timeout may have cut it ` + `off — try fewer renders, or tick the .ogp download and open it via Import. If a run got ` + `stuck, hit Reset on the dashboard.`;
|
|
92
89
|
}
|
|
93
90
|
}
|
|
94
|
-
onMount(
|
|
91
|
+
onMount(() => {
|
|
92
|
+
void run();
|
|
93
|
+
// unmounted (mid-run or in the queue): its request, its queue's waits and its bar's timers go
|
|
94
|
+
return () => {
|
|
95
|
+
owner.close();
|
|
96
|
+
p[Symbol.dispose]();
|
|
97
|
+
};
|
|
98
|
+
});
|
|
95
99
|
</script>
|
|
96
100
|
|
|
97
101
|
<div class="run">
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* URLs too low, so links are disabled there; Chromium/Firefox go up to ~1.5 MB, with a Safari-
|
|
7
7
|
* recipient disclaimer past the universal-safe size. No `$effect`; all state moves on click/submit.
|
|
8
8
|
*/
|
|
9
|
+
import { onDestroy } from "svelte";
|
|
10
|
+
import { scope } from "@ogygia/machine";
|
|
9
11
|
import { encode_permalink, is_webkit_capped, MAX_PERMALINK_CHARS } from "./permalink.js";
|
|
10
12
|
import { get_report } from "./store.js";
|
|
11
13
|
import { report_request } from "./report-request.js";
|
|
@@ -21,6 +23,9 @@ let url = $state("");
|
|
|
21
23
|
let note = $state("");
|
|
22
24
|
let err = $state("");
|
|
23
25
|
let copied = $state(false);
|
|
26
|
+
/** The panel's own timer: let go when it unmounts. */
|
|
27
|
+
const owner = scope();
|
|
28
|
+
onDestroy(() => owner.close());
|
|
24
29
|
async function generate(e) {
|
|
25
30
|
e.preventDefault();
|
|
26
31
|
if (busy || !password) return;
|
|
@@ -55,7 +60,7 @@ async function copy() {
|
|
|
55
60
|
try {
|
|
56
61
|
await navigator.clipboard.writeText(url);
|
|
57
62
|
copied = true;
|
|
58
|
-
|
|
63
|
+
owner.timeout(() => copied = false, 1500);
|
|
59
64
|
} catch {}
|
|
60
65
|
}
|
|
61
66
|
</script>
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* browser kept ("the browser is the database"); when the backend lands, the router fills `server`
|
|
9
9
|
* and the two lists merge — nothing else here changes. `live` is the same seam for a status feed.
|
|
10
10
|
*/
|
|
11
|
+
import { scope, STALE } from "@ogygia/machine";
|
|
11
12
|
import { list_reports } from "./store.js";
|
|
12
13
|
import ThemeSwitch from "./ThemeSwitch.svelte";
|
|
13
14
|
let { base, current = null } = $props();
|
|
@@ -21,19 +22,23 @@ $effect(() => {
|
|
|
21
22
|
// requests are in flight, memory, how many reports it holds. The rail's pulse.
|
|
22
23
|
let live = $state(null);
|
|
23
24
|
$effect(() => {
|
|
24
|
-
|
|
25
|
+
// the poll and its request in flight: the next run of the effect, or the unmount, ends them
|
|
26
|
+
const s = scope();
|
|
25
27
|
const poll = async () => {
|
|
26
28
|
try {
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
+
const next = await s.task(async (signal) => {
|
|
30
|
+
const r = await fetch(`${base}/status.json`, {
|
|
31
|
+
headers: { accept: "application/json" },
|
|
32
|
+
signal
|
|
33
|
+
});
|
|
34
|
+
return r.ok ? await r.json() : null;
|
|
35
|
+
});
|
|
36
|
+
if (next !== STALE && next !== null) live = next;
|
|
29
37
|
} catch {}
|
|
30
38
|
};
|
|
31
|
-
poll();
|
|
32
|
-
|
|
33
|
-
return () =>
|
|
34
|
-
alive = false;
|
|
35
|
-
clearInterval(t);
|
|
36
|
-
};
|
|
39
|
+
void poll();
|
|
40
|
+
s.every(() => void poll(), 4e3);
|
|
41
|
+
return () => s.close();
|
|
37
42
|
});
|
|
38
43
|
let local = $state([]);
|
|
39
44
|
$effect(() => {
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
<script
|
|
1
|
+
<script>/**
|
|
2
|
+
* CPU treemap — a `wake:'load'` canvas island (was TREE_JS). Squarified layout of self-time,
|
|
3
|
+
* root → category → leaf; click a box to drill in, the breadcrumb zooms back out; hover for detail.
|
|
4
|
+
* The zoom stack and tooltip are `$state`; the canvas redraws in an `$effect` when either the stack
|
|
5
|
+
* or a resize changes. `rects` (hit regions) is plain (non-reactive) — it's rebuilt each draw.
|
|
6
|
+
*/
|
|
7
|
+
import { scope } from "@ogygia/machine";
|
|
8
|
+
let { hierarchy } = $props();
|
|
2
9
|
const H = 440;
|
|
3
10
|
let canvas;
|
|
4
11
|
// the zoom stack starts at the root it was given; clicks own it after
|
|
@@ -187,9 +194,9 @@ $effect(() => {
|
|
|
187
194
|
draw();
|
|
188
195
|
});
|
|
189
196
|
$effect(() => {
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
return () =>
|
|
197
|
+
const s = scope();
|
|
198
|
+
s.on(window, "resize", () => resizeTick++);
|
|
199
|
+
return () => s.close();
|
|
193
200
|
});
|
|
194
201
|
function at(ev) {
|
|
195
202
|
const r = canvas.getBoundingClientRect();
|
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CPU treemap — a `wake:'load'` canvas island (was TREE_JS). Squarified layout of self-time,
|
|
3
|
-
* root → category → leaf; click a box to drill in, the breadcrumb zooms back out; hover for detail.
|
|
4
|
-
* The zoom stack and tooltip are `$state`; the canvas redraws in an `$effect` when either the stack
|
|
5
|
-
* or a resize changes. `rects` (hit regions) is plain (non-reactive) — it's rebuilt each draw.
|
|
6
|
-
*/
|
|
7
1
|
interface Leaf {
|
|
8
2
|
label: string;
|
|
9
3
|
value: number;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<script>/** Waiting-by-function table — a `wake:'load'` island with reactive sort (count / wait ms). Where
|
|
2
2
|
* the server WAITED (not computed), attributed to the function that started the I/O. */
|
|
3
3
|
import { fmt_ms, kind_color } from "./format.js";
|
|
4
|
-
import {
|
|
4
|
+
import { Sortable } from "./sort.svelte.js";
|
|
5
5
|
import { pressable } from "./press.js";
|
|
6
6
|
let { rows, maxMs } = $props();
|
|
7
|
-
const s =
|
|
7
|
+
const s = new Sortable(() => rows, "ms");
|
|
8
8
|
</script>
|
|
9
9
|
|
|
10
10
|
<table>
|
|
@@ -1,15 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
* "something is happening" reassurance bar. Call `start()` when the request goes out, `finish()` when
|
|
6
|
-
* it resolves (it awaits one paint at 100% before returning), `fail()` on error.
|
|
7
|
-
*/
|
|
8
|
-
export interface FakeProgress {
|
|
9
|
-
readonly value: number;
|
|
10
|
-
readonly running: boolean;
|
|
1
|
+
export declare class FakeProgress {
|
|
2
|
+
#private;
|
|
3
|
+
get value(): number;
|
|
4
|
+
get running(): boolean;
|
|
11
5
|
start(): void;
|
|
12
6
|
finish(): Promise<void>;
|
|
13
7
|
fail(): void;
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
/** Lets go of every timer, for good (the view unmounted). */
|
|
9
|
+
[Symbol.dispose](): void;
|
|
10
|
+
}
|
|
@@ -1,38 +1,59 @@
|
|
|
1
|
+
import { STALE, scope } from "@ogygia/machine";
|
|
1
2
|
// src/profiler/ui/fake-progress.svelte.ts
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
/**
|
|
4
|
+
* A Windows-style "determinate-ish" progress: crawls forward fast at first, eases toward 95% with the
|
|
5
|
+
* occasional burst, then snaps to 100% when the real work finishes. There is no true percentage to
|
|
6
|
+
* report — the profile endpoint just works until it's done — so this is a timed illusion, the standard
|
|
7
|
+
* "something is happening" reassurance bar. Call `start()` when the request goes out, `finish()` when
|
|
8
|
+
* it resolves (it awaits one paint at 100% before returning), `fail()` on error.
|
|
9
|
+
*
|
|
10
|
+
* Its timers belong to it (a scope): the view that shows it disposes it when it unmounts, and every
|
|
11
|
+
* timer goes with it, mid-run or not.
|
|
12
|
+
*/
|
|
13
|
+
var FakeProgress = class {
|
|
14
|
+
#value = $state(0);
|
|
15
|
+
#running = $state(false);
|
|
16
|
+
#owner = scope();
|
|
17
|
+
/** The run's ticking, while one runs. */
|
|
18
|
+
#run = null;
|
|
19
|
+
get value() {
|
|
20
|
+
return this.#value;
|
|
21
|
+
}
|
|
22
|
+
get running() {
|
|
23
|
+
return this.#running;
|
|
24
|
+
}
|
|
25
|
+
start() {
|
|
26
|
+
this.#stop();
|
|
27
|
+
if (this.#owner.closed) return;
|
|
28
|
+
this.#value = 0;
|
|
29
|
+
this.#running = true;
|
|
30
|
+
this.#run = this.#owner.child();
|
|
31
|
+
this.#run.every(() => this.#tick(), 170);
|
|
32
|
+
}
|
|
33
|
+
async finish() {
|
|
34
|
+
this.#stop();
|
|
35
|
+
this.#value = 100;
|
|
36
|
+
const owner = this.#owner;
|
|
37
|
+
if (await owner.task(() => new Promise((r) => owner.timeout(r, 420))) !== STALE) this.#running = false;
|
|
38
|
+
}
|
|
39
|
+
fail() {
|
|
40
|
+
this.#stop();
|
|
41
|
+
this.#running = false;
|
|
42
|
+
this.#value = 0;
|
|
43
|
+
}
|
|
44
|
+
/** Lets go of every timer, for good (the view unmounted). */
|
|
45
|
+
[Symbol.dispose]() {
|
|
46
|
+
this.#owner.close();
|
|
47
|
+
}
|
|
48
|
+
#stop() {
|
|
49
|
+
this.#run?.close();
|
|
50
|
+
this.#run = null;
|
|
51
|
+
}
|
|
52
|
+
#tick() {
|
|
53
|
+
if (this.#value >= 95) return;
|
|
54
|
+
const gap = 95 - this.#value;
|
|
9
55
|
const burst = Math.random() < .18 ? gap * .14 : 0;
|
|
10
|
-
value = Math.min(95, value + gap * .045 + burst + .5);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return value;
|
|
15
|
-
},
|
|
16
|
-
get running() {
|
|
17
|
-
return running;
|
|
18
|
-
},
|
|
19
|
-
start() {
|
|
20
|
-
clearInterval(timer);
|
|
21
|
-
value = 0;
|
|
22
|
-
running = true;
|
|
23
|
-
timer = setInterval(tick, 170);
|
|
24
|
-
},
|
|
25
|
-
async finish() {
|
|
26
|
-
clearInterval(timer);
|
|
27
|
-
value = 100;
|
|
28
|
-
await new Promise((r) => setTimeout(r, 420));
|
|
29
|
-
running = false;
|
|
30
|
-
},
|
|
31
|
-
fail() {
|
|
32
|
-
clearInterval(timer);
|
|
33
|
-
running = false;
|
|
34
|
-
value = 0;
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
export { fake_progress };
|
|
56
|
+
this.#value = Math.min(95, this.#value + gap * .045 + burst + .5);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
export { FakeProgress };
|