From dbf976afd4b59bf33b74e4e888ff46696b4d404f Mon Sep 17 00:00:00 2001 From: Hubert Date: Mon, 26 May 2025 00:22:18 +0200 Subject: [PATCH 1/6] initial commit for landscapers from different branch --- public/locales/en.json | 3 +- src/components/ControlPlane/Landscapers.tsx | 128 ++++++++++++++++++ src/lib/api/types/landscaper/hooks.ts | 102 +++++++------- .../api/types/landscaper/listDeployItems.ts | 37 ++--- .../api/types/landscaper/listExecutions.ts | 37 ++--- .../api/types/landscaper/listInstallations.ts | 50 +++---- src/lib/shared/constants.ts | 2 +- src/views/ControlPlanes/ControlPlaneView.tsx | 21 +++ 8 files changed, 262 insertions(+), 118 deletions(-) create mode 100644 src/components/ControlPlane/Landscapers.tsx diff --git a/public/locales/en.json b/public/locales/en.json index 85615579..66a2439a 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -149,7 +149,8 @@ "accessError": "Managed Control Plane does not have access information yet", "componentsTitle": "Components", "crossplaneTitle": "Crossplane", - "gitOpsTitle": "GitOps" + "gitOpsTitle": "GitOps", + "landscapersTitle": "Landscapers" }, "ToastContext": { "errorMessage": "useToast must be used within a ToastProvider" diff --git a/src/components/ControlPlane/Landscapers.tsx b/src/components/ControlPlane/Landscapers.tsx new file mode 100644 index 00000000..10d28b6d --- /dev/null +++ b/src/components/ControlPlane/Landscapers.tsx @@ -0,0 +1,128 @@ +import { useTranslation } from 'react-i18next'; +import { + AnalyticalTable, + AnalyticalTableColumnDefinition, + AnalyticalTableScaleWidthMode, + Title, + MultiComboBox, + MultiComboBoxItem, +} from '@ui5/webcomponents-react'; +import useResource from '../../lib/api/useApiResource'; +import '@ui5/webcomponents-icons/dist/sys-enter-2'; +import '@ui5/webcomponents-icons/dist/sys-cancel-2'; +import { ListNamespaces } from '../../lib/api/types/k8s/listNamespaces'; +import { useEffect, useState } from 'react'; +import { resourcesInterval } from '../../lib/shared/constants'; +import { InstalationsRequest } from '../../lib/api/types/landscaper/listInstallations'; + +export function Landscapers() { + const { t } = useTranslation(); + + // Namespaces z API + const { data: namespaces, error: namespacesError } = useResource( + ListNamespaces, + { + refreshInterval: resourcesInterval, + }, + ); + + const [selectedNamespaces, setSelectedNamespaces] = useState([]); + const [installations, setInstallations] = useState([]); + const [loading, setLoading] = useState(false); + + // Handler wyboru namespace’ów + const handleSelectionChange = (e: CustomEvent) => { + const selectedItems = Array.from(e.detail.items || []); + const selectedValues = selectedItems.map((item: any) => item.text); + setSelectedNamespaces(selectedValues); + }; + + // Fetch installations, gdy zmienią się namespace’y + useEffect(() => { + const fetchInstallations = async () => { + if (selectedNamespaces.length === 0) { + setInstallations([]); + return; + } + setLoading(true); + try { + const paths = selectedNamespaces + .map((ns) => InstalationsRequest(ns).path) + .filter((p): p is string => p !== null && p !== undefined); + + const allResponses = await Promise.all( + paths.map((path) => fetch(path).then((res) => res.json())), + ); + + const allItems = allResponses.flatMap((res) => res.items || []); + setInstallations(allItems); + } catch (error) { + console.error(error); + setInstallations([]); + } finally { + setLoading(false); + } + }; + + fetchInstallations(); + }, [selectedNamespaces]); + + // Definicja kolumn tabeli + const columns: AnalyticalTableColumnDefinition[] = [ + { + Header: t('Namespace'), + accessor: 'metadata.namespace', + }, + { + Header: t('Name'), + accessor: 'metadata.name', + }, + { + Header: t('Phase'), + accessor: 'status.phase', + }, + { + Header: t('Created At'), + accessor: 'metadata.creationTimestamp', + }, + ]; + + return ( + <> + {t('Providers.headerProviders')} + + {namespaces && ( + + {namespaces.map((ns) => ( + + ))} + + )} + + + + ); +} diff --git a/src/lib/api/types/landscaper/hooks.ts b/src/lib/api/types/landscaper/hooks.ts index b007cde6..e85fb60d 100644 --- a/src/lib/api/types/landscaper/hooks.ts +++ b/src/lib/api/types/landscaper/hooks.ts @@ -1,55 +1,53 @@ -import useApiResource from '../../useApiResource'; -import { ListGraphInstallations } from './listInstallations'; -import { ListGraphExecutions } from './listExecutions'; -import { ListGraphDeployItems } from './listDeployItems'; +// import useApiResource from '../../useApiResource'; +// import { ListGraphInstallations } from './listInstallations'; +// import { ListGraphExecutions } from './listExecutions'; -interface GraphLandscaperResourceType { - kind: string; - apiVersion: string; - metadata: { - name: string; - namespace: string; - uid: string; - ownerReferences: { - uid: string; - }[]; - }; - status: { - phase: string; - }; -} +// interface GraphLandscaperResourceType { +// kind: string; +// apiVersion: string; +// metadata: { +// name: string; +// namespace: string; +// uid: string; +// ownerReferences: { +// uid: string; +// }[]; +// }; +// status: { +// phase: string; +// }; +// } -export const useLandscaperGraphResources = () => { - const installations = useApiResource(ListGraphInstallations); - const executions = useApiResource(ListGraphExecutions); - const deployItems = useApiResource(ListGraphDeployItems); +// export const useLandscaperGraphResources = () => { +// const installations = useApiResource(ListGraphInstallations); +// const executions = useApiResource(ListGraphExecutions); - return { - data: [ - ...(installations.data?.map((m) => { - return { - kind: 'Installation', - apiVersion: 'landscaper.gardener.cloud/v1alpha1', - ...m, - }; - }) ?? []), - ...(executions.data?.map((m) => { - return { - kind: 'Execution', - apiVersion: 'landscaper.gardener.cloud/v1alpha1', - ...m, - }; - }) ?? []), - ...(deployItems.data?.map((m) => { - return { - kind: 'DeployItem', - apiVersion: 'landscaper.gardener.cloud/v1alpha1', - ...m, - }; - }) ?? []), - ] as GraphLandscaperResourceType[], - error: [installations.error, executions.error, deployItems.error].filter( - (e) => e !== undefined, - ), - }; -}; +// return { +// data: [ +// ...(installations.data?.map((m) => { +// return { +// kind: 'Installation', +// apiVersion: 'landscaper.gardener.cloud/v1alpha1', +// ...m, +// }; +// }) ?? []), +// ...(executions.data?.map((m) => { +// return { +// kind: 'Execution', +// apiVersion: 'landscaper.gardener.cloud/v1alpha1', +// ...m, +// }; +// }) ?? []), +// ...(deployItems.data?.map((m) => { +// return { +// kind: 'DeployItem', +// apiVersion: 'landscaper.gardener.cloud/v1alpha1', +// ...m, +// }; +// }) ?? []), +// ] as GraphLandscaperResourceType[], +// error: [installations.error, executions.error, deployItems.error].filter( +// (e) => e !== undefined, +// ), +// }; +// }; diff --git a/src/lib/api/types/landscaper/listDeployItems.ts b/src/lib/api/types/landscaper/listDeployItems.ts index 9315030b..6f2c898d 100644 --- a/src/lib/api/types/landscaper/listDeployItems.ts +++ b/src/lib/api/types/landscaper/listDeployItems.ts @@ -1,20 +1,25 @@ import { Resource } from '../resource'; -interface GraphDeployItemsType { - metadata: { - name: string; - namespace: string; - uid: string; - ownerReferences: { - uid: string; - }[]; - }; - status: { - phase: string; - }; +interface DeployItemsListResponse { + items: [ + { + metadata: { + name: string; + namespace: string; + uid: string; + ownerReferences: { + uid: string; + }[]; + }; + status: { + phase: string; + }; + }, + ]; } -export const ListGraphDeployItems: Resource = { - path: '/apis/landscaper.gardener.cloud/v1alpha1/deployitems', - jq: '[.items[] | {metadata: .metadata | {name, namespace, uid, ownerReferences: (try [{uid: .ownerReferences[].uid}] catch [])}, status: .status | {phase}}]', -}; +export const DeployItemsRequest = ( + namespace: string, +): Resource => ({ + path: `/apis/landscaper.gardener.cloud/v1alpha1/namespaces/${namespace}/installations`, +}); diff --git a/src/lib/api/types/landscaper/listExecutions.ts b/src/lib/api/types/landscaper/listExecutions.ts index dab34672..1abb0423 100644 --- a/src/lib/api/types/landscaper/listExecutions.ts +++ b/src/lib/api/types/landscaper/listExecutions.ts @@ -1,20 +1,25 @@ import { Resource } from '../resource'; -interface GraphExecutionsType { - metadata: { - name: string; - namespace: string; - uid: string; - ownerReferences: { - uid: string; - }[]; - }; - status: { - phase: string; - }; +interface ExecutionsListResponse { + items: [ + { + metadata: { + name: string; + namespace: string; + uid: string; + ownerReferences: { + uid: string; + }[]; + }; + status: { + phase: string; + }; + }, + ]; } -export const ListGraphExecutions: Resource = { - path: '/apis/landscaper.gardener.cloud/v1alpha1/executions', - jq: '[.items[] | {metadata: .metadata | {name, namespace, uid, ownerReferences: (try [{uid: .ownerReferences[].uid}] catch [])}, status: .status | {phase}}]', -}; +export const ExecutionsRequest = ( + namespace: string, +): Resource => ({ + path: `/apis/landscaper.gardener.cloud/v1alpha1/namespaces/${namespace}/installations`, +}); diff --git a/src/lib/api/types/landscaper/listInstallations.ts b/src/lib/api/types/landscaper/listInstallations.ts index 589111f5..2e3aad22 100644 --- a/src/lib/api/types/landscaper/listInstallations.ts +++ b/src/lib/api/types/landscaper/listInstallations.ts @@ -1,36 +1,22 @@ import { Resource } from '../resource'; -interface GraphInstallationsType { - metadata: { - name: string; - namespace: string; - uid: string; - ownerReferences: { - uid: string; - }[]; - }; - status: { - phase: string; - }; +interface InstalationsListResponse { + items: [ + { + metadata: { + name: string; + namespace: string; + creationTimestamp: string; + }; + status: { + phase: string; + }; + }, + ]; } -export const ListGraphInstallations: Resource = { - path: '/apis/landscaper.gardener.cloud/v1alpha1/installations', - jq: '[.items[] | {metadata: .metadata | {name, namespace, uid, ownerReferences: (try [{uid: .ownerReferences[].uid}] catch [])}, status: .status | {phase}}]', -}; - -interface TableInstallationsType { - metadata: { - name: string; - namespace: string; - creationTimestamp: string; - }; - status: { - phase: string; - }; -} - -export const ListTableInstallations: Resource = { - path: '/apis/landscaper.gardener.cloud/v1alpha1/installations', - jq: '[.items[] | {metadata: .metadata | {name, namespace, creationTimestamp}, status: .status | {phase}}]', -}; +export const InstalationsRequest = ( + namespace: string, +): Resource => ({ + path: `/apis/landscaper.gardener.cloud/v1alpha1/namespaces/landscaper02/installations`, +}); diff --git a/src/lib/shared/constants.ts b/src/lib/shared/constants.ts index 8465326c..972b4fa0 100644 --- a/src/lib/shared/constants.ts +++ b/src/lib/shared/constants.ts @@ -1 +1 @@ -export const resourcesInterval = 30000; +export const resourcesInterval = 100000; diff --git a/src/views/ControlPlanes/ControlPlaneView.tsx b/src/views/ControlPlanes/ControlPlaneView.tsx index 3b957d8f..2f0a3209 100644 --- a/src/views/ControlPlanes/ControlPlaneView.tsx +++ b/src/views/ControlPlanes/ControlPlaneView.tsx @@ -29,6 +29,7 @@ import MCPHealthPopoverButton from '../../components/ControlPlane/MCPHealthPopov import useResource from '../../lib/api/useApiResource'; import { YamlViewButtonWithLoader } from '../../components/Yaml/YamlViewButtonWithLoader.tsx'; +import { Landscapers } from '../../components/ControlPlane/Landscapers.tsx'; export default function ControlPlaneView() { const { projectName, workspaceName, controlPlaneName, contextName } = @@ -149,6 +150,26 @@ export default function ControlPlaneView() { + + + {t('ControlPlaneView.landscapersTitle')} + + } + noAnimation + > + + + Date: Mon, 26 May 2025 00:45:53 +0200 Subject: [PATCH 2/6] working installations into table --- src/components/ControlPlane/FluxList.tsx | 2 +- src/components/ControlPlane/Landscapers.tsx | 24 +++++++++---------- .../api/types/landscaper/listInstallations.ts | 2 +- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/components/ControlPlane/FluxList.tsx b/src/components/ControlPlane/FluxList.tsx index 51ddee98..1336bd38 100644 --- a/src/components/ControlPlane/FluxList.tsx +++ b/src/components/ControlPlane/FluxList.tsx @@ -22,7 +22,7 @@ export default function FluxList() { data: gitReposData, error: repoErr, isLoading: repoIsLoading, - } = useResource(FluxRequest); //404 if component not enabled + } = useResource(FluxRequest); //404 if component not enabled` const { data: kustmizationData, error: kustomizationErr, diff --git a/src/components/ControlPlane/Landscapers.tsx b/src/components/ControlPlane/Landscapers.tsx index 10d28b6d..26db7102 100644 --- a/src/components/ControlPlane/Landscapers.tsx +++ b/src/components/ControlPlane/Landscapers.tsx @@ -11,47 +11,46 @@ import useResource from '../../lib/api/useApiResource'; import '@ui5/webcomponents-icons/dist/sys-enter-2'; import '@ui5/webcomponents-icons/dist/sys-cancel-2'; import { ListNamespaces } from '../../lib/api/types/k8s/listNamespaces'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useContext } from 'react'; import { resourcesInterval } from '../../lib/shared/constants'; import { InstalationsRequest } from '../../lib/api/types/landscaper/listInstallations'; +import { ApiConfigContext } from '../../components/Shared/k8s'; +import { fetchApiServerJson } from '../../lib/api/fetch'; export function Landscapers() { const { t } = useTranslation(); + const apiConfig = useContext(ApiConfigContext); - // Namespaces z API - const { data: namespaces, error: namespacesError } = useResource( - ListNamespaces, - { - refreshInterval: resourcesInterval, - }, - ); + const { data: namespaces } = useResource(ListNamespaces, { + refreshInterval: resourcesInterval, + }); const [selectedNamespaces, setSelectedNamespaces] = useState([]); const [installations, setInstallations] = useState([]); const [loading, setLoading] = useState(false); - // Handler wyboru namespace’ów const handleSelectionChange = (e: CustomEvent) => { const selectedItems = Array.from(e.detail.items || []); const selectedValues = selectedItems.map((item: any) => item.text); setSelectedNamespaces(selectedValues); }; - // Fetch installations, gdy zmienią się namespace’y useEffect(() => { const fetchInstallations = async () => { if (selectedNamespaces.length === 0) { setInstallations([]); return; } + setLoading(true); + try { const paths = selectedNamespaces .map((ns) => InstalationsRequest(ns).path) .filter((p): p is string => p !== null && p !== undefined); const allResponses = await Promise.all( - paths.map((path) => fetch(path).then((res) => res.json())), + paths.map((path) => fetchApiServerJson(path, apiConfig)), ); const allItems = allResponses.flatMap((res) => res.items || []); @@ -65,9 +64,8 @@ export function Landscapers() { }; fetchInstallations(); - }, [selectedNamespaces]); + }, [selectedNamespaces, apiConfig]); - // Definicja kolumn tabeli const columns: AnalyticalTableColumnDefinition[] = [ { Header: t('Namespace'), diff --git a/src/lib/api/types/landscaper/listInstallations.ts b/src/lib/api/types/landscaper/listInstallations.ts index 2e3aad22..f629b182 100644 --- a/src/lib/api/types/landscaper/listInstallations.ts +++ b/src/lib/api/types/landscaper/listInstallations.ts @@ -18,5 +18,5 @@ interface InstalationsListResponse { export const InstalationsRequest = ( namespace: string, ): Resource => ({ - path: `/apis/landscaper.gardener.cloud/v1alpha1/namespaces/landscaper02/installations`, + path: `/apis/landscaper.gardener.cloud/v1alpha1/namespaces/${namespace}/installations`, }); From 65cd407f76179698e4d02c2b34b413dabd8d2ef3 Mon Sep 17 00:00:00 2001 From: Hubert Date: Mon, 26 May 2025 00:58:24 +0200 Subject: [PATCH 3/6] adding executions and deploy items --- src/components/ControlPlane/Landscapers.tsx | 101 ++++++++++++++++-- .../api/types/landscaper/listExecutions.ts | 2 +- 2 files changed, 93 insertions(+), 10 deletions(-) diff --git a/src/components/ControlPlane/Landscapers.tsx b/src/components/ControlPlane/Landscapers.tsx index 26db7102..dc1ee347 100644 --- a/src/components/ControlPlane/Landscapers.tsx +++ b/src/components/ControlPlane/Landscapers.tsx @@ -8,12 +8,12 @@ import { MultiComboBoxItem, } from '@ui5/webcomponents-react'; import useResource from '../../lib/api/useApiResource'; -import '@ui5/webcomponents-icons/dist/sys-enter-2'; -import '@ui5/webcomponents-icons/dist/sys-cancel-2'; import { ListNamespaces } from '../../lib/api/types/k8s/listNamespaces'; import { useEffect, useState, useContext } from 'react'; import { resourcesInterval } from '../../lib/shared/constants'; import { InstalationsRequest } from '../../lib/api/types/landscaper/listInstallations'; +import { ExecutionsRequest } from '../../lib/api/types/landscaper/listExecutions'; +import { DeployItemsRequest } from '../../lib/api/types/landscaper/listDeployItems'; import { ApiConfigContext } from '../../components/Shared/k8s'; import { fetchApiServerJson } from '../../lib/api/fetch'; @@ -27,6 +27,8 @@ export function Landscapers() { const [selectedNamespaces, setSelectedNamespaces] = useState([]); const [installations, setInstallations] = useState([]); + const [executions, setExecutions] = useState([]); + const [deployItems, setDeployItems] = useState([]); const [loading, setLoading] = useState(false); const handleSelectionChange = (e: CustomEvent) => { @@ -36,34 +38,67 @@ export function Landscapers() { }; useEffect(() => { - const fetchInstallations = async () => { + const fetchAllResources = async () => { if (selectedNamespaces.length === 0) { setInstallations([]); + setExecutions([]); + setDeployItems([]); return; } setLoading(true); try { - const paths = selectedNamespaces + // === INSTALLATIONS === + const installationPaths = selectedNamespaces .map((ns) => InstalationsRequest(ns).path) .filter((p): p is string => p !== null && p !== undefined); - const allResponses = await Promise.all( - paths.map((path) => fetchApiServerJson(path, apiConfig)), + const installationResponses = await Promise.all( + installationPaths.map((path) => fetchApiServerJson(path, apiConfig)), ); - const allItems = allResponses.flatMap((res) => res.items || []); - setInstallations(allItems); + const installationsData = installationResponses.flatMap( + (res) => res.items || [], + ); + setInstallations(installationsData); + + // === EXECUTIONS === + const executionPaths = selectedNamespaces + .map((ns) => ExecutionsRequest(ns).path) + .filter((p): p is string => p !== null && p !== undefined); + + const executionResponses = await Promise.all( + executionPaths.map((path) => fetchApiServerJson(path, apiConfig)), + ); + + const executionsData = executionResponses.flatMap( + (res) => res.items || [], + ); + setExecutions(executionsData); + + // === DEPLOY ITEMS === + const deployPaths = selectedNamespaces + .map((ns) => DeployItemsRequest(ns).path) + .filter((p): p is string => p !== null && p !== undefined); + + const deployResponses = await Promise.all( + deployPaths.map((path) => fetchApiServerJson(path, apiConfig)), + ); + + const deployItemsData = deployResponses.flatMap((res) => res.items || []); + setDeployItems(deployItemsData); } catch (error) { console.error(error); setInstallations([]); + setExecutions([]); + setDeployItems([]); } finally { setLoading(false); } }; - fetchInstallations(); + fetchAllResources(); }, [selectedNamespaces, apiConfig]); const columns: AnalyticalTableColumnDefinition[] = [ @@ -85,6 +120,52 @@ export function Landscapers() { }, ]; + const renderRowSubComponent = (row: any) => { + const installation = row.original; + + const relatedExecutions = executions.filter((execution) => + execution.metadata.ownerReferences?.some( + (ref) => ref.uid === installation.metadata.uid, + ), + ); + + const relatedDeployItems = deployItems.filter((deploy) => + deploy.metadata.ownerReferences?.some( + (ref) => ref.uid === installation.metadata.uid, + ), + ); + + return ( +
+
{t('Executions')}
+ {relatedExecutions.length > 0 ? ( +
    + {relatedExecutions.map((execution: any) => ( +
  • + {execution.metadata.name} – {execution.status.phase} +
  • + ))} +
+ ) : ( +

{t('No executions found')}

+ )} + +
{t('Deploy Items')}
+ {relatedDeployItems.length > 0 ? ( +
    + {relatedDeployItems.map((deploy: any) => ( +
  • + {deploy.metadata.name} – {deploy.status.phase} +
  • + ))} +
+ ) : ( +

{t('No deploy items found')}

+ )} +
+ ); + }; + return ( <> {t('Providers.headerProviders')} @@ -109,6 +190,8 @@ export function Landscapers() { scaleWidthMode={AnalyticalTableScaleWidthMode.Smart} filterable retainColumnWidth + renderRowSubComponent={renderRowSubComponent} + subComponentsBehavior="IncludeHeightExpandable" reactTableOptions={{ autoResetHiddenColumns: false, autoResetPage: false, diff --git a/src/lib/api/types/landscaper/listExecutions.ts b/src/lib/api/types/landscaper/listExecutions.ts index 1abb0423..1e4e96f3 100644 --- a/src/lib/api/types/landscaper/listExecutions.ts +++ b/src/lib/api/types/landscaper/listExecutions.ts @@ -21,5 +21,5 @@ interface ExecutionsListResponse { export const ExecutionsRequest = ( namespace: string, ): Resource => ({ - path: `/apis/landscaper.gardener.cloud/v1alpha1/namespaces/${namespace}/installations`, + path: `/apis/landscaper.gardener.cloud/v1alpha1/namespaces/${namespace}/executions`, }); From c242cd3452eece21f95435d49360a5d25c550914 Mon Sep 17 00:00:00 2001 From: Hubert Date: Wed, 28 May 2025 13:14:37 +0200 Subject: [PATCH 4/6] working landscapers --- public/locales/en.json | 10 + src/components/ControlPlane/Landscapers.tsx | 302 ++++++++---------- .../api/types/landscaper/listDeployItems.ts | 33 +- .../api/types/landscaper/listExecutions.ts | 35 +- .../api/types/landscaper/listInstallations.ts | 36 ++- src/lib/api/useApiResource.ts | 71 ++++ 6 files changed, 275 insertions(+), 212 deletions(-) diff --git a/public/locales/en.json b/public/locales/en.json index 66a2439a..9c54d5de 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -155,6 +155,16 @@ "ToastContext": { "errorMessage": "useToast must be used within a ToastProvider" }, + "Landscapers": { + "headerLandscapers": "Landscapers", + "multiComboBoxPlaceholder": "Select namespace", + "noItemsFound": "No Deploy items found", + "deployItems": "Deploy Items", + "treeDeployItem": "Deploy Item", + "treeInstallation": "Installation", + "treeExecution": "Execution", + "noExecutionFound": "No Exeuctions found" + }, "CopyButton": { "copiedMessage": "Copied To Clipboard", "failedMessage": "Failed to copy" diff --git a/src/components/ControlPlane/Landscapers.tsx b/src/components/ControlPlane/Landscapers.tsx index dc1ee347..af9d3c46 100644 --- a/src/components/ControlPlane/Landscapers.tsx +++ b/src/components/ControlPlane/Landscapers.tsx @@ -1,179 +1,171 @@ import { useTranslation } from 'react-i18next'; import { - AnalyticalTable, - AnalyticalTableColumnDefinition, - AnalyticalTableScaleWidthMode, - Title, MultiComboBox, + MultiComboBoxDomRef, MultiComboBoxItem, + Tree, + TreeItem, + Ui5CustomEvent, } from '@ui5/webcomponents-react'; -import useResource from '../../lib/api/useApiResource'; -import { ListNamespaces } from '../../lib/api/types/k8s/listNamespaces'; -import { useEffect, useState, useContext } from 'react'; +import { useState, JSX } from 'react'; import { resourcesInterval } from '../../lib/shared/constants'; -import { InstalationsRequest } from '../../lib/api/types/landscaper/listInstallations'; -import { ExecutionsRequest } from '../../lib/api/types/landscaper/listExecutions'; -import { DeployItemsRequest } from '../../lib/api/types/landscaper/listDeployItems'; -import { ApiConfigContext } from '../../components/Shared/k8s'; -import { fetchApiServerJson } from '../../lib/api/fetch'; +import useResource, { + useMultipleApiResources, +} from '../../lib/api/useApiResource'; +import { ListNamespaces } from '../../lib/api/types/k8s/listNamespaces'; +import { + Installation, + InstalationsRequest, +} from '../../lib/api/types/landscaper/listInstallations'; +import { + Execution, + ExecutionsRequest, +} from '../../lib/api/types/landscaper/listExecutions'; +import { + DeployItem, + DeployItemsRequest, +} from '../../lib/api/types/landscaper/listDeployItems'; + +import { MultiComboBoxSelectionChangeEventDetail } from '@ui5/webcomponents/dist/MultiComboBox.js'; export function Landscapers() { const { t } = useTranslation(); - const apiConfig = useContext(ApiConfigContext); const { data: namespaces } = useResource(ListNamespaces, { refreshInterval: resourcesInterval, }); const [selectedNamespaces, setSelectedNamespaces] = useState([]); - const [installations, setInstallations] = useState([]); - const [executions, setExecutions] = useState([]); - const [deployItems, setDeployItems] = useState([]); - const [loading, setLoading] = useState(false); - const handleSelectionChange = (e: CustomEvent) => { + const { data: installations = [] } = useMultipleApiResources( + selectedNamespaces, + InstalationsRequest, + ); + + const { data: executions = [] } = useMultipleApiResources( + selectedNamespaces, + ExecutionsRequest, + ); + + const { data: deployItems = [] } = useMultipleApiResources( + selectedNamespaces, + DeployItemsRequest, + ); + + const handleSelectionChange = ( + e: Ui5CustomEvent< + MultiComboBoxDomRef, + MultiComboBoxSelectionChangeEventDetail + >, + ) => { const selectedItems = Array.from(e.detail.items || []); - const selectedValues = selectedItems.map((item: any) => item.text); + const selectedValues = selectedItems + .map((item) => item.text) + .filter((text): text is string => typeof text === 'string'); + setSelectedNamespaces(selectedValues); }; - useEffect(() => { - const fetchAllResources = async () => { - if (selectedNamespaces.length === 0) { - setInstallations([]); - setExecutions([]); - setDeployItems([]); - return; - } - - setLoading(true); - - try { - // === INSTALLATIONS === - const installationPaths = selectedNamespaces - .map((ns) => InstalationsRequest(ns).path) - .filter((p): p is string => p !== null && p !== undefined); - - const installationResponses = await Promise.all( - installationPaths.map((path) => fetchApiServerJson(path, apiConfig)), - ); - - const installationsData = installationResponses.flatMap( - (res) => res.items || [], - ); - setInstallations(installationsData); - - // === EXECUTIONS === - const executionPaths = selectedNamespaces - .map((ns) => ExecutionsRequest(ns).path) - .filter((p): p is string => p !== null && p !== undefined); - - const executionResponses = await Promise.all( - executionPaths.map((path) => fetchApiServerJson(path, apiConfig)), - ); - - const executionsData = executionResponses.flatMap( - (res) => res.items || [], - ); - setExecutions(executionsData); - - // === DEPLOY ITEMS === - const deployPaths = selectedNamespaces - .map((ns) => DeployItemsRequest(ns).path) - .filter((p): p is string => p !== null && p !== undefined); - - const deployResponses = await Promise.all( - deployPaths.map((path) => fetchApiServerJson(path, apiConfig)), - ); - - const deployItemsData = deployResponses.flatMap((res) => res.items || []); - setDeployItems(deployItemsData); - } catch (error) { - console.error(error); - setInstallations([]); - setExecutions([]); - setDeployItems([]); - } finally { - setLoading(false); - } - }; - - fetchAllResources(); - }, [selectedNamespaces, apiConfig]); - - const columns: AnalyticalTableColumnDefinition[] = [ - { - Header: t('Namespace'), - accessor: 'metadata.namespace', - }, - { - Header: t('Name'), - accessor: 'metadata.name', - }, - { - Header: t('Phase'), - accessor: 'status.phase', - }, - { - Header: t('Created At'), - accessor: 'metadata.creationTimestamp', - }, - ]; - - const renderRowSubComponent = (row: any) => { - const installation = row.original; - - const relatedExecutions = executions.filter((execution) => - execution.metadata.ownerReferences?.some( - (ref) => ref.uid === installation.metadata.uid, - ), - ); + const getStatusSymbol = (phase?: string) => { + if (!phase) return '⚪'; - const relatedDeployItems = deployItems.filter((deploy) => - deploy.metadata.ownerReferences?.some( - (ref) => ref.uid === installation.metadata.uid, - ), + const phaseLower = phase.toLowerCase(); + + if (phaseLower === 'succeeded') { + return '✅'; + } else if (phaseLower === 'failed') { + return '❌'; + } + + return '⚪'; + }; + + const renderTreeItems = (installation: Installation): JSX.Element => { + const subInstallations = + (installation.status?.subInstCache?.activeSubs + ?.map((sub) => + installations.find( + (i) => + i.metadata.name === sub.objectName && + i.metadata.namespace === installation.metadata.namespace, + ), + ) + .filter(Boolean) as Installation[]) || []; + + const execution = executions.find( + (e) => + e.metadata.name === installation.status?.executionRef?.name && + e.metadata.namespace === installation.status?.executionRef?.namespace, ); - return ( -
-
{t('Executions')}
- {relatedExecutions.length > 0 ? ( -
    - {relatedExecutions.map((execution: any) => ( -
  • - {execution.metadata.name} – {execution.status.phase} -
  • - ))} -
- ) : ( -

{t('No executions found')}

- )} + const relatedDeployItems = + (execution?.status?.deployItemCache?.activeDIs + ?.map((di) => + deployItems.find( + (item) => + item.metadata.name === di.objectName && + item.metadata.namespace === execution.metadata.namespace, + ), + ) + .filter(Boolean) as DeployItem[]) || []; -
{t('Deploy Items')}
- {relatedDeployItems.length > 0 ? ( -
    - {relatedDeployItems.map((deploy: any) => ( -
  • - {deploy.metadata.name} – {deploy.status.phase} -
  • - ))} -
+ return ( + + {subInstallations.length > 0 ? ( + subInstallations.map((sub) => renderTreeItems(sub)) ) : ( -

{t('No deploy items found')}

+ <> + + + + {relatedDeployItems.length > 0 ? ( + relatedDeployItems.map((di) => ( + + )) + ) : ( + + )} + + )} -
+ ); }; + const rootInstallations = installations.filter((inst) => { + return !installations.some((parent) => + parent.status?.subInstCache?.activeSubs?.some( + (sub: { objectName: string }) => + sub.objectName === inst.metadata.name && + parent.metadata.namespace === inst.metadata.namespace, + ), + ); + }); + return ( <> - {t('Providers.headerProviders')} - {namespaces && ( {namespaces.map((ns) => ( @@ -181,29 +173,7 @@ export function Landscapers() { ))} )} - - + {rootInstallations.map((inst) => renderTreeItems(inst))} ); } diff --git a/src/lib/api/types/landscaper/listDeployItems.ts b/src/lib/api/types/landscaper/listDeployItems.ts index 6f2c898d..20828781 100644 --- a/src/lib/api/types/landscaper/listDeployItems.ts +++ b/src/lib/api/types/landscaper/listDeployItems.ts @@ -1,25 +1,24 @@ import { Resource } from '../resource'; -interface DeployItemsListResponse { - items: [ - { - metadata: { - name: string; - namespace: string; - uid: string; - ownerReferences: { - uid: string; - }[]; - }; - status: { - phase: string; - }; - }, - ]; +export interface DeployItem { + objectName: string; + metadata: { + name: string; + namespace: string; + uid: string; + ownerReferences: { uid: string }[]; + }; + status: { + phase: string; + }; +} + +export interface DeployItemsListResponse { + items: DeployItem[]; } export const DeployItemsRequest = ( namespace: string, ): Resource => ({ - path: `/apis/landscaper.gardener.cloud/v1alpha1/namespaces/${namespace}/installations`, + path: `/apis/landscaper.gardener.cloud/v1alpha1/namespaces/${namespace}/deployitems`, }); diff --git a/src/lib/api/types/landscaper/listExecutions.ts b/src/lib/api/types/landscaper/listExecutions.ts index 1e4e96f3..9405fa7f 100644 --- a/src/lib/api/types/landscaper/listExecutions.ts +++ b/src/lib/api/types/landscaper/listExecutions.ts @@ -1,21 +1,24 @@ import { Resource } from '../resource'; -interface ExecutionsListResponse { - items: [ - { - metadata: { - name: string; - namespace: string; - uid: string; - ownerReferences: { - uid: string; - }[]; - }; - status: { - phase: string; - }; - }, - ]; +export interface Execution { + objectName: string; + metadata: { + name: string; + namespace: string; + uid: string; + }; + status?: { + phase?: string; + deployItemCache?: { + activeDIs?: { + objectName: string; + }[]; + }; + }; +} + +export interface ExecutionsListResponse { + items: Execution[]; } export const ExecutionsRequest = ( diff --git a/src/lib/api/types/landscaper/listInstallations.ts b/src/lib/api/types/landscaper/listInstallations.ts index f629b182..4c970dd1 100644 --- a/src/lib/api/types/landscaper/listInstallations.ts +++ b/src/lib/api/types/landscaper/listInstallations.ts @@ -1,18 +1,28 @@ import { Resource } from '../resource'; -interface InstalationsListResponse { - items: [ - { - metadata: { - name: string; - namespace: string; - creationTimestamp: string; - }; - status: { - phase: string; - }; - }, - ]; +export interface Installation { + objectName: string; + metadata: { + name: string; + namespace: string; + uid: string; + }; + status?: { + phase?: string; + executionRef?: { + name: string; + namespace: string; + }; + subInstCache?: { + activeSubs?: { + objectName: string; + }[]; + }; + }; +} + +export interface InstalationsListResponse { + items: Installation[]; } export const InstalationsRequest = ( diff --git a/src/lib/api/useApiResource.ts b/src/lib/api/useApiResource.ts index d088433c..5179fbab 100644 --- a/src/lib/api/useApiResource.ts +++ b/src/lib/api/useApiResource.ts @@ -217,3 +217,74 @@ export function useRevalidateApiResource(resource: Resource) { return onRevalidate; } + +export function useMultipleApiResources( + namespaces: string[], + getResource: (namespace: string) => { path: string | null }, +) { + const apiConfig = useContext(ApiConfigContext); + const [data, setData] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (namespaces.length === 0) { + setData([]); + setError(null); + return; + } + + const fetchData = async () => { + setIsLoading(true); + setError(null); + + try { + const results = await fetchMultipleResources( + namespaces, + getResource, + apiConfig, + ); + setData(results); + } catch (err) { + setError(err as Error); + setData([]); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, [namespaces, getResource, apiConfig]); + + return { data, isLoading, error }; +} + +async function fetchMultipleResources( + namespaces: string[], + getResource: (namespace: string) => { path: string | null }, + apiConfig: ApiConfig, +): Promise { + const paths = namespaces + .map((ns) => getResource(ns).path) + .filter((path): path is string => !!path); + + const results = await Promise.allSettled( + paths.map((path) => fetchApiServerJson(path, apiConfig)), + ); + + const data: T[] = []; + + for (const result of results) { + if (result.status === 'fulfilled') { + const res = result.value; + if (res && typeof res === 'object' && 'items' in res) { + const items = (res as { items?: unknown }).items; + if (Array.isArray(items)) { + data.push(...(items as T[])); + } + } + } + } + + return data; +} From 5ba9ae906459379bf39a0f467e79261650bcb8ba Mon Sep 17 00:00:00 2001 From: Hubert Date: Wed, 28 May 2025 13:17:51 +0200 Subject: [PATCH 5/6] removing unused code --- src/components/ControlPlane/FluxList.tsx | 2 +- src/lib/api/types/landscaper/hooks.ts | 53 ------------------------ 2 files changed, 1 insertion(+), 54 deletions(-) delete mode 100644 src/lib/api/types/landscaper/hooks.ts diff --git a/src/components/ControlPlane/FluxList.tsx b/src/components/ControlPlane/FluxList.tsx index 1336bd38..51ddee98 100644 --- a/src/components/ControlPlane/FluxList.tsx +++ b/src/components/ControlPlane/FluxList.tsx @@ -22,7 +22,7 @@ export default function FluxList() { data: gitReposData, error: repoErr, isLoading: repoIsLoading, - } = useResource(FluxRequest); //404 if component not enabled` + } = useResource(FluxRequest); //404 if component not enabled const { data: kustmizationData, error: kustomizationErr, diff --git a/src/lib/api/types/landscaper/hooks.ts b/src/lib/api/types/landscaper/hooks.ts deleted file mode 100644 index e85fb60d..00000000 --- a/src/lib/api/types/landscaper/hooks.ts +++ /dev/null @@ -1,53 +0,0 @@ -// import useApiResource from '../../useApiResource'; -// import { ListGraphInstallations } from './listInstallations'; -// import { ListGraphExecutions } from './listExecutions'; - -// interface GraphLandscaperResourceType { -// kind: string; -// apiVersion: string; -// metadata: { -// name: string; -// namespace: string; -// uid: string; -// ownerReferences: { -// uid: string; -// }[]; -// }; -// status: { -// phase: string; -// }; -// } - -// export const useLandscaperGraphResources = () => { -// const installations = useApiResource(ListGraphInstallations); -// const executions = useApiResource(ListGraphExecutions); - -// return { -// data: [ -// ...(installations.data?.map((m) => { -// return { -// kind: 'Installation', -// apiVersion: 'landscaper.gardener.cloud/v1alpha1', -// ...m, -// }; -// }) ?? []), -// ...(executions.data?.map((m) => { -// return { -// kind: 'Execution', -// apiVersion: 'landscaper.gardener.cloud/v1alpha1', -// ...m, -// }; -// }) ?? []), -// ...(deployItems.data?.map((m) => { -// return { -// kind: 'DeployItem', -// apiVersion: 'landscaper.gardener.cloud/v1alpha1', -// ...m, -// }; -// }) ?? []), -// ] as GraphLandscaperResourceType[], -// error: [installations.error, executions.error, deployItems.error].filter( -// (e) => e !== undefined, -// ), -// }; -// }; From 1b4c78997dea2b9d09b6dd4b725e29c4e2ae8e12 Mon Sep 17 00:00:00 2001 From: Andreas Kienle Date: Fri, 30 May 2025 09:30:10 +0200 Subject: [PATCH 6/6] Fix spelling --- public/locales/en.json | 4 ++-- src/components/ControlPlane/Landscapers.tsx | 4 ++-- src/lib/api/types/landscaper/listInstallations.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/public/locales/en.json b/public/locales/en.json index 9c54d5de..63ff429e 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -158,12 +158,12 @@ "Landscapers": { "headerLandscapers": "Landscapers", "multiComboBoxPlaceholder": "Select namespace", - "noItemsFound": "No Deploy items found", + "noItemsFound": "No Deploy Items found", "deployItems": "Deploy Items", "treeDeployItem": "Deploy Item", "treeInstallation": "Installation", "treeExecution": "Execution", - "noExecutionFound": "No Exeuctions found" + "noExecutionFound": "No Executions found" }, "CopyButton": { "copiedMessage": "Copied To Clipboard", diff --git a/src/components/ControlPlane/Landscapers.tsx b/src/components/ControlPlane/Landscapers.tsx index af9d3c46..15c93ba9 100644 --- a/src/components/ControlPlane/Landscapers.tsx +++ b/src/components/ControlPlane/Landscapers.tsx @@ -15,7 +15,7 @@ import useResource, { import { ListNamespaces } from '../../lib/api/types/k8s/listNamespaces'; import { Installation, - InstalationsRequest, + InstallationsRequest, } from '../../lib/api/types/landscaper/listInstallations'; import { Execution, @@ -39,7 +39,7 @@ export function Landscapers() { const { data: installations = [] } = useMultipleApiResources( selectedNamespaces, - InstalationsRequest, + InstallationsRequest, ); const { data: executions = [] } = useMultipleApiResources( diff --git a/src/lib/api/types/landscaper/listInstallations.ts b/src/lib/api/types/landscaper/listInstallations.ts index 4c970dd1..0fc64fcf 100644 --- a/src/lib/api/types/landscaper/listInstallations.ts +++ b/src/lib/api/types/landscaper/listInstallations.ts @@ -21,12 +21,12 @@ export interface Installation { }; } -export interface InstalationsListResponse { +export interface InstallationsListResponse { items: Installation[]; } -export const InstalationsRequest = ( +export const InstallationsRequest = ( namespace: string, -): Resource => ({ +): Resource => ({ path: `/apis/landscaper.gardener.cloud/v1alpha1/namespaces/${namespace}/installations`, });