portal-mojo 0.2.13 → 0.2.15
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/docs/admin-modals.md +6 -0
- package/docs/auth.md +10 -2
- package/docs/charts.md +103 -5
- package/docs/realtime.md +21 -1
- package/package.json +1 -1
- package/src/charts/MetricsChart.tsx +29 -331
- package/src/charts/MetricsChartView.tsx +280 -0
- package/src/charts/SeriesChart.tsx +99 -18
- package/src/charts/chart-dialogs.tsx +13 -7
- package/src/charts/index.ts +2 -0
- package/src/charts/metrics-window.ts +57 -0
- package/src/charts/stats.ts +8 -6
- package/src/client/auth.ts +17 -4
- package/src/client/realtime.ts +112 -11
- package/src/styles/components/charts-c2.css +15 -0
- package/src/ui/modal.tsx +6 -0
package/src/charts/stats.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
export interface SeriesLike {
|
|
8
8
|
label: string;
|
|
9
|
-
data: number[];
|
|
9
|
+
data: (number | null | undefined)[];
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export interface SeriesStat {
|
|
@@ -31,15 +31,17 @@ export function toNumber(v: unknown): number {
|
|
|
31
31
|
/**
|
|
32
32
|
* Per-series summary stats. Empty datasets produce no entry (the caller
|
|
33
33
|
* renders its "No data" state); an empty series inside a non-empty set
|
|
34
|
-
* yields
|
|
34
|
+
* yields unavailable (NaN) values so the table stays aligned with the legend.
|
|
35
|
+
* Aggregates use finite observations only; Latest refers to the final sample.
|
|
35
36
|
*/
|
|
36
37
|
export function computeSeriesStats(datasets: readonly SeriesLike[] | null | undefined): SeriesStat[] {
|
|
37
38
|
// (Array.isArray would widen the readonly array to any[] — a TS quirk.)
|
|
38
39
|
const list: readonly SeriesLike[] = datasets ?? [];
|
|
39
40
|
return list.map((d) => {
|
|
40
|
-
const
|
|
41
|
+
const data = Array.isArray(d.data) ? d.data : [];
|
|
42
|
+
const values = data.filter((v): v is number => typeof v === 'number' && Number.isFinite(v));
|
|
41
43
|
if (values.length === 0) {
|
|
42
|
-
return { label: d.label ?? '', count: 0, sum:
|
|
44
|
+
return { label: d.label ?? '', count: 0, sum: NaN, avg: NaN, median: NaN, min: NaN, max: NaN, latest: NaN };
|
|
43
45
|
}
|
|
44
46
|
const sorted = [...values].sort((a, b) => a - b);
|
|
45
47
|
const sum = values.reduce((a, b) => a + b, 0);
|
|
@@ -53,14 +55,14 @@ export function computeSeriesStats(datasets: readonly SeriesLike[] | null | unde
|
|
|
53
55
|
median,
|
|
54
56
|
min: sorted[0]!,
|
|
55
57
|
max: sorted[sorted.length - 1]!,
|
|
56
|
-
latest:
|
|
58
|
+
latest: Number.isFinite(data.at(-1)) ? data.at(-1)! : NaN,
|
|
57
59
|
};
|
|
58
60
|
});
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
/** '–' for nothing; integers plain; decimals to 2 places (source parity). */
|
|
62
64
|
export function defaultStatFormat(n: number | null | undefined): string {
|
|
63
|
-
if (n == null || Number.
|
|
65
|
+
if (n == null || !Number.isFinite(n)) return '–';
|
|
64
66
|
if (Number.isInteger(n)) return n.toLocaleString();
|
|
65
67
|
return Number(n).toLocaleString(undefined, { maximumFractionDigits: 2 });
|
|
66
68
|
}
|
package/src/client/auth.ts
CHANGED
|
@@ -61,13 +61,17 @@ export function sessionIsPersistent(): boolean {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
export function setTokens(access: string, refresh?: string | null, persistent = true): void {
|
|
64
|
+
storeTokens(access, refresh, persistent, 'session');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function storeTokens(access: string, refresh: string | null | undefined, persistent: boolean, source: 'session' | 'refresh'): void {
|
|
64
68
|
// Clear both storages first — a leftover token in the other storage must
|
|
65
69
|
// never shadow this session (web-mojo wrote without clearing).
|
|
66
70
|
clearTokens({ silent: true });
|
|
67
71
|
const storage = persistent ? localStorage : sessionStorage;
|
|
68
72
|
storage.setItem(ACCESS_KEY, access);
|
|
69
73
|
if (refresh) storage.setItem(REFRESH_KEY, refresh);
|
|
70
|
-
notifyAuthChanged();
|
|
74
|
+
notifyAuthChanged(source);
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
export function clearTokens(opts: { silent?: boolean } = {}): void {
|
|
@@ -89,8 +93,14 @@ export interface AuthSnapshot {
|
|
|
89
93
|
|
|
90
94
|
let snapshotCache: AuthSnapshot = { authenticated: false, uid: null, email: null };
|
|
91
95
|
let snapshotKey = '∅';
|
|
96
|
+
let sessionRevision = 0;
|
|
92
97
|
const authListeners = new Set<() => void>();
|
|
93
98
|
|
|
99
|
+
/** @internal Same-tab replacement fence; ordinary renewal does not advance it. */
|
|
100
|
+
export function getAuthSessionRevision(): number {
|
|
101
|
+
return sessionRevision;
|
|
102
|
+
}
|
|
103
|
+
|
|
94
104
|
function computeSnapshot(): void {
|
|
95
105
|
const access = getAccessToken();
|
|
96
106
|
const refresh = getRefreshToken();
|
|
@@ -106,7 +116,8 @@ function computeSnapshot(): void {
|
|
|
106
116
|
};
|
|
107
117
|
}
|
|
108
118
|
|
|
109
|
-
function notifyAuthChanged(): void {
|
|
119
|
+
function notifyAuthChanged(source: 'session' | 'refresh' = 'session'): void {
|
|
120
|
+
if (source === 'session') sessionRevision += 1;
|
|
110
121
|
computeSnapshot();
|
|
111
122
|
for (const cb of authListeners) cb();
|
|
112
123
|
}
|
|
@@ -212,10 +223,12 @@ async function doRefresh(): Promise<RefreshOutcome> {
|
|
|
212
223
|
const superseded = (): boolean => getRefreshToken() !== refresh;
|
|
213
224
|
const sessionUsable = (): boolean => checkTokenStatus().action !== 'logout';
|
|
214
225
|
try {
|
|
215
|
-
const body = await mojoCall(REFRESH_PATH, {
|
|
226
|
+
const body = await mojoCall(REFRESH_PATH, {
|
|
227
|
+
method: 'POST', body: { refresh_token: refresh }, signal: AbortSignal.timeout(15_000),
|
|
228
|
+
});
|
|
216
229
|
if (superseded()) return { ok: sessionUsable() };
|
|
217
230
|
const pkg = body.data as { access_token: string; refresh_token?: string };
|
|
218
|
-
|
|
231
|
+
storeTokens(pkg.access_token, pkg.refresh_token, persistent, 'refresh');
|
|
219
232
|
emitAuth('refreshed');
|
|
220
233
|
return { ok: true };
|
|
221
234
|
} catch (error) {
|
package/src/client/realtime.ts
CHANGED
|
@@ -3,7 +3,8 @@ import {
|
|
|
3
3
|
useSyncExternalStore, type PropsWithChildren,
|
|
4
4
|
} from 'react';
|
|
5
5
|
import { apiOrigin } from './client';
|
|
6
|
-
import { getAccessToken, subscribeAuth } from './auth';
|
|
6
|
+
import { ensureValidToken, getAccessToken, getAuthSessionRevision, getRefreshToken, subscribeAuth } from './auth';
|
|
7
|
+
import { AuthRequiredError } from './errors';
|
|
7
8
|
|
|
8
9
|
export type RealtimeLifecycleStatus =
|
|
9
10
|
| 'disabled' | 'idle' | 'connecting' | 'authenticating' | 'ready'
|
|
@@ -106,6 +107,10 @@ export class RealtimeClient {
|
|
|
106
107
|
private readonly topicTimeoutMs: number;
|
|
107
108
|
private socket: RealtimeSocket | null = null;
|
|
108
109
|
private token: string | null = null;
|
|
110
|
+
private authBinding: { unsubscribe: () => void } | null = null;
|
|
111
|
+
private authSessionRevision: number | null = null;
|
|
112
|
+
private preparing: object | null = null;
|
|
113
|
+
private authSent: RealtimeSocket | null = null;
|
|
109
114
|
private enabled = false;
|
|
110
115
|
private generation = 0;
|
|
111
116
|
private reconnectAttempt = 0;
|
|
@@ -142,11 +147,44 @@ export class RealtimeClient {
|
|
|
142
147
|
return () => this.statusListeners.delete(listener);
|
|
143
148
|
};
|
|
144
149
|
|
|
150
|
+
/** Bind one session owner; renewal shares HTTP's single-flight auth gate. */
|
|
151
|
+
bindAuthSession(): () => void {
|
|
152
|
+
if (this.authBinding) throw new Error('Realtime client already has an auth session owner');
|
|
153
|
+
const binding = { unsubscribe: () => {} };
|
|
154
|
+
this.authBinding = binding;
|
|
155
|
+
this.stop(this.suspended ?? (this.enabled ? 'idle' : 'disabled'));
|
|
156
|
+
// Synchronous invalidation is essential: a React effect can run after
|
|
157
|
+
// an old refresh/handshake has completed for the previous account.
|
|
158
|
+
const syncSession = () => {
|
|
159
|
+
const revision = getAuthSessionRevision();
|
|
160
|
+
const replaced = this.authSessionRevision !== revision;
|
|
161
|
+
this.authSessionRevision = revision;
|
|
162
|
+
// A renewal already in flight when the server denied/revoked this
|
|
163
|
+
// connection is not an intentional replacement session. The fence
|
|
164
|
+
// also survives a renewal completed while no binding was mounted.
|
|
165
|
+
if (!replaced && this.suspended) {
|
|
166
|
+
this.token = getAccessToken();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
this.setToken(getAccessToken());
|
|
170
|
+
};
|
|
171
|
+
binding.unsubscribe = subscribeAuth(syncSession);
|
|
172
|
+
syncSession();
|
|
173
|
+
if (this.enabled) this.open();
|
|
174
|
+
return () => {
|
|
175
|
+
if (this.authBinding !== binding) return;
|
|
176
|
+
this.disconnect();
|
|
177
|
+
binding.unsubscribe();
|
|
178
|
+
this.authBinding = null;
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
145
182
|
setEnabled(enabled: boolean): void {
|
|
146
183
|
if (this.enabled === enabled) return;
|
|
147
184
|
this.enabled = enabled;
|
|
148
185
|
if (!enabled) this.stop('disabled');
|
|
149
|
-
else if (this.
|
|
186
|
+
else if (this.suspended) this.setLifecycle(this.suspended);
|
|
187
|
+
else if (this.hasCredentials()) this.open();
|
|
150
188
|
else this.setLifecycle('idle');
|
|
151
189
|
}
|
|
152
190
|
|
|
@@ -156,7 +194,7 @@ export class RealtimeClient {
|
|
|
156
194
|
this.token = token;
|
|
157
195
|
this.suspended = null;
|
|
158
196
|
this.stop(this.enabled && token ? 'idle' : this.enabled ? 'idle' : 'disabled');
|
|
159
|
-
if (this.enabled &&
|
|
197
|
+
if (this.enabled && this.hasCredentials()) this.open();
|
|
160
198
|
}
|
|
161
199
|
|
|
162
200
|
connect(token: string): void {
|
|
@@ -171,6 +209,8 @@ export class RealtimeClient {
|
|
|
171
209
|
|
|
172
210
|
destroy(): void {
|
|
173
211
|
this.disconnect();
|
|
212
|
+
this.authBinding?.unsubscribe();
|
|
213
|
+
this.authBinding = null;
|
|
174
214
|
this.statusListeners.clear();
|
|
175
215
|
this.topicListeners.clear();
|
|
176
216
|
this.eventListeners.clear();
|
|
@@ -232,14 +272,18 @@ export class RealtimeClient {
|
|
|
232
272
|
}
|
|
233
273
|
|
|
234
274
|
nudge(): void {
|
|
235
|
-
if (!this.enabled || !this.
|
|
275
|
+
if (!this.enabled || !this.hasCredentials() || this.suspended || this.socket || this.retryTimer == null) return;
|
|
236
276
|
if (this.clock.now() < this.extendedBackoffUntil) return;
|
|
237
277
|
this.clearRetry();
|
|
238
278
|
this.open();
|
|
239
279
|
}
|
|
240
280
|
|
|
281
|
+
private hasCredentials(): boolean {
|
|
282
|
+
return Boolean(this.token || (this.authBinding && getRefreshToken()));
|
|
283
|
+
}
|
|
284
|
+
|
|
241
285
|
private open(): void {
|
|
242
|
-
if (!this.enabled || !this.
|
|
286
|
+
if (!this.enabled || !this.hasCredentials() || this.suspended || this.socket || this.preparing) return;
|
|
243
287
|
const run = this.generation;
|
|
244
288
|
// Token refresh and provider remount use this same entry point; neither
|
|
245
289
|
// may bypass a server admission deadline.
|
|
@@ -248,6 +292,47 @@ export class RealtimeClient {
|
|
|
248
292
|
return;
|
|
249
293
|
}
|
|
250
294
|
this.setLifecycle('connecting');
|
|
295
|
+
this.prepareSession(run, null, () => this.openSocket(run));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private prepareSession(run: number, socket: RealtimeSocket | null, ready: () => void): void {
|
|
299
|
+
const binding = this.authBinding;
|
|
300
|
+
if (!binding) { ready(); return; }
|
|
301
|
+
if (this.preparing) return;
|
|
302
|
+
const attempt = {};
|
|
303
|
+
this.preparing = attempt;
|
|
304
|
+
const current = () => this.preparing === attempt && this.authBinding === binding
|
|
305
|
+
&& this.enabled && !this.suspended && run === this.generation && this.socket === socket;
|
|
306
|
+
void Promise.resolve().then(() => {
|
|
307
|
+
if (current()) return ensureValidToken();
|
|
308
|
+
}).then(() => {
|
|
309
|
+
if (!current()) return;
|
|
310
|
+
this.preparing = null;
|
|
311
|
+
ready();
|
|
312
|
+
}).catch((error: unknown) => {
|
|
313
|
+
if (!current()) return;
|
|
314
|
+
this.preparing = null;
|
|
315
|
+
if (error instanceof AuthRequiredError) this.suspend('auth-failed', 4003, 'Authentication failed');
|
|
316
|
+
else this.retryAuthentication(run, socket);
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private retryAuthentication(run: number, socket: RealtimeSocket | null): void {
|
|
321
|
+
this.preparing = null;
|
|
322
|
+
this.authSent = null;
|
|
323
|
+
this.socket = null;
|
|
324
|
+
this.clearHeartbeat();
|
|
325
|
+
this.resetTopicConnectionState();
|
|
326
|
+
this.scheduleReconnect(run, 0);
|
|
327
|
+
socket?.close(1000, 'Session renewal pending');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private openSocket(run: number): void {
|
|
331
|
+
// A renewal may have completed while an admission cooldown was set.
|
|
332
|
+
if (this.clock.now() < this.extendedBackoffUntil) {
|
|
333
|
+
this.scheduleReconnect(run, 0);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
251
336
|
let socket: RealtimeSocket;
|
|
252
337
|
try {
|
|
253
338
|
socket = this.socketFactory(this.url);
|
|
@@ -267,6 +352,8 @@ export class RealtimeClient {
|
|
|
267
352
|
socket.onerror = () => { /* close is the single retry path */ };
|
|
268
353
|
socket.onclose = (event) => {
|
|
269
354
|
if (!this.isCurrent(run, socket)) return;
|
|
355
|
+
this.preparing = null;
|
|
356
|
+
this.authSent = null;
|
|
270
357
|
this.socket = null;
|
|
271
358
|
this.clearHeartbeat();
|
|
272
359
|
this.resetTopicConnectionState();
|
|
@@ -283,12 +370,16 @@ export class RealtimeClient {
|
|
|
283
370
|
if (!frame || typeof frame.type !== 'string') return;
|
|
284
371
|
|
|
285
372
|
if (frame.type === 'auth_required') {
|
|
286
|
-
if (this.lifecycle !== 'authenticating' || !this.token) return;
|
|
287
|
-
|
|
373
|
+
if (this.lifecycle !== 'authenticating' || !this.token || this.authSent === socket) return;
|
|
374
|
+
this.prepareSession(run, socket, () => {
|
|
375
|
+
// Latch before send: a transport may answer synchronously.
|
|
376
|
+
this.authSent = socket;
|
|
377
|
+
socket.send(JSON.stringify({ type: 'authenticate', token: this.token, prefix: 'bearer' }));
|
|
378
|
+
});
|
|
288
379
|
return;
|
|
289
380
|
}
|
|
290
381
|
if (frame.type === 'auth_success') {
|
|
291
|
-
if (this.lifecycle !== 'authenticating') return;
|
|
382
|
+
if (this.lifecycle !== 'authenticating' || this.authSent !== socket) return;
|
|
292
383
|
this.reconnectAttempt = 0;
|
|
293
384
|
this.extendedBackoffUntil = 0;
|
|
294
385
|
this.automaticUserTopic = (typeof frame.user_type === 'string' && (typeof frame.user_id === 'number' || typeof frame.user_id === 'string'))
|
|
@@ -325,6 +416,8 @@ export class RealtimeClient {
|
|
|
325
416
|
// Fence queued frames before closing: late auth_success must not
|
|
326
417
|
// clear the deadline or make this refused socket ready.
|
|
327
418
|
this.extendedBackoffUntil = this.clock.now() + 60_000;
|
|
419
|
+
this.preparing = null;
|
|
420
|
+
this.authSent = null;
|
|
328
421
|
this.socket = null;
|
|
329
422
|
this.clearHeartbeat();
|
|
330
423
|
this.resetTopicConnectionState();
|
|
@@ -335,7 +428,11 @@ export class RealtimeClient {
|
|
|
335
428
|
}
|
|
336
429
|
if (frame.type === 'error') {
|
|
337
430
|
if (this.lifecycle === 'authenticating') {
|
|
338
|
-
|
|
431
|
+
// The server's admission timer can expire while renewal is
|
|
432
|
+
// pending. No credential was rejected in this specific case.
|
|
433
|
+
if (frame.message === 'Authentication timeout' && this.authBinding && this.authSent !== socket) {
|
|
434
|
+
this.retryAuthentication(run, socket);
|
|
435
|
+
} else this.suspend('auth-failed', 4003, 'Authentication failed');
|
|
339
436
|
} else if (this.topicOperation) {
|
|
340
437
|
this.denyCurrentTopic();
|
|
341
438
|
} else {
|
|
@@ -492,6 +589,9 @@ export class RealtimeClient {
|
|
|
492
589
|
|
|
493
590
|
private suspend(reason: 'auth-failed' | 'forced-disconnect', code: number, message: string): void {
|
|
494
591
|
this.suspended = reason;
|
|
592
|
+
this.preparing = null;
|
|
593
|
+
this.authSent = null;
|
|
594
|
+
this.clearRetry();
|
|
495
595
|
this.setLifecycle(reason);
|
|
496
596
|
const socket = this.socket;
|
|
497
597
|
this.socket = null;
|
|
@@ -502,6 +602,8 @@ export class RealtimeClient {
|
|
|
502
602
|
|
|
503
603
|
private stop(status: RealtimeLifecycleStatus): void {
|
|
504
604
|
this.generation += 1;
|
|
605
|
+
this.preparing = null;
|
|
606
|
+
this.authSent = null;
|
|
505
607
|
this.clearRetry();
|
|
506
608
|
this.clearHeartbeat();
|
|
507
609
|
this.resetTopicConnectionState();
|
|
@@ -561,8 +663,7 @@ export type RealtimeProviderProps = PropsWithChildren<{
|
|
|
561
663
|
|
|
562
664
|
export function RealtimeProvider({ children, enabled = true, client, socketFactory, origin }: RealtimeProviderProps) {
|
|
563
665
|
const owned = useMemo(() => client ?? new RealtimeClient({ socketFactory, origin }), [client, socketFactory, origin]);
|
|
564
|
-
|
|
565
|
-
useEffect(() => { owned.setToken(token); }, [owned, token]);
|
|
666
|
+
useEffect(() => owned.bindAuthSession(), [owned]);
|
|
566
667
|
useEffect(() => {
|
|
567
668
|
owned.setEnabled(enabled);
|
|
568
669
|
return () => owned.setEnabled(false);
|
|
@@ -247,3 +247,18 @@ button.cprog { cursor: pointer; font: inherit; }
|
|
|
247
247
|
|
|
248
248
|
/* Settings dialog fields */
|
|
249
249
|
.mmw-settings { display: flex; flex-direction: column; gap: 12px; }
|
|
250
|
+
|
|
251
|
+
/* Shared MetricsChartView also lives in narrow cards inside wide pages. */
|
|
252
|
+
.chart-panel { container-type: inline-size; min-width: 0; }
|
|
253
|
+
.chart-panel .chart-error { flex-wrap: wrap; overflow-wrap: anywhere; }
|
|
254
|
+
.chart-range-dialog { container-type: inline-size; }
|
|
255
|
+
@container (max-width: 560px) {
|
|
256
|
+
.chart-panel .gran-toggle { display: none; }
|
|
257
|
+
.chart-panel .gran-select { display: inline-block; }
|
|
258
|
+
}
|
|
259
|
+
@container (max-width: 500px) {
|
|
260
|
+
.chart-range-dialog .mojo-daterange-with-presets { grid-template-columns: minmax(0, 1fr); }
|
|
261
|
+
.chart-range-dialog .mojo-calendar-presets { flex-direction: row; flex-wrap: wrap; border-right: 0; border-bottom: 1px solid var(--line); }
|
|
262
|
+
.chart-range-dialog .mojo-calendar-presets-eyebrow { width: 100%; }
|
|
263
|
+
.chart-range-dialog .mojo-calendar-presets-divider { display: none; }
|
|
264
|
+
}
|
package/src/ui/modal.tsx
CHANGED
|
@@ -113,9 +113,12 @@ export interface ModalOptions {
|
|
|
113
113
|
flush?: boolean;
|
|
114
114
|
/** Guards Escape/backdrop dismissal. Programmatic close still succeeds. */
|
|
115
115
|
canDismiss?: () => boolean;
|
|
116
|
+
/** Abort removes this entry immediately, even without a mounted ModalHost. */
|
|
117
|
+
signal?: AbortSignal;
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
function open<T>(render: (close: (value: T) => void) => ReactNode, opts: ModalOptions = {}): Promise<T | null> {
|
|
121
|
+
if (opts.signal?.aborted) return Promise.resolve(null);
|
|
119
122
|
wireAuth();
|
|
120
123
|
return new Promise<T | null>((resolve) => {
|
|
121
124
|
const id = nextId++;
|
|
@@ -123,10 +126,13 @@ function open<T>(render: (close: (value: T) => void) => ReactNode, opts: ModalOp
|
|
|
123
126
|
const close = (value: unknown) => {
|
|
124
127
|
if (settled) return;
|
|
125
128
|
settled = true;
|
|
129
|
+
opts.signal?.removeEventListener('abort', abort);
|
|
126
130
|
stack = stack.filter((m) => m.id !== id);
|
|
127
131
|
emit();
|
|
128
132
|
resolve(value as T | null);
|
|
129
133
|
};
|
|
134
|
+
const abort = () => close(null);
|
|
135
|
+
opts.signal?.addEventListener('abort', abort, { once: true });
|
|
130
136
|
stack = [...stack, {
|
|
131
137
|
id,
|
|
132
138
|
variant: 'modal',
|