@atolljs/devtools 0.1.7 → 0.1.8
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/index.html +161 -2
- package/app/main.js +877 -127
- 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-4N3NBFL3.js → chunk-AGT6VNZI.js} +179 -217
- 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 +153 -27
- package/src/probes.ts +155 -0
- package/src/protocol.ts +52 -4
- package/src/server.ts +83 -3
|
@@ -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;' +
|
|
@@ -57,18 +77,53 @@ const ANCHOR: Record<DevtoolsOverlayPosition, (m: number) => string> = {
|
|
|
57
77
|
bottomright: (m) => `right:${m}px;bottom:${m}px`,
|
|
58
78
|
};
|
|
59
79
|
|
|
80
|
+
const MIN_W = 340;
|
|
81
|
+
const MIN_H = 220;
|
|
82
|
+
let seq = 0;
|
|
83
|
+
|
|
84
|
+
/** 'Alt+Shift+D' → a keydown predicate. Letters/digits match on `code`, so Alt/Option layouts still hit. */
|
|
85
|
+
export function parseHotkey(spec: string): (e: KeyboardEvent) => boolean {
|
|
86
|
+
const parts = spec.split('+').map((p) => p.trim().toLowerCase()).filter(Boolean);
|
|
87
|
+
const key = parts.pop() ?? '';
|
|
88
|
+
const mods = new Set(parts.map((m) => (m === 'option' ? 'alt' : m === 'cmd' || m === 'meta' ? 'meta' : m === 'control' ? 'ctrl' : m)));
|
|
89
|
+
const code = /^[a-z]$/.test(key) ? `Key${key.toUpperCase()}` : /^[0-9]$/.test(key) ? `Digit${key}` : null;
|
|
90
|
+
return (e) =>
|
|
91
|
+
e.altKey === mods.has('alt') &&
|
|
92
|
+
e.shiftKey === mods.has('shift') &&
|
|
93
|
+
e.ctrlKey === mods.has('ctrl') &&
|
|
94
|
+
e.metaKey === mods.has('meta') &&
|
|
95
|
+
(code ? e.code === code : e.key.toLowerCase() === key);
|
|
96
|
+
}
|
|
97
|
+
|
|
60
98
|
export function mountDevtoolsOverlay(opts: DevtoolsOverlayOptions = {}): DevtoolsOverlay {
|
|
61
99
|
const src = opts.src ?? '/__atoll/?mini=1';
|
|
62
|
-
const
|
|
63
|
-
const
|
|
100
|
+
const storeKey = opts.persist === false ? null : typeof opts.persist === 'string' ? opts.persist : 'atoll-devtools:overlay';
|
|
101
|
+
const load = (): Saved => {
|
|
102
|
+
if (!storeKey) return {};
|
|
103
|
+
try { return JSON.parse(localStorage.getItem(storeKey) ?? '{}') as Saved; } catch { return {}; }
|
|
104
|
+
};
|
|
105
|
+
const saved = load();
|
|
106
|
+
const W = Math.max(MIN_W, saved.width ?? opts.width ?? 760);
|
|
107
|
+
const H = Math.max(MIN_H, saved.height ?? opts.height ?? 580);
|
|
64
108
|
const anchor = ANCHOR[opts.position ?? 'bottomright'];
|
|
109
|
+
const hotkey = opts.hotkey === false ? null : opts.hotkey ?? 'Alt+Shift+D';
|
|
110
|
+
const isHotkey = hotkey ? parseHotkey(hotkey) : null;
|
|
111
|
+
const hint = hotkey ? ` (${hotkey})` : '';
|
|
112
|
+
const id = `atoll-devtools-overlay-${++seq}`;
|
|
65
113
|
let open = false;
|
|
66
114
|
|
|
67
115
|
const btn = document.createElement('button');
|
|
116
|
+
btn.type = 'button';
|
|
68
117
|
btn.textContent = 'atoll devtools';
|
|
118
|
+
btn.title = `Open atoll devtools${hint}`;
|
|
119
|
+
btn.setAttribute('aria-controls', id);
|
|
120
|
+
btn.setAttribute('aria-expanded', 'false');
|
|
69
121
|
btn.style.cssText = `${BTN_STYLE};${anchor(12)}`;
|
|
70
122
|
|
|
71
123
|
const panel = document.createElement('div');
|
|
124
|
+
panel.id = id;
|
|
125
|
+
panel.setAttribute('role', 'dialog');
|
|
126
|
+
panel.setAttribute('aria-label', 'atoll devtools');
|
|
72
127
|
panel.style.cssText =
|
|
73
128
|
`${PANEL_STYLE};width:${W}px;height:${H}px;display:none;${anchor(16)}`;
|
|
74
129
|
|
|
@@ -77,37 +132,89 @@ export function mountDevtoolsOverlay(opts: DevtoolsOverlayOptions = {}): Devtool
|
|
|
77
132
|
'display:flex;align-items:center;gap:8px;padding:5px 10px;flex:none;' +
|
|
78
133
|
'border-bottom:1px solid #21262d;font:11px ui-monospace,monospace;' +
|
|
79
134
|
'color:#8b949e;background:#161b22;cursor:move;user-select:none';
|
|
80
|
-
|
|
135
|
+
const title = document.createElement('span');
|
|
136
|
+
title.textContent = 'atoll devtools';
|
|
81
137
|
|
|
138
|
+
// Full-page dashboard = src minus its query/hash — stays relative when the
|
|
139
|
+
// caller passes a relative src (e.g. '__atoll/?mini=1' under a mounted
|
|
140
|
+
// base path where absolute /__atoll/ would escape the mount) — plus the
|
|
141
|
+
// iframe's current hash route, so the tab opens on the same view.
|
|
142
|
+
const base = src.split(/[?#]/)[0];
|
|
143
|
+
const frame = document.createElement('iframe');
|
|
144
|
+
let route = '';
|
|
145
|
+
const currentRoute = (): string => {
|
|
146
|
+
try {
|
|
147
|
+
const h = frame.contentWindow?.location.hash;
|
|
148
|
+
if (typeof h === 'string' && h.startsWith('#/')) return h;
|
|
149
|
+
} catch { /* cross-origin src: rely on posted routes */ }
|
|
150
|
+
return route;
|
|
151
|
+
};
|
|
82
152
|
const pop = document.createElement('a');
|
|
83
|
-
pop.href =
|
|
153
|
+
pop.href = base;
|
|
84
154
|
pop.target = '_blank';
|
|
85
155
|
pop.rel = 'noopener';
|
|
86
156
|
pop.textContent = 'full page ↗';
|
|
157
|
+
pop.title = 'Open this view in the full-page dashboard';
|
|
87
158
|
pop.style.cssText = 'color:#58a6ff;text-decoration:none;margin-left:auto';
|
|
159
|
+
const syncPop = () => { pop.href = base + currentRoute(); };
|
|
160
|
+
pop.addEventListener('pointerenter', syncPop);
|
|
161
|
+
pop.addEventListener('focus', syncPop);
|
|
162
|
+
pop.addEventListener('click', syncPop); // runs before the default navigation
|
|
88
163
|
const x = document.createElement('button');
|
|
89
|
-
x.
|
|
90
|
-
x.
|
|
164
|
+
x.type = 'button';
|
|
165
|
+
x.textContent = '×';
|
|
166
|
+
x.title = `Close${hint}`;
|
|
167
|
+
x.setAttribute('aria-label', 'Close atoll devtools');
|
|
91
168
|
x.style.cssText =
|
|
92
|
-
'color:#8b949e;background:none;border:none;cursor:pointer;font:
|
|
93
|
-
head.append(pop, x);
|
|
169
|
+
'color:#8b949e;background:none;border:none;cursor:pointer;font:14px/1 ui-monospace,monospace;padding:0 2px';
|
|
170
|
+
head.append(title, pop, x);
|
|
94
171
|
|
|
95
|
-
const frame = document.createElement('iframe');
|
|
96
172
|
frame.src = src;
|
|
173
|
+
frame.title = 'atoll devtools dashboard';
|
|
97
174
|
frame.style.cssText = 'flex:1;border:0;background:#0d1117;min-height:0';
|
|
98
175
|
|
|
99
176
|
// manual resize grip — CSS `resize` doesn't work over an iframe
|
|
100
177
|
const grip = document.createElement('div');
|
|
178
|
+
grip.setAttribute('aria-hidden', 'true');
|
|
101
179
|
grip.style.cssText =
|
|
102
180
|
'position:absolute;right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize;' +
|
|
103
181
|
'background:linear-gradient(135deg,transparent 50%,#30363d 50%);border-radius:0 0 7px';
|
|
104
182
|
panel.append(head, frame, grip);
|
|
105
183
|
document.body.append(panel, btn);
|
|
106
184
|
|
|
185
|
+
const placeAt = (left: number, top: number) => {
|
|
186
|
+
// keep at least 40px of the title bar on screen
|
|
187
|
+
panel.style.left = `${Math.min(Math.max(0, left), Math.max(0, innerWidth - 60))}px`;
|
|
188
|
+
panel.style.top = `${Math.min(Math.max(0, top), Math.max(0, innerHeight - 40))}px`;
|
|
189
|
+
panel.style.right = 'auto';
|
|
190
|
+
panel.style.bottom = 'auto';
|
|
191
|
+
panel.style.transform = 'none'; // drop the center anchor's translateX
|
|
192
|
+
};
|
|
193
|
+
if (typeof saved.left === 'number' && typeof saved.top === 'number') placeAt(saved.left, saved.top);
|
|
194
|
+
|
|
195
|
+
// Only what the user changed is remembered: an untouched size/position
|
|
196
|
+
// keeps following the options.
|
|
197
|
+
const mem: Saved = { ...saved };
|
|
198
|
+
const save = (what: 'open' | 'move' | 'size') => {
|
|
199
|
+
if (!storeKey) return;
|
|
200
|
+
if (what === 'open') mem.open = open;
|
|
201
|
+
if (what === 'size') {
|
|
202
|
+
mem.width = parseFloat(panel.style.width);
|
|
203
|
+
mem.height = parseFloat(panel.style.height);
|
|
204
|
+
}
|
|
205
|
+
if (what === 'move') {
|
|
206
|
+
mem.left = parseFloat(panel.style.left);
|
|
207
|
+
mem.top = parseFloat(panel.style.top);
|
|
208
|
+
}
|
|
209
|
+
try { localStorage.setItem(storeKey, JSON.stringify(mem)); } catch { /* storage off */ }
|
|
210
|
+
};
|
|
211
|
+
|
|
107
212
|
/** Pointer-drag helper: run `move` on pointermove until pointerup. */
|
|
108
|
-
const drag = (e: PointerEvent, move: (dx: number, dy: number) => void) => {
|
|
213
|
+
const drag = (e: PointerEvent, what: 'move' | 'size', move: (dx: number, dy: number) => void) => {
|
|
109
214
|
e.preventDefault();
|
|
110
215
|
let lx = e.clientX, ly = e.clientY;
|
|
216
|
+
// the iframe would swallow pointermove while the cursor is over it
|
|
217
|
+
frame.style.pointerEvents = 'none';
|
|
111
218
|
const mm = (ev: PointerEvent) => {
|
|
112
219
|
move(ev.clientX - lx, ev.clientY - ly);
|
|
113
220
|
lx = ev.clientX; ly = ev.clientY;
|
|
@@ -115,6 +222,8 @@ export function mountDevtoolsOverlay(opts: DevtoolsOverlayOptions = {}): Devtool
|
|
|
115
222
|
const up = () => {
|
|
116
223
|
removeEventListener('pointermove', mm);
|
|
117
224
|
removeEventListener('pointerup', up);
|
|
225
|
+
frame.style.pointerEvents = '';
|
|
226
|
+
save(what);
|
|
118
227
|
};
|
|
119
228
|
addEventListener('pointermove', mm);
|
|
120
229
|
addEventListener('pointerup', up);
|
|
@@ -122,42 +231,59 @@ export function mountDevtoolsOverlay(opts: DevtoolsOverlayOptions = {}): Devtool
|
|
|
122
231
|
|
|
123
232
|
head.onpointerdown = (e) => {
|
|
124
233
|
if ((e.target as HTMLElement).closest('a,button')) return;
|
|
125
|
-
drag(e, (dx, dy) => {
|
|
234
|
+
drag(e, 'move', (dx, dy) => {
|
|
126
235
|
const r = panel.getBoundingClientRect();
|
|
127
|
-
|
|
128
|
-
const left = Math.min(Math.max(0, r.left + dx), innerWidth - 60);
|
|
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
|
|
236
|
+
placeAt(r.left + dx, r.top + dy);
|
|
135
237
|
});
|
|
136
238
|
};
|
|
137
239
|
|
|
138
240
|
grip.onpointerdown = (e) => {
|
|
139
241
|
e.stopPropagation();
|
|
140
|
-
drag(e, (dx, dy) => {
|
|
242
|
+
drag(e, 'size', (dx, dy) => {
|
|
141
243
|
const r = panel.getBoundingClientRect();
|
|
142
|
-
panel.style.width = `${Math.max(
|
|
143
|
-
panel.style.height = `${Math.max(
|
|
244
|
+
panel.style.width = `${Math.max(MIN_W, r.width + dx)}px`;
|
|
245
|
+
panel.style.height = `${Math.max(MIN_H, r.height + dy)}px`;
|
|
144
246
|
});
|
|
145
247
|
};
|
|
146
248
|
|
|
147
|
-
const set = (v: boolean) => {
|
|
249
|
+
const set = (v: boolean, focus = false, persist = true) => {
|
|
250
|
+
const hadFocus = panel.contains(document.activeElement);
|
|
148
251
|
open = v;
|
|
149
252
|
panel.style.display = v ? 'flex' : 'none';
|
|
150
253
|
btn.style.display = v ? 'none' : 'block';
|
|
254
|
+
btn.setAttribute('aria-expanded', String(v));
|
|
255
|
+
if (v && focus) frame.focus();
|
|
256
|
+
if (!v && hadFocus) btn.focus();
|
|
257
|
+
if (persist) save('open');
|
|
151
258
|
};
|
|
152
|
-
btn.onclick = () => set(true);
|
|
259
|
+
btn.onclick = () => set(true, true);
|
|
153
260
|
x.onclick = () => set(false);
|
|
154
|
-
|
|
261
|
+
|
|
262
|
+
const onKey = (e: KeyboardEvent) => {
|
|
263
|
+
if (!isHotkey?.(e)) return;
|
|
264
|
+
e.preventDefault();
|
|
265
|
+
set(!open, true);
|
|
266
|
+
};
|
|
267
|
+
const onMessage = (e: MessageEvent) => {
|
|
268
|
+
if (e.source !== frame.contentWindow || !e.data || typeof e.data !== 'object') return;
|
|
269
|
+
const d = e.data as { type?: string; hash?: unknown };
|
|
270
|
+
if (d.type === 'atoll-devtools:toggle') set(!open);
|
|
271
|
+
else if (d.type === 'atoll-devtools:route' && typeof d.hash === 'string' && d.hash.startsWith('#/')) {
|
|
272
|
+
route = d.hash;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
if (isHotkey) addEventListener('keydown', onKey);
|
|
276
|
+
addEventListener('message', onMessage);
|
|
277
|
+
|
|
278
|
+
set(opts.startOpen ?? saved.open === true, false, false);
|
|
155
279
|
|
|
156
280
|
return {
|
|
157
281
|
open: () => set(true),
|
|
158
282
|
close: () => set(false),
|
|
159
283
|
toggle: () => set(!open),
|
|
160
284
|
unmount: () => {
|
|
285
|
+
removeEventListener('keydown', onKey);
|
|
286
|
+
removeEventListener('message', onMessage);
|
|
161
287
|
panel.remove();
|
|
162
288
|
btn.remove();
|
|
163
289
|
},
|