diff --git a/apps/ocp-plugin/src/components/AppContext/AppContext.tsx b/apps/ocp-plugin/src/components/AppContext/AppContext.tsx index 246d0242d..67c1824e2 100644 --- a/apps/ocp-plugin/src/components/AppContext/AppContext.tsx +++ b/apps/ocp-plugin/src/components/AppContext/AppContext.tsx @@ -1,3 +1,4 @@ +import * as React from 'react'; import { AppContext, AppContextProps, @@ -5,6 +6,7 @@ import { NavLinkFC, PromptFC, } from '@flightctl/ui-components/src/hooks/useAppContext'; +import { SystemRestoreProvider } from '@flightctl/ui-components/src/hooks/useSystemRestoreContext'; import { ROUTE } from '@flightctl/ui-components/src/hooks/useNavigate'; import { Link, @@ -24,7 +26,20 @@ import { useFetch } from '../../hooks/useFetch'; import './AppContext.css'; -export const OCPPluginAppContext = AppContext.Provider; +/** + * OCP Plugin App Context Provider that includes SystemRestoreProvider + * The OCP plugin system calls useValuesAppContext separately and passes the value as a prop + */ +export const OCPPluginAppContext: React.FC> = ({ + children, + value, +}) => { + return ( + + {children} + + ); +}; const appRoutes = { [ROUTE.ROOT]: '/', diff --git a/apps/ocp-plugin/src/hooks/useFetch.ts b/apps/ocp-plugin/src/hooks/useFetch.ts index 37a8a4a1e..0be0b05f1 100644 --- a/apps/ocp-plugin/src/hooks/useFetch.ts +++ b/apps/ocp-plugin/src/hooks/useFetch.ts @@ -42,9 +42,16 @@ export const useFetch = () => { [], ); - const post = React.useCallback(async (kind: string, obj: R): Promise => postData(kind, obj), []); + const post = React.useCallback( + async (kind: string, data: TRequest): Promise => { + return postData(kind, data); + }, + [], + ); - const put = React.useCallback(async (kind: string, obj: R): Promise => putData(kind, obj), []); + const put = React.useCallback(async (kind: string, data: TRequest): Promise => { + return putData(kind, data); + }, []); const remove = React.useCallback( async (kind: string, abortSignal?: AbortSignal): Promise => deleteData(kind, abortSignal), @@ -52,8 +59,8 @@ export const useFetch = () => { ); const patch = React.useCallback( - async (kind: string, obj: PatchRequest, abortSignal?: AbortSignal): Promise => - patchData(kind, obj, abortSignal), + async (kind: string, patches: PatchRequest, abortSignal?: AbortSignal): Promise => + patchData(kind, patches, abortSignal), [], ); diff --git a/apps/ocp-plugin/src/utils/apiCalls.ts b/apps/ocp-plugin/src/utils/apiCalls.ts index 6cab73e76..165c0032e 100644 --- a/apps/ocp-plugin/src/utils/apiCalls.ts +++ b/apps/ocp-plugin/src/utils/apiCalls.ts @@ -72,7 +72,11 @@ export const handleApiJSONResponse = async (response: Response): Promise = throw new Error(await getErrorMsgFromApiResponse(response)); }; -const putOrPostData = async (kind: string, data: R, method: 'PUT' | 'POST'): Promise => { +const putOrPostData = async ( + kind: string, + data: TRequest, + method: 'PUT' | 'POST', +): Promise => { const options: RequestInit = { headers: { 'Content-Type': 'application/json', @@ -90,9 +94,11 @@ const putOrPostData = async (kind: string, data: R, method: 'PUT' | 'POST'): } }; -export const postData = async (kind: string, data: R): Promise => putOrPostData(kind, data, 'POST'); +export const postData = async (kind: string, data: TRequest): Promise => + putOrPostData(kind, data, 'POST'); -export const putData = async (kind: string, data: R): Promise => putOrPostData(kind, data, 'PUT'); +export const putData = async (kind: string, data: TRequest): Promise => + putOrPostData(kind, data, 'PUT'); export const deleteData = async (kind: string, abortSignal?: AbortSignal): Promise => { const options: RequestInit = { diff --git a/apps/standalone/src/app/hooks/useFetch.ts b/apps/standalone/src/app/hooks/useFetch.ts index 2176618a9..2a15c380a 100644 --- a/apps/standalone/src/app/hooks/useFetch.ts +++ b/apps/standalone/src/app/hooks/useFetch.ts @@ -8,9 +8,16 @@ export const useFetch = () => { [], ); - const post = React.useCallback(async (kind: string, obj: R): Promise => postData(kind, obj), []); + const post = React.useCallback( + async (kind: string, data: TRequest): Promise => + postData(kind, data), + [], + ); - const put = React.useCallback(async (kind: string, obj: R): Promise => putData(kind, obj), []); + const put = React.useCallback( + async (kind: string, data: TRequest): Promise => putData(kind, data), + [], + ); const remove = React.useCallback( async (kind: string, abortSignal?: AbortSignal): Promise => deleteData(kind, abortSignal), diff --git a/apps/standalone/src/app/index.tsx b/apps/standalone/src/app/index.tsx index 3712d4493..fcda78f8f 100644 --- a/apps/standalone/src/app/index.tsx +++ b/apps/standalone/src/app/index.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { AppContext } from '@flightctl/ui-components/src/hooks/useAppContext'; +import { SystemRestoreProvider } from '@flightctl/ui-components/src/hooks/useSystemRestoreContext'; import { AppRouter } from './routes'; import { useStandaloneAppContext } from './hooks/useStandaloneAppContext'; @@ -19,7 +20,9 @@ const App: React.FunctionComponent = () => { }> - + + + diff --git a/apps/standalone/src/app/utils/apiCalls.ts b/apps/standalone/src/app/utils/apiCalls.ts index d40358e69..f08f92c62 100644 --- a/apps/standalone/src/app/utils/apiCalls.ts +++ b/apps/standalone/src/app/utils/apiCalls.ts @@ -105,9 +105,13 @@ export const fetchCliArtifacts = async (abortSignal?: AbortSignal): Promise(kind: string, data: R, method: 'PUT' | 'POST'): Promise => { +const putOrPostData = async ( + kind: string, + data: TRequest, + method: 'PUT' | 'POST', +): Promise => { try { - return await fetchWithRetry(kind, { + return await fetchWithRetry(kind, { headers: { 'Content-Type': 'application/json', }, @@ -121,9 +125,11 @@ const putOrPostData = async (kind: string, data: R, method: 'PUT' | 'POST'): } }; -export const postData = async (kind: string, data: R): Promise => putOrPostData(kind, data, 'POST'); +export const postData = async (kind: string, data: TRequest): Promise => + putOrPostData(kind, data, 'POST'); -export const putData = async (kind: string, data: R): Promise => putOrPostData(kind, data, 'PUT'); +export const putData = async (kind: string, data: TRequest): Promise => + putOrPostData(kind, data, 'PUT'); export const deleteData = async (kind: string, abortSignal?: AbortSignal): Promise => { try { diff --git a/libs/ansible/src/hooks/useFetch.ts b/libs/ansible/src/hooks/useFetch.ts index e1a11b4f6..338bdf885 100644 --- a/libs/ansible/src/hooks/useFetch.ts +++ b/libs/ansible/src/hooks/useFetch.ts @@ -24,12 +24,13 @@ export const useFetch = (getCookie: (name: string) => string | undefined, servic ); const post = React.useCallback( - async (kind: string, obj: R): Promise => postData(kind, obj, serviceUrl, applyHeaders), + async (kind: string, data: TRequest): Promise => + postData(kind, data, serviceUrl, applyHeaders), [serviceUrl, applyHeaders], ); const put = React.useCallback( - async (kind: string, obj: R): Promise => putData(kind, obj, serviceUrl, applyHeaders), + async (kind: string, data: TRequest): Promise => putData(kind, data, serviceUrl, applyHeaders), [serviceUrl, applyHeaders], ); diff --git a/libs/ansible/src/utils/apiCalls.ts b/libs/ansible/src/utils/apiCalls.ts index 3fb33a63f..1d82e7903 100644 --- a/libs/ansible/src/utils/apiCalls.ts +++ b/libs/ansible/src/utils/apiCalls.ts @@ -17,13 +17,13 @@ const handleApiJSONResponse = async (response: Response): Promise => { throw new Error(await getErrorMsgFromApiResponse(response)); }; -const putOrPostData = async ( +const putOrPostData = async ( kind: string, - data: R, + data: TRequest, serviceUrl: string, applyOptions: (options: RequestInit) => RequestInit, method: 'PUT' | 'POST', -): Promise => { +): Promise => { const options: RequestInit = { headers: { 'Content-Type': 'application/json', @@ -41,19 +41,19 @@ const putOrPostData = async ( } }; -export const postData = async ( +export const postData = async ( kind: string, - data: R, + data: TRequest, serviceUrl: string, applyOptions: (options: RequestInit) => RequestInit, -) => putOrPostData(kind, data, serviceUrl, applyOptions, 'POST'); +): Promise => putOrPostData(kind, data, serviceUrl, applyOptions, 'POST'); -export const putData = async ( +export const putData = async ( kind: string, - data: R, + data: TRequest, serviceUrl: string, applyOptions: (options: RequestInit) => RequestInit, -) => putOrPostData(kind, data, serviceUrl, applyOptions, 'PUT'); +): Promise => putOrPostData(kind, data, serviceUrl, applyOptions, 'PUT'); export const deleteData = async ( kind: string, diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 9198a8435..3dded55cf 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -90,6 +90,8 @@ "Failed to retrieve resource details": "Failed to retrieve resource details", "Delete {{ resourceType }}": "Delete {{ resourceType }}", "Decommission device": "Decommission device", + "Resume device": "Resume device", + "You are about to resume device <1>{deviceNameOrAlias}": "You are about to resume device <1>{deviceNameOrAlias}", "Actions dropdown": "Actions dropdown", "Actions": "Actions", "Device applications table": "Device applications table", @@ -122,6 +124,7 @@ "Applications": "Applications", "Track systemd services": "Track systemd services", "Delete forever": "Delete forever", + "You are about to resume <1>{deviceNameOrAlias}": "You are about to resume <1>{deviceNameOrAlias}", "Details": "Details", "YAML": "YAML", "Terminal": "Terminal", @@ -355,9 +358,11 @@ "Device specification has returned to a valid state": "Device specification has returned to a valid state", "Device specification is invalid": "Device specification is invalid", "Device is paused after database restore": "Device is paused after database restore", + "Device conflict has been resolved": "Device conflict has been resolved", "Enrollment request was approved": "Enrollment request was approved", "Enrollment request approval failed": "Enrollment request approval failed", "Internal task failed": "Internal task failed", + "Internal task permanently failed": "Internal task permanently failed", "Repository is accessible": "Repository is accessible", "Repository is inaccessible": "Repository is inaccessible", "Referenced repository was updated": "Referenced repository was updated", @@ -460,6 +465,9 @@ "Fleet devices popover": "Fleet devices popover", "Rollout error": "Rollout error", "Managed by the resource sync {{resourceSyncName}}": "Managed by the resource sync {{resourceSyncName}}", + "Resume all": "Resume all", + "You are about to resume all<1>{suspendedDevicesCount} suspended devices in <4>{fleetId}_one": "You are about to resume <1>{suspendedDevicesCount} suspended device in <4>{fleetId}", + "You are about to resume all<1>{suspendedDevicesCount} suspended devices in <4>{fleetId}_other": "You are about to resume all<1>{suspendedDevicesCount} suspended devices in <4>{fleetId}", "Importing fleets from <1>{name}. This might take a few minutes to complete.": "Importing fleets from <1>{name}. This might take a few minutes to complete.", "Fleets import failed": "Fleets import failed", "Importing fleets from <1>{name} failed. Check the resource sync for more details.": "Importing fleets from <1>{name} failed. Check the resource sync for more details.", @@ -558,9 +566,12 @@ "Invalid systemd service names: {{invalidPatterns}}": "Invalid systemd service names: {{invalidPatterns}}", "Systemd service names must be unique": "Systemd service names must be unique", "Device aliases must be unique. Add a number to the template to generate unique aliases.": "Device aliases must be unique. Add a number to the template to generate unique aliases.", + "Fleet selection is required": "Fleet selection is required", + "At least one label is required": "At least one label is required", "device": "device", "pending device": "pending device", "resource sync": "resource sync", + "You are about to resume device <1>{deviceName}": "You are about to resume device <1>{deviceName}", "No {{ productName }} command line tools were found for this deployment at this time.": "No {{ productName }} command line tools were found for this deployment at this time.", "Could not list the {{ productName }} command line tools": "Could not list the {{ productName }} command line tools", "Download flightctl CLI for {{ os }} for {{ arch }}": "Download flightctl CLI for {{ os }} for {{ arch }}", @@ -616,6 +627,47 @@ "Delete resource syncs": "Delete resource syncs", "Are you sure you want to delete the following resource syncs?": "Are you sure you want to delete the following resource syncs?", "Revision": "Revision", + "Failed to obtain the number of matching devices": "Failed to obtain the number of matching devices", + "Resume devices": "Resume devices", + "Resume selection": "Resume selection", + "Following a system restore, devices have been identified with configurations newer than the server's records. To prevent data loss, they have been suspended from receiving updates.": "Following a system restore, devices have been identified with configurations newer than the server's records. To prevent data loss, they have been suspended from receiving updates.", + "Choose the criteria to select the devices to resume": "Choose the criteria to select the devices to resume", + "Resume all suspended devices associated with a given fleet": "Resume all suspended devices associated with a given fleet", + "Resume all suspended devices matching the specified labels": "Resume all suspended devices matching the specified labels", + "All suspended devices": "All suspended devices", + "Resume all suspended devices": "Resume all suspended devices", + "Select a fleet": "Select a fleet", + "Device selector labels": "Device selector labels", + "This fleet does not select any devices": "This fleet does not select any devices", + "Refreshing device count": "Refreshing device count", + "Checking how many suspended devices match your criteria...": "Checking how many suspended devices match your criteria...", + "Unable to refresh device count": "Unable to refresh device count", + "Devices found": "Devices found", + "<0>{deviceCount} suspended devices are currently associated with fleet <3>{values.fleetId}.": "<0>{deviceCount} suspended devices are currently associated with fleet <3>{values.fleetId}.", + "<0>{deviceCount} suspended devices match the specified labels.": "<0>{deviceCount} suspended devices match the specified labels.", + "You are about to resume all <1>{deviceCount} suspended devices._one": "You are about to resume <1>{deviceCount} suspended device.", + "You are about to resume all <1>{deviceCount} suspended devices._other": "You are about to resume all <1>{deviceCount} suspended devices.", + "This action is irreversible and will allow all affected devices to receive new configuration updates from the server.": "This action is irreversible and will allow all affected devices to receive new configuration updates from the server.", + "No devices found": "No devices found", + "No suspended devices are associated with fleet <1>{values.fleetId}.": "No suspended devices are associated with fleet <1>{values.fleetId}.", + "No suspended devices found.": "No suspended devices found.", + "No suspended devices match the specified labels.": "No suspended devices match the specified labels.", + "Resume devices failed": "Resume devices failed", + "Resume successful": "Resume successful", + "{{ resumedCount }} devices were resumed": "{{ resumedCount }} devices were resumed", + "Resumed with warnings": "Resumed with warnings", + "{{ expectedCount }} devices to resume, and {{ resumedCount }} resumed successfully": "{{ expectedCount }} devices to resume, and {{ resumedCount }} resumed successfully", + "Resume all {{ deviceCount }} devices?": "Resume all {{ deviceCount }} devices?", + "Resume all devices": "Resume all devices", + "Resume devices?_one": "Resume devices?", + "Resume devices?_other": "Resume devices?", + "Resume": "Resume", + "This action will resolve the configuration conflict and allow the devices to receive new updates from the server. This action is irreversible, please ensure the devices' assigned configuration is correct before proceeding._one": "This action will resolve the configuration conflict and allow the device to receive new updates from the server. This action is irreversible, please ensure the device's assigned configuration is correct before proceeding.", + "This action will resolve the configuration conflict and allow the devices to receive new updates from the server. This action is irreversible, please ensure the devices' assigned configuration is correct before proceeding._other": "This action will resolve the configuration conflict and allow the devices to receive new updates from the server. This action is irreversible, please ensure the devices' assigned configuration is correct before proceeding.", + "Resuming devices failed_one": "Resuming device failed", + "Resuming devices failed_other": "Resuming devices failed", + "All {{ resumedCount }} devices resumed successfully": "All {{ resumedCount }} devices resumed successfully", + "Resume with warnings": "Resume with warnings", "Some application workloads are degraded": "Some application workloads are degraded", "Resource was deleted successfully": "Resource was deleted successfully", "Enrollment request": "Enrollment request", @@ -730,6 +782,18 @@ "Overall status of application workloads.": "Overall status of application workloads.", "Overall status of device hardware and operating system.": "Overall status of device hardware and operating system.", "Current system configuration vs. latest system configuration.": "Current system configuration vs. latest system configuration.", + "{{brand}} is waiting for the device to connect and report its status. It will report a ʼPending syncʼ status until it is able to reconnect. If it has configuration conflicts, it will report a ʼSuspendedʼ status and require manual action to resume.": "{{brand}} is waiting for the device to connect and report its status. It will report a ʼPending syncʼ status until it is able to reconnect. If it has configuration conflicts, it will report a ʼSuspendedʼ status and require manual action to resume.", + "{{brand}} is waiting for devices to connect and report their status. Devices will report a ʼPending syncʼ status until they are able to connect. Devices with configuration conflicts will report a ʼSuspendedʼ status and require manual action to resume.": "{{brand}} is waiting for devices to connect and report their status. Devices will report a ʼPending syncʼ status until they are able to connect. Devices with configuration conflicts will report a ʼSuspendedʼ status and require manual action to resume.", + "System recovery complete": "System recovery complete", + "This device is suspended because its local configuration is newer than the server's record. It will not receive updates until it is resumed.": "This device is suspended because its local configuration is newer than the server's record. It will not receive updates until it is resumed.", + "<0>{suspendedCountStr} <2>devices in this fleet are suspended because their local configuration is newer than the server's record. These devices will not receive updates until they are resumed._one": "<0>{suspendedCountStr} <2>device in this fleet is suspended because its local configuration is newer than the server's record. This device will not receive updates until it is resumed.", + "<0>{suspendedCountStr} <2>devices in this fleet are suspended because their local configuration is newer than the server's record. These devices will not receive updates until they are resumed._other": "<0>{suspendedCountStr} <2>devices in this fleet are suspended because their local configuration is newer than the server's record. These devices will not receive updates until they are resumed.", + "<0>{suspendedCountStr} devices are suspended because their local configuration is newer than the server's record. These devices will not receive updates until they are resumed._one": "<0>{suspendedCountStr} device is suspended because its local configuration is newer than the server's record. This device will not receive updates until it is resumed.", + "<0>{suspendedCountStr} devices are suspended because their local configuration is newer than the server's record. These devices will not receive updates until they are resumed._other": "<0>{suspendedCountStr} devices are suspended because their local configuration is newer than the server's record. These devices will not receive updates until they are resumed.", + "Resume suspended devices": "Resume suspended devices", + "Suspended devices detected": "Suspended devices detected", + "<0>Warning: Please review this fleet's configuration before taking action. Resuming a device will cause it to apply the current specification, which may be older than what is on the device.": "<0>Warning: Please review this fleet's configuration before taking action. Resuming a device will cause it to apply the current specification, which may be older than what is on the device.", + "<0>Warning: Please review device configurations before taking action. Resuming a device will cause it to apply the current specification, which may be older than what is on the device.": "<0>Warning: Please review device configurations before taking action. Resuming a device will cause it to apply the current specification, which may be older than what is on the device.", "No results found": "No results found", "Clear all filters and try again.": "Clear all filters and try again.", "Select all rows": "Select all rows", @@ -764,6 +828,7 @@ "< 1 minute ago": "< 1 minute ago", "Device is bound to a fleet and its configurations cannot be edited.": "Device is bound to a fleet and its configurations cannot be edited.", "Device decommissioning already started.": "Device decommissioning already started.", + "Device is not suspended.": "Device is not suspended.", "Error": "Error", "Degraded": "Degraded", "Healthy": "Healthy", @@ -771,11 +836,11 @@ "Starting": "Starting", "Running": "Running", "Completed": "Completed", - "Awaiting reconnect": "Awaiting reconnect", - "Updates paused": "Updates paused", "Rebooting": "Rebooting", "Powered Off": "Powered Off", "Online": "Online", + "Pending sync": "Pending sync", + "Suspended": "Suspended", "Decommissioned": "Decommissioned", "Decommissioning": "Decommissioning", "Enrolled": "Enrolled", diff --git a/libs/types/index.ts b/libs/types/index.ts index aec5d0cd8..d5ec1293e 100644 --- a/libs/types/index.ts +++ b/libs/types/index.ts @@ -50,6 +50,8 @@ export type { DeviceOsStatus } from './models/DeviceOsStatus'; export type { DeviceOwnershipChangedDetails } from './models/DeviceOwnershipChangedDetails'; export type { DeviceResourceStatus } from './models/DeviceResourceStatus'; export { DeviceResourceStatusType } from './models/DeviceResourceStatusType'; +export type { DeviceResumeRequest } from './models/DeviceResumeRequest'; +export type { DeviceResumeResponse } from './models/DeviceResumeResponse'; export type { DeviceSpec } from './models/DeviceSpec'; export type { DevicesSummary } from './models/DevicesSummary'; export type { DeviceStatus } from './models/DeviceStatus'; @@ -109,6 +111,7 @@ export type { ImageVolumeSource } from './models/ImageVolumeSource'; export type { InlineApplicationProviderSpec } from './models/InlineApplicationProviderSpec'; export type { InlineConfigProviderSpec } from './models/InlineConfigProviderSpec'; export type { InternalTaskFailedDetails } from './models/InternalTaskFailedDetails'; +export type { InternalTaskPermanentlyFailedDetails } from './models/InternalTaskPermanentlyFailedDetails'; export type { KubernetesSecretProviderSpec } from './models/KubernetesSecretProviderSpec'; export type { LabelList } from './models/LabelList'; export type { LabelSelector } from './models/LabelSelector'; diff --git a/libs/types/models/DeviceResumeRequest.ts b/libs/types/models/DeviceResumeRequest.ts new file mode 100644 index 000000000..60a767598 --- /dev/null +++ b/libs/types/models/DeviceResumeRequest.ts @@ -0,0 +1,18 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Request to resume devices based on label selector and/or field selector. At least one selector must be provided. + */ +export type DeviceResumeRequest = { + /** + * A selector to restrict the list of devices to resume by their labels. Uses the same format as Kubernetes label selectors (e.g., "key1=value1,key2!=value2"). + */ + labelSelector?: string; + /** + * A selector to restrict the list of devices to resume by their fields. Uses the same format as Kubernetes field selectors (e.g., "metadata.name=device1,status.phase!=Pending"). + */ + fieldSelector?: string; +}; + diff --git a/libs/types/models/DeviceResumeResponse.ts b/libs/types/models/DeviceResumeResponse.ts new file mode 100644 index 000000000..ac03e5137 --- /dev/null +++ b/libs/types/models/DeviceResumeResponse.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Response from resuming devices. + */ +export type DeviceResumeResponse = { + /** + * Number of devices that were successfully resumed. + */ + resumedDevices: number; +}; + diff --git a/libs/types/models/Event.ts b/libs/types/models/Event.ts index b93289251..0f7d6f82a 100644 --- a/libs/types/models/Event.ts +++ b/libs/types/models/Event.ts @@ -67,6 +67,7 @@ export namespace Event { DEVICE_DISCONNECTED = 'DeviceDisconnected', DEVICE_IS_REBOOTING = 'DeviceIsRebooting', DEVICE_CONFLICT_PAUSED = 'DeviceConflictPaused', + DEVICE_CONFLICT_RESOLVED = 'DeviceConflictResolved', DEVICE_CONNECTED = 'DeviceConnected', DEVICE_CONTENT_UP_TO_DATE = 'DeviceContentUpToDate', DEVICE_CONTENT_OUT_OF_DATE = 'DeviceContentOutOfDate', @@ -79,6 +80,7 @@ export namespace Event { DEVICE_SPEC_VALID = 'DeviceSpecValid', DEVICE_SPEC_INVALID = 'DeviceSpecInvalid', INTERNAL_TASK_FAILED = 'InternalTaskFailed', + INTERNAL_TASK_PERMANENTLY_FAILED = 'InternalTaskPermanentlyFailed', REPOSITORY_ACCESSIBLE = 'RepositoryAccessible', REPOSITORY_INACCESSIBLE = 'RepositoryInaccessible', REFERENCED_REPOSITORY_UPDATED = 'ReferencedRepositoryUpdated', diff --git a/libs/types/models/EventDetails.ts b/libs/types/models/EventDetails.ts index 8adf0ad2e..f5f229c10 100644 --- a/libs/types/models/EventDetails.ts +++ b/libs/types/models/EventDetails.ts @@ -12,11 +12,12 @@ import type { FleetRolloutDeviceSelectedDetails } from './FleetRolloutDeviceSele import type { FleetRolloutFailedDetails } from './FleetRolloutFailedDetails'; import type { FleetRolloutStartedDetails } from './FleetRolloutStartedDetails'; import type { InternalTaskFailedDetails } from './InternalTaskFailedDetails'; +import type { InternalTaskPermanentlyFailedDetails } from './InternalTaskPermanentlyFailedDetails'; import type { ReferencedRepositoryUpdatedDetails } from './ReferencedRepositoryUpdatedDetails'; import type { ResourceSyncCompletedDetails } from './ResourceSyncCompletedDetails'; import type { ResourceUpdatedDetails } from './ResourceUpdatedDetails'; /** * Event-specific details, structured based on event type. */ -export type EventDetails = (ResourceUpdatedDetails | DeviceOwnershipChangedDetails | DeviceMultipleOwnersDetectedDetails | DeviceMultipleOwnersResolvedDetails | InternalTaskFailedDetails | ResourceSyncCompletedDetails | ReferencedRepositoryUpdatedDetails | FleetRolloutStartedDetails | FleetRolloutFailedDetails | FleetRolloutCompletedDetails | FleetRolloutBatchDispatchedDetails | FleetRolloutBatchCompletedDetails | FleetRolloutDeviceSelectedDetails); +export type EventDetails = (ResourceUpdatedDetails | DeviceOwnershipChangedDetails | DeviceMultipleOwnersDetectedDetails | DeviceMultipleOwnersResolvedDetails | InternalTaskFailedDetails | InternalTaskPermanentlyFailedDetails | ResourceSyncCompletedDetails | ReferencedRepositoryUpdatedDetails | FleetRolloutStartedDetails | FleetRolloutFailedDetails | FleetRolloutCompletedDetails | FleetRolloutBatchDispatchedDetails | FleetRolloutBatchCompletedDetails | FleetRolloutDeviceSelectedDetails); diff --git a/libs/types/models/InternalTaskPermanentlyFailedDetails.ts b/libs/types/models/InternalTaskPermanentlyFailedDetails.ts new file mode 100644 index 000000000..8ae7b0639 --- /dev/null +++ b/libs/types/models/InternalTaskPermanentlyFailedDetails.ts @@ -0,0 +1,21 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Event } from './Event'; +export type InternalTaskPermanentlyFailedDetails = { + /** + * The type of detail for discriminator purposes. + */ + detailType: 'InternalTaskPermanentlyFailed'; + /** + * The error message describing the permanent failure. + */ + errorMessage: string; + /** + * Number of times the task was retried before being marked as permanently failed. + */ + retryCount: number; + originalEvent: Event; +}; + diff --git a/libs/types/models/PatchRequest.ts b/libs/types/models/PatchRequest.ts index cd0bed391..1866d3233 100644 --- a/libs/types/models/PatchRequest.ts +++ b/libs/types/models/PatchRequest.ts @@ -14,5 +14,5 @@ export type PatchRequest = Array<{ /** * The operation to perform. */ - op: 'add' | 'replace' | 'remove'; + op: 'add' | 'replace' | 'remove' | 'test'; }>; diff --git a/libs/ui-components/src/components/DetailsPage/DetailsPage.tsx b/libs/ui-components/src/components/DetailsPage/DetailsPage.tsx index 0594ba751..dd03765f0 100644 --- a/libs/ui-components/src/components/DetailsPage/DetailsPage.tsx +++ b/libs/ui-components/src/components/DetailsPage/DetailsPage.tsx @@ -31,6 +31,7 @@ export type DetailsPageProps = { resourceLink: Route; actions?: React.ReactNode; nav?: React.ReactNode; + banner?: React.ReactNode; }; const DetailsPage = ({ @@ -45,6 +46,7 @@ const DetailsPage = ({ resourceTypeLabel, actions, nav, + banner, }: DetailsPageProps) => { const { t } = useTranslation(); let content = children; @@ -86,6 +88,7 @@ const DetailsPage = ({ {actions} + {banner} {nav && ( {nav} diff --git a/libs/ui-components/src/components/DetailsPage/DetailsPageActions.tsx b/libs/ui-components/src/components/DetailsPage/DetailsPageActions.tsx index 7e1c3fb16..a21597eaf 100644 --- a/libs/ui-components/src/components/DetailsPage/DetailsPageActions.tsx +++ b/libs/ui-components/src/components/DetailsPage/DetailsPageActions.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { Dropdown, DropdownItem, MenuToggle } from '@patternfly/react-core'; +import { Trans } from 'react-i18next'; import { DeviceDecommissionTargetType } from '@flightctl/types'; @@ -7,6 +8,7 @@ import { useTranslation } from '../../hooks/useTranslation'; import { getDisabledTooltipProps } from '../../utils/tooltip'; import DeleteModal from '../modals/DeleteModal/DeleteModal'; import DecommissionModal from '../modals/DecommissionModal/DecommissionModal'; +import ResumeDevicesModal from '../modals/ResumeDevicesModal/ResumeDevicesModal'; type DeleteActionProps = { onDelete: () => Promise; @@ -21,6 +23,13 @@ type DecommissionActionProps = { disabledReason?: string; }; +type ResumeActionProps = { + deviceId: string; + alias?: string; + disabledReason?: string; + onResumeComplete?: () => void; +}; + export const useDeleteAction = ({ resourceType, resourceName, @@ -73,6 +82,42 @@ export const useDecommissionAction = ({ onDecommission, disabledReason }: Decomm return { decommissionAction, decommissionModal }; }; +export const useResumeAction = ({ disabledReason, deviceId, alias, onResumeComplete }: ResumeActionProps) => { + const { t } = useTranslation(); + const [isResumeModalOpen, setIsResumeModalOpen] = React.useState(false); + const resumeProps = getDisabledTooltipProps(disabledReason); + const deviceNameOrAlias = alias || deviceId; + const resumeAction = ( + setIsResumeModalOpen(true)} {...resumeProps}> + {t('Resume device')} + + ); + + const resumeSelector = { + fieldSelector: `metadata.name=${deviceId}`, + }; + const resumeModal = isResumeModalOpen && ( + + You are about to resume device {deviceNameOrAlias} + + } + selector={resumeSelector} + expectedCount={1} + onClose={(hasResumed) => { + setIsResumeModalOpen(false); + if (hasResumed) { + onResumeComplete?.(); + } + }} + /> + ); + + return { resumeAction, resumeModal }; +}; + const DetailsPageActions = ({ children }: React.PropsWithChildren) => { const { t } = useTranslation(); const [actionsOpen, setActionsOpen] = React.useState(false); diff --git a/libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsPage.tsx b/libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsPage.tsx index 936979697..b61547cc1 100644 --- a/libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsPage.tsx +++ b/libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsPage.tsx @@ -1,25 +1,37 @@ import * as React from 'react'; +import { Trans } from 'react-i18next'; import { Button, DropdownItem, DropdownList, Nav, NavList } from '@patternfly/react-core'; -import { Device, DeviceDecommission, DeviceDecommissionTargetType, ResourceKind } from '@flightctl/types'; +import { + Device, + DeviceDecommission, + DeviceDecommissionTargetType, + DeviceSummaryStatusType, + ResourceKind, +} from '@flightctl/types'; import { useFetchPeriodically } from '../../../hooks/useFetchPeriodically'; import { useFetch } from '../../../hooks/useFetch'; import { getDisabledTooltipProps } from '../../../utils/tooltip'; import DetailsPage from '../../DetailsPage/DetailsPage'; -import DetailsPageActions, { useDecommissionAction, useDeleteAction } from '../../DetailsPage/DetailsPageActions'; +import DetailsPageActions, { + useDecommissionAction, + useDeleteAction, + useResumeAction, +} from '../../DetailsPage/DetailsPageActions'; import { useTranslation } from '../../../hooks/useTranslation'; import { ROUTE, useNavigate } from '../../../hooks/useNavigate'; import { useAppContext } from '../../../hooks/useAppContext'; import DeviceDetailsTab from './DeviceDetailsTab'; import TerminalTab from './TerminalTab'; import NavItem from '../../NavItem/NavItem'; -import { getEditDisabledReason, isDeviceEnrolled } from '../../../utils/devices'; +import { getEditDisabledReason, getResumeDisabledReason, isDeviceEnrolled } from '../../../utils/devices'; import { RESOURCE, VERB } from '../../../types/rbac'; import { useAccessReview } from '../../../hooks/useAccessReview'; import EventsCard from '../../Events/EventsCard'; import PageWithPermissions from '../../common/PageWithPermissions'; import YamlEditor from '../../common/CodeEditor/YamlEditor'; import DeviceAliasEdit from './DeviceAliasEdit'; +import { SystemRestoreBanners } from '../../SystemRestore/SystemRestoreBanners'; type DeviceDetailsPageProps = React.PropsWithChildren<{ hideTerminal?: boolean }>; @@ -35,12 +47,14 @@ const DeviceDetailsPage = ({ children, hideTerminal }: DeviceDetailsPageProps) = const deviceLabels = device?.metadata.labels; const deviceAlias = deviceLabels?.alias; + const deviceNameOrAlias = deviceAlias || deviceId; const isEnrolled = !device || isDeviceEnrolled(device); const [hasTerminalAccess] = useAccessReview(RESOURCE.DEVICE_CONSOLE, VERB.GET); const [canDelete] = useAccessReview(RESOURCE.DEVICE, VERB.DELETE); const [canEdit] = useAccessReview(RESOURCE.DEVICE, VERB.PATCH); const [canDecommission] = useAccessReview(RESOURCE.DEVICE_DECOMMISSION, VERB.UPDATE); + const [canResume] = useAccessReview(RESOURCE.DEVICE_RESUME, VERB.UPDATE); const canOpenTerminal = hasTerminalAccess && isEnrolled; @@ -49,7 +63,7 @@ const DeviceDetailsPage = ({ children, hideTerminal }: DeviceDetailsPageProps) = await remove(`devices/${deviceId}`); navigate(ROUTE.DEVICES); }, - resourceName: deviceAlias || deviceId, + resourceName: deviceNameOrAlias, resourceType: 'device', buttonLabel: isEnrolled ? undefined : t('Delete forever'), }); @@ -64,7 +78,33 @@ const DeviceDetailsPage = ({ children, hideTerminal }: DeviceDetailsPageProps) = }, }); + const { resumeAction, resumeModal } = useResumeAction({ + deviceId, + alias: deviceAlias, + disabledReason: device ? getResumeDisabledReason(device, t) : undefined, + onResumeComplete: refetch, + }); + const editActionProps = device ? getDisabledTooltipProps(getEditDisabledReason(device, t)) : undefined; + const resumeDevice = { + actionText: t('Resume device'), + title: ( + + You are about to resume {deviceNameOrAlias} + + ), + requestSelector: { + fieldSelector: `metadata.name=${deviceId}`, + }, + }; + + const deviceSummaryStatus = device?.status?.summary.status; + const deviceSummary = { + [DeviceSummaryStatusType.DeviceSummaryStatusConflictPaused]: + deviceSummaryStatus === DeviceSummaryStatusType.DeviceSummaryStatusConflictPaused ? 1 : 0, + [DeviceSummaryStatusType.DeviceSummaryStatusAwaitingReconnect]: + deviceSummaryStatus === DeviceSummaryStatusType.DeviceSummaryStatusAwaitingReconnect ? 1 : 0, + }; return ( + ) + } resourceLink={ROUTE.DEVICES} resourceType="Devices" resourceTypeLabel={t('Devices')} @@ -111,6 +162,7 @@ const DeviceDetailsPage = ({ children, hideTerminal }: DeviceDetailsPageProps) = {t('Edit device configurations')} )} + {canResume && resumeAction} {canDecommission && decommissionAction} @@ -143,7 +195,7 @@ const DeviceDetailsPage = ({ children, hideTerminal }: DeviceDetailsPageProps) = )} - {deleteModal || decommissionModal} + {deleteModal || decommissionModal || resumeModal} ); }; diff --git a/libs/ui-components/src/components/Device/DevicesPage/DevicesPage.tsx b/libs/ui-components/src/components/Device/DevicesPage/DevicesPage.tsx index 5ed9395bc..da3de6193 100644 --- a/libs/ui-components/src/components/Device/DevicesPage/DevicesPage.tsx +++ b/libs/ui-components/src/components/Device/DevicesPage/DevicesPage.tsx @@ -102,6 +102,7 @@ const DevicesPage = ({ canListER }: { canListER: boolean }) => { setSelectedLabels={setSelectedLabels} isFilterUpdating={updating} pagination={pagination} + refetchDevices={refetch} /> )} diff --git a/libs/ui-components/src/components/Device/DevicesPage/EnrolledDeviceTableRow.tsx b/libs/ui-components/src/components/Device/DevicesPage/EnrolledDeviceTableRow.tsx index ad84b4f24..8d240add5 100644 --- a/libs/ui-components/src/components/Device/DevicesPage/EnrolledDeviceTableRow.tsx +++ b/libs/ui-components/src/components/Device/DevicesPage/EnrolledDeviceTableRow.tsx @@ -4,7 +4,7 @@ import { ActionsColumn, OnSelect, Td, Tr } from '@patternfly/react-table'; import { Device } from '@flightctl/types'; import DeviceFleet from '../DeviceDetails/DeviceFleet'; import { timeSinceText } from '../../../utils/dates'; -import { getDecommissionDisabledReason, getEditDisabledReason } from '../../../utils/devices'; +import { getDecommissionDisabledReason, getEditDisabledReason, getResumeDisabledReason } from '../../../utils/devices'; import { getDisabledTooltipProps } from '../../../utils/tooltip'; import { ListAction } from '../../ListPage/types'; import ApplicationSummaryStatus from '../../Status/ApplicationSummaryStatus'; @@ -22,6 +22,8 @@ type EnrolledDeviceTableRowProps = { canEdit: boolean; canDecommission: boolean; decommissionAction: ListAction; + canResume: boolean; + resumeAction: ListAction; }; const EnrolledDeviceTableRow = ({ @@ -32,6 +34,8 @@ const EnrolledDeviceTableRow = ({ canEdit, canDecommission, decommissionAction, + canResume, + resumeAction, }: EnrolledDeviceTableRowProps) => { const { t } = useTranslation(); const navigate = useNavigate(); @@ -39,6 +43,7 @@ const EnrolledDeviceTableRow = ({ const deviceAlias = device.metadata.labels?.alias; const editActionProps = getDisabledTooltipProps(getEditDisabledReason(device, t)); const decommissionDisabledReason = getDecommissionDisabledReason(device, t); + const resumeDisabledReason = getResumeDisabledReason(device, t); return ( @@ -84,6 +89,15 @@ const EnrolledDeviceTableRow = ({ title: t('View device details'), onClick: () => navigate({ route: ROUTE.DEVICE_DETAILS, postfix: deviceName }), }, + ...(canResume + ? [ + resumeAction({ + resourceId: deviceName, + resourceName: deviceAlias, + disabledReason: resumeDisabledReason, + }), + ] + : []), ...(canDecommission ? [ decommissionAction({ diff --git a/libs/ui-components/src/components/Device/DevicesPage/EnrolledDevicesTable.tsx b/libs/ui-components/src/components/Device/DevicesPage/EnrolledDevicesTable.tsx index 89dd47810..88687849b 100644 --- a/libs/ui-components/src/components/Device/DevicesPage/EnrolledDevicesTable.tsx +++ b/libs/ui-components/src/components/Device/DevicesPage/EnrolledDevicesTable.tsx @@ -20,7 +20,7 @@ import { } from '../../Status/utils'; import Table, { ApiSortTableColumn } from '../../Table/Table'; -import { useDecommissionListAction } from '../../ListPage/ListPageActions'; +import { useDecommissionListAction, useResumeListAction } from '../../ListPage/ListPageActions'; import TablePagination from '../../Table/TablePagination'; import MassDecommissionDeviceModal from '../../modals/massModals/MassDecommissionDeviceModal/MassDecommissionDeviceModal'; import AddDeviceModal from '../AddDeviceModal/AddDeviceModal'; @@ -28,6 +28,7 @@ import { EnrolledDevicesEmptyState } from './DevicesEmptyStates'; import DeviceTableToolbar from './DeviceTableToolbar'; import EnrolledDeviceTableRow from './EnrolledDeviceTableRow'; import { FilterSearchParams } from '../../../utils/status/devices'; +import { GlobalSystemRestoreBanners } from '../../SystemRestore/SystemRestoreBanners'; interface EnrolledDeviceTableProps { devices: Array; @@ -43,6 +44,7 @@ interface EnrolledDeviceTableProps { setSelectedLabels: (labels: FlightCtlLabel[]) => void; isFilterUpdating: boolean; pagination: Pick, 'currentPage' | 'setCurrentPage' | 'itemCount'>; + refetchDevices: VoidFunction; // getSortParams: (columnIndex: number) => ThProps['sort']; } @@ -87,6 +89,7 @@ const EnrolledDevicesTable = ({ hasFiltersEnabled, isFilterUpdating, pagination, + refetchDevices, }: EnrolledDeviceTableProps) => { const { t } = useTranslation(); const { put } = useFetch(); @@ -97,6 +100,7 @@ const EnrolledDevicesTable = ({ const { onRowSelect, hasSelectedRows, isAllSelected, isRowSelected, setAllSelected } = useTableSelect(); + const { action: resumeDeviceAction, modal: resumeDeviceModal } = useResumeListAction(refetchDevices); const { action: decommissionDeviceAction, modal: decommissionDeviceModal } = useDecommissionListAction({ resourceType: 'Device', onConfirm: async (deviceId: string, params) => { @@ -109,6 +113,7 @@ const EnrolledDevicesTable = ({ const [canEdit] = useAccessReview(RESOURCE.DEVICE, VERB.PATCH); const [canDecommission] = useAccessReview(RESOURCE.DEVICE_DECOMMISSION, VERB.UPDATE); + const [canResume] = useAccessReview(RESOURCE.DEVICE_RESUME, VERB.UPDATE); const clearAllFilters = () => { if (hasFiltersEnabled) { @@ -125,6 +130,8 @@ const EnrolledDevicesTable = ({ return ( <> + + ))} @@ -194,7 +203,7 @@ const EnrolledDevicesTable = ({ {!hasFiltersEnabled && devices.length === 0 && ( setAddDeviceModal(true)} /> )} - {decommissionDeviceModal} + {decommissionDeviceModal || resumeDeviceModal} {addDeviceModal && setAddDeviceModal(false)} />} {isMassDecommissionModalOpen && ( { const { t } = useTranslation(); @@ -45,6 +46,7 @@ const FleetDetailPage = () => { resourceLink={ROUTE.FLEETS} resourceType="Fleets" resourceTypeLabel={t('Fleets')} + banner={} nav={