datajunction-ui 0.0.26-alpha.0 → 0.0.27-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/package.json +2 -2
  2. package/src/app/components/Search.jsx +41 -33
  3. package/src/app/components/__tests__/Search.test.jsx +46 -11
  4. package/src/app/index.tsx +1 -1
  5. package/src/app/pages/AddEditNodePage/MetricQueryField.jsx +57 -8
  6. package/src/app/pages/AddEditNodePage/UpstreamNodeField.jsx +17 -5
  7. package/src/app/pages/AddEditNodePage/__tests__/index.test.jsx +97 -1
  8. package/src/app/pages/AddEditNodePage/index.jsx +61 -17
  9. package/src/app/pages/NodePage/WatchNodeButton.jsx +12 -5
  10. package/src/app/pages/QueryPlannerPage/MetricFlowGraph.jsx +93 -15
  11. package/src/app/pages/QueryPlannerPage/PreAggDetailsPanel.jsx +2320 -65
  12. package/src/app/pages/QueryPlannerPage/SelectionPanel.jsx +234 -25
  13. package/src/app/pages/QueryPlannerPage/__tests__/MetricFlowGraph.test.jsx +315 -122
  14. package/src/app/pages/QueryPlannerPage/__tests__/PreAggDetailsPanel.test.jsx +2672 -314
  15. package/src/app/pages/QueryPlannerPage/__tests__/SelectionPanel.test.jsx +567 -0
  16. package/src/app/pages/QueryPlannerPage/__tests__/index.test.jsx +480 -55
  17. package/src/app/pages/QueryPlannerPage/index.jsx +1021 -14
  18. package/src/app/pages/QueryPlannerPage/styles.css +1990 -62
  19. package/src/app/pages/Root/__tests__/index.test.jsx +79 -8
  20. package/src/app/pages/Root/index.tsx +1 -1
  21. package/src/app/pages/SQLBuilderPage/__tests__/index.test.jsx +82 -0
  22. package/src/app/pages/SettingsPage/__tests__/CreateServiceAccountModal.test.jsx +37 -0
  23. package/src/app/pages/SettingsPage/__tests__/ServiceAccountsSection.test.jsx +48 -0
  24. package/src/app/pages/SettingsPage/__tests__/index.test.jsx +169 -1
  25. package/src/app/services/DJService.js +492 -3
  26. package/src/app/services/__tests__/DJService.test.jsx +582 -0
  27. package/src/mocks/mockNodes.jsx +36 -0
  28. package/webpack.config.js +27 -0
@@ -1,3 +1,4 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
1
2
  import { Link } from 'react-router-dom';
2
3
  import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
3
4
  import { atomOneLight } from 'react-syntax-highlighter/src/styles/hljs';
@@ -18,21 +19,534 @@ function getDimensionNodeName(dimPath) {
18
19
  return pathWithoutRole;
19
20
  }
20
21
 
22
+ /**
23
+ * Helper to normalize grain columns to short names for lookup key
24
+ */
25
+ function normalizeGrain(grainCols) {
26
+ return (grainCols || [])
27
+ .map(col => col.split('.').pop())
28
+ .sort()
29
+ .join(',');
30
+ }
31
+
32
+ /**
33
+ * Helper to get a human-readable schedule summary from cron expression
34
+ */
35
+ function getScheduleSummary(schedule) {
36
+ if (!schedule) return null;
37
+
38
+ // Basic cron parsing for common patterns
39
+ const parts = schedule.split(' ');
40
+ if (parts.length < 5) return schedule;
41
+
42
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
43
+
44
+ // Daily at specific hour
45
+ if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
46
+ const hourNum = parseInt(hour, 10);
47
+ const minuteNum = parseInt(minute, 10);
48
+ if (!isNaN(hourNum) && !isNaN(minuteNum)) {
49
+ const period = hourNum >= 12 ? 'pm' : 'am';
50
+ const displayHour = hourNum > 12 ? hourNum - 12 : hourNum || 12;
51
+ const displayMinute = minuteNum.toString().padStart(2, '0');
52
+ return `Daily @ ${displayHour}:${displayMinute}${period}`;
53
+ }
54
+ }
55
+
56
+ // Weekly
57
+ if (dayOfMonth === '*' && month === '*' && dayOfWeek !== '*') {
58
+ const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
59
+ const dayNum = parseInt(dayOfWeek, 10);
60
+ if (!isNaN(dayNum) && dayNum >= 0 && dayNum <= 6) {
61
+ return `Weekly on ${days[dayNum]}`;
62
+ }
63
+ }
64
+
65
+ return schedule;
66
+ }
67
+
68
+ /**
69
+ * Helper to get status display info
70
+ */
71
+ function getStatusInfo(preagg) {
72
+ if (!preagg || preagg.workflow_urls?.length === 0) {
73
+ return {
74
+ icon: '○',
75
+ text: 'Not planned',
76
+ className: 'status-not-planned',
77
+ color: '#94a3b8',
78
+ };
79
+ }
80
+
81
+ // Check if this is a compatible (superset) pre-agg, not exact match
82
+ if (preagg._isCompatible) {
83
+ // Show that this grain is covered by an existing pre-agg with more dimensions
84
+ const preaggGrain = preagg.grain_columns
85
+ ?.map(g => g.split('.').pop())
86
+ .join(', ');
87
+
88
+ // Check if that compatible pre-agg has data
89
+ if (preagg.availability || preagg.status === 'active') {
90
+ return {
91
+ icon: '✓',
92
+ text: `Covered (${preaggGrain})`,
93
+ className: 'status-compatible-materialized',
94
+ color: '#059669',
95
+ isCompatible: true,
96
+ };
97
+ }
98
+ return {
99
+ icon: '◐',
100
+ text: `Covered (${preaggGrain})`,
101
+ className: 'status-compatible',
102
+ color: '#d97706',
103
+ isCompatible: true,
104
+ };
105
+ }
106
+
107
+ // Check for running status (job is in progress)
108
+ if (preagg.status === 'running') {
109
+ return {
110
+ icon: '◉',
111
+ text: 'Running',
112
+ className: 'status-running',
113
+ color: '#2563eb',
114
+ };
115
+ }
116
+
117
+ // Check availability for materialization status (active = has data)
118
+ if (preagg.availability || preagg.status === 'active') {
119
+ return {
120
+ icon: '●',
121
+ text: 'Materialized',
122
+ className: 'status-materialized',
123
+ color: '#059669',
124
+ };
125
+ }
126
+
127
+ // Check if workflow is active
128
+ if (preagg.workflow_status === 'active') {
129
+ return {
130
+ icon: '◐',
131
+ text: 'Workflow Active',
132
+ className: 'status-workflow-active',
133
+ color: '#2563eb',
134
+ };
135
+ }
136
+
137
+ // Check if workflow is paused
138
+ if (preagg.workflow_status === 'paused') {
139
+ return {
140
+ icon: '◐',
141
+ text: 'Workflow Paused',
142
+ className: 'status-workflow-paused',
143
+ color: '#94a3b8',
144
+ };
145
+ }
146
+
147
+ // Check if workflow is being configured (has URLs but not yet active)
148
+ // Only show "Pending" if workflows exist but aren't active yet
149
+ if (preagg.workflow_urls?.length > 0) {
150
+ return {
151
+ icon: '◐',
152
+ text: 'Pending',
153
+ className: 'status-pending',
154
+ color: '#d97706',
155
+ };
156
+ }
157
+
158
+ // No workflow configured - show "Not planned"
159
+ return {
160
+ icon: '○',
161
+ text: 'Not planned',
162
+ className: 'status-not-planned',
163
+ color: '#94a3b8',
164
+ };
165
+ }
166
+
21
167
  /**
22
168
  * QueryOverviewPanel - Default view showing metrics SQL and pre-agg summary
23
169
  *
24
170
  * Shown when no node is selected in the graph
25
171
  */
172
+ /**
173
+ * Get recommended schedule based on granularity
174
+ */
175
+ function getRecommendedSchedule(granularity) {
176
+ switch (granularity?.toUpperCase()) {
177
+ case 'HOUR':
178
+ return { cron: '0 * * * *', label: 'Hourly' };
179
+ case 'DAY':
180
+ default:
181
+ return { cron: '0 6 * * *', label: 'Daily at 6:00 AM' };
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Get granularity hint from grain columns
187
+ */
188
+ function inferGranularity(grainGroups) {
189
+ for (const gg of grainGroups || []) {
190
+ for (const col of gg.grain || []) {
191
+ const colName = col.split('.').pop().toLowerCase();
192
+ if (colName.includes('hour')) return 'HOUR';
193
+ }
194
+ }
195
+ return 'DAY'; // Default
196
+ }
197
+
198
+ /**
199
+ * CubeBackfillModal - Simple modal to collect start/end dates for backfill
200
+ */
201
+ function CubeBackfillModal({ onClose, onSubmit, loading }) {
202
+ // Default to last 7 days
203
+ const today = new Date().toISOString().split('T')[0];
204
+ const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
205
+ .toISOString()
206
+ .split('T')[0];
207
+
208
+ const [startDate, setStartDate] = useState(weekAgo);
209
+ const [endDate, setEndDate] = useState(today);
210
+
211
+ return (
212
+ <div className="backfill-modal-overlay" onClick={onClose}>
213
+ <div className="backfill-modal" onClick={e => e.stopPropagation()}>
214
+ <div className="backfill-modal-header">
215
+ <h3>Run Cube Backfill</h3>
216
+ <button className="modal-close" onClick={onClose}>
217
+ ×
218
+ </button>
219
+ </div>
220
+ <div className="backfill-modal-body">
221
+ <p className="backfill-description">
222
+ Run a backfill for the specified date range:
223
+ </p>
224
+ <div className="backfill-date-inputs">
225
+ <div className="date-input-group">
226
+ <label htmlFor="backfill-start">Start Date</label>
227
+ <input
228
+ id="backfill-start"
229
+ type="date"
230
+ value={startDate}
231
+ onChange={e => setStartDate(e.target.value)}
232
+ disabled={loading}
233
+ />
234
+ </div>
235
+ <div className="date-input-group">
236
+ <label htmlFor="backfill-end">End Date</label>
237
+ <input
238
+ id="backfill-end"
239
+ type="date"
240
+ value={endDate}
241
+ onChange={e => setEndDate(e.target.value)}
242
+ disabled={loading}
243
+ />
244
+ </div>
245
+ </div>
246
+ </div>
247
+ <div className="backfill-modal-actions">
248
+ <button
249
+ className="action-btn action-btn-secondary"
250
+ onClick={onClose}
251
+ disabled={loading}
252
+ >
253
+ Cancel
254
+ </button>
255
+ <button
256
+ className="action-btn action-btn-primary"
257
+ disabled={loading || !startDate}
258
+ onClick={() => onSubmit(startDate, endDate || null)}
259
+ >
260
+ {loading ? (
261
+ <>
262
+ <span className="spinner" /> Starting...
263
+ </>
264
+ ) : (
265
+ 'Start Backfill'
266
+ )}
267
+ </button>
268
+ </div>
269
+ </div>
270
+ </div>
271
+ );
272
+ }
273
+
26
274
  export function QueryOverviewPanel({
27
275
  measuresResult,
28
276
  metricsResult,
29
277
  selectedMetrics,
30
278
  selectedDimensions,
279
+ plannedPreaggs = {},
280
+ onPlanMaterialization,
281
+ onUpdateConfig,
282
+ onCreateWorkflow,
283
+ onRunBackfill,
284
+ onRunAdhoc,
285
+ onFetchRawSql,
286
+ onSetPartition,
287
+ onRefreshMeasures,
288
+ onFetchNodePartitions,
289
+ materializationError,
290
+ onClearError,
291
+ workflowUrls = [],
292
+ onClearWorkflowUrls,
293
+ loadedCubeName = null, // Existing cube name if loaded from preset
294
+ cubeMaterialization = null, // Full cube materialization info {schedule, strategy, lookbackWindow, ...}
295
+ onUpdateCubeConfig,
296
+ onRefreshCubeWorkflow,
297
+ onRunCubeBackfill,
298
+ onDeactivatePreaggWorkflow,
299
+ onDeactivateCubeWorkflow,
31
300
  }) {
301
+ // Extract default namespace from the first selected metric (e.g., "v3.total_revenue" -> "v3")
302
+ const getDefaultNamespace = useCallback(() => {
303
+ if (selectedMetrics && selectedMetrics.length > 0) {
304
+ const firstMetric = selectedMetrics[0];
305
+ const parts = firstMetric.split('.');
306
+ // Take all parts except the last one (the metric name)
307
+ if (parts.length > 1) {
308
+ return parts.slice(0, -1).join('.');
309
+ }
310
+ }
311
+ return 'default';
312
+ }, [selectedMetrics]);
313
+
314
+ const [expandedCards, setExpandedCards] = useState({});
315
+ const [configuringCard, setConfiguringCard] = useState(null); // '__all__' for section-level
316
+ const [editingCard, setEditingCard] = useState(null); // grainKey of existing card being edited
317
+ const [editingCube, setEditingCube] = useState(false); // Whether cube config form is open
318
+ const [cubeBackfillModal, setCubeBackfillModal] = useState(false); // Cube backfill modal state
319
+ const [cubeConfigForm, setCubeConfigForm] = useState({
320
+ strategy: 'incremental_time',
321
+ schedule: '0 6 * * *',
322
+ lookbackWindow: '1 DAY',
323
+ });
324
+ const [isSavingCube, setIsSavingCube] = useState(false);
325
+
326
+ // Partition setup form state (for setting up temporal partition inline)
327
+ // Map of nodeName -> { column, granularity, format }
328
+ const [showPartitionSetup, setShowPartitionSetup] = useState(false);
329
+ const [partitionForms, setPartitionForms] = useState({});
330
+ const [settingPartitionFor, setSettingPartitionFor] = useState(null); // nodeName being set
331
+ const [partitionErrors, setPartitionErrors] = useState({}); // nodeName -> error
332
+
333
+ // Actual temporal partitions from source nodes (fetched via API)
334
+ // Map of nodeName -> { columns, temporalPartitions }
335
+ const [allNodePartitions, setAllNodePartitions] = useState({});
336
+ const [partitionsLoading, setPartitionsLoading] = useState(false);
337
+
338
+ // Enhanced config form state
339
+ const [configForm, setConfigForm] = useState({
340
+ strategy: 'incremental_time',
341
+ backfillFrom: '',
342
+ backfillTo: 'today', // 'today' or specific date
343
+ backfillToDate: '',
344
+ continueAfterBackfill: true,
345
+ schedule: '',
346
+ scheduleType: 'auto', // 'auto' or 'custom'
347
+ lookbackWindow: '1 day',
348
+ // Druid cube materialization settings
349
+ enableDruidCube: true,
350
+ druidCubeNamespace: '', // Will be initialized with user's namespace
351
+ druidCubeName: '', // Short name without namespace
352
+ });
353
+ const [isSaving, setIsSaving] = useState(false);
354
+ const [loadingAction, setLoadingAction] = useState(null); // Track which action is loading: 'workflow', 'backfill', 'trigger'
355
+
356
+ // Backfill modal state (for existing pre-aggs)
357
+ const [backfillModal, setBackfillModal] = useState(null);
358
+
359
+ // Toast state for job URLs
360
+ const [toastMessage, setToastMessage] = useState(null);
361
+
362
+ // SQL view toggle state: 'optimized' (uses pre-aggs) or 'raw' (from source tables)
363
+ const [sqlViewMode, setSqlViewMode] = useState('optimized');
364
+ const [rawSql, setRawSql] = useState(null);
365
+ const [loadingRawSql, setLoadingRawSql] = useState(false);
366
+
367
+ // Handle SQL view toggle
368
+ const handleSqlViewToggle = async mode => {
369
+ setSqlViewMode(mode);
370
+ // Fetch raw SQL lazily when switching to raw mode
371
+ if (mode === 'raw' && !rawSql && onFetchRawSql) {
372
+ setLoadingRawSql(true);
373
+ const sql = await onFetchRawSql();
374
+ setRawSql(sql);
375
+ setLoadingRawSql(false);
376
+ }
377
+ };
378
+
379
+ // Get unique parent nodes from grain groups
380
+ const getUniqueParentNodes = useCallback(() => {
381
+ const grainGroups = measuresResult?.grain_groups || [];
382
+ const uniqueNodes = new Set();
383
+ grainGroups.forEach(gg => {
384
+ if (gg.parent_name) uniqueNodes.add(gg.parent_name);
385
+ });
386
+ return Array.from(uniqueNodes);
387
+ }, [measuresResult?.grain_groups]);
388
+
389
+ // Fetch actual partition info for ALL source nodes when config form opens
390
+ // Returns a map of nodeName -> { columns, temporalPartitions }
391
+ const fetchAllNodePartitions = useCallback(async () => {
392
+ const parentNodes = getUniqueParentNodes();
393
+ if (parentNodes.length === 0 || !onFetchNodePartitions) {
394
+ setAllNodePartitions({});
395
+ return {};
396
+ }
397
+
398
+ setPartitionsLoading(true);
399
+ try {
400
+ const results = {};
401
+ // Fetch partitions for all nodes in parallel
402
+ const promises = parentNodes.map(async nodeName => {
403
+ try {
404
+ const result = await onFetchNodePartitions(nodeName);
405
+ results[nodeName] = result;
406
+ } catch (err) {
407
+ console.error(`Failed to fetch partitions for ${nodeName}:`, err);
408
+ results[nodeName] = { columns: [], temporalPartitions: [] };
409
+ }
410
+ });
411
+ await Promise.all(promises);
412
+ console.log('[fetchAllNodePartitions] results:', results);
413
+ setAllNodePartitions(results);
414
+ return results;
415
+ } catch (err) {
416
+ console.error('Failed to fetch node partitions:', err);
417
+ setAllNodePartitions({});
418
+ return {};
419
+ } finally {
420
+ setPartitionsLoading(false);
421
+ }
422
+ }, [getUniqueParentNodes, onFetchNodePartitions]);
423
+
424
+ // Check if ALL parent nodes have temporal partitions
425
+ const hasActualTemporalPartitions = useCallback(() => {
426
+ const parentNodes = getUniqueParentNodes();
427
+ if (parentNodes.length === 0) return false;
428
+
429
+ // All nodes must have temporal partitions
430
+ const allHaveTemporal = parentNodes.every(nodeName => {
431
+ const nodePartitions = allNodePartitions[nodeName];
432
+ return nodePartitions?.temporalPartitions?.length > 0;
433
+ });
434
+
435
+ console.log('[hasActualTemporalPartitions]', {
436
+ parentNodes,
437
+ allNodePartitions,
438
+ allHaveTemporal,
439
+ });
440
+ return allHaveTemporal;
441
+ }, [getUniqueParentNodes, allNodePartitions]);
442
+
443
+ // Get nodes that are missing temporal partitions
444
+ const getNodesMissingPartitions = useCallback(() => {
445
+ const parentNodes = getUniqueParentNodes();
446
+ return parentNodes.filter(nodeName => {
447
+ const nodePartitions = allNodePartitions[nodeName];
448
+ return !nodePartitions?.temporalPartitions?.length;
449
+ });
450
+ }, [getUniqueParentNodes, allNodePartitions]);
451
+
452
+ // Initialize config form with smart defaults when opening
453
+ const openConfigForm = async () => {
454
+ const grainGroups = measuresResult?.grain_groups || [];
455
+ const granularity = inferGranularity(grainGroups);
456
+ const recommended = getRecommendedSchedule(granularity);
457
+
458
+ // Default backfill start to 30 days ago
459
+ const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
460
+ .toISOString()
461
+ .split('T')[0];
462
+
463
+ // Fetch actual partition info for ALL source nodes
464
+ const partitionResults = await fetchAllNodePartitions();
465
+
466
+ // Check if ALL nodes have temporal partitions
467
+ const parentNodes = getUniqueParentNodes();
468
+ const allHaveTemporal = parentNodes.every(
469
+ nodeName => partitionResults[nodeName]?.temporalPartitions?.length > 0,
470
+ );
471
+ console.log(
472
+ '[openConfigForm] allHaveTemporal:',
473
+ allHaveTemporal,
474
+ 'partitionResults:',
475
+ partitionResults,
476
+ );
477
+
478
+ // Initialize partition forms for nodes missing partitions
479
+ const datePattern =
480
+ /date|time|day|month|year|hour|ds|dt|dateint|timestamp/i;
481
+ const initialForms = {};
482
+ parentNodes.forEach(nodeName => {
483
+ const nodePartitions = partitionResults[nodeName];
484
+ if (!nodePartitions?.temporalPartitions?.length) {
485
+ // Find best default column (date-like, unpartitioned)
486
+ const cols = (nodePartitions?.columns || []).filter(c => !c.partition);
487
+ const sortedCols = [...cols].sort((a, b) => {
488
+ const aIsDate = datePattern.test(a.name);
489
+ const bIsDate = datePattern.test(b.name);
490
+ if (aIsDate && !bIsDate) return -1;
491
+ if (!aIsDate && bIsDate) return 1;
492
+ return a.name.localeCompare(b.name);
493
+ });
494
+ initialForms[nodeName] = {
495
+ column: sortedCols[0]?.name || '',
496
+ granularity: 'day',
497
+ format: 'yyyyMMdd',
498
+ };
499
+ }
500
+ });
501
+ setPartitionForms(initialForms);
502
+ setPartitionErrors({});
503
+
504
+ setConfigForm({
505
+ strategy: allHaveTemporal ? 'incremental_time' : 'full',
506
+ runBackfill: true, // Option to skip backfill
507
+ backfillFrom: thirtyDaysAgo,
508
+ backfillTo: 'today',
509
+ backfillToDate: '',
510
+ continueAfterBackfill: true,
511
+ schedule: recommended.cron,
512
+ scheduleType: 'auto',
513
+ lookbackWindow: '1 day',
514
+ _recommendedSchedule: recommended, // Store for display
515
+ _granularity: granularity,
516
+ // Druid cube settings
517
+ enableDruidCube: true,
518
+ druidCubeNamespace: getDefaultNamespace(), // Default to first metric's namespace
519
+ druidCubeName: '', // Short name without namespace
520
+ });
521
+ setConfiguringCard('__all__');
522
+ };
523
+
524
+ // Helper to start editing an existing pre-agg's config
525
+ const startEditingConfig = (grainKey, existingPreagg) => {
526
+ setConfigForm({
527
+ strategy: existingPreagg.strategy || 'incremental_time',
528
+ backfillFrom: '',
529
+ backfillTo: 'today',
530
+ backfillToDate: '',
531
+ continueAfterBackfill: true,
532
+ schedule: existingPreagg.schedule || '',
533
+ scheduleType: existingPreagg.schedule ? 'custom' : 'auto',
534
+ lookbackWindow: existingPreagg.lookback_window || '',
535
+ });
536
+ setEditingCard(grainKey);
537
+ };
538
+
32
539
  const copyToClipboard = text => {
33
540
  navigator.clipboard.writeText(text);
34
541
  };
35
542
 
543
+ const toggleCardExpanded = cardKey => {
544
+ setExpandedCards(prev => ({
545
+ ...prev,
546
+ [cardKey]: !prev[cardKey],
547
+ }));
548
+ };
549
+
36
550
  // No selection yet
37
551
  if (!selectedMetrics?.length || !selectedDimensions?.length) {
38
552
  return (
@@ -65,11 +579,36 @@ export function QueryOverviewPanel({
65
579
  const metricFormulas = measuresResult.metric_formulas || [];
66
580
  const sql = metricsResult.sql || '';
67
581
 
582
+ // Determine if materialization is already configured (has active workflows)
583
+ const isMaterialized =
584
+ workflowUrls.length > 0 ||
585
+ Object.values(plannedPreaggs).some(p => p?.workflow_urls?.length > 0);
586
+
587
+ // Get materialization summary for collapsed state
588
+ const getMaterializationSummary = () => {
589
+ const hasCube = workflowUrls.length > 0;
590
+ const preaggCount = Object.values(plannedPreaggs).filter(
591
+ p => p?.workflow_urls?.length > 0,
592
+ ).length;
593
+ const strategy =
594
+ Object.values(plannedPreaggs).find(p => p?.strategy)?.strategy ||
595
+ 'incremental_time';
596
+ const schedule =
597
+ Object.values(plannedPreaggs).find(p => p?.schedule)?.schedule ||
598
+ '0 6 * * *';
599
+ return {
600
+ hasCube,
601
+ preaggCount,
602
+ strategy: strategy === 'incremental_time' ? 'Incremental' : 'Full',
603
+ schedule: getScheduleSummary(schedule),
604
+ };
605
+ };
606
+
68
607
  return (
69
608
  <div className="details-panel">
70
609
  {/* Header */}
71
610
  <div className="details-header">
72
- <h2 className="details-title">Generated Query Overview</h2>
611
+ <h2 className="details-title">Query Plan</h2>
73
612
  <p className="details-full-name">
74
613
  {selectedMetrics.length} metric
75
614
  {selectedMetrics.length !== 1 ? 's' : ''} ×{' '}
@@ -78,12 +617,1072 @@ export function QueryOverviewPanel({
78
617
  </p>
79
618
  </div>
80
619
 
81
- {/* Pre-aggregations Summary */}
620
+ {/* Global Materialization Error Banner - always visible when there's an error */}
621
+ {materializationError && (
622
+ <div className="materialization-error global-error">
623
+ <div className="error-content">
624
+ <span className="error-icon">⚠</span>
625
+ <span className="error-message">{materializationError}</span>
626
+ </div>
627
+ <button
628
+ className="error-dismiss"
629
+ onClick={onClearError}
630
+ aria-label="Dismiss error"
631
+ >
632
+ ×
633
+ </button>
634
+ </div>
635
+ )}
636
+
637
+ {/* Materialization Config Section - Only show when there's content */}
638
+ {grainGroups.length > 0 &&
639
+ onPlanMaterialization &&
640
+ (!isMaterialized || configuringCard === '__all__') && (
641
+ <div className="details-section">
642
+ {/* State A: Not materialized - show CTA */}
643
+ {!isMaterialized && configuringCard !== '__all__' && (
644
+ <div className="plan-materialization-cta">
645
+ <div className="cta-content">
646
+ <div className="cta-icon">⚡</div>
647
+ <div className="cta-text">
648
+ <strong>Ready to materialize?</strong>
649
+ <span>
650
+ Configure scheduled materialization for faster queries
651
+ </span>
652
+ </div>
653
+ </div>
654
+ <button
655
+ className="action-btn action-btn-primary"
656
+ type="button"
657
+ onClick={openConfigForm}
658
+ >
659
+ Configure
660
+ </button>
661
+ </div>
662
+ )}
663
+
664
+ {/* Section-level Configuration Form - Enhanced */}
665
+ {configuringCard === '__all__' && (
666
+ <div className="materialization-config-form section-level-config">
667
+ <div className="config-form-header">
668
+ <span>Configure Materialization</span>
669
+ <button
670
+ className="config-close-btn"
671
+ type="button"
672
+ onClick={() => setConfiguringCard(null)}
673
+ >
674
+ ×
675
+ </button>
676
+ </div>
677
+ <div className="config-form-body">
678
+ {/* Strategy */}
679
+ <div className="config-form-row">
680
+ <label className="config-form-label">Strategy</label>
681
+ <div className="config-form-options">
682
+ <label className="radio-option">
683
+ <input
684
+ type="radio"
685
+ name="strategy-all"
686
+ value="full"
687
+ checked={configForm.strategy === 'full'}
688
+ onChange={e =>
689
+ setConfigForm(prev => ({
690
+ ...prev,
691
+ strategy: e.target.value,
692
+ }))
693
+ }
694
+ />
695
+ <span>Full</span>
696
+ </label>
697
+ <label className="radio-option">
698
+ <input
699
+ type="radio"
700
+ name="strategy-all"
701
+ value="incremental_time"
702
+ checked={configForm.strategy === 'incremental_time'}
703
+ onChange={e => {
704
+ setConfigForm(prev => ({
705
+ ...prev,
706
+ strategy: e.target.value,
707
+ }));
708
+ // Auto-show partition setup if any node is missing temporal partition
709
+ if (
710
+ !hasActualTemporalPartitions() &&
711
+ !partitionsLoading
712
+ ) {
713
+ setShowPartitionSetup(true);
714
+ }
715
+ }}
716
+ />
717
+ <span>Incremental</span>
718
+ {configForm.strategy === 'incremental_time' &&
719
+ (partitionsLoading ? (
720
+ <span className="option-hint">(checking...)</span>
721
+ ) : hasActualTemporalPartitions() ? (
722
+ <span className="partition-badge">
723
+ {getUniqueParentNodes()
724
+ .map(
725
+ nodeName =>
726
+ allNodePartitions[nodeName]
727
+ ?.temporalPartitions?.[0]?.name,
728
+ )
729
+ .filter(Boolean)
730
+ .join(', ')}
731
+ </span>
732
+ ) : null)}
733
+ </label>
734
+ </div>
735
+ </div>
736
+
737
+ {/* Inline Partition Setup Form - Per Node */}
738
+ {showPartitionSetup &&
739
+ configForm.strategy === 'incremental_time' &&
740
+ !hasActualTemporalPartitions() && (
741
+ <div className="partition-setup-form">
742
+ <div className="partition-setup-header">
743
+ <span className="partition-setup-icon">⚠️</span>
744
+ <span>
745
+ Set up temporal partitions for incremental builds
746
+ </span>
747
+ </div>
748
+
749
+ <div className="partition-setup-body">
750
+ {getUniqueParentNodes().map(nodeName => {
751
+ const nodePartitions = allNodePartitions[nodeName];
752
+ const hasTemporal =
753
+ nodePartitions?.temporalPartitions?.length > 0;
754
+ const form = partitionForms[nodeName] || {
755
+ column: '',
756
+ granularity: 'day',
757
+ format: 'yyyyMMdd',
758
+ };
759
+ const error = partitionErrors[nodeName];
760
+ const isSettingThis =
761
+ settingPartitionFor === nodeName;
762
+ const shortName = nodeName.split('.').pop();
763
+
764
+ // If this node already has temporal partitions, show success
765
+ if (hasTemporal) {
766
+ return (
767
+ <div
768
+ key={nodeName}
769
+ className="partition-node-section partition-node-done"
770
+ >
771
+ <div className="partition-node-header">
772
+ <span className="partition-node-name">
773
+ <span className="partition-node-icon">
774
+
775
+ </span>
776
+ {shortName}
777
+ </span>
778
+ </div>
779
+ <div className="partition-node-status">
780
+ <span className="partition-badge">
781
+ {
782
+ nodePartitions.temporalPartitions[0]
783
+ ?.name
784
+ }
785
+ </span>
786
+ </div>
787
+ </div>
788
+ );
789
+ }
790
+
791
+ // Show setup form for this node
792
+ return (
793
+ <div
794
+ key={nodeName}
795
+ className="partition-node-section"
796
+ >
797
+ <div className="partition-node-header">
798
+ <span className="partition-node-name">
799
+ <span className="partition-node-icon">
800
+ 📦
801
+ </span>
802
+ {shortName}
803
+ </span>
804
+ </div>
805
+
806
+ {error && (
807
+ <div className="partition-setup-error">
808
+ {error}
809
+ </div>
810
+ )}
811
+
812
+ <div className="partition-node-form">
813
+ <div className="partition-field">
814
+ <label>Column</label>
815
+ <select
816
+ value={form.column}
817
+ onChange={e =>
818
+ setPartitionForms(prev => ({
819
+ ...prev,
820
+ [nodeName]: {
821
+ ...form,
822
+ column: e.target.value,
823
+ },
824
+ }))
825
+ }
826
+ >
827
+ <option value="">Select...</option>
828
+ {(() => {
829
+ const datePattern =
830
+ /date|time|day|month|year|hour|ds|dt|dateint|timestamp/i;
831
+ return (nodePartitions?.columns || [])
832
+ .filter(col => !col.partition)
833
+ .map(col => ({
834
+ ...col,
835
+ isDateLike: datePattern.test(
836
+ col.name,
837
+ ),
838
+ }))
839
+ .sort((a, b) => {
840
+ if (a.isDateLike && !b.isDateLike)
841
+ return -1;
842
+ if (!a.isDateLike && b.isDateLike)
843
+ return 1;
844
+ return a.name.localeCompare(b.name);
845
+ })
846
+ .map(col => (
847
+ <option
848
+ key={col.name}
849
+ value={col.name}
850
+ >
851
+ {col.name}
852
+ {col.isDateLike ? ' ★' : ''}
853
+ </option>
854
+ ));
855
+ })()}
856
+ </select>
857
+ </div>
858
+ <div className="partition-field partition-field-small">
859
+ <label>Granularity</label>
860
+ <select
861
+ value={form.granularity}
862
+ onChange={e =>
863
+ setPartitionForms(prev => ({
864
+ ...prev,
865
+ [nodeName]: {
866
+ ...form,
867
+ granularity: e.target.value,
868
+ },
869
+ }))
870
+ }
871
+ >
872
+ <option value="day">Day</option>
873
+ <option value="hour">Hour</option>
874
+ <option value="month">Month</option>
875
+ </select>
876
+ </div>
877
+ <div className="partition-field partition-field-small">
878
+ <label>Format</label>
879
+ <input
880
+ type="text"
881
+ placeholder="yyyyMMdd"
882
+ value={form.format}
883
+ onChange={e =>
884
+ setPartitionForms(prev => ({
885
+ ...prev,
886
+ [nodeName]: {
887
+ ...form,
888
+ format: e.target.value,
889
+ },
890
+ }))
891
+ }
892
+ />
893
+ </div>
894
+ <button
895
+ type="button"
896
+ className="partition-set-btn"
897
+ disabled={!form.column || isSettingThis}
898
+ onClick={async () => {
899
+ if (!form.column) return;
900
+
901
+ setSettingPartitionFor(nodeName);
902
+ setPartitionErrors(prev => ({
903
+ ...prev,
904
+ [nodeName]: null,
905
+ }));
906
+
907
+ try {
908
+ const result = await onSetPartition(
909
+ nodeName,
910
+ form.column,
911
+ 'temporal',
912
+ form.format || 'yyyyMMdd',
913
+ form.granularity,
914
+ );
915
+
916
+ if (result?.status >= 400) {
917
+ throw new Error(
918
+ result.json?.message ||
919
+ 'Failed to set partition',
920
+ );
921
+ }
922
+
923
+ // Refresh partitions to pick up the new one
924
+ await fetchAllNodePartitions();
925
+
926
+ // Check if all nodes now have partitions
927
+ if (hasActualTemporalPartitions()) {
928
+ setShowPartitionSetup(false);
929
+ }
930
+ } catch (err) {
931
+ setPartitionErrors(prev => ({
932
+ ...prev,
933
+ [nodeName]:
934
+ err.message ||
935
+ 'Failed to set partition',
936
+ }));
937
+ } finally {
938
+ setSettingPartitionFor(null);
939
+ }
940
+ }}
941
+ >
942
+ {isSettingThis ? '...' : 'Set'}
943
+ </button>
944
+ </div>
945
+ </div>
946
+ );
947
+ })}
948
+ </div>
949
+ </div>
950
+ )}
951
+
952
+ {/* Run Backfill Option (only for incremental) */}
953
+ {configForm.strategy === 'incremental_time' && (
954
+ <div className="config-form-row">
955
+ <label className="checkbox-option">
956
+ <input
957
+ type="checkbox"
958
+ checked={configForm.runBackfill}
959
+ onChange={e =>
960
+ setConfigForm(prev => ({
961
+ ...prev,
962
+ runBackfill: e.target.checked,
963
+ }))
964
+ }
965
+ />
966
+ <span>Run initial backfill</span>
967
+ </label>
968
+ <span className="config-form-hint">
969
+ Populate historical data. Uncheck to only set up ongoing
970
+ materialization.
971
+ </span>
972
+ </div>
973
+ )}
974
+
975
+ {/* Backfill Date Range (only if runBackfill is checked) */}
976
+ {configForm.strategy === 'incremental_time' &&
977
+ configForm.runBackfill && (
978
+ <div className="config-form-section">
979
+ <label className="config-form-section-label">
980
+ Backfill Date Range
981
+ </label>
982
+ <div className="backfill-range">
983
+ <div className="backfill-field">
984
+ <label>From</label>
985
+ <input
986
+ type="date"
987
+ value={configForm.backfillFrom}
988
+ onChange={e =>
989
+ setConfigForm(prev => ({
990
+ ...prev,
991
+ backfillFrom: e.target.value,
992
+ }))
993
+ }
994
+ />
995
+ </div>
996
+ <div className="backfill-field">
997
+ <label>To</label>
998
+ <select
999
+ value={configForm.backfillTo}
1000
+ onChange={e =>
1001
+ setConfigForm(prev => ({
1002
+ ...prev,
1003
+ backfillTo: e.target.value,
1004
+ }))
1005
+ }
1006
+ >
1007
+ <option value="today">Today</option>
1008
+ <option value="specific">Specific date</option>
1009
+ </select>
1010
+ {configForm.backfillTo === 'specific' && (
1011
+ <input
1012
+ type="date"
1013
+ value={configForm.backfillToDate}
1014
+ onChange={e =>
1015
+ setConfigForm(prev => ({
1016
+ ...prev,
1017
+ backfillToDate: e.target.value,
1018
+ }))
1019
+ }
1020
+ style={{ marginTop: '6px' }}
1021
+ />
1022
+ )}
1023
+ </div>
1024
+ </div>
1025
+ </div>
1026
+ )}
1027
+
1028
+ {/* Schedule - always shown since we always create workflows */}
1029
+ <div className="config-form-row">
1030
+ <label className="config-form-label">Schedule</label>
1031
+ <select
1032
+ className="config-form-select"
1033
+ value={configForm.scheduleType}
1034
+ onChange={e => {
1035
+ const type = e.target.value;
1036
+ setConfigForm(prev => ({
1037
+ ...prev,
1038
+ scheduleType: type,
1039
+ schedule:
1040
+ type === 'auto'
1041
+ ? prev._recommendedSchedule?.cron || '0 6 * * *'
1042
+ : prev.schedule,
1043
+ }));
1044
+ }}
1045
+ >
1046
+ <option value="auto">
1047
+ {configForm._recommendedSchedule?.label ||
1048
+ 'Daily at 6:00 AM'}{' '}
1049
+ (recommended)
1050
+ </option>
1051
+ <option value="hourly">Hourly</option>
1052
+ <option value="custom">Custom cron...</option>
1053
+ </select>
1054
+ {configForm.scheduleType === 'custom' && (
1055
+ <input
1056
+ type="text"
1057
+ className="config-form-input"
1058
+ placeholder="0 6 * * *"
1059
+ value={configForm.schedule}
1060
+ onChange={e =>
1061
+ setConfigForm(prev => ({
1062
+ ...prev,
1063
+ schedule: e.target.value,
1064
+ }))
1065
+ }
1066
+ style={{ marginTop: '6px' }}
1067
+ />
1068
+ )}
1069
+ {configForm.scheduleType === 'auto' && (
1070
+ <span className="config-form-hint">
1071
+ Based on{' '}
1072
+ {configForm._granularity?.toLowerCase() || 'daily'}{' '}
1073
+ partition granularity
1074
+ </span>
1075
+ )}
1076
+ </div>
1077
+
1078
+ {/* Lookback Window (only for incremental) */}
1079
+ {configForm.strategy === 'incremental_time' && (
1080
+ <div className="config-form-row">
1081
+ <label className="config-form-label">
1082
+ Lookback Window
1083
+ </label>
1084
+ <input
1085
+ type="text"
1086
+ className="config-form-input"
1087
+ placeholder="1 day"
1088
+ value={configForm.lookbackWindow}
1089
+ onChange={e =>
1090
+ setConfigForm(prev => ({
1091
+ ...prev,
1092
+ lookbackWindow: e.target.value,
1093
+ }))
1094
+ }
1095
+ />
1096
+ <span className="config-form-hint">
1097
+ For late-arriving data (e.g., "1 day", "3 days")
1098
+ </span>
1099
+ </div>
1100
+ )}
1101
+
1102
+ {/* Druid Cube Materialization Section */}
1103
+ <div className="config-form-divider">
1104
+ <span>Cube Materialization (Druid)</span>
1105
+ </div>
1106
+
1107
+ <div className="config-form-row">
1108
+ <label className="checkbox-option">
1109
+ <input
1110
+ type="checkbox"
1111
+ checked={configForm.enableDruidCube}
1112
+ onChange={e =>
1113
+ setConfigForm(prev => ({
1114
+ ...prev,
1115
+ enableDruidCube: e.target.checked,
1116
+ }))
1117
+ }
1118
+ />
1119
+ Enable Druid cube materialization
1120
+ </label>
1121
+ <span className="config-form-hint">
1122
+ Combines pre-aggs into a single Druid datasource for fast
1123
+ interactive queries
1124
+ </span>
1125
+ </div>
1126
+
1127
+ {/* Druid cube config (shown when enabled and there is no associated cube) */}
1128
+ {configForm.enableDruidCube && !loadedCubeName && (
1129
+ <div className="config-form-section druid-cube-config">
1130
+ {/* Show existing cube or prompt for new name */}
1131
+ {loadedCubeName ? (
1132
+ <div className="config-form-row">
1133
+ <label className="config-form-label">
1134
+ Using Cube
1135
+ </label>
1136
+ <div className="existing-cube-name">
1137
+ <span className="cube-badge">📦</span>
1138
+ <code>{loadedCubeName}</code>
1139
+ </div>
1140
+ <span className="config-form-hint">
1141
+ Materialization will be added to this existing cube
1142
+ </span>
1143
+ </div>
1144
+ ) : (
1145
+ <div className="config-form-row">
1146
+ <label className="config-form-label">Cube Name</label>
1147
+ <div className="cube-name-input-group">
1148
+ <input
1149
+ type="text"
1150
+ className="config-form-input namespace-input"
1151
+ placeholder="users.myname"
1152
+ value={configForm.druidCubeNamespace}
1153
+ onChange={e =>
1154
+ setConfigForm(prev => ({
1155
+ ...prev,
1156
+ druidCubeNamespace: e.target.value,
1157
+ }))
1158
+ }
1159
+ />
1160
+ <span className="namespace-separator">.</span>
1161
+ <input
1162
+ type="text"
1163
+ className="config-form-input name-input"
1164
+ placeholder="my_cube"
1165
+ value={configForm.druidCubeName}
1166
+ onChange={e =>
1167
+ setConfigForm(prev => ({
1168
+ ...prev,
1169
+ druidCubeName: e.target.value,
1170
+ }))
1171
+ }
1172
+ />
1173
+ </div>
1174
+ <span className="config-form-hint">
1175
+ Full name:{' '}
1176
+ <code>
1177
+ {configForm.druidCubeNamespace}.
1178
+ {configForm.druidCubeName || 'my_cube'}
1179
+ </code>
1180
+ </span>
1181
+ </div>
1182
+ )}
1183
+
1184
+ {/* Preview of what will be combined */}
1185
+ <div className="druid-cube-preview">
1186
+ <div className="preview-label">
1187
+ Pre-aggregations to combine:
1188
+ </div>
1189
+ <div className="preview-list">
1190
+ {(measuresResult?.grain_groups || []).map((gg, i) => (
1191
+ <div key={i} className="preview-item">
1192
+ <span className="preview-source">
1193
+ {gg.parent_name?.split('.').pop()}
1194
+ </span>
1195
+ <span className="preview-grain">
1196
+ (
1197
+ {(gg.grain || [])
1198
+ .map(g => g.split('.').pop())
1199
+ .join(', ')}
1200
+ )
1201
+ </span>
1202
+ </div>
1203
+ ))}
1204
+ </div>
1205
+ <div className="preview-info">
1206
+ <span className="info-icon">ℹ️</span>
1207
+ Pre-aggs will be combined with FULL OUTER JOIN on
1208
+ shared dimensions and ingested to Druid
1209
+ </div>
1210
+ </div>
1211
+ </div>
1212
+ )}
1213
+ </div>
1214
+ <div className="config-form-actions">
1215
+ <button
1216
+ className="action-btn action-btn-secondary"
1217
+ type="button"
1218
+ onClick={() => setConfiguringCard(null)}
1219
+ >
1220
+ Cancel
1221
+ </button>
1222
+ <button
1223
+ className="action-btn action-btn-primary"
1224
+ type="button"
1225
+ disabled={isSaving}
1226
+ onClick={async () => {
1227
+ setIsSaving(true);
1228
+ try {
1229
+ // Compute the actual schedule value
1230
+ let finalSchedule = configForm.schedule;
1231
+ if (configForm.scheduleType === 'auto') {
1232
+ finalSchedule =
1233
+ configForm._recommendedSchedule?.cron ||
1234
+ '0 6 * * *';
1235
+ } else if (configForm.scheduleType === 'hourly') {
1236
+ finalSchedule = '0 * * * *';
1237
+ }
1238
+
1239
+ // Compute backfill end date
1240
+ let backfillEndDate = null;
1241
+ if (configForm.strategy === 'incremental_time') {
1242
+ backfillEndDate =
1243
+ configForm.backfillTo === 'today'
1244
+ ? new Date().toISOString().split('T')[0]
1245
+ : configForm.backfillToDate;
1246
+ }
1247
+
1248
+ // Build the config object for the API
1249
+ const apiConfig = {
1250
+ strategy: configForm.strategy,
1251
+ schedule: finalSchedule, // Always set - we always create workflows
1252
+ lookbackWindow:
1253
+ configForm.strategy === 'incremental_time'
1254
+ ? configForm.lookbackWindow
1255
+ : null,
1256
+ // Backfill info (only for incremental + runBackfill checked)
1257
+ runBackfill: configForm.runBackfill,
1258
+ backfillFrom:
1259
+ configForm.strategy === 'incremental_time' &&
1260
+ configForm.runBackfill
1261
+ ? configForm.backfillFrom
1262
+ : null,
1263
+ backfillTo:
1264
+ configForm.strategy === 'incremental_time' &&
1265
+ configForm.runBackfill
1266
+ ? backfillEndDate
1267
+ : null,
1268
+ // Druid cube config
1269
+ enableDruidCube: configForm.enableDruidCube,
1270
+ // Only send cube name if creating a new cube (no existing cube loaded)
1271
+ // Combine namespace and name: "users.myname.my_cube"
1272
+ druidCubeName:
1273
+ configForm.enableDruidCube &&
1274
+ configForm.druidCubeName
1275
+ ? `${configForm.druidCubeNamespace}.${configForm.druidCubeName}`
1276
+ : null,
1277
+ };
1278
+
1279
+ await onPlanMaterialization(null, apiConfig);
1280
+ setConfiguringCard(null);
1281
+ // Reset form to defaults
1282
+ setConfigForm({
1283
+ strategy: 'incremental_time',
1284
+ runBackfill: true,
1285
+ backfillFrom: '',
1286
+ backfillTo: 'today',
1287
+ backfillToDate: '',
1288
+ continueAfterBackfill: true,
1289
+ schedule: '',
1290
+ scheduleType: 'auto',
1291
+ lookbackWindow: '1 day',
1292
+ enableDruidCube: true,
1293
+ druidCubeNamespace: getDefaultNamespace(),
1294
+ druidCubeName: '',
1295
+ });
1296
+ } catch (err) {
1297
+ console.error('Failed to plan:', err);
1298
+ }
1299
+ setIsSaving(false);
1300
+ }}
1301
+ >
1302
+ {isSaving ? (
1303
+ <>
1304
+ <span className="spinner" /> Creating...
1305
+ </>
1306
+ ) : configForm.enableDruidCube ? (
1307
+ 'Create Pre-Agg Workflows & Schedule Cube'
1308
+ ) : configForm.strategy === 'incremental_time' &&
1309
+ configForm.runBackfill ? (
1310
+ 'Create Workflow & Start Backfill'
1311
+ ) : (
1312
+ 'Create Workflow'
1313
+ )}
1314
+ </button>
1315
+ </div>
1316
+ </div>
1317
+ )}
1318
+ </div>
1319
+ )}
1320
+
1321
+ {/* Druid Cube Section - shown when cube is scheduled */}
1322
+ {workflowUrls.length > 0 && (
1323
+ <div className="details-section">
1324
+ <div className="section-header-row">
1325
+ <h3 className="section-title">
1326
+ <span className="section-icon cube-icon">◆</span>
1327
+ Druid Cube
1328
+ </h3>
1329
+ </div>
1330
+
1331
+ <div className="preagg-summary-card cube-card">
1332
+ {editingCube ? (
1333
+ /* Edit Cube Config Form - matches pre-agg edit form exactly */
1334
+ <div className="materialization-config-form">
1335
+ <div className="config-form-header">
1336
+ <span>Edit Materialization Config</span>
1337
+ <button
1338
+ className="config-close-btn"
1339
+ type="button"
1340
+ onClick={() => setEditingCube(false)}
1341
+ >
1342
+ ×
1343
+ </button>
1344
+ </div>
1345
+ <div className="config-form-body">
1346
+ <div className="config-form-row">
1347
+ <label className="config-form-label">Strategy</label>
1348
+ <div className="config-form-options">
1349
+ <label className="radio-option">
1350
+ <input
1351
+ type="radio"
1352
+ name="cube-strategy"
1353
+ value="full"
1354
+ checked={cubeConfigForm.strategy === 'full'}
1355
+ onChange={e =>
1356
+ setCubeConfigForm(prev => ({
1357
+ ...prev,
1358
+ strategy: e.target.value,
1359
+ }))
1360
+ }
1361
+ />
1362
+ <span>Full</span>
1363
+ </label>
1364
+ <label className="radio-option">
1365
+ <input
1366
+ type="radio"
1367
+ name="cube-strategy"
1368
+ value="incremental_time"
1369
+ checked={
1370
+ cubeConfigForm.strategy === 'incremental_time'
1371
+ }
1372
+ onChange={e =>
1373
+ setCubeConfigForm(prev => ({
1374
+ ...prev,
1375
+ strategy: e.target.value,
1376
+ }))
1377
+ }
1378
+ />
1379
+ <span>Incremental (Time)</span>
1380
+ </label>
1381
+ </div>
1382
+ </div>
1383
+ <div className="config-form-row">
1384
+ <label className="config-form-label">Schedule (cron)</label>
1385
+ <input
1386
+ type="text"
1387
+ className="config-form-input"
1388
+ placeholder="0 6 * * * (daily at 6am)"
1389
+ value={cubeConfigForm.schedule}
1390
+ onChange={e =>
1391
+ setCubeConfigForm(prev => ({
1392
+ ...prev,
1393
+ schedule: e.target.value,
1394
+ }))
1395
+ }
1396
+ />
1397
+ </div>
1398
+ {cubeConfigForm.strategy === 'incremental_time' && (
1399
+ <div className="config-form-row">
1400
+ <label className="config-form-label">
1401
+ Lookback Window
1402
+ </label>
1403
+ <input
1404
+ type="text"
1405
+ className="config-form-input"
1406
+ placeholder="3 days"
1407
+ value={cubeConfigForm.lookbackWindow}
1408
+ onChange={e =>
1409
+ setCubeConfigForm(prev => ({
1410
+ ...prev,
1411
+ lookbackWindow: e.target.value,
1412
+ }))
1413
+ }
1414
+ />
1415
+ </div>
1416
+ )}
1417
+ </div>
1418
+ <div className="config-form-actions">
1419
+ <button
1420
+ className="action-btn action-btn-secondary"
1421
+ type="button"
1422
+ onClick={() => setEditingCube(false)}
1423
+ >
1424
+ Cancel
1425
+ </button>
1426
+ <button
1427
+ className="action-btn action-btn-primary"
1428
+ type="button"
1429
+ disabled={isSavingCube}
1430
+ onClick={async () => {
1431
+ setIsSavingCube(true);
1432
+ try {
1433
+ if (onUpdateCubeConfig) {
1434
+ await onUpdateCubeConfig(cubeConfigForm);
1435
+ }
1436
+ setEditingCube(false);
1437
+ } catch (err) {
1438
+ console.error('Failed to save cube config:', err);
1439
+ }
1440
+ setIsSavingCube(false);
1441
+ }}
1442
+ >
1443
+ {isSavingCube ? (
1444
+ <>
1445
+ <span className="spinner" /> Saving...
1446
+ </>
1447
+ ) : (
1448
+ 'Save'
1449
+ )}
1450
+ </button>
1451
+ </div>
1452
+ </div>
1453
+ ) : (
1454
+ /* Cube Summary View - matches pre-agg expandable pattern */
1455
+ <>
1456
+ {/* Card header with name */}
1457
+ <div className="preagg-summary-header">
1458
+ <span className="preagg-summary-name cube-name">
1459
+ {cubeMaterialization?.druidDatasource ||
1460
+ (loadedCubeName
1461
+ ? `dj__${loadedCubeName.replace(/\./g, '_')}`
1462
+ : 'dj__cube')}
1463
+ </span>
1464
+ <span className="status-pill status-active">● Active</span>
1465
+ </div>
1466
+
1467
+ {/* Clickable workflow status bar */}
1468
+ <div
1469
+ className="materialization-header clickable"
1470
+ onClick={() =>
1471
+ setExpandedCards(prev => ({ ...prev, cube: !prev.cube }))
1472
+ }
1473
+ >
1474
+ <div className="materialization-status">
1475
+ <span
1476
+ className="status-indicator status-materialized"
1477
+ style={{ color: '#059669' }}
1478
+ >
1479
+
1480
+ </span>
1481
+ <span className="status-text">Workflow active</span>
1482
+ {cubeMaterialization?.schedule && (
1483
+ <>
1484
+ <span className="status-separator">|</span>
1485
+ <span className="schedule-summary">
1486
+ {getScheduleSummary(cubeMaterialization.schedule)}
1487
+ </span>
1488
+ </>
1489
+ )}
1490
+ </div>
1491
+ <button
1492
+ className="expand-toggle"
1493
+ type="button"
1494
+ aria-label={expandedCards.cube ? 'Collapse' : 'Expand'}
1495
+ >
1496
+ {expandedCards.cube ? '▲' : '▼'}
1497
+ </button>
1498
+ </div>
1499
+
1500
+ {/* Expandable Details */}
1501
+ {expandedCards.cube && (
1502
+ <div className="materialization-details">
1503
+ <div className="materialization-config">
1504
+ <div className="config-row">
1505
+ <span className="config-label">Strategy:</span>
1506
+ <span className="config-value">
1507
+ {cubeMaterialization?.strategy === 'incremental_time'
1508
+ ? 'Incremental (Time-based)'
1509
+ : cubeMaterialization?.strategy === 'full'
1510
+ ? 'Full'
1511
+ : cubeMaterialization?.strategy || 'Not set'}
1512
+ </span>
1513
+ </div>
1514
+ {cubeMaterialization?.schedule && (
1515
+ <div className="config-row">
1516
+ <span className="config-label">Schedule:</span>
1517
+ <span className="config-value config-mono">
1518
+ {cubeMaterialization.schedule}
1519
+ </span>
1520
+ </div>
1521
+ )}
1522
+ {cubeMaterialization?.lookbackWindow && (
1523
+ <div className="config-row">
1524
+ <span className="config-label">Lookback:</span>
1525
+ <span className="config-value">
1526
+ {cubeMaterialization.lookbackWindow}
1527
+ </span>
1528
+ </div>
1529
+ )}
1530
+ <div className="config-row">
1531
+ <span className="config-label">Dependencies:</span>
1532
+ <span className="config-value">
1533
+ {cubeMaterialization?.preaggTables?.length ||
1534
+ Object.keys(plannedPreaggs).length ||
1535
+ grainGroups.length}{' '}
1536
+ pre-agg(s)
1537
+ </span>
1538
+ </div>
1539
+ {/* Workflow URLs */}
1540
+ {workflowUrls.length > 0 && (
1541
+ <div className="config-row">
1542
+ <span className="config-label">Workflows:</span>
1543
+ <div className="workflow-links">
1544
+ {workflowUrls.map((wf, idx) => {
1545
+ // Support both {label, url} objects and plain strings
1546
+ const url = typeof wf === 'string' ? wf : wf.url;
1547
+ const label =
1548
+ typeof wf === 'string'
1549
+ ? wf.includes('adhoc') ||
1550
+ wf.includes('backfill')
1551
+ ? 'Backfill'
1552
+ : 'Scheduled'
1553
+ : wf.label || 'Workflow';
1554
+ return (
1555
+ <a
1556
+ key={idx}
1557
+ href={url}
1558
+ target="_blank"
1559
+ rel="noopener noreferrer"
1560
+ className="action-btn action-btn-secondary"
1561
+ >
1562
+ {label === 'backfill'
1563
+ ? 'Backfill'
1564
+ : label === 'scheduled'
1565
+ ? 'Scheduled'
1566
+ : label}
1567
+ </a>
1568
+ );
1569
+ })}
1570
+ </div>
1571
+ </div>
1572
+ )}
1573
+ </div>
1574
+ {/* Action Buttons */}
1575
+ <div className="materialization-actions">
1576
+ {onUpdateCubeConfig && (
1577
+ <button
1578
+ className="action-btn action-btn-secondary"
1579
+ type="button"
1580
+ onClick={e => {
1581
+ e.stopPropagation();
1582
+ setCubeConfigForm({
1583
+ strategy:
1584
+ cubeMaterialization?.strategy ||
1585
+ 'incremental_time',
1586
+ schedule:
1587
+ cubeMaterialization?.schedule || '0 6 * * *',
1588
+ lookbackWindow:
1589
+ cubeMaterialization?.lookbackWindow || '1 DAY',
1590
+ });
1591
+ setEditingCube(true);
1592
+ }}
1593
+ >
1594
+ Edit Config
1595
+ </button>
1596
+ )}
1597
+
1598
+ {onRefreshCubeWorkflow && (
1599
+ <button
1600
+ className="action-btn action-btn-secondary"
1601
+ type="button"
1602
+ title="Refresh workflow (re-push to scheduler)"
1603
+ onClick={async e => {
1604
+ e.stopPropagation();
1605
+ setLoadingAction('refresh-cube');
1606
+ try {
1607
+ await onRefreshCubeWorkflow();
1608
+ } finally {
1609
+ setLoadingAction(null);
1610
+ }
1611
+ }}
1612
+ disabled={loadingAction === 'refresh-cube'}
1613
+ >
1614
+ {loadingAction === 'refresh-cube' ? (
1615
+ <>
1616
+ <span className="spinner" /> Refreshing...
1617
+ </>
1618
+ ) : (
1619
+ '↻ Refresh'
1620
+ )}
1621
+ </button>
1622
+ )}
1623
+
1624
+ {onRunCubeBackfill && (
1625
+ <button
1626
+ className="action-btn action-btn-secondary"
1627
+ type="button"
1628
+ onClick={e => {
1629
+ e.stopPropagation();
1630
+ setCubeBackfillModal(true);
1631
+ }}
1632
+ >
1633
+ Run Backfill
1634
+ </button>
1635
+ )}
1636
+
1637
+ {onDeactivateCubeWorkflow && (
1638
+ <button
1639
+ className="action-btn action-btn-danger"
1640
+ type="button"
1641
+ title="Deactivate this cube materialization"
1642
+ onClick={async e => {
1643
+ e.stopPropagation();
1644
+ if (
1645
+ window.confirm(
1646
+ 'Are you sure you want to deactivate this cube materialization? The workflow will be stopped.',
1647
+ )
1648
+ ) {
1649
+ setLoadingAction('deactivate-cube');
1650
+ try {
1651
+ await onDeactivateCubeWorkflow();
1652
+ } finally {
1653
+ setLoadingAction(null);
1654
+ }
1655
+ }
1656
+ }}
1657
+ disabled={loadingAction === 'deactivate-cube'}
1658
+ >
1659
+ {loadingAction === 'deactivate-cube' ? (
1660
+ <>
1661
+ <span className="spinner" /> Deactivating...
1662
+ </>
1663
+ ) : (
1664
+ '⏹ Deactivate'
1665
+ )}
1666
+ </button>
1667
+ )}
1668
+ </div>
1669
+ </div>
1670
+ )}
1671
+ </>
1672
+ )}
1673
+ </div>
1674
+ </div>
1675
+ )}
1676
+
1677
+ {/* Pre-Aggregations Section */}
82
1678
  <div className="details-section">
83
- <h3 className="section-title">
84
- <span className="section-icon">◫</span>
85
- Pre-Aggregations ({grainGroups.length})
86
- </h3>
1679
+ <div className="section-header-row">
1680
+ <h3 className="section-title">
1681
+ <span className="section-icon">◫</span>
1682
+ Pre-Aggregations ({grainGroups.length})
1683
+ </h3>
1684
+ </div>
1685
+
87
1686
  <div className="preagg-summary-list">
88
1687
  {grainGroups.map((gg, i) => {
89
1688
  const shortName = gg.parent_name?.split('.').pop() || 'Unknown';
@@ -92,14 +1691,30 @@ export function QueryOverviewPanel({
92
1691
  gg.components?.some(pc => pc.name === comp),
93
1692
  ),
94
1693
  );
1694
+
1695
+ // Look up existing pre-agg by normalized grain key
1696
+ const grainKey = `${gg.parent_name}|${normalizeGrain(gg.grain)}`;
1697
+ const existingPreagg = plannedPreaggs[grainKey];
1698
+ const statusInfo = getStatusInfo(existingPreagg);
1699
+ const isExpanded = expandedCards[grainKey] || false;
1700
+ const scheduleSummary = existingPreagg?.schedule
1701
+ ? getScheduleSummary(existingPreagg.schedule)
1702
+ : null;
1703
+
1704
+ // Determine status badge
1705
+ const isActive =
1706
+ existingPreagg?.strategy && existingPreagg?.schedule;
1707
+ const statusPillClass = isActive
1708
+ ? 'status-active'
1709
+ : 'status-not-set';
1710
+ const statusPillText = isActive ? '● Active' : '○ Not Set';
1711
+
95
1712
  return (
96
1713
  <div key={i} className="preagg-summary-card">
97
1714
  <div className="preagg-summary-header">
98
1715
  <span className="preagg-summary-name">{shortName}</span>
99
- <span
100
- className={`aggregability-pill aggregability-${gg.aggregability?.toLowerCase()}`}
101
- >
102
- {gg.aggregability}
1716
+ <span className={`status-pill ${statusPillClass}`}>
1717
+ {statusPillText}
103
1718
  </span>
104
1719
  </div>
105
1720
  <div className="preagg-summary-details">
@@ -109,39 +1724,536 @@ export function QueryOverviewPanel({
109
1724
  {gg.grain?.join(', ') || 'None'}
110
1725
  </span>
111
1726
  </div>
112
- <div className="preagg-summary-row">
113
- <span className="label">Measures:</span>
114
- <span className="value">{gg.components?.length || 0}</span>
1727
+ {isActive && scheduleSummary && (
1728
+ <div className="preagg-summary-row">
1729
+ <span className="label">Schedule:</span>
1730
+ <span className="value">{scheduleSummary}</span>
1731
+ </div>
1732
+ )}
1733
+ </div>
1734
+
1735
+ {/* Materialization Status Header */}
1736
+ {editingCard === grainKey ? (
1737
+ /* Edit Config Form (only for existing pre-aggs) */
1738
+ <div className="materialization-config-form">
1739
+ <div className="config-form-header">
1740
+ <span>Edit Materialization Config</span>
1741
+ <button
1742
+ className="config-close-btn"
1743
+ type="button"
1744
+ onClick={() => {
1745
+ setEditingCard(null);
1746
+ }}
1747
+ >
1748
+ ×
1749
+ </button>
1750
+ </div>
1751
+ <div className="config-form-body">
1752
+ <div className="config-form-row">
1753
+ <label className="config-form-label">Strategy</label>
1754
+ <div className="config-form-options">
1755
+ <label className="radio-option">
1756
+ <input
1757
+ type="radio"
1758
+ name={`strategy-${i}`}
1759
+ value="full"
1760
+ checked={configForm.strategy === 'full'}
1761
+ onChange={e =>
1762
+ setConfigForm(prev => ({
1763
+ ...prev,
1764
+ strategy: e.target.value,
1765
+ }))
1766
+ }
1767
+ />
1768
+ <span>Full</span>
1769
+ </label>
1770
+ <label className="radio-option">
1771
+ <input
1772
+ type="radio"
1773
+ name={`strategy-${i}`}
1774
+ value="incremental_time"
1775
+ checked={
1776
+ configForm.strategy === 'incremental_time'
1777
+ }
1778
+ onChange={e =>
1779
+ setConfigForm(prev => ({
1780
+ ...prev,
1781
+ strategy: e.target.value,
1782
+ }))
1783
+ }
1784
+ />
1785
+ <span>Incremental (Time)</span>
1786
+ </label>
1787
+ </div>
1788
+ </div>
1789
+ <div className="config-form-row">
1790
+ <label className="config-form-label">
1791
+ Schedule (cron)
1792
+ </label>
1793
+ <input
1794
+ type="text"
1795
+ className="config-form-input"
1796
+ placeholder="0 6 * * * (daily at 6am)"
1797
+ value={configForm.schedule}
1798
+ onChange={e =>
1799
+ setConfigForm(prev => ({
1800
+ ...prev,
1801
+ schedule: e.target.value,
1802
+ }))
1803
+ }
1804
+ />
1805
+ </div>
1806
+ {configForm.strategy === 'incremental_time' && (
1807
+ <div className="config-form-row">
1808
+ <label className="config-form-label">
1809
+ Lookback Window
1810
+ </label>
1811
+ <input
1812
+ type="text"
1813
+ className="config-form-input"
1814
+ placeholder="3 days"
1815
+ value={configForm.lookbackWindow}
1816
+ onChange={e =>
1817
+ setConfigForm(prev => ({
1818
+ ...prev,
1819
+ lookbackWindow: e.target.value,
1820
+ }))
1821
+ }
1822
+ />
1823
+ </div>
1824
+ )}
1825
+ </div>
1826
+ <div className="config-form-actions">
1827
+ <button
1828
+ className="action-btn action-btn-secondary"
1829
+ type="button"
1830
+ onClick={() => setEditingCard(null)}
1831
+ >
1832
+ Cancel
1833
+ </button>
1834
+ <button
1835
+ className="action-btn action-btn-primary"
1836
+ type="button"
1837
+ disabled={isSaving}
1838
+ onClick={async () => {
1839
+ setIsSaving(true);
1840
+ try {
1841
+ if (onUpdateConfig && existingPreagg?.id) {
1842
+ await onUpdateConfig(
1843
+ existingPreagg.id,
1844
+ configForm,
1845
+ );
1846
+ }
1847
+ setEditingCard(null);
1848
+ setConfigForm({
1849
+ strategy: 'full',
1850
+ schedule: '',
1851
+ lookbackWindow: '',
1852
+ });
1853
+ } catch (err) {
1854
+ console.error('Failed to save:', err);
1855
+ }
1856
+ setIsSaving(false);
1857
+ }}
1858
+ >
1859
+ {isSaving ? (
1860
+ <>
1861
+ <span className="spinner" /> Saving...
1862
+ </>
1863
+ ) : (
1864
+ 'Save'
1865
+ )}
1866
+ </button>
1867
+ </div>
115
1868
  </div>
116
- <div className="preagg-summary-row">
117
- <span className="label">Metrics:</span>
118
- <span className="value">
119
- {relatedMetrics.map(m => m.short_name).join(', ') ||
120
- 'None'}
121
- </span>
1869
+ ) : existingPreagg ? (
1870
+ /* Existing Pre-agg Status Header */
1871
+ <>
1872
+ <div
1873
+ className="materialization-header clickable"
1874
+ onClick={() => toggleCardExpanded(grainKey)}
1875
+ >
1876
+ <div className="materialization-status">
1877
+ <span
1878
+ className={`status-indicator ${statusInfo.className}`}
1879
+ style={{ color: statusInfo.color }}
1880
+ >
1881
+ {statusInfo.icon}
1882
+ </span>
1883
+ <span className="status-text">{statusInfo.text}</span>
1884
+ {scheduleSummary && (
1885
+ <>
1886
+ <span className="status-separator">|</span>
1887
+ <span className="schedule-summary">
1888
+ {scheduleSummary}
1889
+ </span>
1890
+ </>
1891
+ )}
1892
+ </div>
1893
+ <button
1894
+ className="expand-toggle"
1895
+ type="button"
1896
+ aria-label={isExpanded ? 'Collapse' : 'Expand'}
1897
+ >
1898
+ {isExpanded ? '▲' : '▼'}
1899
+ </button>
1900
+ </div>
1901
+
1902
+ {/* Expandable Materialization Details */}
1903
+ {isExpanded && (
1904
+ <div className="materialization-details">
1905
+ {/* Note for compatible (superset) pre-aggs */}
1906
+ {existingPreagg._isCompatible && (
1907
+ <div className="compatible-preagg-note">
1908
+ <span className="note-icon">ℹ️</span>
1909
+ <span>
1910
+ This query can use an existing pre-agg with finer
1911
+ grain:
1912
+ <strong>
1913
+ {' '}
1914
+ {existingPreagg.grain_columns
1915
+ ?.map(g => g.split('.').pop())
1916
+ .join(', ')}
1917
+ </strong>
1918
+ </span>
1919
+ </div>
1920
+ )}
1921
+ <div className="materialization-config">
1922
+ <div className="config-row">
1923
+ <span className="config-label">Strategy:</span>
1924
+ <span className="config-value">
1925
+ {existingPreagg.strategy === 'incremental_time'
1926
+ ? 'Incremental (Time-based)'
1927
+ : existingPreagg.strategy === 'full'
1928
+ ? 'Full'
1929
+ : existingPreagg.strategy || 'Not set'}
1930
+ </span>
1931
+ </div>
1932
+ {existingPreagg.schedule && (
1933
+ <div className="config-row">
1934
+ <span className="config-label">Schedule:</span>
1935
+ <span className="config-value config-mono">
1936
+ {existingPreagg.schedule}
1937
+ </span>
1938
+ </div>
1939
+ )}
1940
+ {existingPreagg.lookback_window && (
1941
+ <div className="config-row">
1942
+ <span className="config-label">Lookback:</span>
1943
+ <span className="config-value">
1944
+ {existingPreagg.lookback_window}
1945
+ </span>
1946
+ </div>
1947
+ )}
1948
+ {existingPreagg.availability?.updated_at && (
1949
+ <div className="config-row">
1950
+ <span className="config-label">Last Run:</span>
1951
+ <span className="config-value">
1952
+ {new Date(
1953
+ existingPreagg.availability.updated_at,
1954
+ ).toLocaleString()}{' '}
1955
+ <span className="run-status success">✓</span>
1956
+ </span>
1957
+ </div>
1958
+ )}
1959
+ {/* Workflow URLs (shown when job is running or has been triggered) */}
1960
+ {existingPreagg.workflow_urls?.length > 0 && (
1961
+ <div className="config-row">
1962
+ <span className="config-label">Workflows:</span>
1963
+ <div className="workflow-links">
1964
+ {existingPreagg.workflow_urls.map((wf, idx) => {
1965
+ // Support both {label, url} objects and plain strings
1966
+ const url =
1967
+ typeof wf === 'string' ? wf : wf.url;
1968
+ const label =
1969
+ typeof wf === 'string'
1970
+ ? wf.includes('backfill') ||
1971
+ wf.includes('adhoc')
1972
+ ? 'backfill'
1973
+ : 'scheduled'
1974
+ : wf.label || 'workflow';
1975
+ return (
1976
+ <a
1977
+ key={idx}
1978
+ href={url}
1979
+ target="_blank"
1980
+ rel="noopener noreferrer"
1981
+ className="action-btn action-btn-secondary"
1982
+ >
1983
+ {label === 'scheduled'
1984
+ ? 'Scheduled'
1985
+ : label === 'backfill'
1986
+ ? 'Backfill'
1987
+ : label}
1988
+ </a>
1989
+ );
1990
+ })}
1991
+ </div>
1992
+ </div>
1993
+ )}
1994
+ </div>
1995
+ <div className="materialization-actions">
1996
+ {onUpdateConfig && existingPreagg.id && (
1997
+ <button
1998
+ className="action-btn action-btn-secondary"
1999
+ type="button"
2000
+ onClick={e => {
2001
+ e.stopPropagation();
2002
+ startEditingConfig(grainKey, existingPreagg);
2003
+ }}
2004
+ >
2005
+ Edit Config
2006
+ </button>
2007
+ )}
2008
+
2009
+ {/* Workflow actions */}
2010
+ {existingPreagg.strategy && existingPreagg.schedule && (
2011
+ <>
2012
+ {(!existingPreagg.workflow_urls ||
2013
+ existingPreagg.workflow_urls.length === 0) &&
2014
+ onCreateWorkflow && (
2015
+ <button
2016
+ className="action-btn action-btn-secondary"
2017
+ type="button"
2018
+ disabled={
2019
+ loadingAction ===
2020
+ `workflow-${existingPreagg.id}`
2021
+ }
2022
+ onClick={async e => {
2023
+ e.stopPropagation();
2024
+ setLoadingAction(
2025
+ `workflow-${existingPreagg.id}`,
2026
+ );
2027
+ try {
2028
+ const result = await onCreateWorkflow(
2029
+ existingPreagg.id,
2030
+ );
2031
+ if (result?.workflow_urls?.length > 0) {
2032
+ const firstUrl =
2033
+ typeof result.workflow_urls[0] ===
2034
+ 'string'
2035
+ ? result.workflow_urls[0]
2036
+ : result.workflow_urls[0].url;
2037
+ setToastMessage(
2038
+ `Workflow created: ${firstUrl}`,
2039
+ );
2040
+ setTimeout(
2041
+ () => setToastMessage(null),
2042
+ 5000,
2043
+ );
2044
+ }
2045
+ } finally {
2046
+ setLoadingAction(null);
2047
+ }
2048
+ }}
2049
+ >
2050
+ {loadingAction ===
2051
+ `workflow-${existingPreagg.id}` ? (
2052
+ <>
2053
+ <span className="spinner" /> Creating...
2054
+ </>
2055
+ ) : (
2056
+ 'Create Workflow'
2057
+ )}
2058
+ </button>
2059
+ )}
2060
+ {existingPreagg.workflow_urls?.length > 0 && (
2061
+ <>
2062
+ <button
2063
+ className="action-btn action-btn-secondary"
2064
+ type="button"
2065
+ title="Refresh workflow (re-push to scheduler)"
2066
+ disabled={
2067
+ loadingAction ===
2068
+ `refresh-${existingPreagg.id}`
2069
+ }
2070
+ onClick={async e => {
2071
+ e.stopPropagation();
2072
+ setLoadingAction(
2073
+ `refresh-${existingPreagg.id}`,
2074
+ );
2075
+ try {
2076
+ // Re-push the workflow
2077
+ await onCreateWorkflow(
2078
+ existingPreagg.id,
2079
+ true,
2080
+ );
2081
+ } finally {
2082
+ setLoadingAction(null);
2083
+ }
2084
+ }}
2085
+ >
2086
+ {loadingAction ===
2087
+ `refresh-${existingPreagg.id}` ? (
2088
+ <>
2089
+ <span className="spinner" />{' '}
2090
+ Refreshing...
2091
+ </>
2092
+ ) : (
2093
+ '↻ Refresh'
2094
+ )}
2095
+ </button>
2096
+ </>
2097
+ )}
2098
+ </>
2099
+ )}
2100
+
2101
+ {/* Backfill button */}
2102
+ {existingPreagg.strategy && onRunBackfill && (
2103
+ <button
2104
+ className="action-btn action-btn-secondary"
2105
+ type="button"
2106
+ onClick={e => {
2107
+ e.stopPropagation();
2108
+ // Open backfill modal
2109
+ const today = new Date()
2110
+ .toISOString()
2111
+ .split('T')[0];
2112
+ const weekAgo = new Date(
2113
+ Date.now() - 7 * 24 * 60 * 60 * 1000,
2114
+ )
2115
+ .toISOString()
2116
+ .split('T')[0];
2117
+ setBackfillModal({
2118
+ preaggId: existingPreagg.id,
2119
+ startDate: weekAgo,
2120
+ endDate: today,
2121
+ });
2122
+ }}
2123
+ >
2124
+ Run Backfill
2125
+ </button>
2126
+ )}
2127
+
2128
+ {/* Deactivate button */}
2129
+ {existingPreagg.workflow_urls?.length > 0 &&
2130
+ onDeactivatePreaggWorkflow && (
2131
+ <button
2132
+ className="action-btn action-btn-danger"
2133
+ type="button"
2134
+ title="Deactivate (pause) this workflow"
2135
+ onClick={async e => {
2136
+ e.stopPropagation();
2137
+ if (
2138
+ window.confirm(
2139
+ 'Are you sure you want to deactivate this workflow? It will be paused and can be re-activated later.',
2140
+ )
2141
+ ) {
2142
+ setLoadingAction(
2143
+ `deactivate-${existingPreagg.id}`,
2144
+ );
2145
+ try {
2146
+ await onDeactivatePreaggWorkflow(
2147
+ existingPreagg.id,
2148
+ );
2149
+ } finally {
2150
+ setLoadingAction(null);
2151
+ }
2152
+ }
2153
+ }}
2154
+ disabled={
2155
+ loadingAction ===
2156
+ `deactivate-${existingPreagg.id}`
2157
+ }
2158
+ >
2159
+ {loadingAction ===
2160
+ `deactivate-${existingPreagg.id}` ? (
2161
+ <>
2162
+ <span className="spinner" /> Deactivating...
2163
+ </>
2164
+ ) : (
2165
+ '⏹ Deactivate'
2166
+ )}
2167
+ </button>
2168
+ )}
2169
+ </div>
2170
+ </div>
2171
+ )}
2172
+ </>
2173
+ ) : (
2174
+ /* Not Planned - Show status only (use section-level button to plan) */
2175
+ <div className="materialization-header">
2176
+ <div className="materialization-status">
2177
+ <span
2178
+ className={`status-indicator ${statusInfo.className}`}
2179
+ style={{ color: statusInfo.color }}
2180
+ >
2181
+ {statusInfo.icon}
2182
+ </span>
2183
+ <span className="status-text">{statusInfo.text}</span>
2184
+ </div>
122
2185
  </div>
123
- </div>
124
- <div className="preagg-summary-status">
125
- <span className="status-indicator status-not-materialized">
126
-
127
- </span>
128
- <span>Not materialized</span>
129
- </div>
2186
+ )}
130
2187
  </div>
131
2188
  );
132
2189
  })}
133
2190
  </div>
134
2191
  </div>
135
2192
 
2193
+ {/* SQL Section */}
2194
+ {sql && (
2195
+ <div className="details-section details-section-full details-sql-section">
2196
+ <div className="section-header-row">
2197
+ <h3 className="section-title">
2198
+ <span className="section-icon">⌘</span>
2199
+ Generated SQL
2200
+ </h3>
2201
+ <div className="sql-view-toggle">
2202
+ <button
2203
+ className={`sql-toggle-btn ${
2204
+ sqlViewMode === 'optimized' ? 'active' : ''
2205
+ }`}
2206
+ onClick={() => handleSqlViewToggle('optimized')}
2207
+ type="button"
2208
+ title="SQL using pre-aggregations (when available)"
2209
+ >
2210
+ Optimized
2211
+ </button>
2212
+ <button
2213
+ className={`sql-toggle-btn ${
2214
+ sqlViewMode === 'raw' ? 'active' : ''
2215
+ }`}
2216
+ onClick={() => handleSqlViewToggle('raw')}
2217
+ type="button"
2218
+ title="SQL computed directly from source tables"
2219
+ disabled={loadingRawSql}
2220
+ >
2221
+ {loadingRawSql ? '...' : 'Raw'}
2222
+ </button>
2223
+ </div>
2224
+ </div>
2225
+ <div className="sql-code-wrapper">
2226
+ <SyntaxHighlighter
2227
+ language="sql"
2228
+ style={atomOneLight}
2229
+ customStyle={{
2230
+ margin: 0,
2231
+ borderRadius: '6px',
2232
+ fontSize: '11px',
2233
+ background: '#f8fafc',
2234
+ border: '1px solid #e2e8f0',
2235
+ }}
2236
+ >
2237
+ {sqlViewMode === 'raw' ? rawSql || 'Loading...' : sql}
2238
+ </SyntaxHighlighter>
2239
+ </div>
2240
+ </div>
2241
+ )}
2242
+
136
2243
  {/* Metrics & Dimensions Summary - Two columns */}
137
2244
  <div className="details-section">
2245
+ <div className="section-header-row">
2246
+ <h3 className="section-title">
2247
+ <span className="section-icon">◈</span>
2248
+ Selection Summary
2249
+ </h3>
2250
+ </div>
138
2251
  <div className="selection-summary-grid">
139
2252
  {/* Metrics Column */}
140
2253
  <div className="selection-summary-column">
141
- <h3 className="section-title">
142
- <span className="section-icon">◈</span>
2254
+ <div className="selection-summary-label">
143
2255
  Metrics ({metricFormulas.length})
144
- </h3>
2256
+ </div>
145
2257
  <div className="selection-summary-list">
146
2258
  {metricFormulas.map((m, i) => (
147
2259
  <Link
@@ -160,10 +2272,9 @@ export function QueryOverviewPanel({
160
2272
 
161
2273
  {/* Dimensions Column */}
162
2274
  <div className="selection-summary-column">
163
- <h3 className="section-title">
164
- <span className="section-icon">⊞</span>
2275
+ <div className="selection-summary-label">
165
2276
  Dimensions ({selectedDimensions.length})
166
- </h3>
2277
+ </div>
167
2278
  <div className="selection-summary-list">
168
2279
  {selectedDimensions.map((dim, i) => {
169
2280
  const shortName = dim.split('.').pop().split('[')[0]; // Remove role suffix too
@@ -183,39 +2294,144 @@ export function QueryOverviewPanel({
183
2294
  </div>
184
2295
  </div>
185
2296
 
186
- {/* SQL Section */}
187
- {sql && (
188
- <div className="details-section details-section-full details-sql-section">
189
- <div className="section-header-row">
190
- <h3 className="section-title">
191
- <span className="section-icon">⌘</span>
192
- Generated SQL
193
- </h3>
194
- <button
195
- className="copy-sql-btn"
196
- onClick={() => copyToClipboard(sql)}
197
- type="button"
198
- >
199
- Copy SQL
200
- </button>
201
- </div>
202
- <div className="sql-code-wrapper">
203
- <SyntaxHighlighter
204
- language="sql"
205
- style={atomOneLight}
206
- customStyle={{
207
- margin: 0,
208
- borderRadius: '6px',
209
- fontSize: '11px',
210
- background: '#f8fafc',
211
- border: '1px solid #e2e8f0',
212
- }}
213
- >
214
- {sql}
215
- </SyntaxHighlighter>
2297
+ {/* Backfill Modal */}
2298
+ {backfillModal && (
2299
+ <div
2300
+ className="backfill-modal-overlay"
2301
+ onClick={() => setBackfillModal(null)}
2302
+ >
2303
+ <div className="backfill-modal" onClick={e => e.stopPropagation()}>
2304
+ <div className="backfill-modal-header">
2305
+ <h3>Run Backfill</h3>
2306
+ <button
2307
+ className="modal-close"
2308
+ onClick={() => setBackfillModal(null)}
2309
+ >
2310
+ ×
2311
+ </button>
2312
+ </div>
2313
+ <div className="backfill-modal-body">
2314
+ <div className="backfill-form-row">
2315
+ <label>Start Date</label>
2316
+ <input
2317
+ type="date"
2318
+ value={backfillModal.startDate}
2319
+ onChange={e =>
2320
+ setBackfillModal(prev => ({
2321
+ ...prev,
2322
+ startDate: e.target.value,
2323
+ }))
2324
+ }
2325
+ />
2326
+ </div>
2327
+ <div className="backfill-form-row">
2328
+ <label>End Date</label>
2329
+ <input
2330
+ type="date"
2331
+ value={backfillModal.endDate}
2332
+ onChange={e =>
2333
+ setBackfillModal(prev => ({
2334
+ ...prev,
2335
+ endDate: e.target.value,
2336
+ }))
2337
+ }
2338
+ />
2339
+ </div>
2340
+ </div>
2341
+ <div className="backfill-modal-actions">
2342
+ <button
2343
+ className="action-btn action-btn-secondary"
2344
+ onClick={() => setBackfillModal(null)}
2345
+ disabled={loadingAction === 'backfill-modal'}
2346
+ >
2347
+ Cancel
2348
+ </button>
2349
+ <button
2350
+ className="action-btn action-btn-primary"
2351
+ disabled={loadingAction === 'backfill-modal'}
2352
+ onClick={async () => {
2353
+ setLoadingAction('backfill-modal');
2354
+ try {
2355
+ const result = await onRunBackfill(
2356
+ backfillModal.preaggId,
2357
+ backfillModal.startDate,
2358
+ backfillModal.endDate,
2359
+ );
2360
+ setBackfillModal(null);
2361
+ if (result?.job_url) {
2362
+ setToastMessage(
2363
+ <span>
2364
+ Backfill started:{' '}
2365
+ <a
2366
+ href={result.job_url}
2367
+ target="_blank"
2368
+ rel="noopener noreferrer"
2369
+ >
2370
+ View Job ↗
2371
+ </a>
2372
+ </span>,
2373
+ );
2374
+ setTimeout(() => setToastMessage(null), 10000);
2375
+ }
2376
+ } finally {
2377
+ setLoadingAction(null);
2378
+ }
2379
+ }}
2380
+ >
2381
+ {loadingAction === 'backfill-modal' ? (
2382
+ <>
2383
+ <span className="spinner" /> Starting...
2384
+ </>
2385
+ ) : (
2386
+ 'Start Backfill'
2387
+ )}
2388
+ </button>
2389
+ </div>
216
2390
  </div>
217
2391
  </div>
218
2392
  )}
2393
+
2394
+ {/* Cube Backfill Modal */}
2395
+ {cubeBackfillModal && (
2396
+ <CubeBackfillModal
2397
+ onClose={() => setCubeBackfillModal(false)}
2398
+ onSubmit={async (startDate, endDate) => {
2399
+ setLoadingAction('cube-backfill');
2400
+ try {
2401
+ const result = await onRunCubeBackfill(startDate, endDate);
2402
+ setCubeBackfillModal(false);
2403
+ if (result?.job_url) {
2404
+ setToastMessage(
2405
+ <span>
2406
+ Backfill started:{' '}
2407
+ <a
2408
+ href={result.job_url}
2409
+ target="_blank"
2410
+ rel="noopener noreferrer"
2411
+ >
2412
+ View Job ↗
2413
+ </a>
2414
+ </span>,
2415
+ );
2416
+ setTimeout(() => setToastMessage(null), 10000);
2417
+ }
2418
+ } finally {
2419
+ setLoadingAction(null);
2420
+ }
2421
+ }}
2422
+ loading={loadingAction === 'cube-backfill'}
2423
+ />
2424
+ )}
2425
+
2426
+ {/* Toast Message */}
2427
+ {toastMessage && (
2428
+ <div className="toast-message">
2429
+ {toastMessage}
2430
+ <button className="toast-close" onClick={() => setToastMessage(null)}>
2431
+ ×
2432
+ </button>
2433
+ </div>
2434
+ )}
219
2435
  </div>
220
2436
  );
221
2437
  }
@@ -225,7 +2441,25 @@ export function QueryOverviewPanel({
225
2441
  *
226
2442
  * Shows comprehensive info when a preagg node is selected in the graph
227
2443
  */
228
- export function PreAggDetailsPanel({ preAgg, metricFormulas, onClose }) {
2444
+ export function PreAggDetailsPanel({
2445
+ preAgg,
2446
+ metricFormulas,
2447
+ onClose,
2448
+ highlightedComponent,
2449
+ }) {
2450
+ const componentsSectionRef = useRef(null);
2451
+
2452
+ // Scroll to and highlight component when highlightedComponent changes
2453
+ useEffect(() => {
2454
+ if (highlightedComponent && componentsSectionRef.current) {
2455
+ // Scroll the components section into view
2456
+ componentsSectionRef.current.scrollIntoView({
2457
+ behavior: 'smooth',
2458
+ block: 'start',
2459
+ });
2460
+ }
2461
+ }, [highlightedComponent]);
2462
+
229
2463
  if (!preAgg) {
230
2464
  return null;
231
2465
  }
@@ -304,7 +2538,10 @@ export function PreAggDetailsPanel({ preAgg, metricFormulas, onClose }) {
304
2538
  </div>
305
2539
 
306
2540
  {/* Components Table */}
307
- <div className="details-section details-section-full">
2541
+ <div
2542
+ className="details-section details-section-full"
2543
+ ref={componentsSectionRef}
2544
+ >
308
2545
  <h3 className="section-title">
309
2546
  <span className="section-icon">⚙</span>
310
2547
  Components ({preAgg.components?.length || 0})
@@ -321,7 +2558,14 @@ export function PreAggDetailsPanel({ preAgg, metricFormulas, onClose }) {
321
2558
  </thead>
322
2559
  <tbody>
323
2560
  {preAgg.components?.map((comp, i) => (
324
- <tr key={comp.name || i}>
2561
+ <tr
2562
+ key={comp.name || i}
2563
+ className={
2564
+ highlightedComponent === comp.name
2565
+ ? 'component-row-highlighted'
2566
+ : ''
2567
+ }
2568
+ >
325
2569
  <td className="comp-name-cell">
326
2570
  <code>{comp.name}</code>
327
2571
  </td>
@@ -416,6 +2660,17 @@ export function MetricDetailsPanel({ metric, grainGroups, onClose }) {
416
2660
  <p className="details-full-name">{metric.name}</p>
417
2661
  </div>
418
2662
 
2663
+ {/* Definition */}
2664
+ <div className="details-section">
2665
+ <h3 className="section-title">
2666
+ <span className="section-icon">⌘</span>
2667
+ Definition
2668
+ </h3>
2669
+ <div className="formula-display">
2670
+ <code>{metric.query}</code>
2671
+ </div>
2672
+ </div>
2673
+
419
2674
  {/* Formula */}
420
2675
  <div className="details-section">
421
2676
  <h3 className="section-title">