From 035c493c13e9fb759ab353c98776428adf07d914 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:00:53 +0200 Subject: [PATCH 1/6] Bump fast-uri from 3.1.4 to 3.1.5 (#759) Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.4 to 3.1.5. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5) Made-with: Cursor --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> (cherry picked from commit e17bc3010cfec404c673de2ef7c52b9e27b63d4c) --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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", From fd0cc2c9b0ffdfaf6ae9e05264165a73d64b0966 Mon Sep 17 00:00:00 2001 From: Celia Amador Gonzalez Date: Mon, 10 Aug 2026 15:08:32 +0200 Subject: [PATCH 2/6] EDM-4999: Redefine which statuses allow start/stop actions (#763) Made-with: Cursor (cherry picked from commit a5959d89dd5927d389c4e84f6796f38736f8b3b2) --- .../DetailsPage/Tables/ApplicationLifecycleActions.tsx | 4 +++- libs/ui-components/src/utils/applicationLifecycle.ts | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/libs/ui-components/src/components/DetailsPage/Tables/ApplicationLifecycleActions.tsx b/libs/ui-components/src/components/DetailsPage/Tables/ApplicationLifecycleActions.tsx index 9c31b7e95..c7fa8fbd7 100644 --- a/libs/ui-components/src/components/DetailsPage/Tables/ApplicationLifecycleActions.tsx +++ b/libs/ui-components/src/components/DetailsPage/Tables/ApplicationLifecycleActions.tsx @@ -21,6 +21,7 @@ import { type ApplicationLifecycleAction, hasAplicationStatusMismatch, startableStatuses, + stoppableStatuses, transitionalStatuses, } from '../../../utils/applicationLifecycle'; import { @@ -107,6 +108,7 @@ const ApplicationLifecycleActions = ({ 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 +164,7 @@ const ApplicationLifecycleActions = ({ {t('Start')} )} - {isAppRunning && ( + {canStop && ( handleAction('stop')}> {t('Stop')} diff --git a/libs/ui-components/src/utils/applicationLifecycle.ts b/libs/ui-components/src/utils/applicationLifecycle.ts index ff42f63fe..1ccadc337 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'; From fb6f3c134c8ae2eedb93927f82671028b20553f2 Mon Sep 17 00:00:00 2001 From: Celia Amador Gonzalez Date: Mon, 10 Aug 2026 15:51:15 +0200 Subject: [PATCH 3/6] EDM-4987: Do not send wrong frame that causes the session logout (#760) Made-with: Cursor (cherry picked from commit ae991296ab6e45662f0e5bde5a113e939fefdb8d) --- libs/ui-components/src/hooks/terminalWsUtils.ts | 9 --------- .../src/hooks/useAppConsoleWebSocket.ts | 13 +++++-------- libs/ui-components/src/hooks/useWebSocket.ts | 15 +++++++++++++-- 3 files changed, 18 insertions(+), 19 deletions(-) delete mode 100644 libs/ui-components/src/hooks/terminalWsUtils.ts diff --git a/libs/ui-components/src/hooks/terminalWsUtils.ts b/libs/ui-components/src/hooks/terminalWsUtils.ts deleted file mode 100644 index 0e0b627aa..000000000 --- a/libs/ui-components/src/hooks/terminalWsUtils.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** Prefix WebSocket payload with a k8s-style channel byte (0x00 stdin, 0x04 resize). */ -export const msgToBytes = (msg: string, resize?: boolean) => { - 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/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)); } }, []); From e1c31b9747c6cb307c904c4d3edd283bb5bc21cc Mon Sep 17 00:00:00 2001 From: Celia Amador Gonzalez Date: Mon, 10 Aug 2026 16:02:45 +0200 Subject: [PATCH 4/6] EDM-5034: Show restart app loop warnings (#764) Made-with: Cursor (cherry picked from commit 51bac50940cf1ba021af50c4f90f6fbc0b02a29c) --- libs/i18n/locales/en/translation.json | 7 + .../Tables/ApplicationLifecycleActions.tsx | 25 +-- .../DetailsPage/Tables/ApplicationsTable.tsx | 165 +++++++++++++++++- .../src/hooks/useRestartSpikes.ts | 74 ++++++++ 4 files changed, 253 insertions(+), 18 deletions(-) create mode 100644 libs/ui-components/src/hooks/useRestartSpikes.ts diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index d3c1801fe..201c90a39 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -607,6 +607,13 @@ "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", diff --git a/libs/ui-components/src/components/DetailsPage/Tables/ApplicationLifecycleActions.tsx b/libs/ui-components/src/components/DetailsPage/Tables/ApplicationLifecycleActions.tsx index c7fa8fbd7..d0fb8340d 100644 --- a/libs/ui-components/src/components/DetailsPage/Tables/ApplicationLifecycleActions.tsx +++ b/libs/ui-components/src/components/DetailsPage/Tables/ApplicationLifecycleActions.tsx @@ -14,7 +14,6 @@ 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 { @@ -73,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(); @@ -97,13 +104,7 @@ 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; 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/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; +}; From 21c5b26f785a97568553df15af378b288be1bfd8 Mon Sep 17 00:00:00 2001 From: Celia Amador Gonzalez Date: Mon, 10 Aug 2026 16:09:57 +0200 Subject: [PATCH 5/6] EDM-4888: Enable the restart action on success (#762) Made-with: Cursor (cherry picked from commit 0e19b122239c7960937e87e84dbdf8d33855ba32) --- libs/i18n/locales/en/translation.json | 1 + .../Tables/ApplicationLifecycleActions.tsx | 2 +- .../src/hooks/useApplicationLifecycle.ts | 21 ++++++++++++++++++- .../src/utils/applicationLifecycle.ts | 10 ++++++++- 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 201c90a39..e6b0ee214 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -602,6 +602,7 @@ "Stop": "Stop", "Restart": "Restart", "Open console": "Open console", + "The requested action failed": "The requested action failed", "Expand row": "Expand row", "Ready": "Ready", "Restarts": "Restarts", diff --git a/libs/ui-components/src/components/DetailsPage/Tables/ApplicationLifecycleActions.tsx b/libs/ui-components/src/components/DetailsPage/Tables/ApplicationLifecycleActions.tsx index d0fb8340d..00abd875a 100644 --- a/libs/ui-components/src/components/DetailsPage/Tables/ApplicationLifecycleActions.tsx +++ b/libs/ui-components/src/components/DetailsPage/Tables/ApplicationLifecycleActions.tsx @@ -219,7 +219,7 @@ const ApplicationLifecycleActions = ({ } > 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/utils/applicationLifecycle.ts b/libs/ui-components/src/utils/applicationLifecycle.ts index 1ccadc337..86f20bc63 100644 --- a/libs/ui-components/src/utils/applicationLifecycle.ts +++ b/libs/ui-components/src/utils/applicationLifecycle.ts @@ -158,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, @@ -175,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 || From 8aea4fdd5aa45d24c0af7e184bc6b08020f1ed57 Mon Sep 17 00:00:00 2001 From: Celia Amador Gonzalez Date: Tue, 11 Aug 2026 16:46:28 +0200 Subject: [PATCH 6/6] EDM-4726: Explain updated behaviour for VM apps (#766) Made-with: Cursor --- libs/i18n/locales/en/translation.json | 6 ++++-- .../Device/EditDeviceWizard/deviceSpecUtils.ts | 2 ++ .../EditDeviceWizard/steps/ApplicationVmForm.tsx | 14 ++++++++++---- libs/ui-components/src/types/deviceSpec.ts | 2 ++ 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index e6b0ee214..8666dcdc3 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -864,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)", @@ -874,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/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.')}