UNPKG

datajunction-ui

Version:
1,442 lines (1,304 loc) 53.6 kB
import { useContext, useEffect, useState, useCallback, useRef, lazy, Suspense, } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import DJClientContext from '../../providers/djclient'; import SelectionPanel from './SelectionPanel'; import { PreAggDetailsPanel, MetricDetailsPanel, QueryOverviewPanel, } from './PreAggDetailsPanel'; import ResultsView from './ResultsView'; import './styles.css'; // Lazy load the graph component - ReactFlow and dagre are heavy (~500KB) // This allows API calls to start immediately while the graph loads in parallel const MetricFlowGraph = lazy(() => import('./MetricFlowGraph')); /** * Helper to normalize grain columns to short names for comparison * "default.date_dim.date_id" -> "date_id" */ function normalizeGrain(grainCols) { return (grainCols || []) .map(col => col.split('.').pop()) .sort() .join(','); } export function QueryPlannerPage() { const djClient = useContext(DJClientContext).DataJunctionAPI; const location = useLocation(); const navigate = useNavigate(); // Available options const [metrics, setMetrics] = useState([]); const [commonDimensions, setCommonDimensions] = useState([]); const [cubes, setCubes] = useState([]); // Selection state - initialized from URL params const [selectedMetrics, setSelectedMetrics] = useState([]); const [selectedDimensions, setSelectedDimensions] = useState([]); const [loadedCubeName, setLoadedCubeName] = useState(null); // Track loaded cube preset // Track if we've initialized from URL (to avoid overwriting URL on first render) const initializedFromUrl = useRef(false); const pendingDimensionsFromUrl = useRef([]); const pendingCubeFromUrl = useRef(null); // Dimension objects seeded directly from a loaded cube preset. Kept so the // background common-dimensions fetch can be merged in (union) without dropping // the cube's own dimensions from the available list or the selection. const cubeDimensionObjects = useRef([]); // Compatible metrics when dimensions are selected (phase 1: dimension-first flow) const [compatibleMetrics, setCompatibleMetrics] = useState(null); // null = no filter active // Results state const [measuresResult, setMeasuresResult] = useState(null); const [metricsResult, setMetricsResult] = useState(null); const [loading, setLoading] = useState(false); const [dimensionsLoading, setDimensionsLoading] = useState(false); const [error, setError] = useState(null); // Query execution state const [showResults, setShowResults] = useState(false); const [queryResults, setQueryResults] = useState(null); const [queryLoading, setQueryLoading] = useState(false); const [queryError, setQueryError] = useState(null); const [queryStartTime, setQueryStartTime] = useState(null); const [queryElapsedTime, setQueryElapsedTime] = useState(null); const [queryLinks, setQueryLinks] = useState([]); // Filters state const [filters, setFilters] = useState([]); // Engine selection: null = auto, 'druid', 'trino' const [selectedEngine, setSelectedEngine] = useState(null); // Cube availability state (for displaying freshness info) const [cubeAvailability, setCubeAvailability] = useState(null); // Node selection for details panel const [selectedNode, setSelectedNode] = useState(null); // Resizable pane widths (px); null = use CSS default const [selectionWidth, setSelectionWidth] = useState(null); const [detailsWidth, setDetailsWidth] = useState(null); const plannerLayoutRef = useRef(null); const handleSelectionResizerMouseDown = useCallback( e => { e.preventDefault(); const container = plannerLayoutRef.current; if (!container) return; const startX = e.clientX; const startWidth = selectionWidth ?? container.offsetWidth * 0.2; // match CSS default 20% const onMouseMove = moveEvent => { const delta = moveEvent.clientX - startX; const newWidth = Math.max( 200, Math.min(container.offsetWidth * 0.5, startWidth + delta), ); setSelectionWidth(newWidth); }; const onMouseUp = () => { document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }, [selectionWidth], ); const handleDetailsResizerMouseDown = useCallback( e => { e.preventDefault(); const container = plannerLayoutRef.current; if (!container) return; const startX = e.clientX; const startWidth = detailsWidth ?? container.offsetWidth * 0.4; // match CSS default 40% const onMouseMove = moveEvent => { const delta = startX - moveEvent.clientX; const newWidth = Math.max( 280, Math.min(container.offsetWidth * 0.7, startWidth + delta), ); setDetailsWidth(newWidth); }; const onMouseUp = () => { document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }, [detailsWidth], ); // Materialization state - map of grain_key -> pre-agg info const [plannedPreaggs, setPlannedPreaggs] = useState({}); // Materialization error state const [materializationError, setMaterializationError] = useState(null); // Workflow URLs from successful cube materialization const [workflowUrls, setWorkflowUrls] = useState([]); // Full cube materialization info (for edit/refresh/backfill) const [cubeMaterialization, setCubeMaterialization] = useState(null); // Initialize selection from URL params on mount useEffect(() => { const params = new URLSearchParams(location.search); const urlMetrics = params.get('metrics')?.split(',').filter(Boolean) || []; const urlDimensions = params.get('dimensions')?.split(',').filter(Boolean) || []; const urlFilters = params.getAll('filters'); const urlCube = params.get('cube'); if (urlFilters.length > 0) { setFilters(urlFilters); } if (urlMetrics.length > 0) { setSelectedMetrics(urlMetrics); // Store dimensions to apply after commonDimensions are loaded if (urlDimensions.length > 0) { pendingDimensionsFromUrl.current = urlDimensions; } // Store cube name - will be set after cube data is loaded if (urlCube) { pendingCubeFromUrl.current = urlCube; // Don't set loadedCubeName here - wait until cube data loads } initializedFromUrl.current = true; } else if (urlCube) { // Cube specified without metrics - will load cube on mount pendingCubeFromUrl.current = urlCube; initializedFromUrl.current = true; } }, []); // Only run on mount // Update URL when selection changes useEffect(() => { // Skip the first render if we just initialized from URL if ( !initializedFromUrl.current && selectedMetrics.length === 0 && !loadedCubeName ) { return; } const params = new URLSearchParams(); if (loadedCubeName) { // Cube is loaded - only include cube name (metrics/dims come from cube definition) params.set('cube', loadedCubeName); } else { // No cube - include metrics and dimensions if (selectedMetrics.length > 0) { params.set('metrics', selectedMetrics.join(',')); } if (selectedDimensions.length > 0) { params.set('dimensions', selectedDimensions.join(',')); } filters.forEach(f => params.append('filters', f)); } const newSearch = params.toString(); const currentSearch = location.search.replace(/^\?/, ''); // Only update if different (avoid unnecessary history entries) if (newSearch !== currentSearch) { navigate( { pathname: location.pathname, search: newSearch ? `?${newSearch}` : '', }, { replace: true }, ); } }, [ selectedMetrics, selectedDimensions, filters, loadedCubeName, location.pathname, navigate, ]); // Get metrics list and cube names on mount // Uses GraphQL for lightweight cube listing with display names useEffect(() => { const fetchData = async () => { const [metricsList, cubesList] = await Promise.all([ djClient.metrics(), djClient.listCubesForPreset().catch(() => []), ]); setMetrics(metricsList); // cubesList returns [{name, display_name}] from GraphQL setCubes(cubesList); // If there's a pending cube from URL, load it now if (pendingCubeFromUrl.current) { const cubeName = pendingCubeFromUrl.current; pendingCubeFromUrl.current = null; // Clear to prevent re-loading try { // Use lightweight GraphQL query - much faster than REST endpoint const cubeData = await djClient.cubeForPlanner(cubeName); // Validate cube data has expected fields if (cubeData && Array.isArray(cubeData.cube_node_metrics)) { const cubeMetrics = cubeData.cube_node_metrics || []; const cubeDimensions = cubeData.cube_node_dimensions || []; const cubeDimObjects = cubeData.cube_dimension_objects || []; setLoadedCubeName(cubeName); setSelectedMetrics(cubeMetrics); // Seed the dimension picker and selection straight from the cube so // they appear instantly and correctly; the full common-dimensions // list is fetched in the background (and merged) for adding more. cubeDimensionObjects.current = cubeDimObjects; setCommonDimensions(cubeDimObjects); setSelectedDimensions(cubeDimensions); // Materialization info is included in the GraphQL response const cubeMat = cubeData.cubeMaterialization; setCubeMaterialization(cubeMat || null); setWorkflowUrls(cubeMat?.workflowUrls || []); } else { console.error('Invalid cube data from URL:', cubeData); } } catch (err) { console.error('Failed to load cube from URL:', err); } } }; fetchData().catch(console.error); }, [djClient]); // Get common dimensions when metrics change useEffect(() => { const fetchData = async () => { if (selectedMetrics.length > 0) { // Capture cube-seeded dimensions for this run. When a cube preset is // loaded its dimensions are already shown and selected, so the (slow) // common-dimensions intersection runs in the background — don't blank // the list behind a spinner, and merge rather than replace below. const seeded = cubeDimensionObjects.current; if (seeded.length === 0) { setDimensionsLoading(true); } try { const dims = await djClient.commonDimensions(selectedMetrics); // Server returns an error body (e.g. {message: "..."}) on 404/422 with // a non-OK status, but commonDimensions() doesn't surface that — guard // against passing a non-array into setCommonDimensions. if (!Array.isArray(dims)) { console.error('commonDimensions returned non-array:', dims); // Keep the cube-seeded dims (if any) rather than blanking the list. setCommonDimensions(seeded); return; } // Merge: fetched dims win (richer: path/type), but keep any cube // dimensions the intersection omits so the cube's selection stays valid. const fetchedNames = new Set(dims.map(d => d.name)); const merged = [ ...dims, ...seeded.filter(d => !fetchedNames.has(d.name)), ]; setCommonDimensions(merged); // Apply pending dimensions from URL if we have them (metrics+dimensions // URL path; cube presets seed their dimensions directly instead). if (pendingDimensionsFromUrl.current.length > 0) { const validDimNames = merged.map(d => d.name); const validPending = pendingDimensionsFromUrl.current.filter(d => validDimNames.includes(d), ); if (validPending.length > 0) { setSelectedDimensions(validPending); } pendingDimensionsFromUrl.current = []; // Clear after applying } } catch (err) { console.error('Failed to fetch dimensions:', err); // Preserve cube-seeded dims on failure rather than blanking the list. setCommonDimensions(seeded); } setDimensionsLoading(false); } else { setCommonDimensions([]); setSelectedDimensions([]); } }; fetchData().catch(console.error); }, [selectedMetrics, djClient]); // Clear dimension selections that are no longer valid useEffect(() => { const validDimNames = commonDimensions.map(d => d.name); const validSelections = selectedDimensions.filter(d => validDimNames.includes(d), ); if (validSelections.length !== selectedDimensions.length) { setSelectedDimensions(validSelections); } }, [commonDimensions, selectedDimensions]); // Phase 1: When dimensions are selected, reactively find compatible metrics useEffect(() => { if (selectedDimensions.length === 0) { setCompatibleMetrics(null); return; } let cancelled = false; djClient .commonMetrics(selectedDimensions) .then(result => { if (cancelled) return; // API returns array of node names (strings) or objects with a name field if (Array.isArray(result)) { const names = result.map(r => (typeof r === 'string' ? r : r.name)); setCompatibleMetrics(new Set(names)); } else { setCompatibleMetrics(null); } }) .catch(() => { if (!cancelled) setCompatibleMetrics(null); }); return () => { cancelled = true; }; }, [selectedDimensions, djClient]); // Fetch V3 measures and metrics SQL when selection, filters, or engine changes useEffect(() => { const fetchData = async () => { if (selectedMetrics.length > 0) { setLoading(true); setError(null); setSelectedNode(null); try { // Derive useMaterialized and dialect from selectedEngine const useMaterialized = selectedEngine !== 'trino'; const dialect = selectedEngine || null; // Fetch both measures and metrics SQL in parallel const [measures, metrics] = await Promise.all([ djClient.measuresV3(selectedMetrics, selectedDimensions), djClient.metricsV3( selectedMetrics, selectedDimensions, filters, useMaterialized, dialect, ), ]); setMeasuresResult(measures); setMetricsResult(metrics); } catch (err) { setError(err.message || 'Failed to fetch data'); setMeasuresResult(null); setMetricsResult(null); } setLoading(false); } else { setMeasuresResult(null); setMetricsResult(null); } }; fetchData().catch(console.error); }, [djClient, selectedMetrics, selectedDimensions, filters, selectedEngine]); // Fetch existing pre-aggregations for the grain groups useEffect(() => { const fetchExistingPreaggs = async () => { if (!measuresResult?.grain_groups?.length) { setPlannedPreaggs({}); return; } // Get unique node names from grain groups try { // For each grain group, ask server to find matching pre-aggs const newPreaggs = {}; // Use requested_dimensions from the measures result - these are the fully qualified // dimension references that pre-aggs are created with const requestedDims = measuresResult.requested_dimensions || []; const grainColsForLookup = requestedDims.join(','); console.log( 'grainCols for pre-agg lookup (from requested_dimensions):', grainColsForLookup, ); await Promise.all( measuresResult.grain_groups.map(async gg => { const grainKey = `${gg.parent_name}|${normalizeGrain(gg.grain)}`; // Extract measure names const measureNames = (gg.components || []) .map(c => c.name) .filter(Boolean) .join(','); console.log( `Looking for pre-agg: node=${gg.parent_name}, grain=${grainColsForLookup}, measures=${measureNames}`, ); // First try exact match using requested_dimensions let result = await djClient.listPreaggs({ node_name: gg.parent_name, grain: grainColsForLookup, grain_mode: 'exact', measures: measureNames || undefined, }); console.log(`Exact match result for ${gg.parent_name}:`, result); let preaggs = result.items || result.pre_aggregations || []; let match = preaggs[0]; // If no exact match, try superset (finer grain) if (!match) { result = await djClient.listPreaggs({ node_name: gg.parent_name, grain: grainColsForLookup, grain_mode: 'superset', measures: measureNames || undefined, }); console.log( `Superset match result for ${gg.parent_name}:`, result, ); preaggs = result.items || result.pre_aggregations || []; match = preaggs[0]; // Mark as compatible (not exact match) if (match) { match = { ...match, _isCompatible: true }; } } if (match) { console.log(`Found pre-agg match for ${gg.parent_name}:`, match); newPreaggs[grainKey] = match; } else { console.log(`No pre-agg match found for ${gg.parent_name}`); } }), ); setPlannedPreaggs(newPreaggs); } catch (err) { console.error('Failed to fetch existing pre-aggs:', err); } }; fetchExistingPreaggs(); }, [measuresResult, djClient]); // Auto-detect cube info when metricsResult has cube_name (backend found a matching cube) // This effect manages cubeAvailability, cubeMaterialization, and workflowUrls based on // either: 1) explicit user selection (loadedCubeName), or 2) auto-detected cube (metricsResult.cube_name) useEffect(() => { const fetchCubeInfo = async () => { // Determine which cube to use: explicit selection takes precedence over auto-detection const cubeName = loadedCubeName || metricsResult?.cube_name; if (!cubeName) { // No cube - clear state setCubeAvailability(null); setCubeMaterialization(null); setWorkflowUrls([]); return; } try { // Fetch full cube info including materialization const cubeData = await djClient.cubeForPlanner(cubeName); setCubeAvailability(cubeData?.availability || null); if (cubeData) { const cubeMat = cubeData.cubeMaterialization; setCubeMaterialization(cubeMat || null); setWorkflowUrls(cubeMat?.workflowUrls || []); } else { setCubeMaterialization(null); setWorkflowUrls([]); } } catch (err) { console.error('Failed to fetch cube info:', err); setCubeAvailability(null); setCubeMaterialization(null); setWorkflowUrls([]); } }; fetchCubeInfo(); }, [metricsResult?.cube_name, loadedCubeName, djClient]); // Leave cube-preset mode: drop the cube name (so the URL expands to its // components and workflow/materialization state is recomputed) and forget the // cube-seeded dims, which are no longer authoritative once the user edits. const leaveCubeMode = useCallback(() => { setLoadedCubeName(null); cubeDimensionObjects.current = []; }, []); const handleMetricsChange = useCallback( newMetrics => { setSelectedMetrics(newMetrics); setSelectedNode(null); leaveCubeMode(); }, [leaveCubeMode], ); // Load a cube preset - sets both metrics and dimensions from the cube definition const handleLoadCubePreset = useCallback( async cubeName => { if (!cubeName) return; try { // Use lightweight GraphQL query - much faster than REST endpoint const cubeData = await djClient.cubeForPlanner(cubeName); // Validate cube data has expected fields if (cubeData && Array.isArray(cubeData.cube_node_metrics)) { // Extract metrics and dimensions from the cube const cubeMetrics = cubeData.cube_node_metrics || []; const cubeDimensions = cubeData.cube_node_dimensions || []; const cubeDimObjects = cubeData.cube_dimension_objects || []; // Set the cube name for URL and display setLoadedCubeName(cubeName); // Set the metrics first - common dimensions load in the background setSelectedMetrics(cubeMetrics); // Seed the dimension picker and selection straight from the cube so // they appear instantly and correctly, without waiting on the slow // common-dimensions intersection over every cube metric. cubeDimensionObjects.current = cubeDimObjects; setCommonDimensions(cubeDimObjects); setSelectedDimensions(cubeDimensions); // Start with a clean slate: filters from a previously loaded cube may // reference dimensions this cube doesn't have, so don't carry them over. setFilters([]); setSelectedNode(null); // Materialization and availability info is included in the GraphQL response setCubeAvailability(cubeData.availability || null); const cubeMat = cubeData.cubeMaterialization; setCubeMaterialization(cubeMat || null); setWorkflowUrls(cubeMat?.workflowUrls || []); } else { console.error('Invalid cube data received:', cubeData); } } catch (err) { console.error('Failed to load cube preset:', err); } }, [djClient], ); // Clear cube preset when selection is manually cleared const handleClearSelection = useCallback(() => { setSelectedMetrics([]); setSelectedDimensions([]); setFilters([]); setWorkflowUrls([]); setCubeMaterialization(null); leaveCubeMode(); }, [leaveCubeMode]); const handleDimensionsChange = useCallback( newDimensions => { setSelectedDimensions(newDimensions); setSelectedNode(null); leaveCubeMode(); }, [leaveCubeMode], ); const handleNodeSelect = useCallback(node => { setSelectedNode(node); }, []); const handleClosePanel = useCallback(() => { setSelectedNode(null); }, []); // Handle planning/saving a new materialization configuration // Note: This creates pre-aggs for ALL grain groups with the same settings const handlePlanMaterialization = useCallback( async (grainGroup, config) => { setMaterializationError(null); // Clear any previous error try { // Step 1: Create the pre-agg records with config const result = await djClient.planPreaggs( selectedMetrics, selectedDimensions, config.strategy, config.schedule, config.lookbackWindow, ); // Check for error in response if (result._error || result.message || result.detail) { const errorMsg = result.message || result.detail || 'Failed to plan pre-aggregations'; setMaterializationError(errorMsg); throw new Error(errorMsg); } // Get the created pre-aggs (API returns `preaggs`, list endpoint returns `items`) const preaggs = result.preaggs || result.items || result.pre_aggregations || []; // Update local state const newPreaggs = { ...plannedPreaggs }; preaggs.forEach(preagg => { const grainKey = `${preagg.node_name}|${normalizeGrain( preagg.grain_columns, )}`; newPreaggs[grainKey] = preagg; }); setPlannedPreaggs(newPreaggs); // Step 2: Create scheduled workflows and optionally run backfills if (preaggs.length > 0 && config.schedule) { const workflowPromises = []; const backfillPromises = []; for (const preagg of preaggs) { // Always create the scheduled workflow when a schedule is provided workflowPromises.push( djClient.materializePreagg(preagg.id).catch(err => { console.error( `Failed to create workflow for preagg ${preagg.id}:`, err, ); return null; }), ); } // First: Wait for all workflows to be created const workflowResults = await Promise.all(workflowPromises); // Second: Only after workflows are created, start backfills for (const preagg of preaggs) { if ( config.runBackfill && config.backfillFrom && config.backfillTo ) { backfillPromises.push( djClient .runPreaggBackfill( preagg.id, config.backfillFrom, config.backfillTo, ) .catch(err => { console.error( `Failed to run backfill for preagg ${preagg.id}:`, err, ); return null; }), ); } } // Wait for all backfills to complete const backfillResults = await Promise.all(backfillPromises); // Update state with workflow URLs const updatedPreaggs = { ...newPreaggs }; preaggs.forEach((preagg, idx) => { const grainKey = `${preagg.node_name}|${normalizeGrain( preagg.grain_columns, )}`; const workflowResult = workflowResults[idx]; if (workflowResult?.workflow_urls?.length > 0) { updatedPreaggs[grainKey] = { ...updatedPreaggs[grainKey], workflow_urls: workflowResult.workflow_urls, workflow_status: workflowResult.workflow_status || 'active', }; } }); setPlannedPreaggs(updatedPreaggs); // Show toast with backfill info const successfulBackfills = backfillResults.filter(r => r?.job_url); if (successfulBackfills.length > 0) { console.log('Backfills started:', successfulBackfills); } } // Step 3: If Druid cube enabled, schedule cube materialization console.log( 'Scheduling Druid cube materialization for:', config.enableDruidCube, ); if (config.enableDruidCube) { // Use existing cube if loaded, otherwise use the new cube name from config const cubeName = loadedCubeName || config.druidCubeName; if (!cubeName) { const errorMsg = 'Cube name is required for Druid materialization'; setMaterializationError(errorMsg); throw new Error(errorMsg); // Throw so form doesn't close } // Continue with cube creation/materialization console.log('Scheduling Druid cube materialization for:', cubeName); // If no existing cube, create one first if (!loadedCubeName && config.druidCubeName) { const cubeResult = await djClient.createCube( config.druidCubeName, // name config.druidCubeName.split('.').pop(), // display_name (short name) `Druid cube for ${selectedMetrics.join(', ')}`, // description 'published', // mode selectedMetrics, // metrics selectedDimensions, // dimensions [], // filters ); if (cubeResult.status >= 400) { const errorMsg = cubeResult.json?.message || cubeResult.json?.detail || 'Failed to create cube'; // Check if it's a "cube already exists" error - that's okay if (!errorMsg.toLowerCase().includes('already exists')) { setMaterializationError(`Failed to create cube: ${errorMsg}`); return result; // Don't proceed with materialization } console.log( 'Cube already exists, proceeding with materialization', ); } // Set the loaded cube name so the banner shows it setLoadedCubeName(config.druidCubeName); } // Schedule cube materialization (waits on pre-aggs, ingests to Druid) const cubeMaterializeResult = await djClient.materializeCubeV2( cubeName, config.schedule || '0 6 * * *', config.strategy, config.lookbackWindow, config.runBackfill ?? true, ); if (cubeMaterializeResult.status >= 400) { const errorMsg = cubeMaterializeResult.json?.message || cubeMaterializeResult.json?.detail || 'Failed to schedule cube materialization'; console.error('Cube materialization failed:', errorMsg); // Don't throw - pre-aggs were still created successfully setMaterializationError( `Pre-aggs created but cube materialization failed: ${errorMsg}`, ); } else { console.log( 'Cube materialization scheduled:', cubeMaterializeResult.json, ); console.log( 'Workflow URLs from response:', cubeMaterializeResult.json?.workflow_urls, ); // Store workflow URLs for display const urls = cubeMaterializeResult.json?.workflow_urls || []; console.log('Setting workflowUrls to:', urls); setWorkflowUrls(urls); // If backfill is enabled, also kick off cube backfill if ( config.runBackfill && config.backfillFrom && config.backfillTo ) { console.log( 'Running cube backfill from', config.backfillFrom, 'to', config.backfillTo, ); try { const cubeBackfillResult = await djClient.runCubeBackfill( cubeName, config.backfillFrom, config.backfillTo, ); if (cubeBackfillResult._error) { console.error( 'Cube backfill failed:', cubeBackfillResult.message, ); // Don't throw - cube materialization was still created } else { console.log( 'Cube backfill started:', cubeBackfillResult.job_url, ); } } catch (cubeBackfillErr) { console.error('Failed to run cube backfill:', cubeBackfillErr); // Don't throw - cube materialization was still created } } } } return result; } catch (err) { console.error('Failed to plan materialization:', err); const errorMsg = err.message || 'Failed to plan materialization'; setMaterializationError(errorMsg); throw err; } }, [ djClient, selectedMetrics, selectedDimensions, plannedPreaggs, loadedCubeName, ], ); // Handle updating config for a single existing pre-agg const handleUpdateConfig = useCallback( async (preaggId, config) => { setMaterializationError(null); try { const result = await djClient.updatePreaggConfig( preaggId, config.strategy, config.schedule, config.lookbackWindow, ); if (result._error || result.message || result.detail) { const errorMsg = result.message || result.detail || 'Failed to update config'; setMaterializationError(errorMsg); throw new Error(errorMsg); } // Update the specific pre-agg in our state setPlannedPreaggs(prev => { const updated = { ...prev }; for (const key in updated) { if (updated[key].id === preaggId) { updated[key] = { ...updated[key], ...result }; break; } } return updated; }); return result; } catch (err) { console.error('Failed to update config:', err); const errorMsg = err.message || 'Failed to update config'; setMaterializationError(errorMsg); throw err; } }, [djClient], ); // Handle creating/refreshing a scheduled workflow for a pre-agg const handleCreateWorkflow = useCallback( async preaggId => { setMaterializationError(null); try { const result = await djClient.materializePreagg(preaggId); if (result._error || result.message || result.detail) { const errorMsg = result.message || result.detail || 'Failed to create workflow'; setMaterializationError(errorMsg); return null; } // Update the pre-agg with workflow info setPlannedPreaggs(prev => { const updated = { ...prev }; for (const key in updated) { if (updated[key].id === preaggId) { updated[key] = { ...updated[key], workflow_urls: result.workflow_urls, workflow_status: result.workflow_status || 'active', }; break; } } return updated; }); return result; } catch (err) { console.error('Failed to create workflow:', err); setMaterializationError(err.message || 'Failed to create workflow'); return null; } }, [djClient], ); // Handle running a backfill for a pre-agg const handleRunBackfill = useCallback( async (preaggId, startDate, endDate) => { setMaterializationError(null); try { const result = await djClient.runPreaggBackfill( preaggId, startDate, endDate, ); if (result._error || result.message || result.detail) { const errorMsg = result.message || result.detail || 'Failed to run backfill'; setMaterializationError(errorMsg); return null; } // Return the job URL so the UI can display it return result; } catch (err) { console.error('Failed to run backfill:', err); setMaterializationError(err.message || 'Failed to run backfill'); return null; } }, [djClient], ); // Handle running an ad-hoc job for a pre-agg (uses backfill with same start/end date) const handleRunAdhoc = useCallback( async (preaggId, partitionDate) => { setMaterializationError(null); try { // Use backfill endpoint with same start and end date for single-date runs const result = await djClient.runPreaggBackfill( preaggId, partitionDate, partitionDate, ); if (result._error || result.message || result.detail) { const errorMsg = result.message || result.detail || 'Failed to run ad-hoc job'; setMaterializationError(errorMsg); return null; } // Return the job URL so the UI can display it return result; } catch (err) { console.error('Failed to run ad-hoc job:', err); setMaterializationError(err.message || 'Failed to run ad-hoc job'); return null; } }, [djClient], ); // Handle deactivating (pausing) a pre-agg workflow const handleDeactivatePreaggWorkflow = useCallback( async preaggId => { setMaterializationError(null); try { const result = await djClient.deactivatePreaggWorkflow(preaggId); if (result._error || result.message?.includes('Failed')) { const errorMsg = result.message || result.detail || 'Failed to deactivate workflow'; setMaterializationError(errorMsg); return null; } // Update the pre-agg in our state to reflect deactivation // Backend clears all config, so we clear it here too for clean slate setPlannedPreaggs(prev => { const updated = { ...prev }; for (const key in updated) { if (updated[key].id === preaggId) { updated[key] = { ...updated[key], strategy: null, schedule: null, lookback_window: null, workflow_status: null, workflow_urls: null, }; break; } } return updated; }); return result; } catch (err) { console.error('Failed to deactivate workflow:', err); setMaterializationError(err.message || 'Failed to deactivate workflow'); return null; } }, [djClient], ); // Handle deactivating cube workflow const handleDeactivateCubeWorkflow = useCallback(async () => { if (!loadedCubeName) { setMaterializationError('No cube loaded'); return null; } setMaterializationError(null); try { const result = await djClient.deactivateCubeWorkflow(loadedCubeName); if (result.status >= 400) { const errorMsg = result.json?.message || result.json?.detail || 'Failed to deactivate cube workflow'; setMaterializationError(errorMsg); return null; } // Clear cube materialization state setWorkflowUrls([]); setCubeMaterialization(null); return result.json; } catch (err) { console.error('Failed to deactivate cube workflow:', err); setMaterializationError( err.message || 'Failed to deactivate cube workflow', ); return null; } }, [djClient, loadedCubeName]); // Handle updating cube config const handleUpdateCubeConfig = useCallback( async config => { if (!loadedCubeName) { setMaterializationError('No cube loaded'); return null; } setMaterializationError(null); try { // Re-call materialize with new config (run_backfill=false to just update) const result = await djClient.refreshCubeWorkflow( loadedCubeName, config.schedule, config.strategy, config.lookbackWindow, ); if (result.status >= 400) { const errorMsg = result.json?.message || result.json?.detail || 'Failed to update cube config'; setMaterializationError(errorMsg); return null; } // Update local state const urls = result.json?.workflow_urls || []; setWorkflowUrls(urls); setCubeMaterialization(prev => ({ ...prev, schedule: config.schedule, strategy: config.strategy, lookbackWindow: config.lookbackWindow, workflowUrls: urls, })); return result.json; } catch (err) { console.error('Failed to update cube config:', err); setMaterializationError(err.message || 'Failed to update cube config'); return null; } }, [djClient, loadedCubeName], ); // Handle refreshing cube workflow const handleRefreshCubeWorkflow = useCallback(async () => { if (!loadedCubeName || !cubeMaterialization) { setMaterializationError('No cube materialization to refresh'); return null; } setMaterializationError(null); try { const result = await djClient.refreshCubeWorkflow( loadedCubeName, cubeMaterialization.schedule, cubeMaterialization.strategy, cubeMaterialization.lookbackWindow, ); if (result.status >= 400) { const errorMsg = result.json?.message || result.json?.detail || 'Failed to refresh cube workflow'; setMaterializationError(errorMsg); return null; } // Update workflow URLs const urls = result.json?.workflow_urls || []; setWorkflowUrls(urls); setCubeMaterialization(prev => ({ ...prev, workflowUrls: urls, })); return result.json; } catch (err) { console.error('Failed to refresh cube workflow:', err); setMaterializationError(err.message || 'Failed to refresh cube workflow'); return null; } }, [djClient, loadedCubeName, cubeMaterialization]); // Handle running cube backfill with date range const handleRunCubeBackfill = useCallback( async (startDate, endDate) => { if (!loadedCubeName) { setMaterializationError('No cube to backfill'); return null; } if (!startDate) { setMaterializationError('Start date is required'); return null; } setMaterializationError(null); try { const result = await djClient.runCubeBackfill( loadedCubeName, startDate, endDate, ); if (result._error) { setMaterializationError( result.message || 'Failed to run cube backfill', ); return null; } return result; } catch (err) { console.error('Failed to run cube backfill:', err); setMaterializationError(err.message || 'Failed to run cube backfill'); return null; } }, [djClient, loadedCubeName], ); // Fetch raw SQL (without pre-aggregations) const handleFetchRawSql = useCallback(async () => { try { const result = await djClient.metricsV3( selectedMetrics, selectedDimensions, '', false, // useMaterialized = false for raw SQL ); return result; // Return full result including scan_estimate } catch (err) { console.error('Failed to fetch raw SQL:', err); return null; } }, [djClient, selectedMetrics, selectedDimensions]); // Set partition on a column (for enabling incremental materialization) const handleSetPartition = useCallback( async (nodeName, columnName, partitionType, format, granularity) => { try { return await djClient.setPartition( nodeName, columnName, partitionType, format, granularity, ); } catch (err) { console.error('Failed to set partition:', err); throw err; } }, [djClient], ); // Refresh measures result (after setting partition) const handleRefreshMeasures = useCallback(async () => { if (selectedMetrics.length > 0) { try { const [measures, metrics] = await Promise.all([ djClient.measuresV3(selectedMetrics, selectedDimensions), djClient.metricsV3(selectedMetrics, selectedDimensions), ]); setMeasuresResult(measures); setMetricsResult(metrics); } catch (err) { console.error('Failed to refresh measures:', err); } } }, [djClient, selectedMetrics, selectedDimensions]); // Fetch node columns with partition info (for checking if temporal partitions exist) const handleFetchNodePartitions = useCallback( async nodeName => { return await djClient.getNodeColumnsWithPartitions(nodeName); }, [djClient], ); // Run query and fetch results const handleRunQuery = useCallback(async () => { if (selectedMetrics.length === 0) { setQueryError('Select at least one metric'); return; } setQueryLoading(true); setQueryError(null); setQueryResults(null); setQueryLinks([]); setShowResults(true); const startTime = Date.now(); setQueryStartTime(startTime); setQueryElapsedTime(null); try { // Fetch data from /data/ endpoint const results = await djClient.data( selectedMetrics, selectedDimensions, filters, selectedEngine || null, progress => setQueryLinks(progress.links || []), ); const elapsed = (Date.now() - startTime) / 1000; setQueryElapsedTime(elapsed); setQueryResults(results); } catch (err) { console.error('Query failed:', err); setQueryError(err.message || 'Failed to execute query'); } finally { setQueryLoading(false); } }, [djClient, selectedMetrics, selectedDimensions, filters, selectedEngine]); // Handle back to plan view const handleBackToPlan = useCallback(() => { setShowResults(false); setQueryResults(null); setQueryError(null); }, []); // Handle filter changes const handleFiltersChange = useCallback( newFilters => { setFilters(newFilters); // A cube-based URL (?cube=...) only encodes the cube name, so filters can't // be represented while a cube preset is loaded. Leaving cube mode expands // the URL into its fully-qualified metrics/dimensions/filters form so the // applied filter is reflected in the URL and the link stays shareable. leaveCubeMode(); }, [leaveCubeMode], ); return ( <div className="planner-page"> {error && ( <div className="header-error planner-error-banner">{error}</div> )} {/* Three-column layout */} <div className="planner-layout" ref={plannerLayoutRef}> {/* Left: Selection Panel */} <aside className="planner-selection" style={selectionWidth != null ? { width: selectionWidth } : undefined} > <SelectionPanel metrics={metrics} selectedMetrics={selectedMetrics} onMetricsChange={handleMetricsChange} dimensions={commonDimensions} selectedDimensions={selectedDimensions} onDimensionsChange={handleDimensionsChange} loading={dimensionsLoading} cubes={cubes} onLoadCubePreset={handleLoadCubePreset} loadedCubeName={loadedCubeName} onClearSelection={handleClearSelection} filters={filters} onFiltersChange={handleFiltersChange} selectedEngine={selectedEngine} onEngineChange={setSelectedEngine} onRunQuery={handleRunQuery} canRunQuery={selectedMetrics.length > 0} queryLoading={queryLoading} compatibleMetrics={compatibleMetrics} /> </aside> {/* Vertical resizer between selection panel and main content */} <div className="vertical-resizer" onMouseDown={handleSelectionResizerMouseDown} title="Drag to resize" /> {/* Main Content Area - Either Results or Graph+Details */} {showResults ? ( <ResultsView sql={metricsResult?.sql} results={queryResults} loading={queryLoading} error={queryError} elapsedTime={queryElapsedTime} onBackToPlan={handleBackToPlan} selectedMetrics={selectedMetrics} selectedDimensions={selectedDimensions} filters={filters} dialect={metricsResult?.dialect} cubeName={metricsResult?.cube_name} availability={cubeAvailability} links={queryLinks} /> ) : ( <> {/* Center: Graph */} <main className="planner-graph"> {loading ? ( <div className="graph-loading"> <div className="loading-spinner" /> <span>Building data flow...</span> </div> ) : measuresResult ? ( <> <div className="graph-header"> <span className="graph-stats"> {measuresResult.grain_groups?.length || 0}{' '} pre-aggregations →{' '} {measuresResult.metric_formulas?.length || 0} metrics </span> </div> {/* Suspense boundary for lazy-loaded ReactFlow graph */} <Suspense fallback={ <div className="graph-loading"> <div className="loading-spinner" /> <span>Loading graph...</span> </div> } > <MetricFlowGraph grainGroups={measuresResult.grain_groups} metricFormulas={measuresResult.metric_formulas} selectedNode={selectedNode} onNodeSelect={handleNodeSelect} /> </Suspense>