datajunction-ui 0.0.31 → 0.0.41

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.
@@ -0,0 +1,656 @@
1
+ import { useEffect, useState, useMemo, useContext } from 'react';
2
+ import DJClientContext from '../../providers/djclient';
3
+ import { labelize } from '../../../utils/form';
4
+ import '../../../styles/preaggregations.css';
5
+
6
+ const cronstrue = require('cronstrue');
7
+
8
+ /**
9
+ * Pre-aggregations tab for non-cube nodes (transform, metric, dimension).
10
+ * Shows pre-aggs grouped by staleness (current vs stale versions).
11
+ */
12
+ export default function NodePreAggregationsTab({ node }) {
13
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
14
+ const [preaggs, setPreaggs] = useState([]);
15
+ const [loading, setLoading] = useState(true);
16
+ const [error, setError] = useState(null);
17
+ const [expandedIds, setExpandedIds] = useState(new Set());
18
+ const [expandedGrainIds, setExpandedGrainIds] = useState(new Set());
19
+ const [deactivating, setDeactivating] = useState(new Set());
20
+
21
+ const MAX_VISIBLE_GRAIN = 10;
22
+
23
+ // Fetch pre-aggregations for this node
24
+ useEffect(() => {
25
+ const fetchPreaggs = async () => {
26
+ if (!node?.name) return;
27
+
28
+ setLoading(true);
29
+ setError(null);
30
+
31
+ try {
32
+ const result = await djClient.listPreaggs({
33
+ node_name: node.name,
34
+ include_stale: true,
35
+ });
36
+ if (result._error) {
37
+ setError(result.message);
38
+ } else {
39
+ setPreaggs(result.items || []);
40
+ }
41
+ } catch (err) {
42
+ setError(err.message || 'Failed to load pre-aggregations');
43
+ } finally {
44
+ setLoading(false);
45
+ }
46
+ };
47
+
48
+ fetchPreaggs();
49
+ }, [node?.name, djClient]);
50
+
51
+ // Group pre-aggs by staleness
52
+ const { currentPreaggs, stalePreaggs } = useMemo(() => {
53
+ const currentVersion = node?.version;
54
+ const current = [];
55
+ const stale = [];
56
+
57
+ preaggs.forEach(preagg => {
58
+ if (preagg.node_version === currentVersion) {
59
+ current.push(preagg);
60
+ } else {
61
+ stale.push(preagg);
62
+ }
63
+ });
64
+
65
+ return { currentPreaggs: current, stalePreaggs: stale };
66
+ }, [preaggs, node?.version]);
67
+
68
+ // Auto-expand the first current pre-agg when data loads
69
+ useEffect(() => {
70
+ if (currentPreaggs.length > 0 && expandedIds.size === 0) {
71
+ setExpandedIds(new Set([currentPreaggs[0].id]));
72
+ }
73
+ }, [currentPreaggs]);
74
+
75
+ // Toggle expanded state for a pre-agg row
76
+ const toggleExpanded = id => {
77
+ setExpandedIds(prev => {
78
+ const next = new Set(prev);
79
+ if (next.has(id)) {
80
+ next.delete(id);
81
+ } else {
82
+ next.add(id);
83
+ }
84
+ return next;
85
+ });
86
+ };
87
+
88
+ // Deactivate a single pre-agg workflow
89
+ const handleDeactivate = async preaggId => {
90
+ if (
91
+ !window.confirm(
92
+ 'Are you sure you want to deactivate this workflow? ' +
93
+ 'The materialization will stop running.',
94
+ )
95
+ ) {
96
+ return;
97
+ }
98
+
99
+ setDeactivating(prev => new Set(prev).add(preaggId));
100
+
101
+ try {
102
+ const result = await djClient.deactivatePreaggWorkflow(preaggId);
103
+ if (result._error) {
104
+ alert(`Failed to deactivate: ${result.message}`);
105
+ } else {
106
+ // Refresh the list
107
+ const refreshed = await djClient.listPreaggs({
108
+ node_name: node.name,
109
+ include_stale: true,
110
+ });
111
+ if (!refreshed._error) {
112
+ setPreaggs(refreshed.items || []);
113
+ }
114
+ }
115
+ } catch (err) {
116
+ alert(`Error: ${err.message}`);
117
+ } finally {
118
+ setDeactivating(prev => {
119
+ const next = new Set(prev);
120
+ next.delete(preaggId);
121
+ return next;
122
+ });
123
+ }
124
+ };
125
+
126
+ // Bulk deactivate all stale workflows
127
+ const handleDeactivateAllStale = async () => {
128
+ const activeStale = stalePreaggs.filter(
129
+ p => p.workflow_status === 'active',
130
+ );
131
+ if (activeStale.length === 0) {
132
+ alert('No active stale workflows to deactivate.');
133
+ return;
134
+ }
135
+
136
+ if (
137
+ !window.confirm(
138
+ `Are you sure you want to deactivate ${activeStale.length} stale workflow(s)? ` +
139
+ 'These materializations are from older node versions and will stop running.',
140
+ )
141
+ ) {
142
+ return;
143
+ }
144
+
145
+ setDeactivating(prev => {
146
+ const next = new Set(prev);
147
+ activeStale.forEach(p => next.add(p.id));
148
+ return next;
149
+ });
150
+
151
+ try {
152
+ const result = await djClient.bulkDeactivatePreaggWorkflows(
153
+ node.name,
154
+ true,
155
+ );
156
+ if (result._error) {
157
+ alert(`Failed to deactivate: ${result.message}`);
158
+ } else {
159
+ // Refresh the list
160
+ const refreshed = await djClient.listPreaggs({
161
+ node_name: node.name,
162
+ include_stale: true,
163
+ });
164
+ if (!refreshed._error) {
165
+ setPreaggs(refreshed.items || []);
166
+ }
167
+ }
168
+ } catch (err) {
169
+ alert(`Error: ${err.message}`);
170
+ } finally {
171
+ setDeactivating(new Set());
172
+ }
173
+ };
174
+
175
+ // Format cron expression to human-readable
176
+ const formatSchedule = schedule => {
177
+ if (!schedule) return 'Not scheduled';
178
+ try {
179
+ return cronstrue.toString(schedule);
180
+ } catch {
181
+ return schedule;
182
+ }
183
+ };
184
+
185
+ // Render a single pre-agg row
186
+ const renderPreaggRow = (preagg, isStale = false) => {
187
+ const isExpanded = expandedIds.has(preagg.id);
188
+ const isDeactivating = deactivating.has(preagg.id);
189
+ const hasActiveWorkflow = preagg.workflow_status === 'active';
190
+
191
+ return (
192
+ <div
193
+ key={preagg.id}
194
+ className={`preagg-row ${isStale ? 'preagg-row--stale' : ''}`}
195
+ >
196
+ {/* Collapsed header row */}
197
+ <div
198
+ className="preagg-row-header"
199
+ onClick={() => toggleExpanded(preagg.id)}
200
+ >
201
+ <span className="preagg-row-toggle">
202
+ {isExpanded ? '\u25BC' : '\u25B6'}
203
+ </span>
204
+
205
+ <div className="preagg-row-grain-chips">
206
+ {(() => {
207
+ const grainCols = preagg.grain_columns || [];
208
+ const maxVisible = MAX_VISIBLE_GRAIN;
209
+ const visibleCols = grainCols.slice(0, maxVisible);
210
+ const hiddenCount = grainCols.length - maxVisible;
211
+
212
+ return (
213
+ <>
214
+ {visibleCols.map((col, idx) => {
215
+ const parts = col.split('.');
216
+ const shortName = parts[parts.length - 1];
217
+ return (
218
+ <span key={idx} className="preagg-grain-chip">
219
+ {shortName}
220
+ </span>
221
+ );
222
+ })}
223
+ {hiddenCount > 0 && (
224
+ <span className="preagg-grain-chip preagg-grain-chip--more">
225
+ +{hiddenCount}
226
+ </span>
227
+ )}
228
+ </>
229
+ );
230
+ })()}
231
+ </div>
232
+
233
+ <span className="preagg-row-measures">
234
+ {preagg.measures?.length || 0} measure
235
+ {(preagg.measures?.length || 0) !== 1 ? 's' : ''}
236
+ </span>
237
+
238
+ {preagg.related_metrics?.length > 0 && (
239
+ <span className="preagg-metric-count-badge">
240
+ {preagg.related_metrics.length} metric
241
+ {preagg.related_metrics.length !== 1 ? 's' : ''}
242
+ </span>
243
+ )}
244
+
245
+ {hasActiveWorkflow ? (
246
+ <span className="preagg-status-badge preagg-status-badge--active">
247
+ Active
248
+ </span>
249
+ ) : preagg.workflow_status === 'paused' ? (
250
+ <span className="preagg-status-badge preagg-status-badge--paused">
251
+ Paused
252
+ </span>
253
+ ) : (
254
+ <span className="preagg-status-badge preagg-status-badge--pending">
255
+ Pending
256
+ </span>
257
+ )}
258
+
259
+ {preagg.schedule && (
260
+ <span className="preagg-row-schedule">
261
+ {formatSchedule(preagg.schedule).toLowerCase()}
262
+ </span>
263
+ )}
264
+
265
+ {isStale && (
266
+ <span className="preagg-row-version">
267
+ was {preagg.node_version}
268
+ </span>
269
+ )}
270
+ </div>
271
+
272
+ {/* Expanded details */}
273
+ {isExpanded && (
274
+ <div
275
+ className={`preagg-details ${
276
+ isStale ? 'preagg-details--stale' : ''
277
+ }`}
278
+ >
279
+ {isStale && (
280
+ <div className="preagg-stale-banner">
281
+ <span className="preagg-stale-banner-icon">⚠️</span>
282
+ <div>
283
+ <strong>Built for {preagg.node_version}</strong> — current is{' '}
284
+ {node.version}
285
+ <br />
286
+ <span className="preagg-stale-banner-text">
287
+ This workflow is still running but won't be used for
288
+ queries.
289
+ </span>
290
+ </div>
291
+ </div>
292
+ )}
293
+
294
+ <div className="preagg-stack">
295
+ {/* Config + Grain side by side */}
296
+ <div className="preagg-two-column">
297
+ {/* Config */}
298
+ <div>
299
+ <div className="preagg-card-label">Config</div>
300
+ <div className="preagg-card">
301
+ {/* Table-style key-value pairs */}
302
+ <table className="preagg-config-table">
303
+ <tbody>
304
+ <tr>
305
+ <td className="preagg-config-key">Strategy</td>
306
+ <td className="preagg-config-value">
307
+ {preagg.strategy
308
+ ? labelize(preagg.strategy)
309
+ : 'Not set'}
310
+ </td>
311
+ </tr>
312
+ <tr>
313
+ <td className="preagg-config-key">Schedule</td>
314
+ <td className="preagg-config-value">
315
+ {preagg.schedule ? (
316
+ <>
317
+ {formatSchedule(preagg.schedule)}
318
+ <span className="preagg-config-schedule-cron">
319
+ ({preagg.schedule})
320
+ </span>
321
+ </>
322
+ ) : (
323
+ 'Not scheduled'
324
+ )}
325
+ </td>
326
+ </tr>
327
+ {preagg.lookback_window && (
328
+ <tr>
329
+ <td className="preagg-config-key">Lookback</td>
330
+ <td className="preagg-config-value">
331
+ {preagg.lookback_window}
332
+ </td>
333
+ </tr>
334
+ )}
335
+ {preagg.max_partition &&
336
+ preagg.max_partition.length > 0 && (
337
+ <tr>
338
+ <td className="preagg-config-key">
339
+ Max Partition
340
+ </td>
341
+ <td className="preagg-config-value">
342
+ <code>{preagg.max_partition.join(', ')}</code>
343
+ </td>
344
+ </tr>
345
+ )}
346
+ </tbody>
347
+ </table>
348
+
349
+ {/* Actions */}
350
+ <div className="preagg-actions">
351
+ {/* Workflow buttons - one per URL */}
352
+ {preagg.workflow_urls?.map((wf, idx) => {
353
+ const label = wf.label || 'Workflow';
354
+ const capitalizedLabel =
355
+ label.charAt(0).toUpperCase() + label.slice(1);
356
+ return (
357
+ <a
358
+ key={idx}
359
+ href={wf.url}
360
+ target="_blank"
361
+ rel="noopener noreferrer"
362
+ className="preagg-action-btn"
363
+ >
364
+ {capitalizedLabel}
365
+ </a>
366
+ );
367
+ })}
368
+
369
+ {hasActiveWorkflow && (
370
+ <button
371
+ className={`preagg-action-btn preagg-action-btn--danger`}
372
+ disabled={isDeactivating}
373
+ onClick={e => {
374
+ e.stopPropagation();
375
+ handleDeactivate(preagg.id);
376
+ }}
377
+ >
378
+ {isDeactivating ? 'Deactivating...' : 'Deactivate'}
379
+ </button>
380
+ )}
381
+ </div>
382
+ </div>
383
+ </div>
384
+
385
+ {/* Grain */}
386
+ <div>
387
+ <div className="preagg-card-label">Grain</div>
388
+ <div className="preagg-card preagg-card--compact">
389
+ <div className="preagg-grain-list">
390
+ {(() => {
391
+ const grainCols = preagg.grain_columns || [];
392
+ const isGrainExpanded = expandedGrainIds.has(preagg.id);
393
+ const visibleCols = isGrainExpanded
394
+ ? grainCols
395
+ : grainCols.slice(0, MAX_VISIBLE_GRAIN);
396
+ const hiddenCount =
397
+ grainCols.length - MAX_VISIBLE_GRAIN;
398
+
399
+ return (
400
+ <>
401
+ {visibleCols.map((col, idx) => {
402
+ const parts = col.split('.');
403
+ const nodeName = parts.slice(0, -1).join('.');
404
+ return (
405
+ <a
406
+ key={idx}
407
+ href={`/nodes/${nodeName}`}
408
+ title={`View ${nodeName}`}
409
+ className="preagg-grain-badge"
410
+ >
411
+ {col}
412
+ </a>
413
+ );
414
+ })}
415
+ {!isGrainExpanded && hiddenCount > 0 && (
416
+ <button
417
+ className="preagg-expand-btn"
418
+ onClick={e => {
419
+ e.stopPropagation();
420
+ setExpandedGrainIds(prev => {
421
+ const next = new Set(prev);
422
+ next.add(preagg.id);
423
+ return next;
424
+ });
425
+ }}
426
+ >
427
+ +{hiddenCount} more
428
+ </button>
429
+ )}
430
+ {isGrainExpanded && hiddenCount > 0 && (
431
+ <button
432
+ className="preagg-expand-btn"
433
+ onClick={e => {
434
+ e.stopPropagation();
435
+ setExpandedGrainIds(prev => {
436
+ const next = new Set(prev);
437
+ next.delete(preagg.id);
438
+ return next;
439
+ });
440
+ }}
441
+ >
442
+ Show less
443
+ </button>
444
+ )}
445
+ </>
446
+ );
447
+ })()}
448
+ </div>
449
+ </div>
450
+ </div>
451
+ </div>
452
+
453
+ {/* Measures */}
454
+ <div>
455
+ <div className="preagg-card-label preagg-card-label--with-info">
456
+ Measures
457
+ <span
458
+ className="preagg-info-icon"
459
+ title="Pre-computed aggregations stored in this pre-aggregation. At query time, DJ uses these to avoid re-scanning raw data."
460
+ >
461
+
462
+ </span>
463
+ </div>
464
+ <div
465
+ className="preagg-card preagg-card--table"
466
+ style={{ border: '1px solid #e2e8f0' }}
467
+ >
468
+ <table className="preagg-measures-table">
469
+ <thead>
470
+ <tr>
471
+ <th>Name</th>
472
+ <th>
473
+ Aggregation
474
+ <span
475
+ className="preagg-info-icon"
476
+ title="Phase 1: How raw data is aggregated when building the pre-agg table"
477
+ >
478
+
479
+ </span>
480
+ </th>
481
+ <th>
482
+ Merge
483
+ <span
484
+ className="preagg-info-icon"
485
+ title="Phase 2: How pre-aggregated values are combined at query time"
486
+ >
487
+
488
+ </span>
489
+ </th>
490
+ <th>
491
+ Rule
492
+ <span
493
+ className="preagg-info-icon"
494
+ title="Additivity: FULL = can roll up across any dimension"
495
+ >
496
+
497
+ </span>
498
+ </th>
499
+ <th>
500
+ Used By
501
+ <span
502
+ className="preagg-info-icon"
503
+ title="Metrics that use this measure"
504
+ >
505
+
506
+ </span>
507
+ </th>
508
+ </tr>
509
+ </thead>
510
+ <tbody>
511
+ {preagg.measures?.map((measure, idx) => (
512
+ <tr key={idx}>
513
+ <td className="preagg-measure-name">
514
+ {measure.name}
515
+ </td>
516
+ <td>
517
+ <code className="preagg-agg-badge">
518
+ {measure.aggregation
519
+ ? `${measure.aggregation}(${measure.expression})`
520
+ : measure.expression}
521
+ </code>
522
+ </td>
523
+ <td>
524
+ {measure.merge && (
525
+ <code className="preagg-merge-badge">
526
+ {measure.merge}
527
+ </code>
528
+ )}
529
+ </td>
530
+ <td>
531
+ {measure.rule && (
532
+ <span className="preagg-rule-badge">
533
+ {typeof measure.rule === 'object'
534
+ ? measure.rule.type || ''
535
+ : measure.rule}
536
+ </span>
537
+ )}
538
+ </td>
539
+ <td>
540
+ {measure.used_by_metrics?.length > 0 && (
541
+ <div className="preagg-metrics-list">
542
+ {measure.used_by_metrics.map((metric, mIdx) => (
543
+ <a
544
+ key={mIdx}
545
+ href={`/nodes/${metric.name}`}
546
+ title={metric.name}
547
+ className="preagg-metric-badge"
548
+ >
549
+ {metric.display_name ||
550
+ metric.name.split('.').pop()}
551
+ </a>
552
+ ))}
553
+ </div>
554
+ )}
555
+ </td>
556
+ </tr>
557
+ ))}
558
+ </tbody>
559
+ </table>
560
+ </div>
561
+ </div>
562
+ </div>
563
+ </div>
564
+ )}
565
+ </div>
566
+ );
567
+ };
568
+
569
+ // Loading state
570
+ if (loading) {
571
+ return <div className="preagg-loading">Loading pre-aggregations...</div>;
572
+ }
573
+
574
+ // Error state
575
+ if (error) {
576
+ return (
577
+ <div className="message alert preagg-error">
578
+ Error loading pre-aggregations: {error}
579
+ </div>
580
+ );
581
+ }
582
+
583
+ // No pre-aggs
584
+ if (preaggs.length === 0) {
585
+ return (
586
+ <div className="preagg-no-data">
587
+ <div className="message alert preagg-no-data-alert">
588
+ No pre-aggregations found for this node.
589
+ </div>
590
+ <p className="preagg-no-data-text">
591
+ Pre-aggregations are created when you use the{' '}
592
+ <a href="/query-planner">Query Planner</a> to plan materializations
593
+ for metrics derived from this node.
594
+ </p>
595
+ </div>
596
+ );
597
+ }
598
+
599
+ // Calculate if there are active stale workflows
600
+ const activeStaleCount = stalePreaggs.filter(
601
+ p => p.workflow_status === 'active',
602
+ ).length;
603
+
604
+ return (
605
+ <div className="preagg-container">
606
+ {/* Current Version Section */}
607
+ <div className="preagg-section">
608
+ <div className="preagg-section-header">
609
+ <h3 className="preagg-section-title">
610
+ Current Pre-Aggregations ({node.version})
611
+ </h3>
612
+ <span className="preagg-section-count">
613
+ {currentPreaggs.length} pre-aggregation
614
+ {currentPreaggs.length !== 1 ? 's' : ''}
615
+ </span>
616
+ </div>
617
+
618
+ {currentPreaggs.length > 0 ? (
619
+ currentPreaggs.map(preagg => renderPreaggRow(preagg, false))
620
+ ) : (
621
+ <div className="preagg-empty">
622
+ No pre-aggregations for the current version.
623
+ </div>
624
+ )}
625
+ </div>
626
+
627
+ {/* Stale Section */}
628
+ {stalePreaggs.length > 0 && (
629
+ <div className="preagg-section">
630
+ <div className="preagg-section-header preagg-section-header--stale">
631
+ <div className="preagg-section-header-left">
632
+ <h3 className="preagg-section-title preagg-section-title--stale">
633
+ Stale Pre-Aggregations ({stalePreaggs.length})
634
+ </h3>
635
+ <span className="preagg-section-count preagg-section-count--stale">
636
+ {activeStaleCount} active workflow
637
+ {activeStaleCount !== 1 ? 's' : ''}
638
+ </span>
639
+ </div>
640
+
641
+ {activeStaleCount > 0 && (
642
+ <button
643
+ className="preagg-action-btn preagg-action-btn--danger-fill"
644
+ onClick={handleDeactivateAllStale}
645
+ >
646
+ Deactivate All Stale
647
+ </button>
648
+ )}
649
+ </div>
650
+
651
+ {stalePreaggs.map(preagg => renderPreaggRow(preagg, true))}
652
+ </div>
653
+ )}
654
+ </div>
655
+ );
656
+ }
@@ -342,8 +342,10 @@ export default function NodeValidateTab({ node, djClient }) {
342
342
  .slice(0, 100)
343
343
  .map((rowData, index) => (
344
344
  <tr key={`data-row:${index}`}>
345
- {rowData.map(rowValue => (
346
- <td key={rowValue}>{rowValue}</td>
345
+ {rowData.map((rowValue, colIndex) => (
346
+ <td key={`${index}-${colIndex}`}>
347
+ {rowValue}
348
+ </td>
347
349
  ))}
348
350
  </tr>
349
351
  ))}