* Copyright (C) 2022 Red Hat, Inc.
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
import cockpit from "cockpit";
import React, { useContext, useEffect, useRef, useState } from "react";
import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
import { Content } from "@patternfly/react-core/dist/esm/components/Content/index.js";
import { ProgressStep, ProgressStepper } from "@patternfly/react-core/dist/esm/components/ProgressStepper/index.js";
import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
import { CheckCircleIcon } from "@patternfly/react-icons/dist/esm/icons/check-circle-icon";
import { ExclamationCircleIcon } from "@patternfly/react-icons/dist/esm/icons/exclamation-circle-icon";
import { InProgressIcon } from "@patternfly/react-icons/dist/esm/icons/in-progress-icon";
import { PendingIcon } from "@patternfly/react-icons/dist/esm/icons/pending-icon";
import { BossClient, getActiveInstallationTask, getSteps, INSTALLATION_STATUS, installWithTasks } from "../../apis/boss.js";
import { exitGui, rebootSystem } from "../../helpers/exit.js";
import { debug } from "../../helpers/log.js";
import { BossContext, OsReleaseContext, SystemTypeContext } from "../../contexts/Common.jsx";
import { EmptyStatePanel } from "cockpit-components-empty-state.jsx";
import { Feedback } from "./Feedback.jsx";
import { InstallationNonCriticalErrorDialog } from "./InstallationNonCriticalErrorDialog.jsx";
import { useAutoReboot } from "./useAutoReboot.js";
import "./InstallationProgress.scss";
const _ = cockpit.gettext;
const N_ = cockpit.noop;
const SCREEN_ID = "anaconda-screen-progress";
const DETAIL_TYPE_YESNO = "yesno";
const PROGRESS_STEPS_DONE = 4;
const progressStepsMap = {
BOOTLOADER_INSTALLATION: 2,
ENVIRONMENT_CONFIGURATION: 0,
FINALIZATION: 3,
SOFTWARE_INSTALLATION: 1,
STORAGE_CONFIGURATION: 0,
SYSTEM_CONFIGURATION: 2,
};
export const InstallationProgress = ({ automatedInstall, onCritFail }) => {
const [status, setStatus] = useState();
const [statusMessage, setStatusMessage] = useState("");
const [steps, setSteps] = useState();
const [currentProgressStep, setCurrentProgressStep] = useState(0);
const [errorDialogData, setErrorDialogData] = useState(null);
const refStatusMessage = useRef("");
const isBootIso = useContext(SystemTypeContext).systemType === "BOOT_ISO";
const osRelease = useContext(OsReleaseContext);
const { installationStatus, pendingError } = useContext(BossContext);
useAutoReboot(status, automatedInstall);
useEffect(() => {
const failureCtx = { context: _("Installation of the system failed") };
const connectToTask = (taskPath, shouldStart) => {
const taskProxy = new BossClient().client.proxy(
"org.fedoraproject.Anaconda.Task",
taskPath
);
const categoryProxy = new BossClient().client.proxy(
"org.fedoraproject.Anaconda.TaskCategory",
taskPath
);
let lastStep = null;
const addEventListeners = () => {
taskProxy.addEventListener("ProgressChanged", (_, step, message) => {
if (step === 0 && lastStep !== 0) {
getSteps({ task: taskPath })
.then(
ret => setSteps(ret.v),
onCritFail()
);
}
lastStep = step;
if (message && message !== refStatusMessage.current) {
debug("ProgressChanged:", message);
setStatusMessage(message);
refStatusMessage.current = message;
}
});
taskProxy.addEventListener("Failed", () => {
debug("Installation task Failed signal received");
setStatus("danger");
});
taskProxy.addEventListener("Stopped", () => {
debug("Installation task Stopped signal received");
taskProxy.Finish().catch(onCritFail({
context: cockpit.format(N_("Installation of the system failed: $0"), refStatusMessage.current),
}));
});
categoryProxy.addEventListener("changed", (_, data) => {
if ("CurrentCategory" in data) {
debug("CategoryChanged:", data.CurrentCategory);
const step = progressStepsMap[data.CurrentCategory];
setCurrentProgressStep(current => {
if (step !== undefined && step >= current) {
return step;
}
return current;
});
}
});
categoryProxy.addEventListener("ErrorRaised", (_, message, detailType) => {
handleError(message, detailType, categoryProxy);
});
taskProxy.addEventListener("Succeeded", () => {
debug("Installation task Succeeded signal received");
setStatus("success");
setCurrentProgressStep(PROGRESS_STEPS_DONE);
});
};
Promise.all([taskProxy.wait(), categoryProxy.wait()]).then(() => {
addEventListeners();
if (shouldStart) {
taskProxy.Start().catch(onCritFail(failureCtx));
} else {
const step = progressStepsMap[categoryProxy.CurrentCategory];
if (step !== undefined) {
setCurrentProgressStep(step);
}
getSteps({ task: taskPath })
.then(
ret => setSteps(ret.v),
onCritFail()
);
handleError(pendingError.message, pendingError.type, categoryProxy);
}
});
};
const startNewInstallation = () =>
installWithTasks().then(
tasks => connectToTask(tasks[0], true),
onCritFail(failureCtx)
);
const handleError = (message, detailType, categoryProxy) => {
if (!message) return;
if (detailType === DETAIL_TYPE_YESNO && categoryProxy) {
setErrorDialogData({ categoryProxy, message });
} else {
setStatus("danger");
categoryProxy?.RespondToError(false);
onCritFail()({ message });
}
};
const syncInstallState = async () => {
debug("syncInstallState: installationStatus=", installationStatus);
switch (installationStatus) {
case INSTALLATION_STATUS.SUCCEEDED:
setStatus("success");
setCurrentProgressStep(PROGRESS_STEPS_DONE);
setSteps([]);
break;
case INSTALLATION_STATUS.FAILED:
handleError(pendingError.message, pendingError.type, null);
break;
case INSTALLATION_STATUS.RUNNING:
try {
const activeTask = await getActiveInstallationTask();
if (activeTask) {
connectToTask(activeTask, false);
} else {
const errMsg = "Installation status is RUNNING but no active installation task was found";
debug(errMsg);
throw new Error(errMsg);
}
} catch (error) {
onCritFail(failureCtx)(error);
}
break;
default:
startNewInstallation();
}
};
syncInstallState();
}, [installationStatus, onCritFail]);
const submitErrorDecision = (shouldContinue) => {
if (!errorDialogData) {
return;
}
errorDialogData.categoryProxy.RespondToError(shouldContinue)
.finally(() => {
setErrorDialogData(null);
});
};
if (steps === undefined) {
return null;
}
let icon;
let title;
if (status === "success") {
icon = CheckCircleIcon;
title = _("Successfully installed");
} else if (status === "danger") {
icon = ExclamationCircleIcon;
title = _("Installation failed");
} else {
title = _("Installing");
}
const progressSteps = [
{
description: _("Storage configuration: Storage is currently being configured."),
id: SCREEN_ID + "-step-storage",
title: _("Storage configuration"),
},
{
description: _("Software installation: Storage configuration complete. The software is now being installed onto your device."),
id: SCREEN_ID + "-step-payload",
title: _("Software installation"),
},
{
description: _("System configuration: Software installation complete. The system is now being configured."),
id: SCREEN_ID + "-step-configuration",
title: _("System configuration"),
},
{
description: _("Finalizing: The system configuration is complete. Finalizing installation may take a few moments."),
id: SCREEN_ID + "-step-boot-loader",
title: _("Finalization"),
},
];
const successActions = [
<Button key="reboot" onClick={rebootSystem}>{_("Reboot")}</Button>,
...(!isBootIso ? [<Button key="exit" variant="link" onClick={exitGui}>{_("Exit to live desktop")}</Button>] : []),
];
return (
<Flex direction={{ default: "column" }} className={SCREEN_ID + "-status " + SCREEN_ID + "-status-" + status}>
<EmptyStatePanel
icon={icon}
isFullHeight
loading={!icon}
variant="full"
paragraph={
<Flex direction={{ default: "column" }}>
<Content component="p" id={SCREEN_ID + "-step-done-description"}>
{currentProgressStep < PROGRESS_STEPS_DONE
? progressSteps[currentProgressStep].description
: cockpit.format(_("To begin using $0, reboot your system."), osRelease.PRETTY_NAME)}
</Content>
{currentProgressStep < PROGRESS_STEPS_DONE && (
<>
<FlexItem spacer={{ default: "spacerXl" }} />
<ProgressStepper isCenterAligned>
{progressSteps.map((progressStep, index) => {
let variant = "pending";
let ariaLabel = _("pending step");
let phaseText = _("Pending");
let statusText = "";
let phaseIcon = <PendingIcon />;
if (index < currentProgressStep) {
variant = "success";
ariaLabel = _("completed step");
phaseText = _("Completed");
phaseIcon = <CheckCircleIcon />;
} else if (index === currentProgressStep) {
variant = status === "danger" ? status : "info";
ariaLabel = _("current step");
phaseText = _("In progress");
statusText = statusMessage;
if (status === "danger") {
phaseIcon = <ExclamationCircleIcon />;
} else {
phaseIcon = <InProgressIcon />;
}
}
return (
<ProgressStep
aria-label={ariaLabel}
id={SCREEN_ID + "-step-" + index}
isCurrent={index === currentProgressStep}
icon={phaseIcon}
titleId={progressStep.id}
key={index}
variant={variant}
description={
<Flex direction={{ default: "column" }}>
<FlexItem spacer={{ default: "spacerNone" }}>
<Content ouiaId={progressStep.id + "-phase"} component="p">{phaseText}</Content>
</FlexItem>
<FlexItem spacer={{ default: "spacerNone" }}>
<Content component="p">{statusText}</Content>
</FlexItem>
</Flex>
}
>
{progressStep.title}
</ProgressStep>
);
})}
</ProgressStepper>
</>)}
</Flex>
}
secondary={status === "success" && successActions}
title={title}
headingLevel="h2"
/>
<InstallationNonCriticalErrorDialog
errorDialogData={errorDialogData}
onSubmitDecision={submitErrorDecision}
/>
{(status === "success" || status === "danger") && <Feedback />}
</Flex>
);
};