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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "datajunction-ui",
3
- "version": "0.0.70",
3
+ "version": "0.0.72",
4
4
  "description": "DataJunction UI",
5
5
  "module": "src/index.tsx",
6
6
  "repository": {
@@ -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
- defaultValue={defaultValue}
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={{ selectedOption: '' }} onSubmit={jest.fn()}>
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={{ selectedOption: '' }} onSubmit={jest.fn()}>
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).then(_ => {
76
- window.scrollTo({ top: 0, left: 0, behavior: 'smooth' });
77
- setSubmitting(false);
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
- useEffect(() => {
51
- const fetchData = async () => {
52
- if (node) {
53
- const data = await djClient.materializations(node.name);
54
-
55
- // Store raw data
56
- setRawMaterializations(data);
57
-
58
- // Fetch availability states
59
- const availabilityData = await djClient.availabilityStates(node.name);
60
- setAvailabilityStates(availabilityData);
61
-
62
- // Group availability states by version
63
- const availabilityGrouped = availabilityData.reduce((acc, avail) => {
64
- const version = avail.node_version || node.version;
65
- if (!acc[version]) {
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
- Object.keys(materializationsByRevision).length > 0
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 = Object.keys(materializationsByRevision).sort(
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 active materializations
197
- const hasLatestVersionMaterialization =
198
- materializationsByRevision[node?.version] &&
199
- materializationsByRevision[node?.version].length > 0;
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 create a new version of the cube and build new materialization workflows. The previous version of the cube and its materialization will be accessible using a specific version label. Would you like to continue?',
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="Create a new version of the cube and re-create its materialization workflows."
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 [rawSql, setRawSql] = useState(null);
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' && !rawSql && onFetchRawSql) {
432
+ if (mode === 'raw' && !rawResult && onFetchRawSql) {
373
433
  setLoadingRawSql(true);
374
- const sql = await onFetchRawSql();
375
- setRawSql(sql);
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-info-row">
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' ? rawSql || 'Loading...' : sql}
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.sql;
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;