datajunction-ui
Version:
DataJunction UI
677 lines (631 loc) • 24 kB
JSX
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>
);
}