@finos/legend-application-query 13.8.23 → 13.8.25

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.
Files changed (36) hide show
  1. package/lib/__lib__/LegendQueryNavigation.d.ts +1 -0
  2. package/lib/__lib__/LegendQueryNavigation.d.ts.map +1 -1
  3. package/lib/__lib__/LegendQueryNavigation.js +3 -0
  4. package/lib/__lib__/LegendQueryNavigation.js.map +1 -1
  5. package/lib/components/DataSpaceArtifactInspector.d.ts +19 -0
  6. package/lib/components/DataSpaceArtifactInspector.d.ts.map +1 -0
  7. package/lib/components/DataSpaceArtifactInspector.js +866 -0
  8. package/lib/components/DataSpaceArtifactInspector.js.map +1 -0
  9. package/lib/components/LegendQueryWebApplication.d.ts.map +1 -1
  10. package/lib/components/LegendQueryWebApplication.js +2 -1
  11. package/lib/components/LegendQueryWebApplication.js.map +1 -1
  12. package/lib/components/QueryEditor.d.ts.map +1 -1
  13. package/lib/components/QueryEditor.js +48 -10
  14. package/lib/components/QueryEditor.js.map +1 -1
  15. package/lib/components/__test-utils__/QueryEditorComponentTestUtils.d.ts +1 -1
  16. package/lib/components/__test-utils__/QueryEditorComponentTestUtils.d.ts.map +1 -1
  17. package/lib/index.css +2 -2
  18. package/lib/index.css.map +1 -1
  19. package/lib/light-mode.css +2 -2
  20. package/lib/light-mode.css.map +1 -1
  21. package/lib/package.json +1 -1
  22. package/lib/stores/LegendQueryApplicationPlugin.d.ts +29 -4
  23. package/lib/stores/LegendQueryApplicationPlugin.d.ts.map +1 -1
  24. package/lib/stores/LegendQueryApplicationPlugin.js.map +1 -1
  25. package/lib/stores/QueryEditorStore.d.ts.map +1 -1
  26. package/lib/stores/QueryEditorStore.js +1 -1
  27. package/lib/stores/QueryEditorStore.js.map +1 -1
  28. package/package.json +11 -11
  29. package/src/__lib__/LegendQueryNavigation.ts +3 -0
  30. package/src/components/DataSpaceArtifactInspector.tsx +1469 -0
  31. package/src/components/LegendQueryWebApplication.tsx +7 -0
  32. package/src/components/QueryEditor.tsx +69 -23
  33. package/src/components/__test-utils__/QueryEditorComponentTestUtils.tsx +1 -1
  34. package/src/stores/LegendQueryApplicationPlugin.tsx +27 -4
  35. package/src/stores/QueryEditorStore.ts +1 -0
  36. package/tsconfig.json +1 -0
@@ -0,0 +1,1469 @@
1
+ /**
2
+ * Copyright (c) 2026-present, Goldman Sachs
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * Developer-only diagnostic page for measuring the size and shape of the
19
+ * DataSpace analytics artifact returned by the Depot server. Used to
20
+ * investigate Chrome tab OOM crashes when loading large multi execution
21
+ * context DataSpaces.
22
+ *
23
+ * Route: /dev/dataspace-inspector
24
+ *
25
+ * The page reuses the application's existing depot client (so it inherits
26
+ * the same auth / cookies / Envoy origin headers), fetches:
27
+ * GET /generations/{groupId}/{artifactId}/{versionId}/types/dataSpace-analytics?elementPath={path}
28
+ * (the existing per-type endpoint, now narrowed via the `elementPath`
29
+ * query parameter for QUERY-1054) and reports total uncompressed payload
30
+ * bytes plus a per-file breakdown for that one DataSpace.
31
+ *
32
+ * Also supports loading inputs from a saved query id (hits the engine
33
+ * query server `/pure/v1/query/{id}` endpoint and pulls groupId /
34
+ * artifactId / versionId + the dataspace path from the saved query's
35
+ * `taggedValues` / `executionContext`), and an inline JSON viewer (Monaco)
36
+ * for the parsed response and individual file contents.
37
+ */
38
+
39
+ import { observer } from 'mobx-react-lite';
40
+ import { useState } from 'react';
41
+ import { StoreProjectData } from '@finos/legend-server-depot';
42
+ import { LogEvent } from '@finos/legend-shared';
43
+ import { CodeEditor } from '@finos/legend-lego/code-editor';
44
+ import { CODE_EDITOR_LANGUAGE } from '@finos/legend-code-editor';
45
+ import { LEGEND_QUERY_APP_EVENT } from '../__lib__/LegendQueryEvent.js';
46
+ import {
47
+ useLegendQueryApplicationStore,
48
+ useLegendQueryBaseStore,
49
+ } from './LegendQueryFrameworkProvider.js';
50
+ import { DATA_SPACE_ELEMENT_CLASSIFIER_PATH } from '@finos/legend-extension-dsl-data-space/graph';
51
+
52
+ const MB = 1024 * 1024;
53
+ const fmtMB = (bytes: number): string => `${(bytes / MB).toFixed(2)} MB`;
54
+
55
+ const QUERY_PROFILE_PATH = 'meta::pure::profiles::query';
56
+ const QUERY_PROFILE_TAG_DATA_SPACE = 'dataSpace';
57
+
58
+ interface DataSpaceOption {
59
+ groupId: string;
60
+ artifactId: string;
61
+ versionId: string;
62
+ path: string;
63
+ label: string;
64
+ }
65
+
66
+ interface FileSizeRow {
67
+ path: string;
68
+ type: string;
69
+ filePath: string;
70
+ bytes: number;
71
+ }
72
+
73
+ interface InspectionResult {
74
+ mode: 'parsed' | 'streamed' | 'streamed-headers';
75
+ totalBytes: number;
76
+ fileCount: number;
77
+ rows: FileSizeRow[];
78
+ fetchMs: number;
79
+ sizingMs: number;
80
+ streamedBodyBytes?: number | undefined;
81
+ streamedCompressedBytes?: number | undefined;
82
+ streamedContentEncoding?: string | undefined;
83
+ streamedUrl?: string | undefined;
84
+ // Only populated in parsed mode — used by the inline JSON viewer.
85
+ rawFiles?: unknown[] | undefined;
86
+ }
87
+
88
+ interface JsonViewerState {
89
+ title: string;
90
+ content: string;
91
+ }
92
+
93
+ /**
94
+ * Pretty-print arbitrary JSON-ish input. If the input is already a string
95
+ * that happens to be valid JSON (depot returns `content` as a string for
96
+ * files like `AnalyticsResult.json`), reparse and re-stringify so the
97
+ * viewer gets a properly indented, foldable tree instead of a long
98
+ * single-line escaped string.
99
+ */
100
+ const prettyJson = (value: unknown): string => {
101
+ if (typeof value === 'string') {
102
+ const trimmed = value.trim();
103
+ if (
104
+ (trimmed.startsWith('{') && trimmed.endsWith('}')) ||
105
+ (trimmed.startsWith('[') && trimmed.endsWith(']'))
106
+ ) {
107
+ try {
108
+ return JSON.stringify(JSON.parse(value), null, 2);
109
+ } catch {
110
+ // fall through, return raw string
111
+ }
112
+ }
113
+ return value;
114
+ }
115
+ try {
116
+ return JSON.stringify(value, null, 2);
117
+ } catch {
118
+ return String(value);
119
+ }
120
+ };
121
+
122
+ const inputStyle: React.CSSProperties = {
123
+ padding: '0.4rem 0.6rem',
124
+ background: '#2a2a2a',
125
+ color: '#eee',
126
+ border: '1px solid #444',
127
+ borderRadius: 4,
128
+ fontFamily: 'monospace',
129
+ };
130
+
131
+ const thStyle: React.CSSProperties = {
132
+ padding: '0.4rem 0.6rem',
133
+ textAlign: 'left',
134
+ borderBottom: '1px solid #444',
135
+ };
136
+
137
+ const tdStyle: React.CSSProperties = {
138
+ padding: '0.35rem 0.6rem',
139
+ borderBottom: '1px solid #2a2a2a',
140
+ fontFamily: 'monospace',
141
+ fontSize: '0.85rem',
142
+ };
143
+
144
+ const Row: React.FC<{ label: string; value: string }> = ({ label, value }) => (
145
+ <tr>
146
+ <td style={{ padding: '0.2rem 0.75rem 0.2rem 0', color: '#888' }}>
147
+ {label}
148
+ </td>
149
+ <td style={{ padding: '0.2rem 0', fontFamily: 'monospace' }}>{value}</td>
150
+ </tr>
151
+ );
152
+
153
+ const JsonViewerModal: React.FC<{
154
+ title: string;
155
+ content: string;
156
+ onClose: () => void;
157
+ }> = ({ title, content, onClose }) => (
158
+ <div
159
+ style={{
160
+ position: 'fixed',
161
+ top: 0,
162
+ left: 0,
163
+ right: 0,
164
+ bottom: 0,
165
+ background: 'rgba(0, 0, 0, 0.65)',
166
+ display: 'flex',
167
+ alignItems: 'center',
168
+ justifyContent: 'center',
169
+ zIndex: 1000,
170
+ }}
171
+ onClick={onClose}
172
+ >
173
+ <div
174
+ style={{
175
+ background: '#1e1e1e',
176
+ border: '1px solid #444',
177
+ borderRadius: 6,
178
+ width: 'min(1400px, 95vw)',
179
+ height: 'min(900px, 92vh)',
180
+ display: 'flex',
181
+ flexDirection: 'column',
182
+ boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5)',
183
+ }}
184
+ onClick={(e) => e.stopPropagation()}
185
+ >
186
+ <div
187
+ style={{
188
+ padding: '0.6rem 1rem',
189
+ borderBottom: '1px solid #333',
190
+ display: 'flex',
191
+ alignItems: 'center',
192
+ justifyContent: 'space-between',
193
+ }}
194
+ >
195
+ <div
196
+ style={{
197
+ color: '#ddd',
198
+ fontFamily: 'monospace',
199
+ fontSize: '0.9rem',
200
+ overflow: 'hidden',
201
+ textOverflow: 'ellipsis',
202
+ whiteSpace: 'nowrap',
203
+ marginRight: '1rem',
204
+ }}
205
+ title={title}
206
+ >
207
+ {title}
208
+ <span style={{ color: '#888', marginLeft: '1rem' }}>
209
+ ({content.length.toLocaleString()} chars)
210
+ </span>
211
+ </div>
212
+ <button
213
+ type="button"
214
+ onClick={onClose}
215
+ style={{
216
+ padding: '0.3rem 0.8rem',
217
+ background: '#444',
218
+ color: 'white',
219
+ border: '1px solid #666',
220
+ borderRadius: 4,
221
+ cursor: 'pointer',
222
+ }}
223
+ >
224
+ Close
225
+ </button>
226
+ </div>
227
+ <div style={{ flex: 1, minHeight: 0 }}>
228
+ <CodeEditor
229
+ inputValue={content}
230
+ isReadOnly={true}
231
+ language={CODE_EDITOR_LANGUAGE.JSON}
232
+ hideMinimap={false}
233
+ hideGutter={false}
234
+ />
235
+ </div>
236
+ </div>
237
+ </div>
238
+ );
239
+
240
+ export const DataSpaceArtifactInspector = observer(() => {
241
+ const applicationStore = useLegendQueryApplicationStore();
242
+ const depotServerClient = useLegendQueryBaseStore().depotServerClient;
243
+
244
+ const [groupId, setGroupId] = useState('');
245
+ const [artifactId, setArtifactId] = useState('');
246
+ const [versionId, setVersionId] = useState('');
247
+ const [elementPath, setElementPath] = useState('');
248
+
249
+ const [savedQueryId, setSavedQueryId] = useState('');
250
+ const [loadingQuery, setLoadingQuery] = useState(false);
251
+ const [queryLoadError, setQueryLoadError] = useState<string | undefined>(
252
+ undefined,
253
+ );
254
+ const [queryLoadInfo, setQueryLoadInfo] = useState<string | undefined>(
255
+ undefined,
256
+ );
257
+
258
+ const [dataSpaceOptions, setDataSpaceOptions] = useState<DataSpaceOption[]>(
259
+ [],
260
+ );
261
+ const [loadingDataSpaces, setLoadingDataSpaces] = useState(false);
262
+ const [dataSpacesError, setDataSpacesError] = useState<string | undefined>(
263
+ undefined,
264
+ );
265
+ const [selectedDataSpaceKey, setSelectedDataSpaceKey] = useState('');
266
+ const [dataSpaceFilter, setDataSpaceFilter] = useState('');
267
+
268
+ const [running, setRunning] = useState(false);
269
+ const [error, setError] = useState<string | undefined>(undefined);
270
+ const [result, setResult] = useState<InspectionResult | undefined>(undefined);
271
+ const [viewer, setViewer] = useState<JsonViewerState | undefined>(undefined);
272
+
273
+ const elementPathMissing = !elementPath.trim();
274
+ const canRun =
275
+ !running && !!groupId && !!artifactId && !!versionId && !elementPathMissing;
276
+
277
+ /**
278
+ * Load groupId / artifactId / versionId / elementPath from a saved
279
+ * query. Hits the engine query server `/pure/v1/query/{id}` endpoint
280
+ * directly (no graphManager dependency required for a diagnostic page).
281
+ */
282
+ const loadFromSavedQuery = async (): Promise<void> => {
283
+ const id = savedQueryId.trim();
284
+ if (!id) {
285
+ return;
286
+ }
287
+ setLoadingQuery(true);
288
+ setQueryLoadError(undefined);
289
+ setQueryLoadInfo(undefined);
290
+ try {
291
+ const config = applicationStore.config;
292
+ const base = config.engineQueryServerUrl ?? config.engineServerUrl;
293
+ if (!base) {
294
+ throw new Error('Engine (query) server URL is not configured');
295
+ }
296
+ const url = `${base}/pure/v1/query/${encodeURIComponent(id)}`;
297
+ const response = await fetch(url, {
298
+ method: 'GET',
299
+ credentials: 'include',
300
+ headers: { Accept: 'application/json' },
301
+ });
302
+ if (!response.ok) {
303
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
304
+ }
305
+ const body = (await response.json()) as {
306
+ groupId?: string;
307
+ artifactId?: string;
308
+ versionId?: string;
309
+ taggedValues?: {
310
+ profile?: string;
311
+ tag?: string;
312
+ value?: string;
313
+ }[];
314
+ executionContext?: {
315
+ _type?: string;
316
+ dataSpacePath?: string;
317
+ };
318
+ };
319
+
320
+ const nextGroupId = body.groupId ?? '';
321
+ const nextArtifactId = body.artifactId ?? '';
322
+ const nextVersionId = body.versionId ?? '';
323
+
324
+ // Prefer the executionContext.dataSpacePath (set when the saved
325
+ // query targets a dataspace exec context); fall back to the legacy
326
+ // `dataSpace` tagged value.
327
+ const fromExecutionContext =
328
+ body.executionContext?.dataSpacePath?.trim() ?? '';
329
+ const fromTaggedValues = (body.taggedValues ?? []).find(
330
+ (t) =>
331
+ t.profile === QUERY_PROFILE_PATH &&
332
+ t.tag === QUERY_PROFILE_TAG_DATA_SPACE &&
333
+ typeof t.value === 'string' &&
334
+ t.value.length > 0,
335
+ )?.value;
336
+ const nextElementPath = fromExecutionContext || (fromTaggedValues ?? '');
337
+
338
+ if (!nextGroupId || !nextArtifactId || !nextVersionId) {
339
+ throw new Error(
340
+ 'Saved query is missing groupId / artifactId / versionId',
341
+ );
342
+ }
343
+ if (!nextElementPath) {
344
+ throw new Error(
345
+ 'Saved query has no dataspace tagged value or dataspace execution context — cannot infer elementPath. Fill it in manually.',
346
+ );
347
+ }
348
+
349
+ setGroupId(nextGroupId);
350
+ setArtifactId(nextArtifactId);
351
+ setVersionId(nextVersionId);
352
+ setElementPath(nextElementPath);
353
+ setQueryLoadInfo(
354
+ `Loaded ${nextGroupId}:${nextArtifactId}:${nextVersionId} → ${nextElementPath}`,
355
+ );
356
+ } catch (e) {
357
+ setQueryLoadError(e instanceof Error ? e.message : String(e));
358
+ applicationStore.logService.error(
359
+ LogEvent.create(LEGEND_QUERY_APP_EVENT.GENERIC_FAILURE),
360
+ e,
361
+ );
362
+ } finally {
363
+ setLoadingQuery(false);
364
+ }
365
+ };
366
+
367
+ /**
368
+ * Load every DataSpace registered in depot (latest version of each)
369
+ * into a dropdown. Uses the lightweight `summary` classifier endpoint
370
+ * so we only get `{groupId, artifactId, versionId, path}` rows, not
371
+ * full entity content — safe to call with thousands of entries.
372
+ */
373
+ const loadAllDataSpaces = async (): Promise<void> => {
374
+ setLoadingDataSpaces(true);
375
+ setDataSpacesError(undefined);
376
+ try {
377
+ const base = depotServerClient.baseUrl;
378
+ if (!base) {
379
+ throw new Error('Depot server baseUrl is not configured');
380
+ }
381
+ const url = `${base}/classifiers/${encodeURIComponent(
382
+ DATA_SPACE_ELEMENT_CLASSIFIER_PATH,
383
+ )}?summary=true&latest=true`;
384
+ const response = await fetch(url, {
385
+ method: 'GET',
386
+ credentials: 'include',
387
+ headers: { Accept: 'application/json' },
388
+ });
389
+ if (!response.ok) {
390
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
391
+ }
392
+ const body = (await response.json()) as {
393
+ groupId?: string;
394
+ artifactId?: string;
395
+ versionId?: string;
396
+ path?: string;
397
+ }[];
398
+ const options: DataSpaceOption[] = body
399
+ .map((e): DataSpaceOption | undefined => {
400
+ const { groupId: g, artifactId: a, versionId: v, path: p } = e;
401
+ if (!g || !a || !v || !p) {
402
+ return undefined;
403
+ }
404
+ return {
405
+ groupId: g,
406
+ artifactId: a,
407
+ versionId: v,
408
+ path: p,
409
+ label: `${p} \u2014 ${g}:${a}:${v}`,
410
+ };
411
+ })
412
+ .filter((o): o is DataSpaceOption => o !== undefined)
413
+ .sort((a, b) => a.label.localeCompare(b.label));
414
+ setDataSpaceOptions(options);
415
+ } catch (e) {
416
+ setDataSpacesError(e instanceof Error ? e.message : String(e));
417
+ applicationStore.logService.error(
418
+ LogEvent.create(LEGEND_QUERY_APP_EVENT.GENERIC_FAILURE),
419
+ e,
420
+ );
421
+ } finally {
422
+ setLoadingDataSpaces(false);
423
+ }
424
+ };
425
+
426
+ const applyDataSpaceOption = (key: string): void => {
427
+ setSelectedDataSpaceKey(key);
428
+ if (!key) {
429
+ return;
430
+ }
431
+ const option = dataSpaceOptions.find(
432
+ (o) => `${o.groupId}:${o.artifactId}:${o.versionId}:${o.path}` === key,
433
+ );
434
+ if (!option) {
435
+ return;
436
+ }
437
+ setGroupId(option.groupId);
438
+ setArtifactId(option.artifactId);
439
+ setVersionId(option.versionId);
440
+ setElementPath(option.path);
441
+ };
442
+
443
+ const filteredDataSpaceOptions = (() => {
444
+ const f = dataSpaceFilter.trim().toLowerCase();
445
+ if (!f) {
446
+ return dataSpaceOptions;
447
+ }
448
+ return dataSpaceOptions.filter((o) => o.label.toLowerCase().includes(f));
449
+ })();
450
+
451
+ const run = async (): Promise<void> => {
452
+ setRunning(true);
453
+ setError(undefined);
454
+ setResult(undefined);
455
+ try {
456
+ const project = StoreProjectData.serialization.fromJson(
457
+ await depotServerClient.getProject(groupId.trim(), artifactId.trim()),
458
+ );
459
+
460
+ const fetchStart = performance.now();
461
+ const files = await depotServerClient.getGenerationFilesByType(
462
+ project,
463
+ versionId.trim(),
464
+ 'dataSpace-analytics',
465
+ elementPath.trim(),
466
+ );
467
+ const fetchMs = performance.now() - fetchStart;
468
+
469
+ const sizingStart = performance.now();
470
+ const rows: FileSizeRow[] = files
471
+ .map((f) => {
472
+ const elemPath = (f as { path?: unknown }).path;
473
+ const type = (f as { type?: unknown }).type;
474
+ const file = (f as { file?: { path?: unknown; content?: unknown } })
475
+ .file;
476
+ const filePath = file?.path;
477
+ const content = file?.content;
478
+ let bytes = 0;
479
+ try {
480
+ bytes =
481
+ typeof content === 'string'
482
+ ? content.length
483
+ : JSON.stringify(content).length;
484
+ } catch {
485
+ bytes = -1;
486
+ }
487
+ return {
488
+ path: typeof elemPath === 'string' ? elemPath : elementPath.trim(),
489
+ type: typeof type === 'string' ? type : '<unknown>',
490
+ filePath: typeof filePath === 'string' ? filePath : '<unknown>',
491
+ bytes,
492
+ };
493
+ })
494
+ .sort((a, b) => b.bytes - a.bytes);
495
+ const sizingMs = performance.now() - sizingStart;
496
+
497
+ const totalBytes = rows.reduce((sum, r) => sum + Math.max(0, r.bytes), 0);
498
+ setResult({
499
+ mode: 'parsed',
500
+ totalBytes,
501
+ fileCount: rows.length,
502
+ rows,
503
+ fetchMs,
504
+ sizingMs,
505
+ rawFiles: files,
506
+ });
507
+ } catch (e) {
508
+ setError(e instanceof Error ? e.message : String(e));
509
+ applicationStore.logService.error(
510
+ LogEvent.create(LEGEND_QUERY_APP_EVENT.GENERIC_FAILURE),
511
+ e,
512
+ );
513
+ } finally {
514
+ setRunning(false);
515
+ }
516
+ };
517
+
518
+ /**
519
+ * Streaming variant that NEVER parses the JSON. Uses fetch() directly,
520
+ * reads the response as a byte stream, and counts bytes as they arrive.
521
+ * Memory footprint stays flat regardless of payload size, so this works
522
+ * even on the OOM-causing payload where the parsed variant would crash.
523
+ */
524
+ const runStreaming = async (): Promise<void> => {
525
+ setRunning(true);
526
+ setError(undefined);
527
+ setResult(undefined);
528
+ try {
529
+ const base = depotServerClient.baseUrl;
530
+ if (!base) {
531
+ throw new Error('Depot server baseUrl is not configured');
532
+ }
533
+ const url =
534
+ `${base}/generations/${encodeURIComponent(groupId.trim())}` +
535
+ `/${encodeURIComponent(artifactId.trim())}` +
536
+ `/${encodeURIComponent(versionId.trim())}` +
537
+ `/types/dataSpace-analytics` +
538
+ `?elementPath=${encodeURIComponent(elementPath.trim())}`;
539
+
540
+ const fetchStart = performance.now();
541
+ // `credentials: 'include'` mirrors what AbstractServerClient does so
542
+ // we inherit the user's session cookies.
543
+ const response = await fetch(url, {
544
+ method: 'GET',
545
+ credentials: 'include',
546
+ headers: { Accept: 'application/json' },
547
+ });
548
+ if (!response.ok) {
549
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
550
+ }
551
+ const contentEncoding =
552
+ response.headers.get('content-encoding') ?? '<none>';
553
+ const compressedHeader = response.headers.get('content-length');
554
+ const compressedBytes = compressedHeader
555
+ ? Number(compressedHeader)
556
+ : undefined;
557
+
558
+ const reader = response.body?.getReader();
559
+ if (!reader) {
560
+ throw new Error('Response body is not readable as a stream');
561
+ }
562
+ let streamedBodyBytes = 0;
563
+ let chunk = await reader.read();
564
+ while (!chunk.done) {
565
+ streamedBodyBytes += chunk.value.byteLength;
566
+ chunk = await reader.read();
567
+ }
568
+ const fetchMs = performance.now() - fetchStart;
569
+
570
+ setResult({
571
+ mode: 'streamed',
572
+ totalBytes: streamedBodyBytes,
573
+ fileCount: 0,
574
+ rows: [],
575
+ fetchMs,
576
+ sizingMs: 0,
577
+ streamedBodyBytes,
578
+ streamedCompressedBytes: compressedBytes,
579
+ streamedContentEncoding: contentEncoding,
580
+ streamedUrl: url,
581
+ });
582
+ } catch (e) {
583
+ setError(e instanceof Error ? e.message : String(e));
584
+ applicationStore.logService.error(
585
+ LogEvent.create(LEGEND_QUERY_APP_EVENT.GENERIC_FAILURE),
586
+ e,
587
+ );
588
+ } finally {
589
+ setRunning(false);
590
+ }
591
+ };
592
+
593
+ /**
594
+ * Streaming variant that walks the response with a tiny JSON state
595
+ * machine, recognises top-level array element boundaries, and for each
596
+ * element keeps only:
597
+ * - its total byte length (from where it started to where it ended)
598
+ * - its first 64 KB of text (enough to regex out `path`)
599
+ * Content (the large field) is read past and dropped on the fly, so peak
600
+ * heap stays roughly element-bounded, not response-bounded. Works on
601
+ * OOM-causing payloads because we never materialize the full body.
602
+ */
603
+ const runStreamingWithHeaders = async (): Promise<void> => {
604
+ setRunning(true);
605
+ setError(undefined);
606
+ setResult(undefined);
607
+ try {
608
+ const base = depotServerClient.baseUrl;
609
+ if (!base) {
610
+ throw new Error('Depot server baseUrl is not configured');
611
+ }
612
+ const url =
613
+ `${base}/generations/${encodeURIComponent(groupId.trim())}` +
614
+ `/${encodeURIComponent(artifactId.trim())}` +
615
+ `/${encodeURIComponent(versionId.trim())}` +
616
+ `/types/dataSpace-analytics` +
617
+ `?elementPath=${encodeURIComponent(elementPath.trim())}`;
618
+
619
+ const fetchStart = performance.now();
620
+ const response = await fetch(url, {
621
+ method: 'GET',
622
+ credentials: 'include',
623
+ headers: { Accept: 'application/json' },
624
+ });
625
+ if (!response.ok) {
626
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
627
+ }
628
+ const contentEncoding =
629
+ response.headers.get('content-encoding') ?? '<none>';
630
+ const compressedHeader = response.headers.get('content-length');
631
+ const compressedBytes = compressedHeader
632
+ ? Number(compressedHeader)
633
+ : undefined;
634
+
635
+ const reader = response.body?.getReader();
636
+ if (!reader) {
637
+ throw new Error('Response body is not readable as a stream');
638
+ }
639
+
640
+ // Cap element header buffer at 64 KB. `StoredFileGeneration.path`
641
+ // and `type` reliably appear before the (potentially huge)
642
+ // `file.content` payload, so 64 KB is overkill but safe.
643
+ const MAX_HEADER_BYTES = 64 * 1024;
644
+ const decoder = new TextDecoder('utf-8');
645
+
646
+ let streamedBodyBytes = 0;
647
+ // JSON state machine
648
+ let depth = 0; // brace/bracket depth across all containers
649
+ let inString = false;
650
+ let escape = false;
651
+ let inElement = false; // true while inside a top-level array element
652
+ let elementByteStart = 0;
653
+ let elementHeader = ''; // capped to MAX_HEADER_BYTES per element
654
+ const rows: FileSizeRow[] = [];
655
+
656
+ const onElementComplete = (
657
+ startByte: number,
658
+ endByte: number,
659
+ headerText: string,
660
+ ): void => {
661
+ const pathMatch = /"path"\s*:\s*"(?<value>(?:[^"\\]|\\.)*)"/.exec(
662
+ headerText,
663
+ );
664
+ const filePath = pathMatch?.groups?.value ?? '<unknown>';
665
+ rows.push({
666
+ path: elementPath.trim(),
667
+ type: '<n/a>',
668
+ filePath,
669
+ bytes: endByte - startByte,
670
+ });
671
+ };
672
+
673
+ let chunk = await reader.read();
674
+ while (!chunk.done) {
675
+ const { value } = chunk;
676
+ const chunkBytes = value.byteLength;
677
+ const chunkText = decoder.decode(value, { stream: true });
678
+ const chunkStartByte = streamedBodyBytes;
679
+
680
+ for (let i = 0; i < chunkText.length; i++) {
681
+ const ch = chunkText[i];
682
+
683
+ if (inString) {
684
+ if (escape) {
685
+ escape = false;
686
+ } else if (ch === '\\') {
687
+ escape = true;
688
+ } else if (ch === '"') {
689
+ inString = false;
690
+ }
691
+ } else if (ch === '"') {
692
+ inString = true;
693
+ } else if (ch === '{' || ch === '[') {
694
+ if (depth === 1 && ch === '{') {
695
+ // Entering a top-level array element
696
+ inElement = true;
697
+ elementByteStart = chunkStartByte + i; // approximate (UTF-8/16 mismatch is OK for sizing)
698
+ elementHeader = '';
699
+ }
700
+ depth++;
701
+ } else if (ch === '}' || ch === ']') {
702
+ depth--;
703
+ if (depth === 1 && inElement && ch === '}') {
704
+ const elementByteEnd = chunkStartByte + i + 1;
705
+ onElementComplete(
706
+ elementByteStart,
707
+ elementByteEnd,
708
+ elementHeader,
709
+ );
710
+ inElement = false;
711
+ elementHeader = '';
712
+ }
713
+ }
714
+
715
+ if (inElement && elementHeader.length < MAX_HEADER_BYTES) {
716
+ elementHeader += ch;
717
+ }
718
+ }
719
+ streamedBodyBytes += chunkBytes;
720
+ chunk = await reader.read();
721
+ }
722
+ const fetchMs = performance.now() - fetchStart;
723
+
724
+ rows.sort((a, b) => b.bytes - a.bytes);
725
+ const totalBytes = rows.reduce((sum, r) => sum + Math.max(0, r.bytes), 0);
726
+
727
+ setResult({
728
+ mode: 'streamed-headers',
729
+ totalBytes,
730
+ fileCount: rows.length,
731
+ rows,
732
+ fetchMs,
733
+ sizingMs: 0,
734
+ streamedBodyBytes,
735
+ streamedCompressedBytes: compressedBytes,
736
+ streamedContentEncoding: contentEncoding,
737
+ streamedUrl: url,
738
+ });
739
+ } catch (e) {
740
+ setError(e instanceof Error ? e.message : String(e));
741
+ applicationStore.logService.error(
742
+ LogEvent.create(LEGEND_QUERY_APP_EVENT.GENERIC_FAILURE),
743
+ e,
744
+ );
745
+ } finally {
746
+ setRunning(false);
747
+ }
748
+ };
749
+
750
+ /**
751
+ * Stream the full response straight to a file on disk. Uses the File
752
+ * System Access API (`showSaveFilePicker`) so chunks are flushed to the
753
+ * filesystem as they arrive and the entire body never sits in memory at
754
+ * once — safe for the OOM-causing payload.
755
+ *
756
+ * Falls back to building a Blob and triggering an <a download> click for
757
+ * browsers without the File System Access API. Note: the fallback path
758
+ * DOES hold the full response in memory and will OOM on huge payloads;
759
+ * a warning is logged in that case.
760
+ */
761
+ const runStreamDownload = async (): Promise<void> => {
762
+ setRunning(true);
763
+ setError(undefined);
764
+ setResult(undefined);
765
+ try {
766
+ const base = depotServerClient.baseUrl;
767
+ if (!base) {
768
+ throw new Error('Depot server baseUrl is not configured');
769
+ }
770
+ const url =
771
+ `${base}/generations/${encodeURIComponent(groupId.trim())}` +
772
+ `/${encodeURIComponent(artifactId.trim())}` +
773
+ `/${encodeURIComponent(versionId.trim())}` +
774
+ `/types/dataSpace-analytics` +
775
+ `?elementPath=${encodeURIComponent(elementPath.trim())}`;
776
+
777
+ const suggestedName = `dataSpace-analytics_${groupId.trim()}_${artifactId.trim()}_${versionId.trim()}_${elementPath.trim().replace(/::/g, '_')}.json`;
778
+
779
+ // File System Access API is Chromium-only as of 2026. Probe before use.
780
+ const showSaveFilePicker = (
781
+ window as unknown as {
782
+ showSaveFilePicker?: (opts?: unknown) => Promise<{
783
+ createWritable: () => Promise<{
784
+ write: (chunk: Uint8Array) => Promise<void>;
785
+ close: () => Promise<void>;
786
+ }>;
787
+ }>;
788
+ }
789
+ ).showSaveFilePicker;
790
+
791
+ const fetchStart = performance.now();
792
+ const response = await fetch(url, {
793
+ method: 'GET',
794
+ credentials: 'include',
795
+ headers: { Accept: 'application/json' },
796
+ });
797
+ if (!response.ok) {
798
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
799
+ }
800
+ const contentEncoding =
801
+ response.headers.get('content-encoding') ?? '<none>';
802
+ const compressedHeader = response.headers.get('content-length');
803
+ const compressedBytes = compressedHeader
804
+ ? Number(compressedHeader)
805
+ : undefined;
806
+
807
+ let streamedBodyBytes = 0;
808
+
809
+ if (showSaveFilePicker) {
810
+ // Streaming path — bytes flushed to disk per chunk.
811
+ const handle = await showSaveFilePicker({
812
+ suggestedName,
813
+ types: [
814
+ {
815
+ description: 'JSON file',
816
+ accept: { 'application/json': ['.json'] },
817
+ },
818
+ ],
819
+ });
820
+ const writable = await handle.createWritable();
821
+ const reader = response.body?.getReader();
822
+ if (!reader) {
823
+ await writable.close();
824
+ throw new Error('Response body is not readable as a stream');
825
+ }
826
+ let chunk = await reader.read();
827
+ while (!chunk.done) {
828
+ await writable.write(chunk.value);
829
+ streamedBodyBytes += chunk.value.byteLength;
830
+ chunk = await reader.read();
831
+ }
832
+ await writable.close();
833
+ } else {
834
+ // Fallback: blob + anchor click. WARNING: holds full body in memory.
835
+ // eslint-disable-next-line no-console
836
+ console.warn(
837
+ '[DataSpaceArtifactInspector] showSaveFilePicker not available; ' +
838
+ 'falling back to blob download. This holds the full body in memory.',
839
+ );
840
+ const blob = await response.blob();
841
+ streamedBodyBytes = blob.size;
842
+ const objectUrl = URL.createObjectURL(blob);
843
+ const a = document.createElement('a');
844
+ a.href = objectUrl;
845
+ a.download = suggestedName;
846
+ document.body.appendChild(a);
847
+ a.click();
848
+ document.body.removeChild(a);
849
+ URL.revokeObjectURL(objectUrl);
850
+ }
851
+
852
+ const fetchMs = performance.now() - fetchStart;
853
+
854
+ setResult({
855
+ mode: 'streamed',
856
+ totalBytes: streamedBodyBytes,
857
+ fileCount: 0,
858
+ rows: [],
859
+ fetchMs,
860
+ sizingMs: 0,
861
+ streamedBodyBytes,
862
+ streamedCompressedBytes: compressedBytes,
863
+ streamedContentEncoding: contentEncoding,
864
+ streamedUrl: url,
865
+ });
866
+ } catch (e) {
867
+ // AbortError from the user cancelling the save dialog is not a real
868
+ // error — silently no-op.
869
+ if (e instanceof DOMException && e.name === 'AbortError') {
870
+ return;
871
+ }
872
+ setError(e instanceof Error ? e.message : String(e));
873
+ applicationStore.logService.error(
874
+ LogEvent.create(LEGEND_QUERY_APP_EVENT.GENERIC_FAILURE),
875
+ e,
876
+ );
877
+ } finally {
878
+ setRunning(false);
879
+ }
880
+ };
881
+
882
+ const openFullResponseViewer = (): void => {
883
+ if (!result?.rawFiles) {
884
+ return;
885
+ }
886
+ setViewer({
887
+ title: 'Full parsed response (StoredFileGeneration[])',
888
+ content: prettyJson(result.rawFiles),
889
+ });
890
+ };
891
+
892
+ const openFileViewer = (index: number): void => {
893
+ const entry = result?.rawFiles?.[index] as
894
+ | {
895
+ path?: string;
896
+ type?: string;
897
+ file?: { path?: string; content?: unknown };
898
+ }
899
+ | undefined;
900
+ if (!entry) {
901
+ return;
902
+ }
903
+ const filePath = entry.file?.path ?? entry.path ?? '<unknown>';
904
+ setViewer({
905
+ title: `File: ${filePath}`,
906
+ content: prettyJson(entry.file?.content),
907
+ });
908
+ };
909
+
910
+ return (
911
+ <div
912
+ style={{
913
+ padding: '1.5rem',
914
+ fontFamily:
915
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
916
+ color: '#ddd',
917
+ background: '#1e1e1e',
918
+ minHeight: '100vh',
919
+ }}
920
+ >
921
+ <h1 style={{ marginTop: 0 }}>DataSpace Artifact Inspector</h1>
922
+ <p style={{ color: '#aaa', maxWidth: 760 }}>
923
+ Diagnostic tool: fetches the per-element depot analytics endpoint for a
924
+ single DataSpace and reports total uncompressed payload size plus a
925
+ per-file breakdown. Use this to identify oversized partition files that
926
+ may cause Chrome tab OOM.
927
+ </p>
928
+
929
+ <h2 style={{ marginTop: '1.5rem', marginBottom: '0.25rem' }}>
930
+ Load inputs from a saved query (optional)
931
+ </h2>
932
+ <p style={{ color: '#888', maxWidth: 760, marginTop: 0 }}>
933
+ Paste a Legend saved query id; we&apos;ll fetch{' '}
934
+ <code>/pure/v1/query/&lt;id&gt;</code> and pre-fill groupId, artifactId,
935
+ versionId, and the dataspace path from the query&apos;s tagged values /
936
+ execution context.
937
+ </p>
938
+ <div
939
+ style={{
940
+ display: 'grid',
941
+ gridTemplateColumns: '160px 1fr auto',
942
+ gap: '0.5rem 0.75rem',
943
+ maxWidth: 760,
944
+ }}
945
+ >
946
+ <label htmlFor="ds-savedQueryId">saved query id</label>
947
+ <input
948
+ id="ds-savedQueryId"
949
+ value={savedQueryId}
950
+ onChange={(e) => setSavedQueryId(e.target.value)}
951
+ placeholder="e.g. 65f1c0a8b1c0d20012345678"
952
+ style={inputStyle}
953
+ />
954
+ <button
955
+ type="button"
956
+ onClick={() => {
957
+ loadFromSavedQuery().catch(() => undefined);
958
+ }}
959
+ disabled={loadingQuery || !savedQueryId.trim()}
960
+ style={{
961
+ padding: '0.4rem 0.75rem',
962
+ background: loadingQuery ? '#555' : '#444',
963
+ color: 'white',
964
+ border: '1px solid #666',
965
+ borderRadius: 4,
966
+ cursor: loadingQuery ? 'wait' : 'pointer',
967
+ whiteSpace: 'nowrap',
968
+ }}
969
+ >
970
+ {loadingQuery ? 'Loading…' : 'Load'}
971
+ </button>
972
+ </div>
973
+ {queryLoadError && (
974
+ <div
975
+ style={{
976
+ marginTop: '0.5rem',
977
+ padding: '0.5rem 0.75rem',
978
+ background: '#5a1f1f',
979
+ border: '1px solid #a33',
980
+ borderRadius: 4,
981
+ maxWidth: 760,
982
+ }}
983
+ >
984
+ {queryLoadError}
985
+ </div>
986
+ )}
987
+ {queryLoadInfo && (
988
+ <div
989
+ style={{
990
+ marginTop: '0.5rem',
991
+ padding: '0.5rem 0.75rem',
992
+ background: '#1f3a1f',
993
+ border: '1px solid #3a6',
994
+ borderRadius: 4,
995
+ maxWidth: 760,
996
+ fontFamily: 'monospace',
997
+ fontSize: '0.85rem',
998
+ }}
999
+ >
1000
+ {queryLoadInfo}
1001
+ </div>
1002
+ )}
1003
+
1004
+ <h2 style={{ marginTop: '1.5rem', marginBottom: '0.25rem' }}>
1005
+ Pick from all DataSpaces (optional)
1006
+ </h2>
1007
+ <p style={{ color: '#888', maxWidth: 760, marginTop: 0 }}>
1008
+ Load every DataSpace registered in depot (latest version of each) via{' '}
1009
+ <code>
1010
+ /classifiers/meta::pure::metamodel::dataSpace::DataSpace?summary=true&amp;latest=true
1011
+ </code>
1012
+ , then pick one to pre-fill all four inputs.
1013
+ </p>
1014
+ <div
1015
+ style={{
1016
+ display: 'flex',
1017
+ alignItems: 'center',
1018
+ gap: '0.5rem',
1019
+ maxWidth: 1000,
1020
+ marginBottom: '0.5rem',
1021
+ }}
1022
+ >
1023
+ <button
1024
+ type="button"
1025
+ onClick={() => {
1026
+ loadAllDataSpaces().catch(() => undefined);
1027
+ }}
1028
+ disabled={loadingDataSpaces}
1029
+ style={{
1030
+ padding: '0.4rem 0.75rem',
1031
+ background: loadingDataSpaces ? '#555' : '#444',
1032
+ color: 'white',
1033
+ border: '1px solid #666',
1034
+ borderRadius: 4,
1035
+ cursor: loadingDataSpaces ? 'wait' : 'pointer',
1036
+ whiteSpace: 'nowrap',
1037
+ }}
1038
+ >
1039
+ {loadingDataSpaces
1040
+ ? 'Loading…'
1041
+ : dataSpaceOptions.length > 0
1042
+ ? `Reload (${dataSpaceOptions.length} loaded)`
1043
+ : 'Load all dataspaces'}
1044
+ </button>
1045
+ <input
1046
+ type="text"
1047
+ value={dataSpaceFilter}
1048
+ onChange={(e) => setDataSpaceFilter(e.target.value)}
1049
+ placeholder="filter…"
1050
+ disabled={dataSpaceOptions.length === 0}
1051
+ style={{ ...inputStyle, flex: '0 0 220px' }}
1052
+ />
1053
+ <select
1054
+ value={selectedDataSpaceKey}
1055
+ onChange={(e) => applyDataSpaceOption(e.target.value)}
1056
+ disabled={dataSpaceOptions.length === 0}
1057
+ style={{
1058
+ ...inputStyle,
1059
+ flex: 1,
1060
+ minWidth: 0,
1061
+ }}
1062
+ >
1063
+ <option value="">
1064
+ {dataSpaceOptions.length === 0
1065
+ ? '(load dataspaces first)'
1066
+ : `Select one of ${filteredDataSpaceOptions.length} dataspace${filteredDataSpaceOptions.length === 1 ? '' : 's'}…`}
1067
+ </option>
1068
+ {filteredDataSpaceOptions.map((o) => {
1069
+ const key = `${o.groupId}:${o.artifactId}:${o.versionId}:${o.path}`;
1070
+ return (
1071
+ <option key={key} value={key}>
1072
+ {o.label}
1073
+ </option>
1074
+ );
1075
+ })}
1076
+ </select>
1077
+ </div>
1078
+ {dataSpacesError && (
1079
+ <div
1080
+ style={{
1081
+ marginTop: '0.5rem',
1082
+ padding: '0.5rem 0.75rem',
1083
+ background: '#5a1f1f',
1084
+ border: '1px solid #a33',
1085
+ borderRadius: 4,
1086
+ maxWidth: 1000,
1087
+ }}
1088
+ >
1089
+ {dataSpacesError}
1090
+ </div>
1091
+ )}
1092
+
1093
+ <h2 style={{ marginTop: '1.5rem', marginBottom: '0.25rem' }}>Inputs</h2>
1094
+ <div
1095
+ style={{
1096
+ display: 'grid',
1097
+ gridTemplateColumns: '160px 1fr',
1098
+ gap: '0.5rem 0.75rem',
1099
+ maxWidth: 760,
1100
+ }}
1101
+ >
1102
+ <label htmlFor="ds-groupId">groupId</label>
1103
+ <input
1104
+ id="ds-groupId"
1105
+ value={groupId}
1106
+ onChange={(e) => setGroupId(e.target.value)}
1107
+ placeholder="com.groupId"
1108
+ style={inputStyle}
1109
+ />
1110
+ <label htmlFor="ds-artifactId">artifactId</label>
1111
+ <input
1112
+ id="ds-artifactId"
1113
+ value={artifactId}
1114
+ onChange={(e) => setArtifactId(e.target.value)}
1115
+ placeholder="my-artifactId"
1116
+ style={inputStyle}
1117
+ />
1118
+ <label htmlFor="ds-versionId">versionId</label>
1119
+ <input
1120
+ id="ds-versionId"
1121
+ value={versionId}
1122
+ onChange={(e) => setVersionId(e.target.value)}
1123
+ placeholder="1.0.0 or master-SNAPSHOT"
1124
+ style={inputStyle}
1125
+ />
1126
+ <label htmlFor="ds-elementPath">
1127
+ elementPath <span style={{ color: '#e88' }}>*</span>
1128
+ </label>
1129
+ <input
1130
+ id="ds-elementPath"
1131
+ value={elementPath}
1132
+ onChange={(e) => setElementPath(e.target.value)}
1133
+ placeholder="com::path::to::MyDataSpace (required)"
1134
+ style={{
1135
+ ...inputStyle,
1136
+ borderColor: elementPathMissing ? '#a33' : '#444',
1137
+ }}
1138
+ aria-required="true"
1139
+ aria-invalid={elementPathMissing}
1140
+ />
1141
+ </div>
1142
+ {elementPathMissing && (
1143
+ <div
1144
+ style={{
1145
+ marginTop: '0.5rem',
1146
+ color: '#e88',
1147
+ fontSize: '0.85rem',
1148
+ }}
1149
+ >
1150
+ elementPath is required — this endpoint fetches files for a single
1151
+ DataSpace.
1152
+ </div>
1153
+ )}
1154
+
1155
+ <button
1156
+ type="button"
1157
+ onClick={() => {
1158
+ run().catch(() => undefined);
1159
+ }}
1160
+ disabled={!canRun}
1161
+ style={{
1162
+ marginTop: '1rem',
1163
+ padding: '0.5rem 1rem',
1164
+ background: running ? '#555' : '#0066cc',
1165
+ color: 'white',
1166
+ border: 'none',
1167
+ borderRadius: 4,
1168
+ cursor: running ? 'wait' : 'pointer',
1169
+ }}
1170
+ >
1171
+ {running ? 'Fetching…' : 'Fetch and inspect (parsed)'}
1172
+ </button>
1173
+ <button
1174
+ type="button"
1175
+ onClick={() => {
1176
+ runStreaming().catch(() => undefined);
1177
+ }}
1178
+ disabled={!canRun}
1179
+ title="Streams the response and counts bytes without parsing. Survives OOM-causing payloads."
1180
+ style={{
1181
+ marginTop: '1rem',
1182
+ marginLeft: '0.5rem',
1183
+ padding: '0.5rem 1rem',
1184
+ background: running ? '#555' : '#444',
1185
+ color: 'white',
1186
+ border: '1px solid #666',
1187
+ borderRadius: 4,
1188
+ cursor: running ? 'wait' : 'pointer',
1189
+ }}
1190
+ >
1191
+ {running ? 'Streaming…' : 'Measure raw size only (streaming)'}
1192
+ </button>
1193
+ <button
1194
+ type="button"
1195
+ onClick={() => {
1196
+ runStreamingWithHeaders().catch(() => undefined);
1197
+ }}
1198
+ disabled={!canRun}
1199
+ title="Streams the response with a tiny JSON state machine, extracts each file's path and total byte length, drops the content as it flies past. Survives OOM-causing payloads AND gives per-file breakdown."
1200
+ style={{
1201
+ marginTop: '1rem',
1202
+ marginLeft: '0.5rem',
1203
+ padding: '0.5rem 1rem',
1204
+ background: running ? '#555' : '#2a6',
1205
+ color: 'white',
1206
+ border: '1px solid #2a6',
1207
+ borderRadius: 4,
1208
+ cursor: running ? 'wait' : 'pointer',
1209
+ }}
1210
+ >
1211
+ {running ? 'Streaming…' : 'Stream + per-file breakdown (no content)'}
1212
+ </button>
1213
+ <button
1214
+ type="button"
1215
+ onClick={() => {
1216
+ runStreamDownload().catch(() => undefined);
1217
+ }}
1218
+ disabled={!canRun}
1219
+ title="Stream the full response straight to a file on disk via the File System Access API. Body never sits in memory. Chromium-only for the streaming path; falls back to an in-memory blob download on other browsers."
1220
+ style={{
1221
+ marginTop: '1rem',
1222
+ marginLeft: '0.5rem',
1223
+ padding: '0.5rem 1rem',
1224
+ background: running ? '#555' : '#963',
1225
+ color: 'white',
1226
+ border: '1px solid #963',
1227
+ borderRadius: 4,
1228
+ cursor: running ? 'wait' : 'pointer',
1229
+ }}
1230
+ >
1231
+ {running ? 'Downloading…' : 'Download full response to file'}
1232
+ </button>
1233
+
1234
+ {error && (
1235
+ <div
1236
+ style={{
1237
+ marginTop: '1rem',
1238
+ padding: '0.75rem',
1239
+ background: '#5a1f1f',
1240
+ border: '1px solid #a33',
1241
+ borderRadius: 4,
1242
+ whiteSpace: 'pre-wrap',
1243
+ }}
1244
+ >
1245
+ {error}
1246
+ </div>
1247
+ )}
1248
+
1249
+ {result && (
1250
+ <div style={{ marginTop: '1.5rem' }}>
1251
+ <h2 style={{ marginBottom: '0.25rem' }}>
1252
+ Summary (
1253
+ {result.mode === 'streamed'
1254
+ ? 'streaming mode — size only'
1255
+ : result.mode === 'streamed-headers'
1256
+ ? 'streaming mode — per-file headers'
1257
+ : 'parsed mode'}
1258
+ )
1259
+ </h2>
1260
+ <table style={{ borderCollapse: 'collapse', marginBottom: '1rem' }}>
1261
+ <tbody>
1262
+ {result.mode === 'streamed' ||
1263
+ result.mode === 'streamed-headers' ? (
1264
+ <>
1265
+ <Row label="URL" value={result.streamedUrl ?? '<unknown>'} />
1266
+ <Row
1267
+ label="Content-Encoding"
1268
+ value={result.streamedContentEncoding ?? '<none>'}
1269
+ />
1270
+ <Row
1271
+ label="Content-Length header"
1272
+ value={
1273
+ result.streamedCompressedBytes !== undefined
1274
+ ? `${result.streamedCompressedBytes} bytes (${fmtMB(result.streamedCompressedBytes)})`
1275
+ : '<not set>'
1276
+ }
1277
+ />
1278
+ <Row
1279
+ label="Streamed body (decoded by browser)"
1280
+ value={`${result.streamedBodyBytes ?? 0} bytes (${fmtMB(result.streamedBodyBytes ?? 0)})`}
1281
+ />
1282
+ <Row
1283
+ label="Fetch + drain time"
1284
+ value={`${result.fetchMs.toFixed(0)} ms`}
1285
+ />
1286
+ {result.mode === 'streamed-headers' && (
1287
+ <>
1288
+ <Row
1289
+ label="Element count"
1290
+ value={String(result.fileCount)}
1291
+ />
1292
+ {result.fileCount > 0 && (
1293
+ <Row
1294
+ label="Mean element size"
1295
+ value={fmtMB(result.totalBytes / result.fileCount)}
1296
+ />
1297
+ )}
1298
+ {result.rows[0] && (
1299
+ <Row
1300
+ label="Largest element"
1301
+ value={`${fmtMB(result.rows[0].bytes)} — ${result.rows[0].filePath} (${result.rows[0].type}) [${result.rows[0].path}]`}
1302
+ />
1303
+ )}
1304
+ </>
1305
+ )}
1306
+ </>
1307
+ ) : (
1308
+ <>
1309
+ <Row label="File count" value={String(result.fileCount)} />
1310
+ <Row
1311
+ label="Total uncompressed"
1312
+ value={fmtMB(result.totalBytes)}
1313
+ />
1314
+ <Row
1315
+ label="Total uncompressed (bytes)"
1316
+ value={String(result.totalBytes)}
1317
+ />
1318
+ <Row
1319
+ label="Depot fetch time"
1320
+ value={`${result.fetchMs.toFixed(0)} ms`}
1321
+ />
1322
+ <Row
1323
+ label="Sizing walk time"
1324
+ value={`${result.sizingMs.toFixed(0)} ms`}
1325
+ />
1326
+ {result.fileCount > 0 && (
1327
+ <Row
1328
+ label="Mean file size"
1329
+ value={fmtMB(result.totalBytes / result.fileCount)}
1330
+ />
1331
+ )}
1332
+ {result.rows[0] && (
1333
+ <Row
1334
+ label="Largest file"
1335
+ value={`${fmtMB(result.rows[0].bytes)} — ${result.rows[0].filePath} [${result.rows[0].path}]`}
1336
+ />
1337
+ )}
1338
+ </>
1339
+ )}
1340
+ </tbody>
1341
+ </table>
1342
+
1343
+ {result.mode === 'parsed' && result.rawFiles && (
1344
+ <button
1345
+ type="button"
1346
+ onClick={openFullResponseViewer}
1347
+ style={{
1348
+ padding: '0.4rem 0.9rem',
1349
+ background: '#444',
1350
+ color: 'white',
1351
+ border: '1px solid #666',
1352
+ borderRadius: 4,
1353
+ cursor: 'pointer',
1354
+ marginBottom: '1rem',
1355
+ }}
1356
+ title="Pretty-print the full StoredFileGeneration[] response in a foldable JSON viewer."
1357
+ >
1358
+ View full JSON response
1359
+ </button>
1360
+ )}
1361
+
1362
+ <h2 style={{ marginBottom: '0.25rem' }}>
1363
+ Per-file breakdown (descending)
1364
+ </h2>
1365
+ {result.mode === 'streamed' ? (
1366
+ <p style={{ color: '#aaa' }}>
1367
+ Per-file breakdown is only available in parsed or streamed-headers
1368
+ mode. Raw streaming mode only measures total bytes.
1369
+ </p>
1370
+ ) : (
1371
+ <table
1372
+ style={{
1373
+ borderCollapse: 'collapse',
1374
+ width: '100%',
1375
+ maxWidth: 1200,
1376
+ }}
1377
+ >
1378
+ <thead>
1379
+ <tr style={{ background: '#2a2a2a' }}>
1380
+ <th style={thStyle}>#</th>
1381
+ <th style={thStyle}>DataSpace path</th>
1382
+ <th style={thStyle}>Type</th>
1383
+ <th style={thStyle}>File</th>
1384
+ <th style={{ ...thStyle, textAlign: 'right' }}>Size</th>
1385
+ <th style={{ ...thStyle, textAlign: 'right' }}>% of total</th>
1386
+ {result.mode === 'parsed' && (
1387
+ <th style={{ ...thStyle, textAlign: 'right' }}>View</th>
1388
+ )}
1389
+ </tr>
1390
+ </thead>
1391
+ <tbody>
1392
+ {result.rows.map((r, i) => {
1393
+ // In parsed mode, the per-row index in the descending
1394
+ // size table doesn't match the position in `rawFiles`.
1395
+ // Match by the inner `file.path` (the actual file path) —
1396
+ // the outer `path` field on `StoredFileGeneration` is the
1397
+ // element path and is the same for every row.
1398
+ const rawIndex =
1399
+ result.mode === 'parsed' && result.rawFiles
1400
+ ? result.rawFiles.findIndex(
1401
+ (f) =>
1402
+ (f as { file?: { path?: unknown } }).file?.path ===
1403
+ r.filePath,
1404
+ )
1405
+ : -1;
1406
+ return (
1407
+ <tr
1408
+ key={`${r.path}::${r.type}::${r.filePath}::${r.bytes}`}
1409
+ style={{
1410
+ background: i % 2 === 0 ? '#222' : '#1a1a1a',
1411
+ }}
1412
+ >
1413
+ <td style={tdStyle}>{i + 1}</td>
1414
+ <td style={tdStyle}>{r.path}</td>
1415
+ <td style={{ ...tdStyle, color: '#9cf' }}>{r.type}</td>
1416
+ <td style={{ ...tdStyle, color: '#fc9' }}>
1417
+ {r.filePath}
1418
+ </td>
1419
+ <td style={{ ...tdStyle, textAlign: 'right' }}>
1420
+ {fmtMB(r.bytes)}
1421
+ </td>
1422
+ <td style={{ ...tdStyle, textAlign: 'right' }}>
1423
+ {result.totalBytes
1424
+ ? `${((r.bytes / result.totalBytes) * 100).toFixed(1)}%`
1425
+ : '—'}
1426
+ </td>
1427
+ {result.mode === 'parsed' && (
1428
+ <td style={{ ...tdStyle, textAlign: 'right' }}>
1429
+ {rawIndex >= 0 ? (
1430
+ <button
1431
+ type="button"
1432
+ onClick={() => openFileViewer(rawIndex)}
1433
+ style={{
1434
+ padding: '0.15rem 0.5rem',
1435
+ background: '#345',
1436
+ color: 'white',
1437
+ border: '1px solid #567',
1438
+ borderRadius: 3,
1439
+ cursor: 'pointer',
1440
+ fontSize: '0.8rem',
1441
+ }}
1442
+ title="Open this file's content in the JSON viewer."
1443
+ >
1444
+ view
1445
+ </button>
1446
+ ) : (
1447
+ '—'
1448
+ )}
1449
+ </td>
1450
+ )}
1451
+ </tr>
1452
+ );
1453
+ })}
1454
+ </tbody>
1455
+ </table>
1456
+ )}
1457
+ </div>
1458
+ )}
1459
+
1460
+ {viewer && (
1461
+ <JsonViewerModal
1462
+ title={viewer.title}
1463
+ content={viewer.content}
1464
+ onClose={() => setViewer(undefined)}
1465
+ />
1466
+ )}
1467
+ </div>
1468
+ );
1469
+ });