/** * Copyright (c) 2020-present, Goldman Sachs * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { observer } from 'mobx-react-lite'; import { useEditorStore } from '../EditorStoreProvider.js'; import { forwardRef, useEffect, useState, type ReactNode } from 'react'; import { PanelFormSection, PanelFormValidatedTextField, PanelHeader, PanelContent, Panel, clsx, Dialog, CheckCircleIcon, ModalBody, ModalFooterButton, ModalFooter, ModalHeader, Modal, ModalTitle, PanelDivider, CircleNotchIcon, PauseCircleIcon, TimesCircleIcon, BanIcon, QuestionCircleIcon, MenuContent, MenuContentItem, ContextMenu, PanelForm, PanelFormBooleanField, TrashIcon, PlusIcon, CogIcon, InfoCircleIcon, CompareIcon, ChevronDownIcon, ChevronRightIcon, } from '@finos/legend-art'; import { CODE_EDITOR_LANGUAGE } from '@finos/legend-code-editor'; import { CodeDiffView, CodeEditor } from '@finos/legend-lego/code-editor'; import { type BuildLog, type BuildPhaseActionState, BuildPhaseStatus, type DeployProjectResponse, LogType, MetadataRequestOptions, } from '@finos/legend-graph'; import { flowResult } from 'mobx'; const BuildOverrideEditor = observer( (props: { buildOverrides: Record; onUpdate: (overrides: Record) => void; }) => { const { buildOverrides, onUpdate } = props; const [newKey, setNewKey] = useState(''); const [newValue, setNewValue] = useState(''); const handleAddOverride = () => { if (newKey.trim() && newValue.trim()) { onUpdate({ ...buildOverrides, [newKey.trim()]: newValue.trim(), }); setNewKey(''); setNewValue(''); } }; const handleDeleteOverride = (key: string) => { const updatedOverrides = { ...buildOverrides }; delete updatedOverrides[key]; onUpdate(updatedOverrides); }; const handleUpdateOverride = (key: string, value: string) => { onUpdate({ ...buildOverrides, [key]: value, }); }; return (
Build Overrides Key-value pairs to override build parameters
{/* Existing overrides */}
{Object.entries(buildOverrides).map(([key, value]) => (
{ if (val && val !== key && val.trim()) { const newOverrides = { ...buildOverrides }; delete newOverrides[key]; newOverrides[val.trim()] = value; onUpdate(newOverrides); } }} /> handleUpdateOverride(key, val ?? '')} />
))}
{/* Add new override */}
Add New Override
setNewKey(val ?? '')} /> setNewValue(val ?? '')} />
); }, ); const DevMetadataOptionsModal = observer( (props: { isOpen: boolean; onClose: () => void; options: MetadataRequestOptions; onSave: (options: MetadataRequestOptions) => void; }) => { const { isOpen, onClose, options, onSave } = props; const editorStore = useEditorStore(); const applicationStore = editorStore.applicationStore; const [includeArtifacts, setIncludeArtifacts] = useState( options.includeArtifacts ?? false, ); const [buildOverrides, setBuildOverrides] = useState( options.buildOverrides ?? {}, ); useEffect(() => { if (isOpen) { setIncludeArtifacts(options.includeArtifacts ?? false); setBuildOverrides(options.buildOverrides ?? {}); } }, [isOpen, options]); const handleSave = () => { const updatedOptions = new MetadataRequestOptions(); updatedOptions.includeArtifacts = includeArtifacts; updatedOptions.buildOverrides = Object.keys(buildOverrides).length > 0 ? buildOverrides : undefined; onSave(updatedOptions); onClose(); }; const handleCancel = () => { setIncludeArtifacts(options.includeArtifacts ?? false); setBuildOverrides(options.buildOverrides ?? {}); onClose(); }; if (!isOpen) { return null; } return (
Artifact Settings
setIncludeArtifacts(Boolean(val)) } isReadOnly={false} />
When enabled, includes generated artifacts in the deployment
); }, ); const getPhaseStatusIcon = (status: BuildPhaseStatus): ReactNode => { switch (status) { case BuildPhaseStatus.NOT_STARTED: return (
); case BuildPhaseStatus.IN_PROGRESS: return (
); case BuildPhaseStatus.SUCCESS: return (
); case BuildPhaseStatus.FAIL: return (
); case BuildPhaseStatus.SKIPPED: return (
); default: return (
); } }; const DevMetadataCompareModal = observer(() => { const editorStore = useEditorStore(); const applicationStore = editorStore.applicationStore; const devMetadataState = editorStore.devMetadataState; const isOpen = devMetadataState.isCompareModalOpen; const isLoading = devMetadataState.compareState.isInProgress; if (!isOpen) { return null; } return ( devMetadataState.closeCompareModal()} classes={{ root: 'editor-modal__root-container', container: 'editor-modal__container', paper: 'editor-modal__content', }} > {isLoading && (
Loading diff…
)} {!isLoading && devMetadataState.snapshotNotAvailable && (
Dev snapshot not available
No deployed metadata was found for this project at version{' '} 1.0.0-SNAPSHOT. Push to dev first to create a snapshot to compare against.
)} {!isLoading && !devMetadataState.snapshotNotAvailable && (
Deployed Dev Snapshot 1.0.0-SNAPSHOT
Local Current Workspace
)}
devMetadataState.closeCompareModal()} type="secondary" />
); }); const PhaseLogsViewer = observer( (props: { phase: BuildPhaseActionState; onClose: () => void }) => { const { phase, onClose } = props; const editorStore = useEditorStore(); const applicationStore = editorStore.applicationStore; const formatLogs = (logs: BuildLog[]): string => { return logs .map((log) => { const prefix = log.logType ? `[${log.logType}]` : ''; const title = log.title ? `${log.title}: ` : ''; return `${prefix} ${title}${log.log}`; }) .join('\n'); }; const logs = phase.logs ? formatLogs(phase.logs) : 'No logs available'; const handleCopyLogs = (): void => { applicationStore.clipboardService .copyTextToClipboard(logs) .then(() => applicationStore.notificationService.notifySuccess( 'Logs copied to clipboard', ), ) .catch(applicationStore.alertUnhandledError); }; return ( ); }, ); const PhaseContextMenu = observer( forwardRef< HTMLDivElement, { phase: BuildPhaseActionState; onViewLogs: () => void; } >(function PhaseContextMenu(props, ref) { const { phase, onViewLogs } = props; const hasLogs = phase.logs && phase.logs.length > 0; return ( {hasLogs && ( View Logs ({phase.logs?.length} entries) )} ); }), ); const DeploymentPhaseNode = observer( (props: { phase: BuildPhaseActionState; onViewLogs: (phase: BuildPhaseActionState) => void; }) => { const { phase, onViewLogs } = props; const statusIcon = getPhaseStatusIcon(phase.status); const hasLogs = phase.logs && phase.logs.length > 0; const errorLogs = phase.logs?.filter((log) => log.logType === LogType.ERROR).length ?? 0; const warnLogs = phase.logs?.filter((log) => log.logType === LogType.WARN).length ?? 0; return ( onViewLogs(phase)} /> } menuProps={{ elevation: 7 }} >
onViewLogs(phase) : undefined} title={hasLogs ? 'Click to view logs' : undefined} >
{statusIcon}
{phase.phase}
{phase.status} {phase.message && ( {phase.message} )} {hasLogs && ( {errorLogs > 0 && ( {errorLogs} errors )} {warnLogs > 0 && ( {warnLogs} warnings )} {phase.logs?.length} logs )}
); }, ); const DeploymentStatusPanel = observer( (props: { deploymentResponse: DeployProjectResponse }) => { const { deploymentResponse } = props; const [selectedPhaseForLogs, setSelectedPhaseForLogs] = useState(null); const handleViewLogs = (phase: BuildPhaseActionState) => { setSelectedPhaseForLogs(phase); }; const closeLogsViewer = () => { setSelectedPhaseForLogs(null); }; return (
Deployment Status
{getPhaseStatusIcon(deploymentResponse.finalStatus)} {deploymentResponse.finalStatus}
{/* Project Details */}
Project Details
Group ID: {deploymentResponse.projectDetails.groupId}
Artifact ID: {deploymentResponse.projectDetails.artifactId}
Version: {deploymentResponse.projectDetails.version}
{/* Phases */} {deploymentResponse.phaseStates && deploymentResponse.phaseStates.length > 0 && (
Deployment Phases ({deploymentResponse.phaseStates.length})
{deploymentResponse.phaseStates.map((phase) => ( ))}
)} {/* Logs Viewer Modal */} {selectedPhaseForLogs && ( )}
); }, ); export const DevMetadataPanel = observer(() => { const editorStore = useEditorStore(); const devMetadataState = editorStore.devMetadataState; const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false); const [isInfoExpanded, setIsInfoExpanded] = useState(false); const handlePush = (): void => { flowResult(devMetadataState.push()).catch( editorStore.applicationStore.alertUnhandledError, ); }; const handleSaveOptions = (newOptions: MetadataRequestOptions): void => { devMetadataState.setOptions(newOptions); }; const handleCompare = (): void => { devMetadataState.openCompareModal(); flowResult(devMetadataState.compareWithSnapshot()).catch( editorStore.applicationStore.alertUnhandledError, ); }; const isPushing = devMetadataState.pushState.isInProgress; const isComparing = devMetadataState.compareState.isInProgress; return (
Push to Dev
Project Information
Group ID: {devMetadataState.projectGAV?.groupId ?? 'Not available'}
Artifact ID: {devMetadataState.projectGAV?.artifactId ?? 'Not available'}
{isInfoExpanded && (
This pushes your current workspace straight to your dev branch ( 1.0.0-SNAPSHOT ), bypassing the GitLab build pipeline so you can iterate and test changes faster.
Any Lakehouse elements in your workspace — including ingests, materialized views, and data products — will be deployed by default.
)}
Deploy Metadata
{isPushing ? 'Pushing metadata to dev environment...' : 'Push current workspace metadata to dev'}
{isPushing && (
Deploying metadata to development environment...
)}
{devMetadataState.result && ( <> )} setIsOptionsModalOpen(false)} options={devMetadataState.options} onSave={handleSaveOptions} />
); });