datajunction-ui 0.0.70 → 0.0.72
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/.env +7 -1
- package/package.json +1 -1
- package/src/app/constants.js +17 -0
- package/src/app/pages/AddEditNodePage/FormikSelect.jsx +24 -1
- package/src/app/pages/AddEditNodePage/__tests__/FormikSelect.test.jsx +6 -6
- package/src/app/pages/NodePage/AddBackfillPopover.jsx +11 -4
- package/src/app/pages/NodePage/EditColumnPopover.jsx +1 -1
- package/src/app/pages/NodePage/NodeMaterializationTab.jsx +47 -43
- package/src/app/pages/NodePage/PartitionColumnPopover.jsx +27 -0
- package/src/app/pages/QueryPlannerPage/PreAggDetailsPanel.jsx +138 -51
- package/src/app/pages/QueryPlannerPage/__tests__/PreAggDetailsPanel.test.jsx +433 -0
- package/src/app/pages/QueryPlannerPage/index.jsx +1 -1
- package/src/app/pages/QueryPlannerPage/styles.css +124 -0
package/.env
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
REACT_APP_DJ_URL=http://localhost:8000
|
|
2
2
|
REACT_USE_SSE=true
|
|
3
|
-
REACT_ENABLE_GOOGLE_OAUTH=true
|
|
3
|
+
REACT_ENABLE_GOOGLE_OAUTH=true
|
|
4
|
+
|
|
5
|
+
# Scan estimate warning thresholds (in GB)
|
|
6
|
+
# Orange warning icon appears when scan > this value (default: 10 GB)
|
|
7
|
+
REACT_APP_SCAN_WARNING_THRESHOLD_GB=10
|
|
8
|
+
# Red critical icon appears when scan > this value (default: 100 GB)
|
|
9
|
+
REACT_APP_SCAN_CRITICAL_THRESHOLD_GB=100
|
package/package.json
CHANGED
package/src/app/constants.js
CHANGED
|
@@ -1,2 +1,19 @@
|
|
|
1
1
|
export const AUTH_COOKIE = '__dj';
|
|
2
2
|
export const LOGGED_IN_FLAG_COOKIE = '__djlif';
|
|
3
|
+
|
|
4
|
+
// Scan estimate thresholds (in GB)
|
|
5
|
+
// These determine the warning level colors in the UI
|
|
6
|
+
const GB = 1024 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
export const SCAN_WARNING_THRESHOLD_GB = parseInt(
|
|
9
|
+
process.env.REACT_APP_SCAN_WARNING_THRESHOLD_GB || '10',
|
|
10
|
+
10,
|
|
11
|
+
);
|
|
12
|
+
export const SCAN_CRITICAL_THRESHOLD_GB = parseInt(
|
|
13
|
+
process.env.REACT_APP_SCAN_CRITICAL_THRESHOLD_GB || '100',
|
|
14
|
+
10,
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
// Export as bytes for internal use
|
|
18
|
+
export const SCAN_WARNING_THRESHOLD = SCAN_WARNING_THRESHOLD_GB * GB;
|
|
19
|
+
export const SCAN_CRITICAL_THRESHOLD = SCAN_CRITICAL_THRESHOLD_GB * GB;
|
|
@@ -39,10 +39,33 @@ export const FormikSelect = ({
|
|
|
39
39
|
}
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
+
// Convert Formik field value to React Select format
|
|
43
|
+
const getSelectValue = () => {
|
|
44
|
+
if (!field.value) {
|
|
45
|
+
return isMulti ? [] : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (isMulti) {
|
|
49
|
+
// For multi-select, map array of values to option objects
|
|
50
|
+
return Array.isArray(field.value)
|
|
51
|
+
? field.value.map(
|
|
52
|
+
val =>
|
|
53
|
+
selectOptions.find(opt => opt.value === val) || {
|
|
54
|
+
value: val,
|
|
55
|
+
label: val,
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
: [];
|
|
59
|
+
} else {
|
|
60
|
+
// For single-select, find the matching option
|
|
61
|
+
return selectOptions.find(opt => opt.value === field.value) || null;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
42
65
|
return (
|
|
43
66
|
<Select
|
|
44
67
|
className={className}
|
|
45
|
-
|
|
68
|
+
value={getSelectValue()}
|
|
46
69
|
options={selectOptions}
|
|
47
70
|
name={field.name}
|
|
48
71
|
placeholder={placeholder}
|
|
@@ -14,7 +14,7 @@ describe('FormikSelect', () => {
|
|
|
14
14
|
|
|
15
15
|
const singleSelect = () => {
|
|
16
16
|
const utils = render(
|
|
17
|
-
<Formik initialValues={{
|
|
17
|
+
<Formik initialValues={{ namespace: '' }} onSubmit={jest.fn()}>
|
|
18
18
|
<Form>
|
|
19
19
|
<FormikSelect
|
|
20
20
|
selectOptions={namespaces}
|
|
@@ -38,7 +38,7 @@ describe('FormikSelect', () => {
|
|
|
38
38
|
|
|
39
39
|
const multiSelect = () => {
|
|
40
40
|
const utils = render(
|
|
41
|
-
<Formik initialValues={{
|
|
41
|
+
<Formik initialValues={{ namespace: [] }} onSubmit={jest.fn()}>
|
|
42
42
|
<Form>
|
|
43
43
|
<FormikSelect
|
|
44
44
|
selectOptions={namespaces}
|
|
@@ -61,15 +61,15 @@ describe('FormikSelect', () => {
|
|
|
61
61
|
};
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
-
it('renders the single select component with provided options', () => {
|
|
64
|
+
it('renders the single select component with provided options', async () => {
|
|
65
65
|
singleSelect();
|
|
66
|
-
userEvent.click(screen.getByRole('combobox')); // to open the dropdown
|
|
66
|
+
await userEvent.click(screen.getByRole('combobox')); // to open the dropdown
|
|
67
67
|
expect(screen.getByText('basic.one')).toBeInTheDocument();
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
it('renders the multi-select component with provided options', () => {
|
|
70
|
+
it('renders the multi-select component with provided options', async () => {
|
|
71
71
|
multiSelect();
|
|
72
|
-
userEvent.click(screen.getByRole('combobox')); // to open the dropdown
|
|
72
|
+
await userEvent.click(screen.getByRole('combobox')); // to open the dropdown
|
|
73
73
|
expect(screen.getByText('basic.one')).toBeInTheDocument();
|
|
74
74
|
});
|
|
75
75
|
});
|
|
@@ -64,18 +64,25 @@ export default function AddBackfillPopover({
|
|
|
64
64
|
);
|
|
65
65
|
if (response.status === 200 || response.status === 201) {
|
|
66
66
|
setStatus({ success: 'Saved!' });
|
|
67
|
+
return true;
|
|
67
68
|
} else {
|
|
68
69
|
setStatus({
|
|
69
70
|
failure: `${response.json.message}`,
|
|
70
71
|
});
|
|
72
|
+
return false;
|
|
71
73
|
}
|
|
72
74
|
};
|
|
73
75
|
|
|
74
76
|
const submitBackfill = async (values, { setSubmitting, setStatus }) => {
|
|
75
|
-
await runBackfill(values, setStatus)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
const success = await runBackfill(values, setStatus);
|
|
78
|
+
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' });
|
|
79
|
+
setSubmitting(false);
|
|
80
|
+
if (success) {
|
|
81
|
+
setPopoverAnchor(false);
|
|
82
|
+
if (onSubmit) {
|
|
83
|
+
onSubmit();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
79
86
|
};
|
|
80
87
|
|
|
81
88
|
return (
|
|
@@ -63,7 +63,7 @@ export default function EditColumnPopover({ column, node, options, onSubmit }) {
|
|
|
63
63
|
initialValues={{
|
|
64
64
|
column: column.name,
|
|
65
65
|
node: node.name,
|
|
66
|
-
attributes:
|
|
66
|
+
attributes: column.attributes.map(attr => attr.attribute_type.name),
|
|
67
67
|
}}
|
|
68
68
|
onSubmit={saveAttributes}
|
|
69
69
|
>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useState, useMemo } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useState, useMemo } from 'react';
|
|
2
2
|
import TableIcon from '../../icons/TableIcon';
|
|
3
3
|
import AddMaterializationPopover from './AddMaterializationPopover';
|
|
4
4
|
import * as React from 'react';
|
|
@@ -47,54 +47,56 @@ export default function NodeMaterializationTab({ node, djClient }) {
|
|
|
47
47
|
}, {});
|
|
48
48
|
}, [filteredMaterializations, node?.version]);
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
acc[version] = [];
|
|
67
|
-
}
|
|
68
|
-
acc[version].push(avail);
|
|
69
|
-
return acc;
|
|
70
|
-
}, {});
|
|
71
|
-
|
|
72
|
-
setAvailabilityStatesByRevision(availabilityGrouped);
|
|
73
|
-
|
|
74
|
-
// Clear rebuilding state once data is loaded after a page reload
|
|
75
|
-
if (localStorage.getItem(`rebuilding-${node.name}`) === 'true') {
|
|
76
|
-
localStorage.removeItem(`rebuilding-${node.name}`);
|
|
77
|
-
setIsRebuilding(false);
|
|
50
|
+
const fetchData = useCallback(async () => {
|
|
51
|
+
if (node) {
|
|
52
|
+
const data = await djClient.materializations(node.name);
|
|
53
|
+
|
|
54
|
+
// Store raw data
|
|
55
|
+
setRawMaterializations(data);
|
|
56
|
+
|
|
57
|
+
// Fetch availability states
|
|
58
|
+
const availabilityData = await djClient.availabilityStates(node.name);
|
|
59
|
+
setAvailabilityStates(availabilityData);
|
|
60
|
+
|
|
61
|
+
// Group availability states by version
|
|
62
|
+
const availabilityGrouped = availabilityData.reduce((acc, avail) => {
|
|
63
|
+
const version = avail.node_version || node.version;
|
|
64
|
+
if (!acc[version]) {
|
|
65
|
+
acc[version] = [];
|
|
78
66
|
}
|
|
67
|
+
acc[version].push(avail);
|
|
68
|
+
return acc;
|
|
69
|
+
}, {});
|
|
70
|
+
|
|
71
|
+
setAvailabilityStatesByRevision(availabilityGrouped);
|
|
72
|
+
|
|
73
|
+
// Clear rebuilding state once data is loaded after a page reload
|
|
74
|
+
if (localStorage.getItem(`rebuilding-${node.name}`) === 'true') {
|
|
75
|
+
localStorage.removeItem(`rebuilding-${node.name}`);
|
|
76
|
+
setIsRebuilding(false);
|
|
79
77
|
}
|
|
80
|
-
}
|
|
81
|
-
fetchData().catch(console.error);
|
|
78
|
+
}
|
|
82
79
|
}, [djClient, node]);
|
|
83
80
|
|
|
84
|
-
// Separate useEffect to set default selected tab
|
|
85
81
|
useEffect(() => {
|
|
82
|
+
fetchData().catch(console.error);
|
|
83
|
+
}, [fetchData]);
|
|
84
|
+
|
|
85
|
+
// Set default selected tab, or reset if current tab is no longer visible
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
const versions = Object.keys(materializationsByRevision);
|
|
88
|
+
if (versions.length === 0) return;
|
|
89
|
+
|
|
86
90
|
if (
|
|
87
|
-
!selectedRevisionTab
|
|
88
|
-
|
|
91
|
+
!selectedRevisionTab ||
|
|
92
|
+
!materializationsByRevision[selectedRevisionTab]
|
|
89
93
|
) {
|
|
90
94
|
// First try to find current node version
|
|
91
95
|
if (materializationsByRevision[node?.version]) {
|
|
92
96
|
setSelectedRevisionTab(node.version);
|
|
93
97
|
} else {
|
|
94
98
|
// Otherwise, select the most recent version (sort by version string)
|
|
95
|
-
const sortedVersions =
|
|
96
|
-
(a, b) => b.localeCompare(a),
|
|
97
|
-
);
|
|
99
|
+
const sortedVersions = versions.sort((a, b) => b.localeCompare(a));
|
|
98
100
|
setSelectedRevisionTab(sortedVersions[0]);
|
|
99
101
|
}
|
|
100
102
|
}
|
|
@@ -193,16 +195,17 @@ export default function NodeMaterializationTab({ node, djClient }) {
|
|
|
193
195
|
return b.localeCompare(a);
|
|
194
196
|
});
|
|
195
197
|
|
|
196
|
-
// Check if latest version has
|
|
197
|
-
const hasLatestVersionMaterialization =
|
|
198
|
-
|
|
199
|
-
|
|
198
|
+
// Check if latest version has any materializations (including inactive ones)
|
|
199
|
+
const hasLatestVersionMaterialization = rawMaterializations.some(mat => {
|
|
200
|
+
const matVersion = mat.config?.cube?.version || node?.version;
|
|
201
|
+
return matVersion === node?.version;
|
|
202
|
+
});
|
|
200
203
|
|
|
201
204
|
// Refresh latest materialization function
|
|
202
205
|
const refreshLatestMaterialization = async () => {
|
|
203
206
|
if (
|
|
204
207
|
!window.confirm(
|
|
205
|
-
'This will
|
|
208
|
+
'This will rebuild the materialization workflows for the current cube version without creating a new version. Would you like to continue?',
|
|
206
209
|
)
|
|
207
210
|
) {
|
|
208
211
|
return;
|
|
@@ -286,7 +289,7 @@ export default function NodeMaterializationTab({ node, djClient }) {
|
|
|
286
289
|
tabIndex="0"
|
|
287
290
|
onClick={refreshLatestMaterialization}
|
|
288
291
|
disabled={isRebuilding}
|
|
289
|
-
title="
|
|
292
|
+
title="Rebuild the materialization workflows for the current cube version (no version bump)."
|
|
290
293
|
style={{
|
|
291
294
|
opacity: isRebuilding ? 0.7 : 1,
|
|
292
295
|
cursor: isRebuilding ? 'not-allowed' : 'pointer',
|
|
@@ -398,6 +401,7 @@ export default function NodeMaterializationTab({ node, djClient }) {
|
|
|
398
401
|
<AddBackfillPopover
|
|
399
402
|
node={node}
|
|
400
403
|
materialization={materialization}
|
|
404
|
+
onSubmit={fetchData}
|
|
401
405
|
/>
|
|
402
406
|
</li>
|
|
403
407
|
{materialization.backfills.map(backfill => (
|
|
@@ -141,6 +141,33 @@ export default function PartitionColumnPopover({ column, node, onSubmit }) {
|
|
|
141
141
|
>
|
|
142
142
|
Save
|
|
143
143
|
</button>
|
|
144
|
+
<button
|
|
145
|
+
className="delete_button"
|
|
146
|
+
type="button"
|
|
147
|
+
aria-label="RemovePartition"
|
|
148
|
+
aria-hidden="false"
|
|
149
|
+
onClick={() => {
|
|
150
|
+
setFieldValue('partition_type', '');
|
|
151
|
+
setFieldValue('format', '');
|
|
152
|
+
setFieldValue('granularity', '');
|
|
153
|
+
savePartition(
|
|
154
|
+
{
|
|
155
|
+
node: node.name,
|
|
156
|
+
column: column.name,
|
|
157
|
+
partition_type: '',
|
|
158
|
+
format: '',
|
|
159
|
+
granularity: '',
|
|
160
|
+
},
|
|
161
|
+
{ setSubmitting: () => {}, setStatus: s => {} },
|
|
162
|
+
);
|
|
163
|
+
}}
|
|
164
|
+
style={{
|
|
165
|
+
marginLeft: '10px',
|
|
166
|
+
backgroundColor: '#dc3545',
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
Remove Partition
|
|
170
|
+
</button>
|
|
144
171
|
</Form>
|
|
145
172
|
);
|
|
146
173
|
}}
|
|
@@ -2,6 +2,10 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
|
|
2
2
|
import { Link } from 'react-router-dom';
|
|
3
3
|
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|
4
4
|
import { atomOneLight } from 'react-syntax-highlighter/src/styles/hljs';
|
|
5
|
+
import {
|
|
6
|
+
SCAN_WARNING_THRESHOLD,
|
|
7
|
+
SCAN_CRITICAL_THRESHOLD,
|
|
8
|
+
} from '../../constants';
|
|
5
9
|
|
|
6
10
|
/**
|
|
7
11
|
* Helper to extract dimension node name from a dimension path
|
|
@@ -195,6 +199,63 @@ function inferGranularity(grainGroups) {
|
|
|
195
199
|
return 'DAY'; // Default
|
|
196
200
|
}
|
|
197
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Format bytes to human-readable string
|
|
204
|
+
*/
|
|
205
|
+
function formatBytes(bytes) {
|
|
206
|
+
if (!bytes || bytes === 0) return '0 B';
|
|
207
|
+
const k = 1024;
|
|
208
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
209
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
210
|
+
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Get warning level for scan size
|
|
215
|
+
*/
|
|
216
|
+
function getScanWarningLevel(bytes) {
|
|
217
|
+
if (bytes > SCAN_CRITICAL_THRESHOLD) return 'critical';
|
|
218
|
+
if (bytes > SCAN_WARNING_THRESHOLD) return 'warning';
|
|
219
|
+
return 'ok';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Format scan estimate for display
|
|
224
|
+
*/
|
|
225
|
+
function formatScanEstimate(scanEstimate) {
|
|
226
|
+
if (
|
|
227
|
+
!scanEstimate ||
|
|
228
|
+
!scanEstimate.sources ||
|
|
229
|
+
scanEstimate.sources.length === 0
|
|
230
|
+
) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Check if any sources are missing size data
|
|
235
|
+
const hasMissingData = scanEstimate.sources.some(
|
|
236
|
+
s => s.total_bytes === null || s.total_bytes === undefined,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// Determine warning level based on total_bytes (if available)
|
|
240
|
+
let level = 'unknown';
|
|
241
|
+
let icon = 'ℹ️';
|
|
242
|
+
if (
|
|
243
|
+
scanEstimate.total_bytes !== null &&
|
|
244
|
+
scanEstimate.total_bytes !== undefined
|
|
245
|
+
) {
|
|
246
|
+
level = getScanWarningLevel(scanEstimate.total_bytes);
|
|
247
|
+
icon = level === 'critical' ? '⚠️' : level === 'warning' ? '⚡' : '✓';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
icon,
|
|
252
|
+
level,
|
|
253
|
+
totalBytes: scanEstimate.total_bytes,
|
|
254
|
+
sources: scanEstimate.sources || [],
|
|
255
|
+
hasMissingData,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
198
259
|
/**
|
|
199
260
|
* CubeBackfillModal - Simple modal to collect start/end dates for backfill
|
|
200
261
|
*/
|
|
@@ -292,7 +353,6 @@ export function QueryOverviewPanel({
|
|
|
292
353
|
onClearWorkflowUrls,
|
|
293
354
|
loadedCubeName = null, // Existing cube name if loaded from preset
|
|
294
355
|
cubeMaterialization = null, // Full cube materialization info {schedule, strategy, lookbackWindow, ...}
|
|
295
|
-
cubeAvailability = null, // Cube availability info for data freshness
|
|
296
356
|
onUpdateCubeConfig,
|
|
297
357
|
onRefreshCubeWorkflow,
|
|
298
358
|
onRunCubeBackfill,
|
|
@@ -362,17 +422,17 @@ export function QueryOverviewPanel({
|
|
|
362
422
|
|
|
363
423
|
// SQL view toggle state: 'optimized' (uses pre-aggs) or 'raw' (from source tables)
|
|
364
424
|
const [sqlViewMode, setSqlViewMode] = useState('optimized');
|
|
365
|
-
const [
|
|
425
|
+
const [rawResult, setRawResult] = useState(null);
|
|
366
426
|
const [loadingRawSql, setLoadingRawSql] = useState(false);
|
|
367
427
|
|
|
368
428
|
// Handle SQL view toggle
|
|
369
429
|
const handleSqlViewToggle = async mode => {
|
|
370
430
|
setSqlViewMode(mode);
|
|
371
431
|
// Fetch raw SQL lazily when switching to raw mode
|
|
372
|
-
if (mode === 'raw' && !
|
|
432
|
+
if (mode === 'raw' && !rawResult && onFetchRawSql) {
|
|
373
433
|
setLoadingRawSql(true);
|
|
374
|
-
const
|
|
375
|
-
|
|
434
|
+
const result = await onFetchRawSql();
|
|
435
|
+
setRawResult(result);
|
|
376
436
|
setLoadingRawSql(false);
|
|
377
437
|
}
|
|
378
438
|
};
|
|
@@ -579,9 +639,6 @@ export function QueryOverviewPanel({
|
|
|
579
639
|
const grainGroups = measuresResult.grain_groups || [];
|
|
580
640
|
const metricFormulas = measuresResult.metric_formulas || [];
|
|
581
641
|
const sql = metricsResult.sql || '';
|
|
582
|
-
const dialect = metricsResult.dialect || null;
|
|
583
|
-
const cubeName = metricsResult.cube_name || null;
|
|
584
|
-
const isFastQuery = !!cubeName; // Fast if using materialized cube
|
|
585
642
|
|
|
586
643
|
// Determine if materialization is already configured (has active workflows)
|
|
587
644
|
const isMaterialized =
|
|
@@ -613,29 +670,11 @@ export function QueryOverviewPanel({
|
|
|
613
670
|
{/* Header */}
|
|
614
671
|
<div className="details-header">
|
|
615
672
|
<h2 className="details-title">Query Plan</h2>
|
|
616
|
-
<p className="details-
|
|
673
|
+
<p className="details-full-name">
|
|
617
674
|
{selectedMetrics.length} metric
|
|
618
675
|
{selectedMetrics.length !== 1 ? 's' : ''} ×{' '}
|
|
619
676
|
{selectedDimensions.length} dimension
|
|
620
677
|
{selectedDimensions.length !== 1 ? 's' : ''}
|
|
621
|
-
{isFastQuery && (
|
|
622
|
-
<>
|
|
623
|
-
{' · '}
|
|
624
|
-
<span className="info-materialized">
|
|
625
|
-
<span style={{ fontFamily: 'sans-serif' }}>⚡</span>{' '}
|
|
626
|
-
Materialized cube available
|
|
627
|
-
</span>
|
|
628
|
-
{cubeAvailability?.validThroughTs && (
|
|
629
|
-
<>
|
|
630
|
-
{' '}
|
|
631
|
-
· Valid thru{' '}
|
|
632
|
-
{new Date(
|
|
633
|
-
cubeAvailability.validThroughTs,
|
|
634
|
-
).toLocaleDateString()}
|
|
635
|
-
</>
|
|
636
|
-
)}
|
|
637
|
-
</>
|
|
638
|
-
)}
|
|
639
678
|
</p>
|
|
640
679
|
</div>
|
|
641
680
|
|
|
@@ -2220,29 +2259,6 @@ export function QueryOverviewPanel({
|
|
|
2220
2259
|
<span className="section-icon">⌘</span>
|
|
2221
2260
|
Generated SQL
|
|
2222
2261
|
</h3>
|
|
2223
|
-
<span className="sql-info-inline">
|
|
2224
|
-
{sqlViewMode === 'optimized' && isFastQuery ? (
|
|
2225
|
-
<>
|
|
2226
|
-
<span className="info-materialized">
|
|
2227
|
-
<span style={{ fontFamily: 'sans-serif' }}>⚡</span> Using
|
|
2228
|
-
materialized cube
|
|
2229
|
-
</span>
|
|
2230
|
-
{cubeAvailability?.validThroughTs && (
|
|
2231
|
-
<>
|
|
2232
|
-
{' · Valid thru '}
|
|
2233
|
-
{new Date(
|
|
2234
|
-
cubeAvailability.validThroughTs,
|
|
2235
|
-
).toLocaleDateString()}
|
|
2236
|
-
</>
|
|
2237
|
-
)}
|
|
2238
|
-
</>
|
|
2239
|
-
) : sqlViewMode === 'raw' ? (
|
|
2240
|
-
<span className="info-base-tables">
|
|
2241
|
-
<span style={{ fontFamily: 'sans-serif' }}>⚠️</span> Using
|
|
2242
|
-
base tables
|
|
2243
|
-
</span>
|
|
2244
|
-
) : null}
|
|
2245
|
-
</span>
|
|
2246
2262
|
<div className="sql-view-toggle">
|
|
2247
2263
|
<button
|
|
2248
2264
|
className={`sql-toggle-btn ${
|
|
@@ -2267,6 +2283,65 @@ export function QueryOverviewPanel({
|
|
|
2267
2283
|
</button>
|
|
2268
2284
|
</div>
|
|
2269
2285
|
</div>
|
|
2286
|
+
|
|
2287
|
+
{/* Scan Estimate Info */}
|
|
2288
|
+
{(() => {
|
|
2289
|
+
const currentScanEstimate =
|
|
2290
|
+
sqlViewMode === 'raw'
|
|
2291
|
+
? rawResult?.scan_estimate
|
|
2292
|
+
: metricsResult?.scan_estimate;
|
|
2293
|
+
const scanInfo = formatScanEstimate(currentScanEstimate);
|
|
2294
|
+
|
|
2295
|
+
if (scanInfo) {
|
|
2296
|
+
return (
|
|
2297
|
+
<div
|
|
2298
|
+
className={`scan-estimate-banner scan-estimate-${scanInfo.level}`}
|
|
2299
|
+
>
|
|
2300
|
+
<span className="scan-estimate-icon">{scanInfo.icon}</span>
|
|
2301
|
+
<div className="scan-estimate-content">
|
|
2302
|
+
<div className="scan-estimate-header">
|
|
2303
|
+
<strong>Scan Cost:</strong>{' '}
|
|
2304
|
+
{scanInfo.totalBytes !== null &&
|
|
2305
|
+
scanInfo.totalBytes !== undefined
|
|
2306
|
+
? (scanInfo.hasMissingData ? '≥ ' : '') +
|
|
2307
|
+
formatBytes(scanInfo.totalBytes)
|
|
2308
|
+
: 'Unknown'}
|
|
2309
|
+
</div>
|
|
2310
|
+
<div className="scan-estimate-sources">
|
|
2311
|
+
{scanInfo.sources.map((source, idx) => {
|
|
2312
|
+
// Prefer schema.table display, fall back to node name
|
|
2313
|
+
let displayName = source.source_name;
|
|
2314
|
+
if (source.schema_ && source.table) {
|
|
2315
|
+
displayName = `${source.schema_}.${source.table}`;
|
|
2316
|
+
} else if (source.table) {
|
|
2317
|
+
displayName = source.table;
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
return (
|
|
2321
|
+
<div key={idx} className="scan-source-item">
|
|
2322
|
+
<span
|
|
2323
|
+
className="scan-source-name"
|
|
2324
|
+
title={source.source_name}
|
|
2325
|
+
>
|
|
2326
|
+
{displayName}
|
|
2327
|
+
</span>
|
|
2328
|
+
<span className="scan-source-size">
|
|
2329
|
+
{source.total_bytes !== null &&
|
|
2330
|
+
source.total_bytes !== undefined
|
|
2331
|
+
? formatBytes(source.total_bytes)
|
|
2332
|
+
: 'no size data'}
|
|
2333
|
+
</span>
|
|
2334
|
+
</div>
|
|
2335
|
+
);
|
|
2336
|
+
})}
|
|
2337
|
+
</div>
|
|
2338
|
+
</div>
|
|
2339
|
+
</div>
|
|
2340
|
+
);
|
|
2341
|
+
}
|
|
2342
|
+
return null;
|
|
2343
|
+
})()}
|
|
2344
|
+
|
|
2270
2345
|
<div className="sql-code-wrapper">
|
|
2271
2346
|
<SyntaxHighlighter
|
|
2272
2347
|
language="sql"
|
|
@@ -2279,7 +2354,7 @@ export function QueryOverviewPanel({
|
|
|
2279
2354
|
border: '1px solid #e2e8f0',
|
|
2280
2355
|
}}
|
|
2281
2356
|
>
|
|
2282
|
-
{sqlViewMode === 'raw' ?
|
|
2357
|
+
{sqlViewMode === 'raw' ? rawResult?.sql || 'Loading...' : sql}
|
|
2283
2358
|
</SyntaxHighlighter>
|
|
2284
2359
|
</div>
|
|
2285
2360
|
</div>
|
|
@@ -2767,4 +2842,16 @@ export function MetricDetailsPanel({ metric, grainGroups, onClose }) {
|
|
|
2767
2842
|
);
|
|
2768
2843
|
}
|
|
2769
2844
|
|
|
2845
|
+
// Export helper functions for testing
|
|
2846
|
+
export {
|
|
2847
|
+
getDimensionNodeName,
|
|
2848
|
+
normalizeGrain,
|
|
2849
|
+
getScheduleSummary,
|
|
2850
|
+
getStatusInfo,
|
|
2851
|
+
inferGranularity,
|
|
2852
|
+
formatBytes,
|
|
2853
|
+
getScanWarningLevel,
|
|
2854
|
+
formatScanEstimate,
|
|
2855
|
+
};
|
|
2856
|
+
|
|
2770
2857
|
export default PreAggDetailsPanel;
|
|
@@ -10,6 +10,14 @@ import {
|
|
|
10
10
|
QueryOverviewPanel,
|
|
11
11
|
PreAggDetailsPanel,
|
|
12
12
|
MetricDetailsPanel,
|
|
13
|
+
getDimensionNodeName,
|
|
14
|
+
normalizeGrain,
|
|
15
|
+
getScheduleSummary,
|
|
16
|
+
getStatusInfo,
|
|
17
|
+
inferGranularity,
|
|
18
|
+
formatBytes,
|
|
19
|
+
getScanWarningLevel,
|
|
20
|
+
formatScanEstimate,
|
|
13
21
|
} from '../PreAggDetailsPanel';
|
|
14
22
|
import React from 'react';
|
|
15
23
|
|
|
@@ -2994,3 +3002,428 @@ describe('QueryOverviewPanel - Custom Schedule and Druid Config', () => {
|
|
|
2994
3002
|
});
|
|
2995
3003
|
});
|
|
2996
3004
|
});
|
|
3005
|
+
|
|
3006
|
+
// ============================================================================
|
|
3007
|
+
// Helper Function Tests
|
|
3008
|
+
// ============================================================================
|
|
3009
|
+
|
|
3010
|
+
describe('Helper Functions', () => {
|
|
3011
|
+
describe('getDimensionNodeName', () => {
|
|
3012
|
+
it('extracts node name from dimension path', () => {
|
|
3013
|
+
expect(getDimensionNodeName('v3.customer.name')).toBe('v3.customer');
|
|
3014
|
+
expect(getDimensionNodeName('v3.date.month')).toBe('v3.date');
|
|
3015
|
+
});
|
|
3016
|
+
|
|
3017
|
+
it('handles role suffix in dimension path', () => {
|
|
3018
|
+
expect(getDimensionNodeName('v3.date.month[order]')).toBe('v3.date');
|
|
3019
|
+
expect(getDimensionNodeName('v3.customer.id[billing]')).toBe(
|
|
3020
|
+
'v3.customer',
|
|
3021
|
+
);
|
|
3022
|
+
});
|
|
3023
|
+
|
|
3024
|
+
it('returns path without role when no dots after removing role', () => {
|
|
3025
|
+
expect(getDimensionNodeName('singlename[role]')).toBe('singlename');
|
|
3026
|
+
expect(getDimensionNodeName('singlename')).toBe('singlename');
|
|
3027
|
+
});
|
|
3028
|
+
});
|
|
3029
|
+
|
|
3030
|
+
describe('normalizeGrain', () => {
|
|
3031
|
+
it('extracts and sorts column short names', () => {
|
|
3032
|
+
expect(normalizeGrain(['v3.date.id', 'v3.customer.name'])).toBe(
|
|
3033
|
+
'id,name',
|
|
3034
|
+
);
|
|
3035
|
+
expect(normalizeGrain(['v3.customer.name', 'v3.date.id'])).toBe(
|
|
3036
|
+
'id,name',
|
|
3037
|
+
);
|
|
3038
|
+
});
|
|
3039
|
+
|
|
3040
|
+
it('handles empty grain', () => {
|
|
3041
|
+
expect(normalizeGrain([])).toBe('');
|
|
3042
|
+
expect(normalizeGrain(null)).toBe('');
|
|
3043
|
+
});
|
|
3044
|
+
});
|
|
3045
|
+
|
|
3046
|
+
describe('getScheduleSummary', () => {
|
|
3047
|
+
it('returns null for empty schedule', () => {
|
|
3048
|
+
expect(getScheduleSummary(null)).toBeNull();
|
|
3049
|
+
expect(getScheduleSummary('')).toBeNull();
|
|
3050
|
+
});
|
|
3051
|
+
|
|
3052
|
+
it('returns original schedule for invalid format', () => {
|
|
3053
|
+
expect(getScheduleSummary('0 0')).toBe('0 0');
|
|
3054
|
+
expect(getScheduleSummary('invalid')).toBe('invalid');
|
|
3055
|
+
});
|
|
3056
|
+
|
|
3057
|
+
it('parses daily schedule at specific hour', () => {
|
|
3058
|
+
expect(getScheduleSummary('0 0 * * *')).toBe('Daily @ 12:00am');
|
|
3059
|
+
expect(getScheduleSummary('0 14 * * *')).toBe('Daily @ 2:00pm');
|
|
3060
|
+
expect(getScheduleSummary('30 9 * * *')).toBe('Daily @ 9:30am');
|
|
3061
|
+
expect(getScheduleSummary('0 12 * * *')).toBe('Daily @ 12:00pm');
|
|
3062
|
+
});
|
|
3063
|
+
|
|
3064
|
+
it('parses weekly schedule', () => {
|
|
3065
|
+
expect(getScheduleSummary('0 0 * * 0')).toBe('Weekly on Sun');
|
|
3066
|
+
expect(getScheduleSummary('0 0 * * 1')).toBe('Weekly on Mon');
|
|
3067
|
+
expect(getScheduleSummary('0 0 * * 6')).toBe('Weekly on Sat');
|
|
3068
|
+
});
|
|
3069
|
+
|
|
3070
|
+
it('returns original schedule for unrecognized patterns', () => {
|
|
3071
|
+
expect(getScheduleSummary('0 0 1 * *')).toBe('0 0 1 * *');
|
|
3072
|
+
expect(getScheduleSummary('0 0 * 1 *')).toBe('0 0 * 1 *');
|
|
3073
|
+
});
|
|
3074
|
+
});
|
|
3075
|
+
|
|
3076
|
+
describe('formatBytes', () => {
|
|
3077
|
+
it('handles zero and null bytes', () => {
|
|
3078
|
+
expect(formatBytes(0)).toBe('0 B');
|
|
3079
|
+
expect(formatBytes(null)).toBe('0 B');
|
|
3080
|
+
expect(formatBytes(undefined)).toBe('0 B');
|
|
3081
|
+
});
|
|
3082
|
+
|
|
3083
|
+
it('formats bytes correctly', () => {
|
|
3084
|
+
expect(formatBytes(1023)).toBe('1023 B');
|
|
3085
|
+
expect(formatBytes(1024)).toBe('1 KB');
|
|
3086
|
+
expect(formatBytes(1024 * 1024)).toBe('1 MB');
|
|
3087
|
+
expect(formatBytes(1024 * 1024 * 1024)).toBe('1 GB');
|
|
3088
|
+
expect(formatBytes(1024 * 1024 * 1024 * 1024)).toBe('1 TB');
|
|
3089
|
+
expect(formatBytes(1024 * 1024 * 1024 * 1024 * 1024)).toBe('1 PB');
|
|
3090
|
+
});
|
|
3091
|
+
|
|
3092
|
+
it('rounds to 2 decimal places', () => {
|
|
3093
|
+
expect(formatBytes(1536)).toBe('1.5 KB');
|
|
3094
|
+
expect(formatBytes(1024 * 1.234)).toBe('1.23 KB');
|
|
3095
|
+
expect(formatBytes(5.5 * 1024 * 1024 * 1024)).toBe('5.5 GB');
|
|
3096
|
+
});
|
|
3097
|
+
});
|
|
3098
|
+
|
|
3099
|
+
describe('getScanWarningLevel', () => {
|
|
3100
|
+
it('returns critical for very large scans', () => {
|
|
3101
|
+
// Assuming SCAN_CRITICAL_THRESHOLD is 100GB = 107374182400
|
|
3102
|
+
expect(getScanWarningLevel(200 * 1024 * 1024 * 1024)).toBe('critical');
|
|
3103
|
+
});
|
|
3104
|
+
|
|
3105
|
+
it('returns warning for medium scans', () => {
|
|
3106
|
+
// Assuming SCAN_WARNING_THRESHOLD is 10GB = 10737418240
|
|
3107
|
+
expect(getScanWarningLevel(50 * 1024 * 1024 * 1024)).toBe('warning');
|
|
3108
|
+
});
|
|
3109
|
+
|
|
3110
|
+
it('returns ok for small scans', () => {
|
|
3111
|
+
expect(getScanWarningLevel(1 * 1024 * 1024 * 1024)).toBe('ok');
|
|
3112
|
+
expect(getScanWarningLevel(1024)).toBe('ok');
|
|
3113
|
+
});
|
|
3114
|
+
});
|
|
3115
|
+
|
|
3116
|
+
describe('formatScanEstimate', () => {
|
|
3117
|
+
it('returns null for empty scan estimate', () => {
|
|
3118
|
+
expect(formatScanEstimate(null)).toBeNull();
|
|
3119
|
+
expect(formatScanEstimate({})).toBeNull();
|
|
3120
|
+
expect(formatScanEstimate({ sources: [] })).toBeNull();
|
|
3121
|
+
});
|
|
3122
|
+
|
|
3123
|
+
it('formats scan estimate with total bytes', () => {
|
|
3124
|
+
const estimate = {
|
|
3125
|
+
total_bytes: 50 * 1024 * 1024 * 1024, // 50GB - warning level
|
|
3126
|
+
sources: [
|
|
3127
|
+
{
|
|
3128
|
+
source_name: 'source.sales',
|
|
3129
|
+
total_bytes: 30 * 1024 * 1024 * 1024,
|
|
3130
|
+
},
|
|
3131
|
+
{
|
|
3132
|
+
source_name: 'source.orders',
|
|
3133
|
+
total_bytes: 20 * 1024 * 1024 * 1024,
|
|
3134
|
+
},
|
|
3135
|
+
],
|
|
3136
|
+
};
|
|
3137
|
+
|
|
3138
|
+
const result = formatScanEstimate(estimate);
|
|
3139
|
+
expect(result).not.toBeNull();
|
|
3140
|
+
expect(result.level).toBe('warning');
|
|
3141
|
+
expect(result.icon).toBe('⚡');
|
|
3142
|
+
expect(result.totalBytes).toBe(50 * 1024 * 1024 * 1024);
|
|
3143
|
+
expect(result.sources).toHaveLength(2);
|
|
3144
|
+
expect(result.hasMissingData).toBe(false);
|
|
3145
|
+
});
|
|
3146
|
+
|
|
3147
|
+
it('detects missing size data', () => {
|
|
3148
|
+
const estimate = {
|
|
3149
|
+
total_bytes: null,
|
|
3150
|
+
sources: [
|
|
3151
|
+
{
|
|
3152
|
+
source_name: 'source.sales',
|
|
3153
|
+
total_bytes: null,
|
|
3154
|
+
},
|
|
3155
|
+
{
|
|
3156
|
+
source_name: 'source.orders',
|
|
3157
|
+
total_bytes: undefined,
|
|
3158
|
+
},
|
|
3159
|
+
],
|
|
3160
|
+
};
|
|
3161
|
+
|
|
3162
|
+
const result = formatScanEstimate(estimate);
|
|
3163
|
+
expect(result).not.toBeNull();
|
|
3164
|
+
expect(result.level).toBe('unknown');
|
|
3165
|
+
expect(result.icon).toBe('ℹ️');
|
|
3166
|
+
expect(result.hasMissingData).toBe(true);
|
|
3167
|
+
});
|
|
3168
|
+
|
|
3169
|
+
it('shows critical icon for very large scans', () => {
|
|
3170
|
+
const estimate = {
|
|
3171
|
+
total_bytes: 200 * 1024 * 1024 * 1024, // 200GB
|
|
3172
|
+
sources: [
|
|
3173
|
+
{
|
|
3174
|
+
source_name: 'source.large_table',
|
|
3175
|
+
total_bytes: 200 * 1024 * 1024 * 1024,
|
|
3176
|
+
},
|
|
3177
|
+
],
|
|
3178
|
+
};
|
|
3179
|
+
|
|
3180
|
+
const result = formatScanEstimate(estimate);
|
|
3181
|
+
expect(result.level).toBe('critical');
|
|
3182
|
+
expect(result.icon).toBe('⚠️');
|
|
3183
|
+
});
|
|
3184
|
+
|
|
3185
|
+
it('shows ok icon for small scans', () => {
|
|
3186
|
+
const estimate = {
|
|
3187
|
+
total_bytes: 1 * 1024 * 1024 * 1024, // 1GB
|
|
3188
|
+
sources: [
|
|
3189
|
+
{
|
|
3190
|
+
source_name: 'source.small_table',
|
|
3191
|
+
total_bytes: 1 * 1024 * 1024 * 1024,
|
|
3192
|
+
},
|
|
3193
|
+
],
|
|
3194
|
+
};
|
|
3195
|
+
|
|
3196
|
+
const result = formatScanEstimate(estimate);
|
|
3197
|
+
expect(result.level).toBe('ok');
|
|
3198
|
+
expect(result.icon).toBe('✓');
|
|
3199
|
+
});
|
|
3200
|
+
});
|
|
3201
|
+
|
|
3202
|
+
describe('getStatusInfo', () => {
|
|
3203
|
+
it('returns not-planned status for preagg without workflows', () => {
|
|
3204
|
+
const result = getStatusInfo({
|
|
3205
|
+
workflow_urls: [],
|
|
3206
|
+
});
|
|
3207
|
+
|
|
3208
|
+
expect(result.text).toBe('Not planned');
|
|
3209
|
+
expect(result.className).toBe('status-not-planned');
|
|
3210
|
+
expect(result.icon).toBe('○');
|
|
3211
|
+
});
|
|
3212
|
+
|
|
3213
|
+
it('returns not-planned status for null/undefined preagg', () => {
|
|
3214
|
+
expect(getStatusInfo(null).text).toBe('Not planned');
|
|
3215
|
+
expect(getStatusInfo(undefined).text).toBe('Not planned');
|
|
3216
|
+
});
|
|
3217
|
+
|
|
3218
|
+
it('returns compatible status for superset preagg with availability', () => {
|
|
3219
|
+
const result = getStatusInfo({
|
|
3220
|
+
_isCompatible: true,
|
|
3221
|
+
grain_columns: ['v3.date.id', 'v3.customer.name'],
|
|
3222
|
+
availability: { valid_through_ts: '2024-01-01' },
|
|
3223
|
+
workflow_urls: ['http://workflow.com/1'],
|
|
3224
|
+
});
|
|
3225
|
+
|
|
3226
|
+
expect(result.text).toContain('Covered');
|
|
3227
|
+
expect(result.text).toContain('id, name');
|
|
3228
|
+
expect(result.className).toBe('status-compatible-materialized');
|
|
3229
|
+
expect(result.isCompatible).toBe(true);
|
|
3230
|
+
});
|
|
3231
|
+
|
|
3232
|
+
it('returns compatible status for superset preagg without availability', () => {
|
|
3233
|
+
const result = getStatusInfo({
|
|
3234
|
+
_isCompatible: true,
|
|
3235
|
+
grain_columns: ['v3.date.id', 'v3.region.code'],
|
|
3236
|
+
availability: null,
|
|
3237
|
+
workflow_urls: ['http://workflow.com/1'],
|
|
3238
|
+
});
|
|
3239
|
+
|
|
3240
|
+
expect(result.text).toContain('Covered');
|
|
3241
|
+
expect(result.text).toContain('id, code');
|
|
3242
|
+
expect(result.className).toBe('status-compatible');
|
|
3243
|
+
expect(result.isCompatible).toBe(true);
|
|
3244
|
+
});
|
|
3245
|
+
});
|
|
3246
|
+
|
|
3247
|
+
describe('inferGranularity', () => {
|
|
3248
|
+
it('infers HOUR granularity from hour columns', () => {
|
|
3249
|
+
const grainGroups = [
|
|
3250
|
+
{
|
|
3251
|
+
grain: ['date_hour', 'customer_id'],
|
|
3252
|
+
},
|
|
3253
|
+
];
|
|
3254
|
+
expect(inferGranularity(grainGroups)).toBe('HOUR');
|
|
3255
|
+
});
|
|
3256
|
+
|
|
3257
|
+
it('infers HOUR granularity from hour suffix', () => {
|
|
3258
|
+
const grainGroups = [
|
|
3259
|
+
{
|
|
3260
|
+
grain: ['v3.date.hour_of_day', 'region'],
|
|
3261
|
+
},
|
|
3262
|
+
];
|
|
3263
|
+
expect(inferGranularity(grainGroups)).toBe('HOUR');
|
|
3264
|
+
});
|
|
3265
|
+
|
|
3266
|
+
it('defaults to DAY granularity', () => {
|
|
3267
|
+
const grainGroups = [
|
|
3268
|
+
{
|
|
3269
|
+
grain: ['date_id', 'customer_id'],
|
|
3270
|
+
},
|
|
3271
|
+
];
|
|
3272
|
+
expect(inferGranularity(grainGroups)).toBe('DAY');
|
|
3273
|
+
});
|
|
3274
|
+
|
|
3275
|
+
it('handles empty grain groups', () => {
|
|
3276
|
+
expect(inferGranularity([])).toBe('DAY');
|
|
3277
|
+
expect(inferGranularity(null)).toBe('DAY');
|
|
3278
|
+
});
|
|
3279
|
+
});
|
|
3280
|
+
});
|
|
3281
|
+
|
|
3282
|
+
// ============================================================================
|
|
3283
|
+
// Scan Estimation UI Tests
|
|
3284
|
+
// ============================================================================
|
|
3285
|
+
|
|
3286
|
+
describe('Scan Estimation Features', () => {
|
|
3287
|
+
const propsWithScanEstimate = {
|
|
3288
|
+
measuresResult: mockMeasuresResult,
|
|
3289
|
+
metricsResult: {
|
|
3290
|
+
sql: 'SELECT * FROM orders',
|
|
3291
|
+
scan_estimate: {
|
|
3292
|
+
total_bytes: 50 * 1024 * 1024 * 1024, // 50GB
|
|
3293
|
+
sources: [
|
|
3294
|
+
{
|
|
3295
|
+
source_name: 'source.sales_fact',
|
|
3296
|
+
catalog: 'default',
|
|
3297
|
+
schema_: 'prod',
|
|
3298
|
+
table: 'sales',
|
|
3299
|
+
total_bytes: 30 * 1024 * 1024 * 1024,
|
|
3300
|
+
partition_columns: ['utc_date'],
|
|
3301
|
+
total_partition_count: 365,
|
|
3302
|
+
},
|
|
3303
|
+
{
|
|
3304
|
+
source_name: 'source.customers',
|
|
3305
|
+
total_bytes: 20 * 1024 * 1024 * 1024,
|
|
3306
|
+
partition_columns: [],
|
|
3307
|
+
total_partition_count: null,
|
|
3308
|
+
},
|
|
3309
|
+
],
|
|
3310
|
+
has_materialization: false,
|
|
3311
|
+
},
|
|
3312
|
+
},
|
|
3313
|
+
selectedMetrics: ['default.num_repair_orders'],
|
|
3314
|
+
selectedDimensions: ['v3.date.id'],
|
|
3315
|
+
loadingMetrics: false,
|
|
3316
|
+
errorMetrics: null,
|
|
3317
|
+
loadingMeasures: false,
|
|
3318
|
+
errorMeasures: null,
|
|
3319
|
+
onToggleMaterialization: jest.fn(),
|
|
3320
|
+
onUpdateCubeConfig: jest.fn(),
|
|
3321
|
+
onClearWorkflowUrls: jest.fn(),
|
|
3322
|
+
};
|
|
3323
|
+
|
|
3324
|
+
it('displays scan estimate banner with warning level', async () => {
|
|
3325
|
+
renderWithRouter(<QueryOverviewPanel {...propsWithScanEstimate} />);
|
|
3326
|
+
|
|
3327
|
+
await waitFor(() => {
|
|
3328
|
+
expect(screen.getByText(/50 GB/)).toBeInTheDocument();
|
|
3329
|
+
});
|
|
3330
|
+
});
|
|
3331
|
+
|
|
3332
|
+
it('shows scan estimate icon based on size', async () => {
|
|
3333
|
+
const criticalProps = {
|
|
3334
|
+
...propsWithScanEstimate,
|
|
3335
|
+
metricsResult: {
|
|
3336
|
+
...propsWithScanEstimate.metricsResult,
|
|
3337
|
+
scan_estimate: {
|
|
3338
|
+
...propsWithScanEstimate.metricsResult.scan_estimate,
|
|
3339
|
+
total_bytes: 200 * 1024 * 1024 * 1024, // 200GB - critical
|
|
3340
|
+
},
|
|
3341
|
+
},
|
|
3342
|
+
};
|
|
3343
|
+
|
|
3344
|
+
renderWithRouter(<QueryOverviewPanel {...criticalProps} />);
|
|
3345
|
+
|
|
3346
|
+
await waitFor(() => {
|
|
3347
|
+
expect(screen.getByText(/200 GB/)).toBeInTheDocument();
|
|
3348
|
+
});
|
|
3349
|
+
});
|
|
3350
|
+
|
|
3351
|
+
it('handles scan estimate with missing size data', async () => {
|
|
3352
|
+
const propsWithMissingData = {
|
|
3353
|
+
...propsWithScanEstimate,
|
|
3354
|
+
metricsResult: {
|
|
3355
|
+
...propsWithScanEstimate.metricsResult,
|
|
3356
|
+
scan_estimate: {
|
|
3357
|
+
total_bytes: null,
|
|
3358
|
+
sources: [
|
|
3359
|
+
{
|
|
3360
|
+
source_name: 'source.unknown',
|
|
3361
|
+
total_bytes: null,
|
|
3362
|
+
partition_columns: [],
|
|
3363
|
+
total_partition_count: null,
|
|
3364
|
+
},
|
|
3365
|
+
],
|
|
3366
|
+
has_materialization: false,
|
|
3367
|
+
},
|
|
3368
|
+
},
|
|
3369
|
+
};
|
|
3370
|
+
|
|
3371
|
+
renderWithRouter(<QueryOverviewPanel {...propsWithMissingData} />);
|
|
3372
|
+
|
|
3373
|
+
// Should still render but show unknown/info icon
|
|
3374
|
+
await waitFor(() => {
|
|
3375
|
+
expect(screen.getByText(/Generated SQL/)).toBeInTheDocument();
|
|
3376
|
+
});
|
|
3377
|
+
});
|
|
3378
|
+
|
|
3379
|
+
it('toggles between optimized and raw SQL views', async () => {
|
|
3380
|
+
const mockFetchRawSql = jest.fn().mockResolvedValue({
|
|
3381
|
+
sql: 'SELECT * FROM raw_tables',
|
|
3382
|
+
scan_estimate: {
|
|
3383
|
+
total_bytes: 100 * 1024 * 1024 * 1024, // 100GB
|
|
3384
|
+
sources: [
|
|
3385
|
+
{
|
|
3386
|
+
source_name: 'source.large_raw_table',
|
|
3387
|
+
total_bytes: 100 * 1024 * 1024 * 1024,
|
|
3388
|
+
},
|
|
3389
|
+
],
|
|
3390
|
+
},
|
|
3391
|
+
});
|
|
3392
|
+
|
|
3393
|
+
renderWithRouter(
|
|
3394
|
+
<QueryOverviewPanel
|
|
3395
|
+
{...propsWithScanEstimate}
|
|
3396
|
+
onFetchRawSql={mockFetchRawSql}
|
|
3397
|
+
/>,
|
|
3398
|
+
);
|
|
3399
|
+
|
|
3400
|
+
// Click raw SQL toggle
|
|
3401
|
+
const rawButton = screen.getByText('Raw');
|
|
3402
|
+
await act(async () => {
|
|
3403
|
+
fireEvent.click(rawButton);
|
|
3404
|
+
});
|
|
3405
|
+
|
|
3406
|
+
await waitFor(() => {
|
|
3407
|
+
expect(mockFetchRawSql).toHaveBeenCalled();
|
|
3408
|
+
});
|
|
3409
|
+
});
|
|
3410
|
+
|
|
3411
|
+
it('does not show scan estimate when not provided', async () => {
|
|
3412
|
+
const propsWithoutScan = {
|
|
3413
|
+
...propsWithScanEstimate,
|
|
3414
|
+
metricsResult: {
|
|
3415
|
+
sql: 'SELECT * FROM orders',
|
|
3416
|
+
// No scan_estimate field
|
|
3417
|
+
},
|
|
3418
|
+
};
|
|
3419
|
+
|
|
3420
|
+
renderWithRouter(<QueryOverviewPanel {...propsWithoutScan} />);
|
|
3421
|
+
|
|
3422
|
+
await waitFor(() => {
|
|
3423
|
+
expect(screen.getByText(/Generated SQL/)).toBeInTheDocument();
|
|
3424
|
+
});
|
|
3425
|
+
|
|
3426
|
+
// Should not show scan estimate banner
|
|
3427
|
+
expect(screen.queryByText(/GB/)).not.toBeInTheDocument();
|
|
3428
|
+
});
|
|
3429
|
+
});
|
|
@@ -1074,7 +1074,7 @@ export function QueryPlannerPage() {
|
|
|
1074
1074
|
'',
|
|
1075
1075
|
false, // useMaterialized = false for raw SQL
|
|
1076
1076
|
);
|
|
1077
|
-
return result
|
|
1077
|
+
return result; // Return full result including scan_estimate
|
|
1078
1078
|
} catch (err) {
|
|
1079
1079
|
console.error('Failed to fetch raw SQL:', err);
|
|
1080
1080
|
return null;
|
|
@@ -1540,6 +1540,87 @@
|
|
|
1540
1540
|
background: #2563eb;
|
|
1541
1541
|
}
|
|
1542
1542
|
|
|
1543
|
+
/* Scan Estimate Banner */
|
|
1544
|
+
.scan-estimate-banner {
|
|
1545
|
+
display: flex;
|
|
1546
|
+
align-items: flex-start;
|
|
1547
|
+
gap: 10px;
|
|
1548
|
+
margin: 0 16px 12px;
|
|
1549
|
+
padding: 12px;
|
|
1550
|
+
border-radius: var(--radius-sm);
|
|
1551
|
+
font-size: 12px;
|
|
1552
|
+
line-height: 1.4;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
.scan-estimate-icon {
|
|
1556
|
+
font-size: 16px;
|
|
1557
|
+
flex-shrink: 0;
|
|
1558
|
+
margin-top: 2px;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
.scan-estimate-content {
|
|
1562
|
+
flex: 1;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
.scan-estimate-header {
|
|
1566
|
+
margin-bottom: 8px;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
.scan-estimate-header strong {
|
|
1570
|
+
font-weight: 600;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
.scan-estimate-sources {
|
|
1574
|
+
display: flex;
|
|
1575
|
+
flex-direction: column;
|
|
1576
|
+
gap: 4px;
|
|
1577
|
+
margin-top: 8px;
|
|
1578
|
+
padding-top: 8px;
|
|
1579
|
+
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
.scan-source-item {
|
|
1583
|
+
display: flex;
|
|
1584
|
+
justify-content: space-between;
|
|
1585
|
+
align-items: center;
|
|
1586
|
+
gap: 12px;
|
|
1587
|
+
font-size: 11px;
|
|
1588
|
+
font-family: var(--font-display);
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
.scan-source-name {
|
|
1592
|
+
flex: 1;
|
|
1593
|
+
color: var(--planner-text-muted);
|
|
1594
|
+
overflow: hidden;
|
|
1595
|
+
text-overflow: ellipsis;
|
|
1596
|
+
white-space: nowrap;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
.scan-source-size {
|
|
1600
|
+
flex-shrink: 0;
|
|
1601
|
+
font-weight: 600;
|
|
1602
|
+
opacity: 0.9;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
/* Scan estimate warning levels */
|
|
1606
|
+
.scan-estimate-ok {
|
|
1607
|
+
background: rgba(5, 150, 105, 0.08);
|
|
1608
|
+
border: 1px solid rgba(5, 150, 105, 0.2);
|
|
1609
|
+
color: var(--accent-success);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
.scan-estimate-warning {
|
|
1613
|
+
background: rgba(217, 119, 6, 0.08);
|
|
1614
|
+
border: 1px solid rgba(217, 119, 6, 0.25);
|
|
1615
|
+
color: var(--accent-warning);
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
.scan-estimate-critical {
|
|
1619
|
+
background: rgba(220, 38, 38, 0.08);
|
|
1620
|
+
border: 1px solid rgba(220, 38, 38, 0.25);
|
|
1621
|
+
color: var(--accent-error);
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1543
1624
|
.sql-code-wrapper {
|
|
1544
1625
|
padding: 0 16px 16px;
|
|
1545
1626
|
}
|
|
@@ -1689,6 +1770,49 @@
|
|
|
1689
1770
|
white-space: nowrap;
|
|
1690
1771
|
}
|
|
1691
1772
|
|
|
1773
|
+
/* Scan estimate styling */
|
|
1774
|
+
.preagg-summary-row .value.scan-estimate {
|
|
1775
|
+
display: flex;
|
|
1776
|
+
align-items: center;
|
|
1777
|
+
gap: 4px;
|
|
1778
|
+
font-weight: 500;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
.scan-estimate-icon {
|
|
1782
|
+
font-size: 12px;
|
|
1783
|
+
display: inline-block;
|
|
1784
|
+
min-width: 14px;
|
|
1785
|
+
text-align: center;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
.scan-estimate-hint {
|
|
1789
|
+
font-size: 10px;
|
|
1790
|
+
color: var(--planner-text-dim);
|
|
1791
|
+
font-style: italic;
|
|
1792
|
+
font-weight: 400;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
.scan-estimate-low {
|
|
1796
|
+
color: #059669;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
.scan-estimate-medium {
|
|
1800
|
+
color: #d97706;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
.scan-estimate-high {
|
|
1804
|
+
color: #dc2626;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
.scan-estimate-critical {
|
|
1808
|
+
color: #991b1b;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
.scan-estimate-unavailable {
|
|
1812
|
+
color: var(--planner-text-dim);
|
|
1813
|
+
font-style: italic;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1692
1816
|
.preagg-summary-status {
|
|
1693
1817
|
display: flex;
|
|
1694
1818
|
align-items: center;
|