diff --git a/CONFIGURATION.md b/CONFIGURATION.md index df764bc8..37bf354b 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -11,19 +11,20 @@ This document describes all environment variables and configuration options avai ## Backend configuration -| Variable | Description | Default | Values | -| --------------------------------------- | ------------------------------------ | ------------------------ | -------------------------------------- | -| `BASE_UI_URL` | Base URL for UI application | `http://localhost:9000` | `https://ui.flightctl.example.com` | -| `FLIGHTCTL_SERVER` | Flight Control API server URL | `https://localhost:3443` | `https://api.flightctl.example.com` | -| `FLIGHTCTL_SERVER_INSECURE_SKIP_VERIFY` | Skip backend server TLS verification | `false` | `true`, `false` | -| `FLIGHTCTL_CLI_ARTIFACTS_SERVER` | CLI artifacts server URL | `http://localhost:8090` | `https://cli.flightctl.example.com` | -| `FLIGHTCTL_ALERTMANAGER_PROXY` | AlertManager proxy server URL | `https://localhost:8443` | `https://alerts.flightctl.example.com` | -| `AUTH_INSECURE_SKIP_VERIFY` | Skip auth server TLS verification | `false` | `true`, `false` | -| `TLS_CERT` | Path to TLS certificate | _(empty)_ | `/path/to/server.crt` | -| `TLS_KEY` | Path to TLS private key | _(empty)_ | `/path/to/server.key` | -| `API_PORT` | UI proxy server port | `3001` | `8080`, `3000`, etc. | -| `IS_OCP_PLUGIN` | Run as OpenShift Console plugin | `false` | `true`, `false` | -| `IS_RHEM` | Red Hat Enterprise Mode | _(empty)_ | `true`, `false` | +| Variable | Description | Default | Values | +| --------------------------------------- | ------------------------------------ | ------------------------ | -------------------------------------------- | +| `BASE_UI_URL` | Base URL for UI application | `http://localhost:9000` | `https://ui.flightctl.example.com` | +| `FLIGHTCTL_SERVER` | Flight Control API server URL | `https://localhost:3443` | `https://api.flightctl.example.com` | +| `FLIGHTCTL_SERVER_INSECURE_SKIP_VERIFY` | Skip backend server TLS verification | `false` | `true`, `false` | +| `FLIGHTCTL_CLI_ARTIFACTS_SERVER` | CLI artifacts server URL | `http://localhost:8090` | `https://cli.flightctl.example.com` | +| `FLIGHTCTL_ALERTMANAGER_PROXY` | AlertManager proxy server URL | `https://localhost:8443` | `https://alerts.flightctl.example.com` | +| `FLIGHTCTL_IMAGEBUILDER_SERVER` | ImageBuilder API server URL | `https://localhost:8445` | `https://imagebuilder.flightctl.example.com` | +| `AUTH_INSECURE_SKIP_VERIFY` | Skip auth server TLS verification | `false` | `true`, `false` | +| `TLS_CERT` | Path to TLS certificate | _(empty)_ | `/path/to/server.crt` | +| `TLS_KEY` | Path to TLS private key | _(empty)_ | `/path/to/server.key` | +| `API_PORT` | UI proxy server port | `3001` | `8080`, `3000`, etc. | +| `IS_OCP_PLUGIN` | Run as OpenShift Console plugin | `false` | `true`, `false` | +| `IS_RHEM` | Red Hat Enterprise Mode | _(empty)_ | `true`, `false` | ## Configuration examples diff --git a/apps/ocp-plugin/console-extensions.json b/apps/ocp-plugin/console-extensions.json index e26c4761..f2c12b4c 100644 --- a/apps/ocp-plugin/console-extensions.json +++ b/apps/ocp-plugin/console-extensions.json @@ -45,6 +45,16 @@ "section": "fctl" } }, + { + "type": "console.navigation/href", + "properties": { + "id": "fctl-imagebuilds", + "name": "%plugin__flightctl-plugin~Image builds%", + "href": "/edge/imagebuilds", + "perspective": "acm", + "section": "fctl" + } + }, { "type": "console.page/route", "properties": { @@ -125,6 +135,30 @@ "component": { "$codeRef": "RepositoryDetailsPage" } } }, + { + "type": "console.page/route", + "properties": { + "exact": true, + "path": ["/edge/imagebuilds"], + "component": { "$codeRef": "ImageBuildsPage" } + } + }, + { + "type": "console.page/route", + "properties": { + "exact": true, + "path": ["/edge/imagebuilds/create", "/edge/imagebuilds/edit/:imageBuildId"], + "component": { "$codeRef": "CreateImageBuildWizardPage" } + } + }, + { + "type": "console.page/route", + "properties": { + "exact": false, + "path": ["/edge/imagebuilds/:imageBuildId"], + "component": { "$codeRef": "ImageBuildDetailsPage" } + } + }, { "type": "console.page/route", "properties": { diff --git a/apps/ocp-plugin/package.json b/apps/ocp-plugin/package.json index 16ee9a32..6cb11956 100644 --- a/apps/ocp-plugin/package.json +++ b/apps/ocp-plugin/package.json @@ -22,6 +22,9 @@ "RepositoriesPage": "./src/components/Repositories/RepositoriesPage.tsx", "RepositoryDetailsPage": "./src/components/Repositories/RepositoryDetailsPage.tsx", "CreateRepositoryPage": "./src/components/Repositories/CreateRepositoryPage.tsx", + "ImageBuildsPage": "./src/components/ImageBuilds/ImageBuildsPage.tsx", + "ImageBuildDetailsPage": "./src/components/ImageBuilds/ImageBuildDetailsPage.tsx", + "CreateImageBuildWizardPage": "./src/components/ImageBuilds/CreateImageBuildWizardPage.tsx", "ResourceSyncToRepositoryPage": "./src/components/ResourceSyncs/ResourceSyncToRepositoryPage.tsx", "EnrollmentRequestDetailsPage": "./src/components/EnrollmentRequests/EnrollmentRequestDetailsPage.tsx", "appContext": "./src/components/AppContext/AppContext.tsx", diff --git a/apps/ocp-plugin/src/components/AppContext/AppContext.tsx b/apps/ocp-plugin/src/components/AppContext/AppContext.tsx index eba8c9e1..7cc90134 100644 --- a/apps/ocp-plugin/src/components/AppContext/AppContext.tsx +++ b/apps/ocp-plugin/src/components/AppContext/AppContext.tsx @@ -50,6 +50,10 @@ const appRoutes = { [ROUTE.REPO_EDIT]: '/edge/repositories/edit', [ROUTE.REPO_DETAILS]: '/edge/repositories', [ROUTE.REPOSITORIES]: '/edge/repositories', + [ROUTE.IMAGE_BUILDS]: '/edge/imagebuilds', + [ROUTE.IMAGE_BUILD_CREATE]: '/edge/imagebuilds/create', + [ROUTE.IMAGE_BUILD_DETAILS]: '/edge/imagebuilds', + [ROUTE.IMAGE_BUILD_EDIT]: '/edge/imagebuilds/edit', [ROUTE.RESOURCE_SYNC_DETAILS]: '/edge/resourcesyncs', [ROUTE.ENROLLMENT_REQUESTS]: '/edge/enrollmentrequests', [ROUTE.ENROLLMENT_REQUEST_DETAILS]: '/edge/enrollmentrequests', diff --git a/apps/ocp-plugin/src/components/ImageBuilds/CreateImageBuildWizardPage.tsx b/apps/ocp-plugin/src/components/ImageBuilds/CreateImageBuildWizardPage.tsx new file mode 100644 index 00000000..e5f6421b --- /dev/null +++ b/apps/ocp-plugin/src/components/ImageBuilds/CreateImageBuildWizardPage.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import CreateImageBuildWizard from '@flightctl/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/CreateImageBuildWizard'; +import WithPageLayout from '../common/WithPageLayout'; + +const CreateImageBuildWizardPage = () => { + return ( + + + + ); +}; + +export default CreateImageBuildWizardPage; diff --git a/apps/ocp-plugin/src/components/ImageBuilds/ImageBuildDetailsPage.tsx b/apps/ocp-plugin/src/components/ImageBuilds/ImageBuildDetailsPage.tsx new file mode 100644 index 00000000..55792162 --- /dev/null +++ b/apps/ocp-plugin/src/components/ImageBuilds/ImageBuildDetailsPage.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import ImageBuildDetails from '@flightctl/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildDetailsPage'; +import WithPageLayout from '../common/WithPageLayout'; + +const OcpImageBuildDetailsPage = () => { + return ( + + + + ); +}; + +export default OcpImageBuildDetailsPage; diff --git a/apps/ocp-plugin/src/components/ImageBuilds/ImageBuildsPage.tsx b/apps/ocp-plugin/src/components/ImageBuilds/ImageBuildsPage.tsx new file mode 100644 index 00000000..6a155168 --- /dev/null +++ b/apps/ocp-plugin/src/components/ImageBuilds/ImageBuildsPage.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import ImageBuildsPage from '@flightctl/ui-components/src/components/ImageBuilds/ImageBuildsPage'; +import WithPageLayout from '../common/WithPageLayout'; + +const OcpImageBuildsPage = () => { + return ( + + + + ); +}; + +export default OcpImageBuildsPage; diff --git a/apps/ocp-plugin/src/utils/apiCalls.ts b/apps/ocp-plugin/src/utils/apiCalls.ts index 99fce637..24d71178 100644 --- a/apps/ocp-plugin/src/utils/apiCalls.ts +++ b/apps/ocp-plugin/src/utils/apiCalls.ts @@ -38,6 +38,7 @@ const apiServer = `${window.location.hostname}${ export const uiProxy = `${window.location.protocol}//${apiServer}`; const flightCtlAPI = `${uiProxy}/api/flightctl`; const alertsAPI = `${uiProxy}/api/alerts`; +const imageBuilderPathRegex = /^image(builds|exports)/; export const wsEndpoint = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${apiServer}`; export const fetchUiProxy = async (endpoint: string, requestInit: RequestInit): Promise => { @@ -50,6 +51,9 @@ const getFullApiUrl = (path: string) => { if (path.startsWith('alerts')) { return { api: 'alerts', url: `${alertsAPI}/api/v2/${path}` }; } + if (imageBuilderPathRegex.test(path)) { + return { api: 'imagebuilder', url: `${uiProxy}/imagebuilder/api/v1/${path}` }; + } return { api: 'flightctl', url: `${flightCtlAPI}/api/v1/${path}` }; }; @@ -102,7 +106,8 @@ const putOrPostData = async ( const options = addRequiredHeaders(baseOptions); try { - const response = await fetch(`${flightCtlAPI}/api/v1/${kind}`, options); + const { url } = getFullApiUrl(kind); + const response = await fetch(url, options); return handleApiJSONResponse(response); } catch (error) { console.error(`Error making ${method} request for ${kind}:`, error); @@ -124,7 +129,8 @@ export const deleteData = async (kind: string, abortSignal?: AbortSignal): Pr const options = addRequiredHeaders(baseOptions); try { - const response = await fetch(`${flightCtlAPI}/api/v1/${kind}`, options); + const { url } = getFullApiUrl(kind); + const response = await fetch(url, options); return handleApiJSONResponse(response); } catch (error) { console.error('Error making DELETE request:', error); @@ -144,7 +150,8 @@ export const patchData = async (kind: string, data: PatchRequest, abortSignal const options = addRequiredHeaders(baseOptions); try { - const response = await fetch(`${flightCtlAPI}/api/v1/${kind}`, options); + const { url } = getFullApiUrl(kind); + const response = await fetch(url, options); return handleApiJSONResponse(response); } catch (error) { console.error('Error making PATCH request:', error); diff --git a/apps/ocp-plugin/tsconfig.json b/apps/ocp-plugin/tsconfig.json index 3d3880f3..cd75da53 100644 --- a/apps/ocp-plugin/tsconfig.json +++ b/apps/ocp-plugin/tsconfig.json @@ -23,6 +23,7 @@ "paths": { "@flightctl/ui-components/*": ["../../libs/ui-components/*"], "@flightctl/types": ["../../libs/types"], + "@flightctl/types/imagebuilder": ["../../libs/types/imagebuilder"], }, }, "include": ["**/*.ts", "**/*.tsx", "**/*.jsx", "**/*.js", "**/*.json"], diff --git a/apps/standalone/scripts/setup_env.sh b/apps/standalone/scripts/setup_env.sh index 45a4b00f..fb8bb7b9 100755 --- a/apps/standalone/scripts/setup_env.sh +++ b/apps/standalone/scripts/setup_env.sh @@ -63,6 +63,7 @@ echo "Using external IP: $EXTERNAL_IP" >&2 export FLIGHTCTL_SERVER_INSECURE_SKIP_VERIFY='true' export FLIGHTCTL_SERVER="https://$EXTERNAL_IP:3443" export FLIGHTCTL_SERVER_EXTERNAL="https://api.$EXTERNAL_IP.nip.io:3443" +export FLIGHTCTL_IMAGEBUILDER_SERVER="https://$EXTERNAL_IP:8445" # CLI artifacts - get setting from kind cluster, unless it has been configured already if [ -z "$ENABLE_CLI_ARTIFACTS" ]; then @@ -91,6 +92,7 @@ echo "🌐 Environment variables set:" >&2 echo " FLIGHTCTL_SERVER_INSECURE_SKIP_VERIFY=$FLIGHTCTL_SERVER_INSECURE_SKIP_VERIFY" >&2 echo " FLIGHTCTL_SERVER=$FLIGHTCTL_SERVER" >&2 echo " FLIGHTCTL_SERVER_EXTERNAL=$FLIGHTCTL_SERVER_EXTERNAL" >&2 +echo " FLIGHTCTL_IMAGEBUILDER_SERVER=$FLIGHTCTL_IMAGEBUILDER_SERVER" >&2 echo " FLIGHTCTL_CLI_ARTIFACTS_SERVER=${FLIGHTCTL_CLI_ARTIFACTS_SERVER:-'(disabled)'}" >&2 echo " FLIGHTCTL_ALERTMANAGER_PROXY=${FLIGHTCTL_ALERTMANAGER_PROXY:-'(disabled)'}" >&2 echo >&2 diff --git a/apps/standalone/src/app/routes.tsx b/apps/standalone/src/app/routes.tsx index 269ac3d8..3ef58cb9 100644 --- a/apps/standalone/src/app/routes.tsx +++ b/apps/standalone/src/app/routes.tsx @@ -76,6 +76,13 @@ const CreateAuthProvider = React.lazy( const AuthProviderDetails = React.lazy( () => import('@flightctl/ui-components/src/components/AuthProvider/AuthProviderDetails/AuthProviderDetails'), ); +const ImageBuildsPage = React.lazy(() => import('@flightctl/ui-components/src/components/ImageBuilds/ImageBuildsPage')); +const ImageBuildDetails = React.lazy( + () => import('@flightctl/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildDetailsPage'), +); +const CreateImageBuildWizard = React.lazy( + () => import('@flightctl/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/CreateImageBuildWizard'), +); export type ExtendedRouteObject = RouteObject & { title?: string; @@ -259,6 +266,49 @@ const getAppRoutes = (t: TFunction): ExtendedRouteObject[] => [ }, ], }, + { + path: '/devicemanagement/imagebuilds', + showInNav: true, + title: t('Image builds'), + children: [ + { + index: true, + title: t('Image builds'), + element: ( + + + + ), + }, + { + path: 'create', + title: t('Build new image'), + element: ( + + + + ), + }, + { + path: 'edit/:imageBuildId', + title: t('Duplicate image build'), + element: ( + + + + ), + }, + { + path: ':imageBuildId/*', + title: t('Image build'), + element: ( + + + + ), + }, + ], + }, { path: '/devicemanagement/repositories', showInNav: true, diff --git a/apps/standalone/src/app/utils/apiCalls.ts b/apps/standalone/src/app/utils/apiCalls.ts index 7f785bc7..9540a621 100644 --- a/apps/standalone/src/app/utils/apiCalls.ts +++ b/apps/standalone/src/app/utils/apiCalls.ts @@ -14,6 +14,8 @@ const apiServer = `${window.location.hostname}${apiPort ? `:${apiPort}` : ''}`; const flightCtlAPI = `${window.location.protocol}//${apiServer}/api/flightctl`; const uiProxyAPI = `${window.location.protocol}//${apiServer}/api`; +const imageBuilderPathRegex = /^image(builds|exports)/; + export const loginAPI = `${window.location.protocol}//${apiServer}/api/login`; export const wsEndpoint = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${apiServer}`; @@ -46,6 +48,9 @@ const getFullApiUrl = (path: 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}` }; + } return { api: 'flightctl', url: `${flightCtlAPI}/api/v1/${path}` }; }; diff --git a/apps/standalone/tsconfig.json b/apps/standalone/tsconfig.json index bfe96832..1a21afe0 100644 --- a/apps/standalone/tsconfig.json +++ b/apps/standalone/tsconfig.json @@ -24,6 +24,7 @@ "@fctl-assets/*": ["src/assets/*"], "@flightctl/ui-components/*": ["../../libs/ui-components/*"], "@flightctl/types": ["../../libs/types"], + "@flightctl/types/imagebuilder": ["../../libs/types/imagebuilder"], }, }, "include": ["**/*.ts", "**/*.tsx", "**/*.jsx", "**/*.js", "**/*.json"], diff --git a/libs/ansible/src/const.ts b/libs/ansible/src/const.ts index 5782fab1..e2513483 100644 --- a/libs/ansible/src/const.ts +++ b/libs/ansible/src/const.ts @@ -17,6 +17,10 @@ export const appRoutes = { [ROUTE.RESOURCE_SYNC_DETAILS]: '/edge/resourcesyncs', [ROUTE.ENROLLMENT_REQUESTS]: '/edge/enrollmentrequests', [ROUTE.ENROLLMENT_REQUEST_DETAILS]: '/edge/enrollmentrequests', + [ROUTE.IMAGE_BUILDS]: '/edge/imagebuilds', + [ROUTE.IMAGE_BUILD_CREATE]: '/edge/imagebuilds/create', + [ROUTE.IMAGE_BUILD_DETAILS]: '/edge/imagebuilds', + [ROUTE.IMAGE_BUILD_EDIT]: '/edge/imagebuilds/edit', // Unimplemented UI routes [ROUTE.COMMAND_LINE_TOOLS]: '/', [ROUTE.AUTH_PROVIDERS]: '/', diff --git a/libs/cypress/support/interceptors/auth.ts b/libs/cypress/support/interceptors/auth.ts index 5f3706e5..f4beb539 100644 --- a/libs/cypress/support/interceptors/auth.ts +++ b/libs/cypress/support/interceptors/auth.ts @@ -5,7 +5,6 @@ import { createListMatcher } from './matchers'; const loadInterceptors = () => { cy.intercept('GET', '/api/login/info', (req) => { req.reply({ - // CELIA-WIP MOST LIKELY WE NEED TO ADD MORE MOCKS FOR CYPRESS TESTS TO CONTINUE WORKING statusCode: 200, body: { username: 'cypress-user', diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 05541c32..c3371ae2 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -36,6 +36,10 @@ "Device": "Device", "Devices": "Devices", "Edit device": "Edit device", + "Image builds": "Image builds", + "Build new image": "Build new image", + "Duplicate image build": "Duplicate image build", + "Image build": "Image build", "Repositories": "Repositories", "Create Repository": "Create Repository", "Edit repository": "Edit repository", @@ -449,7 +453,6 @@ "Delete configuration": "Delete configuration", "Add configuration": "Add configuration", "Failed to load repositories": "Failed to load repositories", - "Missing repository": "Missing repository", "Branch/tag/commit": "Branch/tag/commit", "Path": "Path", "Path in the repository where the configuration files are located.": "Path in the repository where the configuration files are located.", @@ -458,9 +461,6 @@ "Suffix that will be combined with the repository's base URL to invoke the HTTP service. Can include query parameters.": "Suffix that will be combined with the repository's base URL to invoke the HTTP service. Can include query parameters.", "File path": "File path", "Path of the file where the response will be stored in the device filesystem.": "Path of the file where the response will be stored in the device filesystem.", - "Repository": "Repository", - "Select a repository": "Select a repository", - "Create repository": "Create repository", "Register all MicroShift devices to ACM": "Register all MicroShift devices to ACM", "Select this when all the devices in the fleet are running MicroShift and you want to register them to ACM.": "Select this when all the devices in the fleet are running MicroShift and you want to register them to ACM.", "To remove registration, you'll need to uncheck this option and also remove the clusters from ACM's clusters list": "To remove registration, you'll need to uncheck this option and also remove the clusters from ACM's clusters list", @@ -683,6 +683,8 @@ "Import": "Import", "Select or create repository": "Select or create repository", "Add resource sync": "Add resource sync", + "Repository": "Repository", + "Select a repository": "Select a repository", "URL": "URL", "Sync status": "Sync status", "Last transition": "Last transition", @@ -697,6 +699,10 @@ "Add item": "Add item", "Resolved": "Resolved", "Name must be unique": "Name must be unique", + "Accessible": "Accessible", + "Not accessible": "Not accessible", + "Missing repository": "Missing repository", + "Create repository": "Create repository", "Max file size {{ maxFileSize }} MB": "Max file size {{ maxFileSize }} MB", "Content exceeds max file size of {{ maxFileSize }} MB": "Content exceeds max file size of {{ maxFileSize }} MB", "Drag a file or browse to upload": "Drag a file or browse to upload", @@ -791,6 +797,89 @@ "Device aliases must be unique. Add a number to the template to generate unique aliases.": "Device aliases must be unique. Add a number to the template to generate unique aliases.", "Fleet selection is required": "Fleet selection is required", "At least one label is required": "At least one label is required", + "Retry image build": "Retry image build", + "Image build was created but has a different name": "Image build was created but has a different name", + "Image details": "Image details", + "Image output": "Image output", + "Registration": "Registration", + "Build image": "Build image", + "Repository is read-only and cannot be used as the target repository.": "Repository is read-only and cannot be used as the target repository.", + "Management-ready by default": "Management-ready by default", + "The agent is automatically included in this image. This ensures your devices are ready to be managed immediately after they are deployed.": "The agent is automatically included in this image. This ensures your devices are ready to be managed immediately after they are deployed.", + "Target repository": "Target repository", + "Storage repository for your completed image.": "Storage repository for your completed image.", + "Image name": "Image name", + "The image name that will be pushed to the repository. For example: flightctl/rhel-bootc": "The image name that will be pushed to the repository. For example: flightctl/rhel-bootc", + "Image tag": "Image tag", + "Specify the version (e.g., latest or 9.6)": "Specify the version (e.g., latest or 9.6)", + "Export formats": "Export formats", + "Choose formats you need for this image. Each selection will generate a separate, ready-to-use image file.": "Choose formats you need for this image. Each selection will generate a separate, ready-to-use image file.", + "{{count}} image export tasks will be created._one": "{{count}} image export task will be created.", + "{{count}} image export tasks will be created._other": "{{count}} image export tasks will be created.", + "Early binding": "Early binding", + "This image is automatically secured. Register it within {{ validityPeriod }} years to keep it active._one": "This image is automatically secured. Register it within {{ validityPeriod }} year to keep it active.", + "This image is automatically secured. Register it within {{ validityPeriod }} years to keep it active._other": "This image is automatically secured. Register it within {{ validityPeriod }} years to keep it active.", + "Enrollment": "Enrollment", + "Auto-create certificate": "Auto-create certificate", + "{{ validityPeriod }} years (Standard)_one": "{{ validityPeriod }} year (Standard)", + "{{ validityPeriod }} years (Standard)_other": "{{ validityPeriod }} years (Standard)", + "Late binding": "Late binding", + "No additional user input required (cloud-init and ignition are enabled automatically)": "No additional user input required (cloud-init and ignition are enabled automatically)", + "Base image": "Base image", + "Source repository": "Source repository", + "Image reference URL": "Image reference URL", + "Image output name": "Image output name", + "Image output tag": "Image output tag", + "Image output reference URL": "Image output reference URL", + "Binding type": "Binding type", + "Cloud-init and ignition are enabled automatically": "Cloud-init and ignition are enabled automatically", + "Failed to create image build": "Failed to create image build", + "Image build created, but some exports failed": "Image build created, but some exports failed", + "The image build \"{{buildName}}\" was created successfully, however the following export(s) failed:": "The image build \"{{buildName}}\" was created successfully, however the following export(s) failed:", + "The image name from the registry. For example: rhel9/rhel-bootc": "The image name from the registry. For example: rhel9/rhel-bootc", + "Source repository is required": "Source repository is required", + "Image name is required": "Image name is required", + "Image tag is required": "Image tag is required", + "Source image is required": "Source image is required", + "Target repository is required": "Target repository is required", + "Target image is required": "Target image is required", + "Binding type is required": "Binding type is required", + "Delete this build?": "Delete this build?", + "This will remove the record of this build and its history._one": "This will remove the record of this build and its history.", + "This will remove the record of this build and its history._other": "This will remove the record of these builds and their history.", + "The actual image files in your storage will not be deleted.": "The actual image files in your storage will not be deleted.", + "Deletion of image build failed.": "Deletion of image build failed.", + "Queued": "Queued", + "Building": "Building", + "Pushing": "Pushing", + "Complete": "Complete", + "Failed": "Failed", + "Converting": "Converting", + "Image built successfully": "Image built successfully", + "Export images": "Export images", + "Logs": "Logs", + "Retry": "Retry", + "Duplicate": "Duplicate", + "Delete image build": "Delete image build", + "Build information": "Build information", + "Build status": "Build status", + "Source image": "Source image", + "Image builds cannot be edited. Use Retry to create a new image build based on this one.": "Image builds cannot be edited. Use Retry to create a new image build based on this one.", + "Date": "Date", + "Build failed. Please retry.": "Build failed. Please retry.", + "View more": "View more", + "There are no image builds in your environment.": "There are no image builds in your environment.", + "Generate system images for consistent deployment to edge devices.": "Generate system images for consistent deployment to edge devices.", + "Delete image builds": "Delete image builds", + "Image builds table": "Image builds table", + "Downloading...": "Downloading...", + "View logs": "View logs", + "Export image": "Export image", + "Created: {{date}}": "Created: {{date}}", + "We couldn't export your image": "We couldn't export your image", + "We couldn't download your image": "We couldn't download your image", + "Something went wrong on our end. Please review the error details and try again.": "Something went wrong on our end. Please review the error details and try again.", + "Enter the image details to view the URL it resolves to": "Enter the image details to view the URL it resolves to", "device": "device", "pending device": "pending device", "resource sync": "resource sync", @@ -856,6 +945,7 @@ "Some fleets you selected are managed by a resource sync and cannot be deleted. To remove those fleets, delete the resource syncs from the related repositories inside the \"Repositories\" tab.": "Some fleets you selected are managed by a resource sync and cannot be deleted. To remove those fleets, delete the resource syncs from the related repositories inside the \"Repositories\" tab.", "Show fleets": "Show fleets", "{{progress}} of {{progressTotal}}": "{{progress}} of {{progressTotal}}", + "Delete image builds?": "Delete image builds?", "Failed to delete resource syncs. {{errorDetails}}": "Failed to delete resource syncs. {{errorDetails}}", "Delete repositories ?": "Delete repositories ?", "Please wait while the repository details are loading": "Please wait while the repository details are loading", @@ -1052,6 +1142,7 @@ "No results found": "No results found", "Clear all filters and try again.": "Clear all filters and try again.", "Select all rows": "Select all rows", + "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...", @@ -1087,6 +1178,9 @@ "Device is bound to a fleet and its configurations cannot be edited.": "Device is bound to a fleet and its configurations cannot be edited.", "Device decommissioning already started.": "Device decommissioning already started.", "Device is not suspended.": "Device is not suspended.", + "For enterprise virtualization platforms.": "For enterprise virtualization platforms.", + "For virtualized edge workloads and OpenShift Virtualization.": "For virtualized edge workloads and OpenShift Virtualization.", + "For physical edge devices and bare metal.": "For physical edge devices and bare metal.", "Error": "Error", "Degraded": "Degraded", "No applications": "No applications", @@ -1111,15 +1205,12 @@ "Sync pending": "Sync pending", "Awaiting first sync": "Awaiting first sync", "Last rollout did not complete successfully": "Last rollout did not complete successfully", - "Failed": "Failed", "Unsupported": "Unsupported", "Verified": "Verified", "Synced": "Synced", "Not synced": "Not synced", "Parsed": "Parsed", "Not parsed": "Not parsed", - "Accessible": "Accessible", - "Not accessible": "Not accessible", "Out-of-date": "Out-of-date", "Updating": "Updating", "Up-to-date": "Up-to-date", diff --git a/libs/types/imagebuilder/index.ts b/libs/types/imagebuilder/index.ts new file mode 100644 index 00000000..48157926 --- /dev/null +++ b/libs/types/imagebuilder/index.ts @@ -0,0 +1,31 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export { BindingType } from './models/BindingType'; +export type { EarlyBinding } from './models/EarlyBinding'; +export { ExportFormatType } from './models/ExportFormatType'; +export type { ImageBuild } from './models/ImageBuild'; +export type { ImageBuildBinding } from './models/ImageBuildBinding'; +export type { ImageBuildCondition } from './models/ImageBuildCondition'; +export { ImageBuildConditionReason } from './models/ImageBuildConditionReason'; +export { ImageBuildConditionType } from './models/ImageBuildConditionType'; +export type { ImageBuildDestination } from './models/ImageBuildDestination'; +export type { ImageBuildList } from './models/ImageBuildList'; +export type { ImageBuildRefSource } from './models/ImageBuildRefSource'; +export type { ImageBuildSource } from './models/ImageBuildSource'; +export type { ImageBuildSpec } from './models/ImageBuildSpec'; +export type { ImageBuildStatus } from './models/ImageBuildStatus'; +export type { ImageExport } from './models/ImageExport'; +export type { ImageExportCondition } from './models/ImageExportCondition'; +export { ImageExportConditionReason } from './models/ImageExportConditionReason'; +export { ImageExportConditionType } from './models/ImageExportConditionType'; +export { ImageExportFormatPhase } from './models/ImageExportFormatPhase'; +export type { ImageExportList } from './models/ImageExportList'; +export type { ImageExportSource } from './models/ImageExportSource'; +export { ImageExportSourceType } from './models/ImageExportSourceType'; +export type { ImageExportSpec } from './models/ImageExportSpec'; +export type { ImageExportStatus } from './models/ImageExportStatus'; +export type { LateBinding } from './models/LateBinding'; +export { ResourceKind } from './models/ResourceKind'; diff --git a/libs/types/imagebuilder/models/BindingType.ts b/libs/types/imagebuilder/models/BindingType.ts new file mode 100644 index 00000000..4edfe25c --- /dev/null +++ b/libs/types/imagebuilder/models/BindingType.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * The type of binding for the image build. + */ +export enum BindingType { + BindingTypeEarly = 'early', + BindingTypeLate = 'late', +} diff --git a/libs/types/imagebuilder/models/EarlyBinding.ts b/libs/types/imagebuilder/models/EarlyBinding.ts new file mode 100644 index 00000000..8cdce078 --- /dev/null +++ b/libs/types/imagebuilder/models/EarlyBinding.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Early binding configuration - embeds certificate in the image. + */ +export type EarlyBinding = { + /** + * The type of binding. + */ + type: 'early'; +}; + diff --git a/libs/types/imagebuilder/models/ExportFormatType.ts b/libs/types/imagebuilder/models/ExportFormatType.ts new file mode 100644 index 00000000..5a396c98 --- /dev/null +++ b/libs/types/imagebuilder/models/ExportFormatType.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * The type of format to export the image to. + */ +export enum ExportFormatType { + ExportFormatTypeVMDK = 'vmdk', + ExportFormatTypeQCOW2 = 'qcow2', + ExportFormatTypeISO = 'iso', +} diff --git a/libs/types/imagebuilder/models/ImageBuild.ts b/libs/types/imagebuilder/models/ImageBuild.ts new file mode 100644 index 00000000..0b7ca0c7 --- /dev/null +++ b/libs/types/imagebuilder/models/ImageBuild.ts @@ -0,0 +1,29 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ObjectMeta } from '../../models/ObjectMeta'; +import type { ImageBuildSpec } from './ImageBuildSpec'; +import type { ImageBuildStatus } from './ImageBuildStatus'; +import type { ImageExport } from './ImageExport'; +/** + * ImageBuild represents a build request for a container image. + */ +export type ImageBuild = { + /** + * 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. + */ + apiVersion: string; + /** + * 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: ImageBuildSpec; + status?: ImageBuildStatus; + /** + * Array of ImageExport resources that reference this ImageBuild. Only populated when withExports query parameter is true. + */ + imageexports?: Array; +}; + diff --git a/libs/types/imagebuilder/models/ImageBuildBinding.ts b/libs/types/imagebuilder/models/ImageBuildBinding.ts new file mode 100644 index 00000000..2d6b91d8 --- /dev/null +++ b/libs/types/imagebuilder/models/ImageBuildBinding.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { EarlyBinding } from './EarlyBinding'; +import type { LateBinding } from './LateBinding'; +/** + * ImageBuildBinding specifies binding configuration for the build. + */ +export type ImageBuildBinding = (EarlyBinding | LateBinding); + diff --git a/libs/types/imagebuilder/models/ImageBuildCondition.ts b/libs/types/imagebuilder/models/ImageBuildCondition.ts new file mode 100644 index 00000000..f8d9005f --- /dev/null +++ b/libs/types/imagebuilder/models/ImageBuildCondition.ts @@ -0,0 +1,13 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ConditionBase } from '../../models/ConditionBase'; +import type { ImageBuildConditionType } from './ImageBuildConditionType'; +/** + * Condition for ImageBuild resources. + */ +export type ImageBuildCondition = (ConditionBase & { + type: ImageBuildConditionType; +}); + diff --git a/libs/types/imagebuilder/models/ImageBuildConditionReason.ts b/libs/types/imagebuilder/models/ImageBuildConditionReason.ts new file mode 100644 index 00000000..d2a32d70 --- /dev/null +++ b/libs/types/imagebuilder/models/ImageBuildConditionReason.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Reason for the ImageBuild Ready condition. + */ +export enum ImageBuildConditionReason { + ImageBuildConditionReasonPending = 'Pending', + ImageBuildConditionReasonBuilding = 'Building', + ImageBuildConditionReasonPushing = 'Pushing', + ImageBuildConditionReasonCompleted = 'Completed', + ImageBuildConditionReasonFailed = 'Failed', +} diff --git a/libs/types/imagebuilder/models/ImageBuildConditionType.ts b/libs/types/imagebuilder/models/ImageBuildConditionType.ts new file mode 100644 index 00000000..313b02e4 --- /dev/null +++ b/libs/types/imagebuilder/models/ImageBuildConditionType.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Type of ImageBuild condition. + */ +export enum ImageBuildConditionType { + ImageBuildConditionTypeReady = 'Ready', +} diff --git a/libs/types/imagebuilder/models/ImageBuildDestination.ts b/libs/types/imagebuilder/models/ImageBuildDestination.ts new file mode 100644 index 00000000..2cdf713c --- /dev/null +++ b/libs/types/imagebuilder/models/ImageBuildDestination.ts @@ -0,0 +1,22 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * ImageBuildDestination specifies the destination for the built image. + */ +export type ImageBuildDestination = { + /** + * The name of the Repository resource of type OCI to push the built image to. + */ + repository: string; + /** + * The name of the output image. + */ + imageName: string; + /** + * The tag of the output image. + */ + imageTag: string; +}; + diff --git a/libs/types/imagebuilder/models/ImageBuildList.ts b/libs/types/imagebuilder/models/ImageBuildList.ts new file mode 100644 index 00000000..685395e7 --- /dev/null +++ b/libs/types/imagebuilder/models/ImageBuildList.ts @@ -0,0 +1,25 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ListMeta } from '../../models/ListMeta'; +import type { ImageBuild } from './ImageBuild'; +/** + * ImageBuildList is a list of ImageBuild resources. + */ +export type ImageBuildList = { + /** + * 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. + */ + apiVersion: string; + /** + * 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 ImageBuild resources. + */ + items: Array; +}; + diff --git a/libs/types/imagebuilder/models/ImageBuildRefSource.ts b/libs/types/imagebuilder/models/ImageBuildRefSource.ts new file mode 100644 index 00000000..baa6aeec --- /dev/null +++ b/libs/types/imagebuilder/models/ImageBuildRefSource.ts @@ -0,0 +1,18 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * ImageBuildRefSource specifies a source image from an ImageBuild resource. + */ +export type ImageBuildRefSource = { + /** + * The type of source. + */ + type: 'imageBuild'; + /** + * The name of the ImageBuild resource to use as source. + */ + imageBuildRef: string; +}; + diff --git a/libs/types/imagebuilder/models/ImageBuildSource.ts b/libs/types/imagebuilder/models/ImageBuildSource.ts new file mode 100644 index 00000000..08712ee2 --- /dev/null +++ b/libs/types/imagebuilder/models/ImageBuildSource.ts @@ -0,0 +1,22 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * ImageBuildSource specifies the source image for the build. + */ +export type ImageBuildSource = { + /** + * The name of the Repository resource of type OCI containing the source image. + */ + repository: string; + /** + * The name of the source image. + */ + imageName: string; + /** + * The tag of the source image. + */ + imageTag: string; +}; + diff --git a/libs/types/imagebuilder/models/ImageBuildSpec.ts b/libs/types/imagebuilder/models/ImageBuildSpec.ts new file mode 100644 index 00000000..408f59df --- /dev/null +++ b/libs/types/imagebuilder/models/ImageBuildSpec.ts @@ -0,0 +1,16 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ImageBuildBinding } from './ImageBuildBinding'; +import type { ImageBuildDestination } from './ImageBuildDestination'; +import type { ImageBuildSource } from './ImageBuildSource'; +/** + * ImageBuildSpec describes the specification for an image build. + */ +export type ImageBuildSpec = { + source: ImageBuildSource; + destination: ImageBuildDestination; + binding: ImageBuildBinding; +}; + diff --git a/libs/types/imagebuilder/models/ImageBuildStatus.ts b/libs/types/imagebuilder/models/ImageBuildStatus.ts new file mode 100644 index 00000000..f82663b4 --- /dev/null +++ b/libs/types/imagebuilder/models/ImageBuildStatus.ts @@ -0,0 +1,31 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ImageBuildCondition } from './ImageBuildCondition'; +/** + * ImageBuildStatus represents the current status of an ImageBuild. + */ +export type ImageBuildStatus = { + /** + * Current conditions of the ImageBuild. + */ + conditions?: Array; + /** + * The full image reference of the built image (e.g., quay.io/org/imagename:tag). + */ + imageReference?: string; + /** + * The architecture of the built image. + */ + architecture?: string; + /** + * The digest of the built image manifest. + */ + manifestDigest?: string; + /** + * The last time the build was seen (heartbeat). + */ + lastSeen?: string; +}; + diff --git a/libs/types/imagebuilder/models/ImageExport.ts b/libs/types/imagebuilder/models/ImageExport.ts new file mode 100644 index 00000000..41d3ad33 --- /dev/null +++ b/libs/types/imagebuilder/models/ImageExport.ts @@ -0,0 +1,24 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ObjectMeta } from '../../models/ObjectMeta'; +import type { ImageExportSpec } from './ImageExportSpec'; +import type { ImageExportStatus } from './ImageExportStatus'; +/** + * ImageExport represents an export request to convert and push images to different formats. + */ +export type ImageExport = { + /** + * 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. + */ + apiVersion: string; + /** + * 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: ImageExportSpec; + status?: ImageExportStatus; +}; + diff --git a/libs/types/imagebuilder/models/ImageExportCondition.ts b/libs/types/imagebuilder/models/ImageExportCondition.ts new file mode 100644 index 00000000..ffd5ca59 --- /dev/null +++ b/libs/types/imagebuilder/models/ImageExportCondition.ts @@ -0,0 +1,13 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ConditionBase } from '../../models/ConditionBase'; +import type { ImageExportConditionType } from './ImageExportConditionType'; +/** + * Condition for ImageExport resources. + */ +export type ImageExportCondition = (ConditionBase & { + type: ImageExportConditionType; +}); + diff --git a/libs/types/imagebuilder/models/ImageExportConditionReason.ts b/libs/types/imagebuilder/models/ImageExportConditionReason.ts new file mode 100644 index 00000000..75535081 --- /dev/null +++ b/libs/types/imagebuilder/models/ImageExportConditionReason.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Reason for the ImageExport Ready condition. + */ +export enum ImageExportConditionReason { + ImageExportConditionReasonPending = 'Pending', + ImageExportConditionReasonConverting = 'Converting', + ImageExportConditionReasonPushing = 'Pushing', + ImageExportConditionReasonCompleted = 'Completed', + ImageExportConditionReasonFailed = 'Failed', +} diff --git a/libs/types/imagebuilder/models/ImageExportConditionType.ts b/libs/types/imagebuilder/models/ImageExportConditionType.ts new file mode 100644 index 00000000..1048da8d --- /dev/null +++ b/libs/types/imagebuilder/models/ImageExportConditionType.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Type of ImageExport condition. + */ +export enum ImageExportConditionType { + ImageExportConditionTypeReady = 'Ready', +} diff --git a/libs/types/imagebuilder/models/ImageExportFormatPhase.ts b/libs/types/imagebuilder/models/ImageExportFormatPhase.ts new file mode 100644 index 00000000..adfb3240 --- /dev/null +++ b/libs/types/imagebuilder/models/ImageExportFormatPhase.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * The phase of a single format conversion. + */ +export enum ImageExportFormatPhase { + ImageExportFormatPhaseQueued = 'queued', + ImageExportFormatPhaseConverting = 'converting', + ImageExportFormatPhasePushing = 'pushing', + ImageExportFormatPhaseComplete = 'complete', + ImageExportFormatPhaseFailed = 'failed', +} diff --git a/libs/types/imagebuilder/models/ImageExportList.ts b/libs/types/imagebuilder/models/ImageExportList.ts new file mode 100644 index 00000000..cae50926 --- /dev/null +++ b/libs/types/imagebuilder/models/ImageExportList.ts @@ -0,0 +1,25 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ListMeta } from '../../models/ListMeta'; +import type { ImageExport } from './ImageExport'; +/** + * ImageExportList is a list of ImageExport resources. + */ +export type ImageExportList = { + /** + * 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. + */ + apiVersion: string; + /** + * 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 ImageExport resources. + */ + items: Array; +}; + diff --git a/libs/types/imagebuilder/models/ImageExportSource.ts b/libs/types/imagebuilder/models/ImageExportSource.ts new file mode 100644 index 00000000..a9d3efd7 --- /dev/null +++ b/libs/types/imagebuilder/models/ImageExportSource.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ImageBuildRefSource } from './ImageBuildRefSource'; +/** + * ImageExportSource specifies the source image for the export. + */ +export type ImageExportSource = ImageBuildRefSource; + diff --git a/libs/types/imagebuilder/models/ImageExportSourceType.ts b/libs/types/imagebuilder/models/ImageExportSourceType.ts new file mode 100644 index 00000000..830e3e9f --- /dev/null +++ b/libs/types/imagebuilder/models/ImageExportSourceType.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * The type of source for the image export. + */ +export enum ImageExportSourceType { + ImageExportSourceTypeImageBuild = 'imageBuild', +} diff --git a/libs/types/imagebuilder/models/ImageExportSpec.ts b/libs/types/imagebuilder/models/ImageExportSpec.ts new file mode 100644 index 00000000..0ff581a5 --- /dev/null +++ b/libs/types/imagebuilder/models/ImageExportSpec.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ExportFormatType } from './ExportFormatType'; +import type { ImageExportSource } from './ImageExportSource'; +/** + * ImageExportSpec describes the specification for an image export. + */ +export type ImageExportSpec = { + source: ImageExportSource; + format: ExportFormatType; +}; + diff --git a/libs/types/imagebuilder/models/ImageExportStatus.ts b/libs/types/imagebuilder/models/ImageExportStatus.ts new file mode 100644 index 00000000..f5fef87d --- /dev/null +++ b/libs/types/imagebuilder/models/ImageExportStatus.ts @@ -0,0 +1,23 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ImageExportCondition } from './ImageExportCondition'; +/** + * ImageExportStatus represents the current status of an ImageExport. + */ +export type ImageExportStatus = { + /** + * Current conditions of the ImageExport. + */ + conditions?: Array; + /** + * The digest of the exported image manifest for this format. + */ + manifestDigest?: string; + /** + * The last time the export was seen (heartbeat). + */ + lastSeen?: string; +}; + diff --git a/libs/types/imagebuilder/models/LateBinding.ts b/libs/types/imagebuilder/models/LateBinding.ts new file mode 100644 index 00000000..813ba5c6 --- /dev/null +++ b/libs/types/imagebuilder/models/LateBinding.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Late binding configuration - device binds at first boot. + */ +export type LateBinding = { + /** + * The type of binding. + */ + type: 'late'; +}; + diff --git a/libs/types/imagebuilder/models/ResourceKind.ts b/libs/types/imagebuilder/models/ResourceKind.ts new file mode 100644 index 00000000..0f2e73c5 --- /dev/null +++ b/libs/types/imagebuilder/models/ResourceKind.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Resource types exposed via the ImageBuilder API. + */ +export enum ResourceKind { + IMAGE_BUILD = 'ImageBuild', + IMAGE_EXPORT = 'ImageExport', +} diff --git a/libs/types/index.ts b/libs/types/index.ts index aceb1b4b..8ab150f6 100644 --- a/libs/types/index.ts +++ b/libs/types/index.ts @@ -36,6 +36,7 @@ export type { CertificateSigningRequestList } from './models/CertificateSigningR export type { CertificateSigningRequestSpec } from './models/CertificateSigningRequestSpec'; export type { CertificateSigningRequestStatus } from './models/CertificateSigningRequestStatus'; export type { Condition } from './models/Condition'; +export type { ConditionBase } from './models/ConditionBase'; export { ConditionStatus } from './models/ConditionStatus'; export { ConditionType } from './models/ConditionType'; export type { ConfigProviderSpec } from './models/ConfigProviderSpec'; diff --git a/libs/types/models/Condition.ts b/libs/types/models/Condition.ts index fa6eb2f9..4af96885 100644 --- a/libs/types/models/Condition.ts +++ b/libs/types/models/Condition.ts @@ -2,29 +2,12 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -import type { ConditionStatus } from './ConditionStatus'; +import type { ConditionBase } from './ConditionBase'; import type { ConditionType } from './ConditionType'; /** * Condition contains details for one aspect of the current state of this API Resource. */ -export type Condition = { +export type Condition = (ConditionBase & { type: ConditionType; - status: ConditionStatus; - /** - * The .metadata.generation that the condition was set based upon. - */ - observedGeneration?: number; - /** - * The last time the condition transitioned from one status to another. - */ - lastTransitionTime: string; - /** - * Human readable message indicating details about last transition. - */ - message: string; - /** - * A (brief) reason for the condition's last transition. - */ - reason: string; -}; +}); diff --git a/libs/types/models/ConditionBase.ts b/libs/types/models/ConditionBase.ts new file mode 100644 index 00000000..be2b8852 --- /dev/null +++ b/libs/types/models/ConditionBase.ts @@ -0,0 +1,28 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ConditionStatus } from './ConditionStatus'; +/** + * Base condition structure following Kubernetes API conventions. Use with allOf to add a specific type enum. + */ +export type ConditionBase = { + status: ConditionStatus; + /** + * The .metadata.generation that the condition was set based upon. + */ + observedGeneration?: number; + /** + * The last time the condition transitioned from one status to another. + */ + lastTransitionTime: string; + /** + * Human readable message indicating details about last transition. + */ + message: string; + /** + * A (brief) reason for the condition's last transition. + */ + reason: string; +}; + diff --git a/libs/types/models/KubernetesSecretProviderSpec.ts b/libs/types/models/KubernetesSecretProviderSpec.ts index d73b0f39..cff6d2ed 100644 --- a/libs/types/models/KubernetesSecretProviderSpec.ts +++ b/libs/types/models/KubernetesSecretProviderSpec.ts @@ -23,6 +23,14 @@ export type KubernetesSecretProviderSpec = { * Path in the device's file system at which the secret should be mounted. */ mountPath: string; + /** + * The file's owner, specified either as a name or numeric ID. Defaults to "root". + */ + user?: string; + /** + * The file's group, specified either as a name or numeric ID. Defaults to "root". + */ + group?: string; }; }; diff --git a/libs/types/package.json b/libs/types/package.json index e46a8abc..9151c955 100644 --- a/libs/types/package.json +++ b/libs/types/package.json @@ -11,16 +11,22 @@ "source": "./index.ts", "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./imagebuilder": { + "source": "./imagebuilder/index.ts", + "types": "./dist/imagebuilder/index.d.ts", + "default": "./dist/imagebuilder/index.js" } }, "files": [ "dist", - "index.ts" + "index.ts", + "imagebuilder" ], "scripts": { "prebuild": "tsc --noEmit && rimraf dist", "build": "tsc --build", - "gen-types": "rimraf ./models && node ./scripts/openapi-typescript.js && rsync -a --remove-source-files tmp-types/ ./ && npm run build", + "gen-types": "node ./scripts/openapi-typescript.js && npm run build", "ts-node": "ts-node -O '{\"module\":\"commonjs\"}'" }, "devDependencies": { diff --git a/libs/types/scripts/openapi-typescript.js b/libs/types/scripts/openapi-typescript.js index 103941e5..d4a6951c 100644 --- a/libs/types/scripts/openapi-typescript.js +++ b/libs/types/scripts/openapi-typescript.js @@ -1,8 +1,17 @@ #!/usr/bin/env node +const fs = require('fs'); +const fsPromises = require('fs/promises'); const path = require('path'); const OpenAPI = require('openapi-typescript-codegen'); const YAML = require('js-yaml'); +const { rimraf, copyDir, fixImagebuilderCoreReferences } = require('./openapi-utils'); + +const CORE_API = 'core'; +const IMAGEBUILDER_API = 'imagebuilder'; + +const getSwaggerUrl = (api) => `https://raw.githubusercontent.com/flightctl/flightctl/main/api/${api}/v1beta1`; + const processJsonAPI = (jsonString) => { const json = YAML.load(jsonString); if (json.components) { @@ -16,13 +25,33 @@ const processJsonAPI = (jsonString) => { return json; }; -async function main() { - const swaggerUrl = 'https://raw.githubusercontent.com/flightctl/flightctl/main/api/v1beta1/openapi.yaml'; - const output = path.resolve(__dirname, '../tmp-types'); +// Generate types from OpenAPI spec +async function generateTypes(mode) { + const config = { + [CORE_API]: { + swaggerUrl: `${getSwaggerUrl(CORE_API)}/openapi.yaml`, + output: path.resolve(__dirname, '../tmp-types'), + finalDir: path.resolve(__dirname, '../models'), + }, + [IMAGEBUILDER_API]: { + swaggerUrl: `${getSwaggerUrl(IMAGEBUILDER_API)}/openapi.yaml`, + output: path.resolve(__dirname, '../tmp-imagebuilder-types'), + finalDir: path.resolve(__dirname, '../imagebuilder/models'), + }, + }; + + if (!config[mode]) { + throw new Error(`Unknown mode: ${mode}. Use 'core' or 'imagebuilder'`); + } + + const { swaggerUrl, output, finalDir } = config[mode]; + + console.log(`Fetching ${mode} OpenAPI spec from ${swaggerUrl}...`); const response = await fetch(swaggerUrl); const data = await response.text(); - OpenAPI.generate({ + console.log(`Generating ${mode} types...`); + await OpenAPI.generate({ input: processJsonAPI(data), output, exportCore: false, @@ -31,6 +60,57 @@ async function main() { exportSchemas: false, indent: '2', }); + + if (mode === CORE_API) { + // Copy the flightctl API types to their final location + await rimraf(finalDir); + 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 + 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); + + // 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 }); + } + await fsPromises.copyFile(indexPath, path.join(imagebuilderDir, 'index.ts')); + } + await rimraf(output); + } +} + +async function main() { + try { + const rootDir = path.resolve(__dirname, '..'); + + // Clean up existing directories + console.log('Cleaning up existing directories...'); + await Promise.all([ + rimraf(path.join(rootDir, 'models')), + rimraf(path.join(rootDir, 'imagebuilder')), + rimraf(path.join(rootDir, 'tmp-types')), + rimraf(path.join(rootDir, 'tmp-imagebuilder-types')), + ]); + + console.log('Generating types...'); + await generateTypes(CORE_API); + await generateTypes(IMAGEBUILDER_API); + + console.log('✅ Type generation complete!'); + } catch (error) { + console.error('❌ Error generating types:', error); + process.exit(1); + } } void main(); diff --git a/libs/types/scripts/openapi-utils.js b/libs/types/scripts/openapi-utils.js new file mode 100755 index 00000000..2de98485 --- /dev/null +++ b/libs/types/scripts/openapi-utils.js @@ -0,0 +1,116 @@ +#!/usr/bin/env node +const fs = require('fs'); +const fsPromises = require('fs/promises'); +const path = require('path'); + +// Recursively remove directory +async function rimraf(dir) { + if (!fs.existsSync(dir)) { + return; + } + const entries = await fsPromises.readdir(dir, { withFileTypes: true }); + await Promise.all( + entries.map(async (entry) => { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + await rimraf(fullPath); + } else { + await fsPromises.unlink(fullPath); + } + }), + ); + await fsPromises.rmdir(dir); +} + +// Recursively copy directory +async function copyDir(src, dest) { + if (!fs.existsSync(src)) { + return; + } + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, { recursive: true }); + } + const entries = await fsPromises.readdir(src, { withFileTypes: true }); + await Promise.all( + entries.map(async (entry) => { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + if (entry.isDirectory()) { + await copyDir(srcPath, destPath); + } else { + const content = await fsPromises.readFile(srcPath); + await fsPromises.writeFile(destPath, content); + await fsPromises.unlink(srcPath); + } + }), + ); +} + +// Recursively find all TypeScript files +function findTsFiles(dir) { + const files = []; + if (!fs.existsSync(dir)) { + return files; + } + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...findTsFiles(fullPath)); + } else if (entry.isFile() && entry.name.endsWith('.ts')) { + files.push(fullPath); + } + } + + 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) { + const files = findTsFiles(modelsDir); + + 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'", + ); + + // 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'); + + // Only write if content changed + if (content !== originalContent) { + await fsPromises.writeFile(filePath, content, 'utf8'); + } + }), + ); +} + +module.exports = { + rimraf, + copyDir, + fixImagebuilderCoreReferences, +}; diff --git a/libs/ui-components/src/components/DetailsPage/DetailsPage.tsx b/libs/ui-components/src/components/DetailsPage/DetailsPage.tsx index 4a1f0168..256f08da 100644 --- a/libs/ui-components/src/components/DetailsPage/DetailsPage.tsx +++ b/libs/ui-components/src/components/DetailsPage/DetailsPage.tsx @@ -26,7 +26,13 @@ export type DetailsPageProps = { children: React.ReactNode; error: unknown; loading: boolean; - resourceType: 'Fleets' | 'Devices' | 'Repositories' | 'Enrollment requests' | 'Authentication providers'; + resourceType: + | 'Fleets' + | 'Devices' + | 'Repositories' + | 'Enrollment requests' + | 'Authentication providers' + | 'Image builds'; resourceTypeLabel: string; resourceLink: Route; actions?: React.ReactNode; diff --git a/libs/ui-components/src/components/Device/DeviceDetails/DeviceFleet.tsx b/libs/ui-components/src/components/Device/DeviceDetails/DeviceFleet.tsx index ad135f5f..77b8aca9 100644 --- a/libs/ui-components/src/components/Device/DeviceDetails/DeviceFleet.tsx +++ b/libs/ui-components/src/components/Device/DeviceDetails/DeviceFleet.tsx @@ -3,7 +3,8 @@ import { Button, Icon, List, ListItem, Popover } from '@patternfly/react-core'; import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons/dist/js/icons/outlined-question-circle-icon'; import { ExclamationTriangleIcon } from '@patternfly/react-icons/dist/js/icons/exclamation-triangle-icon'; -import { Condition, ConditionType, Device } from '@flightctl/types'; +import { ConditionType, Device } from '@flightctl/types'; +import { GenericCondition } from '../../../types/extraTypes'; import { getDeviceFleet } from '../../../utils/devices'; import { getCondition } from '../../../utils/api'; import { useTranslation } from '../../../hooks/useTranslation'; @@ -28,7 +29,7 @@ const FleetLessDevice = () => { ); }; -const MultipleDeviceOwners = ({ multipleOwnersCondition }: { multipleOwnersCondition: Condition }) => { +const MultipleDeviceOwners = ({ multipleOwnersCondition }: { multipleOwnersCondition: GenericCondition }) => { const { t } = useTranslation(); const owners: string[] = (multipleOwnersCondition.message || '').split(','); 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 f24feeef..c5b06a5f 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationTemplates.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationTemplates.tsx @@ -1,6 +1,16 @@ import * as React from 'react'; -import { Button, FormGroup, FormSection, Grid, Split, SplitItem, Stack, StackItem } from '@patternfly/react-core'; +import { + Button, + Content, + FormGroup, + FormSection, + Grid, + Split, + SplitItem, + Stack, + StackItem, +} from '@patternfly/react-core'; import { FieldArray, useField, useFormikContext } from 'formik'; import { MinusCircleIcon } from '@patternfly/react-icons/dist/js/icons/minus-circle-icon'; import { PlusCircleIcon } from '@patternfly/react-icons/dist/js/icons/plus-circle-icon'; @@ -275,11 +285,11 @@ const ApplicationTemplates = ({ isReadOnly }: { isReadOnly?: boolean }) => { content={t('Define the application workloads that shall run on the device.')} > <> - + {t( 'Configure containerized applications and services that will run on your fleet devices. You can deploy single containers, Quadlet applications for advanced container orchestration or inline applications with custom files.', )} - + {({ push, remove }) => ( <> diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ConfigWithRepositoryTemplateForm.tsx b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ConfigWithRepositoryTemplateForm.tsx index 1bfda1a1..72a1bb35 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ConfigWithRepositoryTemplateForm.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ConfigWithRepositoryTemplateForm.tsx @@ -1,18 +1,14 @@ import * as React from 'react'; import { useFormikContext } from 'formik'; -import { Button, FormGroup, Icon, MenuFooter } from '@patternfly/react-core'; -import { PlusCircleIcon } from '@patternfly/react-icons/dist/js/icons/plus-circle-icon'; -import { ExclamationCircleIcon } from '@patternfly/react-icons/dist/js/icons/exclamation-circle-icon'; -import { TFunction, Trans } from 'react-i18next'; +import { FormGroup } from '@patternfly/react-core'; +import { Trans } from 'react-i18next'; import { GenericRepoSpec, HttpRepoSpec, RepoSpecType, Repository } from '@flightctl/types'; import { DeviceSpecConfigFormValues, GitConfigTemplate, HttpConfigTemplate } from '../../../../types/deviceSpec'; import { useTranslation } from '../../../../hooks/useTranslation'; import TextField from '../../../form/TextField'; -import FormSelect from '../../../form/FormSelect'; -import CreateRepositoryModal from '../../../modals/CreateRepositoryModal/CreateRepositoryModal'; +import RepositorySelect from '../../../form/RepositorySelect'; import { FormGroupWithHelperText } from '../../../common/WithHelperText'; -import { getRepoUrlOrRegistry } from '../../../Repository/CreateRepository/utils'; type ConfigWithRepositoryTemplateFormProps = { repoType: RepoSpecType; @@ -23,40 +19,6 @@ type ConfigWithRepositoryTemplateFormProps = { canCreateRepo: boolean; }; -const getRepositoryItems = ( - t: TFunction, - repositories: Repository[], - repoType: RepoSpecType, - forcedRepoName?: string, -) => { - const repositoryItems = repositories.reduce((acc, curr) => { - if (curr.spec.type === repoType) { - const description = getRepoUrlOrRegistry(curr.spec); - const repoName = curr.metadata.name || ''; - acc[repoName] = { - label: repoName, - description, - }; - } - return acc; - }, {}); - // If there's a broken reference to a repository, we must add an item so the name shows in the dropdown - if (forcedRepoName && !repositoryItems[forcedRepoName]) { - repositoryItems[forcedRepoName] = { - label: forcedRepoName, - description: ( - <> - - - {' '} - {t('Missing repository')} - - ), - }; - } - return repositoryItems; -}; - const GitConfigForm = ({ template, index, @@ -161,51 +123,23 @@ const ConfigWithRepositoryTemplateForm = ({ isReadOnly, canCreateRepo, }: ConfigWithRepositoryTemplateFormProps) => { - const { t } = useTranslation(); - const { values, setFieldValue } = useFormikContext(); - const [createRepoModalOpen, setCreateRepoModalOpen] = React.useState(false); + const { values } = useFormikContext(); const ct = values.configTemplates[index] as HttpConfigTemplate | GitConfigTemplate; - const selectedRepoName = ct.repository; - - const repositoryItems = isReadOnly - ? { - [selectedRepoName]: { - label: selectedRepoName, - description: '', - }, - } - : getRepositoryItems(t, repositories, repoType, selectedRepoName); - - const selectedRepo = repositories.find((repo) => repo.metadata.name === selectedRepoName); + const selectedRepo = repositories.find((repo) => repo.metadata.name === ct.repository); const repoSpec = selectedRepo?.spec as GenericRepoSpec | HttpRepoSpec | undefined; + return ( <> - - - {canCreateRepo && ( - - - - )} - - + {repoType === RepoSpecType.GIT && ( )} @@ -217,20 +151,6 @@ const ConfigWithRepositoryTemplateForm = ({ isReadOnly={isReadOnly} /> )} - {createRepoModalOpen && ( - setCreateRepoModalOpen(false)} - onSuccess={(repo) => { - setCreateRepoModalOpen(false); - repoRefetch(); - void setFieldValue(`configTemplates[${index}].repository`, repo.metadata.name, true); - }} - options={{ - canUseResourceSyncs: false, - allowedRepoTypes: [repoType], - }} - /> - )} ); }; diff --git a/libs/ui-components/src/components/Events/EventsCard.tsx b/libs/ui-components/src/components/Events/EventsCard.tsx index e6d55384..f6e3caf3 100644 --- a/libs/ui-components/src/components/Events/EventsCard.tsx +++ b/libs/ui-components/src/components/Events/EventsCard.tsx @@ -19,16 +19,17 @@ import { } from '@patternfly/react-core'; import SyncAltIcon from '@patternfly/react-icons/dist/js/icons/sync-alt-icon'; -import { Event, ResourceKind } from '@flightctl/types'; +import { Event } from '@flightctl/types'; import { useTranslation } from '../../hooks/useTranslation'; import { timeSinceEpochText } from '../../utils/dates'; +import { FlightctlKind } from '../../types/extraTypes'; import useEvents, { DisplayEvent, SelectableEventType } from './useEvents'; import EventItem from './EventItem'; import './EventsCard.css'; type EventListProps = { - kind: ResourceKind; + kind: FlightctlKind; objId: string; type?: Event.type; }; diff --git a/libs/ui-components/src/components/Fleet/CreateFleet/CreateFleetWizard.tsx b/libs/ui-components/src/components/Fleet/CreateFleet/CreateFleetWizard.tsx index 35a5d034..017c57a6 100644 --- a/libs/ui-components/src/components/Fleet/CreateFleet/CreateFleetWizard.tsx +++ b/libs/ui-components/src/components/Fleet/CreateFleet/CreateFleetWizard.tsx @@ -56,16 +56,9 @@ const getValidStepIds = (formikErrors: FormikErrors): string[] return validStepIds; }; -const isDisabledStep = (stepId: string | undefined, validStepIds: string[]) => { - if (!stepId) { - return true; - } - - const stepIdx = orderedIds.findIndex((stepOrderId) => stepOrderId === stepId); - - return orderedIds.some((orderedId, orderedStepIdx) => { - return orderedStepIdx < stepIdx && !validStepIds.includes(orderedId); - }); +const isDisabledStep = (stepId: string, validStepIds: string[]) => { + const validIndex = validStepIds.indexOf(stepId); + return validIndex === -1 || validIndex !== orderedIds.indexOf(stepId); }; const CreateFleetWizard = () => { diff --git a/libs/ui-components/src/components/Fleet/FleetRow.tsx b/libs/ui-components/src/components/Fleet/FleetRow.tsx index 699b3974..e12a2b0f 100644 --- a/libs/ui-components/src/components/Fleet/FleetRow.tsx +++ b/libs/ui-components/src/components/Fleet/FleetRow.tsx @@ -16,7 +16,7 @@ type FleetRowProps = { rowIndex: number; onRowSelect: (fleet: Fleet) => OnSelect; isRowSelected: (fleet: Fleet) => boolean; - onDeleteClick: () => void; + onDeleteClick: VoidFunction; canDelete: boolean; canEdit: boolean; }; diff --git a/libs/ui-components/src/components/Fleet/ImportFleetWizard/steps/RepositoryStep.tsx b/libs/ui-components/src/components/Fleet/ImportFleetWizard/steps/RepositoryStep.tsx index 65cdf505..223602b1 100644 --- a/libs/ui-components/src/components/Fleet/ImportFleetWizard/steps/RepositoryStep.tsx +++ b/libs/ui-components/src/components/Fleet/ImportFleetWizard/steps/RepositoryStep.tsx @@ -8,7 +8,7 @@ import { ImportFleetFormValues } from '../types'; import { RepositoryForm } from '../../../Repository/CreateRepository/CreateRepositoryForm'; import RepositoryStatus from '../../../Status/RepositoryStatus'; -import { getLastTransitionTimeText, getRepositorySyncStatus } from '../../../../utils/status/repository'; +import { getLastTransitionTimeText } from '../../../../utils/status/repository'; import { useTranslation } from '../../../../hooks/useTranslation'; import FormSelect from '../../../form/FormSelect'; import FlightCtlForm from '../../../form/FlightCtlForm'; @@ -62,7 +62,7 @@ const ExistingRepoForm = ({ repositories }: { repositories: Repository[] }) => { {repoSpec?.url || '-'} - + {getLastTransitionTimeText(currentRepo, t).text} diff --git a/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/CreateImageBuildWizard.tsx b/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/CreateImageBuildWizard.tsx new file mode 100644 index 00000000..37efc017 --- /dev/null +++ b/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/CreateImageBuildWizard.tsx @@ -0,0 +1,234 @@ +import * as React from 'react'; +import { + Alert, + Breadcrumb, + BreadcrumbItem, + Bullseye, + PageSection, + Spinner, + Title, + Wizard, + WizardStep, + WizardStepType, +} from '@patternfly/react-core'; +import { Formik, FormikErrors } from 'formik'; + +import { ExportFormatType, ImageBuild } from '@flightctl/types/imagebuilder'; +import { RESOURCE, VERB } from '../../../types/rbac'; +import { useTranslation } from '../../../hooks/useTranslation'; +import { Link, ROUTE, useNavigate } from '../../../hooks/useNavigate'; + +import ReviewStep, { reviewStepId } from './steps/ReviewStep'; +import { getErrorMessage } from '../../../utils/error'; +import { getImageBuildResource, getImageExportResources, getInitialValues, getValidationSchema } from './utils'; +import { isPromiseRejected } from '../../../types/typeUtils'; +import { ImageBuildFormValues, ImageBuildWizardError } from './types'; +import LeaveFormConfirmation from '../../common/LeaveFormConfirmation'; +import ErrorBoundary from '../../common/ErrorBoundary'; +import PageWithPermissions from '../../common/PageWithPermissions'; +import { usePermissionsContext } from '../../common/PermissionsContext'; +import SourceImageStep, { isSourceImageStepValid, sourceImageStepId } from './steps/SourceImageStep'; +import OutputImageStep, { isOutputImageStepValid, outputImageStepId } from './steps/OutputImageStep'; +import RegistrationStep, { isRegistrationStepValid, registrationStepId } from './steps/RegistrationStep'; +import CreateImageBuildWizardFooter from './CreateImageBuildWizardFooter'; +import { useFetch } from '../../../hooks/useFetch'; +import { useEditImageBuild } from './useEditImageBuild'; +import { OciRegistriesContextProvider, useOciRegistriesContext } from '../OciRegistriesContext'; +import { hasImageBuildFailed } from '../../../utils/imageBuilds'; + +const orderedIds = [sourceImageStepId, outputImageStepId, registrationStepId, reviewStepId]; + +const getValidStepIds = (formikErrors: FormikErrors): string[] => { + const validStepIds: string[] = []; + if (isSourceImageStepValid(formikErrors)) { + validStepIds.push(sourceImageStepId); + } + if (isOutputImageStepValid(formikErrors)) { + validStepIds.push(outputImageStepId); + } + if (isRegistrationStepValid(formikErrors)) { + validStepIds.push(registrationStepId); + } + // Review step is always valid. We disable it if some of the previous steps are invalid + if (validStepIds.length === orderedIds.length - 1) { + validStepIds.push(reviewStepId); + } + return validStepIds; +}; + +const isDisabledStep = (stepId: string, validStepIds: string[]) => { + const stepIdx = orderedIds.findIndex((stepOrderId) => stepOrderId === stepId); + return orderedIds.some((orderedId, orderedStepIdx) => { + return orderedStepIdx < stepIdx && !validStepIds.includes(orderedId); + }); +}; + +const CreateImageBuildWizard = () => { + const { t } = useTranslation(); + const { post } = useFetch(); + const navigate = useNavigate(); + const [error, setError] = React.useState(); + const [currentStep, setCurrentStep] = React.useState(); + const [imageBuildId, imageBuild, imageBuildLoading, editError] = useEditImageBuild(); + const { isLoading: registriesLoading, error: registriesError } = useOciRegistriesContext(); + + const isEdit = !!imageBuildId; + const hasFailed = imageBuild ? hasImageBuildFailed(imageBuild) : false; + + let title: string; + if (isEdit) { + title = hasFailed ? t('Retry image build') : t('Duplicate image build'); + } else { + title = t('Build new image'); + } + + return ( + <> + + + + {t('Image builds')} + + {imageBuildId && ( + + {imageBuildId} + + )} + {title} + + + + + {title} + + + + + {registriesLoading || imageBuildLoading ? ( + + + + ) : registriesError || editError ? ( + + {getErrorMessage(registriesError || editError)} + + ) : ( + + initialValues={getInitialValues(imageBuild)} + validationSchema={getValidationSchema(t)} + validateOnMount + onSubmit={async (values) => { + setError(undefined); + let buildName: string; + + try { + const imageBuild = getImageBuildResource(values); + buildName = imageBuild.metadata.name as string; + const createdBuild = await post('imagebuilds', imageBuild); + if (createdBuild.metadata.name !== buildName) { + throw new Error(t('Image build was created but has a different name')); + } + } catch (err) { + // Build creation failed + setError({ type: 'build', error: getErrorMessage(err) }); + return; + } + + if (values.exportFormats.length > 0) { + const imageExports = getImageExportResources(values, buildName); + const exportResults = await Promise.allSettled( + imageExports.map((imageExport) => post('imageexports', imageExport)), + ); + + const exportErrors = exportResults.reduce( + (acc, result, index) => { + if (isPromiseRejected(result)) { + acc.push({ + format: values.exportFormats[index], + error: result.reason, + }); + } + return acc; + }, + [] as Array<{ format: ExportFormatType; error: unknown }>, + ); + + if (exportErrors.length > 0) { + setError({ + type: 'export', + buildName, + errors: exportErrors, + }); + return; + } + } + + navigate(ROUTE.IMAGE_BUILDS); + }} + > + {({ errors: formikErrors }) => { + const validStepIds = getValidStepIds(formikErrors); + + return ( + <> + + } + onStepChange={(_, step) => { + if (error) { + setError(undefined); + } + setCurrentStep(step); + }} + > + + {(!currentStep || currentStep?.id === sourceImageStepId) && } + + + {currentStep?.id === outputImageStepId && } + + + {currentStep?.id === registrationStepId && } + + + {currentStep?.id === reviewStepId && } + + + + ); + }} + + )} + + + + ); +}; + +const createImageBuildWizardPermissions = [{ kind: RESOURCE.IMAGE_BUILD, verb: VERB.CREATE }]; + +const CreateImageBuildWizardWithPermissions = () => { + const { checkPermissions, loading } = usePermissionsContext(); + const [createAllowed] = checkPermissions(createImageBuildWizardPermissions); + return ( + + + + + + ); +}; + +export default CreateImageBuildWizardWithPermissions; diff --git a/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/CreateImageBuildWizardFooter.tsx b/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/CreateImageBuildWizardFooter.tsx new file mode 100644 index 00000000..6655d96e --- /dev/null +++ b/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/CreateImageBuildWizardFooter.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { FormikErrors } from 'formik'; + +import { ImageBuildFormValues } from './types'; +import { useTranslation } from '../../../hooks/useTranslation'; +import FlightCtlWizardFooter from '../../common/FlightCtlWizardFooter'; +import { reviewStepId } from './steps/ReviewStep'; +import { isSourceImageStepValid, sourceImageStepId } from './steps/SourceImageStep'; +import { isOutputImageStepValid, outputImageStepId } from './steps/OutputImageStep'; +import { isRegistrationStepValid, registrationStepId } from './steps/RegistrationStep'; + +const validateStep = (activeStepId: string, errors: FormikErrors): boolean => { + if (activeStepId === sourceImageStepId) { + return isSourceImageStepValid(errors); + } + if (activeStepId === outputImageStepId) { + return isOutputImageStepValid(errors); + } + if (activeStepId === registrationStepId) { + return isRegistrationStepValid(errors); + } + return true; +}; + +const CreateImageBuildWizardFooter = () => { + const { t } = useTranslation(); + + return ( + + firstStepId={sourceImageStepId} + submitStepId={reviewStepId} + validateStep={validateStep} + saveButtonText={t('Build image')} + /> + ); +}; + +export default CreateImageBuildWizardFooter; diff --git a/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/steps/OutputImageStep.tsx b/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/steps/OutputImageStep.tsx new file mode 100644 index 00000000..d2a19cec --- /dev/null +++ b/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/steps/OutputImageStep.tsx @@ -0,0 +1,133 @@ +import * as React from 'react'; +import { Alert, Content, FormGroup, FormSection, Gallery, Grid } from '@patternfly/react-core'; +import { FormikErrors, useFormikContext } from 'formik'; + +import { OciRepoSpec, RepoSpecType, Repository } from '@flightctl/types'; +import { ExportFormatType } from '@flightctl/types/imagebuilder'; +import { ImageBuildFormValues } from '../types'; +import { useTranslation } from '../../../../hooks/useTranslation'; +import FlightCtlForm from '../../../form/FlightCtlForm'; +import TextField from '../../../form/TextField'; +import RepositorySelect from '../../../form/RepositorySelect'; +import { usePermissionsContext } from '../../../common/PermissionsContext'; +import { RESOURCE, VERB } from '../../../../types/rbac'; +import { SelectImageBuildExportCard } from '../../ImageExportCards'; +import { getImageReference } from '../../../../utils/imageBuilds'; +import { isOciRepoSpec } from '../../../Repository/CreateRepository/utils'; +import ImageUrlCard from '../../ImageUrlCard'; +import { useOciRegistriesContext } from '../../OciRegistriesContext'; + +export const outputImageStepId = 'output-image'; + +export const isOutputImageStepValid = (errors: FormikErrors) => { + const { destination } = errors; + if (!destination) { + return true; + } + return !destination.repository && !destination.imageName && !destination.imageTag; +}; + +const OutputImageStep = () => { + const { t } = useTranslation(); + const { values, setFieldValue } = useFormikContext(); + const { checkPermissions } = usePermissionsContext(); + const { ociRegistries, refetch } = useOciRegistriesContext(); + const [canCreateRepo] = checkPermissions([{ kind: RESOURCE.REPOSITORY, verb: VERB.CREATE }]); + + const writableRepoValidation = React.useCallback( + (repo: Repository) => { + if (isOciRepoSpec(repo.spec) && repo.spec.accessMode === OciRepoSpec.accessMode.READ) { + return t('Repository is read-only and cannot be used as the target repository.'); + } + return undefined; + }, + [t], + ); + + const handleFormatToggle = (format: ExportFormatType, isChecked: boolean) => { + const currentFormats = values.exportFormats; + if (isChecked) { + setFieldValue('exportFormats', [...currentFormats, format]); + } else { + setFieldValue( + 'exportFormats', + currentFormats.filter((f) => f !== format), + ); + } + }; + + const imageReference = React.useMemo(() => { + return getImageReference(ociRegistries, values.destination); + }, [ociRegistries, values.destination]); + + return ( + + + + + {t( + 'The agent is automatically included in this image. This ensures your devices are ready to be managed immediately after they are deployed.', + )} + + + + + + + + + + + + {t( + 'Choose formats you need for this image. Each selection will generate a separate, ready-to-use image file.', + )} + + + + + + + + {values.exportFormats.length > 0 && ( + + {t('{{count}} image export tasks will be created.', { count: values.exportFormats.length })} + + )} + + + + ); +}; + +export default OutputImageStep; diff --git a/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/steps/RegistrationStep.tsx b/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/steps/RegistrationStep.tsx new file mode 100644 index 00000000..f0c5cc65 --- /dev/null +++ b/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/steps/RegistrationStep.tsx @@ -0,0 +1,148 @@ +import * as React from 'react'; +import { + Card, + CardBody, + Content, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Divider, + Flex, + FlexItem, + FormSection, + Radio, + Stack, + StackItem, + Title, +} from '@patternfly/react-core'; +import { FormikErrors, useFormikContext } from 'formik'; + +import { BindingType } from '@flightctl/types/imagebuilder'; +import { ImageBuildFormValues } from '../types'; +import { useTranslation } from '../../../../hooks/useTranslation'; +import FlightCtlForm from '../../../form/FlightCtlForm'; +import { CERTIFICATE_VALIDITY_IN_YEARS } from '../../../../constants'; + +export const registrationStepId = 'registration'; + +export const isRegistrationStepValid = (errors: FormikErrors) => !errors.bindingType; + +const RegistrationStep = () => { + const { t } = useTranslation(); + const { values, setFieldValue } = useFormikContext(); + + const isEarlyBindingSelected = values.bindingType === BindingType.BindingTypeEarly; + + const handleEarlyBindingSelect = () => { + if (values.bindingType !== BindingType.BindingTypeEarly) { + setFieldValue('bindingType', BindingType.BindingTypeEarly); + } + }; + + const handleLateBindingSelect = () => { + if (values.bindingType !== BindingType.BindingTypeLate) { + setFieldValue('bindingType', BindingType.BindingTypeLate); + } + }; + + return ( + + + + + + + + + + + + + {t('Early binding')} + + + + + + + {t( + 'This image is automatically secured. Register it within {{ validityPeriod }} years to keep it active.', + { validityPeriod: CERTIFICATE_VALIDITY_IN_YEARS, count: CERTIFICATE_VALIDITY_IN_YEARS }, + )} + + + + {isEarlyBindingSelected && ( + <> + + + + + + + + + {t('Enrollment')} + {t('Auto-create certificate')} + + + {t('Registration')} + + {t('{{ validityPeriod }} years (Standard)', { + validityPeriod: CERTIFICATE_VALIDITY_IN_YEARS, + count: CERTIFICATE_VALIDITY_IN_YEARS, + })} + + + + + + + + )} + + + + + + + + + + + + + + + {t('Late binding')} + + + + + + + {t('No additional user input required (cloud-init and ignition are enabled automatically)')} + + + + + + + + ); +}; + +export default RegistrationStep; diff --git a/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/steps/ReviewStep.tsx b/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/steps/ReviewStep.tsx new file mode 100644 index 00000000..7ee507f5 --- /dev/null +++ b/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/steps/ReviewStep.tsx @@ -0,0 +1,211 @@ +import * as React from 'react'; +import { + Alert, + Card, + CardBody, + CardTitle, + Content, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Flex, + FlexItem, + Icon, + Label, + List, + ListItem, + Stack, + StackItem, +} from '@patternfly/react-core'; +import { useFormikContext } from 'formik'; +import { InfoCircleIcon } from '@patternfly/react-icons/dist/js/icons/info-circle-icon'; + +import { BindingType } from '@flightctl/types/imagebuilder'; +import { useTranslation } from '../../../../hooks/useTranslation'; +import { getErrorMessage } from '../../../../utils/error'; +import FlightCtlDescriptionList from '../../../common/FlightCtlDescriptionList'; +import { ImageBuildFormValues, ImageBuildWizardError } from '../types'; +import { getImageReference } from '../../../../utils/imageBuilds'; +import { getExportFormatLabel } from '../../../../utils/imageBuilds'; +import { CERTIFICATE_VALIDITY_IN_YEARS } from '../../../../constants'; +import { useOciRegistriesContext } from '../../OciRegistriesContext'; +import ImageUrl from '../../ImageUrl'; + +export const reviewStepId = 'review'; + +type ReviewStepProps = { + error?: ImageBuildWizardError; +}; + +const ReviewStep = ({ error }: ReviewStepProps) => { + const { t } = useTranslation(); + const { values } = useFormikContext(); + const { ociRegistries } = useOciRegistriesContext(); + + const srcImageReference = React.useMemo( + () => getImageReference(ociRegistries, values.source), + [ociRegistries, values.source], + ); + + const dstImageReference = React.useMemo( + () => getImageReference(ociRegistries, values.destination), + [ociRegistries, values.destination], + ); + + const isEarlyBinding = values.bindingType === BindingType.BindingTypeEarly; + + return ( + + + + {t('Base image')} + + + + {t('Source repository')} + {values.source.repository} + + + {t('Image name')} + {values.source.imageName} + + + {t('Image tag')} + {values.source.imageTag} + + {srcImageReference && ( + + {t('Image reference URL')} + + + + + )} + + + + + + + {t('Image output')} + + + + {t('Target repository')} + {values.destination.repository} + + + {t('Image output name')} + {values.destination.imageName} + + + {t('Image output tag')} + {values.destination.imageTag} + + {dstImageReference && ( + + {t('Image output reference URL')} + + + + + )} + + {t('Export formats')} + + {values.exportFormats.length > 0 ? ( + <> + {values.exportFormats.map((format) => ( + + ))} + + ) : ( + t('None') + )} + + + + + + + + + {t('Registration')} + + + + {t('Binding type')} + + {isEarlyBinding ? t('Early binding') : t('Late binding')} + + + {isEarlyBinding ? ( + <> + + {t('Enrollment')} + {t('Auto-create certificate')} + + + {t('Registration')} + + {t('{{ validityPeriod }} years (Standard)', { + validityPeriod: CERTIFICATE_VALIDITY_IN_YEARS, + count: CERTIFICATE_VALIDITY_IN_YEARS, + })} + + + + ) : ( + + {t('Registration')} + + + + + + + + {t('Cloud-init and ignition are enabled automatically')} + + + + )} + + + + + {!!error && ( + + {error.type === 'build' ? ( + + {getErrorMessage(error.error)} + + ) : ( + + + {t( + 'The image build "{{buildName}}" was created successfully, however the following export(s) failed:', + { + buildName: error.buildName, + }, + )} + + + + {error.errors.map(({ format, error: exportError }, index) => ( + + {getExportFormatLabel(format)}: {getErrorMessage(exportError)} + + ))} + + + )} + + )} + + ); +}; + +export default ReviewStep; diff --git a/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/steps/SourceImageStep.tsx b/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/steps/SourceImageStep.tsx new file mode 100644 index 00000000..2509fe14 --- /dev/null +++ b/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/steps/SourceImageStep.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import { FormGroup, FormSection, Grid } from '@patternfly/react-core'; +import { FormikErrors, useFormikContext } from 'formik'; + +import { RepoSpecType } from '@flightctl/types'; +import { ImageBuildFormValues } from '../types'; +import { useTranslation } from '../../../../hooks/useTranslation'; +import FlightCtlForm from '../../../form/FlightCtlForm'; +import TextField from '../../../form/TextField'; +import RepositorySelect from '../../../form/RepositorySelect'; +import { usePermissionsContext } from '../../../common/PermissionsContext'; +import { RESOURCE, VERB } from '../../../../types/rbac'; +import { getImageReference } from '../../../../utils/imageBuilds'; +import ImageUrlCard from '../../ImageUrlCard'; +import { useOciRegistriesContext } from '../../OciRegistriesContext'; + +export const sourceImageStepId = 'source-image'; + +export const isSourceImageStepValid = (errors: FormikErrors) => { + const { source } = errors; + if (!source) { + return true; + } + return !source.repository && !source.imageName && !source.imageTag; +}; + +const SourceImageStep = () => { + const { t } = useTranslation(); + const { values } = useFormikContext(); + const { checkPermissions } = usePermissionsContext(); + const [canCreateRepo] = checkPermissions([{ kind: RESOURCE.REPOSITORY, verb: VERB.CREATE }]); + const { ociRegistries, refetch } = useOciRegistriesContext(); + + const imageReference = React.useMemo(() => { + return getImageReference(ociRegistries, values.source); + }, [ociRegistries, values.source]); + + return ( + + + + + + + + + + + + + + + + + ); +}; + +export default SourceImageStep; diff --git a/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/types.ts b/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/types.ts new file mode 100644 index 00000000..ab29222f --- /dev/null +++ b/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/types.ts @@ -0,0 +1,21 @@ +import { BindingType, ImageBuildDestination, ImageBuildSource } from '@flightctl/types/imagebuilder'; +import { ExportFormatType } from '@flightctl/types/imagebuilder'; + +export type ImageBuildFormValues = { + // name is autogenereated by us + source: ImageBuildSource; + destination: ImageBuildDestination; + bindingType: BindingType; + exportFormats: ExportFormatType[]; +}; + +export type ImageBuildWizardError = + | { + type: 'build'; + error: unknown; + } + | { + type: 'export'; + buildName: string; + errors: Array<{ format: ExportFormatType; error: unknown }>; + }; diff --git a/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/useEditImageBuild.ts b/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/useEditImageBuild.ts new file mode 100644 index 00000000..a4a0b086 --- /dev/null +++ b/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/useEditImageBuild.ts @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { ImageBuild } from '@flightctl/types/imagebuilder'; +import { useAppContext } from '../../../hooks/useAppContext'; +import { useFetch } from '../../../hooks/useFetch'; +import { ImageBuildWithExports } from '../../../types/extraTypes'; +import { toImageBuildWithExports } from './utils'; + +export const useEditImageBuild = (): [string | undefined, ImageBuildWithExports | undefined, boolean, unknown] => { + const { + router: { useParams }, + } = useAppContext(); + const { imageBuildId } = useParams<{ imageBuildId: string }>(); + + const { get } = useFetch(); + const [imageBuild, setImageBuild] = React.useState(); + const [isLoading, setIsLoading] = React.useState(!!imageBuildId); + const [error, setError] = React.useState(); + + React.useEffect(() => { + const fetch = async () => { + try { + const imageBuild = await get(`imagebuilds/${imageBuildId}?withExports=true`); + setImageBuild(toImageBuildWithExports(imageBuild)); + } catch (err) { + setError(err); + } finally { + setIsLoading(false); + } + }; + + if (imageBuildId && !imageBuild) { + fetch(); + } + }, [imageBuildId, get, imageBuild]); + + return [imageBuildId, imageBuild, isLoading, error]; +}; diff --git a/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/utils.ts b/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/utils.ts new file mode 100644 index 00000000..fdb250ad --- /dev/null +++ b/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/utils.ts @@ -0,0 +1,185 @@ +import { TFunction } from 'i18next'; +import * as Yup from 'yup'; + +import { + BindingType, + ExportFormatType, + ImageBuild, + ImageBuildDestination, + ImageBuildSource, + ImageExport, + ImageExportConditionReason, + ImageExportConditionType, + ResourceKind, +} from '@flightctl/types/imagebuilder'; +import { API_VERSION } from '../../../constants'; +import { ImageBuildFormValues } from './types'; +import { ImageBuildWithExports } from '../../../types/extraTypes'; + +export const getValidationSchema = (t: TFunction) => { + return Yup.object({ + source: Yup.object({ + repository: Yup.string().required(t('Source repository is required')), + imageName: Yup.string().required(t('Image name is required')), + imageTag: Yup.string().required(t('Image tag is required')), + }).required(t('Source image is required')), + destination: Yup.object({ + repository: Yup.string().required(t('Target repository is required')), + imageName: Yup.string().required(t('Image name is required')), + imageTag: Yup.string().required(t('Image tag is required')), + }).required(t('Target image is required')), + bindingType: Yup.string().required(t('Binding type is required')), + }); +}; + +// Returns an array with one item per format (VMDK, QCOW2, ISO), where each item is either +// undefined or the latest ImageExport for that format. +const getImageExportsByFormat = ( + imageExports?: ImageExport[], +): { imageExports: (ImageExport | undefined)[]; exportsCount: number } => { + const formatMap: Partial> = {}; + + imageExports?.forEach((ie) => { + const format = ie.spec.format; + const existing = formatMap[format]; + + if (!existing) { + formatMap[format] = ie; + } else { + // Compare creation timestamps to keep the most recent + const existingTimestamp = existing.metadata.creationTimestamp || ''; + const currentTimestamp = ie.metadata.creationTimestamp; + + if (existingTimestamp && currentTimestamp) { + const existingTime = new Date(existingTimestamp).getTime(); + const currentTime = new Date(currentTimestamp).getTime(); + if (currentTime > existingTime) { + formatMap[format] = ie; + } + } + } + }); + + return { + imageExports: [ + formatMap[ExportFormatType.ExportFormatTypeVMDK], + formatMap[ExportFormatType.ExportFormatTypeQCOW2], + formatMap[ExportFormatType.ExportFormatTypeISO], + ], + exportsCount: imageExports?.length || 0, + }; +}; + +export const toImageBuildWithExports = (imageBuild: ImageBuild): ImageBuildWithExports => { + const allExports = imageBuild.imageexports || []; + const imageExportsByFormat = getImageExportsByFormat(allExports); + const latestExports = [...imageExportsByFormat.imageExports]; + + // Disable the rule as we want to omit the "imageexports" field + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { imageexports, ...imageBuildWithoutExports } = imageBuild; + return { + ...imageBuildWithoutExports, + imageExports: latestExports, + exportsCount: allExports.length, + }; +}; + +export const getInitialValues = (imageBuild?: ImageBuildWithExports): ImageBuildFormValues => { + if (imageBuild) { + const exportFormats = imageBuild.imageExports + .filter((ie): ie is ImageExport => ie !== undefined) + .map((imageExport) => imageExport.spec.format); + return { + source: imageBuild.spec.source, + destination: imageBuild.spec.destination, + bindingType: imageBuild.spec.binding.type as BindingType, + exportFormats: exportFormats || [], + }; + } + + return { + source: { + repository: '', + imageName: '', + imageTag: '', + }, + destination: { + repository: '', + imageName: '', + imageTag: '', + }, + bindingType: BindingType.BindingTypeEarly, + exportFormats: [], + }; +}; + +// Generates a random 6-digit hex hash +const getHash = () => + Math.floor(Math.random() * 0x1000000) + .toString(16) + .padStart(6, '0'); + +const generateBuildName = () => `imagebuild-${getHash()}`; +const generateExportName = (imageBuildName: string, format: ExportFormatType) => { + return `${imageBuildName}-${format}-${getHash()}`; +}; + +export const getImageBuildResource = (values: ImageBuildFormValues): ImageBuild => { + const name = generateBuildName(); + return { + apiVersion: API_VERSION, + kind: ResourceKind.IMAGE_BUILD, + metadata: { + name, + }, + spec: { + source: values.source, + destination: values.destination, + binding: { + type: values.bindingType, + }, + }, + }; +}; + +export const getImageExportResource = (imageBuildName: string, format: ExportFormatType): ImageExport => { + const exportName = generateExportName(imageBuildName, format); + + return { + apiVersion: API_VERSION, + kind: ResourceKind.IMAGE_EXPORT, + metadata: { + name: exportName, + }, + spec: { + source: { + type: 'imageBuild', + imageBuildRef: imageBuildName, + }, + format, + }, + }; +}; + +export const getImageExportResources = (values: ImageBuildFormValues, imageBuildName: string): ImageExport[] => { + if (!values.exportFormats || values.exportFormats.length === 0) { + return []; + } + + return values.exportFormats.map((format) => getImageExportResource(imageBuildName, format)); +}; + +export const isImageExportFailed = (imageExport: ImageExport): boolean => { + const readyCondition = imageExport.status?.conditions?.find( + (c) => c.type === ImageExportConditionType.ImageExportConditionTypeReady, + ); + return readyCondition?.reason === ImageExportConditionReason.ImageExportConditionReasonFailed; +}; + +export const isImageExportCompleted = (imageExport: ImageExport): boolean => { + const readyCondition = imageExport.status?.conditions?.find( + (c) => c.type === ImageExportConditionType.ImageExportConditionTypeReady, + ); + return readyCondition?.reason === ImageExportConditionReason.ImageExportConditionReasonCompleted; +}; diff --git a/libs/ui-components/src/components/ImageBuilds/DeleteImageBuildModal/DeleteImageBuildModal.tsx b/libs/ui-components/src/components/ImageBuilds/DeleteImageBuildModal/DeleteImageBuildModal.tsx new file mode 100644 index 00000000..d4cb9948 --- /dev/null +++ b/libs/ui-components/src/components/ImageBuilds/DeleteImageBuildModal/DeleteImageBuildModal.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; +import { + Alert, + Button, + Content, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + Stack, + StackItem, +} from '@patternfly/react-core'; + +import { useTranslation } from '../../../hooks/useTranslation'; +import { useFetch } from '../../../hooks/useFetch'; +import { getErrorMessage } from '../../../utils/error'; + +const DeleteImageBuildModal = ({ + imageBuildId, + onClose, +}: { + imageBuildId: string; + onClose: (hasDeleted?: boolean) => void; +}) => { + const { t } = useTranslation(); + const { remove } = useFetch(); + + const [error, setError] = React.useState(); + const [isDeleting, setIsDeleting] = React.useState(); + + return ( + { + onClose(); + }} + variant="small" + > + + + + + {t('This will remove the record of this build and its history.', { count: 1 })} + + + + {t('The actual image files in your storage will not be deleted.')} + + + {error && ( + + + {error} + + + )} + + + + + + + + ); +}; + +export default DeleteImageBuildModal; diff --git a/libs/ui-components/src/components/ImageBuilds/ImageBuildAndExportStatus.tsx b/libs/ui-components/src/components/ImageBuilds/ImageBuildAndExportStatus.tsx new file mode 100644 index 00000000..f3228512 --- /dev/null +++ b/libs/ui-components/src/components/ImageBuilds/ImageBuildAndExportStatus.tsx @@ -0,0 +1,134 @@ +import * as React from 'react'; +import { Stack, StackItem } from '@patternfly/react-core'; +import { TFunction } from 'react-i18next'; + +import { + ImageBuildCondition, + ImageBuildConditionReason, + ImageBuildConditionType, + ImageBuildStatus, + ImageExportCondition, + ImageExportConditionReason, + ImageExportConditionType, + ImageExportStatus, +} from '@flightctl/types/imagebuilder'; +import { useTranslation } from '../../hooks/useTranslation'; +import { StatusLevel } from '../../utils/status/common'; +import { StatusDisplayContent } from '../Status/StatusDisplay'; +import ImageUrl from './ImageUrl'; + +type ImageBuildStatusProps = { + buildStatus?: ImageBuildStatus; +}; + +type ImageExportStatusProps = { + imageStatus?: ImageExportStatus; + imageReference?: string; +}; + +type LevelAndLabel = { + level: StatusLevel; + label: string; +}; + +const getImageBuildStatusInfo = (condition: ImageBuildCondition | undefined, t: TFunction): LevelAndLabel => { + // Without the Ready condition, the build wouldn't be even queued for processing. + if (!condition) { + return { level: 'unknown', label: t('Unknown') }; + } + + switch (condition.reason) { + case ImageBuildConditionReason.ImageBuildConditionReasonPending: + return { level: 'unknown', label: t('Queued') }; + case ImageBuildConditionReason.ImageBuildConditionReasonBuilding: + return { level: 'info', label: t('Building') }; + case ImageBuildConditionReason.ImageBuildConditionReasonPushing: + return { level: 'info', label: t('Pushing') }; + case ImageBuildConditionReason.ImageBuildConditionReasonCompleted: + return { level: 'success', label: t('Complete') }; + case ImageBuildConditionReason.ImageBuildConditionReasonFailed: + return { level: 'danger', label: t('Failed') }; + default: + return { level: 'unknown', label: t('Unknown') }; + } +}; + +const getImageExportStatusInfo = (condition: ImageExportCondition | undefined, t: TFunction): LevelAndLabel => { + const reason = condition?.reason; + switch (reason) { + case ImageExportConditionReason.ImageExportConditionReasonPending: + // ImageExports will have a "pending" state while their associated imageBuild is incomplete. + return { level: 'unknown', label: t('Queued') }; + case ImageExportConditionReason.ImageExportConditionReasonConverting: + // Main status while the export image is being generated + return { level: 'info', label: t('Converting') }; + case ImageExportConditionReason.ImageExportConditionReasonPushing: + return { level: 'info', label: t('Pushing') }; + case ImageExportConditionReason.ImageExportConditionReasonCompleted: + return { level: 'success', label: t('Complete') }; + case ImageExportConditionReason.ImageExportConditionReasonFailed: + return { level: 'danger', label: t('Failed') }; + default: + return { level: 'unknown', label: t('Unknown') }; + } +}; + +const ImageBuildAndExportStatus = ({ + level, + label, + message, + imageReference, +}: { + level: StatusLevel; + label: string; + message: React.ReactNode | undefined; + imageReference: string | undefined; +}) => { + const { t } = useTranslation(); + if (imageReference) { + message = ( + + {t('Image built successfully')} + + + + + ); + } + + return ; +}; + +export const ImageBuildStatusDisplay = ({ buildStatus }: ImageBuildStatusProps) => { + const { t } = useTranslation(); + + const conditions = buildStatus?.conditions || []; + const readyCondition = conditions.find((c) => c.type === ImageBuildConditionType.ImageBuildConditionTypeReady); + const { level, label } = getImageBuildStatusInfo(readyCondition, t); + + return ( + + ); +}; + +export const ImageExportStatusDisplay = ({ imageStatus, imageReference }: ImageExportStatusProps) => { + const { t } = useTranslation(); + + const conditions = imageStatus?.conditions || []; + const readyCondition = conditions.find((c) => c.type === ImageExportConditionType.ImageExportConditionTypeReady); + const { level, label } = getImageExportStatusInfo(readyCondition, t); + + return ( + + ); +}; diff --git a/libs/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildDetailsPage.tsx b/libs/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildDetailsPage.tsx new file mode 100644 index 00000000..f7f11445 --- /dev/null +++ b/libs/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildDetailsPage.tsx @@ -0,0 +1,117 @@ +import * as React from 'react'; +import { DropdownItem, DropdownList, Tab } from '@patternfly/react-core'; + +import { RESOURCE, VERB } from '../../../types/rbac'; +import PageWithPermissions from '../../common/PageWithPermissions'; +import { useTranslation } from '../../../hooks/useTranslation'; +import { ROUTE, useNavigate } from '../../../hooks/useNavigate'; +import { usePermissionsContext } from '../../common/PermissionsContext'; +import { useAppContext } from '../../../hooks/useAppContext'; +import DetailsPage from '../../DetailsPage/DetailsPage'; +import DetailsPageActions from '../../DetailsPage/DetailsPageActions'; +import DeleteImageBuildModal from '../DeleteImageBuildModal/DeleteImageBuildModal'; +import { useImageBuild } from '../useImageBuilds'; +import TabsNav from '../../TabsNav/TabsNav'; +import { OciRegistriesContextProvider } from '../OciRegistriesContext'; +import ImageBuildYaml from './ImageBuildYaml'; +import ImageBuildDetailsTab from './ImageBuildDetailsTab'; +import ImageBuildExportsGallery from './ImageBuildExportsGallery'; +import { hasImageBuildFailed } from '../../../utils/imageBuilds'; + +const imageBuildDetailsPermissions = [ + { kind: RESOURCE.IMAGE_BUILD, verb: VERB.CREATE }, + { kind: RESOURCE.IMAGE_BUILD, verb: VERB.DELETE }, +]; + +const ImageBuildDetailsPageContent = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { + router: { useParams, Routes, Route, Navigate }, + } = useAppContext(); + + const { imageBuildId } = useParams() as { imageBuildId: string }; + const [imageBuild, isLoading, error, refetch] = useImageBuild(imageBuildId); + const [isDeleteModalOpen, setIsDeleteModalOpen] = React.useState(); + const { checkPermissions } = usePermissionsContext(); + const [canCreate, canDelete] = checkPermissions(imageBuildDetailsPermissions); + const hasFailed = imageBuild ? hasImageBuildFailed(imageBuild) : false; + + return ( + + + + + + + } + actions={ + (canCreate || canDelete) && ( + + + {canCreate && ( + navigate({ route: ROUTE.IMAGE_BUILD_EDIT, postfix: imageBuildId })}> + {hasFailed ? t('Retry') : t('Duplicate')} + + )} + {canDelete && ( + setIsDeleteModalOpen(true)}>{t('Delete image build')} + )} + + + ) + } + > + {imageBuild && ( + <> + + } /> + } /> + } /> + } /> + TODO Logs} /> + + {isDeleteModalOpen && ( + { + if (hasDeleted) { + navigate(ROUTE.IMAGE_BUILDS); + } + setIsDeleteModalOpen(false); + }} + /> + )} + + )} + + ); +}; + +const ImageBuildDetailsPage = () => { + return ( + + + + ); +}; + +const ImageBuildDetailsWithPermissions = () => { + const { checkPermissions, loading } = usePermissionsContext(); + const [allowed] = checkPermissions([{ kind: RESOURCE.IMAGE_BUILD, verb: VERB.GET }]); + return ( + + + + ); +}; + +export default ImageBuildDetailsWithPermissions; diff --git a/libs/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildDetailsTab.tsx b/libs/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildDetailsTab.tsx new file mode 100644 index 00000000..a9065dc5 --- /dev/null +++ b/libs/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildDetailsTab.tsx @@ -0,0 +1,197 @@ +import * as React from 'react'; +import { + CardBody, + CardTitle, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Flex, + FlexItem, + Grid, + GridItem, + Icon, + Label, + Stack, + StackItem, +} from '@patternfly/react-core'; +import { InfoCircleIcon } from '@patternfly/react-icons/dist/js/icons/info-circle-icon'; + +import { BindingType, ImageExport } from '@flightctl/types/imagebuilder'; +import { getDateDisplay } from '../../../utils/dates'; +import { getExportFormatLabel, getImageReference } from '../../../utils/imageBuilds'; +import { useTranslation } from '../../../hooks/useTranslation'; +import FlightCtlDescriptionList from '../../common/FlightCtlDescriptionList'; +import DetailsPageCard from '../../DetailsPage/DetailsPageCard'; +import { CERTIFICATE_VALIDITY_IN_YEARS } from '../../../constants'; +import { ImageBuildWithExports } from '../../../types/extraTypes'; +import { useOciRegistriesContext } from '../OciRegistriesContext'; +import { ImageBuildStatusDisplay } from '../ImageBuildAndExportStatus'; +import ImageUrl from '../ImageUrl'; + +const ImageBuildDetailsTab = ({ imageBuild }: { imageBuild: ImageBuildWithExports }) => { + const { t } = useTranslation(); + const srcRepositoryName = imageBuild.spec.source.repository; + const dstRepositoryName = imageBuild.spec.destination.repository; + + const { ociRegistries } = useOciRegistriesContext(); + const isEarlyBinding = imageBuild.spec.binding.type === BindingType.BindingTypeEarly; + + const hasExports = imageBuild.exportsCount > 0; + const existingImageExports = imageBuild.imageExports.filter( + (imageExport) => imageExport !== undefined, + ) as ImageExport[]; + + const srcImageReference = React.useMemo(() => { + return getImageReference(ociRegistries, imageBuild.spec.source) || ''; + }, [ociRegistries, imageBuild.spec.source]); + + const dstImageReference = React.useMemo(() => { + return getImageReference(ociRegistries, imageBuild.spec.destination) || ''; + }, [ociRegistries, imageBuild.spec.destination]); + + return ( + + + + + + {t('Build information')} + + + + {t('Build status')} + + + + + + {t('Created')} + + {getDateDisplay(imageBuild.metadata.creationTimestamp)} + + + + + + + + + {t('Registration')} + + + + {t('Binding type')} + + {isEarlyBinding ? t('Early binding') : t('Late binding')} + + + {isEarlyBinding ? ( + <> + + {t('Enrollment')} + {t('Auto-create certificate')} + + + {t('Registration')} + + {t('{{ validityPeriod }} years (Standard)', { + validityPeriod: CERTIFICATE_VALIDITY_IN_YEARS, + count: CERTIFICATE_VALIDITY_IN_YEARS, + })} + + + + ) : ( + <> + + {t('Registration')} + + + + + + + + {t('Cloud-init and ignition are enabled automatically')} + + + + + )} + + + + + + + {t('Source image')} + + + + {t('Source repository')} + {srcRepositoryName} + + + {t('Image name')} + {imageBuild.spec.source.imageName} + + + {t('Image tag')} + {imageBuild.spec.source.imageTag} + + + {t('Image reference URL')} + + + + + + + + + + + {t('Image output')} + + + + {t('Target repository')} + {dstRepositoryName} + + + {t('Image output name')} + {imageBuild.spec.destination.imageName} + + + {t('Image output tag')} + {imageBuild.spec.destination.imageTag} + + + {t('Image reference URL')} + + + + + + {t('Export formats')} + + {hasExports + ? existingImageExports.map((imageExport) => ( + + )) + : t('None')} + + + + + + + + + + ); +}; + +export default ImageBuildDetailsTab; diff --git a/libs/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildExportsGallery.tsx b/libs/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildExportsGallery.tsx new file mode 100644 index 00000000..c8b4f5d7 --- /dev/null +++ b/libs/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildExportsGallery.tsx @@ -0,0 +1,133 @@ +import * as React from 'react'; +import { Gallery } from '@patternfly/react-core'; +import { saveAs } from 'file-saver'; + +import { ExportFormatType, ImageExport } from '@flightctl/types/imagebuilder'; +import { ImageBuildWithExports } from '../../../types/extraTypes'; +import { useFetch } from '../../../hooks/useFetch'; +import { getErrorMessage } from '../../../utils/error'; +import { getImageExportResource } from '../CreateImageBuildWizard/utils'; +import { ViewImageBuildExportCard } from '../ImageExportCards'; +import { useOciRegistriesContext } from '../OciRegistriesContext'; +import { showSpinnerBriefly } from '../../../utils/time'; +import { getExportDownloadResult, getImageReference } from '../../../utils/imageBuilds'; + +type ImageBuildExportsGalleryProps = { + imageBuild: ImageBuildWithExports; + refetch: VoidFunction; +}; + +const REFRESH_IMAGE_BUILD_DELAY = 450; +// Delay to keep loading state while browser processes redirect +const DOWNLOAD_REDIRECT_DELAY = 1000; + +const allFormats = [ + ExportFormatType.ExportFormatTypeVMDK, + ExportFormatType.ExportFormatTypeQCOW2, + ExportFormatType.ExportFormatTypeISO, +]; + +const createDownloadLink = (url: string) => { + const link = document.createElement('a'); + link.href = url; + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +}; + +const ImageBuildExportsGallery = ({ imageBuild, refetch }: ImageBuildExportsGalleryProps) => { + const { post, proxyFetch } = useFetch(); + const [error, setError] = React.useState<{ + format: ExportFormatType; + message: string; + mode: 'export' | 'download'; + }>(); + const { ociRegistries } = useOciRegistriesContext(); + const [exportingFormat, setExportingFormat] = React.useState(); + const [downloadingFormat, setDownloadingFormat] = React.useState(); + + const handleExportImage = async (format: ExportFormatType) => { + setExportingFormat(format); + setError(undefined); + try { + const imageExport = getImageExportResource(imageBuild.metadata.name as string, format); + await post('imageexports', imageExport); + // The "Export image" button wouldn't be seen as spinning without this delay. + await showSpinnerBriefly(REFRESH_IMAGE_BUILD_DELAY); + refetch(); + } catch (error) { + // If process failed, it was likely very fast, so we also add the delay in this case. + await showSpinnerBriefly(REFRESH_IMAGE_BUILD_DELAY); + + setError({ format, message: getErrorMessage(error), mode: 'export' }); + } finally { + setExportingFormat(undefined); + } + }; + + const handleDownload = async (format: ExportFormatType) => { + const imageExport = imageBuild.imageExports.find((ie) => ie?.spec.format === format); + if (!imageExport) { + return; + } + + setDownloadingFormat(format); + try { + const ieName = imageExport.metadata.name as string; + const downloadEndpoint = `imagebuilder/api/v1/imageexports/${ieName}/download`; + const response = await proxyFetch(downloadEndpoint, { + method: 'GET', + credentials: 'include', + redirect: 'manual', // Prevent automatic redirect following to avoid CORS issues + }); + + const downloadResult = await getExportDownloadResult(response); + if (downloadResult === null) { + await showSpinnerBriefly(DOWNLOAD_REDIRECT_DELAY); + throw new Error(`Download failed: ${response.status} ${response.statusText}`); + } else if (downloadResult.type === 'redirect') { + createDownloadLink(downloadResult.url); + await showSpinnerBriefly(DOWNLOAD_REDIRECT_DELAY); + } else { + const defaultFilename = `image-export-${ieName}.${format}`; + saveAs(downloadResult.blob, downloadResult.filename || defaultFilename); + } + } catch (err) { + setError({ format, message: getErrorMessage(err), mode: 'download' }); + } finally { + setDownloadingFormat(undefined); + } + }; + + return ( + + {allFormats.map((format) => { + const imageExport = imageBuild.imageExports.find((imageExport) => imageExport?.spec.format === format); + const isDisabled = exportingFormat && exportingFormat !== format; + // We can only link to the generic destination for the image build. + // The individual export artifacts are references to this generic output image. + const imageReference = getImageReference(ociRegistries, imageBuild.spec.destination); + + const hasError = error?.format === format; + return ( + setError(undefined)} + onExportImage={handleExportImage} + onDownload={handleDownload} + /> + ); + })} + + ); +}; + +export default ImageBuildExportsGallery; diff --git a/libs/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildYaml.tsx b/libs/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildYaml.tsx new file mode 100644 index 00000000..21872703 --- /dev/null +++ b/libs/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildYaml.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; + +import { ImageBuild } from '@flightctl/types/imagebuilder'; +import { useTranslation } from '../../../hooks/useTranslation'; +import YamlEditor from '../../common/CodeEditor/YamlEditor'; +import { ImageBuildWithExports } from '../../../types/extraTypes'; + +// In the YAML editor, we must have the raw ImageBuild object +// For that reason, we must remove the fields we add to "ImageBuildWithExports" +const ImageBuildYaml = ({ imageBuild, refetch }: { imageBuild: ImageBuildWithExports; refetch: VoidFunction }) => { + const { t } = useTranslation(); + const rawImageBuild = { ...imageBuild, imageExports: undefined, exportsCount: undefined } as ImageBuild; + return ( + + ); +}; + +export default ImageBuildYaml; diff --git a/libs/ui-components/src/components/ImageBuilds/ImageBuildRow.tsx b/libs/ui-components/src/components/ImageBuilds/ImageBuildRow.tsx new file mode 100644 index 00000000..5561db7f --- /dev/null +++ b/libs/ui-components/src/components/ImageBuilds/ImageBuildRow.tsx @@ -0,0 +1,152 @@ +import * as React from 'react'; +import { Button, Content, Flex, FlexItem, Icon, Stack, StackItem, Title } from '@patternfly/react-core'; +import { ActionsColumn, ExpandableRowContent, IAction, OnSelect, Tbody, Td, Tr } from '@patternfly/react-table'; +import { ExclamationCircleIcon } from '@patternfly/react-icons/dist/js/icons/exclamation-circle-icon'; + +import { ImageBuild } from '@flightctl/types/imagebuilder'; +import { ImageBuildWithExports } from '../../types/extraTypes'; +import { useTranslation } from '../../hooks/useTranslation'; +import { ROUTE, useNavigate } from '../../hooks/useNavigate'; +import { getImageBuildImage, hasImageBuildFailed } from '../../utils/imageBuilds'; +import { getDateDisplay } from '../../utils/dates'; +import ResourceLink from '../common/ResourceLink'; +import { ImageBuildStatusDisplay } from './ImageBuildAndExportStatus'; +import ImageBuildExportsGallery from './ImageBuildDetails/ImageBuildExportsGallery'; + +type ImageBuildRowProps = { + imageBuild: ImageBuildWithExports; + rowIndex: number; + onRowSelect: (imageBuild: ImageBuild) => OnSelect; + isRowSelected: (imageBuild: ImageBuild) => boolean; + canDelete: boolean; + onDeleteClick: VoidFunction; + refetch: VoidFunction; +}; + +const ImageBuildRow = ({ + imageBuild, + rowIndex, + onRowSelect, + isRowSelected, + onDeleteClick, + canDelete, + refetch, +}: ImageBuildRowProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [isExpanded, setIsExpanded] = React.useState(false); + + const imageBuildName = imageBuild.metadata.name || ''; + + const actions: IAction[] = [ + { + title: t('View details'), + onClick: () => { + navigate({ route: ROUTE.IMAGE_BUILD_DETAILS, postfix: imageBuildName }); + }, + }, + ]; + + actions.push({ + title: hasImageBuildFailed(imageBuild) ? t('Retry') : t('Duplicate'), + onClick: () => { + navigate({ route: ROUTE.IMAGE_BUILD_EDIT, postfix: imageBuildName }); + }, + }); + + if (canDelete) { + actions.push({ + title: t('Delete image build'), + onClick: onDeleteClick, + }); + } + + const sourceImage = getImageBuildImage(imageBuild.spec.source); + const destinationImage = getImageBuildImage(imageBuild.spec.destination); + + const hasError = hasImageBuildFailed(imageBuild); + + return ( + + + + setIsExpanded(!isExpanded), + }} + /> + + + + {sourceImage} + {destinationImage} + + + + {`${imageBuild.exportsCount || 0}`} + {getDateDisplay(imageBuild.metadata.creationTimestamp)} + + + + + + + + + + + + + {t('Build information')} + + + {hasError && ( + + + + + + + + {t('Build failed. Please retry.')} + + + + + + )} + + + + + + + + + + + + + + ); +}; + +export default ImageBuildRow; diff --git a/libs/ui-components/src/components/ImageBuilds/ImageBuildsPage.tsx b/libs/ui-components/src/components/ImageBuilds/ImageBuildsPage.tsx new file mode 100644 index 00000000..50dba450 --- /dev/null +++ b/libs/ui-components/src/components/ImageBuilds/ImageBuildsPage.tsx @@ -0,0 +1,203 @@ +import * as React from 'react'; +import { TFunction } from 'i18next'; +import { + Button, + EmptyStateActions, + EmptyStateBody, + EmptyStateFooter, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, +} from '@patternfly/react-core'; +import PlusCircleIcon from '@patternfly/react-icons/dist/js/icons/plus-circle-icon'; +import PlusIcon from '@patternfly/react-icons/dist/js/icons/plus-icon'; + +import { RESOURCE, VERB } from '../../types/rbac'; +import { useTableSelect } from '../../hooks/useTableSelect'; +import { useTranslation } from '../../hooks/useTranslation'; +import { ROUTE, useNavigate } from '../../hooks/useNavigate'; +import ResourceListEmptyState from '../common/ResourceListEmptyState'; +import PageWithPermissions from '../common/PageWithPermissions'; +import { usePermissionsContext } from '../common/PermissionsContext'; +import ListPage from '../ListPage/ListPage'; +import ListPageBody from '../ListPage/ListPageBody'; +import TablePagination from '../Table/TablePagination'; +import TableTextSearch from '../Table/TableTextSearch'; +import Table, { ApiSortTableColumn } from '../Table/Table'; + +import MassDeleteImageBuildModal from '../modals/massModals/MassDeleteImageBuildModal/MassDeleteImageBuildModal'; +import DeleteImageBuildModal from './DeleteImageBuildModal/DeleteImageBuildModal'; +import { useImageBuilds, useImageBuildsBackendFilters } from './useImageBuilds'; +import ImageBuildRow from './ImageBuildRow'; +import { OciRegistriesContextProvider } from './OciRegistriesContext'; + +const getColumns = (t: TFunction): ApiSortTableColumn[] => [ + { + name: t('Name'), + }, + { + name: t('Base image'), + }, + { + name: t('Image output'), + }, + { + name: t('Status'), + }, + { + name: t('Export images'), + }, + { + name: t('Date'), + }, +]; + +const imageBuildTablePermissions = [ + { kind: RESOURCE.IMAGE_BUILD, verb: VERB.CREATE }, + { kind: RESOURCE.IMAGE_BUILD, verb: VERB.DELETE }, +]; + +const ImageBuildsEmptyState = ({ onCreateClick }: { onCreateClick: () => void }) => { + const { t } = useTranslation(); + return ( + + {t('Generate system images for consistent deployment to edge devices.')} + + + + + + + ); +}; + +const ImageBuildTable = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const imageBuildColumns = React.useMemo(() => getColumns(t), [t]); + const { name, setName, hasFiltersEnabled } = useImageBuildsBackendFilters(); + + const { imageBuilds, isLoading, error, isUpdating, refetch, pagination } = useImageBuilds({ name }); + + const [imageBuildToDeleteId, setImageBuildToDeleteId] = React.useState(); + const [isMassDeleteModalOpen, setIsMassDeleteModalOpen] = React.useState(false); + + const { onRowSelect, isAllSelected, hasSelectedRows, isRowSelected, setAllSelected } = useTableSelect(); + + const { checkPermissions } = usePermissionsContext(); + const [canCreate, canDelete] = checkPermissions(imageBuildTablePermissions); + + const handleCreateClick = React.useCallback(() => { + navigate(ROUTE.IMAGE_BUILD_CREATE); + }, [navigate]); + + return ( + + + + + + + + + {canCreate && ( + + + + )} + {canDelete && ( + + + + )} + + + setName('')} + isAllSelected={isAllSelected} + onSelectAll={setAllSelected} + isExpandable + > + {imageBuilds.map((imageBuild, rowIndex) => { + const name = imageBuild.metadata.name || ''; + return ( + { + setImageBuildToDeleteId(name); + }} + isRowSelected={() => isRowSelected(imageBuild)} + onRowSelect={() => onRowSelect(imageBuild)} + refetch={refetch} + /> + ); + })} +
+ + {!isUpdating && imageBuilds.length === 0 && !name && } + + {imageBuildToDeleteId && ( + { + setImageBuildToDeleteId(undefined); + if (hasDeleted) { + refetch(); + } + }} + /> + )} + {isMassDeleteModalOpen && ( + setIsMassDeleteModalOpen(false)} + imageBuilds={imageBuilds.filter(isRowSelected)} + onDeleteSuccess={() => { + setIsMassDeleteModalOpen(false); + refetch(); + }} + /> + )} +
+ ); +}; + +const ImageBuildsPage = () => { + const { t } = useTranslation(); + + return ( + + + + ); +}; + +const ImageBuildsPageWithPermissions = () => { + const { checkPermissions, loading } = usePermissionsContext(); + const [allowed] = checkPermissions([{ kind: RESOURCE.IMAGE_BUILD, verb: VERB.LIST }]); + + return ( + + + + + + ); +}; + +export default ImageBuildsPageWithPermissions; diff --git a/libs/ui-components/src/components/ImageBuilds/ImageExportCards.css b/libs/ui-components/src/components/ImageBuilds/ImageExportCards.css new file mode 100644 index 00000000..0a14bb4e --- /dev/null +++ b/libs/ui-components/src/components/ImageBuilds/ImageExportCards.css @@ -0,0 +1,13 @@ +/* +The UX design for this ImageExportCards is using a lot of customization. +In the UI, we replicate the group component "ServiceCard" which is the closest to the design, and override some defaults. +*/ + +.fctl-imageexport-card .pf-v6-c-content--h2 { + --pf-v6-c-content--h2--FontWeight: normal; +} + +.fctl-imageexport-card .fctl-imageexport-card__status .pf-v6-c-button { + /* Status is placed in the card title and needs a smaller font */ + --pf-v6-c-button--FontSize: 0.75rem; +} diff --git a/libs/ui-components/src/components/ImageBuilds/ImageExportCards.tsx b/libs/ui-components/src/components/ImageBuilds/ImageExportCards.tsx new file mode 100644 index 00000000..1e17dafa --- /dev/null +++ b/libs/ui-components/src/components/ImageBuilds/ImageExportCards.tsx @@ -0,0 +1,206 @@ +import * as React from 'react'; +import { + Alert, + AlertActionCloseButton, + AlertGroup, + Button, + Card, + CardBody, + CardFooter, + CardHeader, + Content, + ContentVariants, + Flex, + FlexItem, + Icon, + Stack, + StackItem, +} from '@patternfly/react-core'; +import { VirtualMachineIcon } from '@patternfly/react-icons/dist/js/icons/virtual-machine-icon'; +import { CloudSecurityIcon } from '@patternfly/react-icons/dist/js/icons/cloud-security-icon'; +import { ServerGroupIcon } from '@patternfly/react-icons/dist/js/icons/server-group-icon'; + +import { ExportFormatType, ImageExport } from '@flightctl/types/imagebuilder'; +import { getExportFormatDescription, getExportFormatLabel } from '../../utils/imageBuilds'; +import { getDateDisplay } from '../../utils/dates'; +import { useTranslation } from '../../hooks/useTranslation'; +import { isImageExportCompleted, isImageExportFailed } from './CreateImageBuildWizard/utils'; +import { ImageExportStatusDisplay } from './ImageBuildAndExportStatus'; + +import './ImageExportCards.css'; + +const iconMap: Record = { + [ExportFormatType.ExportFormatTypeVMDK]: , + [ExportFormatType.ExportFormatTypeQCOW2]: , + [ExportFormatType.ExportFormatTypeISO]: , +}; + +export type ImageExportFormatCardProps = { + imageReference: string | undefined; + format: ExportFormatType; + error?: { message: string; mode: 'export' | 'download' } | null; + imageExport?: ImageExport; + onExportImage: (format: ExportFormatType) => void; + onDownload: (format: ExportFormatType) => void; + onDismissError: VoidFunction; + isCreating: boolean; + isDownloading?: boolean; + isDisabled?: boolean; +}; + +type SelectImageBuildExportCardProps = { + format: ExportFormatType; + isChecked: boolean; + onToggle: (format: ExportFormatType, isChecked: boolean) => void; +}; + +export const SelectImageBuildExportCard = ({ format, isChecked, onToggle }: SelectImageBuildExportCardProps) => { + const { t } = useTranslation(); + + const title = getExportFormatLabel(format); + const description = getExportFormatDescription(t, format); + + const id = `export-format-${format}`; + return ( + + onToggle(format, !isChecked), + }} + > + + + {iconMap[format]} + + + + {title} + + + + + {description} + + ); +}; + +export const ViewImageBuildExportCard = ({ + format, + imageExport, + imageReference, + onExportImage, + onDownload, + onDismissError, + isCreating = false, + isDownloading = false, + isDisabled = false, + error, +}: ImageExportFormatCardProps) => { + const { t } = useTranslation(); + const exists = !!imageExport; + const failedExport = exists && isImageExportFailed(imageExport); + const completedExport = exists && isImageExportCompleted(imageExport); + const title = getExportFormatLabel(format); + const description = getExportFormatDescription(t, format); + + return ( + + + + + + + {iconMap[format]} + + {exists && ( + + + + )} + + + + + {title} + + + + + {description} + + + + + {failedExport && ( + + + + )} + {completedExport && ( + + + + )} + + {exists ? ( + + ) : ( + + )} + + + + + + {t('Created: {{date}}', { date: getDateDisplay(imageExport?.metadata.creationTimestamp) })} + + + {error && ( + + } + > + {t('Something went wrong on our end. Please review the error details and try again.')} +
{error.message}
+
+
+ )} +
+
+
+ ); +}; diff --git a/libs/ui-components/src/components/ImageBuilds/ImageUrl.tsx b/libs/ui-components/src/components/ImageBuilds/ImageUrl.tsx new file mode 100644 index 00000000..6aaa4f41 --- /dev/null +++ b/libs/ui-components/src/components/ImageBuilds/ImageUrl.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; +import LearnMoreLink from '../common/LearnMoreLink'; + +const getImageUrl = (url: string): string => { + if (!url || /^https?:\/\//i.test(url)) { + return url; + } + return `https://${url}`; +}; + +const ImageUrl = ({ imageReference }: { imageReference: string }) => ( + +); + +export default ImageUrl; diff --git a/libs/ui-components/src/components/ImageBuilds/ImageUrlCard.tsx b/libs/ui-components/src/components/ImageBuilds/ImageUrlCard.tsx new file mode 100644 index 00000000..e750fa34 --- /dev/null +++ b/libs/ui-components/src/components/ImageBuilds/ImageUrlCard.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { Content, Stack, StackItem } from '@patternfly/react-core'; +import { useTranslation } from '../../hooks/useTranslation'; +import ImageUrl from './ImageUrl'; + +const ImageUrlCard = ({ imageReference }: { imageReference: string | undefined }) => { + const { t } = useTranslation(); + + let content: React.ReactNode; + if (imageReference) { + content = ; + } else { + content = ( + + {t('Enter the image details to view the URL it resolves to')} + + ); + } + + return ( + + {t('Image reference URL')} + {content} + + ); +}; + +export default ImageUrlCard; diff --git a/libs/ui-components/src/components/ImageBuilds/OciRegistriesContext.tsx b/libs/ui-components/src/components/ImageBuilds/OciRegistriesContext.tsx new file mode 100644 index 00000000..4402f36c --- /dev/null +++ b/libs/ui-components/src/components/ImageBuilds/OciRegistriesContext.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; + +import { RepoSpecType, Repository, RepositoryList } from '@flightctl/types'; + +import { useFetchPeriodically } from '../../hooks/useFetchPeriodically'; + +export type OciRegistriesContextType = { + ociRegistries: Repository[]; + isLoading: boolean; + error: unknown; + refetch: VoidFunction; +}; + +const OciRegistriesContext = React.createContext(undefined); + +export const OciRegistriesContextProvider = ({ children }: React.PropsWithChildren) => { + const [repoList, isLoading, error, refetch] = useFetchPeriodically({ + endpoint: `repositories?fieldSelector=spec.type=${RepoSpecType.OCI}`, + }); + + const ociRegistries = React.useMemo(() => repoList?.items || [], [repoList]); + + const context = React.useMemo( + () => ({ + ociRegistries, + isLoading, + error, + refetch, + }), + [ociRegistries, isLoading, error, refetch], + ); + + return {children}; +}; + +export const useOciRegistriesContext = (): OciRegistriesContextType => { + const context = React.useContext(OciRegistriesContext); + if (context === undefined) { + throw new Error('useOciRegistriesContext must be used within an OciRegistriesContextProvider'); + } + return context; +}; diff --git a/libs/ui-components/src/components/ImageBuilds/useImageBuilds.ts b/libs/ui-components/src/components/ImageBuilds/useImageBuilds.ts new file mode 100644 index 00000000..d9ec180c --- /dev/null +++ b/libs/ui-components/src/components/ImageBuilds/useImageBuilds.ts @@ -0,0 +1,116 @@ +import * as React from 'react'; +import { useDebounce } from 'use-debounce'; + +import { ImageBuild, ImageBuildList } from '@flightctl/types/imagebuilder'; +import { ImageBuildWithExports } from '../../types/extraTypes'; +import { useAppContext } from '../../hooks/useAppContext'; +import { useFetchPeriodically } from '../../hooks/useFetchPeriodically'; +import { PaginationDetails, useTablePagination } from '../../hooks/useTablePagination'; +import { PAGE_SIZE } from '../../constants'; +import { toImageBuildWithExports } from './CreateImageBuildWizard/utils'; + +export enum ImageBuildsSearchParams { + Name = 'name', +} + +type ImageBuildsEndpointArgs = { + name?: string; + nextContinue?: string; +}; + +export const useImageBuildsBackendFilters = () => { + const { + router: { useSearchParams }, + } = useAppContext(); + const [searchParams, setSearchParams] = useSearchParams(); + const paramsRef = React.useRef(searchParams); + const name = searchParams.get(ImageBuildsSearchParams.Name) || undefined; + + const setName = React.useCallback( + (nameVal: string) => { + const newParams = new URLSearchParams({ + [ImageBuildsSearchParams.Name]: nameVal, + }); + paramsRef.current = newParams; + setSearchParams(newParams); + }, + [setSearchParams], + ); + + const hasFiltersEnabled = !!name; + + return { + name, + setName, + hasFiltersEnabled, + }; +}; + +const getImageBuildsEndpoint = ({ name, nextContinue }: { name?: string; nextContinue?: string }) => { + const params = new URLSearchParams({ + limit: `${PAGE_SIZE}`, + withExports: 'true', + }); + if (name) { + params.set('fieldSelector', `metadata.name contains ${name}`); + } + if (nextContinue) { + params.set('continue', nextContinue); + } + return `imagebuilds?${params.toString()}`; +}; + +const useImageBuildsEndpoint = (args: ImageBuildsEndpointArgs): [string, boolean] => { + const endpoint = getImageBuildsEndpoint(args); + const [ibEndpointDebounced] = useDebounce(endpoint, 1000); + return [ibEndpointDebounced, endpoint !== ibEndpointDebounced]; +}; + +export type ImageBuildsLoadBase = { + isLoading: boolean; + error: unknown; + isUpdating: boolean; + refetch: VoidFunction; +}; + +export type ImageBuildsLoad = ImageBuildsLoadBase & { + imageBuilds: ImageBuildWithExports[]; + pagination: PaginationDetails; +}; + +export type ImageBuildLoad = ImageBuildsLoadBase & { + imageBuild: ImageBuildWithExports; +}; + +export const useImageBuilds = (args: ImageBuildsEndpointArgs): ImageBuildsLoad => { + const pagination = useTablePagination(); + const [imageBuildsEndpoint, imageBuildsDebouncing] = useImageBuildsEndpoint({ + ...args, + nextContinue: pagination.nextContinue, + }); + const [imageBuildsList, isLoading, error, refetch, updating] = useFetchPeriodically( + { + endpoint: imageBuildsEndpoint, + }, + pagination.onPageFetched, + ); + + return { + imageBuilds: (imageBuildsList?.items || []).map(toImageBuildWithExports), + isLoading, + error, + isUpdating: updating || imageBuildsDebouncing, + refetch, + pagination, + }; +}; + +export const useImageBuild = ( + imageBuildId: string, +): [ImageBuildWithExports | undefined, boolean, unknown, VoidFunction] => { + const [imageBuild, isLoading, error, refetch] = useFetchPeriodically({ + endpoint: `imagebuilds/${imageBuildId}?withExports=true`, + }); + + return [imageBuild ? toImageBuildWithExports(imageBuild) : undefined, isLoading, error, refetch]; +}; diff --git a/libs/ui-components/src/components/Repository/CreateRepository/utils.ts b/libs/ui-components/src/components/Repository/CreateRepository/utils.ts index 7b03b577..8f2e175a 100644 --- a/libs/ui-components/src/components/Repository/CreateRepository/utils.ts +++ b/libs/ui-components/src/components/Repository/CreateRepository/utils.ts @@ -132,9 +132,7 @@ export const getInitValues = ({ formValues.useAdvancedConfig = !!( repository.spec.ociAuth || repository.spec['ca.crt'] || - repository.spec.skipServerVerification || - repository.spec.scheme || - repository.spec.accessMode + repository.spec.skipServerVerification ); formValues.ociConfig = { registry: repository.spec.registry, diff --git a/libs/ui-components/src/components/Repository/RepositoryDetails/RepositoryGeneralDetailsCard.tsx b/libs/ui-components/src/components/Repository/RepositoryDetails/RepositoryGeneralDetailsCard.tsx index e18b620e..15ad5f4b 100644 --- a/libs/ui-components/src/components/Repository/RepositoryDetails/RepositoryGeneralDetailsCard.tsx +++ b/libs/ui-components/src/components/Repository/RepositoryDetails/RepositoryGeneralDetailsCard.tsx @@ -13,7 +13,7 @@ import { LockOpenIcon } from '@patternfly/react-icons/dist/js/icons/lock-open-ic import { Repository } from '@flightctl/types'; -import { getLastTransitionTimeText, getRepositorySyncStatus } from '../../../utils/status/repository'; +import { getLastTransitionTimeText } from '../../../utils/status/repository'; import { useTranslation } from '../../../hooks/useTranslation'; import FlightControlDescriptionList from '../../common/FlightCtlDescriptionList'; import RepositoryStatus from '../../Status/RepositoryStatus'; @@ -96,7 +96,7 @@ const DetailsTab = ({ repoDetails }: { repoDetails: Repository }) => { {t('Status')} {' '} - {repoDetails ? : '-'} + {repoDetails ? : '-'} diff --git a/libs/ui-components/src/components/Repository/RepositoryList.tsx b/libs/ui-components/src/components/Repository/RepositoryList.tsx index cfd3f570..df9f191b 100644 --- a/libs/ui-components/src/components/Repository/RepositoryList.tsx +++ b/libs/ui-components/src/components/Repository/RepositoryList.tsx @@ -13,10 +13,10 @@ import { ActionsColumn, IAction, OnSelect, Tbody, Td, Tr } from '@patternfly/rea import { RepositoryIcon } from '@patternfly/react-icons/dist/js/icons/repository-icon'; import { TFunction } from 'i18next'; -import { RepoSpecType, Repository } from '@flightctl/types'; +import { Repository } from '@flightctl/types'; import ListPageBody from '../ListPage/ListPageBody'; import ListPage from '../ListPage/ListPage'; -import { getLastTransitionTimeText, getRepositorySyncStatus } from '../../utils/status/repository'; +import { getLastTransitionTimeText } from '../../utils/status/repository'; import { useTableTextSearch } from '../../hooks/useTableTextSearch'; import DeleteRepositoryModal from './RepositoryDetails/DeleteRepositoryModal'; import TableTextSearch from '../Table/TableTextSearch'; @@ -134,11 +134,9 @@ const RepositoryTableRow = ({ {getRepoTypeLabel(t, repository.spec.type)} - - {getRepoUrlOrRegistry(repository.spec) || '-'} - + {getRepoUrlOrRegistry(repository.spec) || '-'} - + {getLastTransitionTimeText(repository, t).text} {!!actions.length && ( diff --git a/libs/ui-components/src/components/Status/IntegrityStatus.tsx b/libs/ui-components/src/components/Status/IntegrityStatus.tsx index 62225eb8..5a3fa7b4 100644 --- a/libs/ui-components/src/components/Status/IntegrityStatus.tsx +++ b/libs/ui-components/src/components/Status/IntegrityStatus.tsx @@ -98,7 +98,9 @@ const IntegrityStatus = ({ integrityStatus }: { integrityStatus?: DeviceIntegrit {integrityStatus.lastVerified && ( - {t('Last verification at: {{ timestamp }}', { timestamp: getDateDisplay(integrityStatus.lastVerified) })} + {t('Last verification at: {{ timestamp }}', { + timestamp: getDateDisplay(integrityStatus.lastVerified), + })} )} diff --git a/libs/ui-components/src/components/Status/RepositoryStatus.tsx b/libs/ui-components/src/components/Status/RepositoryStatus.tsx index 4c0549f7..fe55a63e 100644 --- a/libs/ui-components/src/components/Status/RepositoryStatus.tsx +++ b/libs/ui-components/src/components/Status/RepositoryStatus.tsx @@ -1,14 +1,15 @@ import * as React from 'react'; import { InProgressIcon } from '@patternfly/react-icons/dist/js/icons/in-progress-icon'; -import { ConditionType } from '@flightctl/types'; -import { RepositorySyncStatus, repositoryStatusLabels } from '../../utils/status/repository'; +import { ConditionType, Repository } from '@flightctl/types'; +import { getRepositorySyncStatus, repositoryStatusLabels } from '../../utils/status/repository'; import { StatusLevel } from '../../utils/status/common'; import { useTranslation } from '../../hooks/useTranslation'; import { StatusDisplayContent } from './StatusDisplay'; import { SVGIconProps } from '@patternfly/react-icons/dist/js/createIcon'; -const RepositoryStatus = ({ statusInfo }: { statusInfo: { status: RepositorySyncStatus; message?: string } }) => { +const RepositoryStatus = ({ repository }: { repository: Repository }) => { + const statusInfo = getRepositorySyncStatus(repository); const statusType = statusInfo.status; const { t } = useTranslation(); diff --git a/libs/ui-components/src/components/Table/Table.tsx b/libs/ui-components/src/components/Table/Table.tsx index 7aec3ea2..d2294bf1 100644 --- a/libs/ui-components/src/components/Table/Table.tsx +++ b/libs/ui-components/src/components/Table/Table.tsx @@ -36,6 +36,7 @@ type TableProps = { // getSortParams: (columnIndex: number) => ThProps['sort']; onSelectAll?: (isSelected: boolean) => void; isAllSelected?: boolean; + isExpandable?: boolean; }; type TableFC = (props: TableProps) => JSX.Element; @@ -49,6 +50,7 @@ const Table: TableFC = ({ clearFilters, onSelectAll, isAllSelected, + isExpandable, ...rest }) => { const { t } = useTranslation(); @@ -70,7 +72,7 @@ const Table: TableFC = ({ } return ( - + {!emptyData && onSelectAll && ( @@ -82,6 +84,7 @@ const Table: TableFC = ({ }} /> )} + {isExpandable && !emptyData && } {columns.map((c) => ( {c.helperText ? ( diff --git a/libs/ui-components/src/components/common/CodeEditor/YamlEditor.tsx b/libs/ui-components/src/components/common/CodeEditor/YamlEditor.tsx index 4a8e12a1..c2e7bb74 100644 --- a/libs/ui-components/src/components/common/CodeEditor/YamlEditor.tsx +++ b/libs/ui-components/src/components/common/CodeEditor/YamlEditor.tsx @@ -6,6 +6,7 @@ import { compare } from 'fast-json-patch'; import type * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; import { AuthProvider, Device, Fleet, PatchRequest, Repository, ResourceKind } from '@flightctl/types'; +import { ImageBuild } from '@flightctl/types/imagebuilder'; import { fromAPILabel } from '../../../utils/labels'; import { getLabelPatches } from '../../../utils/patch'; import { getErrorMessage, isResourceVersionTestFailure } from '../../../utils/error'; @@ -16,9 +17,7 @@ import YamlEditorBase from './YamlEditorBase'; import './YamlEditor.css'; -// CELIA-WIP entire YAML editor needs to be reviewed for PF6. -// SEe how it works in OCP and ACM, and make sure we are consistent. -type FlightCtlYamlResource = Fleet | Device | Repository | AuthProvider; +type FlightCtlYamlResource = Fleet | Device | Repository | AuthProvider | ImageBuild; type YamlEditorProps = Partial> & { /** FlightCtl resource to display in the editor. */ @@ -206,7 +205,7 @@ const YamlEditor = ({ navigate('../.'); }} onReload={() => { - void refetch(); + refetch(); setIsSavedSuccessfully(false); setSaveError(undefined); setDoUpdate(true); diff --git a/libs/ui-components/src/components/common/OrganizationGuard.tsx b/libs/ui-components/src/components/common/OrganizationGuard.tsx index f05805ec..e80d677c 100644 --- a/libs/ui-components/src/components/common/OrganizationGuard.tsx +++ b/libs/ui-components/src/components/common/OrganizationGuard.tsx @@ -3,6 +3,7 @@ import { Organization, OrganizationList } from '@flightctl/types'; import { useAppContext } from '../../hooks/useAppContext'; import { getErrorMessage } from '../../utils/error'; import { getCurrentOrganizationId, storeCurrentOrganizationId } from '../../utils/organizationStorage'; +import { showSpinnerBriefly } from '../../utils/time'; interface OrganizationContextType { currentOrganization?: Organization; @@ -96,7 +97,7 @@ const OrganizationGuard = ({ children }: React.PropsWithChildren) => { async (addDelay: number = 0) => { setIsReloading(true); try { - await new Promise((resolve) => setTimeout(resolve, addDelay)); + await showSpinnerBriefly(addDelay); await fetchOrganizations(); } finally { setIsReloading(false); diff --git a/libs/ui-components/src/components/form/FormSelect.tsx b/libs/ui-components/src/components/form/FormSelect.tsx index 53a43eb3..13191e12 100644 --- a/libs/ui-components/src/components/form/FormSelect.tsx +++ b/libs/ui-components/src/components/form/FormSelect.tsx @@ -6,7 +6,7 @@ import ErrorHelperText, { DefaultHelperText } from './FieldHelperText'; import './FormSelect.css'; -type SelectItem = { label: string; description?: React.ReactNode }; +export type SelectItem = { label: string; description?: React.ReactNode; isDisabled?: boolean }; type FormSelectProps = { name: string; @@ -97,9 +97,14 @@ const FormSelect = ({ {itemKeys.map((key) => { const item = items[key]; - const desc = isItemObject(item) ? item.description : undefined; + let desc: React.ReactNode; + let isDisabled = false; + if (isItemObject(item)) { + desc = item.description || ''; + isDisabled = Boolean(item.isDisabled); + } return ( - + {getItemLabel(item)} ); diff --git a/libs/ui-components/src/components/form/RepositorySelect.tsx b/libs/ui-components/src/components/form/RepositorySelect.tsx new file mode 100644 index 00000000..32d28c4c --- /dev/null +++ b/libs/ui-components/src/components/form/RepositorySelect.tsx @@ -0,0 +1,223 @@ +import * as React from 'react'; +import { useField, useFormikContext } from 'formik'; +import { + Button, + Flex, + FlexItem, + FormGroup, + Icon, + MenuFooter, + SelectList, + SelectOption, + Stack, + StackItem, +} from '@patternfly/react-core'; +import { PlusCircleIcon } from '@patternfly/react-icons/dist/js/icons/plus-circle-icon'; +import { ExclamationCircleIcon } from '@patternfly/react-icons/dist/js/icons/exclamation-circle-icon'; +import { TFunction } from 'react-i18next'; + +import { ConditionStatus, ConditionType, RepoSpecType, Repository } from '@flightctl/types'; +import { useTranslation } from '../../hooks/useTranslation'; +import { StatusLevel } from '../../utils/status/common'; +import CreateRepositoryModal from '../modals/CreateRepositoryModal/CreateRepositoryModal'; +import { StatusDisplayContent } from '../Status/StatusDisplay'; +import { getRepoUrlOrRegistry } from '../Repository/CreateRepository/utils'; +import FormSelect, { SelectItem } from './FormSelect'; +import { DefaultHelperText } from './FieldHelperText'; + +export const getRepositoryItems = ( + t: TFunction, + repositories: Repository[], + repoType: RepoSpecType, + selectedRepoName?: string, + // Returns an error message if the repository cannot be selected + validateRepoSelection?: (repo: Repository) => string | undefined, +) => { + const invalidRepoItems: Record = {}; + const validRepoItems: Record = {}; + + repositories + .filter((repo) => { + return repo.spec.type === repoType; + }) + .forEach((repo) => { + const selectionError = validateRepoSelection ? validateRepoSelection(repo) : undefined; + const repoName = repo.metadata.name as string; + if (selectionError) { + invalidRepoItems[repoName] = { + label: repoName, + description: ( + + {getRepoUrlOrRegistry(repo.spec)} + {selectionError} + + ), + }; + } else { + const accessibleCondition = repo.status?.conditions?.find((c) => c.type === ConditionType.RepositoryAccessible); + const isAccessible = accessibleCondition && accessibleCondition.status === ConditionStatus.ConditionStatusTrue; + const isInaccessible = + accessibleCondition && accessibleCondition.status === ConditionStatus.ConditionStatusFalse; + const urlOrRegistry = getRepoUrlOrRegistry(repo.spec); + + let accessText = t('Unknown'); + let level: StatusLevel = 'unknown'; + if (isAccessible) { + accessText = t('Accessible'); + level = 'success'; + } else if (isInaccessible) { + accessText = t('Not accessible'); + level = 'danger'; + } + + validRepoItems[repoName] = { + label: repoName, + description: ( + + {urlOrRegistry} + + + + + ), + }; + } + }); + + // If the selected repository has been removed, we still consider it "valid" since it needs to be selected initially + const isSelectedRepoMissing = + selectedRepoName && !repositories.some((repo) => repo.metadata.name === selectedRepoName); + if (isSelectedRepoMissing && !validRepoItems[selectedRepoName]) { + validRepoItems[selectedRepoName] = { + label: selectedRepoName, + description: ( + <> + + + {' '} + {t('Missing repository')} + + ), + }; + } + + return { validRepoItems, invalidRepoItems }; +}; + +type RepositorySelectProps = { + name: string; + label?: string; + helperText?: string; + repositories: Repository[]; + repoType: RepoSpecType; + canCreateRepo: boolean; + isReadOnly?: boolean; + repoRefetch?: VoidFunction; + isRequired?: boolean; + validateRepoSelection?: (repo: Repository) => string | undefined; +}; + +const ReadOnlyRepositoryListItem = ({ invalidRepoItems }: { invalidRepoItems: Record }) => { + const itemKeys = Object.keys(invalidRepoItems); + if (itemKeys.length === 0) { + return null; + } + return ( + + {itemKeys.map((key) => { + const item = invalidRepoItems[key]; + return ( + + {item.label} + + ); + })} + + ); +}; + +const RepositorySelect = ({ + name, + repositories, + repoType, + canCreateRepo, + isReadOnly, + repoRefetch, + label, + helperText, + isRequired, + validateRepoSelection, +}: RepositorySelectProps) => { + const { t } = useTranslation(); + const { setFieldValue, setFieldError } = useFormikContext(); + const [field] = useField(name); + const [createRepoModalOpen, setCreateRepoModalOpen] = React.useState(false); + + const { validRepoItems, invalidRepoItems } = React.useMemo(() => { + return getRepositoryItems(t, repositories, repoType, field.value, validateRepoSelection); + }, [t, repositories, repoType, field.value, validateRepoSelection]); + + const handleCreateRepository = (repo: Repository) => { + setCreateRepoModalOpen(false); + if (repoRefetch) { + repoRefetch(); + } + + // If the created repository cannot be selected, we set the error and skip marking the repository as selected + if (validateRepoSelection) { + const selectionError = validateRepoSelection(repo); + if (selectionError) { + setFieldError(name, selectionError); + return; + } + } + + void setFieldValue(name, repo.metadata.name, true); + }; + + return ( + <> + + + + + {canCreateRepo && ( + + + + )} + + + {helperText && } + + {createRepoModalOpen && ( + setCreateRepoModalOpen(false)} + onSuccess={handleCreateRepository} + /> + )} + + ); +}; + +export default RepositorySelect; diff --git a/libs/ui-components/src/components/modals/CreateRepositoryModal/CreateRepositoryModal.tsx b/libs/ui-components/src/components/modals/CreateRepositoryModal/CreateRepositoryModal.tsx index 6ead7586..c46e48f3 100644 --- a/libs/ui-components/src/components/modals/CreateRepositoryModal/CreateRepositoryModal.tsx +++ b/libs/ui-components/src/components/modals/CreateRepositoryModal/CreateRepositoryModal.tsx @@ -1,25 +1,30 @@ import * as React from 'react'; import { Modal, ModalBody, ModalHeader } from '@patternfly/react-core'; -import { Repository } from '@flightctl/types'; +import { RepoSpecType, Repository } from '@flightctl/types'; import { useTranslation } from '../../../hooks/useTranslation'; -import CreateRepositoryForm, { - CreateRepositoryFormProps, -} from '../../Repository/CreateRepository/CreateRepositoryForm'; +import CreateRepositoryForm from '../../Repository/CreateRepository/CreateRepositoryForm'; type CreateRepositoryModalProps = { + type: RepoSpecType; onClose: VoidFunction; onSuccess: (repository: Repository) => void; - options?: CreateRepositoryFormProps['options']; }; -const CreateRepositoryModal = ({ options, onClose, onSuccess }: CreateRepositoryModalProps) => { +const CreateRepositoryModal = ({ type, onClose, onSuccess }: CreateRepositoryModalProps) => { const { t } = useTranslation(); return ( - + ); diff --git a/libs/ui-components/src/components/modals/massModals/MassDeleteImageBuildModal/MassDeleteImageBuildModal.tsx b/libs/ui-components/src/components/modals/massModals/MassDeleteImageBuildModal/MassDeleteImageBuildModal.tsx new file mode 100644 index 00000000..e58ef533 --- /dev/null +++ b/libs/ui-components/src/components/modals/massModals/MassDeleteImageBuildModal/MassDeleteImageBuildModal.tsx @@ -0,0 +1,156 @@ +import * as React from 'react'; +import { + Alert, + Button, + Content, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + Progress, + ProgressMeasureLocation, + Stack, + StackItem, +} from '@patternfly/react-core'; + +import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; + +import { ImageBuild } from '@flightctl/types/imagebuilder'; +import { getErrorMessage } from '../../../../utils/error'; +import { useFetch } from '../../../../hooks/useFetch'; +import { useTranslation } from '../../../../hooks/useTranslation'; +import { isPromiseRejected } from '../../../../types/typeUtils'; +import { getImageBuildImage } from '../../../../utils/imageBuilds'; + +type MassDeleteImageBuildModalProps = { + onClose: VoidFunction; + imageBuilds: Array; + onDeleteSuccess: VoidFunction; +}; + +const MassDeleteImageBuildTable = ({ imageBuilds }: { imageBuilds: Array }) => { + const { t } = useTranslation(); + return ( + + + + + + + + + + {imageBuilds.map((imageBuild) => { + const name = imageBuild.metadata.name || ''; + const baseImage = getImageBuildImage(imageBuild.spec.source); + const outputImage = getImageBuildImage(imageBuild.spec.destination); + return ( + + + + + + ); + })} + +
{t('Name')}{t('Base image')}{t('Image output')}
{name}{baseImage}{outputImage}
+ ); +}; + +const MassDeleteImageBuildModal = ({ onClose, imageBuilds, onDeleteSuccess }: MassDeleteImageBuildModalProps) => { + const { t } = useTranslation(); + const [progress, setProgress] = React.useState(0); + const [progressTotal, setProgressTotal] = React.useState(0); + const [isDeleting, setIsDeleting] = React.useState(false); + const [errors, setErrors] = React.useState(); + const { remove } = useFetch(); + + const imageBuildsCount = imageBuilds.length; + + const deleteImageBuilds = async () => { + setErrors(undefined); + setProgress(0); + setProgressTotal(imageBuilds.length); + setIsDeleting(true); + + const promises = imageBuilds.map(async (imageBuild) => { + await remove(`imagebuilds/${imageBuild.metadata.name}`); + setProgress((p) => p + 1); + }); + + const results = await Promise.allSettled(promises); + setIsDeleting(false); + + const rejectedResults = results.filter(isPromiseRejected); + + if (rejectedResults.length) { + setErrors(rejectedResults.map((r) => getErrorMessage(r.reason))); + } else { + onDeleteSuccess(); + } + }; + + return ( + + + + + + + {t('This will remove the record of this build and its history.', { count: imageBuildsCount })} + + + + + {t('The actual image files in your storage will not be deleted.')} + + + + + + + {isDeleting && ( + + + + )} + {errors?.length && ( + + + + {errors.map((e, index) => ( + {e} + ))} + + + + )} + + + + + + + + ); +}; + +export default MassDeleteImageBuildModal; diff --git a/libs/ui-components/src/components/modals/massModals/ResumeDevicesModal/MassResumeDevicesModal.tsx b/libs/ui-components/src/components/modals/massModals/ResumeDevicesModal/MassResumeDevicesModal.tsx index 895f1861..a8f916e2 100644 --- a/libs/ui-components/src/components/modals/massModals/ResumeDevicesModal/MassResumeDevicesModal.tsx +++ b/libs/ui-components/src/components/modals/massModals/ResumeDevicesModal/MassResumeDevicesModal.tsx @@ -35,10 +35,9 @@ import { getApiListCount } from '../../../../utils/api'; import { fromAPILabel, labelToExactApiMatchString } from '../../../../utils/labels'; import { useFleets } from '../../../Fleet/useFleets'; import ResumeAllDevicesConfirmationDialog from './ResumeAllDevicesConfirmationDialog'; +import { showSpinnerBriefly } from '../../../../utils/time'; // Adds an artificial delay to make sure that the user notices the count is refreshing. -// This is specially needed when users switch between modes, and the selection for the new mode is already valid. -const showSpinnerBriefly = () => new Promise((resolve) => setTimeout(resolve, 450)); type MassResumeFormValues = { mode: SelectionMode; @@ -56,6 +55,9 @@ enum SelectionMode { ALL = 'all', } +// Delay needed specially when users switch between modes, and the selection for the new mode is already valid. +const REFRESH_COUNT_DELAY = 450; + const getSelectedFleetLabels = (fleets: Fleet[], fleetId: string) => { const selectedFleet = fleets.find((fleet) => fleet.metadata.name === fleetId); if (!selectedFleet) { @@ -115,10 +117,10 @@ const MassResumeDevicesModalContent = ({ onClose }: MassResumeDevicesModalProps) } const deviceResult = await get(queryEndpoint); - await showSpinnerBriefly(); + await showSpinnerBriefly(REFRESH_COUNT_DELAY); setDeviceCountNum(getApiListCount(deviceResult) || 0); } catch (error) { - await showSpinnerBriefly(); + await showSpinnerBriefly(REFRESH_COUNT_DELAY); setCountError(t('Failed to obtain the number of matching devices')); } finally { setIsCountLoading(false); diff --git a/libs/ui-components/src/constants.ts b/libs/ui-components/src/constants.ts index 792a0710..0853133b 100644 --- a/libs/ui-components/src/constants.ts +++ b/libs/ui-components/src/constants.ts @@ -2,5 +2,6 @@ const APP_TITLE = 'Edge Manager'; const API_VERSION = 'v1beta1'; const PAGE_SIZE = 15; const EVENT_PAGE_SIZE = 200; // It's 500 in OCP console +const CERTIFICATE_VALIDITY_IN_YEARS = 1; -export { APP_TITLE, API_VERSION, PAGE_SIZE, EVENT_PAGE_SIZE }; +export { APP_TITLE, API_VERSION, PAGE_SIZE, EVENT_PAGE_SIZE, CERTIFICATE_VALIDITY_IN_YEARS }; diff --git a/libs/ui-components/src/hooks/useAppContext.tsx b/libs/ui-components/src/hooks/useAppContext.tsx index 23bfac9d..c876a1de 100644 --- a/libs/ui-components/src/hooks/useAppContext.tsx +++ b/libs/ui-components/src/hooks/useAppContext.tsx @@ -37,6 +37,10 @@ export const appRoutes = { [ROUTE.AUTH_PROVIDER_CREATE]: '/admin/authproviders/create', [ROUTE.AUTH_PROVIDER_EDIT]: '/admin/authproviders/edit', [ROUTE.AUTH_PROVIDER_DETAILS]: '/admin/authproviders', + [ROUTE.IMAGE_BUILDS]: '/devicemanagement/imagebuilds', + [ROUTE.IMAGE_BUILD_CREATE]: '/devicemanagement/imagebuilds/create', + [ROUTE.IMAGE_BUILD_DETAILS]: '/devicemanagement/imagebuilds', + [ROUTE.IMAGE_BUILD_EDIT]: '/devicemanagement/imagebuilds/edit', }; export type NavLinkFC = React.FC<{ to: string; children: (props: { isActive: boolean }) => React.ReactNode }>; diff --git a/libs/ui-components/src/hooks/useNavigate.tsx b/libs/ui-components/src/hooks/useNavigate.tsx index 8ba1523e..d3fac2a2 100644 --- a/libs/ui-components/src/hooks/useNavigate.tsx +++ b/libs/ui-components/src/hooks/useNavigate.tsx @@ -29,6 +29,10 @@ export enum ROUTE { AUTH_PROVIDER_CREATE = 'AUTH_PROVIDER_CREATE', AUTH_PROVIDER_EDIT = 'AUTH_PROVIDER_EDIT', AUTH_PROVIDER_DETAILS = 'AUTH_PROVIDER_DETAILS', + IMAGE_BUILDS = 'IMAGE_BUILDS', + IMAGE_BUILD_CREATE = 'IMAGE_BUILD_CREATE', + IMAGE_BUILD_DETAILS = 'IMAGE_BUILD_DETAILS', + IMAGE_BUILD_EDIT = 'IMAGE_BUILD_EDIT', } export type RouteWithPostfix = @@ -41,7 +45,9 @@ export type RouteWithPostfix = | ROUTE.DEVICE_EDIT | ROUTE.ENROLLMENT_REQUEST_DETAILS | ROUTE.AUTH_PROVIDER_EDIT - | ROUTE.AUTH_PROVIDER_DETAILS; + | ROUTE.AUTH_PROVIDER_DETAILS + | ROUTE.IMAGE_BUILD_DETAILS + | ROUTE.IMAGE_BUILD_EDIT; export type Route = Exclude; type ToObj = { route: RouteWithPostfix; postfix: string | undefined }; diff --git a/libs/ui-components/src/types/extraTypes.ts b/libs/ui-components/src/types/extraTypes.ts index b8604eec..1dd666ff 100644 --- a/libs/ui-components/src/types/extraTypes.ts +++ b/libs/ui-components/src/types/extraTypes.ts @@ -3,6 +3,7 @@ import { ApplicationEnvVars, ApplicationVolumeProviderSpec, AuthProvider, + Condition, ConditionType, Device, EnrollmentRequest, @@ -12,8 +13,18 @@ import { OAuth2ProviderSpec, OIDCProviderSpec, RelativePath, + ResourceKind, ResourceSync, } from '@flightctl/types'; +import { + ImageBuild, + ImageBuildCondition, + ImageBuildConditionType, + ResourceKind as ImageBuilderResourceKind, + ImageExport, + ImageExportCondition, + ImageExportConditionType, +} from '@flightctl/types/imagebuilder'; export interface FlightCtlLabel { key: string; @@ -33,6 +44,10 @@ export enum DeviceAnnotation { RenderedVersion = 'device-controller/renderedVersion', } +export type GenericCondition = Condition | ImageBuildCondition | ImageExportCondition; +export type GenericConditionType = ConditionType | ImageBuildConditionType | ImageExportConditionType; +export type FlightctlKind = ResourceKind | ImageBuilderResourceKind; + export const isEnrollmentRequest = (resource: Device | EnrollmentRequest): resource is EnrollmentRequest => resource.kind === 'EnrollmentRequest'; @@ -79,6 +94,12 @@ export type AlertManagerAlert = { receivers: Array<{ name: string }>; }; +// ImageBuild with the latest exports for each format +export type ImageBuildWithExports = Omit & { + imageExports: (ImageExport | undefined)[]; + exportsCount: number; +}; + // AuthProviders that can be added dynamically to the system can only be OAuth2 or OIDC. export type DynamicAuthProviderSpec = OIDCProviderSpec | OAuth2ProviderSpec; export type DynamicAuthProvider = AuthProvider & { spec: DynamicAuthProviderSpec }; diff --git a/libs/ui-components/src/types/rbac.ts b/libs/ui-components/src/types/rbac.ts index 3c069d04..62d8f693 100644 --- a/libs/ui-components/src/types/rbac.ts +++ b/libs/ui-components/src/types/rbac.ts @@ -20,4 +20,5 @@ export enum RESOURCE { ENROLLMENT_REQUEST_APPROVAL = 'enrollmentrequests/approval', ALERTS = 'alerts', AUTH_PROVIDER = 'authproviders', + IMAGE_BUILD = 'imagebuilds', } diff --git a/libs/ui-components/src/utils/api.ts b/libs/ui-components/src/utils/api.ts index 55db4bc7..83625176 100644 --- a/libs/ui-components/src/utils/api.ts +++ b/libs/ui-components/src/utils/api.ts @@ -1,7 +1,5 @@ import { - Condition, ConditionStatus, - ConditionType, DeviceList, EnrollmentRequestList, FleetList, @@ -9,10 +7,17 @@ import { RepositoryList, ResourceSyncList, } from '@flightctl/types'; +import { ImageBuildList } from '@flightctl/types/imagebuilder'; -import { AnnotationType } from '../types/extraTypes'; +import { AnnotationType, GenericCondition, GenericConditionType } from '../types/extraTypes'; -export type ApiList = EnrollmentRequestList | DeviceList | FleetList | RepositoryList | ResourceSyncList; +export type ApiList = + | EnrollmentRequestList + | DeviceList + | FleetList + | RepositoryList + | ResourceSyncList + | ImageBuildList; const getApiListCount = (listResponse: ApiList | undefined): number | undefined => { if (listResponse === undefined) { @@ -31,10 +36,10 @@ const getMetadataAnnotation = (metadata: ObjectMeta | undefined, annotation: Ann }; const getCondition = ( - conditions: Condition[] | undefined, - type: ConditionType, + conditions: GenericCondition[] | undefined, + type: GenericConditionType, status: ConditionStatus = ConditionStatus.ConditionStatusTrue, -) => { +): GenericCondition | undefined => { const typeCond = conditions?.filter((c) => c.type === type); if (typeCond) { return typeCond.find((tc) => tc.status === status); diff --git a/libs/ui-components/src/utils/imageBuilds.ts b/libs/ui-components/src/utils/imageBuilds.ts new file mode 100644 index 00000000..39c573fb --- /dev/null +++ b/libs/ui-components/src/utils/imageBuilds.ts @@ -0,0 +1,126 @@ +import { TFunction } from 'react-i18next'; +import { + ExportFormatType, + ImageBuild, + ImageBuildConditionReason, + ImageBuildConditionType, + ImageBuildDestination, + ImageBuildSource, +} from '@flightctl/types/imagebuilder'; +import { Repository } from '@flightctl/types'; +import { isOciRepoSpec } from '../components/Repository/CreateRepository/utils'; + +export const getImageBuildImage = (srcOrDst: ImageBuildSource | ImageBuildDestination | undefined) => { + if (!srcOrDst) { + return '-'; + } + return `${srcOrDst.imageName}:${srcOrDst.imageTag}`; +}; + +export const getExportFormatDescription = (t: TFunction, format: ExportFormatType) => { + switch (format) { + case ExportFormatType.ExportFormatTypeVMDK: + return t('For enterprise virtualization platforms.'); + case ExportFormatType.ExportFormatTypeQCOW2: + return t('For virtualized edge workloads and OpenShift Virtualization.'); + case ExportFormatType.ExportFormatTypeISO: + return t('For physical edge devices and bare metal.'); + } +}; + +export const getExportFormatLabel = (format: ExportFormatType) => `.${format.toUpperCase()}`; + +const getOciRepositoryUrl = (repositories: Repository[], repositoryName: string): string | undefined => { + const repo = repositories.find((r) => r.metadata.name === repositoryName); + if (!repo || !isOciRepoSpec(repo.spec)) { + return undefined; + } + return repo.spec.registry; +}; + +export const getImageReference = ( + repositories: Repository[], + imageTarget: ImageBuildSource | ImageBuildDestination, +) => { + const registryUrl = getOciRepositoryUrl(repositories, imageTarget.repository); + if (!registryUrl) { + return undefined; + } + if (!(imageTarget.imageTag && imageTarget.imageName)) { + return undefined; + } + return `${registryUrl}/${imageTarget.imageName}:${imageTarget.imageTag}`; +}; + +export const hasImageBuildFailed = (imageBuild: ImageBuild): boolean => { + const readyCondition = imageBuild.status?.conditions?.find( + (c) => c.type === ImageBuildConditionType.ImageBuildConditionTypeReady, + ); + return readyCondition?.reason === ImageBuildConditionReason.ImageBuildConditionReasonFailed; +}; + +const isDownloadResultRedirect = (response: Response): boolean => { + return ( + response.status === 302 || + response.status === 301 || + response.status === 307 || + response.status === 308 || + response.type === 'opaqueredirect' || + response.status === 0 + ); +}; + +// Validate that a URL is safe to use for download (only http/https protocols) +const isValidDownloadUrl = (url: string): boolean => { + try { + const parsedUrl = new URL(url, window.location.origin); + return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:'; + } catch { + return false; + } +}; + +const getRedirectUrl = (response: Response): string | undefined => { + // If the Location header is present, it indicates a redirect. + const redirectUrl = response.headers.get('Location'); + if (redirectUrl) { + return redirectUrl; + } + if (isDownloadResultRedirect(response) && response.url) { + return response.url; + } + return undefined; +}; + +export type ExportDownloadResult = + | { type: 'redirect'; url: string } + | { type: 'blob'; blob: Blob; filename: string | undefined }; + +// The download endpoint returns two types of responses: a redirect URL or a blob. +// If a redirect URL is found, we should use it to trigger the download in the browser. +// If no redirect URL is found, we should download the blob directly. +export const getExportDownloadResult = async (response: Response): Promise => { + if (!response.ok && response.status !== 0) { + return null; + } + const redirectUrl = getRedirectUrl(response); + if (redirectUrl) { + if (!isValidDownloadUrl(redirectUrl)) { + throw new Error('Invalid redirect URL received from server'); + } + return { type: 'redirect', url: redirectUrl }; + } + + const blob = await response.blob(); + + let filename = ''; + const disposition = response.headers.get('Content-Disposition'); + if (disposition) { + const filenameMatch = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); + if (filenameMatch && filenameMatch[1]) { + filename = filenameMatch[1].replace(/['"]/g, ''); + } + } + + return { type: 'blob', blob, filename }; +}; diff --git a/libs/ui-components/src/utils/status/repository.ts b/libs/ui-components/src/utils/status/repository.ts index ded21ff4..48e413b9 100644 --- a/libs/ui-components/src/utils/status/repository.ts +++ b/libs/ui-components/src/utils/status/repository.ts @@ -2,6 +2,7 @@ import { TFunction } from 'i18next'; import { Condition, ConditionStatus, ConditionType, Repository, ResourceSync } from '@flightctl/types'; import { timeSinceText } from '../dates'; import { getConditionMessage } from '../error'; +import { getCondition } from '../api'; export type RepositorySyncStatus = | ConditionType.ResourceSyncSynced @@ -22,6 +23,12 @@ const repositoryStatusLabels = (t: TFunction) => ({ 'Sync pending': t('Sync pending'), }); +export const isAccessibleRepository = (repository: Repository): boolean => { + const conditions = repository.status?.conditions; + // By default it checks for true condition + return getCondition(conditions, ConditionType.RepositoryAccessible) !== undefined; +}; + const getRepositorySyncStatus = ( repository: Repository | ResourceSync, t: TFunction = (s: string) => s, diff --git a/libs/ui-components/src/utils/time.ts b/libs/ui-components/src/utils/time.ts index b7bcb7f1..347273d4 100644 --- a/libs/ui-components/src/utils/time.ts +++ b/libs/ui-components/src/utils/time.ts @@ -121,3 +121,6 @@ export const getUpdateCronExpression = (startTime: string, scheduleMode: UpdateS .filter((num) => num !== null); return `${minutes} ${hours} * * ${weekDayVals.join(',')}`; }; + +// Adds an artificial delay to make sure that the user notices the data is refreshing. +export const showSpinnerBriefly = (time: number = 450) => new Promise((resolve) => setTimeout(resolve, time)); diff --git a/libs/ui-components/tsconfig.json b/libs/ui-components/tsconfig.json index c7ef4314..c738c56c 100644 --- a/libs/ui-components/tsconfig.json +++ b/libs/ui-components/tsconfig.json @@ -2,7 +2,6 @@ "extends": "../../tsconfig.json", "compilerOptions": { "baseUrl": ".", - "rootDir": ".", "outDir": "dist", "module": "CommonJS", "target": "es2015", @@ -23,6 +22,10 @@ "skipLibCheck": true, "declaration": true, "declarationMap": true, + "paths": { + "@flightctl/types": ["../types"], + "@flightctl/types/imagebuilder": ["../types/imagebuilder"], + }, }, "include": ["**/*.ts", "**/*.tsx", "**/*.jsx", "**/*.js", "**/*.json"], "exclude": ["node_modules", "dist"], diff --git a/proxy/app.go b/proxy/app.go index fecef0f6..8b4181da 100644 --- a/proxy/app.go +++ b/proxy/app.go @@ -40,6 +40,8 @@ func main() { os.Exit(1) } + apiRouter.Handle("/imagebuilder/{forward:.*}", bridge.NewImageBuilderHandler(tlsConfig)) + apiRouter.Handle("/flightctl/{forward:.*}", bridge.NewFlightCtlHandler(tlsConfig)) alertManagerUrl, alertManagerEnabled := os.LookupEnv("FLIGHTCTL_ALERTMANAGER_PROXY") diff --git a/proxy/bridge/handler.go b/proxy/bridge/handler.go index eb4a8dea..f1d6f5c7 100644 --- a/proxy/bridge/handler.go +++ b/proxy/bridge/handler.go @@ -118,6 +118,16 @@ func NewAlertManagerHandler(tlsConfig *tls.Config) handler { return handler{target: target, proxy: proxy} } +func NewImageBuilderHandler(tlsConfig *tls.Config) handler { + target, proxy := createReverseProxy(config.FctlImageBuilderApiUrl) + + proxy.Transport = &http.Transport{ + TLSClientConfig: tlsConfig, + } + + return handler{target: target, proxy: proxy} +} + func UnimplementedHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } diff --git a/proxy/config/config.go b/proxy/config/config.go index 036dab0d..370734c2 100644 --- a/proxy/config/config.go +++ b/proxy/config/config.go @@ -6,18 +6,19 @@ import ( ) var ( - BridgePort = ":" + getEnvVar("API_PORT", "3001") - FctlApiUrl = getEnvUrlVar("FLIGHTCTL_SERVER", "https://localhost:3443") - FctlApiExternalUrl = getEnvUrlVar("FLIGHTCTL_SERVER_EXTERNAL", "https://localhost:3443") - FctlApiInsecure = getEnvVar("FLIGHTCTL_SERVER_INSECURE_SKIP_VERIFY", "false") - FctlCliArtifactsUrl = getEnvUrlVar("FLIGHTCTL_CLI_ARTIFACTS_SERVER", "http://localhost:8090") - AlertManagerApiUrl = getEnvUrlVar("FLIGHTCTL_ALERTMANAGER_PROXY", "https://localhost:8443") - TlsKeyPath = getEnvVar("TLS_KEY", "") - TlsCertPath = getEnvVar("TLS_CERT", "") - BaseUiUrl = getEnvUrlVar("BASE_UI_URL", "http://localhost:9000") - AuthInsecure = getEnvVar("AUTH_INSECURE_SKIP_VERIFY", "") - OcpPlugin = getEnvVar("IS_OCP_PLUGIN", "false") - IsRHEM = getEnvVar("IS_RHEM", "") + BridgePort = ":" + getEnvVar("API_PORT", "3001") + FctlApiUrl = getEnvUrlVar("FLIGHTCTL_SERVER", "https://localhost:3443") + FctlApiExternalUrl = getEnvUrlVar("FLIGHTCTL_SERVER_EXTERNAL", "https://localhost:3443") + FctlImageBuilderApiUrl = getEnvUrlVar("FLIGHTCTL_IMAGEBUILDER_SERVER", "https://localhost:8445") + FctlApiInsecure = getEnvVar("FLIGHTCTL_SERVER_INSECURE_SKIP_VERIFY", "false") + FctlCliArtifactsUrl = getEnvUrlVar("FLIGHTCTL_CLI_ARTIFACTS_SERVER", "http://localhost:8090") + AlertManagerApiUrl = getEnvUrlVar("FLIGHTCTL_ALERTMANAGER_PROXY", "https://localhost:8443") + TlsKeyPath = getEnvVar("TLS_KEY", "") + TlsCertPath = getEnvVar("TLS_CERT", "") + BaseUiUrl = getEnvUrlVar("BASE_UI_URL", "http://localhost:9000") + AuthInsecure = getEnvVar("AUTH_INSECURE_SKIP_VERIFY", "") + OcpPlugin = getEnvVar("IS_OCP_PLUGIN", "false") + IsRHEM = getEnvVar("IS_RHEM", "") ) func getEnvUrlVar(key string, defaultValue string) string {