From 40cbc06caa0071b8567a4771a6a9e5ac5efd0d1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 20 Mar 2024 16:21:08 +0000 Subject: [PATCH 01/26] Move the selected patterns to the proposal --- rust/agama-server/src/software/web.rs | 43 +++++++-------------------- rust/agama-server/src/web/docs.rs | 1 - rust/agama-server/src/web/event.rs | 6 +++- 3 files changed, 15 insertions(+), 35 deletions(-) diff --git a/rust/agama-server/src/software/web.rs b/rust/agama-server/src/software/web.rs index f5c7bd6cb5..b7ccc4298b 100644 --- a/rust/agama-server/src/software/web.rs +++ b/rust/agama-server/src/software/web.rs @@ -92,7 +92,7 @@ async fn patterns_changed_stream( } None }) - .filter_map(|e| e.map(Event::PatternsChanged)); + .filter_map(|e| e.map(|patterns| Event::SoftwareProposalChanged { patterns })); Ok(stream) } @@ -150,43 +150,16 @@ async fn products(State(state): State>) -> Result), + (status = 200, description = "List of known software patterns", body = Vec), (status = 400, description = "The D-Bus service could not perform the action") ))] -async fn patterns( - State(state): State>, -) -> Result>, Error> { +async fn patterns(State(state): State>) -> Result>, Error> { let patterns = state.software.patterns(true).await?; - let selected = state.software.selected_patterns().await?; - let items = patterns - .into_iter() - .map(|pattern| { - let selected_by: SelectedBy = selected - .get(&pattern.id) - .copied() - .unwrap_or(SelectedBy::None); - PatternEntry { - pattern, - selected_by, - } - }) - .collect(); - - Ok(Json(items)) + Ok(Json(patterns)) } /// Sets the software configuration. @@ -229,7 +202,7 @@ async fn get_config(State(state): State>) -> Result, } /// Returns the proposal information. @@ -251,7 +227,8 @@ pub struct SoftwareProposal { ))] async fn proposal(State(state): State>) -> Result, Error> { let size = state.software.used_disk_space().await?; - let proposal = SoftwareProposal { size }; + let patterns = state.software.selected_patterns().await?; + let proposal = SoftwareProposal { size, patterns }; Ok(Json(proposal)) } diff --git a/rust/agama-server/src/web/docs.rs b/rust/agama-server/src/web/docs.rs index 34ded88acf..b69908b07c 100644 --- a/rust/agama-server/src/web/docs.rs +++ b/rust/agama-server/src/web/docs.rs @@ -26,7 +26,6 @@ use utoipa::OpenApi; schemas(crate::l10n::LocaleEntry), schemas(crate::l10n::TimezoneEntry), schemas(crate::l10n::web::LocaleConfig), - schemas(crate::software::web::PatternEntry), schemas(crate::software::web::SoftwareConfig), schemas(crate::software::web::SoftwareProposal), schemas(crate::manager::web::InstallerStatus), diff --git a/rust/agama-server/src/web/event.rs b/rust/agama-server/src/web/event.rs index 999e4d6619..ef6cbf0f50 100644 --- a/rust/agama-server/src/web/event.rs +++ b/rust/agama-server/src/web/event.rs @@ -21,7 +21,11 @@ pub enum Event { ProductChanged { id: String, }, - PatternsChanged(HashMap), + // TODO: it should include the full software proposal or, at least, + // all the relevant changes. + SoftwareProposalChanged { + patterns: HashMap, + }, InstallationPhaseChanged { phase: InstallationPhase, }, From dd0c91797bf1ec0ad006ab01a5b5973ba3dd9296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 20 Mar 2024 16:55:06 +0000 Subject: [PATCH 02/26] Rename Pattern 'id' to 'name' --- rust/agama-lib/src/software/client.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/rust/agama-lib/src/software/client.rs b/rust/agama-lib/src/software/client.rs index 4769976847..9c42094da5 100644 --- a/rust/agama-lib/src/software/client.rs +++ b/rust/agama-lib/src/software/client.rs @@ -7,8 +7,8 @@ use zbus::Connection; /// Represents a software product #[derive(Debug, Serialize, utoipa::ToSchema)] pub struct Pattern { - /// Pattern ID (eg., "aaa_base", "gnome") - pub id: String, + /// Pattern name (eg., "aaa_base", "gnome") + pub name: String, /// Pattern category (e.g., "Production") pub category: String, /// Pattern icon path locally on system @@ -69,8 +69,8 @@ impl<'a> SoftwareClient<'a> { .await? .into_iter() .map( - |(id, (category, description, icon, summary, order))| Pattern { - id, + |(name, (category, description, icon, summary, order))| Pattern { + name, category, icon, description, From 64d16254066b3d0baa640a2e174e39b4189c3748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 20 Mar 2024 16:55:27 +0000 Subject: [PATCH 03/26] Serialize SelectedBy as a number --- rust/agama-lib/src/software/client.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rust/agama-lib/src/software/client.rs b/rust/agama-lib/src/software/client.rs index 9c42094da5..d8f1095403 100644 --- a/rust/agama-lib/src/software/client.rs +++ b/rust/agama-lib/src/software/client.rs @@ -1,6 +1,7 @@ use super::proxies::Software1Proxy; use crate::error::ServiceError; use serde::Serialize; +use serde_repr::Serialize_repr; use std::collections::HashMap; use zbus::Connection; @@ -22,7 +23,8 @@ pub struct Pattern { } /// Represents the reason why a pattern is selected. -#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Serialize_repr)] +#[repr(u8)] pub enum SelectedBy { /// The pattern was selected by the user. User = 0, From 248d040d447fd065881fa3522c015ccba50416b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 20 Mar 2024 17:36:45 +0000 Subject: [PATCH 04/26] Adapt the SoftwareClient to the HTTP/JSON API --- web/src/client/index.js | 4 +- web/src/client/software.js | 135 +++++++++---------------------------- 2 files changed, 35 insertions(+), 104 deletions(-) diff --git a/web/src/client/index.js b/web/src/client/index.js index 206365b695..63b6e2b422 100644 --- a/web/src/client/index.js +++ b/web/src/client/index.js @@ -78,7 +78,7 @@ const createClient = (url) => { const manager = new ManagerClient(client); // const monitor = new Monitor(address, MANAGER_SERVICE); // const network = new NetworkClient(address); - // const software = new SoftwareClient(address); + const software = new SoftwareClient(client); // const storage = new StorageClient(address); // const users = new UsersClient(address); // const questions = new QuestionsClient(address); @@ -139,7 +139,7 @@ const createClient = (url) => { manager, // monitor, // network, - // software, + software, // storage, // users, // questions, diff --git a/web/src/client/software.js b/web/src/client/software.js index 8ed8f2e8f7..4e9907ef5b 100644 --- a/web/src/client/software.js +++ b/web/src/client/software.js @@ -22,12 +22,9 @@ // @ts-check import DBusClient from "./dbus"; -import { WithIssues, WithStatus, WithProgress } from "./mixins"; +import { WithIssues, WithProgress, WithStatus } from "./mixins"; const SOFTWARE_SERVICE = "org.opensuse.Agama.Software1"; -const SOFTWARE_IFACE = "org.opensuse.Agama.Software1"; -const SOFTWARE_PATH = "/org/opensuse/Agama/Software1"; -const PRODUCT_IFACE = "org.opensuse.Agama.Software1.Product"; const PRODUCT_PATH = "/org/opensuse/Agama/Software1/Product"; const REGISTRATION_IFACE = "org.opensuse.Agama1.Registration"; @@ -65,57 +62,6 @@ class BaseProductManager { this.proxies = {}; } - /** - * Returns the list of available products. - * - * @return {Promise>} - */ - async getAll() { - const proxy = await this.client.proxy(PRODUCT_IFACE); - return proxy.AvailableProducts.map(product => { - const [id, name, meta] = product; - return { id, name, description: meta.description?.v }; - }); - } - - /** - * Returns the selected product. - * - * @return {Promise} - */ - async getSelected() { - const products = await this.getAll(); - const proxy = await this.client.proxy(PRODUCT_IFACE); - if (proxy.SelectedProduct === "") { - return null; - } - return products.find(product => product.id === proxy.SelectedProduct); - } - - /** - * Selects a product for installation. - * - * @param {string} id - Product ID. - */ - async select(id) { - const proxy = await this.client.proxy(PRODUCT_IFACE); - return proxy.SelectProduct(id); - } - - /** - * Registers a callback to run when properties in the Product object change. - * - * @param {(id: string) => void} handler - Callback function. - */ - onChange(handler) { - return this.client.onObjectChanged(PRODUCT_PATH, PRODUCT_IFACE, changes => { - if ("SelectedProduct" in changes) { - const selected = changes.SelectedProduct.v.toString(); - handler(selected); - } - }); - } - /** * Returns the registration of the selected product. * @@ -147,7 +93,7 @@ class BaseProductManager { return { success: result[0] === 0, - message: result[1] + message: result[1], }; } @@ -162,7 +108,7 @@ class BaseProductManager { return { success: result[0] === 0, - message: result[1] + message: result[1], }; } @@ -203,11 +149,6 @@ class BaseProductManager { } } -/** - * Manages product selection. - */ -class ProductManager extends WithIssues(BaseProductManager, PRODUCT_PATH) { } - /** * Software client * @@ -217,10 +158,11 @@ class SoftwareBaseClient { /** * @param {string|undefined} address - D-Bus address; if it is undefined, it uses the system bus. */ - constructor(address = undefined) { - this.client = new DBusClient(SOFTWARE_SERVICE, address); - this.product = new ProductManager(this.client); - this.proxies = {}; + /** + * @param {import("./http").HTTPClient} client - HTTP client. + */ + constructor(client) { + this.client = client; } /** @@ -228,67 +170,54 @@ class SoftwareBaseClient { * * @return {Promise} */ - async probe() { - const proxy = await this.client.proxy(SOFTWARE_IFACE); - return proxy.Probe(); + probe() { + return this.client.post("/software/probe"); } /** * Returns how much space installation takes on disk * - * @return {Promise} + * @return {Promise} */ - async getUsedSpace() { - const proxy = await this.client.proxy(SOFTWARE_IFACE); - return proxy.UsedDiskSpace(); + getProposal() { + return this.client.get("/software/proposal"); } /** * Returns available patterns * - * @param {boolean} filter - `true` = filter the patterns, `false` = all patterns - * @return {Promise>} + * @return {Promise} */ - async patterns(filter) { - const proxy = await this.client.proxy(SOFTWARE_IFACE); - return proxy.ListPatterns(filter); + getPatterns() { + return this.client.get("/software/patterns"); } /** * @typedef {Object.} PatternSelection mapping "name" => * "who selected the pattern" */ - - /** - * Returns selected patterns - * - * @return {Promise} - */ - async selectedPatterns() { - const proxy = await this.client.proxy(SOFTWARE_IFACE); - return proxy.SelectedPatterns; + config() { + return this.client.get("/software/config"); } /** - * Select a pattern to install - * - * @param {string} name - name of the pattern + * @param {string[]} patterns - name of the user-selected patterns. * @return {Promise} */ - async addPattern(name) { - const proxy = await this.client.proxy(SOFTWARE_IFACE); - return proxy.AddPattern(name); + selectPatterns(patterns) { + return this.client.put("/software/config", { patterns }); } /** - * Deselect a pattern to install + * Registers a callback to run when the select product changes. * - * @param {string} name - name of the pattern - * @return {Promise} + * @param {(changes: object) => void} handler - Callback function. + * @return {import ("./http").RemoveFn} Function to remove the callback. */ - async removePattern(name) { - const proxy = await this.client.proxy(SOFTWARE_IFACE); - return proxy.RemovePattern(name); + onSelectedPatternsChanged(handler) { + return this.client.onEvent("SoftwareProposalChanged", ({ patterns }) => { + handler(patterns); + }); } } @@ -297,10 +226,12 @@ class SoftwareBaseClient { */ class SoftwareClient extends WithIssues( WithProgress( - WithStatus(SoftwareBaseClient, SOFTWARE_PATH), - SOFTWARE_PATH, + WithStatus(SoftwareBaseClient, "software/status", SOFTWARE_SERVICE), + "software/progress", + SOFTWARE_SERVICE, ), - SOFTWARE_PATH, + "software/issues/software", + "/org/opensuse/Agama/Software1", ) {} class ProductBaseClient { From bcbfb4e8c7a4923d06fc656ac6e974c82f79f925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 20 Mar 2024 17:39:40 +0000 Subject: [PATCH 05/26] Initial adaptation of the software section to the HTTP/JSON API --- web/src/components/overview/OverviewPage.jsx | 8 +- .../components/overview/SoftwareSection.jsx | 36 ++++---- .../overview/SoftwareSection.test.jsx | 18 ++-- web/src/components/software/PatternItem.jsx | 37 ++------ .../components/software/PatternSelector.jsx | 91 ++++++++++++------- 5 files changed, 94 insertions(+), 96 deletions(-) diff --git a/web/src/components/overview/OverviewPage.jsx b/web/src/components/overview/OverviewPage.jsx index 6c18505320..b51a6129d0 100644 --- a/web/src/components/overview/OverviewPage.jsx +++ b/web/src/components/overview/OverviewPage.jsx @@ -22,14 +22,14 @@ import React, { useState } from "react"; import { useProduct } from "~/context/product"; import { Navigate } from "react-router-dom"; -import { Page, InstallButton } from "~/components/core"; +import { InstallButton, Page } from "~/components/core"; import { L10nSection, NetworkSection, ProductSection, SoftwareSection, StorageSection, - UsersSection + UsersSection, } from "~/components/overview"; import { _ } from "~/i18n"; @@ -47,11 +47,8 @@ export default function OverviewPage() { // // TRANSLATORS: page title // title={_("Installation Summary")} // > - // - // // // - // // // @@ -68,6 +65,7 @@ export default function OverviewPage() { > + ); } diff --git a/web/src/components/overview/SoftwareSection.jsx b/web/src/components/overview/SoftwareSection.jsx index a5ac99b8fc..b6887a2502 100644 --- a/web/src/components/overview/SoftwareSection.jsx +++ b/web/src/components/overview/SoftwareSection.jsx @@ -19,7 +19,7 @@ * find current contact information at www.suse.com. */ -import React, { useReducer, useEffect } from "react"; +import React, { useEffect, useReducer } from "react"; import { BUSY } from "~/client/status"; import { Button } from "@patternfly/react-core"; import { Icon } from "~/components/layout"; @@ -35,7 +35,7 @@ const initialState = { errorsRead: false, size: "", patterns: {}, - progress: { message: _("Reading software repositories"), current: 0, total: 0, finished: false } + progress: { message: _("Reading software repositories"), current: 0, total: 0, finished: false }, }; const reducer = (state, action) => { @@ -82,8 +82,8 @@ export default function SoftwareSection({ showErrors }) { useEffect(() => { const updateProposal = async () => { const errors = await cancellablePromise(client.getIssues()); - const size = await cancellablePromise(client.getUsedSpace()); - const patterns = await cancellablePromise(client.patterns(true)); + const { size } = await cancellablePromise(client.getProposal()); + const patterns = await cancellablePromise(client.getPatterns(true)); dispatch({ type: "UPDATE_PROPOSAL", payload: { errors, size, patterns } }); }; @@ -95,7 +95,7 @@ export default function SoftwareSection({ showErrors }) { cancellablePromise(client.getProgress()).then(({ message, current, total, finished }) => { dispatch({ type: "UPDATE_PROGRESS", - payload: { message, current, total, finished } + payload: { message, current, total, finished }, }); }); }, [client, cancellablePromise]); @@ -104,7 +104,7 @@ export default function SoftwareSection({ showErrors }) { return client.onProgressChange(({ message, current, total, finished }) => { dispatch({ type: "UPDATE_PROGRESS", - payload: { message, current, total, finished } + payload: { message, current, total, finished }, }); }); }, [client, cancellablePromise]); @@ -114,24 +114,24 @@ export default function SoftwareSection({ showErrors }) { const SectionContent = () => { if (state.busy) { const { message, current, total } = state.progress; - return ( - - ); + return ; } return ( <> {errors.length > 0 && - } + ( + + )} ); }; diff --git a/web/src/components/overview/SoftwareSection.test.jsx b/web/src/components/overview/SoftwareSection.test.jsx index d0fdf55711..f0f94638b3 100644 --- a/web/src/components/overview/SoftwareSection.test.jsx +++ b/web/src/components/overview/SoftwareSection.test.jsx @@ -35,14 +35,14 @@ const kdePattern = { "Packages providing the Plasma desktop environment and applications from KDE.", "./pattern-kde", "KDE Applications and Plasma 5 Desktop", - "1110" - ] + "1110", + ], }; let getStatusFn = jest.fn().mockResolvedValue(IDLE); let getProgressFn = jest.fn().mockResolvedValue({}); let getIssuesFn = jest.fn().mockResolvedValue([]); -let patternsFn = jest.fn().mockResolvedValue(kdePattern); +let getPatternsFn = jest.fn().mockResolvedValue(kdePattern); beforeEach(() => { createClient.mockImplementation(() => { @@ -53,8 +53,8 @@ beforeEach(() => { getIssues: getIssuesFn, onStatusChange: noop, onProgressChange: noop, - patterns: patternsFn, - getUsedSpace: jest.fn().mockResolvedValue("500 MB") + getPatterns: getPatternsFn, + getProposal: jest.fn().mockResolvedValue({ size: "500 MiB" }), }, }; }); @@ -63,13 +63,13 @@ beforeEach(() => { describe("when the proposal is calculated", () => { beforeEach(() => { getStatusFn = jest.fn().mockResolvedValue(IDLE); - patternsFn = jest.fn().mockResolvedValue(kdePattern); + getPatternsFn = jest.fn().mockResolvedValue(kdePattern); }); it("renders the required space", async () => { installerRender(); await screen.findByText("Installation will take"); - await screen.findByText("500 MB"); + await screen.findByText("500 MiB"); }); describe("patterns are available", () => { @@ -83,7 +83,7 @@ describe("when the proposal is calculated", () => { describe("no patterns are available", () => { beforeEach(() => { - patternsFn = jest.fn().mockResolvedValue({}); + getPatternsFn = jest.fn().mockResolvedValue({}); }); it("the header is a plain text", async () => { @@ -110,7 +110,7 @@ describe("when the proposal is being calculated", () => { beforeEach(() => { getStatusFn = jest.fn().mockResolvedValue(BUSY); getProgressFn = jest.fn().mockResolvedValue( - { message: "Initializing target repositories", current: 1, total: 4, finished: false } + { message: "Initializing target repositories", current: 1, total: 4, finished: false }, ); }); diff --git a/web/src/components/software/PatternItem.jsx b/web/src/components/software/PatternItem.jsx index cc6f5ac7e8..75cdc464de 100644 --- a/web/src/components/software/PatternItem.jsx +++ b/web/src/components/software/PatternItem.jsx @@ -77,53 +77,32 @@ function stateAriaLabel(selected) { * @param {function} onChange callback called when the pattern status is changed * @returns {JSX.Element} */ -function PatternItem({ pattern, onChange }) { - const client = useInstallerClient(); +function PatternItem({ pattern, onToggle }) { const [icon, setIcon] = useState(); - const onClick = () => { - switch (pattern.selected) { - // available pattern (not selected) - case undefined: - client.software.addPattern(pattern.name).then(onChange); - break; - // user selected - case 0: - client.software.removePattern(pattern.name).then(onChange); - break; - // auto selected - case 1: - // try to deselect an automatically selected pattern, - // that can work only for soft dependencies (Recommends, Suggests,...), - // the hard dependencies (Requires) cannot be changed - client.software.removePattern(pattern.name).then(onChange); - break; - default: - console.error("Unknown patterns status: ", pattern.selected); - } - }; - // download the pattern icon from the system useEffect(() => { if (icon) return; cockpit.file(sprintf(ICON_PATH, pattern.icon)).read() - .then((data) => { setIcon(data) }); + .then((data) => { + setIcon(data); + }); }, [pattern.icon, icon]); - const patternIcon = (icon) + const patternIcon = icon // use a Base64 encoded inline pattern image ? // fallback icon :