UNPKG

datajunction-ui

Version:
1,273 lines (1,191 loc) 115 kB
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>