diff --git a/src/app/selectors.js b/src/app/selectors.js index 70e329e0b..fd648d294 100644 --- a/src/app/selectors.js +++ b/src/app/selectors.js @@ -18,7 +18,7 @@ import { @returns {Object|Null} The list of model data or null if none found. */ export const getModelData = (state) => { - if (state.juju && state.juju.modelData) { + if (state?.juju?.modelData) { return state.juju.modelData; } return null; @@ -107,7 +107,7 @@ const getFilteredModelData = (filters) => */ const getUserCredentials = (state) => { let storedMacaroons = null; - if (state.root && state.root.bakery) { + if (state?.root?.bakery) { storedMacaroons = state.root.bakery.storage._store; } return storedMacaroons; diff --git a/src/components/panels/AppsPanel/AppsPanel.js b/src/components/panels/AppsPanel/AppsPanel.js index 45576f1a4..55eb40cbc 100644 --- a/src/components/panels/AppsPanel/AppsPanel.js +++ b/src/components/panels/AppsPanel/AppsPanel.js @@ -1,7 +1,6 @@ import React, { useMemo } from "react"; import { useSelector } from "react-redux"; import { getConfig } from "app/selectors"; -import SlidePanel from "components/SlidePanel/SlidePanel"; import MainTable from "@canonical/react-components/dist/components/MainTable"; import useModelStatus from "hooks/useModelStatus"; @@ -24,7 +23,7 @@ import { import "./_apps-panel.scss"; -export default function AppsPanel({ isActive, onClose, entity }) { +export default function AppsPanel({ entity, panelRowClick }) { // Get model status info const modelStatusData = useModelStatus(); @@ -105,13 +104,13 @@ export default function AppsPanel({ isActive, onClose, entity }) { ); const machinesPanelRows = useMemo( - () => generateMachineRows(filteredModelStatusData), - [filteredModelStatusData] + () => generateMachineRows(filteredModelStatusData, panelRowClick), + [filteredModelStatusData, panelRowClick] ); const unitPanelRows = useMemo( - () => generateUnitRows(filteredModelStatusData, baseAppURL), - [baseAppURL, filteredModelStatusData] + () => generateUnitRows(filteredModelStatusData, panelRowClick), + [filteredModelStatusData, panelRowClick] ); const relationPanelRows = useMemo( @@ -119,42 +118,32 @@ export default function AppsPanel({ isActive, onClose, entity }) { [filteredModelStatusData, baseAppURL] ); - // Check for loading status - const isLoading = !filteredModelStatusData?.applications?.[entity]; - return ( - - <> - {appPanelHeader} -
- - - -
- -
+ <> + {appPanelHeader} +
+ + + +
+ ); } diff --git a/src/components/panels/MachinesPanel/MachinesPanel.js b/src/components/panels/MachinesPanel/MachinesPanel.js index 79ed17fff..bce046ba8 100644 --- a/src/components/panels/MachinesPanel/MachinesPanel.js +++ b/src/components/panels/MachinesPanel/MachinesPanel.js @@ -1,23 +1,21 @@ import React, { useMemo, useCallback } from "react"; -import SlidePanel from "components/SlidePanel/SlidePanel"; import MainTable from "@canonical/react-components/dist/components/MainTable"; +import cloneDeep from "clone-deep"; import useModelStatus from "hooks/useModelStatus"; import { unitTableHeaders, + generateUnitRows, applicationTableHeaders, + generateApplicationRows, } from "pages/Models/Details/generators"; import { generateStatusElement } from "app/utils"; import "./_machines-panel.scss"; -export default function MachinesPanel({ - isActive, - onClose, - entity: machineId, -}) { +export default function MachinesPanel({ entity: machineId, panelRowClick }) { const modelStatusData = useModelStatus(); const machine = modelStatusData?.machines[machineId]; @@ -39,7 +37,7 @@ export default function MachinesPanel({ {machine && (
-
+
Machine '{machineId}' - {machine?.series} @@ -86,35 +84,94 @@ export default function MachinesPanel({ [modelStatusData, machineId, generateMachinesPanelHeader] ); - // Check for loading status - const isLoading = !modelStatusData?.machines; + const filteredModelStatusDataByApp = useCallback( + (machineId) => { + const filteredModelStatusData = cloneDeep(modelStatusData); + filteredModelStatusData && + Object.keys(filteredModelStatusData.applications).forEach( + (application) => { + const units = + filteredModelStatusData.applications[application]?.units; + + if (Object.entries(units).length) { + Object.values(units).forEach((unit) => { + if ( + // Delete any app without a unit matching this machineId... + unit.machine !== machineId || + // ...delete any app without units at all + !Object.entries(units).length + ) { + delete filteredModelStatusData.applications[application]; + } + }); + } else { + delete filteredModelStatusData.applications[application]; + } + } + ); + return filteredModelStatusData; + }, + [modelStatusData] + ); + + const filteredModelStatusDataByUnit = useCallback( + (machineId) => { + const filteredModelStatusData = cloneDeep(modelStatusData); + filteredModelStatusData && + Object.keys(filteredModelStatusData.applications).forEach( + (application) => { + const units = + filteredModelStatusData.applications[application].units; + for (let [key, unit] of Object.entries(units)) { + if (unit.machine !== machineId) { + delete filteredModelStatusData.applications[application].units[ + key + ]; + } + } + } + ); + return filteredModelStatusData; + }, + [modelStatusData] + ); + + // Generate apps table content + const applicationRows = useMemo( + () => + generateApplicationRows( + filteredModelStatusDataByApp(machineId), + panelRowClick + ), + [filteredModelStatusDataByApp, machineId, panelRowClick] + ); + + // Generate units table content + const unitRows = useMemo( + () => + generateUnitRows(filteredModelStatusDataByUnit(machineId), panelRowClick), + [filteredModelStatusDataByUnit, machineId, panelRowClick] + ); return ( - - <> - {machinePanelHeader} -
- - -
- -
+ <> + {machinePanelHeader} +
+ + +
+ ); } diff --git a/src/components/panels/MachinesPanel/_machines-panel.scss b/src/components/panels/MachinesPanel/_machines-panel.scss index b4962c8e8..7e1550cae 100644 --- a/src/components/panels/MachinesPanel/_machines-panel.scss +++ b/src/components/panels/MachinesPanel/_machines-panel.scss @@ -1,6 +1,6 @@ @import "../panels"; -.machine-panel { +.machines-panel { &__id { padding-left: 2rem; text-transform: capitalize; diff --git a/src/components/panels/UnitsPanel/UnitsPanel.js b/src/components/panels/UnitsPanel/UnitsPanel.js index d1fa9cdd2..87950658a 100644 --- a/src/components/panels/UnitsPanel/UnitsPanel.js +++ b/src/components/panels/UnitsPanel/UnitsPanel.js @@ -1,24 +1,62 @@ import React, { useMemo, useCallback } from "react"; -import SlidePanel from "components/SlidePanel/SlidePanel"; import MainTable from "@canonical/react-components/dist/components/MainTable"; +import cloneDeep from "clone-deep"; import useModelStatus from "hooks/useModelStatus"; import { machineTableHeaders, applicationTableHeaders, + generateMachineRows, + generateApplicationRows, } from "pages/Models/Details/generators"; import { generateStatusElement, extractRevisionNumber } from "app/utils"; import "./_units-panel.scss"; -export default function UnitsPanel({ isActive, onClose, entity: unitId }) { +export default function UnitsPanel({ + isActive, + onClose, + entity: unitId, + panelRowClick, +}) { const modelStatusData = useModelStatus(); const appName = unitId?.split("/")[0]; const unit = modelStatusData?.applications[appName]?.units[unitId]; const app = modelStatusData?.applications[appName]; + const filteredModelStatusDataByMachine = useCallback( + (unit) => { + const filteredModelStatusData = cloneDeep(modelStatusData); + if (unit?.machine) { + Object.keys(filteredModelStatusData.machines).forEach((machineId) => { + if (machineId !== unit.machine) { + delete filteredModelStatusData.machines[machineId]; + } + }); + } + return filteredModelStatusData; + }, + [modelStatusData] + ); + + const filteredModelStatusDataByApp = useCallback( + (appName) => { + const filteredModelStatusData = cloneDeep(modelStatusData); + filteredModelStatusData && + Object.keys(filteredModelStatusData.applications).forEach( + (application) => { + if (application !== appName) { + delete filteredModelStatusData.applications[application]; + } + } + ); + return filteredModelStatusData; + }, + [modelStatusData] + ); + // Generate panel header for given entity const generateUnitsPanelHeader = useCallback(() => { return ( @@ -64,40 +102,50 @@ export default function UnitsPanel({ isActive, onClose, entity: unitId }) { ); }, [app, unit, unitId]); - const machinePanelHeader = useMemo( + const unitsPanelHeader = useMemo( () => generateUnitsPanelHeader(modelStatusData?.applications[unitId]), [modelStatusData, unitId, generateUnitsPanelHeader] ); - // Check for loading status - const isLoading = !modelStatusData?.machines; + // Generate machines table content + const machineRows = useMemo( + () => + generateMachineRows( + filteredModelStatusDataByMachine(unit, "machines"), + panelRowClick + ), + [filteredModelStatusDataByMachine, panelRowClick, unit] + ); + + // Generate apps table content + const applicationRows = useMemo( + () => + generateApplicationRows( + filteredModelStatusDataByApp(appName), + panelRowClick + ), + [filteredModelStatusDataByApp, panelRowClick, appName] + ); return ( - - <> - {machinePanelHeader} -
- - -
- -
+ <> + {unitsPanelHeader} +
+ + +
+ ); } diff --git a/src/components/panels/_panels.scss b/src/components/panels/_panels.scss index d79016951..7e40316eb 100644 --- a/src/components/panels/_panels.scss +++ b/src/components/panels/_panels.scss @@ -54,8 +54,4 @@ grid-template-columns: repeat(2, 1fr); } } - - &__table tr { - pointer-events: none; - } } diff --git a/src/pages/Models/Details/ModelDetails.js b/src/pages/Models/Details/ModelDetails.js index 912d895d0..62826305c 100644 --- a/src/pages/Models/Details/ModelDetails.js +++ b/src/pages/Models/Details/ModelDetails.js @@ -1,5 +1,6 @@ -import React, { useEffect, useMemo } from "react"; +import React, { useEffect, useMemo, useCallback } from "react"; import MainTable from "@canonical/react-components/dist/components/MainTable"; +import Spinner from "@canonical/react-components/dist/components/Spinner"; import { useDispatch, useSelector } from "react-redux"; import { useParams } from "react-router-dom"; import { useQueryParams, StringParam, withDefault } from "use-query-params"; @@ -10,6 +11,7 @@ import InfoPanel from "components/InfoPanel/InfoPanel"; import Layout from "components/Layout/Layout"; import Header from "components/Header/Header"; import Terminal from "components/Terminal/Terminal"; +import SlidePanel from "components/SlidePanel/SlidePanel"; import AppsPanel from "components/panels/AppsPanel/AppsPanel"; import MachinesPanel from "components/panels/MachinesPanel/MachinesPanel"; @@ -191,6 +193,13 @@ const ModelDetails = () => { setQuery({ activeView: view }); }; + const panelRowClick = useCallback( + (entityName, entityPanel) => { + return setQuery({ panel: entityPanel, entity: entityName }); + }, + [setQuery] + ); + useEffect(() => { dispatch(collapsibleSidebar(true)); return () => { @@ -207,49 +216,26 @@ const ModelDetails = () => { }, [dispatch, modelUUID, modelStatusData]); const applicationTableRows = useMemo(() => { - const handleAppRowClick = (e) => { - setQuery({ panel: "apps", entity: e.currentTarget.dataset.app }); - }; return generateApplicationRows( modelStatusData, - handleAppRowClick, + panelRowClick, baseAppURL, query?.entity ); - }, [baseAppURL, modelStatusData, setQuery, query]); + }, [baseAppURL, modelStatusData, query, panelRowClick]); const unitTableRows = useMemo(() => { - const handleUnitsRowClick = (e) => { - if (process.env.NODE_ENV !== "production") { - setQuery({ - panel: "units", - entity: e.currentTarget.dataset.unit, - }); - } - }; return generateUnitRows( modelStatusData, - handleUnitsRowClick, + panelRowClick, baseAppURL, query?.entity ); - }, [baseAppURL, modelStatusData, query, setQuery]); + }, [baseAppURL, modelStatusData, query, panelRowClick]); const machinesTableRows = useMemo(() => { - const handleMachineRowClick = (e) => { - if (process.env.NODE_ENV !== "production") { - setQuery({ - panel: "machines", - entity: e.currentTarget.dataset.machine, - }); - } - }; - return generateMachineRows( - modelStatusData, - handleMachineRowClick, - query?.entity - ); - }, [modelStatusData, setQuery, query]); + return generateMachineRows(modelStatusData, panelRowClick, query?.entity); + }, [modelStatusData, panelRowClick, query]); const relationTableRows = useMemo( () => generateRelationRows(modelStatusData, baseAppURL), @@ -276,110 +262,120 @@ const ModelDetails = () => { {modelStatusData ? modelStatusData.model.name : "..."}
- + {modelStatusData && ( + + )}
-
-
- -
- {renderCounts(activeView, modelStatusData)} - {shouldShow("apps", activeView) && ( - - )} - {shouldShow("units", activeView) && ( - - )} - {shouldShow("machines", activeView) && ( - - )} - {shouldShow("relations", activeView) && ( - <> - {shouldShow("relations-title", activeView) && ( -
Relations ({relationTableRows.length})
+ {!modelStatusData ? ( +
+ +
+ ) : ( +
+
+ +
+ {renderCounts(activeView, modelStatusData)} + {shouldShow("apps", activeView) && + applicationTableRows.length > 0 && ( + )} + {shouldShow("units", activeView) && unitTableRows.length > 0 && ( - {shouldShow("relations-title", activeView) && ( -
- Cross-model relations ( - {consumedTableRows.length + offersTableRows.length}) -
- )} - {consumedTableRows.length ? ( - - ) : null} - {offersTableRows.length ? ( + )} + {shouldShow("machines", activeView) && + machinesTableRows.length > 0 && ( - ) : null} - - )} + )} + {shouldShow("relations", activeView) && + relationTableRows.length > 0 && ( + <> + {shouldShow("relations-title", activeView) && ( +
Relations ({relationTableRows.length})
+ )} + + {shouldShow("relations-title", activeView) && ( +
+ Cross-model relations ( + {consumedTableRows.length + offersTableRows.length}) +
+ )} + {consumedTableRows.length ? ( + + ) : null} + {offersTableRows.length ? ( + + ) : null} + + )} +
+ + setQuery(closePanelConfig)} + isLoading={!entity} + className={`${activePanel}-panel`} + > + {activePanel === "apps" && ( + + )} + {activePanel === "machines" && ( + + )} + {activePanel === "units" && ( + + )} +
- setQuery(closePanelConfig)} - /> - setQuery(closePanelConfig)} - /> - setQuery(closePanelConfig)} - /> -
+ )} {generateTerminalComponent(modelUUID, controllerWSHost)} ); diff --git a/src/pages/Models/Details/ModelDetails.test.js b/src/pages/Models/Details/ModelDetails.test.js index 5158155b0..1af3e9663 100644 --- a/src/pages/Models/Details/ModelDetails.test.js +++ b/src/pages/Models/Details/ModelDetails.test.js @@ -31,7 +31,7 @@ describe("ModelDetail Container", () => { ); expect(wrapper.find("Topology").length).toBe(1); - expect(wrapper.find(".model-details__main table").length).toBe(4); + expect(wrapper.find(".model-details__main table").length).toBe(2); }); it("renders the details pane for models shared-with-me", () => { @@ -186,14 +186,10 @@ describe("ModelDetail Container", () => { ); - expect(wrapper.find(".slide-panel.apps-panel").prop("aria-hidden")).toBe( - true - ); + expect(wrapper.find(".slide-panel.apps-panel").length).toBe(0); const applicationRow = wrapper.find(`tr[data-app="${testApp}"]`); applicationRow.simulate("click"); - expect(wrapper.find(".slide-panel.apps-panel").prop("aria-hidden")).toBe( - false - ); + expect(wrapper.find(".slide-panel.apps-panel").length).toBe(1); expect( wrapper.find(".slide-panel.apps-panel .panel-header .entity-name").text() ).toBe("kibana"); @@ -213,14 +209,12 @@ describe("ModelDetail Container", () => { ); - expect( - wrapper.find(".slide-panel.machines-panel").prop("aria-hidden") - ).toBe(true); - const machineRow = wrapper.find(`tr[data-machine="${testMachine}"]`); + expect(wrapper.find(".slide-panel.machines-panel").length).toBe(0); + const machineRow = wrapper.find( + `.model-details__main tr[data-machine="${testMachine}"]` + ); machineRow.simulate("click"); - expect( - wrapper.find(".slide-panel.machines-panel").prop("aria-hidden") - ).toBe(false); + expect(wrapper.find(".slide-panel.machines-panel").length).toBe(1); expect( wrapper .find(".slide-panel.machines-panel .panel-header .entity-name") @@ -242,14 +236,10 @@ describe("ModelDetail Container", () => { ); - expect(wrapper.find(".slide-panel.units-panel").prop("aria-hidden")).toBe( - true - ); + expect(wrapper.find(".slide-panel.units-panel").length).toBe(0); const unitRow = wrapper.find(`tr[data-unit="${testUnit}"]`); unitRow.simulate("click"); - expect(wrapper.find(".slide-panel.units-panel").prop("aria-hidden")).toBe( - false - ); + expect(wrapper.find(".slide-panel.units-panel").length).toBe(1); expect( wrapper.find(".slide-panel.units-panel .panel-header .entity-name").text() ).toBe("kibana/0"); diff --git a/src/pages/Models/Details/_model-details.scss b/src/pages/Models/Details/_model-details.scss index 6d6fcf007..a84615521 100644 --- a/src/pages/Models/Details/_model-details.scss +++ b/src/pages/Models/Details/_model-details.scss @@ -9,6 +9,14 @@ padding-bottom: 3rem; padding-top: 1rem; + &__loading { + align-items: center; + display: flex; + justify-items: center; + min-height: calc(100vh - 48px); + width: 100%; + } + @media (min-width: $breakpoint-medium) { gap: 1rem; grid-template-columns: 230px 1fr; @@ -49,23 +57,21 @@ } } -@mixin model-details-main { - .model-details__main { - .subordinate-row { - border-top: none !important; - } +@mixin model-details-subordinates { + .subordinate-row { + border-top: none !important; + } - .subordinate { - margin-right: 0.5rem; - padding-left: 1.5rem; - position: relative; + .subordinate { + margin-right: 0.5rem; + padding-left: 1.5rem; + position: relative; - &::before { - content: url("../../../static/images/unit-tree.svg"); - left: 0.75rem; - position: absolute; - top: -0.25rem; - } + &::before { + content: url("../../../static/images/unit-tree.svg"); + left: 0.75rem; + position: absolute; + top: -0.25rem; } } } @@ -190,8 +196,8 @@ } .model-details__apps, - [data-enable-panels="true"] .model-details__machines, - [data-enable-panels="true"] .model-details__units { + .model-details__machines, + .model-details__units { tbody tr:hover { background-color: #e7f9ff; cursor: pointer; @@ -214,6 +220,6 @@ @include model-details-layout; @include model-details-header; @include model-details-title; -@include model-details-main; +@include model-details-subordinates; @include model-details-tables; @include model-details-entity-icons; diff --git a/src/pages/Models/Details/generators.js b/src/pages/Models/Details/generators.js index a421fc9d9..e46518222 100644 --- a/src/pages/Models/Details/generators.js +++ b/src/pages/Models/Details/generators.js @@ -183,7 +183,7 @@ export function generateApplicationRows( os: "Ubuntu", notes: "-", }, - onClick: (e) => onRowClick(e, app), + onClick: () => onRowClick(key, "apps"), "data-app": key, className: selectedEntity === key ? "is-selected" : "", }; @@ -250,7 +250,7 @@ export function generateUnitRows( port, message, }, - onClick: (e) => onRowClick(e, unitId), + onClick: () => onRowClick(unitId, "units"), "data-unit": unitId, className: selectedEntity === unitId ? "is-selected" : "", }); @@ -267,7 +267,8 @@ export function generateUnitRows( subordinate.charm, key, true, - baseAppURL + baseAppURL, + true // disable link ), className: "u-truncate", }, @@ -366,7 +367,7 @@ export function generateMachineRows( instanceId: machine.instanceId, message: machine?.agentStatus?.info, }, - onClick: (e) => onRowClick(e, machineId), + onClick: () => onRowClick(machineId, "machines"), "data-machine": machineId, className: selectedEntity === machineId ? "is-selected" : "", }; diff --git a/src/scss/custom/_status_icons.scss b/src/scss/custom/_status_icons.scss index cff73a385..a914ef699 100644 --- a/src/scss/custom/_status_icons.scss +++ b/src/scss/custom/_status_icons.scss @@ -32,13 +32,18 @@ } &.is-running, - &.is-started, - &.is-active { + &.is-started { &::before { color: $color-mid-light; } } + &.is-active { + &::before { + color: $color-positive; + } + } + &.is-unknown { &::before { border: 1px solid $color-mid-light;