Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions libs/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -73,37 +72,39 @@ const ApplicationStatusMismatchAlert = ({
);
};

export type ApplicationLifecycleControls = {
start: () => Promise<void>;
stop: () => Promise<void>;
restart: () => Promise<void>;
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();
const [actionsOpen, setActionsOpen] = React.useState(false);
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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -82,13 +89,102 @@ const AppExpandedDetails = ({
return <WorkloadAppExpandedDetails application={application} desiredState={desiredState} />;
};

type RestartLoopStopButtonProps = {
onStop: () => void;
isDisabled: boolean;
isLoading: boolean;
};

const RestartLoopStopButton = ({ onStop, isDisabled, isLoading }: RestartLoopStopButtonProps) => {
const { t } = useTranslation();
return (
<Button variant="secondary" onClick={onStop} isDisabled={isDisabled} isLoading={isLoading}>
{t('Stop application')}
</Button>
);
};

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 (
<Popover
aria-label={t('Restart loop detected')}
headerContent={t('Restart loop detected')}
bodyContent={
<Stack hasGutter>
<StackItem>
{t(
'This application has restarted {{ count }} times this session. Stop the application to prevent further restarts and investigate the issue.',
{
count: restartDelta,
},
)}
</StackItem>

{canStop && (
<StackItem>
<RestartLoopStopButton onStop={onStop} isDisabled={stopDisabled} isLoading={stopLoading} />
</StackItem>
)}
</Stack>
}
withFocusTrap={false}
>
<Button aria-label={t('Restart loop detected')} variant="link" isInline>
<Label status="warning" variant="outline">
{restarts}
</Label>
</Button>
</Popover>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
};

const RestartLoopAlert = ({ restartDelta, canStop, stopDisabled, stopLoading, onStop }: RestartLoopWarningProps) => {
const { t } = useTranslation();
return (
<Alert isInline variant="warning" title={t('This application is restarting repeatedly')}>
<Stack hasGutter>
<StackItem>
{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,
},
)}
</StackItem>
{canStop && (
<StackItem>
<RestartLoopStopButton onStop={onStop} isDisabled={stopDisabled} isLoading={stopLoading} />
</StackItem>
)}
</Stack>
</Alert>
);
};

type ApplicationTableRowProps = BaseApplicationsTableProps & {
desiredState?: ApplicationDesiredState;
application: StatusAppWithSpec;
rowIndex: number;
isExpanded: boolean;
onToggle: VoidFunction;
canManageLifecycle: boolean;
restartDelta?: number;
};

const ApplicationTableRow = ({
Expand All @@ -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 (
<Tbody isExpanded={isExpanded}>
Expand All @@ -126,23 +253,46 @@ const ApplicationTableRow = ({
</Label>
</Td>
<Td dataLabel={t('Ready')}>{appStatusObj.ready}</Td>
<Td dataLabel={t('Restarts')}>{appStatusObj.restarts}</Td>
<Td dataLabel={t('Restarts')}>
{showRestartLoopWarning ? (
<AppRestartsWarning
restarts={appStatusObj.restarts}
restartDelta={restartDelta}
canStop={canStop}
stopDisabled={isStopDisabled}
stopLoading={lifecycle.isSubmitting}
onStop={onStop}
/>
) : (
appStatusObj.restarts
)}
</Td>
</Tr>
<Tr isExpanded={isExpanded}>
<Td colSpan={COL_COUNT}>
<ExpandableRowContent>
<Stack hasGutter>
<StackItem>
<ApplicationLifecycleActions
deviceName={deviceName}
refetch={refetch}
lifecycleDisabledReason={lifecycleDisabledReason}
desiredState={desiredState}
appStatus={application.status}
canManageLifecycle={canManageLifecycle}
lifecycle={lifecycle}
onOpenConsole={onOpenConsole}
/>
</StackItem>
{showRestartLoopWarning && isExpanded && (
<StackItem>
<RestartLoopAlert
restartDelta={restartDelta}
canStop={canStop}
stopDisabled={isStopDisabled}
stopLoading={lifecycle.isSubmitting}
onStop={onStop}
/>
</StackItem>
)}
<StackItem>
<AppExpandedDetails application={application} desiredState={desiredState} />
</StackItem>
Expand Down Expand Up @@ -175,6 +325,8 @@ const ApplicationsTable = ({
const [expandedRow, setExpandedRow] = React.useState<string | null>(null);

const { workloadApps, vmApps } = React.useMemo(() => getAppsByType(appsStatus, appsSpecs), [appsStatus, appsSpecs]);
const restartDeltas = useRestartSpikes(appsStatus);

if (workloadApps.length === 0 && vmApps.length === 0) {
return <Bullseye>{t('No applications found')}</Bullseye>;
}
Expand All @@ -201,6 +353,7 @@ const ApplicationsTable = ({
onToggle={() => setExpandedRow(expandedRow === rowKey ? null : rowKey)}
canManageLifecycle={canManageLifecycle}
onOpenConsole={canOpenConsole ? onOpenConsole : undefined}
restartDelta={restartDeltas[name]}
/>
);
})}
Expand Down
74 changes: 74 additions & 0 deletions libs/ui-components/src/hooks/useRestartSpikes.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> = {};

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<string, number> => {
const baselineRef = React.useRef<Record<string, number>>({});
const appsRef = React.useRef(apps);
appsRef.current = apps;

const [spikes, setSpikes] = React.useState<Record<string, number>>(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<string, number> = {};
const seen = new Set<string>();

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;
};
Loading