diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss
index 9ebb24e15b..1f3cbaa9aa 100644
--- a/web/src/assets/styles/index.scss
+++ b/web/src/assets/styles/index.scss
@@ -580,6 +580,16 @@ button:focus-visible {
}
}
+span.in-quotes {
+ &::before {
+ content: open-quote;
+ }
+
+ &::after {
+ content: close-quote;
+ }
+}
+
// FIXME: make ir more generic, if possible, or even without CSS but not
// rendering such a label if "storage instructions" are more than one
.storage-structure:has(> li:nth-child(2)) span.action-text {
diff --git a/web/src/components/core/ConfirmPage.tsx b/web/src/components/core/ConfirmPage.tsx
new file mode 100644
index 0000000000..2d4216dc91
--- /dev/null
+++ b/web/src/components/core/ConfirmPage.tsx
@@ -0,0 +1,103 @@
+/*
+ * Copyright (c) [2025] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React from "react";
+import { Navigate } from "react-router";
+import { Button, Content, Flex, Split } from "@patternfly/react-core";
+import Page from "~/components/core/Page";
+import Text from "~/components/core/Text";
+import Details from "~/components/core/Details";
+import HostnameDetailsItem from "~/components/system/HostnameDetailsItem";
+import L10nDetailsItem from "~/components/l10n/L10nDetailsItem";
+import StorageDetailsItem from "~/components/storage/StorageDetailsItem";
+import NetworkDetailsItem from "~/components/network/NetworkDetailsItem";
+import SoftwareDetailsItem from "~/components/software/SoftwareDetailsItem";
+import PotentialDataLossAlert from "~/components/storage/PotentialDataLossAlert";
+import { startInstallation } from "~/model/manager";
+import { useProduct } from "~/hooks/model/config";
+import { PRODUCT } from "~/routes/paths";
+import { useDestructiveActions } from "~/hooks/use-destructive-actions";
+import { _ } from "~/i18n";
+
+export default function ConfirmPage() {
+ const product = useProduct();
+ const { actions } = useDestructiveActions();
+ const hasDestructiveActions = actions.length > 0;
+
+ if (!product) {
+ return ;
+ }
+
+ // TRANSLATORS: title shown in the confirmation page before
+ // starting the installation. %s will be replaced with the product name.
+ const [titleStart, titleEnd] = _("Start %s installation?").split("%s");
+
+ return (
+
+
+
+
+ {titleStart} {product.name} {titleEnd}
+
+
+ {
+ // TRANSLATORS: Part of the introductory text shown in the confirmation page before
+ // starting the installation.
+ _(
+ "Review the summary below. If anything seems incorrect or you have doubts, go back and adjust the settings before proceeding.",
+ )
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+ {_("Go back")}
+
+
+
+
+ );
+}
diff --git a/web/src/components/core/Details.test.tsx b/web/src/components/core/Details.test.tsx
new file mode 100644
index 0000000000..3619eda7c0
--- /dev/null
+++ b/web/src/components/core/Details.test.tsx
@@ -0,0 +1,165 @@
+/*
+ * Copyright (c) [2025] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React from "react";
+import { screen } from "@testing-library/react";
+import { plainRender } from "~/test-utils";
+import Details from "./Details";
+
+describe("Details", () => {
+ describe("basic rendering", () => {
+ it("renders a PatternFly description list", () => {
+ const { container } = plainRender(
+
+ John Doe
+ ,
+ );
+
+ const descriptionList = container.querySelector("dl");
+ expect(descriptionList).toBeInTheDocument();
+ expect(descriptionList.classList).toContain("pf-v6-c-description-list");
+ });
+
+ it("renders multiple items", () => {
+ plainRender(
+
+ John Doe
+ john@example.com
+ Developer
+ ,
+ );
+
+ screen.getByText("Name");
+ screen.getByText("John Doe");
+ screen.getByText("Email");
+ screen.getByText("john@example.com");
+ screen.getByText("Role");
+ screen.getByText("Developer");
+ });
+
+ it("renders with no items", () => {
+ const { container } = plainRender();
+
+ const descriptionList = container.querySelector("dl");
+ expect(descriptionList).toBeInTheDocument();
+ expect(descriptionList).toBeEmptyDOMElement();
+ });
+ });
+
+ describe("Details.Item", () => {
+ it("renders label and children correctly", () => {
+ plainRender(
+
+ AMD Ryzen 7
+ ,
+ );
+
+ screen.getByText("CPU");
+ screen.getByText("AMD Ryzen 7");
+ });
+
+ describe("PatternFly props passthrough", () => {
+ it("passes props to DescriptionList", () => {
+ const { container } = plainRender(
+
+ John
+ ,
+ );
+
+ const descriptionList = container.querySelector("dl");
+ expect(descriptionList).toHaveClass("pf-m-horizontal");
+ expect(descriptionList).toHaveClass("pf-m-compact");
+ });
+ });
+
+ describe("termProps and descriptionProps", () => {
+ it("passes termProps to DescriptionListTerm", () => {
+ const { container } = plainRender(
+
+
+ John
+
+ ,
+ );
+
+ const term = container.querySelector("dt");
+ expect(term).toHaveClass("custom-term-class");
+ });
+
+ it("passes descriptionProps to DescriptionListDescription", () => {
+ const { container } = plainRender(
+
+
+ John
+
+ ,
+ );
+
+ const description = container.querySelector("dd");
+ expect(description).toHaveClass("custom-desc-class");
+ });
+ });
+ });
+
+ describe("Details.StackItem", () => {
+ it("renders given data within an opinionated flex layout", () => {
+ const { container } = plainRender(
+
+ Use device vdd (20 GiB)}
+ description="Potential data loss"
+ />
+ ,
+ );
+
+ const flexContainer = container.querySelector(
+ '[class*="pf-v"][class*="-l-flex"][class*=pf-m-column]',
+ );
+
+ screen.getByText("Storage");
+ screen.getByRole("link", { name: /Use device vdd/i });
+ const small = container.querySelector("small");
+ expect(small).toHaveTextContent("Potential data loss");
+ expect(small).toHaveClass(/pf-v.-u-text-color-subtle/);
+ });
+
+ it("renders skeleton placeholders instead of content when isLoading is true", () => {
+ const { container } = plainRender(
+
+ Use device vdd (20 GiB)}
+ description="Potential data loss"
+ isLoading
+ />
+ ,
+ );
+
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
+ expect(screen.queryByText("Use device vdd (20 GiB)")).not.toBeInTheDocument();
+ expect(screen.queryByText("Potential data loss")).not.toBeInTheDocument();
+ const skeletons = container.querySelectorAll('[class*="pf-v"][class*="-c-skeleton"]');
+ expect(skeletons.length).toBe(2);
+ });
+ });
+});
diff --git a/web/src/components/core/Details.tsx b/web/src/components/core/Details.tsx
new file mode 100644
index 0000000000..d84107adf7
--- /dev/null
+++ b/web/src/components/core/Details.tsx
@@ -0,0 +1,131 @@
+/*
+ * Copyright (c) [2025] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React from "react";
+import {
+ DescriptionList,
+ DescriptionListGroup,
+ DescriptionListTerm,
+ DescriptionListDescription,
+ Flex,
+ Skeleton,
+} from "@patternfly/react-core";
+import type {
+ DescriptionListTermProps,
+ DescriptionListDescriptionProps,
+ DescriptionListProps,
+} from "@patternfly/react-core";
+import { _ } from "~/i18n";
+
+import textStyles from "@patternfly/react-styles/css/utilities/Text/text";
+
+type ItemProps = {
+ /** The label/term for this field */
+ label: DescriptionListTermProps["children"];
+ /** The value/description for this field */
+ children: DescriptionListDescriptionProps["children"];
+ /** Additional props passed to the DescriptionListTerm component */
+ termProps?: Omit;
+ /** Additional props passed to the DescriptionListDescription component */
+ descriptionProps?: Omit;
+};
+
+/**
+ * A single item in a `Details` description list.
+ *
+ * Wraps a PatternFly `DescriptionListGroup` with `DescriptionListTerm` and
+ * `DescriptionListDescription`.
+ */
+const Item = ({ label, children, termProps = {}, descriptionProps = {} }: ItemProps) => {
+ return (
+
+ {label}
+ {children}
+
+ );
+};
+
+type SummaryItemProps = {
+ /** The label for the DescriptionListTerm */
+ label: React.ReactNode;
+ /** The primary value of the item */
+ content: React.ReactNode;
+ /** Secondary information displayed below the content */
+ description?: React.ReactNode;
+ /** Whether to display the skeleton loading state */
+ isLoading?: boolean;
+};
+
+/**
+ * A layout-opinionated item for `Details`.
+ *
+ * Used for rendering items in the Agama overview and confirmation pages, where
+ * a single term has to be rendered with a primary value and an optional
+ * description as well as a consistent "loading skeleton states" when isLoading
+ * is true.
+ */
+const StackItem = ({ label, content, description, isLoading }: SummaryItemProps) => {
+ return (
+
+
+ {isLoading ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+ {content}
+ {description && {description}}
+ >
+ )}
+
+
+ );
+};
+
+/**
+ * An abstraction over PatternFly's `DescriptionList`.
+ *
+ * Provides a simpler, more declarative API for building description lists using
+ * the compound component pattern with `Details.Item`.
+ *
+ * @example
+ * ```tsx
+ *
+ * Lenovo ThinkPad P14s Gen 4
+ * AMD Ryzen™ 7 × 16
+ *
+ *
+ *
+ *
+ * ```
+ */
+const Details = ({ children, ...props }: DescriptionListProps) => {
+ return {children};
+};
+
+Details.Item = Item;
+Details.StackItem = StackItem;
+
+export default Details;
+export type { ItemProps as DetailsItemProps };
diff --git a/web/src/components/core/InstallButton.test.tsx b/web/src/components/core/InstallButton.test.tsx
index e669546911..1363ecd240 100644
--- a/web/src/components/core/InstallButton.test.tsx
+++ b/web/src/components/core/InstallButton.test.tsx
@@ -41,15 +41,6 @@ jest.mock("~/hooks/model/issue", () => ({
useIssues: () => mockIssues(),
}));
-const clickInstallButton = async () => {
- const { user } = installerRender();
- const button = await screen.findByRole("button", { name: "Install" });
- await user.click(button);
-
- const dialog = screen.getByRole("dialog", { name: "Confirm Installation" });
- return { user, dialog };
-};
-
describe("InstallButton", () => {
describe("when there are installation issues", () => {
beforeEach(() => {
@@ -73,13 +64,12 @@ describe("InstallButton", () => {
screen.getByRole("tooltip", { name: /Not possible with the current setup/ });
});
- it("triggers the onClickWithIssues callback without rendering the confirmation dialog", async () => {
+ it("triggers the onClickWithIssues callback", async () => {
const onClickWithIssuesFn = jest.fn();
const { user } = installerRender();
const button = screen.getByRole("button", { name: /Install/ });
await user.click(button);
expect(onClickWithIssuesFn).toHaveBeenCalled();
- await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
});
});
@@ -100,15 +90,6 @@ describe("InstallButton", () => {
).toBeNull();
});
- it("renders a confirmation dialog when clicked without triggering the onClickWithIssues callback", async () => {
- const onClickWithIssuesFn = jest.fn();
- const { user } = installerRender();
- const button = await screen.findByRole("button", { name: "Install" });
- await user.click(button);
- expect(onClickWithIssuesFn).not.toHaveBeenCalled();
- screen.getByRole("dialog", { name: "Confirm Installation" });
- });
-
describe.each([
["login", ROOT.login],
["product selection", PRODUCT.changeProduct],
@@ -126,76 +107,4 @@ describe("InstallButton", () => {
});
});
});
-
- describe("when there are only non-critical issues", () => {
- beforeEach(() => {
- mockIssues.mockReturnValue([
- {
- description: "Fake warning",
- class: "generic",
- details: "Fake Issue details",
- scope: "product",
- },
- ] as Issue[]);
- });
-
- it("renders the button without any additional information", async () => {
- const { user, container } = installerRender();
- const button = screen.getByRole("button", { name: "Install" });
- // Renders nothing else
- const icon = container.querySelector("svg");
- expect(icon).toBeNull();
- await user.hover(button);
- expect(
- screen.queryByRole("tooltip", { name: /Not possible with the current setup/ }),
- ).toBeNull();
- });
- });
-});
-
-describe("InstallConfirmationPopup", () => {
- it("closes the dialog without triggering installation if user press {enter} before 'Continue' gets the focus", async () => {
- const { user, dialog } = await clickInstallButton();
- const continueButton = within(dialog).getByRole("button", { name: "Continue" });
- expect(continueButton).not.toHaveFocus();
- await user.keyboard("{enter}");
- expect(mockStartInstallationFn).not.toHaveBeenCalled();
- await waitFor(() => {
- expect(dialog).not.toBeInTheDocument();
- });
- });
-
- it("closes the dialog and triggers installation if user {enter} when 'Continue' has the focus", async () => {
- const { user, dialog } = await clickInstallButton();
- const continueButton = within(dialog).getByRole("button", { name: "Continue" });
- expect(continueButton).not.toHaveFocus();
- await user.keyboard("{tab}");
- expect(continueButton).toHaveFocus();
- await user.keyboard("{enter}");
- expect(mockStartInstallationFn).toHaveBeenCalled();
- await waitFor(() => {
- expect(dialog).not.toBeInTheDocument();
- });
- });
-
- it("closes the dialog and triggers installation if user clicks on 'Continue'", async () => {
- const { user, dialog } = await clickInstallButton();
- const continueButton = within(dialog).getByRole("button", { name: "Continue" });
- await user.click(continueButton);
- expect(mockStartInstallationFn).toHaveBeenCalled();
- await waitFor(() => {
- expect(dialog).not.toBeInTheDocument();
- });
- });
-
- it("closes the dialog without triggering installation if the user clicks on 'Cancel'", async () => {
- const { user, dialog } = await clickInstallButton();
- const cancelButton = within(dialog).getByRole("button", { name: "Cancel" });
- await user.click(cancelButton);
- expect(mockStartInstallationFn).not.toHaveBeenCalled();
-
- await waitFor(() => {
- expect(dialog).not.toBeInTheDocument();
- });
- });
});
diff --git a/web/src/components/core/InstallButton.tsx b/web/src/components/core/InstallButton.tsx
index 09ef093244..8b25a16669 100644
--- a/web/src/components/core/InstallButton.tsx
+++ b/web/src/components/core/InstallButton.tsx
@@ -20,52 +20,15 @@
* find current contact information at www.suse.com.
*/
-import React, { useId, useState } from "react";
-import { Button, ButtonProps, Stack, Tooltip, TooltipProps } from "@patternfly/react-core";
-import { Popup } from "~/components/core";
-import { startInstallation } from "~/model/manager";
+import React, { useId } from "react";
+import { Button, ButtonProps, Tooltip, TooltipProps } from "@patternfly/react-core";
import { useIssues } from "~/hooks/model/issue";
-import { useLocation } from "react-router";
-import { SIDE_PATHS } from "~/routes/paths";
+import { useLocation, useNavigate } from "react-router";
+import { ROOT, SIDE_PATHS } from "~/routes/paths";
import { _ } from "~/i18n";
import { Icon } from "../layout";
import { isEmpty } from "radashi";
-/**
- * List of paths where the InstallButton must not be shown.
- *
- * Apart from obvious login and installation paths, it does not make sense to
- * show the button neither, when the user is about to change the product,
- * defining the root authentication for the first time, nor when the installer
- * is setting the chosen product.
- * */
-
-const InstallConfirmationPopup = ({ onAccept, onClose }) => {
- return (
-
-
-
- {_(
- "If you continue, partitions on your hard disk will be modified \
-according to the provided installation settings.",
- )}
-
-
{_("Please, cancel and check the settings if you are unsure.")}
-
-
-
- {/* TRANSLATORS: button label */}
- {_("Continue")}
-
-
- {/* TRANSLATORS: button label */}
- {_("Cancel")}
-
-
-
- );
-};
-
/**
* Installation button
*
@@ -79,19 +42,15 @@ const InstallButton = (
const labelId = useId();
const tooltipId = useId();
const issues = useIssues();
- const [isOpen, setIsOpen] = useState(false);
+ const navigate = useNavigate();
const location = useLocation();
const hasIssues = !isEmpty(issues);
if (SIDE_PATHS.includes(location.pathname)) return;
+ const navigateToConfirmation = () => navigate(ROOT.confirm);
+
const { onClickWithIssues, ...buttonProps } = props;
- const open = async () => setIsOpen(true);
- const close = () => setIsOpen(false);
- const onAccept = () => {
- close();
- startInstallation();
- };
// TRANSLATORS: The install button label
const buttonText = _("Install");
@@ -113,14 +72,13 @@ const InstallButton = (
variant="control"
className="agm-install-button"
{...buttonProps}
- onClick={hasIssues ? onClickWithIssues : open}
+ onClick={hasIssues ? onClickWithIssues : navigateToConfirmation}
icon={hasIssues && }
iconPosition="end"
>
{buttonText}
- {isOpen && }
>
);
};
diff --git a/web/src/components/core/ProgressBackdrop.tsx b/web/src/components/core/ProgressBackdrop.tsx
index 4b9ca39f5c..0e4ced567e 100644
--- a/web/src/components/core/ProgressBackdrop.tsx
+++ b/web/src/components/core/ProgressBackdrop.tsx
@@ -20,16 +20,15 @@
* find current contact information at www.suse.com.
*/
-import React, { useEffect, useState } from "react";
+import React from "react";
import { Alert, Backdrop, Flex, FlexItem, Spinner } from "@patternfly/react-core";
-import { concat, isEmpty } from "radashi";
+import { concat } from "radashi";
import { sprintf } from "sprintf-js";
import { COMMON_PROPOSAL_KEYS } from "~/hooks/model/proposal";
-import { useStatus } from "~/hooks/model/status";
-import useTrackQueriesRefetch from "~/hooks/use-track-queries-refetch";
import type { Scope } from "~/model/status";
import { _ } from "~/i18n";
import textStyles from "@patternfly/react-styles/css/utilities/Text/text";
+import { useProgressTracking } from "~/hooks/use-progress-tracking";
/**
* Props for the ProgressBackdrop component.
@@ -86,32 +85,11 @@ export default function ProgressBackdrop({
scope,
ensureRefetched,
}: ProgressBackdropProps): React.ReactNode {
- const { progresses: tasks } = useStatus();
- const [isBlocked, setIsBlocked] = useState(false);
- const [progressFinishedAt, setProgressFinishedAt] = useState(null);
- const progress = !isEmpty(scope) && tasks.find((t) => t.scope === scope);
- const { startTracking } = useTrackQueriesRefetch(
+ const { loading: isBlocked, progress } = useProgressTracking(
+ scope,
concat(COMMON_PROPOSAL_KEYS, ensureRefetched),
- (_, completedAt) => {
- if (completedAt > progressFinishedAt) {
- setIsBlocked(false);
- setProgressFinishedAt(null);
- }
- },
);
- useEffect(() => {
- if (!progress && isBlocked && !progressFinishedAt) {
- setProgressFinishedAt(Date.now());
- startTracking();
- }
- }, [progress, isBlocked, progressFinishedAt, startTracking]);
-
- if (progress && !isBlocked) {
- setIsBlocked(true);
- setProgressFinishedAt(null);
- }
-
if (!isBlocked) return null;
return (
diff --git a/web/src/components/l10n/L10nDetailsItem.tsx b/web/src/components/l10n/L10nDetailsItem.tsx
new file mode 100644
index 0000000000..3e318e7c8a
--- /dev/null
+++ b/web/src/components/l10n/L10nDetailsItem.tsx
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) [2025] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React from "react";
+import Details from "~/components/core/Details";
+import Link from "~/components/core/Link";
+import { useProposal } from "~/hooks/model/proposal/l10n";
+import { useSystem } from "~/hooks/model/system/l10n";
+import { L10N } from "~/routes/paths";
+import { sprintf } from "sprintf-js";
+import { _ } from "~/i18n";
+
+export default function L10nDetailsItem({ withoutLink = false }: { withoutLink?: boolean }) {
+ const l10nProposal = useProposal();
+ const l10nSystem = useSystem();
+ const locale =
+ l10nProposal.locale && l10nSystem.locales.find((l) => l.id === l10nProposal.locale);
+ const keymap =
+ l10nProposal.keymap && l10nSystem.keymaps.find((k) => k.id === l10nProposal.keymap);
+ const timezone =
+ l10nProposal.timezone && l10nSystem.timezones.find((t) => t.id === l10nProposal.timezone);
+
+ // TRANSLATORS: Summary of the selected language and territory.
+ // %1$s is the language name (e.g. "Spanish").
+ // %2$s is the territory/region name (e.g. "Spain").
+ const title = sprintf(_("%1$s (%2$s)"), locale.language, locale.territory);
+
+ return (
+
+ {title}
+
+ )
+ }
+ description={
+ // TRANSLATORS: Additional details shown under the language selection.
+ // %1$s is the keyboard layout name (e.g. "Spanish").
+ // %2$s is the time zone identifier (e.g. "Atlantic/Canary").
+ sprintf(_("%1$s keyboard - %2$s timezone"), keymap.description, timezone.id)
+ }
+ />
+ );
+}
diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx
index 589a0d0528..c46e7d91b0 100644
--- a/web/src/components/layout/Header.tsx
+++ b/web/src/components/layout/Header.tsx
@@ -148,17 +148,17 @@ export default function Header({
-
-
-
-
-
-
{showInstallerOptions && (
-
+
)}
+
+
+
+
+
+
diff --git a/web/src/components/network/FormattedIpsList.test.tsx b/web/src/components/network/FormattedIpsList.test.tsx
new file mode 100644
index 0000000000..48069d31a5
--- /dev/null
+++ b/web/src/components/network/FormattedIpsList.test.tsx
@@ -0,0 +1,95 @@
+/*
+ * Copyright (c) [2026] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React from "react";
+import { screen } from "@testing-library/react";
+import { installerRender } from "~/test-utils";
+import FormattedIPsList from "./FormattedIpsList";
+import { NETWORK } from "~/routes/paths";
+
+let mockUseIpAddressesFn: jest.Mock = jest.fn();
+
+jest.mock("~/hooks/model/system/network", () => ({
+ ...jest.requireActual("~/hooks/model/system/network"),
+ useIpAddresses: () => mockUseIpAddressesFn(),
+}));
+
+describe("FormattedIPsList", () => {
+ it("renders nothing when no IP addresses are available", () => {
+ mockUseIpAddressesFn.mockReturnValue([]);
+
+ const { container } = installerRender();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders a single IP address when only one address is available", () => {
+ mockUseIpAddressesFn.mockReturnValue(["192.168.1.1"]);
+
+ const { rerender } = installerRender();
+ screen.getByText("192.168.1.1");
+
+ mockUseIpAddressesFn.mockReturnValue(["fe80::1"]);
+ rerender();
+ screen.getByText("fe80::1");
+ });
+
+ it("does not render a link when exactly two addresses are available", () => {
+ mockUseIpAddressesFn.mockReturnValue(["192.168.1.1", "fe80::1"]);
+
+ installerRender();
+ screen.getByText("192.168.1.1 and fe80::1");
+ expect(screen.queryAllByRole("link")).toEqual([]);
+ });
+
+ it("renders a link when there are more than two IP addresses", () => {
+ mockUseIpAddressesFn.mockReturnValue(["192.168.1.1", "fe80::1", "192.168.1.2", "fe80::2"]);
+
+ installerRender();
+ const moreLink = screen.getByRole("link", { name: "2 more" });
+ expect(moreLink).toHaveAttribute("href", NETWORK.root);
+ });
+
+ it("renders a link when there are multiple IP addresses of the same type", () => {
+ mockUseIpAddressesFn.mockReturnValue(["192.168.1.1", "192.168.1.2"]);
+
+ const { rerender } = installerRender();
+ screen.getByText(/192\.168\.1\.1/);
+ const moreLink = screen.getByRole("link", { name: "1 more" });
+ expect(moreLink).toHaveAttribute("href", NETWORK.root);
+
+ mockUseIpAddressesFn.mockReturnValue(["fe80::1", "fe80::2"]);
+ rerender();
+ screen.getByText(/fe80::1/);
+ const moreLink2 = screen.getByRole("link", { name: "1 more" });
+ expect(moreLink2).toHaveAttribute("href", NETWORK.root);
+ });
+
+ it("renders only the first IP of each type when there are multiple addresses of each type", () => {
+ mockUseIpAddressesFn.mockReturnValue(["192.168.1.1", "fe80::1", "192.168.1.2", "fe80::2"]);
+
+ installerRender();
+ screen.getByText(/192\.168\.1\.1/);
+ screen.getByText(/fe80::1/);
+ expect(screen.queryByText(/192\.168\.1\.2/)).not.toBeInTheDocument();
+ expect(screen.queryByText(/fe80::2/)).not.toBeInTheDocument();
+ screen.getByRole("link", { name: "2 more" });
+ });
+});
diff --git a/web/src/components/network/FormattedIpsList.tsx b/web/src/components/network/FormattedIpsList.tsx
new file mode 100644
index 0000000000..6df24d3863
--- /dev/null
+++ b/web/src/components/network/FormattedIpsList.tsx
@@ -0,0 +1,106 @@
+/*
+ * Copyright (c) [2025-2026] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React from "react";
+import { IPv4, IPv6 } from "ipaddr.js";
+import { isEmpty } from "radashi";
+import { sprintf } from "sprintf-js";
+import Link from "~/components/core/Link";
+import { NETWORK } from "~/routes/paths";
+import { useIpAddresses } from "~/hooks/model/system/network";
+import { _ } from "~/i18n";
+
+/**
+ * Displays a formatted list of IP addresses from connected devices.
+ *
+ * Shows up to two IP addresses (one IPv4 and one IPv6 when available) in a
+ * compact format. If there are additional addresses beyond the first two,
+ * displays a "X more" link that navigates to the network page where all
+ * addresses can be viewed.
+ *
+ * Display formats:
+ * - Single IP: "192.168.1.1"
+ * - Two IPs: "192.168.1.1 and fe80::1"
+ * - With extras: "192.168.1.1, fe80::1 and 2 more" (where "2 more" is a
+ * clickable link)
+ */
+export default function FormattedIPsList() {
+ const addresses = useIpAddresses({ formatted: true });
+ let firstIPv4: string | undefined;
+ let firstIPv6: string | undefined;
+ const rest: string[] = [];
+
+ // Iterate over the addresses to find firstIPv4, firstIPv6, and the rest
+ for (const address of addresses) {
+ if (IPv4.isValid(address) && !firstIPv4) {
+ firstIPv4 = address;
+ } else if (IPv6.isValid(address) && !firstIPv6) {
+ firstIPv6 = address;
+ } else {
+ rest.push(address);
+ }
+ }
+
+ if (!isEmpty(rest)) {
+ let text: string;
+ let params: (string | number)[];
+
+ if (firstIPv4 && firstIPv6) {
+ // TRANSLATORS: Displays both IPv4 and IPv6 addresses with count of
+ // additional IPs (e.g., "192.168.122.237, fe80::5054:ff:fe46:2af9 and 1
+ // more"). %1$s is the IPv4 address, %2$s is the IPv6 address, and %3$d
+ // is the number of remaining IPs. The text wrapped in square brackets []
+ // is displayed as a link. Keep the brackets to ensure the link works
+ // correctly.
+ text = _("%1$s, %2$s and [%3$d more]");
+ params = [firstIPv4, firstIPv6, rest.length];
+ } else {
+ // TRANSLATORS: Displays a single IP address (either IPv4 or IPv6) with
+ // count of additional IPs (e.g., "192.168.122.237 and [2 more]"). %1$s is
+ // the IP address and %2$d is the number of remaining IPs. The text
+ // wrapped in square brackets [] is displayed as a link. Keep the brackets
+ // to ensure the link works correctly.
+ text = _("%1$s and [%2$d more]");
+ params = [firstIPv4 || firstIPv6, rest.length];
+ }
+
+ const [textStart, link, textEnd] = sprintf(text, ...params).split(/[[\]]/);
+
+ return (
+ <>
+ {textStart}{" "}
+
+ {link}
+ {" "}
+ {textEnd}
+ >
+ );
+ }
+
+ if (firstIPv4 && firstIPv6) {
+ // TRANSLATORS: Displays first IPv4 and IPv6 when both are present.
+ // %1$s is the first IPv4 and %2$s is the first IPv6.
+ return sprintf(_("%1$s and %2$s"), firstIPv4, firstIPv6);
+ }
+
+ return firstIPv4 || firstIPv6;
+}
diff --git a/web/src/components/network/NetworkDetailsItem.test.tsx b/web/src/components/network/NetworkDetailsItem.test.tsx
new file mode 100644
index 0000000000..ae1715f3c8
--- /dev/null
+++ b/web/src/components/network/NetworkDetailsItem.test.tsx
@@ -0,0 +1,237 @@
+/*
+ * Copyright (c) [2026] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React from "react";
+import { screen } from "@testing-library/react";
+import { installerRender } from "~/test-utils";
+import { NetworkStatus } from "~/hooks/model/system/network";
+import NetworkDetailsItem from "./NetworkDetailsItem";
+
+const mockUseNetworkStatusFn = jest.fn();
+
+jest.mock("~/hooks/model/system/network", () => ({
+ ...jest.requireActual("~/hooks/model/system/network"),
+ useNetworkStatus: () => mockUseNetworkStatusFn(),
+}));
+
+describe("NetworkDetailsItem", () => {
+ describe("when network status is NOT_CONFIGURED", () => {
+ it("renders 'Not configured'", () => {
+ mockUseNetworkStatusFn.mockReturnValue({
+ status: NetworkStatus.NOT_CONFIGURED,
+ persistentConnections: [],
+ });
+ installerRender();
+ screen.getByText("Not configured");
+ });
+ });
+
+ describe("when network status is NO_PERSISTENT", () => {
+ it("renders 'Installation only' and a short explanation", () => {
+ mockUseNetworkStatusFn.mockReturnValue({
+ status: NetworkStatus.NO_PERSISTENT,
+ persistentConnections: [],
+ });
+ installerRender();
+ screen.getByText("Installation only");
+ screen.getByText("System will have no network connections");
+ });
+ });
+
+ describe("when network status is AUTO", () => {
+ it("renders mode and singular form summary when there is one connection", () => {
+ mockUseNetworkStatusFn.mockReturnValue({
+ status: NetworkStatus.AUTO,
+ persistentConnections: [{ addresses: [] }],
+ });
+ installerRender();
+ screen.getByText("Auto");
+ screen.getByText("Configured with 1 connection");
+ });
+
+ it("renders mode and plural form summary when multiple connections", () => {
+ mockUseNetworkStatusFn.mockReturnValue({
+ status: NetworkStatus.AUTO,
+ persistentConnections: [{ addresses: [] }, { addresses: [] }],
+ });
+ installerRender();
+ screen.getByText("Auto");
+ screen.getByText("Configured with 2 connections");
+ });
+ });
+
+ describe("when network status is MANUAL", () => {
+ it("renders mode and single IP for single connection with one address", () => {
+ mockUseNetworkStatusFn.mockReturnValue({
+ status: NetworkStatus.MANUAL,
+ persistentConnections: [{ addresses: [{ address: "192.168.1.100", prefix: 24 }] }],
+ });
+ installerRender();
+ screen.getByText("Manual");
+ screen.getByText("192.168.1.100");
+ });
+
+ it("renders mode and all IPs for single connection with two addresses", () => {
+ mockUseNetworkStatusFn.mockReturnValue({
+ status: NetworkStatus.MANUAL,
+ persistentConnections: [
+ {
+ addresses: [
+ { address: "192.168.1.100", prefix: 24 },
+ { address: "192.168.1.101", prefix: 24 },
+ ],
+ },
+ ],
+ });
+ installerRender();
+ screen.getByText("Manual");
+ screen.getByText("192.168.1.100 and 192.168.1.101");
+ });
+
+ it("renders mode and the first IP and count for single connection with multiple addresses", () => {
+ mockUseNetworkStatusFn.mockReturnValue({
+ status: NetworkStatus.MANUAL,
+ persistentConnections: [
+ {
+ addresses: [
+ { address: "192.168.1.100", prefix: 24 },
+ { address: "192.168.1.101", prefix: 24 },
+ { address: "192.168.1.102", prefix: 24 },
+ ],
+ },
+ ],
+ });
+ installerRender();
+ screen.getByText("Manual");
+ screen.getByText("192.168.1.100 and 2 others");
+ });
+
+ it("renders mode and connection count and IP summary for multiple connections", () => {
+ mockUseNetworkStatusFn.mockReturnValue({
+ status: NetworkStatus.MANUAL,
+ persistentConnections: [
+ { addresses: [{ address: "192.168.1.100", prefix: 24 }] },
+ { addresses: [{ address: "10.0.0.5", prefix: 8 }] },
+ ],
+ });
+ installerRender();
+ screen.getByText("Manual");
+ screen.getByText("Using 2 connections with 192.168.1.100 and 10.0.0.5");
+ });
+
+ it("renders mode and connection count with IP summary when multiple connections have more than two IPs", () => {
+ mockUseNetworkStatusFn.mockReturnValue({
+ status: NetworkStatus.MANUAL,
+ persistentConnections: [
+ {
+ addresses: [
+ { address: "192.168.1.100", prefix: 24 },
+ { address: "192.168.1.101", prefix: 24 },
+ ],
+ },
+ { addresses: [{ address: "10.0.0.5", prefix: 8 }] },
+ ],
+ });
+ installerRender();
+ screen.getByText("Manual");
+ screen.getByText("Using 2 connections with 192.168.1.100 and 2 others");
+ });
+ });
+
+ describe("when network status is MIXED", () => {
+ it("renders mode and 'DHCP' and single IP when there is only one static IP", () => {
+ mockUseNetworkStatusFn.mockReturnValue({
+ status: NetworkStatus.MIXED,
+ persistentConnections: [{ addresses: [{ address: "192.168.1.100", prefix: 24 }] }],
+ });
+ installerRender();
+ screen.getByText("Auto and manual");
+ screen.getByText("DHCP and 192.168.1.100");
+ });
+
+ it("renders mode and 'DHCP' with two IPs when there are up to two static IPs", () => {
+ mockUseNetworkStatusFn.mockReturnValue({
+ status: NetworkStatus.MIXED,
+ persistentConnections: [
+ {
+ addresses: [
+ { address: "192.168.1.100", prefix: 24 },
+ { address: "192.168.1.101", prefix: 24 },
+ ],
+ },
+ ],
+ });
+ installerRender();
+ screen.getByText("Auto and manual");
+ screen.getByText("DHCP, 192.168.1.100 and 192.168.1.101");
+ });
+
+ it("renders mode and 'DHCP' with IP summary when there are more than two static IPs", () => {
+ mockUseNetworkStatusFn.mockReturnValue({
+ status: NetworkStatus.MIXED,
+ persistentConnections: [
+ {
+ addresses: [
+ { address: "192.168.1.100", prefix: 24 },
+ { address: "192.168.1.101", prefix: 24 },
+ { address: "192.168.1.102", prefix: 24 },
+ ],
+ },
+ ],
+ });
+ installerRender();
+ screen.getByText("Auto and manual");
+ screen.getByText("DHCP, 192.168.1.100 and 2 others");
+ });
+
+ it("renders mode and connection count with 'DHCP' and IP for multiple connections", () => {
+ mockUseNetworkStatusFn.mockReturnValue({
+ status: NetworkStatus.MIXED,
+ persistentConnections: [
+ { addresses: [{ address: "192.168.1.100", prefix: 24 }] },
+ { addresses: [] },
+ ],
+ });
+ installerRender();
+ screen.getByText("Auto and manual");
+ screen.getByText("Using 2 connections with DHCP and 192.168.1.100");
+ });
+
+ it("renders mode and connection count with 'DHCP' and IP summary when there are many static IPs", () => {
+ mockUseNetworkStatusFn.mockReturnValue({
+ status: NetworkStatus.MIXED,
+ persistentConnections: [
+ {
+ addresses: [
+ { address: "192.168.1.100", prefix: 24 },
+ { address: "192.168.1.101", prefix: 24 },
+ ],
+ },
+ { addresses: [{ address: "10.0.0.5", prefix: 8 }] },
+ ],
+ });
+ installerRender();
+ screen.getByText("Auto and manual");
+ screen.getByText("Using 2 connections with DHCP, 192.168.1.100 and 2 others");
+ });
+ });
+});
diff --git a/web/src/components/network/NetworkDetailsItem.tsx b/web/src/components/network/NetworkDetailsItem.tsx
new file mode 100644
index 0000000000..766280e6ac
--- /dev/null
+++ b/web/src/components/network/NetworkDetailsItem.tsx
@@ -0,0 +1,159 @@
+/*
+ * Copyright (c) [2025-2026] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React from "react";
+import { sprintf } from "sprintf-js";
+import Details from "~/components/core/Details";
+import Link from "~/components/core/Link";
+import { NETWORK } from "~/routes/paths";
+import { NetworkStatus, useNetworkStatus } from "~/hooks/model/system/network";
+import { formatIp } from "~/utils/network";
+import { _, n_ } from "~/i18n";
+import type { Connection } from "~/types/network";
+
+type NetworkStatusValue = (typeof NetworkStatus)[keyof typeof NetworkStatus];
+
+type DescriptionProps = {
+ status: NetworkStatusValue;
+ connections: Connection[];
+};
+
+type TitleProps = {
+ status: NetworkStatusValue;
+};
+
+/**
+ * Helper component that renders a brief description of the current network
+ * configuration, based on the provided status and network connections
+ */
+const Description = ({ status, connections }: DescriptionProps) => {
+ const connectionsQty = connections.length;
+ const staticIps = connections
+ .flatMap((c) => c.addresses)
+ .map((a) => formatIp(a, { removePrefix: true }));
+ const staticIpsQty = staticIps.length;
+
+ // Format IP list once
+ const ipList = (() => {
+ if (staticIpsQty === 0) return "";
+ if (staticIpsQty === 1) return staticIps[0];
+ // TRANSLATORS: Displays two IP addresses. E.g., "192.168.1.1 and 192.168.1.2"
+ // %s will be replaced by IP addresses
+ if (staticIpsQty === 2) return sprintf(_("%s and %s"), staticIps[0], staticIps[1]);
+ // TRANSLATORS: IPs summary when there are more than 2 static IP addresses.
+ // E.g., "192.168.1.1 and 2 others"
+ // %s is replaced by an IP address (e.g., "192.168.1.1")
+ // %d is replaced by the count of remaining IPs (e.g., "2")
+ return sprintf(_("%s and %d others"), staticIps[0], staticIpsQty - 1);
+ })();
+
+ switch (status) {
+ case NetworkStatus.NOT_CONFIGURED:
+ return null;
+ case NetworkStatus.NO_PERSISTENT:
+ // TRANSLATORS: Description shown when network connections are configured
+ // only for installation and won't be available in the installed system
+ return _("System will have no network connections");
+ // return _("No connections will be copied to the installed system");
+ case NetworkStatus.MANUAL: {
+ if (connectionsQty === 1) return ipList;
+ // TRANSLATORS: Summary for multiple manual network connections. E.g.,
+ // "Using 3 connections with 192.168.1.1 and 2 others"
+ // %d is replaced by the number of connections (e.g., "3")
+ // %s is replaced by IP address summary (e.g., "192.168.1.1 and 2 others")
+ return sprintf(_("Using %d connections with %s"), connectionsQty, ipList);
+ }
+ case NetworkStatus.MIXED: {
+ // TRANSLATORS: Summary combining DHCP (automatic) with static IP
+ // addresses %s is replaced by IP address(es), e.g., "192.168.1.1" or
+ // "192.168.1.1 and 2 others"
+ const dhcpAndIps =
+ staticIpsQty === 1 ? sprintf(_("DHCP and %s"), ipList) : sprintf(_("DHCP, %s"), ipList);
+ return connectionsQty === 1
+ ? dhcpAndIps
+ : // TRANSLATORS: Summary for multiple connections mixing automatic
+ // (DHCP) and manual configuration. E.g., "Using 2 connections with DHCP
+ // and 192.168.1.1"
+ // %d is replaced by the number of connections (e.g., "2")
+ // %s is replaced by configuration summary (e.g., "DHCP and 192.168.1.1")
+ // Full example:
+ sprintf(_("Using %d connections with %s"), connectionsQty, dhcpAndIps);
+ }
+ default:
+ // TRANSLATORS: Generic summary for configured network connections
+ // %d is replaced by the number of connections
+ return sprintf(
+ n_("Configured with %d connection", "Configured with %d connections", connectionsQty),
+ connectionsQty,
+ );
+ }
+};
+
+/**
+ * Helper component that renders a title representing the current network
+ * configuration based on the provided network status
+ */
+const Title = ({ status }: TitleProps) => {
+ const result = {
+ // TRANSLATORS: Network summary title when no network has been configured
+ [NetworkStatus.NOT_CONFIGURED]: _("Not configured"),
+ // TRANSLATORS: Network summary title when connections are set up only for
+ // installation and won't persist in the installed system
+ [NetworkStatus.NO_PERSISTENT]: _("Installation only"),
+ // [NetworkStatus.NO_PERSISTENT]: _("Not persistent"),
+ // TRANSLATORS: Network summary title when using both automatic (DHCP) and manual configuration
+ [NetworkStatus.MIXED]: _("Auto and manual"),
+ // TRANSLATORS: Network summary title when using automatic configuration (DHCP)
+ [NetworkStatus.AUTO]: _("Auto"),
+ // TRANSLATORS: Network summary title when using manual/static IP configuration
+ [NetworkStatus.MANUAL]: _("Manual"),
+ };
+ return result[status];
+};
+
+/**
+ * Renders a summary of the network settings, providing a brief overview of the
+ * network status and configuration.
+ *
+ * It displays the current network "mode" (e.g., Auto, Manual, etc.) and the
+ * most relevant data (e.g., IP addresses), giving users a quick understanding
+ * of the network setup before they dive into the section for more details.
+ */
+export default function NetworkDetailsItem({ withoutLink = false }: { withoutLink?: boolean }) {
+ const { status, persistentConnections } = useNetworkStatus();
+
+ return (
+
+ ) : (
+
+
+
+ )
+ }
+ description={}
+ />
+ );
+}
diff --git a/web/src/components/overview/InstallationSummarySection.tsx b/web/src/components/overview/InstallationSummarySection.tsx
new file mode 100644
index 0000000000..20f807143d
--- /dev/null
+++ b/web/src/components/overview/InstallationSummarySection.tsx
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) [2025] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React from "react";
+import Page from "~/components/core/Page";
+import NestedContent from "~/components/core/NestedContent";
+import Details from "~/components/core/Details";
+import HostnameDetailsItem from "~/components/system/HostnameDetailsItem";
+import L10nDetailsItem from "~/components/l10n/L10nDetailsItem";
+import StorageDetailsItem from "../storage/StorageDetailsItem";
+import NetworkDetailsItem from "../network/NetworkDetailsItem";
+import SoftwareDetailsItem from "../software/SoftwareDetailsItem";
+import { _ } from "~/i18n";
+
+export default function InstallationSummarySection() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/components/overview/L10nSection.test.tsx b/web/src/components/overview/L10nSection.test.tsx
deleted file mode 100644
index b25460f8c7..0000000000
--- a/web/src/components/overview/L10nSection.test.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
- * Copyright (c) [2023-2025] SUSE LLC
- *
- * All Rights Reserved.
- *
- * This program is free software; you can redistribute it and/or modify it
- * under the terms of the GNU General Public License as published by the Free
- * Software Foundation; either version 2 of the License, or (at your option)
- * any later version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
- * more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, contact SUSE LLC.
- *
- * To contact SUSE LLC about this file by physical or electronic mail, you may
- * find current contact information at www.suse.com.
- */
-
-import React from "react";
-import { screen } from "@testing-library/react";
-import { plainRender } from "~/test-utils";
-import { L10nSection } from "~/components/overview";
-import { L10n } from "~/model/system";
-
-const locales: L10n.Locale[] = [
- { id: "en_US.UTF-8", language: "English", territory: "United States" },
- { id: "de_DE.UTF-8", language: "German", territory: "Germany" },
-];
-
-jest.mock("~/queries/system", () => ({
- ...jest.requireActual("~/queries/system"),
- useSystem: () => ({
- l10n: { locale: "en_US.UTF-8", locales, keymap: "us" },
- }),
-}));
-
-jest.mock("~/queries/proposal", () => ({
- ...jest.requireActual("~/queries/proposal"),
- useProposal: () => ({
- l10n: { locale: "en_US.UTF-8", keymap: "us" },
- }),
-}));
-
-it("displays the selected locale", () => {
- plainRender();
-
- expect(screen.getByText(/English \(United States\)/)).toBeInTheDocument();
-});
diff --git a/web/src/components/overview/L10nSection.tsx b/web/src/components/overview/L10nSection.tsx
deleted file mode 100644
index 8d692a7571..0000000000
--- a/web/src/components/overview/L10nSection.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- * Copyright (c) [2023-2025] SUSE LLC
- *
- * All Rights Reserved.
- *
- * This program is free software; you can redistribute it and/or modify it
- * under the terms of the GNU General Public License as published by the Free
- * Software Foundation; either version 2 of the License, or (at your option)
- * any later version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
- * more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, contact SUSE LLC.
- *
- * To contact SUSE LLC about this file by physical or electronic mail, you may
- * find current contact information at www.suse.com.
- */
-
-import React from "react";
-import { Content } from "@patternfly/react-core";
-import { useProposal } from "~/hooks/model/proposal/l10n";
-import { useSystem } from "~/hooks/model/system/l10n";
-import { _ } from "~/i18n";
-import type { Locale } from "~/model/system/l10n";
-
-export default function L10nSection() {
- const proposal = useProposal();
- const system = useSystem();
- const locale = proposal?.locale && system?.locales?.find((l: Locale) => l.id === proposal.locale);
-
- // TRANSLATORS: %s will be replaced by a language name and territory, example:
- // "English (United States)".
- const [msg1, msg2] = _("The system will use %s as its default language.").split("%s");
-
- return (
-
- {_("Localization")}
-
- {locale ? `${msg1}${locale.id} (${locale.territory})${msg2}` : _("Not selected yet")}
-
-
- );
-}
diff --git a/web/src/components/overview/OverviewPage.test.tsx b/web/src/components/overview/OverviewPage.test.tsx
index d37d118670..36c583fee6 100644
--- a/web/src/components/overview/OverviewPage.test.tsx
+++ b/web/src/components/overview/OverviewPage.test.tsx
@@ -23,17 +23,10 @@
import React from "react";
import { screen } from "@testing-library/react";
import { installerRender } from "~/test-utils";
-import { OverviewPage } from "~/components/overview";
-
-jest.mock("~/components/overview/L10nSection", () => () =>
-));
+import OverviewPage from "~/components/overview/OverviewPage";
describe("when a product is selected", () => {
- it("renders the overview page content", async () => {
+ it.skip("renders the overview page content", async () => {
installerRender();
await screen.findByText("Localization Section");
await screen.findByText("Storage Section");
diff --git a/web/src/components/overview/OverviewPage.tsx b/web/src/components/overview/OverviewPage.tsx
index 3ebae8a41f..4f734f57af 100644
--- a/web/src/components/overview/OverviewPage.tsx
+++ b/web/src/components/overview/OverviewPage.tsx
@@ -21,15 +21,14 @@
*/
import React from "react";
-import { Content, Grid, GridItem, Stack } from "@patternfly/react-core";
+import { Navigate } from "react-router";
+import { Content, Grid, GridItem } from "@patternfly/react-core";
import { Page } from "~/components/core";
-import L10nSection from "./L10nSection";
-import StorageSection from "./StorageSection";
-import SoftwareSection from "./SoftwareSection";
-import { _ } from "~/i18n";
-import { PRODUCT } from "~/routes/paths";
import { useProduct } from "~/hooks/model/config";
-import { Navigate } from "react-router";
+import { PRODUCT } from "~/routes/paths";
+import { _ } from "~/i18n";
+import SystemInformationSection from "./SystemInformationSection";
+import InstallationSummarySection from "./InstallationSummarySection";
export default function OverviewPage() {
const product = useProduct();
@@ -46,17 +45,11 @@ export default function OverviewPage() {
-
-
-
- {_(
- "These are the most relevant installation settings. Feel free to browse the sections in the menu for further details.",
- )}
-
-
-
-
-
+
+
+
+
+
diff --git a/web/src/components/overview/SoftwareSection.test.tsx b/web/src/components/overview/SoftwareSection.test.tsx
deleted file mode 100644
index e4e1aba3c5..0000000000
--- a/web/src/components/overview/SoftwareSection.test.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * Copyright (c) [2024] SUSE LLC
- *
- * All Rights Reserved.
- *
- * This program is free software; you can redistribute it and/or modify it
- * under the terms of the GNU General Public License as published by the Free
- * Software Foundation; either version 2 of the License, or (at your option)
- * any later version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
- * more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, contact SUSE LLC.
- *
- * To contact SUSE LLC about this file by physical or electronic mail, you may
- * find current contact information at www.suse.com.
- */
-
-import React from "react";
-import { screen } from "@testing-library/react";
-import { installerRender } from "~/test-utils";
-// import testingProposal from "~/components/software/proposal.test.json";
-import SoftwareSection from "~/components/overview/SoftwareSection";
-// import { SoftwareProposal } from "~/types/software";
-
-// let mockTestingProposal: SoftwareProposal;
-
-// FIXME: redo this tests once new overview is done after api v2
-describe.skip("SoftwareSection", () => {
- describe("when the proposal does not have patterns to select", () => {
- // beforeEach(() => {
- // mockTestingProposal = { patterns: {}, size: "" };
- // });
-
- it("renders nothing", () => {
- const { container } = installerRender();
- expect(container).toBeEmptyDOMElement();
- });
- });
-
- describe("when the proposal has patterns to select", () => {
- // beforeEach(() => {
- // mockTestingProposal = testingProposal;
- // });
-
- it("renders the required space and the selected patterns", () => {
- installerRender();
- screen.getByText("4.6 GiB");
- screen.getAllByText(/GNOME/);
- screen.getByText("YaST Base Utilities");
- screen.getByText("YaST Desktop Utilities");
- screen.getByText("Multimedia");
- screen.getAllByText(/Office Software/);
- expect(screen.queryByText("KDE")).toBeNull();
- expect(screen.queryByText("XFCE")).toBeNull();
- expect(screen.queryByText("YaST Server Utilities")).toBeNull();
- });
- });
-});
diff --git a/web/src/components/overview/SoftwareSection.tsx b/web/src/components/overview/SoftwareSection.tsx
deleted file mode 100644
index e4950398b5..0000000000
--- a/web/src/components/overview/SoftwareSection.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
- * Copyright (c) [2022-2025] SUSE LLC
- *
- * All Rights Reserved.
- *
- * This program is free software; you can redistribute it and/or modify it
- * under the terms of the GNU General Public License as published by the Free
- * Software Foundation; either version 2 of the License, or (at your option)
- * any later version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
- * more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, contact SUSE LLC.
- *
- * To contact SUSE LLC about this file by physical or electronic mail, you may
- * find current contact information at www.suse.com.
- */
-
-import React from "react";
-import { Content, List, ListItem } from "@patternfly/react-core";
-import { isEmpty } from "radashi";
-import { _ } from "~/i18n";
-import { useProposal } from "~/hooks/model/proposal/software";
-import { useSystem } from "~/hooks/model/system/software";
-import xbytes from "xbytes";
-
-export default function SoftwareSection(): React.ReactNode {
- const system = useSystem();
- const proposal = useProposal();
-
- if (!proposal) {
- return null;
- }
-
- const usedSpace = xbytes(proposal.usedSpace * 1024);
-
- if (isEmpty(proposal.patterns)) return;
- const selectedPatternsIds = Object.keys(proposal.patterns);
-
- const TextWithoutList = () => {
- return (
- <>
- {_("The installation will take")} {usedSpace}
- >
- );
- };
-
- const TextWithList = () => {
- // TRANSLATORS: %s will be replaced with the installation size, example: "5GiB".
- const [msg1, msg2] = _("The installation will take %s including:").split("%s");
- const selectedPatterns = system.patterns.filter((p) => selectedPatternsIds.includes(p.name));
-
- return (
- <>
-
- {msg1}
- {usedSpace}
- {msg2}
-
-
- {selectedPatterns.map((p) => (
- {p.summary}
- ))}
-
- >
- );
- };
-
- return (
-
- {_("Software")}
- {selectedPatternsIds.length ? : }
-
- );
-}
diff --git a/web/src/components/overview/StorageSection.test.tsx b/web/src/components/overview/StorageSection.test.tsx
deleted file mode 100644
index 581d1d26b3..0000000000
--- a/web/src/components/overview/StorageSection.test.tsx
+++ /dev/null
@@ -1,278 +0,0 @@
-/*
- * Copyright (c) [2022-2025] SUSE LLC
- *
- * All Rights Reserved.
- *
- * This program is free software; you can redistribute it and/or modify it
- * under the terms of the GNU General Public License as published by the Free
- * Software Foundation; either version 2 of the License, or (at your option)
- * any later version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
- * more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, contact SUSE LLC.
- *
- * To contact SUSE LLC about this file by physical or electronic mail, you may
- * find current contact information at www.suse.com.
- */
-
-import React from "react";
-import { screen } from "@testing-library/react";
-import { plainRender } from "~/test-utils";
-import { StorageSection } from "~/components/overview";
-
-let mockModel = {
- drives: [],
-};
-
-const sda = {
- sid: 59,
- name: "/dev/sda",
- description: "",
- isDrive: false,
- type: "drive",
- active: true,
- encrypted: false,
- shrinking: { unsuppored: [] },
- size: 536870912000,
- start: 0,
- systems: [],
- udevIds: [],
- udevPaths: [],
-};
-
-const sdb = {
- sid: 60,
- name: "/dev/sdb",
- description: "",
- isDrive: false,
- type: "drive",
- active: true,
- encrypted: false,
- shrinking: { unsuppored: [] },
- size: 697932185600,
- start: 0,
- systems: [],
- udevIds: [],
- udevPaths: [],
-};
-
-jest.mock("~/queries/storage/config-model", () => ({
- ...jest.requireActual("~/queries/storage/config-model"),
- useConfigModel: () => mockModel,
-}));
-
-const mockDevices = [sda, sdb];
-
-jest.mock("~/queries/storage", () => ({
- ...jest.requireActual("~/queries/storage"),
- useDevices: () => mockDevices,
-}));
-
-let mockAvailableDevices = [sda, sdb];
-
-jest.mock("~/hooks/storage/system", () => ({
- ...jest.requireActual("~/hooks/storage/system"),
- useAvailableDevices: () => mockAvailableDevices,
-}));
-
-let mockSystemErrors = [];
-
-jest.mock("~/queries/issues", () => ({
- ...jest.requireActual("~/queries/issues"),
- useSystemErrors: () => mockSystemErrors,
-}));
-
-beforeEach(() => {
- mockSystemErrors = [];
-});
-
-describe("when the configuration does not include any device", () => {
- beforeEach(() => {
- mockModel = {
- drives: [],
- };
- });
-
- it("indicates that a device is not selected", async () => {
- plainRender();
-
- await screen.findByText(/No device selected/);
- });
-});
-
-describe("when the configuration contains one drive", () => {
- beforeEach(() => {
- mockModel = {
- drives: [{ name: "/dev/sda", spacePolicy: "delete" }],
- };
- });
-
- it("renders the proposal summary", async () => {
- plainRender();
-
- await screen.findByText(/Install using device/);
- await screen.findByText(/sda \(500 GiB\)/);
- await screen.findByText(/and deleting all its content/);
- });
-
- describe("and the space policy is set to 'resize'", () => {
- beforeEach(() => {
- mockModel = {
- drives: [{ name: "/dev/sda", spacePolicy: "resize" }],
- };
- });
-
- it("indicates that partitions may be shrunk", async () => {
- plainRender();
-
- await screen.findByText(/shrinking existing partitions as needed/);
- });
- });
-
- describe("and the space policy is set to 'keep'", () => {
- beforeEach(() => {
- mockModel = {
- drives: [{ name: "/dev/sda", spacePolicy: "keep" }],
- };
- });
-
- it("indicates that partitions will be kept", async () => {
- plainRender();
-
- await screen.findByText(/without modifying existing partitions/);
- });
- });
-
- describe("and the space policy is set to 'custom'", () => {
- beforeEach(() => {
- mockModel = {
- drives: [{ name: "/dev/sda", spacePolicy: "custom" }],
- };
- });
-
- it("indicates that custom strategy for allocating space is set", async () => {
- plainRender();
-
- await screen.findByText(/custom strategy to find the needed space/);
- });
- });
-
- describe("and the drive matches no disk", () => {
- beforeEach(() => {
- mockModel = {
- drives: [{ name: undefined, spacePolicy: "delete" }],
- };
- });
-
- it("indicates that a device is not selected", async () => {
- plainRender();
-
- await screen.findByText(/No device selected/);
- });
- });
-});
-
-describe("when the configuration contains several drives", () => {
- beforeEach(() => {
- mockModel = {
- drives: [
- { name: "/dev/sda", spacePolicy: "delete" },
- { name: "/dev/sdb", spacePolicy: "delete" },
- ],
- };
- });
-
- it("renders the proposal summary", async () => {
- plainRender();
-
- await screen.findByText(/Install using several devices/);
- await screen.findByText(/and deleting all its content/);
- });
-
- describe("but one of them has a different space policy", () => {
- beforeEach(() => {
- mockModel = {
- drives: [
- { name: "/dev/sda", spacePolicy: "delete" },
- { name: "/dev/sdb", spacePolicy: "resize" },
- ],
- };
- });
-
- it("indicates that custom strategy for allocating space is set", async () => {
- plainRender();
-
- await screen.findByText(/custom strategy to find the needed space/);
- });
- });
-});
-
-describe("when there is no configuration model (unsupported features)", () => {
- beforeEach(() => {
- mockModel = null;
- });
-
- describe("if the storage proposal succeeded", () => {
- describe("and there are no available devices", () => {
- beforeEach(() => {
- mockAvailableDevices = [];
- });
-
- it("indicates that an unhandled configuration was used", async () => {
- plainRender();
- await screen.findByText(/advanced configuration/);
- });
- });
-
- describe("and there are available disks", () => {
- beforeEach(() => {
- mockAvailableDevices = [sda];
- });
-
- it("indicates that an unhandled configuration was used", async () => {
- plainRender();
- await screen.findByText(/advanced configuration/);
- });
- });
- });
-
- describe("if the storage proposal was not possible", () => {
- beforeEach(() => {
- mockSystemErrors = [
- {
- description: "System error",
- kind: "storage",
- details: "",
- scope: "storage",
- },
- ];
- });
-
- describe("and there are no available devices", () => {
- beforeEach(() => {
- mockAvailableDevices = [];
- });
-
- it("indicates that there are no available disks", async () => {
- plainRender();
- await screen.findByText(/no disks available/);
- });
- });
-
- describe("and there are available devices", () => {
- beforeEach(() => {
- mockAvailableDevices = [sda];
- });
-
- it("indicates that an unhandled configuration was used", async () => {
- plainRender();
- await screen.findByText(/advanced configuration/);
- });
- });
- });
-});
diff --git a/web/src/components/overview/StorageSection.tsx b/web/src/components/overview/StorageSection.tsx
deleted file mode 100644
index 207a127b58..0000000000
--- a/web/src/components/overview/StorageSection.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-/*
- * Copyright (c) [2022-2025] SUSE LLC
- *
- * All Rights Reserved.
- *
- * This program is free software; you can redistribute it and/or modify it
- * under the terms of the GNU General Public License as published by the Free
- * Software Foundation; either version 2 of the License, or (at your option)
- * any later version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
- * more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, contact SUSE LLC.
- *
- * To contact SUSE LLC about this file by physical or electronic mail, you may
- * find current contact information at www.suse.com.
- */
-
-import React from "react";
-import { Content } from "@patternfly/react-core";
-import { deviceLabel } from "~/components/storage/utils";
-import { useAvailableDevices, useDevices, useIssues } from "~/hooks/model/system/storage";
-import { useConfigModel } from "~/hooks/model/storage/config-model";
-import { _ } from "~/i18n";
-import type { Storage } from "~/model/system";
-import type { ConfigModel } from "~/model/storage/config-model";
-
-const findDriveDevice = (drive: ConfigModel.Drive, devices: Storage.Device[]) =>
- devices.find((d) => d.name === drive.name);
-
-const NoDeviceSummary = () => _("No device selected yet");
-
-const SingleDiskSummary = ({ drive }: { drive: ConfigModel.Drive }) => {
- const devices = useDevices();
- const device = findDriveDevice(drive, devices);
- const options = {
- // TRANSLATORS: %s will be replaced by the device name and its size,
- // example: "/dev/sda, 20 GiB"
- resize: _("Install using device %s shrinking existing partitions as needed."),
- // TRANSLATORS: %s will be replaced by the device name and its size,
- // example: "/dev/sda, 20 GiB"
- keep: _("Install using device %s without modifying existing partitions."),
- // TRANSLATORS: %s will be replaced by the device name and its size,
- // example: "/dev/sda, 20 GiB"
- delete: _("Install using device %s and deleting all its content."),
- // TRANSLATORS: %s will be replaced by the device name and its size,
- // example: "/dev/sda, 20 GiB"
- custom: _("Install using device %s with a custom strategy to find the needed space."),
- };
-
- const [textStart, textEnd] = options[drive.spacePolicy].split("%s");
-
- return (
- <>
- {textStart}
- {device ? deviceLabel(device) : drive.name}
- {textEnd}
- >
- );
-};
-
-const MultipleDisksSummary = ({ drives }: { drives: ConfigModel.Drive[] }): string => {
- const options = {
- resize: _("Install using several devices shrinking existing partitions as needed."),
- keep: _("Install using several devices without modifying existing partitions."),
- delete: _("Install using several devices and deleting all its content."),
- custom: _("Install using several devices with a custom strategy to find the needed space."),
- };
-
- if (drives.find((d) => d.spacePolicy !== drives[0].spacePolicy)) {
- return options.custom;
- }
-
- return options[drives[0].spacePolicy];
-};
-
-const ModelSummary = ({ model }: { model: ConfigModel.Config }): React.ReactNode => {
- const devices = useDevices();
- const drives = model?.drives || [];
- const existDevice = (name: string) => devices.some((d) => d.name === name);
- const noDrive = drives.length === 0 || drives.some((d) => !existDevice(d.name));
-
- if (noDrive) return ;
- if (drives.length > 1) return ;
- return ;
-};
-
-const NoModelSummary = (): React.ReactNode => {
- const availableDevices = useAvailableDevices();
- const systemErrors = useIssues();
- const hasDisks = !!availableDevices.length;
- const hasResult = !systemErrors.length;
-
- if (!hasResult && !hasDisks) return _("There are no disks available for the installation.");
- return _("Install using an advanced configuration.");
-};
-
-/**
- * Text explaining the storage proposal
- */
-export default function StorageSection() {
- const config = useConfigModel();
-
- return (
-
- {_("Storage")}
-
- {config && }
- {!config && }
-
-
- );
-}
diff --git a/web/src/components/overview/SystemInformationSection.tsx b/web/src/components/overview/SystemInformationSection.tsx
new file mode 100644
index 0000000000..6a1008284e
--- /dev/null
+++ b/web/src/components/overview/SystemInformationSection.tsx
@@ -0,0 +1,29 @@
+import React from "react";
+import xbytes from "xbytes";
+import Page from "~/components/core/Page";
+import NestedContent from "~/components/core/NestedContent";
+import Details from "~/components/core/Details";
+import FormattedIPsList from "~/components/network/FormattedIpsList";
+import { useSystem } from "~/hooks/model/system";
+import { _ } from "~/i18n";
+
+export default function SystemInformationSection() {
+ const { hardware } = useSystem();
+
+ return (
+
+
+
+ {hardware.model}
+ {hardware.cpu}
+
+ {hardware.memory ? xbytes(hardware.memory, { iec: true }) : undefined}
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/components/overview/index.ts b/web/src/components/overview/index.ts
deleted file mode 100644
index 6db707d5a2..0000000000
--- a/web/src/components/overview/index.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-/*
- * Copyright (c) [2022-2023] SUSE LLC
- *
- * All Rights Reserved.
- *
- * This program is free software; you can redistribute it and/or modify it
- * under the terms of the GNU General Public License as published by the Free
- * Software Foundation; either version 2 of the License, or (at your option)
- * any later version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
- * more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, contact SUSE LLC.
- *
- * To contact SUSE LLC about this file by physical or electronic mail, you may
- * find current contact information at www.suse.com.
- */
-
-export { default as OverviewPage } from "./OverviewPage";
-export { default as L10nSection } from "./L10nSection";
-export { default as StorageSection } from "./StorageSection";
-export { default as SoftwareSection } from "./SoftwareSection";
diff --git a/web/src/components/software/SoftwareDetailsItem.test.tsx b/web/src/components/software/SoftwareDetailsItem.test.tsx
new file mode 100644
index 0000000000..ced60d9cb5
--- /dev/null
+++ b/web/src/components/software/SoftwareDetailsItem.test.tsx
@@ -0,0 +1,162 @@
+/*
+ * Copyright (c) [2026] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React from "react";
+import { screen } from "@testing-library/react";
+import { installerRender } from "~/test-utils";
+import { useSelectedPatterns } from "~/hooks/model/system/software";
+import { useProgressTracking } from "~/hooks/use-progress-tracking";
+import { useProposal } from "~/hooks/model/proposal/software";
+import { SelectedBy } from "~/model/proposal/software";
+import SoftwareDetailsItem from "./SoftwareDetailsItem";
+
+let mockUseProgressTrackingFn: jest.Mock> = jest.fn();
+let mockUseProposalFn: jest.Mock> = jest.fn();
+let mockUseSelectedPatternsFn: jest.Mock> = jest.fn();
+
+jest.mock("~/hooks/model/system/software", () => ({
+ ...jest.requireActual("~/hooks/model/system/software"),
+ useSelectedPatterns: () => mockUseSelectedPatternsFn(),
+}));
+
+jest.mock("~/hooks/model/proposal/software", () => ({
+ ...jest.requireActual("~/hooks/model/proposal/software"),
+ useProposal: () => mockUseProposalFn(),
+}));
+
+jest.mock("~/hooks/use-progress-tracking", () => ({
+ ...jest.requireActual("~/hooks/use-progress-tracking"),
+ useProgressTracking: () => mockUseProgressTrackingFn(),
+}));
+
+describe("SoftwareDetailsItem", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("when software data is still loading", () => {
+ beforeEach(() => {
+ mockUseProgressTrackingFn.mockReturnValue({
+ loading: true,
+ progress: {
+ index: 1,
+ scope: "software",
+ size: 3,
+ steps: ["one", "two", "three"],
+ step: "two",
+ },
+ });
+ });
+
+ it("renders skeletons instead of content", () => {
+ mockUseProposalFn.mockReturnValue({ usedSpace: 0, patterns: {} });
+ mockUseSelectedPatternsFn.mockReturnValue([]);
+
+ installerRender();
+
+ screen.queryByText(/Software/);
+ screen.queryByText("Waiting for proposal"); // Skeleton aria-label
+ expect(screen.queryByText(/Needs about/)).not.toBeInTheDocument();
+ });
+ });
+
+ describe("when software data is loaded (no progress active)", () => {
+ beforeEach(() => {
+ mockUseProgressTrackingFn.mockReturnValue({
+ loading: false,
+ progress: undefined,
+ });
+ });
+
+ it("renders 'Required packages' without patterns count when no none is selected", () => {
+ mockUseProposalFn.mockReturnValue({ usedSpace: 1955420, patterns: {} });
+ mockUseSelectedPatternsFn.mockReturnValue([]);
+
+ installerRender();
+
+ screen.getByText("Required packages");
+ screen.getByText(/Needs about 1\.86 GiB/);
+ });
+
+ it("renders 'Required packages' and the patterns count with correct pluralization when some is selected", () => {
+ mockUseProposalFn.mockReturnValue({
+ usedSpace: 6239191,
+ patterns: {
+ yast2_server: SelectedBy.NONE,
+ basic_desktop: SelectedBy.NONE,
+ xfce: SelectedBy.NONE,
+ gnome: SelectedBy.USER,
+ yast2_desktop: SelectedBy.NONE,
+ kde: SelectedBy.NONE,
+ multimedia: SelectedBy.NONE,
+ office: SelectedBy.NONE,
+ yast2_basis: SelectedBy.AUTO,
+ selinux: SelectedBy.NONE,
+ apparmor: SelectedBy.NONE,
+ },
+ });
+
+ mockUseSelectedPatternsFn.mockReturnValue([
+ {
+ name: "gnome",
+ category: "Graphical Environments",
+ icon: "./pattern-gnome-wayland",
+ description: "The GNOME desktop environment ...",
+ summary: "GNOME Desktop Environment (Wayland)",
+ order: 1010,
+ preselected: false,
+ },
+ ]);
+
+ const { rerender } = installerRender();
+
+ // Singular
+ screen.getByText("Required packages and 1 pattern");
+ screen.getByText(/Needs about 5\.95 GiB/);
+
+ mockUseSelectedPatternsFn.mockReturnValue([
+ {
+ name: "gnome",
+ category: "Graphical Environments",
+ icon: "./pattern-gnome-wayland",
+ description: "The GNOME desktop environment ...",
+ summary: "GNOME Desktop Environment (Wayland)",
+ order: 1010,
+ preselected: false,
+ },
+ {
+ name: "yast2_basis",
+ category: "Base Technologies",
+ icon: "./yast",
+ description: "YaST tools for basic system administration.",
+ summary: "YaST Base Utilities",
+ order: 1220,
+ preselected: false,
+ },
+ ]);
+ rerender();
+
+ // Plural
+ screen.getByText("Required packages and 2 patterns");
+ screen.getByText(/Needs about 5\.95 GiB/);
+ });
+ });
+});
diff --git a/web/src/components/software/SoftwareDetailsItem.tsx b/web/src/components/software/SoftwareDetailsItem.tsx
new file mode 100644
index 0000000000..d38e39f208
--- /dev/null
+++ b/web/src/components/software/SoftwareDetailsItem.tsx
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) [2025] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React from "react";
+import xbytes from "xbytes";
+import { sprintf } from "sprintf-js";
+
+import { useProposal } from "~/hooks/model/proposal/software";
+import { useProgressTracking } from "~/hooks/use-progress-tracking";
+import { useSelectedPatterns } from "~/hooks/model/system/software";
+import { SOFTWARE } from "~/routes/paths";
+import { _, n_ } from "~/i18n";
+import Details from "~/components/core/Details";
+import Link from "~/components/core/Link";
+
+/**
+ * Renders a summary text describing the software selection.
+ */
+const Summary = () => {
+ const patterns = useSelectedPatterns();
+ const patternsQty = patterns.length;
+
+ if (patternsQty === 0) {
+ return _("Required packages");
+ }
+
+ return sprintf(
+ // TRANSLATORS: %s will be replaced with amount of selected patterns.
+ n_("Required packages and %s pattern", "Required packages and %s patterns", patternsQty),
+ patternsQty,
+ );
+};
+
+/**
+ * Renders the estimated disk space required for the installation.
+ */
+const Description = () => {
+ const proposal = useProposal();
+
+ if (!proposal.usedSpace) return;
+
+ return sprintf(
+ // TRANSLATORS: %s will be replaced with a human-readable installation size
+ // (e.g. 5.95 GiB).
+ _("Needs about %s"),
+ xbytes(proposal.usedSpace * 1024, { iec: true }),
+ );
+};
+
+/**
+ * A software installation summary.
+ */
+export default function SoftwareDetailsItem({ withoutLink = false }: { withoutLink?: boolean }) {
+ const { loading } = useProgressTracking("software");
+ return (
+
+ ) : (
+
+
+
+ )
+ }
+ description={}
+ isLoading={loading}
+ />
+ );
+}
diff --git a/web/src/components/storage/PotentialDataLossAlert.tsx b/web/src/components/storage/PotentialDataLossAlert.tsx
new file mode 100644
index 0000000000..134dc9fd0f
--- /dev/null
+++ b/web/src/components/storage/PotentialDataLossAlert.tsx
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) [2026] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React from "react";
+import { Alert, ExpandableSection, List, ListItem } from "@patternfly/react-core";
+import { _, formatList } from "~/i18n";
+import { sprintf } from "sprintf-js";
+import { useDestructiveActions } from "~/hooks/use-destructive-actions";
+
+// FIXME: this component has a bunch of logic/calls copied from
+// storage/ProposalResultSection that should be moved to a reusable hook.
+export default function PotentialDataLossAlert() {
+ let title: string;
+ const { actions, affectedSystems } = useDestructiveActions();
+
+ if (actions.length === 0) return;
+
+ if (affectedSystems.length) {
+ title = sprintf(
+ // TRANSLATORS: %s will be replaced by a formatted list of affected
+ // systems like "Windows and openSUSE Tumbleweed".
+ _("Proceeding may result in data loss affecting at least %s"),
+ formatList(affectedSystems),
+ );
+ } else {
+ title = _("Proceeding may result in data loss");
+ }
+
+ return (
+
+
+
+ {actions.map((a, i) => (
+ {a.text}
+ ))}
+
+
+
+ );
+}
diff --git a/web/src/components/storage/StorageDetailsItem.tsx b/web/src/components/storage/StorageDetailsItem.tsx
new file mode 100644
index 0000000000..8e1d2ffa8c
--- /dev/null
+++ b/web/src/components/storage/StorageDetailsItem.tsx
@@ -0,0 +1,156 @@
+/*
+ * Copyright (c) [2025] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React from "react";
+import { sprintf } from "sprintf-js";
+
+import Details from "~/components/core/Details";
+import Link from "~/components/core/Link";
+
+import { useConfigModel } from "~/hooks/model/storage/config-model";
+import {
+ useFlattenDevices as useSystemFlattenDevices,
+ useAvailableDevices,
+ useDevices,
+} from "~/hooks/model/system/storage";
+import {
+ useFlattenDevices as useProposalFlattenDevices,
+ useActions,
+} from "~/hooks/model/proposal/storage";
+import DevicesManager from "~/model/storage/devices-manager";
+import { useIssues } from "~/hooks/model/issue";
+import { deviceLabel } from "~/components/storage/utils";
+import { STORAGE } from "~/routes/paths";
+import { _, formatList } from "~/i18n";
+
+import type { Storage } from "~/model/system";
+import type { ConfigModel } from "~/model/storage/config-model";
+import { useProgressTracking } from "~/hooks/use-progress-tracking";
+
+const findDriveDevice = (drive: ConfigModel.Drive, devices: Storage.Device[]) =>
+ devices.find((d) => d.name === drive.name);
+
+const NoDeviceSummary = () => _("No device selected yet");
+
+const SingleDeviceSummary = ({ target }: { target: ConfigModel.Drive | ConfigModel.MdRaid }) => {
+ const devices = useDevices();
+ const device = findDriveDevice(target, devices);
+ // TRANSLATORS: %s will be replaced by the device name and its size,
+ // example: "/dev/sda, 20 GiB"
+ const text = _("Use device %s");
+ const [textStart, textEnd] = text.split("%s");
+
+ return (
+
+ );
+};
+
+const ModelSummary = ({ model }: { model: ConfigModel.Config }): React.ReactNode => {
+ const devices = useDevices();
+ const drives = model?.drives || [];
+ // We are only interested in RAIDs and VGs that are being reused. With the current model,
+ // that means all RAIDs and no VGs. Revisit when (a) the model allows to create new RAIDs or
+ // (b) the model allows to reuse existing VGs.
+ const raids = model?.mdRaids || [];
+ const targets = drives.concat(raids);
+ const existDevice = (name: string) => devices.some((d) => d.name === name);
+ const noTarget = targets.length === 0 || targets.some((d) => !existDevice(d.name));
+
+ if (noTarget) return ;
+ if (targets.length > 1) return _("Use several devices");
+ return ;
+};
+
+const LinkContent = () => {
+ const availableDevices = useAvailableDevices();
+ const model = useConfigModel();
+ const issues = useIssues("storage");
+ const configIssues = issues.filter((i) => i.class !== "proposal");
+
+ if (!availableDevices.length) return _("There are no disks available for the installation");
+ if (configIssues.length) return _("Invalid settings");
+ if (!model) return _("Using an advanced storage configuration");
+
+ return ;
+};
+
+const DescriptionContent = () => {
+ const system = useSystemFlattenDevices();
+ const staging = useProposalFlattenDevices();
+ const actions = useActions();
+ const issues = useIssues("storage");
+ const configIssues = issues.filter((i) => i.class !== "proposal");
+ const manager = new DevicesManager(system, staging, actions);
+
+ if (configIssues.length) return;
+ if (!actions.length) return _("Failed to calculate a storage layout");
+
+ const deleteActions = manager.actions.filter((a) => a.delete && !a.subvol).length;
+ if (!deleteActions) return _("No data loss is expected");
+
+ const systems = manager.deletedSystems();
+ if (systems.length) {
+ return sprintf(
+ // TRANSLATORS: %s will be replaced by a formatted list of affected systems
+ // like "Windows and openSUSE Tumbleweed".
+ _("Potential data loss affecting at least %s"),
+ formatList(systems),
+ );
+ }
+
+ return _("Potential data loss");
+};
+
+/**
+ * In the near future, this component may receive one or more props (to be
+ * defined) to display additional or alternative information. This will be
+ * especially useful for reusing the component in the interface where users are
+ * asked to confirm that they want to proceed with the installation.
+ *
+ * DISCLAIMER: Naming still has significant room for improvement, starting with
+ * the component name itself. These changes should be addressed in a final step,
+ * once all "overview/confirmation" items are clearly defined.
+ */
+export default function StorageDetailsItem({ withoutLink = false }: { withoutLink?: boolean }) {
+ const { loading } = useProgressTracking("storage");
+
+ return (
+
+ ) : (
+
+
+
+ )
+ }
+ description={}
+ isLoading={loading}
+ />
+ );
+}
diff --git a/web/src/components/system/HostnameDetailsItem.tsx b/web/src/components/system/HostnameDetailsItem.tsx
new file mode 100644
index 0000000000..a5e7acdbeb
--- /dev/null
+++ b/web/src/components/system/HostnameDetailsItem.tsx
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) [2025] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import React from "react";
+import { isEmpty } from "radashi";
+import Details from "~/components/core/Details";
+import Link from "~/components/core/Link";
+import { useProposal } from "~/hooks/model/proposal/hostname";
+import { HOSTNAME } from "~/routes/paths";
+import { _ } from "~/i18n";
+
+/**
+ * Hostname settings summary
+ *
+ * If a transient hostname is in use, it shows a brief explanation to inform
+ * users that the hostname may change after reboot or network changes.
+ */
+export default function HostnameDetailsItem({ withoutLink = false }: { withoutLink?: boolean }) {
+ const { hostname: transientHostname, static: staticHostname } = useProposal();
+
+ return (
+
+ {staticHostname || transientHostname}
+
+ )
+ }
+ description={
+ // TRANSLATORS: a note to briefly explain the possible side-effects
+ // of using a transient hostname
+ isEmpty(staticHostname) &&
+ _("Temporary name that may change after reboot or network changes")
+ }
+ />
+ );
+}
diff --git a/web/src/hooks/model/system/network.test.ts b/web/src/hooks/model/system/network.test.ts
new file mode 100644
index 0000000000..8a9ea33260
--- /dev/null
+++ b/web/src/hooks/model/system/network.test.ts
@@ -0,0 +1,309 @@
+/*
+ * Copyright (c) [2026] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import { NetworkStatus, getIpAddresses, getNetworkStatus } from "~/hooks/model/system/network";
+import {
+ Connection,
+ ConnectionMethod,
+ ConnectionState,
+ ConnectionType,
+ Device,
+ DeviceState,
+} from "~/model/network/types";
+
+const createConnection = (
+ id: string,
+ overrides: Partial[1]> = {},
+): Connection => {
+ return new Connection(id, {
+ method4: ConnectionMethod.AUTO,
+ method6: ConnectionMethod.AUTO,
+ state: ConnectionState.activated,
+ persistent: true,
+ ...overrides,
+ });
+};
+
+const createDevice = (overrides: Partial = {}): Device => ({
+ name: "eth0",
+ type: ConnectionType.ETHERNET,
+ state: DeviceState.CONNECTED,
+ addresses: [{ address: "192.168.1.100", prefix: 24 }],
+ nameservers: [],
+ gateway4: "192.168.1.1",
+ gateway6: "",
+ method4: ConnectionMethod.AUTO,
+ method6: ConnectionMethod.AUTO,
+ macAddress: "AA:11:22:33:44:55",
+ ...overrides,
+});
+
+describe("getNetworkStatus", () => {
+ it("returns NOT_CONFIGURED status when no connections are given", () => {
+ const result = getNetworkStatus([]);
+
+ expect(result.status).toBe(NetworkStatus.NOT_CONFIGURED);
+ expect(result.connections).toEqual([]);
+ expect(result.persistentConnections).toEqual([]);
+ });
+
+ it("returns all given connections", () => {
+ const persistentConnection = createConnection("Network 1");
+ const nonPersistentConnection = createConnection("Network 2", {
+ method4: ConnectionMethod.MANUAL,
+ method6: ConnectionMethod.MANUAL,
+ state: ConnectionState.activating,
+ persistent: false,
+ });
+
+ const result = getNetworkStatus([persistentConnection, nonPersistentConnection]);
+
+ expect(result.connections).toEqual([persistentConnection, nonPersistentConnection]);
+ });
+
+ it("returns given persistent connections", () => {
+ const persistentConnection = createConnection("Network 1");
+ const nonPersistentConnection = createConnection("Network 2", {
+ method4: ConnectionMethod.MANUAL,
+ method6: ConnectionMethod.MANUAL,
+ state: ConnectionState.activating,
+ persistent: false,
+ });
+
+ const result = getNetworkStatus([persistentConnection, nonPersistentConnection]);
+
+ expect(result.persistentConnections).toEqual([persistentConnection]);
+ });
+
+ it("returns NO_PERSISTENT status when there are no persistent connections and includeNonPersistent is false", () => {
+ const nonPersistentConnection = createConnection("Network 1", {
+ method4: ConnectionMethod.MANUAL,
+ method6: ConnectionMethod.MANUAL,
+ state: ConnectionState.activating,
+ persistent: false,
+ });
+
+ const result = getNetworkStatus([nonPersistentConnection], { includeNonPersistent: false });
+
+ expect(result.status).toBe(NetworkStatus.NO_PERSISTENT);
+ });
+
+ it("checks against non-persistent connections too when includeNonPersistent is true", () => {
+ const persistentConnection = createConnection("Network 1");
+ const nonPersistentConnection = createConnection("Network 2", {
+ method4: ConnectionMethod.MANUAL,
+ method6: ConnectionMethod.MANUAL,
+ state: ConnectionState.activating,
+ persistent: false,
+ });
+
+ const result = getNetworkStatus([persistentConnection, nonPersistentConnection], {
+ includeNonPersistent: true,
+ });
+
+ expect(result.status).toBe(NetworkStatus.MIXED);
+ });
+
+ it("returns AUTO status when there are only connections with auto method and without static IP addresses", () => {
+ const autoConnection = createConnection("Network 1", {
+ state: ConnectionState.activating,
+ addresses: [],
+ });
+
+ const result = getNetworkStatus([autoConnection]);
+
+ expect(result.status).toBe(NetworkStatus.AUTO);
+ });
+
+ it("returns MANUAL status when there are only manual connections", () => {
+ const manualConnection = createConnection("Network 1", {
+ method4: ConnectionMethod.MANUAL,
+ method6: ConnectionMethod.MANUAL,
+ state: ConnectionState.activating,
+ });
+
+ const result = getNetworkStatus([manualConnection]);
+
+ expect(result.status).toBe(NetworkStatus.MANUAL);
+ });
+
+ it("returns MANUAL status when connection has manual method and static IP addresses", () => {
+ const manualWithStaticIp = createConnection("Network 1", {
+ method4: ConnectionMethod.MANUAL,
+ method6: ConnectionMethod.MANUAL,
+ state: ConnectionState.activating,
+ addresses: [{ address: "192.168.1.10", prefix: 24 }],
+ });
+
+ const result = getNetworkStatus([manualWithStaticIp]);
+
+ expect(result.status).toBe(NetworkStatus.MANUAL);
+ });
+
+ it("returns MIXED status when there are both manual and auto connections", () => {
+ const mixedConnection = createConnection("Network 1", {
+ method4: ConnectionMethod.AUTO,
+ method6: ConnectionMethod.MANUAL,
+ state: ConnectionState.activating,
+ });
+
+ const result = getNetworkStatus([mixedConnection]);
+
+ expect(result.status).toBe(NetworkStatus.MIXED);
+ });
+
+ it("returns MIXED status when there is an auto connection with static IP address", () => {
+ const autoWithStaticIp = createConnection("Network 1", {
+ state: ConnectionState.activating,
+ addresses: [{ address: "192.168.1.10", prefix: 24 }],
+ });
+
+ const result = getNetworkStatus([autoWithStaticIp]);
+
+ expect(result.status).toBe(NetworkStatus.MIXED);
+ });
+});
+
+describe("getIpAddresses", () => {
+ it("returns empty array when no devices are given", () => {
+ const connection = createConnection("conn1");
+ const result = getIpAddresses([], [connection]);
+
+ expect(result).toEqual([]);
+ });
+
+ it("returns empty array when no connections are given", () => {
+ const device = createDevice({
+ addresses: [
+ { address: "192.168.1.100", prefix: 24 },
+ { address: "192.168.1.101", prefix: 24 },
+ ],
+ });
+
+ const result = getIpAddresses([device], []);
+
+ expect(result).toEqual([]);
+ });
+
+ it("returns IP addresses from devices linked to existing connections", () => {
+ const connection = createConnection("conn1");
+ const device = createDevice({
+ connection: connection.id,
+ addresses: [
+ { address: "192.168.1.100", prefix: 24 },
+ { address: "fe80::1", prefix: 64 },
+ ],
+ nameservers: ["8.8.8.8"],
+ });
+
+ const result = getIpAddresses([device], [connection]);
+
+ expect(result).toEqual([
+ { address: "192.168.1.100", prefix: 24 },
+ { address: "fe80::1", prefix: 64 },
+ ]);
+ });
+
+ it("filters out devices not linked to any connection", () => {
+ const connection = createConnection("conn1");
+ const linkedDevice = createDevice({ connection: connection.id });
+ const unlinkedDevice = createDevice({
+ name: "wlan0",
+ type: ConnectionType.WIFI,
+ state: DeviceState.DISCONNECTED,
+ addresses: [{ address: "192.168.1.200", prefix: 24 }],
+ gateway4: "",
+ macAddress: "BB:11:22:33:44:55",
+ });
+
+ const result = getIpAddresses([linkedDevice, unlinkedDevice], [connection]);
+
+ expect(result).toEqual([{ address: "192.168.1.100", prefix: 24 }]);
+ });
+
+ it("flattens IP addresses from multiple devices", () => {
+ const connection1 = createConnection("conn1");
+ const connection2 = createConnection("conn2");
+
+ const device1 = createDevice({
+ connection: connection1.id,
+ addresses: [
+ { address: "192.168.1.100", prefix: 24 },
+ { address: "192.168.1.101", prefix: 24 },
+ ],
+ });
+
+ const device2 = createDevice({
+ name: "wlan0",
+ connection: connection2.id,
+ type: ConnectionType.WIFI,
+ addresses: [{ address: "10.0.0.50", prefix: 8 }],
+ gateway4: "10.0.0.1",
+ macAddress: "BB:11:22:33:44:55",
+ });
+
+ const result = getIpAddresses([device1, device2], [connection1, connection2]);
+
+ expect(result).toEqual([
+ { address: "192.168.1.100", prefix: 24 },
+ { address: "192.168.1.101", prefix: 24 },
+ { address: "10.0.0.50", prefix: 8 },
+ ]);
+ });
+
+ it("returns formatted IP addresses when formatted option is true", () => {
+ const connection = createConnection("conn1");
+ const device = createDevice({
+ connection: connection.id,
+ addresses: [
+ { address: "192.168.1.100", prefix: 24 },
+ { address: "fe80::1", prefix: 64 },
+ ],
+ });
+
+ const result = getIpAddresses([device], [connection], { formatted: true });
+
+ expect(result).toEqual(["192.168.1.100", "fe80::1"]);
+ });
+
+ it("returns raw IPAddress objects when formatted option is false", () => {
+ const connection = createConnection("conn1");
+ const device = createDevice({ connection: connection.id });
+
+ const result = getIpAddresses([device], [connection], { formatted: false });
+
+ expect(result).toEqual([{ address: "192.168.1.100", prefix: 24 }]);
+ });
+
+ it("returns empty array when devices have no addresses", () => {
+ const connection = createConnection("conn1");
+ const device = createDevice({
+ connection: connection.id,
+ addresses: [],
+ gateway4: "",
+ });
+
+ const result = getIpAddresses([device], [connection]);
+
+ expect(result).toEqual([]);
+ });
+});
diff --git a/web/src/hooks/model/system/network.ts b/web/src/hooks/model/system/network.ts
index 59f1ab6d87..d12bfe31a8 100644
--- a/web/src/hooks/model/system/network.ts
+++ b/web/src/hooks/model/system/network.ts
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2025] SUSE LLC
+ * Copyright (c) [2025-2026] SUSE LLC
*
* All Rights Reserved.
*
@@ -33,9 +33,13 @@ import {
DeviceState,
WifiNetwork,
ConnectionState,
+ IPAddress,
+ ConnectionMethod,
} from "~/types/network";
import { useInstallerClient } from "~/context/installer";
import React, { useCallback } from "react";
+import { formatIp } from "~/utils/network";
+import { isEmpty } from "radashi";
const selectSystem = (data: System | null): NetworkSystem =>
data ? NetworkSystem.fromApi(data.network) : null;
@@ -115,6 +119,181 @@ const useWifiNetworks = () => {
});
};
+/**
+ * Options to influence returned value by useIpAddresses
+ */
+type UseIpAddressesOptions = {
+ /**
+ * Whether format IPs as string or not. When true, returns IP addresses as
+ * formatted strings without prefix. If false or omitted, returns raw
+ * IPAddress objects.
+ */
+ formatted?: boolean;
+};
+
+/**
+ * @internal
+ *
+ * Retrieves all IP addresses from devices associated with active connections.
+ *
+ * It filters devices to only include those linked to existing connections, then
+ * extracts and flattens all IP addresses from those devices.
+ *
+ * @note
+ *
+ * This is actually the implementation of {@link useIpAddresses}
+ *
+ * Extracted and exported for testing purposes only. See reasoning at note in
+ * {@link useNetworkStatus}
+ *
+ */
+function getIpAddresses(
+ devices: Device[] = [],
+ connections: Connection[] = [],
+ options: UseIpAddressesOptions = {},
+) {
+ const connectionsIds = connections.map((c) => c.id);
+ const filteredDevices = devices.filter((d) => connectionsIds.includes(d.connection));
+
+ if (options.formatted) {
+ return filteredDevices.flatMap((d) =>
+ d.addresses.map((a) => formatIp(a, { removePrefix: true })),
+ );
+ }
+
+ return filteredDevices.flatMap((d) => d.addresses);
+}
+
+/**
+ * Retrieves all IP addresses from devices associated with active connections.
+ *
+ * @see {@link getIpAddresses} for implementation details
+ */
+function useIpAddresses(options: { formatted: true }): string[];
+function useIpAddresses(options?: { formatted?: false }): IPAddress[];
+function useIpAddresses(options: UseIpAddressesOptions = {}): string[] | IPAddress[] {
+ const devices = useDevices();
+ const connections = useConnections();
+
+ return getIpAddresses(devices, connections, options);
+}
+
+/**
+ * Network status constants
+ */
+export const NetworkStatus = {
+ MANUAL: "manual",
+ AUTO: "auto",
+ MIXED: "mixed",
+ NOT_CONFIGURED: "not_configured",
+ NO_PERSISTENT: "no_persistent",
+};
+
+type NetworkStatusType = (typeof NetworkStatus)[keyof typeof NetworkStatus];
+
+/**
+ * Options for useNetworkStatus hook
+ */
+export type NetworkStatusOptions = {
+ /** If true, uses also non-persistent connections to determine the network
+ * configuration status */
+ includeNonPersistent?: boolean;
+};
+
+/**
+ * @internal
+ *
+ * Determines the global network configuration status.
+ *
+ * @note
+ *
+ * This is actually the implementation of {@link useNetworkStatus}
+ *
+ * @note
+ *
+ * Exported for testing purposes only. Since useNetworkStatus and useConnections
+ * live in the same module, mocking useConnections doesn't work because internal
+ * calls within a module bypass mocks.
+ *
+ * Rather than split hooks into multiple files or mock React Query internals
+ * (both worse trade-offs), the main logic was extracted as a pure function for
+ * direct testing. Given the complexity and importance of this network status
+ * logic, leaving it untested was not an acceptable option.
+ *
+ * If a better approach is found, this can be moved back into
+ * the hook.
+ */
+function getNetworkStatus(
+ connections: Connection[],
+ { includeNonPersistent = false }: NetworkStatusOptions = {},
+) {
+ const persistentConnections = connections.filter((c) => c.persistent);
+
+ // Filter connections based on includeNonPersistent option
+ const connectionsToCheck = includeNonPersistent ? connections : persistentConnections;
+
+ let status: NetworkStatusType;
+
+ if (isEmpty(connections)) {
+ status = NetworkStatus.NOT_CONFIGURED;
+ } else if (!includeNonPersistent && isEmpty(connectionsToCheck)) {
+ status = NetworkStatus.NO_PERSISTENT;
+ } else {
+ const someManual = connectionsToCheck.some(
+ (c) =>
+ c.method4 === ConnectionMethod.MANUAL ||
+ c.method6 === ConnectionMethod.MANUAL ||
+ !isEmpty(c.addresses),
+ );
+
+ const someAuto = connectionsToCheck.some(
+ (c) => c.method4 === ConnectionMethod.AUTO || c.method6 === ConnectionMethod.AUTO,
+ );
+
+ if (someManual && someAuto) {
+ status = NetworkStatus.MIXED;
+ } else if (someAuto) {
+ status = NetworkStatus.AUTO;
+ } else {
+ status = NetworkStatus.MANUAL;
+ }
+ }
+
+ return {
+ status,
+ connections,
+ persistentConnections,
+ };
+}
+
+/**
+ * Determines the global network configuration status.
+ *
+ * Returns the network status, the full collection of connections (both
+ * persistent and non-persistent), and a filtered list of only persistent
+ * connections.
+ *
+ * The `status` reflects the network configuration, depending on the state of
+ * connections (whether there are connections, and whether some are persistent)
+ * and the so called network mode (manual, auto, mixed)
+ * - If there are no connections, the status will be `NetworkStatus.NOT_CONFIGURED`.
+ * - If there are no persistent connections and `includeNonPersistent` is false
+ * (the default), the status will be `NetworkStatus.NO_PERSISTENT`.
+ * - When `includeNonPersistent` is true, non-persistent connections are
+ * included in the mode calculation, and the **NO_PERSISTENT** status is ignored.
+ * - When at least one connection has defined at least one static IP, the
+ * status will be either, manual or mixed depending in the connection.method4
+ * and connection.method6 value
+ *
+ *
+ * @see {@link getNetworkStatus} for implementation details and why the logic
+ * was extracted.
+ */
+const useNetworkStatus = ({ includeNonPersistent = false }: NetworkStatusOptions = {}) => {
+ const connections = useConnections();
+ return getNetworkStatus(connections, { includeNonPersistent });
+};
+
/**
* FIXME: ADAPT to the new config HTTP API
* Hook that returns a useEffect to listen for NetworkChanged events
@@ -192,4 +371,15 @@ const useNetworkChanges = () => {
}, [client, queryClient, updateDevices, updateConnectionState]);
};
-export { useConnections, useDevices, useNetworkChanges, useSystem, useWifiNetworks, useState };
+export {
+ useConnections,
+ useDevices,
+ useNetworkChanges,
+ useNetworkStatus,
+ useSystem,
+ useIpAddresses,
+ useWifiNetworks,
+ useState,
+ getNetworkStatus,
+ getIpAddresses,
+};
diff --git a/web/src/hooks/model/system/software.ts b/web/src/hooks/model/system/software.ts
index c043396689..f619d1ef42 100644
--- a/web/src/hooks/model/system/software.ts
+++ b/web/src/hooks/model/system/software.ts
@@ -20,8 +20,11 @@
* find current contact information at www.suse.com.
*/
+import { shake } from "radashi";
import { useSuspenseQuery } from "@tanstack/react-query";
import { systemQuery } from "~/hooks/model/system";
+import { useProposal } from "~/hooks/model/proposal/software";
+import { SelectedBy } from "~/model/proposal/software";
import type { System, Software } from "~/model/system";
const selectSystem = (data: System | null): Software.System => data?.software;
@@ -34,4 +37,18 @@ function useSystem(): Software.System | null {
return data;
}
-export { useSystem };
+/**
+ * Retrieves the list of patterns currently selected in the active proposal.
+ */
+function useSelectedPatterns() {
+ const proposal = useProposal();
+ const { patterns } = useSystem();
+
+ const selectedPatternsKeys = Object.keys(
+ shake(proposal.patterns, (value) => value === SelectedBy.NONE),
+ );
+
+ return patterns.filter((p) => selectedPatternsKeys.includes(p.name));
+}
+
+export { useSystem, useSelectedPatterns };
diff --git a/web/src/hooks/use-destructive-actions.ts b/web/src/hooks/use-destructive-actions.ts
new file mode 100644
index 0000000000..18e8bd6c86
--- /dev/null
+++ b/web/src/hooks/use-destructive-actions.ts
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) [2026] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import DevicesManager from "~/model/storage/devices-manager";
+import { useFlattenDevices as useSystemFlattenDevices } from "~/hooks/model/system/storage";
+import {
+ useFlattenDevices as useProposalFlattenDevices,
+ useActions,
+} from "~/hooks/model/proposal/storage";
+
+/**
+ * Custom hook that returns a list of destructive actions and affected systemsm
+ *
+ * FIXME:: review, document, test and relocate if needed.
+ */
+export function useDestructiveActions() {
+ const system = useSystemFlattenDevices();
+ const staging = useProposalFlattenDevices();
+ const actions = useActions();
+ const manager = new DevicesManager(system, staging, actions);
+ const affectedSystems = manager.deletedSystems();
+ const deleteActions = manager.actions.filter((a) => a.delete && !a.subvol);
+
+ return { actions: deleteActions, affectedSystems };
+}
diff --git a/web/src/hooks/use-progress-tracking.test.ts b/web/src/hooks/use-progress-tracking.test.ts
new file mode 100644
index 0000000000..98632343b5
--- /dev/null
+++ b/web/src/hooks/use-progress-tracking.test.ts
@@ -0,0 +1,145 @@
+/*
+ * Copyright (c) [2025] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import { renderHook, waitFor } from "@testing-library/react";
+import { useProgressTracking } from "./use-progress-tracking";
+import { useStatus } from "~/hooks/model/status";
+import useTrackQueriesRefetch from "~/hooks/use-track-queries-refetch";
+import { COMMON_PROPOSAL_KEYS } from "~/hooks/model/proposal";
+import type { Progress } from "~/model/status";
+import { act } from "react";
+
+const mockProgressesFn: jest.Mock