diff --git a/web/html/src/branding/css/base/visualization.less b/web/html/src/branding/css/base/visualization.less index 4e84e99279c6..c1d0c15c44c2 100644 --- a/web/html/src/branding/css/base/visualization.less +++ b/web/html/src/branding/css/base/visualization.less @@ -7,10 +7,6 @@ display: inline-block; } -.no-bold { - font-weight: normal!important; -} - .inline-icon { i { width: 1em; @@ -49,7 +45,6 @@ #visualization-filter-wrapper { display: none; - overflow-y: auto; background: lighten(@gray-light, 8%); font-size: 0.9em; border: 1px solid #ddd; @@ -83,10 +78,9 @@ vertical-align: top; padding: 3px 10px; > .filter-title { - max-width: 250px; font-weight: bold; - padding: 4px 2px; margin-right: 4px; + margin-bottom: 4px; } label { font-weight: normal; @@ -124,6 +118,32 @@ .grpCriterion { margin-top: 1px; } + + .btn-group { + margin-top: 8px; + display: block; + } + + .combobox { + display: flex; + align-items: center; + gap: 8px; + width: 250px; + margin-top: 4px; + + > form { + flex: 1; + display: block; + + .form-group { + margin: 0 !important; + } + + .help-block { + display: none !important; + } + } + } } select { width: 100%; diff --git a/web/html/src/components/combobox.tsx b/web/html/src/components/combobox.tsx index f8ddf6dd9b83..50ff06c09e28 100644 --- a/web/html/src/components/combobox.tsx +++ b/web/html/src/components/combobox.tsx @@ -23,6 +23,7 @@ type ComboboxProps = { onFocus?: () => void; onSelect: (value: ComboboxItem) => void; getNewOptionData?: (userInput: string, label: string) => { id: string; value: string; label: string }; + placeholder?: string; /** Id for testing purposes */ "data-testid"?: string; @@ -94,6 +95,7 @@ export class Combobox extends React.Component { menuPortalTarget={document.body} formatCreateLabel={(label: string) => t("Create {label}", { label })} getNewOptionData={this.props.getNewOptionData} + placeholder={this.props.placeholder} {...testAttributes} /> ); diff --git a/web/html/src/manager/visualization/data-tree.ts b/web/html/src/manager/visualization/data-tree.ts index c0c8aaf45ef8..aae6fbb613bf 100644 --- a/web/html/src/manager/visualization/data-tree.ts +++ b/web/html/src/manager/visualization/data-tree.ts @@ -63,19 +63,19 @@ function dataTree(data, container) { function instance() {} - instance.data = function (d) { + instance.data = function (d?: any) { return arguments.length ? ((data = d), instance) : data; }; - instance.preprocessor = function (p) { + instance.preprocessor = function (p?: any): any { return arguments.length ? ((preprocessor = p), instance) : preprocessor; }; - instance.filters = function (f) { + instance.filters = function (f?: any) { return arguments.length ? ((filters = f), instance) : filters; }; - instance.partitioning = function (p) { + instance.partitioning = function (p?: any) { return arguments.length ? ((partitioning = p), instance) : partitioning; }; diff --git a/web/html/src/manager/visualization/hierarchy.tsx b/web/html/src/manager/visualization/hierarchy.tsx index 9510567a4ecb..27029084cc03 100644 --- a/web/html/src/manager/visualization/hierarchy.tsx +++ b/web/html/src/manager/visualization/hierarchy.tsx @@ -4,8 +4,11 @@ import * as d3 from "d3"; import SpaRenderer from "core/spa/spa-renderer"; +import { DateTimePicker } from "components/datetime"; +import { Form, Select } from "components/input"; import { TopPanel } from "components/panels/TopPanel"; +import { LocalizedMoment, localizedMoment } from "utils"; import { DEPRECATED_unsafeEquals } from "utils/legacy"; import Network from "../../utils/network"; @@ -23,28 +26,6 @@ declare global { } } -function displayHierarchy(data) { - // disable the #spacewalk-content observer: - // drawing svg triggers it for every small changes, - // and it is not the desired behaviour/what the observer stands for - // note: leaving it connected slow down the svg usability - spacewalkContentObserver.disconnect(); - - const container = Utils.prepareDom(); - const tree = DataTree.dataTree(data, container); - if (window.view === "grouping") { - // hack - derive preprocessor from global variable - tree.preprocessor(Preprocessing.grouping()); - } - tree.refresh(); - - initUI(tree); - - d3.select(window).on("resize", function () { - Utils.adjustSvgDimensions(); - }); -} - function showFilterTab(tabIdToShow) { d3.selectAll(".filter-tab-selector").classed("active", false); d3.selectAll(".filter-tab").classed("active", false); @@ -53,140 +34,268 @@ function showFilterTab(tabIdToShow) { Utils.adjustSvgDimensions(); } -// util function for adding the UI to the dom and setting its callbacks -function initUI(tree) { - const filterNavTab = d3.select("#visualization-filter-wrapper").append("ul").attr("class", "nav nav-tabs"); - - filterNavTab - .append("li") - .attr("id", "filtering-tab-selector") - .attr("class", "filter-tab-selector active") - .append("a") - .text(t("Filtering")) - .on("click", (d) => { - showFilterTab("filtering-tab"); +// NB! This is a magic constant +const NO_GROUP_LABEL = "** NO GROUP **"; + +type Props = {}; + +type State = { + showFilters: boolean; + hasGroupingFilter: boolean; + tree: ReturnType | undefined; + partitioningDateTime: LocalizedMoment; + systemGroups: any[]; + selectedSystemGroups: string[][]; +}; + +class Hierarchy extends React.Component { + state: State = { + showFilters: false, + hasGroupingFilter: false, + tree: undefined, + partitioningDateTime: localizedMoment(), + systemGroups: [NO_GROUP_LABEL], + selectedSystemGroups: [], + }; + + componentDidMount() { + // Get data & put everything together in the graph! + Network.get(window.endpoint).then( + (data) => jQuery(document).ready(() => this.displayHierarchy(data)), + (xhr) => d3.select("#svg-wrapper").text(t("There was an error fetching data from the server.")) + ); + } + + componentWillUnmount() { + d3.select("#visualization-filter-wrapper").exit().remove(); + d3.select("#svg-wrapper").exit().remove(); + // This was disabled in displayHierarchy + registerSpacewalkContentObservers && registerSpacewalkContentObservers(); + } + + displayHierarchy = (data) => { + // disable the #spacewalk-content observer: + // drawing svg triggers it for every small changes, + // and it is not the desired behaviour/what the observer stands for + // note: leaving it connected slow down the svg usability + spacewalkContentObserver.disconnect(); + + const container = Utils.prepareDom(); + const tree = DataTree.dataTree(data, container); + if (window.view === "grouping") { + // hack - derive preprocessor from global variable + tree.preprocessor(Preprocessing.grouping()); + } + tree.refresh(); + + this.initUI(tree); + + const treeSystemGroups = tree + .data() + .map((e) => e.managed_groups || []) + .reduce((a, b) => a.concat(b)); + const systemGroups = [NO_GROUP_LABEL, "foo", "bar", ...treeSystemGroups]; + this.setState({ tree, systemGroups }); + + d3.select(window).on("resize", function () { + Utils.adjustSvgDimensions(); }); - filterNavTab - .append("li") - .attr("id", "partitioning-tab-selector") - .attr("class", "filter-tab-selector") - .append("a") - .text(t("Partitioning")) - .on("click", (d) => { - showFilterTab("partitioning-tab"); + }; + + initUI = (tree: ReturnType) => { + // Patch count filter + const patchCountsFilter = d3.select("#filtering-tab").append("div").attr("class", "filter"); + + patchCountsFilter.append("div").attr("class", "filter-title").text(t("Show systems with:")); + + // state of the patch status checkboxes: + // [bug fix adv. checked, prod. enhancements checked, security adv. checked] + const patchCountFilterConfig = [false, false, false]; + // create a callback function that + // - updates patchCountFilterConfig at given index, + // - updates the filters based on patchCountFilterConfig + // - refreshes the tree + function patchCountFilterCallback(idx) { + return function (checked) { + patchCountFilterConfig[idx] = checked; + if (!patchCountFilterConfig.includes(true)) { + tree.filters().remove("patch_count_filter"); + } else { + tree.filters().put("patch_count_filter", (d) => { + return ( + Utils.isSystemType(d) && + patchCountFilterConfig // based on the checkboxes state, take into account the patch count + .map((value, index) => value && (d.data.patch_counts || [])[index] > 0) + .reduce((a, b) => a || b, false) + ); + }); + } + tree.refresh(); + }; + } + UI.addCheckbox( + patchCountsFilter, + t("security advisories"), + "fa-shield", + "security-patches", + patchCountFilterCallback(2) + ); + UI.addCheckbox(patchCountsFilter, t("bug fix advisories"), "fa-bug", "bug-patches", patchCountFilterCallback(0)); + UI.addCheckbox( + patchCountsFilter, + t("product enhancement advisories"), + "spacewalk-icon-enhancement", + "minor-patches", + patchCountFilterCallback(1) + ); + + d3.select("#filtering-tab").append("div").attr("id", "filter-systems-box"); + // System name filter + UI.addFilter(d3.select("#filter-systems-box"), t("Filter by system name"), t("e.g., client.nue.sles"), (input) => { + tree.filters().put("name", (d) => d.data.name.toLowerCase().includes(input.toLowerCase())); + tree.refresh(); + }); + + // Base channel filter + UI.addFilter(d3.select("#filter-systems-box"), t("Filter by system base channel"), t("e.g., SLE12"), (input) => { + tree + .filters() + .put("base_channel", (d) => (d.data.base_channel || "").toLowerCase().includes(input.toLowerCase())); + tree.refresh(); }); - d3.select("#visualization-filter-wrapper") - .append("div") - .attr("id", "filtering-tab") - .attr("class", "filter-tab active"); - d3.select("#visualization-filter-wrapper").append("div").attr("id", "partitioning-tab").attr("class", "filter-tab"); - - // Patch count filter - const patchCountsFilter = d3.select("#filtering-tab").append("div").attr("class", "filter"); - - patchCountsFilter.append("div").attr("class", "filter-title no-bold").text(t("Show systems with:")); - - // state of the patch status checkboxes: - // [bug fix adv. checked, prod. enhancements checked, security adv. checked] - const patchCountFilterConfig = [false, false, false]; - // create a callback function that - // - updates patchCountFilterConfig at given index, - // - updates the filters based on patchCountFilterConfig - // - refreshes the tree - function patchCountFilterCallback(idx) { - return function (checked) { - patchCountFilterConfig[idx] = checked; - if (!patchCountFilterConfig.includes(true)) { - tree.filters().remove("patch_count_filter"); - } else { - tree.filters().put("patch_count_filter", (d) => { - return ( - Utils.isSystemType(d) && - patchCountFilterConfig // based on the checkboxes state, take into account the patch count - .map((value, index) => value && (d.data.patch_counts || [])[index] > 0) - .reduce((a, b) => a || b, false) - ); - }); + // Installed products filter + UI.addFilter( + d3.select("#filter-systems-box"), + t("Filter by system installed products"), + t("e.g., SLES"), + (input) => { + if (!input) { + tree.filters().remove("installedProducts"); + } else { + tree + .filters() + .put("installedProducts", (d) => + (d.data.installedProducts || []) + .map((ip) => ip.toLowerCase().includes(input.toLowerCase())) + .reduce((v1, v2) => v1 || v2, false) + ); + } + tree.refresh(); } + ); + + // TODO: Remove + // Partitioning by checkin time + function partitionByCheckin(datetime) { + tree.partitioning().get()["user-partitioning"] = (d) => { + if (DEPRECATED_unsafeEquals(d.data.checkin, undefined)) { + return ""; + } + const firstPartition = d.data.checkin < datetime.getTime(); + d.data.partition = firstPartition; + return firstPartition ? "stroke-red non-checking-in" : "stroke-green checking-in"; + }; tree.refresh(); - }; - } - UI.addCheckbox( - patchCountsFilter, - t("security advisories"), - "fa-shield", - "security-patches", - patchCountFilterCallback(2) - ); - UI.addCheckbox(patchCountsFilter, t("bug fix advisories"), "fa-bug", "bug-patches", patchCountFilterCallback(0)); - UI.addCheckbox( - patchCountsFilter, - t("product enhancement advisories"), - "spacewalk-icon-enhancement", - "minor-patches", - patchCountFilterCallback(1) - ); - - d3.select("#filtering-tab").append("div").attr("id", "filter-systems-box"); - // System name filter - UI.addFilter(d3.select("#filter-systems-box"), t("Filter by system name"), t("e.g., client.nue.sles"), (input) => { - tree.filters().put("name", (d) => d.data.name.toLowerCase().includes(input.toLowerCase())); - tree.refresh(); - }); + } + // TODO: Remove + function clearPartitioning() { + tree.partitioning().get()["user-partitioning"] = (d) => { + return ""; + }; + tree.refresh(); + } + // TODO: Remove + UI.addCheckinTimePartitioningSelect("#partitioning-tab", partitionByCheckin, clearPartitioning); + + // Partitioning by patch existence + const hasPatchesPartitioning = d3.select("#partitioning-tab").append("div").attr("class", "filter"); + + // TODO: Remove + hasPatchesPartitioning + .append("div") + .attr("class", "filter-title") + .text(t("Partition systems based on whether there are patches for them:")); + + // TODO: Remove + function applyPatchesPartitioning() { + tree.partitioning().get()["user-partitioning"] = (d) => { + if (!Utils.isSystemType(d) || DEPRECATED_unsafeEquals(d.data.patch_counts, undefined)) { + return ""; + } + const firstPartition = d.data.patch_counts.filter((pc) => pc > 0).length > 0; + d.data.partition = firstPartition; + return firstPartition ? "stroke-red unpatched" : "stroke-green patched"; + }; + tree.refresh(); + } - // Base channel filter - UI.addFilter(d3.select("#filter-systems-box"), t("Filter by system base channel"), t("e.g., SLE12"), (input) => { - tree.filters().put("base_channel", (d) => (d.data.base_channel || "").toLowerCase().includes(input.toLowerCase())); - tree.refresh(); - }); + const patchesPartitioningButtons = hasPatchesPartitioning.append("div").attr("class", "btn-group"); + UI.addButton(patchesPartitioningButtons, "Apply", applyPatchesPartitioning); + UI.addButton(patchesPartitioningButtons, "Clear", clearPartitioning); + + // TODO: Remove this + // Grouping UI (based on the preprocessor type) + if (tree.preprocessor().groupingConfiguration) { + // we have a processor responding to groupingConfiguration + UI.addGroupSelector( + d3.select("#partitioning-tab"), + tree + .data() + .map((e) => e.managed_groups || []) + .reduce((a, b) => a.concat(b)), + (data) => { + tree.preprocessor().groupingConfiguration(data); + tree.refresh(); + } + ); + } - // Installed products filter - UI.addFilter(d3.select("#filter-systems-box"), t("Filter by system installed products"), t("e.g., SLES"), (input) => { - if (DEPRECATED_unsafeEquals(input, undefined) || DEPRECATED_unsafeEquals(input, "")) { - tree.filters().remove("installedProducts"); + this.setState({ + hasGroupingFilter: !!tree.preprocessor().groupingConfiguration, + }); + }; + + toggleFilters = () => { + const filterBox = jQuery("#visualization-filter-wrapper"); + if (filterBox.hasClass("open")) { + filterBox.removeClass("open").slideUp(Number.MIN_VALUE, () => { + Utils.adjustSvgDimensions(); + }); + this.setState({ showFilters: false }); } else { - tree - .filters() - .put("installedProducts", (d) => - (d.data.installedProducts || []) - .map((ip) => ip.toLowerCase().includes(input.toLowerCase())) - .reduce((v1, v2) => v1 || v2, false) - ); + filterBox.addClass("open").slideDown(Number.MIN_VALUE, () => { + Utils.adjustSvgDimensions(); + }); + this.setState({ showFilters: true }); + } + }; + + // Partitioning filters + partitionByCheckin = () => { + const tree = this.state.tree; + if (!tree) { + return; } - tree.refresh(); - }); - // Partitioning by checkin time - function partitionByCheckin(datetime) { tree.partitioning().get()["user-partitioning"] = (d) => { if (DEPRECATED_unsafeEquals(d.data.checkin, undefined)) { return ""; } - const firstPartition = d.data.checkin < datetime.getTime(); + const referenceDateTime = this.state.partitioningDateTime.valueOf(); + const firstPartition = d.data.checkin < referenceDateTime; d.data.partition = firstPartition; return firstPartition ? "stroke-red non-checking-in" : "stroke-green checking-in"; }; tree.refresh(); - } - function clearPartitioning() { - tree.partitioning().get()["user-partitioning"] = (d) => { - return ""; - }; - tree.refresh(); - } - - UI.addCheckinTimePartitioningSelect("#partitioning-tab", partitionByCheckin, clearPartitioning); - - // Partitioning by patch existence - const hasPatchesPartitioning = d3.select("#partitioning-tab").append("div").attr("class", "filter"); + }; - hasPatchesPartitioning - .append("div") - .attr("class", "filter-title") - .text(t("Partition systems based on whether there are patches for them:")); + applyPatchesPartitioning = () => { + const tree = this.state.tree; + if (!tree) { + return; + } - function applyPatchesPartitioning() { tree.partitioning().get()["user-partitioning"] = (d) => { if (!Utils.isSystemType(d) || DEPRECATED_unsafeEquals(d.data.patch_counts, undefined)) { return ""; @@ -196,64 +305,22 @@ function initUI(tree) { return firstPartition ? "stroke-red unpatched" : "stroke-green patched"; }; tree.refresh(); - } - - const patchesPartitioningButtons = hasPatchesPartitioning.append("div").attr("class", "btn-group"); - UI.addButton(patchesPartitioningButtons, "Apply", applyPatchesPartitioning); - UI.addButton(patchesPartitioningButtons, "Clear", clearPartitioning); - - // Grouping UI (based on the preprocessor type) - if (tree.preprocessor().groupingConfiguration) { - // we have a processor responding to groupingConfiguration - UI.addGroupSelector( - d3.select("#partitioning-tab"), - tree - .data() - .map((e) => e.managed_groups || []) - .reduce((a, b) => a.concat(b)), - (data) => { - tree.preprocessor().groupingConfiguration(data); - tree.refresh(); - } - ); - } -} - -class Hierarchy extends React.Component { - state = { showFilters: false }; - - componentDidMount() { - // Get data & put everything together in the graph! - Network.get(window.endpoint).then( - (data) => jQuery(document).ready(() => displayHierarchy(data)), - (xhr) => d3.select("#svg-wrapper").text(t("There was an error fetching data from the server.")) - ); - } - - componentWillUnmount() { - d3.select("#visualization-filter-wrapper").exit().remove(); - d3.select("#svg-wrapper").exit().remove(); - // This was disabled in displayHierarchy - registerSpacewalkContentObservers && registerSpacewalkContentObservers(); - } + }; - showFilters = () => { - const filterBox = jQuery("#visualization-filter-wrapper"); - if (filterBox.hasClass("open")) { - filterBox.removeClass("open").slideUp("fast", () => { - Utils.adjustSvgDimensions(); - }); - this.setState({ showFilters: false }); - } else { - filterBox.addClass("open").slideDown("fast", () => { - Utils.adjustSvgDimensions(); - }); - this.setState({ showFilters: true }); + clearPartitioning = () => { + const tree = this.state.tree; + if (!tree) { + return; } + + tree.partitioning().get()["user-partitioning"] = (d) => { + return ""; + }; + tree.refresh(); }; render() { - var hurl: string | undefined = undefined; + let hurl: string | undefined = undefined; if (window.title === "Virtualization Hierarchy") { hurl = "reference/systems/visualization-menu.html"; } else if (window.title === "Proxy Hierarchy") { @@ -262,13 +329,123 @@ class Hierarchy extends React.Component { hurl = "reference/systems/visualization-menu.html"; } + const formModel = this.state.selectedSystemGroups.reduce((result, item, index) => { + result[index] = item; + return result; + }, {}); + return ( - -
+
+ +
+
+ {/* TODO: Remove the old tab and update logic */} +
+
+
{t("Partition systems by given check-in time:")}
+
+ this.setState({ partitioningDateTime })} + /> +
+
+ + +
+
+
+
{t("Partition systems based on whether there are patches for them:")}
+
+ + +
+
+
+
{t("Split into groups")}
+ {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} + this.setState({ selectedSystemGroups: [...this.state.selectedSystemGroups, []] })} + > + {t("Add a grouping level")} + + {this.state.selectedSystemGroups.map((item, index) => { + return ( +
+
+