diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index d3c1801fe..8666dcdc3 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -602,11 +602,19 @@ "Stop": "Stop", "Restart": "Restart", "Open console": "Open console", + "The requested action failed": "The requested action failed", "Expand row": "Expand row", "Ready": "Ready", "Restarts": "Restarts", "Workload applications": "Workload applications", "Virtual machines": "Virtual machines", + "Stop application": "Stop application", + "Restart loop detected": "Restart loop detected", + "This application has restarted {{ count }} times this session. Stop the application to prevent further restarts and investigate the issue._one": "This application has restarted {{ count }} times this session. Stop the application to prevent further restarts and investigate the issue.", + "This application has restarted {{ count }} times this session. Stop the application to prevent further restarts and investigate the issue._other": "This application has restarted {{ count }} times this session. Stop the application to prevent further restarts and investigate the issue.", + "This application is restarting repeatedly": "This application is restarting repeatedly", + "The application has restarted {{ count }} times this session and may be misconfigured. Stop the application to prevent further restarts and investigate the issue._one": "The application has restarted {{ count }} times this session and may be misconfigured. Stop the application to prevent further restarts and investigate the issue.", + "The application has restarted {{ count }} times this session and may be misconfigured. Stop the application to prevent further restarts and investigate the issue._other": "The application has restarted {{ count }} times this session and may be misconfigured. Stop the application to prevent further restarts and investigate the issue.", "No applications found": "No applications found", "Applications": "Applications", "Message": "Message", @@ -856,6 +864,8 @@ "Configuration mode": "Configuration mode", "Form": "Form", "Use Form for standard Linux VMs. Use YAML for advanced configurations such as Windows VMs or custom KubeVirt setups that require additional spec fields.": "Use Form for standard Linux VMs. Use YAML for advanced configurations such as Windows VMs or custom KubeVirt setups that require additional spec fields.", + "VM restart notice": "VM restart notice", + "Updating the VM configuration will restart existing virtual machine applications. Cloud-init user data and credentials are reapplied when the VM boots.": "Updating the VM configuration will restart existing virtual machine applications. Cloud-init user data and credentials are reapplied when the VM boots.", "Advanced YAML configuration detected": "Advanced YAML configuration detected", "This VM uses settings that are not supported in form view. YAML mode is selected to preserve the full configuration. Editing in form view may overwrite these custom settings.": "This VM uses settings that are not supported in form view. YAML mode is selected to preserve the full configuration. Editing in form view may overwrite these custom settings.", "Disk image (OCI)": "Disk image (OCI)", @@ -866,9 +876,9 @@ "Memory allocated to the VM using KubeVirt units. Examples: \"2Gi\", \"512Mi\", \"4294967296\".": "Memory allocated to the VM using KubeVirt units. Examples: \"2Gi\", \"512Mi\", \"4294967296\".", "Provide a valid KubeVirt memory value (e.g., \"2Gi\", \"512Mi\", \"4294967296\").": "Provide a valid KubeVirt memory value (e.g., \"2Gi\", \"512Mi\", \"4294967296\").", "Cloud-init user data": "Cloud-init user data", - "Cloud-init user data in YAML format. Applied on first boot only. SSH key and password entries in this field are managed by the Credentials section below and may be overwritten when toggling those options.": "Cloud-init user data in YAML format. Applied on first boot only. SSH key and password entries in this field are managed by the Credentials section below and may be overwritten when toggling those options.", + "Cloud-init user data in YAML format. SSH key and password entries in this field are managed by the Credentials section below and may be overwritten when toggling those options.": "Cloud-init user data in YAML format. SSH key and password entries in this field are managed by the Credentials section below and may be overwritten when toggling those options.", "Credentials": "Credentials", - "Credentials are applied through cloud-init on first boot only. Changes here will take effect on newly created VMs but will not update VMs already running on existing devices. To update credentials on a running VM, access it directly through the console.": "Credentials are applied through cloud-init on first boot only. Changes here will take effect on newly created VMs but will not update VMs already running on existing devices. To update credentials on a running VM, access it directly through the console.", + "Credentials are written into the cloud-init user data above.": "Credentials are written into the cloud-init user data above.", "SSH public key": "SSH public key", "Add a public SSH key to enable direct SSH access to the application's VM. Requires publishing the SSH port to the host device.": "Add a public SSH key to enable direct SSH access to the application's VM. Requires publishing the SSH port to the host device.", "Password": "Password", diff --git a/libs/ui-components/src/components/DetailsPage/Tables/ApplicationLifecycleActions.tsx b/libs/ui-components/src/components/DetailsPage/Tables/ApplicationLifecycleActions.tsx index 9c31b7e95..00abd875a 100644 --- a/libs/ui-components/src/components/DetailsPage/Tables/ApplicationLifecycleActions.tsx +++ b/libs/ui-components/src/components/DetailsPage/Tables/ApplicationLifecycleActions.tsx @@ -14,13 +14,13 @@ import { StackItem, } from '@patternfly/react-core'; import { TerminalIcon } from '@patternfly/react-icons/dist/js/icons/terminal-icon'; -import { useApplicationLifecycle } from '../../../hooks/useApplicationLifecycle'; import { useTranslation } from '../../../hooks/useTranslation'; import { getDisabledTooltipProps } from '../../../utils/tooltip'; import { type ApplicationLifecycleAction, hasAplicationStatusMismatch, startableStatuses, + stoppableStatuses, transitionalStatuses, } from '../../../utils/applicationLifecycle'; import { @@ -72,23 +72,31 @@ const ApplicationStatusMismatchAlert = ({ ); }; +export type ApplicationLifecycleControls = { + start: () => Promise; + stop: () => Promise; + restart: () => Promise; + isSubmitting: boolean; + pendingAction: ApplicationLifecycleAction | null; + error: string | undefined; + clearError: VoidFunction; +}; + type ApplicationLifecycleActionsProps = { - deviceName: string; - refetch: VoidFunction; lifecycleDisabledReason?: string; desiredState?: ApplicationDesiredState; appStatus: DeviceApplicationStatus; canManageLifecycle: boolean; + lifecycle: ApplicationLifecycleControls; onOpenConsole?: (name: string) => void; }; const ApplicationLifecycleActions = ({ - deviceName, - refetch, lifecycleDisabledReason, desiredState, appStatus: appStatusObj, canManageLifecycle, + lifecycle, onOpenConsole, }: ApplicationLifecycleActionsProps) => { const { t } = useTranslation(); @@ -96,17 +104,12 @@ const ApplicationLifecycleActions = ({ const appStatus = appStatusObj.status; const isVm = appStatusObj.appType === AppType.AppTypeVm; - const { start, stop, restart, isSubmitting, pendingAction, error, clearError } = useApplicationLifecycle({ - deviceName, - appName: appStatusObj.name, - appStatus, - appRestarts: appStatusObj.restarts, - refetch, - }); + const { start, stop, restart, isSubmitting, pendingAction, error, clearError } = lifecycle; const isAppRunning = appStatus === ApplicationStatusType.ApplicationStatusRunning; const desiredStateIsStopped = desiredState === ApplicationDesiredState.ApplicationDesiredStateStopped; const canStart = startableStatuses.includes(appStatus); + const canStop = stoppableStatuses.includes(appStatus); const isTransitioning = transitionalStatuses.includes(appStatus); const hasStatusMismatch = hasAplicationStatusMismatch(appStatus, desiredState); const isUserInitiatedTransition = pendingAction != null; @@ -162,7 +165,7 @@ const ApplicationLifecycleActions = ({ {t('Start')} )} - {isAppRunning && ( + {canStop && ( handleAction('stop')}> {t('Stop')} @@ -216,7 +219,7 @@ const ApplicationLifecycleActions = ({ } > diff --git a/libs/ui-components/src/components/DetailsPage/Tables/ApplicationsTable.tsx b/libs/ui-components/src/components/DetailsPage/Tables/ApplicationsTable.tsx index 24f0140f5..ceb0a054a 100644 --- a/libs/ui-components/src/components/DetailsPage/Tables/ApplicationsTable.tsx +++ b/libs/ui-components/src/components/DetailsPage/Tables/ApplicationsTable.tsx @@ -1,15 +1,22 @@ import * as React from 'react'; -import { Bullseye, Label, Stack, StackItem, Title } from '@patternfly/react-core'; +import { Alert, Bullseye, Button, Label, Popover, Stack, StackItem, Title } from '@patternfly/react-core'; import { ExpandableRowContent, Table, Tbody, Td, Th, Tr } from '@patternfly/react-table'; import { - type ApplicationDesiredState, + ApplicationDesiredState, type ApplicationProviderSpec, + ApplicationStatusType, type DeviceApplicationStatus, } from '@flightctl/types'; +import { useApplicationLifecycle } from '../../../hooks/useApplicationLifecycle'; +import { useRestartSpikes } from '../../../hooks/useRestartSpikes'; import { useTranslation } from '../../../hooks/useTranslation'; import { getAppTypeLabel } from '../../../utils/apps'; -import { type DeviceAppLifecycleOverrides } from '../../../utils/applicationLifecycle'; +import { + type DeviceAppLifecycleOverrides, + hasAplicationStatusMismatch, + transitionalStatuses, +} from '../../../utils/applicationLifecycle'; import { type StatusAppWithSpec, getAppsByType } from '../../../utils/vmApplications'; import { isVmAppSpec } from '../../../types/deviceSpec'; import { RESOURCE, VERB } from '../../../types/rbac'; @@ -82,6 +89,94 @@ const AppExpandedDetails = ({ return ; }; +type RestartLoopStopButtonProps = { + onStop: () => void; + isDisabled: boolean; + isLoading: boolean; +}; + +const RestartLoopStopButton = ({ onStop, isDisabled, isLoading }: RestartLoopStopButtonProps) => { + const { t } = useTranslation(); + return ( + + ); +}; + +type RestartLoopWarningProps = { + restartDelta: number; + canStop: boolean; + stopDisabled: boolean; + stopLoading: boolean; + onStop: () => void; +}; + +const AppRestartsWarning = ({ + restarts, + restartDelta, + canStop, + stopDisabled, + stopLoading, + onStop, +}: RestartLoopWarningProps & { restarts: number }) => { + const { t } = useTranslation(); + return ( + + + {t( + 'This application has restarted {{ count }} times this session. Stop the application to prevent further restarts and investigate the issue.', + { + count: restartDelta, + }, + )} + + + {canStop && ( + + + + )} + + } + withFocusTrap={false} + > + + + ); +}; + +const RestartLoopAlert = ({ restartDelta, canStop, stopDisabled, stopLoading, onStop }: RestartLoopWarningProps) => { + const { t } = useTranslation(); + return ( + + + + {t( + 'The application has restarted {{ count }} times this session and may be misconfigured. Stop the application to prevent further restarts and investigate the issue.', + { + count: restartDelta, + }, + )} + + {canStop && ( + + + + )} + + + ); +}; + type ApplicationTableRowProps = BaseApplicationsTableProps & { desiredState?: ApplicationDesiredState; application: StatusAppWithSpec; @@ -89,6 +184,7 @@ type ApplicationTableRowProps = BaseApplicationsTableProps & { isExpanded: boolean; onToggle: VoidFunction; canManageLifecycle: boolean; + restartDelta?: number; }; const ApplicationTableRow = ({ @@ -102,9 +198,40 @@ const ApplicationTableRow = ({ onToggle, canManageLifecycle, onOpenConsole, + restartDelta, }: ApplicationTableRowProps) => { const { t } = useTranslation(); const { status: appStatusObj } = application; + const appStatus = appStatusObj.status; + + const lifecycle = useApplicationLifecycle({ + deviceName, + appName: appStatusObj.name, + appStatus, + appRestarts: appStatusObj.restarts, + refetch, + }); + + const isTransitioning = transitionalStatuses.includes(appStatus); + const hasStatusMismatch = hasAplicationStatusMismatch(appStatus, desiredState); + const isUserInitiatedTransition = lifecycle.pendingAction != null; + const isExternallyTransitioning = isTransitioning && !isUserInitiatedTransition && !hasStatusMismatch; + const isStopDisabled = + !!lifecycleDisabledReason || + lifecycle.isSubmitting || + isUserInitiatedTransition || + hasStatusMismatch || + isExternallyTransitioning; + const isStopRequested = + lifecycle.pendingAction === 'stop' || desiredState === ApplicationDesiredState.ApplicationDesiredStateStopped; + const isStoppedOrStopping = + appStatus === ApplicationStatusType.ApplicationStatusStopping || + appStatus === ApplicationStatusType.ApplicationStatusStopped; + const showRestartLoopWarning = !!restartDelta && !isStopRequested && !isStoppedOrStopping; + const canStop = canManageLifecycle && !isStopRequested; + const onStop = () => { + void lifecycle.stop(); + }; return ( @@ -126,7 +253,20 @@ const ApplicationTableRow = ({ {appStatusObj.ready} - {appStatusObj.restarts} + + {showRestartLoopWarning ? ( + + ) : ( + appStatusObj.restarts + )} + @@ -134,15 +274,25 @@ const ApplicationTableRow = ({ + {showRestartLoopWarning && isExpanded && ( + + + + )} @@ -175,6 +325,8 @@ const ApplicationsTable = ({ const [expandedRow, setExpandedRow] = React.useState(null); const { workloadApps, vmApps } = React.useMemo(() => getAppsByType(appsStatus, appsSpecs), [appsStatus, appsSpecs]); + const restartDeltas = useRestartSpikes(appsStatus); + if (workloadApps.length === 0 && vmApps.length === 0) { return {t('No applications found')}; } @@ -201,6 +353,7 @@ const ApplicationsTable = ({ onToggle={() => setExpandedRow(expandedRow === rowKey ? null : rowKey)} canManageLifecycle={canManageLifecycle} onOpenConsole={canOpenConsole ? onOpenConsole : undefined} + restartDelta={restartDeltas[name]} /> ); })} diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts b/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts index e865213f3..bbc125a21 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts @@ -651,6 +651,7 @@ const createDefaultVmAppForm = (name: string = ''): VmAppForm => ({ specType: AppSpecType.INLINE, name, configMode: 'form', + isExisting: false, hasAdvancedVmSettings: false, vmYaml: '', diskImage: '', @@ -680,6 +681,7 @@ const toVmAppForm = (vmApp: VmApplication | undefined): VmAppForm => { ...defaults, name: vmApp.name || '', configMode, + isExisting: true, hasAdvancedVmSettings: hasAdvanced, vmYaml, diskImage: parsed?.diskImage || defaults.diskImage, diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationVmForm.tsx b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationVmForm.tsx index e9cce7850..b97c60853 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationVmForm.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationVmForm.tsx @@ -193,6 +193,14 @@ const ApplicationVmForm = ({ index, isReadOnly }: { index: number; isReadOnly?: + {app.isExisting && ( + + {t( + 'Updating the VM configuration will restart existing virtual machine applications. Cloud-init user data and credentials are reapplied when the VM boots.', + )} + + )} + {app.configMode === 'yaml' && app.hasAdvancedVmSettings && ( {t( @@ -257,16 +265,14 @@ const ApplicationVmForm = ({ index, isReadOnly }: { index: number; isReadOnly?: minHeight="auto" isDisabled={isReadOnly} helperText={t( - 'Cloud-init user data in YAML format. Applied on first boot only. SSH key and password entries in this field are managed by the Credentials section below and may be overwritten when toggling those options.', + 'Cloud-init user data in YAML format. SSH key and password entries in this field are managed by the Credentials section below and may be overwritten when toggling those options.', )} onChangeCustom={handleCloudInitCustom} /> - {t( - 'Credentials are applied through cloud-init on first boot only. Changes here will take effect on newly created VMs but will not update VMs already running on existing devices. To update credentials on a running VM, access it directly through the console.', - )} + {t('Credentials are written into the cloud-init user data above.')} { - const encoder = new TextEncoder(); - const encodedData = encoder.encode(msg); - const result = new Uint8Array(encodedData.length + 1); - result[0] = resize ? 0x4 : 0x00; - result.set(encodedData, 1); - return result; -}; diff --git a/libs/ui-components/src/hooks/useAppConsoleWebSocket.ts b/libs/ui-components/src/hooks/useAppConsoleWebSocket.ts index 5c38ed40d..8a9c5de0a 100644 --- a/libs/ui-components/src/hooks/useAppConsoleWebSocket.ts +++ b/libs/ui-components/src/hooks/useAppConsoleWebSocket.ts @@ -1,6 +1,5 @@ import * as React from 'react'; import { useAppContext } from './useAppContext'; -import { msgToBytes } from './terminalWsUtils'; const APP_CONSOLE_CLOSE_CODE = 1000; const APP_CONSOLE_CLOSE_REASON = 'client disconnect'; @@ -100,16 +99,14 @@ export const useAppConsoleWebSocket = ( const forceConnectRef = React.useRef(false); const sendMessage = React.useCallback((data: string, resize?: boolean) => { - const ws = wsRef.current; - if (ws?.readyState !== WebSocket.OPEN) { - return; - } - // Serial stdin is raw bytes; resize uses device-console channel framing (0x4 + size JSON). + // App console uses raw serial websocket (raw bytes, not k8s channels). Resize is not supported if (resize) { - ws.send(msgToBytes(data, true)); return; } - ws.send(new TextEncoder().encode(data)); + const ws = wsRef.current; + if (ws?.readyState === WebSocket.OPEN) { + ws.send(new TextEncoder().encode(data)); + } }, []); const disconnect = React.useCallback(() => { diff --git a/libs/ui-components/src/hooks/useApplicationLifecycle.ts b/libs/ui-components/src/hooks/useApplicationLifecycle.ts index 8fed345a1..e76f6e1dd 100644 --- a/libs/ui-components/src/hooks/useApplicationLifecycle.ts +++ b/libs/ui-components/src/hooks/useApplicationLifecycle.ts @@ -2,7 +2,11 @@ import * as React from 'react'; import type { ApplicationStatusType, Device } from '@flightctl/types'; -import { type ApplicationLifecycleAction, shouldClearPendingLifecycleAction } from '../utils/applicationLifecycle'; +import { + type ApplicationLifecycleAction, + RESTART_PENDING_TIMEOUT_MS, + shouldClearPendingLifecycleAction, +} from '../utils/applicationLifecycle'; import { getErrorMessage } from '../utils/error'; import { useFetch } from './useFetch'; @@ -57,6 +61,21 @@ export const useApplicationLifecycle = ({ } }, [appRestarts, appStatus, pendingAction]); + React.useEffect(() => { + if (pendingAction !== 'restart') { + return; + } + + // Restarting a running application does not produce an observable change in status. + // If the action was successfully submitted, it is considered finished after a short timeout. + const timer = window.setTimeout(() => { + setPendingAction(null); + restartsAtRequestRef.current = undefined; + }, RESTART_PENDING_TIMEOUT_MS); + + return () => window.clearTimeout(timer); + }, [pendingAction]); + const executeAction = React.useCallback( async (action: ApplicationLifecycleAction) => { setIsSubmitting(true); diff --git a/libs/ui-components/src/hooks/useRestartSpikes.ts b/libs/ui-components/src/hooks/useRestartSpikes.ts new file mode 100644 index 000000000..5756dbc8b --- /dev/null +++ b/libs/ui-components/src/hooks/useRestartSpikes.ts @@ -0,0 +1,74 @@ +import * as React from 'react'; + +/** Minimum total restart increase since the page was opened that triggers a warning. */ +export const RESTART_SPIKE_THRESHOLD = 20; + +const EMPTY_SPIKES: Record = {}; + +type AppRestartInput = { + name: string; + restarts: number; +}; + +/** + * Tracks per-app restart counts for the current mount session. + * Returns a stable map of app name → session increase for apps that met `threshold`. + * + * Cheap by design: fingerprint ignores non-restart status fields, skips setState when + * the spike set is unchanged, and uses no timers or intervals. + */ +export const useRestartSpikes = (apps: AppRestartInput[]): Record => { + const baselineRef = React.useRef>({}); + const appsRef = React.useRef(apps); + appsRef.current = apps; + + const [spikes, setSpikes] = React.useState>(EMPTY_SPIKES); + + // Fingerprint only name+restarts so unrelated status field churn does not re-run detection. + const restartSnapshot = apps + .map((app) => `${app.name}:${app.restarts}`) + .sort() + .join('|'); + + React.useEffect(() => { + const currentApps = appsRef.current; + const nextSpikes: Record = {}; + const seen = new Set(); + + for (const app of currentApps) { + seen.add(app.name); + const baseline = baselineRef.current[app.name]; + + // First sight, or restart counter reset (e.g. app recreated) → new baseline. + if (baseline === undefined || app.restarts < baseline) { + baselineRef.current[app.name] = app.restarts; + continue; + } + + const sessionIncrease = app.restarts - baseline; + if (sessionIncrease >= RESTART_SPIKE_THRESHOLD) { + nextSpikes[app.name] = sessionIncrease; + } + } + + for (const name of Object.keys(baselineRef.current)) { + if (!seen.has(name)) { + delete baselineRef.current[name]; + } + } + + setSpikes((current) => { + const nextKeys = Object.keys(nextSpikes); + if (nextKeys.length === 0) { + return Object.keys(current).length === 0 ? current : EMPTY_SPIKES; + } + const currentKeys = Object.keys(current); + if (currentKeys.length === nextKeys.length && nextKeys.every((name) => current[name] === nextSpikes[name])) { + return current; + } + return nextSpikes; + }); + }, [restartSnapshot]); + + return spikes; +}; diff --git a/libs/ui-components/src/hooks/useWebSocket.ts b/libs/ui-components/src/hooks/useWebSocket.ts index f384cf35e..34faafb6e 100644 --- a/libs/ui-components/src/hooks/useWebSocket.ts +++ b/libs/ui-components/src/hooks/useWebSocket.ts @@ -1,10 +1,21 @@ import * as React from 'react'; import { useTranslation } from './useTranslation'; import { useAppContext } from './useAppContext'; -import { msgToBytes } from './terminalWsUtils'; const isErrorCloseEvent = (evt: CloseEvent) => evt.code !== 1000 && evt.code !== 1001; +const k8sStreamChannel = 0x00; +const k8sResizeChannel = 0x04; + +/** Device console uses k8s stream channels */ +const encodeK8sChannelFrame = (msg: string, resize?: boolean): Uint8Array => { + const encodedData = new TextEncoder().encode(msg); + const frame = new Uint8Array(new ArrayBuffer(encodedData.length + 1)); + frame[0] = resize ? k8sResizeChannel : k8sStreamChannel; + frame.set(encodedData, 1); + return frame; +}; + type WsMetadata = { tty: boolean; term: string; @@ -42,7 +53,7 @@ export const useWebSocket = ( const sendMessage = React.useCallback((data: string, resize?: boolean) => { const ws = wsRef.current; if (ws?.readyState === WebSocket.OPEN) { - ws.send(msgToBytes(data, resize)); + ws.send(encodeK8sChannelFrame(data, resize)); } }, []); diff --git a/libs/ui-components/src/types/deviceSpec.ts b/libs/ui-components/src/types/deviceSpec.ts index 1dd1e512d..cca40adba 100644 --- a/libs/ui-components/src/types/deviceSpec.ts +++ b/libs/ui-components/src/types/deviceSpec.ts @@ -149,6 +149,8 @@ export type VmAppForm = { configMode: VmConfigMode; // Full vm.yaml file that can be edited in YAML mode; independent from form field vmYaml: string; + // Whether this app was loaded from an existing device/fleet spec (not newly added in the form). + isExisting: boolean; // Whether vm.yaml has settings which are not supported by the guided form. // Only meaningful for existing applications. hasAdvancedVmSettings: boolean; diff --git a/libs/ui-components/src/utils/applicationLifecycle.ts b/libs/ui-components/src/utils/applicationLifecycle.ts index ff42f63fe..86f20bc63 100644 --- a/libs/ui-components/src/utils/applicationLifecycle.ts +++ b/libs/ui-components/src/utils/applicationLifecycle.ts @@ -29,6 +29,13 @@ export const startableStatuses = [ ApplicationStatusType.ApplicationStatusStopping, ApplicationStatusType.ApplicationStatusStopped, ApplicationStatusType.ApplicationStatusError, + ApplicationStatusType.ApplicationStatusUnknown, +]; + +export const stoppableStatuses = [ + ApplicationStatusType.ApplicationStatusRunning, + ApplicationStatusType.ApplicationStatusError, + ApplicationStatusType.ApplicationStatusUnknown, ]; export type ApplicationLifecycleAction = 'start' | 'stop' | 'restart'; @@ -151,6 +158,12 @@ export const getDeviceAppLifecycleOverrides = ( return mergeApplicationLifecycleLayers(fleetOverrides, deviceOverrides); }; +/** + * The "status.apps[].restarts" field is only modified on "real" app restarts/crashes, not by successful restarts. + * Successfully restarting an application is not observable through status, and the action is cleared after a timeout. + */ +export const RESTART_PENDING_TIMEOUT_MS = 500; + export const shouldClearPendingLifecycleAction = ( pendingAction: ApplicationLifecycleAction, currentStatus: ApplicationStatusType, @@ -168,11 +181,13 @@ export const shouldClearPendingLifecycleAction = ( case 'start': return ( - currentStatus === ApplicationStatusType.ApplicationStatusStarting || + currentStatus === ApplicationStatusType.ApplicationStatusCompleted || currentStatus === ApplicationStatusType.ApplicationStatusRunning || currentStatus === ApplicationStatusType.ApplicationStatusError ); case 'restart': + // The following conditions to clearing pending "restart" actions may not always eventually be satisfied. + // See RESTART_PENDING_TIMEOUT_MS for more details. return ( currentStatus === ApplicationStatusType.ApplicationStatusStarting || currentStatus !== statusAtRequest || diff --git a/package-lock.json b/package-lock.json index ac557d8c4..0721ce204 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9636,9 +9636,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github",