diff --git a/web/package/cockpit-agama.changes b/web/package/cockpit-agama.changes index 7899366961..87fbb37d54 100644 --- a/web/package/cockpit-agama.changes +++ b/web/package/cockpit-agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Fri Mar 1 10:56:35 UTC 2024 - José Iván López González + +- Indicate whether the system is transactional + (gh#openSUSE/agama/1063). + ------------------------------------------------------------------- Wed Feb 28 22:26:23 UTC 2024 - Balsa Asanovic diff --git a/web/src/components/storage/ProposalPage.test.jsx b/web/src/components/storage/ProposalPage.test.jsx index 5a2993b413..3b8464f84b 100644 --- a/web/src/components/storage/ProposalPage.test.jsx +++ b/web/src/components/storage/ProposalPage.test.jsx @@ -39,6 +39,13 @@ jest.mock("@patternfly/react-core", () => { jest.mock("~/components/core/Sidebar", () => () =>
Agama sidebar
); jest.mock("~/components/storage/ProposalPageMenu", () => () =>
ProposalPage Options
); +jest.mock("~/context/product", () => ({ + ...jest.requireActual("~/context/product"), + useProduct: () => ({ + selectedProduct : { name: "Test" } + }) +})); + const vda = { sid: "59", type: "disk", diff --git a/web/src/components/storage/ProposalSettingsSection.jsx b/web/src/components/storage/ProposalSettingsSection.jsx index 1ce78674db..453bb366c6 100644 --- a/web/src/components/storage/ProposalSettingsSection.jsx +++ b/web/src/components/storage/ProposalSettingsSection.jsx @@ -21,12 +21,14 @@ import React, { useEffect, useState } from "react"; import { Checkbox, Form, Skeleton, Switch, Tooltip } from "@patternfly/react-core"; +import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; import { If, PasswordAndConfirmationInput, Section, Popup } from "~/components/core"; import { Icon } from "~/components/layout"; import { noop } from "~/utils"; -import { hasFS } from "~/components/storage/utils"; +import { hasFS, isTransactionalSystem } from "~/components/storage/utils"; +import { useProduct } from "~/context/product"; /** * @typedef {import ("~/client/storage").ProposalManager.ProposalSettings} ProposalSettings @@ -147,7 +149,9 @@ const SnapshotsField = ({ const forcedSnapshots = !configurableSnapshots && hasFS(rootVolume, "Btrfs") && rootVolume.snapshots; const SnapshotsToggle = () => { - const explanation = _("Uses Btrfs for the root file system allowing to boot to a previous version of the system after configuration changes or software upgrades."); + const explanation = _("Uses Btrfs for the root file system allowing to boot to a previous \ +version of the system after configuration changes or software upgrades."); + return ( <> { onChange({ encryptionPassword: password, encryptionMethod: method }); }; @@ -312,12 +318,29 @@ export default function ProposalSettingsSection({ const encryption = settings.encryptionPassword !== undefined && settings.encryptionPassword.length > 0; + const transactional = isTransactionalSystem(settings?.volumes || []); + return ( <>
- + +
+ {/* TRANSLATORS: %s is replaced by a product name (e.g., openSUSE Tumbleweed) */} + {sprintf(_("%s is an immutable system with atomic updates using a read-only Btrfs \ +root file system."), selectedProduct.name)} +
+ + } + else={ + + } /> { }; }); +jest.mock("~/context/product", () => ({ + ...jest.requireActual("~/context/product"), + useProduct: () => ({ + selectedProduct : { name: "Test" } + }) +})); + let props; beforeEach(() => { props = {}; }); +const rootVolume = { mountPath: "/", fsType: "Btrfs", outline: { snapshotsConfigurable: true } }; + +describe("if the system is not transactional", () => { + beforeEach(() => { + props.settings = { volumes: [rootVolume] }; + }); + + it("renders the snapshots switch", () => { + plainRender(); + + screen.getByRole("checkbox", { name: "Use Btrfs Snapshots" }); + }); +}); + +describe("if the system is transactional", () => { + beforeEach(() => { + props.settings = { volumes: [{ ...rootVolume, transactional: true }] }; + }); + + it("renders explanation about transactional system", () => { + plainRender(); + + screen.getByText("Transactional system"); + }); +}); + describe("Encryption field", () => { describe("if encryption password setting is not set yet", () => { beforeEach(() => { diff --git a/web/src/components/storage/ProposalVolumes.jsx b/web/src/components/storage/ProposalVolumes.jsx index 8b6398b43a..be080ece93 100644 --- a/web/src/components/storage/ProposalVolumes.jsx +++ b/web/src/components/storage/ProposalVolumes.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -34,7 +34,7 @@ import { _ } from "~/i18n"; import { Em, If, Popup, RowActions, Tip } from '~/components/core'; import { Icon } from '~/components/layout'; import { VolumeForm } from '~/components/storage'; -import { deviceSize } from '~/components/storage/utils'; +import { deviceSize, hasSnapshots, isTransactionalRoot } from '~/components/storage/utils'; import { noop } from "~/utils"; /** @@ -200,8 +200,8 @@ const VolumeRow = ({ columns, volume, options, isLoading, onEdit, onDelete }) => }; const Details = ({ volume, options }) => { - const hasSnapshots = volume.fsType === "Btrfs" && volume.snapshots; - const transactional = volume.fsType === "Btrfs" && volume.transactional; + const snapshots = hasSnapshots(volume); + const transactional = isTransactionalRoot(volume); // TRANSLATORS: the filesystem uses a logical volume (LVM) const text = `${volume.fsType} ${options.lvm ? _("logical volume") : _("partition")}`; @@ -215,7 +215,7 @@ const VolumeRow = ({ columns, volume, options, isLoading, onEdit, onDelete }) => {/* TRANSLATORS: filesystem flag, it uses an encryption */} {_("encrypted")}} /> {/* TRANSLATORS: filesystem flag, it allows creating snapshots */} - {_("with snapshots")}} /> + {_("with snapshots")}} /> {/* TRANSLATORS: flag for transactional file system */} {_("transactional")}} /> diff --git a/web/src/components/storage/ProposalVolumes.test.jsx b/web/src/components/storage/ProposalVolumes.test.jsx index 68197b189e..d3b799fb74 100644 --- a/web/src/components/storage/ProposalVolumes.test.jsx +++ b/web/src/components/storage/ProposalVolumes.test.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -217,9 +217,9 @@ describe("if there are volumes", () => { within(popup).getByText("Edit file system"); }); - describe("and there is transactional Btrfs volume", () => { + describe("and there is transactional Btrfs root volume", () => { beforeEach(() => { - props.volumes = [{ ...volumes.root, transactional: true }]; + props.volumes = [{ ...volumes.root, snapshots: true, transactional: true }]; }); it("renders 'transactional' legend as part of its information", async () => { @@ -233,7 +233,7 @@ describe("if there are volumes", () => { describe("and there is Btrfs volume using snapshots", () => { beforeEach(() => { - props.volumes = [{ ...volumes.root, snapshots: true }]; + props.volumes = [{ ...volumes.root, snapshots: true, transactional: false }]; }); it("renders 'with snapshots' legend as part of its information", async () => { @@ -244,20 +244,6 @@ describe("if there are volumes", () => { within(volumes).getByRole("row", { name: "/ Btrfs partition with snapshots 1 KiB - 2 KiB" }); }); }); - - describe("and there is a transactional Btrfs volume using snapshots", () => { - beforeEach(() => { - props.volumes = [{ ...volumes.root, transactional: true, snapshots: true }]; - }); - - it("renders 'with snapshots' and 'transactional' legends as part of its information", async () => { - plainRender(); - - const [, volumes] = await screen.findAllByRole("rowgroup"); - - within(volumes).getByRole("row", { name: "/ Btrfs partition with snapshots transactional 1 KiB - 2 KiB" }); - }); - }); }); describe("if there are not volumes", () => { diff --git a/web/src/components/storage/utils.js b/web/src/components/storage/utils.js index 621e5194dc..d2322bc167 100644 --- a/web/src/components/storage/utils.js +++ b/web/src/components/storage/utils.js @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -25,6 +25,11 @@ import xbytes from "xbytes"; import { N_ } from "~/i18n"; +/** + * @typedef {import ("~/client/storage").Volume} Volume + * @typedef {import ("~/clients/storage").StorageDevice} StorageDevice + */ + /** * @typedef {Object} SizeObject * @@ -125,7 +130,7 @@ const parseToBytes = (size) => { /** * Generates the label for the given device * - * @param {import(~/clients/storage).StorageDevice} device + * @param {StorageDevice} device * @returns {string} */ const deviceLabel = (device) => { @@ -139,7 +144,7 @@ const deviceLabel = (device) => { * Checks if volume uses given fs. This method works same as in backend * case insensitive. * - * @param {import(~/clients/storage).Volume} volume + * @param {Volume} volume * @param {string} fs - Filesystem name to check. * @returns {boolean} true when volume uses given fs */ @@ -149,6 +154,36 @@ const hasFS = (volume, fs) => { return volFS.toLowerCase() === fs.toLocaleLowerCase(); }; +/** + * Checks whether the given volume has snapshots. + * + * @param {Volume} volume + * @returns {boolean} + */ +const hasSnapshots = (volume) => { + return hasFS(volume, "btrfs") && volume.snapshots; +}; + +/** + * Checks whether the given volume defines a transactional root. + * + * @param {Volume} volume + * @returns {boolean} + */ +const isTransactionalRoot = (volume) => { + return volume.mountPath === "/" && volume.transactional; +}; + +/** + * Checks whether the given volumes defines a transactional system. + * + * @param {Volume[]} volumes + * @returns {boolean} + */ +const isTransactionalSystem = (volumes) => { + return volumes.find(v => isTransactionalRoot(v)) !== undefined; +}; + export { DEFAULT_SIZE_UNIT, SIZE_METHODS, @@ -157,5 +192,8 @@ export { deviceSize, parseToBytes, splitSize, - hasFS + hasFS, + hasSnapshots, + isTransactionalRoot, + isTransactionalSystem }; diff --git a/web/src/components/storage/utils.test.js b/web/src/components/storage/utils.test.js index 052e69bbf7..a973e27a0a 100644 --- a/web/src/components/storage/utils.test.js +++ b/web/src/components/storage/utils.test.js @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -19,7 +19,16 @@ * find current contact information at www.suse.com. */ -import { deviceSize, deviceLabel, parseToBytes, splitSize, hasFS } from "./utils"; +import { + deviceSize, + deviceLabel, + parseToBytes, + splitSize, + hasFS, + hasSnapshots, + isTransactionalRoot, + isTransactionalSystem +} from "./utils"; describe("deviceSize", () => { it("returns the size with units", () => { @@ -100,3 +109,51 @@ describe("hasFS", () => { expect(hasFS({ fsType: "Btrfs" }, "EXT4")).toBe(false); }); }); + +describe("hasSnapshots", () => { + it("returns false if the volume has not Btrfs file system", () => { + expect(hasSnapshots({ fsType: "EXT4", snapshots: true })).toBe(false); + }); + + it("returns false if the volume has not snapshots enabled", () => { + expect(hasSnapshots({ fsType: "Btrfs", snapshots: false })).toBe(false); + }); + + it("returns true if the volume has Btrfs file system and snapshots enabled", () => { + expect(hasSnapshots({ fsType: "Btrfs", snapshots: true })).toBe(true); + }); +}); + +describe("isTransactionalRoot", () => { + it("returns false if the volume is not root", () => { + expect(isTransactionalRoot({ mountPath: "/home", transactional: true })).toBe(false); + }); + + it("returns false if the volume has not transactional enabled", () => { + expect(isTransactionalRoot({ mountPath: "/", transactional: false })).toBe(false); + }); + + it("returns true if the volume is root and has transactional enabled", () => { + expect(isTransactionalRoot({ mountPath: "/", transactional: true })).toBe(true); + }); +}); + +describe("isTransactionalSystem", () => { + it("returns false if volumes does not include a transactional root", () => { + expect(isTransactionalSystem([])).toBe(false); + + const volumes = [ + { mountPath: "/" }, + { mountPath: "/home", transactional: true } + ]; + expect(isTransactionalSystem(volumes)).toBe(false); + }); + + it("returns true if volumes includes a transactional root", () => { + const volumes = [ + { mountPath: "EXT4" }, + { mountPath: "/", transactional: true } + ]; + expect(isTransactionalSystem(volumes)).toBe(true); + }); +});