From 6ef010425960d7671fb96819b4926ab9740aa89b Mon Sep 17 00:00:00 2001 From: Celia Amador Date: Mon, 10 Aug 2026 09:03:39 +0200 Subject: [PATCH] EDM-5034: Show restart app loop warnings Made-with: Cursor --- 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; +};