+ );
+};
+
+/**
+ * Rows arrive after the first commit, the way a query's data does. The starting
+ * page has to survive that first render, when the table has nothing in it yet.
+ */
+const LateDataTable = ({
+ pageSize = 2,
+ pageIndex,
+}: {
+ pageSize?: number;
+ pageIndex: number;
+}) => {
+ const [data, setData] = React.useState([]);
+ React.useEffect(() => {
+ setData(testData);
+ }, []);
+ const table = useUniversalTable({
+ data,
+ columns,
+ pageSize,
+ initialState: { pagination: { pageIndex, pageSize } },
+ });
+ return (
+
+
+
+
+ {table.getState().pagination.pageIndex}
+
+
+ );
+};
+
+/** Panel with draft state of its own, so its lifetime is observable. */
+const DraftFilterPanel = () => {
+ const [draft, setDraft] = React.useState("");
+ return (
+ setDraft(e.target.value)}
+ />
+ );
+};
+
+const filterableColumns = [
+ columnHelper.accessor("name", {
+ header: "Name",
+ meta: { renderFilter: () => },
+ }),
+];
+
+const FilterableTable = () => {
+ const table = useUniversalTable({
+ data: testData,
+ columns: filterableColumns,
+ });
+ return ;
+};
+
const footerColumns = [
columnHelper.accessor("name", { header: "Name", footer: "Total" }),
columnHelper.accessor("replicas", {
@@ -433,6 +522,92 @@ describe("UniversalTable", () => {
});
expect(screen.getByText("page 1 of 2")).toBeInTheDocument();
});
+
+ // A page index outlives the rows it was valid for whenever it comes from
+ // outside the table: a bookmarked URL, a link shared into a smaller
+ // environment. Slicing from it would render a header with no rows, and
+ // `TablePagination` hides itself at one page, so nothing would be left to
+ // page back with.
+ it("clamps a starting page past the last page", async () => {
+ await renderComponent();
+
+ expect(screen.getByTestId("page-index")).toHaveTextContent("2");
+ expect(screen.getByText("page 3 of 3")).toBeInTheDocument();
+ expect(screen.getAllByRole("row")).toHaveLength(2); // 1 header + 1 data
+ });
+
+ it("clamps to the only page when everything fits on it", async () => {
+ await renderComponent();
+
+ expect(screen.getByTestId("page-index")).toHaveTextContent("0");
+ expect(screen.getAllByRole("row")).toHaveLength(6); // 1 header + 5 data
+ });
+
+ it("leaves a valid starting page alone", async () => {
+ await renderComponent();
+
+ expect(screen.getByTestId("page-index")).toHaveTextContent("1");
+ expect(screen.getByText("page 2 of 3")).toBeInTheDocument();
+ });
+
+ it("keeps the starting page when the rows arrive after the first render", async () => {
+ await renderComponent();
+
+ // 5 rows over 3 pages, so page 3 is valid once the data lands. Clamping
+ // against the empty first render would have dropped the user to page 1
+ // before the rows that justify page 3 existed.
+ await waitFor(() =>
+ expect(screen.getByTestId("page-index")).toHaveTextContent("2"),
+ );
+ expect(screen.getByText("page 3 of 3")).toBeInTheDocument();
+ });
+
+ it("leaves the page alone when the table has no rows", async () => {
+ await renderComponent(
+ ,
+ );
+
+ // No rows means no page count to judge the index against. A table with
+ // nothing in it shows nothing on any page, so holding the index costs
+ // nothing and keeps it for when rows appear.
+ expect(screen.getByTestId("page-index")).toHaveTextContent("2");
+ });
+
+ it("leaves the page alone under manual pagination", async () => {
+ // The page count belongs to the caller there, and TanStack reports -1
+ // for "not known yet", which must not read as "no pages".
+ await renderComponent(
+ ,
+ );
+
+ expect(screen.getByTestId("page-index")).toHaveTextContent("3");
+ });
+ });
+
+ describe("Column filter panel", () => {
+ const openFilter = (user: ReturnType) =>
+ user.click(screen.getByRole("button", { name: "Filter name" }));
+
+ it("starts a panel fresh on each open", async () => {
+ const user = userEvent.setup();
+ await renderComponent();
+
+ await openFilter(user);
+ await user.type(await screen.findByLabelText("draft"), "abandoned");
+ await openFilter(user);
+
+ // A panel that survived its popover would still be holding an edit the
+ // user typed and walked away from, which then contradicts the filter
+ // actually in force.
+ await openFilter(user);
+ expect(await screen.findByLabelText("draft")).toHaveValue("");
+ });
+
+ it("keeps the panel out of reach while closed", async () => {
+ await renderComponent();
+
+ expect(screen.queryByLabelText("draft")).not.toBeInTheDocument();
+ });
});
describe("Row Click", () => {
diff --git a/console/src/components/Table/UniversalTable.tsx b/console/src/components/Table/UniversalTable.tsx
index 963d301a5f5b0..e57cf2487b42e 100644
--- a/console/src/components/Table/UniversalTable.tsx
+++ b/console/src/components/Table/UniversalTable.tsx
@@ -64,6 +64,8 @@ const ColumnFilterTrigger = ({
const isActive = header.column.getFilterValue() !== undefined;
return (
(
resetPageIndex();
};
- return useReactTable({
+ const table = useReactTable({
...tableOptions,
columns: tableOptions.columns as ColumnDef[],
getCoreRowModel: getCoreRowModel(),
@@ -128,6 +128,37 @@ export const useUniversalTable = (
filterFromLeafRows:
tableOptions.filterFromLeafRows ?? Boolean(tableOptions.getSubRows),
});
+
+ // TanStack slices the visible page straight from the stored page index, and
+ // auto-reset is off (see above), so an index can outlive the rows it was
+ // valid for: a page restored from a URL, a data set that shrank, a filter
+ // that narrowed. What renders then is a header with no rows beneath it, and
+ // `TablePagination` hides itself once there is only one page, so no control
+ // is left to page back with. Clamp to the last page that exists.
+ //
+ // A layout effect rather than an effect: this runs before the browser paints,
+ // so the page that never existed is not shown on the way to the one that
+ // does.
+ //
+ // A count of zero means the table has no rows to place the index against, not
+ // that the index is wrong: data that has not arrived yet, or a filter that
+ // currently matches nothing. Clamping then would throw away a page restored
+ // from a URL before the rows that justify it exist. Leaving it alone costs
+ // nothing, since a table with no rows shows no rows on any page, and the
+ // clamp runs again once there is a count to judge against.
+ //
+ // NOTE: skipped under `manualPagination`, where the page count comes from the
+ // caller and -1 means "not known yet" rather than "no pages".
+ const pageCount = table.getPageCount();
+ const { pageIndex } = table.getState().pagination;
+ React.useLayoutEffect(() => {
+ if (tableOptions.manualPagination || pageCount < 1) return;
+ if (pageIndex > pageCount - 1) {
+ table.setPageIndex(pageCount - 1);
+ }
+ }, [table, tableOptions.manualPagination, pageCount, pageIndex]);
+
+ return table;
};
/**
diff --git a/console/src/config/flexibleDeploymentFlags.ts b/console/src/config/flexibleDeploymentFlags.ts
index 88cb372d5c3f8..db7c110a9973b 100644
--- a/console/src/config/flexibleDeploymentFlags.ts
+++ b/console/src/config/flexibleDeploymentFlags.ts
@@ -12,7 +12,9 @@
* is too big, we default all flags to true and specify the ones we want
* to disable.
*/
-export const disabledFlexibleDeploymentFlags: Record = {};
+export const disabledFlexibleDeploymentFlags: Record = {
+ "usage-metrics-in-cluster-list-CNS121": false,
+};
export const flexibleDeploymentFlags = new Proxy(
{},
diff --git a/console/src/platform/clusters/ClusterUsageTable.tsx b/console/src/platform/clusters/ClusterUsageTable.tsx
index fdc106882757e..67b63eeb67384 100644
--- a/console/src/platform/clusters/ClusterUsageTable.tsx
+++ b/console/src/platform/clusters/ClusterUsageTable.tsx
@@ -8,8 +8,9 @@
// by the Apache License, Version 2.0.
import { HStack, Text, Tooltip, VStack } from "@chakra-ui/react";
-import { createColumnHelper } from "@tanstack/react-table";
+import { ColumnFiltersState, createColumnHelper } from "@tanstack/react-table";
import React from "react";
+import { useLocation } from "react-router-dom";
import {
ClusterWithOwnership,
@@ -25,7 +26,16 @@ import { sortingFunctions } from "~/components/Table/tableColumnBuilders";
import { TablePagination } from "~/components/Table/TablePagination";
import { TableSearch } from "~/components/Table/TableSearch";
import { UniversalTable } from "~/components/Table/UniversalTable";
-import { useUniversalTable } from "~/components/Table/useUniversalTable";
+import {
+ getInitialTableState,
+ useUniversalTable,
+} from "~/components/Table/useUniversalTable";
+import { useSyncObjectToSearchParams } from "~/hooks/useSyncObjectToSearchParams";
+import {
+ EmptyListHeader,
+ EmptyListHeaderContents,
+ EmptyListWrapper,
+} from "~/layouts/listPageComponents";
import WarningIcon from "~/svg/WarningIcon";
import { truncateMaxWidth } from "~/theme/components/Table";
import {
@@ -39,6 +49,13 @@ import {
ClusterTableMeta,
} from "./clusterTableCells";
import { useReplicaUtilization } from "./queries";
+import { UtilizationFilterChips } from "./UtilizationFilterChips";
+import { UtilizationFilterPanel } from "./UtilizationFilterPanel";
+import {
+ utilizationFilterFn,
+ utilizationFilterFromUrl,
+ utilizationFilterToUrl,
+} from "./utilizationFilters";
/**
* The utilization readings a row displays, as fractions of the replica's
@@ -134,18 +151,69 @@ const latestReplicaStatusAt = (replica: Replica | null) =>
const columnHelper = createColumnHelper();
+interface UtilizationColumn {
+ id: string;
+ header: string;
+ urlKey: string;
+ read: (utilization: ReplicaUtilizationValues) => number | null;
+}
+
+/**
+ * The utilization columns, in the order the table shows them. One list defines
+ * both the columns and the toolbar filter controls, so a control cannot end up
+ * labelled differently from the column it filters.
+ */
+const UTILIZATION_COLUMNS: UtilizationColumn[] = [
+ { id: "cpuPercent", header: "CPU", urlKey: "cpu", read: (u) => u.cpuPercent },
+ // NOTE: this is `memory_percent`, RAM against the size's RAM allocation.
+ {
+ id: "memoryPercent",
+ header: "Memory",
+ urlKey: "memory",
+ read: (u) => u.memoryPercent,
+ },
+ // NOTE: the denominator is the size's configured disk allocation, so this is
+ // null, and renders a dash, for any replica on a size that allocates no disk.
+ {
+ id: "diskPercent",
+ header: "Disk",
+ urlKey: "disk",
+ read: (u) => u.diskPercent,
+ },
+ // NOTE: `heap_percent` is RAM plus swap over the heap limit. The heap limit
+ // comes from the orchestrator, not the size catalog, so this is null on any
+ // environment that does not report one.
+ {
+ id: "heapPercent",
+ header: "Heap",
+ urlKey: "heap",
+ read: (u) => u.heapPercent,
+ },
+];
+
+/** The utilization filters a URL asks for, skipping any it cannot parse. */
+const utilizationFiltersFromSearch = (search: string): ColumnFiltersState => {
+ const params = new URLSearchParams(search);
+ return UTILIZATION_COLUMNS.flatMap(({ id, urlKey }) => {
+ const value = utilizationFilterFromUrl(params.get(urlKey));
+ return value ? [{ id, value }] : [];
+ });
+};
+
/** A utilization column, read from the row's readings by `read`. */
-const percentColumn = (
- id: string,
- header: string,
- read: (utilization: ReplicaUtilizationValues) => number | null,
-) =>
+const percentColumn = ({ id, header, read }: UtilizationColumn) =>
columnHelper.accessor((row) => read(row.utilization), {
id,
header,
sortingFn: sortingFunctions.numericNullsLast,
sortDescFirst: true,
+ filterFn: utilizationFilterFn,
cell: (info) => ,
+ meta: {
+ renderFilter: (column) => (
+
+ ),
+ },
});
const columns = [
@@ -175,16 +243,7 @@ const columns = [
sortDescFirst: true,
cell: (info) => info.getValue() ?? "-",
}),
- percentColumn("cpuPercent", "CPU", (u) => u.cpuPercent),
- // NOTE: this is `memory_percent`, RAM against the size's RAM allocation.
- percentColumn("memoryPercent", "Memory", (u) => u.memoryPercent),
- // NOTE: the denominator is the size's configured disk allocation, so this is
- // null, and renders a dash, for any replica on a size that allocates no disk.
- percentColumn("diskPercent", "Disk", (u) => u.diskPercent),
- // NOTE: `heap_percent` is RAM plus swap over the heap limit. The heap limit
- // comes from the orchestrator, not the size catalog, so this is null on any
- // environment that does not report one.
- percentColumn("heapPercent", "Heap", (u) => u.heapPercent),
+ ...UTILIZATION_COLUMNS.map(percentColumn),
columnHelper.accessor((row) => latestReplicaStatusAt(row.replica), {
// NOTE: deliberately not the cluster's own `latestStatusUpdate`. That comes
// from the replica status *history*, so it counts replicas that have since
@@ -217,6 +276,8 @@ const columns = [
}),
];
+const PAGE_SIZE = 20;
+
export interface ClusterUsageTableProps {
clusters: ClusterWithOwnership[];
}
@@ -228,9 +289,19 @@ export const ClusterUsageTable = ({ clusters }: ClusterUsageTableProps) => {
const { data: offlineReplicaMap, error: offlineReplicaError } =
useLatestOfflineReplica();
const { data: replicaUtilization } = useReplicaUtilization();
+ const location = useLocation();
const meta: ClusterTableMeta = { offlineReplicaMap };
+ // Read once, on mount: the URL seeds the table, and from then on the table
+ // drives the URL. Reading it on every render would fight the writer below.
+ const [initialState] = React.useState(() =>
+ getInitialTableState(location.search),
+ );
+ const [columnFilters, setColumnFilters] = React.useState(
+ () => utilizationFiltersFromSearch(location.search),
+ );
+
// TanStack recomputes its row models whenever `data` changes identity, so the
// flattened rows have to outlive the render that built them.
const rows = React.useMemo(
@@ -253,28 +324,97 @@ export const ClusterUsageTable = ({ clusters }: ClusterUsageTableProps) => {
columns,
getRowId: (row) =>
`${row.cluster.id}/${row.replica ? row.replica.id : "no-replica"}`,
- initialSorting: [{ id: "cluster", desc: false }],
- pageSize: 20,
+ initialSorting: initialState.sorting ?? [{ id: "cluster", desc: false }],
+ pageSize: PAGE_SIZE,
+ initialState: {
+ globalFilter: initialState.globalFilter,
+ pagination: {
+ pageIndex: initialState.pageIndex ?? 0,
+ pageSize: PAGE_SIZE,
+ },
+ },
state: {
+ columnFilters,
columnVisibility: {
lastStatusChange: !offlineReplicaError,
},
},
+ onColumnFiltersChange: setColumnFilters,
meta,
});
+ const tableState = table.getState();
+
+ // The whole query string is rewritten from this object, so it has to carry
+ // every piece of table state worth bookmarking, not just the filters. Keys
+ // are left out when they hold nothing, to keep a plain visit to the page from
+ // accumulating empty parameters.
+ const urlParams = React.useMemo(() => {
+ const params: Record = {};
+ for (const { id, urlKey } of UTILIZATION_COLUMNS) {
+ const filter = tableState.columnFilters.find((f) => f.id === id);
+ if (filter) {
+ params[urlKey] = utilizationFilterToUrl(filter.value as number);
+ }
+ }
+ if (tableState.globalFilter) {
+ params.q = tableState.globalFilter;
+ }
+ const [sort] = tableState.sorting;
+ if (sort) {
+ params.sort = sort.id;
+ params.dir = sort.desc ? "desc" : "asc";
+ }
+ if (tableState.pagination.pageIndex > 0) {
+ params.page = tableState.pagination.pageIndex + 1;
+ }
+ return params;
+ }, [
+ tableState.columnFilters,
+ tableState.globalFilter,
+ tableState.sorting,
+ tableState.pagination.pageIndex,
+ ]);
+ useSyncObjectToSearchParams(urlParams);
+
+ // Every cluster contributes at least one row, and the list renders its own
+ // empty state when there are no clusters at all, so an empty row model here
+ // means the search or a filter excluded everything.
+ const noMatches = table.getFilteredRowModel().rows.length === 0;
+
return (
-
-
+ {/*
+ * Above the table, so it outlives an empty result. The message below
+ * replaces the table and takes the column headers, and so the filter
+ * panels, with it. Removing a chip is then the only way back from a
+ * filter that matched nothing.
+ */}
+
+ {noMatches ? (
+
+
+
+
+
+ ) : (
+ <>
+
+
+ >
+ )}
);
};
diff --git a/console/src/platform/clusters/ClustersList.test.tsx b/console/src/platform/clusters/ClustersList.test.tsx
index 42ef8051b442c..1cd22430d64d2 100644
--- a/console/src/platform/clusters/ClustersList.test.tsx
+++ b/console/src/platform/clusters/ClustersList.test.tsx
@@ -10,6 +10,7 @@
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
+import { useLocation } from "react-router-dom";
import { Cluster, Replica } from "~/api/materialize/cluster/clusterList";
import { ReplicaUtilization } from "~/api/materialize/cluster/replicaUtilization";
@@ -181,12 +182,17 @@ const COLUMN = {
actions: 8,
} as const;
+// `queryAllByRole`, not `getAllByRole`: when the search or a filter excludes
+// every replica the table is replaced by a message, so there are no rows at all
+// rather than a header row on its own.
const bodyRows = () =>
screen
- .getAllByRole("row")
+ .queryAllByRole("row")
// The header row is a row too, and has no data cells.
.slice(1);
+const NO_MATCHES_MESSAGE = "No replicas match the current search and filters";
+
const rowFor = (rowLabel: string) => {
const row = screen.getByText(rowLabel).closest("tr");
if (!row) throw new Error(`no row found containing "${rowLabel}"`);
@@ -215,6 +221,93 @@ const rowOrderAfter = async (
return rowOrder();
};
+/**
+ * Column id per heading. `UniversalTable` names a header's filter trigger from
+ * the column id, so a test reaching for one has to know both.
+ */
+const FILTER_COLUMN_IDS: Record = {
+ CPU: "cpuPercent",
+ Memory: "memoryPercent",
+ Disk: "diskPercent",
+ Heap: "heapPercent",
+};
+
+/** The filter trigger in the header of the column headed `label`. */
+const filterTrigger = (label: string) =>
+ screen.getByRole("button", { name: `Filter ${FILTER_COLUMN_IDS[label]}` });
+
+/**
+ * The same trigger, or null. A filter that matches nothing replaces the table
+ * with a message, taking the headers and their triggers with it.
+ */
+const queryFilterTrigger = (label: string) =>
+ screen.queryByRole("button", { name: `Filter ${FILTER_COLUMN_IDS[label]}` });
+
+/** Opens a column's filter panel, returning its Apply button once visible. */
+const openFilter = async (
+ user: ReturnType,
+ label: string,
+) => {
+ await user.click(filterTrigger(label));
+ return screen.findByRole("button", { name: "Apply" });
+};
+
+/**
+ * Sets `percent` as `label`'s threshold and applies it, leaving the panel
+ * closed.
+ *
+ * Applying does not close the panel, matching the Maintained Objects filters,
+ * so this closes it: an open panel covers the table an assertion is about, and
+ * only the open panel's Apply and Clear are reachable by role.
+ */
+const applyFilter = async (
+ user: ReturnType,
+ label: string,
+ percent: string,
+) => {
+ const apply = await openFilter(user, label);
+ await user.clear(screen.getByLabelText(`${label} threshold percentage`));
+ // `type` rejects an empty string, and an empty threshold is a real case: it
+ // is how the panel says "no filter".
+ if (percent !== "") {
+ await user.type(
+ screen.getByLabelText(`${label} threshold percentage`),
+ percent,
+ );
+ }
+ await user.click(apply);
+ // Nothing to close when the filter emptied the table: the header the panel
+ // hung off is gone along with it.
+ const trigger = queryFilterTrigger(label);
+ if (trigger) await user.click(trigger);
+};
+
+/** Clears `label`'s filter from its panel, leaving the panel closed. */
+const clearFilter = async (
+ user: ReturnType,
+ label: string,
+) => {
+ await user.click(filterTrigger(label));
+ await user.click(await screen.findByRole("button", { name: "Clear" }));
+ await user.click(filterTrigger(label));
+};
+
+/**
+ * The values `label`'s panel shows, with the panel left open.
+ *
+ * The trigger is an icon, so what a column is filtered by is only readable
+ * inside its panel.
+ */
+const panelValues = async (
+ user: ReturnType,
+ label: string,
+) => {
+ await openFilter(user, label);
+ return {
+ percent: screen.getByLabelText(`${label} threshold percentage`),
+ };
+};
+
describe("ClustersList replica rows", () => {
it("renders one row per replica, naming the cluster on each", async () => {
await renderClustersList([buildCluster()]);
@@ -866,11 +959,14 @@ describe("ClustersList search", () => {
await expectRowsMatching(user, "100cc", ["beta"]);
});
- it("shows no rows when nothing matches", async () => {
+ it("replaces the table with a message when nothing matches", async () => {
const user = userEvent.setup();
await renderClustersList(twoClusters());
await expectRowsMatching(user, "nonesuch", []);
+
+ expect(screen.getByText(NO_MATCHES_MESSAGE)).toBeInTheDocument();
+ expect(screen.queryByRole("table")).not.toBeInTheDocument();
});
});
@@ -887,7 +983,7 @@ describe("ClustersList keyboard navigation", () => {
const user = userEvent.setup();
await renderClustersList([singleReplicaCluster()]);
- // The header's system-objects switch and the table's search box precede the
+ // The header's system-objects switch and the table's toolbar precede the
// rows in document order.
await user.tab();
expect(screen.getByLabelText("Show system clusters")).toHaveFocus();
@@ -895,6 +991,12 @@ describe("ClustersList keyboard navigation", () => {
await user.tab();
expect(screen.getByLabelText("Search clusters...")).toHaveFocus();
+ // One control per utilization column, in the order the table shows them.
+ for (const label of ["CPU", "Memory", "Disk", "Heap"]) {
+ await user.tab();
+ expect(filterTrigger(label)).toHaveFocus();
+ }
+
await user.tab();
expect(clusterNameLink()).toHaveFocus();
@@ -1033,3 +1135,755 @@ describe("ClustersList row identity", () => {
expect(within(shifted).queryByText(/charlie/)).not.toBeInTheDocument();
});
});
+
+describe("ClustersList CPU filter", () => {
+ /**
+ * Three replicas spread across two clusters, so a threshold has to cut
+ * through both rather than keeping or dropping whole clusters.
+ */
+ const twoClusters = () => [
+ buildCluster({
+ id: "u1",
+ name: "compute",
+ replicas: [
+ buildReplica({ id: "u10", name: "idle", cpuPercent: 0.05 }),
+ buildReplica({ id: "u11", name: "busy", cpuPercent: 0.9 }),
+ ],
+ }),
+ buildCluster({
+ id: "u2",
+ name: "ingest",
+ replicas: [
+ buildReplica({ id: "u20", name: "middling", cpuPercent: 0.5 }),
+ ],
+ }),
+ ];
+
+ const cpuTrigger = () => filterTrigger("CPU");
+
+ const openCpuFilter = (user: ReturnType) =>
+ openFilter(user, "CPU");
+
+ const applyCpuFilter = (
+ user: ReturnType,
+ percent: string,
+ ) => applyFilter(user, "CPU", percent);
+
+ it("renders a control labelled by its column", async () => {
+ await renderClustersList(twoClusters());
+
+ expect(cpuTrigger()).toBeInTheDocument();
+ });
+
+ it("keeps only the replicas above the threshold", async () => {
+ const user = userEvent.setup();
+ await renderClustersList(twoClusters());
+
+ await applyCpuFilter(user, "40");
+
+ expect(rowOrder()).toEqual(["busy", "middling"]);
+ });
+
+ it("compares the reading, not its rounded display value", async () => {
+ const user = userEvent.setup();
+ await renderClustersList([
+ buildCluster({
+ replicas: [
+ // Renders as "80.0%", but sits below a threshold of 80.
+ buildReplica({ id: "u10", name: "just-under", cpuPercent: 0.7996 }),
+ buildReplica({ id: "u11", name: "just-over", cpuPercent: 0.8004 }),
+ ],
+ }),
+ ]);
+
+ await applyCpuFilter(user, "80");
+
+ expect(rowOrder()).toEqual(["just-over"]);
+ });
+
+ it("drops a replica with no CPU sample", async () => {
+ const user = userEvent.setup();
+ await renderClustersList([
+ buildCluster({
+ replicas: [
+ buildReplica({ id: "u10", name: "sampled", cpuPercent: 0.9 }),
+ buildReplica({ id: "u11", name: "unsampled", cpuPercent: null }),
+ ],
+ }),
+ ]);
+
+ // An unsampled replica has not been seen to reach any threshold, so a
+ // filtered list leaves it out.
+ await applyCpuFilter(user, "50");
+
+ expect(rowOrder()).toEqual(["sampled"]);
+ });
+
+ it("drops a cluster with no replicas", async () => {
+ const user = userEvent.setup();
+ await renderClustersList([
+ buildCluster({ id: "u1", name: "empty", replicas: [] }),
+ buildCluster({
+ id: "u2",
+ name: "ingest",
+ replicas: [buildReplica({ id: "u20", name: "busy", cpuPercent: 0.9 })],
+ }),
+ ]);
+
+ await applyCpuFilter(user, "50");
+
+ expect(rowOrder()).toEqual(["busy"]);
+ });
+
+ it("keeps the applied threshold in its panel", async () => {
+ const user = userEvent.setup();
+ await renderClustersList(twoClusters());
+
+ await applyCpuFilter(user, "40");
+ const { percent } = await panelValues(user, "CPU");
+
+ expect(percent).toHaveValue("40");
+ });
+
+ it("leaves the table alone until Apply is clicked", async () => {
+ const user = userEvent.setup();
+ await renderClustersList(twoClusters());
+
+ await openCpuFilter(user);
+ await user.clear(screen.getByLabelText("CPU threshold percentage"));
+ await user.type(screen.getByLabelText("CPU threshold percentage"), "40");
+
+ // A half-typed threshold would otherwise reorder the table on every
+ // keystroke.
+ expect(rowOrder()).toEqual(["idle", "busy", "middling"]);
+ });
+
+ it("stays recoverable when the filter empties the table", async () => {
+ const user = userEvent.setup();
+ await renderClustersList(twoClusters());
+
+ await applyCpuFilter(user, "99");
+
+ // The message replaces the table, headers and filter panels included, so
+ // the chip is what is left to recover with.
+ expect(screen.getByText(NO_MATCHES_MESSAGE)).toBeInTheDocument();
+ expect(screen.queryByRole("table")).not.toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Remove CPU ≥ 99%" }));
+
+ expect(rowOrder()).toEqual(["idle", "busy", "middling"]);
+ });
+
+ it("restores every row when the filter is cleared", async () => {
+ const user = userEvent.setup();
+ await renderClustersList(twoClusters());
+
+ await applyCpuFilter(user, "40");
+ await clearFilter(user, "CPU");
+
+ expect(rowOrder()).toEqual(["idle", "busy", "middling"]);
+ const { percent } = await panelValues(user, "CPU");
+ expect(percent).toHaveValue("");
+ });
+
+ it("reopens on the threshold in force after an abandoned edit", async () => {
+ const user = userEvent.setup();
+ await renderClustersList(twoClusters());
+
+ await applyCpuFilter(user, "40");
+
+ // Blank the threshold, then close without applying.
+ await openFilter(user, "CPU");
+ await user.clear(screen.getByLabelText("CPU threshold percentage"));
+ await user.click(filterTrigger("CPU"));
+
+ // The column is still filtering on 40, so the panel has to say so rather
+ // than carry an edit the user walked away from.
+ const { percent } = await panelValues(user, "CPU");
+ expect(percent).toHaveValue("40");
+ expect(rowOrder()).toEqual(["busy", "middling"]);
+ });
+
+ it("clears a typed threshold that was never applied", async () => {
+ const user = userEvent.setup();
+ await renderClustersList(twoClusters());
+
+ await openCpuFilter(user);
+ await user.type(screen.getByLabelText("CPU threshold percentage"), "50");
+ await user.click(screen.getByRole("button", { name: "Clear" }));
+
+ // No filter was ever applied, so Clear has no applied value to change.
+ // It still has to empty the panel it is sitting in.
+ expect(screen.getByLabelText("CPU threshold percentage")).toHaveValue("");
+ });
+
+ it("treats applying an empty threshold as no filter", async () => {
+ const user = userEvent.setup();
+ await renderClustersList(twoClusters());
+
+ await applyCpuFilter(user, "40");
+ expect(rowOrder()).toEqual(["busy", "middling"]);
+
+ // Matching the freshness filter: an empty or zero threshold is not a
+ // filter, so applying one lifts it rather than being rejected.
+ await applyCpuFilter(user, "");
+
+ expect(rowOrder()).toEqual(["idle", "busy", "middling"]);
+ });
+
+ it("narrows the search results rather than replacing them", async () => {
+ const user = userEvent.setup();
+ await renderClustersList(twoClusters());
+
+ // Searched first, then filtered: closing the panel hands focus back to its
+ // trigger on the next frame, which would swallow keystrokes typed into the
+ // search box in the same tick.
+ await user.type(screen.getByLabelText("Search clusters..."), "compute");
+ await waitFor(() => expect(rowOrder()).toEqual(["idle", "busy"]));
+
+ await applyCpuFilter(user, "40");
+
+ // Both constraints hold: only compute's busy replica clears each.
+ expect(rowOrder()).toEqual(["busy"]);
+ });
+});
+
+describe("ClustersList utilization filters", () => {
+ /**
+ * One control per utilization column, each paired with the reading it filters
+ * on. The label is the table heading verbatim, which is what ties a control
+ * to its column for the user.
+ */
+ const CONTROLS = [
+ ["CPU", (value: number) => ({ cpuPercent: value })],
+ ["Memory", (value: number) => ({ memoryPercent: value })],
+ ["Disk", (value: number) => ({ diskPercent: value })],
+ ["Heap", (value: number) => ({ heapPercent: value })],
+ ] as const;
+
+ it("gives every utilization column a filter named by its heading", async () => {
+ const user = userEvent.setup();
+ await renderClustersList([buildCluster()]);
+
+ for (const [label] of CONTROLS) {
+ expect(
+ screen.getByRole("columnheader", { name: new RegExp(`^${label}`) }),
+ ).toBeInTheDocument();
+
+ // The panel names its input from the heading, so a renamed column cannot
+ // leave its filter labelled with the old name.
+ const { percent } = await panelValues(user, label);
+ expect(percent).toBeInTheDocument();
+ await user.click(filterTrigger(label));
+ }
+ });
+
+ describe.each(CONTROLS)("the %s control", (label, withValue) => {
+ /**
+ * Two replicas differing only in the reading under test. Every other
+ * reading keeps `buildReplica`'s default, so a control wired to the wrong
+ * column sees one value on both rows and cannot produce this split.
+ */
+ const pair = () =>
+ buildCluster({
+ replicas: [
+ buildReplica({ id: "u10", name: "high", ...withValue(0.9) }),
+ buildReplica({ id: "u11", name: "low", ...withValue(0.05) }),
+ ],
+ });
+
+ it("filters on its own column's reading", async () => {
+ const user = userEvent.setup();
+ await renderClustersList([pair()]);
+
+ await applyFilter(user, label, "50");
+
+ expect(rowOrder()).toEqual(["high"]);
+ });
+
+ it("holds the condition in its own panel and no other", async () => {
+ const user = userEvent.setup();
+ await renderClustersList([pair()]);
+
+ await applyFilter(user, label, "50");
+
+ const own = await panelValues(user, label);
+ expect(own.percent).toHaveValue("50");
+ await user.click(filterTrigger(label));
+
+ for (const [other] of CONTROLS.filter(([name]) => name !== label)) {
+ const { percent } = await panelValues(user, other);
+ expect(percent).toHaveValue("");
+ await user.click(filterTrigger(other));
+ }
+ });
+
+ it("is cleared without disturbing the other columns", async () => {
+ const user = userEvent.setup();
+ await renderClustersList([pair()]);
+
+ await applyFilter(user, label, "50");
+ await clearFilter(user, label);
+
+ expect(rowOrder()).toEqual(["high", "low"]);
+ });
+ });
+
+ it("applies every filter at once", async () => {
+ const user = userEvent.setup();
+ await renderClustersList([
+ buildCluster({
+ replicas: [
+ buildReplica({
+ id: "u10",
+ name: "hot-both",
+ cpuPercent: 0.9,
+ memoryPercent: 0.9,
+ }),
+ buildReplica({
+ id: "u11",
+ name: "hot-cpu-only",
+ cpuPercent: 0.9,
+ memoryPercent: 0.1,
+ }),
+ buildReplica({
+ id: "u12",
+ name: "hot-memory-only",
+ cpuPercent: 0.1,
+ memoryPercent: 0.9,
+ }),
+ ],
+ }),
+ ]);
+
+ await applyFilter(user, "CPU", "50");
+ await applyFilter(user, "Memory", "50");
+
+ // Filters narrow each other rather than replacing one another.
+ expect(rowOrder()).toEqual(["hot-both"]);
+ });
+});
+
+/**
+ * Renders the router's query string, so what the table writes to the URL is
+ * assertable. Only the URL tests mount this: the rendered text would otherwise
+ * be one more place `getByText` could match a cluster or replica name.
+ */
+const RenderWithSearch = ({ children }: { children: React.ReactNode }) => {
+ const { search } = useLocation();
+ return (
+ <>
+ {children}
+
{search}
+ >
+ );
+};
+
+describe("ClustersList filter URL state", () => {
+ const twoClusters = () => [
+ buildCluster({
+ id: "u1",
+ name: "compute",
+ replicas: [
+ buildReplica({
+ id: "u10",
+ name: "idle",
+ cpuPercent: 0.05,
+ memoryPercent: 0.05,
+ }),
+ buildReplica({
+ id: "u11",
+ name: "busy",
+ cpuPercent: 0.9,
+ memoryPercent: 0.9,
+ }),
+ ],
+ }),
+ buildCluster({
+ id: "u2",
+ name: "ingest",
+ replicas: [
+ buildReplica({
+ id: "u20",
+ name: "middling",
+ cpuPercent: 0.5,
+ memoryPercent: 0.5,
+ }),
+ ],
+ }),
+ ];
+
+ /**
+ * Renders the list at `url`, so a bookmarked query string can be replayed.
+ * Settles on the table, or on the empty state when the URL's filters match
+ * nothing and there is no table to wait for.
+ */
+ const renderAt = async (clusters: Cluster[], url = "/") => {
+ getStore().set(allClusters, mockSubscribeState({ data: clusters }));
+ const rendered = renderComponent(
+
+
+ ,
+ { initialRouterEntries: [url] },
+ );
+ await waitFor(() =>
+ expect(
+ screen.queryByRole("table") ?? screen.queryByText(NO_MATCHES_MESSAGE),
+ ).not.toBeNull(),
+ );
+ return rendered;
+ };
+
+ const currentSearch = () =>
+ new URLSearchParams(screen.getByTestId("search").textContent ?? "");
+
+ it("writes an applied filter to the URL", async () => {
+ const user = userEvent.setup();
+ await renderAt(twoClusters());
+
+ await applyFilter(user, "CPU", "40");
+
+ await waitFor(() => expect(currentSearch().get("cpu")).toBe("40"));
+ });
+
+ it("writes a threshold a reader can make sense of", async () => {
+ const user = userEvent.setup();
+ await renderAt(twoClusters());
+
+ await applyFilter(user, "CPU", "40");
+
+ // Nothing percent-encoded: the parameter is the threshold itself.
+ await waitFor(() => expect(currentSearch().get("cpu")).toBe("40"));
+ expect(screen.getByTestId("search").textContent).not.toContain("%");
+ });
+
+ it("writes one parameter per filtered column", async () => {
+ const user = userEvent.setup();
+ await renderAt(twoClusters());
+
+ await applyFilter(user, "CPU", "40");
+ await applyFilter(user, "Memory", "80");
+
+ await waitFor(() => {
+ const params = currentSearch();
+ expect(params.get("cpu")).toBe("40");
+ expect(params.get("memory")).toBe("80");
+ });
+ });
+
+ it("drops a cleared filter from the URL", async () => {
+ const user = userEvent.setup();
+ await renderAt(twoClusters());
+
+ await applyFilter(user, "CPU", "40");
+ await waitFor(() => expect(currentSearch().get("cpu")).toBe("40"));
+
+ await user.click(filterTrigger("CPU"));
+ await user.click(await screen.findByRole("button", { name: "Clear" }));
+
+ await waitFor(() => expect(currentSearch().has("cpu")).toBe(false));
+ });
+
+ it("restores a bookmarked filter, in the rows and in the panel", async () => {
+ const user = userEvent.setup();
+ await renderAt(twoClusters(), "/?cpu=40");
+
+ expect(rowOrder()).toEqual(["busy", "middling"]);
+
+ const { percent } = await panelValues(user, "CPU");
+ expect(percent).toHaveValue("40");
+ });
+
+ it("restores a bookmarked filter for every column at once", async () => {
+ const user = userEvent.setup();
+ await renderAt(twoClusters(), "/?cpu=40&memory=80");
+
+ // middling reaches CPU 40 but not Memory 80; busy reaches both.
+ expect(rowOrder()).toEqual(["busy"]);
+
+ const cpu = await panelValues(user, "CPU");
+ expect(cpu.percent).toHaveValue("40");
+ await user.click(filterTrigger("CPU"));
+
+ const memory = await panelValues(user, "Memory");
+ expect(memory.percent).toHaveValue("80");
+ });
+
+ it("opens the panel on a bookmarked filter's own values", async () => {
+ const user = userEvent.setup();
+ await renderAt(twoClusters(), "/?cpu=40");
+
+ const { percent } = await panelValues(user, "CPU");
+
+ expect(percent).toHaveValue("40");
+ });
+
+ it("restores a bookmarked search term in the search box", async () => {
+ await renderAt(twoClusters(), "/?q=ingest");
+
+ // The box has to show the term it is filtering by, or the table looks
+ // broken rather than filtered.
+ expect(screen.getByLabelText("Search clusters...")).toHaveValue("ingest");
+ expect(rowOrder()).toEqual(["middling"]);
+ });
+
+ it("keeps a bookmarked sort", async () => {
+ await renderAt(twoClusters(), "/?sort=cpuPercent&dir=desc");
+
+ expect(rowOrder()).toEqual(["busy", "middling", "idle"]);
+ });
+
+ describe.each([
+ ["a stale comparison prefix", "/?cpu=gt.40"],
+ ["a non-numeric threshold", "/?cpu=abc"],
+ ["a negative threshold", "/?cpu=-10"],
+ ["a threshold of zero", "/?cpu=0"],
+ ["an empty value", "/?cpu="],
+ ])("given %s", (_label, url) => {
+ it("ignores it and leaves the table unfiltered", async () => {
+ const user = userEvent.setup();
+ await renderAt(twoClusters(), url);
+
+ // A hand-edited or stale link must not strand the user behind a filter
+ // the panel cannot show or clear.
+ expect(rowOrder()).toEqual(["idle", "busy", "middling"]);
+ const { percent } = await panelValues(user, "CPU");
+ expect(percent).toHaveValue("");
+ });
+ });
+
+ it("clamps a bookmarked page that is past the last page", async () => {
+ await renderAt(twoClusters(), "/?page=2");
+
+ // Three replicas fit on one page, so page 2 does not exist. Slicing from
+ // the stored index would render a header with no rows, and TablePagination
+ // hides itself at one page, leaving nothing to click back with.
+ expect(rowOrder()).toEqual(["idle", "busy", "middling"]);
+ });
+
+ /**
+ * 21 replicas: two pages at a page size of 20. By default only the last one
+ * clears a 50% threshold; `cpuPercent` gives every replica the same reading.
+ */
+ const twoPagesOfReplicas = (count = 21, cpuPercent?: number) => [
+ buildCluster({
+ id: "u1",
+ name: "compute",
+ replicas: Array.from({ length: count }, (_, i) =>
+ buildReplica({
+ id: `u${100 + i}`,
+ name: `r-${i}`,
+ cpuPercent: cpuPercent ?? (i === 20 ? 0.9 : 0.1),
+ }),
+ ),
+ }),
+ ];
+
+ it("clamps the page when the rows shrink underneath it", async () => {
+ const user = userEvent.setup();
+ await renderAt(twoPagesOfReplicas());
+
+ await user.click(screen.getByRole("button", { name: "Next page" }));
+ expect(rowOrder()).toEqual(["r-20"]);
+
+ // A subscribe update, not a filter change, so nothing resets the page:
+ // `useUniversalTable` turns off TanStack's automatic reset so a background
+ // refresh cannot yank the user back to page 1.
+ getStore().set(
+ allClusters,
+ mockSubscribeState({ data: twoPagesOfReplicas(3) }),
+ );
+
+ await waitFor(() => expect(rowOrder()).toEqual(["r-0", "r-1", "r-2"]));
+ });
+
+ it("keeps a bookmarked page while the filter matches nothing yet", async () => {
+ // No replica reaches 95%, so the bookmarked filter starts out matching
+ // none of them.
+ await renderAt(twoPagesOfReplicas(), "/?cpu=95&page=2");
+
+ expect(screen.getByText(NO_MATCHES_MESSAGE)).toBeInTheDocument();
+ // Nothing matches, so there is no page count to judge page 2 against.
+ // Dropping it here would lose the page before the rows that justify it are
+ // there to be counted, which is what utilization arriving late looks like.
+ await waitFor(() => expect(currentSearch().get("page")).toBe("2"));
+
+ // The readings now clear the threshold, which is what utilization arriving
+ // after the first render looks like. The bookmarked page is still there to
+ // be honoured.
+ getStore().set(
+ allClusters,
+ mockSubscribeState({ data: twoPagesOfReplicas(21, 0.95) }),
+ );
+
+ await waitFor(() => expect(rowOrder()).toEqual(["r-20"]));
+ });
+
+ it("resets the page when a filter shrinks the row count", async () => {
+ const user = userEvent.setup();
+ // 21 replicas: two pages at a page size of 20.
+ await renderAt([
+ buildCluster({
+ id: "u1",
+ name: "compute",
+ replicas: Array.from({ length: 21 }, (_, i) =>
+ buildReplica({
+ id: `u${100 + i}`,
+ name: `r-${i}`,
+ // Only the last replica clears a 50% threshold.
+ cpuPercent: i === 20 ? 0.9 : 0.1,
+ }),
+ ),
+ }),
+ ]);
+
+ await user.click(screen.getByRole("button", { name: "Next page" }));
+ expect(rowOrder()).toEqual(["r-20"]);
+
+ await applyFilter(user, "CPU", "50");
+
+ expect(rowOrder()).toEqual(["r-20"]);
+ });
+
+ it("accepts a fractional threshold", async () => {
+ const user = userEvent.setup();
+ await renderAt(twoClusters(), "/?cpu=7.5");
+
+ expect(rowOrder()).toEqual(["busy", "middling"]);
+ const { percent } = await panelValues(user, "CPU");
+ expect(percent).toHaveValue("7.5");
+ });
+});
+
+describe("ClustersList filter chips", () => {
+ /**
+ * `hot-cpu` clears a high CPU threshold and a low Memory one at once, so two
+ * filters can be in force with rows still on screen.
+ */
+ const twoClusters = () => [
+ buildCluster({
+ id: "u1",
+ name: "compute",
+ replicas: [
+ buildReplica({
+ id: "u10",
+ name: "idle",
+ cpuPercent: 0.05,
+ memoryPercent: 0.05,
+ }),
+ buildReplica({
+ id: "u11",
+ name: "busy",
+ cpuPercent: 0.9,
+ memoryPercent: 0.9,
+ }),
+ buildReplica({
+ id: "u12",
+ name: "hot-cpu",
+ cpuPercent: 0.9,
+ memoryPercent: 0.1,
+ }),
+ ],
+ }),
+ ];
+
+ const chips = () =>
+ screen
+ .queryAllByRole("button", { name: /^Remove / })
+ .map((button) =>
+ button.getAttribute("aria-label")?.replace("Remove ", ""),
+ );
+
+ it("shows no chip until a filter is applied", async () => {
+ await renderClustersList(twoClusters());
+
+ expect(chips()).toEqual([]);
+ });
+
+ it("states the applied condition on a chip", async () => {
+ const user = userEvent.setup();
+ await renderClustersList(twoClusters());
+
+ await applyFilter(user, "CPU", "40");
+
+ // The header trigger only signals that a filter is on, by colour, so the
+ // chip is where the condition is legible.
+ expect(chips()).toEqual(["CPU ≥ 40%"]);
+ });
+
+ it("carries one chip per filtered column, in column order", async () => {
+ const user = userEvent.setup();
+ await renderClustersList(twoClusters());
+
+ await applyFilter(user, "Memory", "80");
+ await applyFilter(user, "CPU", "40");
+
+ // CPU precedes Memory in the table, so its chip leads regardless of which
+ // filter was applied first.
+ expect(chips()).toEqual(["CPU ≥ 40%", "Memory ≥ 80%"]);
+ });
+
+ it("clears the filter when its chip is removed", async () => {
+ const user = userEvent.setup();
+ await renderClustersList(twoClusters());
+
+ await applyFilter(user, "CPU", "40");
+ expect(rowOrder()).toEqual(["busy", "hot-cpu"]);
+
+ await user.click(screen.getByRole("button", { name: "Remove CPU ≥ 40%" }));
+
+ expect(rowOrder()).toEqual(["idle", "busy", "hot-cpu"]);
+ expect(chips()).toEqual([]);
+ });
+
+ it("removes one column's filter and leaves the rest", async () => {
+ const user = userEvent.setup();
+ await renderClustersList(twoClusters());
+
+ await applyFilter(user, "CPU", "40");
+ await applyFilter(user, "Memory", "80");
+
+ await user.click(screen.getByRole("button", { name: "Remove CPU ≥ 40%" }));
+
+ expect(chips()).toEqual(["Memory ≥ 80%"]);
+ });
+
+ it("empties the panel of a filter removed by its chip", async () => {
+ const user = userEvent.setup();
+ await renderClustersList(twoClusters());
+
+ await applyFilter(user, "CPU", "40");
+ await user.click(screen.getByRole("button", { name: "Remove CPU ≥ 40%" }));
+
+ // The panel stays mounted between opens, so it has to follow the filter
+ // rather than hold the value the chip just removed.
+ const { percent } = await panelValues(user, "CPU");
+ expect(percent).toHaveValue("");
+ });
+
+ it("offers a chip for a filter restored from the URL", async () => {
+ getStore().set(allClusters, mockSubscribeState({ data: twoClusters() }));
+ renderComponent(, {
+ initialRouterEntries: ["/?cpu=40"],
+ });
+ await screen.findByRole("table");
+
+ expect(chips()).toEqual(["CPU ≥ 40%"]);
+ });
+
+ it("keeps its chip when the filter empties the table", async () => {
+ const user = userEvent.setup();
+ await renderClustersList(twoClusters());
+
+ await applyFilter(user, "CPU", "99");
+
+ // The table is gone, headers and filter panels with it, so the chip is the
+ // only way back.
+ expect(screen.getByText(NO_MATCHES_MESSAGE)).toBeInTheDocument();
+ expect(screen.queryByRole("table")).not.toBeInTheDocument();
+ expect(chips()).toEqual(["CPU ≥ 99%"]);
+
+ await user.click(screen.getByRole("button", { name: "Remove CPU ≥ 99%" }));
+
+ expect(rowOrder()).toEqual(["idle", "busy", "hot-cpu"]);
+ });
+});
diff --git a/console/src/platform/clusters/UtilizationFilterChips.tsx b/console/src/platform/clusters/UtilizationFilterChips.tsx
new file mode 100644
index 0000000000000..bfffbf6944f75
--- /dev/null
+++ b/console/src/platform/clusters/UtilizationFilterChips.tsx
@@ -0,0 +1,63 @@
+// Copyright Materialize, Inc. and contributors. All rights reserved.
+//
+// Use of this software is governed by the Business Source License
+// included in the LICENSE file.
+//
+// As of the Change Date specified in that file, in accordance with
+// the Business Source License, use of this software will be governed
+// by the Apache License, Version 2.0.
+
+import { HStack, Tag, TagCloseButton, TagLabel } from "@chakra-ui/react";
+import { Table } from "@tanstack/react-table";
+import React from "react";
+
+import { utilizationFilterLabel } from "./utilizationFilters";
+
+export interface UtilizationFilterChipsProps {
+ table: Table;
+ /** The filterable columns, in the order their chips should appear. */
+ columns: readonly { id: string; header: string }[];
+}
+
+/**
+ * The utilization filters in force, each removable.
+ *
+ * A column's filter trigger signals only that it is active, by its colour, and
+ * says what it filters by only once its popover is open. The chips put every
+ * condition in one place, readable and removable without hunting across
+ * headers. Renders nothing when no filter is applied.
+ */
+export const UtilizationFilterChips = ({
+ table,
+ columns,
+}: UtilizationFilterChipsProps) => {
+ const chips = columns.flatMap(({ id, header }) => {
+ const column = table.getColumn(id);
+ const value = column?.getFilterValue() as number | undefined;
+ if (!column || !value) return [];
+ return [
+ {
+ id,
+ label: utilizationFilterLabel(header, value),
+ onRemove: () => column.setFilterValue(undefined),
+ },
+ ];
+ });
+
+ if (chips.length === 0) return null;
+
+ return (
+
+ {chips.map((chip) => (
+
+ {chip.label}
+
+
+ ))}
+
+ );
+};
diff --git a/console/src/platform/clusters/UtilizationFilterPanel.tsx b/console/src/platform/clusters/UtilizationFilterPanel.tsx
new file mode 100644
index 0000000000000..b7a38e6b7e9ce
--- /dev/null
+++ b/console/src/platform/clusters/UtilizationFilterPanel.tsx
@@ -0,0 +1,131 @@
+// Copyright Materialize, Inc. and contributors. All rights reserved.
+//
+// Use of this software is governed by the Business Source License
+// included in the LICENSE file.
+//
+// As of the Change Date specified in that file, in accordance with
+// the Business Source License, use of this software will be governed
+// by the Apache License, Version 2.0.
+
+import {
+ Button,
+ HStack,
+ NumberDecrementStepper,
+ NumberIncrementStepper,
+ NumberInput,
+ NumberInputField,
+ NumberInputStepper,
+ Text,
+ useTheme,
+ VStack,
+} from "@chakra-ui/react";
+import { Column } from "@tanstack/react-table";
+import React from "react";
+
+import { MaterializeTheme } from "~/theme";
+
+export interface UtilizationFilterPanelProps {
+ /** The column to filter, whose `filterFn` must be `utilizationFilterFn`. */
+ column: Column;
+ /** Column heading, shown inside the panel to name what is being filtered. */
+ label: string;
+}
+
+/**
+ * Filters one utilization column by a lowest percentage, for the popover
+ * `UniversalTable` anchors on a column header.
+ *
+ * Every utilization column reads the same way, a fraction of the replica's
+ * allocation, so one panel serves all of them.
+ */
+export const UtilizationFilterPanel = ({
+ column,
+ label,
+}: UtilizationFilterPanelProps) => {
+ const { colors } = useTheme();
+ const filterValue = column.getFilterValue() as number | undefined;
+
+ const [percent, setPercent] = React.useState(
+ filterValue ? String(filterValue) : "",
+ );
+
+ // The panel is remounted on each open, so the seed above is what an opening
+ // panel shows. This covers the filter changing while the panel is already
+ // open, which is what removing the column's chip does.
+ React.useEffect(() => {
+ setPercent(filterValue ? String(filterValue) : "");
+ }, [filterValue]);
+
+ const apply = () => {
+ const parsed = parseFloat(percent);
+ // NOTE: no upper bound. `heap_percent` reports RAM plus swap against the
+ // heap limit and can legitimately exceed 100%.
+ column.setFilterValue(parsed > 0 ? parsed : undefined);
+ };
+
+ const clearFilter = () => {
+ // Reset the draft as well as the filter. With nothing applied, clearing
+ // leaves the applied value as it was, `undefined`, so the sync effect has
+ // no change to react to and a threshold typed but never applied would stay
+ // on screen.
+ setPercent("");
+ column.setFilterValue(undefined);
+ };
+
+ return (
+
+
+
+ {label} ≥
+
+ setPercent(next)}
+ >
+ {
+ if (e.key === "Enter") apply();
+ }}
+ />
+
+
+
+
+
+
+ %
+
+
+
+
+
+
+
+ );
+};
diff --git a/console/src/platform/clusters/utilizationFilters.test.ts b/console/src/platform/clusters/utilizationFilters.test.ts
new file mode 100644
index 0000000000000..c2346fb713558
--- /dev/null
+++ b/console/src/platform/clusters/utilizationFilters.test.ts
@@ -0,0 +1,115 @@
+// Copyright Materialize, Inc. and contributors. All rights reserved.
+//
+// Use of this software is governed by the Business Source License
+// included in the LICENSE file.
+//
+// As of the Change Date specified in that file, in accordance with
+// the Business Source License, use of this software will be governed
+// by the Apache License, Version 2.0.
+
+import { Row } from "@tanstack/react-table";
+
+import {
+ utilizationFilterFn,
+ utilizationFilterFromUrl,
+ utilizationFilterLabel,
+ utilizationFilterToUrl,
+} from "./utilizationFilters";
+
+/**
+ * A row reporting `fraction` in the column under test. The filter reads nothing
+ * else off the row, so the rest of a `Row` is left out.
+ */
+const rowReporting = (fraction: number | null | undefined) =>
+ ({
+ getValue: () => fraction,
+ }) as unknown as Row;
+
+const keeps = (fraction: number | null | undefined, percent: number) =>
+ utilizationFilterFn(rowReporting(fraction), "cpuPercent", percent);
+
+describe("utilizationFilterFn", () => {
+ it("reads the column as a fraction and the threshold as a percentage", () => {
+ expect(keeps(0.9, 50)).toBe(true);
+ expect(keeps(0.1, 50)).toBe(false);
+ });
+
+ it("keeps a reading exactly on the threshold", () => {
+ // The threshold is a floor, so 50% satisfies "at least 50".
+ expect(keeps(0.5, 50)).toBe(true);
+ });
+
+ it("compares the unrounded reading, not its rounded display value", () => {
+ // Both render as "80.0%" through PercentBar's one decimal place.
+ expect(keeps(0.7996, 80)).toBe(false);
+ expect(keeps(0.8004, 80)).toBe(true);
+ });
+
+ it("excludes a replica with no sample", () => {
+ expect(keeps(null, 50)).toBe(false);
+ expect(keeps(undefined, 50)).toBe(false);
+ });
+
+ it("excludes an idle replica reporting zero", () => {
+ // 0 is a real reading, and it does not reach any threshold the panel can
+ // apply, since a threshold of 0 clears the filter instead.
+ expect(keeps(0, 50)).toBe(false);
+ });
+
+ it("handles a reading above the allocation", () => {
+ // `heap_percent` counts RAM plus swap against the heap limit, so it can
+ // exceed 100%.
+ expect(keeps(1.4, 100)).toBe(true);
+ expect(keeps(0.99, 100)).toBe(false);
+ });
+
+ it("accepts a fractional threshold", () => {
+ expect(keeps(0.08, 7.5)).toBe(true);
+ expect(keeps(0.07, 7.5)).toBe(false);
+ });
+});
+
+describe("utilizationFilterToUrl", () => {
+ it("writes the threshold on its own", () => {
+ expect(utilizationFilterToUrl(80)).toBe("80");
+ expect(utilizationFilterToUrl(7.5)).toBe("7.5");
+ });
+
+ it("survives a round trip, fractions included", () => {
+ for (const percent of [1, 100, 7.5, 250]) {
+ expect(utilizationFilterFromUrl(utilizationFilterToUrl(percent))).toBe(
+ percent,
+ );
+ }
+ });
+});
+
+describe("utilizationFilterFromUrl", () => {
+ it("reads a well-formed parameter", () => {
+ expect(utilizationFilterFromUrl("80")).toBe(80);
+ expect(utilizationFilterFromUrl("7.5")).toBe(7.5);
+ });
+
+ // A hand-edited or stale link must leave the table unfiltered rather than
+ // install a filter the panel cannot show or clear.
+ it.each([
+ ["absent", null],
+ ["empty", ""],
+ ["zero, which is every sampled replica", "0"],
+ ["negative", "-10"],
+ ["non-numeric", "abc"],
+ ["trailing junk", "40x"],
+ ["a comparison prefix", "gt.40"],
+ ["a percent sign", "40%"],
+ ["whitespace", " 40"],
+ ])("rejects a parameter that is %s", (_label, raw) => {
+ expect(utilizationFilterFromUrl(raw)).toBeUndefined();
+ });
+});
+
+describe("utilizationFilterLabel", () => {
+ it("reads as the condition it applies", () => {
+ expect(utilizationFilterLabel("CPU", 40)).toBe("CPU ≥ 40%");
+ expect(utilizationFilterLabel("Memory", 7.5)).toBe("Memory ≥ 7.5%");
+ });
+});
diff --git a/console/src/platform/clusters/utilizationFilters.ts b/console/src/platform/clusters/utilizationFilters.ts
new file mode 100644
index 0000000000000..040c623f73e30
--- /dev/null
+++ b/console/src/platform/clusters/utilizationFilters.ts
@@ -0,0 +1,59 @@
+// Copyright Materialize, Inc. and contributors. All rights reserved.
+//
+// Use of this software is governed by the Business Source License
+// included in the LICENSE file.
+//
+// As of the Change Date specified in that file, in accordance with
+// the Business Source License, use of this software will be governed
+// by the Apache License, Version 2.0.
+
+import { Row } from "@tanstack/react-table";
+
+/**
+ * Keeps rows reporting at least `percent` of their allocation. Written for the
+ * columns whose accessor returns a fraction, which the control states as a
+ * percentage.
+ *
+ * The comparison is fixed at "at least": a utilization filter is asked for to
+ * find what is running hot, so the threshold is a floor and needs no operator
+ * alongside it.
+ *
+ * NOTE: compares the unrounded reading, the same value `PercentBar` colours a
+ * bar by, so a row displaying "80.0%" can fall short of 80 when the reading
+ * behind it is 0.7996.
+ */
+export const utilizationFilterFn = (
+ row: Row,
+ columnId: string,
+ percent: number,
+) => {
+ const fraction = row.getValue(columnId);
+ // A replica with no sample in the window cannot be said to have reached a
+ // threshold, so a filtered list leaves it out rather than guessing.
+ if (fraction === null || fraction === undefined) return false;
+
+ return fraction * 100 >= percent;
+};
+
+/** A threshold as its URL parameter value, for example `80`. */
+export const utilizationFilterToUrl = (percent: number) => String(percent);
+
+/**
+ * The threshold a URL parameter asks for, or undefined when it is absent,
+ * malformed, or not a positive percentage. A hand-edited or stale link must
+ * leave the table unfiltered rather than install a filter the panel cannot show
+ * or clear.
+ */
+export const utilizationFilterFromUrl = (raw: string | null) => {
+ if (raw === null || !/^\d+(?:\.\d+)?$/.test(raw)) return undefined;
+ const percent = parseFloat(raw);
+ // Zero is every sampled replica, which is not a filter worth holding.
+ return percent > 0 ? percent : undefined;
+};
+
+/**
+ * A threshold stated for display, for example `CPU ≥ 40%`. Reads as the
+ * condition it applies, so a chip or a summary needs nothing added around it.
+ */
+export const utilizationFilterLabel = (heading: string, percent: number) =>
+ `${heading} ≥ ${percent}%`;