← Back to team overview

sts-sponsors team mailing list archive

[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