@finos/legend-application-query 13.8.24 → 13.8.26
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/lib/__lib__/LegendQueryNavigation.d.ts +1 -0
- package/lib/__lib__/LegendQueryNavigation.d.ts.map +1 -1
- package/lib/__lib__/LegendQueryNavigation.js +3 -0
- package/lib/__lib__/LegendQueryNavigation.js.map +1 -1
- package/lib/components/DataSpaceArtifactInspector.d.ts +19 -0
- package/lib/components/DataSpaceArtifactInspector.d.ts.map +1 -0
- package/lib/components/DataSpaceArtifactInspector.js +866 -0
- package/lib/components/DataSpaceArtifactInspector.js.map +1 -0
- package/lib/components/LegendQueryWebApplication.d.ts.map +1 -1
- package/lib/components/LegendQueryWebApplication.js +2 -1
- package/lib/components/LegendQueryWebApplication.js.map +1 -1
- package/lib/components/__test-utils__/QueryEditorComponentTestUtils.d.ts +1 -1
- package/lib/components/__test-utils__/QueryEditorComponentTestUtils.d.ts.map +1 -1
- package/lib/index.css +1 -1
- package/lib/light-mode.css +2 -2
- package/lib/light-mode.css.map +1 -1
- package/lib/package.json +1 -1
- package/lib/stores/QueryEditorStore.d.ts.map +1 -1
- package/lib/stores/QueryEditorStore.js +1 -1
- package/lib/stores/QueryEditorStore.js.map +1 -1
- package/package.json +13 -13
- package/src/__lib__/LegendQueryNavigation.ts +3 -0
- package/src/components/DataSpaceArtifactInspector.tsx +1469 -0
- package/src/components/LegendQueryWebApplication.tsx +7 -0
- package/src/components/__test-utils__/QueryEditorComponentTestUtils.tsx +1 -1
- package/src/stores/QueryEditorStore.ts +1 -0
- 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'll fetch{' '}
|
|
934
|
+
<code>/pure/v1/query/<id></code> and pre-fill groupId, artifactId,
|
|
935
|
+
versionId, and the dataspace path from the query'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&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
|
+
});
|