UNPKG

datajunction-ui

Version:
604 lines (568 loc) 21.2 kB
import { useCallback, useEffect, useState, useMemo } from 'react'; import TableIcon from '../../icons/TableIcon'; import AddMaterializationPopover from './AddMaterializationPopover'; import * as React from 'react'; import AddBackfillPopover from './AddBackfillPopover'; import { labelize } from '../../../utils/form'; import NodeMaterializationDelete from '../../components/NodeMaterializationDelete'; import Tab from '../../components/Tab'; import NodeRevisionMaterializationTab from './NodeRevisionMaterializationTab'; import AvailabilityStateBlock from './AvailabilityStateBlock'; import cronstrue from 'cronstrue'; /** * Cube materialization tab - shows cube-specific materializations. * For non-cube nodes, the parent component (index.jsx) renders * NodePreAggregationsTab instead. */ export default function NodeMaterializationTab({ node, djClient, readOnly = false, }) { const [rawMaterializations, setRawMaterializations] = useState([]); const [selectedRevisionTab, setSelectedRevisionTab] = useState(null); const [showInactive, setShowInactive] = useState(false); const [availabilityStates, setAvailabilityStates] = useState([]); const [availabilityStatesByRevision, setAvailabilityStatesByRevision] = useState({}); const [isRebuilding, setIsRebuilding] = useState(() => { // Check if we're in the middle of a rebuild operation return localStorage.getItem(`rebuilding-${node?.name}`) === 'true'; }); const filteredMaterializations = useMemo(() => { return showInactive ? rawMaterializations : rawMaterializations.filter(mat => !mat.deactivated_at); }, [rawMaterializations, showInactive]); const materializationsByRevision = useMemo(() => { return filteredMaterializations.reduce((acc, mat) => { // Extract version from materialization config const matVersion = mat.config?.cube?.version || node?.version; if (!acc[matVersion]) { acc[matVersion] = []; } acc[matVersion].push(mat); return acc; }, {}); }, [filteredMaterializations, node?.version]); const fetchData = useCallback(async () => { if (node) { const data = await djClient.materializations(node.name); // Store raw data setRawMaterializations(data); // Fetch availability states const availabilityData = await djClient.availabilityStates(node.name); setAvailabilityStates(availabilityData); // Group availability states by version const availabilityGrouped = availabilityData.reduce((acc, avail) => { const version = avail.node_version || node.version; if (!acc[version]) { acc[version] = []; } acc[version].push(avail); return acc; }, {}); setAvailabilityStatesByRevision(availabilityGrouped); // Clear rebuilding state once data is loaded after a page reload if (localStorage.getItem(`rebuilding-${node.name}`) === 'true') { localStorage.removeItem(`rebuilding-${node.name}`); setIsRebuilding(false); } } }, [djClient, node]); useEffect(() => { fetchData().catch(console.error); }, [fetchData]); // Set default selected tab, or reset if current tab is no longer visible useEffect(() => { const versions = Object.keys(materializationsByRevision); if (versions.length === 0) return; if ( !selectedRevisionTab || !materializationsByRevision[selectedRevisionTab] ) { // First try to find current node version if (materializationsByRevision[node?.version]) { setSelectedRevisionTab(node.version); } else { // Otherwise, select the most recent version (sort by version string) const sortedVersions = versions.sort((a, b) => b.localeCompare(a)); setSelectedRevisionTab(sortedVersions[0]); } } }, [materializationsByRevision, selectedRevisionTab, node?.version]); const partitionColumnsMap = node ? Object.fromEntries( node?.columns .filter(col => col.partition !== null) .map(col => [col.name, col.display_name]), ) : {}; const cron = materialization => { var parsedCron = ''; try { parsedCron = cronstrue.toString(materialization.schedule); } catch (e) {} return parsedCron; }; const onClickRevisionTab = revisionId => () => { setSelectedRevisionTab(revisionId); }; const buildRevisionTabs = () => { const versions = Object.keys(materializationsByRevision); // Check if there are any materializations at all (including inactive ones) const hasAnyMaterializations = rawMaterializations.length > 0; // Determine which versions have only inactive materializations const versionHasOnlyInactive = {}; rawMaterializations.forEach(mat => { const matVersion = mat.config?.cube?.version || node.version; if (!versionHasOnlyInactive[matVersion]) { versionHasOnlyInactive[matVersion] = { hasActive: false, hasInactive: false, }; } if (mat.deactivated_at) { versionHasOnlyInactive[matVersion].hasInactive = true; } else { versionHasOnlyInactive[matVersion].hasActive = true; } }); // If no active versions but there are inactive materializations, show checkbox and button if (versions.length === 0) { return ( <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', marginBottom: '20px', paddingTop: '16px', }} > <div style={{ display: 'flex', alignItems: 'center', gap: '15px' }}> {hasAnyMaterializations && ( <label style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '14px', color: '#333', padding: '4px 8px', borderRadius: '12px', backgroundColor: '#f5f5f5', border: '1px solid #ddd', }} title="Shows inactive materializations for the latest cube." > <input type="checkbox" checked={showInactive} onChange={e => setShowInactive(e.target.checked)} /> Show Inactive </label> )} {node && !readOnly && <AddMaterializationPopover node={node} />} </div> </div> ); } // Sort versions: current version first, then by version string (most recent first) const sortedVersions = versions.sort((a, b) => { // Current node version always comes first if (a === node?.version) return -1; if (b === node?.version) return 1; // Then sort by version string (descending) return b.localeCompare(a); }); // Check if latest version has any materializations (including inactive ones) const hasLatestVersionMaterialization = rawMaterializations.some(mat => { const matVersion = mat.config?.cube?.version || node?.version; return matVersion === node?.version; }); // Refresh latest materialization function const refreshLatestMaterialization = async () => { if ( !window.confirm( 'This will rebuild the materialization workflows for the current cube version without creating a new version. Would you like to continue?', ) ) { return; } // Set loading state in both React state and localStorage setIsRebuilding(true); localStorage.setItem(`rebuilding-${node.name}`, 'true'); try { const { status, json } = await djClient.refreshLatestMaterialization( node.name, ); if (status === 200 || status === 201) { // Keep the loading state during page reload window.location.reload(); // Reload to show the updated materialization } else { alert(`Failed to rebuild materialization: ${json.message}`); // Clear loading state on error localStorage.removeItem(`rebuilding-${node.name}`); setIsRebuilding(false); } } catch (error) { alert(`Error rebuilding materialization: ${error.message}`); // Clear loading state on error localStorage.removeItem(`rebuilding-${node.name}`); setIsRebuilding(false); } }; return ( <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '20px', }} > <div className="align-items-center row"> {sortedVersions.map(version => ( <NodeRevisionMaterializationTab key={version} version={version} node={node} selectedRevisionTab={selectedRevisionTab} onClickRevisionTab={onClickRevisionTab} showInactive={showInactive} versionHasOnlyInactive={versionHasOnlyInactive} /> ))} </div> <div style={{ display: 'flex', alignItems: 'center', gap: '15px' }}> <label style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '14px', color: '#333', padding: '4px 8px', borderRadius: '12px', backgroundColor: '#f5f5f5', border: '1px solid #ddd', }} title="Shows inactive materializations for the latest cube." > <input type="checkbox" checked={showInactive} onChange={e => setShowInactive(e.target.checked)} /> Show Inactive </label> {node && !readOnly && (hasLatestVersionMaterialization ? ( <button className="edit_button" aria-label="RefreshLatestMaterialization" tabIndex="0" onClick={refreshLatestMaterialization} disabled={isRebuilding} title="Rebuild the materialization workflows for the current cube version (no version bump)." style={{ opacity: isRebuilding ? 0.7 : 1, cursor: isRebuilding ? 'not-allowed' : 'pointer', }} > <span className="add_node"> Rebuild (latest) Materialization </span> </button> ) : ( <AddMaterializationPopover node={node} /> ))} </div> </div> ); }; const materializationRows = materializations => { return materializations.map((materialization, index) => ( <div key={`${materialization.name}-${index}`}> <div className="tr"> <div key={materialization.name} style={{ fontSize: 'large' }}> <div className="text-start node_name td" style={{ fontWeight: '600' }} > {materialization.job ?.replace('MaterializationJob', '') .match(/[A-Z][a-z]+/g) .join(' ')} </div> <div className="td"> {!readOnly && ( <NodeMaterializationDelete nodeName={node.name} materializationName={materialization.name} nodeVersion={selectedRevisionTab} /> )} </div> <div className="td"> <span className={`badge cron`}>{materialization.schedule}</span> <div className={`cron-description`}>{cron(materialization)} </div> </div> <div className="td"> <span className={`badge strategy`}> {labelize(materialization.strategy)} </span> </div> </div> </div> <div style={{ display: 'table-row' }}> <div style={{ display: 'inline-flex' }}> <ul className="backfills"> <li className="backfill"> <div className="backfills_header">Output Tables</div>{' '} {materialization.output_tables.map(table => ( <div className={`table__full`} key={table}> <div className="table__header"> <TableIcon />{' '} <span className={`entity-info`}> {table.split('.')[0] + '.' + table.split('.')[1]} </span> </div> <div className={`table__body upstream_tables`}> {table.split('.')[2]} </div> </div> ))} </li> </ul> </div> <div style={{ display: 'inline-flex' }}> <ul className="backfills"> <li> <div className="backfills_header">Workflows</div>{' '} <ul> {materialization.urls.map((url, idx) => ( <li style={{ listStyle: 'none' }} key={idx}> <div className="partitionLink" style={{ fontSize: 'revert' }} > <a href={url} key={`url-${idx}`} className="" target="blank" > {idx === 0 ? 'main' : 'backfill'} </a> </div> </li> ))} </ul> </li> </ul> </div> <div style={{ display: 'inline-flex' }}> <ul className="backfills"> <li className="backfill"> <details open> <summary> <span className="backfills_header">Backfills</span>{' '} </summary> {materialization.strategy === 'incremental_time' ? ( <ul> {!readOnly && ( <li> <AddBackfillPopover node={node} materialization={materialization} onSubmit={fetchData} /> </li> )} {materialization.backfills.map(backfill => ( <li className="backfill"> <div className="partitionLink"> <a href={backfill.urls[0]}> {backfill.spec.map(partition => { const partitionBody = 'range' in partition && partition['range'] !== null ? ( <> <span className="badge partition_value"> {partition.range[0]} </span> to <span className="badge partition_value"> {partition.range[1]} </span> </> ) : ( <span className="badge partition_value"> {partition.values.join(', ')} </span> ); return ( <> <div> { partitionColumnsMap[ partition.column_name.replaceAll( '_DOT_', '.', ) ] }{' '} {partitionBody} </div> </> ); })} </a> </div> </li> ))} </ul> ) : ( <ul> <li>N/A</li> </ul> )} </details> </li> </ul> </div> <div className="td"> <ul className="backfills"> <li className="backfill"> <div className="backfills_header">Partitions</div>{' '} <ul> {node.columns .filter(col => col.partition !== null) .map(column => { return ( <li key={column.name}> <div className="partitionLink"> {column.display_name} <span className="badge partition_value"> {column.partition.type_} </span> </div> </li> ); })} </ul> </li> </ul> </div> </div> </div> )); }; const currentRevisionMaterializations = selectedRevisionTab ? materializationsByRevision[selectedRevisionTab] || [] : filteredMaterializations; const currentRevisionAvailability = selectedRevisionTab ? availabilityStatesByRevision[selectedRevisionTab] || [] : availabilityStates; const renderMaterializedDatasets = availabilityStates => { if (!availabilityStates || availabilityStates.length === 0) { return ( <div className="message alert" style={{ marginTop: '10px' }}> No materialized datasets available for this revision. </div> ); } return availabilityStates.map((availability, index) => ( <AvailabilityStateBlock key={`availability-${index}`} availability={availability} /> )); }; return ( <> <div className="table-vertical" role="table" aria-label="Materializations" style={{ position: 'relative' }} > {/* Loading overlay */} {isRebuilding && ( <div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(255, 255, 255, 0.8)', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', zIndex: 1000, minHeight: '200px', }} > <div style={{ width: '40px', height: '40px', border: '4px solid #f3f3f3', borderTop: '4px solid #3498db', borderRadius: '50%', animation: 'spin 1s linear infinite', marginBottom: '16px', }} /> <div style={{ fontSize: '16px', color: '#666', textAlign: 'center' }} > Rebuilding materialization... <br /> <small style={{ fontSize: '14px' }}> This may take a few moments </small> </div> </div> )} <div> {buildRevisionTabs()} {currentRevisionMaterializations.length > 0 ? ( <div className="card-inner-table table" aria-label="Materializations" aria-hidden="false" > <div style={{ display: 'table' }}> {materializationRows( currentRevisionMaterializations.filter( materialization => !( materialization.name === 'default' && node.type === 'cube' ), ), )} </div> </div> ) : ( <div className="message alert" style={{ marginTop: '10px' }}> No materialization workflows configured for this revision. </div> )} {Object.keys(materializationsByRevision).length > 0 && ( <div style={{ marginTop: '30px' }}> {renderMaterializedDatasets(currentRevisionAvailability)} </div> )} </div> </div> </> ); }