UNPKG

datajunction-ui

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