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 && (
+
+ }
+ onClick={() => {
+ setCreateRepoModalOpen(true);
+ }}
+ isDisabled={isReadOnly}
+ >
+ {t('Create repository')}
+
+
+ )}
+
+
+ {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 (
+
+
+
+ | {t('Name')} |
+ {t('Base image')} |
+ {t('Image output')} |
+
+
+
+ {imageBuilds.map((imageBuild) => {
+ const name = imageBuild.metadata.name || '';
+ const baseImage = getImageBuildImage(imageBuild.spec.source);
+ const outputImage = getImageBuildImage(imageBuild.spec.destination);
+ return (
+
+ | {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 {
|