@finos/legend-application-query 13.8.24 → 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.
@@ -0,0 +1,866 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * Copyright (c) 2026-present, Goldman Sachs
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
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
+ import { observer } from 'mobx-react-lite';
39
+ import { useState } from 'react';
40
+ import { StoreProjectData } from '@finos/legend-server-depot';
41
+ import { LogEvent } from '@finos/legend-shared';
42
+ import { CodeEditor } from '@finos/legend-lego/code-editor';
43
+ import { CODE_EDITOR_LANGUAGE } from '@finos/legend-code-editor';
44
+ import { LEGEND_QUERY_APP_EVENT } from '../__lib__/LegendQueryEvent.js';
45
+ import { useLegendQueryApplicationStore, useLegendQueryBaseStore, } from './LegendQueryFrameworkProvider.js';
46
+ import { DATA_SPACE_ELEMENT_CLASSIFIER_PATH } from '@finos/legend-extension-dsl-data-space/graph';
47
+ const MB = 1024 * 1024;
48
+ const fmtMB = (bytes) => `${(bytes / MB).toFixed(2)} MB`;
49
+ const QUERY_PROFILE_PATH = 'meta::pure::profiles::query';
50
+ const QUERY_PROFILE_TAG_DATA_SPACE = 'dataSpace';
51
+ /**
52
+ * Pretty-print arbitrary JSON-ish input. If the input is already a string
53
+ * that happens to be valid JSON (depot returns `content` as a string for
54
+ * files like `AnalyticsResult.json`), reparse and re-stringify so the
55
+ * viewer gets a properly indented, foldable tree instead of a long
56
+ * single-line escaped string.
57
+ */
58
+ const prettyJson = (value) => {
59
+ if (typeof value === 'string') {
60
+ const trimmed = value.trim();
61
+ if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
62
+ (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
63
+ try {
64
+ return JSON.stringify(JSON.parse(value), null, 2);
65
+ }
66
+ catch {
67
+ // fall through, return raw string
68
+ }
69
+ }
70
+ return value;
71
+ }
72
+ try {
73
+ return JSON.stringify(value, null, 2);
74
+ }
75
+ catch {
76
+ return String(value);
77
+ }
78
+ };
79
+ const inputStyle = {
80
+ padding: '0.4rem 0.6rem',
81
+ background: '#2a2a2a',
82
+ color: '#eee',
83
+ border: '1px solid #444',
84
+ borderRadius: 4,
85
+ fontFamily: 'monospace',
86
+ };
87
+ const thStyle = {
88
+ padding: '0.4rem 0.6rem',
89
+ textAlign: 'left',
90
+ borderBottom: '1px solid #444',
91
+ };
92
+ const tdStyle = {
93
+ padding: '0.35rem 0.6rem',
94
+ borderBottom: '1px solid #2a2a2a',
95
+ fontFamily: 'monospace',
96
+ fontSize: '0.85rem',
97
+ };
98
+ const Row = ({ label, value }) => (_jsxs("tr", { children: [_jsx("td", { style: { padding: '0.2rem 0.75rem 0.2rem 0', color: '#888' }, children: label }), _jsx("td", { style: { padding: '0.2rem 0', fontFamily: 'monospace' }, children: value })] }));
99
+ const JsonViewerModal = ({ title, content, onClose }) => (_jsx("div", { style: {
100
+ position: 'fixed',
101
+ top: 0,
102
+ left: 0,
103
+ right: 0,
104
+ bottom: 0,
105
+ background: 'rgba(0, 0, 0, 0.65)',
106
+ display: 'flex',
107
+ alignItems: 'center',
108
+ justifyContent: 'center',
109
+ zIndex: 1000,
110
+ }, onClick: onClose, children: _jsxs("div", { style: {
111
+ background: '#1e1e1e',
112
+ border: '1px solid #444',
113
+ borderRadius: 6,
114
+ width: 'min(1400px, 95vw)',
115
+ height: 'min(900px, 92vh)',
116
+ display: 'flex',
117
+ flexDirection: 'column',
118
+ boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5)',
119
+ }, onClick: (e) => e.stopPropagation(), children: [_jsxs("div", { style: {
120
+ padding: '0.6rem 1rem',
121
+ borderBottom: '1px solid #333',
122
+ display: 'flex',
123
+ alignItems: 'center',
124
+ justifyContent: 'space-between',
125
+ }, children: [_jsxs("div", { style: {
126
+ color: '#ddd',
127
+ fontFamily: 'monospace',
128
+ fontSize: '0.9rem',
129
+ overflow: 'hidden',
130
+ textOverflow: 'ellipsis',
131
+ whiteSpace: 'nowrap',
132
+ marginRight: '1rem',
133
+ }, title: title, children: [title, _jsxs("span", { style: { color: '#888', marginLeft: '1rem' }, children: ["(", content.length.toLocaleString(), " chars)"] })] }), _jsx("button", { type: "button", onClick: onClose, style: {
134
+ padding: '0.3rem 0.8rem',
135
+ background: '#444',
136
+ color: 'white',
137
+ border: '1px solid #666',
138
+ borderRadius: 4,
139
+ cursor: 'pointer',
140
+ }, children: "Close" })] }), _jsx("div", { style: { flex: 1, minHeight: 0 }, children: _jsx(CodeEditor, { inputValue: content, isReadOnly: true, language: CODE_EDITOR_LANGUAGE.JSON, hideMinimap: false, hideGutter: false }) })] }) }));
141
+ export const DataSpaceArtifactInspector = observer(() => {
142
+ const applicationStore = useLegendQueryApplicationStore();
143
+ const depotServerClient = useLegendQueryBaseStore().depotServerClient;
144
+ const [groupId, setGroupId] = useState('');
145
+ const [artifactId, setArtifactId] = useState('');
146
+ const [versionId, setVersionId] = useState('');
147
+ const [elementPath, setElementPath] = useState('');
148
+ const [savedQueryId, setSavedQueryId] = useState('');
149
+ const [loadingQuery, setLoadingQuery] = useState(false);
150
+ const [queryLoadError, setQueryLoadError] = useState(undefined);
151
+ const [queryLoadInfo, setQueryLoadInfo] = useState(undefined);
152
+ const [dataSpaceOptions, setDataSpaceOptions] = useState([]);
153
+ const [loadingDataSpaces, setLoadingDataSpaces] = useState(false);
154
+ const [dataSpacesError, setDataSpacesError] = useState(undefined);
155
+ const [selectedDataSpaceKey, setSelectedDataSpaceKey] = useState('');
156
+ const [dataSpaceFilter, setDataSpaceFilter] = useState('');
157
+ const [running, setRunning] = useState(false);
158
+ const [error, setError] = useState(undefined);
159
+ const [result, setResult] = useState(undefined);
160
+ const [viewer, setViewer] = useState(undefined);
161
+ const elementPathMissing = !elementPath.trim();
162
+ const canRun = !running && !!groupId && !!artifactId && !!versionId && !elementPathMissing;
163
+ /**
164
+ * Load groupId / artifactId / versionId / elementPath from a saved
165
+ * query. Hits the engine query server `/pure/v1/query/{id}` endpoint
166
+ * directly (no graphManager dependency required for a diagnostic page).
167
+ */
168
+ const loadFromSavedQuery = async () => {
169
+ const id = savedQueryId.trim();
170
+ if (!id) {
171
+ return;
172
+ }
173
+ setLoadingQuery(true);
174
+ setQueryLoadError(undefined);
175
+ setQueryLoadInfo(undefined);
176
+ try {
177
+ const config = applicationStore.config;
178
+ const base = config.engineQueryServerUrl ?? config.engineServerUrl;
179
+ if (!base) {
180
+ throw new Error('Engine (query) server URL is not configured');
181
+ }
182
+ const url = `${base}/pure/v1/query/${encodeURIComponent(id)}`;
183
+ const response = await fetch(url, {
184
+ method: 'GET',
185
+ credentials: 'include',
186
+ headers: { Accept: 'application/json' },
187
+ });
188
+ if (!response.ok) {
189
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
190
+ }
191
+ const body = (await response.json());
192
+ const nextGroupId = body.groupId ?? '';
193
+ const nextArtifactId = body.artifactId ?? '';
194
+ const nextVersionId = body.versionId ?? '';
195
+ // Prefer the executionContext.dataSpacePath (set when the saved
196
+ // query targets a dataspace exec context); fall back to the legacy
197
+ // `dataSpace` tagged value.
198
+ const fromExecutionContext = body.executionContext?.dataSpacePath?.trim() ?? '';
199
+ const fromTaggedValues = (body.taggedValues ?? []).find((t) => t.profile === QUERY_PROFILE_PATH &&
200
+ t.tag === QUERY_PROFILE_TAG_DATA_SPACE &&
201
+ typeof t.value === 'string' &&
202
+ t.value.length > 0)?.value;
203
+ const nextElementPath = fromExecutionContext || (fromTaggedValues ?? '');
204
+ if (!nextGroupId || !nextArtifactId || !nextVersionId) {
205
+ throw new Error('Saved query is missing groupId / artifactId / versionId');
206
+ }
207
+ if (!nextElementPath) {
208
+ throw new Error('Saved query has no dataspace tagged value or dataspace execution context — cannot infer elementPath. Fill it in manually.');
209
+ }
210
+ setGroupId(nextGroupId);
211
+ setArtifactId(nextArtifactId);
212
+ setVersionId(nextVersionId);
213
+ setElementPath(nextElementPath);
214
+ setQueryLoadInfo(`Loaded ${nextGroupId}:${nextArtifactId}:${nextVersionId} → ${nextElementPath}`);
215
+ }
216
+ catch (e) {
217
+ setQueryLoadError(e instanceof Error ? e.message : String(e));
218
+ applicationStore.logService.error(LogEvent.create(LEGEND_QUERY_APP_EVENT.GENERIC_FAILURE), e);
219
+ }
220
+ finally {
221
+ setLoadingQuery(false);
222
+ }
223
+ };
224
+ /**
225
+ * Load every DataSpace registered in depot (latest version of each)
226
+ * into a dropdown. Uses the lightweight `summary` classifier endpoint
227
+ * so we only get `{groupId, artifactId, versionId, path}` rows, not
228
+ * full entity content — safe to call with thousands of entries.
229
+ */
230
+ const loadAllDataSpaces = async () => {
231
+ setLoadingDataSpaces(true);
232
+ setDataSpacesError(undefined);
233
+ try {
234
+ const base = depotServerClient.baseUrl;
235
+ if (!base) {
236
+ throw new Error('Depot server baseUrl is not configured');
237
+ }
238
+ const url = `${base}/classifiers/${encodeURIComponent(DATA_SPACE_ELEMENT_CLASSIFIER_PATH)}?summary=true&latest=true`;
239
+ const response = await fetch(url, {
240
+ method: 'GET',
241
+ credentials: 'include',
242
+ headers: { Accept: 'application/json' },
243
+ });
244
+ if (!response.ok) {
245
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
246
+ }
247
+ const body = (await response.json());
248
+ const options = body
249
+ .map((e) => {
250
+ const { groupId: g, artifactId: a, versionId: v, path: p } = e;
251
+ if (!g || !a || !v || !p) {
252
+ return undefined;
253
+ }
254
+ return {
255
+ groupId: g,
256
+ artifactId: a,
257
+ versionId: v,
258
+ path: p,
259
+ label: `${p} \u2014 ${g}:${a}:${v}`,
260
+ };
261
+ })
262
+ .filter((o) => o !== undefined)
263
+ .sort((a, b) => a.label.localeCompare(b.label));
264
+ setDataSpaceOptions(options);
265
+ }
266
+ catch (e) {
267
+ setDataSpacesError(e instanceof Error ? e.message : String(e));
268
+ applicationStore.logService.error(LogEvent.create(LEGEND_QUERY_APP_EVENT.GENERIC_FAILURE), e);
269
+ }
270
+ finally {
271
+ setLoadingDataSpaces(false);
272
+ }
273
+ };
274
+ const applyDataSpaceOption = (key) => {
275
+ setSelectedDataSpaceKey(key);
276
+ if (!key) {
277
+ return;
278
+ }
279
+ const option = dataSpaceOptions.find((o) => `${o.groupId}:${o.artifactId}:${o.versionId}:${o.path}` === key);
280
+ if (!option) {
281
+ return;
282
+ }
283
+ setGroupId(option.groupId);
284
+ setArtifactId(option.artifactId);
285
+ setVersionId(option.versionId);
286
+ setElementPath(option.path);
287
+ };
288
+ const filteredDataSpaceOptions = (() => {
289
+ const f = dataSpaceFilter.trim().toLowerCase();
290
+ if (!f) {
291
+ return dataSpaceOptions;
292
+ }
293
+ return dataSpaceOptions.filter((o) => o.label.toLowerCase().includes(f));
294
+ })();
295
+ const run = async () => {
296
+ setRunning(true);
297
+ setError(undefined);
298
+ setResult(undefined);
299
+ try {
300
+ const project = StoreProjectData.serialization.fromJson(await depotServerClient.getProject(groupId.trim(), artifactId.trim()));
301
+ const fetchStart = performance.now();
302
+ const files = await depotServerClient.getGenerationFilesByType(project, versionId.trim(), 'dataSpace-analytics', elementPath.trim());
303
+ const fetchMs = performance.now() - fetchStart;
304
+ const sizingStart = performance.now();
305
+ const rows = files
306
+ .map((f) => {
307
+ const elemPath = f.path;
308
+ const type = f.type;
309
+ const file = f
310
+ .file;
311
+ const filePath = file?.path;
312
+ const content = file?.content;
313
+ let bytes = 0;
314
+ try {
315
+ bytes =
316
+ typeof content === 'string'
317
+ ? content.length
318
+ : JSON.stringify(content).length;
319
+ }
320
+ catch {
321
+ bytes = -1;
322
+ }
323
+ return {
324
+ path: typeof elemPath === 'string' ? elemPath : elementPath.trim(),
325
+ type: typeof type === 'string' ? type : '<unknown>',
326
+ filePath: typeof filePath === 'string' ? filePath : '<unknown>',
327
+ bytes,
328
+ };
329
+ })
330
+ .sort((a, b) => b.bytes - a.bytes);
331
+ const sizingMs = performance.now() - sizingStart;
332
+ const totalBytes = rows.reduce((sum, r) => sum + Math.max(0, r.bytes), 0);
333
+ setResult({
334
+ mode: 'parsed',
335
+ totalBytes,
336
+ fileCount: rows.length,
337
+ rows,
338
+ fetchMs,
339
+ sizingMs,
340
+ rawFiles: files,
341
+ });
342
+ }
343
+ catch (e) {
344
+ setError(e instanceof Error ? e.message : String(e));
345
+ applicationStore.logService.error(LogEvent.create(LEGEND_QUERY_APP_EVENT.GENERIC_FAILURE), e);
346
+ }
347
+ finally {
348
+ setRunning(false);
349
+ }
350
+ };
351
+ /**
352
+ * Streaming variant that NEVER parses the JSON. Uses fetch() directly,
353
+ * reads the response as a byte stream, and counts bytes as they arrive.
354
+ * Memory footprint stays flat regardless of payload size, so this works
355
+ * even on the OOM-causing payload where the parsed variant would crash.
356
+ */
357
+ const runStreaming = async () => {
358
+ setRunning(true);
359
+ setError(undefined);
360
+ setResult(undefined);
361
+ try {
362
+ const base = depotServerClient.baseUrl;
363
+ if (!base) {
364
+ throw new Error('Depot server baseUrl is not configured');
365
+ }
366
+ const url = `${base}/generations/${encodeURIComponent(groupId.trim())}` +
367
+ `/${encodeURIComponent(artifactId.trim())}` +
368
+ `/${encodeURIComponent(versionId.trim())}` +
369
+ `/types/dataSpace-analytics` +
370
+ `?elementPath=${encodeURIComponent(elementPath.trim())}`;
371
+ const fetchStart = performance.now();
372
+ // `credentials: 'include'` mirrors what AbstractServerClient does so
373
+ // we inherit the user's session cookies.
374
+ const response = await fetch(url, {
375
+ method: 'GET',
376
+ credentials: 'include',
377
+ headers: { Accept: 'application/json' },
378
+ });
379
+ if (!response.ok) {
380
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
381
+ }
382
+ const contentEncoding = response.headers.get('content-encoding') ?? '<none>';
383
+ const compressedHeader = response.headers.get('content-length');
384
+ const compressedBytes = compressedHeader
385
+ ? Number(compressedHeader)
386
+ : undefined;
387
+ const reader = response.body?.getReader();
388
+ if (!reader) {
389
+ throw new Error('Response body is not readable as a stream');
390
+ }
391
+ let streamedBodyBytes = 0;
392
+ let chunk = await reader.read();
393
+ while (!chunk.done) {
394
+ streamedBodyBytes += chunk.value.byteLength;
395
+ chunk = await reader.read();
396
+ }
397
+ const fetchMs = performance.now() - fetchStart;
398
+ setResult({
399
+ mode: 'streamed',
400
+ totalBytes: streamedBodyBytes,
401
+ fileCount: 0,
402
+ rows: [],
403
+ fetchMs,
404
+ sizingMs: 0,
405
+ streamedBodyBytes,
406
+ streamedCompressedBytes: compressedBytes,
407
+ streamedContentEncoding: contentEncoding,
408
+ streamedUrl: url,
409
+ });
410
+ }
411
+ catch (e) {
412
+ setError(e instanceof Error ? e.message : String(e));
413
+ applicationStore.logService.error(LogEvent.create(LEGEND_QUERY_APP_EVENT.GENERIC_FAILURE), e);
414
+ }
415
+ finally {
416
+ setRunning(false);
417
+ }
418
+ };
419
+ /**
420
+ * Streaming variant that walks the response with a tiny JSON state
421
+ * machine, recognises top-level array element boundaries, and for each
422
+ * element keeps only:
423
+ * - its total byte length (from where it started to where it ended)
424
+ * - its first 64 KB of text (enough to regex out `path`)
425
+ * Content (the large field) is read past and dropped on the fly, so peak
426
+ * heap stays roughly element-bounded, not response-bounded. Works on
427
+ * OOM-causing payloads because we never materialize the full body.
428
+ */
429
+ const runStreamingWithHeaders = async () => {
430
+ setRunning(true);
431
+ setError(undefined);
432
+ setResult(undefined);
433
+ try {
434
+ const base = depotServerClient.baseUrl;
435
+ if (!base) {
436
+ throw new Error('Depot server baseUrl is not configured');
437
+ }
438
+ const url = `${base}/generations/${encodeURIComponent(groupId.trim())}` +
439
+ `/${encodeURIComponent(artifactId.trim())}` +
440
+ `/${encodeURIComponent(versionId.trim())}` +
441
+ `/types/dataSpace-analytics` +
442
+ `?elementPath=${encodeURIComponent(elementPath.trim())}`;
443
+ const fetchStart = performance.now();
444
+ const response = await fetch(url, {
445
+ method: 'GET',
446
+ credentials: 'include',
447
+ headers: { Accept: 'application/json' },
448
+ });
449
+ if (!response.ok) {
450
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
451
+ }
452
+ const contentEncoding = response.headers.get('content-encoding') ?? '<none>';
453
+ const compressedHeader = response.headers.get('content-length');
454
+ const compressedBytes = compressedHeader
455
+ ? Number(compressedHeader)
456
+ : undefined;
457
+ const reader = response.body?.getReader();
458
+ if (!reader) {
459
+ throw new Error('Response body is not readable as a stream');
460
+ }
461
+ // Cap element header buffer at 64 KB. `StoredFileGeneration.path`
462
+ // and `type` reliably appear before the (potentially huge)
463
+ // `file.content` payload, so 64 KB is overkill but safe.
464
+ const MAX_HEADER_BYTES = 64 * 1024;
465
+ const decoder = new TextDecoder('utf-8');
466
+ let streamedBodyBytes = 0;
467
+ // JSON state machine
468
+ let depth = 0; // brace/bracket depth across all containers
469
+ let inString = false;
470
+ let escape = false;
471
+ let inElement = false; // true while inside a top-level array element
472
+ let elementByteStart = 0;
473
+ let elementHeader = ''; // capped to MAX_HEADER_BYTES per element
474
+ const rows = [];
475
+ const onElementComplete = (startByte, endByte, headerText) => {
476
+ const pathMatch = /"path"\s*:\s*"(?<value>(?:[^"\\]|\\.)*)"/.exec(headerText);
477
+ const filePath = pathMatch?.groups?.value ?? '<unknown>';
478
+ rows.push({
479
+ path: elementPath.trim(),
480
+ type: '<n/a>',
481
+ filePath,
482
+ bytes: endByte - startByte,
483
+ });
484
+ };
485
+ let chunk = await reader.read();
486
+ while (!chunk.done) {
487
+ const { value } = chunk;
488
+ const chunkBytes = value.byteLength;
489
+ const chunkText = decoder.decode(value, { stream: true });
490
+ const chunkStartByte = streamedBodyBytes;
491
+ for (let i = 0; i < chunkText.length; i++) {
492
+ const ch = chunkText[i];
493
+ if (inString) {
494
+ if (escape) {
495
+ escape = false;
496
+ }
497
+ else if (ch === '\\') {
498
+ escape = true;
499
+ }
500
+ else if (ch === '"') {
501
+ inString = false;
502
+ }
503
+ }
504
+ else if (ch === '"') {
505
+ inString = true;
506
+ }
507
+ else if (ch === '{' || ch === '[') {
508
+ if (depth === 1 && ch === '{') {
509
+ // Entering a top-level array element
510
+ inElement = true;
511
+ elementByteStart = chunkStartByte + i; // approximate (UTF-8/16 mismatch is OK for sizing)
512
+ elementHeader = '';
513
+ }
514
+ depth++;
515
+ }
516
+ else if (ch === '}' || ch === ']') {
517
+ depth--;
518
+ if (depth === 1 && inElement && ch === '}') {
519
+ const elementByteEnd = chunkStartByte + i + 1;
520
+ onElementComplete(elementByteStart, elementByteEnd, elementHeader);
521
+ inElement = false;
522
+ elementHeader = '';
523
+ }
524
+ }
525
+ if (inElement && elementHeader.length < MAX_HEADER_BYTES) {
526
+ elementHeader += ch;
527
+ }
528
+ }
529
+ streamedBodyBytes += chunkBytes;
530
+ chunk = await reader.read();
531
+ }
532
+ const fetchMs = performance.now() - fetchStart;
533
+ rows.sort((a, b) => b.bytes - a.bytes);
534
+ const totalBytes = rows.reduce((sum, r) => sum + Math.max(0, r.bytes), 0);
535
+ setResult({
536
+ mode: 'streamed-headers',
537
+ totalBytes,
538
+ fileCount: rows.length,
539
+ rows,
540
+ fetchMs,
541
+ sizingMs: 0,
542
+ streamedBodyBytes,
543
+ streamedCompressedBytes: compressedBytes,
544
+ streamedContentEncoding: contentEncoding,
545
+ streamedUrl: url,
546
+ });
547
+ }
548
+ catch (e) {
549
+ setError(e instanceof Error ? e.message : String(e));
550
+ applicationStore.logService.error(LogEvent.create(LEGEND_QUERY_APP_EVENT.GENERIC_FAILURE), e);
551
+ }
552
+ finally {
553
+ setRunning(false);
554
+ }
555
+ };
556
+ /**
557
+ * Stream the full response straight to a file on disk. Uses the File
558
+ * System Access API (`showSaveFilePicker`) so chunks are flushed to the
559
+ * filesystem as they arrive and the entire body never sits in memory at
560
+ * once — safe for the OOM-causing payload.
561
+ *
562
+ * Falls back to building a Blob and triggering an <a download> click for
563
+ * browsers without the File System Access API. Note: the fallback path
564
+ * DOES hold the full response in memory and will OOM on huge payloads;
565
+ * a warning is logged in that case.
566
+ */
567
+ const runStreamDownload = async () => {
568
+ setRunning(true);
569
+ setError(undefined);
570
+ setResult(undefined);
571
+ try {
572
+ const base = depotServerClient.baseUrl;
573
+ if (!base) {
574
+ throw new Error('Depot server baseUrl is not configured');
575
+ }
576
+ const url = `${base}/generations/${encodeURIComponent(groupId.trim())}` +
577
+ `/${encodeURIComponent(artifactId.trim())}` +
578
+ `/${encodeURIComponent(versionId.trim())}` +
579
+ `/types/dataSpace-analytics` +
580
+ `?elementPath=${encodeURIComponent(elementPath.trim())}`;
581
+ const suggestedName = `dataSpace-analytics_${groupId.trim()}_${artifactId.trim()}_${versionId.trim()}_${elementPath.trim().replace(/::/g, '_')}.json`;
582
+ // File System Access API is Chromium-only as of 2026. Probe before use.
583
+ const showSaveFilePicker = window.showSaveFilePicker;
584
+ const fetchStart = performance.now();
585
+ const response = await fetch(url, {
586
+ method: 'GET',
587
+ credentials: 'include',
588
+ headers: { Accept: 'application/json' },
589
+ });
590
+ if (!response.ok) {
591
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
592
+ }
593
+ const contentEncoding = response.headers.get('content-encoding') ?? '<none>';
594
+ const compressedHeader = response.headers.get('content-length');
595
+ const compressedBytes = compressedHeader
596
+ ? Number(compressedHeader)
597
+ : undefined;
598
+ let streamedBodyBytes = 0;
599
+ if (showSaveFilePicker) {
600
+ // Streaming path — bytes flushed to disk per chunk.
601
+ const handle = await showSaveFilePicker({
602
+ suggestedName,
603
+ types: [
604
+ {
605
+ description: 'JSON file',
606
+ accept: { 'application/json': ['.json'] },
607
+ },
608
+ ],
609
+ });
610
+ const writable = await handle.createWritable();
611
+ const reader = response.body?.getReader();
612
+ if (!reader) {
613
+ await writable.close();
614
+ throw new Error('Response body is not readable as a stream');
615
+ }
616
+ let chunk = await reader.read();
617
+ while (!chunk.done) {
618
+ await writable.write(chunk.value);
619
+ streamedBodyBytes += chunk.value.byteLength;
620
+ chunk = await reader.read();
621
+ }
622
+ await writable.close();
623
+ }
624
+ else {
625
+ // Fallback: blob + anchor click. WARNING: holds full body in memory.
626
+ // eslint-disable-next-line no-console
627
+ console.warn('[DataSpaceArtifactInspector] showSaveFilePicker not available; ' +
628
+ 'falling back to blob download. This holds the full body in memory.');
629
+ const blob = await response.blob();
630
+ streamedBodyBytes = blob.size;
631
+ const objectUrl = URL.createObjectURL(blob);
632
+ const a = document.createElement('a');
633
+ a.href = objectUrl;
634
+ a.download = suggestedName;
635
+ document.body.appendChild(a);
636
+ a.click();
637
+ document.body.removeChild(a);
638
+ URL.revokeObjectURL(objectUrl);
639
+ }
640
+ const fetchMs = performance.now() - fetchStart;
641
+ setResult({
642
+ mode: 'streamed',
643
+ totalBytes: streamedBodyBytes,
644
+ fileCount: 0,
645
+ rows: [],
646
+ fetchMs,
647
+ sizingMs: 0,
648
+ streamedBodyBytes,
649
+ streamedCompressedBytes: compressedBytes,
650
+ streamedContentEncoding: contentEncoding,
651
+ streamedUrl: url,
652
+ });
653
+ }
654
+ catch (e) {
655
+ // AbortError from the user cancelling the save dialog is not a real
656
+ // error — silently no-op.
657
+ if (e instanceof DOMException && e.name === 'AbortError') {
658
+ return;
659
+ }
660
+ setError(e instanceof Error ? e.message : String(e));
661
+ applicationStore.logService.error(LogEvent.create(LEGEND_QUERY_APP_EVENT.GENERIC_FAILURE), e);
662
+ }
663
+ finally {
664
+ setRunning(false);
665
+ }
666
+ };
667
+ const openFullResponseViewer = () => {
668
+ if (!result?.rawFiles) {
669
+ return;
670
+ }
671
+ setViewer({
672
+ title: 'Full parsed response (StoredFileGeneration[])',
673
+ content: prettyJson(result.rawFiles),
674
+ });
675
+ };
676
+ const openFileViewer = (index) => {
677
+ const entry = result?.rawFiles?.[index];
678
+ if (!entry) {
679
+ return;
680
+ }
681
+ const filePath = entry.file?.path ?? entry.path ?? '<unknown>';
682
+ setViewer({
683
+ title: `File: ${filePath}`,
684
+ content: prettyJson(entry.file?.content),
685
+ });
686
+ };
687
+ return (_jsxs("div", { style: {
688
+ padding: '1.5rem',
689
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
690
+ color: '#ddd',
691
+ background: '#1e1e1e',
692
+ minHeight: '100vh',
693
+ }, children: [_jsx("h1", { style: { marginTop: 0 }, children: "DataSpace Artifact Inspector" }), _jsx("p", { style: { color: '#aaa', maxWidth: 760 }, children: "Diagnostic tool: fetches the per-element depot analytics endpoint for a single DataSpace and reports total uncompressed payload size plus a per-file breakdown. Use this to identify oversized partition files that may cause Chrome tab OOM." }), _jsx("h2", { style: { marginTop: '1.5rem', marginBottom: '0.25rem' }, children: "Load inputs from a saved query (optional)" }), _jsxs("p", { style: { color: '#888', maxWidth: 760, marginTop: 0 }, children: ["Paste a Legend saved query id; we'll fetch", ' ', _jsx("code", { children: "/pure/v1/query/<id>" }), " and pre-fill groupId, artifactId, versionId, and the dataspace path from the query's tagged values / execution context."] }), _jsxs("div", { style: {
694
+ display: 'grid',
695
+ gridTemplateColumns: '160px 1fr auto',
696
+ gap: '0.5rem 0.75rem',
697
+ maxWidth: 760,
698
+ }, children: [_jsx("label", { htmlFor: "ds-savedQueryId", children: "saved query id" }), _jsx("input", { id: "ds-savedQueryId", value: savedQueryId, onChange: (e) => setSavedQueryId(e.target.value), placeholder: "e.g. 65f1c0a8b1c0d20012345678", style: inputStyle }), _jsx("button", { type: "button", onClick: () => {
699
+ loadFromSavedQuery().catch(() => undefined);
700
+ }, disabled: loadingQuery || !savedQueryId.trim(), style: {
701
+ padding: '0.4rem 0.75rem',
702
+ background: loadingQuery ? '#555' : '#444',
703
+ color: 'white',
704
+ border: '1px solid #666',
705
+ borderRadius: 4,
706
+ cursor: loadingQuery ? 'wait' : 'pointer',
707
+ whiteSpace: 'nowrap',
708
+ }, children: loadingQuery ? 'Loading…' : 'Load' })] }), queryLoadError && (_jsx("div", { style: {
709
+ marginTop: '0.5rem',
710
+ padding: '0.5rem 0.75rem',
711
+ background: '#5a1f1f',
712
+ border: '1px solid #a33',
713
+ borderRadius: 4,
714
+ maxWidth: 760,
715
+ }, children: queryLoadError })), queryLoadInfo && (_jsx("div", { style: {
716
+ marginTop: '0.5rem',
717
+ padding: '0.5rem 0.75rem',
718
+ background: '#1f3a1f',
719
+ border: '1px solid #3a6',
720
+ borderRadius: 4,
721
+ maxWidth: 760,
722
+ fontFamily: 'monospace',
723
+ fontSize: '0.85rem',
724
+ }, children: queryLoadInfo })), _jsx("h2", { style: { marginTop: '1.5rem', marginBottom: '0.25rem' }, children: "Pick from all DataSpaces (optional)" }), _jsxs("p", { style: { color: '#888', maxWidth: 760, marginTop: 0 }, children: ["Load every DataSpace registered in depot (latest version of each) via", ' ', _jsx("code", { children: "/classifiers/meta::pure::metamodel::dataSpace::DataSpace?summary=true&latest=true" }), ", then pick one to pre-fill all four inputs."] }), _jsxs("div", { style: {
725
+ display: 'flex',
726
+ alignItems: 'center',
727
+ gap: '0.5rem',
728
+ maxWidth: 1000,
729
+ marginBottom: '0.5rem',
730
+ }, children: [_jsx("button", { type: "button", onClick: () => {
731
+ loadAllDataSpaces().catch(() => undefined);
732
+ }, disabled: loadingDataSpaces, style: {
733
+ padding: '0.4rem 0.75rem',
734
+ background: loadingDataSpaces ? '#555' : '#444',
735
+ color: 'white',
736
+ border: '1px solid #666',
737
+ borderRadius: 4,
738
+ cursor: loadingDataSpaces ? 'wait' : 'pointer',
739
+ whiteSpace: 'nowrap',
740
+ }, children: loadingDataSpaces
741
+ ? 'Loading…'
742
+ : dataSpaceOptions.length > 0
743
+ ? `Reload (${dataSpaceOptions.length} loaded)`
744
+ : 'Load all dataspaces' }), _jsx("input", { type: "text", value: dataSpaceFilter, onChange: (e) => setDataSpaceFilter(e.target.value), placeholder: "filter\u2026", disabled: dataSpaceOptions.length === 0, style: { ...inputStyle, flex: '0 0 220px' } }), _jsxs("select", { value: selectedDataSpaceKey, onChange: (e) => applyDataSpaceOption(e.target.value), disabled: dataSpaceOptions.length === 0, style: {
745
+ ...inputStyle,
746
+ flex: 1,
747
+ minWidth: 0,
748
+ }, children: [_jsx("option", { value: "", children: dataSpaceOptions.length === 0
749
+ ? '(load dataspaces first)'
750
+ : `Select one of ${filteredDataSpaceOptions.length} dataspace${filteredDataSpaceOptions.length === 1 ? '' : 's'}…` }), filteredDataSpaceOptions.map((o) => {
751
+ const key = `${o.groupId}:${o.artifactId}:${o.versionId}:${o.path}`;
752
+ return (_jsx("option", { value: key, children: o.label }, key));
753
+ })] })] }), dataSpacesError && (_jsx("div", { style: {
754
+ marginTop: '0.5rem',
755
+ padding: '0.5rem 0.75rem',
756
+ background: '#5a1f1f',
757
+ border: '1px solid #a33',
758
+ borderRadius: 4,
759
+ maxWidth: 1000,
760
+ }, children: dataSpacesError })), _jsx("h2", { style: { marginTop: '1.5rem', marginBottom: '0.25rem' }, children: "Inputs" }), _jsxs("div", { style: {
761
+ display: 'grid',
762
+ gridTemplateColumns: '160px 1fr',
763
+ gap: '0.5rem 0.75rem',
764
+ maxWidth: 760,
765
+ }, children: [_jsx("label", { htmlFor: "ds-groupId", children: "groupId" }), _jsx("input", { id: "ds-groupId", value: groupId, onChange: (e) => setGroupId(e.target.value), placeholder: "com.groupId", style: inputStyle }), _jsx("label", { htmlFor: "ds-artifactId", children: "artifactId" }), _jsx("input", { id: "ds-artifactId", value: artifactId, onChange: (e) => setArtifactId(e.target.value), placeholder: "my-artifactId", style: inputStyle }), _jsx("label", { htmlFor: "ds-versionId", children: "versionId" }), _jsx("input", { id: "ds-versionId", value: versionId, onChange: (e) => setVersionId(e.target.value), placeholder: "1.0.0 or master-SNAPSHOT", style: inputStyle }), _jsxs("label", { htmlFor: "ds-elementPath", children: ["elementPath ", _jsx("span", { style: { color: '#e88' }, children: "*" })] }), _jsx("input", { id: "ds-elementPath", value: elementPath, onChange: (e) => setElementPath(e.target.value), placeholder: "com::path::to::MyDataSpace (required)", style: {
766
+ ...inputStyle,
767
+ borderColor: elementPathMissing ? '#a33' : '#444',
768
+ }, "aria-required": "true", "aria-invalid": elementPathMissing })] }), elementPathMissing && (_jsx("div", { style: {
769
+ marginTop: '0.5rem',
770
+ color: '#e88',
771
+ fontSize: '0.85rem',
772
+ }, children: "elementPath is required \u2014 this endpoint fetches files for a single DataSpace." })), _jsx("button", { type: "button", onClick: () => {
773
+ run().catch(() => undefined);
774
+ }, disabled: !canRun, style: {
775
+ marginTop: '1rem',
776
+ padding: '0.5rem 1rem',
777
+ background: running ? '#555' : '#0066cc',
778
+ color: 'white',
779
+ border: 'none',
780
+ borderRadius: 4,
781
+ cursor: running ? 'wait' : 'pointer',
782
+ }, children: running ? 'Fetching…' : 'Fetch and inspect (parsed)' }), _jsx("button", { type: "button", onClick: () => {
783
+ runStreaming().catch(() => undefined);
784
+ }, disabled: !canRun, title: "Streams the response and counts bytes without parsing. Survives OOM-causing payloads.", style: {
785
+ marginTop: '1rem',
786
+ marginLeft: '0.5rem',
787
+ padding: '0.5rem 1rem',
788
+ background: running ? '#555' : '#444',
789
+ color: 'white',
790
+ border: '1px solid #666',
791
+ borderRadius: 4,
792
+ cursor: running ? 'wait' : 'pointer',
793
+ }, children: running ? 'Streaming…' : 'Measure raw size only (streaming)' }), _jsx("button", { type: "button", onClick: () => {
794
+ runStreamingWithHeaders().catch(() => undefined);
795
+ }, disabled: !canRun, 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.", style: {
796
+ marginTop: '1rem',
797
+ marginLeft: '0.5rem',
798
+ padding: '0.5rem 1rem',
799
+ background: running ? '#555' : '#2a6',
800
+ color: 'white',
801
+ border: '1px solid #2a6',
802
+ borderRadius: 4,
803
+ cursor: running ? 'wait' : 'pointer',
804
+ }, children: running ? 'Streaming…' : 'Stream + per-file breakdown (no content)' }), _jsx("button", { type: "button", onClick: () => {
805
+ runStreamDownload().catch(() => undefined);
806
+ }, disabled: !canRun, 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.", style: {
807
+ marginTop: '1rem',
808
+ marginLeft: '0.5rem',
809
+ padding: '0.5rem 1rem',
810
+ background: running ? '#555' : '#963',
811
+ color: 'white',
812
+ border: '1px solid #963',
813
+ borderRadius: 4,
814
+ cursor: running ? 'wait' : 'pointer',
815
+ }, children: running ? 'Downloading…' : 'Download full response to file' }), error && (_jsx("div", { style: {
816
+ marginTop: '1rem',
817
+ padding: '0.75rem',
818
+ background: '#5a1f1f',
819
+ border: '1px solid #a33',
820
+ borderRadius: 4,
821
+ whiteSpace: 'pre-wrap',
822
+ }, children: error })), result && (_jsxs("div", { style: { marginTop: '1.5rem' }, children: [_jsxs("h2", { style: { marginBottom: '0.25rem' }, children: ["Summary (", result.mode === 'streamed'
823
+ ? 'streaming mode — size only'
824
+ : result.mode === 'streamed-headers'
825
+ ? 'streaming mode — per-file headers'
826
+ : 'parsed mode', ")"] }), _jsx("table", { style: { borderCollapse: 'collapse', marginBottom: '1rem' }, children: _jsx("tbody", { children: result.mode === 'streamed' ||
827
+ result.mode === 'streamed-headers' ? (_jsxs(_Fragment, { children: [_jsx(Row, { label: "URL", value: result.streamedUrl ?? '<unknown>' }), _jsx(Row, { label: "Content-Encoding", value: result.streamedContentEncoding ?? '<none>' }), _jsx(Row, { label: "Content-Length header", value: result.streamedCompressedBytes !== undefined
828
+ ? `${result.streamedCompressedBytes} bytes (${fmtMB(result.streamedCompressedBytes)})`
829
+ : '<not set>' }), _jsx(Row, { label: "Streamed body (decoded by browser)", value: `${result.streamedBodyBytes ?? 0} bytes (${fmtMB(result.streamedBodyBytes ?? 0)})` }), _jsx(Row, { label: "Fetch + drain time", value: `${result.fetchMs.toFixed(0)} ms` }), result.mode === 'streamed-headers' && (_jsxs(_Fragment, { children: [_jsx(Row, { label: "Element count", value: String(result.fileCount) }), result.fileCount > 0 && (_jsx(Row, { label: "Mean element size", value: fmtMB(result.totalBytes / result.fileCount) })), result.rows[0] && (_jsx(Row, { label: "Largest element", value: `${fmtMB(result.rows[0].bytes)} — ${result.rows[0].filePath} (${result.rows[0].type}) [${result.rows[0].path}]` }))] }))] })) : (_jsxs(_Fragment, { children: [_jsx(Row, { label: "File count", value: String(result.fileCount) }), _jsx(Row, { label: "Total uncompressed", value: fmtMB(result.totalBytes) }), _jsx(Row, { label: "Total uncompressed (bytes)", value: String(result.totalBytes) }), _jsx(Row, { label: "Depot fetch time", value: `${result.fetchMs.toFixed(0)} ms` }), _jsx(Row, { label: "Sizing walk time", value: `${result.sizingMs.toFixed(0)} ms` }), result.fileCount > 0 && (_jsx(Row, { label: "Mean file size", value: fmtMB(result.totalBytes / result.fileCount) })), result.rows[0] && (_jsx(Row, { label: "Largest file", value: `${fmtMB(result.rows[0].bytes)} — ${result.rows[0].filePath} [${result.rows[0].path}]` }))] })) }) }), result.mode === 'parsed' && result.rawFiles && (_jsx("button", { type: "button", onClick: openFullResponseViewer, style: {
830
+ padding: '0.4rem 0.9rem',
831
+ background: '#444',
832
+ color: 'white',
833
+ border: '1px solid #666',
834
+ borderRadius: 4,
835
+ cursor: 'pointer',
836
+ marginBottom: '1rem',
837
+ }, title: "Pretty-print the full StoredFileGeneration[] response in a foldable JSON viewer.", children: "View full JSON response" })), _jsx("h2", { style: { marginBottom: '0.25rem' }, children: "Per-file breakdown (descending)" }), result.mode === 'streamed' ? (_jsx("p", { style: { color: '#aaa' }, children: "Per-file breakdown is only available in parsed or streamed-headers mode. Raw streaming mode only measures total bytes." })) : (_jsxs("table", { style: {
838
+ borderCollapse: 'collapse',
839
+ width: '100%',
840
+ maxWidth: 1200,
841
+ }, children: [_jsx("thead", { children: _jsxs("tr", { style: { background: '#2a2a2a' }, children: [_jsx("th", { style: thStyle, children: "#" }), _jsx("th", { style: thStyle, children: "DataSpace path" }), _jsx("th", { style: thStyle, children: "Type" }), _jsx("th", { style: thStyle, children: "File" }), _jsx("th", { style: { ...thStyle, textAlign: 'right' }, children: "Size" }), _jsx("th", { style: { ...thStyle, textAlign: 'right' }, children: "% of total" }), result.mode === 'parsed' && (_jsx("th", { style: { ...thStyle, textAlign: 'right' }, children: "View" }))] }) }), _jsx("tbody", { children: result.rows.map((r, i) => {
842
+ // In parsed mode, the per-row index in the descending
843
+ // size table doesn't match the position in `rawFiles`.
844
+ // Match by the inner `file.path` (the actual file path) —
845
+ // the outer `path` field on `StoredFileGeneration` is the
846
+ // element path and is the same for every row.
847
+ const rawIndex = result.mode === 'parsed' && result.rawFiles
848
+ ? result.rawFiles.findIndex((f) => f.file?.path ===
849
+ r.filePath)
850
+ : -1;
851
+ return (_jsxs("tr", { style: {
852
+ background: i % 2 === 0 ? '#222' : '#1a1a1a',
853
+ }, children: [_jsx("td", { style: tdStyle, children: i + 1 }), _jsx("td", { style: tdStyle, children: r.path }), _jsx("td", { style: { ...tdStyle, color: '#9cf' }, children: r.type }), _jsx("td", { style: { ...tdStyle, color: '#fc9' }, children: r.filePath }), _jsx("td", { style: { ...tdStyle, textAlign: 'right' }, children: fmtMB(r.bytes) }), _jsx("td", { style: { ...tdStyle, textAlign: 'right' }, children: result.totalBytes
854
+ ? `${((r.bytes / result.totalBytes) * 100).toFixed(1)}%`
855
+ : '—' }), result.mode === 'parsed' && (_jsx("td", { style: { ...tdStyle, textAlign: 'right' }, children: rawIndex >= 0 ? (_jsx("button", { type: "button", onClick: () => openFileViewer(rawIndex), style: {
856
+ padding: '0.15rem 0.5rem',
857
+ background: '#345',
858
+ color: 'white',
859
+ border: '1px solid #567',
860
+ borderRadius: 3,
861
+ cursor: 'pointer',
862
+ fontSize: '0.8rem',
863
+ }, title: "Open this file's content in the JSON viewer.", children: "view" })) : ('—') }))] }, `${r.path}::${r.type}::${r.filePath}::${r.bytes}`));
864
+ }) })] }))] })), viewer && (_jsx(JsonViewerModal, { title: viewer.title, content: viewer.content, onClose: () => setViewer(undefined) }))] }));
865
+ });
866
+ //# sourceMappingURL=DataSpaceArtifactInspector.js.map