diff --git a/public/locales/en.json b/public/locales/en.json index d92cd02b..597090b5 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -27,6 +27,13 @@ "tableHeaderSynced": "Synced", "tableHeaderReady": "Ready" }, + "ProvidersConfig": { + "headerProviderConfigs": "Provider Configs", + "tableHeaderProvider": "Provider", + "tableHeaderName": "Name", + "tableHeaderCreated": "Created", + "tableHeaderUsage": "Usage" + }, "ControlPlaneListToolbar": { "buttonText": "Workspace" }, @@ -150,9 +157,6 @@ "tableHeaderInstalled": "Installed", "tableHeaderHealthy": "Healthy" }, - "ProvidersConfig": { - "header": "Provider Configs" - }, "validationErrors": { "required": "This field is required!", "properFormatting": "Use A-Z, a-z, 0-9, hyphen (-), and period (.), but note that whitespace (spaces, tabs, etc.) is not allowed for proper compatibility.", diff --git a/src/components/ControlPlane/ComponentList.tsx b/src/components/ControlPlane/ComponentList.tsx index a34aa794..60d128e6 100644 --- a/src/components/ControlPlane/ComponentList.tsx +++ b/src/components/ControlPlane/ComponentList.tsx @@ -49,7 +49,7 @@ export default function ComponentList({ mcp }: { mcp: ControlPlaneType }) { columns={componentTableColumns} minRows={0} data={data} - style={{marginLeft: "12px", marginRight: "12px"}} + style={{ marginLeft: '12px', marginRight: '12px' }} /> ); diff --git a/src/components/ControlPlane/ManagedResources.tsx b/src/components/ControlPlane/ManagedResources.tsx index e20314eb..c2fcbf05 100644 --- a/src/components/ControlPlane/ManagedResources.tsx +++ b/src/components/ControlPlane/ManagedResources.tsx @@ -3,7 +3,6 @@ import { AnalyticalTable, AnalyticalTableColumnDefinition, AnalyticalTableScaleWidthMode, - Icon, Title, } from '@ui5/webcomponents-react'; import useResource from '../../lib/api/useApiResource'; @@ -13,7 +12,7 @@ import IllustratedError from '../Shared/IllustratedError'; import '@ui5/webcomponents-icons/dist/sys-enter-2'; import '@ui5/webcomponents-icons/dist/sys-cancel-2'; import { resourcesInterval } from '../../lib/shared/constants'; -import { StatusCellProps } from '../../lib/shared/interfaces'; +import { ResourceStatusCell } from '../Shared/ResourceStatusCell'; interface CellData { cell: { @@ -140,14 +139,3 @@ export function ManagedResources() { ); } - -function ResourceStatusCell({ value, transitionTime }: StatusCellProps) { - return ( - - ); -} diff --git a/src/components/ControlPlane/Providers.tsx b/src/components/ControlPlane/Providers.tsx index 92ad9287..06e5aed9 100644 --- a/src/components/ControlPlane/Providers.tsx +++ b/src/components/ControlPlane/Providers.tsx @@ -1,6 +1,10 @@ - import { useTranslation } from 'react-i18next'; -import { AnalyticalTable, AnalyticalTableColumnDefinition, AnalyticalTableScaleWidthMode, Icon, Title } from '@ui5/webcomponents-react'; +import { + AnalyticalTable, + AnalyticalTableColumnDefinition, + AnalyticalTableScaleWidthMode, + Title, +} from '@ui5/webcomponents-react'; import useResource from '../../lib/api/useApiResource'; import IllustratedError from '../Shared/IllustratedError'; import '@ui5/webcomponents-icons/dist/sys-enter-2'; @@ -8,32 +12,36 @@ import '@ui5/webcomponents-icons/dist/sys-cancel-2'; import { ProvidersListRequest } from '../../lib/api/types/crossplane/listProviders'; import { resourcesInterval } from '../../lib/shared/constants'; import { timeAgo } from '../../utils/i18n/timeAgo'; -import { StatusCellProps } from '../../lib/shared/interfaces'; +import { ResourceStatusCell } from '../Shared/ResourceStatusCell'; interface CellData { cell: { value: T | null; // null for grouping rows row: { original?: ProvidersRow; // missing for grouping rows - } + }; }; } type ProvidersRow = { - name: string + name: string; version: string; healthy: boolean; healthyTransitionTime: string; installed: boolean; installedTransitionTime: string; created: string; -} +}; export function Providers() { const { t } = useTranslation(); - let {data: providers, error, isLoading} = useResource(ProvidersListRequest, { - refreshInterval: resourcesInterval + const { + data: providers, + error, + isLoading, + } = useResource(ProvidersListRequest, { + refreshInterval: resourcesInterval, }); const columns: AnalyticalTableColumnDefinition[] = [ @@ -48,12 +56,24 @@ export function Providers() { { Header: t('Providers.tableHeaderInstalled'), accessor: 'installed', - Cell: (cellData: CellData) => cellData.cell.row.original?.installed != null ? : null + Cell: (cellData: CellData) => + cellData.cell.row.original?.installed != null ? ( + + ) : null, }, { Header: t('Providers.tableHeaderHealthy'), accessor: 'healthy', - Cell: (cellData: CellData) => cellData.cell.row.original?.installed != null ? : null + Cell: (cellData: CellData) => + cellData.cell.row.original?.installed != null ? ( + + ) : null, }, { Header: t('Providers.tableHeaderCreated'), @@ -63,28 +83,31 @@ export function Providers() { const rows: ProvidersRow[] = providers?.items?.map((item) => { - const installed = item.status.conditions?.find((condition) => condition.type === 'Installed'); - const healthy = item.status.conditions?.find((condition) => condition.type === 'Healthy'); + const installed = item.status.conditions?.find( + (condition) => condition.type === 'Installed', + ); + const healthy = item.status.conditions?.find( + (condition) => condition.type === 'Healthy', + ); return { name: item.metadata.name, created: timeAgo.format(new Date(item.metadata.creationTimestamp)), - installed: installed?.status === "True", - installedTransitionTime: installed?.lastTransitionTime ?? "", - healthy: healthy?.status === "True", - healthyTransitionTime: healthy?.lastTransitionTime ?? "", - version: item.spec.package.match(/\d+(\.\d+)+/g)?.toString() ?? "", - } - }) - ?? []; + installed: installed?.status === 'True', + installedTransitionTime: installed?.lastTransitionTime ?? '', + healthy: healthy?.status === 'True', + healthyTransitionTime: healthy?.lastTransitionTime ?? '', + version: item.spec.package.match(/\d+(\.\d+)+/g)?.toString() ?? '', + }; + }) ?? []; return ( <> - {t('Providers.headerProviders')} + {t('Providers.headerProviders')} - {error && } + {error && } - {!error && + {!error && ( - } + )} - ) -} - -function ResourceStatusCell({ value, transitionTime }: StatusCellProps) { - return + ); } diff --git a/src/components/ControlPlane/ProvidersConfig.tsx b/src/components/ControlPlane/ProvidersConfig.tsx index 74646ac5..edef4cc8 100644 --- a/src/components/ControlPlane/ProvidersConfig.tsx +++ b/src/components/ControlPlane/ProvidersConfig.tsx @@ -1,40 +1,87 @@ - import { useTranslation } from 'react-i18next'; -import { AnalyticalTable, AnalyticalTableColumnDefinition, AnalyticalTableScaleWidthMode, Title } from '@ui5/webcomponents-react'; +import { + AnalyticalTable, + AnalyticalTableColumnDefinition, + AnalyticalTableScaleWidthMode, + Title, +} from '@ui5/webcomponents-react'; import '@ui5/webcomponents-icons/dist/sys-enter-2'; import '@ui5/webcomponents-icons/dist/sys-cancel-2'; +import { useProvidersConfigResource } from '../../lib/api/useApiResource'; +import { timeAgo } from '../../utils/i18n/timeAgo'; + +type Rows = { + parent: string; + name: string; + usage: string; + created: string; +}; -//empty table TBD export function ProvidersConfig() { const { t } = useTranslation(); + const rows: Rows[] = []; + + const { data: providerConfigsList, isLoading } = useProvidersConfigResource({ + refreshInterval: 60000, // Resources are quite expensive to fetch, so we refresh every 60 seconds + }); + + if (providerConfigsList) { + providerConfigsList.forEach((provider) => { + provider.items.forEach((config) => { + rows.push({ + parent: provider.provider, + name: config.metadata.name, + usage: config.metadata.usage ? config.metadata.usage : '0', + created: timeAgo.format(new Date(config.metadata.creationTimestamp)), + }); + }); + }); + } - const columns: AnalyticalTableColumnDefinition[] = []; + const columns: AnalyticalTableColumnDefinition[] = [ + { + Header: t('ProvidersConfig.tableHeaderProvider'), + accessor: 'parent', + }, + { + Header: t('ProvidersConfig.tableHeaderName'), + accessor: 'name', + }, + { + Header: t('ProvidersConfig.tableHeaderUsage'), + accessor: 'usage', + }, + { + Header: t('ProvidersConfig.tableHeaderCreated'), + accessor: 'created', + }, + ]; return ( <> - {t('ProvidersConfig.header')} - + {t('ProvidersConfig.headerProviderConfigs')} + - ) + ); } diff --git a/src/components/ControlPlane/ProvidersList.tsx b/src/components/ControlPlane/ProvidersList.tsx deleted file mode 100644 index 5d00613f..00000000 --- a/src/components/ControlPlane/ProvidersList.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Providers } from "./Providers.tsx"; -import { ProvidersConfig } from "./ProvidersConfig.tsx"; -import { ManagedResources } from './ManagedResources'; - -export default function ProvidersList() { - return ( - <> - - - - - ); -} diff --git a/src/components/ControlPlanes/List/ControlPlaneListToolbar.tsx b/src/components/ControlPlanes/List/ControlPlaneListToolbar.tsx index 0b8578c7..56f9c3ed 100644 --- a/src/components/ControlPlanes/List/ControlPlaneListToolbar.tsx +++ b/src/components/ControlPlanes/List/ControlPlaneListToolbar.tsx @@ -1,21 +1,30 @@ -import { Toolbar, ToolbarButton } from "@ui5/webcomponents-react"; -import { useState } from "react"; +import { Toolbar, ToolbarButton } from '@ui5/webcomponents-react'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import {CreateWorkspaceDialogContainer} from "../../Dialogs/CreateWorkspaceDialogContainer.tsx"; +import { CreateWorkspaceDialogContainer } from '../../Dialogs/CreateWorkspaceDialogContainer.tsx'; - - -export function ControlPlaneListToolbar({ projectName }: { projectName: string }) { +export function ControlPlaneListToolbar({ + projectName, +}: { + projectName: string; +}) { const [dialogCreateProjectIsOpen, setDialogIsOpen] = useState(false); const { t } = useTranslation(); return ( <> - setDialogIsOpen(true)} /> + setDialogIsOpen(true)} + /> - + - ) - -} \ No newline at end of file + ); +} diff --git a/src/components/Shared/ResourceStatusCell.tsx b/src/components/Shared/ResourceStatusCell.tsx new file mode 100644 index 00000000..8062a2fd --- /dev/null +++ b/src/components/Shared/ResourceStatusCell.tsx @@ -0,0 +1,14 @@ +import { Icon } from '@ui5/webcomponents-react'; +import { StatusCellProps } from '../../lib/shared/interfaces'; +import { timeAgo } from '../../utils/i18n/timeAgo'; + +export function ResourceStatusCell({ value, transitionTime }: StatusCellProps) { + return ( + + ); +} diff --git a/src/index.css b/src/index.css index 892b2824..d9da15ec 100644 --- a/src/index.css +++ b/src/index.css @@ -94,6 +94,10 @@ background-color: #FFC933; } +.crossplane-table-element { + margin-bottom: 25px; +} + .cp-panel-gitops { background-color: #D1EFFF; } diff --git a/src/lib/api/types/crossplane/CRDList.ts b/src/lib/api/types/crossplane/CRDList.ts new file mode 100644 index 00000000..9cbd3259 --- /dev/null +++ b/src/lib/api/types/crossplane/CRDList.ts @@ -0,0 +1,42 @@ +import { Resource } from '../resource'; + +export type CRDResponse = { + items: [ + { + metadata: { + name: string; + creationTimestamp: string; + ownerReferences: [ + { + kind: string; + name: string; + }, + ]; + }; + status: { + conditions: [ + { + type: 'Ready' | 'Synced' | unknown; + status: 'True' | 'False'; + lastTransitionTime: string; + }, + ]; + }; + spec: { + names: { + kind: string; + }; + versions: [ + { + name: string; + }, + ]; + group: string; + }; + }, + ]; +}; + +export const CRDRequest: Resource = { + path: '/apis/apiextensions.k8s.io/v1/customresourcedefinitions', +}; diff --git a/src/lib/api/types/crossplane/listProviders.ts b/src/lib/api/types/crossplane/listProviders.ts index ffd92c4b..e6ccb201 100644 --- a/src/lib/api/types/crossplane/listProviders.ts +++ b/src/lib/api/types/crossplane/listProviders.ts @@ -1,7 +1,8 @@ import { Resource } from '../resource'; - export type ProvidersListResponse = { - items: [{ +export type ProvidersListResponse = { + items: [ + { spec: { package: string; }; @@ -11,15 +12,18 @@ import { Resource } from '../resource'; creationTimestamp: string; }; status: { - conditions: [{ - type: "Healthy" | "Installed" | unknown; - status: "True" | "False"; - lastTransitionTime: string; - }] + conditions: [ + { + type: 'Healthy' | 'Installed' | unknown; + status: 'True' | 'False'; + lastTransitionTime: string; + }, + ]; }; - }]; - }; - - export const ProvidersListRequest: Resource = { - path: "/apis/pkg.crossplane.io/v1/providers", - }; \ No newline at end of file + }, + ]; +}; + +export const ProvidersListRequest: Resource = { + path: '/apis/pkg.crossplane.io/v1/providers', +}; diff --git a/src/lib/api/useApiResource.ts b/src/lib/api/useApiResource.ts index 83a4ecc1..262d140c 100644 --- a/src/lib/api/useApiResource.ts +++ b/src/lib/api/useApiResource.ts @@ -1,4 +1,4 @@ -import { useContext } from 'react'; +import { useContext, useEffect, useState } from 'react'; import useSWR, { SWRConfiguration, useSWRConfig } from 'swr'; import { fetchApiServerJson } from './fetch'; import { ApiConfigContext } from '../../components/Shared/k8s'; @@ -7,6 +7,12 @@ import { ApiConfig } from './types/apiConfig'; import { Resource } from './types/resource'; import useSWRMutation, { SWRMutationConfiguration } from 'swr/mutation'; import { MutatorOptions } from 'swr/_internal'; +import { CRDRequest, CRDResponse } from './types/crossplane/CRDList'; +import { + ProviderConfigs, + ProviderConfigsData, + ProviderConfigsDataForRequest, +} from '../shared/types'; export { useApiResource as default }; @@ -39,6 +45,128 @@ export const useApiResource = ( }; }; +export const useProvidersConfigResource = (config?: SWRConfiguration) => { + const apiConfig = useContext(ApiConfigContext); + const { data, error, isValidating } = useSWR( + CRDRequest.path === null + ? null //TODO: is null a valid key? + : [CRDRequest.path, apiConfig], + ([path, apiConfig]) => + fetchApiServerJson( + path, + apiConfig, + CRDRequest.jq, + CRDRequest.method, + CRDRequest.body, + ), + config, + ); + + const providerConfigsDataForRequest: ProviderConfigsDataForRequest[] = []; + + const crdWithProviderConfig = data?.items.filter( + (x) => x.spec.names.kind === 'ProviderConfig', + ); + + const providerConfigsData: ProviderConfigsData[] = + crdWithProviderConfig?.map((item) => { + const providerName = item.metadata.ownerReferences.find( + (x) => x.kind === 'Provider', + )?.name; + + return { + provider: providerName ? providerName : '', + name: item.spec.group, + versions: item.spec.versions, + }; + }) ?? []; + + if (providerConfigsData.length > 0) { + providerConfigsData.forEach((item) => { + item.versions.forEach((version) => { + providerConfigsDataForRequest.push({ + provider: item.provider, + url: item.name, + version: version.name, + }); + }); + }); + } + + const fetchProviderConfigsData = async () => { + const promises = providerConfigsDataForRequest.map(async (item) => { + const data = await fetchApiServerJson( + `/apis/${item.url ?? ''}/${item.version}/providerconfigs`, + apiConfig, + CRDRequest.jq, + CRDRequest.method, + CRDRequest.body, + ); + if (data) { + providerConfigs.push(data); + } + }); + + await Promise.all(promises); + }; + + const providerConfigs: ProviderConfigs[] = []; + + const fetchProviderConfigs = async () => { + try { + // Create an array of promises for each fetch call + const fetchPromises = providerConfigsDataForRequest.map(async (item) => { + const data = await fetchApiServerJson( + `/apis/${item.url ?? ''}/${item.version}/providerconfigs`, + apiConfig, + CRDRequest.jq, + CRDRequest.method, + CRDRequest.body, + ); + data.provider = item.provider; + return data; // Return fetched data + }); + + // Wait for all fetch operations to complete + const providerConfigs = await Promise.all(fetchPromises); + + // Filter out any null/undefined values and return the valid data + return providerConfigs.filter((config) => config !== null); + } catch (error) { + console.error('Error fetching provider configs:', error); + return []; // Return an empty array in case of error + } + }; + const [configs, setConfigs] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchDataAndUpdateState = async () => { + setIsLoading(true); + try { + await fetchProviderConfigsData(); + const finalData = await fetchProviderConfigs(); + + setConfigs(finalData); + if (finalData.length > 0) { + setIsLoading(false); + } + } catch (err) { + setIsLoading(false); + } + }; + + fetchDataAndUpdateState(); + }, [data]); + + return { + data: configs, + error: error as APIError, + isLoading: isLoading, + isValidating: isValidating, + }; +}; + export const useApiResourceMutation = ( resource: Resource, config?: SWRMutationConfiguration, diff --git a/src/lib/shared/interfaces.ts b/src/lib/shared/interfaces.ts index 5493b25d..cba82c1e 100644 --- a/src/lib/shared/interfaces.ts +++ b/src/lib/shared/interfaces.ts @@ -1,4 +1,4 @@ export interface StatusCellProps { - value: boolean; - transitionTime: string; -} \ No newline at end of file + value: boolean; + transitionTime: string; +} diff --git a/src/lib/shared/types.ts b/src/lib/shared/types.ts new file mode 100644 index 00000000..6b8726c2 --- /dev/null +++ b/src/lib/shared/types.ts @@ -0,0 +1,33 @@ +export type ProviderConfigsData = { + provider: string; + name: string; + versions: [ + { + name: string; + }, + ]; +}; + +export type ProviderConfigsDataForRequest = { + provider: string; + url: string; + version: string; +}; + +export type ProviderConfigs = { + provider: string; + items: [ + { + kind: string; + metadata: { + provider: string; + name: string; + usage: string; + creationTimestamp: string; + }; + status: { + count: string; + }; + }, + ]; +}; diff --git a/src/views/ControlPlanes/ControlPlaneView.tsx b/src/views/ControlPlanes/ControlPlaneView.tsx index 722f68a5..f4e3ded9 100644 --- a/src/views/ControlPlanes/ControlPlaneView.tsx +++ b/src/views/ControlPlanes/ControlPlaneView.tsx @@ -17,13 +17,15 @@ import { McpContextProvider, WithinManagedControlPlane, } from '../../lib/shared/McpContext.tsx'; -import ProvidersList from '../../components/ControlPlane/ProvidersList.tsx'; import FluxList from '../../components/ControlPlane/FluxList.tsx'; import { ControlPlane as ControlPlaneResource } from '../../lib/api/types/crate/controlPlanes.ts'; import useResource from '../../lib/api/useApiResource.ts'; import MCPHealthPopoverButton from '../../components/ControlPlane/MCPHealthPopoverButton.tsx'; import ComponentList from '../../components/ControlPlane/ComponentList.tsx'; import { useTranslation } from 'react-i18next'; +import { ManagedResources } from '../../components/ControlPlane/ManagedResources.tsx'; +import { Providers } from '../../components/ControlPlane/Providers.tsx'; +import { ProvidersConfig } from '../../components/ControlPlane/ProvidersConfig.tsx'; export default function ControlPlaneView() { const { projectName, workspaceName, controlPlaneName, contextName } = @@ -122,7 +124,15 @@ export default function ControlPlaneView() { } noAnimation > - +
+ +
+
+ +
+
+ +