From 99d7f78d99e5e037cc3279fa605b8007ef028293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Mon, 6 Oct 2025 14:49:30 +0200 Subject: [PATCH 01/37] init --- public/locales/en.json | 7 +- src/components/Yaml/YamlPanel.tsx | 2 +- src/components/Yaml/YamlSidePanel.tsx | 24 ++-- src/components/Yaml/YamlViewer.tsx | 5 +- src/components/YamlEditor/YamlDiffEditor.tsx | 1 + src/components/YamlEditor/YamlEditor.tsx | 117 ++++++++++++++++--- src/utils/convertToResourceConfig.spec.ts | 81 +++++++++++++ src/utils/convertToResourceConfig.ts | 56 +++++++++ 8 files changed, 266 insertions(+), 27 deletions(-) create mode 100644 src/utils/convertToResourceConfig.spec.ts create mode 100644 src/utils/convertToResourceConfig.ts diff --git a/public/locales/en.json b/public/locales/en.json index 4d2bf0d3..84ec140c 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -376,11 +376,14 @@ "close": "Close", "back": "Back", "cancel": "Cancel", - "update": "Update" + "update": "Update", + "applyChanges": "Apply changes" }, "yaml": { "YAML": "File", - "showOnlyImportant": "Show only important fields" + "showOnlyImportant": "Show only important fields", + "panelTitle": "YAML", + "editorTitle": "YAML Editor" }, "createMCP": { "dialogTitle": "Create Managed Control Plane", diff --git a/src/components/Yaml/YamlPanel.tsx b/src/components/Yaml/YamlPanel.tsx index 92b04a1b..b72855d8 100644 --- a/src/components/Yaml/YamlPanel.tsx +++ b/src/components/Yaml/YamlPanel.tsx @@ -37,7 +37,7 @@ const YamlPanel: FC = ({ yamlString, filename }) => { )} - + ); }; diff --git a/src/components/Yaml/YamlSidePanel.tsx b/src/components/Yaml/YamlSidePanel.tsx index 6f686bfc..36963f4f 100644 --- a/src/components/Yaml/YamlSidePanel.tsx +++ b/src/components/Yaml/YamlSidePanel.tsx @@ -14,6 +14,7 @@ import { YamlViewer } from './YamlViewer.tsx'; import { useSplitter } from '../Splitter/SplitterContext.tsx'; import { useMemo, useState } from 'react'; import { stringify } from 'yaml'; +import { convertToResourceConfig } from '../../utils/convertToResourceConfig.ts'; import { removeManagedFieldsAndFilterData, Resource } from '../../utils/removeManagedFieldsAndFilterData.ts'; import { useCopyToClipboard } from '../../hooks/useCopyToClipboard.ts'; import styles from './YamlSidePanel.module.css'; @@ -26,13 +27,20 @@ export interface YamlSidePanelProps { } export function YamlSidePanel({ resource, filename }: YamlSidePanelProps) { const [showOnlyImportantData, setShowOnlyImportantData] = useState(true); + const isEdit = true; // Currently always editing YAML (YamlViewer receives isEdit=true) const { closeAside } = useSplitter(); const { t } = useTranslation(); const yamlStringToDisplay = useMemo(() => { + if (isEdit) { + return stringify(convertToResourceConfig(resource)); + } return stringify(removeManagedFieldsAndFilterData(resource, showOnlyImportantData)); }, [resource, showOnlyImportantData]); const yamlStringToCopy = useMemo(() => { + if (isEdit) { + return stringify(convertToResourceConfig(resource)); + } return stringify(removeManagedFieldsAndFilterData(resource, false)); }, [resource]); @@ -55,14 +63,16 @@ export function YamlSidePanel({ resource, filename }: YamlSidePanelProps) { fixed header={ - YAML + {t('yaml.panelTitle')} - setShowOnlyImportantData(!showOnlyImportantData)} - /> + {!isEdit && ( + setShowOnlyImportantData(!showOnlyImportantData)} + /> + )}
- +
); diff --git a/src/components/Yaml/YamlViewer.tsx b/src/components/Yaml/YamlViewer.tsx index 55619f58..58c619eb 100644 --- a/src/components/Yaml/YamlViewer.tsx +++ b/src/components/Yaml/YamlViewer.tsx @@ -7,12 +7,13 @@ import styles from './YamlViewer.module.css'; type YamlViewerProps = { yamlString: string; filename: string; + isEdit?: boolean; }; -export const YamlViewer: FC = ({ yamlString, filename }) => { +export const YamlViewer: FC = ({ yamlString, filename, isEdit = false }) => { return (
- +
); }; diff --git a/src/components/YamlEditor/YamlDiffEditor.tsx b/src/components/YamlEditor/YamlDiffEditor.tsx index 3bd4b43d..82560723 100644 --- a/src/components/YamlEditor/YamlDiffEditor.tsx +++ b/src/components/YamlEditor/YamlDiffEditor.tsx @@ -18,6 +18,7 @@ export const YamlDiffEditor = (props: YamlDiffEditorProps) => { const simplifiedOptions = { // Start from consumer-provided options, then enforce our simplified look ...options, + isKubernetes: true, scrollbar: { ...(options?.scrollbar ?? {}), useShadows: false, diff --git a/src/components/YamlEditor/YamlEditor.tsx b/src/components/YamlEditor/YamlEditor.tsx index be56a6f4..60c75b02 100644 --- a/src/components/YamlEditor/YamlEditor.tsx +++ b/src/components/YamlEditor/YamlEditor.tsx @@ -1,30 +1,117 @@ import { Editor } from '@monaco-editor/react'; import type { ComponentProps } from 'react'; +import { Button, Panel, Toolbar, ToolbarSpacer, Title } from '@ui5/webcomponents-react'; +import { parseDocument } from 'yaml'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTheme } from '../../hooks/useTheme'; import { GITHUB_DARK_DEFAULT, GITHUB_LIGHT_DEFAULT } from '../../lib/monaco.ts'; +import { useTranslation } from 'react-i18next'; +import * as monaco from 'monaco-editor'; // Reuse all props from the underlying Monaco Editor component, except language (we force YAML) -export type YamlEditorProps = Omit, 'language'>; +export type YamlEditorProps = Omit, 'language'> & { + // When true, editor becomes editable and an Apply changes button & validation appear + isEdit?: boolean; +}; -// Simple wrapper that forwards all props to Monaco Editor +// Simple wrapper that forwards all props to Monaco Editor, enhanced with edit/apply capability export const YamlEditor = (props: YamlEditorProps) => { const { isDarkTheme } = useTheme(); - const { theme, options, ...rest } = props; + const { t } = useTranslation(); + const { theme, options, value, defaultValue, onChange, isEdit = false, ...rest } = props; const computedTheme = theme ?? (isDarkTheme ? GITHUB_DARK_DEFAULT : GITHUB_LIGHT_DEFAULT); - const enforcedOptions = { - ...options, - minimap: { enabled: false }, - }; + // Maintain internal state only in edit mode; otherwise rely on provided value (viewer mode) + const [code, setCode] = useState(value?.toString() ?? defaultValue?.toString() ?? ''); + const [errors, setErrors] = useState([]); + const [attemptedApply, setAttemptedApply] = useState(false); + + // Keep internal state in sync when value prop changes in non-edit mode + useEffect(() => { + if (typeof value !== 'undefined') { + setCode(value.toString()); + } + }, [value]); + + const enforcedOptions = useMemo( + () => ({ + ...options, + readOnly: isEdit ? false : (options?.readOnly ?? true), + minimap: { enabled: false }, + isKubernetes: true, + wordWrap: 'on', + scrollBeyondLastLine: false, + }), + [options, isEdit], + ); + + const handleInternalChange = useCallback( + (val: string | undefined) => { + if (isEdit) { + setCode(val ?? ''); + } + onChange?.(val ?? '', undefined as unknown as monaco.editor.IModelContentChangedEvent); + }, + [isEdit, onChange], + ); + + const handleApply = useCallback(() => { + setAttemptedApply(true); + try { + const doc = parseDocument(code); + if (doc.errors && doc.errors.length) { + setErrors(doc.errors.map((e) => e.message)); + return; + } + setErrors([]); + const jsObj = doc.toJS(); + + console.log('Parsed YAML object:', jsObj); + } catch (e: unknown) { + if (e && typeof e === 'object' && 'message' in e) { + // @ts-expect-error narrowing message + setErrors([String(e.message)]); + } else { + setErrors(['Unknown YAML parse error']); + } + } + }, [code]); + + const showErrors = isEdit && attemptedApply && errors.length > 0; return ( - +
+ {isEdit && ( + + {t('yaml.editorTitle')} + + + + )} +
+ +
+ {showErrors && ( + +
    + {errors.map((err, idx) => ( +
  • + {err} +
  • + ))} +
+
+ )} +
); }; diff --git a/src/utils/convertToResourceConfig.spec.ts b/src/utils/convertToResourceConfig.spec.ts new file mode 100644 index 00000000..1eba7868 --- /dev/null +++ b/src/utils/convertToResourceConfig.spec.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { convertToResourceConfig } from './convertToResourceConfig'; +import { LAST_APPLIED_CONFIGURATION_ANNOTATION } from '../lib/api/types/shared/keyNames'; +import type { Resource } from './removeManagedFieldsAndFilterData'; + +const baseResource = (): Resource => ({ + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + name: 'example', + namespace: 'demo-ns', + labels: { app: 'demo' }, + annotations: { + [LAST_APPLIED_CONFIGURATION_ANNOTATION]: '{"dummy":"config"}', + 'custom/anno': 'keep-me', + }, + managedFields: [{ manager: 'kube-controller' }], + creationTimestamp: '2025-01-01T00:00:00Z', + finalizers: ['protect'], + generation: 3, + resourceVersion: '12345', + uid: 'abcdef', + }, + spec: { foo: 'bar' }, + status: { observedGeneration: 3 }, +}); + +describe('convertToResourceConfig', () => { + it('produces a lean manifest without status & server-only metadata', () => { + const input = baseResource(); + const output = convertToResourceConfig(input); + + // Keep essentials + expect(output.apiVersion).toEqual('v1'); + expect(output.kind).toEqual('ConfigMap'); + expect(output.metadata.name).toEqual('example'); + expect(output.metadata.namespace).toEqual('demo-ns'); + expect(output.metadata.labels).toEqual({ app: 'demo' }); + expect(output.metadata.finalizers).toEqual(['protect']); + expect(output.spec).toEqual({ foo: 'bar' }); + + // Remove unwanted + expect(output.metadata).not.toHaveProperty('managedFields'); + expect(output.metadata).not.toHaveProperty('resourceVersion'); + expect(output.metadata).not.toHaveProperty('uid'); + expect(output.metadata).not.toHaveProperty('generation'); + expect(output.metadata).not.toHaveProperty('creationTimestamp'); + // Removed annotation + expect(output.metadata.annotations?.[LAST_APPLIED_CONFIGURATION_ANNOTATION]).toBeUndefined(); + // Custom annotation kept + expect(output.metadata.annotations?.['custom/anno']).toEqual('keep-me'); + // Status removed + // @ts-expect-error status intentionally absent + expect(output.status).toBeUndefined(); + }); + + it('handles list resources recursively', () => { + const list: Resource = { + apiVersion: 'v1', + kind: 'ConfigMapList', + metadata: { name: 'ignored-list-meta' }, + items: [baseResource(), baseResource()], + }; + + const out = convertToResourceConfig(list); + expect(out.items).toBeDefined(); + expect(out.items?.length).toEqual(2); + out.items?.forEach((item) => { + expect(item.metadata.annotations?.[LAST_APPLIED_CONFIGURATION_ANNOTATION]).toBeUndefined(); + expect(item.metadata.labels).toEqual({ app: 'demo' }); + // @ts-expect-error status intentionally absent + expect(item.status).toBeUndefined(); + }); + }); + + it('returns empty object shape when input is null/undefined', () => { + // @ts-expect-error test invalid input + const out = convertToResourceConfig(null); + expect(out).toBeInstanceOf(Object); + }); +}); diff --git a/src/utils/convertToResourceConfig.ts b/src/utils/convertToResourceConfig.ts new file mode 100644 index 00000000..6fbce751 --- /dev/null +++ b/src/utils/convertToResourceConfig.ts @@ -0,0 +1,56 @@ +import { LAST_APPLIED_CONFIGURATION_ANNOTATION } from '../lib/api/types/shared/keyNames'; +import type { Resource } from './removeManagedFieldsAndFilterData'; + +/** + * Convert an in-cluster Resource (which may contain status and server-populated metadata) + * into a lean manifest suitable for applying with kubectl. + * Rules: + * - Keep: apiVersion, kind, metadata.name, metadata.namespace, metadata.labels, metadata.annotations (except LAST_APPLIED_CONFIGURATION_ANNOTATION), metadata.finalizers, spec. + * - Remove: metadata.managedFields, metadata.resourceVersion, metadata.uid, metadata.generation, metadata.creationTimestamp, + * LAST_APPLIED_CONFIGURATION_ANNOTATION annotation, status. + * - If a List (has items), convert each item recursively. + */ +export const convertToResourceConfig = (resourceObject: Resource | undefined | null): Resource => { + if (!resourceObject) return {} as Resource; + + const base: Resource = { + apiVersion: resourceObject.apiVersion, + kind: resourceObject.kind, + metadata: { + name: resourceObject.metadata?.name || '', + }, + } as Resource; + + if (resourceObject.metadata?.namespace) { + base.metadata.namespace = resourceObject.metadata.namespace; + } + if (resourceObject.metadata?.labels && Object.keys(resourceObject.metadata.labels).length > 0) { + base.metadata.labels = { ...resourceObject.metadata.labels }; + } + if (resourceObject.metadata?.annotations) { + const filtered = { ...resourceObject.metadata.annotations }; + delete filtered[LAST_APPLIED_CONFIGURATION_ANNOTATION]; + // Remove empty annotation object + const keys = Object.keys(filtered).filter((k) => filtered[k] !== undefined && filtered[k] !== ''); + if (keys.length > 0) { + base.metadata.annotations = keys.reduce>((acc, k) => { + const v = filtered[k]; + if (typeof v === 'string') acc[k] = v; + return acc; + }, {}); + } + } + if (resourceObject.metadata?.finalizers && resourceObject.metadata.finalizers.length > 0) { + base.metadata.finalizers = [...resourceObject.metadata.finalizers]; + } + if (resourceObject.spec !== undefined) { + base.spec = resourceObject.spec; + } + + // If list: map items + if (resourceObject.items) { + base.items = resourceObject.items.map((it) => convertToResourceConfig(it)); + } + + return base; +}; From 4c78673fd66cd5a60194293a64b588d8d6a5092f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Wed, 8 Oct 2025 09:25:47 +0200 Subject: [PATCH 02/37] fix --- public/locales/en.json | 11 +- .../ControlPlane/ManagedResources.tsx | 221 +++++++++++------- .../ManagedResourcesActionMenu.tsx | 8 +- src/components/Yaml/YamlSidePanel.tsx | 49 +++- src/components/Yaml/YamlViewer.tsx | 11 +- src/components/YamlEditor/YamlEditor.tsx | 53 +++-- 6 files changed, 232 insertions(+), 121 deletions(-) diff --git a/public/locales/en.json b/public/locales/en.json index b2c34bb3..def8fd83 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -37,11 +37,15 @@ "tableHeaderReady": "Ready", "tableHeaderDelete": "Delete", "deleteAction": "Delete resource", + "editAction": "Edit resource", "deleteDialogTitle": "Delete resource", "advancedOptions": "Advanced options", "forceDeletion": "Force deletion", "forceWarningLine": "Force deletion removes finalizers. Related resources may not be deleted and cleanup may be skipped.", "deleteStarted": "Deleting {{resourceName}} initialized", + "patchStarted": "Updating {{resourceName}} initialized", + "patchSuccess": "Updated {{resourceName}}", + "patchError": "Failed to update {{resourceName}}", "actionColumnHeader": " " }, "ProvidersConfig": { @@ -373,7 +377,8 @@ "installError": "Install error", "syncError": "Sync error", "error": "Error", - "notHealthy": "Not healthy" + "notHealthy": "Not healthy", + "notReady": "Not ready" }, "buttons": { "viewResource": "View resource", @@ -391,7 +396,9 @@ "YAML": "File", "showOnlyImportant": "Show only important fields", "panelTitle": "YAML", - "editorTitle": "YAML Editor" + "editorTitle": "YAML Editor", + "applySuccess": "Changes applied successfully", + "applySuccess2": "You can safely close this panel." }, "createMCP": { "dialogTitle": "Create Managed Control Plane", diff --git a/src/components/ControlPlane/ManagedResources.tsx b/src/components/ControlPlane/ManagedResources.tsx index 5b91de95..cbeee317 100644 --- a/src/components/ControlPlane/ManagedResources.tsx +++ b/src/components/ControlPlane/ManagedResources.tsx @@ -15,7 +15,7 @@ import IllustratedError from '../Shared/IllustratedError'; import { resourcesInterval } from '../../lib/shared/constants'; import { YamlViewButton } from '../Yaml/YamlViewButton.tsx'; -import { useMemo, useState } from 'react'; +import { useMemo, useState, useContext, useRef } from 'react'; import StatusFilter from '../Shared/StatusFilter/StatusFilter.tsx'; import { ResourceStatusCell } from '../Shared/ResourceStatusCell.tsx'; import { Resource } from '../../utils/removeManagedFieldsAndFilterData.ts'; @@ -30,15 +30,12 @@ import { PatchResourceForForceDeletionBody, } from '../../lib/api/types/crate/deleteResource'; import { useResourcePluralNames } from '../../hooks/useResourcePluralNames'; - -interface CellData { - cell: { - value: T | null; // null for grouping rows - row: { - original?: ResourceRow; // missing for grouping rows - }; - }; -} +import { useSplitter } from '../Splitter/SplitterContext.tsx'; +import { YamlSidePanel } from '../Yaml/YamlSidePanel.tsx'; +import { fetchApiServerJson } from '../../lib/api/fetch'; +import { ApiConfigContext } from '../Shared/k8s'; +import { ErrorDialog, ErrorDialogHandle } from '../Shared/ErrorMessageBox.tsx'; +import { APIError } from '../../lib/api/error.ts'; type ResourceRow = { kind: string; @@ -56,7 +53,10 @@ type ResourceRow = { export function ManagedResources() { const { t } = useTranslation(); const toast = useToast(); + const { openInAside } = useSplitter(); + const apiConfig = useContext(ApiConfigContext); const [pendingDeleteItem, setPendingDeleteItem] = useState(null); + const errorDialogRef = useRef(null); const { data: managedResources, @@ -81,80 +81,121 @@ export function ManagedResources() { PatchResourceForForceDeletion(apiVersion, pluralKind, resourceName, namespace), ); - const columns: AnalyticalTableColumnDefinition[] = useMemo( - () => [ - { - Header: t('ManagedResources.tableHeaderKind'), - accessor: 'kind', - }, - { - Header: t('ManagedResources.tableHeaderName'), - accessor: 'name', - }, - { - Header: t('ManagedResources.tableHeaderCreated'), - accessor: 'created', - }, - { - Header: t('ManagedResources.tableHeaderSynced'), - accessor: 'synced', - hAlign: 'Center', - width: 125, - Filter: ({ column }) => , - Cell: (cellData: CellData) => - cellData.cell.row.original?.synced != null ? ( - - ) : null, - }, - { - Header: t('ManagedResources.tableHeaderReady'), - accessor: 'ready', - hAlign: 'Center', - width: 125, - Filter: ({ column }) => , - Cell: (cellData: CellData) => - cellData.cell.row.original?.ready != null ? ( - - ) : null, - }, - { - Header: t('yaml.YAML'), - hAlign: 'Center', - width: 75, - accessor: 'yaml', - disableFilters: true, - Cell: (cellData: CellData) => { - return cellData.cell.row.original?.item ? ( - - ) : undefined; + const openEditPanel = (item: ManagedResourceItem) => { + openInAside( + await handleResourcePatch(item, parsed)} + />, + ); + }; + + const handleResourcePatch = async (item: ManagedResourceItem, parsed: unknown): Promise => { + const resourceName = item?.metadata?.name ?? ''; + const apiVersion = item?.apiVersion ?? ''; + const pluralKind = getPluralKind(item.kind); + const namespace = item?.metadata?.namespace; + + toast.show(t('ManagedResources.patchStarted', { resourceName })); + + try { + const basePath = `/apis/${apiVersion}`; + const path = namespace + ? `${basePath}/namespaces/${namespace}/${pluralKind}/${resourceName}` + : `${basePath}/${pluralKind}/${resourceName}`; + + await fetchApiServerJson(path, apiConfig, undefined, 'PATCH', JSON.stringify(parsed)); + toast.show(t('ManagedResources.patchSuccess', { resourceName })); + return true; + } catch (e) { + toast.show(t('ManagedResources.patchError', { resourceName })); + if (e instanceof APIError && errorDialogRef.current) { + errorDialogRef.current.showErrorDialog(`${e.message}: ${JSON.stringify(e.info)}`); + } + console.error('Failed to patch resource', e); + return false; + } + }; + + const columns = useMemo( + () => + [ + { + Header: t('ManagedResources.tableHeaderKind'), + accessor: 'kind', + }, + { + Header: t('ManagedResources.tableHeaderName'), + accessor: 'name', + }, + { + Header: t('ManagedResources.tableHeaderCreated'), + accessor: 'created', }, - }, - { - Header: t('ManagedResources.actionColumnHeader'), - hAlign: 'Center', - width: 60, - disableFilters: true, - Cell: (cellData: CellData) => { - const item = cellData.cell.row.original?.item as ManagedResourceItem; - - return cellData.cell.row.original?.item ? ( - - ) : undefined; + { + Header: t('ManagedResources.tableHeaderSynced'), + accessor: 'synced', + hAlign: 'Center', + width: 125, + Filter: ({ column }: any) => , + Cell: ({ row }: any) => { + const original = row.original as ResourceRow; + return original?.synced != null ? ( + + ) : null; + }, }, - }, - ], + { + Header: t('ManagedResources.tableHeaderReady'), + accessor: 'ready', + hAlign: 'Center', + width: 125, + Filter: ({ column }: any) => , + Cell: ({ row }: any) => { + const original = row.original as ResourceRow; + return original?.ready != null ? ( + + ) : null; + }, + }, + { + Header: t('yaml.YAML'), + hAlign: 'Center', + width: 75, + accessor: 'yaml', + disableFilters: true, + Cell: ({ row }: any) => { + const original = row.original as ResourceRow; + return original?.item ? ( + + ) : undefined; + }, + }, + { + Header: t('ManagedResources.actionColumnHeader'), + hAlign: 'Center', + width: 60, + disableFilters: true, + Cell: ({ row }: any) => { + const original = row.original as ResourceRow; + const item = original?.item as ManagedResourceItem; + return item ? : undefined; + }, + }, + ] as AnalyticalTableColumnDefinition[], [t], ); @@ -192,10 +233,19 @@ export function ManagedResources() { await deleteTrigger(); if (force) { - await patchTrigger(PatchResourceForForceDeletionBody); + try { + await patchTrigger(PatchResourceForForceDeletionBody); + } catch (e) { + if (e instanceof APIError && errorDialogRef.current) { + errorDialogRef.current.showErrorDialog(`${e.message}: ${JSON.stringify(e.info)}`); + } + throw e; // rethrow to outer catch + } + } + } catch (e) { + if (e instanceof APIError && errorDialogRef.current) { + errorDialogRef.current.showErrorDialog(`${e.message}: ${JSON.stringify(e.info)}`); } - } catch (_) { - // Ignore errors - will be handled by the mutation hook } finally { setPendingDeleteItem(null); } @@ -247,6 +297,7 @@ export function ManagedResources() { onClose={() => setPendingDeleteItem(null)} onDeletionConfirmed={handleDeletionConfirmed} /> + )} diff --git a/src/components/ControlPlane/ManagedResourcesActionMenu.tsx b/src/components/ControlPlane/ManagedResourcesActionMenu.tsx index 91d8e7a1..a7f0c264 100644 --- a/src/components/ControlPlane/ManagedResourcesActionMenu.tsx +++ b/src/components/ControlPlane/ManagedResourcesActionMenu.tsx @@ -7,10 +7,11 @@ import type { Ui5CustomEvent, ButtonDomRef } from '@ui5/webcomponents-react'; interface RowActionsMenuProps { item: ManagedResourceItem; - onOpen: (item: ManagedResourceItem) => void; + onOpen: (item: ManagedResourceItem) => void; // delete dialog open + onEdit: (item: ManagedResourceItem) => void; // open YAML editor for patch } -export const RowActionsMenu: FC = ({ item, onOpen }) => { +export const RowActionsMenu: FC = ({ item, onOpen, onEdit }) => { const { t } = useTranslation(); const popoverRef = useRef(null); const [open, setOpen] = useState(false); @@ -33,10 +34,13 @@ export const RowActionsMenu: FC = ({ item, onOpen }) => { const action = element.dataset.action; if (action === 'delete') { onOpen(item); + } else if (action === 'edit') { + onEdit(item); } setOpen(false); }} > + diff --git a/src/components/Yaml/YamlSidePanel.tsx b/src/components/Yaml/YamlSidePanel.tsx index 36963f4f..fc5d5c8d 100644 --- a/src/components/Yaml/YamlSidePanel.tsx +++ b/src/components/Yaml/YamlSidePanel.tsx @@ -7,26 +7,30 @@ import { ToolbarButton, ToolbarSeparator, ToolbarSpacer, + Button, } from '@ui5/webcomponents-react'; - +import IllustrationMessageType from '@ui5/webcomponents-fiori/dist/types/IllustrationMessageType.js'; import { useTranslation } from 'react-i18next'; import { YamlViewer } from './YamlViewer.tsx'; import { useSplitter } from '../Splitter/SplitterContext.tsx'; -import { useMemo, useState } from 'react'; +import { useMemo, useState, useCallback } from 'react'; import { stringify } from 'yaml'; import { convertToResourceConfig } from '../../utils/convertToResourceConfig.ts'; import { removeManagedFieldsAndFilterData, Resource } from '../../utils/removeManagedFieldsAndFilterData.ts'; import { useCopyToClipboard } from '../../hooks/useCopyToClipboard.ts'; import styles from './YamlSidePanel.module.css'; +import { IllustratedBanner } from '../Ui/IllustratedBanner/IllustratedBanner.tsx'; export const SHOW_DOWNLOAD_BUTTON = false; // Download button is hidden now due to stakeholder request export interface YamlSidePanelProps { resource: Resource; filename: string; + onApply?: (parsed: unknown, yaml: string) => void | boolean | Promise; // optional apply handler when in edit mode } -export function YamlSidePanel({ resource, filename }: YamlSidePanelProps) { +export function YamlSidePanel({ resource, filename, onApply }: YamlSidePanelProps) { const [showOnlyImportantData, setShowOnlyImportantData] = useState(true); + const [isSuccess, setIsSuccess] = useState(false); const isEdit = true; // Currently always editing YAML (YamlViewer receives isEdit=true) const { closeAside } = useSplitter(); const { t } = useTranslation(); @@ -36,13 +40,13 @@ export function YamlSidePanel({ resource, filename }: YamlSidePanelProps) { return stringify(convertToResourceConfig(resource)); } return stringify(removeManagedFieldsAndFilterData(resource, showOnlyImportantData)); - }, [resource, showOnlyImportantData]); + }, [resource, showOnlyImportantData, isEdit]); const yamlStringToCopy = useMemo(() => { if (isEdit) { return stringify(convertToResourceConfig(resource)); } return stringify(removeManagedFieldsAndFilterData(resource, false)); - }, [resource]); + }, [resource, isEdit]); const { copyToClipboard } = useCopyToClipboard(); const handleDownloadClick = () => { @@ -57,6 +61,21 @@ export function YamlSidePanel({ resource, filename }: YamlSidePanelProps) { window.URL.revokeObjectURL(url); }; + const handleApplyWrapper = useCallback( + async (parsed: unknown, yaml: string) => { + if (!onApply) return; + try { + const result = await onApply(parsed, yaml); + if (result === true) { + setIsSuccess(true); + } + } catch (_) { + // onApply handles its own error display (toast/dialog) + } + }, + [onApply], + ); + return (
- + {isSuccess ? ( + + + + + ) : ( + + )}
); diff --git a/src/components/Yaml/YamlViewer.tsx b/src/components/Yaml/YamlViewer.tsx index 58c619eb..3f688652 100644 --- a/src/components/Yaml/YamlViewer.tsx +++ b/src/components/Yaml/YamlViewer.tsx @@ -8,12 +8,19 @@ type YamlViewerProps = { yamlString: string; filename: string; isEdit?: boolean; + onApply?: (parsed: unknown, yaml: string) => void | boolean | Promise; }; -export const YamlViewer: FC = ({ yamlString, filename, isEdit = false }) => { +export const YamlViewer: FC = ({ yamlString, filename, isEdit = false, onApply }) => { return (
- +
); }; diff --git a/src/components/YamlEditor/YamlEditor.tsx b/src/components/YamlEditor/YamlEditor.tsx index 60c75b02..44614424 100644 --- a/src/components/YamlEditor/YamlEditor.tsx +++ b/src/components/YamlEditor/YamlEditor.tsx @@ -12,13 +12,14 @@ import * as monaco from 'monaco-editor'; export type YamlEditorProps = Omit, 'language'> & { // When true, editor becomes editable and an Apply changes button & validation appear isEdit?: boolean; + onApply?: (parsed: unknown, yaml: string) => void; // callback when user applies valid YAML }; // Simple wrapper that forwards all props to Monaco Editor, enhanced with edit/apply capability export const YamlEditor = (props: YamlEditorProps) => { const { isDarkTheme } = useTheme(); const { t } = useTranslation(); - const { theme, options, value, defaultValue, onChange, isEdit = false, ...rest } = props; + const { theme, options, value, defaultValue, onChange, isEdit = false, onApply, ...rest } = props; const computedTheme = theme ?? (isDarkTheme ? GITHUB_DARK_DEFAULT : GITHUB_LIGHT_DEFAULT); // Maintain internal state only in edit mode; otherwise rely on provided value (viewer mode) @@ -35,11 +36,10 @@ export const YamlEditor = (props: YamlEditorProps) => { const enforcedOptions = useMemo( () => ({ - ...options, + ...(options as monaco.editor.IStandaloneEditorConstructionOptions), readOnly: isEdit ? false : (options?.readOnly ?? true), minimap: { enabled: false }, - isKubernetes: true, - wordWrap: 'on', + wordWrap: 'on' as const, scrollBeyondLastLine: false, }), [options, isEdit], @@ -56,26 +56,31 @@ export const YamlEditor = (props: YamlEditorProps) => { ); const handleApply = useCallback(() => { - setAttemptedApply(true); - try { - const doc = parseDocument(code); - if (doc.errors && doc.errors.length) { - setErrors(doc.errors.map((e) => e.message)); - return; + const run = async () => { + setAttemptedApply(true); + try { + const doc = parseDocument(code); + if (doc.errors && doc.errors.length) { + setErrors(doc.errors.map((e) => e.message)); + return; + } + setErrors([]); + const jsObj = doc.toJS(); + if (onApply) { + await onApply(jsObj, code); + } else { + console.log('Parsed YAML object:', jsObj); + } + } catch (e: unknown) { + if (e && typeof e === 'object' && 'message' in e) { + setErrors([String((e as any).message)]); + } else { + setErrors(['Unknown YAML parse error']); + } } - setErrors([]); - const jsObj = doc.toJS(); - - console.log('Parsed YAML object:', jsObj); - } catch (e: unknown) { - if (e && typeof e === 'object' && 'message' in e) { - // @ts-expect-error narrowing message - setErrors([String(e.message)]); - } else { - setErrors(['Unknown YAML parse error']); - } - } - }, [code]); + }; + run(); + }, [code, onApply]); const showErrors = isEdit && attemptedApply && errors.length > 0; @@ -95,7 +100,7 @@ export const YamlEditor = (props: YamlEditorProps) => { {...rest} value={isEdit ? code : value} theme={computedTheme} - options={enforcedOptions} + options={enforcedOptions as any} height="100%" language="yaml" onChange={handleInternalChange} From 667bb38b6cf17782fe91a005b23abfba545b3b21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Wed, 8 Oct 2025 10:11:25 +0200 Subject: [PATCH 03/37] fixes --- public/locales/en.json | 6 +- .../SummarizeStep.tsx | 1 + .../YamlDiff.module.css | 6 ++ .../CreateManagedControlPlane/YamlDiff.tsx | 6 +- src/components/Yaml/YamlSidePanel.module.css | 36 +++++++ src/components/Yaml/YamlSidePanel.tsx | 95 +++++++++++++------ 6 files changed, 116 insertions(+), 34 deletions(-) diff --git a/public/locales/en.json b/public/locales/en.json index def8fd83..ddad15ae 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -398,7 +398,11 @@ "panelTitle": "YAML", "editorTitle": "YAML Editor", "applySuccess": "Changes applied successfully", - "applySuccess2": "You can safely close this panel." + "applySuccess2": "Your resource update was submitted.", + "diffConfirmTitle": "Review changes", + "diffConfirmMessage": "Are you sure that you want to apply these changes?", + "diffNo": "No, go back", + "diffYes": "Yes" }, "createMCP": { "dialogTitle": "Create Managed Control Plane", diff --git a/src/components/Wizards/CreateManagedControlPlane/SummarizeStep.tsx b/src/components/Wizards/CreateManagedControlPlane/SummarizeStep.tsx index dbb9a523..ef665630 100644 --- a/src/components/Wizards/CreateManagedControlPlane/SummarizeStep.tsx +++ b/src/components/Wizards/CreateManagedControlPlane/SummarizeStep.tsx @@ -77,6 +77,7 @@ export const SummarizeStep: React.FC = ({ {isEditMode ? ( = ({ originalYaml, modifiedYaml }) => { +export const YamlDiff: FC = ({ originalYaml, modifiedYaml, absolutePosition = false }) => { return ( -
+
); diff --git a/src/components/Yaml/YamlSidePanel.module.css b/src/components/Yaml/YamlSidePanel.module.css index 7112b16d..49ec6095 100644 --- a/src/components/Yaml/YamlSidePanel.module.css +++ b/src/components/Yaml/YamlSidePanel.module.css @@ -8,3 +8,39 @@ height: 100%; width: 100%; } + +.successContainer { + gap: 1rem; + padding: 1rem; + align-items: center; +} + +.reviewContainer { + gap: 1rem; + min-height: 100%; + width: 100%; + position: relative; +} + +.stickyHeader { + position: sticky; + top: 0; + height: 6rem; + padding: 1rem; + padding-bottom: 1.5rem; + z-index: 1; + background: var(--sapBackgroundColor); +} + +.stickyHeaderInner { + padding: 0 1rem; +} + +.diffConfirmMessage { + margin-top: 0.5rem; +} + +.reviewButtons { + gap: 0.5rem; + padding: 0 1rem 1rem; +} diff --git a/src/components/Yaml/YamlSidePanel.tsx b/src/components/Yaml/YamlSidePanel.tsx index fc5d5c8d..6dd9a132 100644 --- a/src/components/Yaml/YamlSidePanel.tsx +++ b/src/components/Yaml/YamlSidePanel.tsx @@ -16,10 +16,11 @@ import { useSplitter } from '../Splitter/SplitterContext.tsx'; import { useMemo, useState, useCallback } from 'react'; import { stringify } from 'yaml'; import { convertToResourceConfig } from '../../utils/convertToResourceConfig.ts'; -import { removeManagedFieldsAndFilterData, Resource } from '../../utils/removeManagedFieldsAndFilterData.ts'; +import { Resource } from '../../utils/removeManagedFieldsAndFilterData.ts'; import { useCopyToClipboard } from '../../hooks/useCopyToClipboard.ts'; import styles from './YamlSidePanel.module.css'; import { IllustratedBanner } from '../Ui/IllustratedBanner/IllustratedBanner.tsx'; +import { YamlDiff } from '../Wizards/CreateManagedControlPlane/YamlDiff.tsx'; export const SHOW_DOWNLOAD_BUTTON = false; // Download button is hidden now due to stakeholder request @@ -30,25 +31,26 @@ export interface YamlSidePanelProps { } export function YamlSidePanel({ resource, filename, onApply }: YamlSidePanelProps) { const [showOnlyImportantData, setShowOnlyImportantData] = useState(true); - const [isSuccess, setIsSuccess] = useState(false); - const isEdit = true; // Currently always editing YAML (YamlViewer receives isEdit=true) + const [mode, setMode] = useState<'edit' | 'review' | 'success'>('edit'); + const [editedYaml, setEditedYaml] = useState(null); + const [parsedObject, setParsedObject] = useState(null); + const isEdit = true; // Always edit mode in this context const { closeAside } = useSplitter(); const { t } = useTranslation(); + const originalYaml = useMemo(() => stringify(convertToResourceConfig(resource)), [resource]); + // yamlStringToDisplay used for editor when in edit mode const yamlStringToDisplay = useMemo(() => { - if (isEdit) { - return stringify(convertToResourceConfig(resource)); + if (mode === 'edit') { + return editedYaml ?? originalYaml; } - return stringify(removeManagedFieldsAndFilterData(resource, showOnlyImportantData)); - }, [resource, showOnlyImportantData, isEdit]); - const yamlStringToCopy = useMemo(() => { - if (isEdit) { - return stringify(convertToResourceConfig(resource)); - } - return stringify(removeManagedFieldsAndFilterData(resource, false)); - }, [resource, isEdit]); + return editedYaml ?? originalYaml; + }, [mode, editedYaml, originalYaml]); + + const yamlStringToCopy = useMemo(() => originalYaml, [originalYaml]); const { copyToClipboard } = useCopyToClipboard(); + const handleDownloadClick = () => { const blob = new Blob([yamlStringToCopy], { type: 'text/yaml' }); const url = window.URL.createObjectURL(blob); @@ -61,20 +63,29 @@ export function YamlSidePanel({ resource, filename, onApply }: YamlSidePanelProp window.URL.revokeObjectURL(url); }; - const handleApplyWrapper = useCallback( - async (parsed: unknown, yaml: string) => { - if (!onApply) return; - try { - const result = await onApply(parsed, yaml); - if (result === true) { - setIsSuccess(true); - } - } catch (_) { - // onApply handles its own error display (toast/dialog) + // First apply from editor: validate -> store edited YAML -> go to review + const handleApplyFromEditor = useCallback(async (parsed: unknown, yaml: string) => { + setParsedObject(parsed); + setEditedYaml(yaml); + setMode('review'); + }, []); + + // User confirms diff -> perform patch + const handleConfirmPatch = useCallback(async () => { + if (!onApply || !editedYaml) return; + try { + const result = await onApply(parsedObject, editedYaml); + if (result === true) { + setMode('success'); } - }, - [onApply], - ); + } catch (_) { + // Stay on review mode; error dialog & toast handled upstream + } + }, [onApply, editedYaml, parsedObject]); + + const handleGoBack = () => { + setMode('edit'); + }; return ( copyToClipboard(yamlStringToCopy)} + onClick={() => copyToClipboard(mode === 'edit' ? yamlStringToDisplay : (editedYaml ?? originalYaml))} /> {SHOW_DOWNLOAD_BUTTON ? (
- {isSuccess ? ( - + {mode === 'success' && ( + - ) : ( + )} + {mode === 'review' && ( + +
+
+ {t('yaml.diffConfirmTitle')} +

{t('yaml.diffConfirmMessage')}

+
+ + + + +
+
+ +
+
+ )} + {mode === 'edit' && ( )}
From cae0b1dd8892bf92c4704a67d20ada145da5476d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Wed, 8 Oct 2025 11:22:10 +0200 Subject: [PATCH 04/37] fix --- .../ControlPlane/ManagedResources.tsx | 15 ++++++++------ src/components/Yaml/YamlSidePanel.tsx | 20 ++++--------------- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/src/components/ControlPlane/ManagedResources.tsx b/src/components/ControlPlane/ManagedResources.tsx index cbeee317..b1ba635b 100644 --- a/src/components/ControlPlane/ManagedResources.tsx +++ b/src/components/ControlPlane/ManagedResources.tsx @@ -1,4 +1,5 @@ import { useTranslation } from 'react-i18next'; +import { Fragment, useMemo, useState, useContext, useRef } from 'react'; import { AnalyticalTable, AnalyticalTableColumnDefinition, @@ -15,7 +16,6 @@ import IllustratedError from '../Shared/IllustratedError'; import { resourcesInterval } from '../../lib/shared/constants'; import { YamlViewButton } from '../Yaml/YamlViewButton.tsx'; -import { useMemo, useState, useContext, useRef } from 'react'; import StatusFilter from '../Shared/StatusFilter/StatusFilter.tsx'; import { ResourceStatusCell } from '../Shared/ResourceStatusCell.tsx'; import { Resource } from '../../utils/removeManagedFieldsAndFilterData.ts'; @@ -82,12 +82,15 @@ export function ManagedResources() { ); const openEditPanel = (item: ManagedResourceItem) => { + const identityKey = `${item.kind}:${item.metadata.namespace ?? ''}:${item.metadata.name}`; openInAside( - await handleResourcePatch(item, parsed)} - />, + + await handleResourcePatch(item, parsed)} + /> + , ); }; diff --git a/src/components/Yaml/YamlSidePanel.tsx b/src/components/Yaml/YamlSidePanel.tsx index 6dd9a132..4a29a832 100644 --- a/src/components/Yaml/YamlSidePanel.tsx +++ b/src/components/Yaml/YamlSidePanel.tsx @@ -39,16 +39,8 @@ export function YamlSidePanel({ resource, filename, onApply }: YamlSidePanelProp const { t } = useTranslation(); const originalYaml = useMemo(() => stringify(convertToResourceConfig(resource)), [resource]); - // yamlStringToDisplay used for editor when in edit mode - const yamlStringToDisplay = useMemo(() => { - if (mode === 'edit') { - return editedYaml ?? originalYaml; - } - return editedYaml ?? originalYaml; - }, [mode, editedYaml, originalYaml]); - + const yamlStringToDisplay = useMemo(() => editedYaml ?? originalYaml, [editedYaml, originalYaml]); const yamlStringToCopy = useMemo(() => originalYaml, [originalYaml]); - const { copyToClipboard } = useCopyToClipboard(); const handleDownloadClick = () => { @@ -63,14 +55,12 @@ export function YamlSidePanel({ resource, filename, onApply }: YamlSidePanelProp window.URL.revokeObjectURL(url); }; - // First apply from editor: validate -> store edited YAML -> go to review const handleApplyFromEditor = useCallback(async (parsed: unknown, yaml: string) => { setParsedObject(parsed); setEditedYaml(yaml); setMode('review'); }, []); - // User confirms diff -> perform patch const handleConfirmPatch = useCallback(async () => { if (!onApply || !editedYaml) return; try { @@ -79,13 +69,11 @@ export function YamlSidePanel({ resource, filename, onApply }: YamlSidePanelProp setMode('success'); } } catch (_) { - // Stay on review mode; error dialog & toast handled upstream + // upstream handles error messaging } }, [onApply, editedYaml, parsedObject]); - const handleGoBack = () => { - setMode('edit'); - }; + const handleGoBack = () => setMode('edit'); return ( copyToClipboard(mode === 'edit' ? yamlStringToDisplay : (editedYaml ?? originalYaml))} + onClick={() => copyToClipboard(yamlStringToDisplay)} /> {SHOW_DOWNLOAD_BUTTON ? ( Date: Wed, 8 Oct 2025 11:52:38 +0200 Subject: [PATCH 05/37] fixes --- src/components/Yaml/YamlSidePanel.tsx | 15 +++++++++++---- src/components/Yaml/YamlSidePanelWithLoader.tsx | 10 ++++++++-- src/components/Yaml/YamlViewButton.tsx | 2 ++ 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/components/Yaml/YamlSidePanel.tsx b/src/components/Yaml/YamlSidePanel.tsx index 4a29a832..45fcd73f 100644 --- a/src/components/Yaml/YamlSidePanel.tsx +++ b/src/components/Yaml/YamlSidePanel.tsx @@ -16,7 +16,7 @@ import { useSplitter } from '../Splitter/SplitterContext.tsx'; import { useMemo, useState, useCallback } from 'react'; import { stringify } from 'yaml'; import { convertToResourceConfig } from '../../utils/convertToResourceConfig.ts'; -import { Resource } from '../../utils/removeManagedFieldsAndFilterData.ts'; +import { removeManagedFieldsAndFilterData, Resource } from '../../utils/removeManagedFieldsAndFilterData.ts'; import { useCopyToClipboard } from '../../hooks/useCopyToClipboard.ts'; import styles from './YamlSidePanel.module.css'; import { IllustratedBanner } from '../Ui/IllustratedBanner/IllustratedBanner.tsx'; @@ -28,17 +28,24 @@ export interface YamlSidePanelProps { resource: Resource; filename: string; onApply?: (parsed: unknown, yaml: string) => void | boolean | Promise; // optional apply handler when in edit mode + isEdit?: boolean; } -export function YamlSidePanel({ resource, filename, onApply }: YamlSidePanelProps) { +export function YamlSidePanel({ resource, filename, onApply, isEdit }: YamlSidePanelProps) { const [showOnlyImportantData, setShowOnlyImportantData] = useState(true); const [mode, setMode] = useState<'edit' | 'review' | 'success'>('edit'); const [editedYaml, setEditedYaml] = useState(null); const [parsedObject, setParsedObject] = useState(null); - const isEdit = true; // Always edit mode in this context + const { closeAside } = useSplitter(); const { t } = useTranslation(); - const originalYaml = useMemo(() => stringify(convertToResourceConfig(resource)), [resource]); + const originalYaml = useMemo( + () => + isEdit + ? stringify(convertToResourceConfig(resource)) + : stringify(removeManagedFieldsAndFilterData(resource, showOnlyImportantData)), + [isEdit, resource, showOnlyImportantData], + ); const yamlStringToDisplay = useMemo(() => editedYaml ?? originalYaml, [editedYaml, originalYaml]); const yamlStringToCopy = useMemo(() => originalYaml, [originalYaml]); const { copyToClipboard } = useCopyToClipboard(); diff --git a/src/components/Yaml/YamlSidePanelWithLoader.tsx b/src/components/Yaml/YamlSidePanelWithLoader.tsx index 48a3d10a..fb606f32 100644 --- a/src/components/Yaml/YamlSidePanelWithLoader.tsx +++ b/src/components/Yaml/YamlSidePanelWithLoader.tsx @@ -11,8 +11,14 @@ export interface YamlSidePanelWithLoaderProps { workspaceName?: string; resourceType: 'projects' | 'workspaces' | 'managedcontrolplanes'; resourceName: string; + isEdit?: boolean; } -export function YamlSidePanelWithLoader({ workspaceName, resourceType, resourceName }: YamlSidePanelWithLoaderProps) { +export function YamlSidePanelWithLoader({ + workspaceName, + resourceType, + resourceName, + isEdit = false, +}: YamlSidePanelWithLoaderProps) { const { t } = useTranslation(); const { isLoading, data, error } = useApiResource( ResourceObject(workspaceName ?? '', resourceType, resourceName), @@ -25,5 +31,5 @@ export function YamlSidePanelWithLoader({ workspaceName, resourceType, resourceN const filename = `${workspaceName ? `${workspaceName}_` : ''}${resourceType}_${resourceName}`; - return ; + return ; } diff --git a/src/components/Yaml/YamlViewButton.tsx b/src/components/Yaml/YamlViewButton.tsx index 87636dc0..3d0e4edb 100644 --- a/src/components/Yaml/YamlViewButton.tsx +++ b/src/components/Yaml/YamlViewButton.tsx @@ -30,6 +30,7 @@ export function YamlViewButton(props: YamlViewButtonProps) { const { resource } = props; openInAside( , @@ -41,6 +42,7 @@ export function YamlViewButton(props: YamlViewButtonProps) { const { workspaceName, resourceType, resourceName } = props; openInAside( Date: Wed, 8 Oct 2025 11:58:26 +0200 Subject: [PATCH 06/37] Update ManagedResources.tsx --- src/components/ControlPlane/ManagedResources.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/ControlPlane/ManagedResources.tsx b/src/components/ControlPlane/ManagedResources.tsx index b1ba635b..6adbc106 100644 --- a/src/components/ControlPlane/ManagedResources.tsx +++ b/src/components/ControlPlane/ManagedResources.tsx @@ -86,6 +86,7 @@ export function ManagedResources() { openInAside( await handleResourcePatch(item, parsed)} From 1b65dde2d78888262b3120a4a4417f6e3d70157b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Wed, 8 Oct 2025 17:26:44 +0200 Subject: [PATCH 07/37] refactor --- .../ManagedResourcesActionMenu.tsx | 4 +- src/components/Yaml/YamlSidePanel.tsx | 6 +-- src/components/YamlEditor/YamlDiffEditor.tsx | 6 +-- src/components/YamlEditor/YamlEditor.tsx | 47 ++++++++----------- .../types/crate/createManagedControlPlane.ts | 22 ++++----- 5 files changed, 37 insertions(+), 48 deletions(-) diff --git a/src/components/ControlPlane/ManagedResourcesActionMenu.tsx b/src/components/ControlPlane/ManagedResourcesActionMenu.tsx index a7f0c264..07391645 100644 --- a/src/components/ControlPlane/ManagedResourcesActionMenu.tsx +++ b/src/components/ControlPlane/ManagedResourcesActionMenu.tsx @@ -7,8 +7,8 @@ import type { Ui5CustomEvent, ButtonDomRef } from '@ui5/webcomponents-react'; interface RowActionsMenuProps { item: ManagedResourceItem; - onOpen: (item: ManagedResourceItem) => void; // delete dialog open - onEdit: (item: ManagedResourceItem) => void; // open YAML editor for patch + onOpen: (item: ManagedResourceItem) => void; + onEdit: (item: ManagedResourceItem) => void; } export const RowActionsMenu: FC = ({ item, onOpen, onEdit }) => { diff --git a/src/components/Yaml/YamlSidePanel.tsx b/src/components/Yaml/YamlSidePanel.tsx index 45fcd73f..5795736d 100644 --- a/src/components/Yaml/YamlSidePanel.tsx +++ b/src/components/Yaml/YamlSidePanel.tsx @@ -27,7 +27,7 @@ export const SHOW_DOWNLOAD_BUTTON = false; // Download button is hidden now due export interface YamlSidePanelProps { resource: Resource; filename: string; - onApply?: (parsed: unknown, yaml: string) => void | boolean | Promise; // optional apply handler when in edit mode + onApply?: (parsed: unknown, yaml: string) => void | boolean | Promise; isEdit?: boolean; } export function YamlSidePanel({ resource, filename, onApply, isEdit }: YamlSidePanelProps) { @@ -75,9 +75,7 @@ export function YamlSidePanel({ resource, filename, onApply, isEdit }: YamlSideP if (result === true) { setMode('success'); } - } catch (_) { - // upstream handles error messaging - } + } catch (_) {} }, [onApply, editedYaml, parsedObject]); const handleGoBack = () => setMode('edit'); diff --git a/src/components/YamlEditor/YamlDiffEditor.tsx b/src/components/YamlEditor/YamlDiffEditor.tsx index 82560723..005641f2 100644 --- a/src/components/YamlEditor/YamlDiffEditor.tsx +++ b/src/components/YamlEditor/YamlDiffEditor.tsx @@ -3,7 +3,6 @@ import type { ComponentProps } from 'react'; import { useTheme } from '../../hooks/useTheme'; import { GITHUB_DARK_DEFAULT, GITHUB_LIGHT_DEFAULT } from '../../lib/monaco.ts'; -// Reuse all props from the underlying Monaco DiffEditor component, except language (we force YAML) export type YamlDiffEditorProps = Omit< ComponentProps, 'language' | 'defaultLanguage' | 'originalLanguage' | 'modifiedLanguage' @@ -15,8 +14,7 @@ export const YamlDiffEditor = (props: YamlDiffEditorProps) => { const { theme, options, ...rest } = props; const computedTheme = theme ?? (isDarkTheme ? GITHUB_DARK_DEFAULT : GITHUB_LIGHT_DEFAULT); - const simplifiedOptions = { - // Start from consumer-provided options, then enforce our simplified look + const diffEditorOptions = { ...options, isKubernetes: true, scrollbar: { @@ -38,5 +36,5 @@ export const YamlDiffEditor = (props: YamlDiffEditorProps) => { readOnly: true, }; - return ; + return ; }; diff --git a/src/components/YamlEditor/YamlEditor.tsx b/src/components/YamlEditor/YamlEditor.tsx index 44614424..7bb168ec 100644 --- a/src/components/YamlEditor/YamlEditor.tsx +++ b/src/components/YamlEditor/YamlEditor.tsx @@ -8,29 +8,24 @@ import { GITHUB_DARK_DEFAULT, GITHUB_LIGHT_DEFAULT } from '../../lib/monaco.ts'; import { useTranslation } from 'react-i18next'; import * as monaco from 'monaco-editor'; -// Reuse all props from the underlying Monaco Editor component, except language (we force YAML) export type YamlEditorProps = Omit, 'language'> & { - // When true, editor becomes editable and an Apply changes button & validation appear isEdit?: boolean; - onApply?: (parsed: unknown, yaml: string) => void; // callback when user applies valid YAML + onApply?: (parsed: unknown, yaml: string) => void; }; -// Simple wrapper that forwards all props to Monaco Editor, enhanced with edit/apply capability export const YamlEditor = (props: YamlEditorProps) => { const { isDarkTheme } = useTheme(); const { t } = useTranslation(); const { theme, options, value, defaultValue, onChange, isEdit = false, onApply, ...rest } = props; const computedTheme = theme ?? (isDarkTheme ? GITHUB_DARK_DEFAULT : GITHUB_LIGHT_DEFAULT); - // Maintain internal state only in edit mode; otherwise rely on provided value (viewer mode) - const [code, setCode] = useState(value?.toString() ?? defaultValue?.toString() ?? ''); - const [errors, setErrors] = useState([]); - const [attemptedApply, setAttemptedApply] = useState(false); + const [editorContent, setEditorContent] = useState(value?.toString() ?? defaultValue?.toString() ?? ''); + const [validationErrors, setValidationErrors] = useState([]); + const [applyAttempted, setApplyAttempted] = useState(false); - // Keep internal state in sync when value prop changes in non-edit mode useEffect(() => { if (typeof value !== 'undefined') { - setCode(value.toString()); + setEditorContent(value.toString()); } }, [value]); @@ -45,10 +40,10 @@ export const YamlEditor = (props: YamlEditorProps) => { [options, isEdit], ); - const handleInternalChange = useCallback( + const handleEditorChange = useCallback( (val: string | undefined) => { if (isEdit) { - setCode(val ?? ''); + setEditorContent(val ?? ''); } onChange?.(val ?? '', undefined as unknown as monaco.editor.IModelContentChangedEvent); }, @@ -57,32 +52,30 @@ export const YamlEditor = (props: YamlEditorProps) => { const handleApply = useCallback(() => { const run = async () => { - setAttemptedApply(true); + setApplyAttempted(true); try { - const doc = parseDocument(code); + const doc = parseDocument(editorContent); if (doc.errors && doc.errors.length) { - setErrors(doc.errors.map((e) => e.message)); + setValidationErrors(doc.errors.map((e) => e.message)); return; } - setErrors([]); + setValidationErrors([]); const jsObj = doc.toJS(); if (onApply) { - await onApply(jsObj, code); - } else { - console.log('Parsed YAML object:', jsObj); + await onApply(jsObj, editorContent); } } catch (e: unknown) { if (e && typeof e === 'object' && 'message' in e) { - setErrors([String((e as any).message)]); + setValidationErrors([String((e as any).message)]); } else { - setErrors(['Unknown YAML parse error']); + setValidationErrors(['Unknown YAML parse error']); } } }; run(); - }, [code, onApply]); + }, [editorContent, onApply]); - const showErrors = isEdit && attemptedApply && errors.length > 0; + const showValidationErrors = isEdit && applyAttempted && validationErrors.length > 0; return (
@@ -98,18 +91,18 @@ export const YamlEditor = (props: YamlEditorProps) => {
- {showErrors && ( + {showValidationErrors && (
    - {errors.map((err, idx) => ( + {validationErrors.map((err, idx) => (
  • {err}
  • diff --git a/src/lib/api/types/crate/createManagedControlPlane.ts b/src/lib/api/types/crate/createManagedControlPlane.ts index 315a5fa4..eb8f421d 100644 --- a/src/lib/api/types/crate/createManagedControlPlane.ts +++ b/src/lib/api/types/crate/createManagedControlPlane.ts @@ -57,13 +57,13 @@ export interface CreateManagedControlPlaneType { spec: Spec; } -// rename is used to make creation of MCP working properly -const replaceComponentsName: Record = { +const componentNameMap: Record = { 'sap-btp-service-operator': 'btpServiceOperator', 'external-secrets': 'externalSecretsOperator', }; -export const removeComponents = ['cert-manager']; +export const removedComponents = ['cert-manager']; +export const removeComponents = removedComponents; // backward compatibility alias export const CreateManagedControlPlane = ( name: string, @@ -77,7 +77,7 @@ export const CreateManagedControlPlane = ( }, idpPrefix?: string, ): CreateManagedControlPlaneType => { - const selectedComponentsListObject: Components = + const selectedComponents: Components = optional?.componentsList ?.filter( (component) => @@ -85,8 +85,8 @@ export const CreateManagedControlPlane = ( ) .map((component) => ({ ...component, - name: Object.prototype.hasOwnProperty.call(replaceComponentsName, component.name) - ? replaceComponentsName[component.name] + name: Object.prototype.hasOwnProperty.call(componentNameMap, component.name) + ? componentNameMap[component.name] : component.name, })) .reduce((acc, item) => { @@ -97,17 +97,17 @@ export const CreateManagedControlPlane = ( ({ name, isSelected }) => name === 'crossplane' && isSelected, ); - const providersListObject: Provider[] = + const selectedProviders: Provider[] = optional?.componentsList ?.filter(({ name, isSelected }) => name.includes('provider') && isSelected) .map(({ name, selectedVersion }) => ({ name: name, version: selectedVersion, })) ?? []; - const crossplaneWithProvidersListObject = { + const crossplaneWithProviders = { crossplane: { version: crossplaneComponent?.selectedVersion ?? '', - providers: providersListObject, + providers: selectedProviders, }, }; @@ -128,9 +128,9 @@ export const CreateManagedControlPlane = ( spec: { authentication: { enableSystemIdentityProvider: true }, components: { - ...selectedComponentsListObject, + ...selectedComponents, apiServer: { type: 'GardenerDedicated' }, - ...(crossplaneComponent ? crossplaneWithProvidersListObject : {}), + ...(crossplaneComponent ? crossplaneWithProviders : {}), }, authorization: { roleBindings: From dc27602df82308dc739d40f1c4384a5849a80966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Wed, 8 Oct 2025 18:13:26 +0200 Subject: [PATCH 08/37] fix --- .../ControlPlane/ManagedResources.tsx | 34 ++++++++++++------- src/components/YamlEditor/YamlEditor.tsx | 8 ++--- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/components/ControlPlane/ManagedResources.tsx b/src/components/ControlPlane/ManagedResources.tsx index 6adbc106..088e0551 100644 --- a/src/components/ControlPlane/ManagedResources.tsx +++ b/src/components/ControlPlane/ManagedResources.tsx @@ -37,6 +37,14 @@ import { ApiConfigContext } from '../Shared/k8s'; import { ErrorDialog, ErrorDialogHandle } from '../Shared/ErrorMessageBox.tsx'; import { APIError } from '../../lib/api/error.ts'; +interface StatusFilterColumn { + filterValue?: string; + setFilter?: (value?: string) => void; +} +interface CellRow { + original: T; +} + type ResourceRow = { kind: string; name: string; @@ -45,7 +53,7 @@ type ResourceRow = { syncedTransitionTime: string; ready: boolean; readyTransitionTime: string; - item: unknown; + item: ManagedResourceItem; conditionReadyMessage: string; conditionSyncedMessage: string; }; @@ -142,9 +150,9 @@ export function ManagedResources() { accessor: 'synced', hAlign: 'Center', width: 125, - Filter: ({ column }: any) => , - Cell: ({ row }: any) => { - const original = row.original as ResourceRow; + Filter: ({ column }: { column: StatusFilterColumn }) => , + Cell: ({ row }: { row: CellRow }) => { + const { original } = row; return original?.synced != null ? ( , - Cell: ({ row }: any) => { - const original = row.original as ResourceRow; + Filter: ({ column }: { column: StatusFilterColumn }) => , + Cell: ({ row }: { row: CellRow }) => { + const { original } = row; return original?.ready != null ? ( { - const original = row.original as ResourceRow; + Cell: ({ row }: { row: CellRow }) => { + const { original } = row; return original?.item ? ( ) : undefined; @@ -193,9 +201,9 @@ export function ManagedResources() { hAlign: 'Center', width: 60, disableFilters: true, - Cell: ({ row }: any) => { - const original = row.original as ResourceRow; - const item = original?.item as ManagedResourceItem; + Cell: ({ row }: { row: CellRow }) => { + const { original } = row; + const item = original?.item; return item ? : undefined; }, }, @@ -243,7 +251,7 @@ export function ManagedResources() { if (e instanceof APIError && errorDialogRef.current) { errorDialogRef.current.showErrorDialog(`${e.message}: ${JSON.stringify(e.info)}`); } - throw e; // rethrow to outer catch + throw e; } } } catch (e) { diff --git a/src/components/YamlEditor/YamlEditor.tsx b/src/components/YamlEditor/YamlEditor.tsx index 7bb168ec..44044f4f 100644 --- a/src/components/YamlEditor/YamlEditor.tsx +++ b/src/components/YamlEditor/YamlEditor.tsx @@ -29,7 +29,7 @@ export const YamlEditor = (props: YamlEditorProps) => { } }, [value]); - const enforcedOptions = useMemo( + const enforcedOptions: monaco.editor.IStandaloneEditorConstructionOptions = useMemo( () => ({ ...(options as monaco.editor.IStandaloneEditorConstructionOptions), readOnly: isEdit ? false : (options?.readOnly ?? true), @@ -65,8 +65,8 @@ export const YamlEditor = (props: YamlEditorProps) => { await onApply(jsObj, editorContent); } } catch (e: unknown) { - if (e && typeof e === 'object' && 'message' in e) { - setValidationErrors([String((e as any).message)]); + if (e instanceof Error) { + setValidationErrors([e.message]); } else { setValidationErrors(['Unknown YAML parse error']); } @@ -93,7 +93,7 @@ export const YamlEditor = (props: YamlEditorProps) => { {...rest} value={isEdit ? editorContent : value} theme={computedTheme} - options={enforcedOptions as any} + options={enforcedOptions} height="100%" language="yaml" onChange={handleEditorChange} From 3548a2f7ebf01987c8d434f39b4b80a4baab2dec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Thu, 9 Oct 2025 09:56:04 +0200 Subject: [PATCH 09/37] Update YamlSidePanel.tsx --- src/components/Yaml/YamlSidePanel.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/components/Yaml/YamlSidePanel.tsx b/src/components/Yaml/YamlSidePanel.tsx index 5795736d..5d67c873 100644 --- a/src/components/Yaml/YamlSidePanel.tsx +++ b/src/components/Yaml/YamlSidePanel.tsx @@ -70,12 +70,11 @@ export function YamlSidePanel({ resource, filename, onApply, isEdit }: YamlSideP const handleConfirmPatch = useCallback(async () => { if (!onApply || !editedYaml) return; - try { - const result = await onApply(parsedObject, editedYaml); - if (result === true) { - setMode('success'); - } - } catch (_) {} + + const result = await onApply(parsedObject, editedYaml); + if (result === true) { + setMode('success'); + } }, [onApply, editedYaml, parsedObject]); const handleGoBack = () => setMode('edit'); From 2dbea4b7df9efc251e62c91ba9fd6831e40986b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Thu, 9 Oct 2025 10:32:58 +0200 Subject: [PATCH 10/37] Update ManagedResources.tsx --- src/components/ControlPlane/ManagedResources.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ControlPlane/ManagedResources.tsx b/src/components/ControlPlane/ManagedResources.tsx index 088e0551..5d643f64 100644 --- a/src/components/ControlPlane/ManagedResources.tsx +++ b/src/components/ControlPlane/ManagedResources.tsx @@ -192,7 +192,7 @@ export function ManagedResources() { Cell: ({ row }: { row: CellRow }) => { const { original } = row; return original?.item ? ( - + ) : undefined; }, }, From 23dba7743a1c084a3af5d41b3f1718dc4916914d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Thu, 9 Oct 2025 11:33:09 +0200 Subject: [PATCH 11/37] refactor --- .../ControlPlane/ManagedResources.tsx | 82 ++++++------- .../ProviderConfigsActionMenu.tsx | 44 +++++++ .../ControlPlane/ProvidersConfig.tsx | 109 +++++++++++++----- .../types/crossplane/handleResourcePatch.ts | 44 +++++++ 4 files changed, 206 insertions(+), 73 deletions(-) create mode 100644 src/components/ControlPlane/ProviderConfigsActionMenu.tsx create mode 100644 src/lib/api/types/crossplane/handleResourcePatch.ts diff --git a/src/components/ControlPlane/ManagedResources.tsx b/src/components/ControlPlane/ManagedResources.tsx index 5d643f64..44081fa6 100644 --- a/src/components/ControlPlane/ManagedResources.tsx +++ b/src/components/ControlPlane/ManagedResources.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { Fragment, useMemo, useState, useContext, useRef } from 'react'; +import { Fragment, useMemo, useState, useContext, useRef, useCallback } from 'react'; import { AnalyticalTable, AnalyticalTableColumnDefinition, @@ -32,11 +32,13 @@ import { import { useResourcePluralNames } from '../../hooks/useResourcePluralNames'; import { useSplitter } from '../Splitter/SplitterContext.tsx'; import { YamlSidePanel } from '../Yaml/YamlSidePanel.tsx'; -import { fetchApiServerJson } from '../../lib/api/fetch'; + import { ApiConfigContext } from '../Shared/k8s'; import { ErrorDialog, ErrorDialogHandle } from '../Shared/ErrorMessageBox.tsx'; import { APIError } from '../../lib/api/error.ts'; +import { handleResourcePatch } from '../../lib/api/types/crossplane/handleResourcePatch.ts'; + interface StatusFilterColumn { filterValue?: string; setFilter?: (value?: string) => void; @@ -89,46 +91,36 @@ export function ManagedResources() { PatchResourceForForceDeletion(apiVersion, pluralKind, resourceName, namespace), ); - const openEditPanel = (item: ManagedResourceItem) => { - const identityKey = `${item.kind}:${item.metadata.namespace ?? ''}:${item.metadata.name}`; - openInAside( - - await handleResourcePatch(item, parsed)} - /> - , - ); - }; - - const handleResourcePatch = async (item: ManagedResourceItem, parsed: unknown): Promise => { - const resourceName = item?.metadata?.name ?? ''; - const apiVersion = item?.apiVersion ?? ''; - const pluralKind = getPluralKind(item.kind); - const namespace = item?.metadata?.namespace; - - toast.show(t('ManagedResources.patchStarted', { resourceName })); - - try { - const basePath = `/apis/${apiVersion}`; - const path = namespace - ? `${basePath}/namespaces/${namespace}/${pluralKind}/${resourceName}` - : `${basePath}/${pluralKind}/${resourceName}`; + const openDeleteDialog = useCallback((item: ManagedResourceItem) => { + setPendingDeleteItem(item); + }, []); - await fetchApiServerJson(path, apiConfig, undefined, 'PATCH', JSON.stringify(parsed)); - toast.show(t('ManagedResources.patchSuccess', { resourceName })); - return true; - } catch (e) { - toast.show(t('ManagedResources.patchError', { resourceName })); - if (e instanceof APIError && errorDialogRef.current) { - errorDialogRef.current.showErrorDialog(`${e.message}: ${JSON.stringify(e.info)}`); - } - console.error('Failed to patch resource', e); - return false; - } - }; + const openEditPanel = useCallback( + (item: ManagedResourceItem) => { + const identityKey = `${item.kind}:${item.metadata.namespace ?? ''}:${item.metadata.name}`; + openInAside( + + + await handleResourcePatch({ + item, + parsed, + getPluralKind, + apiConfig, + t, + toast, + errorDialogRef, + }) + } + /> + , + ); + }, + [openInAside, getPluralKind, apiConfig, t, toast, errorDialogRef], + ); const columns = useMemo( () => @@ -208,7 +200,7 @@ export function ManagedResources() { }, }, ] as AnalyticalTableColumnDefinition[], - [t], + [t, openEditPanel, openDeleteDialog], ); const rows: ResourceRow[] = @@ -234,10 +226,6 @@ export function ManagedResources() { }), ) ?? []; - const openDeleteDialog = (item: ManagedResourceItem) => { - setPendingDeleteItem(item); - }; - const handleDeletionConfirmed = async (item: ManagedResourceItem, force: boolean) => { toast.show(t('ManagedResources.deleteStarted', { resourceName: item.metadata.name })); @@ -251,7 +239,7 @@ export function ManagedResources() { if (e instanceof APIError && errorDialogRef.current) { errorDialogRef.current.showErrorDialog(`${e.message}: ${JSON.stringify(e.info)}`); } - throw e; + // already handled } } } catch (e) { diff --git a/src/components/ControlPlane/ProviderConfigsActionMenu.tsx b/src/components/ControlPlane/ProviderConfigsActionMenu.tsx new file mode 100644 index 00000000..2e29a0ab --- /dev/null +++ b/src/components/ControlPlane/ProviderConfigsActionMenu.tsx @@ -0,0 +1,44 @@ +import { FC, useRef, useState } from 'react'; +import { Button, Menu, MenuItem, MenuDomRef } from '@ui5/webcomponents-react'; +import { useTranslation } from 'react-i18next'; +import type { ProviderConfigItem } from '../../lib/shared/types'; +import type { ButtonClickEventDetail } from '@ui5/webcomponents/dist/Button.js'; +import type { Ui5CustomEvent, ButtonDomRef } from '@ui5/webcomponents-react'; + +interface ProviderConfigsRowActionsMenuProps { + item: ProviderConfigItem; + onEdit: (item: ProviderConfigItem) => void; +} + +export const ProviderConfigsRowActionsMenu: FC = ({ item, onEdit }) => { + const { t } = useTranslation(); + const popoverRef = useRef(null); + const [open, setOpen] = useState(false); + + const handleOpenerClick = (e: Ui5CustomEvent) => { + if (popoverRef.current && e.currentTarget) { + popoverRef.current.opener = e.currentTarget as unknown as HTMLElement; + setOpen((prev) => !prev); + } + }; + + return ( + <> +
diff --git a/src/components/Yaml/YamlPanel.module.css b/src/components/Wizards/CreateManagedControlPlane/YamlSummarize.module.css similarity index 100% rename from src/components/Yaml/YamlPanel.module.css rename to src/components/Wizards/CreateManagedControlPlane/YamlSummarize.module.css diff --git a/src/components/Yaml/YamlPanel.tsx b/src/components/Wizards/CreateManagedControlPlane/YamlSummarize.tsx similarity index 76% rename from src/components/Yaml/YamlPanel.tsx rename to src/components/Wizards/CreateManagedControlPlane/YamlSummarize.tsx index b72855d8..e95344be 100644 --- a/src/components/Yaml/YamlPanel.tsx +++ b/src/components/Wizards/CreateManagedControlPlane/YamlSummarize.tsx @@ -1,16 +1,17 @@ import { FC } from 'react'; import { Button, FlexBox } from '@ui5/webcomponents-react'; -import styles from './YamlPanel.module.css'; -import { useCopyToClipboard } from '../../hooks/useCopyToClipboard.ts'; +import styles from './YamlSummarize.module.css'; import { useTranslation } from 'react-i18next'; -import { SHOW_DOWNLOAD_BUTTON } from './YamlSidePanel.tsx'; -import { YamlViewer } from './YamlViewer.tsx'; +import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard.ts'; +import { SHOW_DOWNLOAD_BUTTON } from '../../Yaml/YamlSidePanel.tsx'; +import { YamlViewer } from '../../Yaml/YamlViewer.tsx'; + type YamlPanelProps = { yamlString: string; filename: string; }; -const YamlPanel: FC = ({ yamlString, filename }) => { +const YamlSummarize: FC = ({ yamlString, filename }) => { const { t } = useTranslation(); const { copyToClipboard } = useCopyToClipboard(); const downloadYaml = () => { @@ -37,9 +38,9 @@ const YamlPanel: FC = ({ yamlString, filename }) => { )} - +
); }; -export default YamlPanel; +export default YamlSummarize; From ac411c55629af296956c17fe3b83600c2a0c70d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Thu, 23 Oct 2025 14:58:39 +0200 Subject: [PATCH 25/37] fix --- src/components/YamlEditor/YamlEditor.tsx | 25 ++++++++++++++++++++---- src/lib/monaco.ts | 17 ++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/components/YamlEditor/YamlEditor.tsx b/src/components/YamlEditor/YamlEditor.tsx index eafa5a7a..874e5e2b 100644 --- a/src/components/YamlEditor/YamlEditor.tsx +++ b/src/components/YamlEditor/YamlEditor.tsx @@ -34,14 +34,31 @@ export const YamlEditor = (props: YamlEditorProps) => { ...(options as monaco.editor.IStandaloneEditorConstructionOptions), readOnly: isEdit ? false : (options?.readOnly ?? true), minimap: { enabled: false }, - wordWrap: 'on' as const, scrollBeyondLastLine: false, + tabSize: 2, + insertSpaces: true, + detectIndentation: false, + wordWrap: 'on', + folding: true, + foldingStrategy: 'indentation', + quickSuggestions: { + other: true, + comments: false, + strings: true, + }, + suggestOnTriggerCharacters: true, + glyphMargin: true, + formatOnPaste: true, + formatOnType: true, + fontSize: 13, + lineHeight: 20, + renderWhitespace: 'boundary', }), [options, isEdit], ); const handleEditorChange = useCallback( - (val: string | undefined, event?: monaco.editor.IModelContentChangedEvent) => { + (val: string | undefined, event: monaco.editor.IModelContentChangedEvent) => { if (isEdit) { setEditorContent(val ?? ''); } @@ -100,8 +117,8 @@ export const YamlEditor = (props: YamlEditorProps) => { /> {showValidationErrors && ( - -
    + +
      {validationErrors.map((err, idx) => (
    • {err} diff --git a/src/lib/monaco.ts b/src/lib/monaco.ts index e17d5939..0bfbf1a7 100644 --- a/src/lib/monaco.ts +++ b/src/lib/monaco.ts @@ -7,6 +7,7 @@ import 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution.js'; import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; import YamlWorker from 'monaco-yaml/yaml.worker?worker'; +import { configureMonacoYaml } from 'monaco-yaml'; // Use ESM monaco to avoid loading AMD loader from CDN loader.config({ monaco }); @@ -88,4 +89,20 @@ export const configureMonaco = () => { rules: [], colors: GITHUB_DARK_EDITOR_COLORS, }); + + // Configure monaco-yaml for Kubernetes resources + configureMonacoYaml(monaco, { + enableSchemaRequest: true, + hover: true, + completion: true, + validate: true, + format: true, + schemas: [ + { + // Official Kubernetes schema + uri: 'https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.29.0-standalone-strict/all.json', + fileMatch: ['*'], // Apply to all YAML files + }, + ], + }); }; From 7eaa038e42f5979b22e3f2ebf02df05693cd58aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Fri, 24 Oct 2025 15:15:10 +0200 Subject: [PATCH 26/37] refactor --- public/locales/en.json | 7 +++-- .../ControlPlane/ManagedResources.tsx | 17 ++++++++++- .../ControlPlane/ProvidersConfig.tsx | 19 +++++++++++- src/components/Yaml/YamlSidePanel.tsx | 7 +++-- src/components/Yaml/YamlViewButton.tsx | 13 ++++---- .../YamlEditor/YamlEditor.module.css | 30 +++++++++++++++++++ src/components/YamlEditor/YamlEditor.tsx | 21 +++++++------ 7 files changed, 90 insertions(+), 24 deletions(-) create mode 100644 src/components/YamlEditor/YamlEditor.module.css diff --git a/public/locales/en.json b/public/locales/en.json index 9142a1f4..56bef27b 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -417,19 +417,20 @@ "back": "Back", "cancel": "Cancel", "update": "Update", - "applyChanges": "Apply changes" + "applyChanges": "Apply changes", + "edit": "Edit" }, "yaml": { "YAML": "File", "showOnlyImportant": "Show only important fields", "panelTitle": "YAML", - "editorTitle": "YAML Editor", "applySuccess2": "The Managed Control Plane will reconcile this resource shortly.", "applySuccess": "Update submitted ", "diffConfirmTitle": "Review changes", "diffConfirmMessage": "Are you sure that you want to apply these changes?", "diffNo": "No, go back", - "diffYes": "Yes" + "diffYes": "Yes", + "validationErrors": "Validation errors" }, "createMCP": { "dialogTitle": "Create Managed Control Plane", diff --git a/src/components/ControlPlane/ManagedResources.tsx b/src/components/ControlPlane/ManagedResources.tsx index 80a26ddc..808bfd94 100644 --- a/src/components/ControlPlane/ManagedResources.tsx +++ b/src/components/ControlPlane/ManagedResources.tsx @@ -4,6 +4,7 @@ import { AnalyticalTable, AnalyticalTableColumnDefinition, AnalyticalTableScaleWidthMode, + Button, Panel, Title, Toolbar, @@ -171,7 +172,21 @@ export function ManagedResources() { Cell: ({ row }: { row: CellRow }) => { const { original } = row; return original?.item ? ( - + { + openEditPanel(original?.item); + }} + > + {t('buttons.edit')} + + } + /> ) : undefined; }, }, diff --git a/src/components/ControlPlane/ProvidersConfig.tsx b/src/components/ControlPlane/ProvidersConfig.tsx index 524eaaba..6b70b220 100644 --- a/src/components/ControlPlane/ProvidersConfig.tsx +++ b/src/components/ControlPlane/ProvidersConfig.tsx @@ -3,6 +3,7 @@ import { AnalyticalTable, AnalyticalTableColumnDefinition, AnalyticalTableScaleWidthMode, + Button, Panel, Title, Toolbar, @@ -106,7 +107,23 @@ export function ProvidersConfig() { disableFilters: true, Cell: ({ row }: { row: CellRow }) => { const item = row.original?.resource; - return item ? : undefined; + return item ? ( + { + openEditPanel(item); + }} + > + {t('buttons.edit')} + + } + /> + ) : undefined; }, }, { diff --git a/src/components/Yaml/YamlSidePanel.tsx b/src/components/Yaml/YamlSidePanel.tsx index 5d67c873..10604d48 100644 --- a/src/components/Yaml/YamlSidePanel.tsx +++ b/src/components/Yaml/YamlSidePanel.tsx @@ -13,7 +13,7 @@ import IllustrationMessageType from '@ui5/webcomponents-fiori/dist/types/Illustr import { useTranslation } from 'react-i18next'; import { YamlViewer } from './YamlViewer.tsx'; import { useSplitter } from '../Splitter/SplitterContext.tsx'; -import { useMemo, useState, useCallback } from 'react'; +import { useMemo, useState, useCallback, JSX } from 'react'; import { stringify } from 'yaml'; import { convertToResourceConfig } from '../../utils/convertToResourceConfig.ts'; import { removeManagedFieldsAndFilterData, Resource } from '../../utils/removeManagedFieldsAndFilterData.ts'; @@ -29,8 +29,9 @@ export interface YamlSidePanelProps { filename: string; onApply?: (parsed: unknown, yaml: string) => void | boolean | Promise; isEdit?: boolean; + toolbarContent?: JSX.Element; } -export function YamlSidePanel({ resource, filename, onApply, isEdit }: YamlSidePanelProps) { +export function YamlSidePanel({ resource, filename, onApply, isEdit, toolbarContent }: YamlSidePanelProps) { const [showOnlyImportantData, setShowOnlyImportantData] = useState(true); const [mode, setMode] = useState<'edit' | 'review' | 'success'>('edit'); const [editedYaml, setEditedYaml] = useState(null); @@ -85,7 +86,7 @@ export function YamlSidePanel({ resource, filename, onApply, isEdit }: YamlSideP fixed header={ - {t('yaml.panelTitle')} + {toolbarContent ?? {t('yaml.panelTitle')}} {!isEdit && ( diff --git a/src/components/Yaml/YamlViewButton.tsx b/src/components/Yaml/YamlViewButton.tsx index 3d0e4edb..ad086c11 100644 --- a/src/components/Yaml/YamlViewButton.tsx +++ b/src/components/Yaml/YamlViewButton.tsx @@ -7,10 +7,12 @@ import { YamlIcon } from './YamlIcon.tsx'; import { useSplitter } from '../Splitter/SplitterContext.tsx'; import { YamlSidePanel } from './YamlSidePanel.tsx'; import { YamlSidePanelWithLoader } from './YamlSidePanelWithLoader.tsx'; +import { JSX } from 'react'; export interface YamlViewButtonResourceProps { variant: 'resource'; resource: Resource; + toolbarContent?: JSX.Element; } export interface YamlViewButtonLoaderProps { variant: 'loader'; @@ -20,26 +22,27 @@ export interface YamlViewButtonLoaderProps { } export type YamlViewButtonProps = YamlViewButtonResourceProps | YamlViewButtonLoaderProps; -export function YamlViewButton(props: YamlViewButtonProps) { +export function YamlViewButton({ variant, ...props }: YamlViewButtonProps) { const { t } = useTranslation(); const { openInAside } = useSplitter(); const openSplitterSidePanel = () => { - switch (props.variant) { + switch (variant) { case 'resource': { - const { resource } = props; + const { resource, toolbarContent } = props as YamlViewButtonResourceProps; openInAside( , ); break; } case 'loader': { - const { workspaceName, resourceType, resourceName } = props; + const { workspaceName, resourceType, resourceName } = props as YamlViewButtonLoaderProps; openInAside( , 'language'> & { isEdit?: boolean; @@ -43,7 +44,7 @@ export const YamlEditor = (props: YamlEditorProps) => { foldingStrategy: 'indentation', quickSuggestions: { other: true, - comments: false, + comments: true, strings: true, }, suggestOnTriggerCharacters: true, @@ -95,17 +96,15 @@ export const YamlEditor = (props: YamlEditorProps) => { const showValidationErrors = isEdit && applyAttempted && validationErrors.length > 0; return ( -
      +
      {isEdit && ( - {t('yaml.editorTitle')} - - )} -
      +
      { />
      {showValidationErrors && ( - -
        + +
          {validationErrors.map((err, idx) => ( -
        • +
        • {err}
        • ))} From 53c0b3bcb7c90a3443333eb0a9dd46699b38bcb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Fri, 24 Oct 2025 15:23:00 +0200 Subject: [PATCH 27/37] Update YamlEditor.module.css --- src/components/YamlEditor/YamlEditor.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/YamlEditor/YamlEditor.module.css b/src/components/YamlEditor/YamlEditor.module.css index c170403a..88111bb6 100644 --- a/src/components/YamlEditor/YamlEditor.module.css +++ b/src/components/YamlEditor/YamlEditor.module.css @@ -6,7 +6,7 @@ } .applyButton { - margin-bottom: 0.25rem; + margin-bottom: 0.5rem; } .editorWrapper { From 0391900d0dfc0109c112c09e61750be42f2ac192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Fri, 24 Oct 2025 16:19:05 +0200 Subject: [PATCH 28/37] refactor --- public/locales/en.json | 3 +- src/components/ControlPlane/ActionsMenu.tsx | 11 ++++++- .../ControlPlane/GitRepositories.tsx | 32 ++++++++++++++++--- .../ControlPlane/Kustomizations.tsx | 32 ++++++++++++++++--- .../ControlPlane/ManagedResources.tsx | 9 ++++++ 5 files changed, 77 insertions(+), 10 deletions(-) diff --git a/public/locales/en.json b/public/locales/en.json index 56bef27b..50164e06 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -430,7 +430,8 @@ "diffConfirmMessage": "Are you sure that you want to apply these changes?", "diffNo": "No, go back", "diffYes": "Yes", - "validationErrors": "Validation errors" + "validationErrors": "Validation errors", + "fluxManaged": "This resource is managed by Flux and cannot be edited here" }, "createMCP": { "dialogTitle": "Create Managed Control Plane", diff --git a/src/components/ControlPlane/ActionsMenu.tsx b/src/components/ControlPlane/ActionsMenu.tsx index 7a911df0..06bc7418 100644 --- a/src/components/ControlPlane/ActionsMenu.tsx +++ b/src/components/ControlPlane/ActionsMenu.tsx @@ -2,6 +2,7 @@ import { useRef, useState } from 'react'; import { Button, Menu, MenuItem, MenuDomRef } from '@ui5/webcomponents-react'; import type { ButtonClickEventDetail } from '@ui5/webcomponents/dist/Button.js'; import type { Ui5CustomEvent, ButtonDomRef } from '@ui5/webcomponents-react'; +import tooltipCell from '../Shared/TooltipCell.tsx'; export type ActionItem = { key: string; @@ -9,6 +10,7 @@ export type ActionItem = { icon?: string; disabled?: boolean; onClick: (item: T) => void; + tooltip?: string; }; export type ActionsMenuProps = { @@ -45,7 +47,14 @@ export function ActionsMenu({ item, actions, buttonIcon = 'overflow' }: Actio }} > {actions.map((a) => ( - + ))} diff --git a/src/components/ControlPlane/GitRepositories.tsx b/src/components/ControlPlane/GitRepositories.tsx index e12ec596..a8212189 100644 --- a/src/components/ControlPlane/GitRepositories.tsx +++ b/src/components/ControlPlane/GitRepositories.tsx @@ -1,5 +1,12 @@ import ConfiguredAnalyticstable from '../Shared/ConfiguredAnalyticsTable.tsx'; -import { AnalyticalTableColumnDefinition, Panel, Title, Toolbar, ToolbarSpacer } from '@ui5/webcomponents-react'; +import { + AnalyticalTableColumnDefinition, + Panel, + Title, + Toolbar, + ToolbarSpacer, + Button, +} from '@ui5/webcomponents-react'; import IllustratedError from '../Shared/IllustratedError.tsx'; import { useApiResource } from '../../lib/api/useApiResource'; import { FluxRequest } from '../../lib/api/types/flux/listGitRepo'; @@ -100,9 +107,26 @@ export function GitRepositories() { width: 75, accessor: 'yaml', disableFilters: true, - Cell: ({ row }: { row: CellRow }) => ( - - ), + Cell: ({ row }: { row: CellRow }) => { + const item = row.original?.item; + return item ? ( + { + openEditPanel(item); + }} + > + {t('buttons.edit')} + + } + /> + ) : undefined; + }, }, { Header: t('ManagedResources.actionColumnHeader'), diff --git a/src/components/ControlPlane/Kustomizations.tsx b/src/components/ControlPlane/Kustomizations.tsx index 0cc69768..45082dd4 100644 --- a/src/components/ControlPlane/Kustomizations.tsx +++ b/src/components/ControlPlane/Kustomizations.tsx @@ -1,5 +1,12 @@ import ConfiguredAnalyticstable from '../Shared/ConfiguredAnalyticsTable.tsx'; -import { AnalyticalTableColumnDefinition, Panel, Title, Toolbar, ToolbarSpacer } from '@ui5/webcomponents-react'; +import { + AnalyticalTableColumnDefinition, + Panel, + Title, + Toolbar, + ToolbarSpacer, + Button, +} from '@ui5/webcomponents-react'; import IllustratedError from '../Shared/IllustratedError.tsx'; import { useApiResource } from '../../lib/api/useApiResource'; import { FluxKustomization } from '../../lib/api/types/flux/listKustomization'; @@ -95,9 +102,26 @@ export function Kustomizations() { width: 75, accessor: 'yaml', disableFilters: true, - Cell: ({ row }: { row: CellRow }) => ( - - ), + Cell: ({ row }: { row: CellRow }) => { + const item = row.original?.item; + return item ? ( + { + openEditPanel(item); + }} + > + {t('buttons.edit')} + + } + /> + ) : undefined; + }, }, { Header: t('ManagedResources.actionColumnHeader'), diff --git a/src/components/ControlPlane/ManagedResources.tsx b/src/components/ControlPlane/ManagedResources.tsx index 808bfd94..98cd6523 100644 --- a/src/components/ControlPlane/ManagedResources.tsx +++ b/src/components/ControlPlane/ManagedResources.tsx @@ -171,6 +171,12 @@ export function ManagedResources() { disableFilters: true, Cell: ({ row }: { row: CellRow }) => { const { original } = row; + // Flux-managed check for disabling Edit + const fluxLabelValue = ( + original?.item?.metadata?.labels as unknown as Record | undefined + )?.['kustomize.toolkit.fluxcd.io/name']; + const isFluxManaged = + typeof fluxLabelValue === 'string' ? fluxLabelValue.trim() !== '' : fluxLabelValue != null; return original?.item ? ( { openEditPanel(original?.item); }} @@ -214,6 +222,7 @@ export function ManagedResources() { icon: 'edit', disabled: isFluxManaged, onClick: openEditPanel, + tooltip: isFluxManaged ? t('yaml.fluxManaged') : undefined, }, { key: 'delete', From 841b0247462ee98950053c7430462375071f3fb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Mon, 27 Oct 2025 09:48:45 +0100 Subject: [PATCH 29/37] Update YamlSidePanel.module.css --- src/components/Yaml/YamlSidePanel.module.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Yaml/YamlSidePanel.module.css b/src/components/Yaml/YamlSidePanel.module.css index 49ec6095..595e94bc 100644 --- a/src/components/Yaml/YamlSidePanel.module.css +++ b/src/components/Yaml/YamlSidePanel.module.css @@ -26,10 +26,10 @@ position: sticky; top: 0; height: 6rem; - padding: 1rem; - padding-bottom: 1.5rem; + padding: 1rem 1rem 1.5rem; z-index: 1; background: var(--sapBackgroundColor); + border-radius: 1rem; } .stickyHeaderInner { From 2a8f6f8a5e953d69b5b388b1f0f273402209a125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Mon, 27 Oct 2025 11:49:08 +0100 Subject: [PATCH 30/37] refactor --- .../ControlPlane/GitRepositories.tsx | 24 ++++++++------ .../ControlPlane/Kustomizations.tsx | 24 ++++++++------ .../ControlPlane/ManagedResources.tsx | 31 +++++++++++-------- .../ControlPlane/ProvidersConfig.tsx | 24 ++++++++------ src/lib/api/types/crate/controlPlanes.ts | 17 +++++++++- src/spaces/mcp/auth/AuthContextMcp.tsx | 15 +++++++-- src/spaces/mcp/pages/McpPage.tsx | 2 +- 7 files changed, 90 insertions(+), 47 deletions(-) diff --git a/src/components/ControlPlane/GitRepositories.tsx b/src/components/ControlPlane/GitRepositories.tsx index a8212189..fcf59474 100644 --- a/src/components/ControlPlane/GitRepositories.tsx +++ b/src/components/ControlPlane/GitRepositories.tsx @@ -24,6 +24,7 @@ import { useHandleResourcePatch } from '../../lib/api/types/crossplane/useHandle import { ErrorDialog, ErrorDialogHandle } from '../Shared/ErrorMessageBox.tsx'; import type { GitReposResponse } from '../../lib/api/types/flux/listGitRepo'; import { ActionsMenu, type ActionItem } from './ActionsMenu'; +import { useAuthMcp } from '../../spaces/mcp/auth/AuthContextMcp.tsx'; export type GitRepoItem = GitReposResponse['items'][0] & { apiVersion?: string; @@ -50,7 +51,7 @@ export function GitRepositories() { readyMessage: string; revision?: string; }; - + const { hasMCPAdminRights } = useAuthMcp(); const openEditPanel = useCallback( (item: GitRepoItem) => { const identityKey = `${item.kind}:${item.metadata.namespace ?? ''}:${item.metadata.name}`; @@ -114,15 +115,17 @@ export function GitRepositories() { variant="resource" resource={item as unknown as Resource} toolbarContent={ - + hasMCPAdminRights ? ( + + ) : undefined } /> ) : undefined; @@ -143,6 +146,7 @@ export function GitRepositories() { text: t('ManagedResources.editAction', 'Edit'), icon: 'edit', onClick: openEditPanel, + disabled: !hasMCPAdminRights, }, ]; return ; diff --git a/src/components/ControlPlane/Kustomizations.tsx b/src/components/ControlPlane/Kustomizations.tsx index 45082dd4..81c6ad89 100644 --- a/src/components/ControlPlane/Kustomizations.tsx +++ b/src/components/ControlPlane/Kustomizations.tsx @@ -24,6 +24,7 @@ import { useHandleResourcePatch } from '../../lib/api/types/crossplane/useHandle import { ErrorDialog, ErrorDialogHandle } from '../Shared/ErrorMessageBox.tsx'; import type { KustomizationsResponse } from '../../lib/api/types/flux/listKustomization'; import { ActionsMenu, type ActionItem } from './ActionsMenu'; +import { useAuthMcp } from '../../spaces/mcp/auth/AuthContextMcp.tsx'; export type KustomizationItem = KustomizationsResponse['items'][0] & { apiVersion?: string; @@ -66,7 +67,7 @@ export function Kustomizations() { }, [openInAside, handlePatch], ); - + const { hasMCPAdminRights } = useAuthMcp(); const columns = useMemo( () => [ @@ -109,15 +110,17 @@ export function Kustomizations() { variant="resource" resource={item as unknown as Resource} toolbarContent={ - + hasMCPAdminRights ? ( + + ) : undefined } /> ) : undefined; @@ -138,6 +141,7 @@ export function Kustomizations() { text: t('ManagedResources.editAction', 'Edit'), icon: 'edit', onClick: openEditPanel, + disabled: !hasMCPAdminRights, }, ]; return ; diff --git a/src/components/ControlPlane/ManagedResources.tsx b/src/components/ControlPlane/ManagedResources.tsx index 98cd6523..6ba862c8 100644 --- a/src/components/ControlPlane/ManagedResources.tsx +++ b/src/components/ControlPlane/ManagedResources.tsx @@ -36,6 +36,7 @@ import { YamlSidePanel } from '../Yaml/YamlSidePanel.tsx'; import { ErrorDialog, ErrorDialogHandle } from '../Shared/ErrorMessageBox.tsx'; import { APIError } from '../../lib/api/error.ts'; import { useHandleResourcePatch } from '../../lib/api/types/crossplane/useHandleResourcePatch.ts'; +import { useAuthMcp } from '../../spaces/mcp/auth/AuthContextMcp.tsx'; interface StatusFilterColumn { filterValue?: string; @@ -109,7 +110,7 @@ export function ManagedResources() { }, [openInAside, handlePatch], ); - + const { hasMCPAdminRights } = useAuthMcp(); const columns = useMemo( () => [ @@ -182,17 +183,19 @@ export function ManagedResources() { variant="resource" resource={original.item as unknown as Resource} toolbarContent={ - + hasMCPAdminRights ? ( + + ) : undefined } /> ) : undefined; @@ -222,13 +225,15 @@ export function ManagedResources() { icon: 'edit', disabled: isFluxManaged, onClick: openEditPanel, - tooltip: isFluxManaged ? t('yaml.fluxManaged') : undefined, + tooltip: isFluxManaged && hasMCPAdminRights ? t('yaml.fluxManaged') : undefined, }, + { key: 'delete', text: t('ManagedResources.deleteAction'), icon: 'delete', onClick: openDeleteDialog, + disabled: !hasMCPAdminRights, }, ]; diff --git a/src/components/ControlPlane/ProvidersConfig.tsx b/src/components/ControlPlane/ProvidersConfig.tsx index 6b70b220..0accb19a 100644 --- a/src/components/ControlPlane/ProvidersConfig.tsx +++ b/src/components/ControlPlane/ProvidersConfig.tsx @@ -24,6 +24,7 @@ import { useSplitter } from '../Splitter/SplitterContext.tsx'; import { YamlSidePanel } from '../Yaml/YamlSidePanel.tsx'; import { useHandleResourcePatch } from '../../lib/api/types/crossplane/useHandleResourcePatch.ts'; import { ErrorDialog, ErrorDialogHandle } from '../Shared/ErrorMessageBox.tsx'; +import { useAuthMcp } from '../../spaces/mcp/auth/AuthContextMcp.tsx'; type Rows = { parent: string; @@ -79,7 +80,7 @@ export function ProvidersConfig() { }, [openInAside, handlePatch], ); - + const { hasMCPAdminRights } = useAuthMcp(); const columns = useMemo( () => [ @@ -112,15 +113,17 @@ export function ProvidersConfig() { variant="resource" resource={item as unknown as Resource} toolbarContent={ - + hasMCPAdminRights ? ( + + ) : undefined } /> ) : undefined; @@ -141,6 +144,7 @@ export function ProvidersConfig() { text: t('ManagedResources.editAction', 'Edit'), icon: 'edit', onClick: openEditPanel, + disabled: !hasMCPAdminRights, }, ]; return ; diff --git a/src/lib/api/types/crate/controlPlanes.ts b/src/lib/api/types/crate/controlPlanes.ts index 1176614e..7ce612f9 100644 --- a/src/lib/api/types/crate/controlPlanes.ts +++ b/src/lib/api/types/crate/controlPlanes.ts @@ -12,6 +12,20 @@ export interface Metadata { }; } +export interface Subject { + kind: string; + name: string; +} + +export interface RoleBinding { + role: string; + subjects: Subject[]; +} + +export interface Authorization { + roleBindings: RoleBinding[]; +} + export interface ControlPlaneType { metadata: Metadata; spec: @@ -19,6 +33,7 @@ export interface ControlPlaneType { authentication: { enableSystemIdentityProvider?: boolean; }; + authorization?: Authorization; components: ControlPlaneComponentsType; } | undefined; @@ -85,6 +100,6 @@ export const ControlPlane = ( ): Resource => { return { path: `/apis/core.openmcp.cloud/v1alpha1/namespaces/project-${projectName}--ws-${workspaceName}/managedcontrolplanes/${controlPlaneName}`, - jq: '{ spec: .spec | {components}, metadata: .metadata | {name, namespace, creationTimestamp, annotations}, status: { conditions: [.status.conditions[] | {type: .type, status: .status, message: .message, reason: .reason, lastTransitionTime: .lastTransitionTime}], access: .status.components.authentication.access, status: .status.status }}', + jq: '{ spec: .spec | {components, authorization}, metadata: .metadata | {name, namespace, creationTimestamp, annotations}, status: { conditions: [.status.conditions[] | {type: .type, status: .status, message: .message, reason: .reason, lastTransitionTime: .lastTransitionTime}], access: .status.components.authentication.access, status: .status.status }}', }; }; diff --git a/src/spaces/mcp/auth/AuthContextMcp.tsx b/src/spaces/mcp/auth/AuthContextMcp.tsx index e728f8a2..e2bf11c2 100644 --- a/src/spaces/mcp/auth/AuthContextMcp.tsx +++ b/src/spaces/mcp/auth/AuthContextMcp.tsx @@ -2,17 +2,26 @@ import { createContext, useState, useEffect, ReactNode, use } from 'react'; import { MeResponseSchema } from './auth.schemas'; import { AUTH_FLOW_SESSION_KEY } from '../../../common/auth/AuthCallbackHandler.tsx'; import { getRedirectSuffix } from '../../../common/auth/getRedirectSuffix.ts'; +import { RoleBinding } from '../../../lib/api/types/crate/controlPlanes.ts'; +import { useAuthOnboarding } from '../../onboarding/auth/AuthContextOnboarding.tsx'; interface AuthContextMcpType { isLoading: boolean; isAuthenticated: boolean; error: Error | null; login: () => void; + hasMCPAdminRights: boolean; } const AuthContextMcp = createContext(null); -export function AuthProviderMcp({ children }: { children: ReactNode }) { +export function AuthProviderMcp({ children, mcpUsers = [] }: { children: ReactNode; mcpUsers?: RoleBinding[] }) { + const auth = useAuthOnboarding(); + const userEmail = auth.user?.email; + + const matchingRoleBinding = mcpUsers.find((roleBinding) => roleBinding.subjects[0]?.name?.includes(userEmail ?? '')); + const hasMCPAdminRights = matchingRoleBinding?.role === 'admin'; + const [isAuthenticated, setIsAuthenticated] = useState(false); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -59,7 +68,9 @@ export function AuthProviderMcp({ children }: { children: ReactNode }) { window.location.replace(`/api/auth/mcp/login?redirectTo=${encodeURIComponent(getRedirectSuffix())}`); }; - return {children}; + return ( + {children} + ); } export const useAuthMcp = () => { diff --git a/src/spaces/mcp/pages/McpPage.tsx b/src/spaces/mcp/pages/McpPage.tsx index 9a82d2b2..9a2f0d55 100644 --- a/src/spaces/mcp/pages/McpPage.tsx +++ b/src/spaces/mcp/pages/McpPage.tsx @@ -93,7 +93,7 @@ export default function McpPage() { name: controlPlaneName, }} > - + Date: Mon, 27 Oct 2025 14:28:17 +0100 Subject: [PATCH 31/37] Update ActionsMenu.tsx --- src/components/ControlPlane/ActionsMenu.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/ControlPlane/ActionsMenu.tsx b/src/components/ControlPlane/ActionsMenu.tsx index 06bc7418..a9beff20 100644 --- a/src/components/ControlPlane/ActionsMenu.tsx +++ b/src/components/ControlPlane/ActionsMenu.tsx @@ -2,7 +2,6 @@ import { useRef, useState } from 'react'; import { Button, Menu, MenuItem, MenuDomRef } from '@ui5/webcomponents-react'; import type { ButtonClickEventDetail } from '@ui5/webcomponents/dist/Button.js'; import type { Ui5CustomEvent, ButtonDomRef } from '@ui5/webcomponents-react'; -import tooltipCell from '../Shared/TooltipCell.tsx'; export type ActionItem = { key: string; From 0a94ecaa19818c8d98e321ab3f0f5d3fab6740ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Mon, 27 Oct 2025 14:32:29 +0100 Subject: [PATCH 32/37] fix --- src/components/ControlPlane/ManagedResources.tsx | 2 -- src/lib/monaco.ts | 4 +--- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/components/ControlPlane/ManagedResources.tsx b/src/components/ControlPlane/ManagedResources.tsx index 6ba862c8..45f527f8 100644 --- a/src/components/ControlPlane/ManagedResources.tsx +++ b/src/components/ControlPlane/ManagedResources.tsx @@ -172,7 +172,6 @@ export function ManagedResources() { disableFilters: true, Cell: ({ row }: { row: CellRow }) => { const { original } = row; - // Flux-managed check for disabling Edit const fluxLabelValue = ( original?.item?.metadata?.labels as unknown as Record | undefined )?.['kustomize.toolkit.fluxcd.io/name']; @@ -211,7 +210,6 @@ export function ManagedResources() { const item = original?.item; if (!item) return undefined; - // Flux-managed check for disabling Edit const fluxLabelValue = (item?.metadata?.labels as unknown as Record | undefined)?.[ 'kustomize.toolkit.fluxcd.io/name' ]; diff --git a/src/lib/monaco.ts b/src/lib/monaco.ts index 0bfbf1a7..2d7839a5 100644 --- a/src/lib/monaco.ts +++ b/src/lib/monaco.ts @@ -90,7 +90,6 @@ export const configureMonaco = () => { colors: GITHUB_DARK_EDITOR_COLORS, }); - // Configure monaco-yaml for Kubernetes resources configureMonacoYaml(monaco, { enableSchemaRequest: true, hover: true, @@ -99,9 +98,8 @@ export const configureMonaco = () => { format: true, schemas: [ { - // Official Kubernetes schema uri: 'https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.29.0-standalone-strict/all.json', - fileMatch: ['*'], // Apply to all YAML files + fileMatch: ['*'], }, ], }); From b55f44fdcf668d57fa1af33f0f6913f00ec6dfd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Mon, 27 Oct 2025 15:43:52 +0100 Subject: [PATCH 33/37] Update monaco.ts --- src/lib/monaco.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/lib/monaco.ts b/src/lib/monaco.ts index 2d7839a5..0febd19a 100644 --- a/src/lib/monaco.ts +++ b/src/lib/monaco.ts @@ -91,16 +91,9 @@ export const configureMonaco = () => { }); configureMonacoYaml(monaco, { - enableSchemaRequest: true, hover: true, completion: true, validate: true, format: true, - schemas: [ - { - uri: 'https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.29.0-standalone-strict/all.json', - fileMatch: ['*'], - }, - ], }); }; From f2fd02f526a772c3907fe7b9dbf002beb098a936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Mon, 27 Oct 2025 15:55:48 +0100 Subject: [PATCH 34/37] fix --- src/components/ControlPlane/GitRepositories.tsx | 2 +- src/components/ControlPlane/Kustomizations.tsx | 2 +- src/components/ControlPlane/ManagedResources.tsx | 2 +- src/components/ControlPlane/ProvidersConfig.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/ControlPlane/GitRepositories.tsx b/src/components/ControlPlane/GitRepositories.tsx index 946a6580..04fc4be7 100644 --- a/src/components/ControlPlane/GitRepositories.tsx +++ b/src/components/ControlPlane/GitRepositories.tsx @@ -149,7 +149,7 @@ export function GitRepositories() { }, }, ] as AnalyticalTableColumnDefinition[], - [t, openEditPanel], + [t, hasMCPAdminRights, openEditPanel], ); if (error) { diff --git a/src/components/ControlPlane/Kustomizations.tsx b/src/components/ControlPlane/Kustomizations.tsx index 0afad661..9677f1cd 100644 --- a/src/components/ControlPlane/Kustomizations.tsx +++ b/src/components/ControlPlane/Kustomizations.tsx @@ -144,7 +144,7 @@ export function Kustomizations() { }, }, ] as AnalyticalTableColumnDefinition[], - [t, openEditPanel], + [t, openEditPanel, hasMCPAdminRights], ); if (error) { diff --git a/src/components/ControlPlane/ManagedResources.tsx b/src/components/ControlPlane/ManagedResources.tsx index f83292ce..cc7887eb 100644 --- a/src/components/ControlPlane/ManagedResources.tsx +++ b/src/components/ControlPlane/ManagedResources.tsx @@ -236,7 +236,7 @@ export function ManagedResources() { }, }, ] as AnalyticalTableColumnDefinition[], - [t, openEditPanel, openDeleteDialog], + [t, openEditPanel, openDeleteDialog, hasMCPAdminRights], ); const rows: ResourceRow[] = diff --git a/src/components/ControlPlane/ProvidersConfig.tsx b/src/components/ControlPlane/ProvidersConfig.tsx index 0b0a858e..a0769b1d 100644 --- a/src/components/ControlPlane/ProvidersConfig.tsx +++ b/src/components/ControlPlane/ProvidersConfig.tsx @@ -147,7 +147,7 @@ export function ProvidersConfig() { }, }, ] as AnalyticalTableColumnDefinition[], - [t, openEditPanel], + [t, openEditPanel, hasMCPAdminRights], ); return ( From c54e0e5083d9e2f04e2840424cfe12b1d064606f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Tue, 28 Oct 2025 09:28:40 +0100 Subject: [PATCH 35/37] refactor --- .../ControlPlane/ManagedResources.tsx | 25 +++++++++++-------- src/spaces/mcp/auth/AuthContextMcp.tsx | 7 +++++- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/components/ControlPlane/ManagedResources.tsx b/src/components/ControlPlane/ManagedResources.tsx index cc7887eb..c029a723 100644 --- a/src/components/ControlPlane/ManagedResources.tsx +++ b/src/components/ControlPlane/ManagedResources.tsx @@ -56,6 +56,19 @@ type ResourceRow = { conditionSyncedMessage: string; }; +/** + * Checks if a resource is managed by Flux based on the kustomize.toolkit.fluxcd.io/name label + */ +const isResourceFluxManaged = (item: ManagedResourceItem | undefined): boolean => { + if (!item) return false; + + const fluxLabelValue = (item?.metadata?.labels as unknown as Record | undefined)?.[ + 'kustomize.toolkit.fluxcd.io/name' + ]; + + return fluxLabelValue != null && (typeof fluxLabelValue !== 'string' || fluxLabelValue.trim() !== ''); +}; + export function ManagedResources() { const { t } = useTranslation(); const toast = useToast(); @@ -169,11 +182,7 @@ export function ManagedResources() { disableFilters: true, Cell: ({ row }) => { const { original } = row; - const fluxLabelValue = ( - original?.item?.metadata?.labels as unknown as Record | undefined - )?.['kustomize.toolkit.fluxcd.io/name']; - const isFluxManaged = - typeof fluxLabelValue === 'string' ? fluxLabelValue.trim() !== '' : fluxLabelValue != null; + const isFluxManaged = isResourceFluxManaged(original?.item); return original?.item ? ( | undefined)?.[ - 'kustomize.toolkit.fluxcd.io/name' - ]; - const isFluxManaged = - typeof fluxLabelValue === 'string' ? fluxLabelValue.trim() !== '' : fluxLabelValue != null; + const isFluxManaged = isResourceFluxManaged(item); const actions: ActionItem[] = [ { diff --git a/src/spaces/mcp/auth/AuthContextMcp.tsx b/src/spaces/mcp/auth/AuthContextMcp.tsx index e2bf11c2..11b85e72 100644 --- a/src/spaces/mcp/auth/AuthContextMcp.tsx +++ b/src/spaces/mcp/auth/AuthContextMcp.tsx @@ -17,9 +17,14 @@ const AuthContextMcp = createContext(null); export function AuthProviderMcp({ children, mcpUsers = [] }: { children: ReactNode; mcpUsers?: RoleBinding[] }) { const auth = useAuthOnboarding(); + const userEmail = auth.user?.email; - const matchingRoleBinding = mcpUsers.find((roleBinding) => roleBinding.subjects[0]?.name?.includes(userEmail ?? '')); + const matchingRoleBinding = mcpUsers.find( + (roleBinding) => + Array.isArray(roleBinding.subjects) && + roleBinding.subjects.some((subject) => subject?.name?.includes(userEmail ?? '')), + ); const hasMCPAdminRights = matchingRoleBinding?.role === 'admin'; const [isAuthenticated, setIsAuthenticated] = useState(false); From 3fe0bfcb29eea2eac194486d59cdd693f6983390 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Tue, 28 Oct 2025 09:32:12 +0100 Subject: [PATCH 36/37] Update YamlEditor.tsx --- src/components/YamlEditor/YamlEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/YamlEditor/YamlEditor.tsx b/src/components/YamlEditor/YamlEditor.tsx index 3764b79e..d4aaed94 100644 --- a/src/components/YamlEditor/YamlEditor.tsx +++ b/src/components/YamlEditor/YamlEditor.tsx @@ -59,7 +59,7 @@ export const YamlEditor = (props: YamlEditorProps) => { ); const handleEditorChange = useCallback( - (val: string | undefined, event: monaco.editor.IModelContentChangedEvent) => { + (val: string | undefined, event?: monaco.editor.IModelContentChangedEvent) => { if (isEdit) { setEditorContent(val ?? ''); } From 7dd1332c183506f592c40083d57a84533481c742 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Goral?= Date: Fri, 31 Oct 2025 13:30:04 +0100 Subject: [PATCH 37/37] refactor --- .../ControlPlane/GitRepositories.tsx | 4 ++-- .../ControlPlane/Kustomizations.tsx | 4 ++-- .../ControlPlane/ManagedResources.tsx | 4 ++-- .../ControlPlane/ProvidersConfig.tsx | 4 ++-- src/lib/shared/McpContext.tsx | 4 +++- src/spaces/mcp/auth/AuthContextMcp.tsx | 20 ++----------------- src/spaces/mcp/auth/useHasMcpAdminRights.ts | 20 +++++++++++++++++++ src/spaces/mcp/pages/McpPage.tsx | 3 ++- 8 files changed, 35 insertions(+), 28 deletions(-) create mode 100644 src/spaces/mcp/auth/useHasMcpAdminRights.ts diff --git a/src/components/ControlPlane/GitRepositories.tsx b/src/components/ControlPlane/GitRepositories.tsx index 04fc4be7..451899cf 100644 --- a/src/components/ControlPlane/GitRepositories.tsx +++ b/src/components/ControlPlane/GitRepositories.tsx @@ -24,7 +24,7 @@ import { useHandleResourcePatch } from '../../lib/api/types/crossplane/useHandle import { ErrorDialog, ErrorDialogHandle } from '../Shared/ErrorMessageBox.tsx'; import type { GitReposResponse } from '../../lib/api/types/flux/listGitRepo'; import { ActionsMenu, type ActionItem } from './ActionsMenu'; -import { useAuthMcp } from '../../spaces/mcp/auth/AuthContextMcp.tsx'; +import { useHasMcpAdminRights } from '../../spaces/mcp/auth/useHasMcpAdminRights.ts'; export type GitRepoItem = GitReposResponse['items'][0] & { apiVersion?: string; @@ -47,7 +47,6 @@ export function GitRepositories() { readyMessage: string; revision?: string; }; - const { hasMCPAdminRights } = useAuthMcp(); const openEditPanel = useCallback( (item: GitRepoItem) => { const identityKey = `${item.kind}:${item.metadata.namespace ?? ''}:${item.metadata.name}`; @@ -64,6 +63,7 @@ export function GitRepositories() { }, [openInAside, handlePatch], ); + const hasMCPAdminRights = useHasMcpAdminRights(); const columns = useMemo( () => diff --git a/src/components/ControlPlane/Kustomizations.tsx b/src/components/ControlPlane/Kustomizations.tsx index 9677f1cd..fb0528d1 100644 --- a/src/components/ControlPlane/Kustomizations.tsx +++ b/src/components/ControlPlane/Kustomizations.tsx @@ -24,7 +24,7 @@ import { useHandleResourcePatch } from '../../lib/api/types/crossplane/useHandle import { ErrorDialog, ErrorDialogHandle } from '../Shared/ErrorMessageBox.tsx'; import type { KustomizationsResponse } from '../../lib/api/types/flux/listKustomization'; import { ActionsMenu, type ActionItem } from './ActionsMenu'; -import { useAuthMcp } from '../../spaces/mcp/auth/AuthContextMcp.tsx'; +import { useHasMcpAdminRights } from '../../spaces/mcp/auth/useHasMcpAdminRights.ts'; export type KustomizationItem = KustomizationsResponse['items'][0] & { apiVersion?: string; @@ -63,7 +63,7 @@ export function Kustomizations() { }, [openInAside, handlePatch], ); - const { hasMCPAdminRights } = useAuthMcp(); + const hasMCPAdminRights = useHasMcpAdminRights(); const columns = useMemo( () => [ diff --git a/src/components/ControlPlane/ManagedResources.tsx b/src/components/ControlPlane/ManagedResources.tsx index c029a723..48d77ec6 100644 --- a/src/components/ControlPlane/ManagedResources.tsx +++ b/src/components/ControlPlane/ManagedResources.tsx @@ -36,7 +36,7 @@ import { YamlSidePanel } from '../Yaml/YamlSidePanel.tsx'; import { ErrorDialog, ErrorDialogHandle } from '../Shared/ErrorMessageBox.tsx'; import { APIError } from '../../lib/api/error.ts'; import { useHandleResourcePatch } from '../../lib/api/types/crossplane/useHandleResourcePatch.ts'; -import { useAuthMcp } from '../../spaces/mcp/auth/AuthContextMcp.tsx'; +import { useHasMcpAdminRights } from '../../spaces/mcp/auth/useHasMcpAdminRights.ts'; interface StatusFilterColumn { filterValue?: string; @@ -120,7 +120,7 @@ export function ManagedResources() { }, [openInAside, handlePatch], ); - const { hasMCPAdminRights } = useAuthMcp(); + const hasMCPAdminRights = useHasMcpAdminRights(); const columns = useMemo( () => [ diff --git a/src/components/ControlPlane/ProvidersConfig.tsx b/src/components/ControlPlane/ProvidersConfig.tsx index a0769b1d..76a4e6a5 100644 --- a/src/components/ControlPlane/ProvidersConfig.tsx +++ b/src/components/ControlPlane/ProvidersConfig.tsx @@ -24,7 +24,7 @@ import { useSplitter } from '../Splitter/SplitterContext.tsx'; import { YamlSidePanel } from '../Yaml/YamlSidePanel.tsx'; import { useHandleResourcePatch } from '../../lib/api/types/crossplane/useHandleResourcePatch.ts'; import { ErrorDialog, ErrorDialogHandle } from '../Shared/ErrorMessageBox.tsx'; -import { useAuthMcp } from '../../spaces/mcp/auth/AuthContextMcp.tsx'; +import { useHasMcpAdminRights } from '../../spaces/mcp/auth/useHasMcpAdminRights.ts'; type Rows = { parent: string; @@ -76,7 +76,7 @@ export function ProvidersConfig() { }, [openInAside, handlePatch], ); - const { hasMCPAdminRights } = useAuthMcp(); + const hasMCPAdminRights = useHasMcpAdminRights(); const columns = useMemo( () => [ diff --git a/src/lib/shared/McpContext.tsx b/src/lib/shared/McpContext.tsx index 8c46a06d..4c6fcebe 100644 --- a/src/lib/shared/McpContext.tsx +++ b/src/lib/shared/McpContext.tsx @@ -1,5 +1,5 @@ import { createContext, ReactNode, useContext } from 'react'; -import { ControlPlane as ManagedControlPlaneResource } from '../api/types/crate/controlPlanes.ts'; +import { ControlPlane as ManagedControlPlaneResource, RoleBinding } from '../api/types/crate/controlPlanes.ts'; import { ApiConfigProvider } from '../../components/Shared/k8s'; import { useApiResource } from '../api/useApiResource.ts'; import { GetKubeconfig } from '../api/types/crate/getKubeconfig.ts'; @@ -15,6 +15,7 @@ interface Mcp { secretName?: string; secretKey?: string; kubeconfig?: string; + roleBindings?: RoleBinding[]; } interface Props { @@ -44,6 +45,7 @@ export const McpContextProvider = ({ children, context }: Props) => { return <>; } context.kubeconfig = kubeconfig.data; + context.roleBindings = mcp.data?.spec?.authorization?.roleBindings; return {children}; }; diff --git a/src/spaces/mcp/auth/AuthContextMcp.tsx b/src/spaces/mcp/auth/AuthContextMcp.tsx index 11b85e72..e728f8a2 100644 --- a/src/spaces/mcp/auth/AuthContextMcp.tsx +++ b/src/spaces/mcp/auth/AuthContextMcp.tsx @@ -2,31 +2,17 @@ import { createContext, useState, useEffect, ReactNode, use } from 'react'; import { MeResponseSchema } from './auth.schemas'; import { AUTH_FLOW_SESSION_KEY } from '../../../common/auth/AuthCallbackHandler.tsx'; import { getRedirectSuffix } from '../../../common/auth/getRedirectSuffix.ts'; -import { RoleBinding } from '../../../lib/api/types/crate/controlPlanes.ts'; -import { useAuthOnboarding } from '../../onboarding/auth/AuthContextOnboarding.tsx'; interface AuthContextMcpType { isLoading: boolean; isAuthenticated: boolean; error: Error | null; login: () => void; - hasMCPAdminRights: boolean; } const AuthContextMcp = createContext(null); -export function AuthProviderMcp({ children, mcpUsers = [] }: { children: ReactNode; mcpUsers?: RoleBinding[] }) { - const auth = useAuthOnboarding(); - - const userEmail = auth.user?.email; - - const matchingRoleBinding = mcpUsers.find( - (roleBinding) => - Array.isArray(roleBinding.subjects) && - roleBinding.subjects.some((subject) => subject?.name?.includes(userEmail ?? '')), - ); - const hasMCPAdminRights = matchingRoleBinding?.role === 'admin'; - +export function AuthProviderMcp({ children }: { children: ReactNode }) { const [isAuthenticated, setIsAuthenticated] = useState(false); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -73,9 +59,7 @@ export function AuthProviderMcp({ children, mcpUsers = [] }: { children: ReactNo window.location.replace(`/api/auth/mcp/login?redirectTo=${encodeURIComponent(getRedirectSuffix())}`); }; - return ( - {children} - ); + return {children}; } export const useAuthMcp = () => { diff --git a/src/spaces/mcp/auth/useHasMcpAdminRights.ts b/src/spaces/mcp/auth/useHasMcpAdminRights.ts new file mode 100644 index 00000000..46fbc6f9 --- /dev/null +++ b/src/spaces/mcp/auth/useHasMcpAdminRights.ts @@ -0,0 +1,20 @@ +import { useAuthOnboarding } from '../../onboarding/auth/AuthContextOnboarding.tsx'; +import { useMcp } from '../../../lib/shared/McpContext.tsx'; + +export function useHasMcpAdminRights(): boolean { + const auth = useAuthOnboarding(); + const mcp = useMcp(); + const userEmail = auth.user?.email; + const mcpUsers = mcp.roleBindings ?? []; + + if (!userEmail) { + return false; + } + + const matchingRoleBinding = mcpUsers.find( + (roleBinding) => + Array.isArray(roleBinding.subjects) && roleBinding.subjects.some((subject) => subject?.name?.includes(userEmail)), + ); + + return matchingRoleBinding?.role === 'admin'; +} diff --git a/src/spaces/mcp/pages/McpPage.tsx b/src/spaces/mcp/pages/McpPage.tsx index 9a2f0d55..2d5d226b 100644 --- a/src/spaces/mcp/pages/McpPage.tsx +++ b/src/spaces/mcp/pages/McpPage.tsx @@ -28,6 +28,7 @@ import { useApiResource } from '../../../lib/api/useApiResource.ts'; import { YamlViewButton } from '../../../components/Yaml/YamlViewButton.tsx'; import { Landscapers } from '../../../components/ControlPlane/Landscapers.tsx'; import { AuthProviderMcp } from '../auth/AuthContextMcp.tsx'; +import { useHasMcpAdminRights } from '../auth/useHasMcpAdminRights.ts'; import { isNotFoundError } from '../../../lib/api/error.ts'; import { NotFoundBanner } from '../../../components/Ui/NotFoundBanner/NotFoundBanner.tsx'; import Graph from '../../../components/Graphs/Graph.tsx'; @@ -93,7 +94,7 @@ export default function McpPage() { name: controlPlaneName, }} > - +