🏡 index : quine-global/quine-anaconda-webui.git

/*
 * 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 });
            }
        };

        /** Sync UI with the backend installation status: finalize if done, reconnect if running, or start a new installation. */
        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 {
                        // this should be impossible. How is the status = RUNNING and no task is active?
                        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();
    // eslint-disable-next-line react-hooks/exhaustive-deps -- pendingError is read on reconnection only, not reactively
    }, [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>
    );
};