datajunction-ui 0.0.26 → 0.0.27
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/package.json +2 -2
- package/src/app/components/Search.jsx +41 -33
- package/src/app/components/__tests__/Search.test.jsx +46 -11
- package/src/app/index.tsx +3 -3
- package/src/app/pages/AddEditNodePage/MetricQueryField.jsx +57 -8
- package/src/app/pages/AddEditNodePage/UpstreamNodeField.jsx +17 -5
- package/src/app/pages/AddEditNodePage/__tests__/index.test.jsx +97 -1
- package/src/app/pages/AddEditNodePage/index.jsx +61 -17
- package/src/app/pages/NodePage/WatchNodeButton.jsx +12 -5
- package/src/app/pages/QueryPlannerPage/MetricFlowGraph.jsx +93 -15
- package/src/app/pages/QueryPlannerPage/PreAggDetailsPanel.jsx +2320 -65
- package/src/app/pages/QueryPlannerPage/SelectionPanel.jsx +234 -25
- package/src/app/pages/QueryPlannerPage/__tests__/MetricFlowGraph.test.jsx +315 -122
- package/src/app/pages/QueryPlannerPage/__tests__/PreAggDetailsPanel.test.jsx +2672 -314
- package/src/app/pages/QueryPlannerPage/__tests__/SelectionPanel.test.jsx +567 -0
- package/src/app/pages/QueryPlannerPage/__tests__/index.test.jsx +480 -55
- package/src/app/pages/QueryPlannerPage/index.jsx +1021 -14
- package/src/app/pages/QueryPlannerPage/styles.css +1990 -62
- package/src/app/pages/Root/__tests__/index.test.jsx +79 -8
- package/src/app/pages/Root/index.tsx +1 -6
- package/src/app/pages/SQLBuilderPage/__tests__/index.test.jsx +82 -0
- package/src/app/pages/SettingsPage/__tests__/CreateServiceAccountModal.test.jsx +37 -0
- package/src/app/pages/SettingsPage/__tests__/ServiceAccountsSection.test.jsx +48 -0
- package/src/app/pages/SettingsPage/__tests__/index.test.jsx +169 -1
- package/src/app/services/DJService.js +492 -3
- package/src/app/services/__tests__/DJService.test.jsx +582 -0
- package/src/mocks/mockNodes.jsx +36 -0
- 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">
|
|
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
|
-
{/*
|
|
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
|
-
<
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
<
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
142
|
-
<span className="section-icon">◈</span>
|
|
2254
|
+
<div className="selection-summary-label">
|
|
143
2255
|
Metrics ({metricFormulas.length})
|
|
144
|
-
</
|
|
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
|
-
<
|
|
164
|
-
<span className="section-icon">⊞</span>
|
|
2275
|
+
<div className="selection-summary-label">
|
|
165
2276
|
Dimensions ({selectedDimensions.length})
|
|
166
|
-
</
|
|
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
|
-
{/*
|
|
187
|
-
{
|
|
188
|
-
<div
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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({
|
|
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
|
|
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
|
|
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">
|