UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

339 lines (338 loc) 18 kB
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; /* * Copyright 2025 The Kubernetes Authors * * 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 '../../../i18n/config'; import { DiffEditor, Editor } from '@monaco-editor/react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import FormControlLabel from '@mui/material/FormControlLabel'; import FormGroup from '@mui/material/FormGroup'; import Grid from '@mui/material/Grid'; import Switch from '@mui/material/Switch'; import Typography from '@mui/material/Typography'; import * as yaml from 'js-yaml'; import _ from 'lodash'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { getCluster } from '../../../lib/cluster'; import { apply } from '../../../lib/k8s/api/v1/apply'; import { useId } from '../../../lib/util'; import { clusterAction } from '../../../redux/clusterActionSlice'; import { EventStatus, HeadlampEventType, useEventCallback, } from '../../../redux/headlampEventSlice'; import { useCurrentAppTheme } from '../../App/themeSlice'; import { useLocalStorageState } from '../../globalSearch/useLocalStorageState'; import ConfirmButton from '../ConfirmButton'; import { Dialog } from '../Dialog'; import Loader from '../Loader'; import Tabs from '../Tabs'; import DocsViewer from './DocsViewer'; import SimpleEditor from './SimpleEditor'; import { UploadDialog } from './UploadDialog'; export default function EditorDialog(props) { const { item, onClose, onSave = 'default', onEditorChanged, setOpen, saveLabel, errorMessage, allowToHideManagedFields, title, actions = [], toolbarActions, formContent, treatItemChangesAsEdits, cluster, ...other } = props; const editorOptions = { selectOnLineNumbers: true, readOnly: isReadOnly(), automaticLayout: true, }; const initialCode = typeof item === 'string' ? item : yaml.dump(item || {}); const originalCodeRef = React.useRef({ code: initialCode, format: item ? 'yaml' : '' }); const [code, setCode] = React.useState(originalCodeRef.current); const codeRef = React.useRef(code); const lastCodeCheckHandler = React.useRef(0); const previousVersionRef = React.useRef(isKubeObjectIsh(item) ? item?.metadata?.resourceVersion || '' : ''); const [error, setError] = React.useState(''); const [docSpecs, setDocSpecs] = React.useState([]); const { t } = useTranslation(); const theme = useCurrentAppTheme(); const [hideManagedFields, setHideManagedFields] = useLocalStorageState('hideManagedFields', true); const [useSimpleEditor, setUseSimpleEditor] = useLocalStorageState('useSimpleEditor', false); const [uploadFiles, setUploadFiles] = React.useState(false); const [hasOpenedDiffEditor, setHasOpenedDiffEditor] = React.useState(false); const dispatchCreateEvent = useEventCallback(HeadlampEventType.CREATE_RESOURCE); const dispatch = useDispatch(); function isKubeObjectIsh(item) { return item && typeof item === 'object' && !Array.isArray(item) && 'metadata' in item; } // Update the code when the item changes, but only if the code hasn't been touched. React.useEffect(() => { const clonedItem = _.cloneDeep(item); if (!item || Object.keys(item || {}).length === 0) { const defaultCode = '# Enter your YAML or JSON here'; originalCodeRef.current = { code: defaultCode, format: 'yaml' }; setCode({ code: defaultCode, format: 'yaml' }); return; } if (allowToHideManagedFields && hideManagedFields) { if (isKubeObjectIsh(clonedItem) && clonedItem.metadata) { delete clonedItem.metadata.managedFields; } } // Determine the format (YAML or JSON) and serialize to string const format = looksLikeJson(originalCodeRef.current.code) ? 'json' : 'yaml'; const itemCode = format === 'json' ? JSON.stringify(clonedItem) : yaml.dump(clonedItem); // Update the code if the item representation has changed if (itemCode !== originalCodeRef.current.code) { if (!treatItemChangesAsEdits) { originalCodeRef.current = { code: itemCode, format }; } setCode({ code: itemCode, format }); } // Additional handling for Kubernetes objects if (isKubeObjectIsh(item) && item.metadata) { const resourceVersionsDiffer = (previousVersionRef.current || '') !== (item.metadata.resourceVersion || ''); // Only change if the code hasn't been touched. // We use the codeRef in this effect instead of the code, because we need to access the current // state of the code but we don't want to trigger a re-render when we set the code here. if (resourceVersionsDiffer || codeRef.current.code === originalCodeRef.current.code) { // Prevent updating to the same code, which would lead to an infinite loop. if (codeRef.current.code !== itemCode) { setCode({ code: itemCode, format: originalCodeRef.current.format }); } if (resourceVersionsDiffer && !!item.metadata.resourceVersion) { previousVersionRef.current = item.metadata.resourceVersion; } } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [item, hideManagedFields]); React.useEffect(() => { codeRef.current = code; }, [code]); function isReadOnly() { return onSave === null; } function looksLikeJson(code) { const trimmedCode = code.trimLeft(); const firstChar = !!trimmedCode ? trimmedCode[0] : ''; if (['{', '['].includes(firstChar)) { return true; } return false; } function onChange(value) { // Clear any ongoing attempts to check the code. window.clearTimeout(lastCodeCheckHandler.current); // Only check the code for errors after the user has stopped typing for a moment. lastCodeCheckHandler.current = window.setTimeout(() => { const { error: err, format } = getObjectsFromCode({ code: value || '', format: originalCodeRef.current.format, }); if (code.format !== format) { setCode(currentCode => ({ code: currentCode.code || '', format })); } if (error !== (err?.message || '')) { setError(err?.message || ''); } }, 500); // ms setCode(currentCode => ({ code: value, format: currentCode.format })); if (onEditorChanged) { onEditorChanged(value); } } function getObjectsFromCode(codeInfo) { const { code, format } = codeInfo; const res = { obj: null, format, error: null, }; if (!format || (!res.obj && looksLikeJson(code))) { res.format = 'json'; try { let helperArr = []; const parsedCode = JSON.parse(code); if (!Array.isArray(parsedCode)) { helperArr.push(parsedCode); } else { helperArr = parsedCode; } res.obj = helperArr; return res; } catch (e) { res.error = new Error(e.message || t('Invalid JSON')); } } if (!res.obj) { res.format = 'yaml'; try { res.obj = yaml.loadAll(code); res.obj = res.obj.filter(obj => !!obj); return res; } catch (e) { res.error = new Error(e.message || t('Invalid YAML')); } } if (!!res.obj) { res.error = null; } return res; } function handleTabChange(tabIndex) { const docsTabIndex = formContent ? 2 : 1; const diffTabIndex = formContent ? 3 : 2; if (tabIndex === diffTabIndex) { setHasOpenedDiffEditor(true); } if (tabIndex === docsTabIndex) { const { obj: codeObjs } = getObjectsFromCode(code); setDocSpecs(codeObjs); } } function onUndo() { setCode(originalCodeRef.current); } const applyFunc = async (newItems, clusterName) => { await Promise.allSettled(newItems.map(newItem => apply(newItem, clusterName))).then((values) => { values.forEach((value, index) => { if (value.status === 'rejected') { let msg; const kind = newItems[index].kind; const name = newItems[index].metadata.name; const apiVersion = newItems[index].apiVersion; if (newItems.length === 1) { msg = t('translation|Failed to create {{ kind }} {{ name }}.', { kind, name }); } else { msg = t('translation|Failed to create {{ kind }} {{ name }} in {{ apiVersion }}.', { kind, name, apiVersion, }); } const errorDetail = value.reason?.message || msg; setError(errorDetail); setOpen?.(true); // throw msg; throw new Error(msg); } }); }); onClose(); }; function handleSave() { // Verify the YAML even means anything before trying to use it. const { obj, format, error } = getObjectsFromCode(code); if (!!error) { setError(t('Error parsing the code: {{error}}', { error: error.message })); return; } if (format !== code.format) { setCode(currentCode => ({ code: currentCode.code, format })); } if (!getObjectsFromCode(code)) { setError(t("Error parsing the code. Please verify it's valid YAML or JSON!")); return; } const newItemDefs = obj; if (typeof onSave === 'string' && onSave === 'default') { const resourceNames = newItemDefs.map(newItemDef => newItemDef.metadata.name); const clusterName = cluster || item?.cluster || getCluster() || ''; dispatch(clusterAction(() => applyFunc(newItemDefs, clusterName), { startMessage: t('translation|Applying {{ newItemName }}…', { newItemName: resourceNames.join(','), }), cancelledMessage: t('translation|Cancelled applying {{ newItemName }}.', { newItemName: resourceNames.join(','), }), successMessage: t('translation|Applied {{ newItemName }}.', { newItemName: resourceNames.join(','), }), errorMessage: t('translation|Failed to apply {{ newItemName }}.', { newItemName: resourceNames.join(','), }), })); dispatchCreateEvent({ status: EventStatus.CONFIRMED, }); } else if (typeof onSave === 'function') { onSave(obj); } } function makeEditor() { const language = originalCodeRef.current.format || 'yaml'; return (_jsx(Box, { height: "100%", id: editorId, children: useSimpleEditor ? (_jsx(SimpleEditor, { language: language, value: code.code, onChange: onChange })) : (_jsx(Editor, { language: language, theme: theme.base === 'dark' ? 'vs-dark' : 'light', value: code.code, options: editorOptions, onChange: onChange, height: "100%" })) })); } function makeDiffEditor() { const language = code.format || originalCodeRef.current.format || 'yaml'; return (_jsx(Box, { height: "100%", children: _jsx(DiffEditor, { original: originalCodeRef.current.code, modified: code.code, language: language, theme: theme.base === 'dark' ? 'vs-dark' : 'light', height: "100%", options: { automaticLayout: true, readOnly: true, renderSideBySide: true, } }) })); } const errorLabel = error || errorMessage; let dialogTitle = title; if (!dialogTitle && item) { const itemName = (isKubeObjectIsh(item) && item.metadata?.name) || t('New Object'); dialogTitle = isReadOnly() ? t('translation|View: {{ itemName }}', { itemName }) : t('translation|Edit: {{ itemName }}', { itemName }); } const dialogTitleId = useId('editor-dialog-title-'); const editorId = useId('editor-textarea-'); const content = !item ? (_jsx(Loader, { title: t('Loading editor') })) : (_jsxs(React.Fragment, { children: [uploadFiles ? _jsx(UploadDialog, { setUploadFiles: setUploadFiles, setCode: setCode }) : '', _jsxs(DialogContent, { sx: { height: '80%', overflowY: 'hidden', display: 'flex', flexDirection: 'column', }, children: [_jsx(Box, { py: 1, children: _jsxs(Grid, { container: true, spacing: 2, justifyContent: "space-between", children: [actions.length > 0 ? (actions.map((action, i) => (_jsx(Grid, { item: true, children: action }, `editor_action_${i}`)))) : (_jsx(Grid, { item: true })) // Just to keep the layout consistent. , _jsx(Grid, { item: true, children: _jsxs(FormGroup, { row: true, children: [allowToHideManagedFields && (_jsx(FormControlLabel, { control: _jsx(Switch, { checked: hideManagedFields, onChange: () => setHideManagedFields(() => !hideManagedFields), name: "hideManagedFields" }), label: t('Hide Managed Fields') })), _jsx(FormControlLabel, { control: _jsx(Switch, { checked: useSimpleEditor, onChange: () => setUseSimpleEditor(() => !useSimpleEditor), name: "useSimpleEditor" }), label: t('Use minimal editor') }), _jsx(Button, { variant: "contained", onClick: () => { setUploadFiles(true); }, children: t('translation|Upload File/URL') }), toolbarActions && toolbarActions.map((action, i) => (_jsx(React.Fragment, { children: action }, `toolbar_action_${i}`)))] }) })] }) }), isReadOnly() ? (makeEditor()) : (_jsx(Tabs, { onTabChanged: handleTabChange, ariaLabel: t('translation|Editor'), tabs: [ { label: t('translation|Editor'), component: makeEditor(), }, ...(formContent ? [ { label: t('translation|Form'), component: (_jsx(Box, { sx: { height: '100%', overflowY: 'auto' }, children: formContent })), }, ] : []), { label: t('translation|Documentation'), component: (_jsx(Box, { sx: { height: '100%', overflowY: 'auto' }, children: _jsx(DocsViewer, { docSpecs: docSpecs }) })), }, { label: t('translation|Review Changes'), component: hasOpenedDiffEditor ? makeDiffEditor() : null, }, ] }))] }), _jsxs(DialogActions, { children: [!isReadOnly() && (_jsx(ConfirmButton, { disabled: originalCodeRef.current.code === code.code, color: "secondary", variant: "contained", "aria-label": t('translation|Undo'), onConfirm: onUndo, confirmTitle: t('translation|Are you sure?'), confirmDescription: t('This will discard your changes in the editor. Do you want to proceed?'), "aria-controls": editorId, children: t('translation|Undo Changes') })), _jsx("div", { style: { flex: '1 0 0' } }), errorLabel && _jsx(Typography, { color: "error", children: errorLabel }), _jsx("div", { style: { flex: '1 0 0' } }), _jsx(Button, { onClick: onClose, color: "secondary", variant: "contained", children: t('translation|Close') }), !isReadOnly() && (_jsx(Button, { onClick: handleSave, color: "primary", variant: "contained", disabled: originalCodeRef.current.code === code.code || !!error, "aria-controls": editorId, children: saveLabel || t('translation|Save & Apply') }))] })] })); if (!other.open && !other.keepMounted) { return null; } if (other.noDialog) { return content; } return (_jsx(Dialog, { title: dialogTitle, "aria-busy": !item, maxWidth: "lg", scroll: "paper", fullWidth: true, withFullScreen: true, onClose: onClose, ...other, "aria-labelledby": dialogTitleId, titleProps: { id: dialogTitleId, }, children: content })); } export function ViewDialog(props) { return _jsx(EditorDialog, { ...props, onSave: null }); }