diff --git a/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml index 965d06450c..8c702ec99e 100644 --- a/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml +++ b/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml @@ -42,9 +42,13 @@ - + + + + + @@ -69,6 +73,7 @@ + diff --git a/doc/dbus/org.opensuse.Agama.Software1.doc.xml b/doc/dbus/org.opensuse.Agama.Software1.doc.xml index 0080f876ff..058c17a348 100644 --- a/doc/dbus/org.opensuse.Agama.Software1.doc.xml +++ b/doc/dbus/org.opensuse.Agama.Software1.doc.xml @@ -58,15 +58,25 @@ --> - + + + + + + + @@ -97,5 +107,6 @@ + diff --git a/rust/agama-lib/src/http/event.rs b/rust/agama-lib/src/http/event.rs index 223ddd8292..4ad3366d7b 100644 --- a/rust/agama-lib/src/http/event.rs +++ b/rust/agama-lib/src/http/event.rs @@ -24,7 +24,7 @@ use crate::{ manager::InstallationPhase, network::model::NetworkChange, progress::Progress, - software::SelectedBy, + software::{model::Conflict, SelectedBy}, storage::{ model::{ dasd::{DASDDevice, DASDFormatSummary}, @@ -69,6 +69,9 @@ pub enum Event { SoftwareProposalChanged { patterns: HashMap, }, + ConflictsChanged { + conflicts: Vec, + }, QuestionsChanged, InstallationPhaseChanged { phase: InstallationPhase, diff --git a/rust/agama-lib/src/software/client.rs b/rust/agama-lib/src/software/client.rs index 6a87843e18..b4e1dbcce9 100644 --- a/rust/agama-lib/src/software/client.rs +++ b/rust/agama-lib/src/software/client.rs @@ -19,7 +19,7 @@ // find current contact information at www.suse.com. use super::{ - model::{Repository, ResolvableType}, + model::{Conflict, ConflictSolve, Repository, ResolvableType}, proxies::{ProposalProxy, Software1Proxy}, }; use crate::error::ServiceError; @@ -169,6 +169,24 @@ impl<'a> SoftwareClient<'a> { Ok(patterns) } + /// returns current list of conflicts + pub async fn get_conflicts(&self) -> Result, ServiceError> { + let conflicts = self.software_proxy.conflicts().await?; + let conflicts = conflicts + .into_iter() + .map(|c| Conflict::from_dbus(c)) + .collect(); + + Ok(conflicts) + } + + /// Sets solutions ( not necessary for all conflicts ) and recompute conflicts + pub async fn solve_conflicts(&self, solutions: Vec) -> Result<(), ServiceError> { + let solutions: Vec<(u32, u32)> = solutions.into_iter().map(|s| s.into()).collect(); + + Ok(self.software_proxy.solve_conflicts(&solutions).await?) + } + /// Selects patterns by user pub async fn select_patterns( &self, diff --git a/rust/agama-lib/src/software/model.rs b/rust/agama-lib/src/software/model.rs index 0bca26c619..4a9c14defa 100644 --- a/rust/agama-lib/src/software/model.rs +++ b/rust/agama-lib/src/software/model.rs @@ -18,10 +18,12 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +mod conflict; mod license; mod packages; mod registration; +pub use conflict::*; pub use license::*; pub use packages::*; pub use registration::*; diff --git a/rust/agama-lib/src/software/model/conflict.rs b/rust/agama-lib/src/software/model/conflict.rs new file mode 100644 index 0000000000..527fea41a7 --- /dev/null +++ b/rust/agama-lib/src/software/model/conflict.rs @@ -0,0 +1,104 @@ +// 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. + +use serde::{Deserialize, Serialize}; + +/// Information about conflict when resolving software +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ConflictSolve { + /// conflict id + pub conflict_id: u32, + /// selected solution id + pub solution_id: u32, +} + +impl From for (u32, u32) { + fn from(solve: ConflictSolve) -> Self { + (solve.conflict_id, solve.solution_id) + } +} + +/// Information about possible solution for conflict +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Solution { + /// conflict id + pub id: u32, + /// localized description of solution + pub description: String, + /// localized details about solution. Can be missing + pub details: Option, +} + +/// Information about conflict when resolving software +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Conflict { + /// conflict id + pub id: u32, + /// localized description of conflict + pub description: String, + /// localized details about conflict. Can be missing + pub details: Option, + /// list of possible solutions + pub solutions: Vec, +} + +impl Solution { + pub fn from_dbus(dbus_solution: (u32, String, String)) -> Self { + let details = dbus_solution.2; + let details = if details.is_empty() { + None + } else { + Some(details) + }; + + Self { + id: dbus_solution.0, + description: dbus_solution.1, + details, + } + } +} + +impl Conflict { + pub fn from_dbus(dbus_conflict: (u32, String, String, Vec<(u32, String, String)>)) -> Self { + let details = dbus_conflict.2; + let details = if details.is_empty() { + None + } else { + Some(details) + }; + + let solutions = dbus_conflict.3; + let solutions = solutions + .into_iter() + .map(|s| Solution::from_dbus(s)) + .collect(); + + Self { + id: dbus_conflict.0, + description: dbus_conflict.1, + details, + solutions, + } + } +} diff --git a/rust/agama-lib/src/software/proxies/software.rs b/rust/agama-lib/src/software/proxies/software.rs index 02dc1040c7..95555b7e3c 100644 --- a/rust/agama-lib/src/software/proxies/software.rs +++ b/rust/agama-lib/src/software/proxies/software.rs @@ -97,6 +97,9 @@ pub trait Software1 { /// SetUserPatterns method fn set_user_patterns(&self, add: &[&str], remove: &[&str]) -> zbus::Result>; + /// SolveConflicts method + fn solve_conflicts(&self, solutions: &[(u32, u32)]) -> zbus::Result<()>; + /// UsedDiskSpace method fn used_disk_space(&self) -> zbus::Result; @@ -104,6 +107,11 @@ pub trait Software1 { #[zbus(signal)] fn probe_finished(&self) -> zbus::Result<()>; + /// Conflicts property + #[zbus(property)] + #[allow(clippy::type_complexity)] + fn conflicts(&self) -> zbus::Result)>>; + /// SelectedPatterns property #[zbus(property)] fn selected_patterns(&self) -> zbus::Result>; diff --git a/rust/agama-server/src/software/web.rs b/rust/agama-server/src/software/web.rs index 56d6d8d1a1..8a537a7f50 100644 --- a/rust/agama-server/src/software/web.rs +++ b/rust/agama-server/src/software/web.rs @@ -39,8 +39,9 @@ use agama_lib::{ product::{proxies::RegistrationProxy, Product, ProductClient}, software::{ model::{ - AddonParams, AddonProperties, License, LicenseContent, LicensesRepo, RegistrationError, - RegistrationInfo, RegistrationParams, Repository, ResolvableParams, SoftwareConfig, + AddonParams, AddonProperties, Conflict, ConflictSolve, License, LicenseContent, + LicensesRepo, RegistrationError, RegistrationInfo, RegistrationParams, Repository, + ResolvableParams, SoftwareConfig, }, proxies::{Software1Proxy, SoftwareProductProxy}, Pattern, SelectedBy, SoftwareClient, UnknownSelectedBy, @@ -81,6 +82,10 @@ pub async fn software_streams(dbus: zbus::Connection) -> Result Result, Error> { + let proxy = Software1Proxy::new(&dbus).await?; + let stream = proxy + .receive_conflicts_changed() + .await + .then(|change| async move { + if let Ok(conflicts) = change.get().await { + return Some( + conflicts + .into_iter() + .map(|c| Conflict::from_dbus(c)) + .collect(), + ); + } + None + }) + .filter_map(|e| e.map(|conflicts| Event::ConflictsChanged { conflicts })); + Ok(stream) +} + async fn registration_email_changed_stream( dbus: zbus::Connection, ) -> Result, Error> { @@ -237,6 +264,7 @@ pub async fn software_service( let router = Router::new() .route("/patterns", get(patterns)) + .route("/conflicts", get(get_conflicts).patch(solve_conflicts)) .route("/repositories", get(repositories)) .route("/products", get(products)) .route("/licenses", get(licenses)) @@ -311,6 +339,45 @@ async fn repositories( Ok(Json(repositories)) } +/// Returns the list of conflicts that proposal found. +/// +/// * `state`: service state. +#[utoipa::path( + get, + path = "/conflicts", + context_path = "/api/software", + responses( + (status = 200, description = "List of software conflicts", body = Vec), + (status = 400, description = "The D-Bus service could not perform the action") + ) +)] +async fn get_conflicts( + State(state): State>, +) -> Result>, Error> { + let conflicts = state.software.get_conflicts().await?; + Ok(Json(conflicts)) +} + +/// Solve conflicts. Not all conflicts needs to be solved at once. +/// +/// * `state`: service state. +#[utoipa::path( + patch, + path = "/conflicts", + context_path = "/api/software", + request_body = Vec, + responses( + (status = 200, description = "Operation success"), + (status = 400, description = "The D-Bus service could not perform the action") + ) +)] +async fn solve_conflicts( + State(state): State>, + Json(solutions): Json>, +) -> Result<(), Error> { + Ok(state.software.solve_conflicts(solutions).await?) +} + /// returns registration info /// /// * `state`: service state. diff --git a/service/lib/agama/dbus/software/manager.rb b/service/lib/agama/dbus/software/manager.rb index 6556f8ce8e..abbf3b18a3 100644 --- a/service/lib/agama/dbus/software/manager.rb +++ b/service/lib/agama/dbus/software/manager.rb @@ -56,6 +56,7 @@ def initialize(backend, logger) register_progress_callbacks register_service_status_callbacks @selected_patterns = {} + @conflicts = [] end # List of software related issues, see {DBus::Interfaces::Issues} @@ -115,6 +116,12 @@ def issues [backend.assign_patterns(add, remove)] end + dbus_reader_attr_accessor :conflicts, "a(ussa(uss))" + + dbus_method :SolveConflicts, "in solutions:a(uu)" do |solutions| + backend.proposal.solve_conflicts(solutions) + end + dbus_method :ProvisionsSelected, "in Provisions:as, out Result:ab" do |provisions| [provisions.map { |p| backend.provision_selected?(p) }] end @@ -203,6 +210,17 @@ def register_callbacks self.selected_patterns = compute_patterns end + backend.proposal.on_conflicts_change do |conflicts| + self.conflicts = conflicts.map do |conflict| + [ + conflict["id"], conflict["description"], conflict["details"] || "", + conflict["solutions"].map do |solution| + [solution["id"], solution["description"], solution["details"] || ""] + end + ] + end + end + backend.on_issues_change { issues_properties_changed } end diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index 707998599e..5286fc624d 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -448,6 +448,12 @@ def locale=(locale) selected.each { |s| Yast::Pkg.ResolvableInstall(s.name, s.kind) } end + def proposal + @proposal ||= Proposal.new.tap do |proposal| + proposal.on_issues_change { update_issues } + end + end + private # @return [Agama::Config] @@ -474,12 +480,6 @@ def find_initial_product nil end - def proposal - @proposal ||= Proposal.new.tap do |proposal| - proposal.on_issues_change { update_issues } - end - end - def import_gpg_keys gpg_keys = Dir.glob(GPG_KEYS_GLOB).map(&:to_s) logger.info "Importing GPG keys: #{gpg_keys}" diff --git a/service/lib/agama/software/proposal.rb b/service/lib/agama/software/proposal.rb index 3b7e4559f1..031e7ed18c 100644 --- a/service/lib/agama/software/proposal.rb +++ b/service/lib/agama/software/proposal.rb @@ -62,6 +62,9 @@ class Proposal # @return [Array] List of languages to install attr_reader :languages + # @return [Array>>] List of conflicts from the last solver run + attr_reader :conflicts + # Constructor # # @param logger [Logger] @@ -71,6 +74,8 @@ def initialize(logger: nil) @logger = logger || Logger.new($stdout) @base_product = nil @addon_products = [] + @conflicts = [] + @conflicts_change_callbacks = [] end # Adds the given list of resolvables to the proposal @@ -109,6 +114,7 @@ def solve_dependencies res = Yast::Pkg.PkgSolve(unused = true) logger.info "Solver run #{res.inspect}" update_issues + update_conflicts return true if res @@ -118,6 +124,31 @@ def solve_dependencies false end + # @param [Array<(Integer, Integer)>] solutions is array of conflict id and solution id + def solve_conflicts(solutions) + pkg_solutions = solutions.map do |sol| + con_id, sol_id = sol + conflict = @conflicts[con_id] or raise "Unknown conflict id #{con_id.inspect}" + solution = conflict["solutions"][sol_id] or raise "unknown solution id #{sol_id.inspect}" + { + "description" => conflict["description"], + "details" => conflict["details"], + "solution_description" => solution["description"], + "solution_details" => solution["details"] + } + end + logger.info "Sending solver solutions #{pkg_solutions.inspect}" + + Yast::Pkg.PkgSetSolveSolutions(pkg_solutions) + + # and rerun solver to also update conflicts + solve_dependencies + end + + def on_conflicts_change(&block) + @conflicts_change_callbacks << block + end + # Returns the count of packages to install # # @return [Integer] count of packages to install @@ -199,7 +230,6 @@ def select_addon_products def update_issues msgs = [] msgs.concat(warning_messages(proposal)) if proposal - msgs.concat(solver_messages) issues = msgs.map do |msg| Issue.new(msg, @@ -207,7 +237,14 @@ def update_issues severity: Issue::Severity::ERROR) end - self.issues = issues + solver_issues = solver_messages.map do |msg| + Issue.new(msg, + source: Issue::Source::CONFIG, + severity: Issue::Severity::ERROR, + kind: :solver) + end + + self.issues = issues + solver_issues end # Extracts the warning messages from the proposal result @@ -229,12 +266,27 @@ def solver_messages solve_errors = Yast::Pkg.PkgSolveErrors return [] if solve_errors.zero? - last_error = Yast::Pkg.LastError res = [] - res << last_error unless last_error.empty? res << (_("Found %s dependency issues.") % solve_errors) if solve_errors > 0 res end + + def update_conflicts + pkg_conflicts = Yast::Pkg.PkgSolveProblems + @conflicts = [] + pkg_conflicts.each_with_index do |pkg_conflict, index| + conflict = pkg_conflict + conflict["id"] = index + conflict["solutions"].each_with_index do |solution, index2| + solution["id"] = index2 + end + @conflicts << conflict + end + + @conflicts_change_callbacks.each { |c| c.call(@conflicts) } + + @conflicts + end end end end diff --git a/service/test/agama/software/proposal_test.rb b/service/test/agama/software/proposal_test.rb index de71872b95..0aba698009 100644 --- a/service/test/agama/software/proposal_test.rb +++ b/service/test/agama/software/proposal_test.rb @@ -91,13 +91,11 @@ end context "when solver errors are reported" do - let(:last_error) { "Solving errors..." } let(:solve_errors) { 5 } it "registers them as issues" do subject.calculate expect(subject.issues).to contain_exactly( - an_object_having_attributes(description: "Solving errors..."), an_object_having_attributes(description: "Found 5 dependency issues.") ) end diff --git a/service/test/agama/ssl/certificate_details_spec.rb b/service/test/agama/ssl/certificate_details_spec.rb index 77508bd778..2df4149977 100755 --- a/service/test/agama/ssl/certificate_details_spec.rb +++ b/service/test/agama/ssl/certificate_details_spec.rb @@ -56,10 +56,5 @@ ) # rubocop:enable Layout/TrailingWhitespace end - - it "can optionaly limit line lenght to fit terminal width" do - # the longest line still fits 80 chars wide terminal - expect(subject.summary(small_space: true).split("\n").map(&:size).max).to be < 80 - end end end diff --git a/web/src/api/software.ts b/web/src/api/software.ts index e83768d6f6..d74b34d5c5 100644 --- a/web/src/api/software.ts +++ b/web/src/api/software.ts @@ -22,6 +22,8 @@ import { AddonInfo, + Conflict, + ConflictSolution, License, LicenseContent, Pattern, @@ -32,7 +34,7 @@ import { SoftwareConfig, SoftwareProposal, } from "~/types/software"; -import { get, post, put } from "~/api/http"; +import { get, patch, post, put } from "~/api/http"; /** * Returns the software configuration @@ -86,6 +88,11 @@ const fetchPatterns = (): Promise => get("/api/software/patterns"); */ const fetchRepositories = (): Promise => get("/api/software/repositories"); +/** + * Returns the list of conflicts + */ +const fetchConflicts = (): Promise => get("/api/software/conflicts"); + /** * Updates the software configuration * @@ -110,9 +117,15 @@ const register = ({ key, email }: { key: string; email?: string }) => const registerAddon = (addon: RegisteredAddonInfo) => post("/api/software/registration/addons/register", addon); +/** + * Request for solving a conflict by applying given solution + */ +const solveConflict = (solution: ConflictSolution) => patch("/api/software/conflicts", [solution]); + export { fetchAddons, fetchConfig, + fetchConflicts, fetchLicense, fetchLicenses, fetchPatterns, @@ -124,5 +137,6 @@ export { probe, register, registerAddon, + solveConflict, updateConfig, }; diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index c70db24dbf..57ad1574b5 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -355,6 +355,10 @@ .pf-v6-c-button { --pf-v6-c-button--BorderRadius: var(--pf-t--global--border--radius--small); + + &:disabled { + fill: currentcolor; + } } .pf-v6-c-menu-toggle.pf-m-split-button > :first-child { diff --git a/web/src/components/core/IssuesAlert.test.tsx b/web/src/components/core/IssuesAlert.test.tsx index 0e9337fff2..9df795e754 100644 --- a/web/src/components/core/IssuesAlert.test.tsx +++ b/web/src/components/core/IssuesAlert.test.tsx @@ -24,13 +24,30 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import { IssuesAlert } from "~/components/core"; +import { Issue, IssueSeverity, IssueSource } from "~/types/issues"; +import { SOFTWARE } from "~/routes/paths"; -it("renders a list of issues", () => { - const issue = { - description: "You need to create a user", - source: "config", - severity: "error", - }; - plainRender(); - expect(screen.getByText(issue.description)).toBeInTheDocument(); +describe("IssueAlert", () => { + it("renders a list of issues", () => { + const issue: Issue = { + description: "A generic issue", + source: IssueSource.Config, + severity: IssueSeverity.Error, + kind: "generic", + }; + plainRender(); + expect(screen.getByText(issue.description)).toBeInTheDocument(); + }); + + it("renders a link to conflict resolution when there is a 'solver' issue", () => { + const issue: Issue = { + description: "Conflicts found", + source: IssueSource.Config, + severity: IssueSeverity.Error, + kind: "solver", + }; + plainRender(); + const link = screen.getByRole("link", { name: "Review and fix" }); + expect(link).toHaveAttribute("href", SOFTWARE.conflicts); + }); }); diff --git a/web/src/components/core/IssuesAlert.tsx b/web/src/components/core/IssuesAlert.tsx index 35663eced9..18c635aefd 100644 --- a/web/src/components/core/IssuesAlert.tsx +++ b/web/src/components/core/IssuesAlert.tsx @@ -24,6 +24,8 @@ import React from "react"; import { Alert, List, ListItem } from "@patternfly/react-core"; import { _ } from "~/i18n"; import { Issue } from "~/types/issues"; +import Link from "./Link"; +import { PATHS } from "~/routes/software"; export default function IssuesAlert({ issues }) { if (issues === undefined || issues.length === 0) return; @@ -35,7 +37,17 @@ export default function IssuesAlert({ issues }) { > {issues.map((i: Issue, idx: number) => ( - {i.description} + + {i.description}{" "} + {i.kind === "solver" && ( + + { + // TRANSLATORS: Clickable link to show and resolve package dependency conflicts + _("Review and fix") + } + + )} + ))} diff --git a/web/src/components/layout/Icon.tsx b/web/src/components/layout/Icon.tsx index 6b8fe9adbb..9c6e5edf0d 100644 --- a/web/src/components/layout/Icon.tsx +++ b/web/src/components/layout/Icon.tsx @@ -28,6 +28,8 @@ import Apps from "@icons/apps.svg?component"; import AppRegistration from "@icons/app_registration.svg?component"; import Backspace from "@icons/backspace.svg?component"; import CheckCircle from "@icons/check_circle.svg?component"; +import ChevronLeft from "@icons/chevron_left.svg?component"; +import ChevronRight from "@icons/chevron_right.svg?component"; import Delete from "@icons/delete.svg?component"; import EditSquare from "@icons/edit_square.svg?component"; import Error from "@icons/error.svg?component"; @@ -50,6 +52,8 @@ import NetworkWifi1Bar from "@icons/network_wifi_1_bar.svg?component"; import NetworkWifi3Bar from "@icons/network_wifi_3_bar.svg?component"; import Translate from "@icons/translate.svg?component"; import SettingsEthernet from "@icons/settings_ethernet.svg?component"; +import UnfoldLess from "@icons/unfold_less.svg?component"; +import UnfoldMore from "@icons/unfold_more.svg?component"; import Warning from "@icons/warning.svg?component"; import Visibility from "@icons/visibility.svg?component"; import VisibilityOff from "@icons/visibility_off.svg?component"; @@ -61,6 +65,8 @@ const icons = { app_registration: AppRegistration, backspace: Backspace, check_circle: CheckCircle, + chevron_left: ChevronLeft, + chevron_right: ChevronRight, delete: Delete, edit_square: EditSquare, error: Error, @@ -81,10 +87,12 @@ const icons = { network_wifi: NetworkWifi, network_wifi_1_bar: NetworkWifi1Bar, network_wifi_3_bar: NetworkWifi3Bar, + settings_ethernet: SettingsEthernet, translate: Translate, + unfold_less: UnfoldLess, + unfold_more: UnfoldMore, visibility: Visibility, visibility_off: VisibilityOff, - settings_ethernet: SettingsEthernet, warning: Warning, wifi: Wifi, wifi_off: WifiOff, diff --git a/web/src/components/software/SoftwareConflicts.test.tsx b/web/src/components/software/SoftwareConflicts.test.tsx new file mode 100644 index 0000000000..94f194741d --- /dev/null +++ b/web/src/components/software/SoftwareConflicts.test.tsx @@ -0,0 +1,318 @@ +/* + * 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, within } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import { Conflict } from "~/types/software"; +import SoftwareConflicts from "./SoftwareConflicts"; + +const conflicts = [ + { + id: 0, + description: + "the to be installed busybox-gawk-1.37.0-33.4.noarch conflicts with 'gawk' provided by the to be installed gawk-5.3.2-1.1.x86_64", + details: null, + solutions: [ + { + id: 0, + description: "Following actions will be done:", + details: + "do not install gawk-5.3.2-1.1.x86_64\ndo not install kernel-default-6.14.4-1.1.x86_64\ndo not install pattern:selinux-20241218-9.1.x86_64", + }, + { + id: 1, + description: "do not install busybox-gawk-1.37.0-33.4.noarch", + details: null, + }, + ], + }, + { + id: 1, + description: + "the to be installed tuned-2.25.1.0+git.889387b-3.1.noarch conflicts with 'tlp' provided by the to be installed tlp-1.8.0-1.1.noarch", + details: null, + solutions: [ + { + id: 0, + description: "do not install tuned-2.25.1.0+git.889387b-3.1.noarch", + details: null, + }, + { + id: 1, + description: "do not install tlp-1.8.0-1.1.noarch", + details: null, + }, + ], + }, + { + id: 2, + description: + "the to be installed pattern:microos_ra_verifier-5.0-98.1.x86_64 requires 'patterns-microos-ra_verifier', but this requirement cannot be provided", + details: + "not installable providers: patterns-microos-ra_verifier-5.0-98.1.x86_64[https-download.opensuse.org-6594e038]", + solutions: [ + { + id: 0, + description: "do not install pattern:microos_ra_verifier-5.0-98.1.x86_64", + details: null, + }, + { + id: 1, + description: "do not install pattern:microos_ra_agent-5.0-98.1.x86_64", + details: null, + }, + { + id: 2, + description: + "break pattern:microos_ra_verifier-5.0-98.1.x86_64 by ignoring some of its dependencies", + details: null, + }, + ], + }, +]; + +let mockConflicts: Conflict[]; +const mockSolveConflict = jest.fn(); + +jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( +
ProductRegistrationAlert Mock
+)); + +jest.mock("~/queries/software", () => ({ + ...jest.requireActual("~/queries/software"), + useConflicts: () => mockConflicts, + useConflictsMutation: () => ({ mutate: mockSolveConflict }), +})); + +describe("SofwareConflicts", () => { + beforeEach(() => { + mockConflicts = [{ ...conflicts[0] }]; + }); + + it("does not render the conflicts toolbar", () => { + installerRender(); + expect(screen.queryByText(/Multiple conflicts found/)).toBeNull(); + expect(screen.queryByText(/any order/)).toBeNull(); + expect(screen.queryByText(/resolve others/)).toBeNull(); + expect(screen.queryByText("1 of 3")).toBeNull(); + expect(screen.queryByRole("button", { name: "Skip to previous" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Skip to next" })).toBeNull(); + }); + + it("allows applying the selected solution", async () => { + const { user } = installerRender(); + const applyButton = screen.getByRole("button", { name: "Apply selected solution" }); + const secondOption = screen.getByRole("radio", { + name: conflicts[0].solutions[1].description, + }); + await user.click(secondOption); + await user.click(applyButton); + expect(mockSolveConflict).toHaveBeenCalledWith({ conflictId: 0, solutionId: 1 }); + }); + + it("displays an error if no solution is selected before submission", async () => { + const { user } = installerRender(); + const applyButton = screen.getByRole("button", { name: "Apply selected solution" }); + const firstSolution = screen.getAllByRole("radio")[0]; + await user.click(applyButton); + screen.getByText("Warning alert:"); + screen.getByText("Select a solution to continue"); + await user.click(firstSolution); + await user.click(applyButton); + expect(screen.queryByText("Warning alert:")).toBeNull(); + expect(screen.queryByText("Select a solution to continue")).toBeNull(); + }); + + describe("when a conflict solution has details", () => { + beforeEach(() => { + mockConflicts = [ + { + id: 0, + description: "Fake conflict", + details: null, + solutions: [ + { + id: 0, + description: `Fake solution with details`, + details: "Action 1\nAction 2", + }, + ], + }, + ]; + }); + + it("renders details in a list, splitting by newline", () => { + installerRender(); + const details = screen.getByRole("list"); + within(details).getByText("Action 1"); + within(details).getByText("Action 2"); + }); + + describe("and the number of details is within the visible limit", () => { + it("does not render a toggle to show/hide more", () => { + installerRender(); + expect(screen.queryByRole("button", { name: /^Show.*actions$"/ })).toBeNull(); + }); + }); + + describe("but the number of details exceeds the visible limit", () => { + beforeEach(() => { + mockConflicts = [ + { + id: 0, + description: "Fake conflict", + details: null, + solutions: [ + { + id: 0, + description: `Fake solution with details`, + details: "Action 1\nAction 2\nAction 3\nAction 4", + }, + ], + }, + ]; + }); + + it("renders a toggle to show/hide all actions", async () => { + const { user } = installerRender(); + const actionsToggle = screen.getByRole("button", { name: /^Show.*actions$/ }); + const details = screen.getByRole("list"); + within(details).getByText("Action 1"); + within(details).getByText("Action 2"); + within(details).getByText("Action 3"); + expect(within(details).queryByText("Action 4")).toBeNull(); + await user.click(actionsToggle); + within(details).getByText("Action 4"); + expect(actionsToggle).toHaveTextContent("Show less actions"); + await user.click(actionsToggle); + expect(within(details).queryByText("Action 4")).toBeNull(); + }); + }); + }); + + describe("when there is more than one conflict", () => { + beforeEach(() => { + mockConflicts = conflicts; + }); + + it("renders the conflicts toolbar with information and links", () => { + installerRender(); + screen.getByText(/Multiple conflicts found/); + screen.getByText(/any order/); + screen.getByText(/resolve others/); + screen.getByText("1 of 3"); + screen.getByRole("button", { name: "Skip to previous" }); + screen.getByRole("button", { name: "Skip to next" }); + }); + + it("allows navigating between conflicts without exceeding bounds", async () => { + const { user } = installerRender(); + screen.getByText("1 of 3"); + const skipToPrevious = screen.getByRole("button", { name: "Skip to previous" }); + const skipToNext = screen.getByRole("button", { name: "Skip to next" }); + + expect(skipToPrevious).toBeDisabled(); + expect(skipToNext).not.toBeDisabled(); + + await user.click(skipToPrevious); + screen.getByText("1 of 3"); + screen.getByText(conflicts[0].description); + await user.click(skipToNext); + expect(skipToPrevious).not.toBeDisabled(); + expect(skipToNext).not.toBeDisabled(); + screen.getByText("2 of 3"); + expect(screen.queryByText(conflicts[0].description)).toBeNull(); + screen.getByText(conflicts[1].description); + await user.click(skipToNext); + screen.getByText("3 of 3"); + expect(screen.queryByText(conflicts[1].description)).toBeNull(); + screen.getByText(conflicts[2].description); + expect(skipToPrevious).not.toBeDisabled(); + expect(skipToNext).toBeDisabled(); + await user.click(skipToNext); + screen.getByText("3 of 3"); + }); + + it("does not preserve the selected option after navigating", async () => { + const { user } = installerRender(); + screen.getByText("1 of 3"); + const skipToPrevious = screen.getByRole("button", { name: "Skip to previous" }); + const skipToNext = screen.getByRole("button", { name: "Skip to next" }); + + screen.getByText("1 of 3"); + screen.getByText(conflicts[0].description); + let options = screen.getAllByRole("radio", { checked: false }); + expect(options.length).toBe(conflicts[0].solutions.length); + + await user.click(options[0]); + expect(options[0]).toBeChecked(); + + await user.click(skipToNext); + screen.getByText("2 of 3"); + screen.getByText(conflicts[1].description); + options = screen.getAllByRole("radio", { checked: false }); + expect(options.length).toBe(conflicts[1].solutions.length); + expect(options[0]).not.toBeChecked(); + + await user.click(options[0]); + expect(options[0]).toBeChecked(); + + await user.click(skipToPrevious); + options = screen.getAllByRole("radio", { checked: false }); + expect(options.length).toBe(conflicts[0].solutions.length); + expect(options[0]).not.toBeChecked(); + }); + + it("allows applying the selected solution for the current conflict", async () => { + const { user } = installerRender(); + const skipToNext = screen.getByRole("button", { name: "Skip to next" }); + + await user.click(skipToNext); + const applyButton = screen.getByRole("button", { name: "Apply selected solution" }); + const secondOption = screen.getByRole("radio", { + name: conflicts[1].solutions[1].description, + }); + await user.click(secondOption); + await user.click(applyButton); + expect(mockSolveConflict).toHaveBeenCalledWith({ conflictId: 1, solutionId: 1 }); + }); + }); + + describe("when there are no conflicts", () => { + beforeEach(() => { + mockConflicts = []; + }); + + it("does not render the solution selection form", () => { + installerRender(); + expect(screen.queryAllByRole("radio").length).toBe(0); + expect(screen.queryByRole("button", { name: "Apply selected solution" })).toBeNull(); + }); + + it("renders a message indicating there are no conflicts to address", () => { + installerRender(); + screen.queryByRole("heading", { name: "No conflicts to address" }); + screen.getByText(/All conflicts have been resolved, or none were detected/); + }); + }); +}); diff --git a/web/src/components/software/SoftwareConflicts.tsx b/web/src/components/software/SoftwareConflicts.tsx new file mode 100644 index 0000000000..e076129958 --- /dev/null +++ b/web/src/components/software/SoftwareConflicts.tsx @@ -0,0 +1,300 @@ +/* + * 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, { useState } from "react"; +import { + ActionGroup, + Alert, + Button, + ButtonProps, + Content, + Divider, + Flex, + Form, + FormGroup, + List, + ListItem, + Radio, + RadioProps, + Title, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, +} from "@patternfly/react-core"; +import { Icon } from "~/components/layout"; +import { Page, SubtleContent } from "~/components/core"; +import { ConflictSolutionOption } from "~/types/software"; +import { useConflicts, useConflictsChanges, useConflictsMutation } from "~/queries/software"; +import { isEmpty } from "~/utils"; +import { sprintf } from "sprintf-js"; +import { _ } from "~/i18n"; + +/** + * Renders a list of conflict details as a bullet list. + * Used to display all actions associated with a conflict solution. + * + * @param props - Component props. + * @param props.items - An array of strings representing the detail items to display. + */ +const DetailsList = ({ items }: { items: string[] }) => ( + + {items.map((d, i) => ( + + {d} + + ))} + +); + +type ConflictSolutionRadioProps = { + /** Newline-separated string of solution actions */ + details?: ConflictSolutionOption["details"]; + /** Max number of visible detail lines before enabling toggle behavior */ + maxVisibleDetails?: number; +} & Omit; + +/** + * A custom wrapper around PatternFly's Radio component for presenting a + * conflict solution option. Optionally displays additional details or actions + * with an expandable/collapsible list. + * + * Behavior: + * - If no details are provided, a plain radio button is rendered. + * - If a small number of details exist, they are shown directly. + * - If there are more than `maxVisibleDetails` (default 3), the list is + * collapsible. + */ +const ConflictSolutionRadio = ({ + details: rawDetails, + maxVisibleDetails = 3, + ...props +}: ConflictSolutionRadioProps) => { + const [expanded, setExpanded] = useState(false); + const details = rawDetails ? rawDetails?.split("\n") : []; + + if (details.length === 0) return ; + if (details.length <= maxVisibleDetails) + return } />; + + const visibleDetails = expanded ? details : details.slice(0, maxVisibleDetails); + const toggleText = expanded ? _("Show less actions") : _("Show more actions"); + const toggleIcon = expanded ? "unfold_less" : "unfold_more"; + const toggleVisibility = () => setExpanded(!expanded); + + return ( + + + + + } + {...props} + /> + ); +}; + +/** + * Internal component responsible of rendering the form to allow users choose + * and eventually apply a solution for given conflict + */ +const ConflictsForm = ({ conflict }): React.ReactNode => { + const { mutate: solve } = useConflictsMutation(); + const [error, setError] = useState(null); + const [chosenSolution, setChosenSolution] = useState(); + + const onSubmit = async (e) => { + e.preventDefault(); + + if (!isEmpty(chosenSolution)) { + setError(null); + solve({ conflictId: conflict.id, solutionId: chosenSolution }); + } else { + setError(_("Select a solution to continue")); + } + }; + + return ( +
+ {error && } + {conflict.description} + + {conflict.solutions.map((solution: ConflictSolutionOption) => ( + {solution.description}} + onChange={() => setChosenSolution(solution.id)} + isChecked={solution.id === chosenSolution} + details={solution.details} + /> + ))} + + + {_("Apply selected solution")} + + + ); +}; + +type ConflictsToolbarProps = { + current: number; + total: number; + onNext: ButtonProps["onClick"]; + onBack: ButtonProps["onClick"]; +}; +const ConflictsToolbar = ({ + current, + total, + onNext, + onBack, +}: ConflictsToolbarProps): React.ReactNode => ( + + + + + {_( + "Multiple conflicts found. You can address them in any order, and resolving one may resolve others.", + )} + + + + + + + + + { + // TRANSLATORS: This is a short status message like "1 of 3". It + // indicates the position of the current item out of a total. The + // first %d will be replaced with the current item number, the + // second %d with the total number of items. + sprintf(_("%d of %d"), current, total) + } + + + + + + + +); + +/** + * Displays content when there are no conflicts to resolve. + * Typically shown when user lands on this page with no actionable items. + */ +const NoConflictsContent = () => ( + <> + {_("No conflicts to address")} + + {_( + "All conflicts have been resolved, or none were detected. You can safely continue with your setup.", + )} + + +); + +/** + * Main content component to display and navigate between multiple conflicts. + * + * Renders a toolbar (if more than one conflict), and the conflict resolution form. + * + * It uses a `key` prop for forcing an state reset when navigating back and + * forward. + * + * See https://react.dev/learn/preserving-and-resetting-state#option-2-resetting-state-with-a-key + */ +const ConflictsContent = ({ conflicts }) => { + const [currentConflictIndex, setCurrentConflictIndex] = useState(0); + const totalConflicts = conflicts.length; + const lastConflictIndex = totalConflicts - 1; + + const onNext = async () => { + currentConflictIndex < lastConflictIndex && setCurrentConflictIndex(currentConflictIndex + 1); + }; + const onBack = async () => { + currentConflictIndex > 0 && setCurrentConflictIndex(currentConflictIndex - 1); + }; + + const currentConflict = conflicts[currentConflictIndex]; + + return ( + <> + {conflicts.length > 1 && ( + <> + + + + )} + + + ); +}; + +/** + * Top-level page for handling software conflicts resolution. + * + * Displays either a resolution form or a message when no conflicts are present. + */ +function SoftwareConflicts(): React.ReactNode { + useConflictsChanges(); + const conflicts = useConflicts(); + + return ( + + + {_("Software conflicts resolution")} + + + + {conflicts.length > 0 ? : } + + + + {_("Close")} + + + ); +} + +export default SoftwareConflicts; diff --git a/web/src/queries/software.ts b/web/src/queries/software.ts index d4a5410e64..837fb73275 100644 --- a/web/src/queries/software.ts +++ b/web/src/queries/software.ts @@ -32,6 +32,7 @@ import { import { useInstallerClient } from "~/context/installer"; import { AddonInfo, + Conflict, License, Pattern, PatternsSelection, @@ -46,6 +47,7 @@ import { import { fetchAddons, fetchConfig, + fetchConflicts, fetchLicenses, fetchPatterns, fetchProducts, @@ -56,6 +58,7 @@ import { probe, register, registerAddon, + solveConflict, updateConfig, } from "~/api/software"; import { QueryHookOptions } from "~/types/queries"; @@ -143,6 +146,14 @@ const repositoriesQuery = () => ({ queryFn: fetchRepositories, }); +/** + * Query to retrieve conflicts + */ +const conflictsQuery = () => ({ + queryKey: ["software", "conflicts"], + queryFn: fetchConflicts, +}); + /** * Hook that builds a mutation to update the software configuration * @@ -323,6 +334,29 @@ const useRepositories = (): Repository[] => { return repositories; }; +/** + * Returns conclifts info + */ +const useConflicts = (): Conflict[] => { + const { data: conflicts } = useSuspenseQuery(conflictsQuery()); + return conflicts; +}; + +/** + * Hook that builds a mutation for solving a conflict + */ +const useConflictsMutation = () => { + const queryClient = useQueryClient(); + + const query = { + mutationFn: solveConflict, + onSuccess: async () => { + queryClient.invalidateQueries({ queryKey: conflictsQuery().queryKey }); + }, + }; + return useMutation(query); +}; + /** * Hook that returns a useEffect to listen for software proposal events * @@ -367,12 +401,34 @@ const useProposalChanges = () => { }, [client, queryClient]); }; +/** + * Hook that registers a useEffect to listen for conflicts changes + * + */ +const useConflictsChanges = () => { + const client = useInstallerClient(); + const queryClient = useQueryClient(); + React.useEffect(() => { + if (!client) return; + + return client.onEvent((event) => { + if (event.type === "ConflictsChanged") { + const { conflicts } = event; + queryClient.setQueryData([conflictsQuery().queryKey], conflicts); + } + }); + }); +}; + export { configQuery, productsQuery, selectedProductQuery, useAddons, useConfigMutation, + useConflicts, + useConflictsMutation, + useConflictsChanges, useLicenses, usePatterns, useProduct, diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts index ed44abd311..1c135c13fc 100644 --- a/web/src/routes/paths.ts +++ b/web/src/routes/paths.ts @@ -68,6 +68,7 @@ const USER = { const SOFTWARE = { root: "/software", patternsSelection: "/software/patterns/select", + conflicts: "/software/conflicts", }; const STORAGE = { diff --git a/web/src/routes/software.tsx b/web/src/routes/software.tsx index 2795a0c38f..e35800bce5 100644 --- a/web/src/routes/software.tsx +++ b/web/src/routes/software.tsx @@ -26,6 +26,7 @@ import SoftwarePatternsSelection from "~/components/software/SoftwarePatternsSel import { Route } from "~/types/routes"; import { SOFTWARE as PATHS } from "~/routes/paths"; import { N_ } from "~/i18n"; +import SoftwareConflicts from "~/components/software/SoftwareConflicts"; const routes = (): Route => ({ path: PATHS.root, @@ -42,6 +43,10 @@ const routes = (): Route => ({ path: PATHS.patternsSelection, element: , }, + { + path: PATHS.conflicts, + element: , + }, ], }); diff --git a/web/src/types/issues.ts b/web/src/types/issues.ts index 891780c392..827ad256bc 100644 --- a/web/src/types/issues.ts +++ b/web/src/types/issues.ts @@ -59,8 +59,8 @@ type Issue = { description: string; /** Issue kind **/ kind: string; - /** Issue details. It is not mandatory. */ - details: string | undefined; + /** Issue details */ + details?: string; /** Where the issue comes from */ source: IssueSource; /** How severe is the issue */ diff --git a/web/src/types/software.ts b/web/src/types/software.ts index 2cf757fc63..8d33e05628 100644 --- a/web/src/types/software.ts +++ b/web/src/types/software.ts @@ -129,9 +129,30 @@ type RegisteredAddonInfo = { registrationCode: string; }; +type ConflictSolutionOption = { + id: number; + description: string; + details: string | null; +}; + +type Conflict = { + id: number; + description: string; + details: string | null; + solutions: ConflictSolutionOption[]; +}; + +type ConflictSolution = { + conflictId: Conflict["id"]; + solutionId: ConflictSolutionOption["id"]; +}; + export { SelectedBy }; export type { AddonInfo, + Conflict, + ConflictSolution, + ConflictSolutionOption, License, LicenseContent, Pattern,