diff --git a/public/locales/en.json b/public/locales/en.json index 85615579..63ff429e 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -149,11 +149,22 @@ "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" }, + "Landscapers": { + "headerLandscapers": "Landscapers", + "multiComboBoxPlaceholder": "Select namespace", + "noItemsFound": "No Deploy Items found", + "deployItems": "Deploy Items", + "treeDeployItem": "Deploy Item", + "treeInstallation": "Installation", + "treeExecution": "Execution", + "noExecutionFound": "No Executions found" + }, "CopyButton": { "copiedMessage": "Copied To Clipboard", "failedMessage": "Failed to copy" diff --git a/src/components/ControlPlane/Landscapers.tsx b/src/components/ControlPlane/Landscapers.tsx new file mode 100644 index 00000000..15c93ba9 --- /dev/null +++ b/src/components/ControlPlane/Landscapers.tsx @@ -0,0 +1,179 @@ +import { useTranslation } from 'react-i18next'; +import { + MultiComboBox, + MultiComboBoxDomRef, + MultiComboBoxItem, + Tree, + TreeItem, + Ui5CustomEvent, +} from '@ui5/webcomponents-react'; +import { useState, JSX } from 'react'; +import { resourcesInterval } from '../../lib/shared/constants'; +import useResource, { + useMultipleApiResources, +} from '../../lib/api/useApiResource'; +import { ListNamespaces } from '../../lib/api/types/k8s/listNamespaces'; +import { + Installation, + InstallationsRequest, +} 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 { data: namespaces } = useResource(ListNamespaces, { + refreshInterval: resourcesInterval, + }); + + const [selectedNamespaces, setSelectedNamespaces] = useState([]); + + const { data: installations = [] } = useMultipleApiResources( + selectedNamespaces, + InstallationsRequest, + ); + + 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) => item.text) + .filter((text): text is string => typeof text === 'string'); + + setSelectedNamespaces(selectedValues); + }; + + const getStatusSymbol = (phase?: string) => { + if (!phase) return '⚪'; + + 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, + ); + + 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[]) || []; + + return ( + + {subInstallations.length > 0 ? ( + subInstallations.map((sub) => renderTreeItems(sub)) + ) : ( + <> + + + + {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 ( + <> + {namespaces && ( + + {namespaces.map((ns) => ( + + ))} + + )} + {rootInstallations.map((inst) => renderTreeItems(inst))} + + ); +} diff --git a/src/lib/api/types/landscaper/hooks.ts b/src/lib/api/types/landscaper/hooks.ts deleted file mode 100644 index b007cde6..00000000 --- a/src/lib/api/types/landscaper/hooks.ts +++ /dev/null @@ -1,55 +0,0 @@ -import useApiResource from '../../useApiResource'; -import { ListGraphInstallations } from './listInstallations'; -import { ListGraphExecutions } from './listExecutions'; -import { ListGraphDeployItems } from './listDeployItems'; - -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); - - 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..20828781 100644 --- a/src/lib/api/types/landscaper/listDeployItems.ts +++ b/src/lib/api/types/landscaper/listDeployItems.ts @@ -1,20 +1,24 @@ import { Resource } from '../resource'; -interface GraphDeployItemsType { +export interface DeployItem { + objectName: string; metadata: { name: string; namespace: string; uid: string; - ownerReferences: { - 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 interface DeployItemsListResponse { + items: DeployItem[]; +} + +export const DeployItemsRequest = ( + namespace: string, +): Resource => ({ + 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 dab34672..9405fa7f 100644 --- a/src/lib/api/types/landscaper/listExecutions.ts +++ b/src/lib/api/types/landscaper/listExecutions.ts @@ -1,20 +1,28 @@ import { Resource } from '../resource'; -interface GraphExecutionsType { +export interface Execution { + objectName: string; metadata: { name: string; namespace: string; uid: string; - ownerReferences: { - uid: string; - }[]; }; - status: { - phase: string; + status?: { + phase?: string; + deployItemCache?: { + activeDIs?: { + objectName: 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 interface ExecutionsListResponse { + items: Execution[]; +} + +export const ExecutionsRequest = ( + namespace: string, +): Resource => ({ + path: `/apis/landscaper.gardener.cloud/v1alpha1/namespaces/${namespace}/executions`, +}); diff --git a/src/lib/api/types/landscaper/listInstallations.ts b/src/lib/api/types/landscaper/listInstallations.ts index 589111f5..0fc64fcf 100644 --- a/src/lib/api/types/landscaper/listInstallations.ts +++ b/src/lib/api/types/landscaper/listInstallations.ts @@ -1,36 +1,32 @@ import { Resource } from '../resource'; -interface GraphInstallationsType { +export interface Installation { + objectName: string; metadata: { name: string; namespace: string; uid: string; - ownerReferences: { - uid: string; - }[]; }; - status: { - phase: string; + status?: { + phase?: string; + executionRef?: { + name: string; + namespace: string; + }; + subInstCache?: { + activeSubs?: { + objectName: 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 interface InstallationsListResponse { + items: Installation[]; } -export const ListTableInstallations: Resource = { - path: '/apis/landscaper.gardener.cloud/v1alpha1/installations', - jq: '[.items[] | {metadata: .metadata | {name, namespace, creationTimestamp}, status: .status | {phase}}]', -}; +export const InstallationsRequest = ( + namespace: string, +): Resource => ({ + path: `/apis/landscaper.gardener.cloud/v1alpha1/namespaces/${namespace}/installations`, +}); 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; +} 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 + > + + +