UNPKG

datajunction-ui

Version:
677 lines (631 loc) 24 kB
import { useEffect, useState, useMemo, useContext } from 'react'; import DJClientContext from '../../providers/djclient'; import { labelize } from '../../../utils/form'; import '../../../styles/preaggregations.css'; import cronstrue from 'cronstrue'; /** * Pre-aggregations tab for non-cube nodes (transform, metric, dimension). * Shows pre-aggs grouped by staleness (current vs stale versions). */ export default function NodePreAggregationsTab({ node }) { const djClient = useContext(DJClientContext).DataJunctionAPI; const [preaggs, setPreaggs] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [expandedIds, setExpandedIds] = useState(new Set()); const [expandedGrainIds, setExpandedGrainIds] = useState(new Set()); const [deactivating, setDeactivating] = useState(new Set()); const MAX_VISIBLE_GRAIN = 10; // Fetch pre-aggregations for this node useEffect(() => { const fetchPreaggs = async () => { if (!node?.name) return; setLoading(true); setError(null); try { const result = await djClient.listPreaggs({ node_name: node.name, include_stale: true, }); if (result._error) { setError(result.message); } else { setPreaggs(result.items || []); } } catch (err) { setError(err.message || 'Failed to load pre-aggregations'); } finally { setLoading(false); } }; fetchPreaggs(); }, [node?.name, djClient]); // Group pre-aggs by staleness const { currentPreaggs, stalePreaggs } = useMemo(() => { const currentVersion = node?.version; const current = []; const stale = []; preaggs.forEach(preagg => { if (preagg.node_version === currentVersion) { current.push(preagg); } else { stale.push(preagg); } }); return { currentPreaggs: current, stalePreaggs: stale }; }, [preaggs, node?.version]); // Auto-expand the first current pre-agg when data loads useEffect(() => { if (currentPreaggs.length > 0 && expandedIds.size === 0) { setExpandedIds(new Set([currentPreaggs[0].id])); } }, [currentPreaggs]); // Toggle expanded state for a pre-agg row const toggleExpanded = id => { setExpandedIds(prev => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }; // Deactivate a single pre-agg workflow const handleDeactivate = async preaggId => { if ( !window.confirm( 'Are you sure you want to deactivate this workflow? ' + 'The materialization will stop running.', ) ) { return; } setDeactivating(prev => new Set(prev).add(preaggId)); try { const result = await djClient.deactivatePreaggWorkflow(preaggId); if (result._error) { alert(`Failed to deactivate: ${result.message}`); } else { // Refresh the list const refreshed = await djClient.listPreaggs({ node_name: node.name, include_stale: true, }); if (!refreshed._error) { setPreaggs(refreshed.items || []); } } } catch (err) { alert(`Error: ${err.message}`); } finally { setDeactivating(prev => { const next = new Set(prev); next.delete(preaggId); return next; }); } }; // Bulk deactivate all stale workflows const handleDeactivateAllStale = async () => { const activeStale = stalePreaggs.filter( p => p.workflow_status === 'active', ); if (activeStale.length === 0) { alert('No active stale workflows to deactivate.'); return; } if ( !window.confirm( `Are you sure you want to deactivate ${activeStale.length} stale workflow(s)? ` + 'These materializations are from older node versions and will stop running.', ) ) { return; } setDeactivating(prev => { const next = new Set(prev); activeStale.forEach(p => next.add(p.id)); return next; }); try { const result = await djClient.bulkDeactivatePreaggWorkflows( node.name, true, ); if (result._error) { alert(`Failed to deactivate: ${result.message}`); } else { // Refresh the list const refreshed = await djClient.listPreaggs({ node_name: node.name, include_stale: true, }); if (!refreshed._error) { setPreaggs(refreshed.items || []); } } } catch (err) { alert(`Error: ${err.message}`); } finally { setDeactivating(new Set()); } }; // Format cron expression to human-readable const formatSchedule = schedule => { if (!schedule) return 'Not scheduled'; try { return cronstrue.toString(schedule); } catch { return schedule; } }; // Render a single pre-agg row const renderPreaggRow = (preagg, isStale = false) => { const isExpanded = expandedIds.has(preagg.id); const isDeactivating = deactivating.has(preagg.id); const hasActiveWorkflow = preagg.workflow_status === 'active'; return ( <div key={preagg.id} className={`preagg-row ${isStale ? 'preagg-row--stale' : ''}`} > {/* Collapsed header row */} <div className="preagg-row-header" onClick={() => toggleExpanded(preagg.id)} > <span className="preagg-row-toggle"> {isExpanded ? '\u25BC' : '\u25B6'} </span> {preagg.preagg_hash && ( <code className="preagg-row-hash" title="Pre-aggregation hash - used in table and workflow names" > {preagg.preagg_hash} </code> )} <div className="preagg-row-grain-chips"> {(() => { const grainCols = preagg.grain_columns || []; const maxVisible = MAX_VISIBLE_GRAIN; const visibleCols = grainCols.slice(0, maxVisible); const hiddenCount = grainCols.length - maxVisible; return ( <> {visibleCols.map((col, idx) => { const parts = col.split('.'); const shortName = parts[parts.length - 1]; return ( <span key={idx} className="preagg-grain-chip"> {shortName} </span> ); })} {hiddenCount > 0 && ( <span className="preagg-grain-chip preagg-grain-chip--more"> +{hiddenCount} </span> )} </> ); })()} </div> <span className="preagg-row-measures"> {preagg.measures?.length || 0} measure {(preagg.measures?.length || 0) !== 1 ? 's' : ''} </span> {preagg.related_metrics?.length > 0 && ( <span className="preagg-metric-count-badge"> {preagg.related_metrics.length} metric {preagg.related_metrics.length !== 1 ? 's' : ''} </span> )} {hasActiveWorkflow ? ( <span className="preagg-status-badge preagg-status-badge--active"> Active </span> ) : preagg.workflow_status === 'paused' ? ( <span className="preagg-status-badge preagg-status-badge--paused"> Paused </span> ) : ( <span className="preagg-status-badge preagg-status-badge--pending"> Pending </span> )} {preagg.schedule && ( <span className="preagg-row-schedule"> {formatSchedule(preagg.schedule).toLowerCase()} </span> )} {isStale && ( <span className="preagg-row-version"> was {preagg.node_version} </span> )} </div> {/* Expanded details */} {isExpanded && ( <div className={`preagg-details ${ isStale ? 'preagg-details--stale' : '' }`} > {isStale && ( <div className="preagg-stale-banner"> <span className="preagg-stale-banner-icon">⚠️</span> <div> <strong>Built for {preagg.node_version}</strong> — current is{' '} {node.version} <br /> <span className="preagg-stale-banner-text"> This workflow is still running but won't be used for queries. </span> </div> </div> )} <div className="preagg-stack"> {/* Config + Grain side by side */} <div className="preagg-two-column"> {/* Config */} <div> <div className="preagg-card-label">Config</div> <div className="preagg-card"> {/* Table-style key-value pairs */} <table className="preagg-config-table"> <tbody> <tr> <td className="preagg-config-key">Strategy</td> <td className="preagg-config-value"> {preagg.strategy ? labelize(preagg.strategy) : 'Not set'} </td> </tr> {preagg.preagg_hash && ( <tr> <td className="preagg-config-key">Hash</td> <td className="preagg-config-value"> <code className="preagg-hash-badge" title="Unique identifier for this pre-aggregation. Used in table and workflow names." > {preagg.preagg_hash} </code> </td> </tr> )} <tr> <td className="preagg-config-key">Schedule</td> <td className="preagg-config-value"> {preagg.schedule ? ( <> {formatSchedule(preagg.schedule)} <span className="preagg-config-schedule-cron"> ({preagg.schedule}) </span> </> ) : ( 'Not scheduled' )} </td> </tr> {preagg.lookback_window && ( <tr> <td className="preagg-config-key">Lookback</td> <td className="preagg-config-value"> {preagg.lookback_window} </td> </tr> )} {preagg.max_partition && preagg.max_partition.length > 0 && ( <tr> <td className="preagg-config-key"> Max Partition </td> <td className="preagg-config-value"> <code>{preagg.max_partition.join(', ')}</code> </td> </tr> )} </tbody> </table> {/* Actions */} <div className="preagg-actions"> {/* Workflow buttons - one per URL */} {preagg.workflow_urls?.map((wf, idx) => { const label = wf.label || 'Workflow'; const capitalizedLabel = label.charAt(0).toUpperCase() + label.slice(1); return ( <a key={idx} href={wf.url} target="_blank" rel="noopener noreferrer" className="preagg-action-btn" > {capitalizedLabel} </a> ); })} {hasActiveWorkflow && ( <button className={`preagg-action-btn preagg-action-btn--danger`} disabled={isDeactivating} onClick={e => { e.stopPropagation(); handleDeactivate(preagg.id); }} > {isDeactivating ? 'Deactivating...' : 'Deactivate'} </button> )} </div> </div> </div> {/* Grain */} <div> <div className="preagg-card-label">Grain</div> <div className="preagg-card preagg-card--compact"> <div className="preagg-grain-list"> {(() => { const grainCols = preagg.grain_columns || []; const isGrainExpanded = expandedGrainIds.has(preagg.id); const visibleCols = isGrainExpanded ? grainCols : grainCols.slice(0, MAX_VISIBLE_GRAIN); const hiddenCount = grainCols.length - MAX_VISIBLE_GRAIN; return ( <> {visibleCols.map((col, idx) => { const parts = col.split('.'); const nodeName = parts.slice(0, -1).join('.'); return ( <a key={idx} href={`/nodes/${nodeName}`} title={`View ${nodeName}`} className="preagg-grain-badge" > {col} </a> ); })} {!isGrainExpanded && hiddenCount > 0 && ( <button className="preagg-expand-btn" onClick={e => { e.stopPropagation(); setExpandedGrainIds(prev => { const next = new Set(prev); next.add(preagg.id); return next; }); }} > +{hiddenCount} more </button> )} {isGrainExpanded && hiddenCount > 0 && ( <button className="preagg-expand-btn" onClick={e => { e.stopPropagation(); setExpandedGrainIds(prev => { const next = new Set(prev); next.delete(preagg.id); return next; }); }} > Show less </button> )} </> ); })()} </div> </div> </div> </div> {/* Measures */} <div> <div className="preagg-card-label preagg-card-label--with-info"> Measures <span className="preagg-info-icon" title="Pre-computed aggregations stored in this pre-aggregation. At query time, DJ uses these to avoid re-scanning raw data." ></span> </div> <div className="preagg-card preagg-card--table" style={{ border: '1px solid #e2e8f0' }} > <table className="preagg-measures-table"> <thead> <tr> <th>Name</th> <th> Aggregation <span className="preagg-info-icon" title="Phase 1: How raw data is aggregated when building the pre-agg table" ></span> </th> <th> Merge <span className="preagg-info-icon" title="Phase 2: How pre-aggregated values are combined at query time" ></span> </th> <th> Rule <span className="preagg-info-icon" title="Additivity: FULL = can roll up across any dimension" ></span> </th> <th> Used By <span className="preagg-info-icon" title="Metrics that use this measure" ></span> </th> </tr> </thead> <tbody> {preagg.measures?.map((measure, idx) => ( <tr key={idx}> <td className="preagg-measure-name"> {measure.name} </td> <td> <code className="preagg-agg-badge"> {measure.aggregation ? `${measure.aggregation}(${measure.expression})` : measure.expression} </code> </td> <td> {measure.merge && ( <code className="preagg-merge-badge"> {measure.merge} </code> )} </td> <td> {measure.rule && ( <span className="preagg-rule-badge"> {typeof measure.rule === 'object' ? measure.rule.type || '' : measure.rule} </span> )} </td> <td> {measure.used_by_metrics?.length > 0 && ( <div className="preagg-metrics-list"> {measure.used_by_metrics.map((metric, mIdx) => ( <a key={mIdx} href={`/nodes/${metric.name}`} title={metric.name} className="preagg-metric-badge" > {metric.display_name || metric.name.split('.').pop()} </a> ))} </div> )} </td> </tr> ))} </tbody> </table> </div> </div> </div> </div> )} </div> ); }; // Loading state if (loading) { return <div className="preagg-loading">Loading pre-aggregations...</div>; } // Error state if (error) { return ( <div className="message alert preagg-error"> Error loading pre-aggregations: {error} </div> ); } // No pre-aggs if (preaggs.length === 0) { return ( <div className="preagg-no-data"> <div className="message alert preagg-no-data-alert"> No pre-aggregations found for this node. </div> <p className="preagg-no-data-text"> Visit <a href="/planner">Explorer</a> to plan materializations for metrics derived from this node. </p> </div> ); } // Calculate if there are active stale workflows const activeStaleCount = stalePreaggs.filter( p => p.workflow_status === 'active', ).length; return ( <div className="preagg-container"> {/* Current Version Section */} <div className="preagg-section"> <div className="preagg-section-header"> <h3 className="preagg-section-title"> Current Pre-Aggregations ({node.version}) </h3> <span className="preagg-section-count"> {currentPreaggs.length} pre-aggregation {currentPreaggs.length !== 1 ? 's' : ''} </span> </div> {currentPreaggs.length > 0 ? ( currentPreaggs.map(preagg => renderPreaggRow(preagg, false)) ) : ( <div className="preagg-empty"> No pre-aggregations for the current version. </div> )} </div> {/* Stale Section */} {stalePreaggs.length > 0 && ( <div className="preagg-section"> <div className="preagg-section-header preagg-section-header--stale"> <div className="preagg-section-header-left"> <h3 className="preagg-section-title preagg-section-title--stale"> Stale Pre-Aggregations ({stalePreaggs.length}) </h3> <span className="preagg-section-count preagg-section-count--stale"> {activeStaleCount} active workflow {activeStaleCount !== 1 ? 's' : ''} </span> </div> {activeStaleCount > 0 && ( <button className="preagg-action-btn preagg-action-btn--danger-fill" onClick={handleDeactivateAllStale} > Deactivate All Stale </button> )} </div> {stalePreaggs.map(preagg => renderPreaggRow(preagg, true))} </div> )} </div> ); }