From 7ba813b380806132f620e24d1f9931b216b27efa Mon Sep 17 00:00:00 2001 From: Rastislav Wagner Date: Tue, 3 Mar 2026 16:10:26 +0100 Subject: [PATCH] EDM-2947: Unified Software Catalog & Install (#533) (cherry picked from commit 65deb962597e50b388964a3b3d23c10aff9a89d4) --- apps/ocp-plugin/console-extensions.json | 42 + apps/ocp-plugin/package.json | 7 +- .../src/components/AppContext/AppContext.tsx | 4 + .../Catalog/CatalogEditDeviceWizard.tsx | 13 + .../Catalog/CatalogEditFleetWizard.tsx | 13 + .../Catalog/CatalogInstallWizard.tsx | 13 + .../src/components/Catalog/CatalogPage.tsx | 13 + apps/ocp-plugin/src/typings.d.ts | 15 + apps/ocp-plugin/src/utils/apiCalls.ts | 10 +- apps/ocp-plugin/tsconfig.json | 1 + apps/ocp-plugin/webpack.config.ts | 10 +- apps/standalone/package.json | 1 + apps/standalone/src/app/routes.tsx | 59 + apps/standalone/src/app/utils/apiCalls.ts | 9 +- apps/standalone/tsconfig.json | 1 + apps/standalone/webpack.config.ts | 10 +- libs/i18n/locales/en/translation.json | 172 ++- libs/types/alpha/index.ts | 24 + libs/types/alpha/models/ApiVersion.ts | 11 + libs/types/alpha/models/Catalog.ts | 19 + libs/types/alpha/models/CatalogItem.ts | 20 + .../types/alpha/models/CatalogItemArtifact.ts | 20 + .../alpha/models/CatalogItemArtifactType.ts | 18 + .../types/alpha/models/CatalogItemCategory.ts | 11 + .../alpha/models/CatalogItemConfigurable.ts | 22 + .../alpha/models/CatalogItemDeprecation.ts | 18 + libs/types/alpha/models/CatalogItemList.ts | 23 + libs/types/alpha/models/CatalogItemMeta.ts | 15 + .../alpha/models/CatalogItemReference.ts | 19 + libs/types/alpha/models/CatalogItemSpec.ts | 55 + libs/types/alpha/models/CatalogItemType.ts | 17 + libs/types/alpha/models/CatalogItemVersion.ts | 46 + .../alpha/models/CatalogItemVisibility.ts | 11 + libs/types/alpha/models/CatalogList.ts | 23 + libs/types/alpha/models/CatalogSpec.ts | 32 + libs/types/alpha/models/CatalogStatus.ts | 15 + libs/types/alpha/models/Status.ts | 32 + libs/types/package.json | 5 + libs/types/scripts/openapi-typescript.js | 31 +- libs/types/scripts/openapi-utils.js | 39 +- .../assets/flight-control-logo.png | Bin 0 -> 4508 bytes libs/ui-components/package.json | 10 +- .../components/Catalog/CatalogItemCard.tsx | 86 ++ .../components/Catalog/CatalogItemDetails.css | 4 + .../components/Catalog/CatalogItemDetails.tsx | 294 ++++ .../src/components/Catalog/CatalogPage.css | 3 + .../src/components/Catalog/CatalogPage.tsx | 285 ++++ .../components/Catalog/CatalogPageToolbar.tsx | 36 + .../Catalog/EditWizard/EditAppWizard.tsx | 188 +++ .../Catalog/EditWizard/EditOsWizard.tsx | 136 ++ .../Catalog/EditWizard/EditWizard.tsx | 278 ++++ .../Catalog/EditWizard/steps/ReviewStep.tsx | 83 ++ .../Catalog/EditWizard/steps/UpdateGraph.css | 4 + .../Catalog/EditWizard/steps/UpdateGraph.tsx | 313 +++++ .../Catalog/EditWizard/steps/UpdateStep.tsx | 138 ++ .../components/Catalog/EditWizard/types.ts | 3 + .../InstallWizard/InstallAppWizard.tsx | 228 +++ .../Catalog/InstallWizard/InstallOsWizard.tsx | 197 +++ .../Catalog/InstallWizard/InstallWizard.tsx | 80 ++ .../InstallWizard/UpdateSuccessPage.tsx | 80 ++ .../InstallWizard/steps/AppConfigStep.css | 12 + .../InstallWizard/steps/AppConfigStep.tsx | 202 +++ .../InstallWizard/steps/ReviewStep.tsx | 192 +++ .../InstallWizard/steps/SelectTargetStep.tsx | 319 +++++ .../steps/SpecificationsStep.tsx | 309 ++++ .../components/Catalog/InstallWizard/types.ts | 37 + .../components/Catalog/InstallWizard/utils.ts | 106 ++ .../components/Catalog/InstalledSoftware.tsx | 370 +++++ .../ResourceCatalog/ResourceCatalogPage.css | 3 + .../ResourceCatalog/ResourceCatalogPage.tsx | 67 + .../src/components/Catalog/const.ts | 16 + .../components/Catalog/useCatalogFilter.ts | 21 + .../src/components/Catalog/useCatalogs.ts | 106 ++ .../Catalog/useSubmitCatalogForm.ts | 60 + .../src/components/Catalog/utils.ts | 317 +++++ .../DeviceDetails/DeviceDetailsCatalog.tsx | 60 + .../DeviceDetails/DeviceDetailsPage.tsx | 10 +- .../Device/DevicesPage/DevicesPage.tsx | 8 +- .../DevicesPage/EnrolledDeviceTableRow.tsx | 144 +- .../DevicesPage/EnrolledDevicesTable.tsx | 11 +- .../Device/DevicesPage/useDevices.ts | 72 +- .../EditDeviceWizard/EditDeviceWizard.tsx | 2 +- .../steps/ApplicationTemplates.tsx | 69 +- .../steps/DeviceTemplateStep.tsx | 42 +- .../components/DynamicForm/DynamicForm.tsx | 96 ++ .../components/DynamicForm/FieldErrors.tsx | 17 + .../components/DynamicForm/FieldTemplate.tsx | 246 ++++ .../src/components/DynamicForm/FormWidget.tsx | 218 +++ .../DynamicForm/VolumeImageField.tsx | 365 +++++ .../Fleet/CreateFleet/CreateFleetWizard.tsx | 4 +- .../FleetDetails/FleetDetailsCatalog.tsx | 65 + .../Fleet/FleetDetails/FleetDetailsPage.tsx | 5 +- .../src/components/Fleet/FleetRow.tsx | 23 +- .../src/components/Fleet/FleetsPage.tsx | 4 +- .../src/components/Fleet/useFleets.ts | 1 + .../src/components/Table/Table.tsx | 8 +- .../common/CodeEditor/YamlEditor.tsx | 2 +- .../common/CodeEditor/YamlEditorBase.tsx | 79 +- .../common/FlightCtlWizardFooter.tsx | 5 +- .../src/components/common/LabelsView.tsx | 3 +- .../src/components/common/PageNavigation.tsx | 2 +- .../src/components/common/WithHelperText.tsx | 2 +- .../src/components/common/WithTooltip.tsx | 16 +- .../src/components/form/FormSelect.tsx | 15 +- .../src/components/form/LabelsField.tsx | 54 +- libs/ui-components/src/constants.ts | 5 +- .../ui-components/src/hooks/useAppContext.tsx | 4 + libs/ui-components/src/hooks/useNavigate.tsx | 9 +- libs/ui-components/src/utils/api.ts | 4 +- libs/ui-components/tsconfig.json | 1 + package-lock.json | 1252 ++++++++++++++++- 111 files changed, 8150 insertions(+), 275 deletions(-) create mode 100644 apps/ocp-plugin/src/components/Catalog/CatalogEditDeviceWizard.tsx create mode 100644 apps/ocp-plugin/src/components/Catalog/CatalogEditFleetWizard.tsx create mode 100644 apps/ocp-plugin/src/components/Catalog/CatalogInstallWizard.tsx create mode 100644 apps/ocp-plugin/src/components/Catalog/CatalogPage.tsx create mode 100644 apps/ocp-plugin/src/typings.d.ts create mode 100644 libs/types/alpha/index.ts create mode 100644 libs/types/alpha/models/ApiVersion.ts create mode 100644 libs/types/alpha/models/Catalog.ts create mode 100644 libs/types/alpha/models/CatalogItem.ts create mode 100644 libs/types/alpha/models/CatalogItemArtifact.ts create mode 100644 libs/types/alpha/models/CatalogItemArtifactType.ts create mode 100644 libs/types/alpha/models/CatalogItemCategory.ts create mode 100644 libs/types/alpha/models/CatalogItemConfigurable.ts create mode 100644 libs/types/alpha/models/CatalogItemDeprecation.ts create mode 100644 libs/types/alpha/models/CatalogItemList.ts create mode 100644 libs/types/alpha/models/CatalogItemMeta.ts create mode 100644 libs/types/alpha/models/CatalogItemReference.ts create mode 100644 libs/types/alpha/models/CatalogItemSpec.ts create mode 100644 libs/types/alpha/models/CatalogItemType.ts create mode 100644 libs/types/alpha/models/CatalogItemVersion.ts create mode 100644 libs/types/alpha/models/CatalogItemVisibility.ts create mode 100644 libs/types/alpha/models/CatalogList.ts create mode 100644 libs/types/alpha/models/CatalogSpec.ts create mode 100644 libs/types/alpha/models/CatalogStatus.ts create mode 100644 libs/types/alpha/models/Status.ts create mode 100644 libs/ui-components/assets/flight-control-logo.png create mode 100644 libs/ui-components/src/components/Catalog/CatalogItemCard.tsx create mode 100644 libs/ui-components/src/components/Catalog/CatalogItemDetails.css create mode 100644 libs/ui-components/src/components/Catalog/CatalogItemDetails.tsx create mode 100644 libs/ui-components/src/components/Catalog/CatalogPage.css create mode 100644 libs/ui-components/src/components/Catalog/CatalogPage.tsx create mode 100644 libs/ui-components/src/components/Catalog/CatalogPageToolbar.tsx create mode 100644 libs/ui-components/src/components/Catalog/EditWizard/EditAppWizard.tsx create mode 100644 libs/ui-components/src/components/Catalog/EditWizard/EditOsWizard.tsx create mode 100644 libs/ui-components/src/components/Catalog/EditWizard/EditWizard.tsx create mode 100644 libs/ui-components/src/components/Catalog/EditWizard/steps/ReviewStep.tsx create mode 100644 libs/ui-components/src/components/Catalog/EditWizard/steps/UpdateGraph.css create mode 100644 libs/ui-components/src/components/Catalog/EditWizard/steps/UpdateGraph.tsx create mode 100644 libs/ui-components/src/components/Catalog/EditWizard/steps/UpdateStep.tsx create mode 100644 libs/ui-components/src/components/Catalog/EditWizard/types.ts create mode 100644 libs/ui-components/src/components/Catalog/InstallWizard/InstallAppWizard.tsx create mode 100644 libs/ui-components/src/components/Catalog/InstallWizard/InstallOsWizard.tsx create mode 100644 libs/ui-components/src/components/Catalog/InstallWizard/InstallWizard.tsx create mode 100644 libs/ui-components/src/components/Catalog/InstallWizard/UpdateSuccessPage.tsx create mode 100644 libs/ui-components/src/components/Catalog/InstallWizard/steps/AppConfigStep.css create mode 100644 libs/ui-components/src/components/Catalog/InstallWizard/steps/AppConfigStep.tsx create mode 100644 libs/ui-components/src/components/Catalog/InstallWizard/steps/ReviewStep.tsx create mode 100644 libs/ui-components/src/components/Catalog/InstallWizard/steps/SelectTargetStep.tsx create mode 100644 libs/ui-components/src/components/Catalog/InstallWizard/steps/SpecificationsStep.tsx create mode 100644 libs/ui-components/src/components/Catalog/InstallWizard/types.ts create mode 100644 libs/ui-components/src/components/Catalog/InstallWizard/utils.ts create mode 100644 libs/ui-components/src/components/Catalog/InstalledSoftware.tsx create mode 100644 libs/ui-components/src/components/Catalog/ResourceCatalog/ResourceCatalogPage.css create mode 100644 libs/ui-components/src/components/Catalog/ResourceCatalog/ResourceCatalogPage.tsx create mode 100644 libs/ui-components/src/components/Catalog/const.ts create mode 100644 libs/ui-components/src/components/Catalog/useCatalogFilter.ts create mode 100644 libs/ui-components/src/components/Catalog/useCatalogs.ts create mode 100644 libs/ui-components/src/components/Catalog/useSubmitCatalogForm.ts create mode 100644 libs/ui-components/src/components/Catalog/utils.ts create mode 100644 libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsCatalog.tsx create mode 100644 libs/ui-components/src/components/DynamicForm/DynamicForm.tsx create mode 100644 libs/ui-components/src/components/DynamicForm/FieldErrors.tsx create mode 100644 libs/ui-components/src/components/DynamicForm/FieldTemplate.tsx create mode 100644 libs/ui-components/src/components/DynamicForm/FormWidget.tsx create mode 100644 libs/ui-components/src/components/DynamicForm/VolumeImageField.tsx create mode 100644 libs/ui-components/src/components/Fleet/FleetDetails/FleetDetailsCatalog.tsx diff --git a/apps/ocp-plugin/console-extensions.json b/apps/ocp-plugin/console-extensions.json index f2c12b4c2..1548cf6df 100644 --- a/apps/ocp-plugin/console-extensions.json +++ b/apps/ocp-plugin/console-extensions.json @@ -35,6 +35,16 @@ "section": "fctl" } }, + { + "type": "console.navigation/href", + "properties": { + "id": "fctl-catalog", + "name": "%plugin__flightctl-plugin~Software Catalog%", + "href": "/edge/catalog", + "perspective": "acm", + "section": "fctl" + } + }, { "type": "console.navigation/href", "properties": { @@ -87,6 +97,14 @@ "component": { "$codeRef": "FleetDetailsPage" } } }, + { + "type": "console.page/route", + "properties": { + "exact": false, + "path": ["/edge/fleets/catalog/:fleetId/:catalogId/:itemId"], + "component": { "$codeRef": "CatalogEditFleetWizard" } + } + }, { "type": "console.page/route", "properties": { @@ -111,6 +129,30 @@ "component": { "$codeRef": "EditDeviceWizardPage" } } }, + { + "type": "console.page/route", + "properties": { + "exact": false, + "path": ["/edge/devices/catalog/:deviceId/:catalogId/:itemId"], + "component": { "$codeRef": "CatalogEditDeviceWizard" } + } + }, + { + "type": "console.page/route", + "properties": { + "exact": true, + "path": ["/edge/catalog"], + "component": { "$codeRef": "CatalogPage" } + } + }, + { + "type": "console.page/route", + "properties": { + "exact": false, + "path": ["/edge/catalog/install/:catalogId/:itemId"], + "component": { "$codeRef": "CatalogInstallWizard" } + } + }, { "type": "console.page/route", "properties": { diff --git a/apps/ocp-plugin/package.json b/apps/ocp-plugin/package.json index 705ce02a6..c5ec24822 100644 --- a/apps/ocp-plugin/package.json +++ b/apps/ocp-plugin/package.json @@ -28,7 +28,11 @@ "ResourceSyncToRepositoryPage": "./src/components/ResourceSyncs/ResourceSyncToRepositoryPage.tsx", "EnrollmentRequestDetailsPage": "./src/components/EnrollmentRequests/EnrollmentRequestDetailsPage.tsx", "appContext": "./src/components/AppContext/AppContext.tsx", - "OverviewTab": "./src/components/OverviewTab/OverviewTab.tsx" + "OverviewTab": "./src/components/OverviewTab/OverviewTab.tsx", + "CatalogPage": "./src/components/Catalog/CatalogPage.tsx", + "CatalogInstallWizard": "./src/components/Catalog/CatalogInstallWizard.tsx", + "CatalogEditDeviceWizard": "./src/components/Catalog/CatalogEditDeviceWizard.tsx", + "CatalogEditFleetWizard": "./src/components/Catalog/CatalogEditFleetWizard.tsx" }, "dependencies": { "@console/pluginAPI": "*" @@ -74,6 +78,7 @@ "@patternfly/react-icons": "^6.4.0", "@patternfly/react-styles": "^6.4.0", "@patternfly/react-table": "^6.4.0", + "@patternfly/react-topology": "^6.4.0", "@types/react-redux": "^7.1.33", "formik": "^2.4.5", "fuzzysearch": "^1.0.3", diff --git a/apps/ocp-plugin/src/components/AppContext/AppContext.tsx b/apps/ocp-plugin/src/components/AppContext/AppContext.tsx index 87ff7f4a3..ac27850e7 100644 --- a/apps/ocp-plugin/src/components/AppContext/AppContext.tsx +++ b/apps/ocp-plugin/src/components/AppContext/AppContext.tsx @@ -64,6 +64,10 @@ const appRoutes = { [ROUTE.AUTH_PROVIDER_CREATE]: '/', [ROUTE.AUTH_PROVIDER_EDIT]: '/', [ROUTE.AUTH_PROVIDER_DETAILS]: '/', + [ROUTE.CATALOG]: '/edge/catalog', + [ROUTE.CATALOG_INSTALL]: '/edge/catalog/install', + [ROUTE.CATALOG_FLEET_EDIT]: '/edge/fleets/catalog', + [ROUTE.CATALOG_DEVICE_EDIT]: '/edge/devices/catalog', }; export const useValuesAppContext = (): AppContextProps => { diff --git a/apps/ocp-plugin/src/components/Catalog/CatalogEditDeviceWizard.tsx b/apps/ocp-plugin/src/components/Catalog/CatalogEditDeviceWizard.tsx new file mode 100644 index 000000000..d13133762 --- /dev/null +++ b/apps/ocp-plugin/src/components/Catalog/CatalogEditDeviceWizard.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { EditDeviceWizard } from '@flightctl/ui-components/src/components/Catalog/EditWizard/EditWizard'; +import WithPageLayout from '../common/WithPageLayout'; + +const CatalogEditDeviceWizard = () => { + return ( + + + + ); +}; + +export default CatalogEditDeviceWizard; diff --git a/apps/ocp-plugin/src/components/Catalog/CatalogEditFleetWizard.tsx b/apps/ocp-plugin/src/components/Catalog/CatalogEditFleetWizard.tsx new file mode 100644 index 000000000..4386c248d --- /dev/null +++ b/apps/ocp-plugin/src/components/Catalog/CatalogEditFleetWizard.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { EditFleetWizard } from '@flightctl/ui-components/src/components/Catalog/EditWizard/EditWizard'; +import WithPageLayout from '../common/WithPageLayout'; + +const CatalogEditFleetWizard = () => { + return ( + + + + ); +}; + +export default CatalogEditFleetWizard; diff --git a/apps/ocp-plugin/src/components/Catalog/CatalogInstallWizard.tsx b/apps/ocp-plugin/src/components/Catalog/CatalogInstallWizard.tsx new file mode 100644 index 000000000..b0befa876 --- /dev/null +++ b/apps/ocp-plugin/src/components/Catalog/CatalogInstallWizard.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import InstallWizard from '@flightctl/ui-components/src/components/Catalog/InstallWizard/InstallWizard'; +import WithPageLayout from '../common/WithPageLayout'; + +const OcpInstallWizard = () => { + return ( + + + + ); +}; + +export default OcpInstallWizard; diff --git a/apps/ocp-plugin/src/components/Catalog/CatalogPage.tsx b/apps/ocp-plugin/src/components/Catalog/CatalogPage.tsx new file mode 100644 index 000000000..fd34d2533 --- /dev/null +++ b/apps/ocp-plugin/src/components/Catalog/CatalogPage.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import CatalogPage from '@flightctl/ui-components/src/components/Catalog/CatalogPage'; +import WithPageLayout from '../common/WithPageLayout'; + +const OcpCatalogPage = () => { + return ( + + + + ); +}; + +export default OcpCatalogPage; diff --git a/apps/ocp-plugin/src/typings.d.ts b/apps/ocp-plugin/src/typings.d.ts new file mode 100644 index 000000000..77b9725af --- /dev/null +++ b/apps/ocp-plugin/src/typings.d.ts @@ -0,0 +1,15 @@ +declare module '*.png'; +declare module '*.jpg'; +declare module '*.jpeg'; +declare module '*.gif'; +declare module '*.svg' { + const content: string; + export default content; +} +declare module '*.css'; +declare module '*.wav'; +declare module '*.mp3'; +declare module '*.m4a'; +declare module '*.rdf'; +declare module '*.ttl'; +declare module '*.pdf'; diff --git a/apps/ocp-plugin/src/utils/apiCalls.ts b/apps/ocp-plugin/src/utils/apiCalls.ts index 31b26e3fb..1f16d8c52 100644 --- a/apps/ocp-plugin/src/utils/apiCalls.ts +++ b/apps/ocp-plugin/src/utils/apiCalls.ts @@ -16,7 +16,7 @@ declare global { } } -type Api = 'flightctl' | 'imagebuilder' | 'alerts'; +type Api = 'flightctl' | 'imagebuilder' | 'alerts' | 'catalog'; const addRequiredHeaders = (options: RequestInit, api?: Api): RequestInit => { const token = getCSRFToken(); @@ -47,6 +47,8 @@ export const uiProxy = `${window.location.protocol}//${apiServer}`; const flightCtlAPI = `${uiProxy}/api/flightctl`; const alertsAPI = `${uiProxy}/api/alerts`; const imageBuilderPathRegex = /^image(builds|exports)/; +const catalogPathRegex = /^(catalogs|catalogitems)/; + export const wsEndpoint = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${apiServer}`; export const fetchUiProxy = async (endpoint: string, requestInit: RequestInit): Promise => { @@ -62,6 +64,12 @@ const getFullApiUrl = (path: string): { api: Api; url: string } => { if (imageBuilderPathRegex.test(path)) { return { api: 'imagebuilder', url: `${uiProxy}/api/imagebuilder/api/v1/${path}` }; } + if (catalogPathRegex.test(path)) { + return { + api: 'catalog', + url: `${flightCtlAPI}/api/v1/${path}`, + }; + } return { api: 'flightctl', url: `${flightCtlAPI}/api/v1/${path}` }; }; diff --git a/apps/ocp-plugin/tsconfig.json b/apps/ocp-plugin/tsconfig.json index 04e7b8b01..cffff5bf3 100644 --- a/apps/ocp-plugin/tsconfig.json +++ b/apps/ocp-plugin/tsconfig.json @@ -24,6 +24,7 @@ "@flightctl/ui-components/*": ["../../libs/ui-components/*"], "@flightctl/types": ["../../libs/types"], "@flightctl/types/imagebuilder": ["../../libs/types/imagebuilder"], + "@flightctl/types/alpha": ["../../libs/types/alpha"], }, }, "include": ["**/*.ts", "**/*.tsx", "**/*.jsx", "**/*.js", "**/*.json"], diff --git a/apps/ocp-plugin/webpack.config.ts b/apps/ocp-plugin/webpack.config.ts index 22d7b00f4..13bc22ab2 100644 --- a/apps/ocp-plugin/webpack.config.ts +++ b/apps/ocp-plugin/webpack.config.ts @@ -50,7 +50,15 @@ const config: Configuration & { use: ['style-loader', 'css-loader'], }, { - test: /\.(png|jpg|jpeg|gif|svg|woff2?|ttf|eot|otf)(\?.*$|$)/, + test: /\.(jpg|jpeg|png|gif|svg)$/i, + include: [path.resolve(__dirname, '../../libs/ui-components/assets')], + type: 'asset/resource', + generator: { + filename: 'assets/[name].[ext]', + }, + }, + { + test: /\.(woff2?|ttf|eot|otf)(\?.*$|$)/, loader: 'file-loader', options: { name: 'assets/[name].[ext]', diff --git a/apps/standalone/package.json b/apps/standalone/package.json index c0a2a44f0..d9171df7a 100644 --- a/apps/standalone/package.json +++ b/apps/standalone/package.json @@ -47,6 +47,7 @@ "@patternfly/react-icons": "^6.4.0", "@patternfly/react-styles": "^6.4.0", "@patternfly/react-table": "^6.4.0", + "@patternfly/react-topology": "^6.4.0", "formik": "^2.4.5", "fuzzysearch": "^1.0.3", "i18next": "^21.8.14", diff --git a/apps/standalone/src/app/routes.tsx b/apps/standalone/src/app/routes.tsx index 3ef58cb9f..c36ca379e 100644 --- a/apps/standalone/src/app/routes.tsx +++ b/apps/standalone/src/app/routes.tsx @@ -84,6 +84,22 @@ const CreateImageBuildWizard = React.lazy( () => import('@flightctl/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/CreateImageBuildWizard'), ); +const CatalogPage = React.lazy(() => import('@flightctl/ui-components/src/components/Catalog/CatalogPage')); +const CatalogInstallWizard = React.lazy( + () => import('@flightctl/ui-components/src/components/Catalog/InstallWizard/InstallWizard'), +); +const CatalogEditFleetWizard = React.lazy(() => + import('@flightctl/ui-components/src/components/Catalog/EditWizard/EditWizard').then((module) => ({ + default: module.EditFleetWizard, + })), +); + +const CatalogEditDeviceWizard = React.lazy(() => + import('@flightctl/ui-components/src/components/Catalog/EditWizard/EditWizard').then((module) => ({ + default: module.EditDeviceWizard, + })), +); + export type ExtendedRouteObject = RouteObject & { title?: string; showInNav?: boolean; @@ -215,6 +231,15 @@ const getAppRoutes = (t: TFunction): ExtendedRouteObject[] => [ ), }, + { + path: 'catalog/:fleetId/:catalogId/:itemId', + title: t('Edit Fleet'), + element: ( + + + + ), + }, { path: ':fleetId/*', title: t('Fleet Details'), @@ -264,6 +289,40 @@ const getAppRoutes = (t: TFunction): ExtendedRouteObject[] => [ ), }, + { + path: 'catalog/:deviceId/:catalogId/:itemId', + title: t('Edit device'), + element: ( + + + + ), + }, + ], + }, + { + path: '/catalog', + title: t('Software Catalog'), + showInNav: true, + children: [ + { + index: true, + title: t('Software Catalog'), + element: ( + + + + ), + }, + { + path: 'install/:catalogId/:itemId', + title: t('Install Catalog item'), + element: ( + + + + ), + }, ], }, { diff --git a/apps/standalone/src/app/utils/apiCalls.ts b/apps/standalone/src/app/utils/apiCalls.ts index 3014c1a16..a5dd83f79 100644 --- a/apps/standalone/src/app/utils/apiCalls.ts +++ b/apps/standalone/src/app/utils/apiCalls.ts @@ -16,6 +16,7 @@ const flightCtlAPI = `${window.location.protocol}//${apiServer}/api/flightctl`; const uiProxyAPI = `${window.location.protocol}//${apiServer}/api`; const imageBuilderPathRegex = /^image(builds|exports)/; +const catalogPathRegex = /^(catalogs|catalogitems)/; export const loginAPI = `${window.location.protocol}//${apiServer}/api/login`; export const wsEndpoint = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${apiServer}`; @@ -45,13 +46,19 @@ export const fetchUiProxy = async (endpoint: string, requestInit: RequestInit): return await fetch(`${uiProxyAPI}/${endpoint}`, options); }; -const getFullApiUrl = (path: string): { api: 'flightctl' | 'imagebuilder' | 'alerts'; url: string } => { +const getFullApiUrl = (path: string): { api: 'flightctl' | 'imagebuilder' | 'alerts' | 'catalog'; url: string } => { if (path.startsWith('alerts')) { return { api: 'alerts', url: `${uiProxyAPI}/alerts/api/v2/${path}` }; } if (imageBuilderPathRegex.test(path)) { return { api: 'imagebuilder', url: `${uiProxyAPI}/imagebuilder/api/v1/${path}` }; } + if (catalogPathRegex.test(path)) { + return { + api: 'catalog', + url: `${flightCtlAPI}/api/v1/${path}`, + }; + } return { api: 'flightctl', url: `${flightCtlAPI}/api/v1/${path}` }; }; diff --git a/apps/standalone/tsconfig.json b/apps/standalone/tsconfig.json index e248fbcea..ecb4ee2c5 100644 --- a/apps/standalone/tsconfig.json +++ b/apps/standalone/tsconfig.json @@ -25,6 +25,7 @@ "@flightctl/ui-components/*": ["../../libs/ui-components/*"], "@flightctl/types": ["../../libs/types"], "@flightctl/types/imagebuilder": ["../../libs/types/imagebuilder"], + "@flightctl/types/alpha": ["../../libs/types/alpha"], }, }, "include": ["**/*.ts", "**/*.tsx", "**/*.jsx", "**/*.js", "**/*.json"], diff --git a/apps/standalone/webpack.config.ts b/apps/standalone/webpack.config.ts index 9a6f4bbac..7422c1082 100644 --- a/apps/standalone/webpack.config.ts +++ b/apps/standalone/webpack.config.ts @@ -109,6 +109,7 @@ const config: Configuration & { include: [ path.resolve(__dirname, 'src'), path.resolve(__dirname, 'src/assets/images'), + path.resolve(__dirname, '../../libs/ui-components/assets'), path.resolve(__dirname, 'node_modules/patternfly'), path.resolve(__dirname, 'node_modules/@patternfly/patternfly/assets/images'), path.resolve(__dirname, 'node_modules/@patternfly/react-styles/css/assets/images'), @@ -127,15 +128,6 @@ const config: Configuration & { ), ], type: 'asset/inline', - use: [ - { - options: { - limit: 5000, - outputPath: 'images', - name: '[name].[ext]', - }, - }, - ], }, ], }, diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 04ba5a946..f04be819c 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -36,6 +36,9 @@ "Device": "Device", "Devices": "Devices", "Edit device": "Edit device", + "Software Catalog": "Software Catalog", + "Catalog": "Catalog", + "Install Catalog item": "Install Catalog item", "Image builds": "Image builds", "Build new image": "Build new image", "Duplicate image build": "Duplicate image build", @@ -184,6 +187,145 @@ "Field": "Field", "Value": "Value", "Close": "Close", + "Select {{ name }}": "Select {{ name }}", + "Provided by {{provider}}": "Provided by {{provider}}", + "Deprecated": "Deprecated", + "Resize panel": "Resize panel", + "Data catalog item can be deployed as part of an application.": "Data catalog item can be deployed as part of an application.", + "Deploy": "Deploy", + "Provider": "Provider", + "N/A": "N/A", + "Documentation URL": "Documentation URL", + "Homepage": "Homepage", + "Description": "Description", + "Readme": "Readme", + "No results found": "No results found", + "No catalog items yet": "No catalog items yet", + "No catalog items match the selected filters or search. Try adjusting the category or search.": "No catalog items match the selected filters or search. Try adjusting the category or search.", + "Catalog items are applications and system images you can deploy to your devices.": "Catalog items are applications and system images you can deploy to your devices.", + "Learn about catalogs": "Learn about catalogs", + "Operating system": "Operating system", + "Application": "Application", + "Container": "Container", + "Helm": "Helm", + "Quadlet": "Quadlet", + "Compose": "Compose", + "Data": "Data", + "Category": "Category", + "Search by name": "Search by name", + "Update": "Update", + "Version": "Version", + "Review": "Review", + "Version must be selected": "Version must be selected", + "Application name is required": "Application name is required", + "Application with the same name already exists.": "Application with the same name already exists.", + "Version {{version}} not found": "Version {{version}} not found", + "Failed to load catalog item": "Failed to load catalog item", + "Failed to load device": "Failed to load device", + "Failed to load fleet": "Failed to load fleet", + "Loading": "Loading", + "Failed to find operating system": "Failed to find operating system", + "Failed to find application": "Failed to find application", + "Software catalog": "Software catalog", + "Deploy {{ name }}": "Deploy {{ name }}", + "Edit {{name}}": "Edit {{name}}", + "Return to device catalog": "Return to device catalog", + "Return to fleet catalog": "Return to fleet catalog", + "Review update specifications": "Review update specifications", + "Review deployment specifications": "Review deployment specifications", + "Update specifications": "Update specifications", + "Installation specifications": "Installation specifications", + "Channel": "Channel", + "Failed to update": "Failed to update", + "Failed to deploy": "Failed to deploy", + "Configuration is not valid": "Configuration is not valid", + "The current version is deprecated": "The current version is deprecated", + "The selected version is deprecated": "The selected version is deprecated", + "Version update": "Version update", + "Current channel": "Current channel", + "Target channel": "Target channel", + "Current version": "Current version", + "Target version": "Target version", + "Up to date": "Up to date", + "{{ channel }} channel": "{{ channel }} channel", + "Deployment specifications": "Deployment specifications", + "Update available": "Update available", + "Version: {{version}}, Channel: {{channel}}": "Version: {{version}}, Channel: {{channel}}", + "Loading installed software": "Loading installed software", + "Deployed Software": "Deployed Software", + "No software deployed": "No software deployed", + "Select an operating system or application from the catalog below.": "Select an operating system or application from the catalog below.", + "operating system": "operating system", + "application": "application", + "Specifications": "Specifications", + "Select target": "Select target", + "Application configuration": "Application configuration", + "Target must be selected": "Target must be selected", + "Channel must be selected": "Channel must be selected", + "Device must be selected": "Device must be selected", + "Fleet must be selected": "Fleet must be selected", + "Failed to find requested version {{version}}": "Failed to find requested version {{version}}", + "Loading catalog item": "Loading catalog item", + "Install {{name}}": "Install {{name}}", + "Application name": "Application name", + "Application name must be unique.": "Application name must be unique.", + "Configure via:": "Configure via:", + "Form view": "Form view", + "YAML view": "YAML view", + "Form view is disabled for this application because the schema is not available": "Form view is disabled for this application because the schema is not available", + "Note: Some fields may not be represented in this form view. Please select \"YAML view\" for full control.": "Note: Some fields may not be represented in this form view. Please select \"YAML view\" for full control.", + "Fleet update": "Fleet update", + "This will deploy the OS <1>{osImageName} for all <3>({numOfDevices}) devices in the <5>{values.fleet?.metadata.name} fleet. Devices will download and apply the update according to the configured update policies.": "This will deploy the OS <1>{osImageName} for all <3>({numOfDevices}) devices in the <5>{values.fleet?.metadata.name} fleet. Devices will download and apply the update according to the configured update policies.", + "You are about to update OS <1>{osImageName}. This will update the OS image for all <4>({numOfDevices}) devices in the <6>{values.fleet?.metadata.name} fleet. Devices will download and apply the update according to the configured update policies.": "You are about to update OS <1>{osImageName}. This will update the OS image for all <4>({numOfDevices}) devices in the <6>{values.fleet?.metadata.name} fleet. Devices will download and apply the update according to the configured update policies.", + "Existing OS image detected": "Existing OS image detected", + "You are about to replace OS with <1>{osImageName}. This will update the OS image for all <4>({numOfDevices}) devices in the <6>{values.fleet?.metadata.name} fleet. Devices will download and apply the update according to the configured update policies.": "You are about to replace OS with <1>{osImageName}. This will update the OS image for all <4>({numOfDevices}) devices in the <6>{values.fleet?.metadata.name} fleet. Devices will download and apply the update according to the configured update policies.", + "Device update": "Device update", + "This will deploy the OS <1>{osImageName}. Device will download and apply the update according to the configured update policies.": "This will deploy the OS <1>{osImageName}. Device will download and apply the update according to the configured update policies.", + "You are about to update OS with <1>{osImageName}. Device will download and apply the update according to the configured update policies.": "You are about to update OS with <1>{osImageName}. Device will download and apply the update according to the configured update policies.", + "You are about to replace OS with <1>{osImageName}. Device will download and apply the update according to the configured update policies.": "You are about to replace OS with <1>{osImageName}. Device will download and apply the update according to the configured update policies.", + "Target": "Target", + "Target type": "Target type", + "Fleet": "Fleet", + "Select device": "Select device", + "Search by name or alias": "Search by name or alias", + "Enrolled devices table": "Enrolled devices table", + "Select fleet": "Select fleet", + "Fleets table": "Fleets table", + "Unknown": "Unknown", + "OpenShift Virtualization": "OpenShift Virtualization", + "Bare Metal": "Bare Metal", + "Amazon Web Services": "Amazon Web Services", + "Anaconda Installer": "Anaconda Installer", + "Google Cloud": "Google Cloud", + "KVM/custom cloud import": "KVM/custom cloud import", + "Microsoft Hyper-V": "Microsoft Hyper-V", + "VMware vSphere": "VMware vSphere", + "Cloud native": "Cloud native", + "Deployment target": "Deployment target", + "No items": "No items", + "Show readme": "Show readme", + "This version is deprecated": "This version is deprecated", + "You do not have permissions to list fleets": "You do not have permissions to list fleets", + "You do not have permissions to edit fleets": "You do not have permissions to edit fleets", + "No fleet is available": "No fleet is available", + "You do not have permissions to list devices": "You do not have permissions to list devices", + "You do not have permissions to edit devices": "You do not have permissions to edit devices", + "No device is available": "No device is available", + "Loading targets": "Loading targets", + "Existing Fleet": "Existing Fleet", + "Install to all devices in a fleet": "Install to all devices in a fleet", + "Existing Device": "Existing Device", + "Install to a single fleetless device": "Install to a single fleetless device", + "New Device": "New Device", + "Provision a brand new, unenrolled device": "Provision a brand new, unenrolled device", + "Update configuration successful": "Update configuration successful", + "Device will download and apply the update according to the configured update policies.": "Device will download and apply the update according to the configured update policies.", + "Devices will download and apply the update according to the configured update policies.": "Devices will download and apply the update according to the configured update policies.", + "Return to catalog": "Return to catalog", + "View device": "View device", + "View fleet": "View fleet", + "Not a valid configuration": "Not a valid configuration", + "OS image": "OS image", "No devices": "No devices", "Restricted Access": "Restricted Access", "You don't have access to this section.": "You don't have access to this section.", @@ -334,7 +476,6 @@ "You can add devices and label them to match fleets, or you can <2>start with a fleet and add devices into it.": "You can add devices and label them to match fleets, or you can <2>start with a fleet and add devices into it.", "You can add devices and label them to match fleets": "You can add devices and label them to match fleets", "No decommissioning or decommissioned devices here!": "No decommissioning or decommissioned devices here!", - "Fleet": "Fleet", "Name / Alias": "Name / Alias", "Clear all filters": "Clear all filters", "Searching...": "Searching...", @@ -345,7 +486,6 @@ "Labels and fleets": "Labels and fleets", "Filter by labels and fleets": "Filter by labels and fleets", "Decommission devices": "Decommission devices", - "Enrolled devices table": "Enrolled devices table", "Device is non-editable": "Device is non-editable", "General info": "General info", "Device template": "Device template", @@ -355,7 +495,6 @@ "This port mapping already exists": "This port mapping already exists", "Port mapping must be in format \"hostPort:containerPort\"": "Port mapping must be in format \"hostPort:containerPort\"", "Invalid port values": "Invalid port values", - "Application name": "Application name", "If not specified, the image name will be used. Application name must be unique.": "If not specified, the image name will be used. Application name must be unique.", "Image": "Image", "Provide a valid image reference": "Provide a valid image reference", @@ -409,6 +548,7 @@ "Rootless user identity": "Rootless user identity", "The recommended user identity is '{{ runAsUser }}'. To specify a custom user identity, edit the application configuration via YAML or CLI.": "The recommended user identity is '{{ runAsUser }}'. To specify a custom user identity, edit the application configuration via YAML or CLI.", "Application {{ appNum }}": "Application {{ appNum }}", + "Application is managed by Software Catalog": "Application is managed by Software Catalog", "Application type": "Application type", "Select an application type": "Select an application type", "Definition source": "Definition source", @@ -499,6 +639,7 @@ "System image": "System image", "The target system image for this fleet's devices.": "The target system image for this fleet's devices.", "The target system image for this device.": "The target system image for this device.", + "System image is managed by Software Catalog": "System image is managed by Software Catalog", "Must be a reference to a bootable container image (such as \"quay.io//my-rhel-with-fc-agent:\"). If you do not want to manage your OS from Edge management, leave this field empty.": "Must be a reference to a bootable container image (such as \"quay.io//my-rhel-with-fc-agent:\"). If you do not want to manage your OS from Edge management, leave this field empty.", "Use basic configurations": "Use basic configurations", "Advanced configurations": "Advanced configurations", @@ -518,6 +659,16 @@ "Maximum unavailable devices: {{ maxUnavailable }}": "Maximum unavailable devices: {{ maxUnavailable }}", "Add service": "Add service", "Tracked systemd services": "Tracked systemd services", + "Items": "Items", + "Delete item": "Delete item", + "Add item": "Add item", + "Choose asset from catalog": "Choose asset from catalog", + "Clear all filters and try again.": "Clear all filters and try again.", + "No assets available in catalog": "No assets available in catalog", + "There are no asset catalog items to choose from. Add assets to your catalogs to select them here.": "There are no asset catalog items to choose from. Add assets to your catalogs to select them here.", + "Select": "Select", + "Enter image reference or choose from catalog": "Enter image reference or choose from catalog", + "Choose from catalog": "Choose from catalog", "Approve": "Approve", "Certificate signing request": "Certificate signing request", "A PEM-encoded PKCS#10 certificate signing request.": "A PEM-encoded PKCS#10 certificate signing request.", @@ -531,7 +682,6 @@ "Created": "Created", "Devices pending approval": "Devices pending approval", "Table for devices pending approval": "Table for devices pending approval", - "Search by name": "Search by name", "{{ count }} devices pending approval_one": "{{ count }} device pending approval", "{{ count }} devices pending approval_other": "{{ count }} devices pending approval", "No matching events": "No matching events", @@ -600,9 +750,7 @@ "Resourcesync is not accessible": "Resourcesync is not accessible", "Resourcesync new commit detected": "Resourcesync new commit detected", "Resource": "Resource", - "Review": "Review", "Review and create": "Review and create", - "View fleet": "View fleet", "Edit fleet": "Edit fleet", "Create fleet": "Create fleet", "Failed to determine the number of selected devices": "Failed to determine the number of selected devices", @@ -698,7 +846,6 @@ "Fleets allow you to edit and update your devices at once.": "Fleets allow you to edit and update your devices at once.", "To get started, create a new fleet or import an existing configuration.": "To get started, create a new fleet or import an existing configuration.", "Delete fleets": "Delete fleets", - "Fleets table": "Fleets table", "Repository is required": "Repository is required", "Import": "Import", "Select or create repository": "Select or create repository", @@ -717,10 +864,8 @@ "Target revision": "Target revision", "Fleets will appear in the fleets table list and their status will be reflecting the resource sync process status. After a few minutes, they should be synced and enabled.": "Fleets will appear in the fleets table list and their status will be reflecting the resource sync process status. After a few minutes, they should be synced and enabled.", "Invalid {{ itemType }}": "Invalid {{ itemType }}", - "Add item": "Add item", "Resolved": "Resolved", "Name must be unique": "Name must be unique", - "Unknown": "Unknown", "Accessible": "Accessible", "Not accessible": "Not accessible", "Missing repository": "Missing repository", @@ -786,7 +931,6 @@ "YAML content is invalid.": "YAML content is invalid.", "Name is required for quadlet applications.": "Name is required for quadlet applications.", "Name is required for compose applications.": "Name is required for compose applications.", - "Application name must be unique.": "Application name must be unique.", "Name is required, another application uses the same image.": "Name is required, another application uses the same image.", "Cannot be decimal": "Cannot be decimal", "Percentage must be between 1 and 100.": "Percentage must be between 1 and 100.", @@ -1004,7 +1148,6 @@ "Select your preferred provider and use the command to log in to the Flight Control CLI.": "Select your preferred provider and use the command to log in to the Flight Control CLI.", "Use the following command to log in to the Flight Control CLI:": "Use the following command to log in to the Flight Control CLI:", "Copy Login Command": "Copy Login Command", - "Provider": "Provider", "Approve pending devices": "Approve pending devices", "Make sure you recognise and expect the following devices before approving them. Are you sure you want to approve the listed devices?": "Make sure you recognise and expect the following devices before approving them. Are you sure you want to approve the listed devices?", "Alias devices using a custom template. Add a number using": "Alias devices using a custom template. Add a number using", @@ -1218,15 +1361,13 @@ "Suspended devices detected": "Suspended devices detected", "<0>Warning: Please review this fleet's configuration before taking action. Resuming a device will cause it to apply the current specification, which may be older than what is on the device.": "<0>Warning: Please review this fleet's configuration before taking action. Resuming a device will cause it to apply the current specification, which may be older than what is on the device.", "<0>Warning: Please review device configurations before taking action. Resuming a device will cause it to apply the current specification, which may be older than what is on the device.": "<0>Warning: Please review device configurations before taking action. Resuming a device will cause it to apply the current specification, which may be older than what is on the device.", - "No results found": "No results found", - "Clear all filters and try again.": "Clear all filters and try again.", "Select all rows": "Select all rows", + "Row select": "Row select", "Expand row": "Expand row", "{page} of <2>{totalPages}": "{page} of <2>{totalPages}", "{{ numberOfItems }} items": "{{ numberOfItems }} items", "Waiting for terminal session to open...": "Waiting for terminal session to open...", "Architecture": "Architecture", - "Operating system": "Operating system", "Agent version": "Agent version", "Distro": "Distro", "Hostname": "Hostname", @@ -1247,9 +1388,6 @@ "Helm application": "Helm application", "Compose application": "Compose application", "Single Container": "Single Container", - "Quadlet": "Quadlet", - "Compose": "Compose", - "Helm": "Helm", "OpenShift": "OpenShift", "Kubernetes": "Kubernetes", "Ansible Automation Platform": "Ansible Automation Platform", diff --git a/libs/types/alpha/index.ts b/libs/types/alpha/index.ts new file mode 100644 index 000000000..a211d3ede --- /dev/null +++ b/libs/types/alpha/index.ts @@ -0,0 +1,24 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export { ApiVersion } from './models/ApiVersion'; +export type { Catalog } from './models/Catalog'; +export type { CatalogItem } from './models/CatalogItem'; +export type { CatalogItemArtifact } from './models/CatalogItemArtifact'; +export { CatalogItemArtifactType } from './models/CatalogItemArtifactType'; +export { CatalogItemCategory } from './models/CatalogItemCategory'; +export type { CatalogItemConfigurable } from './models/CatalogItemConfigurable'; +export type { CatalogItemDeprecation } from './models/CatalogItemDeprecation'; +export type { CatalogItemList } from './models/CatalogItemList'; +export type { CatalogItemMeta } from './models/CatalogItemMeta'; +export type { CatalogItemReference } from './models/CatalogItemReference'; +export type { CatalogItemSpec } from './models/CatalogItemSpec'; +export { CatalogItemType } from './models/CatalogItemType'; +export type { CatalogItemVersion } from './models/CatalogItemVersion'; +export { CatalogItemVisibility } from './models/CatalogItemVisibility'; +export type { CatalogList } from './models/CatalogList'; +export type { CatalogSpec } from './models/CatalogSpec'; +export type { CatalogStatus } from './models/CatalogStatus'; +export type { Status } from './models/Status'; diff --git a/libs/types/alpha/models/ApiVersion.ts b/libs/types/alpha/models/ApiVersion.ts new file mode 100644 index 000000000..d1e05a873 --- /dev/null +++ b/libs/types/alpha/models/ApiVersion.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources. + */ +export enum ApiVersion { + V1ALPHA1 = 'v1alpha1', + FLIGHTCTL_IO_V1ALPHA1 = 'flightctl.io/v1alpha1', +} diff --git a/libs/types/alpha/models/Catalog.ts b/libs/types/alpha/models/Catalog.ts new file mode 100644 index 000000000..e6326edd0 --- /dev/null +++ b/libs/types/alpha/models/Catalog.ts @@ -0,0 +1,19 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiVersion } from './ApiVersion'; +import type { CatalogSpec } from './CatalogSpec'; +import type { CatalogStatus } from './CatalogStatus'; +import type { ObjectMeta } from '../../models/ObjectMeta'; +export type Catalog = { + apiVersion: ApiVersion; + /** + * Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds. + */ + kind: string; + metadata: ObjectMeta; + spec: CatalogSpec; + status?: CatalogStatus; +}; + diff --git a/libs/types/alpha/models/CatalogItem.ts b/libs/types/alpha/models/CatalogItem.ts new file mode 100644 index 000000000..57ba61670 --- /dev/null +++ b/libs/types/alpha/models/CatalogItem.ts @@ -0,0 +1,20 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiVersion } from './ApiVersion'; +import type { CatalogItemMeta } from './CatalogItemMeta'; +import type { CatalogItemSpec } from './CatalogItemSpec'; +/** + * CatalogItem represents an application template from a catalog. It provides default configuration values that can be customized when adding the application to a fleet. + */ +export type CatalogItem = { + apiVersion: ApiVersion; + /** + * Kind is a string value representing the REST resource this object represents. + */ + kind: string; + metadata: CatalogItemMeta; + spec: CatalogItemSpec; +}; + diff --git a/libs/types/alpha/models/CatalogItemArtifact.ts b/libs/types/alpha/models/CatalogItemArtifact.ts new file mode 100644 index 000000000..dcce127c0 --- /dev/null +++ b/libs/types/alpha/models/CatalogItemArtifact.ts @@ -0,0 +1,20 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CatalogItemArtifactType } from './CatalogItemArtifactType'; +/** + * An alternative artifact format. + */ +export type CatalogItemArtifact = { + type?: CatalogItemArtifactType; + /** + * Optional human-readable display name for this artifact. + */ + name?: string; + /** + * Artifact URI (OCI reference, URL, S3 path, etc.). + */ + uri: string; +}; + diff --git a/libs/types/alpha/models/CatalogItemArtifactType.ts b/libs/types/alpha/models/CatalogItemArtifactType.ts new file mode 100644 index 000000000..869498ca9 --- /dev/null +++ b/libs/types/alpha/models/CatalogItemArtifactType.ts @@ -0,0 +1,18 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Type of artifact format. Includes bootc-image-builder output formats. Defaults to container if only one artifact. + */ +export enum CatalogItemArtifactType { + CatalogItemArtifactTypeContainer = 'container', + CatalogItemArtifactTypeQcow2 = 'qcow2', + CatalogItemArtifactTypeAmi = 'ami', + CatalogItemArtifactTypeIso = 'iso', + CatalogItemArtifactTypeAnacondaIso = 'anaconda-iso', + CatalogItemArtifactTypeVmdk = 'vmdk', + CatalogItemArtifactTypeVhd = 'vhd', + CatalogItemArtifactTypeRaw = 'raw', + CatalogItemArtifactTypeGce = 'gce', +} diff --git a/libs/types/alpha/models/CatalogItemCategory.ts b/libs/types/alpha/models/CatalogItemCategory.ts new file mode 100644 index 000000000..2b83bbefc --- /dev/null +++ b/libs/types/alpha/models/CatalogItemCategory.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Category of a catalog item. + */ +export enum CatalogItemCategory { + CatalogItemCategorySystem = 'system', + CatalogItemCategoryApplication = 'application', +} diff --git a/libs/types/alpha/models/CatalogItemConfigurable.ts b/libs/types/alpha/models/CatalogItemConfigurable.ts new file mode 100644 index 000000000..27b4bf02c --- /dev/null +++ b/libs/types/alpha/models/CatalogItemConfigurable.ts @@ -0,0 +1,22 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Configuration fields that can be specified at item level (as defaults) and overridden at version level. Version-level values fully replace item-level values (not merged). + */ +export type CatalogItemConfigurable = { + /** + * Configuration values (envVars, ports, volumes, resources, etc.). + */ + config?: Record; + /** + * JSON Schema defining configurable parameters and their validation. + */ + configSchema?: Record; + /** + * Detailed documentation, preferably in markdown format. + */ + readme?: string; +}; + diff --git a/libs/types/alpha/models/CatalogItemDeprecation.ts b/libs/types/alpha/models/CatalogItemDeprecation.ts new file mode 100644 index 000000000..91603de32 --- /dev/null +++ b/libs/types/alpha/models/CatalogItemDeprecation.ts @@ -0,0 +1,18 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Deprecation information for a catalog item or version. Presence indicates deprecated status. + */ +export type CatalogItemDeprecation = { + /** + * Required message explaining why this is deprecated and what to do instead. + */ + message: string; + /** + * Optional name of the replacement catalog item (item-level only). + */ + replacement?: string; +}; + diff --git a/libs/types/alpha/models/CatalogItemList.ts b/libs/types/alpha/models/CatalogItemList.ts new file mode 100644 index 000000000..a958abdb9 --- /dev/null +++ b/libs/types/alpha/models/CatalogItemList.ts @@ -0,0 +1,23 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiVersion } from './ApiVersion'; +import type { CatalogItem } from './CatalogItem'; +import type { ListMeta } from '../../models/ListMeta'; +/** + * CatalogItemList is a list of CatalogItems. + */ +export type CatalogItemList = { + apiVersion: ApiVersion; + /** + * Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds. + */ + kind: string; + metadata: ListMeta; + /** + * List of CatalogItems. + */ + items: Array; +}; + diff --git a/libs/types/alpha/models/CatalogItemMeta.ts b/libs/types/alpha/models/CatalogItemMeta.ts new file mode 100644 index 000000000..264837f46 --- /dev/null +++ b/libs/types/alpha/models/CatalogItemMeta.ts @@ -0,0 +1,15 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ObjectMeta } from '../../models/ObjectMeta'; +/** + * Metadata for CatalogItem resources. Extends ObjectMeta with catalog scoping. + */ +export type CatalogItemMeta = (ObjectMeta & { + /** + * The catalog this item belongs to. Similar to namespace in Kubernetes. + */ + catalog: string; +}); + diff --git a/libs/types/alpha/models/CatalogItemReference.ts b/libs/types/alpha/models/CatalogItemReference.ts new file mode 100644 index 000000000..63f7f8de0 --- /dev/null +++ b/libs/types/alpha/models/CatalogItemReference.ts @@ -0,0 +1,19 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CatalogItemArtifact } from './CatalogItemArtifact'; +/** + * Reference to the primary artifact and optional alternative formats. + */ +export type CatalogItemReference = { + /** + * Primary artifact URI without version tag. Supports OCI references, URLs, S3 paths, etc. + */ + uri: string; + /** + * Alternative artifact formats (e.g., qcow2, ISO for bootc images). + */ + artifacts?: Array; +}; + diff --git a/libs/types/alpha/models/CatalogItemSpec.ts b/libs/types/alpha/models/CatalogItemSpec.ts new file mode 100644 index 000000000..b4f92de58 --- /dev/null +++ b/libs/types/alpha/models/CatalogItemSpec.ts @@ -0,0 +1,55 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CatalogItemCategory } from './CatalogItemCategory'; +import type { CatalogItemConfigurable } from './CatalogItemConfigurable'; +import type { CatalogItemDeprecation } from './CatalogItemDeprecation'; +import type { CatalogItemReference } from './CatalogItemReference'; +import type { CatalogItemType } from './CatalogItemType'; +import type { CatalogItemVersion } from './CatalogItemVersion'; +import type { CatalogItemVisibility } from './CatalogItemVisibility'; +/** + * CatalogItemSpec defines the configuration for a catalog item. + */ +export type CatalogItemSpec = { + category?: CatalogItemCategory; + type: CatalogItemType; + reference: CatalogItemReference; + /** + * Available versions using Cincinnati model. Use replaces for primary edge, skips when stable channel skips intermediate versions. + */ + versions: Array; + defaults?: CatalogItemConfigurable; + /** + * Human-readable display name shown in catalog listings. + */ + displayName?: string; + /** + * A brief one-line description of the catalog item. + */ + shortDescription?: string; + /** + * URL or data URI of the catalog item icon for display in UI. + */ + icon?: string; + visibility?: CatalogItemVisibility; + deprecation?: CatalogItemDeprecation; + /** + * Provider or publisher of the catalog item (company or team name). + */ + provider?: string; + /** + * Link to support resources or documentation for getting help. + */ + support?: string; + /** + * The homepage URL for the catalog item project. + */ + homepage?: string; + /** + * Link to external documentation. + */ + documentationUrl?: string; +}; + diff --git a/libs/types/alpha/models/CatalogItemType.ts b/libs/types/alpha/models/CatalogItemType.ts new file mode 100644 index 000000000..49575fe05 --- /dev/null +++ b/libs/types/alpha/models/CatalogItemType.ts @@ -0,0 +1,17 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Type of catalog item within its category. + */ +export enum CatalogItemType { + CatalogItemTypeOS = 'os', + CatalogItemTypeFirmware = 'firmware', + CatalogItemTypeDriver = 'driver', + CatalogItemTypeContainer = 'container', + CatalogItemTypeHelm = 'helm', + CatalogItemTypeQuadlet = 'quadlet', + CatalogItemTypeCompose = 'compose', + CatalogItemTypeData = 'data', +} diff --git a/libs/types/alpha/models/CatalogItemVersion.ts b/libs/types/alpha/models/CatalogItemVersion.ts new file mode 100644 index 000000000..6c54a70cd --- /dev/null +++ b/libs/types/alpha/models/CatalogItemVersion.ts @@ -0,0 +1,46 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CatalogItemConfigurable } from './CatalogItemConfigurable'; +import type { CatalogItemDeprecation } from './CatalogItemDeprecation'; +/** + * A version of a catalog item following the Cincinnati model where versions + * are nodes in an upgrade graph and channels are labels on those nodes. + * Upgrade edges are defined by replaces (single), skips (multiple), or + * skipRange (semver range). Includes CatalogItemConfigurable fields that + * override item-level defaults. Exactly one of tag or digest must be specified. + * + */ +export type CatalogItemVersion = (CatalogItemConfigurable & { + /** + * Semantic version identifier (e.g., 1.2.3, v2.0.0-rc1). Required for version ordering and upgrade graph. + */ + version: string; + /** + * Image tag to pull. Mutually exclusive with digest. + */ + tag?: string; + /** + * OCI digest for immutable reference. Mutually exclusive with tag. Format: sha256:... + */ + digest?: string; + /** + * Channels this version belongs to. + */ + channels: Array; + /** + * The single version this one replaces, defining the primary upgrade edge. + */ + replaces?: string; + /** + * Additional versions that can upgrade directly to this one. Use when stable channel skips intermediate fast-only versions. + */ + skips?: Array; + /** + * Semver range of versions that can upgrade directly to this one. Use for z-stream updates or hotfixes. + */ + skipRange?: string; + deprecation?: CatalogItemDeprecation; +}); + diff --git a/libs/types/alpha/models/CatalogItemVisibility.ts b/libs/types/alpha/models/CatalogItemVisibility.ts new file mode 100644 index 000000000..578b43dc1 --- /dev/null +++ b/libs/types/alpha/models/CatalogItemVisibility.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Visibility controls who can see and use the catalog item. + */ +export enum CatalogItemVisibility { + CatalogItemVisibilityDraft = 'draft', + CatalogItemVisibilityPublished = 'published', +} diff --git a/libs/types/alpha/models/CatalogList.ts b/libs/types/alpha/models/CatalogList.ts new file mode 100644 index 000000000..74b0e57c7 --- /dev/null +++ b/libs/types/alpha/models/CatalogList.ts @@ -0,0 +1,23 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiVersion } from './ApiVersion'; +import type { Catalog } from './Catalog'; +import type { ListMeta } from '../../models/ListMeta'; +/** + * CatalogList is a list of Catalogs. + */ +export type CatalogList = { + apiVersion: ApiVersion; + /** + * Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds. + */ + kind: string; + metadata: ListMeta; + /** + * List of Catalogs. + */ + items: Array; +}; + diff --git a/libs/types/alpha/models/CatalogSpec.ts b/libs/types/alpha/models/CatalogSpec.ts new file mode 100644 index 000000000..fc319c6b9 --- /dev/null +++ b/libs/types/alpha/models/CatalogSpec.ts @@ -0,0 +1,32 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CatalogItemVisibility } from './CatalogItemVisibility'; +/** + * CatalogSpec describes the configuration of a catalog. Catalogs are containers for locally-managed CatalogItems. + */ +export type CatalogSpec = { + /** + * Human-readable display name shown in catalog listings. + */ + displayName?: string; + /** + * A brief one-line description of the catalog. + */ + shortDescription?: string; + /** + * URL or data URI of the catalog icon for display in UI. + */ + icon?: string; + visibility?: CatalogItemVisibility; + /** + * Provider or publisher of the catalog (company or team name). + */ + provider?: string; + /** + * Link to support resources or documentation for getting help. + */ + support?: string; +}; + diff --git a/libs/types/alpha/models/CatalogStatus.ts b/libs/types/alpha/models/CatalogStatus.ts new file mode 100644 index 000000000..63b4d241f --- /dev/null +++ b/libs/types/alpha/models/CatalogStatus.ts @@ -0,0 +1,15 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Condition } from '../../models/Condition'; +/** + * CatalogStatus represents the current status of a catalog source. + */ +export type CatalogStatus = { + /** + * Current state of the catalog source. + */ + conditions: Array; +}; + diff --git a/libs/types/alpha/models/Status.ts b/libs/types/alpha/models/Status.ts new file mode 100644 index 000000000..2faa28ad8 --- /dev/null +++ b/libs/types/alpha/models/Status.ts @@ -0,0 +1,32 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiVersion } from './ApiVersion'; +/** + * Status is a return value for calls that don't return other objects. + */ +export type Status = { + apiVersion: ApiVersion; + /** + * Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds. + */ + kind: string; + /** + * Suggested HTTP return code for this status, 0 if not set. + */ + code: number; + /** + * A human-readable description of the status of this operation. + */ + message: string; + /** + * A machine-readable description of why this operation is in the "Failure" status. If this value is empty there is no information available. A Reason clarifies an HTTP status code but does not override it. + */ + reason: string; + /** + * Status of the operation. One of: "Success" or "Failure". More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status. + */ + status: string; +}; + diff --git a/libs/types/package.json b/libs/types/package.json index 9151c9557..8e1eb25c2 100644 --- a/libs/types/package.json +++ b/libs/types/package.json @@ -16,6 +16,11 @@ "source": "./imagebuilder/index.ts", "types": "./dist/imagebuilder/index.d.ts", "default": "./dist/imagebuilder/index.js" + }, + "./alpha": { + "source": "./alpha/index.ts", + "types": "./dist/alpha/index.d.ts", + "default": "./dist/alpha/index.js" } }, "files": [ diff --git a/libs/types/scripts/openapi-typescript.js b/libs/types/scripts/openapi-typescript.js index d51c32461..ac68552af 100644 --- a/libs/types/scripts/openapi-typescript.js +++ b/libs/types/scripts/openapi-typescript.js @@ -5,14 +5,14 @@ const path = require('path'); const OpenAPI = require('openapi-typescript-codegen'); const YAML = require('js-yaml'); -const { rimraf, copyDir, fixImagebuilderCoreReferences } = require('./openapi-utils'); +const { rimraf, copyDir, fixCoreReferences } = require('./openapi-utils'); const CORE_API = 'core'; +const ALPHA_CORE_API = 'alphacore'; const IMAGEBUILDER_API = 'imagebuilder'; const getSwaggerUrl = (api) => { - const apiVersion = api === CORE_API ? 'v1beta1' : 'v1alpha1'; - return `https://raw.githubusercontent.com/flightctl/flightctl/main/api/${api}/${apiVersion}/openapi.yaml`; + return `https://raw.githubusercontent.com/flightctl/flightctl/main/api/${api}/openapi.yaml`; }; const processJsonAPI = (jsonString) => { @@ -32,14 +32,23 @@ const processJsonAPI = (jsonString) => { async function generateTypes(mode) { const config = { [CORE_API]: { - swaggerUrl: getSwaggerUrl(CORE_API), + swaggerUrl: getSwaggerUrl('core/v1beta1'), output: path.resolve(__dirname, '../tmp-types'), finalDir: path.resolve(__dirname, '../models'), }, + [ALPHA_CORE_API]: { + swaggerUrl: getSwaggerUrl('core/v1alpha1'), + output: path.resolve(__dirname, '../tmp-alpha-types'), + finalDir: path.resolve(__dirname, '../alpha/models'), + coreRef: 'v1beta1_openapi_yaml_components_schemas', + outputDir: path.resolve(__dirname, '../alpha'), + }, [IMAGEBUILDER_API]: { - swaggerUrl: getSwaggerUrl(IMAGEBUILDER_API), + swaggerUrl: getSwaggerUrl('imagebuilder/v1alpha1'), output: path.resolve(__dirname, '../tmp-imagebuilder-types'), finalDir: path.resolve(__dirname, '../imagebuilder/models'), + coreRef: 'core_v1beta1_openapi_yaml_components_schemas', + outputDir: path.resolve(__dirname, '../imagebuilder'), }, }; @@ -70,23 +79,22 @@ async function generateTypes(mode) { await copyDir(output, path.resolve(__dirname, '..')); await rimraf(output); } else { - // Image builder types need to be fixed before they can be moved to their final location + // Image builder and alpha types need to be fixed before they can be moved to their final location await rimraf(finalDir); const modelsDir = path.join(output, 'models'); if (fs.existsSync(modelsDir)) { await copyDir(modelsDir, finalDir); } console.log(`Fixing references to core API types...`); - await fixImagebuilderCoreReferences(finalDir); + await fixCoreReferences(finalDir, config[mode].coreRef); // Copy the generated index.ts to imagebuilder/index.ts const indexPath = path.join(output, 'index.ts'); if (fs.existsSync(indexPath)) { - const imagebuilderDir = path.resolve(__dirname, '../imagebuilder'); - if (!fs.existsSync(imagebuilderDir)) { - fs.mkdirSync(imagebuilderDir, { recursive: true }); + if (!fs.existsSync(config[mode].outputDir)) { + fs.mkdirSync(config[mode].outputDir, { recursive: true }); } - await fsPromises.copyFile(indexPath, path.join(imagebuilderDir, 'index.ts')); + await fsPromises.copyFile(indexPath, path.join(config[mode].outputDir, 'index.ts')); } await rimraf(output); } @@ -107,6 +115,7 @@ async function main() { console.log('Generating types...'); await generateTypes(CORE_API); + await generateTypes(ALPHA_CORE_API); await generateTypes(IMAGEBUILDER_API); console.log('✅ Type generation complete!'); diff --git a/libs/types/scripts/openapi-utils.js b/libs/types/scripts/openapi-utils.js index 2de984859..f186b4157 100755 --- a/libs/types/scripts/openapi-utils.js +++ b/libs/types/scripts/openapi-utils.js @@ -66,42 +66,25 @@ function findTsFiles(dir) { return files; } -/** - * Fixes references from the auto-generated imagebuilder types so they point to the correct types of the "core" API module. - * The generated types are in the form of: - * import type { core_v1beta1_openapi_yaml_components_schemas_ObjectMeta } from './core_v1beta1_openapi_yaml_components_schemas_ObjectMeta'; - * type SomeType = { - * ... - * someField: core_v1beta1_openapi_yaml_components_schemas_ObjectMeta; - * } - * - * The fixed types will be like this: - * import type { ObjectMeta } from '../../models/ObjectMeta'; - * type SomeType = { - * ... - * someField: ObjectMeta; - * } - * - * @param {string} modelsDir - Directory containing TypeScript files to fix - */ -async function fixImagebuilderCoreReferences(modelsDir) { +async function fixCoreReferences(modelsDir, prefix) { const files = findTsFiles(modelsDir); + // Pre-construct the Regex patterns using the dynamic prefix + // We escape special characters in the prefix if needed, though usually not for schema names + const importRegex = new RegExp(`from\\s+['"]\\.\\/${prefix}_([A-Za-z][A-Za-z0-9]*)['"]`, 'g'); + const typeRegex = new RegExp(`\\b${prefix}_([A-Za-z][A-Za-z0-9]*)\\b`, 'g'); + await Promise.all( files.map(async (filePath) => { let content = await fsPromises.readFile(filePath, 'utf8'); const originalContent = content; - // Modify the path to properly point to the type from the "core" module - content = content.replace( - /from\s+['"]\.\/core_v1beta1_openapi_yaml_components_schemas_([A-Za-z][A-Za-z0-9]*)['"]/g, - "from '../../models/$1'", - ); + // 1. Modify the import path + content = content.replace(importRegex, "from '../../models/$1'"); - // Correct the import name and the references to this type by removing the prefix - content = content.replace(/\bcore_v1beta1_openapi_yaml_components_schemas_([A-Za-z][A-Za-z0-9]*)\b/g, '$1'); + // 2. Correct the name references by removing the prefix + content = content.replace(typeRegex, '$1'); - // Only write if content changed if (content !== originalContent) { await fsPromises.writeFile(filePath, content, 'utf8'); } @@ -112,5 +95,5 @@ async function fixImagebuilderCoreReferences(modelsDir) { module.exports = { rimraf, copyDir, - fixImagebuilderCoreReferences, + fixCoreReferences, }; diff --git a/libs/ui-components/assets/flight-control-logo.png b/libs/ui-components/assets/flight-control-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ed445108083bcc11a88861a1fd842758fc1a8a48 GIT binary patch literal 4508 zcmV;N5o7L&P)wha^iQw8h^n!qe!W^KDj!ulp+ZQJadBA$jS zUYp7W_=T|wh!QumG_pPSKi#C7jOdMBJbAMLoC`C@0zgJWQ}J4|5zMDd2^3ezzGHC zy;)z&%wGtO0A{U_;Lh?m%A0#s0#3l%wsE$;^gA`a?U2^CVGAza_DyN?dung)Q3yEU z;QXVD^`)oCBhnBq!2R82`*)hGv4Xk5=yh`!E#L$uFglp+2T8meq;o@lILt2vN8+^! zLH2U3iWBev;i7wRA*~G0_i&h7`Gw8zxG_9mD2@OYhdCbGKX4FlZ`Z0A0S{npkKm8X z6`g+xvspHCI5$HY;@yIa_o9BoLA-#$#5QXI4;Y;9nq}V_eBRai##pkWL%M>5Zf4st z8cl4o74T#Y2H@iWr@okF(^yPGSIpu3Gl$Rm;{Gtlvg-%*B>daq*hXQ+ECuZNcyi(4 zFvp@bmIcgiBx~zSOdUS!gLmzk+y?qCY?2iX#$mYiJgdO zF5E_Zky5rc7mT?Mh{9D{*W(!Q0JV6VWQr?RDfO%@Fyi`uU%*2|?X1!bI+nKdz=!p1 zSRhUYt}{8&;4ztEol?(Dz_I9@WjkIl<~XJkr~K`U1JlfD-BUeCY@z_xM079 z5U>uK^frQ?(Z5x{gSNdD9diSJ*WlA(ZvS-3QNY*WcdXA)nsk;;=X-{|zj+Zm9umsr z&A`QcgO}91G&v!B*0*?%0H!V#dD#hAM0jL2R>nqTQh|B8V1rm+6DDg+bz=-5Uck}% z0+|cAP3w`_{H=OqN@v-gE?835*Ooxyb+*1hev>#Q^kr-y^cJH>rYP#SkR&IB&-l7v z%<=Q7u!8@2hnJ;PMOk29c$z4dVtrTepSt&AeSyL(un#PkzjVPo93vPMS-A%AtjMyh z5T@?L+d|gdQprK_c)^0ieSI)ll@-vE3Cu%m=X`+cE`ro6Thr~)QDTN~!-I3d{J z{0Vaf;2(OXscqZ?zovfgObYJ34+4|lr?IxzCN4!= zV5%DX3cgz6Ex1TGbRk2*0v_8HRidJ8aYK}CdcV1g7BIT+_A7)FS<2S8Y3d?l*ZRM= z$GiaZeZXP`Os<10GuK|fw)1pa=IJ;g(A846E6;ojILOUitbhS!4EEUi|IWktlC{1B zQ~Ye-E}aap)q(2#))XmVdR(d|Wj^Tx2(T;AHzC-~U8I1ao~!`4ur?dV z^QCTmb>GKI^1833OU%=8E+|gGLbpa47mrI1Fwe*HjjCtG7sbuz!w(L6a~CIIaPg`p zE3!D`-tv5@TVLSP=ND1MWqk;kc{qlY5(TUZ6&)deT<$E-Cp%tB$euV)8u!U{A{YGT zE>XZ!#m{{2MWGWwDlBp7&mN)oja^}Zk6)yKHIBDAdlvlVc(iEUkxK~S*&YtOeo9p*{2IlVyA0^kVX#zs_= zHEaYDZ6Ryc7FgI(^A<0bCPbF44U2FNW9a)>;WCVP1@8Z=A~e2;8dl795+^LY{2RPf z@=pMtCu_TUQv-kYOTxY{gS_6?cJDaTY#g&Zj}_Sp81j{}@5u+WL6^+fLR};@7~}%k zZs)4P#1FoYoz%%vz_k2FWl?Mk)>U!QCn(yC4hh|`I@jro<>C9fWLsTlpAcjvU|Nv) zh(I0M$$$O>FBkhr|9*Fvrzu|I5&FJu%<|YP=$}jv7-;W~Y@!f?mFctO{g;Hr<`we! zb*_W5&u@=&Ou>cR-ok7PfJK@(canvG4IAu9Hz6qv`d<-2If1-{~=lu z!E%2e%Y{;e4@c=XwTU8N@@v0Yd~`G+u`Z}J)(Y|@#hbC*$TDJu(QG>%?tBg+7s%?~ zL0^D#i($5&O$ILlCQEULmsqWKyQUs&$1)KSp-!2)mDLSJhsgTi6h@=kgcX3#I8QXZ zg-`@SLNr;n{)B;Y>qA0m;g9IQAKfS>uqv#5U}4W-Rc*o>ArvNi{~p{`*nmF{H>C&P zyCApWUxSZ~dVbJgk0T{UuG+F!H^X%KxFDmOCz#?hk)hnaHD^dr3_>b=vc9-crFb#oyE-LNB@|awT-`V?wg z;1%StOq&>IX*6BleK*Ta4#C7+6~wgSnQC1zthzBE;CyCJIzd z$G|Vt5!@YMz-fDeDPATj&n5f0me#h5jq6x!`RUjH{^(O-_D2a8NKpz}Z2d+XvbMM2 z2B}Z1yARQ}6ln7bddqrWxOf5n9k6JF830PqG`%H!PRO5zOlIkddG;va!uBY_FEYXs z$lA7HfsWgYS5U76SmZwIHe5kc1OcsWo5A;74@t-qWnzOIo}MS=%JLELO`s=h+)^*)f5C6SP~zPJ z_yxcl@SD0XEXZgq$zq!=S16qJJ=Nt;LjA=8cEvQNoHfT*G*oGmT(g=YTnGx8Ex}tk zJ%bnM?3$maYHqINZ9%U848#}W=!+8 zPYFjLLXNAmzQaMhNn2ka&q*AE`aemKl=(;?obUz^ROk$D(bP;wNo z&WBvY7=#Pd$>tNxx>>6D(6#NXP!nwvkxAbA0vWmxP%nYXW)%rNB^YoDYt7KH8H@&L zQ*lS}0vT9eAj3?YA^?8`-%BFtDziRNsI3w6jQ(!B09)`iix)n%2k|DEb?YKK0RsT- z4k|X|NIrG!3QfeCKi5#SxuUM}F7*}J^a06Iz`$9PZ2(x6V0nGeadVrJjr9dG7I5Vf z58?$fvc5o`QHV+hp2V9)(U(qFiI&y7iWkTv@*q-Oh(wV@5;`ZV zlQnj5U&)b`@7qvah;-@duycj(O0u1Y^Dz&{h)xx7x-2BGzi#cX?1OD?zHdVnaDq^> zM&%N;E7~@Wr_6S@r7GYwQI&!oJzLBD&|77>Pz9VA6m?LgfPMSG%;B?xQw5wJy2m-| zcx`8SuB@#uPz9V6w9-W7`}%I@&paM$5UPOFVxjpt>w3}BC(hpb0#(4Nq4Irwr+Jyj zV+}?Xa9XHVXe>0J-D`e+_TCb#FHi-X8cg!~ryjnq?&p2ZlO0vS2|-&u{Dl`we1!i^ z7>lvKK&?g_6j}>+HVJdsuVjOM9e!4go2uU?LHoIFAa6)`1qXqV>miF`6uaoP3OFz%+!gqq z7dju+wQt%Cd>@cz&x<{H1C?os;svS?9F(@N+k>6U`DnS(!sQsbQ3dP(E$yEIc$O^d zU!lKv_b6|-)Zo-LafS#x!Ow7BcyoW4ztr(ki0o_6!*0Q&5IaAk91qn8?#X101udTZ z2EGEgT{ZTeR#KSqfYufOmHt#0LIe4+cj&k3cY&AA@x8)Y6g{X|QZ29o>bLM2{g`cZ-*Umlav+D= zhDr~r3*m=7_!h2JsFA4_Sk5EV`_S#dE$(A=35KKkzzS#*_rCS=69ATLC!5k3wScMm zOYH1;(Y9aMzXAX@Rk&#}S^+EekvM{HjJ=CJriwXZp2R((dlj%#Sz{$GqZBZeH>ye- zXK_YSab&M0;cR**B*0ivTZjyHbx_0nzZ#vG`y=5xO^CmfB|je zJb?FLSVb61#S>!^@J#dbhm?1@uXHme0jtQi)%j;IP88RjTuh88$ uVOi>u^D!{GR5FFq#7)9?0}iL+$o~Q1JV5$t1 literal 0 HcmV?d00001 diff --git a/libs/ui-components/package.json b/libs/ui-components/package.json index ac9982178..b6dc0fa56 100644 --- a/libs/ui-components/package.json +++ b/libs/ui-components/package.json @@ -31,6 +31,9 @@ "react-router-dom": "6.30.3" }, "dependencies": { + "@rjsf/core": "5.24.13", + "@rjsf/utils": "5.24.13", + "@rjsf/validator-ajv8": "5.24.13", "@types/js-yaml": "^4.0.9", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", @@ -39,6 +42,8 @@ "fuzzysearch": "^1.0.3", "js-yaml": "^4.1.1", "percent-round": "^2.3.1", + "react-markdown": "^8.0.7", + "semver": "^7.7.3", "use-debounce": "^10.0.1", "yup": "^1.3.3" }, @@ -50,12 +55,13 @@ "@patternfly/react-icons": "^6.4.0", "@patternfly/react-styles": "^6.4.0", "@patternfly/react-table": "^6.4.0", - "victory": "^37.3.6", + "@patternfly/react-topology": "^6.4.0", "i18next": "21.8.14 - 23.x", + "monaco-editor": "^0.51.0", "react": "17.0.1 - 18.x", "react-dom": "17.0.1 - 18.x", "react-i18next": "11.7.3 - 15.x", "react-router-dom": "^5.3 || ^6.30.3", - "monaco-editor": "^0.51.0" + "victory": "^37.3.6" } } diff --git a/libs/ui-components/src/components/Catalog/CatalogItemCard.tsx b/libs/ui-components/src/components/Catalog/CatalogItemCard.tsx new file mode 100644 index 000000000..9bb57a708 --- /dev/null +++ b/libs/ui-components/src/components/Catalog/CatalogItemCard.tsx @@ -0,0 +1,86 @@ +import { + Card, + CardBody, + CardHeader, + Content, + ContentVariants, + Label, + Split, + SplitItem, + Stack, + StackItem, + Title, +} from '@patternfly/react-core'; +import * as React from 'react'; +import { CatalogItem, CatalogItemCategory } from '@flightctl/types/alpha'; + +import { useTranslation } from '../../hooks/useTranslation'; +import { getCatalogItemBadge, getCatalogItemIcon } from './utils'; + +export type CatalogItemCardProps = { + catalogItem: CatalogItem; + onSelect: VoidFunction; +}; + +const CatalogItemCard: React.FC = ({ catalogItem, onSelect }) => { + const { t } = useTranslation(); + return ( + + + + + {`${catalogItem.metadata.name} + + + + + + + + + + + + {catalogItem.spec.displayName || catalogItem.metadata.name} + + {catalogItem.spec.provider && ( + + + {t('Provided by {{provider}}', { provider: catalogItem.spec.provider })} + + + )} + + + {catalogItem.spec.shortDescription && {catalogItem.spec.shortDescription}} + {catalogItem.spec.deprecation && ( + + + + )} + + + + ); +}; + +export default CatalogItemCard; diff --git a/libs/ui-components/src/components/Catalog/CatalogItemDetails.css b/libs/ui-components/src/components/Catalog/CatalogItemDetails.css new file mode 100644 index 000000000..93373739d --- /dev/null +++ b/libs/ui-components/src/components/Catalog/CatalogItemDetails.css @@ -0,0 +1,4 @@ +.fctl-catalog-item-details { + overflow-wrap: break-word; + word-break: break-word; +} diff --git a/libs/ui-components/src/components/Catalog/CatalogItemDetails.tsx b/libs/ui-components/src/components/Catalog/CatalogItemDetails.tsx new file mode 100644 index 000000000..34a173e57 --- /dev/null +++ b/libs/ui-components/src/components/Catalog/CatalogItemDetails.tsx @@ -0,0 +1,294 @@ +import { CatalogItem, CatalogItemType } from '@flightctl/types/alpha'; +import { + Alert, + Button, + Content, + ContentVariants, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Divider, + Drawer, + DrawerActions, + DrawerCloseButton, + DrawerContent, + DrawerContentBody, + DrawerHead, + DrawerPanelBody, + DrawerPanelContent, + Grid, + GridItem, + Split, + SplitItem, + Stack, + StackItem, + Title, +} from '@patternfly/react-core'; +import * as React from 'react'; +import { createPortal } from 'react-dom'; +import * as semver from 'semver'; +import ReactMarkdown from 'react-markdown'; +import { Formik, useFormikContext } from 'formik'; + +import { useTranslation } from '../../hooks/useTranslation'; +import { InstallSpec, InstallSpecFormik } from './InstallWizard/steps/SpecificationsStep'; +import FlightCtlForm from '../form/FlightCtlForm'; +import { getCatalogItemIcon } from './utils'; + +import './CatalogItemDetails.css'; + +type CatalogItemDetailsPanelProps = { + item: CatalogItem; + onClose: VoidFunction; + canInstall: boolean; +}; + +type CatalogItemDetailsProps = CatalogItemDetailsPanelProps & { + onInstall: (installItem: { item: CatalogItem; channel: string; version: string }) => void; +}; + +const getPageContentTop = () => { + // Try multiple selectors to find the masthead + const masthead = + document.getElementById('stack-inline-masthead') || // Standalone masthead + document.getElementById('page-main-header'); // OCP Console masthead + + const pageTop = document.getElementById('fctl-cmd-panel'); + + return masthead?.getBoundingClientRect()?.bottom || pageTop?.getBoundingClientRect()?.top || 60; +}; + +const usePageContentTop = () => { + const [topOffset, setTopOffset] = React.useState(() => getPageContentTop()); + + React.useEffect(() => { + const measureTop = () => { + setTopOffset(getPageContentTop()); + }; + + // Measure immediately + measureTop(); + + // Also measure after a short delay in case layout isn't complete + const timeoutId = setTimeout(measureTop, 50); + + window.addEventListener('resize', measureTop); + + return () => { + clearTimeout(timeoutId); + window.removeEventListener('resize', measureTop); + }; + }, []); + + return topOffset; +}; + +type CatalogItemDetailsHeaderProps = { + item: CatalogItem; +}; + +export const CatalogItemDetailsHeader = ({ item }: CatalogItemDetailsHeaderProps) => { + const { t } = useTranslation(); + return ( + + + {`${item.metadata.name} + + + {item.spec.displayName || item.metadata.name} + {item.spec.provider && ( + + {t('Provided by {{provider}}', { provider: item.spec.provider })} + + )} + + + ); +}; + +const CatalogItemDetailsPanel = ({ item, onClose, canInstall }: CatalogItemDetailsPanelProps) => { + const { t } = useTranslation(); + const topOffset = usePageContentTop(); + + const { + values: { version, channel }, + submitForm, + isValid, + } = useFormikContext(); + + const installEnabled = !!version && !!channel && canInstall; + + const panelContent = ( + + + + + + + + + + + + + + + {item.spec.type === CatalogItemType.CatalogItemTypeData ? ( + + ) : ( + + + + )} + + + + + + + + + + ); + + const drawerWrapper = ( +
+ + + + + +
+ ); + + return createPortal(drawerWrapper, document.body); +}; + +type CatalogItemDetailsContentProps = { + item: CatalogItem; +}; + +export const CatalogItemDetailsContent = ({ item }: CatalogItemDetailsContentProps) => { + const { t } = useTranslation(); + + const { + values: { version }, + } = useFormikContext(); + + const readme = item.spec.versions.find((v) => v.version === version)?.readme; + + return ( + + + + + {t('Provider')} + + {item.spec.provider || t('N/A')} + + + + {t('Documentation URL')} + + {item.spec.documentationUrl ? ( + + ) : ( + t('N/A') + )} + + + + {t('Homepage')} + + {item.spec.homepage ? ( + + ) : ( + t('N/A') + )} + + + + + + {t('Description')} + {item.spec.shortDescription || t('N/A')} + {t('Readme')} + {readme ? ( + + {readme} + + ) : ( + t('N/A') + )} + + + ); +}; + +export const getDefaultChannelAndVersion = (item: CatalogItem) => { + if (!item.spec.versions.length) { + return { + version: '', + channel: '', + }; + } + + const versions = item.spec.versions.sort((v1, v2) => semver.rcompare(v1.version, v2.version)); + + // release then prerelease + const latestVersion = versions.find((v) => !semver.prerelease(v.version)) || versions[0]; + + return { + version: latestVersion.version, + channel: latestVersion.channels[0], + }; +}; + +const CatalogItemDetails = ({ item, onInstall, ...rest }: CatalogItemDetailsProps) => { + // reinitialize when item changes + // eslint-disable-next-line react-hooks/exhaustive-deps + const initialValues = React.useMemo(() => getDefaultChannelAndVersion(item), [item.metadata.name]); + + return ( + + initialValues={initialValues} + enableReinitialize + onSubmit={({ channel, version }) => { + onInstall({ item, channel, version }); + }} + > + + + ); +}; + +export default CatalogItemDetails; diff --git a/libs/ui-components/src/components/Catalog/CatalogPage.css b/libs/ui-components/src/components/Catalog/CatalogPage.css new file mode 100644 index 000000000..d2d9329f2 --- /dev/null +++ b/libs/ui-components/src/components/Catalog/CatalogPage.css @@ -0,0 +1,3 @@ +.fctl-catalog-page { + padding-top: var(--pf-t--global--spacer--md); +} diff --git a/libs/ui-components/src/components/Catalog/CatalogPage.tsx b/libs/ui-components/src/components/Catalog/CatalogPage.tsx new file mode 100644 index 000000000..24aa50c53 --- /dev/null +++ b/libs/ui-components/src/components/Catalog/CatalogPage.tsx @@ -0,0 +1,285 @@ +import { + Button, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Divider, + EmptyStateActions, + EmptyStateBody, + EmptyStateFooter, + Gallery, + PageSection, + Split, + SplitItem, + Stack, + StackItem, + TreeView, + TreeViewDataItem, +} from '@patternfly/react-core'; +import { SearchIcon } from '@patternfly/react-icons/dist/js/icons/search-icon'; +import * as React from 'react'; +import { CatalogItem, CatalogItemCategory, CatalogItemType } from '@flightctl/types/alpha'; + +import { useTranslation } from '../../hooks/useTranslation'; +import CatalogItemCard from './CatalogItemCard'; +import CatalogPageToolbar from './CatalogPageToolbar'; +import { CatalogFilter, useCatalogFilter } from './useCatalogFilter'; +import CatalogItemDetails from './CatalogItemDetails'; +import { appTypeIds, useCatalogItems } from './useCatalogs'; +import ListPageBody from '../ListPage/ListPageBody'; +import ResourceListEmptyState from '../common/ResourceListEmptyState'; +import { RESOURCE, VERB } from '../../types/rbac'; +import { usePermissionsContext } from '../common/PermissionsContext'; +import { ROUTE, useNavigate } from '../../hooks/useNavigate'; +import ListPage from '../ListPage/ListPage'; + +import './CatalogPage.css'; + +type CatalogPageContentProps = { + canInstall: boolean; + onInstall: (installItem: { item: CatalogItem; channel: string; version: string }) => void; +}; + +type CatalogEmptyStateProps = { + hasFilters: boolean; +}; + +const CatalogEmptyState = ({ hasFilters }: CatalogEmptyStateProps) => { + const { t } = useTranslation(); + return ( + + + + {hasFilters ? ( + + {t('No catalog items match the selected filters or search. Try adjusting the category or search.')} + + ) : ( + <> + + {t('Catalog items are applications and system images you can deploy to your devices.')} + + + )} + + + {!hasFilters && ( + + + + + + )} + + ); +}; + +const CatalogPageFilter = ({ catalogFilter }: { catalogFilter: CatalogFilter }) => { + const { t } = useTranslation(); + + const handleCheck = (event: React.ChangeEvent, item: TreeViewDataItem) => { + const id = item.id as string; + + if (id === CatalogItemCategory.CatalogItemCategoryApplication) { + if (appTypeIds.every((id) => catalogFilter.itemType.includes(id))) { + catalogFilter.setItemType(catalogFilter.itemType.filter((id) => !appTypeIds.includes(id))); + } else { + const newTypes = catalogFilter.itemType.filter((id) => !appTypeIds.includes(id)); + catalogFilter.setItemType([...newTypes, ...appTypeIds]); + } + } else { + const newTypes = catalogFilter.itemType.includes(id as CatalogItemType) + ? catalogFilter.itemType.filter((c) => c !== id) + : [...catalogFilter.itemType, id as CatalogItemType]; + catalogFilter.setItemType(newTypes); + } + }; + + const osTypeChecked = catalogFilter.itemType.includes(CatalogItemType.CatalogItemTypeOS); + const anyAppTypeChecked = appTypeIds.some((t) => catalogFilter.itemType.includes(t)); + + const filterData: TreeViewDataItem[] = [ + { + name: t('Operating system'), + id: CatalogItemType.CatalogItemTypeOS, + checkProps: { + checked: osTypeChecked, + }, + }, + { + name: t('Application'), + id: CatalogItemCategory.CatalogItemCategoryApplication, + checkProps: { + checked: appTypeIds.every((id) => catalogFilter.itemType.includes(id)) + ? true + : anyAppTypeChecked + ? null + : false, + }, + defaultExpanded: true, + children: [ + { + name: t('Container'), + id: CatalogItemType.CatalogItemTypeContainer, + checkProps: { + checked: catalogFilter.itemType.includes(CatalogItemType.CatalogItemTypeContainer), + }, + }, + { + name: t('Helm'), + id: CatalogItemType.CatalogItemTypeHelm, + checkProps: { + checked: catalogFilter.itemType.includes(CatalogItemType.CatalogItemTypeHelm), + }, + }, + { + name: t('Quadlet'), + id: CatalogItemType.CatalogItemTypeQuadlet, + checkProps: { + checked: catalogFilter.itemType.includes(CatalogItemType.CatalogItemTypeQuadlet), + }, + }, + { + name: t('Compose'), + id: CatalogItemType.CatalogItemTypeCompose, + checkProps: { + checked: catalogFilter.itemType.includes(CatalogItemType.CatalogItemTypeCompose), + }, + }, + { + name: t('Data'), + id: CatalogItemType.CatalogItemTypeData, + checkProps: { + checked: catalogFilter.itemType.includes(CatalogItemType.CatalogItemTypeData), + }, + }, + ], + }, + ]; + + return ; +}; + +export const CatalogPageContent = ({ canInstall, onInstall }: CatalogPageContentProps) => { + const [selectedItem, setSelectedItem] = React.useState<{ itemName: string; catalog: string }>(); + const { t } = useTranslation(); + + const catalogFilter = useCatalogFilter(); + + const [catalogItems, isLoading, error, pagination, isUpdating] = useCatalogItems(catalogFilter); + + const item = selectedItem + ? catalogItems?.find( + ({ metadata }) => metadata.name === selectedItem.itemName && metadata.catalog === selectedItem.catalog, + ) + : undefined; + + const filterIsEmpty = catalogFilter.itemType.length === 0; + + return ( + <> + +
+ + + + + + + {t('Category')} + + + + + + + + + {!isLoading && catalogItems.length === 0 ? ( + + ) : ( + + {catalogItems.map((ci) => ( + + setSelectedItem((val) => { + if (!val || val.itemName !== ci.metadata.name || val.catalog !== ci.metadata.catalog) { + return { + itemName: ci.metadata.name || '', + catalog: ci.metadata.catalog, + }; + } else { + return undefined; + } + }) + } + /> + ))} + + )} + + + +
+
+ {!!item && ( + setSelectedItem(undefined)} + item={item} + canInstall={canInstall} + onInstall={onInstall} + /> + )} + + ); +}; + +const catalogInstallPermissions = [ + { kind: RESOURCE.FLEET, verb: VERB.PATCH }, + { kind: RESOURCE.DEVICE, verb: VERB.PATCH }, +]; + +const CatalogPage = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { checkPermissions } = usePermissionsContext(); + const [canEditFleet, canEditDevice] = checkPermissions(catalogInstallPermissions); + + return ( + + { + const params = new URLSearchParams({ + channel, + version, + }); + navigate({ + route: ROUTE.CATALOG_INSTALL, + postfix: `${item.metadata.catalog}/${item.metadata.name}?${params.toString()}`, + }); + }} + /> + + ); +}; + +export default CatalogPage; diff --git a/libs/ui-components/src/components/Catalog/CatalogPageToolbar.tsx b/libs/ui-components/src/components/Catalog/CatalogPageToolbar.tsx new file mode 100644 index 000000000..6199d3603 --- /dev/null +++ b/libs/ui-components/src/components/Catalog/CatalogPageToolbar.tsx @@ -0,0 +1,36 @@ +import { Toolbar, ToolbarContent, ToolbarItem } from '@patternfly/react-core'; +import * as React from 'react'; +import TableTextSearch from '../Table/TableTextSearch'; +import { useTranslation } from '../../hooks/useTranslation'; +import { CatalogFilter } from './useCatalogFilter'; +import TablePagination from '../Table/TablePagination'; +import { PaginationDetails } from '../../hooks/useTablePagination'; +import { CatalogItemList } from '@flightctl/types/alpha'; + +type CatalogPageToolbarProps = CatalogFilter & { + pagination: PaginationDetails; + isUpdating: boolean; +}; + +const CatalogPageToolbar: React.FC = ({ + nameFilter, + setNameFilter, + pagination, + isUpdating, +}) => { + const { t } = useTranslation(); + return ( + + + + + + + + + + + ); +}; + +export default CatalogPageToolbar; diff --git a/libs/ui-components/src/components/Catalog/EditWizard/EditAppWizard.tsx b/libs/ui-components/src/components/Catalog/EditWizard/EditAppWizard.tsx new file mode 100644 index 000000000..285c371ae --- /dev/null +++ b/libs/ui-components/src/components/Catalog/EditWizard/EditAppWizard.tsx @@ -0,0 +1,188 @@ +import { Wizard, WizardStep, WizardStepType } from '@patternfly/react-core'; +import * as React from 'react'; +import { CatalogItem, CatalogItemVersion } from '@flightctl/types/alpha'; +import { Formik, FormikErrors, useFormikContext } from 'formik'; +import * as Yup from 'yup'; +import { RJSFValidationError } from '@rjsf/utils'; +import { ApplicationProviderSpec } from '@flightctl/types'; +import semver from 'semver'; + +import { useTranslation } from '../../../hooks/useTranslation'; +import { applyInitialConfig, getInitialAppConfig } from '../InstallWizard/utils'; +import AppConfigStep, { isAppConfigStepValid } from '../InstallWizard/steps/AppConfigStep'; +import FlightCtlWizardFooter from '../../common/FlightCtlWizardFooter'; +import { useSubmitCatalogForm } from '../useSubmitCatalogForm'; +import { getUpdates } from '../utils'; +import { AppUpdateFormik } from './types'; +import UpdateStep, { isUpdateStepValid } from './steps/UpdateStep'; +import ReviewStep from './steps/ReviewStep'; +import LeaveFormConfirmation from '../../common/LeaveFormConfirmation'; +import { validApplicationAndVolumeName } from '../../form/validations'; + +const versionStepId = 'version-step'; +const configStepId = 'config-step'; +const reviewStepId = 'review-step'; + +const validateUpdateWizardStep = ( + activeStepId: string, + errors: FormikErrors, + values: AppUpdateFormik, +) => { + if (activeStepId === versionStepId) return isUpdateStepValid(errors); + if (activeStepId === configStepId) return isAppConfigStepValid(values, errors); + return true; +}; + +type WizardContentProps = { + currentVersion: CatalogItemVersion; + appSpec?: ApplicationProviderSpec; + catalogItem: CatalogItem; + error: string | undefined; + schemaErrors: RJSFValidationError[] | undefined; + currentLabels: Record | undefined; + setError: (err: string | undefined) => void; +}; + +const WizardContent: React.FC = ({ + currentVersion, + appSpec, + catalogItem, + error, + schemaErrors, + currentLabels, + setError, +}) => { + const { t } = useTranslation(); + const [currentStep, setCurrentStep] = React.useState(); + + const { values, errors, setFieldValue } = useFormikContext(); + + const isVersionStepValid = !!values.version; + const isConfigStepValid = isAppConfigStepValid(values, errors); + + return ( + <> + + + firstStepId={versionStepId} + submitStepId={reviewStepId} + validateStep={(activeStepId, errors, values) => validateUpdateWizardStep(activeStepId, errors, values)} + saveButtonText={appSpec ? t('Update') : t('Deploy')} + /> + } + onStepChange={(_, step) => { + if (error) { + setError(undefined); + } + setCurrentStep(step); + }} + > + + {(!currentStep || currentStep?.id === versionStepId) && ( + { + const appConfig = getInitialAppConfig(catalogItem, version, appSpec, currentLabels); + applyInitialConfig(setFieldValue, appConfig); + }} + /> + )} + + + {currentStep?.id === configStepId && } + + + {currentStep?.id === reviewStepId && ( + + )} + + + + ); +}; + +type EditAppWizardProps = { + catalogItem: CatalogItem; + currentVersion: CatalogItemVersion; + onUpdate: (catalogItemVersion: CatalogItemVersion, values: AppUpdateFormik) => Promise; + currentChannel: string; + appSpec?: ApplicationProviderSpec; + currentLabels: Record | undefined; + currentApps: ApplicationProviderSpec[] | undefined; + version: string; + channel: string; +}; + +const EditAppWizard: React.FC = ({ + catalogItem, + currentVersion, + onUpdate, + currentChannel, + appSpec, + currentLabels, + currentApps, + version, + channel, +}) => { + const { t } = useTranslation(); + + const latestVersion = getUpdates(catalogItem, currentChannel, currentVersion.version).sort((a, b) => + semver.rcompare(a.version, b.version), + )[0]?.version; + const appVersion = appSpec ? latestVersion || currentVersion.version : version; + const appConfig = getInitialAppConfig(catalogItem, appVersion, appSpec, currentLabels); + + const validationSchema = Yup.object({ + version: Yup.string().required(t('Version must be selected')), + appName: appSpec + ? Yup.string() + : validApplicationAndVolumeName(t) + .required(t('Application name is required')) + .test('is-unique', t('Application with the same name already exists.'), (value) => { + if (!value || value.length === 0) { + return true; + } + if (!currentApps?.length) { + return true; + } + return !currentApps.some((app) => app.name === value); + }), + }); + + const { onSubmit, error, schemaErrors, setError } = useSubmitCatalogForm(async (values) => { + const catalogItemVersion = catalogItem.spec.versions.find((v) => v.version === values.version); + if (!catalogItemVersion) { + throw t('Version {{version}} not found', { version: values.version }); + } + await onUpdate(catalogItemVersion, values); + }); + + return ( + + validationSchema={validationSchema} + initialValues={{ + version: appVersion, + channel: appSpec ? currentChannel : channel, + ...appConfig, + }} + validateOnMount + onSubmit={onSubmit} + > + + + ); +}; + +export default EditAppWizard; diff --git a/libs/ui-components/src/components/Catalog/EditWizard/EditOsWizard.tsx b/libs/ui-components/src/components/Catalog/EditWizard/EditOsWizard.tsx new file mode 100644 index 000000000..bd2e0393f --- /dev/null +++ b/libs/ui-components/src/components/Catalog/EditWizard/EditOsWizard.tsx @@ -0,0 +1,136 @@ +import { Wizard, WizardStep, WizardStepType } from '@patternfly/react-core'; +import * as React from 'react'; +import { CatalogItem, CatalogItemVersion } from '@flightctl/types/alpha'; +import { Formik, FormikErrors, useFormikContext } from 'formik'; +import * as Yup from 'yup'; +import semver from 'semver'; + +import { useTranslation } from '../../../hooks/useTranslation'; +import { InstallSpecFormik } from '../InstallWizard/types'; +import FlightCtlWizardFooter from '../../common/FlightCtlWizardFooter'; +import { getUpdates } from '../utils'; +import UpdateStep, { isUpdateStepValid } from './steps/UpdateStep'; +import { getErrorMessage } from '../../../utils/error'; +import ReviewStep from './steps/ReviewStep'; +import LeaveFormConfirmation from '../../common/LeaveFormConfirmation'; + +const versionStepId = 'version-step'; +const reviewStepId = 'review-step'; + +const validateUpdateWizardStep = (activeStepId: string, errors: FormikErrors) => { + if (activeStepId === versionStepId) return isUpdateStepValid(errors); + return true; +}; + +type WizardContentProps = { + currentVersion: CatalogItemVersion; + catalogItem: CatalogItem; + error: string | undefined; + setError: (err: string | undefined) => void; + isEdit: boolean; +}; + +const WizardContent: React.FC = ({ currentVersion, catalogItem, error, setError, isEdit }) => { + const { t } = useTranslation(); + const [currentStep, setCurrentStep] = React.useState(); + + const { values } = useFormikContext(); + + const isVersionStepValid = !!values.version; + + return ( + <> + + + firstStepId={versionStepId} + submitStepId={reviewStepId} + validateStep={(activeStepId, errors) => validateUpdateWizardStep(activeStepId, errors)} + saveButtonText={isEdit ? t('Update') : t('Deploy')} + /> + } + onStepChange={(_, step) => { + if (error) { + setError(undefined); + } + setCurrentStep(step); + }} + > + + {(!currentStep || currentStep?.id === versionStepId) && ( + + )} + + + {currentStep?.id === reviewStepId && } + + + + ); +}; + +type EditOsWizardProps = { + catalogItem: CatalogItem; + currentVersion: CatalogItemVersion; + onUpdate: (catalogItemVersion: CatalogItemVersion, values: InstallSpecFormik) => Promise; + currentChannel: string; + currentLabels: Record | undefined; + isEdit: boolean; + version: string; + channel: string; +}; + +const EditOsWizard: React.FC = ({ + catalogItem, + currentVersion, + onUpdate, + currentChannel, + isEdit, + version, + channel, +}) => { + const [error, setError] = React.useState(); + const { t } = useTranslation(); + + const validationSchema = Yup.object({ + version: Yup.string().required(t('Version must be selected')), + }); + + const latestVersion = getUpdates(catalogItem, currentChannel, currentVersion.version).sort((a, b) => + semver.rcompare(a.version, b.version), + )[0]?.version; + + return ( + + validationSchema={validationSchema} + initialValues={{ + version: isEdit ? latestVersion || currentVersion.version : version, + channel: isEdit ? currentChannel : channel, + }} + validateOnMount + onSubmit={async (values) => { + const catalogItemVersion = catalogItem.spec.versions.find((v) => v.version === values.version); + if (!catalogItemVersion) { + setError(t('Version {{version}} not found', { version: values.version })); + return; + } + try { + await onUpdate(catalogItemVersion, values); + } catch (e) { + setError(getErrorMessage(e)); + } + }} + > + + + ); +}; + +export default EditOsWizard; diff --git a/libs/ui-components/src/components/Catalog/EditWizard/EditWizard.tsx b/libs/ui-components/src/components/Catalog/EditWizard/EditWizard.tsx new file mode 100644 index 000000000..3c7495d4a --- /dev/null +++ b/libs/ui-components/src/components/Catalog/EditWizard/EditWizard.tsx @@ -0,0 +1,278 @@ +import * as React from 'react'; +import { + Alert, + Breadcrumb, + BreadcrumbItem, + Button, + Content, + ContentVariants, + EmptyState, + PageSection, + Spinner, + Stack, + StackItem, + Title, +} from '@patternfly/react-core'; +import { CatalogItemCategory } from '@flightctl/types/alpha'; +import { ApplicationProviderSpec, ContainerApplication, Device, Fleet } from '@flightctl/types'; + +import ErrorBoundary from '../../common/ErrorBoundary'; +import { getErrorMessage } from '../../../utils/error'; +import { getAppPatches, getFullReferenceURI, getOsPatches } from '../utils'; +import EditOsWizard from './EditOsWizard'; +import { APP_CHANNEL_LABEL_KEY, OS_CHANNEL_LABEL_KEY } from '../const'; +import EditAppWizard from './EditAppWizard'; +import { useAppContext } from '../../../hooks/useAppContext'; +import { useFetch } from '../../../hooks/useFetch'; +import { Link, ROUTE, useNavigate } from '../../../hooks/useNavigate'; +import { useTranslation } from '../../../hooks/useTranslation'; +import { useCatalogItem } from '../useCatalogs'; +import { useFetchPeriodically } from '../../../hooks/useFetchPeriodically'; +import { UpdateSuccessPageContent } from '../InstallWizard/UpdateSuccessPage'; + +type EditWizardProps = { + specPath: string; + currentLabels: Record | undefined; + currentOsImage: string | undefined; + currentApps: ApplicationProviderSpec[] | undefined; + loading: boolean; + error: unknown; + resourceId: string; + isDevice: boolean; +}; + +const EditWizard = ({ + specPath, + currentLabels, + currentOsImage, + currentApps, + error, + loading, + resourceId, + isDevice, +}: EditWizardProps) => { + const [isSuccess, setIsSuccess] = React.useState(false); + const { t } = useTranslation(); + const { patch } = useFetch(); + const { + router: { useParams }, + } = useAppContext(); + const { catalogId, itemId } = useParams() as { catalogId: string; itemId: string }; + + const [catalogItem, catalogItemLoading, catalogItemErr] = useCatalogItem(catalogId, itemId); + + const { + router: { useSearchParams }, + } = useAppContext(); + const [searchParams] = useSearchParams(); + const appName = searchParams.get('appName') || ''; + const version = searchParams.get('version') || ''; + const channel = searchParams.get('channel') || ''; + const navigate = useNavigate(); + + let content: React.ReactNode; + if (catalogItemErr) { + content = ( + + {getErrorMessage(catalogItemErr)} + + ); + } else if (error) { + content = ( + + {getErrorMessage(error)} + + ); + } else if (catalogItemLoading || loading) { + content = ; + } else if (catalogItem?.spec.category === CatalogItemCategory.CatalogItemCategorySystem) { + const currentVersion = version + ? catalogItem.spec.versions.find((v) => v.version === version) + : catalogItem.spec.versions.find( + (v) => getFullReferenceURI(catalogItem.spec.reference.uri, v) === currentOsImage, + ); + const currentChannel = channel || currentLabels?.[OS_CHANNEL_LABEL_KEY]; + if (!currentVersion || !currentChannel) { + content = ; + } else { + content = ( + { + const allPatches = getOsPatches({ + catalogItem, + catalogItemVersion, + channel: values.channel, + currentLabels, + specPath, + currentOsImage, + }); + await patch(`${isDevice ? 'devices' : 'fleets'}/${resourceId}`, allPatches); + setIsSuccess(true); + }} + /> + ); + } + } else if (catalogItem?.spec.category === CatalogItemCategory.CatalogItemCategoryApplication) { + const appSpec = appName ? currentApps?.find((app) => app.name === appName) : undefined; + + if (!!appName && !appSpec) { + content = ; + } else { + const currentVersion = appSpec + ? catalogItem.spec.versions.find( + (v) => getFullReferenceURI(catalogItem.spec.reference.uri, v) === (appSpec as ContainerApplication).image, + ) + : catalogItem.spec.versions.find((v) => v.version === version); + const currentChannel = appSpec ? currentLabels?.[`${appName}.${APP_CHANNEL_LABEL_KEY}`] : channel; + + if (!currentVersion || !currentChannel) { + content = ; + } else { + content = ( + { + const allPatches = getAppPatches({ + appName: values.appName, + catalogItem, + catalogItemVersion, + channel: values.channel, + currentApps, + currentLabels, + formValues: values.formValues, + selectedAssets: values.selectedAssets, + specPath, + }); + await patch(`${isDevice ? 'devices' : 'fleets'}/${resourceId}`, allPatches); + setIsSuccess(true); + }} + /> + ); + } + } + } + + return ( + <> + + + + {isDevice ? t('Devices') : t('Fleets')} + + + + {resourceId} + + + + + {t('Software catalog')} + + + {`${catalogItem?.spec.displayName || itemId}${appName ? ` (${appName})` : ''}`} + + + + + + + {version + ? t('Deploy {{ name }}', { name: catalogItem?.spec.displayName || itemId }) + : t('Edit {{name}}', { name: catalogItem?.spec.displayName || itemId })} + + + + {catalogItem?.spec.shortDescription && ( + {catalogItem.spec.shortDescription} + )} + + + + + + {isSuccess ? ( + + + + ) : ( + content + )} + + + + ); +}; + +export const EditDeviceWizard = () => { + const { + router: { useParams }, + } = useAppContext(); + const { deviceId } = useParams() as { deviceId: string }; + + const [device, loading, error] = useFetchPeriodically>({ + endpoint: `devices/${deviceId}`, + }); + return ( + + ); +}; + +export const EditFleetWizard = () => { + const { + router: { useParams }, + } = useAppContext(); + const params = useParams() as { fleetId: string }; + + const [fleet, loading, error] = useFetchPeriodically>({ + endpoint: `fleets/${params.fleetId}`, + }); + return ( + + ); +}; diff --git a/libs/ui-components/src/components/Catalog/EditWizard/steps/ReviewStep.tsx b/libs/ui-components/src/components/Catalog/EditWizard/steps/ReviewStep.tsx new file mode 100644 index 000000000..9447b5374 --- /dev/null +++ b/libs/ui-components/src/components/Catalog/EditWizard/steps/ReviewStep.tsx @@ -0,0 +1,83 @@ +import { + Alert, + Card, + CardBody, + CardTitle, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + List, + ListItem, + Stack, + StackItem, + Title, +} from '@patternfly/react-core'; +import * as React from 'react'; + +import FlightCtlForm from '../../../form/FlightCtlForm'; +import { useTranslation } from '../../../../hooks/useTranslation'; +import { InstallSpecFormik } from '../../InstallWizard/types'; +import { useFormikContext } from 'formik'; +import { RJSFValidationError } from '@rjsf/utils'; + +type ReviewStepProps = { + error?: string; + schemaErrors?: RJSFValidationError[]; + isEdit: boolean; +}; + +const ReviewStep = ({ error, schemaErrors, isEdit }: ReviewStepProps) => { + const { t } = useTranslation(); + const { values } = useFormikContext(); + return ( + + + + + {isEdit ? t('Review update specifications') : t('Review deployment specifications')} + + + + + {isEdit ? t('Update specifications') : t('Installation specifications')} + + + + {t('Channel')} + {values.channel} + + + {t('Version')} + {values.version} + + + + + + {error && ( + + + {error} + + + )} + {!!schemaErrors?.length && ( + + + + {schemaErrors.map((e, index) => ( + + {e.property}: {e.message} + + ))} + + + + )} + + + ); +}; + +export default ReviewStep; diff --git a/libs/ui-components/src/components/Catalog/EditWizard/steps/UpdateGraph.css b/libs/ui-components/src/components/Catalog/EditWizard/steps/UpdateGraph.css new file mode 100644 index 000000000..998502e98 --- /dev/null +++ b/libs/ui-components/src/components/Catalog/EditWizard/steps/UpdateGraph.css @@ -0,0 +1,4 @@ +.fctl-update-graph .pf-topology-content { + background-color: unset; + height: 400px; +} \ No newline at end of file diff --git a/libs/ui-components/src/components/Catalog/EditWizard/steps/UpdateGraph.tsx b/libs/ui-components/src/components/Catalog/EditWizard/steps/UpdateGraph.tsx new file mode 100644 index 000000000..2b78a2ccf --- /dev/null +++ b/libs/ui-components/src/components/Catalog/EditWizard/steps/UpdateGraph.tsx @@ -0,0 +1,313 @@ +import { Alert, Card, CardBody, Stack, StackItem } from '@patternfly/react-core'; +import semver from 'semver'; +import * as React from 'react'; +import { + ComponentFactory, + DagreLayout, + DefaultEdge, + DefaultNode, + EdgeModel, + EdgeStyle, + GRAPH_LAYOUT_END_EVENT, + Graph, + GraphComponent, + LEFT_TO_RIGHT, + Layout, + LayoutFactory, + Model, + ModelKind, + Node, + NodeModel, + NodeShape, + SELECTION_EVENT, + TopologyControlBar, + TopologyView, + Visualization, + VisualizationProvider, + VisualizationSurface, + WithSelectionProps, + action, + createTopologyControlButtons, + defaultControlButtonsOptions, + observer, + withPanZoom, + withSelection, +} from '@patternfly/react-topology'; +import { CatalogItemVersion } from '@flightctl/types/alpha'; +import warningColor from '@patternfly/react-tokens/dist/js/t_global_icon_color_status_warning_default'; +import successColor from '@patternfly/react-tokens/dist/js/t_global_icon_color_status_success_default'; +import blueColor from '@patternfly/react-tokens/dist/js/chart_color_blue_400'; +import { ArrowCircleUpIcon, CheckCircleIcon, WarningTriangleIcon } from '@patternfly/react-icons/dist/js/icons'; + +import { useTranslation } from '../../../../hooks/useTranslation'; + +import '@patternfly/react-topology/dist/esm/css/topology-components.css'; +import '@patternfly/react-topology/dist/esm/css/topology-controlbar.css'; +import '@patternfly/react-topology/dist/esm/css/topology-view.css'; + +import './UpdateGraph.css'; + +const ICON_SIZE = 12; + +type VersionNodeData = { + version: string; + channel: string; + isCurrentVersion: boolean; + isDeprecated: boolean; + entryName: string; +}; + +const NODE_DIAMETER = 20; + +type VersionNodeProps = { + element: Node; +} & WithSelectionProps; + +const VersionNodeComponent: React.FC = observer(({ element, selected, onSelect }) => { + const data = element.getData() as VersionNodeData; + + let nodeIcon: React.ReactNode = undefined; + if (data.isCurrentVersion) { + nodeIcon = ; + } else if (data.isDeprecated) { + nodeIcon = ; + } else if (selected) { + nodeIcon = ; + } + + return ( + + + + {data.version} + + {nodeIcon && {nodeIcon}} + + + ); +}); + +const VersionNode = withSelection()(VersionNodeComponent); + +const customComponentFactory: ComponentFactory = (kind: ModelKind) => { + switch (kind) { + case ModelKind.graph: + return withPanZoom()(GraphComponent); + case ModelKind.node: + return VersionNode as never; + case ModelKind.edge: + return DefaultEdge; + default: + return undefined; + } +}; + +const customLayoutFactory: LayoutFactory = (type: string, graph: Graph): Layout | undefined => + new DagreLayout(graph, { + rankdir: LEFT_TO_RIGHT, + nodesep: 50, + ranksep: 80, + edgesep: 20, + }); + +const buildTopologyModel = ( + currentVersionEntry: CatalogItemVersion, + directUpgradeEntries: CatalogItemVersion[], + currentChannel: string, +): Model => { + const nodes: NodeModel[] = []; + const edges: EdgeModel[] = []; + + // Deduplicate entries + const entriesMap = new Map(); + directUpgradeEntries.forEach((entry) => entriesMap.set(entry.version, entry)); + if (currentVersionEntry) { + entriesMap.set(currentVersionEntry.version, currentVersionEntry); + } + const allEntries = Array.from(entriesMap.values()); + + // Map to track version nodes for edge creation + const versionToNodeId = new Map(); + + // Create version nodes + allEntries.forEach((versionEntry) => { + const versionName = versionEntry.version; + const nodeId = versionName; + + versionToNodeId.set(versionName, nodeId); + + nodes.push({ + id: nodeId, + type: 'node', + label: versionName, + width: NODE_DIAMETER, + height: NODE_DIAMETER, + shape: NodeShape.ellipse, + data: { + version: versionName, + channel: currentChannel, + isCurrentVersion: versionName === currentVersionEntry.version, + isDeprecated: !!versionEntry.deprecation?.message, + entryName: versionName, + } as VersionNodeData, + }); + }); + + const latestVersion = allEntries + .filter((e) => e.version !== currentVersionEntry.version) + .sort((a, b) => semver.rcompare(a.version, b.version))[0]?.version; + + const getEdgeStyle = (source: string, target: string) => + source === currentVersionEntry.version && target === latestVersion ? EdgeStyle.default : EdgeStyle.dashed; + + // Create edges based on replaces, skips, and skipRange + allEntries.forEach((entry) => { + const targetNodeId = versionToNodeId.get(entry.version); + if (!targetNodeId) return; + + // Edge from replaces (single version string) + if (entry.replaces) { + const sourceNodeId = versionToNodeId.get(entry.replaces); + if (sourceNodeId) { + edges.push({ + id: `edge-${sourceNodeId}-${targetNodeId}`, + type: 'edge', + source: sourceNodeId, + target: targetNodeId, + edgeStyle: getEdgeStyle(sourceNodeId, targetNodeId), + }); + } + } + + // Edges from skips (array of version strings) + entry.skips?.forEach((skippedVersion) => { + const sourceNodeId = versionToNodeId.get(skippedVersion); + if (sourceNodeId) { + edges.push({ + id: `edge-skip-${sourceNodeId}-${targetNodeId}`, + type: 'edge', + source: sourceNodeId, + target: targetNodeId, + edgeStyle: getEdgeStyle(sourceNodeId, targetNodeId), + }); + } + }); + + // Edges from skipRange - find all versions in the graph that satisfy the range + if (entry.skipRange) { + allEntries.forEach((sourceEntry) => { + if (sourceEntry.version === entry.version) return; + if (semver.satisfies(sourceEntry.version, entry.skipRange!, { includePrerelease: true })) { + const sourceNodeId = versionToNodeId.get(sourceEntry.version); + if (sourceNodeId) { + edges.push({ + id: `edge-skiprange-${sourceNodeId}-${targetNodeId}`, + type: 'edge', + source: sourceNodeId, + target: targetNodeId, + edgeStyle: getEdgeStyle(sourceNodeId, targetNodeId), + }); + } + } + }); + } + }); + + return { + nodes, + edges, + graph: { + id: 'update-graph', + type: 'graph', + layout: 'Dagre', + }, + }; +}; + +const UpdateGraph: React.FC<{ + selectedVersion: string; + currentVersion: CatalogItemVersion; + updates: CatalogItemVersion[]; + currentChannel: string; + onSelectionChange: (nodeId: string, tag: string) => void; +}> = ({ selectedVersion, currentVersion, currentChannel, updates, onSelectionChange }) => { + const { t } = useTranslation(); + const controller = React.useMemo(() => { + const newController = new Visualization(); + newController.registerComponentFactory(customComponentFactory); + newController.registerLayoutFactory(customLayoutFactory); + newController.addEventListener(GRAPH_LAYOUT_END_EVENT, () => { + newController.getGraph().fit(80); + }); + newController.addEventListener(SELECTION_EVENT, (ids: string[]) => { + const selectedId = ids[0]; + if (selectedId) { + const node = newController.getNodeById(selectedId); + if (node) { + const data = node.getData() as VersionNodeData | undefined; + if (data?.entryName) { + onSelectionChange(selectedId, data.entryName); + } + } + } + }); + + const model = buildTopologyModel(currentVersion, updates, currentChannel); + newController.fromModel(model, false); + + return newController; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentChannel]); + + const update = updates.find((v) => v.version === selectedVersion); + + return ( + + + + + + { + controller.getGraph().fit(80); + }), + zoomIn: false, + zoomOut: false, + resetView: false, + legend: false, + })} + /> + } + > + + + + + + + {!update && currentVersion.deprecation && ( + + + {currentVersion.deprecation.message} + + + )} + {update?.deprecation && ( + + + {update.deprecation.message} + + + )} + + ); +}; + +export default UpdateGraph; diff --git a/libs/ui-components/src/components/Catalog/EditWizard/steps/UpdateStep.tsx b/libs/ui-components/src/components/Catalog/EditWizard/steps/UpdateStep.tsx new file mode 100644 index 000000000..1bafc375a --- /dev/null +++ b/libs/ui-components/src/components/Catalog/EditWizard/steps/UpdateStep.tsx @@ -0,0 +1,138 @@ +import { FormGroup, Grid, GridItem, Icon, Title } from '@patternfly/react-core'; +import ArrowRightIcon from '@patternfly/react-icons/dist/js/icons/arrow-right-icon'; +import * as React from 'react'; +import { FormikErrors, useFormikContext } from 'formik'; +import { CatalogItem, CatalogItemVersion } from '@flightctl/types/alpha'; +import semver from 'semver'; + +import FlightCtlForm from '../../../form/FlightCtlForm'; +import UpdateGraph from './UpdateGraph'; +import { useTranslation } from '../../../../hooks/useTranslation'; +import FormSelect from '../../../form/FormSelect'; +import { getUpdates } from '../../utils'; +import { InstallSpecFormik } from '../../InstallWizard/types'; +import { StatusDisplayContent } from '../../../Status/StatusDisplay'; +import { InstallSpec } from '../../InstallWizard/steps/SpecificationsStep'; + +export const isUpdateStepValid = (errors: FormikErrors) => { + return !errors.version && !errors.channel; +}; + +type UpdateStepProps = { + currentVersion: CatalogItemVersion; + catalogItem: CatalogItem; + onVersionChange?: (version: string) => void; + isEdit: boolean; +}; + +const UpdateStep = ({ currentVersion, catalogItem, onVersionChange, isEdit }: UpdateStepProps) => { + const { t } = useTranslation(); + const { values, initialValues, setFieldValue } = useFormikContext(); + + const updates = getUpdates(catalogItem, values.channel, currentVersion.version); + + return isEdit ? ( + + + + {t('Version update')} + + + + + + + + + + + + + {initialValues.channel} + + + + + + + { + acc[curr] = curr; + return acc; + }, {})} + onChange={(val) => { + const latestVersion = getUpdates(catalogItem, val, currentVersion.version).sort((a, b) => + semver.rcompare(a.version, b.version), + )[0]?.version; + setFieldValue('version', latestVersion || currentVersion.version); + }} + /> + + + + + + + + + + + + + {currentVersion.version} + + + + + + + {updates.length ? ( + { + acc[curr.version] = curr.version; + return acc; + }, {})} + /> + ) : ( + + )} + + + + + + {!!updates.length && ( + + + { + onVersionChange?.(version); + setFieldValue('version', version); + }} + updates={updates} + /> + + + )} + + + ) : ( + + + + {t('Deployment specifications')} + + + + + + + ); +}; + +export default UpdateStep; diff --git a/libs/ui-components/src/components/Catalog/EditWizard/types.ts b/libs/ui-components/src/components/Catalog/EditWizard/types.ts new file mode 100644 index 000000000..16131456c --- /dev/null +++ b/libs/ui-components/src/components/Catalog/EditWizard/types.ts @@ -0,0 +1,3 @@ +import { DynamicFormConfigFormik, InstallSpecFormik } from '../InstallWizard/types'; + +export type AppUpdateFormik = DynamicFormConfigFormik & InstallSpecFormik; diff --git a/libs/ui-components/src/components/Catalog/InstallWizard/InstallAppWizard.tsx b/libs/ui-components/src/components/Catalog/InstallWizard/InstallAppWizard.tsx new file mode 100644 index 000000000..854043b86 --- /dev/null +++ b/libs/ui-components/src/components/Catalog/InstallWizard/InstallAppWizard.tsx @@ -0,0 +1,228 @@ +import { CatalogItem } from '@flightctl/types/alpha'; +import { Wizard, WizardStep, WizardStepType } from '@patternfly/react-core'; +import { Formik, useFormikContext } from 'formik'; +import * as React from 'react'; +import * as Yup from 'yup'; +import { load } from 'js-yaml'; +import { RJSFValidationError } from '@rjsf/utils'; +import { Device, Fleet } from '@flightctl/types'; + +import { useTranslation } from '../../../hooks/useTranslation'; +import { useFetch } from '../../../hooks/useFetch'; +import { getAppPatches } from '../utils'; +import { InstallAppFormik, appConfigStepId, reviewStepId, selectTargetStepId, specificationsStepId } from './types'; +import SpecificationsStep, { isSpecsStepValid } from './steps/SpecificationsStep'; +import SelectTargetStep, { isSelectTargetStepValid } from './steps/SelectTargetStep'; +import AppConfigStep, { isAppConfigStepValid } from './steps/AppConfigStep'; +import ReviewStep from './steps/ReviewStep'; +import LeaveFormConfirmation from '../../common/LeaveFormConfirmation'; +import UpdateSuccessPage from './UpdateSuccessPage'; +import FlightCtlWizardFooter, { FlightCtlWizardFooterProps } from '../../common/FlightCtlWizardFooter'; +import { useAppContext } from '../../../hooks/useAppContext'; +import { getInitialAppConfig } from './utils'; +import { useSubmitCatalogForm } from '../useSubmitCatalogForm'; +import { validApplicationAndVolumeName } from '../../form/validations'; + +export const validateAppWizardStep: FlightCtlWizardFooterProps['validateStep'] = ( + activeStepId, + errors, + values, +) => { + if (activeStepId === specificationsStepId) return isSpecsStepValid(errors); + if (activeStepId === selectTargetStepId) return isSelectTargetStepValid(errors); + if (activeStepId === appConfigStepId) return isAppConfigStepValid(values, errors); + return true; +}; + +type InstallAppWizardContentProps = { + currentStep: WizardStepType | undefined; + setCurrentStep: (step: WizardStepType) => void; + error: string | undefined; + schemaErrors: RJSFValidationError[] | undefined; + catalogItem: CatalogItem; + isSuccessful: boolean; + setError: (err: string | undefined) => void; +}; + +const InstallAppWizardContent = ({ + currentStep, + setCurrentStep, + error, + schemaErrors, + catalogItem, + isSuccessful, + setError, +}: InstallAppWizardContentProps) => { + const { t } = useTranslation(); + const { values, errors } = useFormikContext(); + return isSuccessful ? ( + + ) : ( + <> + + + firstStepId={specificationsStepId} + submitStepId={reviewStepId} + validateStep={validateAppWizardStep} + saveButtonText={t('Deploy')} + /> + } + onStepChange={(_, step) => { + if (error) { + setError(undefined); + } + setCurrentStep(step); + }} + > + + {(!currentStep || currentStep?.id === specificationsStepId) && ( + + )} + + + {currentStep?.id === selectTargetStepId && } + + + {currentStep?.id === appConfigStepId && } + + + {currentStep?.id === reviewStepId && } + + + + ); +}; + +type InstallAppWizardProps = { + catalogItem: CatalogItem; +}; + +const InstallAppWizard = ({ catalogItem }: InstallAppWizardProps) => { + const { t } = useTranslation(); + const [isSuccessful, setIsSuccessful] = React.useState(false); + const [currentStep, setCurrentStep] = React.useState(); + const { patch, get } = useFetch(); + + const { + router: { useSearchParams }, + } = useAppContext(); + const [searchParams] = useSearchParams(); + const channel = searchParams.get('channel') || ''; + const version = searchParams.get('version') || ''; + + const validationSchema = Yup.lazy((values: InstallAppFormik) => + Yup.object({ + target: Yup.string().required(t('Target must be selected')), + channel: Yup.string().required(t('Channel must be selected')), + version: Yup.string().required(t('Version must be selected')), + device: values.target === 'device' ? Yup.object().required(t('Device must be selected')) : Yup.object(), + fleet: values.target === 'fleet' ? Yup.object().required(t('Fleet must be selected')) : Yup.object(), + appName: validApplicationAndVolumeName(t) + .required(t('Application name is required')) + .test('is-unique', t('Application with the same name already exists.'), (value) => { + if (!value || value.length === 0 || !values.target) { + return true; + } + const apps = + values.target === 'device' + ? values.device?.spec?.applications + : values.fleet?.spec.template.spec.applications; + if (!apps?.length) { + return true; + } + return !apps.some((app) => app.name === value); + }), + }), + ); + + const initialValues = React.useMemo(() => { + const appConfig = getInitialAppConfig(catalogItem, version); + return { + version, + channel, + target: undefined, + fleet: undefined, + device: undefined, + ...appConfig, + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const { onSubmit, error, schemaErrors, setError } = useSubmitCatalogForm(async (values) => { + if (values.target !== 'fleet' && values.target !== 'device') { + return; + } + const selectedDevice = values.device; + const selectedFleet = values.fleet; + const installToDevice = values.target === 'device'; + const resourceId = installToDevice + ? `devices/${selectedDevice?.metadata.name}` + : `fleets/${selectedFleet?.metadata.name}`; + + const res = await get(resourceId); + const currentLabels = res?.metadata.labels; + const specPath = installToDevice ? '/' : '/spec/template/'; + const catalogItemVersion = catalogItem.spec.versions.find((v) => v.version === values.version); + + if (!catalogItemVersion) { + throw t('Failed to find requested version {{version}}', { version: values.version }); + } + const currentApps = installToDevice + ? (res as Device)?.spec?.applications + : (res as Fleet)?.spec.template.spec.applications; + const allPatches = getAppPatches({ + appName: values.appName, + currentApps, + currentLabels, + catalogItem, + catalogItemVersion, + channel: values.channel, + formValues: + values.configureVia === 'editor' ? (load(values.editorContent) as Record) : values.formValues, + specPath, + selectedAssets: values.configureVia === 'form' ? values.selectedAssets : [], + }); + if (!allPatches.length) { + return; + } + await patch(resourceId, allPatches); + }); + + return ( + + validationSchema={validationSchema} + initialValues={initialValues} + validateOnMount + onSubmit={async (values) => { + const success = await onSubmit(values); + if (success) { + setIsSuccessful(true); + } + }} + > + + + ); +}; + +export default InstallAppWizard; diff --git a/libs/ui-components/src/components/Catalog/InstallWizard/InstallOsWizard.tsx b/libs/ui-components/src/components/Catalog/InstallWizard/InstallOsWizard.tsx new file mode 100644 index 000000000..1158f8e3a --- /dev/null +++ b/libs/ui-components/src/components/Catalog/InstallWizard/InstallOsWizard.tsx @@ -0,0 +1,197 @@ +import { CatalogItem } from '@flightctl/types/alpha'; +import { Wizard, WizardStep, WizardStepType } from '@patternfly/react-core'; +import { Formik, useFormikContext } from 'formik'; +import * as React from 'react'; +import * as Yup from 'yup'; +import { Device, Fleet } from '@flightctl/types'; + +import { useTranslation } from '../../../hooks/useTranslation'; +import { useFetch } from '../../../hooks/useFetch'; +import { getOsPatches } from '../utils'; +import { getErrorMessage } from '../../../utils/error'; +import { InstallOsFormik, reviewStepId, selectTargetStepId, specificationsStepId } from './types'; +import SpecificationsStep, { isSpecsStepValid } from './steps/SpecificationsStep'; +import SelectTargetStep, { isSelectTargetStepValid } from './steps/SelectTargetStep'; +import ReviewStep from './steps/ReviewStep'; +import LeaveFormConfirmation from '../../common/LeaveFormConfirmation'; +import UpdateSuccessPage from './UpdateSuccessPage'; +import FlightCtlWizardFooter, { FlightCtlWizardFooterProps } from '../../common/FlightCtlWizardFooter'; +import { useAppContext } from '../../../hooks/useAppContext'; +import { useNavigate } from '../../../hooks/useNavigate'; + +export const validateOsWizardStep: FlightCtlWizardFooterProps['validateStep'] = ( + activeStepId, + errors, +) => { + if (activeStepId === specificationsStepId) return isSpecsStepValid(errors); + if (activeStepId === selectTargetStepId) return isSelectTargetStepValid(errors); + return true; +}; + +type InstallOsWizardContentProps = { + currentStep: WizardStepType | undefined; + setCurrentStep: (step: WizardStepType) => void; + error: string | undefined; + catalogItem: CatalogItem; + isSuccessful: boolean; + setError: (err: string | undefined) => void; +}; + +const InstallOsWizardContent = ({ + currentStep, + setCurrentStep, + error, + catalogItem, + isSuccessful, + setError, +}: InstallOsWizardContentProps) => { + const { t } = useTranslation(); + const { values, errors } = useFormikContext(); + return isSuccessful ? ( + + ) : ( + <> + + + firstStepId={specificationsStepId} + submitStepId={values.target === 'new-device' ? selectTargetStepId : reviewStepId} + validateStep={validateOsWizardStep} + saveButtonText={values.target === 'new-device' ? t('Close') : t('Deploy')} + /> + } + onStepChange={(_, step) => { + if (error) { + setError(undefined); + } + setCurrentStep(step); + }} + > + + {(!currentStep || currentStep?.id === specificationsStepId) && ( + + )} + + + {currentStep?.id === selectTargetStepId && } + + {values.target !== 'new-device' && ( + + {currentStep?.id === reviewStepId && } + + )} + + + ); +}; + +type InstallOsWizardProps = { + catalogItem: CatalogItem; +}; + +const InstallOsWizard = ({ catalogItem }: InstallOsWizardProps) => { + const { t } = useTranslation(); + const [error, setError] = React.useState(); + const [isSuccessful, setIsSuccessful] = React.useState(false); + const [currentStep, setCurrentStep] = React.useState(); + const { patch, get } = useFetch(); + const navigate = useNavigate(); + + const { + router: { useSearchParams }, + } = useAppContext(); + const [searchParams] = useSearchParams(); + const channel = searchParams.get('channel') || ''; + const version = searchParams.get('version') || ''; + + const validationSchema = Yup.lazy((values: InstallOsFormik) => + Yup.object({ + target: Yup.string().required(t('Target must be selected')), + device: values.target === 'device' ? Yup.object().required(t('Device must be selected')) : Yup.object(), + fleet: values.target === 'fleet' ? Yup.object().required(t('Fleet must be selected')) : Yup.object(), + channel: Yup.string().required(t('Channel must be selected')), + version: Yup.string().required(t('Version must be selected')), + }), + ); + + const initialValues = React.useMemo( + () => ({ + version, + channel, + target: undefined, + fleet: undefined, + device: undefined, + deploymentTarget: undefined, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const onSubmit = async (values: InstallOsFormik) => { + setError(undefined); + if (values.target === 'new-device') { + navigate(-1); + return; + } + const selectedDevice = values.device; + const selectedFleet = values.fleet; + const installToDevice = values.target === 'device'; + const resourceId = installToDevice + ? `devices/${selectedDevice?.metadata.name}` + : `fleets/${selectedFleet?.metadata.name}`; + + try { + const res = await get(resourceId); + const catalogItemVersion = catalogItem.spec.versions.find((v) => v.version === values.version); + + if (!catalogItemVersion || !values.channel) { + return; + } + const currentOsImage = installToDevice + ? (res as Device)?.spec?.os?.image + : (res as Fleet)?.spec.template.spec.os?.image; + const allPatches = getOsPatches({ + currentOsImage, + currentLabels: res?.metadata.labels, + catalogItem, + catalogItemVersion, + channel: values.channel, + specPath: installToDevice ? '/' : '/spec/template/', + }); + + if (!allPatches.length) { + setIsSuccessful(true); + } else { + await patch(resourceId, allPatches); + setIsSuccessful(true); + } + } catch (e) { + setError(getErrorMessage(e)); + } + }; + + return ( + + validationSchema={validationSchema} + initialValues={initialValues} + validateOnMount + onSubmit={onSubmit} + > + + + ); +}; + +export default InstallOsWizard; diff --git a/libs/ui-components/src/components/Catalog/InstallWizard/InstallWizard.tsx b/libs/ui-components/src/components/Catalog/InstallWizard/InstallWizard.tsx new file mode 100644 index 000000000..dc37c470a --- /dev/null +++ b/libs/ui-components/src/components/Catalog/InstallWizard/InstallWizard.tsx @@ -0,0 +1,80 @@ +import * as React from 'react'; +import { + Alert, + Breadcrumb, + BreadcrumbItem, + Content, + ContentVariants, + EmptyState, + PageSection, + Spinner, + Stack, + StackItem, + Title, +} from '@patternfly/react-core'; +import { CatalogItemCategory } from '@flightctl/types/alpha'; + +import { useTranslation } from '../../../hooks/useTranslation'; +import ErrorBoundary from '../../common/ErrorBoundary'; +import { Link, ROUTE } from '../../../hooks/useNavigate'; +import InstallOsWizard from './InstallOsWizard'; +import InstallAppWizard from './InstallAppWizard'; +import { useAppContext } from '../../../hooks/useAppContext'; +import { useCatalogItem } from '../useCatalogs'; +import { getErrorMessage } from '../../../utils/error'; + +const InstallWizard = () => { + const { t } = useTranslation(); + const { + router: { useParams }, + } = useAppContext(); + const { catalogId, itemId } = useParams() as { catalogId: string; itemId: string }; + const [catalogItem, loading, error] = useCatalogItem(catalogId, itemId); + + let content: React.ReactNode; + if (error) { + content = ( + + {getErrorMessage(error)} + + ); + } else if (loading) { + content = ; + } else if (catalogItem?.spec.category === CatalogItemCategory.CatalogItemCategorySystem) { + content = ; + } else if (catalogItem?.spec.category === CatalogItemCategory.CatalogItemCategoryApplication) { + content = ; + } + + return ( + <> + + + + {t('Software Catalog')} + + {catalogItem?.spec.displayName || itemId} + + + + + + + {t('Install {{name}}', { name: catalogItem?.spec.displayName || itemId })} + + + + {catalogItem?.spec.shortDescription && ( + {catalogItem.spec.shortDescription} + )} + + + + + {content} + + + ); +}; + +export default InstallWizard; diff --git a/libs/ui-components/src/components/Catalog/InstallWizard/UpdateSuccessPage.tsx b/libs/ui-components/src/components/Catalog/InstallWizard/UpdateSuccessPage.tsx new file mode 100644 index 000000000..1665e958c --- /dev/null +++ b/libs/ui-components/src/components/Catalog/InstallWizard/UpdateSuccessPage.tsx @@ -0,0 +1,80 @@ +import { + Button, + EmptyState, + EmptyStateActions, + EmptyStateBody, + EmptyStateFooter, + EmptyStateStatus, + Stack, + StackItem, +} from '@patternfly/react-core'; +import * as React from 'react'; +import { useFormikContext } from 'formik'; + +import { TargetPickerFormik } from './types'; +import { ROUTE, useNavigate } from '../../../hooks/useNavigate'; +import { useTranslation } from '../../../hooks/useTranslation'; + +type UpdateSuccessPageContentProps = React.PropsWithChildren<{ + isDevice: boolean; +}>; + +export const UpdateSuccessPageContent = ({ isDevice, children }: UpdateSuccessPageContentProps) => { + const { t } = useTranslation(); + return ( + + + {isDevice + ? t('Device will download and apply the update according to the configured update policies.') + : t('Devices will download and apply the update according to the configured update policies.')} + + + {children} + + + ); +}; + +const UpdateSuccessPage = () => { + const { t } = useTranslation(); + const { + values: { target, device, fleet }, + } = useFormikContext(); + const navigate = useNavigate(); + return ( + + + + + + + + + + + ); +}; + +export default UpdateSuccessPage; diff --git a/libs/ui-components/src/components/Catalog/InstallWizard/steps/AppConfigStep.css b/libs/ui-components/src/components/Catalog/InstallWizard/steps/AppConfigStep.css new file mode 100644 index 000000000..14dc01856 --- /dev/null +++ b/libs/ui-components/src/components/Catalog/InstallWizard/steps/AppConfigStep.css @@ -0,0 +1,12 @@ +.fctl-yaml-editor .monaco-editor { + min-height: 400px; +} + +/* Style of the bar with the line numbers for light and dark themes */ +.fctl-yaml-editor .monaco-editor .margin { + --vscode-editorGutter-background: #f5f5f5; +} + +.pf-v6-theme-dark .fctl-yaml-editor .monaco-editor .margin { + --vscode-editorGutter-background: #292e34; +} diff --git a/libs/ui-components/src/components/Catalog/InstallWizard/steps/AppConfigStep.tsx b/libs/ui-components/src/components/Catalog/InstallWizard/steps/AppConfigStep.tsx new file mode 100644 index 000000000..0bf4dfccd --- /dev/null +++ b/libs/ui-components/src/components/Catalog/InstallWizard/steps/AppConfigStep.tsx @@ -0,0 +1,202 @@ +import { + Alert, + FormGroup, + Grid, + GridItem, + List, + ListItem, + Split, + SplitItem, + Stack, + StackItem, + Title, +} from '@patternfly/react-core'; +import { RJSFValidationError } from '@rjsf/utils'; +import { FormikErrors, useFormikContext } from 'formik'; +import type * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; +import * as React from 'react'; + +import { useTranslation } from '../../../../hooks/useTranslation'; +import DynamicForm, { AssetSelection } from '../../../DynamicForm/DynamicForm'; +import YamlEditorBase from '../../../common/CodeEditor/YamlEditorBase'; +import TextField from '../../../form/TextField'; +import { FormGroupWithHelperText } from '../../../common/WithHelperText'; +import RadioField from '../../../form/RadioField'; +import FlightCtlForm from '../../../form/FlightCtlForm'; +import { DynamicFormConfigFormik, InstallAppFormik } from '../types'; + +import './AppConfigStep.css'; + +type DynamicAppFormProps = { + schemaErrors?: RJSFValidationError[]; + isEdit: boolean; +}; + +export const DynamicAppForm = ({ schemaErrors, isEdit }: DynamicAppFormProps) => { + const editorRef = React.useRef(null); + const { t } = useTranslation(); + const { values, setFieldValue, setFieldTouched } = useFormikContext(); + + const formContext = React.useMemo(() => { + const onAssetSelected = (selection: AssetSelection) => { + const existing = values.selectedAssets.findIndex((a) => a.volumeIndex === selection.volumeIndex); + let newAssets: AssetSelection[]; + if (existing >= 0) { + const updated = [...values.selectedAssets]; + updated[existing] = selection; + newAssets = updated; + } else { + newAssets = [...values.selectedAssets, selection]; + } + setFieldValue('selectedAssets', newAssets); + }; + + const onBeforeArrayItemRemoved = (arrayId: string, removedIndex: number) => { + if (arrayId === 'root_volumes') { + const newAssets = values.selectedAssets + .filter((a) => a.volumeIndex !== removedIndex) + .map((a) => (a.volumeIndex > removedIndex ? { ...a, volumeIndex: a.volumeIndex - 1 } : a)); + setFieldValue('selectedAssets', newAssets); + } + }; + + const onAssetCleared = (volumeIndex: number) => { + const newAssets = values.selectedAssets.filter((a) => a.volumeIndex !== volumeIndex); + setFieldValue('selectedAssets', newAssets); + }; + + return { onAssetSelected, onBeforeArrayItemRemoved, onAssetCleared, selectedAssets: values.selectedAssets }; + }, [values.selectedAssets, setFieldValue]); + + return ( + + + + + + + + + + {t('Configure via:')} + + + + + + + + + {!values.configSchema && ( + + + + )} + + {values.configSchema && values.configureVia === 'form' ? ( + + + + + + + + { + await setFieldValue('formValues', val); + await setFieldTouched('formValues', true); + }} + onValidate={(valid) => { + setFieldValue('dynamicFormValid', valid); + }} + formContext={formContext} + /> + + + + + ) : ( + + +
+ {}} + code={values.editorContent} + editorRef={editorRef} + onChange={async (val) => { + await setFieldValue('editorContent', val); + await setFieldTouched('editorContent', true); + }} + /> +
+
+
+ )} +
+ {schemaErrors?.length ? ( + + + + {schemaErrors.map((e, index) => ( + + {e.property}: {e.message} + + ))} + + + + ) : null} +
+ ); +}; + +export const isAppConfigStepValid = (values: DynamicFormConfigFormik, errors: FormikErrors) => + !errors.appName && (values.configureVia === 'form' ? values.dynamicFormValid : !errors.editorContent); + +type AppConfigStepProps = { + schemaErrors?: RJSFValidationError[]; + isEdit?: boolean; +}; + +const AppConfigStep = ({ schemaErrors, isEdit }: AppConfigStepProps) => { + const { t } = useTranslation(); + + return ( + + + + {t('Application configuration')} + + + + + + + ); +}; + +export default AppConfigStep; diff --git a/libs/ui-components/src/components/Catalog/InstallWizard/steps/ReviewStep.tsx b/libs/ui-components/src/components/Catalog/InstallWizard/steps/ReviewStep.tsx new file mode 100644 index 000000000..1e7b51c98 --- /dev/null +++ b/libs/ui-components/src/components/Catalog/InstallWizard/steps/ReviewStep.tsx @@ -0,0 +1,192 @@ +import { + Alert, + Card, + CardBody, + CardTitle, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Stack, + StackItem, + Title, +} from '@patternfly/react-core'; +import { useFormikContext } from 'formik'; +import * as React from 'react'; +import { CatalogItem, CatalogItemType } from '@flightctl/types/alpha'; + +import { useTranslation } from '../../../../hooks/useTranslation'; +import FlightCtlForm from '../../../form/FlightCtlForm'; +import { InstallAppFormik, InstallOsFormik } from '../types'; +import { OS_ITEM_LABEL_KEY } from '../../const'; +import { getFullReferenceURI } from '../../utils'; +import { DeviceSpec } from '@flightctl/types'; +import { Trans } from 'react-i18next'; + +const isOsUpdate = ( + catalogItem: CatalogItem, + version: string, + labels: Record | undefined, + spec: DeviceSpec, +) => { + const existingOsItem = labels?.[OS_ITEM_LABEL_KEY]; + const catalogItemVersion = catalogItem.spec.versions.find((v) => v.version === version); + if (existingOsItem === catalogItem.metadata.name && catalogItemVersion) { + return spec?.os?.image !== getFullReferenceURI(catalogItem.spec.reference.uri, catalogItemVersion); + } + return false; +}; + +type UpdateAlertsProps = { + catalogItem: CatalogItem; +}; + +const UpdateAlerts = ({ catalogItem }: UpdateAlertsProps) => { + const { t } = useTranslation(); + const { values } = useFormikContext(); + + if (catalogItem.spec.type !== CatalogItemType.CatalogItemTypeOS) { + return false; + } + + const osImageName = `${catalogItem.spec.displayName || catalogItem.metadata.name}:${values.version}`; + + if (values.target === 'fleet') { + const numOfDevices = `${values.fleet?.status?.devicesSummary?.total || 0}`; + if (!values.fleet?.spec.template.spec.os?.image) { + return ( + + + This will deploy the OS {osImageName} for all ({numOfDevices}) devices in + the {values.fleet?.metadata.name} fleet. Devices will download and apply the update + according to the configured update policies. + + + ); + } else { + if (isOsUpdate(catalogItem, values.version, values.fleet?.metadata.labels, values.fleet?.spec.template.spec)) { + return ( + + + You are about to update OS {osImageName}. This will update the OS image for all{' '} + ({numOfDevices}) devices in the {values.fleet?.metadata.name} fleet. + Devices will download and apply the update according to the configured update policies. + + + ); + } + + return ( + + + You are about to replace OS with {osImageName}. This will update the OS image for all{' '} + ({numOfDevices}) devices in the {values.fleet?.metadata.name} fleet. + Devices will download and apply the update according to the configured update policies. + + + ); + } + } else if (values.target === 'device') { + if (!values.device?.spec?.os?.image) { + return ( + + + This will deploy the OS {osImageName}. Device will download and apply the update according + to the configured update policies. + + + ); + } else { + if (isOsUpdate(catalogItem, values.version, values.device?.metadata.labels, values.device?.spec)) { + return ( + + + You are about to update OS with {osImageName}. Device will download and apply the update + according to the configured update policies. + + + ); + } + + return ( + + + You are about to replace OS with {osImageName}. Device will download and apply the update + according to the configured update policies. + + + ); + } + } + return false; +}; + +type ReviewStepProps = { + catalogItem: CatalogItem; + error?: string; +}; + +const ReviewStep = ({ error, catalogItem }: ReviewStepProps) => { + const { t } = useTranslation(); + const { values } = useFormikContext(); + + return ( + + + + {t('Review deployment specifications')} + + + + + {t('Installation specifications')} + + + + {t('Channel')} + {values.channel} + + + {t('Version')} + {values.version} + + + + + + + + {t('Target')} + + + + {t('Target type')} + + {values.target === 'fleet' ? t('Fleet') : t('Device')} + + + + {t('Target')} + + {values.target === 'fleet' + ? values.fleet?.metadata.name + : values.device?.metadata.labels?.alias || values.device?.metadata.name} + + + + + + + {error && ( + + + {error} + + + )} + + + ); +}; + +export default ReviewStep; diff --git a/libs/ui-components/src/components/Catalog/InstallWizard/steps/SelectTargetStep.tsx b/libs/ui-components/src/components/Catalog/InstallWizard/steps/SelectTargetStep.tsx new file mode 100644 index 000000000..b3166acd1 --- /dev/null +++ b/libs/ui-components/src/components/Catalog/InstallWizard/steps/SelectTargetStep.tsx @@ -0,0 +1,319 @@ +import { Device, Fleet } from '@flightctl/types'; +import { FormGroup, Stack, StackItem, Title, Toolbar, ToolbarContent, ToolbarItem } from '@patternfly/react-core'; +import { Tbody } from '@patternfly/react-table'; +import { FormikErrors, useFormikContext } from 'formik'; +import * as React from 'react'; +import { CatalogItem, CatalogItemArtifact, CatalogItemArtifactType } from '@flightctl/types/alpha'; +import { TFunction } from 'i18next'; + +import { useTranslation } from '../../../../hooks/useTranslation'; +import Table from '../../../Table/Table'; +import TablePagination from '../../../Table/TablePagination'; +import TableTextSearch from '../../../Table/TableTextSearch'; +import { getResourceId } from '../../../../utils/resource'; +import { useFleets } from '../../../Fleet/useFleets'; +import { getFleetTableColumns } from '../../../Fleet/FleetsPage'; +import FleetRow from '../../../Fleet/FleetRow'; +import { useDevicesPaginated } from '../../../Device/DevicesPage/useDevices'; +import { getDeviceTableColumns } from '../../../Device/DevicesPage/EnrolledDevicesTable'; +import EnrolledDeviceTableRow from '../../../Device/DevicesPage/EnrolledDeviceTableRow'; +import FlightCtlForm from '../../../form/FlightCtlForm'; +import { InstallAppFormik, InstallOsFormik } from '../types'; +import FormSelect from '../../../form/FormSelect'; +import { getFullReferenceURI } from '../../utils'; +import ImageUrl from '../../../ImageBuilds/ImageUrl'; + +export const isSelectTargetStepValid = (errors: FormikErrors) => { + return !errors.device && !errors.fleet; +}; + +const DeviceTarget = () => { + const { t } = useTranslation(); + const { values, setFieldValue, setFieldTouched } = useFormikContext(); + const [deviceNameFilter, setDeviceNameFilter] = React.useState(''); + + const { + devices, + isLoading: devicesLoading, + isUpdating: devicesUpdating, + pagination: devicePagination, + } = useDevicesPaginated({ + nameOrAlias: deviceNameFilter || undefined, + onlyDecommissioned: false, + onlyFleetless: true, + }); + + const handleDeviceSelect = React.useCallback( + async (device: Device) => { + await setFieldValue('device', device); + await setFieldValue('fleet', undefined); + await setFieldTouched('device', true); + }, + [setFieldValue, setFieldTouched], + ); + + const deviceColumns = React.useMemo(() => getDeviceTableColumns(t).filter(({ id }) => id !== 'fleet'), [t]); + + const isDeviceSelected = React.useCallback( + (device: Device) => values.device?.metadata.name === device.metadata.name, + [values.device?.metadata.name], + ); + + return ( + + + + {t('Select device')} + + + + + + + + + + + + + setDeviceNameFilter('')} + variant="compact" + singleSelect + > + + {devices.map((device, index) => ( + () => handleDeviceSelect(device)} + isRowSelected={isDeviceSelected} + rowIndex={index} + singleSelect + hideActions + deviceColumns={deviceColumns} + /> + ))} + +
+
+
+
+ ); +}; + +const FleetTarget = () => { + const { t } = useTranslation(); + const { values, setFieldValue, setFieldTouched } = useFormikContext(); + const [fleetNameFilter, setFleetNameFilter] = React.useState(''); + + const { + fleets, + isLoading: fleetsLoading, + isUpdating: fleetsUpdating, + pagination: fleetPagination, + } = useFleets({ + name: fleetNameFilter || undefined, + addDevicesSummary: true, + }); + + const handleFleetSelect = React.useCallback( + async (fleet: Fleet) => { + await setFieldValue('fleet', fleet); + await setFieldValue('device', undefined); + await setFieldTouched('fleet', true); + }, + [setFieldValue, setFieldTouched], + ); + + const fleetColumns = React.useMemo(() => getFleetTableColumns(t), [t]); + + const isFleetSelected = React.useCallback( + (fleet: Fleet) => values.fleet?.metadata.name === fleet.metadata.name, + [values.fleet?.metadata.name], + ); + + return ( + + + + {t('Select fleet')} + + + + + + + + + + + + + setFleetNameFilter('')} + variant="compact" + singleSelect + > + + {fleets.map((fleet, rowIndex) => ( + () => handleFleetSelect(fleet)} + singleSelect + hideActions + /> + ))} + +
+
+
+
+ ); +}; + +const getArtifactLabel = (artifact: CatalogItemArtifact, t: TFunction) => { + if (artifact.name) { + return artifact.name; + } + if (!artifact.type) { + return t('Unknown'); + } + + let artifactType: CatalogItemArtifactType; + switch (artifact.type) { + case CatalogItemArtifactType.CatalogItemArtifactTypeQcow2: + artifactType = t('OpenShift Virtualization'); + break; + case CatalogItemArtifactType.CatalogItemArtifactTypeIso: + artifactType = t('Bare Metal'); + break; + case CatalogItemArtifactType.CatalogItemArtifactTypeAmi: + artifactType = t('Amazon Web Services'); + break; + case CatalogItemArtifactType.CatalogItemArtifactTypeAnacondaIso: + artifactType = t('Anaconda Installer'); + break; + case CatalogItemArtifactType.CatalogItemArtifactTypeGce: + artifactType = t('Google Cloud'); + break; + case CatalogItemArtifactType.CatalogItemArtifactTypeRaw: + artifactType = t('KVM/custom cloud import'); + break; + case CatalogItemArtifactType.CatalogItemArtifactTypeVhd: + artifactType = t('Microsoft Hyper-V'); + break; + case CatalogItemArtifactType.CatalogItemArtifactTypeVmdk: + artifactType = t('VMware vSphere'); + break; + default: + artifactType = t('Cloud native'); + } + + return `${artifactType} (${artifact.type})`; +}; + +type NewDeviceTargetProps = { + catalogItem: CatalogItem; +}; + +const NewDeviceTarget = ({ catalogItem }: NewDeviceTargetProps) => { + const { t } = useTranslation(); + const { values, setFieldValue } = useFormikContext(); + + const artifacts = React.useMemo(() => { + return catalogItem.spec.reference.artifacts?.sort((a, b) => + getArtifactLabel(a, t).localeCompare(getArtifactLabel(b, t)), + ); + }, [catalogItem, t]); + + React.useEffect(() => { + if (!values.deploymentTarget && artifacts?.length) { + setFieldValue('deploymentTarget', artifacts[0].uri); + } + }, [values, artifacts, setFieldValue]); + + const currentVersion = catalogItem.spec.versions.find((v) => v.version === values.version); + + const artifactUrl = + values.deploymentTarget && currentVersion + ? getFullReferenceURI(values.deploymentTarget, currentVersion) + : undefined; + + return ( + + + + {t('Installation specifications')} + + + + { + acc[curr.uri] = { + label: getArtifactLabel(curr, t), + }; + return acc; + }, {}) + : { + 'no-items': { + label: t('No items'), + isDisabled: true, + }, + } + } + /> + + + {artifactUrl && ( + + + + )} + + + ); +}; + +type SelectTargetStepProps = { + catalogItem: CatalogItem; +}; + +const SelectTargetStep = ({ catalogItem }: SelectTargetStepProps) => { + const { values } = useFormikContext(); + + switch (values.target) { + case 'device': + return ; + case 'fleet': + return ; + default: + return ; + } +}; + +export default SelectTargetStep; diff --git a/libs/ui-components/src/components/Catalog/InstallWizard/steps/SpecificationsStep.tsx b/libs/ui-components/src/components/Catalog/InstallWizard/steps/SpecificationsStep.tsx new file mode 100644 index 000000000..3963a514f --- /dev/null +++ b/libs/ui-components/src/components/Catalog/InstallWizard/steps/SpecificationsStep.tsx @@ -0,0 +1,309 @@ +import { CatalogItem } from '@flightctl/types/alpha'; +import { + Alert, + Button, + Content, + EmptyState, + FormGroup, + Grid, + GridItem, + Label, + Modal, + ModalBody, + Spinner, + Split, + SplitItem, + Stack, + StackItem, + Title, +} from '@patternfly/react-core'; +import * as React from 'react'; +import { FormikErrors, useFormikContext } from 'formik'; +import * as semver from 'semver'; +import ReactMarkdown from 'react-markdown'; +import { TFunction } from 'react-i18next'; + +import { useTranslation } from '../../../../hooks/useTranslation'; +import FlightCtlForm from '../../../form/FlightCtlForm'; +import RadioField from '../../../form/RadioField'; +import FormSelect from '../../../form/FormSelect'; +import { PermissionCheck, usePermissionsContext } from '../../../common/PermissionsContext'; +import { RESOURCE, VERB } from '../../../../types/rbac'; +import { useFleets } from '../../../Fleet/useFleets'; +import { useDevicesPaginated } from '../../../Device/DevicesPage/useDevices'; +import { applyInitialConfig, getInitialAppConfig } from '../utils'; +import { InstallAppFormik } from '../types'; +import WithTooltip from '../../../common/WithTooltip'; + +export const isSpecsStepValid = (errors: FormikErrors) => { + return !errors.target && !errors.version && !errors.channel; +}; + +export type InstallSpecFormik = { + version: string; + channel: string; +}; + +export const InstallSpec = ({ + catalogItem, + hideReadmeLink, +}: { + catalogItem: CatalogItem; + hideReadmeLink?: boolean; +}) => { + const { t } = useTranslation(); + const [showReadme, setShowReadme] = React.useState(false); + const { values, setFieldValue } = useFormikContext(); + + const channels = catalogItem.spec.versions.reduce((acc, v) => { + v.channels.forEach((c) => (acc[c] = c)); + return acc; + }, {}); + + const versions = catalogItem.spec.versions.sort((v1, v2) => semver.compare(v2.version, v1.version)); + + const channelVersions = versions.filter((v) => v.channels.includes(values.channel)); + + const currentVersion = versions.find((v) => v.version === values.version); + + return ( + <> + + {catalogItem.spec.deprecation && ( + + + {catalogItem.spec.deprecation.message} + + + )} + + + { + const newChannelVersions = versions.filter((v) => v.channels.includes(val)); + if (!newChannelVersions.some((v) => v.version === values.version)) { + const newVersion = newChannelVersions.length ? newChannelVersions[0].version : undefined; + setFieldValue('version', newVersion, true); + const appConfig = getInitialAppConfig(catalogItem, newVersion); + applyInitialConfig(setFieldValue, appConfig); + } + }} + /> + + + + + { + const appConfig = getInitialAppConfig(catalogItem, val); + applyInitialConfig(setFieldValue, appConfig); + }} + items={channelVersions.reduce((acc, v) => { + return { + ...acc, + [v.version]: { + label: ( + + {v.version} + {v.deprecation && ( + + + + )} + + ), + selectedLabel: v.version, + }, + }; + }, {})} + /> + + + {!hideReadmeLink && !!currentVersion?.readme && ( + + + + )} + {currentVersion?.deprecation && ( + + + {currentVersion.deprecation.message} + + + )} + + {showReadme && currentVersion?.readme && ( + setShowReadme(false)} variant="medium"> + + + {currentVersion.readme} + + + + )} + + ); +}; + +type SpecificationsStepProps = { + catalogItem: CatalogItem; + showNewDevice?: boolean; +}; + +const targetPermissions: PermissionCheck[] = [ + { + kind: RESOURCE.FLEET, + verb: VERB.PATCH, + }, + { + kind: RESOURCE.FLEET, + verb: VERB.LIST, + }, + { + kind: RESOURCE.DEVICE, + verb: VERB.PATCH, + }, + { + kind: RESOURCE.DEVICE, + verb: VERB.LIST, + }, +]; + +const getFleetDisabledReason = ( + t: TFunction, + { canEdit, canList, size }: { canEdit: boolean; canList: boolean; size: number }, +) => { + if (!canList) { + return t('You do not have permissions to list fleets'); + } + if (!canEdit) { + return t('You do not have permissions to edit fleets'); + } + if (size === 0) { + return t('No fleet is available'); + } + return undefined; +}; + +const getDeviceDisabledReason = ( + t: TFunction, + { canEdit, canList, size }: { canEdit: boolean; canList: boolean; size: number }, +) => { + if (!canList) { + return t('You do not have permissions to list devices'); + } + if (!canEdit) { + return t('You do not have permissions to edit devices'); + } + if (size === 0) { + return t('No device is available'); + } + return undefined; +}; + +const SpecificationsStep = ({ catalogItem, showNewDevice }: SpecificationsStepProps) => { + const { t } = useTranslation(); + const { checkPermissions } = usePermissionsContext(); + const [canEditFleet, canListFleet, canEditDevice, canListDevice] = checkPermissions(targetPermissions); + const fleetRadioRef = React.useRef(null); + const deviceRadioRef = React.useRef(null); + + const { fleets, isLoading: fleetsLoading } = useFleets({}); + const { devices, isLoading: devicesLoading } = useDevicesPaginated({ + onlyDecommissioned: false, + onlyFleetless: true, + }); + + const fleetDisabledReason = getFleetDisabledReason(t, { + canEdit: canEditFleet, + canList: canListFleet, + size: fleets.length, + }); + + const deviceDisabledReason = getDeviceDisabledReason(t, { + canEdit: canEditDevice, + canList: canListDevice, + size: devices.length, + }); + + return ( + + + + {t('Deployment specifications')} + + + + + + + + + + {fleetsLoading || devicesLoading ? ( + + ) : ( + <> + + + + + {t('Existing Fleet')}} + description={t('Install to all devices in a fleet')} + isDisabled={!!fleetDisabledReason} + /> + + + + + {t('Existing Device')}} + description={t('Install to a single fleetless device')} + isDisabled={!!deviceDisabledReason} + /> + + + {showNewDevice && ( + + + + )} + + + + )} + + + + ); +}; + +export default SpecificationsStep; diff --git a/libs/ui-components/src/components/Catalog/InstallWizard/types.ts b/libs/ui-components/src/components/Catalog/InstallWizard/types.ts new file mode 100644 index 000000000..133e15ee9 --- /dev/null +++ b/libs/ui-components/src/components/Catalog/InstallWizard/types.ts @@ -0,0 +1,37 @@ +import { Device, Fleet } from '@flightctl/types'; +import { AssetSelection } from '../../DynamicForm/DynamicForm'; +import { CatalogItemArtifactType } from '@flightctl/types/alpha'; + +export const specificationsStepId = 'specifications'; +export const selectTargetStepId = 'select-target'; +export const appConfigStepId = 'app-config'; +export const reviewStepId = 'review'; + +export type InstallSpecFormik = { + version: string; + channel: string; +}; + +export type TargetPickerFormik = { + target: 'fleet' | 'device' | 'new-device' | undefined; + fleet: Fleet | undefined; + device: Device | undefined; +}; + +export type InstallOsFormik = InstallSpecFormik & + TargetPickerFormik & { + deploymentTarget: CatalogItemArtifactType | undefined; + }; + +export type DynamicFormConfigFormik = { + appName: string; + configureVia: 'editor' | 'form'; + editorContent: string; + selectedAssets: AssetSelection[]; + formValues: Record | undefined; + configSchema: Record | undefined; + /** Set by AppConfigStep when form view is used; used by wizard footer validation */ + dynamicFormValid: boolean; +}; + +export type InstallAppFormik = DynamicFormConfigFormik & InstallSpecFormik & TargetPickerFormik; diff --git a/libs/ui-components/src/components/Catalog/InstallWizard/utils.ts b/libs/ui-components/src/components/Catalog/InstallWizard/utils.ts new file mode 100644 index 000000000..ba557fc31 --- /dev/null +++ b/libs/ui-components/src/components/Catalog/InstallWizard/utils.ts @@ -0,0 +1,106 @@ +import { CatalogItem } from '@flightctl/types/alpha'; +import validator from '@rjsf/validator-ajv8'; +import { createSchemaUtils } from '@rjsf/utils'; +import { ApplicationProviderSpec, ApplicationVolume } from '@flightctl/types'; +import merge from 'lodash/merge'; +import { FormikHelpers } from 'formik'; + +import { + APP_VOLUME_CATALOG_LABEL_KEY, + APP_VOLUME_CHANNEL_LABEL_KEY, + APP_VOLUME_ITEM_LABEL_KEY, + getAppVolumeName, +} from '../const'; +import { AssetSelection } from '../../DynamicForm/DynamicForm'; +import { DynamicFormConfigFormik } from './types'; +import { convertObjToYAMLString } from '../../common/CodeEditor/YamlEditor'; + +const appSpecFilteredKeys = ['name', 'image', 'appType']; + +export const getInitialAppConfig = ( + catalogItem: CatalogItem, + version: string | undefined, + existingApp?: ApplicationProviderSpec, + currentLabels?: Record, +): DynamicFormConfigFormik => { + const configSchema = + catalogItem.spec.versions.find((v) => v.version === version)?.configSchema ?? + catalogItem?.spec.defaults?.configSchema; + let defaultConfig = + catalogItem.spec.versions.find((v) => v.version === version)?.config ?? catalogItem?.spec.defaults?.config; + + let formValues: Record = {}; + if (configSchema) { + const schemaUtils = createSchemaUtils(validator, configSchema); + formValues = schemaUtils.getDefaultFormState(configSchema) as Record; + } + + const selectedAssets: AssetSelection[] = []; + if (existingApp) { + const appConfig = Object.keys(existingApp).reduce( + (acc, key) => { + if (!appSpecFilteredKeys.includes(key)) { + acc[key] = existingApp[key] as unknown; + } + return acc; + }, + {} as Record, + ); + + formValues = merge({}, formValues, appConfig); + defaultConfig = merge({}, defaultConfig || {}, appConfig); + + if (currentLabels) { + const existingVolumes = formValues['volumes']; + if (Array.isArray(existingVolumes)) { + (existingVolumes as ApplicationVolume[]).forEach((vol, idx) => { + const volumeName = vol.name; + if (volumeName) { + const volumeCatalog = + currentLabels[`${getAppVolumeName(existingApp.name, volumeName, APP_VOLUME_CATALOG_LABEL_KEY)}`]; + const volumeChannel = + currentLabels[`${getAppVolumeName(existingApp.name, volumeName, APP_VOLUME_CHANNEL_LABEL_KEY)}`]; + const volumeItem = + currentLabels[`${getAppVolumeName(existingApp.name, volumeName, APP_VOLUME_ITEM_LABEL_KEY)}`]; + + if (volumeCatalog && volumeChannel && volumeItem) { + selectedAssets.push({ + assetCatalog: volumeCatalog, + assetChannel: volumeChannel, + assetItemName: volumeItem, + volumeIndex: idx, + assetVersion: '', // populated async by the parent + } as AssetSelection); + } + } + }); + } + } + } + + const dynamicFormValid = configSchema + ? validator.validateFormData(formValues, configSchema).errors?.length === 0 + : true; + + return { + appName: existingApp?.name || '', + configureVia: configSchema ? 'form' : 'editor', + editorContent: defaultConfig ? convertObjToYAMLString(defaultConfig) : '', + selectedAssets, + formValues, + configSchema, + dynamicFormValid, + }; +}; + +export const applyInitialConfig = ( + setFieldValue: FormikHelpers['setFieldValue'], + appConfig: DynamicFormConfigFormik, +) => { + setFieldValue('configSchema', appConfig.configSchema, true); + setFieldValue('configureVia', appConfig.configureVia, true); + setFieldValue('dynamicFormValid', appConfig.dynamicFormValid, true); + setFieldValue('editorContent', appConfig.editorContent, true); + setFieldValue('formValues', appConfig.formValues, true); + setFieldValue('selectedAssets', appConfig.selectedAssets, true); +}; diff --git a/libs/ui-components/src/components/Catalog/InstalledSoftware.tsx b/libs/ui-components/src/components/Catalog/InstalledSoftware.tsx new file mode 100644 index 000000000..dff6c732a --- /dev/null +++ b/libs/ui-components/src/components/Catalog/InstalledSoftware.tsx @@ -0,0 +1,370 @@ +import { ContainerApplication, DeviceSpec } from '@flightctl/types'; +import { ArrowCircleUpIcon } from '@patternfly/react-icons/dist/js/icons/arrow-circle-up-icon'; +import { ActionsColumn, IAction, Table, Tbody, Td, Tr } from '@patternfly/react-table'; +import * as React from 'react'; +import { CatalogItem, CatalogItemVersion } from '@flightctl/types/alpha'; +import { + Button, + Card, + CardBody, + CardTitle, + Content, + ContentVariants, + EmptyState, + EmptyStateBody, + Flex, + FlexItem, + Label, + Popover, + Spinner, + Stack, + StackItem, + Title, +} from '@patternfly/react-core'; +import { CubeIcon } from '@patternfly/react-icons/dist/js/icons/cube-icon'; + +import { getCatalogItemIcon, getFullReferenceURI, getUpdates } from './utils'; +import { useFetch } from '../../hooks/useFetch'; +import { useTranslation } from '../../hooks/useTranslation'; +import DeleteModal from '../modals/DeleteModal/DeleteModal'; +import { + APP_CATALOG_LABEL_KEY, + APP_CHANNEL_LABEL_KEY, + APP_ITEM_LABEL_KEY, + OS_CATALOG_LABEL_KEY, + OS_CHANNEL_LABEL_KEY, + OS_ITEM_LABEL_KEY, +} from './const'; +import { useCatalogItem } from './useCatalogs'; + +type UpdateColumnProps = { + catalogItem: CatalogItem; + channel: string; + catalogItemVersion: CatalogItemVersion; + appName: string | undefined; + onEditApp: VoidFunction; +}; + +const UpdateAppColumn = ({ catalogItem, channel, catalogItemVersion, onEditApp }: UpdateColumnProps) => { + const { t } = useTranslation(); + const updates = getUpdates(catalogItem, channel, catalogItemVersion.version); + + return ( + <> + {!!updates.length && ( + + )} + + ); +}; + +type UpdateOsColumnProps = { + catalogItem: CatalogItem; + channel: string; + catalogItemVersion: CatalogItemVersion; + onEditOs: VoidFunction; +}; + +const UpdateOsColumn = ({ onEditOs, catalogItem, channel, catalogItemVersion }: UpdateOsColumnProps) => { + const { t } = useTranslation(); + const updates = getUpdates(catalogItem, channel, catalogItemVersion.version); + + return ( + <> + {!!updates.length && ( + + )} + + ); +}; + +export const CatalogItemTitle = ({ + item, + appName, + version, + channel, +}: { + item: CatalogItem; + appName?: string; + version?: string; + channel: string; +}) => { + const { t } = useTranslation(); + return ( + + + {`${item.metadata.name} + + + + + {item.spec.displayName || item.metadata.name} + + {appName && ( + + {appName} + + )} + {version && ( + + + {t('Version: {{version}}, Channel: {{channel}}', { version, channel })} + + + )} + + + + ); +}; + +type InstalledSoftwareProps = { + labels: Record | undefined; + spec: DeviceSpec | undefined; + onDeleteOs: () => Promise; + onDeleteApp: (appName: string) => Promise; + onEdit: (catalogId: string, catalogItemId: string, appName?: string) => void; + canEdit: boolean; +}; + +type AppItem = { item: CatalogItem; name: string }; + +const InstalledSoftware = ({ labels, spec, onDeleteOs, onDeleteApp, onEdit, canEdit }: InstalledSoftwareProps) => { + const { t } = useTranslation(); + const [appItems, setAppItems] = React.useState(); + const [appsLoading, setAppsLoading] = React.useState(true); + const [deleteOs, setDeleteOs] = React.useState(false); + const [appToDelete, setAppToDelete] = React.useState(); + const osItemId = labels?.[OS_ITEM_LABEL_KEY]; + const osChannel = labels?.[OS_CHANNEL_LABEL_KEY]; + const osCatalog = labels?.[OS_CATALOG_LABEL_KEY]; + + const { get } = useFetch(); + + const apps = React.useMemo(() => { + if (!labels) { + return []; + } + return Object.keys(labels).reduce( + (acc, key) => { + if (key.endsWith(APP_ITEM_LABEL_KEY)) { + const appName = key.slice(0, -(APP_ITEM_LABEL_KEY.length + 1)); + const item = labels[`${appName}.${APP_ITEM_LABEL_KEY}`]; + const catalog = labels[`${appName}.${APP_CATALOG_LABEL_KEY}`]; + const channel = labels[`${appName}.${APP_CHANNEL_LABEL_KEY}`]; + if (item && catalog && channel && spec?.applications?.find((a) => a.name === appName)) { + acc.push({ + item, + catalog, + channel, + name: appName, + }); + } + } + return acc; + }, + [] as { + item: string; + catalog: string; + channel: string; + name: string; + }[], + ); + }, [labels, spec?.applications]); + + React.useEffect(() => { + (async () => { + const appRequests = apps.map((app) => get(`catalogs/${app.catalog}/items/${app.item}`)); + const results = await Promise.allSettled(appRequests); + + const items: AppItem[] = []; + results.forEach((r, idx) => { + if (r.status === 'rejected') { + // eslint-disable-next-line no-console + console.warn(`Failed to fetch catalog item ${apps[idx].catalog}/${apps[idx].item}`); + } else { + items.push({ + item: r.value, + name: apps[idx].name, + }); + } + }); + setAppItems(items); + setAppsLoading(false); + })(); + }, [apps, get]); + + const [osItem, osLoading] = useCatalogItem(osCatalog, osItemId); + + if (osLoading || appsLoading) { + return ; + } + + const catalogItemVersion = osItem?.spec.versions.find( + (v) => + getFullReferenceURI(osItem.spec.reference.uri, v) === spec?.os?.image && v.channels.includes(osChannel || ''), + ); + + const hasOs = !!(osItem && osCatalog && osChannel && catalogItemVersion && spec); + const hasApps = !!(appItems && appItems.length > 0); + const isEmpty = !hasOs && !hasApps; + + return ( + <> + + {t('Deployed Software')} + + {isEmpty ? ( + + {t('Select an operating system or application from the catalog below.')} + + ) : ( + + + {osItem && osCatalog && osChannel && catalogItemVersion && spec && ( + + + + + + + )} + {appItems?.map((app) => { + const appChannel = labels?.[`${app.name}.${APP_CHANNEL_LABEL_KEY}`] || ''; + const appSpec = spec?.applications?.find((a) => a.name === app.name); + const itemVersion = + appSpec && + app.item.spec.versions.find((v) => { + const refUri = getFullReferenceURI(app.item.spec.reference.uri, v); + const imageMatches = refUri === (appSpec as ContainerApplication).image; + return imageMatches && v.channels.includes(appChannel); + }); + const actions: IAction[] = [ + ...(itemVersion + ? [ + { + title: t('Edit'), + onClick: () => onEdit(app.item.metadata.catalog, app.item.metadata.name || '', app.name), + }, + ] + : []), + { + title: t('Delete'), + onClick: () => setAppToDelete(app.name), + }, + ]; + + return ( + + + + + + + + + ); + })} + +
+ + + {(osItem.spec.deprecation || catalogItemVersion.deprecation) && ( + + + + )} + + onEdit(osItem.metadata.catalog, osItem.metadata.name || '')} + /> + + {canEdit && ( + onEdit(osItem.metadata.catalog, osItem.metadata.name || ''), + }, + { + title: t('Delete'), + onClick: () => setDeleteOs(true), + }, + ]} + /> + )} +
+ + + {(app.item.spec.deprecation || itemVersion?.deprecation) && ( + + + + )} + + {itemVersion && canEdit && spec && ( + + onEdit(app.item.metadata.catalog, app.item.metadata.name || '', app.name) + } + /> + )} + {canEdit && }
+ )} +
+
+ {deleteOs && ( + setDeleteOs(false)} + onDelete={async () => { + await onDeleteOs(); + setDeleteOs(false); + }} + resourceName={osItem?.spec.displayName || osItem?.metadata.name || ''} + resourceType={t('operating system')} + /> + )} + {appToDelete && ( + setAppToDelete(undefined)} + onDelete={async () => { + await onDeleteApp(appToDelete); + setAppToDelete(undefined); + }} + resourceName={appToDelete} + resourceType={t('application')} + /> + )} + + ); +}; + +export default InstalledSoftware; diff --git a/libs/ui-components/src/components/Catalog/ResourceCatalog/ResourceCatalogPage.css b/libs/ui-components/src/components/Catalog/ResourceCatalog/ResourceCatalogPage.css new file mode 100644 index 000000000..5eb254913 --- /dev/null +++ b/libs/ui-components/src/components/Catalog/ResourceCatalog/ResourceCatalogPage.css @@ -0,0 +1,3 @@ +.fctl-resource-catalog-page { + margin-top: var(--pf-t--global--spacer--4xl); +} \ No newline at end of file diff --git a/libs/ui-components/src/components/Catalog/ResourceCatalog/ResourceCatalogPage.tsx b/libs/ui-components/src/components/Catalog/ResourceCatalog/ResourceCatalogPage.tsx new file mode 100644 index 000000000..8ed4257dd --- /dev/null +++ b/libs/ui-components/src/components/Catalog/ResourceCatalog/ResourceCatalogPage.tsx @@ -0,0 +1,67 @@ +import { DeviceSpec, PatchRequest } from '@flightctl/types'; +import * as React from 'react'; +import { Stack, StackItem } from '@patternfly/react-core'; + +import { CatalogItem } from '@flightctl/types/alpha'; +import { getRemoveAppPatches, getRemoveOsPatches } from '../../Catalog/utils'; +import { CatalogPageContent } from '../../Catalog/CatalogPage'; +import InstalledSoftware from '../../Catalog/InstalledSoftware'; + +import './ResourceCatalogPage.css'; + +type ResourceCatalogPageProps = { + specPath: string; + canEdit: boolean; + spec: DeviceSpec | undefined; + currentLabels: Record | undefined; + onPatch: (allPatches: PatchRequest) => Promise; + onEdit: (catalogId: string, catalogItemId: string, appName?: string) => void; + onInstall: (installItem: { item: CatalogItem; channel: string; version: string }) => void; +}; + +const ResourceCatalogPage = ({ + currentLabels, + spec, + onPatch, + specPath, + canEdit, + onEdit, + onInstall, +}: ResourceCatalogPageProps) => { + const onDeleteOs = async () => { + const allPatches = getRemoveOsPatches({ currentLabels, specPath }); + await onPatch(allPatches); + }; + + const onDeleteApp = async (appName: string) => { + const allPatches = getRemoveAppPatches({ + appName, + currentApps: spec?.applications, + currentLabels, + specPath, + }); + await onPatch(allPatches); + }; + + return ( + <> + + + + + + + + + + ); +}; + +export default ResourceCatalogPage; diff --git a/libs/ui-components/src/components/Catalog/const.ts b/libs/ui-components/src/components/Catalog/const.ts new file mode 100644 index 000000000..d98addc20 --- /dev/null +++ b/libs/ui-components/src/components/Catalog/const.ts @@ -0,0 +1,16 @@ +export const CATALOG_LABEL = 'catalog.flightctl.io/'; + +export const OS_CHANNEL_LABEL_KEY = 'os.catalog.flightctl.io/channel'; +export const OS_CATALOG_LABEL_KEY = 'os.catalog.flightctl.io/catalog'; +export const OS_ITEM_LABEL_KEY = 'os.catalog.flightctl.io/item'; +export const APP_CHANNEL_LABEL_KEY = 'app.catalog.flightctl.io/channel'; +export const APP_CATALOG_LABEL_KEY = 'app.catalog.flightctl.io/catalog'; +export const APP_ITEM_LABEL_KEY = 'app.catalog.flightctl.io/item'; +export const APP_VOLUME_CHANNEL_LABEL_KEY = 'volume.catalog.flightctl.io/channel'; +export const APP_VOLUME_CATALOG_LABEL_KEY = 'volume.catalog.flightctl.io/catalog'; +export const APP_VOLUME_ITEM_LABEL_KEY = 'volume.catalog.flightctl.io/item'; + +export const getAppVolumeName = (appName: string | undefined, volumeName: string, label: string) => { + const appPrefix = appName ? `${appName}.` : ''; + return `${appPrefix}${volumeName}.${label}`; +}; diff --git a/libs/ui-components/src/components/Catalog/useCatalogFilter.ts b/libs/ui-components/src/components/Catalog/useCatalogFilter.ts new file mode 100644 index 000000000..21898fd78 --- /dev/null +++ b/libs/ui-components/src/components/Catalog/useCatalogFilter.ts @@ -0,0 +1,21 @@ +import { CatalogItemType } from '@flightctl/types/alpha'; +import * as React from 'react'; + +export type CatalogFilter = { + nameFilter: string; + setNameFilter: (name: string) => void; + itemType: CatalogItemType[]; + setItemType: (type: CatalogItemType[]) => void; +}; + +export const useCatalogFilter = (): CatalogFilter => { + const [nameFilter, setNameFilter] = React.useState(''); + const [itemType, setItemType] = React.useState([]); + + return { + nameFilter, + setNameFilter, + itemType, + setItemType, + }; +}; diff --git a/libs/ui-components/src/components/Catalog/useCatalogs.ts b/libs/ui-components/src/components/Catalog/useCatalogs.ts new file mode 100644 index 000000000..51d0e1127 --- /dev/null +++ b/libs/ui-components/src/components/Catalog/useCatalogs.ts @@ -0,0 +1,106 @@ +import * as React from 'react'; +import { useDebounce } from 'use-debounce'; +import { CatalogItem, CatalogItemList } from '@flightctl/types/alpha'; +import { CatalogItemCategory, CatalogItemType } from '@flightctl/types/alpha'; +import { useFetchPeriodically } from '../../hooks/useFetchPeriodically'; +import { PaginationDetails, useTablePagination } from '../../hooks/useTablePagination'; +import { PAGE_SIZE } from '../../constants'; + +export const useCatalogItem = ( + catalog: string | undefined, + item: string | undefined, +): [CatalogItem | undefined, boolean, unknown, boolean, VoidFunction] => { + const [catalogItem, loading, error, refetch, updating] = useFetchPeriodically({ + endpoint: catalog && item ? `catalogs/${catalog}/items/${item}` : '', + }); + + return [catalogItem, loading, error, updating, refetch]; +}; + +export const appTypeIds = [ + CatalogItemType.CatalogItemTypeContainer, + CatalogItemType.CatalogItemTypeHelm, + CatalogItemType.CatalogItemTypeQuadlet, + CatalogItemType.CatalogItemTypeCompose, + CatalogItemType.CatalogItemTypeData, +]; + +const systemTypeIds = [CatalogItemType.CatalogItemTypeOS]; + +const buildCatalogItemsFieldSelector = ( + itemType: CatalogItemType[] | undefined, + nameFilter?: string, +): string | undefined => { + const parts: string[] = []; + + if (![...systemTypeIds, ...appTypeIds].every((id) => itemType?.includes(id))) { + const categories: CatalogItemCategory[] = []; + let types = itemType ? [...itemType] : []; + + if (appTypeIds.every((id) => types.includes(id))) { + categories.push(CatalogItemCategory.CatalogItemCategoryApplication); + types = types.filter((t) => !appTypeIds.includes(t)); + } + + if (categories.length === 1) { + parts.push(`spec.category==${categories[0]}`); + } else if (categories.length > 1) { + parts.push(`spec.category in (${categories.join(',')})`); + } + if (types.length === 1) { + parts.push(`spec.type==${types[0]}`); + } else if (types.length > 1) { + parts.push(`spec.type in (${types?.join(',')})`); + } + } + + if (nameFilter?.trim()) { + parts.push(`metadata.name contains ${nameFilter.trim()}`); + } + return parts.length > 0 ? parts.join(',') : undefined; +}; + +export type UseAllCatalogItemsFilter = { + itemType?: CatalogItemType[]; + nameFilter?: string | undefined; +}; + +export const useCatalogItems = ({ + itemType, + nameFilter, +}: UseAllCatalogItemsFilter): [CatalogItem[], boolean, unknown, PaginationDetails, boolean] => { + const pagination = useTablePagination(); + const fieldSelector = React.useMemo( + () => (itemType || nameFilter ? buildCatalogItemsFieldSelector(itemType, nameFilter) : undefined), + [itemType, nameFilter], + ); + const endpoint = React.useMemo(() => { + const params = new URLSearchParams(); + params.set('limit', `${PAGE_SIZE}`); + if (pagination.nextContinue) { + params.set('continue', pagination.nextContinue); + } + if (fieldSelector) { + params.set('fieldSelector', fieldSelector); + } + const query = params.toString(); + return query ? `catalogitems?${query}` : 'catalogitems'; + }, [fieldSelector, pagination.nextContinue]); + + const [endpointDebounced] = useDebounce(endpoint, 1000); + const isDebouncing = endpoint !== endpointDebounced; + + React.useEffect(() => { + pagination.setCurrentPage(1); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nameFilter, itemType]); + + const [catalogItemsList, loading, error] = useFetchPeriodically( + { endpoint: endpointDebounced }, + pagination.onPageFetched, + ); + + const isUpdating = loading || isDebouncing; + + return [catalogItemsList?.items || [], loading, error, pagination, isUpdating]; +}; diff --git a/libs/ui-components/src/components/Catalog/useSubmitCatalogForm.ts b/libs/ui-components/src/components/Catalog/useSubmitCatalogForm.ts new file mode 100644 index 000000000..87c4d4fd1 --- /dev/null +++ b/libs/ui-components/src/components/Catalog/useSubmitCatalogForm.ts @@ -0,0 +1,60 @@ +import * as React from 'react'; +import { load } from 'js-yaml'; +import { RJSFSchema, RJSFValidationError } from '@rjsf/utils'; +import validator from '@rjsf/validator-ajv8'; + +import { useTranslation } from '../../hooks/useTranslation'; +import { getErrorMessage } from '../../utils/error'; +import { DynamicFormConfigFormik } from './InstallWizard/types'; + +type useSubmitProps = (values: F) => Promise; + +export const useSubmitCatalogForm = < + F extends Pick, +>( + onUpdate: useSubmitProps, +): { + onSubmit: (values: F) => Promise; + error?: string; + schemaErrors?: RJSFValidationError[]; + setError: (err: string | undefined) => void; +} => { + const { t } = useTranslation(); + const [error, setError] = React.useState(); + const [schemaErrors, setSchemaErrors] = React.useState(); + + const onSubmit = async (values: F) => { + setError(undefined); + setSchemaErrors(undefined); + if (values.configureVia === 'editor') { + let yamlContent: unknown; + try { + yamlContent = load(values.editorContent); + } catch { + setError(t('Not a valid configuration')); + return false; + } + if (values.configSchema) { + const validationData = validator.validateFormData(yamlContent, values.configSchema as RJSFSchema); + if (validationData.errors.length) { + setSchemaErrors(validationData.errors); + return false; + } + } + } + try { + await onUpdate(values); + return true; + } catch (e) { + setError(getErrorMessage(e)); + return false; + } + }; + + return { + onSubmit, + error, + schemaErrors, + setError, + }; +}; diff --git a/libs/ui-components/src/components/Catalog/utils.ts b/libs/ui-components/src/components/Catalog/utils.ts new file mode 100644 index 000000000..8ecad8189 --- /dev/null +++ b/libs/ui-components/src/components/Catalog/utils.ts @@ -0,0 +1,317 @@ +import { AppType, ApplicationProviderSpec, ContainerApplication, PatchRequest } from '@flightctl/types'; +import { CatalogItem, CatalogItemType, CatalogItemVersion } from '@flightctl/types/alpha'; +import { TFunction } from 'i18next'; +import semver from 'semver'; + +import { appendJSONPatch, getLabelPatches } from '../../utils/patch'; +import { + APP_CATALOG_LABEL_KEY, + APP_CHANNEL_LABEL_KEY, + APP_ITEM_LABEL_KEY, + APP_VOLUME_CATALOG_LABEL_KEY, + APP_VOLUME_CHANNEL_LABEL_KEY, + APP_VOLUME_ITEM_LABEL_KEY, + OS_CATALOG_LABEL_KEY, + OS_CHANNEL_LABEL_KEY, + OS_ITEM_LABEL_KEY, + getAppVolumeName, +} from './const'; +import { fromAPILabel } from '../../utils/labels'; +import { AssetSelection } from '../DynamicForm/DynamicForm'; + +import defaultIcon from '../../../assets/flight-control-logo.png'; + +export const getFullReferenceURI = (refURI: string, version: CatalogItemVersion) => { + if (version.digest) { + return `${refURI}@${version.digest}`; + } + if (version.tag) { + return `${refURI}:${version.tag}`; + } + return `${refURI}@${version.version}`; +}; + +export const getCatalogItemBadge = (itemType: CatalogItemType | undefined, t: TFunction) => { + switch (itemType) { + case CatalogItemType.CatalogItemTypeCompose: { + return t('Compose'); + } + case CatalogItemType.CatalogItemTypeContainer: { + return t('Container'); + } + case CatalogItemType.CatalogItemTypeData: { + return t('Data'); + } + case CatalogItemType.CatalogItemTypeHelm: { + return t('Helm'); + } + case CatalogItemType.CatalogItemTypeQuadlet: { + return t('Quadlet'); + } + case CatalogItemType.CatalogItemTypeOS: { + return t('OS image'); + } + default: { + return t('Unknown'); + } + } +}; + +export const getRemoveOsPatches = ({ + specPath, + currentLabels, +}: { + specPath: string; + currentLabels: Record | undefined; +}) => { + const allPatches: PatchRequest = []; + allPatches.push({ + path: `${specPath}spec/os`, + op: 'remove', + }); + + const newLabels = currentLabels + ? { + ...currentLabels, + } + : {}; + delete newLabels[OS_ITEM_LABEL_KEY]; + delete newLabels[OS_CHANNEL_LABEL_KEY]; + delete newLabels[OS_CATALOG_LABEL_KEY]; + const labelPatches = getLabelPatches('/metadata/labels', currentLabels || {}, fromAPILabel(newLabels)); + + if (labelPatches.length) { + allPatches.push(...labelPatches); + } + + return allPatches; +}; + +const removeAppLabels = (currentLabels: Record, appName: string) => { + const apiLabels = fromAPILabel(currentLabels); + const newLabels = apiLabels.filter(({ key }) => { + return ( + ![ + `${appName}.${APP_ITEM_LABEL_KEY}`, + `${appName}.${APP_CHANNEL_LABEL_KEY}`, + `${appName}.${APP_CATALOG_LABEL_KEY}`, + ].includes(key) && + !( + key.startsWith(`${appName}.`) && + (key.endsWith(`.${APP_VOLUME_ITEM_LABEL_KEY}`) || + key.endsWith(`.${APP_VOLUME_CATALOG_LABEL_KEY}`) || + key.endsWith(`.${APP_VOLUME_CHANNEL_LABEL_KEY}`)) + ) + ); + }); + return newLabels; +}; + +export const getRemoveAppPatches = ({ + appName, + specPath, + currentLabels, + currentApps, +}: { + appName: string; + specPath: string; + currentLabels: Record | undefined; + currentApps: ApplicationProviderSpec[] | undefined; +}) => { + const allPatches: PatchRequest = []; + const appIndex = currentApps?.findIndex((a) => a.name === appName); + + if (currentApps?.length && appIndex !== -1) { + allPatches.push({ + path: `${specPath}spec/applications/${appIndex}`, + op: 'remove', + }); + } + + if (currentLabels) { + const newLabels = removeAppLabels(currentLabels, appName); + const labelPatches = getLabelPatches('/metadata/labels', currentLabels || {}, newLabels); + + if (labelPatches.length) { + allPatches.push(...labelPatches); + } + } + + return allPatches; +}; + +export const getOsPatches = ({ + currentOsImage, + currentLabels, + catalogItem, + catalogItemVersion, + channel, + specPath, +}: { + currentOsImage: string | undefined; + currentLabels: Record | undefined; + catalogItem: CatalogItem; + catalogItemVersion: CatalogItemVersion; + channel: string; + specPath: string; +}) => { + const allPatches: PatchRequest = []; + const newOsImage = getFullReferenceURI(catalogItem.spec.reference.uri, catalogItemVersion); + if (!currentOsImage) { + allPatches.push({ + path: `${specPath}spec/os`, + op: 'add', + value: { image: newOsImage }, + }); + } else if (currentOsImage !== newOsImage) { + appendJSONPatch({ + path: `${specPath}spec/os/image`, + patches: allPatches, + newValue: newOsImage, + originalValue: currentOsImage, + }); + } + + const newLabels = fromAPILabel({ + ...(currentLabels || {}), + [OS_CHANNEL_LABEL_KEY]: channel, + [OS_CATALOG_LABEL_KEY]: catalogItem.metadata.catalog, + [OS_ITEM_LABEL_KEY]: catalogItem.metadata.name || '', + }); + + const labelPatches = getLabelPatches('/metadata/labels', currentLabels || {}, newLabels); + + if (labelPatches.length) { + allPatches.push(...labelPatches); + } + + return allPatches; +}; + +const getAppType = (catalogItem: CatalogItem): AppType | undefined => { + switch (catalogItem.spec.type) { + case CatalogItemType.CatalogItemTypeCompose: + return AppType.AppTypeCompose; + case CatalogItemType.CatalogItemTypeQuadlet: + return AppType.AppTypeQuadlet; + case CatalogItemType.CatalogItemTypeHelm: + return AppType.AppTypeHelm; + case CatalogItemType.CatalogItemTypeContainer: + return AppType.AppTypeContainer; + default: + return undefined; + } +}; + +export const getAppPatches = ({ + appName, + currentApps, + currentLabels, + catalogItem, + catalogItemVersion, + channel, + formValues, + specPath, + selectedAssets, +}: { + appName: string; + currentApps: ApplicationProviderSpec[] | undefined; + currentLabels: Record | undefined; + catalogItem: CatalogItem; + catalogItemVersion: CatalogItemVersion; + channel: string; + formValues: Record | undefined; + specPath: string; + selectedAssets: AssetSelection[]; +}) => { + const allPatches: PatchRequest = []; + + const appType = getAppType(catalogItem); + if (!appType) { + throw new Error('Unknown application type'); + } + + const appSpec: ApplicationProviderSpec = { + ...formValues, + name: appName, + appType, + image: getFullReferenceURI(catalogItem.spec.reference.uri, catalogItemVersion), + }; + const existingAppIndex = currentApps?.findIndex((app) => app.name === appSpec.name); + + if (!currentApps) { + allPatches.push({ + path: `${specPath}spec/applications`, + op: 'add', + value: [appSpec], + }); + } else if (existingAppIndex === -1) { + allPatches.push({ + path: `${specPath}spec/applications/-`, + op: 'add', + value: appSpec, + }); + } else { + allPatches.push({ + path: `${specPath}spec/applications/${existingAppIndex}`, + op: 'replace', + value: appSpec, + }); + } + + const volumeLabels = selectedAssets.reduce((acc, { assetChannel, assetItemName, assetCatalog, volumeIndex }) => { + const volumes = (appSpec as ContainerApplication).volumes; + if (!volumes || volumes.length <= volumeIndex) { + return acc; + } + const volumeName = volumes[volumeIndex].name; + + return { + ...acc, + [`${getAppVolumeName(appSpec.name, volumeName, APP_VOLUME_ITEM_LABEL_KEY)}`]: assetItemName, + [`${getAppVolumeName(appSpec.name, volumeName, APP_VOLUME_CHANNEL_LABEL_KEY)}`]: assetChannel, + [`${getAppVolumeName(appSpec.name, volumeName, APP_VOLUME_CATALOG_LABEL_KEY)}`]: assetCatalog, + }; + }, {}); + + const newLabels = removeAppLabels(currentLabels || {}, appName); + const appLabels = fromAPILabel({ + [`${appSpec.name}.${APP_CHANNEL_LABEL_KEY}`]: channel, + [`${appSpec.name}.${APP_CATALOG_LABEL_KEY}`]: catalogItem.metadata.catalog, + [`${appSpec.name}.${APP_ITEM_LABEL_KEY}`]: catalogItem.metadata.name || '', + ...volumeLabels, + }); + + newLabels.push(...appLabels); + + const labelPatches = getLabelPatches('/metadata/labels', currentLabels || {}, newLabels); + + if (labelPatches.length) { + allPatches.push(...labelPatches); + } + + return allPatches; +}; + +export const getUpdates = (catalogItem: CatalogItem, currentChannel: string, currentVersion: string) => { + return catalogItem.spec.versions.filter((version) => { + if (!version.channels.includes(currentChannel)) return false; + + // Check if current version can upgrade to this version via: + // 1. replaces - direct replacement (now a single string) + if (version.replaces === currentVersion) return true; + + // 2. skips - array of specific versions that can be skipped + if (version.skips?.includes(currentVersion)) return true; + + // 3. skipRange - semver range check + if (version.skipRange && semver.satisfies(currentVersion, version.skipRange, { includePrerelease: true })) { + return true; + } + + return false; + }); +}; + +export const getCatalogItemIcon = (catalogItem: CatalogItem): string => + catalogItem.spec.icon || (defaultIcon as string); diff --git a/libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsCatalog.tsx b/libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsCatalog.tsx new file mode 100644 index 000000000..e8030bd4e --- /dev/null +++ b/libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsCatalog.tsx @@ -0,0 +1,60 @@ +import { Device, PatchRequest } from '@flightctl/types'; +import * as React from 'react'; +import { useFetch } from '../../../hooks/useFetch'; +import ResourceCatalogPage from '../../Catalog/ResourceCatalog/ResourceCatalogPage'; +import { ROUTE, useNavigate } from '../../../hooks/useNavigate'; + +type DeviceDetailsCatalogProps = { + device: Device; + refetch: VoidFunction; + canEdit: boolean; +}; + +const DeviceDetailsCatalog = ({ device, refetch, canEdit }: DeviceDetailsCatalogProps) => { + const { patch } = useFetch(); + const navigate = useNavigate(); + const onPatch = React.useCallback( + async (allPatches: PatchRequest) => { + await patch(`devices/${device.metadata.name}`, allPatches); + refetch(); + }, + [refetch, patch, device.metadata.name], + ); + + return ( + { + let path = `${device.metadata.name}/${catalogId}/${catalogItemId}`; + if (appName) { + const params = new URLSearchParams({ + appName, + }); + path = `${path}?${params.toString()}`; + } + navigate({ + route: ROUTE.CATALOG_DEVICE_EDIT, + postfix: path, + }); + }} + onInstall={({ item, version, channel }) => { + const params = new URLSearchParams({ + version, + channel, + }); + + const path = `${device.metadata.name}/${item.metadata.catalog}/${item.metadata.name}?${params.toString()}`; + navigate({ + route: ROUTE.CATALOG_DEVICE_EDIT, + postfix: path, + }); + }} + /> + ); +}; + +export default DeviceDetailsCatalog; diff --git a/libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsPage.tsx b/libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsPage.tsx index 4c65c5c84..3df653123 100644 --- a/libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsPage.tsx +++ b/libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsPage.tsx @@ -32,6 +32,7 @@ import PageWithPermissions from '../../common/PageWithPermissions'; import YamlEditor from '../../common/CodeEditor/YamlEditor'; import DeviceAliasEdit from './DeviceAliasEdit'; import { SystemRestoreBanners } from '../../SystemRestore/SystemRestoreBanners'; +import DeviceDetailsCatalog from './DeviceDetailsCatalog'; type DeviceDetailsPageProps = React.PropsWithChildren<{ hideTerminal?: boolean }>; @@ -149,8 +150,9 @@ const DeviceDetailsPage = ({ children, hideTerminal }: DeviceDetailsPageProps) = resourceType="Devices" resourceTypeLabel={t('Devices')} nav={ - + + {isEnrolled && } {!hideTerminal && canOpenTerminal && } @@ -192,6 +194,12 @@ const DeviceDetailsPage = ({ children, hideTerminal }: DeviceDetailsPageProps) = } /> + {isEnrolled && ( + } + /> + )} { onlyDecommissioned ? undefined : removeDecommissionedDevices, ); - const [data, loading, error, updating, refetch] = useDevices({ + const { + devices: data, + isLoading: loading, + error, + isUpdating: updating, + refetch, + } = useDevices({ nameOrAlias, ownerFleets, onlyDecommissioned, diff --git a/libs/ui-components/src/components/Device/DevicesPage/EnrolledDeviceTableRow.tsx b/libs/ui-components/src/components/Device/DevicesPage/EnrolledDeviceTableRow.tsx index 10ab892e5..14e1180e3 100644 --- a/libs/ui-components/src/components/Device/DevicesPage/EnrolledDeviceTableRow.tsx +++ b/libs/ui-components/src/components/Device/DevicesPage/EnrolledDeviceTableRow.tsx @@ -12,17 +12,21 @@ import SystemUpdateStatus from '../../Status/SystemUpdateStatus'; import { useTranslation } from '../../../hooks/useTranslation'; import { ROUTE, useNavigate } from '../../../hooks/useNavigate'; import ResourceLink from '../../common/ResourceLink'; +import { ApiSortTableColumn } from '../../Table/Table'; type EnrolledDeviceTableRowProps = { device: Device; rowIndex: number; onRowSelect: (device: Device) => OnSelect; isRowSelected: (device: Device) => boolean; - canEdit: boolean; - canDecommission: boolean; - decommissionAction: ListAction; - canResume: boolean; - resumeAction: ListAction; + canEdit?: boolean; + canDecommission?: boolean; + decommissionAction?: ListAction; + canResume?: boolean; + resumeAction?: ListAction; + singleSelect?: boolean; + hideActions?: boolean; + deviceColumns: ApiSortTableColumn[]; }; const EnrolledDeviceTableRow = ({ @@ -35,6 +39,9 @@ const EnrolledDeviceTableRow = ({ decommissionAction, canResume, resumeAction, + singleSelect, + hideActions, + deviceColumns, }: EnrolledDeviceTableRowProps) => { const { t } = useTranslation(); const navigate = useNavigate(); @@ -44,6 +51,8 @@ const EnrolledDeviceTableRow = ({ const decommissionDisabledReason = getDecommissionDisabledReason(device, t); const resumeDisabledReason = getResumeDisabledReason(device, t); + const columnIds = React.useMemo(() => deviceColumns.map(({ id }) => id), [deviceColumns]); + return ( - - - - - - - - - - - - - - - - - - - - navigate({ route: ROUTE.DEVICE_EDIT, postfix: deviceName }), - ...editActionProps, - }, - ] - : []), - { - title: t('View device details'), - onClick: () => navigate({ route: ROUTE.DEVICE_DETAILS, postfix: deviceName }), - }, - ...(canResume - ? [ - resumeAction({ - resourceId: deviceName, - resourceName: deviceAlias, - disabledReason: resumeDisabledReason, - }), - ] - : []), - ...(canDecommission - ? [ - decommissionAction({ - resourceId: deviceName, - resourceName: deviceAlias, - disabledReason: decommissionDisabledReason, - }), - ] - : []), - ]} - /> - + {columnIds.includes('alias') && ( + + + + )} + {columnIds.includes('name') && ( + + + + )} + {columnIds.includes('fleet') && ( + + + + )} + {columnIds.includes('appStatus') && ( + + + + )} + {columnIds.includes('deviceStatus') && ( + + + + )} + {columnIds.includes('updateStatus') && ( + + + + )} + {!hideActions && ( + + navigate({ route: ROUTE.DEVICE_EDIT, postfix: deviceName }), + ...editActionProps, + }, + ] + : []), + { + title: t('View device details'), + onClick: () => navigate({ route: ROUTE.DEVICE_DETAILS, postfix: deviceName }), + }, + ...(canResume && resumeAction + ? [ + resumeAction({ + resourceId: deviceName, + resourceName: deviceAlias, + disabledReason: resumeDisabledReason, + }), + ] + : []), + ...(canDecommission && decommissionAction + ? [ + decommissionAction({ + resourceId: deviceName, + resourceName: deviceAlias, + disabledReason: decommissionDisabledReason, + }), + ] + : []), + ]} + /> + + )} ); }; diff --git a/libs/ui-components/src/components/Device/DevicesPage/EnrolledDevicesTable.tsx b/libs/ui-components/src/components/Device/DevicesPage/EnrolledDevicesTable.tsx index c0c14214e..f0e6f06bd 100644 --- a/libs/ui-components/src/components/Device/DevicesPage/EnrolledDevicesTable.tsx +++ b/libs/ui-components/src/components/Device/DevicesPage/EnrolledDevicesTable.tsx @@ -48,25 +48,31 @@ interface EnrolledDeviceTableProps { // getSortParams: (columnIndex: number) => ThProps['sort']; } -const getDeviceColumns = (t: TFunction): ApiSortTableColumn[] => [ +export const getDeviceTableColumns = (t: TFunction): ApiSortTableColumn[] => [ { + id: 'alias', name: t('Alias'), }, { + id: 'name', name: t('Name'), }, { + id: 'fleet', name: t('Fleet'), }, { + id: 'appStatus', name: t('Application status'), helperText: getApplicationStatusHelperText(t), }, { + id: 'deviceStatus', name: t('Device status'), helperText: getDeviceStatusHelperText(t), }, { + id: 'updateStatus', name: t('Update status'), helperText: getUpdateStatusHelperText(t), }, @@ -99,7 +105,7 @@ const EnrolledDevicesTable = ({ const [addDeviceModal, setAddDeviceModal] = React.useState(false); const [isMassDecommissionModalOpen, setIsMassDecommissionModalOpen] = React.useState(false); - const deviceColumns = React.useMemo(() => getDeviceColumns(t), [t]); + const deviceColumns = React.useMemo(() => getDeviceTableColumns(t), [t]); const { onRowSelect, hasSelectedRows, isAllSelected, isRowSelected, setAllSelected } = useTableSelect(); @@ -197,6 +203,7 @@ const EnrolledDevicesTable = ({ decommissionAction={decommissionDeviceAction} canResume={canResume} resumeAction={resumeDeviceAction} + deviceColumns={deviceColumns} /> ))} diff --git a/libs/ui-components/src/components/Device/DevicesPage/useDevices.ts b/libs/ui-components/src/components/Device/DevicesPage/useDevices.ts index 99d388be9..421d06845 100644 --- a/libs/ui-components/src/components/Device/DevicesPage/useDevices.ts +++ b/libs/ui-components/src/components/Device/DevicesPage/useDevices.ts @@ -7,10 +7,12 @@ import { useFetchPeriodically } from '../../../hooks/useFetchPeriodically'; import { FlightCtlLabel } from '../../../types/extraTypes'; import { FilterStatusMap } from './types'; import { PAGE_SIZE } from '../../../constants'; +import { PaginationDetails, useTablePagination } from '../../../hooks/useTablePagination'; type DevicesEndpointArgs = { nameOrAlias?: string; ownerFleets?: string[]; + onlyFleetless?: boolean; activeStatuses?: FilterStatusMap; onlyDecommissioned?: boolean; labels?: FlightCtlLabel[]; @@ -36,6 +38,7 @@ const getDevicesEndpoint = ({ onlyDecommissioned, nextContinue, summaryOnly, + onlyFleetless, }: DevicesEndpointArgs) => { const filterByAppStatus = activeStatuses?.[FilterSearchParams.AppStatus]; const filterByDevStatus = activeStatuses?.[FilterSearchParams.DeviceStatus]; @@ -57,6 +60,10 @@ const getDevicesEndpoint = ({ ); } + if (onlyFleetless) { + fieldSelectors.push('!metadata.owner'); + } + if (onlyDecommissioned) { queryUtils.addQueryConditions(fieldSelectors, 'status.lifecycle.status', decommissionedStatuses); } else if (summaryOnly) { @@ -102,6 +109,15 @@ export const useDevicesSummary = ({ return [deviceList?.summary, listLoading]; }; +export type DevicesResult = { + devices: Device[]; + isLoading: boolean; + error: unknown; + isUpdating: boolean; + refetch: VoidFunction; + hasMore: boolean; +}; + export const useDevices = (args: { nameOrAlias?: string; ownerFleets?: string[]; @@ -109,8 +125,9 @@ export const useDevices = (args: { labels?: FlightCtlLabel[]; onlyDecommissioned: boolean; nextContinue?: string; + onlyFleetless?: boolean; onPageFetched?: (data: DeviceList) => void; -}): [Device[], boolean, unknown, boolean, VoidFunction] => { +}): DevicesResult => { const [devicesEndpoint, devicesDebouncing] = useDevicesEndpoint(args); const [devicesList, devicesLoading, devicesError, devicesRefetch, updating] = useFetchPeriodically( @@ -120,5 +137,56 @@ export const useDevices = (args: { args.onPageFetched, ); - return [devicesList?.items || [], devicesLoading, devicesError, updating || devicesDebouncing, devicesRefetch]; + const hasMore = !!devicesList?.metadata?.continue || (devicesList?.metadata?.remainingItemCount ?? 0) > 0; + + return { + devices: devicesList?.items || [], + isLoading: devicesLoading, + error: devicesError, + isUpdating: updating || devicesDebouncing, + refetch: devicesRefetch, + hasMore, + }; +}; + +export type DevicesPaginatedResult = { + devices: Device[]; + isLoading: boolean; + error: unknown; + isUpdating: boolean; + refetch: VoidFunction; + pagination: PaginationDetails; +}; + +/** + * Hook for fetching devices with built-in pagination support. + * Use this for paginated tables/modals. + */ +export const useDevicesPaginated = (args: { + nameOrAlias?: string; + ownerFleets?: string[]; + onlyDecommissioned: boolean; + onlyFleetless?: boolean; +}): DevicesPaginatedResult => { + const pagination = useTablePagination(); + const [devicesEndpoint, devicesDebouncing] = useDevicesEndpoint({ + ...args, + nextContinue: pagination.nextContinue, + }); + + const [devicesList, devicesLoading, devicesError, devicesRefetch, updating] = useFetchPeriodically( + { + endpoint: devicesEndpoint, + }, + pagination.onPageFetched, + ); + + return { + devices: devicesList?.items || [], + isLoading: devicesLoading, + error: devicesError, + isUpdating: updating || devicesDebouncing, + refetch: devicesRefetch, + pagination, + }; }; diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/EditDeviceWizard.tsx b/libs/ui-components/src/components/Device/EditDeviceWizard/EditDeviceWizard.tsx index d35741239..490cf8c57 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/EditDeviceWizard.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/EditDeviceWizard.tsx @@ -133,7 +133,7 @@ const EditDeviceWizard = () => { - + diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationTemplates.tsx b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationTemplates.tsx index 92e42a42d..485328c80 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationTemplates.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationTemplates.tsx @@ -1,11 +1,13 @@ import * as React from 'react'; import { + Alert, Button, Content, FormGroup, FormSection, Grid, + GridItem, Split, SplitItem, Stack, @@ -32,10 +34,19 @@ import ApplicationHelmForm from './ApplicationHelmForm'; import ApplicationVolumeForm from './ApplicationVolumeForm'; import ApplicationVariablesForm from './ApplicationVariablesForm'; import ApplicationIntegritySettings from './ApplicationIntegritySettings'; +import { APP_CATALOG_LABEL_KEY } from '../../../Catalog/const'; import './ApplicationsForm.css'; -const ApplicationSection = ({ index, isReadOnly }: { index: number; isReadOnly?: boolean }) => { +const ApplicationSection = ({ + index, + isReadOnly, + managedByCatalog, +}: { + index: number; + isReadOnly?: boolean; + managedByCatalog: boolean; +}) => { const { t } = useTranslation(); const appFieldName = `applications[${index}]`; const [{ value: app }, , { setValue }] = useField(appFieldName); @@ -69,6 +80,11 @@ const ApplicationSection = ({ index, isReadOnly }: { index: number; isReadOnly?: fieldName={appFieldName} > + {managedByCatalog && ( + + + + )} { +const ApplicationTemplates = ({ + isReadOnly, + labels, +}: { + isReadOnly?: boolean; + labels: Record | undefined; +}) => { const { t } = useTranslation(); const { values } = useFormikContext(); if (isReadOnly && values.applications.length === 0) { @@ -186,26 +208,33 @@ const ApplicationTemplates = ({ isReadOnly }: { isReadOnly?: boolean }) => { {({ push, remove }) => ( <> - {values.applications.map((_app, index) => ( - - - - - - {!isReadOnly && ( - - + + )} + + + + + + ); +}; + +// Base Input Template +const BaseInputTemplate: React.FC = (props) => { + const { type } = props; + + switch (type) { + case 'password': + return ; + case 'email': + return ; + case 'url': + return ; + default: + return ; + } +}; + +export { PFFieldTemplate, PFObjectFieldTemplate, PFArrayFieldTemplate, BaseInputTemplate, pfFields }; diff --git a/libs/ui-components/src/components/DynamicForm/FormWidget.tsx b/libs/ui-components/src/components/DynamicForm/FormWidget.tsx new file mode 100644 index 000000000..4eb34bc39 --- /dev/null +++ b/libs/ui-components/src/components/DynamicForm/FormWidget.tsx @@ -0,0 +1,218 @@ +import { + Checkbox, + MenuToggle, + NumberInput, + Select, + SelectList, + SelectOption, + TextArea, + TextInput, +} from '@patternfly/react-core'; +import { WidgetProps } from '@rjsf/utils'; +import * as React from 'react'; + +// PatternFly Text Widget +const PFTextWidget: React.FC = ({ id, value, onChange, disabled, readonly, rawErrors, placeholder }) => { + const hasError = !!rawErrors?.length; + return ( + onChange(val)} + isDisabled={disabled} + readOnlyVariant={readonly ? 'default' : undefined} + validated={hasError ? 'error' : 'default'} + placeholder={placeholder} + /> + ); +}; + +// PatternFly TextArea Widget +const PFTextareaWidget: React.FC = ({ + id, + value, + onChange, + disabled, + readonly, + rawErrors, + placeholder, +}) => { + const hasError = !!rawErrors?.length; + return ( +