datajunction-ui
Version:
DataJunction UI
1,273 lines (1,191 loc) • 115 kB
JSX
import { useState, useEffect, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom';
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
import atomOneLight from 'react-syntax-highlighter/dist/esm/styles/hljs/atom-one-light';
import {
SCAN_WARNING_THRESHOLD,
SCAN_CRITICAL_THRESHOLD,
} from '../../constants';
/**
* Helper to extract dimension node name from a dimension path
* e.g., "v3.customer.name" -> "v3.customer"
* e.g., "v3.date.month[order]" -> "v3.date"
*/
function getDimensionNodeName(dimPath) {
// Remove role suffix if present (e.g., "[order]")
const pathWithoutRole = dimPath.split('[')[0];
// Split by dot and remove the last segment (column name)
const parts = pathWithoutRole.split('.');
if (parts.length > 1) {
return parts.slice(0, -1).join('.');
}
return pathWithoutRole;
}
/**
* Helper to normalize grain columns to short names for lookup key
*/
function normalizeGrain(grainCols) {
return (grainCols || [])
.map(col => col.split('.').pop())
.sort()
.join(',');
}
/**
* Helper to get a human-readable schedule summary from cron expression
*/
function getScheduleSummary(schedule) {
if (!schedule) return null;
// Basic cron parsing for common patterns
const parts = schedule.split(' ');
if (parts.length < 5) return schedule;
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
// Daily at specific hour
if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
const hourNum = parseInt(hour, 10);
const minuteNum = parseInt(minute, 10);
if (!isNaN(hourNum) && !isNaN(minuteNum)) {
const period = hourNum >= 12 ? 'pm' : 'am';
const displayHour = hourNum > 12 ? hourNum - 12 : hourNum || 12;
const displayMinute = minuteNum.toString().padStart(2, '0');
return `Daily @ ${displayHour}:${displayMinute}${period}`;
}
}
// Weekly
if (dayOfMonth === '*' && month === '*' && dayOfWeek !== '*') {
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const dayNum = parseInt(dayOfWeek, 10);
if (!isNaN(dayNum) && dayNum >= 0 && dayNum <= 6) {
return `Weekly on ${days[dayNum]}`;
}
}
return schedule;
}
/**
* Helper to get status display info
*/
function getStatusInfo(preagg) {
if (!preagg || preagg.workflow_urls?.length === 0) {
return {
icon: '○',
text: 'Not planned',
className: 'status-not-planned',
color: '#94a3b8',
};
}
// Check if this is a compatible (superset) pre-agg, not exact match
if (preagg._isCompatible) {
// Show that this grain is covered by an existing pre-agg with more dimensions
const preaggGrain = preagg.grain_columns
?.map(g => g.split('.').pop())
.join(', ');
// Check if that compatible pre-agg has data
if (preagg.availability || preagg.status === 'active') {
return {
icon: '✓',
text: `Covered (${preaggGrain})`,
className: 'status-compatible-materialized',
color: '#059669',
isCompatible: true,
};
}
return {
icon: '◐',
text: `Covered (${preaggGrain})`,
className: 'status-compatible',
color: '#d97706',
isCompatible: true,
};
}
// Check for running status (job is in progress)
if (preagg.status === 'running') {
return {
icon: '◉',
text: 'Running',
className: 'status-running',
color: '#2563eb',
};
}
// Check availability for materialization status (active = has data)
if (preagg.availability || preagg.status === 'active') {
return {
icon: '●',
text: 'Materialized',
className: 'status-materialized',
color: '#059669',
};
}
// Check if workflow is active
if (preagg.workflow_status === 'active') {
return {
icon: '◐',
text: 'Workflow Active',
className: 'status-workflow-active',
color: '#2563eb',
};
}
// Check if workflow is paused
if (preagg.workflow_status === 'paused') {
return {
icon: '◐',
text: 'Workflow Paused',
className: 'status-workflow-paused',
color: '#94a3b8',
};
}
// Check if workflow is being configured (has URLs but not yet active)
// Only show "Pending" if workflows exist but aren't active yet
if (preagg.workflow_urls?.length > 0) {
return {
icon: '◐',
text: 'Pending',
className: 'status-pending',
color: '#d97706',
};
}
// No workflow configured - show "Not planned"
return {
icon: '○',
text: 'Not planned',
className: 'status-not-planned',
color: '#94a3b8',
};
}
/**
* QueryOverviewPanel - Default view showing metrics SQL and pre-agg summary
*
* Shown when no node is selected in the graph
*/
/**
* Get recommended schedule based on granularity
*/
function getRecommendedSchedule(granularity) {
switch (granularity?.toUpperCase()) {
case 'HOUR':
return { cron: '0 * * * *', label: 'Hourly' };
case 'DAY':
default:
return { cron: '0 6 * * *', label: 'Daily at 6:00 AM' };
}
}
/**
* Get granularity hint from grain columns
*/
function inferGranularity(grainGroups) {
for (const gg of grainGroups || []) {
for (const col of gg.grain || []) {
const colName = col.split('.').pop().toLowerCase();
if (colName.includes('hour')) return 'HOUR';
}
}
return 'DAY'; // Default
}
/**
* Format bytes to human-readable string
*/
function formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
/**
* Get warning level for scan size
*/
function getScanWarningLevel(bytes) {
if (bytes > SCAN_CRITICAL_THRESHOLD) return 'critical';
if (bytes > SCAN_WARNING_THRESHOLD) return 'warning';
return 'ok';
}
/**
* Format scan estimate for display
*/
function formatScanEstimate(scanEstimate) {
if (
!scanEstimate ||
!scanEstimate.sources ||
scanEstimate.sources.length === 0
) {
return null;
}
// Check if any sources are missing size data
const hasMissingData = scanEstimate.sources.some(
s => s.total_bytes === null || s.total_bytes === undefined,
);
// Determine warning level based on total_bytes (if available)
let level = 'unknown';
let icon = 'ℹ️';
if (
scanEstimate.total_bytes !== null &&
scanEstimate.total_bytes !== undefined
) {
level = getScanWarningLevel(scanEstimate.total_bytes);
icon = level === 'critical' ? '⚠️' : level === 'warning' ? '⚡' : '✓';
}
return {
icon,
level,
totalBytes: scanEstimate.total_bytes,
sources: scanEstimate.sources || [],
hasMissingData,
};
}
/**
* CubeBackfillModal - Simple modal to collect start/end dates for backfill
*/
function CubeBackfillModal({ onClose, onSubmit, loading }) {
// Default to last 7 days
const today = new Date().toISOString().split('T')[0];
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0];
const [startDate, setStartDate] = useState(weekAgo);
const [endDate, setEndDate] = useState(today);
return (
<div className="backfill-modal-overlay" onClick={onClose}>
<div className="backfill-modal" onClick={e => e.stopPropagation()}>
<div className="backfill-modal-header">
<h3>Run Cube Backfill</h3>
<button className="modal-close" onClick={onClose}>
×
</button>
</div>
<div className="backfill-modal-body">
<p className="backfill-description">
Run a backfill for the specified date range:
</p>
<div className="backfill-date-inputs">
<div className="date-input-group">
<label htmlFor="backfill-start">Start Date</label>
<input
id="backfill-start"
type="date"
value={startDate}
onChange={e => setStartDate(e.target.value)}
disabled={loading}
/>
</div>
<div className="date-input-group">
<label htmlFor="backfill-end">End Date</label>
<input
id="backfill-end"
type="date"
value={endDate}
onChange={e => setEndDate(e.target.value)}
disabled={loading}
/>
</div>
</div>
</div>
<div className="backfill-modal-actions">
<button
className="action-btn action-btn-secondary"
onClick={onClose}
disabled={loading}
>
Cancel
</button>
<button
className="action-btn action-btn-primary"
disabled={loading || !startDate}
onClick={() => onSubmit(startDate, endDate || null)}
>
{loading ? (
<>
<span className="spinner" /> Starting...
</>
) : (
'Start Backfill'
)}
</button>
</div>
</div>
</div>
);
}
export function QueryOverviewPanel({
measuresResult,
metricsResult,
selectedMetrics,
selectedDimensions,
plannedPreaggs = {},
onPlanMaterialization,
onUpdateConfig,
onCreateWorkflow,
onRunBackfill,
onRunAdhoc,
onFetchRawSql,
onSetPartition,
onRefreshMeasures,
onFetchNodePartitions,
materializationError,
onClearError,
workflowUrls = [],
onClearWorkflowUrls,
loadedCubeName = null, // Existing cube name if loaded from preset
cubeMaterialization = null, // Full cube materialization info {schedule, strategy, lookbackWindow, ...}
onUpdateCubeConfig,
onRefreshCubeWorkflow,
onRunCubeBackfill,
onDeactivatePreaggWorkflow,
onDeactivateCubeWorkflow,
}) {
// Extract default namespace from the first selected metric (e.g., "v3.total_revenue" -> "v3")
const getDefaultNamespace = useCallback(() => {
if (selectedMetrics && selectedMetrics.length > 0) {
const firstMetric = selectedMetrics[0];
const parts = firstMetric.split('.');
// Take all parts except the last one (the metric name)
if (parts.length > 1) {
return parts.slice(0, -1).join('.');
}
}
return 'default';
}, [selectedMetrics]);
const [expandedCards, setExpandedCards] = useState({});
const [configuringCard, setConfiguringCard] = useState(null); // '__all__' for section-level
const [editingCard, setEditingCard] = useState(null); // grainKey of existing card being edited
const [editingCube, setEditingCube] = useState(false); // Whether cube config form is open
const [cubeBackfillModal, setCubeBackfillModal] = useState(false); // Cube backfill modal state
const [cubeConfigForm, setCubeConfigForm] = useState({
strategy: 'incremental_time',
schedule: '0 6 * * *',
lookbackWindow: '1 DAY',
});
const [isSavingCube, setIsSavingCube] = useState(false);
// Partition setup form state (for setting up temporal partition inline)
// Map of nodeName -> { column, granularity, format }
const [showPartitionSetup, setShowPartitionSetup] = useState(false);
const [partitionForms, setPartitionForms] = useState({});
const [settingPartitionFor, setSettingPartitionFor] = useState(null); // nodeName being set
const [partitionErrors, setPartitionErrors] = useState({}); // nodeName -> error
// Actual temporal partitions from source nodes (fetched via API)
// Map of nodeName -> { columns, temporalPartitions }
const [allNodePartitions, setAllNodePartitions] = useState({});
const [partitionsLoading, setPartitionsLoading] = useState(false);
// Enhanced config form state
const [configForm, setConfigForm] = useState({
strategy: 'incremental_time',
backfillFrom: '',
backfillTo: 'today', // 'today' or specific date
backfillToDate: '',
continueAfterBackfill: true,
schedule: '',
scheduleType: 'auto', // 'auto' or 'custom'
lookbackWindow: '1 day',
// Druid cube materialization settings
enableDruidCube: true,
druidCubeNamespace: '', // Will be initialized with user's namespace
druidCubeName: '', // Short name without namespace
});
const [isSaving, setIsSaving] = useState(false);
const [loadingAction, setLoadingAction] = useState(null); // Track which action is loading: 'workflow', 'backfill', 'trigger'
// Backfill modal state (for existing pre-aggs)
const [backfillModal, setBackfillModal] = useState(null);
// Toast state for job URLs
const [toastMessage, setToastMessage] = useState(null);
// SQL view toggle state: 'optimized' (uses pre-aggs) or 'raw' (from source tables)
const [sqlViewMode, setSqlViewMode] = useState('optimized');
const [rawResult, setRawResult] = useState(null);
const [loadingRawSql, setLoadingRawSql] = useState(false);
// Handle SQL view toggle
const handleSqlViewToggle = async mode => {
setSqlViewMode(mode);
// Fetch raw SQL lazily when switching to raw mode
if (mode === 'raw' && !rawResult && onFetchRawSql) {
setLoadingRawSql(true);
const result = await onFetchRawSql();
setRawResult(result);
setLoadingRawSql(false);
}
};
// Get unique parent nodes from grain groups
const getUniqueParentNodes = useCallback(() => {
const grainGroups = measuresResult?.grain_groups || [];
const uniqueNodes = new Set();
grainGroups.forEach(gg => {
if (gg.parent_name) uniqueNodes.add(gg.parent_name);
});
return Array.from(uniqueNodes);
}, [measuresResult?.grain_groups]);
// Fetch actual partition info for ALL source nodes when config form opens
// Returns a map of nodeName -> { columns, temporalPartitions }
const fetchAllNodePartitions = useCallback(async () => {
const parentNodes = getUniqueParentNodes();
if (parentNodes.length === 0 || !onFetchNodePartitions) {
setAllNodePartitions({});
return {};
}
setPartitionsLoading(true);
try {
const results = {};
// Fetch partitions for all nodes in parallel
const promises = parentNodes.map(async nodeName => {
try {
const result = await onFetchNodePartitions(nodeName);
results[nodeName] = result;
} catch (err) {
console.error(`Failed to fetch partitions for ${nodeName}:`, err);
results[nodeName] = { columns: [], temporalPartitions: [] };
}
});
await Promise.all(promises);
console.log('[fetchAllNodePartitions] results:', results);
setAllNodePartitions(results);
return results;
} catch (err) {
console.error('Failed to fetch node partitions:', err);
setAllNodePartitions({});
return {};
} finally {
setPartitionsLoading(false);
}
}, [getUniqueParentNodes, onFetchNodePartitions]);
// Check if ALL parent nodes have temporal partitions
const hasActualTemporalPartitions = useCallback(() => {
const parentNodes = getUniqueParentNodes();
if (parentNodes.length === 0) return false;
// All nodes must have temporal partitions
const allHaveTemporal = parentNodes.every(nodeName => {
const nodePartitions = allNodePartitions[nodeName];
return nodePartitions?.temporalPartitions?.length > 0;
});
console.log('[hasActualTemporalPartitions]', {
parentNodes,
allNodePartitions,
allHaveTemporal,
});
return allHaveTemporal;
}, [getUniqueParentNodes, allNodePartitions]);
// Get nodes that are missing temporal partitions
const getNodesMissingPartitions = useCallback(() => {
const parentNodes = getUniqueParentNodes();
return parentNodes.filter(nodeName => {
const nodePartitions = allNodePartitions[nodeName];
return !nodePartitions?.temporalPartitions?.length;
});
}, [getUniqueParentNodes, allNodePartitions]);
// Initialize config form with smart defaults when opening
const openConfigForm = async () => {
const grainGroups = measuresResult?.grain_groups || [];
const granularity = inferGranularity(grainGroups);
const recommended = getRecommendedSchedule(granularity);
// Default backfill start to 30 days ago
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0];
// Fetch actual partition info for ALL source nodes
const partitionResults = await fetchAllNodePartitions();
// Check if ALL nodes have temporal partitions
const parentNodes = getUniqueParentNodes();
const allHaveTemporal = parentNodes.every(
nodeName => partitionResults[nodeName]?.temporalPartitions?.length > 0,
);
console.log(
'[openConfigForm] allHaveTemporal:',
allHaveTemporal,
'partitionResults:',
partitionResults,
);
// Initialize partition forms for nodes missing partitions
const datePattern =
/date|time|day|month|year|hour|ds|dt|dateint|timestamp/i;
const initialForms = {};
parentNodes.forEach(nodeName => {
const nodePartitions = partitionResults[nodeName];
if (!nodePartitions?.temporalPartitions?.length) {
// Find best default column (date-like, unpartitioned)
const cols = (nodePartitions?.columns || []).filter(c => !c.partition);
const sortedCols = [...cols].sort((a, b) => {
const aIsDate = datePattern.test(a.name);
const bIsDate = datePattern.test(b.name);
if (aIsDate && !bIsDate) return -1;
if (!aIsDate && bIsDate) return 1;
return a.name.localeCompare(b.name);
});
initialForms[nodeName] = {
column: sortedCols[0]?.name || '',
granularity: 'day',
format: 'yyyyMMdd',
};
}
});
setPartitionForms(initialForms);
setPartitionErrors({});
setConfigForm({
strategy: allHaveTemporal ? 'incremental_time' : 'full',
runBackfill: true, // Option to skip backfill
backfillFrom: thirtyDaysAgo,
backfillTo: 'today',
backfillToDate: '',
continueAfterBackfill: true,
schedule: recommended.cron,
scheduleType: 'auto',
lookbackWindow: '1 day',
_recommendedSchedule: recommended, // Store for display
_granularity: granularity,
// Druid cube settings
enableDruidCube: true,
druidCubeNamespace: getDefaultNamespace(), // Default to first metric's namespace
druidCubeName: '', // Short name without namespace
});
setConfiguringCard('__all__');
};
// Helper to start editing an existing pre-agg's config
const startEditingConfig = (grainKey, existingPreagg) => {
setConfigForm({
strategy: existingPreagg.strategy || 'incremental_time',
backfillFrom: '',
backfillTo: 'today',
backfillToDate: '',
continueAfterBackfill: true,
schedule: existingPreagg.schedule || '',
scheduleType: existingPreagg.schedule ? 'custom' : 'auto',
lookbackWindow: existingPreagg.lookback_window || '',
});
setEditingCard(grainKey);
};
const copyToClipboard = text => {
navigator.clipboard.writeText(text);
};
const toggleCardExpanded = cardKey => {
setExpandedCards(prev => ({
...prev,
[cardKey]: !prev[cardKey],
}));
};
// No selection yet
if (!selectedMetrics?.length || !selectedDimensions?.length) {
return (
<div className="details-panel details-panel-empty">
<div className="empty-hint">
<div className="empty-icon">⊞</div>
<h4>Query Planner</h4>
<p>
Select metrics and dimensions from the left panel to see the
generated SQL and pre-aggregation plan
</p>
</div>
</div>
);
}
// Loading or no results yet
if (!measuresResult || !metricsResult) {
return (
<div className="details-panel details-panel-empty">
<div className="empty-hint">
<div className="loading-spinner" />
<p>Building query plan...</p>
</div>
</div>
);
}
const grainGroups = measuresResult.grain_groups || [];
const metricFormulas = measuresResult.metric_formulas || [];
const sql = metricsResult.sql || '';
// Determine if materialization is already configured (has active workflows)
const isMaterialized =
workflowUrls.length > 0 ||
Object.values(plannedPreaggs).some(p => p?.workflow_urls?.length > 0);
// Get materialization summary for collapsed state
const getMaterializationSummary = () => {
const hasCube = workflowUrls.length > 0;
const preaggCount = Object.values(plannedPreaggs).filter(
p => p?.workflow_urls?.length > 0,
).length;
const strategy =
Object.values(plannedPreaggs).find(p => p?.strategy)?.strategy ||
'incremental_time';
const schedule =
Object.values(plannedPreaggs).find(p => p?.schedule)?.schedule ||
'0 6 * * *';
return {
hasCube,
preaggCount,
strategy: strategy === 'incremental_time' ? 'Incremental' : 'Full',
schedule: getScheduleSummary(schedule),
};
};
return (
<div className="details-panel">
{/* Header */}
<div className="details-header">
<h2 className="details-title">Query Plan</h2>
<p className="details-full-name">
{selectedMetrics.length} metric
{selectedMetrics.length !== 1 ? 's' : ''} ×{' '}
{selectedDimensions.length} dimension
{selectedDimensions.length !== 1 ? 's' : ''}
</p>
</div>
{/* Global Materialization Error Banner - always visible when there's an error */}
{materializationError && (
<div className="materialization-error global-error">
<div className="error-content">
<span className="error-icon">⚠</span>
<span className="error-message">{materializationError}</span>
</div>
<button
className="error-dismiss"
onClick={onClearError}
aria-label="Dismiss error"
>
×
</button>
</div>
)}
{/* Materialization Config Section - Only show when there's content */}
{grainGroups.length > 0 &&
onPlanMaterialization &&
(!isMaterialized || configuringCard === '__all__') && (
<div className="details-section">
{/* State A: Not materialized - show CTA */}
{!isMaterialized && configuringCard !== '__all__' && (
<div className="plan-materialization-cta">
<div className="cta-content">
<div className="cta-icon">⚡</div>
<div className="cta-text">
<strong>Ready to materialize?</strong>
<span>
Configure scheduled materialization for faster queries
</span>
</div>
</div>
<button
className="action-btn action-btn-primary"
type="button"
onClick={openConfigForm}
>
Configure
</button>
</div>
)}
{/* Section-level Configuration Form - Enhanced */}
{configuringCard === '__all__' && (
<div className="materialization-config-form section-level-config">
<div className="config-form-header">
<span>Configure Materialization</span>
<button
className="config-close-btn"
type="button"
onClick={() => setConfiguringCard(null)}
>
×
</button>
</div>
<div className="config-form-body">
{/* Strategy */}
<div className="config-form-row">
<label className="config-form-label">Strategy</label>
<div className="config-form-options">
<label className="radio-option">
<input
type="radio"
name="strategy-all"
value="full"
checked={configForm.strategy === 'full'}
onChange={e =>
setConfigForm(prev => ({
...prev,
strategy: e.target.value,
}))
}
/>
<span>Full</span>
</label>
<label className="radio-option">
<input
type="radio"
name="strategy-all"
value="incremental_time"
checked={configForm.strategy === 'incremental_time'}
onChange={e => {
setConfigForm(prev => ({
...prev,
strategy: e.target.value,
}));
// Auto-show partition setup if any node is missing temporal partition
if (
!hasActualTemporalPartitions() &&
!partitionsLoading
) {
setShowPartitionSetup(true);
}
}}
/>
<span>Incremental</span>
{configForm.strategy === 'incremental_time' &&
(partitionsLoading ? (
<span className="option-hint">(checking...)</span>
) : hasActualTemporalPartitions() ? (
<span className="partition-badge">
{getUniqueParentNodes()
.map(
nodeName =>
allNodePartitions[nodeName]
?.temporalPartitions?.[0]?.name,
)
.filter(Boolean)
.join(', ')}
</span>
) : null)}
</label>
</div>
</div>
{/* Inline Partition Setup Form - Per Node */}
{showPartitionSetup &&
configForm.strategy === 'incremental_time' &&
!hasActualTemporalPartitions() && (
<div className="partition-setup-form">
<div className="partition-setup-header">
<span className="partition-setup-icon">⚠️</span>
<span>
Set up temporal partitions for incremental builds
</span>
</div>
<div className="partition-setup-body">
{getUniqueParentNodes().map(nodeName => {
const nodePartitions = allNodePartitions[nodeName];
const hasTemporal =
nodePartitions?.temporalPartitions?.length > 0;
const form = partitionForms[nodeName] || {
column: '',
granularity: 'day',
format: 'yyyyMMdd',
};
const error = partitionErrors[nodeName];
const isSettingThis =
settingPartitionFor === nodeName;
const shortName = nodeName.split('.').pop();
// If this node already has temporal partitions, show success
if (hasTemporal) {
return (
<div
key={nodeName}
className="partition-node-section partition-node-done"
>
<div className="partition-node-header">
<span className="partition-node-name">
<span className="partition-node-icon">
✓
</span>
{shortName}
</span>
</div>
<div className="partition-node-status">
<span className="partition-badge">
{
nodePartitions.temporalPartitions[0]
?.name
}
</span>
</div>
</div>
);
}
// Show setup form for this node
return (
<div
key={nodeName}
className="partition-node-section"
>
<div className="partition-node-header">
<span className="partition-node-name">
<span className="partition-node-icon">
📦
</span>
{shortName}
</span>
</div>
{error && (
<div className="partition-setup-error">
{error}
</div>
)}
<div className="partition-node-form">
<div className="partition-field">
<label>Column</label>
<select
value={form.column}
onChange={e =>
setPartitionForms(prev => ({
...prev,
[nodeName]: {
...form,
column: e.target.value,
},
}))
}
>
<option value="">Select...</option>
{(() => {
const datePattern =
/date|time|day|month|year|hour|ds|dt|dateint|timestamp/i;
return (nodePartitions?.columns || [])
.filter(col => !col.partition)
.map(col => ({
...col,
isDateLike: datePattern.test(
col.name,
),
}))
.sort((a, b) => {
if (a.isDateLike && !b.isDateLike)
return -1;
if (!a.isDateLike && b.isDateLike)
return 1;
return a.name.localeCompare(b.name);
})
.map(col => (
<option
key={col.name}
value={col.name}
>
{col.name}
{col.isDateLike ? ' ★' : ''}
</option>
));
})()}
</select>
</div>
<div className="partition-field partition-field-small">
<label>Granularity</label>
<select
value={form.granularity}
onChange={e =>
setPartitionForms(prev => ({
...prev,
[nodeName]: {
...form,
granularity: e.target.value,
},
}))
}
>
<option value="day">Day</option>
<option value="hour">Hour</option>
<option value="month">Month</option>
</select>
</div>
<div className="partition-field partition-field-small">
<label>Format</label>
<input
type="text"
placeholder="yyyyMMdd"
value={form.format}
onChange={e =>
setPartitionForms(prev => ({
...prev,
[nodeName]: {
...form,
format: e.target.value,
},
}))
}
/>
</div>
<button
type="button"
className="partition-set-btn"
disabled={!form.column || isSettingThis}
onClick={async () => {
if (!form.column) return;
setSettingPartitionFor(nodeName);
setPartitionErrors(prev => ({
...prev,
[nodeName]: null,
}));
try {
const result = await onSetPartition(
nodeName,
form.column,
'temporal',
form.format || 'yyyyMMdd',
form.granularity,
);
if (result?.status >= 400) {
throw new Error(
result.json?.message ||
'Failed to set partition',
);
}
// Refresh partitions to pick up the new one
await fetchAllNodePartitions();
// Check if all nodes now have partitions
if (hasActualTemporalPartitions()) {
setShowPartitionSetup(false);
}
} catch (err) {
setPartitionErrors(prev => ({
...prev,
[nodeName]:
err.message ||
'Failed to set partition',
}));
} finally {
setSettingPartitionFor(null);
}
}}
>
{isSettingThis ? '...' : 'Set'}
</button>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Run Backfill Option (only for incremental) */}
{configForm.strategy === 'incremental_time' && (
<div className="config-form-row">
<label className="checkbox-option">
<input
type="checkbox"
checked={configForm.runBackfill}
onChange={e =>
setConfigForm(prev => ({
...prev,
runBackfill: e.target.checked,
}))
}
/>
<span>Run initial backfill</span>
</label>
<span className="config-form-hint">
Populate historical data. Uncheck to only set up ongoing
materialization.
</span>
</div>
)}
{/* Backfill Date Range (only if runBackfill is checked) */}
{configForm.strategy === 'incremental_time' &&
configForm.runBackfill && (
<div className="config-form-section">
<label className="config-form-section-label">
Backfill Date Range
</label>
<div className="backfill-range">
<div className="backfill-field">
<label>From</label>
<input
type="date"
value={configForm.backfillFrom}
onChange={e =>
setConfigForm(prev => ({
...prev,
backfillFrom: e.target.value,
}))
}
/>
</div>
<div className="backfill-field">
<label>To</label>
<select
value={configForm.backfillTo}
onChange={e =>
setConfigForm(prev => ({
...prev,
backfillTo: e.target.value,
}))
}
>
<option value="today">Today</option>
<option value="specific">Specific date</option>
</select>
{configForm.backfillTo === 'specific' && (
<input
type="date"
value={configForm.backfillToDate}
onChange={e =>
setConfigForm(prev => ({
...prev,
backfillToDate: e.target.value,
}))
}
style={{ marginTop: '6px' }}
/>
)}
</div>
</div>
</div>
)}
{/* Schedule - always shown since we always create workflows */}
<div className="config-form-row">
<label className="config-form-label">Schedule</label>
<select
className="config-form-select"
value={configForm.scheduleType}
onChange={e => {
const type = e.target.value;
setConfigForm(prev => ({
...prev,
scheduleType: type,
schedule:
type === 'auto'
? prev._recommendedSchedule?.cron || '0 6 * * *'
: prev.schedule,
}));
}}
>
<option value="auto">
{configForm._recommendedSchedule?.label ||
'Daily at 6:00 AM'}{' '}
(recommended)
</option>
<option value="hourly">Hourly</option>
<option value="custom">Custom cron...</option>
</select>
{configForm.scheduleType === 'custom' && (
<input
type="text"
className="config-form-input"
placeholder="0 6 * * *"
value={configForm.schedule}
onChange={e =>
setConfigForm(prev => ({
...prev,
schedule: e.target.value,
}))
}
style={{ marginTop: '6px' }}
/>
)}
{configForm.scheduleType === 'auto' && (
<span className="config-form-hint">
Based on{' '}
{configForm._granularity?.toLowerCase() || 'daily'}{' '}
partition granularity
</span>
)}
</div>
{/* Lookback Window (only for incremental) */}
{configForm.strategy === 'incremental_time' && (
<div className="config-form-row">
<label className="config-form-label">
Lookback Window
</label>
<input
type="text"
className="config-form-input"
placeholder="1 day"
value={configForm.lookbackWindow}
onChange={e =>
setConfigForm(prev => ({
...prev,
lookbackWindow: e.target.value,
}))
}
/>
<span className="config-form-hint">
For late-arriving data (e.g., "1 day", "3 days")
</span>
</div>
)}
{/* Druid Cube Materialization Section */}
<div className="config-form-divider">
<span>Cube Materialization (Druid)</span>
</div>
<div className="config-form-row">
<label className="checkbox-option">
<input
type="checkbox"
checked={configForm.enableDruidCube}
onChange={e =>
setConfigForm(prev => ({
...prev,
enableDruidCube: e.target.checked,
}))
}
/>
Enable Druid cube materialization
</label>
<span className="config-form-hint">
Combines pre-aggs into a single Druid datasource for fast
interactive queries
</span>
</div>
{/* Druid cube config (shown when enabled and there is no associated cube) */}
{configForm.enableDruidCube && !loadedCubeName && (
<div className="config-form-section druid-cube-config">
{/* Show existing cube or prompt for new name */}
{loadedCubeName ? (
<div className="config-form-row">
<label className="config-form-label">
Using Cube
</label>
<div className="existing-cube-name">
<span className="cube-badge">📦</span>
<code>{loadedCubeName}</code>
</div>
<span className="config-form-hint">
Materialization will be added to this existing cube
</span>
</div>
) : (
<div className="config-form-row">
<label className="config-form-label">Cube Name</label>
<div className="cube-name-input-group">
<input
type="text"
className="config-form-input namespace-input"
placeholder="users.myname"
value={configForm.druidCubeNamespace}
onChange={e =>
setConfigForm(prev => ({
...prev,
druidCubeNamespace: e.target.value,
}))
}
/>
<span className="namespace-separator">.</span>
<input
type="text"
className="config-form-input name-input"
placeholder="my_cube"
value={configForm.druidCubeName}
onChange={e =>
setConfigForm(prev => ({
...prev,
druidCubeName: e.target.value,
}))
}
/>
</div>
<span className="config-form-hint">
Full name:{' '}
<code>
{configForm.druidCubeNamespace}.
{configForm.druidCubeName || 'my_cube'}
</code>
</span>
</div>
)}
{/* Preview of what will be combined */}
<div className="druid-cube-preview">
<div className="preview-label">
Pre-aggregations to combine:
</div>
<div className="preview-list">
{(measuresResult?.grain_groups || []).map((gg, i) => (
<div key={i} className="preview-item">
<span className="preview-source">
{gg.parent_name?.split('.').pop()}
</span>
<span className="preview-grain">
(
{(gg.grain || [])
.map(g => g.split('.').pop())
.join(', ')}
)
</span>
</div>
))}
</div>
<div className="preview-info">
<span className="info-icon">ℹ️</span>
Pre-aggs will be combined with FULL OUTER JOIN on
shared dimensions and ingested to Druid
</div>
</div>
</div>