sts-sponsors team mailing list archive
-
sts-sponsors team
-
Mailing list archive
-
Message #07405
[Merge] ~petermakowski/maas-site-manager:aggregated-status-MAASENG-1586 into maas-site-manager:main
Peter Makowski has proposed merging ~petermakowski/maas-site-manager:aggregated-status-MAASENG-1586 into maas-site-manager:main.
Commit message:
add aggregated status MAASENG-1586
Requested reviews:
MAAS Committers (maas-committers)
For more details, see:
https://code.launchpad.net/~petermakowski/maas-site-manager/+git/site-manager/+merge/441691
--
Your team MAAS Committers is requested to review the proposed merge of ~petermakowski/maas-site-manager:aggregated-status-MAASENG-1586 into maas-site-manager:main.
diff --git a/frontend/src/App.scss b/frontend/src/App.scss
index 2550ad1..92b941e 100644
--- a/frontend/src/App.scss
+++ b/frontend/src/App.scss
@@ -32,6 +32,7 @@
@include vf-p-icon-status-failed-small;
@include vf-p-icon-status-waiting-small;
@include vf-p-icon-status-succeeded-small;
+@include vf-p-icon-status-queued-small;
@include vf-p-icon-settings;
@include vf-p-icon-machines;
@include vf-p-icon-submit-bug;
@@ -58,6 +59,8 @@
@import "@/components/MainLayout/MainLayout";
@import "@/components/Navigation/Navigation";
@import "@/components/SecondaryNavigation/SecondaryNavigation";
+@import "@/components/Popover/Popover";
+@import "@/components/Meter/Meter";
@import "@/components/TokensCreate/TokensCreate";
@import "@/components/SitesList/SitesTable/SitesTableControls/SitesTableControls";
@import "@/components/SitesList/SitesTable/SitesTableControls/ColumnsVisibilityControl/ColumnsVisibilityControl";
diff --git a/frontend/src/_patterns_icons.scss b/frontend/src/_patterns_icons.scss
index 5fd0e2d..41dedd6 100644
--- a/frontend/src/_patterns_icons.scss
+++ b/frontend/src/_patterns_icons.scss
@@ -15,3 +15,16 @@
@extend %icon;
@include maas-icon-maas-logo();
}
+
+.p-icon--status-ready {
+ @extend %icon;
+ background-image: url("data:image/svg+xml,%3Csvg width='17' height='16' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9 4a4 4 0 110 8 4 4 0 010-8z' fill='#{vf-url-friendly-color(#D3E4ED)}' fill-rule='nonzero'/%3E%3C/svg%3E");
+}
+.p-icon--status-deployed {
+ @extend %icon;
+ @include vf-icon-status-small($color-x-dark);
+}
+.p-icon--status-allocated {
+ @extend %icon;
+ @include vf-icon-status-small($color-information);
+}
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index d8d5ff5..3c986f6 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -3,11 +3,20 @@ export type AccessToken = {
token_type: "bearer";
};
+export type Stats = {
+ allocated_machines: number;
+ deployed_machines: number;
+ ready_machines: number;
+ error_machines: number;
+ last_seen: string; // <ISO 8601 date string>
+ connection: "stable" | "lost" | "unknown";
+};
+
export type Site = {
id: string;
name: string;
url: string; // <full URL including protocol>,
- connection: "stable" | "lost" | "unknown";
+ connection: Stats["connection"];
last_seen: string; // <ISO 8601 date>,
address: {
countrycode: string; // <alpha2 country code>,
@@ -16,12 +25,7 @@ export type Site = {
street: string;
};
timezone: number;
- stats: {
- machines: number;
- occupied_machines: number;
- ready_machines: number;
- error_machines: number;
- };
+ stats: Stats;
};
export type PaginatedQueryResult<D extends unknown> = {
diff --git a/frontend/src/components/Meter/Meter.test.tsx b/frontend/src/components/Meter/Meter.test.tsx
new file mode 100644
index 0000000..b6ac0b1
--- /dev/null
+++ b/frontend/src/components/Meter/Meter.test.tsx
@@ -0,0 +1,139 @@
+import Meter, { defaultSeparatorColor, testIds } from "./Meter";
+
+import { render, screen } from "@/test-utils";
+
+const mockClientRect = ({
+ bottom = 0,
+ height = 0,
+ left = 0,
+ right = 0,
+ toJSON = () => undefined,
+ top = 0,
+ width = 0,
+ x = 0,
+ y = 0,
+}) =>
+ vi.fn(() => {
+ return {
+ bottom,
+ height,
+ left,
+ right,
+ toJSON,
+ top,
+ width,
+ x,
+ y,
+ };
+ });
+
+describe("Meter", () => {
+ it("can be made small", () => {
+ render(<Meter data={[]} small />);
+
+ expect(screen.getByTestId(testIds.container)).toHaveClass("p-meter--small");
+ });
+
+ it("can be given a label", () => {
+ render(<Meter data={[{ value: 1 }, { value: 3 }]} label="Meter label" />);
+
+ expect(screen.getByTestId(testIds.label).textContent).toBe("Meter label");
+ });
+
+ it("can be given a custom empty colour", () => {
+ render(<Meter data={[]} emptyColor="#ABC" />);
+
+ expect(screen.getByTestId(testIds.bar)).toHaveStyle({
+ backgroundColor: "#ABC",
+ });
+ });
+
+ it("can be given custom bar colours", () => {
+ render(
+ <Meter
+ data={[
+ { color: "#AAA", value: 1 },
+ { color: "#BBB", value: 2 },
+ { color: "#CCC", value: 3 },
+ ]}
+ />,
+ );
+ const segments = screen.getAllByTestId(testIds.filled);
+
+ expect(segments[0]).toHaveStyle({ backgroundColor: "#AAA" });
+ expect(segments[1]).toHaveStyle({ backgroundColor: "#BBB" });
+ expect(segments[2]).toHaveStyle({ backgroundColor: "#CCC" });
+ });
+
+ it("changes colour if values exceed given maximum value", () => {
+ render(<Meter data={[{ color: "#ABC", value: 100 }]} max={10} overColor="#DEF" />);
+
+ expect(screen.getByTestId(testIds.meteroverflow)).toHaveStyle({
+ backgroundColor: "#DEF",
+ });
+ });
+
+ it("correctly calculates datum widths", () => {
+ render(
+ <Meter
+ data={[
+ { value: 10 }, // 10/100 = 10%
+ { value: 20 }, // 20/100 = 20%
+ { value: 30 }, // 30/100 = 30%
+ { value: 40 }, // 40/100 = 40%
+ ]}
+ />,
+ );
+ const segments = screen.getAllByTestId(testIds.filled);
+
+ expect(segments[0]).toHaveStyle({ width: "10%" });
+ expect(segments[1]).toHaveStyle({ width: "20%" });
+ expect(segments[2]).toHaveStyle({ width: "30%" });
+ expect(segments[3]).toHaveStyle({ width: "40%" });
+ });
+
+ it("correctly calculates datum positions", () => {
+ render(
+ <Meter
+ data={[
+ { value: 10 }, // 1st = 0%
+ { value: 20 }, // 2nd = 1st width = 10%
+ { value: 30 }, // 3rd = 1st + 2nd width = 30%
+ { value: 40 }, // 4th = 1st + 2nd + 3rd width = 60%
+ ]}
+ />,
+ );
+ const segments = screen.getAllByTestId(testIds.filled);
+
+ expect(segments[0]).toHaveStyle({ left: "0%" });
+ expect(segments[1]).toHaveStyle({ left: "10%" });
+ expect(segments[2]).toHaveStyle({ left: "30%" });
+ expect(segments[3]).toHaveStyle({ left: "60%" });
+ });
+
+ it("can be made segmented", () => {
+ render(<Meter data={[{ value: 2 }]} max={10} segmented />);
+
+ expect(screen.getByTestId(testIds.segments)).toBeInTheDocument();
+ });
+
+ it("can set the segment separator color", () => {
+ render(<Meter data={[{ value: 2 }]} max={10} segmented separatorColor="#abc123" />);
+
+ expect(screen.getByTestId(testIds.segments)).toHaveStyle({
+ background: "rgb(171, 193, 35);",
+ });
+ });
+
+ it("sets segment width to 1px if not enough space to show all segments", () => {
+ // Make width 128px so max number of segments is 64 (1px segment, 1px separator)
+ Element.prototype.getBoundingClientRect = mockClientRect({
+ width: 128,
+ });
+ render(<Meter data={[{ value: 10 }]} max={100} segmented />);
+
+ expect(screen.getByTestId(testIds.segments)).toHaveStyle({
+ background: `repeating-linear-gradient(to right, transparent 0, transparent 1px, ${defaultSeparatorColor} 1px, ${defaultSeparatorColor} 2px );`,
+ });
+ });
+});
diff --git a/frontend/src/components/Meter/Meter.tsx b/frontend/src/components/Meter/Meter.tsx
new file mode 100644
index 0000000..01955f0
--- /dev/null
+++ b/frontend/src/components/Meter/Meter.tsx
@@ -0,0 +1,172 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import * as React from "react";
+
+import { useListener } from "@canonical/react-components";
+import classNames from "classnames";
+
+export const color = {
+ caution: "#F99B11",
+ light: "#F7F7F7",
+ linkFaded: "#D3E4ED",
+ link: "#0066CC",
+ negative: "#C7162B",
+ positiveFaded: "#B7CCB9",
+ positiveMid: "#4DAB4D",
+ positive: "#0E8420",
+} as const;
+
+export const defaultFilledColors = [color.link, color.positive, color.negative, color.caution];
+export const defaultEmptyColor = color.linkFaded;
+export const defaultOverColor = color.caution;
+export const defaultSeparatorColor = color.light;
+const minimumSegmentWidth = 2;
+const separatorWidth = 1;
+
+const calculateWidths = (
+ el: React.MutableRefObject<Element | null>,
+ maximum: number,
+ setSegmentWidth: (size: number) => void,
+) => {
+ const boundingWidth = el?.current?.getBoundingClientRect()?.width || 0;
+ const segmentWidth = boundingWidth > maximum * minimumSegmentWidth ? boundingWidth / maximum : minimumSegmentWidth;
+ setSegmentWidth(segmentWidth);
+};
+
+type MeterDatum = {
+ color?: string;
+ value: number;
+};
+
+type Props = {
+ className?: string;
+ data: MeterDatum[];
+ emptyColor?: string;
+ label?: string | JSX.Element;
+ labelClassName?: string;
+ max?: number;
+ overColor?: string;
+ segmented?: boolean;
+ separatorColor?: string;
+ small?: boolean;
+};
+
+export const testIds = {
+ bar: "meter-bar",
+ container: "meter-container",
+ filled: "meter-filled",
+ label: "meter-label",
+ meteroverflow: "meter-overflow",
+ segments: "meter-segments",
+};
+
+const MeterSegment = ({
+ data,
+ datumWidths,
+ maximum,
+ overColor,
+ segmentWidth,
+ separatorColor,
+}: Props & { datumWidths: number[]; maximum: number; segmentWidth: number }) => {
+ const isOverflowing = () => data.reduce((sum, datum) => sum + datum.value, 0) > maximum;
+
+ const filledStyle = (datum: MeterDatum, i: number) => ({
+ backgroundColor: datum.color,
+ left: `${datumWidths.reduce((leftPos, width, j) => (i > j ? leftPos + width : leftPos), 0)}%`,
+ width: `${datumWidths[i]}%`,
+ });
+
+ const separatorStyle = () => ({
+ background: `repeating-linear-gradient(
+ to right,
+ transparent 0,
+ transparent ${segmentWidth - separatorWidth}px,
+ ${separatorColor} ${segmentWidth - separatorWidth}px,
+ ${separatorColor} ${segmentWidth}px
+ )`,
+ });
+
+ return (
+ <>
+ {isOverflowing() ? (
+ <div
+ className="p-meter__filled"
+ data-testid={testIds.meteroverflow}
+ style={{ backgroundColor: overColor, width: "100%" }}
+ ></div>
+ ) : (
+ data.map((datum, i) => (
+ <div
+ className="p-meter__filled"
+ data-testid={testIds.filled}
+ key={`meter-${i}`}
+ style={filledStyle(datum, i)}
+ ></div>
+ ))
+ )}
+ {segmentWidth > 0 && (
+ <div className="p-meter__separators" data-testid={testIds.segments} style={separatorStyle()} />
+ )}
+ </>
+ );
+};
+
+const MeterLabel = ({ labelClassName, label }: Pick<Props, "labelClassName" | "label">) => {
+ return (
+ <div className={classNames("p-meter__label", labelClassName)} data-testid={testIds.label}>
+ {label}
+ </div>
+ );
+};
+
+const Meter = ({
+ className,
+ data,
+ emptyColor = defaultEmptyColor,
+ label,
+ labelClassName,
+ max,
+ overColor = defaultOverColor,
+ segmented = false,
+ separatorColor = defaultSeparatorColor,
+ small = false,
+}: Props): JSX.Element => {
+ const el = useRef(null);
+ const valueSum = data.reduce((sum, datum) => sum + datum.value, 0);
+ const maximum = max || valueSum;
+ const datumWidths = data.map((datum) => (datum.value / maximum) * 100);
+ const [segmentWidth, setSegmentWidth] = useState(0);
+
+ useEffect(() => {
+ if (segmented) {
+ calculateWidths(el, maximum, setSegmentWidth);
+ }
+ }, [maximum, segmented]);
+
+ const onResize = useCallback(() => {
+ calculateWidths(el, maximum, setSegmentWidth);
+ }, [el, maximum, setSegmentWidth]);
+
+ useListener(window, onResize, "resize", true, segmented);
+
+ return (
+ <div
+ className={classNames(small ? "p-meter--small" : "p-meter", className)}
+ data-testid={testIds.container}
+ ref={el}
+ >
+ <div className="p-meter__bar" data-testid={testIds.bar} style={{ backgroundColor: emptyColor }}>
+ <MeterSegment
+ data={data}
+ datumWidths={datumWidths}
+ maximum={maximum}
+ overColor={overColor}
+ segmentWidth={segmentWidth}
+ separatorColor={separatorColor}
+ />
+ </div>
+ {label && <MeterLabel label={label} labelClassName={labelClassName} />}
+ </div>
+ );
+};
+
+export default Meter;
diff --git a/frontend/src/components/Meter/_Meter.scss b/frontend/src/components/Meter/_Meter.scss
new file mode 100644
index 0000000..2593c24
--- /dev/null
+++ b/frontend/src/components/Meter/_Meter.scss
@@ -0,0 +1,52 @@
+$meter-height: $sp-unit * 1.75;
+$meter-height--small: $sp-unit * 1.5;
+
+.p-meter {
+ margin-bottom: $sp-unit * 1.5;
+ padding-top: $sp-unit * 0.75;
+}
+
+.p-meter__bar {
+ border-radius: $meter-height;
+ height: $meter-height;
+ overflow: hidden;
+ position: relative;
+ width: 100%;
+ border: 1px solid $color-mid;
+}
+
+.p-meter__label {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: -#{$sp-unit * 0.25};
+ padding-top: $sp-unit * 0.5;
+}
+
+.p-meter__filled {
+ height: 100%;
+ position: absolute;
+ width: 0%;
+}
+
+.p-meter__separators {
+ height: 100%;
+ position: absolute;
+ width: 100%;
+ z-index: 1;
+}
+
+.p-meter--small {
+ margin-bottom: $sp-unit * 1.75;
+ padding-top: $sp-unit * 0.75;
+
+ .p-meter__bar {
+ border-radius: $meter-height--small;
+ height: $meter-height--small;
+ margin-bottom: $sp-unit * 0.75;
+ }
+
+ .p-meter__label {
+ margin-bottom: 0;
+ padding-top: 0;
+ }
+}
diff --git a/frontend/src/components/Meter/index.ts b/frontend/src/components/Meter/index.ts
new file mode 100644
index 0000000..c85bb0d
--- /dev/null
+++ b/frontend/src/components/Meter/index.ts
@@ -0,0 +1 @@
+export { default, color } from "./Meter";
diff --git a/frontend/src/components/Popover/Popover.test.tsx b/frontend/src/components/Popover/Popover.test.tsx
new file mode 100644
index 0000000..4b4ab1c
--- /dev/null
+++ b/frontend/src/components/Popover/Popover.test.tsx
@@ -0,0 +1,14 @@
+import Popover from "./Popover";
+
+import { render, screen, userEvent } from "@/test-utils";
+
+it("renders popover content when focused", async () => {
+ render(
+ <Popover content={<span>popover content</span>}>
+ <button type="button">child text</button>
+ </Popover>,
+ );
+ expect(screen.queryByText("popover content")).not.toBeInTheDocument();
+ await userEvent.click(screen.getByText("child text"));
+ expect(screen.getByText("popover content")).toBeInTheDocument();
+});
diff --git a/frontend/src/components/Popover/Popover.tsx b/frontend/src/components/Popover/Popover.tsx
new file mode 100644
index 0000000..5b09d10
--- /dev/null
+++ b/frontend/src/components/Popover/Popover.tsx
@@ -0,0 +1,69 @@
+import { useRef } from "react";
+import type { ReactNode } from "react";
+
+import classNames from "classnames";
+import usePortal from "react-useportal";
+
+type Props = {
+ children: ReactNode;
+ className?: string;
+ content?: ReactNode;
+ position?: "left" | "right";
+};
+
+const getPositionStyle = (el: React.MutableRefObject<Element | null>, position: Props["position"]) => {
+ if (!el?.current) {
+ return {};
+ }
+
+ const dimensions = el.current.getBoundingClientRect();
+ const { height, left, right, top } = dimensions;
+ const styles: {
+ position: string;
+ top: number;
+ left: number | null;
+ right: number | null;
+ } = {
+ position: "absolute",
+ top: top + height + window.scrollY || 0,
+ left: null,
+ right: null,
+ };
+
+ if (position === "left") {
+ styles.left = left + window.scrollX || 0;
+ } else {
+ styles.right = window.innerWidth + window.scrollX - right || 0;
+ }
+ return styles;
+};
+
+const Popover = ({ children, className, content, position = "right" }: Props): JSX.Element => {
+ const el = useRef(null);
+ const { openPortal, closePortal, isOpen, Portal } = usePortal();
+ const positionStyle = getPositionStyle(el, position);
+
+ return (
+ <button
+ className="p-button--base"
+ data-testid="popover-container"
+ onBlur={closePortal}
+ onFocus={openPortal}
+ onMouseOut={closePortal}
+ onMouseOver={openPortal}
+ ref={el}
+ style={{ width: "100%", padding: 0, marginBottom: 0 }}
+ >
+ {children}
+ {isOpen && content && (
+ <Portal>
+ <div className={classNames("p-popover", className)} style={positionStyle}>
+ {content}
+ </div>
+ </Portal>
+ )}
+ </button>
+ );
+};
+
+export default Popover;
diff --git a/frontend/src/components/Popover/_Popover.scss b/frontend/src/components/Popover/_Popover.scss
new file mode 100644
index 0000000..9e5d701
--- /dev/null
+++ b/frontend/src/components/Popover/_Popover.scss
@@ -0,0 +1,35 @@
+.p-popover {
+ @extend %vf-bg--x-light;
+ @extend %vf-has-box-shadow;
+ @extend %vf-has-round-corners;
+ z-index: 10;
+}
+%popover-grid {
+ display: grid;
+ grid-column-gap: $sph--small;
+ grid-template-columns: $sph--small 2rem auto;
+ padding: $spv--small $spv--large;
+}
+
+.p-popover {
+ width: 13.5rem;
+}
+
+.p-popover__header {
+ margin: 0 $sph--large;
+}
+
+.p-popover__primary {
+ @extend %popover-grid;
+}
+
+.p-popover__secondary {
+ @extend %popover-grid;
+ background: $color-light;
+}
+
+.p-popover__separator {
+ grid-column-start: 1;
+ grid-column-end: 4;
+ margin-top: $spv--small;
+}
diff --git a/frontend/src/components/Popover/index.ts b/frontend/src/components/Popover/index.ts
new file mode 100644
index 0000000..04072ed
--- /dev/null
+++ b/frontend/src/components/Popover/index.ts
@@ -0,0 +1 @@
+export { default } from "./Popover";
diff --git a/frontend/src/components/SitesList/SitesList.test.tsx b/frontend/src/components/SitesList/SitesList.test.tsx
index 69cddf2..d80e77e 100644
--- a/frontend/src/components/SitesList/SitesList.test.tsx
+++ b/frontend/src/components/SitesList/SitesList.test.tsx
@@ -14,6 +14,7 @@ beforeAll(() => {
});
afterEach(() => {
mockServer.resetHandlers();
+ localStorage.clear();
});
afterAll(() => {
mockServer.close();
@@ -56,12 +57,12 @@ it("can hide and unhide columns", async () => {
await userEvent.click(screen.getByRole("button", { name: "Columns" }));
await userEvent.click(screen.getByRole("checkbox", { name: /Connection/i }));
- expect(screen.getByRole("checkbox", { name: "3 out of 4 selected" })).toBeInTheDocument();
+ expect(screen.getByRole("checkbox", { name: "4 out of 5 selected" })).toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: /Connection/i })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("checkbox", { name: /Connection/i }));
- expect(screen.getByRole("checkbox", { name: "4 out of 4 selected" })).toBeInTheDocument();
+ expect(screen.getByRole("checkbox", { name: "5 out of 5 selected" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: /Connection/i })).toBeInTheDocument();
});
@@ -74,16 +75,16 @@ it("can hide and unhide all columns", async () => {
expect(screen.getByRole("columnheader", { name: /Machines/i })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Columns" }));
- await userEvent.click(screen.getByRole("checkbox", { name: "4 out of 4 selected" }));
+ await userEvent.click(screen.getByRole("checkbox", { name: "5 out of 5 selected" }));
- expect(screen.getByRole("checkbox", { name: "0 out of 4 selected" })).toBeInTheDocument();
+ expect(screen.getByRole("checkbox", { name: "0 out of 5 selected" })).toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: /Connection/i })).not.toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: /Country/i })).not.toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: /Local time/i })).not.toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: /Machines/i })).not.toBeInTheDocument();
- await userEvent.click(screen.getByRole("checkbox", { name: "0 out of 4 selected" }));
+ await userEvent.click(screen.getByRole("checkbox", { name: "0 out of 5 selected" }));
expect(screen.getByRole("columnheader", { name: /Connection/i })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: /Country/i })).toBeInTheDocument();
diff --git a/frontend/src/components/SitesList/SitesTable/AggregatedStatus/AggregatedStatus.test.tsx b/frontend/src/components/SitesList/SitesTable/AggregatedStatus/AggregatedStatus.test.tsx
new file mode 100644
index 0000000..ff4b466
--- /dev/null
+++ b/frontend/src/components/SitesList/SitesTable/AggregatedStatus/AggregatedStatus.test.tsx
@@ -0,0 +1,23 @@
+import AggregatedStats from "./AggregatedStatus";
+
+import { statsFactory } from "@/mocks/factories";
+import { render, screen, userEvent } from "@/test-utils";
+
+it("displays correct number of deployed machines", async () => {
+ render(
+ <AggregatedStats
+ stats={statsFactory.build({
+ deployed_machines: 100,
+ allocated_machines: 200,
+ ready_machines: 300,
+ error_machines: 400,
+ })}
+ />,
+ );
+
+ await userEvent.click(screen.getByRole("button", { name: /100 of 1000 deployed/i }));
+ expect(screen.getByTestId("deployed")).toHaveTextContent("100");
+ expect(screen.getByTestId("allocated")).toHaveTextContent("200");
+ expect(screen.getByTestId("ready")).toHaveTextContent("300");
+ expect(screen.getByTestId("error")).toHaveTextContent("400");
+});
diff --git a/frontend/src/components/SitesList/SitesTable/AggregatedStatus/AggregatedStatus.tsx b/frontend/src/components/SitesList/SitesTable/AggregatedStatus/AggregatedStatus.tsx
new file mode 100644
index 0000000..26158c9
--- /dev/null
+++ b/frontend/src/components/SitesList/SitesTable/AggregatedStatus/AggregatedStatus.tsx
@@ -0,0 +1,64 @@
+import type { Stats } from "@/api/types";
+import Meter, { color } from "@/components/Meter";
+import Popover from "@/components/Popover/Popover";
+import { getAllMachines } from "@/utils";
+
+const AggregatedStatus = ({ stats }: { stats: Stats }) => {
+ const { deployed_machines, allocated_machines, ready_machines, error_machines } = stats;
+ return (
+ <>
+ <div>
+ <Popover
+ content={
+ <>
+ <div className="p-popover__primary">
+ <div className="u-vertically-center">
+ <i className="p-icon--status-deployed"></i>
+ </div>
+ <div className="u-align--right" data-testid="deployed">
+ {deployed_machines}
+ </div>
+ <div>Deployed</div>
+ <div className="u-vertically-center">
+ <i className="p-icon--status-allocated"></i>
+ </div>
+ <div className="u-align--right" data-testid="allocated">
+ {allocated_machines}
+ </div>
+ <div>Allocated</div>
+ <div className="u-vertically-center">
+ <i className="p-icon--status-ready"></i>
+ </div>
+ <div className="u-align--right" data-testid="ready">
+ {ready_machines}
+ </div>
+ <div>Ready / New</div>
+ </div>
+ <div className="p-popover__secondary">
+ <div />
+ <div className="u-align--right" data-testid="error">
+ {error_machines}
+ </div>
+ <div>Error</div>
+ </div>
+ </>
+ }
+ >
+ <Meter
+ className="u-no-margin--bottom"
+ data={[
+ { color: "black", value: deployed_machines },
+ { color: color.link, value: allocated_machines },
+ { color: color.linkFaded, value: ready_machines },
+ ]}
+ label={`${deployed_machines} of ${getAllMachines(stats)} deployed`}
+ labelClassName="u-text--muted"
+ small
+ />
+ </Popover>
+ </div>
+ </>
+ );
+};
+
+export default AggregatedStatus;
diff --git a/frontend/src/components/SitesList/SitesTable/AggregatedStatus/index.ts b/frontend/src/components/SitesList/SitesTable/AggregatedStatus/index.ts
new file mode 100644
index 0000000..8f279cd
--- /dev/null
+++ b/frontend/src/components/SitesList/SitesTable/AggregatedStatus/index.ts
@@ -0,0 +1 @@
+export { default } from "./AggregatedStatus";
diff --git a/frontend/src/components/SitesList/SitesTable/SitesTable.test.tsx b/frontend/src/components/SitesList/SitesTable/SitesTable.test.tsx
index f8e3468..e2f5c30 100644
--- a/frontend/src/components/SitesList/SitesTable/SitesTable.test.tsx
+++ b/frontend/src/components/SitesList/SitesTable/SitesTable.test.tsx
@@ -3,7 +3,7 @@ import * as timezoneMock from "timezone-mock";
import SitesTable from "./SitesTable";
import urls from "@/api/urls";
-import { enrollmentRequestFactory, siteFactory, sitesQueryResultFactory } from "@/mocks/factories";
+import { enrollmentRequestFactory, siteFactory, sitesQueryResultFactory, statsFactory } from "@/mocks/factories";
import { createMockGetEnrollmentRequestsResolver } from "@/mocks/resolvers";
import { createMockGetServer } from "@/mocks/server";
import { renderWithMemoryRouter, screen, within } from "@/test-utils";
@@ -110,3 +110,24 @@ it("displays full name of the country", () => {
expect(screen.getByText("United Kingdom")).toBeInTheDocument();
});
+
+it("displays correct number of deployed machines", () => {
+ const item = siteFactory.build({
+ stats: statsFactory.build({
+ deployed_machines: 100,
+ allocated_machines: 200,
+ ready_machines: 300,
+ error_machines: 400,
+ }),
+ });
+ renderWithMemoryRouter(
+ <SitesTable
+ data={sitesQueryResultFactory.build({ items: [item], total: 1, page: 1, size: 1 })}
+ isFetchedAfterMount={true}
+ isLoading={false}
+ setSearchText={() => {}}
+ />,
+ );
+
+ expect(screen.getByText("100 of 1000 deployed")).toBeInTheDocument();
+});
diff --git a/frontend/src/components/SitesList/SitesTable/SitesTable.tsx b/frontend/src/components/SitesList/SitesTable/SitesTable.tsx
index e4a8362..9273772 100644
--- a/frontend/src/components/SitesList/SitesTable/SitesTable.tsx
+++ b/frontend/src/components/SitesList/SitesTable/SitesTable.tsx
@@ -5,6 +5,7 @@ import type { ColumnDef, Column, Getter, Row } from "@tanstack/react-table";
import pick from "lodash/fp/pick";
import useLocalStorageState from "use-local-storage-state";
+import AggregatedStats from "./AggregatedStatus";
import ConnectionInfo from "./ConnectionInfo/ConnectionInfo";
import SitesTableControls from "./SitesTableControls/SitesTableControls";
@@ -15,7 +16,7 @@ import SelectAllCheckbox from "@/components/SelectAllCheckbox";
import { isDev } from "@/constants";
import { useAppContext } from "@/context";
import type { UseSitesQueryResult } from "@/hooks/api";
-import { getCountryName, getTimeByUTCOffset, getTimezoneUTCString } from "@/utils";
+import { getAllMachines, getCountryName, getTimeByUTCOffset, getTimezoneUTCString } from "@/utils";
const createAccessor =
<T, K extends keyof T>(keys: K[] | K) =>
@@ -141,25 +142,29 @@ const SitesTable = ({
},
},
{
- id: "status",
+ id: "machines",
accessorFn: createAccessor("stats"),
header: () => (
<>
<div>machines</div>
- <div className="u-text--muted">aggregated status</div>
</>
),
cell: ({ getValue }) => {
const { stats } = getValue();
- const { machines, ready_machines, occupied_machines, error_machines } = stats || {};
- return (
- <>
- <div>{machines}</div>
- <div className="u-text--muted">
- Ready: {ready_machines}, Occupied: {occupied_machines}, Error: {error_machines}
- </div>
- </>
- );
+ return getAllMachines(stats);
+ },
+ },
+ {
+ id: "status",
+ accessorFn: createAccessor("stats"),
+ header: () => (
+ <>
+ <div>aggregated status</div>
+ </>
+ ),
+ cell: ({ getValue }) => {
+ const { stats } = getValue();
+ return stats ? <AggregatedStats stats={stats} /> : null;
},
},
],
diff --git a/frontend/src/mocks/factories.ts b/frontend/src/mocks/factories.ts
index 9b6fb7d..fdc1c54 100644
--- a/frontend/src/mocks/factories.ts
+++ b/frontend/src/mocks/factories.ts
@@ -6,6 +6,25 @@ import type { AccessToken, EnrollmentRequest, PaginatedQueryResult, Site, Token
export const connections: Site["connection"][] = ["stable", "lost", "unknown"];
+export const statsFactory = Factory.define<Site["stats"]>(({ sequence }) => {
+ const chance = new Chance(`maas-${sequence}`);
+ return {
+ deployed_machines: chance.integer({ min: 0, max: 500 }),
+ allocated_machines: chance.integer({ min: 0, max: 500 }),
+ ready_machines: chance.integer({ min: 0, max: 500 }),
+ error_machines: chance.integer({ min: 0, max: 500 }),
+ last_seen: new Date(chance.date({ year: 2023 })).toISOString(),
+ connection: connectionFactory.build(),
+ };
+});
+
+export const connectionFactory = Factory.define<Site["connection"]>(({ sequence }) => {
+ return uniqueNamesGenerator({
+ dictionaries: [connections],
+ seed: sequence,
+ }) as Site["connection"];
+});
+
export const siteFactory = Factory.define<Site>(({ sequence }) => {
const chance = new Chance(`maas-${sequence}`);
const name = uniqueNamesGenerator({
@@ -14,15 +33,12 @@ export const siteFactory = Factory.define<Site>(({ sequence }) => {
length: 2,
seed: sequence,
});
- const connection = uniqueNamesGenerator({
- dictionaries: [connections],
- seed: sequence,
- }) as Site["connection"];
+
return {
id: `${sequence}`,
name,
url: `http://${name}.${chance.tld()}`,
- connection,
+ connection: connectionFactory.build(),
last_seen: new Date(chance.date({ year: 2023 })).toISOString(),
address: {
countrycode: chance.country(), // <alpha2 country code>,
@@ -31,12 +47,7 @@ export const siteFactory = Factory.define<Site>(({ sequence }) => {
street: chance.address(),
},
timezone: chance.integer({ min: -12, max: 14 }),
- stats: {
- machines: chance.integer({ min: 0, max: 1500 }),
- occupied_machines: chance.integer({ min: 0, max: 500 }),
- ready_machines: chance.integer({ min: 0, max: 500 }),
- error_machines: chance.integer({ min: 0, max: 500 }),
- },
+ stats: statsFactory.build(),
};
});
diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts
index 243c513..707c852 100644
--- a/frontend/src/utils.ts
+++ b/frontend/src/utils.ts
@@ -5,6 +5,8 @@ import * as countries from "i18n-iso-countries";
import { getName } from "i18n-iso-countries";
import en from "i18n-iso-countries/langs/en.json";
+import type { Stats } from "./api/types";
+
if (typeof window !== "undefined") {
countries.registerLocale(en);
}
@@ -65,3 +67,8 @@ export const copyToClipboard = (text: string, callback?: (text: string) => void)
Sentry.captureException(new Error("copy to clipboard failed", { cause: error }));
});
};
+
+export const getAllMachines = (stats?: Stats) => {
+ if (!stats) return null;
+ return stats.deployed_machines + stats.allocated_machines + stats.ready_machines + stats.error_machines;
+};
Follow ups
-
[Merge] ~petermakowski/maas-site-manager:aggregated-status-MAASENG-1586 into maas-site-manager:main
From: MAAS Lander, 2023-04-21
-
Re: [UNITTESTS] -b aggregated-status-MAASENG-1586 lp:~petermakowski/maas-site-manager/+git/site-manager into -b main lp:~maas-committers/maas-site-manager - TESTS PASS
From: MAAS Lander, 2023-04-21
-
[Merge] ~petermakowski/maas-site-manager:aggregated-status-MAASENG-1586 into maas-site-manager:main
From: Peter Makowski, 2023-04-21
-
[Merge] ~petermakowski/maas-site-manager:aggregated-status-MAASENG-1586 into maas-site-manager:main
From: MAAS Lander, 2023-04-21
-
Re: [Merge] -b aggregated-status-MAASENG-1586 lp:~petermakowski/maas-site-manager/+git/site-manager into -b main lp:~maas-committers/maas-site-manager - LANDING FAILED
From: MAAS Lander, 2023-04-21
-
Re: [UNITTESTS] -b aggregated-status-MAASENG-1586 lp:~petermakowski/maas-site-manager/+git/site-manager into -b main lp:~maas-committers/maas-site-manager - TESTS FAILED
From: MAAS Lander, 2023-04-21
-
[Merge] ~petermakowski/maas-site-manager:aggregated-status-MAASENG-1586 into maas-site-manager:main
From: Nick De Villiers, 2023-04-21
-
Re: [Merge] ~petermakowski/maas-site-manager:aggregated-status-MAASENG-1586 into maas-site-manager:main
From: Nick De Villiers, 2023-04-21
-
Re: [Merge] ~petermakowski/maas-site-manager:aggregated-status-MAASENG-1586 into maas-site-manager:main
From: Nick De Villiers, 2023-04-21
-
[Merge] ~petermakowski/maas-site-manager:aggregated-status-MAASENG-1586 into maas-site-manager:main
From: Peter Makowski, 2023-04-21
-
[Merge] ~petermakowski/maas-site-manager:aggregated-status-MAASENG-1586 into maas-site-manager:main
From: Peter Makowski, 2023-04-21
-
[Merge] ~petermakowski/maas-site-manager:aggregated-status-MAASENG-1586 into maas-site-manager:main
From: Peter Makowski, 2023-04-21
-
[Merge] ~petermakowski/maas-site-manager:aggregated-status-MAASENG-1586 into maas-site-manager:main
From: Peter Makowski, 2023-04-21