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
14 changes: 12 additions & 2 deletions libs/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -72,41 +72,44 @@ 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;
const canStart = startableStatuses.includes(appStatus);
const canStop = stoppableStatuses.includes(appStatus);
const isTransitioning = transitionalStatuses.includes(appStatus);
const hasStatusMismatch = hasAplicationStatusMismatch(appStatus, desiredState);
const isUserInitiatedTransition = pendingAction != null;
Expand Down Expand Up @@ -162,7 +165,7 @@ const ApplicationLifecycleActions = ({
{t('Start')}
</DropdownItem>
)}
{isAppRunning && (
{canStop && (
<DropdownItem component="button" onClick={() => handleAction('stop')}>
{t('Stop')}
</DropdownItem>
Expand Down Expand Up @@ -216,7 +219,7 @@ const ApplicationLifecycleActions = ({
<Alert
isInline
variant="danger"
title={t('An error occurred')}
title={t('The requested action failed')}
isPlain
actionClose={<AlertActionCloseButton onClose={clearError} />}
>
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>
);
};

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
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,7 @@ const createDefaultVmAppForm = (name: string = ''): VmAppForm => ({
specType: AppSpecType.INLINE,
name,
configMode: 'form',
isExisting: false,
hasAdvancedVmSettings: false,
vmYaml: '',
diskImage: '',
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading