diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index c5ecab72d..9ec7a72ef 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -1375,8 +1375,6 @@ "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.", - "Red Hat Edge Manager": "Red Hat Edge Manager", - "Flight Control": "Flight Control", "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.", @@ -1675,39 +1673,181 @@ "All good!": "All good!", "Tasks": "Tasks", "View the health of your edge environment and explore details across devices, fleets, and security.": "View the health of your edge environment and explore details across devices, fleets, and security.", + "Expand guide": "Expand guide", + "Minimize guide": "Minimize guide", + "Close guide": "Close guide", + "Drag guide": "Drag guide", + "Preview table": "Preview table", + "No issues detected.": "No issues detected.", + "Action not available in preview mode": "Action not available in preview mode", + "See how it looks": "See how it looks", + "What happens after approval": "What happens after approval", + "After approval, the device appears in the enrolled devices table with connectivity status and fleet membership. You can open its detail page to monitor health and apply updates.": "After approval, the device appears in the enrolled devices table with connectivity status and fleet membership. You can open its detail page to monitor health and apply updates.", + "Browse approved devices in the enrolled devices table.": "Browse approved devices in the enrolled devices table.", + "Approve the enrollment": "Approve the enrollment", + "Click Approve to accept a device into your managed environment. After approval, it moves from pending requests into the enrolled devices table.": "Click Approve to accept a device into your managed environment. After approval, it moves from pending requests into the enrolled devices table.", + "Use the Approve action on a pending request to bring the device under management.": "Use the Approve action on a pending request to bring the device under management.", + "Boot the device or VM": "Boot the device or VM", + "Write the downloaded disk image to your edge device (USB stick, SD card, or network boot), or load it into a virtual machine. When the device powers on for the first time, it contacts the control plane and appears as a device pending approval.": "Write the downloaded disk image to your edge device (USB stick, SD card, or network boot), or load it into a virtual machine. When the device powers on for the first time, it contacts the control plane and appears as a device pending approval.", + "For virtual testing, boot the QCOW2 image in a VM. It connects the same way as physical hardware.": "For virtual testing, boot the QCOW2 image in a VM. It connects the same way as physical hardware.", + "What a device detail page shows": "What a device detail page shows", + "After approval, devices appear in the enrolled devices table. Open a device detail page to check connectivity status, system resources, and OS version.": "After approval, devices appear in the enrolled devices table. Open a device detail page to check connectivity status, system resources, and OS version.", + "Example device": "Example device", + "Healthy": "Healthy", + "Online": "Online", + "Preview enrolled devices": "Preview enrolled devices", + "Some devices are already enrolled to the system. Browse their connectivity and fleet membership to see what an approved device looks like.": "Some devices are already enrolled to the system. Browse their connectivity and fleet membership to see what an approved device looks like.", + "The enrolled devices table is empty right now. Once devices are approved, each row will show connectivity and fleet membership.": "The enrolled devices table is empty right now. Once devices are approved, each row will show connectivity and fleet membership.", + "Each row shows connectivity and fleet membership.": "Each row shows connectivity and fleet membership.", + "Find devices pending approval": "Find devices pending approval", + "When a device boots for the first time, it stays in Devices pending approval panel until approved. Each row shows the device alias, name, and when the request was created.": "When a device boots for the first time, it stays in Devices pending approval panel until approved. Each row shows the device alias, name, and when the request was created.", + "When a device boots for the first time, it stays in Devices pending approval panel until approved. The panel is empty right now. Once a device requests enrollment, each row will show the device alias, name, and when the request was created.": "When a device boots for the first time, it stays in Devices pending approval panel until approved. The panel is empty right now. Once a device requests enrollment, each row will show the device alias, name, and when the request was created.", + "Check the pending requests panel above the enrolled devices table.": "Check the pending requests panel above the enrolled devices table.", + "The Devices page is your central hub for all managed edge hardware. From here you can monitor enrolled device health and status and view pending enrollment requests. Each row shows device connectivity, OS version, and fleet membership at a glance.": "The Devices page is your central hub for all managed edge hardware. From here you can monitor enrolled device health and status and view pending enrollment requests. Each row shows device connectivity, OS version, and fleet membership at a glance.", + "This is your device hub. The enrolled devices table is empty right now. Once devices are approved, each row will show connectivity and fleet membership. To show you what it will look like, click the link below.": "This is your device hub. The enrolled devices table is empty right now. Once devices are approved, each row will show connectivity and fleet membership. To show you what it will look like, click the link below.", + "This is your device hub. Enrolled devices appear in the table below with connectivity and fleet membership.": "This is your device hub. Enrolled devices appear in the table below with connectivity and fleet membership.", + "Open the Devices page": "Open the Devices page", + "Devices requiring approval appear in the panel above the table when a device first boots.": "Devices requiring approval appear in the panel above the table when a device first boots.", + "Device pending approval": "Device pending approval", + "2 hours ago": "2 hours ago", + "Review the enrollment details": "Review the enrollment details", + "Click a device pending approval to review system information, requested labels, and the certificate signing request.": "Click a device pending approval to review system information, requested labels, and the certificate signing request.", + "Verify it matches the device you booted before approving.": "Verify it matches the device you booted before approving.", + "Select a request in the pending devices panel to open its details.": "Select a request in the pending devices panel to open its details.", + "Approve a device pending approval": "Approve a device pending approval", + "Download a disk image to provision a device": "Download a disk image to provision a device", + "When you need help from your team": "When you need help from your team", + "Contact your administrator if you need to": "Contact your administrator if you need to", + "View enrolled device health and status in the devices table": "View enrolled device health and status in the devices table", + "View pending enrollment requests in the panel above the table": "View pending enrollment requests in the panel above the table", + "Open enrolled and pending devices detail pages to review configuration and status": "Open enrolled and pending devices detail pages to review configuration and status", + "What you can do on Devices": "What you can do on Devices", + "With your current permissions, you can": "With your current permissions, you can", + "The Devices page is where your organization manages enrolled edge hardware.": "The Devices page is where your organization manages enrolled edge hardware.", + "Flashing devices, approving enrollment requests, and adding devices are handled by an administrator or operator and your administrator or image builder.": "Flashing devices, approving enrollment requests, and adding devices are handled by an administrator or operator and your administrator or image builder.", + "Create your first fleet": "Create your first fleet", + "Click \"Create fleet\" in the toolbar to open the fleet creation wizard. You will define which devices belong to this fleet, what they should run, and how updates roll out.": "Click \"Create fleet\" in the toolbar to open the fleet creation wizard. You will define which devices belong to this fleet, what they should run, and how updates roll out.", + "Fleets group devices that share the same OS image, applications, and update policies.": "Fleets group devices that share the same OS image, applications, and update policies.", + "Example fleet": "Example fleet", + "Valid": "Valid", + "Request a new fleet for your devices": "Request a new fleet for your devices", + "Change fleet configurations or update rollout policies": "Change fleet configurations or update rollout policies", + "Contact your fleet administrator if you need to": "Contact your fleet administrator if you need to", + "How fleet creation works": "How fleet creation works", + "Creating a fleet involves three steps: defining a label selector (which devices belong to this fleet), configuring the device template (desired OS, applications, and configurations), and setting an update policy (batch size and disruption budget for rollouts). Once created, matching devices automatically receive the fleet configuration.": "Creating a fleet involves three steps: defining a label selector (which devices belong to this fleet), configuring the device template (desired OS, applications, and configurations), and setting an update policy (batch size and disruption budget for rollouts). Once created, matching devices automatically receive the fleet configuration.", + "How fleets work": "How fleets work", + "Fleets use label selectors to match devices. Any device whose labels satisfy the selector automatically becomes a fleet member. The fleet's device template defines what that group of devices should look like (OS version, apps, configurations). When you update the template, changes roll out to all members based on the fleet's update policy.": "Fleets use label selectors to match devices. Any device whose labels satisfy the selector automatically becomes a fleet member. The fleet's device template defines what that group of devices should look like (OS version, apps, configurations). When you update the template, changes roll out to all members based on the fleet's update policy.", + "Think of fleets like a desired state: you declare what devices should run, and {{ productName }} drives them toward it.": "Think of fleets like a desired state: you declare what devices should run, and {{ productName }} drives them toward it.", + "Fleets group edge devices that share the same desired configuration: OS image, installed applications, and update policies. From this page you can see which devices belong to each fleet and check fleet compliance and system image versions.": "Fleets group edge devices that share the same desired configuration: OS image, installed applications, and update policies. From this page you can see which devices belong to each fleet and check fleet compliance and system image versions.", + "Fleets group devices that share the same configuration. This page is empty right now. Once you create a fleet, each row will show its name, system image, member count, and rollout status.": "Fleets group devices that share the same configuration. This page is empty right now. Once you create a fleet, each row will show its name, system image, member count, and rollout status.", + "Fleets group devices that share the same configuration. Each row shows a fleet name, system image, member count, and rollout status.": "Fleets group devices that share the same configuration. Each row shows a fleet name, system image, member count, and rollout status.", + "Open the Fleets page": "Open the Fleets page", + "Devices matching a fleet's label selector join automatically.": "Devices matching a fleet's label selector join automatically.", + "Use Create fleet in the empty state to create your first fleet.": "Use Create fleet in the empty state to create your first fleet.", + "Update fleet configurations": "Update fleet configurations", + "Open any fleet's detail page and click \"Edit configurations\" to modify the device template. Changes trigger a rollout: {{ productName }} updates member devices according to the fleet's update policy.": "Open any fleet's detail page and click \"Edit configurations\" to modify the device template. Changes trigger a rollout: {{ productName }} updates member devices according to the fleet's update policy.", + "Browse fleets and see member counts, system images, and rollout status in the table": "Browse fleets and see member counts, system images, and rollout status in the table", + "Open a fleet detail page to review its device selector and configuration template": "Open a fleet detail page to review its device selector and configuration template", + "Update fleet configurations and track rollout progress": "Update fleet configurations and track rollout progress", + "What you can do on Fleets": "What you can do on Fleets", + "The Fleets page is where your organization groups edge devices that share the same configuration.": "The Fleets page is where your organization groups edge devices that share the same configuration.", + "Creating fleets and defining rollout policies are handled by your fleet administrator.": "Creating fleets and defining rollout policies are handled by your fleet administrator.", + "Build your base image": "Build your base image", + "Name your build and choose the source OS from your registry: select the repository, enter the base image path and version tag, and confirm the reference URL {{ productName }} will pull from.": "Name your build and choose the source OS from your registry: select the repository, enter the base image path and version tag, and confirm the reference URL {{ productName }} will pull from.", + "Use a descriptive build name and registry paths your team recognizes.": "Use a descriptive build name and registry paths your team recognizes.", + "Configure device enrollment": "Configure device enrollment", + "Choose how devices enroll with {{ productName }}. Early binding embeds an enrollment certificate in the image so devices auto-register on first boot with zero-touch provisioning. No cloud-init or Ignition scripts are needed. Late binding omits the certificate and relies on cloud-init or Ignition at first boot to complete registration instead. You can also configure optional first-boot settings such as the onboarding wizard and SSH access.": "Choose how devices enroll with {{ productName }}. Early binding embeds an enrollment certificate in the image so devices auto-register on first boot with zero-touch provisioning. No cloud-init or Ignition scripts are needed. Late binding omits the certificate and relies on cloud-init or Ignition at first boot to complete registration instead. You can also configure optional first-boot settings such as the onboarding wizard and SSH access.", + "Early binding is recommended for most edge deployments. Use wizard Next when you are ready for Software Catalog.": "Early binding is recommended for most edge deployments. Use wizard Next when you are ready for Software Catalog.", + "Configure your image output": "Configure your image output", + "Choose where the finished image is pushed, name and tag the output image, and optionally select disk export formats (ISO, QCOW2, and others).": "Choose where the finished image is pushed, name and tag the output image, and optionally select disk export formats (ISO, QCOW2, and others).", + "Use the wizard Next button when you are ready to continue to device enrollment.": "Use the wizard Next button when you are ready to continue to device enrollment.", + "Expand a build row": "Expand a build row", + "After a build finishes, click the expand control at the start of its row to open the summary panel. Use the panel to track progress, review exports, and open the detail page.": "After a build finishes, click the expand control at the start of its row to open the summary panel. Use the panel to track progress, review exports, and open the detail page.", + "Building and exporting can take a few minutes. You can close the guide and return when your build is ready.": "Building and exporting can take a few minutes. You can close the guide and return when your build is ready.", + "Explore the build detail page": "Explore the build detail page", + "The detail page summarizes your build configuration. Base image shows status and settings cards, Export images lists disk formats, YAML shows the resource definition, and Logs can help with troubleshooting.": "The detail page summarizes your build configuration. Base image shows status and settings cards, Export images lists disk formats, YAML shows the resource definition, and Logs can help with troubleshooting.", + "The Actions menu at the top includes Rebuild, Add to catalog, and other operations when you have permission.": "The Actions menu at the top includes Rebuild, Add to catalog, and other operations when you have permission.", + "This is the last step in the image build phase.": "This is the last step in the image build phase.", + "Explore the details page": "Explore the details page", + "On the details page, tabs organize what you can review: Base image for settings, Export images for disk formats, YAML for the resource definition, and Logs for troubleshooting.": "On the details page, tabs organize what you can review: Base image for settings, Export images for disk formats, YAML for the resource definition, and Logs for troubleshooting.", + "Create a new build": "Create a new build", + "Create a new version (Rebuild)": "Create a new version (Rebuild)", + "Open build row actions": "Open build row actions", + "Open the kebab menu at the end of the image build row to see actions you can do": "Open the kebab menu at the end of the image build row to see actions you can do", + "You can perform the same actions via the build detail page as well.": "You can perform the same actions via the build detail page as well.", + "Example image build": "Example image build", + "Meet the image build wizard": "Meet the image build wizard", + "This wizard builds a custom edge OS image from a base OS and pushes the result to your registry. Work through five steps on the left (Base image, Image output, Registration, Software Catalog, and Review) with the form for each step on the right. Use the wizard Next button to move between steps. This guide follows along one step at a time.": "This wizard builds a custom edge OS image from a base OS and pushes the result to your registry. Work through five steps on the left (Base image, Image output, Registration, Software Catalog, and Review) with the form for each step on the right. Use the wizard Next button to move between steps. This guide follows along one step at a time.", + "Base image and Image output are required. Registration sets enrollment binding. Software Catalog is optional.": "Base image and Image output are required. Registration sets enrollment binding. Software Catalog is optional.", + "Open the build detail page": "Open the build detail page", + "Click the build name in the list or View more in the expanded panel to open the detail page for configuration, exports, YAML, and logs.": "Click the build name in the list or View more in the expanded panel to open the detail page for configuration, exports, YAML, and logs.", + "You can open any build from the list.": "You can open any build from the list.", + "Open the details page": "Open the details page", + "Click a build name in the list or choose View more after expanding a row to open its detail page for configuration, exports, YAML, and logs.": "Click a build name in the list or choose View more after expanding a row to open its detail page for configuration, exports, YAML, and logs.", + "The Image builds page tracks OS image builds across your organization. Your administrator or image builder creates and manages images here.": "The Image builds page tracks OS image builds across your organization. Your administrator or image builder creates and manages images here.", + "This is the Image builds page. Here you will see image builds and their status in the list. From here you can open a build to review its configuration and progress.": "This is the Image builds page. Here you will see image builds and their status in the list. From here you can open a build to review its configuration and progress.", + "Open Image builds in the left sidebar, then continue here.": "Open Image builds in the left sidebar, then continue here.", + "Browse the builds in the table behind this guide": "Browse the builds in the table behind this guide", + "Create your first build to get started": "Create your first build to get started", + "Navigate to Image builds": "Navigate to Image builds", + "Preview in the list": "Preview in the list", + "Expand a row using the arrow on the left to see a build summary and any export formats available.": "Expand a row using the arrow on the left to see a build summary and any export formats available.", + "The image builds table is empty right now. Once a build finishes, expand its row to see a summary and any export formats available.": "The image builds table is empty right now. Once a build finishes, expand its row to see a summary and any export formats available.", + "Publish to Software Catalog": "Publish to Software Catalog", + "Optionally publish the built image to the Software Catalog testing channel after a successful build so other teams can discover and deploy it.": "Optionally publish the built image to the Software Catalog testing channel after a successful build so other teams can discover and deploy it.", + "You can skip this step or publish later from the build detail page. Use wizard Next when you are ready for review.": "You can skip this step or publish later from the build detail page. Use wizard Next when you are ready for review.", + "Review and start the build": "Review and start the build", + "Confirm your base image, output, registration, and catalog choices, then click Build image to start the build.": "Confirm your base image, output, registration, and catalog choices, then click Build image to start the build.", + "Review the summary on the right before submitting.": "Review the summary on the right before submitting.", + "Start a new image build": "Start a new image build", + "Click \"Build new image\" in the toolbar to open the image build wizard. This guides you through selecting a base OS, configuring output, embedding the enrollment certificate, and optionally publishing to the Software Catalog.": "Click \"Build new image\" in the toolbar to open the image build wizard. This guides you through selecting a base OS, configuring output, embedding the enrollment certificate, and optionally publishing to the Software Catalog.", + "You'll need access to at least one OCI repository where base images are stored.": "You'll need access to at least one OCI repository where base images are stored.", + "Request a new or updated OS image": "Request a new or updated OS image", + "Obtain a disk image for device provisioning": "Obtain a disk image for device provisioning", + "Publish a build to the Software Catalog": "Publish a build to the Software Catalog", + "See image builds and their status in the list": "See image builds and their status in the list", + "Open a build to review its configuration and progress": "Open a build to review its configuration and progress", + "Review build logs when troubleshooting": "Review build logs when troubleshooting", + "Download exported disk images when they are available": "Download exported disk images when they are available", + "What you can do on Image builds": "What you can do on Image builds", + "From the Image builds area you can": "From the Image builds area you can", + "The Image builds area is where your organization prepares custom edge OS images.": "The Image builds area is where your organization prepares custom edge OS images.", + "New images are created through a build wizard managed by your administrator or image builder.": "New images are created through a build wizard managed by your administrator or image builder.", + "Navigate the console": "Navigate the console", + "The left sidebar is your primary navigation. Each section corresponds to a stage of the edge device lifecycle: Image builds for OS preparation, Devices for enrollment and monitoring, Fleets for group configuration, Software Catalog for application management, and Repositories for GitOps sources.": "The left sidebar is your primary navigation. Each section corresponds to a stage of the edge device lifecycle: Image builds for OS preparation, Devices for enrollment and monitoring, Fleets for group configuration, Software Catalog for application management, and Repositories for GitOps sources.", + "Sections visible to you depend on your permissions. If something isn't listed, your administrator controls access.": "Sections visible to you depend on your permissions. If something isn't listed, your administrator controls access.", + "Explore the Overview dashboard": "Explore the Overview dashboard", + "The Overview page gives you an at-a-glance summary of your edge environment. The Status card shows device and application health. The Tasks card tracks pending enrollment requests. The Security card surfaces active vulnerabilities. Use this page as your starting point each day.": "The Overview page gives you an at-a-glance summary of your edge environment. The Status card shows device and application health. The Tasks card tracks pending enrollment requests. The Security card surfaces active vulnerabilities. Use this page as your starting point each day.", + "Cards adapt to what you have permission to see. Some may not appear for all roles.": "Cards adapt to what you have permission to see. Some may not appear for all roles.", + "What is {{ productName }}?": "What is {{ productName }}?", + "{{ productName }} lets you build OS images, enroll edge devices, organize them into fleets, and deploy software, all from a single console. It handles the full lifecycle from initial provisioning through day-2 operations like updates, monitoring, and decommissioning.": "{{ productName }} lets you build OS images, enroll edge devices, organize them into fleets, and deploy software, all from a single console. It handles the full lifecycle from initial provisioning through day-2 operations like updates, monitoring, and decommissioning.", + "Think of it as a control plane for all your edge hardware, no matter where it is deployed.": "Think of it as a control plane for all your edge hardware, no matter where it is deployed.", + "Locked": "Locked", "{{count}} minutes_one": "{{count}} minute", "{{count}} minutes_other": "{{count}} minutes", "{{count}} tasks_one": "{{count}} task", "{{count}} tasks_other": "{{count}} tasks", - "Locked": "Locked", "Prerequisites": "Prerequisites", "Complete \"{{prevPhaseTitle}}\"": "Complete \"{{prevPhaseTitle}}\"", "Prerequisites (1)": "Prerequisites (1)", "To show it again, open Settings in the toolbar above and choose Show quick start guide.": "To show it again, open Settings in the toolbar above and choose Show quick start guide.", "Quick start guide": "Quick start guide", + "Follow these phases to get your edge management environment up and running.": "Follow these phases to get your edge management environment up and running.", "{{completed}}/{{total}} phases complete": "{{completed}}/{{total}} phases complete", - "Expand guide": "Expand guide", "Collapse guide": "Collapse guide", - "Follow these phases to get your edge management environment up and running.": "Follow these phases to get your edge management environment up and running.", "Progress": "Progress", "{{completed}} of {{total}}": "{{completed}} of {{total}}", "About {{count}} minutes_one": "About {{count}} minute", "About {{count}} minutes_other": "About {{count}} minutes", "Welcome & Orientation": "Welcome & Orientation", - "Get familiar with {{ productName }} and learn how to navigate the console.": "Get familiar with {{ productName }} and learn how to navigate the console.", "Build an OS Image": "Build an OS Image", - "Create a custom edge OS image so devices auto-register on boot.": "Create a custom edge OS image so devices auto-register on boot.", "Enroll Your First Device": "Enroll Your First Device", - "Boot a device with your image, then approve it to bring it under management.": "Boot a device with your image, then approve it to bring it under management.", "Create and Manage Fleets": "Create and Manage Fleets", - "Group devices that share identical or similar configuration and update policy for centralized management.": "Group devices that share identical or similar configuration and update policy for centralized management.", - "Close guide": "Close guide", - "Minimize guide": "Minimize guide", - "Explore the console": "Explore the console", - "Orientation": "Orientation", - "Some description here.": "Some description here.", - "Sections visible to you depend on your permissions.": "Sections visible to you depend on your permissions.", - "Drag guide": "Drag guide", + "Get familiar with {{ productName }} and learn how to navigate the console.": "Get familiar with {{ productName }} and learn how to navigate the console.", + "Create a custom edge OS image so devices auto-register on boot.": "Create a custom edge OS image so devices auto-register on boot.", + "Boot a device with your image, then approve it to bring it under management.": "Boot a device with your image, then approve it to bring it under management.", + "Learn how fleets group devices, what you can view on the Fleets page, and how fleet administrators manage rollouts.": "Learn how fleets group devices, what you can view on the Fleets page, and how fleet administrators manage rollouts.", + "Group devices that share the same OS image and update policy into a fleet for centralized management.": "Group devices that share the same OS image and update policy into a fleet for centralized management.", "Failed to retrieve repository details": "Failed to retrieve repository details", "Failed to retrieve the resource syncs": "Failed to retrieve the resource syncs", "The repository cannot be modified at the moment because some of its details could not be obtained.": "The repository cannot be modified at the moment because some of its details could not be obtained.", @@ -1951,6 +2091,8 @@ "Product UUID": "Product UUID", "TPM vendor info": "TPM vendor info", "Package": "Package", + "Red Hat Edge Manager": "Red Hat Edge Manager", + "Flight Control": "Flight Control", "Unknown error": "Unknown error", "Failed to connect to device terminal: {{reason}}": "Failed to connect to device terminal: {{reason}}", "Failed to connect to device terminal": "Failed to connect to device terminal", @@ -2021,13 +2163,11 @@ "Error": "Error", "Degraded": "Degraded", "No applications": "No applications", - "Healthy": "Healthy", "Preparing": "Preparing", "Starting": "Starting", "Stopping": "Stopping", "Rebooting": "Rebooting", "Powered Off": "Powered Off", - "Online": "Online", "Pending sync": "Pending sync", "Suspended": "Suspended", "Name and alias": "Name and alias", @@ -2038,7 +2178,6 @@ "Pending approval": "Pending approval", "Approved": "Approved", "Denied": "Denied", - "Valid": "Valid", "Invalid": "Invalid", "Sync pending": "Sync pending", "Awaiting first sync": "Awaiting first sync", diff --git a/libs/ui-components/src/components/EnrollmentRequest/EnrollmentRequestList.tsx b/libs/ui-components/src/components/EnrollmentRequest/EnrollmentRequestList.tsx index fe369cf94..3bdf27601 100644 --- a/libs/ui-components/src/components/EnrollmentRequest/EnrollmentRequestList.tsx +++ b/libs/ui-components/src/components/EnrollmentRequest/EnrollmentRequestList.tsx @@ -28,7 +28,7 @@ const EnrollmentRequestEmptyState = () => { return ; }; -const getEnrollmentColumns = (t: TFunction) => [ +export const getEnrollmentRequestTableColumns = (t: TFunction) => [ { name: t('Alias'), }, @@ -57,7 +57,7 @@ const EnrollmentRequestList = ({ refetchDevices, isStandalone }: EnrollmentReque const { remove } = useFetch(); const [search, setSearch] = React.useState(''); - const enrollmentColumns = React.useMemo(() => getEnrollmentColumns(t), [t]); + const enrollmentColumns = React.useMemo(() => getEnrollmentRequestTableColumns(t), [t]); const [pendingEnrollments, isLoading, error, refetch, pagination] = usePendingEnrollments(search); const itemCount = pendingEnrollments.length; diff --git a/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/steps/RegistrationStep.tsx b/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/steps/RegistrationStep.tsx index 8848749d3..62ba5aa54 100644 --- a/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/steps/RegistrationStep.tsx +++ b/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/steps/RegistrationStep.tsx @@ -23,7 +23,7 @@ import { BindingType } from '@flightctl/types/imagebuilder'; import { type ImageBuildFormValues } from '../types'; import { PUBLIC_KEY_MAX_LENGTH } from '../../../form/validations'; import { useTranslation } from '../../../../hooks/useTranslation'; -import { useAppContext } from '../../../../hooks/useAppContext'; +import { useProductName } from '../../../../hooks/useProductName'; import FlightCtlForm from '../../../form/FlightCtlForm'; import TextField from '../../../form/TextField'; import UploadField from '../../../form/UploadField'; @@ -43,9 +43,8 @@ export const isRegistrationStepValid = (errors: FormikErrors { const { t } = useTranslation(); - const { settings } = useAppContext(); const { values, setFieldValue } = useFormikContext(); - const productName = settings.isRHEM ? t('Red Hat Edge Manager') : t('Flight Control'); + const productName = useProductName(); const isEarlyBindingSelected = values.bindingType === BindingType.BindingTypeEarly; diff --git a/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/steps/ReviewStep.tsx b/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/steps/ReviewStep.tsx index 42cc09c04..992f948f5 100644 --- a/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/steps/ReviewStep.tsx +++ b/libs/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/steps/ReviewStep.tsx @@ -20,7 +20,7 @@ import { InfoCircleIcon } from '@patternfly/react-icons/dist/js/icons/info-circl import { BindingType } from '@flightctl/types/imagebuilder'; import { useTranslation } from '../../../../hooks/useTranslation'; -import { useAppContext } from '../../../../hooks/useAppContext'; +import { useProductName } from '../../../../hooks/useProductName'; import { type ImageBuildFormValues, type ImageBuildWizardError } from '../types'; import { getImageReference } from '../../../../utils/imageBuilds'; import { getExportFormatLabel } from '../../../../utils/imageBuilds'; @@ -39,9 +39,8 @@ type ReviewStepProps = { const ReviewStep = ({ error }: ReviewStepProps) => { const { t } = useTranslation(); - const { settings } = useAppContext(); const { values } = useFormikContext(); - const productName = settings.isRHEM ? t('Red Hat Edge Manager') : t('Flight Control'); + const productName = useProductName(); const { ociRegistries } = useOciRegistriesContext(); const srcImageReference = React.useMemo( diff --git a/libs/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildDetailsTab.tsx b/libs/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildDetailsTab.tsx index 4470bfc39..f108c0a2b 100644 --- a/libs/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildDetailsTab.tsx +++ b/libs/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildDetailsTab.tsx @@ -28,7 +28,7 @@ import ImagePromotionStatus from '../../ImagePromotion/ImagePromotionStatus'; import { getDateDisplay } from '../../../utils/dates'; import { getExportFormatLabel, getImageReference } from '../../../utils/imageBuilds'; import { useTranslation } from '../../../hooks/useTranslation'; -import { useAppContext } from '../../../hooks/useAppContext'; +import { useProductName } from '../../../hooks/useProductName'; import DetailsPageCard from '../../DetailsPage/DetailsPageCard'; import { CERTIFICATE_VALIDITY_IN_YEARS } from '../../../constants'; import { type ImageBuildWithExports } from '../../../types/extraTypes'; @@ -203,9 +203,8 @@ const ImagePromotionsCard = ({ const ImageBuildDetailsTab = ({ imageBuild }: { imageBuild: ImageBuildWithExports }) => { const { t } = useTranslation(); - const { settings } = useAppContext(); const { checkPermissions } = usePermissionsContext(); - const productName = settings.isRHEM ? t('Red Hat Edge Manager') : t('Flight Control'); + const productName = useProductName(); const [canListPromotions, canEditPromotions, canDeletePromotions] = checkPermissions(detailsPermissions); const srcRepositoryName = imageBuild.spec.source.repository; const dstRepositoryName = imageBuild.spec.destination.repository; diff --git a/libs/ui-components/src/components/Masthead/CommandLineToolsPage.tsx b/libs/ui-components/src/components/Masthead/CommandLineToolsPage.tsx index 0aa89e550..9fc6d9745 100644 --- a/libs/ui-components/src/components/Masthead/CommandLineToolsPage.tsx +++ b/libs/ui-components/src/components/Masthead/CommandLineToolsPage.tsx @@ -14,7 +14,7 @@ import { import ExternalLinkAltIcon from '@patternfly/react-icons/dist/js/icons/external-link-alt-icon'; import { useTranslation } from '../../hooks/useTranslation'; -import { useAppContext } from '../../hooks/useAppContext'; +import { useProductName } from '../../hooks/useProductName'; import { type CliArtifactsDisplayResponse, useCliArtifacts } from '../../hooks/useCliArtifacts'; import { type CliArtifact, CliArtifactTool } from '../../types/extraTypes'; import { getArtifactDownloadLabel, getArtifactUrl } from '../../utils/cliArtifacts'; @@ -145,10 +145,9 @@ const CommandLineToolsContent = ({ const CommandLineToolsPage = () => { const { t } = useTranslation(); - const { settings } = useAppContext(); const { loading, loadError, hasArtifactsEnabled, artifactsResponse } = useCliArtifacts(); - const productName = settings.isRHEM ? t('Red Hat Edge Manager') : t('Flight Control'); + const productName = useProductName(); return ( diff --git a/libs/ui-components/src/components/QuickStart/QuickStart.css b/libs/ui-components/src/components/QuickStart/QuickStart.css index 906bf3e12..b84b37dea 100644 --- a/libs/ui-components/src/components/QuickStart/QuickStart.css +++ b/libs/ui-components/src/components/QuickStart/QuickStart.css @@ -1,7 +1,25 @@ -.fctl-quickstart-card { +/* Quick start panel on the Overview page */ +.fctl-quickstart-panel { margin-bottom: var(--pf-t--global--spacer--xl); } -.fctl-quickstart-card__expanded { +.fctl-quickstart-panel__expanded { border-top: 3px solid var(--pf-t--global--color--brand--default, #06c); } + +/* Individual phase cards in the quick start panel */ +.fctl-quickstart-phase { + width: 100%; +} + +.fctl-quickstart-phase.fctl-quickstart-phase--locked { + opacity: 65%; +} + +.fctl-quickstart-phase .fctl-quickstart-phase__icon .pf-v6-c-icon__content { + --pf-v6-c-icon__content--Color: var(--pf-t--global--icon--color--brand--default); +} + +.fctl-quickstart-phase.fctl-quickstart-phase--locked .pf-v6-c-icon__content { + --pf-v6-c-icon__content--Color: var(--pf-t--global--icon--color--disabled); +} diff --git a/libs/ui-components/src/components/QuickStart/QuickStart.tsx b/libs/ui-components/src/components/QuickStart/QuickStart.tsx index 86df3e048..758e49351 100644 --- a/libs/ui-components/src/components/QuickStart/QuickStart.tsx +++ b/libs/ui-components/src/components/QuickStart/QuickStart.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { TFunction } from 'i18next'; import { Button, Card, @@ -31,14 +32,18 @@ import HomeIcon from '@patternfly/react-icons/dist/js/icons/home-icon'; import BuilderImageIcon from '@patternfly/react-icons/dist/js/icons/builder-image-icon'; import PlusCircleIcon from '@patternfly/react-icons/dist/js/icons/plus-circle-icon'; import CubesIcon from '@patternfly/react-icons/dist/js/icons/cubes-icon'; +import CheckCircleIcon from '@patternfly/react-icons/dist/js/icons/check-circle-icon'; -import type { QuickStartPhase } from './types'; +import type { PhaseCardStatus, QuickStartPhase, QuickStartPhaseIcon, QuickStartPhaseId } from './types'; import { useTranslation } from '../../hooks/useTranslation'; +import { useProductName } from '../../hooks/useProductName'; +import { usePermissionsContext } from '../common/PermissionsContext'; import { useQuickStart } from './QuickStartContext'; +import { getPhaseDescription, getPhaseTitle } from './quickStartDefinitions'; import './QuickStart.css'; -const PHASE_ICONS: Record = { +const PHASE_ICONS: Record = { home: HomeIcon, image: BuilderImageIcon, plus: PlusCircleIcon, @@ -47,104 +52,128 @@ const PHASE_ICONS: Record = { interface PhaseCardProps { card: QuickStartPhase; - isLocked: boolean; - prevPhaseTitle: string | undefined; + prevPhaseId?: QuickStartPhaseId; } -const PhaseCard = ({ card, isLocked, prevPhaseTitle }: PhaseCardProps) => { +const getActionLabel = (t: TFunction, status: PhaseCardStatus) => { + switch (status) { + case 'complete': + return t('Restart'); + case 'in-progress': + return t('Continue'); + default: + return t('Start'); + } +}; + +const CardStatusLabel = ({ status }: { status: PhaseCardStatus }) => { + const { t } = useTranslation(); + + switch (status) { + case 'complete': + return ( + + ); + case 'in-progress': + return ( + + ); + case 'locked': + return ( + + ); + default: + return null; + } +}; + +const PhaseCard = ({ card, prevPhaseId }: PhaseCardProps) => { const { t } = useTranslation(); const { startPhase } = useQuickStart(); - const PhaseIcon = PHASE_ICONS[card.icon] || HomeIcon; + const { checkPermissions } = usePermissionsContext(); + const PhaseIcon = PHASE_ICONS[card.icon]; + + const isLocked = card.status === 'locked'; + const actionLabel = getActionLabel(t, card.status); + const productName = useProductName(); + + let lockedByPhase: string | undefined; + if (isLocked) { + lockedByPhase = prevPhaseId ? getPhaseTitle(t, prevPhaseId) : undefined; + } return ( - - - - + + + + - - {card.title} - + {getPhaseTitle(t, card.id)} - - {isLocked && ( - - - {card.description} + + {getPhaseDescription(t, card.id, productName, checkPermissions)} - -
- {isLocked ? ( - -
  • {t('Complete "{{prevPhaseTitle}}"', { prevPhaseTitle })}
  • - - } - triggerAction="hover" - > - +
    + ) : ( + - - ) : ( - - )} -
    + )} +
    +
    ); }; -const QuickStartToggleAction = ({ onToggle, compact = false }: { onToggle: () => void; compact?: boolean }) => { +const QuickStartToggleAction = ({ onToggle, compact = false }: { onToggle: VoidFunction; compact?: boolean }) => { const { t } = useTranslation(); return ( @@ -158,31 +187,33 @@ const QuickStartToggleAction = ({ onToggle, compact = false }: { onToggle: () => const QuickStart = () => { const { t } = useTranslation(); - const { panelVisibility, phases, toggleVisibility, toggleCollapsed } = useQuickStart(); + const { panelVisibility, phases, totalPhaseCount, totalMinutes, toggleVisibility, toggleCollapsed } = useQuickStart(); if (panelVisibility === 'hidden') { return null; } - const totalPhases = phases.length; + const heading = t('Quick start guide'); + const subtitle = t('Follow these phases to get your edge management environment up and running.'); + const completedPhaseCount = phases.filter((phase) => phase.status === 'complete').length; if (panelVisibility === 'collapsed') { return ( - + - {t('Quick start guide')} + {heading} {t('{{completed}}/{{total}} phases complete', { - completed: 0, - total: totalPhases, + completed: completedPhaseCount, + total: totalPhaseCount, })} @@ -210,8 +241,10 @@ const QuickStart = () => { ); } + const progressValue = totalPhaseCount > 0 ? Math.round((completedPhaseCount / totalPhaseCount) * 100) : 0; + return ( - + { - {t('Quick start guide')} + {heading} - - {t('Follow these phases to get your edge management environment up and running.')} - + {subtitle} @@ -245,10 +276,13 @@ const QuickStart = () => { @@ -260,7 +294,7 @@ const QuickStart = () => { - {t('About {{count}} minutes', { count: 44 })} + {t('About {{count}} minutes', { count: totalMinutes })} @@ -269,15 +303,14 @@ const QuickStart = () => { - {phases.map((phase, index) => ( - - 0} - prevPhaseTitle={index === 0 ? undefined : phases[index - 1].title} - /> - - ))} + {phases.map((phase, index) => { + const prevPhaseId = index === 0 ? undefined : phases[index - 1]?.id; + return ( + + + + ); + })} diff --git a/libs/ui-components/src/components/QuickStart/QuickStartContext.tsx b/libs/ui-components/src/components/QuickStart/QuickStartContext.tsx index aaa997208..c97fd60ab 100644 --- a/libs/ui-components/src/components/QuickStart/QuickStartContext.tsx +++ b/libs/ui-components/src/components/QuickStart/QuickStartContext.tsx @@ -1,40 +1,54 @@ import * as React from 'react'; -import { getQuickStartPhases } from './quickStartDefinitions'; +import { usePermissionsContext } from '../common/PermissionsContext'; +import { quickStartPhaseDefinitions } from './quickStartDefinitions'; +import { getPhaseStatus, getPhaseStepCount, isPhaseComplete } from './quickStartPhaseUtils'; import { getPersistedQuickStartState, persistQuickStartState } from './quickStartStorage'; -import { useTranslation } from '../../hooks/useTranslation'; -import { useQuickStartProductName } from './useQuickStartProductName'; import { + DEFAULT_GUIDE_PRESENTATION, type PanelVisibility, + type QuickStartGuideActions, + type QuickStartGuidePresentation, type QuickStartPersistedState, type QuickStartPhase, type QuickStartPhaseId, } from './types'; - -interface QuickStartGuideActions { - startPhase: (phaseId: QuickStartPhaseId) => void; - cancelPhase: VoidFunction; - setGuideMinimized: (minimized: boolean) => void; -} +import { VERB } from '../../types/rbac'; export type QuickStartPanelValue = { panelVisibility: PanelVisibility; phases: QuickStartPhase[]; + totalPhaseCount: number; + totalMinutes: number; + completedPhases: QuickStartPhaseId[]; + activePhaseId: QuickStartPhaseId | null; + activeStepIndex: number; toggleVisibility: VoidFunction; toggleCollapsed: VoidFunction; startPhase: (phaseId: QuickStartPhaseId) => void; }; -export type QuickStartGuideValue = QuickStartGuideActions & { - activePhase: QuickStartPhase | null; +export type QuickStartPhaseSessionValue = { + activeStepIndex: number; isGuideMinimized: boolean; + setStepIndex: (stepIndex: number) => void; + setGuideMinimized: (minimized: boolean) => void; + setGuidePresentation: (patch: Partial) => void; + setGuideActions: (actions: QuickStartGuideActions | null) => void; + completePhase: VoidFunction; + cancelPhase: VoidFunction; +}; + +export type QuickStartGuideValue = QuickStartPhaseSessionValue & { + activePhaseId: QuickStartPhaseId | null; + guidePresentation: QuickStartGuidePresentation; + guideActions: QuickStartGuideActions | null; }; type QuickStartStoreValue = QuickStartPanelValue & - QuickStartGuideActions & { - activePhaseId: QuickStartPhaseId | null; - isGuideMinimized: boolean; - phases: QuickStartPhase[]; + QuickStartPhaseSessionValue & { + guidePresentation: QuickStartGuidePresentation; + guideActions: QuickStartGuideActions | null; }; const QuickStartContext = React.createContext(null); @@ -48,43 +62,79 @@ const useQuickStartStore = (): QuickStartStoreValue => { }; export const useQuickStart = (): QuickStartPanelValue => { - const { panelVisibility, phases, toggleVisibility, toggleCollapsed, startPhase } = useQuickStartStore(); - + const store = useQuickStartStore(); return { - panelVisibility, - phases, - toggleVisibility, - toggleCollapsed, - startPhase, + panelVisibility: store.panelVisibility, + phases: store.phases, + totalPhaseCount: store.totalPhaseCount, + totalMinutes: store.totalMinutes, + completedPhases: store.completedPhases, + activePhaseId: store.activePhaseId, + activeStepIndex: store.activeStepIndex, + toggleVisibility: store.toggleVisibility, + toggleCollapsed: store.toggleCollapsed, + startPhase: store.startPhase, }; }; +/** Guide tree API — panel code should use useQuickStart() instead. */ export const useQuickStartGuide = (): QuickStartGuideValue => { - const { activePhaseId, isGuideMinimized, phases, startPhase, cancelPhase, setGuideMinimized } = useQuickStartStore(); - - const activePhase = activePhaseId ? phases.find((phase) => phase.id === activePhaseId) ?? null : null; - + const store = useQuickStartStore(); return { - activePhase, - isGuideMinimized, - startPhase, - cancelPhase, - setGuideMinimized, + activePhaseId: store.activePhaseId, + activeStepIndex: store.activeStepIndex, + guidePresentation: store.guidePresentation, + guideActions: store.guideActions, + isGuideMinimized: store.isGuideMinimized, + setStepIndex: store.setStepIndex, + setGuideMinimized: store.setGuideMinimized, + setGuidePresentation: store.setGuidePresentation, + setGuideActions: store.setGuideActions, + completePhase: store.completePhase, + cancelPhase: store.cancelPhase, }; }; -interface QuickStartProviderProps { - children: React.ReactNode; -} +export const QuickStartProvider = ({ children }: React.PropsWithChildren) => { + const { checkPermissions } = usePermissionsContext(); + + const { visiblePhases, phasesStepCounts, totalMinutes } = React.useMemo(() => { + const visiblePhases = quickStartPhaseDefinitions.filter((phase) => { + if (phase.listResourceNeeded === null) { + return true; + } + return checkPermissions([{ kind: phase.listResourceNeeded, verb: VERB.LIST }])[0]; + }); -export const QuickStartProvider = ({ children }: QuickStartProviderProps) => { - const { t } = useTranslation(); - const productName = useQuickStartProductName(); - const phases = React.useMemo(() => getQuickStartPhases(t, productName), [t, productName]); + let minutesAcc = 0; + const stepCounts = visiblePhases.reduce( + (acc, phase) => { + minutesAcc += phase.estimatedMinutes; + acc[phase.id] = getPhaseStepCount(phase.id, checkPermissions); + return acc; + }, + {} as Record, + ); + return { visiblePhases, phasesStepCounts: stepCounts, totalMinutes: minutesAcc }; + }, [checkPermissions]); const [persisted, setPersisted] = React.useState(getPersistedQuickStartState); const [activePhaseId, setActivePhaseId] = React.useState(null); + const [activeStepIndex, setActiveStepIndex] = React.useState(0); const [isGuideMinimized, setIsGuideMinimized] = React.useState(false); + const [guidePresentation, setGuidePresentationState] = + React.useState(DEFAULT_GUIDE_PRESENTATION); + const [guideActions, setGuideActionsState] = React.useState(null); + + const phases = React.useMemo(() => { + return visiblePhases.map((definition, index) => { + return { + ...definition, + stepCount: phasesStepCounts[definition.id] ?? 0, + status: getPhaseStatus(definition.id, index, visiblePhases, persisted, activePhaseId), + }; + }); + }, [activePhaseId, persisted, phasesStepCounts, visiblePhases]); const updatePersisted = React.useCallback((updater: (prev: QuickStartPersistedState) => QuickStartPersistedState) => { setPersisted((prev) => { @@ -94,10 +144,25 @@ export const QuickStartProvider = ({ children }: QuickStartProviderProps) => { }); }, []); + const resetGuidePresentation = React.useCallback(() => { + setGuidePresentationState(DEFAULT_GUIDE_PRESENTATION); + }, []); + + const setGuidePresentation = React.useCallback((patch: Partial) => { + setGuidePresentationState((prev) => ({ ...prev, ...patch })); + }, []); + + const setGuideActions = React.useCallback((actions: QuickStartGuideActions | null) => { + setGuideActionsState(actions); + }, []); + const clearGuideSession = React.useCallback(() => { setActivePhaseId(null); + setActiveStepIndex(0); setIsGuideMinimized(false); - }, []); + resetGuidePresentation(); + setGuideActionsState(null); + }, [resetGuidePresentation]); const toggleVisibility = React.useCallback(() => { updatePersisted((prev) => ({ @@ -116,46 +181,129 @@ export const QuickStartProvider = ({ children }: QuickStartProviderProps) => { const startPhase = React.useCallback( (phaseId: QuickStartPhaseId) => { - const firstPhaseId = phases[0]?.id; - if (!firstPhaseId || phaseId !== firstPhaseId) { + const phase = phases.find((entry) => entry.id === phaseId); + if (!phase || phase.status === 'locked') { return; } + + const savedIndex = persisted.phaseProgress[phaseId]?.lastStepIndex ?? 0; + const stepIndex = isPhaseComplete(phaseId, persisted) + ? 0 + : Math.min(savedIndex, Math.max(phase.stepCount - 1, 0)); + setActivePhaseId(phaseId); + setActiveStepIndex(stepIndex); setIsGuideMinimized(false); + resetGuidePresentation(); + setGuideActionsState(null); + updatePersisted((prev) => ({ + ...prev, + phaseProgress: { + ...prev.phaseProgress, + [phaseId]: { lastStepIndex: stepIndex }, + }, + })); }, - [phases], + [persisted, phases, resetGuidePresentation, updatePersisted], + ); + + const setStepIndex = React.useCallback( + (stepIndex: number) => { + if (!activePhaseId) { + return; + } + + setActiveStepIndex(stepIndex); + setGuidePresentation({ stepTitle: null }); + updatePersisted((prev) => ({ + ...prev, + phaseProgress: { + ...prev.phaseProgress, + [activePhaseId]: { lastStepIndex: stepIndex }, + }, + })); + }, + [activePhaseId, setGuidePresentation, updatePersisted], ); const cancelPhase = React.useCallback(() => { + if (activePhaseId) { + updatePersisted((prev) => ({ + ...prev, + phaseProgress: { + ...prev.phaseProgress, + [activePhaseId]: { lastStepIndex: activeStepIndex }, + }, + })); + } clearGuideSession(); - }, [clearGuideSession]); + }, [activePhaseId, activeStepIndex, clearGuideSession, updatePersisted]); const setGuideMinimized = React.useCallback((minimized: boolean) => { setIsGuideMinimized(minimized); }, []); + const completePhase = React.useCallback(() => { + if (!activePhaseId) { + return; + } + + updatePersisted((prev) => { + const completedPhases = prev.completedPhases.includes(activePhaseId) + ? prev.completedPhases + : [...prev.completedPhases, activePhaseId]; + const remainingProgress = { ...prev.phaseProgress }; + delete remainingProgress[activePhaseId]; + return { + ...prev, + completedPhases, + phaseProgress: remainingProgress, + }; + }); + clearGuideSession(); + }, [activePhaseId, clearGuideSession, updatePersisted]); + const value = React.useMemo( () => ({ panelVisibility: persisted.panelVisibility, - toggleVisibility, - toggleCollapsed, + phases, + totalPhaseCount: phases.length, + totalMinutes: totalMinutes, + completedPhases: persisted.completedPhases, activePhaseId, + activeStepIndex, + guidePresentation, + guideActions, isGuideMinimized, - phases, + toggleVisibility, + toggleCollapsed, startPhase, - cancelPhase, + setStepIndex, setGuideMinimized, + setGuidePresentation, + setGuideActions, + completePhase, + cancelPhase, }), [ - persisted, - toggleVisibility, - toggleCollapsed, + persisted.panelVisibility, + persisted.completedPhases, + phases, + totalMinutes, activePhaseId, + activeStepIndex, + guidePresentation, + guideActions, isGuideMinimized, - phases, + toggleVisibility, + toggleCollapsed, startPhase, - cancelPhase, + setStepIndex, setGuideMinimized, + setGuidePresentation, + setGuideActions, + completePhase, + cancelPhase, ], ); diff --git a/libs/ui-components/src/components/QuickStart/QuickStartGuide.tsx b/libs/ui-components/src/components/QuickStart/QuickStartGuide.tsx index 15d567088..fdaba8f27 100644 --- a/libs/ui-components/src/components/QuickStart/QuickStartGuide.tsx +++ b/libs/ui-components/src/components/QuickStart/QuickStartGuide.tsx @@ -1,237 +1,38 @@ import * as React from 'react'; -import * as ReactDOM from 'react-dom'; -import { - Button, - Card, - CardBody, - CardFooter, - CardHeader, - CardTitle, - Content, - ContentVariants, - Flex, - FlexItem, - Label, - Split, - SplitItem, - Stack, - StackItem, -} from '@patternfly/react-core'; -import TimesIcon from '@patternfly/react-icons/dist/js/icons/times-icon'; -import WindowMinimizeIcon from '@patternfly/react-icons/dist/js/icons/window-minimize-icon'; -import OutlinedWindowRestoreIcon from '@patternfly/react-icons/dist/js/icons/outlined-window-restore-icon'; -import ArrowRightIcon from '@patternfly/react-icons/dist/js/icons/arrow-right-icon'; -import ArrowLeftIcon from '@patternfly/react-icons/dist/js/icons/arrow-left-icon'; -import EyeIcon from '@patternfly/react-icons/dist/js/icons/eye-icon'; -import { useTranslation } from '../../hooks/useTranslation'; -import { useQuickStartGuide } from './QuickStartContext'; -import { useQuickStartGuideDrag } from './useQuickStartGuideDrag'; - -import './QuickStartGuide.css'; - -const StepChipCounter = ({ className }: { className?: string }) => { - return ( - - ); -}; - -interface GuideWindowActionsProps { - variant: 'expanded' | 'minimized'; - onExpand: VoidFunction; - onClose: VoidFunction; - onMinimize: VoidFunction; -} - -const GuideWindowActions = ({ variant, onExpand, onClose, onMinimize }: GuideWindowActionsProps) => { - const { t } = useTranslation(); - - const closeButton = ( - - - - - - - ) : ( - - - ), - hasNoOffset: true, - }} - > - - - - {activePhase.title} - - - - - - - - - - - - - - - - - - - - - - - )} - , - document.body, + return ( + + {getPhaseComponent(activePhaseId)} + ); }; diff --git a/libs/ui-components/src/components/QuickStart/QuickStartHost.tsx b/libs/ui-components/src/components/QuickStart/QuickStartHost.tsx index 88c369961..dd73852ee 100644 --- a/libs/ui-components/src/components/QuickStart/QuickStartHost.tsx +++ b/libs/ui-components/src/components/QuickStart/QuickStartHost.tsx @@ -1,13 +1,13 @@ import * as React from 'react'; -import { QuickStartProvider, useQuickStartGuide } from './QuickStartContext'; +import { QuickStartProvider, useQuickStart } from './QuickStartContext'; const QuickStartGuideLoader = React.lazy(() => import('./QuickStartGuide')); const QuickStartGuideGate = () => { - const { activePhase } = useQuickStartGuide(); + const { activePhaseId } = useQuickStart(); - if (!activePhase) { + if (!activePhaseId) { return null; } diff --git a/libs/ui-components/src/components/QuickStart/QuickStartGuide.css b/libs/ui-components/src/components/QuickStart/guide/GuideShell.css similarity index 100% rename from libs/ui-components/src/components/QuickStart/QuickStartGuide.css rename to libs/ui-components/src/components/QuickStart/guide/GuideShell.css diff --git a/libs/ui-components/src/components/QuickStart/guide/GuideShell.tsx b/libs/ui-components/src/components/QuickStart/guide/GuideShell.tsx new file mode 100644 index 000000000..90e4b119f --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/guide/GuideShell.tsx @@ -0,0 +1,218 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { + Button, + Card, + CardBody, + CardFooter, + CardHeader, + CardTitle, + Content, + ContentVariants, + Flex, + FlexItem, + Label, + Split, + SplitItem, + Stack, + StackItem, +} from '@patternfly/react-core'; +import TimesIcon from '@patternfly/react-icons/dist/js/icons/times-icon'; +import WindowMinimizeIcon from '@patternfly/react-icons/dist/js/icons/window-minimize-icon'; +import OutlinedWindowRestoreIcon from '@patternfly/react-icons/dist/js/icons/outlined-window-restore-icon'; +import ArrowRightIcon from '@patternfly/react-icons/dist/js/icons/arrow-right-icon'; +import ArrowLeftIcon from '@patternfly/react-icons/dist/js/icons/arrow-left-icon'; + +import { useTranslation } from '../../../hooks/useTranslation'; +import { useQuickStartGuide } from '../QuickStartContext'; +import { getPhaseTitle } from '../quickStartDefinitions'; +import { useQuickStartGuideDrag } from '../useQuickStartGuideDrag'; + +import './GuideShell.css'; + +interface StepChipCounterProps { + current: number; + total: number; + className?: string; +} + +const StepChipCounter = ({ current, total, className }: StepChipCounterProps) => ( + +); + +interface GuideWindowActionsProps { + variant: 'expanded' | 'minimized'; + onToggle: VoidFunction; + onClose: VoidFunction; +} + +const GuideWindowActions = ({ variant, onToggle, onClose }: GuideWindowActionsProps) => { + const { t } = useTranslation(); + const isMinimized = variant === 'minimized'; + return ( + <> + + + + setGuideMinimized(false)} onClose={cancelPhase} /> + + + + ) : ( + + setGuideMinimized(true)} onClose={cancelPhase} /> + ), + hasNoOffset: true, + }} + > + + + + {phaseTitle} + + + + + + + + + + {children} + + + + + + + + + {footerExtra ? {footerExtra} : null} + + + + + + + )} + , + document.body, + ); +}; diff --git a/libs/ui-components/src/components/QuickStart/guide/ListPreviewTable.tsx b/libs/ui-components/src/components/QuickStart/guide/ListPreviewTable.tsx new file mode 100644 index 000000000..1a0d243af --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/guide/ListPreviewTable.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { ModalBody } from '@patternfly/react-core'; +import { Tbody } from '@patternfly/react-table'; + +import { useTranslation } from '../../../hooks/useTranslation'; +import Table, { type ApiTableColumn } from '../../Table/Table'; + +type ListPreviewTableProps = { + columns: ApiTableColumn[]; + isExpandable?: boolean; +}; + +export const ListPreviewTable = ({ + columns, + isExpandable, + children, +}: React.PropsWithChildren) => { + const { t } = useTranslation(); + + return ( + + + {children} +
    +
    + ); +}; diff --git a/libs/ui-components/src/components/QuickStart/guide/SeeHowItLooksPreview.tsx b/libs/ui-components/src/components/QuickStart/guide/SeeHowItLooksPreview.tsx new file mode 100644 index 000000000..b39431d26 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/guide/SeeHowItLooksPreview.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { Button, ModalFooter, ModalHeader, ModalVariant } from '@patternfly/react-core'; + +import { useTranslation } from '../../../hooks/useTranslation'; +import FlightCtlModal from '../../common/FlightCtlModal'; +import WithTooltip from '../../common/WithTooltip'; +import { StatusDisplayContent } from '../../Status/StatusDisplay'; + +export const HealthyStatusPreview = ({ label }: { label: string }) => { + const { t } = useTranslation(); + return ; +}; + +// This button does not have an action handler as the items in preview are dummy data +export const PreviewButton = ({ title }: { title: string }) => { + const { t } = useTranslation(); + return ( + + {title} + + ); +}; + +export const SeeHowItLooksPreview = ({ children, title }: React.PropsWithChildren<{ title: string }>) => { + const { t } = useTranslation(); + const [isOpen, setIsOpen] = React.useState(false); + + return ( + <> + + setIsOpen(false)}> + + {children} + + + + + + ); +}; diff --git a/libs/ui-components/src/components/QuickStart/guide/StepChrome.tsx b/libs/ui-components/src/components/QuickStart/guide/StepChrome.tsx new file mode 100644 index 000000000..dd1b7a04b --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/guide/StepChrome.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { Content, ContentVariants, List, ListItem } from '@patternfly/react-core'; + +import { useQuickStartGuide } from '../QuickStartContext'; + +export const StepTitle = ({ children }: React.PropsWithChildren) => {children}; + +export const StepBody = ({ children }: React.PropsWithChildren) => ( + + {children} + +); + +export const StepItemList = ({ title, items }: { title: string; items: string[] }) => { + if (items.length === 0) { + return null; + } + + return ( + <> + {title}: + + {items.map((item, index) => ( + + {item} + + ))} + + + ); +}; + +export const StepHint = ({ children, hasGap }: React.PropsWithChildren<{ hasGap?: boolean }>) => ( + + {children} + +); + +export const StepHeader = ({ title }: { title: string }) => { + const { setGuidePresentation } = useQuickStartGuide(); + + React.useLayoutEffect(() => { + setGuidePresentation({ stepTitle: title }); + }, [setGuidePresentation, title]); + + return {title}; +}; diff --git a/libs/ui-components/src/components/QuickStart/phases/enrollment/EnrollmentPhase.tsx b/libs/ui-components/src/components/QuickStart/phases/enrollment/EnrollmentPhase.tsx new file mode 100644 index 000000000..2c5371a1d --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/enrollment/EnrollmentPhase.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; + +import { ResourceKind } from '@flightctl/types'; +import { useAppContext } from '../../../../hooks/useAppContext'; +import { usePermissionsContext } from '../../../common/PermissionsContext'; +import { useQuickStartGuide } from '../../QuickStartContext'; +import { buildEnrollmentGuideSteps } from './enrollmentGuideSteps'; +import { pathMatchesRoute } from '../../quickStartPhaseUtils'; +import { ROUTE } from '../../../../hooks/useNavigate'; +import { useQuickStartListHasItems } from '../../useQuickStartListHasItems'; +import { RESOURCE, VERB } from '../../../../types/rbac'; + +const listPermissions = [ + { kind: RESOURCE.DEVICE, verb: VERB.LIST }, + { kind: RESOURCE.ENROLLMENT_REQUEST, verb: VERB.LIST }, +]; + +const EnrollmentPhase = () => { + const { checkPermissions } = usePermissionsContext(); + const { router } = useAppContext(); + const location = router.useLocation(); + const { activePhaseId, activeStepIndex, setStepIndex, setGuidePresentation, setGuideActions, completePhase } = + useQuickStartGuide(); + + const [canListDevices, canListEnrollmentRequests] = checkPermissions(listPermissions); + + const [completedNavigationSteps, setCompletedNavigationSteps] = React.useState>({}); + const devicesProbe = useQuickStartListHasItems(canListDevices ? ResourceKind.DEVICE : undefined); + const pendingProbe = useQuickStartListHasItems( + canListEnrollmentRequests ? ResourceKind.ENROLLMENT_REQUEST : undefined, + ); + const isListProbeLoading = + (canListDevices && devicesProbe.isLoading) || (canListEnrollmentRequests && pendingProbe.isLoading); + const isOnDevicesPage = pathMatchesRoute(location.pathname, router.appRoutes[ROUTE.DEVICES]); + + const steps = React.useMemo(() => { + if (isListProbeLoading) { + return []; + } + return buildEnrollmentGuideSteps({ + isStepActionCompleted: (stepIndex) => completedNavigationSteps[stepIndex] ?? false, + checkPermissions, + isOnDevicesPage, + hasDevices: devicesProbe.hasItems, + hasPendingDevices: pendingProbe.hasItems, + }); + }, [ + checkPermissions, + completedNavigationSteps, + devicesProbe.hasItems, + isListProbeLoading, + isOnDevicesPage, + pendingProbe.hasItems, + ]); + + const activeStep = steps[activeStepIndex]; + const isLastStep = activeStep ? activeStepIndex >= steps.length - 1 : false; + + const onNext = React.useCallback(() => { + if (!activeStep) { + return; + } + if (isLastStep) { + completePhase(); + return; + } + setCompletedNavigationSteps({}); + setStepIndex(activeStepIndex + 1); + }, [activeStep, activeStepIndex, completePhase, isLastStep, setStepIndex]); + + const onBack = React.useCallback(() => { + setCompletedNavigationSteps({}); + setStepIndex(activeStepIndex - 1); + }, [activeStepIndex, setStepIndex]); + + React.useEffect(() => { + if (isListProbeLoading) { + return; + } + if (!activeStep) { + return; + } + setGuidePresentation({ + totalSteps: steps.length, + canGoBack: activeStepIndex > 0, + canGoNext: activeStep.mustBeOnListPage ? isOnDevicesPage : true, + isLastStep: activeStepIndex >= steps.length - 1, + }); + }, [activeStep, activeStepIndex, isListProbeLoading, setGuidePresentation, isOnDevicesPage, steps.length]); + + React.useEffect(() => { + if (isListProbeLoading) { + setGuideActions(null); + return; + } + if (!activeStep) { + setGuideActions(null); + return; + } + setGuideActions({ onBack, onNext }); + return () => setGuideActions(null); + }, [activeStep, isListProbeLoading, onBack, onNext, setGuideActions]); + + if (activePhaseId !== 'enroll-device') { + throw new Error('EnrollmentPhase expected enrollment to be active'); + } + + if (isListProbeLoading || !activeStep) { + return null; + } + + return activeStep.render(); +}; + +export default EnrollmentPhase; diff --git a/libs/ui-components/src/components/QuickStart/phases/enrollment/enrollmentGuideSteps.tsx b/libs/ui-components/src/components/QuickStart/phases/enrollment/enrollmentGuideSteps.tsx new file mode 100644 index 000000000..e14cef0d0 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/enrollment/enrollmentGuideSteps.tsx @@ -0,0 +1,106 @@ +import * as React from 'react'; + +import { RESOURCE, VERB } from '../../../../types/rbac'; +import type { PermissionCheck } from '../../../common/PermissionsContext'; +import type { QuickStartGuideStep } from '../../types'; +import AfterApprovalStep from './steps/AfterApprovalStep'; +import ApproveDeviceStep from './steps/ApproveDeviceStep'; +import BootDeviceStep from './steps/BootDeviceStep'; +import ViewerUserCapabilitiesStep from './steps/ViewerUserCapabilitiesStep'; +import DeviceDetailStep from './steps/DeviceDetailStep'; +import ExploreDevicesStep from './steps/ExploreDevicesStep'; +import FindPendingDevicesStep from './steps/FindPendingStep'; +import OpenDevicesStep from './steps/OpenDevicesStep'; +import ReviewRequestStep from './steps/ReviewRequestStep'; +import TeamEscalationStep from './steps/TeamEscalationStep'; + +export interface EnrollmentGuideRuntimeOptions { + checkPermissions: (checks: PermissionCheck[]) => boolean[]; + isOnDevicesPage: boolean; + isStepActionCompleted: (stepIndex: number) => boolean; + hasDevices?: boolean; + hasPendingDevices?: boolean; +} + +const enrollmentPermissions: PermissionCheck[] = [ + { kind: RESOURCE.DEVICE, verb: VERB.LIST }, + { kind: RESOURCE.DEVICE, verb: VERB.GET }, + { kind: RESOURCE.ENROLLMENT_REQUEST, verb: VERB.LIST }, + { kind: RESOURCE.ENROLLMENT_REQUEST, verb: VERB.GET }, + { kind: RESOURCE.ENROLLMENT_REQUEST_APPROVAL, verb: VERB.UPDATE }, + { kind: RESOURCE.IMAGE_EXPORT_DOWNLOAD, verb: VERB.GET }, + { kind: RESOURCE.IMAGE_EXPORT, verb: VERB.CREATE }, +]; + +export const buildEnrollmentGuideSteps = (options: EnrollmentGuideRuntimeOptions): QuickStartGuideStep[] => { + const { checkPermissions, isOnDevicesPage, hasDevices = false, hasPendingDevices = false } = options; + + const [canListDevices, canViewDevice, canListEr, canViewEr, canApproveEr, canDownloadImage, canCreateImageExport] = + checkPermissions(enrollmentPermissions); + + const steps: QuickStartGuideStep[] = []; + + if (canApproveEr) { + steps.push({ + render: () => , + }); + } + + if (canListDevices) { + steps.push({ + mustBeOnListPage: true, + render: () => , + }); + } + + const hasViewerEscalation = canListDevices && !canApproveEr; + if (hasViewerEscalation) { + steps.push({ + render: () => ( + + ), + }); + } + steps.push({ + render: () => , + }); + + if (canListEr) { + steps.push({ + render: () => , + }); + steps.push({ + render: () => , + }); + + if (canApproveEr) { + steps.push({ + render: () => , + }); + } + } + + if (canViewDevice) { + steps.push({ + render: () => (canApproveEr ? : ), + }); + } + + if (hasViewerEscalation) { + steps.push({ + render: () => ( + + ), + }); + } + + return steps; +}; diff --git a/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/AfterApprovalStep.tsx b/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/AfterApprovalStep.tsx new file mode 100644 index 000000000..49be0c780 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/AfterApprovalStep.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; + +import { StepBody, StepHeader, StepHint } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +const AfterApprovalStep = () => { + const { t } = useTranslation(); + + return ( + <> + + + {t( + 'After approval, the device appears in the enrolled devices table with connectivity status and fleet membership. You can open its detail page to monitor health and apply updates.', + )} + + {t('Browse approved devices in the enrolled devices table.')} + + ); +}; + +export default AfterApprovalStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/ApproveDeviceStep.tsx b/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/ApproveDeviceStep.tsx new file mode 100644 index 000000000..e4bf0b822 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/ApproveDeviceStep.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; + +import { StepBody, StepHeader, StepHint } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +const ApproveDeviceStep = () => { + const { t } = useTranslation(); + + return ( + <> + + + {t( + 'Click Approve to accept a device into your managed environment. After approval, it moves from pending requests into the enrolled devices table.', + )} + + {t('Use the Approve action on a pending request to bring the device under management.')} + + ); +}; + +export default ApproveDeviceStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/BootDeviceStep.tsx b/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/BootDeviceStep.tsx new file mode 100644 index 000000000..ff8b1ca23 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/BootDeviceStep.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; + +import { StepBody, StepHeader, StepHint } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +const BootDeviceStep = () => { + const { t } = useTranslation(); + + return ( + <> + + + {t( + 'Write the downloaded disk image to your edge device (USB stick, SD card, or network boot), or load it into a virtual machine. When the device powers on for the first time, it contacts the control plane and appears as a device pending approval.', + )} + + + {t('For virtual testing, boot the QCOW2 image in a VM. It connects the same way as physical hardware.')} + + + ); +}; + +export default BootDeviceStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/DeviceDetailStep.tsx b/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/DeviceDetailStep.tsx new file mode 100644 index 000000000..4b003e765 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/DeviceDetailStep.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; + +import { StepBody, StepHeader } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +const DeviceDetailStep = () => { + const { t } = useTranslation(); + + return ( + <> + + + {t( + 'After approval, devices appear in the enrolled devices table. Open a device detail page to check connectivity status, system resources, and OS version.', + )} + + + ); +}; + +export default DeviceDetailStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/EnrolledDeviceListPreview.tsx b/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/EnrolledDeviceListPreview.tsx new file mode 100644 index 000000000..3e79f934c --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/EnrolledDeviceListPreview.tsx @@ -0,0 +1,76 @@ +import * as React from 'react'; +import { Flex, FlexItem } from '@patternfly/react-core'; +import { Td, Tr } from '@patternfly/react-table'; + +import { OsModeType } from '@flightctl/types'; + +import { useTranslation } from '../../../../../hooks/useTranslation'; +import ResourceLink from '../../../../common/ResourceLink'; +import OsModeContent from '../../../../common/OsModeContent'; +import { HealthyStatusPreview, PreviewButton, SeeHowItLooksPreview } from '../../../guide/SeeHowItLooksPreview'; +import { ListPreviewTable } from '../../../guide/ListPreviewTable'; +import type { ApiTableColumn } from '../../../../Table/Table'; + +const deviceId = 'fqq3u5mirgt1t25bb554vqeom4i10p149auphrk6o76sl59k4eeg'; + +const EnrolledDeviceListPreviewRow = ({ columns }: { columns: ApiTableColumn[] }) => { + const { t } = useTranslation(); + + return ( + + + + + + + + + + + + + + + test-fleet + + + + + + + + + + + ); +}; + +const EnrolledDeviceListPreview = () => { + const { t } = useTranslation(); + + const columns = React.useMemo( + () => [ + { name: t('Alias') }, + { name: t('Name') }, + { name: t('Fleet') }, + { name: t('Application status') }, + { name: t('Device status') }, + { name: t('Update status') }, + ], + [t], + ); + + return ( + + + + + + ); +}; + +export default EnrolledDeviceListPreview; diff --git a/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/ExploreDevicesStep.tsx b/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/ExploreDevicesStep.tsx new file mode 100644 index 000000000..8abc62430 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/ExploreDevicesStep.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { StepBody, StepHeader, StepHint } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; +import EnrolledDeviceListPreview from './EnrolledDeviceListPreview'; + +const ExploreDevicesStep = ({ hasDevices }: { hasDevices: boolean }) => { + const { t } = useTranslation(); + + return ( + <> + + + + {hasDevices + ? t( + 'Some devices are already enrolled to the system. Browse their connectivity and fleet membership to see what an approved device looks like.', + ) + : t( + 'The enrolled devices table is empty right now. Once devices are approved, each row will show connectivity and fleet membership.', + )} + + {!hasDevices && } + {t('Each row shows connectivity and fleet membership.')} + + ); +}; + +export default ExploreDevicesStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/FindPendingStep.tsx b/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/FindPendingStep.tsx new file mode 100644 index 000000000..9616ad04e --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/FindPendingStep.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; + +import { StepBody, StepHeader, StepHint } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; +import PendingDeviceListPreview from './PendingDeviceListPreview'; + +const FindPendingDevicesStep = ({ hasPendingDevices }: { hasPendingDevices: boolean }) => { + const { t } = useTranslation(); + + return ( + <> + + + {hasPendingDevices + ? t( + 'When a device boots for the first time, it stays in Devices pending approval panel until approved. Each row shows the device alias, name, and when the request was created.', + ) + : t( + 'When a device boots for the first time, it stays in Devices pending approval panel until approved. The panel is empty right now. Once a device requests enrollment, each row will show the device alias, name, and when the request was created.', + )} + + {hasPendingDevices ? ( + {t('Check the pending requests panel above the enrolled devices table.')} + ) : ( + + )} + + ); +}; + +export default FindPendingDevicesStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/OpenDevicesStep.tsx b/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/OpenDevicesStep.tsx new file mode 100644 index 000000000..325bd35c1 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/OpenDevicesStep.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import { type TFunction } from 'react-i18next'; + +import { StepBody, StepHeader, StepHint } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +const devicesPageInfo = (t: TFunction) => + t( + 'The Devices page is your central hub for all managed edge hardware. From here you can monitor enrolled device health and status and view pending enrollment requests. Each row shows device connectivity, OS version, and fleet membership at a glance.', + ); + +const withEmptyDevices = (t: TFunction) => + t( + 'This is your device hub. The enrolled devices table is empty right now. Once devices are approved, each row will show connectivity and fleet membership. To show you what it will look like, click the link below.', + ); + +const withData = (t: TFunction) => + t('This is your device hub. Enrolled devices appear in the table below with connectivity and fleet membership.'); + +type OpenDevicesStepProps = { + hasDevices: boolean; + isOnDevicesPage: boolean; +}; + +const StepBodyContent = ({ hasDevices, isOnDevicesPage }: OpenDevicesStepProps) => { + const { t } = useTranslation(); + if (!isOnDevicesPage) { + return {devicesPageInfo(t)}; + } + if (!hasDevices) { + return {withEmptyDevices(t)}; + } + + return {withData(t)}; +}; + +const OpenDevicesStep = ({ hasDevices, isOnDevicesPage }: OpenDevicesStepProps) => { + const { t } = useTranslation(); + + return ( + <> + + + + + + {t('Devices requiring approval appear in the panel above the table when a device first boots.')} + + + ); +}; + +export default OpenDevicesStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/PendingDeviceListPreview.tsx b/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/PendingDeviceListPreview.tsx new file mode 100644 index 000000000..6cce61e06 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/PendingDeviceListPreview.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { Td, Tr } from '@patternfly/react-table'; + +import { useTranslation } from '../../../../../hooks/useTranslation'; +import ResourceLink from '../../../../common/ResourceLink'; +import { PreviewButton, SeeHowItLooksPreview } from '../../../guide/SeeHowItLooksPreview'; +import { ListPreviewTable } from '../../../guide/ListPreviewTable'; +import type { ApiTableColumn } from '../../../../Table/Table'; + +const enrollmentRequestId = 'mul6f5j1qiak0nsm9bl4n3f32bo4eub6oemntql56daoupdli2n0'; + +const PendingDeviceListPreviewRow = ({ columns }: { columns: ApiTableColumn[] }) => { + const { t } = useTranslation(); + + return ( + + + + + + + + {t('2 hours ago')} + + + + + ); +}; + +const PendingDeviceListPreview = () => { + const { t } = useTranslation(); + + const columns = React.useMemo(() => [{ name: t('Alias') }, { name: t('Name') }, { name: t('Created') }], [t]); + + return ( + + + + + + ); +}; + +export default PendingDeviceListPreview; diff --git a/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/ReviewRequestStep.tsx b/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/ReviewRequestStep.tsx new file mode 100644 index 000000000..c67a2df6a --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/ReviewRequestStep.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; + +import { StepBody, StepHeader, StepHint } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +const ReviewRequestStep = ({ canViewEr, canApprove }: { canViewEr: boolean; canApprove: boolean }) => { + const { t } = useTranslation(); + + return ( + <> + + + {canViewEr && + t( + 'Click a device pending approval to review system information, requested labels, and the certificate signing request.', + )} + {canApprove &&  {t('Verify it matches the device you booted before approving.')}} + + {t('Select a request in the pending devices panel to open its details.')} + + ); +}; + +export default ReviewRequestStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/TeamEscalationStep.tsx b/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/TeamEscalationStep.tsx new file mode 100644 index 000000000..47f4e93e0 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/TeamEscalationStep.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; + +import { StepHeader, StepItemList } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +export type TeamEscalationPermissions = { + canApproveEnrollment: boolean; + canDownloadExport: boolean; + canCreateExport: boolean; +}; + +const TeamEscalationStep = ({ + canApproveEnrollment, + canDownloadExport, + canCreateExport, +}: TeamEscalationPermissions) => { + const { t } = useTranslation(); + + const needs: string[] = []; + if (!canApproveEnrollment) { + needs.push(t('Approve a device pending approval')); + } + if (!canDownloadExport && !canCreateExport) { + needs.push(t('Download a disk image to provision a device')); + } + + if (needs.length === 0) { + return null; + } + + return ( + <> + + + + ); +}; + +export default TeamEscalationStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/ViewerUserCapabilitiesStep.tsx b/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/ViewerUserCapabilitiesStep.tsx new file mode 100644 index 000000000..2d181855c --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/enrollment/steps/ViewerUserCapabilitiesStep.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; + +import { StepBody, StepHeader, StepItemList } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +export type ViewerUserCapabilitiesPermissions = { + canListDevices: boolean; + canViewDevice: boolean; + canListEnrollmentRequests: boolean; +}; + +// This step is only shown to viewers who cannot approve enrollment requests. +const ViewerUserCapabilitiesStep = ({ + canListDevices, + canListEnrollmentRequests, + canViewDevice, +}: ViewerUserCapabilitiesPermissions) => { + const { t } = useTranslation(); + + const capabilities: string[] = []; + if (canListDevices) { + capabilities.push(t('View enrolled device health and status in the devices table')); + } + if (canListEnrollmentRequests) { + capabilities.push(t('View pending enrollment requests in the panel above the table')); + } + if (canViewDevice) { + capabilities.push(t('Open enrolled and pending devices detail pages to review configuration and status')); + } + + return ( + <> + + {capabilities.length > 0 ? ( + + ) : ( + {t('The Devices page is where your organization manages enrolled edge hardware.')} + )} + + {t( + 'Flashing devices, approving enrollment requests, and adding devices are handled by an administrator or operator and your administrator or image builder.', + )} + + + ); +}; + +export default ViewerUserCapabilitiesStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/fleet-management/FleetManagementPhase.tsx b/libs/ui-components/src/components/QuickStart/phases/fleet-management/FleetManagementPhase.tsx new file mode 100644 index 000000000..611131d26 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/fleet-management/FleetManagementPhase.tsx @@ -0,0 +1,96 @@ +import * as React from 'react'; + +import { ResourceKind } from '@flightctl/types'; +import { useAppContext } from '../../../../hooks/useAppContext'; +import { usePermissionsContext } from '../../../common/PermissionsContext'; +import { useQuickStartGuide } from '../../QuickStartContext'; +import { buildFleetManagementGuideSteps } from './fleetManagementGuideSteps'; +import { pathMatchesRoute } from '../../quickStartPhaseUtils'; +import { ROUTE } from '../../../../hooks/useNavigate'; +import { useQuickStartListHasItems } from '../../useQuickStartListHasItems'; +import { RESOURCE, VERB } from '../../../../types/rbac'; + +const listPermissions = [{ kind: RESOURCE.FLEET, verb: VERB.LIST }]; + +const FleetManagementPhase = () => { + const { checkPermissions } = usePermissionsContext(); + const { router } = useAppContext(); + const location = router.useLocation(); + const { activePhaseId, activeStepIndex, setStepIndex, setGuidePresentation, setGuideActions, completePhase } = + useQuickStartGuide(); + + const [canListFleets] = checkPermissions(listPermissions); + const fleetsProbe = useQuickStartListHasItems(canListFleets ? ResourceKind.FLEET : undefined); + const isListProbeLoading = canListFleets && fleetsProbe.isLoading; + const isOnFleetsPage = pathMatchesRoute(location.pathname, router.appRoutes[ROUTE.FLEETS]); + + const steps = React.useMemo(() => { + if (isListProbeLoading) { + return []; + } + return buildFleetManagementGuideSteps({ + checkPermissions, + isOnFleetsPage, + hasFleets: fleetsProbe.hasItems, + }); + }, [checkPermissions, fleetsProbe.hasItems, isListProbeLoading, isOnFleetsPage]); + + const activeStep = steps[activeStepIndex]; + const isLastStep = activeStep ? activeStepIndex >= steps.length - 1 : false; + + const onNext = React.useCallback(() => { + if (!activeStep) { + return; + } + + if (isLastStep) { + completePhase(); + return; + } + setStepIndex(activeStepIndex + 1); + }, [activeStep, activeStepIndex, completePhase, isLastStep, setStepIndex]); + + const onBack = React.useCallback(() => { + setStepIndex(activeStepIndex - 1); + }, [activeStepIndex, setStepIndex]); + + React.useEffect(() => { + if (isListProbeLoading) { + return; + } + if (!activeStep) { + return; + } + setGuidePresentation({ + totalSteps: steps.length, + canGoBack: activeStepIndex > 0, + canGoNext: activeStep.mustBeOnListPage ? isOnFleetsPage : true, + isLastStep: activeStepIndex >= steps.length - 1, + }); + }, [activeStep, activeStepIndex, isListProbeLoading, setGuidePresentation, isOnFleetsPage, steps.length]); + + React.useEffect(() => { + if (isListProbeLoading) { + setGuideActions(null); + return; + } + if (!activeStep) { + setGuideActions(null); + return; + } + setGuideActions({ onBack, onNext }); + return () => setGuideActions(null); + }, [activeStep, isListProbeLoading, onBack, onNext, setGuideActions]); + + if (activePhaseId !== 'manage-fleet') { + throw new Error('FleetManagementPhase expected fleet-management to be active'); + } + + if (isListProbeLoading || !activeStep) { + return null; + } + + return activeStep.render(); +}; + +export default FleetManagementPhase; diff --git a/libs/ui-components/src/components/QuickStart/phases/fleet-management/fleetManagementGuideSteps.tsx b/libs/ui-components/src/components/QuickStart/phases/fleet-management/fleetManagementGuideSteps.tsx new file mode 100644 index 000000000..2c5ebffd4 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/fleet-management/fleetManagementGuideSteps.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; + +import { RESOURCE, VERB } from '../../../../types/rbac'; +import type { PermissionCheck } from '../../../common/PermissionsContext'; +import type { QuickStartGuideStep } from '../../types'; +import CreateFleetStep from './steps/CreateFleetStep'; +import FleetTeamEscalationStep from './steps/FleetTeamEscalationStep'; +import HowFleetCreationWorksStep from './steps/HowFleetCreationWorksStep'; +import HowFleetsWorkStep from './steps/HowFleetsWorkStep'; +import OpenFleetsStep from './steps/OpenFleetsStep'; +import UpdateFleetConfigurationsStep from './steps/UpdateFleetConfigurationsStep'; +import ViewerUserCapabilitiesStep from './steps/ViewerUserCapabilitiesStep'; + +export interface FleetManagementGuideRuntimeOptions { + checkPermissions: (checks: PermissionCheck[]) => boolean[]; + isOnFleetsPage: boolean; + hasFleets?: boolean; +} + +const fleetManagementPermissions: PermissionCheck[] = [ + { kind: RESOURCE.FLEET, verb: VERB.LIST }, + { kind: RESOURCE.FLEET, verb: VERB.GET }, + { kind: RESOURCE.FLEET, verb: VERB.CREATE }, + { kind: RESOURCE.FLEET, verb: VERB.PATCH }, +]; + +export const buildFleetManagementGuideSteps = (options: FleetManagementGuideRuntimeOptions): QuickStartGuideStep[] => { + const { checkPermissions, isOnFleetsPage, hasFleets = false } = options; + + const [canListFleets, canViewFleet, canCreateFleet, canEditFleet] = checkPermissions(fleetManagementPermissions); + const hasViewerEscalation = canListFleets && !canCreateFleet; + + const steps: QuickStartGuideStep[] = []; + + if (canListFleets) { + steps.push({ + mustBeOnListPage: true, + render: () => ( + + ), + }); + } + + steps.push({ + render: () => , + }); + + if (hasViewerEscalation) { + steps.push({ + render: () => ( + + ), + }); + } else { + if (canCreateFleet) { + steps.push({ + render: () => , + }); + steps.push({ + render: () => , + }); + } + + if (canEditFleet) { + steps.push({ + render: () => , + }); + } + } + + if (hasViewerEscalation) { + steps.push({ + render: () => , + }); + } + + return steps; +}; diff --git a/libs/ui-components/src/components/QuickStart/phases/fleet-management/steps/CreateFleetStep.tsx b/libs/ui-components/src/components/QuickStart/phases/fleet-management/steps/CreateFleetStep.tsx new file mode 100644 index 000000000..edc2adec4 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/fleet-management/steps/CreateFleetStep.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; + +import { StepBody, StepHeader, StepHint } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +const CreateFleetStep = () => { + const { t } = useTranslation(); + + return ( + <> + + + {t( + 'Click "Create fleet" in the toolbar to open the fleet creation wizard. You will define which devices belong to this fleet, what they should run, and how updates roll out.', + )} + + {t('Fleets group devices that share the same OS image, applications, and update policies.')} + + ); +}; + +export default CreateFleetStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/fleet-management/steps/FleetListPreview.tsx b/libs/ui-components/src/components/QuickStart/phases/fleet-management/steps/FleetListPreview.tsx new file mode 100644 index 000000000..975c5380b --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/fleet-management/steps/FleetListPreview.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { Td, Tr } from '@patternfly/react-table'; + +import { useTranslation } from '../../../../../hooks/useTranslation'; +import { HealthyStatusPreview, PreviewButton, SeeHowItLooksPreview } from '../../../guide/SeeHowItLooksPreview'; +import { ListPreviewTable } from '../../../guide/ListPreviewTable'; +import type { ApiTableColumn } from '../../../../Table/Table'; + +const FleetListPreviewRow = ({ columns }: { columns: ApiTableColumn[] }) => { + const { t } = useTranslation(); + + return ( + + + + + registry/organization/edge-image:1.0.0 + + 80/93 + + + + + + ); +}; + +const FleetListPreview = () => { + const { t } = useTranslation(); + + const columns = React.useMemo( + () => [{ name: t('Name') }, { name: t('System image') }, { name: t('Up-to-date/devices') }, { name: t('Status') }], + [t], + ); + return ( + + + + + + ); +}; + +export default FleetListPreview; diff --git a/libs/ui-components/src/components/QuickStart/phases/fleet-management/steps/FleetTeamEscalationStep.tsx b/libs/ui-components/src/components/QuickStart/phases/fleet-management/steps/FleetTeamEscalationStep.tsx new file mode 100644 index 000000000..7436e9acf --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/fleet-management/steps/FleetTeamEscalationStep.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; + +import { StepHeader, StepItemList } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +export type FleetTeamEscalationPermissions = { + canCreateFleet: boolean; + canEditFleet: boolean; +}; + +const FleetTeamEscalationStep = ({ canCreateFleet, canEditFleet }: FleetTeamEscalationPermissions) => { + const { t } = useTranslation(); + + const needs: string[] = []; + if (!canCreateFleet) { + needs.push(t('Request a new fleet for your devices')); + } + if (!canEditFleet) { + needs.push(t('Change fleet configurations or update rollout policies')); + } + + if (needs.length === 0) { + return null; + } + + return ( + <> + + + + ); +}; + +export default FleetTeamEscalationStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/fleet-management/steps/HowFleetCreationWorksStep.tsx b/libs/ui-components/src/components/QuickStart/phases/fleet-management/steps/HowFleetCreationWorksStep.tsx new file mode 100644 index 000000000..272c34d4b --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/fleet-management/steps/HowFleetCreationWorksStep.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; + +import { StepBody, StepHeader } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +const HowFleetCreationWorksStep = () => { + const { t } = useTranslation(); + + return ( + <> + + + {t( + 'Creating a fleet involves three steps: defining a label selector (which devices belong to this fleet), configuring the device template (desired OS, applications, and configurations), and setting an update policy (batch size and disruption budget for rollouts). Once created, matching devices automatically receive the fleet configuration.', + )} + + + ); +}; + +export default HowFleetCreationWorksStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/fleet-management/steps/HowFleetsWorkStep.tsx b/libs/ui-components/src/components/QuickStart/phases/fleet-management/steps/HowFleetsWorkStep.tsx new file mode 100644 index 000000000..fa18a0e07 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/fleet-management/steps/HowFleetsWorkStep.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; + +import { useProductName } from '../../../../../hooks/useProductName'; +import { useTranslation } from '../../../../../hooks/useTranslation'; +import { StepBody, StepHeader, StepHint } from '../../../guide/StepChrome'; + +const HowFleetsWorkStep = () => { + const { t } = useTranslation(); + const productName = useProductName(); + + return ( + <> + + + {t( + "Fleets use label selectors to match devices. Any device whose labels satisfy the selector automatically becomes a fleet member. The fleet's device template defines what that group of devices should look like (OS version, apps, configurations). When you update the template, changes roll out to all members based on the fleet's update policy.", + )} + + + {t( + 'Think of fleets like a desired state: you declare what devices should run, and {{ productName }} drives them toward it.', + { + productName, + }, + )} + + + ); +}; + +export default HowFleetsWorkStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/fleet-management/steps/OpenFleetsStep.tsx b/libs/ui-components/src/components/QuickStart/phases/fleet-management/steps/OpenFleetsStep.tsx new file mode 100644 index 000000000..d2f234373 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/fleet-management/steps/OpenFleetsStep.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import { type TFunction } from 'react-i18next'; + +import { StepBody, StepHeader, StepHint } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +import FleetListPreview from './FleetListPreview'; + +const viewerOrientation = (t: TFunction) => + t( + 'Fleets group edge devices that share the same desired configuration: OS image, installed applications, and update policies. From this page you can see which devices belong to each fleet and check fleet compliance and system image versions.', + ); + +const emptyTable = (t: TFunction) => + t( + 'Fleets group devices that share the same configuration. This page is empty right now. Once you create a fleet, each row will show its name, system image, member count, and rollout status.', + ); + +const withData = (t: TFunction) => + t( + 'Fleets group devices that share the same configuration. Each row shows a fleet name, system image, member count, and rollout status.', + ); + +type OpenFleetsStepProps = { + hasFleets: boolean; + canCreateFleet: boolean; + isOnFleetsPage: boolean; +}; + +const StepBodyContent = ({ hasFleets, canCreateFleet, isOnFleetsPage }: OpenFleetsStepProps) => { + const { t } = useTranslation(); + + if (!isOnFleetsPage && !canCreateFleet) { + return {viewerOrientation(t)}; + } + + return {hasFleets ? withData(t) : emptyTable(t)}; +}; + +const OpenFleetsStep = ({ hasFleets, canCreateFleet, isOnFleetsPage }: OpenFleetsStepProps) => { + const { t } = useTranslation(); + + const showCreateFleetHint = !hasFleets && canCreateFleet; + const showFleetPreview = !hasFleets && !canCreateFleet; + return ( + <> + + + + + {showFleetPreview && } + {t("Devices matching a fleet's label selector join automatically.")} + {showCreateFleetHint && ( + {t('Use Create fleet in the empty state to create your first fleet.')} + )} + + ); +}; + +export default OpenFleetsStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/fleet-management/steps/UpdateFleetConfigurationsStep.tsx b/libs/ui-components/src/components/QuickStart/phases/fleet-management/steps/UpdateFleetConfigurationsStep.tsx new file mode 100644 index 000000000..ec69cd1f8 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/fleet-management/steps/UpdateFleetConfigurationsStep.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; + +import { useProductName } from '../../../../../hooks/useProductName'; +import { useTranslation } from '../../../../../hooks/useTranslation'; +import { StepBody, StepHeader } from '../../../guide/StepChrome'; + +const UpdateFleetConfigurationsStep = () => { + const { t } = useTranslation(); + const productName = useProductName(); + + return ( + <> + + + {t( + 'Open any fleet\'s detail page and click "Edit configurations" to modify the device template. Changes trigger a rollout: {{ productName }} updates member devices according to the fleet\'s update policy.', + { productName }, + )} + + + ); +}; + +export default UpdateFleetConfigurationsStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/fleet-management/steps/ViewerUserCapabilitiesStep.tsx b/libs/ui-components/src/components/QuickStart/phases/fleet-management/steps/ViewerUserCapabilitiesStep.tsx new file mode 100644 index 000000000..2134cb0ed --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/fleet-management/steps/ViewerUserCapabilitiesStep.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; + +import { StepBody, StepHeader, StepItemList } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +export type FleetViewerUserCapabilitiesPermissions = { + canListFleets: boolean; + canViewFleet: boolean; + canEditFleet: boolean; +}; + +// User cannot create new fleets +const ViewerUserCapabilitiesStep = ({ + canListFleets, + canViewFleet, + canEditFleet, +}: FleetViewerUserCapabilitiesPermissions) => { + const { t } = useTranslation(); + + const capabilities: string[] = []; + if (canListFleets) { + capabilities.push(t('Browse fleets and see member counts, system images, and rollout status in the table')); + } + if (canViewFleet) { + capabilities.push(t('Open a fleet detail page to review its device selector and configuration template')); + } + if (canEditFleet) { + capabilities.push(t('Update fleet configurations and track rollout progress')); + } + + return ( + <> + + {capabilities.length > 0 ? ( + + ) : ( + + {t('The Fleets page is where your organization groups edge devices that share the same configuration.')} + + )} + {t('Creating fleets and defining rollout policies are handled by your fleet administrator.')} + + ); +}; + +export default ViewerUserCapabilitiesStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/image-building/ImageBuildingPhase.tsx b/libs/ui-components/src/components/QuickStart/phases/image-building/ImageBuildingPhase.tsx new file mode 100644 index 000000000..3867da99f --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/image-building/ImageBuildingPhase.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; + +import { ResourceKind as ImageBuilderResourceKind } from '@flightctl/types/imagebuilder'; +import { useAppContext } from '../../../../hooks/useAppContext'; +import { ROUTE } from '../../../../hooks/useNavigate'; +import { usePermissionsContext } from '../../../common/PermissionsContext'; +import { pathMatchesRoute } from '../../quickStartPhaseUtils'; +import { useQuickStartGuide } from '../../QuickStartContext'; +import { useQuickStartListHasItems } from '../../useQuickStartListHasItems'; +import { buildImageBuildingGuideSteps } from './imageBuildingGuideSteps'; +import { RESOURCE, VERB } from '../../../../types/rbac'; + +const listPermissions = [{ kind: RESOURCE.IMAGE_BUILD, verb: VERB.LIST }]; + +const ImageBuildingPhase = () => { + const { checkPermissions } = usePermissionsContext(); + const { router } = useAppContext(); + const location = router.useLocation(); + const { activePhaseId, activeStepIndex, setStepIndex, setGuidePresentation, setGuideActions, completePhase } = + useQuickStartGuide(); + + const [canListBuilds] = checkPermissions(listPermissions); + const isOnBuildsPage = pathMatchesRoute(location.pathname, router.appRoutes[ROUTE.IMAGE_BUILDS]); + const buildsProbe = useQuickStartListHasItems(canListBuilds ? ImageBuilderResourceKind.IMAGE_BUILD : undefined); + const isListProbeLoading = canListBuilds && buildsProbe.isLoading; + + const steps = React.useMemo(() => { + if (isListProbeLoading) { + return []; + } + return buildImageBuildingGuideSteps({ + checkPermissions, + isOnBuildsPage, + hasBuilds: buildsProbe.hasItems, + }); + }, [buildsProbe.hasItems, checkPermissions, isListProbeLoading, isOnBuildsPage]); + + const activeStep = steps[activeStepIndex]; + const isLastStep = activeStep ? activeStepIndex >= steps.length - 1 : false; + + const onNext = React.useCallback(() => { + if (!activeStep) { + return; + } + if (isLastStep) { + completePhase(); + return; + } + setStepIndex(activeStepIndex + 1); + }, [activeStep, activeStepIndex, completePhase, isLastStep, setStepIndex]); + + const onBack = React.useCallback(() => { + setStepIndex(activeStepIndex - 1); + }, [activeStepIndex, setStepIndex]); + + React.useEffect(() => { + if (isListProbeLoading) { + return; + } + if (!activeStep) { + return; + } + setGuidePresentation({ + totalSteps: steps.length, + canGoBack: activeStepIndex > 0, + canGoNext: activeStep.mustBeOnListPage ? isOnBuildsPage : true, + isLastStep: activeStepIndex >= steps.length - 1, + }); + }, [activeStep, activeStepIndex, isListProbeLoading, setGuidePresentation, isOnBuildsPage, steps.length]); + + React.useEffect(() => { + if (isListProbeLoading) { + setGuideActions(null); + return; + } + if (!activeStep) { + setGuideActions(null); + return; + } + setGuideActions({ onBack, onNext }); + return () => setGuideActions(null); + }, [activeStep, isListProbeLoading, onBack, onNext, setGuideActions]); + + if (activePhaseId !== 'build-image') { + throw new Error('ImageBuildingPhase expected image-building to be active'); + } + + if (isListProbeLoading || !activeStep) { + return null; + } + + return activeStep.render(); +}; + +export default ImageBuildingPhase; diff --git a/libs/ui-components/src/components/QuickStart/phases/image-building/imageBuildingGuideSteps.tsx b/libs/ui-components/src/components/QuickStart/phases/image-building/imageBuildingGuideSteps.tsx new file mode 100644 index 000000000..56ff1c86d --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/image-building/imageBuildingGuideSteps.tsx @@ -0,0 +1,159 @@ +import * as React from 'react'; + +import { RESOURCE, VERB } from '../../../../types/rbac'; +import type { PermissionCheck } from '../../../common/PermissionsContext'; +import type { QuickStartGuideStep } from '../../types'; +import BuildBaseImageStep from './steps/BuildBaseImageStep'; +import ConfigureDeviceEnrollmentStep from './steps/ConfigureDeviceEnrollmentStep'; +import ConfigureImageOutputStep from './steps/ConfigureImageOutputStep'; +import ExpandBuildRowStep from './steps/ExpandBuildRowStep'; +import ExploreBuildDetailPageStep from './steps/ExploreBuildDetailPageStep'; +import ExploreDetailsPageStep from './steps/ExploreDetailsPageStep'; +import MeetImageBuildWizardStep from './steps/MeetImageBuildWizardStep'; +import OpenBuildDetailPageStep from './steps/OpenBuildDetailPageStep'; +import OpenDetailsPageStep from './steps/OpenDetailsPageStep'; +import OpenImageBuildsStep from './steps/OpenImageBuildsStep'; +import FindBuildActionsStep from './steps/FindBuildActionsStep'; +import PreviewInListStep from './steps/PreviewInListStep'; +import PublishToCatalogStep from './steps/PublishToCatalogStep'; +import ReviewAndStartBuildStep from './steps/ReviewAndStartBuildStep'; +import StartNewImageBuildStep from './steps/StartNewImageBuildStep'; +import TeamEscalationStep from './steps/TeamEscalationStep'; +import ViewerUserCapabilitiesStep from './steps/ViewerUserCapabilitiesStep'; + +export interface ImageBuildingGuideRuntimeOptions { + checkPermissions: (checks: PermissionCheck[]) => boolean[]; + isOnBuildsPage: boolean; + hasBuilds: boolean; +} + +const imageBuildingPermissions: PermissionCheck[] = [ + { kind: RESOURCE.IMAGE_BUILD, verb: VERB.LIST }, + { kind: RESOURCE.IMAGE_BUILD, verb: VERB.GET }, + { kind: RESOURCE.IMAGE_BUILD, verb: VERB.CREATE }, + { kind: RESOURCE.IMAGE_BUILD, verb: VERB.DELETE }, + { kind: RESOURCE.IMAGE_BUILD_CANCEL, verb: VERB.CREATE }, + { kind: RESOURCE.IMAGE_BUILD_NEW_VERSION, verb: VERB.CREATE }, + { kind: RESOURCE.IMAGE_BUILD_LOG, verb: VERB.GET }, + { kind: RESOURCE.IMAGE_EXPORT, verb: VERB.CREATE }, + { kind: RESOURCE.IMAGE_EXPORT_DOWNLOAD, verb: VERB.GET }, + { kind: RESOURCE.IMAGE_PROMOTION, verb: VERB.CREATE }, +]; + +export const buildImageBuildingGuideSteps = (options: ImageBuildingGuideRuntimeOptions): QuickStartGuideStep[] => { + const { checkPermissions, isOnBuildsPage, hasBuilds } = options; + + const [ + canListBuilds, + canViewBuild, + canCreateBuild, + canDeleteBuild, + canCancelBuild, + canCreateNewVersion, + canViewLogs, + canCreateExport, + canDownloadExport, + canAddToCatalog, + ] = checkPermissions(imageBuildingPermissions); + + const steps: QuickStartGuideStep[] = []; + + if (canListBuilds) { + steps.push({ + mustBeOnListPage: true, + render: () => ( + + ), + }); + } + + const hasViewerEscalation = canListBuilds && !canCreateBuild; + if (hasViewerEscalation) { + steps.push({ + render: () => ( + + ), + }); + + if (canViewBuild) { + steps.push({ + render: () => , + }); + steps.push({ + render: () => , + }); + steps.push({ + render: () => , + }); + } + + steps.push({ + render: () => ( + + ), + }); + + return steps; + } + + if (canCreateBuild) { + steps.push({ + render: () => , + }); + steps.push({ + render: () => , + }); + steps.push({ + render: () => , + }); + steps.push({ + render: () => , + }); + steps.push({ + render: () => , + }); + if (canAddToCatalog) { + steps.push({ + render: () => , + }); + } + steps.push({ + render: () => , + }); + steps.push({ + render: () => , + }); + steps.push({ + render: () => ( + + ), + }); + } + + if (canViewBuild) { + steps.push({ + render: () => , + }); + steps.push({ + render: () => , + }); + } + + return steps; +}; diff --git a/libs/ui-components/src/components/QuickStart/phases/image-building/steps/BuildBaseImageStep.tsx b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/BuildBaseImageStep.tsx new file mode 100644 index 000000000..7cb898062 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/BuildBaseImageStep.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; + +import { useProductName } from '../../../../../hooks/useProductName'; +import { StepBody, StepHeader, StepHint } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +const BuildBaseImageStep = () => { + const { t } = useTranslation(); + const productName = useProductName(); + + return ( + <> + + + {t( + 'Name your build and choose the source OS from your registry: select the repository, enter the base image path and version tag, and confirm the reference URL {{ productName }} will pull from.', + { productName }, + )} + + {t('Use a descriptive build name and registry paths your team recognizes.')} + + ); +}; + +export default BuildBaseImageStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/image-building/steps/ConfigureDeviceEnrollmentStep.tsx b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/ConfigureDeviceEnrollmentStep.tsx new file mode 100644 index 000000000..ff8189280 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/ConfigureDeviceEnrollmentStep.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; + +import { useProductName } from '../../../../../hooks/useProductName'; +import { StepBody, StepHeader, StepHint } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +const ConfigureDeviceEnrollmentStep = () => { + const { t } = useTranslation(); + const productName = useProductName(); + + return ( + <> + + + {t( + 'Choose how devices enroll with {{ productName }}. Early binding embeds an enrollment certificate in the image so devices auto-register on first boot with zero-touch provisioning. No cloud-init or Ignition scripts are needed. Late binding omits the certificate and relies on cloud-init or Ignition at first boot to complete registration instead. You can also configure optional first-boot settings such as the onboarding wizard and SSH access.', + { productName }, + )} + + + {t( + 'Early binding is recommended for most edge deployments. Use wizard Next when you are ready for Software Catalog.', + )} + + + ); +}; + +export default ConfigureDeviceEnrollmentStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/image-building/steps/ConfigureImageOutputStep.tsx b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/ConfigureImageOutputStep.tsx new file mode 100644 index 000000000..91d1fbe01 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/ConfigureImageOutputStep.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; + +import { StepBody, StepHeader, StepHint } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +const ConfigureImageOutputStep = () => { + const { t } = useTranslation(); + + return ( + <> + + + {t( + 'Choose where the finished image is pushed, name and tag the output image, and optionally select disk export formats (ISO, QCOW2, and others).', + )} + + {t('Use the wizard Next button when you are ready to continue to device enrollment.')} + + ); +}; + +export default ConfigureImageOutputStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/image-building/steps/ExpandBuildRowStep.tsx b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/ExpandBuildRowStep.tsx new file mode 100644 index 000000000..d19bb6037 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/ExpandBuildRowStep.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; + +import { StepBody, StepHeader, StepHint } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +const ExpandBuildRowStep = () => { + const { t } = useTranslation(); + + return ( + <> + + + {t( + 'After a build finishes, click the expand control at the start of its row to open the summary panel. Use the panel to track progress, review exports, and open the detail page.', + )} + + + {t( + 'Building and exporting can take a few minutes. You can close the guide and return when your build is ready.', + )} + + + ); +}; + +export default ExpandBuildRowStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/image-building/steps/ExploreBuildDetailPageStep.tsx b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/ExploreBuildDetailPageStep.tsx new file mode 100644 index 000000000..dc3a1bdaa --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/ExploreBuildDetailPageStep.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; + +import { StepBody, StepHeader, StepHint } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +const ExploreBuildDetailPageStep = () => { + const { t } = useTranslation(); + + return ( + <> + + + {t( + 'The detail page summarizes your build configuration. Base image shows status and settings cards, Export images lists disk formats, YAML shows the resource definition, and Logs can help with troubleshooting.', + )} + + + {t( + 'The Actions menu at the top includes Rebuild, Add to catalog, and other operations when you have permission.', + )} + + {t('This is the last step in the image build phase.')} + + ); +}; + +export default ExploreBuildDetailPageStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/image-building/steps/ExploreDetailsPageStep.tsx b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/ExploreDetailsPageStep.tsx new file mode 100644 index 000000000..30c4c8cd9 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/ExploreDetailsPageStep.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; + +import { StepBody, StepHeader } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +const ExploreDetailsPageStep = () => { + const { t } = useTranslation(); + + return ( + <> + + + {t( + 'On the details page, tabs organize what you can review: Base image for settings, Export images for disk formats, YAML for the resource definition, and Logs for troubleshooting.', + )} + + + ); +}; + +export default ExploreDetailsPageStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/image-building/steps/FindBuildActionsStep.tsx b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/FindBuildActionsStep.tsx new file mode 100644 index 000000000..8e3000ddb --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/FindBuildActionsStep.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; + +import { StepHeader, StepHint, StepItemList } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +type FindBuildActionsStepProps = { + canViewBuild: boolean; + canDeleteBuild: boolean; + canCancelBuild: boolean; + canCreateNewVersion: boolean; + canAddToCatalog: boolean; +}; + +const FindBuildActionsStep = ({ + canViewBuild, + canDeleteBuild, + canCancelBuild, + canCreateNewVersion, + canAddToCatalog, +}: FindBuildActionsStepProps) => { + const { t } = useTranslation(); + + const actions = [t('Create a new build')]; + + if (canViewBuild) { + actions.push(t('View details')); + } + if (canCreateNewVersion) { + actions.push(t('Create a new version (Rebuild)')); + } + if (canAddToCatalog) { + actions.push(t('Add to catalog')); + } + if (canDeleteBuild) { + actions.push(t('Delete')); + } + if (canCancelBuild) { + actions.push(t('Cancel')); + } + + return ( + <> + + + {canViewBuild && {t('You can perform the same actions via the build detail page as well.')}} + + ); +}; + +export default FindBuildActionsStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/image-building/steps/ImageBuildListPreview.tsx b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/ImageBuildListPreview.tsx new file mode 100644 index 000000000..850085947 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/ImageBuildListPreview.tsx @@ -0,0 +1,149 @@ +import * as React from 'react'; +import { Gallery, Stack, StackItem, Title } from '@patternfly/react-core'; +import { ExpandableRowContent, Td, Tr } from '@patternfly/react-table'; + +import { + ApiVersion, + ExportFormatType, + type ImageExport, + ImageExportConditionReason, + ImageExportConditionType, +} from '@flightctl/types/imagebuilder'; +import { ConditionStatus } from '@flightctl/types'; + +import { useTranslation } from '../../../../../hooks/useTranslation'; +import { getAllExportFormats } from '../../../../../utils/imageBuilds'; +import { getDateDisplay } from '../../../../../utils/dates'; +import { type ImageExportAction, ViewImageBuildExportCard } from '../../../../ImageBuilds/ImageExportCards'; +import { HealthyStatusPreview, PreviewButton, SeeHowItLooksPreview } from '../../../guide/SeeHowItLooksPreview'; +import { ListPreviewTable } from '../../../guide/ListPreviewTable'; +import type { ApiTableColumn } from '../../../../Table/Table'; + +const fakeCreationDate = '2026-03-22T00:00:00Z'; +const permissions: ImageExportAction[] = ['download', 'createExport']; + +const exampleQcow2Export: ImageExport = { + apiVersion: ApiVersion.ApiVersionV1alpha1, + kind: 'ImageExport', + metadata: { + name: 'example-image-build-qcow2-export', + creationTimestamp: fakeCreationDate, + }, + spec: { + format: ExportFormatType.ExportFormatTypeQCOW2, + source: { + type: 'imageBuild', + imageBuildRef: 'example-image-build', + }, + }, + status: { + conditions: [ + { + type: ImageExportConditionType.ImageExportConditionTypeReady, + reason: ImageExportConditionReason.ImageExportConditionReasonCompleted, + status: ConditionStatus.ConditionStatusTrue, + lastTransitionTime: '', + message: '', + }, + ], + }, +}; + +const ImageBuildExportsPreview = () => ( + + {getAllExportFormats().map((format) => { + return ( + {}} + onDismissError={() => {}} + /> + ); + })} + +); + +const ImageBuildListPreviewRow = ({ columns }: { columns: ApiTableColumn[] }) => { + const { t } = useTranslation(); + const [isExpanded, setIsExpanded] = React.useState(false); + const rowIndex = 0; + + return ( + <> + + setIsExpanded(!isExpanded), + }} + /> + + + + registry.example.com/rhel-bootc:9.4 + registry.example.com/org/edge-image:1.0.0 + + + + - + {getDateDisplay(fakeCreationDate)} + + + + + + + + + + + {t('Build information')} + + + + + + + + + + + + + + + + ); +}; + +const ImageBuildListPreview = () => { + const { t } = useTranslation(); + + const columns = React.useMemo( + () => [ + { name: t('Name') }, + { name: t('Base image') }, + { name: t('Image output') }, + { name: t('Build status') }, + { name: t('Promotion status') }, + { name: t('Date') }, + ], + [t], + ); + + return ( + + + + + + ); +}; + +export default ImageBuildListPreview; diff --git a/libs/ui-components/src/components/QuickStart/phases/image-building/steps/MeetImageBuildWizardStep.tsx b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/MeetImageBuildWizardStep.tsx new file mode 100644 index 000000000..5c0603895 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/MeetImageBuildWizardStep.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; + +import { StepBody, StepHeader, StepHint } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +const MeetImageBuildWizardStep = () => { + const { t } = useTranslation(); + + return ( + <> + + + {t( + 'This wizard builds a custom edge OS image from a base OS and pushes the result to your registry. Work through five steps on the left (Base image, Image output, Registration, Software Catalog, and Review) with the form for each step on the right. Use the wizard Next button to move between steps. This guide follows along one step at a time.', + )} + + + {t( + 'Base image and Image output are required. Registration sets enrollment binding. Software Catalog is optional.', + )} + + + ); +}; + +export default MeetImageBuildWizardStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/image-building/steps/OpenBuildDetailPageStep.tsx b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/OpenBuildDetailPageStep.tsx new file mode 100644 index 000000000..a769b124f --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/OpenBuildDetailPageStep.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; + +import { StepBody, StepHeader, StepHint } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +const OpenBuildDetailPageStep = () => { + const { t } = useTranslation(); + + return ( + <> + + + {t( + 'Click the build name in the list or View more in the expanded panel to open the detail page for configuration, exports, YAML, and logs.', + )} + + {t('You can open any build from the list.')} + + ); +}; + +export default OpenBuildDetailPageStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/image-building/steps/OpenDetailsPageStep.tsx b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/OpenDetailsPageStep.tsx new file mode 100644 index 000000000..3c9ecf8a8 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/OpenDetailsPageStep.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; + +import { StepBody, StepHeader } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +const OpenDetailsPageStep = () => { + const { t } = useTranslation(); + + return ( + <> + + + {t( + 'Click a build name in the list or choose View more after expanding a row to open its detail page for configuration, exports, YAML, and logs.', + )} + + + ); +}; + +export default OpenDetailsPageStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/image-building/steps/OpenImageBuildsStep.tsx b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/OpenImageBuildsStep.tsx new file mode 100644 index 000000000..bf805b0c2 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/OpenImageBuildsStep.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { type TFunction } from 'react-i18next'; + +import { StepBody, StepHeader, StepHint } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +const viewerOrientation = (t: TFunction) => + t( + 'The Image builds page tracks OS image builds across your organization. Your administrator or image builder creates and manages images here.', + ); + +const adminView = (t: TFunction) => + t( + 'This is the Image builds page. Here you will see image builds and their status in the list. From here you can open a build to review its configuration and progress.', + ); + +type OpenImageBuildsStepProps = { + canCreateBuild: boolean; + hasBuilds?: boolean; + isOnBuildsPage?: boolean; +}; + +const StepHintContent = ({ isOnBuildsPage, hasBuilds, canCreateBuild }: OpenImageBuildsStepProps) => { + const { t } = useTranslation(); + + if (!isOnBuildsPage) { + return {t('Open Image builds in the left sidebar, then continue here.')}; + } + if (hasBuilds) { + return {t('Browse the builds in the table behind this guide')}; + } + if (canCreateBuild) { + return {t('Create your first build to get started')}; + } + return null; +}; + +const StepBodyContent = ({ canCreateBuild }: OpenImageBuildsStepProps) => { + const { t } = useTranslation(); + + if (!canCreateBuild) { + return {viewerOrientation(t)}; + } + + return {adminView(t)}; +}; + +const OpenImageBuildsStep = ({ canCreateBuild, isOnBuildsPage, hasBuilds }: OpenImageBuildsStepProps) => { + const { t } = useTranslation(); + + return ( + <> + + + + + + + ); +}; + +export default OpenImageBuildsStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/image-building/steps/PreviewInListStep.tsx b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/PreviewInListStep.tsx new file mode 100644 index 000000000..e6383084b --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/PreviewInListStep.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; + +import { StepBody, StepHeader } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; +import ImageBuildListPreview from './ImageBuildListPreview'; + +type PreviewInListStepProps = { + hasBuilds: boolean; +}; + +const PreviewInListStep = ({ hasBuilds }: PreviewInListStepProps) => { + const { t } = useTranslation(); + + return ( + <> + + + {hasBuilds + ? t('Expand a row using the arrow on the left to see a build summary and any export formats available.') + : t( + 'The image builds table is empty right now. Once a build finishes, expand its row to see a summary and any export formats available.', + )} + + {!hasBuilds && } + + ); +}; + +export default PreviewInListStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/image-building/steps/PublishToCatalogStep.tsx b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/PublishToCatalogStep.tsx new file mode 100644 index 000000000..fb359e878 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/PublishToCatalogStep.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; + +import { StepBody, StepHeader, StepHint } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +const PublishToCatalogStep = () => { + const { t } = useTranslation(); + + return ( + <> + + + {t( + 'Optionally publish the built image to the Software Catalog testing channel after a successful build so other teams can discover and deploy it.', + )} + + + {t( + 'You can skip this step or publish later from the build detail page. Use wizard Next when you are ready for review.', + )} + + + ); +}; + +export default PublishToCatalogStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/image-building/steps/ReviewAndStartBuildStep.tsx b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/ReviewAndStartBuildStep.tsx new file mode 100644 index 000000000..4d78e8203 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/ReviewAndStartBuildStep.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; + +import { StepBody, StepHeader, StepHint } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +const ReviewAndStartBuildStep = () => { + const { t } = useTranslation(); + + return ( + <> + + + {t( + 'Confirm your base image, output, registration, and catalog choices, then click Build image to start the build.', + )} + + {t('Review the summary on the right before submitting.')} + + ); +}; + +export default ReviewAndStartBuildStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/image-building/steps/StartNewImageBuildStep.tsx b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/StartNewImageBuildStep.tsx new file mode 100644 index 000000000..5fffb2051 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/StartNewImageBuildStep.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; + +import { StepBody, StepHeader, StepHint } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +const StartNewImageBuildStep = () => { + const { t } = useTranslation(); + + return ( + <> + + + {t( + 'Click "Build new image" in the toolbar to open the image build wizard. This guides you through selecting a base OS, configuring output, embedding the enrollment certificate, and optionally publishing to the Software Catalog.', + )} + + {t("You'll need access to at least one OCI repository where base images are stored.")} + + ); +}; + +export default StartNewImageBuildStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/image-building/steps/TeamEscalationStep.tsx b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/TeamEscalationStep.tsx new file mode 100644 index 000000000..ea7d9a36b --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/TeamEscalationStep.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; + +import { StepHeader, StepItemList } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +export type ImageBuildTeamEscalationPermissions = { + canCreateBuild: boolean; + canCreateExport: boolean; + canDownloadExport: boolean; + canAddToCatalog: boolean; +}; + +const TeamEscalationStep = ({ + canCreateBuild, + canCreateExport, + canDownloadExport, + canAddToCatalog, +}: ImageBuildTeamEscalationPermissions) => { + const { t } = useTranslation(); + + const needs: string[] = []; + if (!canCreateBuild) { + needs.push(t('Request a new or updated OS image')); + } + if (!canCreateExport && !canDownloadExport) { + needs.push(t('Obtain a disk image for device provisioning')); + } + if (!canAddToCatalog) { + needs.push(t('Publish a build to the Software Catalog')); + } + + if (needs.length === 0) { + return null; + } + + return ( + <> + + + + ); +}; + +export default TeamEscalationStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/image-building/steps/ViewerUserCapabilitiesStep.tsx b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/ViewerUserCapabilitiesStep.tsx new file mode 100644 index 000000000..331487977 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/image-building/steps/ViewerUserCapabilitiesStep.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; + +import { StepBody, StepHeader, StepItemList } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +export type ImageBuildViewerUserCapabilitiesPermissions = { + canListBuilds: boolean; + canViewBuild: boolean; + canViewLogs: boolean; + canDownloadExport: boolean; +}; + +const ViewerUserCapabilitiesStep = ({ + canListBuilds, + canViewBuild, + canViewLogs, + canDownloadExport, +}: ImageBuildViewerUserCapabilitiesPermissions) => { + const { t } = useTranslation(); + + const capabilities: string[] = []; + if (canListBuilds) { + capabilities.push(t('See image builds and their status in the list')); + } + if (canViewBuild) { + capabilities.push(t('Open a build to review its configuration and progress')); + } + if (canViewLogs) { + capabilities.push(t('Review build logs when troubleshooting')); + } + if (canDownloadExport) { + capabilities.push(t('Download exported disk images when they are available')); + } + + return ( + <> + + {capabilities.length > 0 ? ( + + ) : ( + {t('The Image builds area is where your organization prepares custom edge OS images.')} + )} + + {t('New images are created through a build wizard managed by your administrator or image builder.')} + + + ); +}; + +export default ViewerUserCapabilitiesStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/orientation/OrientationPhase.tsx b/libs/ui-components/src/components/QuickStart/phases/orientation/OrientationPhase.tsx new file mode 100644 index 000000000..4a1cdd48e --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/orientation/OrientationPhase.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; + +import { useQuickStartGuide } from '../../QuickStartContext'; +import ProductStep from './steps/ProductStep'; +import NavStep from './steps/NavStep'; +import OverviewStep from './steps/OverviewStep'; + +export const ORIENTATION_STEP_COUNT = 3; + +const OrientationPhase = () => { + const { activePhaseId, activeStepIndex, setStepIndex, setGuidePresentation, setGuideActions, completePhase } = + useQuickStartGuide(); + + const isLastStep = activeStepIndex >= ORIENTATION_STEP_COUNT - 1; + + const onNext = React.useCallback(() => { + if (isLastStep) { + completePhase(); + return; + } + setStepIndex(activeStepIndex + 1); + }, [activeStepIndex, completePhase, isLastStep, setStepIndex]); + + const onBack = React.useCallback(() => { + setStepIndex(activeStepIndex - 1); + }, [activeStepIndex, setStepIndex]); + + React.useEffect(() => { + setGuidePresentation({ + totalSteps: ORIENTATION_STEP_COUNT, + canGoBack: activeStepIndex > 0, + canGoNext: true, + isLastStep, + }); + }, [activeStepIndex, isLastStep, setGuidePresentation]); + + React.useEffect(() => { + setGuideActions({ onBack, onNext }); + return () => setGuideActions(null); + }, [onBack, onNext, setGuideActions]); + + if (activePhaseId !== 'orientation') { + throw new Error('OrientationPhase expected orientation to be active'); + } + + switch (activeStepIndex) { + case 0: + return ; + case 1: + return ; + case 2: + return ; + default: + return null; + } +}; + +export default OrientationPhase; diff --git a/libs/ui-components/src/components/QuickStart/phases/orientation/steps/NavStep.tsx b/libs/ui-components/src/components/QuickStart/phases/orientation/steps/NavStep.tsx new file mode 100644 index 000000000..92f21a6f8 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/orientation/steps/NavStep.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; + +import { StepBody, StepHeader, StepHint } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +const NavStep = () => { + const { t } = useTranslation(); + + return ( + <> + + + {t( + 'The left sidebar is your primary navigation. Each section corresponds to a stage of the edge device lifecycle: Image builds for OS preparation, Devices for enrollment and monitoring, Fleets for group configuration, Software Catalog for application management, and Repositories for GitOps sources.', + )} + + + {t( + "Sections visible to you depend on your permissions. If something isn't listed, your administrator controls access.", + )} + + + ); +}; + +export default NavStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/orientation/steps/OverviewStep.tsx b/libs/ui-components/src/components/QuickStart/phases/orientation/steps/OverviewStep.tsx new file mode 100644 index 000000000..7ec7904e3 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/orientation/steps/OverviewStep.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; + +import { StepBody, StepHeader, StepHint } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; + +const OverviewStep = () => { + const { t } = useTranslation(); + + return ( + <> + + + {t( + 'The Overview page gives you an at-a-glance summary of your edge environment. The Status card shows device and application health. The Tasks card tracks pending enrollment requests. The Security card surfaces active vulnerabilities. Use this page as your starting point each day.', + )} + + {t('Cards adapt to what you have permission to see. Some may not appear for all roles.')} + + ); +}; + +export default OverviewStep; diff --git a/libs/ui-components/src/components/QuickStart/phases/orientation/steps/ProductStep.tsx b/libs/ui-components/src/components/QuickStart/phases/orientation/steps/ProductStep.tsx new file mode 100644 index 000000000..4090b300b --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/phases/orientation/steps/ProductStep.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; + +import { StepBody, StepHeader, StepHint } from '../../../guide/StepChrome'; +import { useTranslation } from '../../../../../hooks/useTranslation'; +import { useProductName } from '../../../../../hooks/useProductName'; + +const ProductStep = () => { + const { t } = useTranslation(); + const productName = useProductName(); + + return ( + <> + + + {t( + '{{ productName }} lets you build OS images, enroll edge devices, organize them into fleets, and deploy software, all from a single console. It handles the full lifecycle from initial provisioning through day-2 operations like updates, monitoring, and decommissioning.', + { productName }, + )} + + + {t('Think of it as a control plane for all your edge hardware, no matter where it is deployed.')} + + + ); +}; + +export default ProductStep; diff --git a/libs/ui-components/src/components/QuickStart/quickStartDefinitions.ts b/libs/ui-components/src/components/QuickStart/quickStartDefinitions.ts index a164ce7c7..a84335874 100644 --- a/libs/ui-components/src/components/QuickStart/quickStartDefinitions.ts +++ b/libs/ui-components/src/components/QuickStart/quickStartDefinitions.ts @@ -1,38 +1,70 @@ -import type { TFunction } from 'i18next'; +import type { TFunction } from 'react-i18next'; +import { RESOURCE, VERB } from '../../types/rbac'; +import type { PermissionCheck } from '../common/PermissionsContext'; +import type { QuickStartPhaseDefinition } from './types'; -import type { QuickStartPhase } from './types'; +export const QUICK_START_PHASE_IDS = ['orientation', 'enroll-device', 'manage-fleet', 'build-image'] as const; +export type QuickStartPhaseId = (typeof QUICK_START_PHASE_IDS)[number]; -export const QUICK_START_PHASE_ORDER = ['orientation', 'image-building', 'enrollment', 'fleet-management'] as const; - -export type QuickStartPhaseId = (typeof QUICK_START_PHASE_ORDER)[number]; - -export const getQuickStartPhases = (t: TFunction, productName: string): QuickStartPhase[] => [ +export const quickStartPhaseDefinitions: QuickStartPhaseDefinition[] = [ { id: 'orientation', - title: t('Welcome & Orientation'), icon: 'home', - description: t('Get familiar with {{ productName }} and learn how to navigate the console.', { - productName, - }), + estimatedMinutes: 5, + listResourceNeeded: null, }, { - id: 'image-building', - title: t('Build an OS Image'), + id: 'build-image', icon: 'image', - description: t('Create a custom edge OS image so devices auto-register on boot.'), + estimatedMinutes: 15, + listResourceNeeded: RESOURCE.IMAGE_BUILD, }, { - id: 'enrollment', - title: t('Enroll Your First Device'), + id: 'enroll-device', icon: 'plus', - description: t('Boot a device with your image, then approve it to bring it under management.'), + estimatedMinutes: 10, + listResourceNeeded: RESOURCE.DEVICE, }, { - id: 'fleet-management', - title: t('Create and Manage Fleets'), + id: 'manage-fleet', icon: 'cubes', - description: t( - 'Group devices that share identical or similar configuration and update policy for centralized management.', - ), + estimatedMinutes: 16, + listResourceNeeded: RESOURCE.FLEET, }, ]; + +export const getPhaseTitle = (t: TFunction, phaseId: QuickStartPhaseId) => { + switch (phaseId) { + case 'orientation': + return t('Welcome & Orientation'); + case 'build-image': + return t('Build an OS Image'); + case 'enroll-device': + return t('Enroll Your First Device'); + case 'manage-fleet': + return t('Create and Manage Fleets'); + } +}; + +export const getPhaseDescription = ( + t: TFunction, + phaseId: QuickStartPhaseId, + productName: string, + checkPermissions?: (checks: PermissionCheck[]) => boolean[], +) => { + switch (phaseId) { + case 'orientation': + return t('Get familiar with {{ productName }} and learn how to navigate the console.', { productName }); + case 'build-image': + return t('Create a custom edge OS image so devices auto-register on boot.'); + case 'enroll-device': + return t('Boot a device with your image, then approve it to bring it under management.'); + case 'manage-fleet': + if (checkPermissions && !checkPermissions([{ kind: RESOURCE.FLEET, verb: VERB.CREATE }])[0]) { + return t( + 'Learn how fleets group devices, what you can view on the Fleets page, and how fleet administrators manage rollouts.', + ); + } + return t('Group devices that share the same OS image and update policy into a fleet for centralized management.'); + } +}; diff --git a/libs/ui-components/src/components/QuickStart/quickStartPhaseUtils.ts b/libs/ui-components/src/components/QuickStart/quickStartPhaseUtils.ts new file mode 100644 index 000000000..2428f4342 --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/quickStartPhaseUtils.ts @@ -0,0 +1,75 @@ +import type { PhaseCardStatus, QuickStartPersistedState, QuickStartPhaseDefinition, QuickStartPhaseId } from './types'; +import type { PermissionCheck } from '../common/PermissionsContext'; +import { buildEnrollmentGuideSteps } from './phases/enrollment/enrollmentGuideSteps'; +import { buildFleetManagementGuideSteps } from './phases/fleet-management/fleetManagementGuideSteps'; +import { buildImageBuildingGuideSteps } from './phases/image-building/imageBuildingGuideSteps'; +import { ORIENTATION_STEP_COUNT } from './phases/orientation/OrientationPhase'; + +export const isPhaseComplete = (phaseId: QuickStartPhaseId, persisted: QuickStartPersistedState): boolean => + persisted.completedPhases.includes(phaseId); + +export const pathMatchesRoute = (pathname: string, routePath: string): boolean => { + if (routePath === '/') { + return pathname === '/'; + } + return pathname === routePath || pathname.startsWith(`${routePath}/`); +}; + +const isPhaseLocked = ( + phaseIndex: number, + visiblePhases: QuickStartPhaseDefinition[], + persisted: QuickStartPersistedState, +): boolean => { + if (phaseIndex === 0) { + return false; + } + + const previousPhase = visiblePhases[phaseIndex - 1]; + return !isPhaseComplete(previousPhase.id, persisted); +}; + +export const getPhaseStatus = ( + phaseId: QuickStartPhaseId, + phaseIndex: number, + visiblePhases: QuickStartPhaseDefinition[], + persisted: QuickStartPersistedState, + activePhaseId: QuickStartPhaseId | null = null, +): PhaseCardStatus => { + if (isPhaseLocked(phaseIndex, visiblePhases, persisted)) { + return 'locked'; + } + if (isPhaseComplete(phaseId, persisted)) { + return 'complete'; + } + if (activePhaseId === phaseId || persisted.phaseProgress[phaseId] !== undefined) { + return 'in-progress'; + } + return 'not-started'; +}; + +export const getPhaseStepCount = ( + phaseId: QuickStartPhaseId, + checkPermissions: (checks: PermissionCheck[]) => boolean[], +): number => { + switch (phaseId) { + case 'orientation': + return ORIENTATION_STEP_COUNT; + case 'build-image': + return buildImageBuildingGuideSteps({ + checkPermissions, + isOnBuildsPage: false, + hasBuilds: false, + }).length; + case 'enroll-device': + return buildEnrollmentGuideSteps({ + checkPermissions, + isOnDevicesPage: false, + isStepActionCompleted: () => false, + }).length; + case 'manage-fleet': + return buildFleetManagementGuideSteps({ + checkPermissions, + isOnFleetsPage: false, + }).length; + } +}; diff --git a/libs/ui-components/src/components/QuickStart/quickStartStorage.ts b/libs/ui-components/src/components/QuickStart/quickStartStorage.ts index ebae78862..56203d63e 100644 --- a/libs/ui-components/src/components/QuickStart/quickStartStorage.ts +++ b/libs/ui-components/src/components/QuickStart/quickStartStorage.ts @@ -1,10 +1,47 @@ -import { DEFAULT_QUICK_START_STATE, type PanelVisibility, type QuickStartPersistedState } from './types'; +import { + DEFAULT_QUICK_START_STATE, + type PanelVisibility, + type QuickStartPersistedState, + type QuickStartPhaseId, +} from './types'; +import { QUICK_START_PHASE_IDS } from './quickStartDefinitions'; const QUICK_START_STORAGE_KEY = 'flightctl/quickstart-state'; const isPanelVisibility = (value: unknown): value is PanelVisibility => value === 'expanded' || value === 'collapsed' || value === 'hidden'; +const isQuickStartPhaseId = (value: unknown): value is QuickStartPhaseId => + typeof value === 'string' && (QUICK_START_PHASE_IDS as readonly string[]).includes(value); + +const parseCompletedPhases = (value: unknown): QuickStartPhaseId[] => { + if (!Array.isArray(value)) { + return []; + } + return value.filter(isQuickStartPhaseId); +}; + +const parsePhaseProgress = (value: unknown): QuickStartPersistedState['phaseProgress'] => { + if (!value || typeof value !== 'object') { + return {}; + } + + const progress: QuickStartPersistedState['phaseProgress'] = {}; + for (const [key, entry] of Object.entries(value)) { + if (!isQuickStartPhaseId(key)) { + continue; + } + if (!entry || typeof entry !== 'object') { + continue; + } + const lastStepIndex = (entry as { lastStepIndex?: unknown }).lastStepIndex; + if (typeof lastStepIndex === 'number' && lastStepIndex >= 0) { + progress[key] = { lastStepIndex }; + } + } + return progress; +}; + export const getPersistedQuickStartState = (): QuickStartPersistedState => { try { const stored = window.localStorage.getItem(QUICK_START_STORAGE_KEY); @@ -17,6 +54,8 @@ export const getPersistedQuickStartState = (): QuickStartPersistedState => { if (isPanelVisibility(parsed.panelVisibility)) { return { panelVisibility: parsed.panelVisibility, + completedPhases: parseCompletedPhases(parsed.completedPhases), + phaseProgress: parsePhaseProgress(parsed.phaseProgress), }; } } catch { diff --git a/libs/ui-components/src/components/QuickStart/types.ts b/libs/ui-components/src/components/QuickStart/types.ts index d80a21c0f..aff9b01ed 100644 --- a/libs/ui-components/src/components/QuickStart/types.ts +++ b/libs/ui-components/src/components/QuickStart/types.ts @@ -1,21 +1,63 @@ +import type * as React from 'react'; + import type { QuickStartPhaseId } from './quickStartDefinitions'; +import type { RESOURCE } from '../../types/rbac'; export type { QuickStartPhaseId } from './quickStartDefinitions'; -export { QUICK_START_PHASE_ORDER } from './quickStartDefinitions'; - +export type { PermissionCheck } from '../common/PermissionsContext'; export type PanelVisibility = 'expanded' | 'collapsed' | 'hidden'; +export type PhaseCardStatus = 'locked' | 'not-started' | 'in-progress' | 'complete'; + +export type QuickStartGuideStep = { + // If true, the step is only available if the user is on the corresponding list page. + mustBeOnListPage?: boolean; + render: () => React.ReactNode; +}; -export interface QuickStartPhase { +export type QuickStartGuidePresentation = { + stepTitle: React.ReactNode | null; + totalSteps: number; + canGoBack: boolean; + canGoNext: boolean; + isLastStep: boolean; + footerExtra?: React.ReactNode; +}; + +export type QuickStartGuideActions = { + onBack: VoidFunction; + onNext: VoidFunction; +}; + +export const DEFAULT_GUIDE_PRESENTATION: QuickStartGuidePresentation = { + stepTitle: null, + totalSteps: 0, + canGoBack: false, + canGoNext: false, + isLastStep: false, +}; + +export type QuickStartPhaseIcon = 'home' | 'image' | 'plus' | 'cubes'; + +export interface QuickStartPhaseDefinition { id: QuickStartPhaseId; - title: string; - icon: string; - description: string; + icon: 'home' | 'image' | 'plus' | 'cubes'; + estimatedMinutes: number; + listResourceNeeded: RESOURCE | null; +} + +export interface QuickStartPhase extends QuickStartPhaseDefinition { + stepCount: number; + status: PhaseCardStatus; } export interface QuickStartPersistedState { panelVisibility: PanelVisibility; + completedPhases: QuickStartPhaseId[]; + phaseProgress: Partial>; } export const DEFAULT_QUICK_START_STATE: QuickStartPersistedState = { panelVisibility: 'expanded', + completedPhases: [], + phaseProgress: {}, }; diff --git a/libs/ui-components/src/components/QuickStart/useQuickStartListHasItems.ts b/libs/ui-components/src/components/QuickStart/useQuickStartListHasItems.ts new file mode 100644 index 000000000..a786e8d8a --- /dev/null +++ b/libs/ui-components/src/components/QuickStart/useQuickStartListHasItems.ts @@ -0,0 +1,54 @@ +import { DeviceLifecycleStatusType, ResourceKind } from '@flightctl/types'; +import { ResourceKind as ImageBuilderResourceKind } from '@flightctl/types/imagebuilder'; + +import { useFetchPeriodically } from '../../hooks/useFetchPeriodically'; +import { type ApiList, getApiListCount } from '../../utils/api'; + +const enrolledDevicesStatuses = [ + DeviceLifecycleStatusType.DeviceLifecycleStatusEnrolled, + DeviceLifecycleStatusType.DeviceLifecycleStatusUnknown, +]; + +type QuickStartListKind = + | ResourceKind.DEVICE + | ResourceKind.ENROLLMENT_REQUEST + | ResourceKind.FLEET + | ImageBuilderResourceKind.IMAGE_BUILD; + +const getKindQuery = (kind?: QuickStartListKind) => { + switch (kind) { + case ResourceKind.DEVICE: + return `devices?fieldSelector=status.lifecycle.status in (${enrolledDevicesStatuses.join(',')})`; + case ResourceKind.ENROLLMENT_REQUEST: + return 'enrollmentrequests?fieldSelector=!status.approval.approved'; + case ResourceKind.FLEET: + return 'fleets'; + case ImageBuilderResourceKind.IMAGE_BUILD as const: + return 'imagebuilds'; + case undefined: + return ''; + } +}; + +const queryWithLimit = (query: string) => { + if (!query) { + return ''; + } + return `${query}${query.includes('?') ? '&' : '?'}limit=1`; +}; + +/** + * Opt-in list probe for a QuickStart step content component. + * Polls a single list endpoint + */ +export const useQuickStartListHasItems = (kind?: QuickStartListKind): { hasItems: boolean; isLoading: boolean } => { + const query = getKindQuery(kind); + const [list, isLoading] = useFetchPeriodically({ + endpoint: queryWithLimit(query), + }); + + const count = getApiListCount(list); + const hasItems = !isLoading && count !== undefined && count > 0; + + return { hasItems, isLoading }; +}; diff --git a/libs/ui-components/src/components/QuickStart/useQuickStartProductName.ts b/libs/ui-components/src/components/QuickStart/useQuickStartProductName.ts deleted file mode 100644 index 4539284e5..000000000 --- a/libs/ui-components/src/components/QuickStart/useQuickStartProductName.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { useAppContext } from '../../hooks/useAppContext'; -import { useTranslation } from '../../hooks/useTranslation'; - -export const useQuickStartProductName = (): string => { - const { t } = useTranslation(); - const { settings } = useAppContext(); - - return settings.isRHEM ? t('Red Hat Edge Manager') : t('Flight Control'); -}; diff --git a/libs/ui-components/src/hooks/useProductName.ts b/libs/ui-components/src/hooks/useProductName.ts new file mode 100644 index 000000000..7a531d7a3 --- /dev/null +++ b/libs/ui-components/src/hooks/useProductName.ts @@ -0,0 +1,9 @@ +import { useAppContext } from './useAppContext'; +import { useTranslation } from './useTranslation'; + +export const useProductName = (): string => { + const { t } = useTranslation(); + const { settings } = useAppContext(); + + return settings.isRHEM ? t('Red Hat Edge Manager') : t('Flight Control'); +};