@atolljs/devtools 0.1.7 → 0.1.9
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/README.md +408 -71
- package/app/api.js +68 -0
- package/app/fw-logos.js +14 -0
- package/app/index.html +161 -2
- package/app/main.js +897 -222
- package/app/panels/actions.js +324 -0
- package/app/panels/audit-rules.js +728 -0
- package/app/panels/audits.js +438 -0
- package/app/panels/inspect-island.js +351 -0
- package/app/panels/memory-values.js +287 -0
- package/app/panels/performance.js +304 -0
- package/app/panels/reactivity.js +271 -0
- package/app/panels/recorder.js +344 -0
- package/app/panels/recording-format.js +166 -0
- package/app/panels/route.d.ts +44 -0
- package/app/panels/route.js +264 -0
- package/app/panels/shell.js +789 -0
- package/app/panels/toast.js +38 -0
- package/dist/bin.js +704 -5
- package/dist/chunk-57NGHQUL.js +344 -0
- package/dist/chunk-6E54FXKS.js +495 -0
- package/dist/chunk-WIL2P7HG.js +661 -0
- package/dist/client.d.ts +7 -0
- package/dist/control.d.ts +4 -0
- package/dist/index.js +2 -1
- package/dist/node.js +2 -1
- package/dist/nodeWs.d.ts +1 -0
- package/dist/otel.d.ts +35 -0
- package/dist/otel.js +87 -0
- package/dist/otelMap.d.ts +232 -0
- package/dist/otelTransport.d.ts +69 -0
- package/dist/overlay.d.ts +16 -2
- package/dist/probes.d.ts +7 -0
- package/dist/protocol.d.ts +46 -4
- package/dist/server.d.ts +19 -0
- package/dist/server.js +61 -3
- package/package.json +6 -2
- package/src/bin.ts +27 -2
- package/src/broadcast.ts +6 -0
- package/src/client.ts +26 -2
- package/src/control.ts +51 -0
- package/src/nodeWs.ts +7 -1
- package/src/otel.ts +140 -0
- package/src/otelMap.ts +661 -0
- package/src/otelTransport.ts +286 -0
- package/src/overlay.ts +239 -47
- package/src/probes.ts +155 -0
- package/src/protocol.ts +52 -4
- package/src/server.ts +83 -3
- package/dist/chunk-4N3NBFL3.js +0 -474
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OTLP/HTTP JSON transport: a retrying `fetch` sender and the batching
|
|
3
|
+
* pipeline that drains `OtelMapper`s into it. No @atolljs/core runtime
|
|
4
|
+
* imports, so the aggregate server can forward without bundling the SDK.
|
|
5
|
+
*/
|
|
6
|
+
import type { EmittedDevtoolsEvent } from '@atolljs/core';
|
|
7
|
+
import {
|
|
8
|
+
encodeLogs,
|
|
9
|
+
encodeMetrics,
|
|
10
|
+
encodeTraces,
|
|
11
|
+
toAttributes,
|
|
12
|
+
type KeyValue,
|
|
13
|
+
type OtelMapper,
|
|
14
|
+
type OtelSignals,
|
|
15
|
+
} from './otelMap';
|
|
16
|
+
import type { SessionInfo } from './protocol';
|
|
17
|
+
|
|
18
|
+
export type OtlpSignal = 'traces' | 'metrics' | 'logs';
|
|
19
|
+
|
|
20
|
+
export interface OtlpSenderOptions {
|
|
21
|
+
/** OTLP/HTTP base URL; `/v1/<signal>` is appended (default http://localhost:4318). */
|
|
22
|
+
endpoint?: string;
|
|
23
|
+
/** Extra request headers (auth: `x-honeycomb-team`, `authorization`, …). */
|
|
24
|
+
headers?: Record<string, string>;
|
|
25
|
+
/** fetch implementation (default: the global fetch captured at creation). */
|
|
26
|
+
fetch?: typeof fetch;
|
|
27
|
+
/** Retries per request on 429/502/503/504 or network failure (default 3). */
|
|
28
|
+
maxRetries?: number;
|
|
29
|
+
/** Exponential backoff base when no Retry-After is sent (default 500ms). */
|
|
30
|
+
retryBaseMs?: number;
|
|
31
|
+
/** Cap on any single retry delay (default 30s). */
|
|
32
|
+
retryMaxMs?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Export failures (non-retryable status, retries exhausted). Never thrown
|
|
35
|
+
* into app code. Don't route this through atoll's `log()`: that tees back
|
|
36
|
+
* into the devtools stream and becomes the next export.
|
|
37
|
+
*/
|
|
38
|
+
onError?: (error: Error, signal: OtlpSignal) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface OtlpSender {
|
|
42
|
+
readonly endpoint: string;
|
|
43
|
+
/** POST one request body; resolves true when accepted. Never rejects. */
|
|
44
|
+
send(signal: OtlpSignal, body: unknown, opts?: { keepalive?: boolean }): Promise<boolean>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Retryable per the OTLP/HTTP spec; every other 4xx/5xx is final. */
|
|
48
|
+
const RETRYABLE = new Set([429, 502, 503, 504]);
|
|
49
|
+
/** fetch keepalive bodies share a 64KiB in-flight budget. */
|
|
50
|
+
const KEEPALIVE_MAX = 60_000;
|
|
51
|
+
|
|
52
|
+
const sleep = (ms: number) => new Promise<void>((r) => {
|
|
53
|
+
const t = setTimeout(r, ms);
|
|
54
|
+
(t as unknown as { unref?: () => void }).unref?.();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** Retry-After: delta-seconds or an HTTP-date. */
|
|
58
|
+
const retryAfterMs = (h: string | null): number | undefined => {
|
|
59
|
+
if (!h) return undefined;
|
|
60
|
+
const secs = Number(h);
|
|
61
|
+
if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
|
|
62
|
+
const at = Date.parse(h);
|
|
63
|
+
return Number.isNaN(at) ? undefined : Math.max(0, at - Date.now());
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const normalizeEndpoint = (endpoint = 'http://localhost:4318'): string => endpoint.replace(/\/+$/, '');
|
|
67
|
+
|
|
68
|
+
export function createOtlpSender(opts: OtlpSenderOptions = {}): OtlpSender {
|
|
69
|
+
const endpoint = normalizeEndpoint(opts.endpoint);
|
|
70
|
+
const doFetch = opts.fetch ?? (typeof fetch === 'function' ? fetch.bind(globalThis) : undefined);
|
|
71
|
+
const maxRetries = opts.maxRetries ?? 3;
|
|
72
|
+
const base = opts.retryBaseMs ?? 500;
|
|
73
|
+
const cap = opts.retryMaxMs ?? 30_000;
|
|
74
|
+
const fail = (signal: OtlpSignal, message: string) => {
|
|
75
|
+
try {
|
|
76
|
+
opts.onError?.(new Error(message), signal);
|
|
77
|
+
} catch {
|
|
78
|
+
/* a throwing callback must not escape */
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
endpoint,
|
|
84
|
+
async send(signal, body, sendOpts) {
|
|
85
|
+
if (!doFetch) {
|
|
86
|
+
fail(signal, 'otlp: no fetch available');
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
const text = JSON.stringify(body);
|
|
90
|
+
const keepalive = !!sendOpts?.keepalive && text.length < KEEPALIVE_MAX;
|
|
91
|
+
for (let attempt = 0; ; attempt++) {
|
|
92
|
+
let delay: number | undefined;
|
|
93
|
+
let reason: string;
|
|
94
|
+
try {
|
|
95
|
+
const res = await doFetch(`${endpoint}/v1/${signal}`, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { ...opts.headers, 'content-type': 'application/json' },
|
|
98
|
+
body: text,
|
|
99
|
+
keepalive,
|
|
100
|
+
});
|
|
101
|
+
if (res.ok) return true;
|
|
102
|
+
reason = `otlp ${signal}: HTTP ${res.status}`;
|
|
103
|
+
if (!RETRYABLE.has(res.status)) {
|
|
104
|
+
fail(signal, reason);
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
delay = retryAfterMs(res.headers.get('retry-after'));
|
|
108
|
+
} catch (err) {
|
|
109
|
+
reason = `otlp ${signal}: ${err instanceof Error ? err.message : String(err)}`;
|
|
110
|
+
}
|
|
111
|
+
if (attempt >= maxRetries) {
|
|
112
|
+
fail(signal, `${reason} (gave up after ${attempt + 1} attempts)`);
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
const backoff = base * 2 ** attempt;
|
|
116
|
+
await sleep(Math.min(cap, delay ?? backoff * (0.5 + Math.random() / 2)));
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface OtelPipelineOptions {
|
|
123
|
+
sender: OtlpSender;
|
|
124
|
+
/** Builds the mapper for a key on first use ('' for a single-app exporter). */
|
|
125
|
+
newMapper: (key: string) => OtelMapper;
|
|
126
|
+
/** Periodic flush interval (default 5000ms). 0 disables the timer. */
|
|
127
|
+
flushIntervalMs?: number;
|
|
128
|
+
/** Spans / log records per request; reaching it triggers an early flush (default 512). */
|
|
129
|
+
maxBatch?: number;
|
|
130
|
+
signals?: OtelSignals;
|
|
131
|
+
/** Epoch-ms clock for metric collection (default Date.now). */
|
|
132
|
+
now?: () => number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface OtelPipeline {
|
|
136
|
+
ingest(ev: EmittedDevtoolsEvent, recvEpochMs: number, key?: string): void;
|
|
137
|
+
/** Stop tracking a key after its queued data ships on the next flush. */
|
|
138
|
+
release(key: string): void;
|
|
139
|
+
/** Serialized: concurrent calls queue behind the in-flight flush. Never rejects. */
|
|
140
|
+
flush(opts?: { keepalive?: boolean }): Promise<void>;
|
|
141
|
+
close(): Promise<void>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function createOtelPipeline(opts: OtelPipelineOptions): OtelPipeline {
|
|
145
|
+
const { sender, newMapper } = opts;
|
|
146
|
+
const maxBatch = Math.max(1, opts.maxBatch ?? 512);
|
|
147
|
+
const now = opts.now ?? Date.now;
|
|
148
|
+
const traces = opts.signals?.traces !== false;
|
|
149
|
+
const metrics = opts.signals?.metrics !== false;
|
|
150
|
+
const logs = opts.signals?.logs !== false;
|
|
151
|
+
const mappers = new Map<string, OtelMapper>();
|
|
152
|
+
const released = new Set<string>();
|
|
153
|
+
let chain: Promise<void> = Promise.resolve();
|
|
154
|
+
let flushing = false;
|
|
155
|
+
let closed = false;
|
|
156
|
+
|
|
157
|
+
const sendQueued = async (signal: 'traces' | 'logs', keepalive: boolean): Promise<void> => {
|
|
158
|
+
for (;;) {
|
|
159
|
+
let budget = maxBatch;
|
|
160
|
+
const groups: { resource: KeyValue[]; items: unknown[] }[] = [];
|
|
161
|
+
for (const m of mappers.values()) {
|
|
162
|
+
if (budget === 0) break;
|
|
163
|
+
const items = signal === 'traces' ? m.takeSpans(budget) : m.takeLogs(budget);
|
|
164
|
+
if (items.length) {
|
|
165
|
+
groups.push({ resource: m.resource, items });
|
|
166
|
+
budget -= items.length;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (groups.length === 0) return;
|
|
170
|
+
const body =
|
|
171
|
+
signal === 'traces'
|
|
172
|
+
? encodeTraces(groups.map((g) => ({ resource: g.resource, spans: g.items as never })))
|
|
173
|
+
: encodeLogs(groups.map((g) => ({ resource: g.resource, logs: g.items as never })));
|
|
174
|
+
await sender.send(signal, body, { keepalive });
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const doFlush = async (keepalive: boolean): Promise<void> => {
|
|
179
|
+
flushing = true;
|
|
180
|
+
try {
|
|
181
|
+
if (traces) await sendQueued('traces', keepalive);
|
|
182
|
+
if (logs) await sendQueued('logs', keepalive);
|
|
183
|
+
if (metrics) {
|
|
184
|
+
const t = now();
|
|
185
|
+
const groups = [...mappers.values()]
|
|
186
|
+
.map((m) => ({ resource: m.resource, metrics: m.collectMetrics(t) }))
|
|
187
|
+
.filter((g) => g.metrics.length > 0);
|
|
188
|
+
if (groups.length) await sender.send('metrics', encodeMetrics(groups), { keepalive });
|
|
189
|
+
}
|
|
190
|
+
for (const key of released) {
|
|
191
|
+
const m = mappers.get(key);
|
|
192
|
+
if (!m || (m.spans.length === 0 && m.logs.length === 0)) {
|
|
193
|
+
mappers.delete(key);
|
|
194
|
+
released.delete(key);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} finally {
|
|
198
|
+
flushing = false;
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const flush = (f?: { keepalive?: boolean }): Promise<void> => {
|
|
203
|
+
chain = chain.then(() => doFlush(!!f?.keepalive)).catch(() => {});
|
|
204
|
+
return chain;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const interval = opts.flushIntervalMs ?? 5000;
|
|
208
|
+
const timer = interval > 0 ? setInterval(() => void flush(), interval) : undefined;
|
|
209
|
+
(timer as unknown as { unref?: () => void } | undefined)?.unref?.();
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
ingest(ev, recvEpochMs, key = '') {
|
|
213
|
+
if (closed) return;
|
|
214
|
+
let m = mappers.get(key);
|
|
215
|
+
if (!m) {
|
|
216
|
+
m = newMapper(key);
|
|
217
|
+
mappers.set(key, m);
|
|
218
|
+
}
|
|
219
|
+
released.delete(key);
|
|
220
|
+
m.ingest(ev, recvEpochMs);
|
|
221
|
+
if (!flushing && (m.spans.length >= maxBatch || m.logs.length >= maxBatch)) void flush();
|
|
222
|
+
},
|
|
223
|
+
release(key) {
|
|
224
|
+
if (mappers.has(key)) released.add(key);
|
|
225
|
+
},
|
|
226
|
+
flush,
|
|
227
|
+
async close() {
|
|
228
|
+
if (closed) return chain;
|
|
229
|
+
closed = true;
|
|
230
|
+
if (timer) clearInterval(timer);
|
|
231
|
+
return flush();
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/* ── resource ────────────────────────────────────────────────────────────── */
|
|
237
|
+
|
|
238
|
+
export type ResourceAttributes = Record<string, string | number | boolean | undefined>;
|
|
239
|
+
|
|
240
|
+
/** Resource attributes for this process: service.name, telemetry.sdk.*, runtime hints. */
|
|
241
|
+
export function localResource(serviceName?: string, extra: ResourceAttributes = {}): KeyValue[] {
|
|
242
|
+
const g = globalThis as {
|
|
243
|
+
window?: { document?: unknown };
|
|
244
|
+
navigator?: { userAgent?: string };
|
|
245
|
+
process?: { pid?: number; versions?: { node?: string } };
|
|
246
|
+
};
|
|
247
|
+
const browser = typeof g.window !== 'undefined' && typeof g.window.document !== 'undefined';
|
|
248
|
+
const node = !browser && g.process?.versions?.node !== undefined;
|
|
249
|
+
return toAttributes({
|
|
250
|
+
'service.name': serviceName ?? 'unknown_service',
|
|
251
|
+
'telemetry.sdk.name': 'atoll',
|
|
252
|
+
'telemetry.sdk.language': node ? 'nodejs' : 'webjs',
|
|
253
|
+
...(node
|
|
254
|
+
? {
|
|
255
|
+
'process.pid': g.process?.pid,
|
|
256
|
+
'process.runtime.name': 'nodejs',
|
|
257
|
+
'process.runtime.version': g.process?.versions?.node,
|
|
258
|
+
}
|
|
259
|
+
: { 'user_agent.original': g.navigator?.userAgent }),
|
|
260
|
+
...extra,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Resource attributes for a remote app session (aggregate-server
|
|
266
|
+
* forwarding): the session name is the service unless a fixed one is given.
|
|
267
|
+
*/
|
|
268
|
+
export function sessionResource(
|
|
269
|
+
session: SessionInfo,
|
|
270
|
+
serviceName?: string,
|
|
271
|
+
extra: ResourceAttributes = {},
|
|
272
|
+
): KeyValue[] {
|
|
273
|
+
const node = session.runtime === 'node';
|
|
274
|
+
return toAttributes({
|
|
275
|
+
'service.name': serviceName ?? session.name ?? 'unknown_service',
|
|
276
|
+
'service.instance.id': session.id,
|
|
277
|
+
'telemetry.sdk.name': 'atoll',
|
|
278
|
+
'telemetry.sdk.language': node ? 'nodejs' : 'webjs',
|
|
279
|
+
...(node
|
|
280
|
+
? { 'process.runtime.name': 'nodejs', 'process.runtime.version': session.env?.node?.replace(/^v/, '') }
|
|
281
|
+
: { 'user_agent.original': session.env?.userAgent }),
|
|
282
|
+
'atoll.session.hint': session.hint,
|
|
283
|
+
'atoll.framework': session.framework,
|
|
284
|
+
...extra,
|
|
285
|
+
});
|
|
286
|
+
}
|
package/src/overlay.ts
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* connectDevtools();
|
|
10
10
|
* mountDevtoolsOverlay();
|
|
11
11
|
* }
|
|
12
|
+
*
|
|
13
|
+
* Alt+Shift+D toggles the flyout from the host page (`hotkey` option). The
|
|
14
|
+
* dashboard inside the iframe forwards the same chord via postMessage, so
|
|
15
|
+
* it works wherever focus is. "full page ↗" opens the dashboard on the
|
|
16
|
+
* view the flyout is showing (its `#/<view>…` hash route).
|
|
12
17
|
*/
|
|
13
18
|
|
|
14
19
|
export type DevtoolsOverlayPosition =
|
|
@@ -22,13 +27,20 @@ export type DevtoolsOverlayPosition =
|
|
|
22
27
|
export interface DevtoolsOverlayOptions {
|
|
23
28
|
/** Dashboard URL — default '/__atoll/?mini=1' (vite-plugin mount, mini layout). */
|
|
24
29
|
src?: string;
|
|
25
|
-
/** Start expanded (default
|
|
30
|
+
/** Start expanded (default: the remembered state, else collapsed to a corner toggle). */
|
|
26
31
|
startOpen?: boolean;
|
|
27
|
-
/** Initial size (default 760×580). */
|
|
32
|
+
/** Initial size (default 760×580; a remembered resize wins). */
|
|
28
33
|
width?: number;
|
|
29
34
|
height?: number;
|
|
30
35
|
/** Where the flyout (and its collapsed toggle) anchors — default 'bottomright'. */
|
|
31
36
|
position?: DevtoolsOverlayPosition;
|
|
37
|
+
/**
|
|
38
|
+
* Remember position, size and open state in localStorage across reloads —
|
|
39
|
+
* default true; a string is the storage key (default 'atoll-devtools:overlay').
|
|
40
|
+
*/
|
|
41
|
+
persist?: boolean | string;
|
|
42
|
+
/** Host-page shortcut that toggles the flyout — default 'Alt+Shift+D'; false disables. */
|
|
43
|
+
hotkey?: string | false;
|
|
32
44
|
}
|
|
33
45
|
|
|
34
46
|
export interface DevtoolsOverlay {
|
|
@@ -38,6 +50,14 @@ export interface DevtoolsOverlay {
|
|
|
38
50
|
unmount(): void;
|
|
39
51
|
}
|
|
40
52
|
|
|
53
|
+
interface Saved {
|
|
54
|
+
left?: number;
|
|
55
|
+
top?: number;
|
|
56
|
+
width?: number;
|
|
57
|
+
height?: number;
|
|
58
|
+
open?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
41
61
|
const BTN_STYLE =
|
|
42
62
|
'position:fixed;z-index:2147483646;' +
|
|
43
63
|
'font:11px ui-monospace,monospace;color:#c9d1d9;background:#161b22;' +
|
|
@@ -46,7 +66,21 @@ const BTN_STYLE =
|
|
|
46
66
|
const PANEL_STYLE =
|
|
47
67
|
'position:fixed;z-index:2147483646;display:flex;flex-direction:column;' +
|
|
48
68
|
'background:#0d1117;border:1px solid #30363d;border-radius:8px;' +
|
|
49
|
-
'box-shadow:0 12px 40px #000c;overflow:hidden;min-width:340px;min-height:220px'
|
|
69
|
+
'box-shadow:0 12px 40px #000c;overflow:hidden;min-width:340px;min-height:220px;' +
|
|
70
|
+
'max-width:100vw;max-height:100vh;box-sizing:border-box';
|
|
71
|
+
|
|
72
|
+
type Edge = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
|
|
73
|
+
const EDGE_CSS: Record<Edge, string> = {
|
|
74
|
+
n: 'top:0;left:10px;right:10px;height:4px;cursor:ns-resize',
|
|
75
|
+
s: 'bottom:0;left:10px;right:16px;height:5px;cursor:ns-resize',
|
|
76
|
+
e: 'right:0;top:10px;bottom:16px;width:5px;cursor:ew-resize',
|
|
77
|
+
w: 'left:0;top:10px;bottom:10px;width:5px;cursor:ew-resize',
|
|
78
|
+
ne: 'right:0;top:0;width:10px;height:10px;cursor:nesw-resize',
|
|
79
|
+
nw: 'left:0;top:0;width:10px;height:10px;cursor:nwse-resize',
|
|
80
|
+
sw: 'left:0;bottom:0;width:10px;height:10px;cursor:nesw-resize',
|
|
81
|
+
se: 'right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize;' +
|
|
82
|
+
'background:linear-gradient(135deg,transparent 50%,#30363d 50%);border-radius:0 0 7px',
|
|
83
|
+
};
|
|
50
84
|
|
|
51
85
|
const ANCHOR: Record<DevtoolsOverlayPosition, (m: number) => string> = {
|
|
52
86
|
topleft: (m) => `left:${m}px;top:${m}px`,
|
|
@@ -57,18 +91,53 @@ const ANCHOR: Record<DevtoolsOverlayPosition, (m: number) => string> = {
|
|
|
57
91
|
bottomright: (m) => `right:${m}px;bottom:${m}px`,
|
|
58
92
|
};
|
|
59
93
|
|
|
94
|
+
const MIN_W = 340;
|
|
95
|
+
const MIN_H = 220;
|
|
96
|
+
let seq = 0;
|
|
97
|
+
|
|
98
|
+
/** 'Alt+Shift+D' → a keydown predicate. Letters/digits match on `code`, so Alt/Option layouts still hit. */
|
|
99
|
+
export function parseHotkey(spec: string): (e: KeyboardEvent) => boolean {
|
|
100
|
+
const parts = spec.split('+').map((p) => p.trim().toLowerCase()).filter(Boolean);
|
|
101
|
+
const key = parts.pop() ?? '';
|
|
102
|
+
const mods = new Set(parts.map((m) => (m === 'option' ? 'alt' : m === 'cmd' || m === 'meta' ? 'meta' : m === 'control' ? 'ctrl' : m)));
|
|
103
|
+
const code = /^[a-z]$/.test(key) ? `Key${key.toUpperCase()}` : /^[0-9]$/.test(key) ? `Digit${key}` : null;
|
|
104
|
+
return (e) =>
|
|
105
|
+
e.altKey === mods.has('alt') &&
|
|
106
|
+
e.shiftKey === mods.has('shift') &&
|
|
107
|
+
e.ctrlKey === mods.has('ctrl') &&
|
|
108
|
+
e.metaKey === mods.has('meta') &&
|
|
109
|
+
(code ? e.code === code : e.key.toLowerCase() === key);
|
|
110
|
+
}
|
|
111
|
+
|
|
60
112
|
export function mountDevtoolsOverlay(opts: DevtoolsOverlayOptions = {}): DevtoolsOverlay {
|
|
61
113
|
const src = opts.src ?? '/__atoll/?mini=1';
|
|
62
|
-
const
|
|
63
|
-
const
|
|
114
|
+
const storeKey = opts.persist === false ? null : typeof opts.persist === 'string' ? opts.persist : 'atoll-devtools:overlay';
|
|
115
|
+
const load = (): Saved => {
|
|
116
|
+
if (!storeKey) return {};
|
|
117
|
+
try { return JSON.parse(localStorage.getItem(storeKey) ?? '{}') as Saved; } catch { return {}; }
|
|
118
|
+
};
|
|
119
|
+
const saved = load();
|
|
120
|
+
const W = Math.max(MIN_W, saved.width ?? opts.width ?? 760);
|
|
121
|
+
const H = Math.max(MIN_H, saved.height ?? opts.height ?? 580);
|
|
64
122
|
const anchor = ANCHOR[opts.position ?? 'bottomright'];
|
|
123
|
+
const hotkey = opts.hotkey === false ? null : opts.hotkey ?? 'Alt+Shift+D';
|
|
124
|
+
const isHotkey = hotkey ? parseHotkey(hotkey) : null;
|
|
125
|
+
const hint = hotkey ? ` (${hotkey})` : '';
|
|
126
|
+
const id = `atoll-devtools-overlay-${++seq}`;
|
|
65
127
|
let open = false;
|
|
66
128
|
|
|
67
129
|
const btn = document.createElement('button');
|
|
130
|
+
btn.type = 'button';
|
|
68
131
|
btn.textContent = 'atoll devtools';
|
|
132
|
+
btn.title = `Open atoll devtools${hint}`;
|
|
133
|
+
btn.setAttribute('aria-controls', id);
|
|
134
|
+
btn.setAttribute('aria-expanded', 'false');
|
|
69
135
|
btn.style.cssText = `${BTN_STYLE};${anchor(12)}`;
|
|
70
136
|
|
|
71
137
|
const panel = document.createElement('div');
|
|
138
|
+
panel.id = id;
|
|
139
|
+
panel.setAttribute('role', 'dialog');
|
|
140
|
+
panel.setAttribute('aria-label', 'atoll devtools');
|
|
72
141
|
panel.style.cssText =
|
|
73
142
|
`${PANEL_STYLE};width:${W}px;height:${H}px;display:none;${anchor(16)}`;
|
|
74
143
|
|
|
@@ -77,44 +146,123 @@ export function mountDevtoolsOverlay(opts: DevtoolsOverlayOptions = {}): Devtool
|
|
|
77
146
|
'display:flex;align-items:center;gap:8px;padding:5px 10px;flex:none;' +
|
|
78
147
|
'border-bottom:1px solid #21262d;font:11px ui-monospace,monospace;' +
|
|
79
148
|
'color:#8b949e;background:#161b22;cursor:move;user-select:none';
|
|
80
|
-
|
|
149
|
+
const title = document.createElement('span');
|
|
150
|
+
title.textContent = 'atoll devtools';
|
|
81
151
|
|
|
152
|
+
// Full-page dashboard = src minus its query/hash — stays relative when the
|
|
153
|
+
// caller passes a relative src (e.g. '__atoll/?mini=1' under a mounted
|
|
154
|
+
// base path where absolute /__atoll/ would escape the mount) — plus the
|
|
155
|
+
// iframe's current hash route, so the tab opens on the same view.
|
|
156
|
+
const base = src.split(/[?#]/)[0];
|
|
157
|
+
const frame = document.createElement('iframe');
|
|
158
|
+
let route = '';
|
|
159
|
+
const currentRoute = (): string => {
|
|
160
|
+
try {
|
|
161
|
+
const h = frame.contentWindow?.location.hash;
|
|
162
|
+
if (typeof h === 'string' && h.startsWith('#/')) return h;
|
|
163
|
+
} catch { /* cross-origin src: rely on posted routes */ }
|
|
164
|
+
return route;
|
|
165
|
+
};
|
|
82
166
|
const pop = document.createElement('a');
|
|
83
|
-
pop.href =
|
|
167
|
+
pop.href = base;
|
|
84
168
|
pop.target = '_blank';
|
|
85
169
|
pop.rel = 'noopener';
|
|
86
170
|
pop.textContent = 'full page ↗';
|
|
171
|
+
pop.title = 'Open this view in the full-page dashboard';
|
|
87
172
|
pop.style.cssText = 'color:#58a6ff;text-decoration:none;margin-left:auto';
|
|
173
|
+
const syncPop = () => { pop.href = base + currentRoute(); };
|
|
174
|
+
pop.addEventListener('pointerenter', syncPop);
|
|
175
|
+
pop.addEventListener('focus', syncPop);
|
|
176
|
+
pop.addEventListener('click', syncPop); // runs before the default navigation
|
|
88
177
|
const x = document.createElement('button');
|
|
89
|
-
x.
|
|
90
|
-
x.
|
|
178
|
+
x.type = 'button';
|
|
179
|
+
x.textContent = '×';
|
|
180
|
+
x.title = `Close${hint}`;
|
|
181
|
+
x.setAttribute('aria-label', 'Close atoll devtools');
|
|
91
182
|
x.style.cssText =
|
|
92
|
-
'color:#8b949e;background:none;border:none;cursor:pointer;font:
|
|
93
|
-
head.append(pop, x);
|
|
183
|
+
'color:#8b949e;background:none;border:none;cursor:pointer;font:14px/1 ui-monospace,monospace;padding:0 2px';
|
|
184
|
+
head.append(title, pop, x);
|
|
94
185
|
|
|
95
|
-
const frame = document.createElement('iframe');
|
|
96
186
|
frame.src = src;
|
|
187
|
+
frame.title = 'atoll devtools dashboard';
|
|
97
188
|
frame.style.cssText = 'flex:1;border:0;background:#0d1117;min-height:0';
|
|
98
189
|
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
'
|
|
103
|
-
'
|
|
104
|
-
|
|
190
|
+
// Manual resize handles on every edge and corner (CSS `resize` doesn't work
|
|
191
|
+
// over an iframe). The visible se grip stays the panel's last child.
|
|
192
|
+
const handles = (['n', 's', 'e', 'w', 'ne', 'nw', 'sw', 'se'] as const).map((edge) => {
|
|
193
|
+
const h = document.createElement('div');
|
|
194
|
+
h.setAttribute('aria-hidden', 'true');
|
|
195
|
+
h.dataset.edge = edge;
|
|
196
|
+
h.style.cssText = `position:absolute;z-index:1;${EDGE_CSS[edge]}`;
|
|
197
|
+
return h;
|
|
198
|
+
});
|
|
199
|
+
panel.append(head, frame, ...handles);
|
|
105
200
|
document.body.append(panel, btn);
|
|
106
201
|
|
|
107
|
-
|
|
108
|
-
const
|
|
202
|
+
const px = (v: string) => parseFloat(v) || 0;
|
|
203
|
+
const vw = () => document.documentElement.clientWidth || innerWidth;
|
|
204
|
+
const vh = () => document.documentElement.clientHeight || innerHeight;
|
|
205
|
+
const fitW = (w: number) => Math.max(MIN_W, Math.min(w, vw()));
|
|
206
|
+
const fitH = (h: number) => Math.max(MIN_H, Math.min(h, vh()));
|
|
207
|
+
|
|
208
|
+
/** Explicit left/top, clamped so the whole panel (close button included) stays in the viewport. */
|
|
209
|
+
const placeAt = (left: number, top: number) => {
|
|
210
|
+
const w = px(panel.style.width), h = px(panel.style.height);
|
|
211
|
+
panel.style.left = `${Math.min(Math.max(0, left), Math.max(0, vw() - w))}px`;
|
|
212
|
+
panel.style.top = `${Math.min(Math.max(0, top), Math.max(0, vh() - h))}px`;
|
|
213
|
+
panel.style.right = 'auto';
|
|
214
|
+
panel.style.bottom = 'auto';
|
|
215
|
+
panel.style.transform = 'none'; // drop the center anchor's translateX
|
|
216
|
+
};
|
|
217
|
+
const pinned = () => panel.style.right === 'auto';
|
|
218
|
+
/** Swap a corner/center anchor for explicit left/top at the panel's current spot. */
|
|
219
|
+
const pin = () => {
|
|
220
|
+
if (pinned()) return;
|
|
221
|
+
const r = panel.getBoundingClientRect();
|
|
222
|
+
placeAt(r.left, r.top);
|
|
223
|
+
};
|
|
224
|
+
/** Shrink to the viewport and pull back on screen (window resized, or reopened smaller). */
|
|
225
|
+
const refit = () => {
|
|
226
|
+
panel.style.width = `${fitW(px(panel.style.width))}px`;
|
|
227
|
+
panel.style.height = `${fitH(px(panel.style.height))}px`;
|
|
228
|
+
if (pinned()) placeAt(px(panel.style.left), px(panel.style.top));
|
|
229
|
+
};
|
|
230
|
+
if (typeof saved.left === 'number' && typeof saved.top === 'number') {
|
|
231
|
+
panel.style.width = `${fitW(W)}px`;
|
|
232
|
+
panel.style.height = `${fitH(H)}px`;
|
|
233
|
+
placeAt(saved.left, saved.top);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Only what the user changed is remembered: an untouched size/position
|
|
237
|
+
// keeps following the options.
|
|
238
|
+
const mem: Saved = { ...saved };
|
|
239
|
+
const save = (what: 'open' | 'move' | 'size') => {
|
|
240
|
+
if (!storeKey) return;
|
|
241
|
+
if (what === 'open') mem.open = open;
|
|
242
|
+
if (what === 'size') {
|
|
243
|
+
mem.width = parseFloat(panel.style.width);
|
|
244
|
+
mem.height = parseFloat(panel.style.height);
|
|
245
|
+
}
|
|
246
|
+
// resizing pins the panel and a w/n edge moves it, so size saves position too
|
|
247
|
+
if (what === 'move' || what === 'size') {
|
|
248
|
+
mem.left = parseFloat(panel.style.left);
|
|
249
|
+
mem.top = parseFloat(panel.style.top);
|
|
250
|
+
}
|
|
251
|
+
try { localStorage.setItem(storeKey, JSON.stringify(mem)); } catch { /* storage off */ }
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/** Pointer-drag helper: run `move` with the total offset from the press until pointerup. */
|
|
255
|
+
const drag = (e: PointerEvent, what: 'move' | 'size', move: (dx: number, dy: number) => void) => {
|
|
109
256
|
e.preventDefault();
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
};
|
|
257
|
+
const sx = e.clientX, sy = e.clientY;
|
|
258
|
+
// the iframe would swallow pointermove while the cursor is over it
|
|
259
|
+
frame.style.pointerEvents = 'none';
|
|
260
|
+
const mm = (ev: PointerEvent) => move(ev.clientX - sx, ev.clientY - sy);
|
|
115
261
|
const up = () => {
|
|
116
262
|
removeEventListener('pointermove', mm);
|
|
117
263
|
removeEventListener('pointerup', up);
|
|
264
|
+
frame.style.pointerEvents = '';
|
|
265
|
+
save(what);
|
|
118
266
|
};
|
|
119
267
|
addEventListener('pointermove', mm);
|
|
120
268
|
addEventListener('pointerup', up);
|
|
@@ -122,42 +270,86 @@ export function mountDevtoolsOverlay(opts: DevtoolsOverlayOptions = {}): Devtool
|
|
|
122
270
|
|
|
123
271
|
head.onpointerdown = (e) => {
|
|
124
272
|
if ((e.target as HTMLElement).closest('a,button')) return;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const top = Math.min(Math.max(0, r.top + dy), innerHeight - 40);
|
|
130
|
-
panel.style.left = `${left}px`;
|
|
131
|
-
panel.style.top = `${top}px`;
|
|
132
|
-
panel.style.right = 'auto';
|
|
133
|
-
panel.style.bottom = 'auto';
|
|
134
|
-
panel.style.transform = 'none'; // drop the center anchor's translateX
|
|
135
|
-
});
|
|
273
|
+
pin();
|
|
274
|
+
refit();
|
|
275
|
+
const l0 = px(panel.style.left), t0 = px(panel.style.top);
|
|
276
|
+
drag(e, 'move', (dx, dy) => placeAt(l0 + dx, t0 + dy));
|
|
136
277
|
};
|
|
137
278
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
279
|
+
for (const h of handles) {
|
|
280
|
+
const edge = h.dataset.edge as Edge;
|
|
281
|
+
h.onpointerdown = (e) => {
|
|
282
|
+
e.stopPropagation();
|
|
283
|
+
pin();
|
|
284
|
+
refit();
|
|
285
|
+
const l0 = px(panel.style.left), t0 = px(panel.style.top);
|
|
286
|
+
const w0 = px(panel.style.width), h0 = px(panel.style.height);
|
|
287
|
+
drag(e, 'size', (dx, dy) => {
|
|
288
|
+
let left = l0, top = t0, w = w0, hh = h0;
|
|
289
|
+
if (edge.includes('e')) w = Math.max(MIN_W, Math.min(w0 + dx, vw() - l0));
|
|
290
|
+
if (edge.includes('s')) hh = Math.max(MIN_H, Math.min(h0 + dy, vh() - t0));
|
|
291
|
+
if (edge.includes('w')) {
|
|
292
|
+
left = Math.max(0, Math.min(l0 + dx, l0 + w0 - MIN_W));
|
|
293
|
+
w = l0 + w0 - left;
|
|
294
|
+
}
|
|
295
|
+
if (edge.includes('n')) {
|
|
296
|
+
top = Math.max(0, Math.min(t0 + dy, t0 + h0 - MIN_H));
|
|
297
|
+
hh = t0 + h0 - top;
|
|
298
|
+
}
|
|
299
|
+
panel.style.width = `${w}px`;
|
|
300
|
+
panel.style.height = `${hh}px`;
|
|
301
|
+
panel.style.left = `${left}px`;
|
|
302
|
+
panel.style.top = `${top}px`;
|
|
303
|
+
});
|
|
304
|
+
};
|
|
305
|
+
}
|
|
146
306
|
|
|
147
|
-
const set = (v: boolean) => {
|
|
307
|
+
const set = (v: boolean, focus = false, persist = true) => {
|
|
308
|
+
const hadFocus = panel.contains(document.activeElement);
|
|
148
309
|
open = v;
|
|
149
310
|
panel.style.display = v ? 'flex' : 'none';
|
|
311
|
+
if (v) refit();
|
|
150
312
|
btn.style.display = v ? 'none' : 'block';
|
|
313
|
+
btn.setAttribute('aria-expanded', String(v));
|
|
314
|
+
if (v && focus) frame.focus();
|
|
315
|
+
if (!v && hadFocus) btn.focus();
|
|
316
|
+
if (persist) save('open');
|
|
151
317
|
};
|
|
152
|
-
btn.onclick = () => set(true);
|
|
318
|
+
btn.onclick = () => set(true, true);
|
|
153
319
|
x.onclick = () => set(false);
|
|
154
|
-
|
|
320
|
+
|
|
321
|
+
const onKey = (e: KeyboardEvent) => {
|
|
322
|
+
if (!isHotkey?.(e)) return;
|
|
323
|
+
e.preventDefault();
|
|
324
|
+
set(!open, true);
|
|
325
|
+
};
|
|
326
|
+
const onMessage = (e: MessageEvent) => {
|
|
327
|
+
if (e.source !== frame.contentWindow || !e.data || typeof e.data !== 'object') return;
|
|
328
|
+
const d = e.data as { type?: string; hash?: unknown };
|
|
329
|
+
if (d.type === 'atoll-devtools:toggle') set(!open);
|
|
330
|
+
else if (d.type === 'atoll-devtools:route' && typeof d.hash === 'string' && d.hash.startsWith('#/')) {
|
|
331
|
+
route = d.hash;
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
const onResize = () => { if (open) refit(); };
|
|
335
|
+
if (isHotkey) addEventListener('keydown', onKey);
|
|
336
|
+
addEventListener('message', onMessage);
|
|
337
|
+
addEventListener('resize', onResize);
|
|
338
|
+
// a page scrollbar appearing shrinks the visible width without a window resize event
|
|
339
|
+
const ro = typeof ResizeObserver === 'function' ? new ResizeObserver(onResize) : undefined;
|
|
340
|
+
ro?.observe(document.documentElement);
|
|
341
|
+
|
|
342
|
+
set(opts.startOpen ?? saved.open === true, false, false);
|
|
155
343
|
|
|
156
344
|
return {
|
|
157
345
|
open: () => set(true),
|
|
158
346
|
close: () => set(false),
|
|
159
347
|
toggle: () => set(!open),
|
|
160
348
|
unmount: () => {
|
|
349
|
+
removeEventListener('keydown', onKey);
|
|
350
|
+
removeEventListener('message', onMessage);
|
|
351
|
+
removeEventListener('resize', onResize);
|
|
352
|
+
ro?.disconnect();
|
|
161
353
|
panel.remove();
|
|
162
354
|
btn.remove();
|
|
163
355
|
},
|