From be16bdfd73a2a05fbe946f202b549d3cdb23faed Mon Sep 17 00:00:00 2001 From: Jeremy Donelson Date: Tue, 25 Aug 2026 13:22:01 -0400 Subject: [PATCH 1/8] console: CNS-144 Add Filter controls for cluster table. Table rows can be filtered by usage metric thresholds, e.g. >70% Memory. --- .../platform/clusters/ClusterUsageTable.tsx | 195 ++++++- .../platform/clusters/ClustersList.test.tsx | 540 +++++++++++++++++- .../platform/clusters/UtilizationFilter.tsx | 224 ++++++++ .../clusters/utilizationFilters.test.ts | 144 +++++ .../platform/clusters/utilizationFilters.ts | 82 +++ 5 files changed, 1153 insertions(+), 32 deletions(-) create mode 100644 console/src/platform/clusters/UtilizationFilter.tsx create mode 100644 console/src/platform/clusters/utilizationFilters.test.ts create mode 100644 console/src/platform/clusters/utilizationFilters.ts diff --git a/console/src/platform/clusters/ClusterUsageTable.tsx b/console/src/platform/clusters/ClusterUsageTable.tsx index fdc106882757e..f812075f884ac 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 { UtilizationFilter } from "./UtilizationFilter"; +import { + utilizationFilterFn, + utilizationFilterFromUrl, + utilizationFilterToUrl, + UtilizationFilterValue, +} from "./utilizationFilters"; /** * The utilization readings a row displays, as fractions of the replica's @@ -134,17 +151,63 @@ 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) => , }); @@ -175,16 +238,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 +271,8 @@ const columns = [ }), ]; +const PAGE_SIZE = 20; + export interface ClusterUsageTableProps { clusters: ClusterWithOwnership[]; } @@ -228,9 +284,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 +319,99 @@ 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 UtilizationFilterValue, + ); + } + } + 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); + + const noMatches = table.getFilteredRowModel().rows.length === 0; + return ( - - - + + + {UTILIZATION_COLUMNS.map(({ id, header }) => { + const column = table.getColumn(id); + return ( + column && ( + + ) + ); + })} + + {noMatches ? ( + + + + + + ) : ( + <> + + + + )} ); }; diff --git a/console/src/platform/clusters/ClustersList.test.tsx b/console/src/platform/clusters/ClustersList.test.tsx index 42ef8051b442c..6fe8ec63ce493 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,39 @@ const rowOrderAfter = async ( return rowOrder(); }; +/** The toolbar control for the utilization column headed `label`. */ +const filterTrigger = (label: string) => + screen.getByRole("button", { name: new RegExp(`^${label}`) }); + +/** Opens a control's panel, returning its Apply button once mounted. */ +const openFilter = async ( + user: ReturnType, + label: string, +) => { + await user.click(filterTrigger(label)); + return screen.findByRole("button", { name: "Apply" }); +}; + +/** Opens the control for `label`, sets `comparison` and `percent`, applies. */ +const applyFilter = async ( + user: ReturnType, + label: string, + comparison: ">" | "<", + percent: string, +) => { + const apply = await openFilter(user, label); + await user.selectOptions( + screen.getByLabelText(`${label} comparison`), + comparison, + ); + await user.clear(screen.getByLabelText(`${label} threshold percentage`)); + await user.type( + screen.getByLabelText(`${label} threshold percentage`), + percent, + ); + await user.click(apply); +}; + describe("ClustersList replica rows", () => { it("renders one row per replica, naming the cluster on each", async () => { await renderClustersList([buildCluster()]); @@ -866,11 +905,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 +929,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 +937,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 +1081,489 @@ 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, + comparison: ">" | "<", + percent: string, + ) => applyFilter(user, "CPU", comparison, 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("keeps only the replicas below the threshold", async () => { + const user = userEvent.setup(); + await renderClustersList(twoClusters()); + + await applyCpuFilter(user, "<", "40"); + + expect(rowOrder()).toEqual(["idle"]); + }); + + 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 sits on neither side of the threshold, so it is out + // of a filtered list either way. + await applyCpuFilter(user, "<", "50"); + + expect(rowOrder()).toEqual([]); + expect(screen.getByText(NO_MATCHES_MESSAGE)).toBeInTheDocument(); + }); + + 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("states the applied condition on the control", async () => { + const user = userEvent.setup(); + await renderClustersList(twoClusters()); + + await applyCpuFilter(user, ">", "40"); + + // Readable without reopening the panel. + expect( + screen.getByRole("button", { name: /^CPU > 40%/ }), + ).toBeInTheDocument(); + }); + + 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("keeps the control reachable when the filter empties the table", async () => { + const user = userEvent.setup(); + await renderClustersList(twoClusters()); + + await applyCpuFilter(user, ">", "99"); + + // The message replaces the table, not the toolbar: clearing the filter has + // to stay possible. + expect(screen.getByText(NO_MATCHES_MESSAGE)).toBeInTheDocument(); + await user.click(cpuTrigger()); + await user.click(await screen.findByRole("button", { name: "Clear" })); + + 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 user.click(cpuTrigger()); + await user.click(await screen.findByRole("button", { name: "Clear" })); + + expect(rowOrder()).toEqual(["idle", "busy", "middling"]); + expect(cpuTrigger()).toHaveTextContent(/^CPU$/); + }); + + it("reopens showing the filter in force", async () => { + const user = userEvent.setup(); + await renderClustersList(twoClusters()); + + await applyCpuFilter(user, "<", "40"); + await openCpuFilter(user); + + expect(screen.getByLabelText("CPU comparison")).toHaveValue("<"); + expect(screen.getByLabelText("CPU threshold percentage")).toHaveValue("40"); + }); + + it("cannot be applied with an empty threshold", async () => { + const user = userEvent.setup(); + await renderClustersList(twoClusters()); + + const apply = await openCpuFilter(user); + await user.clear(screen.getByLabelText("CPU threshold percentage")); + + expect(apply).toBeDisabled(); + }); + + 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("labels each control with its column heading", async () => { + await renderClustersList([buildCluster()]); + + for (const [label] of CONTROLS) { + expect( + screen.getByRole("columnheader", { name: new RegExp(`^${label}`) }), + ).toBeInTheDocument(); + expect(filterTrigger(label)).toBeInTheDocument(); + } + }); + + 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("states the applied condition on its own control only", async () => { + const user = userEvent.setup(); + await renderClustersList([pair()]); + + await applyFilter(user, label, ">", "50"); + + expect(filterTrigger(label)).toHaveTextContent(`${label} > 50%`); + for (const [other] of CONTROLS.filter(([name]) => name !== label)) { + expect(filterTrigger(other)).toHaveTextContent( + new RegExp(`^${other}$`), + ); + } + }); + + it("is cleared without disturbing the other columns", async () => { + const user = userEvent.setup(); + await renderClustersList([pair()]); + + await applyFilter(user, label, ">", "50"); + await user.click(filterTrigger(label)); + await user.click(await screen.findByRole("button", { name: "Clear" })); + + 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. */ + const renderAt = async (clusters: Cluster[], url = "/") => { + getStore().set(allClusters, mockSubscribeState({ data: clusters })); + const rendered = renderComponent( + + + , + { initialRouterEntries: [url] }, + ); + await screen.findByRole("table"); + 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("gt.40")); + }); + + it("spells the comparison as a word rather than percent-encoding it", async () => { + const user = userEvent.setup(); + await renderAt(twoClusters()); + + await applyFilter(user, "CPU", "<", "40"); + + // A raw ">" or "<" would reach the user's bookmark bar as %3E or %3C. + await waitFor(() => expect(currentSearch().get("cpu")).toBe("lt.40")); + expect(screen.getByTestId("search").textContent).not.toContain("%3"); + }); + + 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("gt.40"); + expect(params.get("memory")).toBe("lt.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("gt.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 on the control", async () => { + await renderAt(twoClusters(), "/?cpu=gt.40"); + + expect(rowOrder()).toEqual(["busy", "middling"]); + expect(filterTrigger("CPU")).toHaveTextContent("CPU > 40%"); + }); + + it("restores a bookmarked filter for every column at once", async () => { + await renderAt(twoClusters(), "/?cpu=gt.40&memory=lt.80"); + + // busy clears CPU > 40 but not Memory < 80; middling clears both. + expect(rowOrder()).toEqual(["middling"]); + expect(filterTrigger("CPU")).toHaveTextContent("CPU > 40%"); + expect(filterTrigger("Memory")).toHaveTextContent("Memory < 80%"); + }); + + it("opens the panel on a bookmarked filter's own values", async () => { + const user = userEvent.setup(); + await renderAt(twoClusters(), "/?cpu=lt.40"); + + await openFilter(user, "CPU"); + + expect(screen.getByLabelText("CPU comparison")).toHaveValue("<"); + expect(screen.getByLabelText("CPU threshold percentage")).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([ + ["an unknown comparison", "/?cpu=ge.40"], + ["a missing threshold", "/?cpu=gt."], + ["a non-numeric threshold", "/?cpu=gt.abc"], + ["a bare number", "/?cpu=40"], + ["an empty value", "/?cpu="], + ])("given %s", (_label, url) => { + it("ignores it and leaves the table unfiltered", async () => { + await renderAt(twoClusters(), url); + + // A hand-edited or stale link must not strand the user behind a filter + // the control cannot show or clear. + expect(rowOrder()).toEqual(["idle", "busy", "middling"]); + expect(filterTrigger("CPU")).toHaveTextContent(/^CPU$/); + }); + }); + + it("accepts a fractional threshold", async () => { + await renderAt(twoClusters(), "/?cpu=gt.7.5"); + + expect(filterTrigger("CPU")).toHaveTextContent("CPU > 7.5%"); + expect(rowOrder()).toEqual(["busy", "middling"]); + }); +}); diff --git a/console/src/platform/clusters/UtilizationFilter.tsx b/console/src/platform/clusters/UtilizationFilter.tsx new file mode 100644 index 0000000000000..50e8e66b61f45 --- /dev/null +++ b/console/src/platform/clusters/UtilizationFilter.tsx @@ -0,0 +1,224 @@ +// 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, + Popover, + PopoverContent, + PopoverTrigger, + Select, + Text, + useTheme, + VStack, +} from "@chakra-ui/react"; +import { Column } from "@tanstack/react-table"; +import React from "react"; + +import { ChevronDownIcon } from "~/icons"; +import { MaterializeTheme } from "~/theme"; +import { viewportOverflowModifier } from "~/theme/components/Popover"; + +import { + DEFAULT_COMPARISON, + UtilizationComparison, + UtilizationFilterValue, +} from "./utilizationFilters"; + +/** + * The trigger's caption: the column name alone, or the condition in force, so + * an applied filter is readable without opening the panel. + */ +const triggerLabel = ( + label: string, + value: UtilizationFilterValue | undefined, +) => (value ? `${label} ${value.comparison} ${value.percent}%` : label); + +/** + * The panel's editable copy of the filter. Applied on Apply rather than on + * every keystroke, so a half-typed threshold never reorders the table. + * + * Mounted fresh on each open (the popover unmounts its content when closed), so + * the draft starts from whatever filter is currently in force. + */ +const UtilizationFilterPanel = ({ + column, + label, + onClose, +}: { + column: Column; + label: string; + onClose: () => void; +}) => { + const { colors } = useTheme(); + const value = column.getFilterValue() as UtilizationFilterValue | undefined; + + const [comparison, setComparison] = React.useState( + value?.comparison ?? DEFAULT_COMPARISON, + ); + const [percent, setPercent] = React.useState( + value ? String(value.percent) : "", + ); + + const parsed = Number.parseFloat(percent); + // NOTE: no upper bound. `heap_percent` reports RAM plus swap against the heap + // limit and can legitimately exceed 100%. + const canApply = Number.isFinite(parsed) && parsed >= 0; + + const apply = () => { + if (!canApply) return; + column.setFilterValue({ comparison, percent: parsed }); + onClose(); + }; + + const clearFilter = () => { + column.setFilterValue(undefined); + onClose(); + }; + + return ( + + + + {label} + + + setPercent(next)} + > + { + if (e.key === "Enter") apply(); + }} + /> + + + + + + + % + + + + + + + + ); +}; + +export interface UtilizationFilterProps { + /** The column to filter, whose `filterFn` must be `utilizationFilterFn`. */ + column: Column; + /** Column name, shown on the trigger and inside the panel. */ + label: string; +} + +/** + * Toolbar control filtering one utilization column by a percentage threshold. + * + * Every utilization column reads the same way, a fraction of the replica's + * allocation, so one control serves all of them. + */ +export const UtilizationFilter = ({ + column, + label, +}: UtilizationFilterProps) => { + const { colors } = useTheme(); + const value = column.getFilterValue() as UtilizationFilterValue | undefined; + const isActive = value !== undefined; + + return ( + + {({ onClose }) => ( + <> + + + + + + + + )} + + ); +}; diff --git a/console/src/platform/clusters/utilizationFilters.test.ts b/console/src/platform/clusters/utilizationFilters.test.ts new file mode 100644 index 0000000000000..b81b1b37c6419 --- /dev/null +++ b/console/src/platform/clusters/utilizationFilters.test.ts @@ -0,0 +1,144 @@ +// 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, + utilizationFilterToUrl, + UtilizationFilterValue, +} 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, + filter: UtilizationFilterValue, +) => utilizationFilterFn(rowReporting(fraction), "cpuPercent", filter); + +describe("utilizationFilterFn", () => { + const above50: UtilizationFilterValue = { comparison: ">", percent: 50 }; + const below50: UtilizationFilterValue = { comparison: "<", percent: 50 }; + + it("reads the column as a fraction and the threshold as a percentage", () => { + expect(keeps(0.9, above50)).toBe(true); + expect(keeps(0.1, above50)).toBe(false); + }); + + it("keeps readings below the threshold when comparing with <", () => { + expect(keeps(0.1, below50)).toBe(true); + expect(keeps(0.9, below50)).toBe(false); + }); + + it("excludes a reading exactly on the threshold, either direction", () => { + // Both comparisons are strict, so 50% satisfies neither "> 50" nor "< 50". + expect(keeps(0.5, above50)).toBe(false); + expect(keeps(0.5, below50)).toBe(false); + }); + + it("compares the unrounded reading, not its rounded display value", () => { + const above80: UtilizationFilterValue = { comparison: ">", percent: 80 }; + // Both render as "80.0%" through PercentBar's one decimal place. + expect(keeps(0.7996, above80)).toBe(false); + expect(keeps(0.8004, above80)).toBe(true); + }); + + it("excludes a replica with no sample, whichever way the filter points", () => { + expect(keeps(null, above50)).toBe(false); + expect(keeps(null, below50)).toBe(false); + expect(keeps(undefined, above50)).toBe(false); + }); + + it("keeps an idle replica reporting zero when the filter allows it", () => { + // 0 is a real reading, not a missing one. + expect(keeps(0, below50)).toBe(true); + expect(keeps(0, above50)).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, { comparison: ">", percent: 100 })).toBe(true); + expect(keeps(1.4, { comparison: "<", percent: 100 })).toBe(false); + }); + + it("accepts a fractional threshold", () => { + expect(keeps(0.08, { comparison: ">", percent: 7.5 })).toBe(true); + expect(keeps(0.07, { comparison: ">", percent: 7.5 })).toBe(false); + }); +}); + +describe("utilizationFilterToUrl", () => { + it("spells the comparison as a word", () => { + // A raw ">" percent-encodes to "%3E", which makes a bookmark unreadable. + expect(utilizationFilterToUrl({ comparison: ">", percent: 80 })).toBe( + "gt.80", + ); + expect(utilizationFilterToUrl({ comparison: "<", percent: 80 })).toBe( + "lt.80", + ); + }); + + it("survives a round trip, fractions included", () => { + for (const value of [ + { comparison: ">", percent: 0 }, + { comparison: "<", percent: 100 }, + { comparison: ">", percent: 7.5 }, + ] satisfies UtilizationFilterValue[]) { + expect(utilizationFilterFromUrl(utilizationFilterToUrl(value))).toEqual( + value, + ); + } + }); +}); + +describe("utilizationFilterFromUrl", () => { + it("reads a well-formed parameter", () => { + expect(utilizationFilterFromUrl("gt.80")).toEqual({ + comparison: ">", + percent: 80, + }); + expect(utilizationFilterFromUrl("lt.5")).toEqual({ + comparison: "<", + percent: 5, + }); + }); + + it("reads a fractional threshold", () => { + expect(utilizationFilterFromUrl("gt.7.5")).toEqual({ + comparison: ">", + percent: 7.5, + }); + }); + + // A hand-edited or stale link must leave the table unfiltered rather than + // install a filter the control cannot display or clear. + it.each([ + ["absent", null], + ["empty", ""], + ["an unknown comparison", "ge.40"], + ["no comparison", "40"], + ["no threshold", "gt."], + ["a non-numeric threshold", "gt.abc"], + ["a negative threshold", "gt.-10"], + ["trailing junk", "gt.40x"], + ["leading junk", "xgt.40"], + ["a comparison alone", "gt"], + ])("rejects a parameter that is %s", (_label, raw) => { + expect(utilizationFilterFromUrl(raw)).toBeUndefined(); + }); +}); diff --git a/console/src/platform/clusters/utilizationFilters.ts b/console/src/platform/clusters/utilizationFilters.ts new file mode 100644 index 0000000000000..b0877f938c1e4 --- /dev/null +++ b/console/src/platform/clusters/utilizationFilters.ts @@ -0,0 +1,82 @@ +// 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"; + +/** The side of the threshold a row has to fall on to be kept. */ +export type UtilizationComparison = ">" | "<"; + +export interface UtilizationFilterValue { + comparison: UtilizationComparison; + /** Threshold as a whole percentage, as typed into the control. */ + percent: number; +} + +export const DEFAULT_COMPARISON: UtilizationComparison = ">"; + +/** + * Keeps rows whose utilization reading falls on the requested side of the + * threshold. Written for the columns whose accessor returns a fraction of the + * replica's allocation, which the control states as a percentage. + * + * NOTE: compares the unrounded reading, the same value `PercentBar` colours a + * bar by, so a row displaying "80.0%" can fall outside "> 80" when the reading + * behind it is 0.7996. + */ +export const utilizationFilterFn = ( + row: Row, + columnId: string, + filterValue: UtilizationFilterValue, +) => { + const fraction = row.getValue(columnId); + // A replica with no sample in the window cannot be said to sit on either + // side of a threshold, so a filtered list leaves it out rather than + // guessing. + if (fraction === null || fraction === undefined) return false; + + const percent = fraction * 100; + return filterValue.comparison === ">" + ? percent > filterValue.percent + : percent < filterValue.percent; +}; + +/** + * How a comparison is spelled in the URL. Words rather than the operators + * themselves: `>` percent-encodes to `%3E`, which makes a bookmarked URL + * unreadable. + */ +const COMPARISON_URL_TOKENS: Record = { + ">": "gt", + "<": "lt", +}; + +/** + * A filter as one URL parameter value, for example `gt.80`. Anchored, so the + * separator is unambiguous even when the threshold carries a decimal point. + */ +const URL_VALUE_PATTERN = /^(gt|lt)\.(\d+(?:\.\d+)?)$/; + +export const utilizationFilterToUrl = (value: UtilizationFilterValue) => + `${COMPARISON_URL_TOKENS[value.comparison]}.${value.percent}`; + +/** + * The filter a URL parameter asks for, or undefined when it is absent or + * malformed. A hand-edited or stale link must leave the table unfiltered rather + * than install a filter the control cannot show or clear. + */ +export const utilizationFilterFromUrl = ( + raw: string | null, +): UtilizationFilterValue | undefined => { + const match = raw?.match(URL_VALUE_PATTERN); + if (!match) return undefined; + return { + comparison: match[1] === "gt" ? ">" : "<", + percent: parseFloat(match[2]), + }; +}; From 11b8ec197c8cf4c9d56962f4862647ada4883488 Mon Sep 17 00:00:00 2001 From: Jeremy Donelson Date: Wed, 26 Aug 2026 12:26:29 -0400 Subject: [PATCH 2/8] CNS-144 Because data might change between page views, URL-driven pagination had the potential to display tables with zero rows. That case is now caught and the table displays its last available page instead. --- .../components/Table/UniversalTable.test.tsx | 67 ++++++++++++++++++ .../src/components/Table/useUniversalTable.ts | 27 +++++++- .../platform/clusters/ClustersList.test.tsx | 69 +++++++++++++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) diff --git a/console/src/components/Table/UniversalTable.test.tsx b/console/src/components/Table/UniversalTable.test.tsx index 07ce8294e6b21..9d0559f7a46e9 100644 --- a/console/src/components/Table/UniversalTable.test.tsx +++ b/console/src/components/Table/UniversalTable.test.tsx @@ -120,6 +120,36 @@ const PaginatedTable = ({ ); }; +/** Seeds a page index the caller chooses, as a URL-restored page would. */ +const TableAtPage = ({ + data = testData, + pageSize = 2, + pageIndex, + manualPagination = false, +}: { + data?: TestCluster[]; + pageSize?: number; + pageIndex: number; + manualPagination?: boolean; +}) => { + const table = useUniversalTable({ + data, + columns, + pageSize, + manualPagination, + initialState: { pagination: { pageIndex, pageSize } }, + }); + return ( +
+ + +
+ {table.getState().pagination.pageIndex} +
+
+ ); +}; + const footerColumns = [ columnHelper.accessor("name", { header: "Name", footer: "Total" }), columnHelper.accessor("replicas", { @@ -433,6 +463,43 @@ 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("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("Row Click", () => { diff --git a/console/src/components/Table/useUniversalTable.ts b/console/src/components/Table/useUniversalTable.ts index 3846f6ecbb5cc..e50bb3e0c8960 100644 --- a/console/src/components/Table/useUniversalTable.ts +++ b/console/src/components/Table/useUniversalTable.ts @@ -89,7 +89,7 @@ export const useUniversalTable = ( resetPageIndex(); }; - return useReactTable({ + const table = useReactTable({ ...tableOptions, columns: tableOptions.columns as ColumnDef[], getCoreRowModel: getCoreRowModel(), @@ -128,6 +128,31 @@ 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. + // + // 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) return; + const lastPage = Math.max(0, pageCount - 1); + if (pageIndex > lastPage) { + table.setPageIndex(lastPage); + } + }, [table, tableOptions.manualPagination, pageCount, pageIndex]); + + return table; }; /** diff --git a/console/src/platform/clusters/ClustersList.test.tsx b/console/src/platform/clusters/ClustersList.test.tsx index 6fe8ec63ce493..6f3edf0ba5164 100644 --- a/console/src/platform/clusters/ClustersList.test.tsx +++ b/console/src/platform/clusters/ClustersList.test.tsx @@ -1560,6 +1560,75 @@ describe("ClustersList filter URL state", () => { }); }); + 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. */ + const twoPagesOfReplicas = (count = 21) => [ + buildCluster({ + id: "u1", + name: "compute", + replicas: Array.from({ length: count }, (_, i) => + buildReplica({ + id: `u${100 + i}`, + name: `r-${i}`, + // Only the last replica clears a 50% threshold. + 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("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 () => { await renderAt(twoClusters(), "/?cpu=gt.7.5"); From b38c4aefeb64a71c4ae94ff71ddbf05b745cd6f5 Mon Sep 17 00:00:00 2001 From: Jeremy Donelson Date: Wed, 26 Aug 2026 13:28:30 -0400 Subject: [PATCH 3/8] CNS-144 covered 'page 0' edge case for URL pagination --- .../components/Table/UniversalTable.test.tsx | 55 +++++++++++++++++++ .../src/components/Table/useUniversalTable.ts | 14 +++-- .../platform/clusters/ClustersList.test.tsx | 44 +++++++++++++-- 3 files changed, 103 insertions(+), 10 deletions(-) diff --git a/console/src/components/Table/UniversalTable.test.tsx b/console/src/components/Table/UniversalTable.test.tsx index 9d0559f7a46e9..b40ae65c9eaaa 100644 --- a/console/src/components/Table/UniversalTable.test.tsx +++ b/console/src/components/Table/UniversalTable.test.tsx @@ -150,6 +150,38 @@ const TableAtPage = ({ ); }; +/** + * 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} +
+
+ ); +}; + const footerColumns = [ columnHelper.accessor("name", { header: "Name", footer: "Total" }), columnHelper.accessor("replicas", { @@ -491,6 +523,29 @@ describe("UniversalTable", () => { 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". diff --git a/console/src/components/Table/useUniversalTable.ts b/console/src/components/Table/useUniversalTable.ts index e50bb3e0c8960..6ef514aa8f6c3 100644 --- a/console/src/components/Table/useUniversalTable.ts +++ b/console/src/components/Table/useUniversalTable.ts @@ -140,15 +140,21 @@ export const useUniversalTable = ( // 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) return; - const lastPage = Math.max(0, pageCount - 1); - if (pageIndex > lastPage) { - table.setPageIndex(lastPage); + if (tableOptions.manualPagination || pageCount < 1) return; + if (pageIndex > pageCount - 1) { + table.setPageIndex(pageCount - 1); } }, [table, tableOptions.manualPagination, pageCount, pageIndex]); diff --git a/console/src/platform/clusters/ClustersList.test.tsx b/console/src/platform/clusters/ClustersList.test.tsx index 6f3edf0ba5164..9c3bb4e752d5f 100644 --- a/console/src/platform/clusters/ClustersList.test.tsx +++ b/console/src/platform/clusters/ClustersList.test.tsx @@ -1439,7 +1439,11 @@ describe("ClustersList filter URL state", () => { }), ]; - /** Renders the list at `url`, so a bookmarked query string can be replayed. */ + /** + * 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( @@ -1448,7 +1452,11 @@ describe("ClustersList filter URL state", () => { , { initialRouterEntries: [url] }, ); - await screen.findByRole("table"); + await waitFor(() => + expect( + screen.queryByRole("table") ?? screen.queryByText(NO_MATCHES_MESSAGE), + ).not.toBeNull(), + ); return rendered; }; @@ -1569,8 +1577,11 @@ describe("ClustersList filter URL state", () => { expect(rowOrder()).toEqual(["idle", "busy", "middling"]); }); - /** 21 replicas: two pages at a page size of 20. */ - const twoPagesOfReplicas = (count = 21) => [ + /** + * 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", @@ -1578,8 +1589,7 @@ describe("ClustersList filter URL state", () => { buildReplica({ id: `u${100 + i}`, name: `r-${i}`, - // Only the last replica clears a 50% threshold. - cpuPercent: i === 20 ? 0.9 : 0.1, + cpuPercent: cpuPercent ?? (i === 20 ? 0.9 : 0.1), }), ), }), @@ -1603,6 +1613,28 @@ describe("ClustersList filter URL state", () => { 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 clears a 90% threshold, so the bookmarked filter starts out + // matching none of them. + await renderAt(twoPagesOfReplicas(), "/?cpu=gt.90&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. From 2f248369adea7955e6c19b83cfe50100dd99ef4a Mon Sep 17 00:00:00 2001 From: Jeremy Donelson Date: Wed, 26 Aug 2026 14:09:42 -0400 Subject: [PATCH 4/8] CNS-144 moved filter controls to column headers and added filter chips to match Maintained Objects table behavior. --- .../platform/clusters/ClusterUsageTable.tsx | 40 ++- .../platform/clusters/ClustersList.test.tsx | 290 +++++++++++++++--- .../clusters/UtilizationFilterChips.tsx | 68 ++++ ...nFilter.tsx => UtilizationFilterPanel.tsx} | 104 ++----- .../clusters/utilizationFilters.test.ts | 12 + .../platform/clusters/utilizationFilters.ts | 9 + 6 files changed, 383 insertions(+), 140 deletions(-) create mode 100644 console/src/platform/clusters/UtilizationFilterChips.tsx rename console/src/platform/clusters/{UtilizationFilter.tsx => UtilizationFilterPanel.tsx} (61%) diff --git a/console/src/platform/clusters/ClusterUsageTable.tsx b/console/src/platform/clusters/ClusterUsageTable.tsx index f812075f884ac..ad021d1005f7a 100644 --- a/console/src/platform/clusters/ClusterUsageTable.tsx +++ b/console/src/platform/clusters/ClusterUsageTable.tsx @@ -49,7 +49,8 @@ import { ClusterTableMeta, } from "./clusterTableCells"; import { useReplicaUtilization } from "./queries"; -import { UtilizationFilter } from "./UtilizationFilter"; +import { UtilizationFilterChips } from "./UtilizationFilterChips"; +import { UtilizationFilterPanel } from "./UtilizationFilterPanel"; import { utilizationFilterFn, utilizationFilterFromUrl, @@ -209,6 +210,11 @@ const percentColumn = ({ id, header, read }: UtilizationColumn) => sortDescFirst: true, filterFn: utilizationFilterFn, cell: (info) => , + meta: { + renderFilter: (column) => ( + + ), + }, }); const columns = [ @@ -374,31 +380,31 @@ export const ClusterUsageTable = ({ clusters }: ClusterUsageTableProps) => { ]); 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 ( - - - {UTILIZATION_COLUMNS.map(({ id, header }) => { - const column = table.getColumn(id); - return ( - column && ( - - ) - ); - })} - + + {/* + * 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 9c3bb4e752d5f..961d9f83671c4 100644 --- a/console/src/platform/clusters/ClustersList.test.tsx +++ b/console/src/platform/clusters/ClustersList.test.tsx @@ -221,11 +221,29 @@ const rowOrderAfter = async ( return rowOrder(); }; -/** The toolbar control for the utilization column headed `label`. */ +/** + * 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: new RegExp(`^${label}`) }); + screen.getByRole("button", { name: `Filter ${FILTER_COLUMN_IDS[label]}` }); -/** Opens a control's panel, returning its Apply button once mounted. */ +/** + * 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, @@ -234,7 +252,14 @@ const openFilter = async ( return screen.findByRole("button", { name: "Apply" }); }; -/** Opens the control for `label`, sets `comparison` and `percent`, applies. */ +/** + * Sets `comparison` and `percent` on `label`'s filter 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, @@ -252,6 +277,37 @@ const applyFilter = async ( 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 { + comparison: screen.getByLabelText(`${label} comparison`), + percent: screen.getByLabelText(`${label} threshold percentage`), + }; }; describe("ClustersList replica rows", () => { @@ -1192,16 +1248,15 @@ describe("ClustersList CPU filter", () => { expect(rowOrder()).toEqual(["busy"]); }); - it("states the applied condition on the control", async () => { + it("keeps the applied condition in its panel", async () => { const user = userEvent.setup(); await renderClustersList(twoClusters()); await applyCpuFilter(user, ">", "40"); + const { comparison, percent } = await panelValues(user, "CPU"); - // Readable without reopening the panel. - expect( - screen.getByRole("button", { name: /^CPU > 40%/ }), - ).toBeInTheDocument(); + expect(comparison).toHaveValue(">"); + expect(percent).toHaveValue("40"); }); it("leaves the table alone until Apply is clicked", async () => { @@ -1217,17 +1272,17 @@ describe("ClustersList CPU filter", () => { expect(rowOrder()).toEqual(["idle", "busy", "middling"]); }); - it("keeps the control reachable when the filter empties the table", async () => { + 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, not the toolbar: clearing the filter has - // to stay possible. + // 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(); - await user.click(cpuTrigger()); - await user.click(await screen.findByRole("button", { name: "Clear" })); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Remove CPU > 99%" })); expect(rowOrder()).toEqual(["idle", "busy", "middling"]); }); @@ -1237,11 +1292,11 @@ describe("ClustersList CPU filter", () => { await renderClustersList(twoClusters()); await applyCpuFilter(user, ">", "40"); - await user.click(cpuTrigger()); - await user.click(await screen.findByRole("button", { name: "Clear" })); + await clearFilter(user, "CPU"); expect(rowOrder()).toEqual(["idle", "busy", "middling"]); - expect(cpuTrigger()).toHaveTextContent(/^CPU$/); + const { percent } = await panelValues(user, "CPU"); + expect(percent).toHaveValue(""); }); it("reopens showing the filter in force", async () => { @@ -1249,10 +1304,10 @@ describe("ClustersList CPU filter", () => { await renderClustersList(twoClusters()); await applyCpuFilter(user, "<", "40"); - await openCpuFilter(user); + const { comparison, percent } = await panelValues(user, "CPU"); - expect(screen.getByLabelText("CPU comparison")).toHaveValue("<"); - expect(screen.getByLabelText("CPU threshold percentage")).toHaveValue("40"); + expect(comparison).toHaveValue("<"); + expect(percent).toHaveValue("40"); }); it("cannot be applied with an empty threshold", async () => { @@ -1295,14 +1350,20 @@ describe("ClustersList utilization filters", () => { ["Heap", (value: number) => ({ heapPercent: value })], ] as const; - it("labels each control with its column heading", async () => { + 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(); - expect(filterTrigger(label)).toBeInTheDocument(); + + // The panel names its controls from the heading, so a renamed column + // cannot leave its filter labelled with the old name. + const { comparison } = await panelValues(user, label); + expect(comparison).toBeInTheDocument(); + await user.click(filterTrigger(label)); } }); @@ -1329,17 +1390,20 @@ describe("ClustersList utilization filters", () => { expect(rowOrder()).toEqual(["high"]); }); - it("states the applied condition on its own control only", async () => { + it("holds the condition in its own panel and no other", async () => { const user = userEvent.setup(); await renderClustersList([pair()]); await applyFilter(user, label, ">", "50"); - expect(filterTrigger(label)).toHaveTextContent(`${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)) { - expect(filterTrigger(other)).toHaveTextContent( - new RegExp(`^${other}$`), - ); + const { percent } = await panelValues(user, other); + expect(percent).toHaveValue(""); + await user.click(filterTrigger(other)); } }); @@ -1348,8 +1412,7 @@ describe("ClustersList utilization filters", () => { await renderClustersList([pair()]); await applyFilter(user, label, ">", "50"); - await user.click(filterTrigger(label)); - await user.click(await screen.findByRole("button", { name: "Clear" })); + await clearFilter(user, label); expect(rowOrder()).toEqual(["high", "low"]); }); @@ -1510,30 +1573,41 @@ describe("ClustersList filter URL state", () => { await waitFor(() => expect(currentSearch().has("cpu")).toBe(false)); }); - it("restores a bookmarked filter, in the rows and on the control", async () => { + it("restores a bookmarked filter, in the rows and in the panel", async () => { + const user = userEvent.setup(); await renderAt(twoClusters(), "/?cpu=gt.40"); expect(rowOrder()).toEqual(["busy", "middling"]); - expect(filterTrigger("CPU")).toHaveTextContent("CPU > 40%"); + + const { comparison, percent } = await panelValues(user, "CPU"); + expect(comparison).toHaveValue(">"); + expect(percent).toHaveValue("40"); }); it("restores a bookmarked filter for every column at once", async () => { + const user = userEvent.setup(); await renderAt(twoClusters(), "/?cpu=gt.40&memory=lt.80"); // busy clears CPU > 40 but not Memory < 80; middling clears both. expect(rowOrder()).toEqual(["middling"]); - expect(filterTrigger("CPU")).toHaveTextContent("CPU > 40%"); - expect(filterTrigger("Memory")).toHaveTextContent("Memory < 80%"); + + const cpu = await panelValues(user, "CPU"); + expect(cpu.percent).toHaveValue("40"); + await user.click(filterTrigger("CPU")); + + const memory = await panelValues(user, "Memory"); + expect(memory.comparison).toHaveValue("<"); + 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=lt.40"); - await openFilter(user, "CPU"); + const { comparison, percent } = await panelValues(user, "CPU"); - expect(screen.getByLabelText("CPU comparison")).toHaveValue("<"); - expect(screen.getByLabelText("CPU threshold percentage")).toHaveValue("40"); + expect(comparison).toHaveValue("<"); + expect(percent).toHaveValue("40"); }); it("restores a bookmarked search term in the search box", async () => { @@ -1559,12 +1633,14 @@ describe("ClustersList filter URL state", () => { ["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 control cannot show or clear. + // the panel cannot show or clear. expect(rowOrder()).toEqual(["idle", "busy", "middling"]); - expect(filterTrigger("CPU")).toHaveTextContent(/^CPU$/); + const { percent } = await panelValues(user, "CPU"); + expect(percent).toHaveValue(""); }); }); @@ -1662,9 +1738,145 @@ describe("ClustersList filter URL state", () => { }); it("accepts a fractional threshold", async () => { + const user = userEvent.setup(); await renderAt(twoClusters(), "/?cpu=gt.7.5"); - expect(filterTrigger("CPU")).toHaveTextContent("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=gt.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..6ee329f123114 --- /dev/null +++ b/console/src/platform/clusters/UtilizationFilterChips.tsx @@ -0,0 +1,68 @@ +// 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, + UtilizationFilterValue, +} 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 + | UtilizationFilterValue + | 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/UtilizationFilter.tsx b/console/src/platform/clusters/UtilizationFilterPanel.tsx similarity index 61% rename from console/src/platform/clusters/UtilizationFilter.tsx rename to console/src/platform/clusters/UtilizationFilterPanel.tsx index 50e8e66b61f45..b26f26b4b6487 100644 --- a/console/src/platform/clusters/UtilizationFilter.tsx +++ b/console/src/platform/clusters/UtilizationFilterPanel.tsx @@ -15,9 +15,6 @@ import { NumberInput, NumberInputField, NumberInputStepper, - Popover, - PopoverContent, - PopoverTrigger, Select, Text, useTheme, @@ -26,9 +23,7 @@ import { import { Column } from "@tanstack/react-table"; import React from "react"; -import { ChevronDownIcon } from "~/icons"; import { MaterializeTheme } from "~/theme"; -import { viewportOverflowModifier } from "~/theme/components/Popover"; import { DEFAULT_COMPARISON, @@ -36,31 +31,24 @@ import { UtilizationFilterValue, } from "./utilizationFilters"; -/** - * The trigger's caption: the column name alone, or the condition in force, so - * an applied filter is readable without opening the panel. - */ -const triggerLabel = ( - label: string, - value: UtilizationFilterValue | undefined, -) => (value ? `${label} ${value.comparison} ${value.percent}%` : label); +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; +} /** - * The panel's editable copy of the filter. Applied on Apply rather than on - * every keystroke, so a half-typed threshold never reorders the table. + * Filters one utilization column by a percentage threshold, for the popover + * `UniversalTable` anchors on a column header. * - * Mounted fresh on each open (the popover unmounts its content when closed), so - * the draft starts from whatever filter is currently in force. + * Every utilization column reads the same way, a fraction of the replica's + * allocation, so one panel serves all of them. */ -const UtilizationFilterPanel = ({ +export const UtilizationFilterPanel = ({ column, label, - onClose, -}: { - column: Column; - label: string; - onClose: () => void; -}) => { +}: UtilizationFilterPanelProps) => { const { colors } = useTheme(); const value = column.getFilterValue() as UtilizationFilterValue | undefined; @@ -71,6 +59,14 @@ const UtilizationFilterPanel = ({ value ? String(value.percent) : "", ); + // The popover keeps its content mounted between opens, so a seed taken once + // would drift from the filter in force. Following the applied value keeps + // Clear, and a filter restored from the URL, visible on the next open. + React.useEffect(() => { + setComparison(value?.comparison ?? DEFAULT_COMPARISON); + setPercent(value ? String(value.percent) : ""); + }, [value]); + const parsed = Number.parseFloat(percent); // NOTE: no upper bound. `heap_percent` reports RAM plus swap against the heap // limit and can legitimately exceed 100%. @@ -79,12 +75,10 @@ const UtilizationFilterPanel = ({ const apply = () => { if (!canApply) return; column.setFilterValue({ comparison, percent: parsed }); - onClose(); }; const clearFilter = () => { column.setFilterValue(undefined); - onClose(); }; return ( @@ -164,61 +158,3 @@ const UtilizationFilterPanel = ({ ); }; - -export interface UtilizationFilterProps { - /** The column to filter, whose `filterFn` must be `utilizationFilterFn`. */ - column: Column; - /** Column name, shown on the trigger and inside the panel. */ - label: string; -} - -/** - * Toolbar control filtering one utilization column by a percentage threshold. - * - * Every utilization column reads the same way, a fraction of the replica's - * allocation, so one control serves all of them. - */ -export const UtilizationFilter = ({ - column, - label, -}: UtilizationFilterProps) => { - const { colors } = useTheme(); - const value = column.getFilterValue() as UtilizationFilterValue | undefined; - const isActive = value !== undefined; - - return ( - - {({ onClose }) => ( - <> - - - - - - - - )} - - ); -}; diff --git a/console/src/platform/clusters/utilizationFilters.test.ts b/console/src/platform/clusters/utilizationFilters.test.ts index b81b1b37c6419..ff90f3981ffb0 100644 --- a/console/src/platform/clusters/utilizationFilters.test.ts +++ b/console/src/platform/clusters/utilizationFilters.test.ts @@ -12,6 +12,7 @@ import { Row } from "@tanstack/react-table"; import { utilizationFilterFn, utilizationFilterFromUrl, + utilizationFilterLabel, utilizationFilterToUrl, UtilizationFilterValue, } from "./utilizationFilters"; @@ -142,3 +143,14 @@ describe("utilizationFilterFromUrl", () => { expect(utilizationFilterFromUrl(raw)).toBeUndefined(); }); }); + +describe("utilizationFilterLabel", () => { + it("reads as the condition it applies", () => { + expect( + utilizationFilterLabel("CPU", { comparison: ">", percent: 40 }), + ).toBe("CPU > 40%"); + expect( + utilizationFilterLabel("Memory", { comparison: "<", percent: 7.5 }), + ).toBe("Memory < 7.5%"); + }); +}); diff --git a/console/src/platform/clusters/utilizationFilters.ts b/console/src/platform/clusters/utilizationFilters.ts index b0877f938c1e4..8bfa69c20ddb4 100644 --- a/console/src/platform/clusters/utilizationFilters.ts +++ b/console/src/platform/clusters/utilizationFilters.ts @@ -80,3 +80,12 @@ export const utilizationFilterFromUrl = ( percent: parseFloat(match[2]), }; }; + +/** + * A filter 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, + value: UtilizationFilterValue, +) => `${heading} ${value.comparison} ${value.percent}%`; From b446ef6ba0ec8e7f41a7d496a145ec0c182ce70d Mon Sep 17 00:00:00 2001 From: Jeremy Donelson Date: Wed, 26 Aug 2026 16:31:20 -0400 Subject: [PATCH 5/8] console: CNS-144 unmount column filter panel on close. This fixes a bug where a filter control could be opened in a statw that does not match the actual filtered state of the table. Fix applies to Maintained Objects filters as well as Cluster table filters. --- .../components/Table/UniversalTable.test.tsx | 53 +++++++++++++++++++ .../src/components/Table/UniversalTable.tsx | 2 + .../platform/clusters/ClustersList.test.tsx | 18 +++++++ .../clusters/UtilizationFilterPanel.tsx | 6 +-- 4 files changed, 76 insertions(+), 3 deletions(-) diff --git a/console/src/components/Table/UniversalTable.test.tsx b/console/src/components/Table/UniversalTable.test.tsx index b40ae65c9eaaa..550e1a74059df 100644 --- a/console/src/components/Table/UniversalTable.test.tsx +++ b/console/src/components/Table/UniversalTable.test.tsx @@ -182,6 +182,33 @@ const LateDataTable = ({ ); }; +/** 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", { @@ -557,6 +584,32 @@ describe("UniversalTable", () => { }); }); + 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", () => { it("calls onRowClick with the row's data", async () => { const onClick = vi.fn(); 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 ( { 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("reopens showing the filter in force", async () => { const user = userEvent.setup(); await renderClustersList(twoClusters()); diff --git a/console/src/platform/clusters/UtilizationFilterPanel.tsx b/console/src/platform/clusters/UtilizationFilterPanel.tsx index b26f26b4b6487..0d9d3a341d122 100644 --- a/console/src/platform/clusters/UtilizationFilterPanel.tsx +++ b/console/src/platform/clusters/UtilizationFilterPanel.tsx @@ -59,9 +59,9 @@ export const UtilizationFilterPanel = ({ value ? String(value.percent) : "", ); - // The popover keeps its content mounted between opens, so a seed taken once - // would drift from the filter in force. Following the applied value keeps - // Clear, and a filter restored from the URL, visible on the next open. + // 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(() => { setComparison(value?.comparison ?? DEFAULT_COMPARISON); setPercent(value ? String(value.percent) : ""); From 418777692b26fb616f94d721c6bf3b0a19d28af9 Mon Sep 17 00:00:00 2001 From: Jeremy Donelson Date: Thu, 27 Aug 2026 12:02:40 -0400 Subject: [PATCH 6/8] console: CNS-144 fixed bug where a value that is entered in a filter panel field could not be cleared in certain conditions. --- .../src/platform/clusters/ClustersList.test.tsx | 15 +++++++++++++++ .../platform/clusters/UtilizationFilterPanel.tsx | 6 ++++++ 2 files changed, 21 insertions(+) diff --git a/console/src/platform/clusters/ClustersList.test.tsx b/console/src/platform/clusters/ClustersList.test.tsx index d999f34307aa2..c71a6a61b44a2 100644 --- a/console/src/platform/clusters/ClustersList.test.tsx +++ b/console/src/platform/clusters/ClustersList.test.tsx @@ -1328,6 +1328,21 @@ describe("ClustersList CPU filter", () => { expect(percent).toHaveValue("40"); }); + it("clears a typed threshold that was never applied", async () => { + const user = userEvent.setup(); + await renderClustersList(twoClusters()); + + await openCpuFilter(user); + await user.selectOptions(screen.getByLabelText("CPU comparison"), "<"); + 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(""); + expect(screen.getByLabelText("CPU comparison")).toHaveValue(">"); + }); + it("cannot be applied with an empty threshold", async () => { const user = userEvent.setup(); await renderClustersList(twoClusters()); diff --git a/console/src/platform/clusters/UtilizationFilterPanel.tsx b/console/src/platform/clusters/UtilizationFilterPanel.tsx index 0d9d3a341d122..ed4208410e5bc 100644 --- a/console/src/platform/clusters/UtilizationFilterPanel.tsx +++ b/console/src/platform/clusters/UtilizationFilterPanel.tsx @@ -78,6 +78,12 @@ export const UtilizationFilterPanel = ({ }; 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. + setComparison(DEFAULT_COMPARISON); + setPercent(""); column.setFilterValue(undefined); }; From ca6ae62e42bfdafc5aed6b570137014bc79d2e43 Mon Sep 17 00:00:00 2001 From: Jeremy Donelson Date: Thu, 27 Aug 2026 14:29:26 -0400 Subject: [PATCH 7/8] =?UTF-8?q?console:=20CNS-144=20Remove=20operator=20me?= =?UTF-8?q?nu=20(>=20or=20<)=20from=20Cluster=20Table=20filter=20panels.?= =?UTF-8?q?=20Filters=20now=20use=20=E2=89=A5=20only.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../platform/clusters/ClusterUsageTable.tsx | 5 +- .../platform/clusters/ClustersList.test.tsx | 206 ++++++++---------- .../clusters/UtilizationFilterChips.tsx | 9 +- .../clusters/UtilizationFilterPanel.tsx | 61 ++---- .../clusters/utilizationFilters.test.ts | 117 ++++------ .../platform/clusters/utilizationFilters.ts | 86 +++----- 6 files changed, 171 insertions(+), 313 deletions(-) diff --git a/console/src/platform/clusters/ClusterUsageTable.tsx b/console/src/platform/clusters/ClusterUsageTable.tsx index ad021d1005f7a..67b63eeb67384 100644 --- a/console/src/platform/clusters/ClusterUsageTable.tsx +++ b/console/src/platform/clusters/ClusterUsageTable.tsx @@ -55,7 +55,6 @@ import { utilizationFilterFn, utilizationFilterFromUrl, utilizationFilterToUrl, - UtilizationFilterValue, } from "./utilizationFilters"; /** @@ -355,9 +354,7 @@ export const ClusterUsageTable = ({ clusters }: ClusterUsageTableProps) => { for (const { id, urlKey } of UTILIZATION_COLUMNS) { const filter = tableState.columnFilters.find((f) => f.id === id); if (filter) { - params[urlKey] = utilizationFilterToUrl( - filter.value as UtilizationFilterValue, - ); + params[urlKey] = utilizationFilterToUrl(filter.value as number); } } if (tableState.globalFilter) { diff --git a/console/src/platform/clusters/ClustersList.test.tsx b/console/src/platform/clusters/ClustersList.test.tsx index c71a6a61b44a2..1cd22430d64d2 100644 --- a/console/src/platform/clusters/ClustersList.test.tsx +++ b/console/src/platform/clusters/ClustersList.test.tsx @@ -253,8 +253,8 @@ const openFilter = async ( }; /** - * Sets `comparison` and `percent` on `label`'s filter and applies it, leaving - * the panel closed. + * 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 @@ -263,19 +263,18 @@ const openFilter = async ( const applyFilter = async ( user: ReturnType, label: string, - comparison: ">" | "<", percent: string, ) => { const apply = await openFilter(user, label); - await user.selectOptions( - screen.getByLabelText(`${label} comparison`), - comparison, - ); await user.clear(screen.getByLabelText(`${label} threshold percentage`)); - await user.type( - screen.getByLabelText(`${label} threshold percentage`), - percent, - ); + // `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. @@ -305,7 +304,6 @@ const panelValues = async ( ) => { await openFilter(user, label); return { - comparison: screen.getByLabelText(`${label} comparison`), percent: screen.getByLabelText(`${label} threshold percentage`), }; }; @@ -1168,9 +1166,8 @@ describe("ClustersList CPU filter", () => { const applyCpuFilter = ( user: ReturnType, - comparison: ">" | "<", percent: string, - ) => applyFilter(user, "CPU", comparison, percent); + ) => applyFilter(user, "CPU", percent); it("renders a control labelled by its column", async () => { await renderClustersList(twoClusters()); @@ -1182,20 +1179,11 @@ describe("ClustersList CPU filter", () => { const user = userEvent.setup(); await renderClustersList(twoClusters()); - await applyCpuFilter(user, ">", "40"); + await applyCpuFilter(user, "40"); expect(rowOrder()).toEqual(["busy", "middling"]); }); - it("keeps only the replicas below the threshold", async () => { - const user = userEvent.setup(); - await renderClustersList(twoClusters()); - - await applyCpuFilter(user, "<", "40"); - - expect(rowOrder()).toEqual(["idle"]); - }); - it("compares the reading, not its rounded display value", async () => { const user = userEvent.setup(); await renderClustersList([ @@ -1208,7 +1196,7 @@ describe("ClustersList CPU filter", () => { }), ]); - await applyCpuFilter(user, ">", "80"); + await applyCpuFilter(user, "80"); expect(rowOrder()).toEqual(["just-over"]); }); @@ -1224,12 +1212,11 @@ describe("ClustersList CPU filter", () => { }), ]); - // An unsampled replica sits on neither side of the threshold, so it is out - // of a filtered list either way. - await applyCpuFilter(user, "<", "50"); + // 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([]); - expect(screen.getByText(NO_MATCHES_MESSAGE)).toBeInTheDocument(); + expect(rowOrder()).toEqual(["sampled"]); }); it("drops a cluster with no replicas", async () => { @@ -1243,19 +1230,18 @@ describe("ClustersList CPU filter", () => { }), ]); - await applyCpuFilter(user, ">", "50"); + await applyCpuFilter(user, "50"); expect(rowOrder()).toEqual(["busy"]); }); - it("keeps the applied condition in its panel", async () => { + it("keeps the applied threshold in its panel", async () => { const user = userEvent.setup(); await renderClustersList(twoClusters()); - await applyCpuFilter(user, ">", "40"); - const { comparison, percent } = await panelValues(user, "CPU"); + await applyCpuFilter(user, "40"); + const { percent } = await panelValues(user, "CPU"); - expect(comparison).toHaveValue(">"); expect(percent).toHaveValue("40"); }); @@ -1276,13 +1262,13 @@ describe("ClustersList CPU filter", () => { const user = userEvent.setup(); await renderClustersList(twoClusters()); - await applyCpuFilter(user, ">", "99"); + 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%" })); + await user.click(screen.getByRole("button", { name: "Remove CPU ≥ 99%" })); expect(rowOrder()).toEqual(["idle", "busy", "middling"]); }); @@ -1291,7 +1277,7 @@ describe("ClustersList CPU filter", () => { const user = userEvent.setup(); await renderClustersList(twoClusters()); - await applyCpuFilter(user, ">", "40"); + await applyCpuFilter(user, "40"); await clearFilter(user, "CPU"); expect(rowOrder()).toEqual(["idle", "busy", "middling"]); @@ -1303,7 +1289,7 @@ describe("ClustersList CPU filter", () => { const user = userEvent.setup(); await renderClustersList(twoClusters()); - await applyCpuFilter(user, ">", "40"); + await applyCpuFilter(user, "40"); // Blank the threshold, then close without applying. await openFilter(user, "CPU"); @@ -1317,40 +1303,31 @@ describe("ClustersList CPU filter", () => { expect(rowOrder()).toEqual(["busy", "middling"]); }); - it("reopens showing the filter in force", async () => { - const user = userEvent.setup(); - await renderClustersList(twoClusters()); - - await applyCpuFilter(user, "<", "40"); - const { comparison, percent } = await panelValues(user, "CPU"); - - expect(comparison).toHaveValue("<"); - expect(percent).toHaveValue("40"); - }); - it("clears a typed threshold that was never applied", async () => { const user = userEvent.setup(); await renderClustersList(twoClusters()); await openCpuFilter(user); - await user.selectOptions(screen.getByLabelText("CPU comparison"), "<"); 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(""); - expect(screen.getByLabelText("CPU comparison")).toHaveValue(">"); }); - it("cannot be applied with an empty threshold", async () => { + it("treats applying an empty threshold as no filter", async () => { const user = userEvent.setup(); await renderClustersList(twoClusters()); - const apply = await openCpuFilter(user); - await user.clear(screen.getByLabelText("CPU threshold percentage")); + await applyCpuFilter(user, "40"); + expect(rowOrder()).toEqual(["busy", "middling"]); - expect(apply).toBeDisabled(); + // 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 () => { @@ -1363,7 +1340,7 @@ describe("ClustersList CPU filter", () => { await user.type(screen.getByLabelText("Search clusters..."), "compute"); await waitFor(() => expect(rowOrder()).toEqual(["idle", "busy"])); - await applyCpuFilter(user, ">", "40"); + await applyCpuFilter(user, "40"); // Both constraints hold: only compute's busy replica clears each. expect(rowOrder()).toEqual(["busy"]); @@ -1392,10 +1369,10 @@ describe("ClustersList utilization filters", () => { screen.getByRole("columnheader", { name: new RegExp(`^${label}`) }), ).toBeInTheDocument(); - // The panel names its controls from the heading, so a renamed column - // cannot leave its filter labelled with the old name. - const { comparison } = await panelValues(user, label); - expect(comparison).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)); } }); @@ -1418,7 +1395,7 @@ describe("ClustersList utilization filters", () => { const user = userEvent.setup(); await renderClustersList([pair()]); - await applyFilter(user, label, ">", "50"); + await applyFilter(user, label, "50"); expect(rowOrder()).toEqual(["high"]); }); @@ -1427,7 +1404,7 @@ describe("ClustersList utilization filters", () => { const user = userEvent.setup(); await renderClustersList([pair()]); - await applyFilter(user, label, ">", "50"); + await applyFilter(user, label, "50"); const own = await panelValues(user, label); expect(own.percent).toHaveValue("50"); @@ -1444,7 +1421,7 @@ describe("ClustersList utilization filters", () => { const user = userEvent.setup(); await renderClustersList([pair()]); - await applyFilter(user, label, ">", "50"); + await applyFilter(user, label, "50"); await clearFilter(user, label); expect(rowOrder()).toEqual(["high", "low"]); @@ -1478,8 +1455,8 @@ describe("ClustersList utilization filters", () => { }), ]); - await applyFilter(user, "CPU", ">", "50"); - await applyFilter(user, "Memory", ">", "50"); + await applyFilter(user, "CPU", "50"); + await applyFilter(user, "Memory", "50"); // Filters narrow each other rather than replacing one another. expect(rowOrder()).toEqual(["hot-both"]); @@ -1563,33 +1540,33 @@ describe("ClustersList filter URL state", () => { const user = userEvent.setup(); await renderAt(twoClusters()); - await applyFilter(user, "CPU", ">", "40"); + await applyFilter(user, "CPU", "40"); - await waitFor(() => expect(currentSearch().get("cpu")).toBe("gt.40")); + await waitFor(() => expect(currentSearch().get("cpu")).toBe("40")); }); - it("spells the comparison as a word rather than percent-encoding it", async () => { + it("writes a threshold a reader can make sense of", async () => { const user = userEvent.setup(); await renderAt(twoClusters()); - await applyFilter(user, "CPU", "<", "40"); + await applyFilter(user, "CPU", "40"); - // A raw ">" or "<" would reach the user's bookmark bar as %3E or %3C. - await waitFor(() => expect(currentSearch().get("cpu")).toBe("lt.40")); - expect(screen.getByTestId("search").textContent).not.toContain("%3"); + // 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 applyFilter(user, "CPU", "40"); + await applyFilter(user, "Memory", "80"); await waitFor(() => { const params = currentSearch(); - expect(params.get("cpu")).toBe("gt.40"); - expect(params.get("memory")).toBe("lt.80"); + expect(params.get("cpu")).toBe("40"); + expect(params.get("memory")).toBe("80"); }); }); @@ -1597,8 +1574,8 @@ describe("ClustersList filter URL state", () => { const user = userEvent.setup(); await renderAt(twoClusters()); - await applyFilter(user, "CPU", ">", "40"); - await waitFor(() => expect(currentSearch().get("cpu")).toBe("gt.40")); + 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" })); @@ -1608,38 +1585,35 @@ describe("ClustersList filter URL state", () => { it("restores a bookmarked filter, in the rows and in the panel", async () => { const user = userEvent.setup(); - await renderAt(twoClusters(), "/?cpu=gt.40"); + await renderAt(twoClusters(), "/?cpu=40"); expect(rowOrder()).toEqual(["busy", "middling"]); - const { comparison, percent } = await panelValues(user, "CPU"); - expect(comparison).toHaveValue(">"); + 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=gt.40&memory=lt.80"); + await renderAt(twoClusters(), "/?cpu=40&memory=80"); - // busy clears CPU > 40 but not Memory < 80; middling clears both. - expect(rowOrder()).toEqual(["middling"]); + // 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.comparison).toHaveValue("<"); 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=lt.40"); + await renderAt(twoClusters(), "/?cpu=40"); - const { comparison, percent } = await panelValues(user, "CPU"); + const { percent } = await panelValues(user, "CPU"); - expect(comparison).toHaveValue("<"); expect(percent).toHaveValue("40"); }); @@ -1659,10 +1633,10 @@ describe("ClustersList filter URL state", () => { }); describe.each([ - ["an unknown comparison", "/?cpu=ge.40"], - ["a missing threshold", "/?cpu=gt."], - ["a non-numeric threshold", "/?cpu=gt.abc"], - ["a bare number", "/?cpu=40"], + ["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 () => { @@ -1723,9 +1697,9 @@ describe("ClustersList filter URL state", () => { }); it("keeps a bookmarked page while the filter matches nothing yet", async () => { - // No replica clears a 90% threshold, so the bookmarked filter starts out - // matching none of them. - await renderAt(twoPagesOfReplicas(), "/?cpu=gt.90&page=2"); + // 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. @@ -1765,14 +1739,14 @@ describe("ClustersList filter URL state", () => { await user.click(screen.getByRole("button", { name: "Next page" })); expect(rowOrder()).toEqual(["r-20"]); - await applyFilter(user, "CPU", ">", "50"); + await applyFilter(user, "CPU", "50"); expect(rowOrder()).toEqual(["r-20"]); }); it("accepts a fractional threshold", async () => { const user = userEvent.setup(); - await renderAt(twoClusters(), "/?cpu=gt.7.5"); + await renderAt(twoClusters(), "/?cpu=7.5"); expect(rowOrder()).toEqual(["busy", "middling"]); const { percent } = await panelValues(user, "CPU"); @@ -1829,33 +1803,33 @@ describe("ClustersList filter chips", () => { const user = userEvent.setup(); await renderClustersList(twoClusters()); - await applyFilter(user, "CPU", ">", "40"); + 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%"]); + 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"); + 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%"]); + 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"); + await applyFilter(user, "CPU", "40"); expect(rowOrder()).toEqual(["busy", "hot-cpu"]); - await user.click(screen.getByRole("button", { name: "Remove CPU > 40%" })); + await user.click(screen.getByRole("button", { name: "Remove CPU ≥ 40%" })); expect(rowOrder()).toEqual(["idle", "busy", "hot-cpu"]); expect(chips()).toEqual([]); @@ -1865,20 +1839,20 @@ describe("ClustersList filter chips", () => { const user = userEvent.setup(); await renderClustersList(twoClusters()); - await applyFilter(user, "CPU", ">", "40"); - await applyFilter(user, "Memory", "<", "80"); + await applyFilter(user, "CPU", "40"); + await applyFilter(user, "Memory", "80"); - await user.click(screen.getByRole("button", { name: "Remove CPU > 40%" })); + await user.click(screen.getByRole("button", { name: "Remove CPU ≥ 40%" })); - expect(chips()).toEqual(["Memory < 80%"]); + 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%" })); + 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. @@ -1889,26 +1863,26 @@ describe("ClustersList filter chips", () => { it("offers a chip for a filter restored from the URL", async () => { getStore().set(allClusters, mockSubscribeState({ data: twoClusters() })); renderComponent(, { - initialRouterEntries: ["/?cpu=gt.40"], + initialRouterEntries: ["/?cpu=40"], }); await screen.findByRole("table"); - expect(chips()).toEqual(["CPU > 40%"]); + 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"); + 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%"]); + expect(chips()).toEqual(["CPU ≥ 99%"]); - await user.click(screen.getByRole("button", { name: "Remove 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 index 6ee329f123114..bfffbf6944f75 100644 --- a/console/src/platform/clusters/UtilizationFilterChips.tsx +++ b/console/src/platform/clusters/UtilizationFilterChips.tsx @@ -11,10 +11,7 @@ import { HStack, Tag, TagCloseButton, TagLabel } from "@chakra-ui/react"; import { Table } from "@tanstack/react-table"; import React from "react"; -import { - utilizationFilterLabel, - UtilizationFilterValue, -} from "./utilizationFilters"; +import { utilizationFilterLabel } from "./utilizationFilters"; export interface UtilizationFilterChipsProps { table: Table; @@ -36,9 +33,7 @@ export const UtilizationFilterChips = ({ }: UtilizationFilterChipsProps) => { const chips = columns.flatMap(({ id, header }) => { const column = table.getColumn(id); - const value = column?.getFilterValue() as - | UtilizationFilterValue - | undefined; + const value = column?.getFilterValue() as number | undefined; if (!column || !value) return []; return [ { diff --git a/console/src/platform/clusters/UtilizationFilterPanel.tsx b/console/src/platform/clusters/UtilizationFilterPanel.tsx index ed4208410e5bc..b7a38e6b7e9ce 100644 --- a/console/src/platform/clusters/UtilizationFilterPanel.tsx +++ b/console/src/platform/clusters/UtilizationFilterPanel.tsx @@ -15,7 +15,6 @@ import { NumberInput, NumberInputField, NumberInputStepper, - Select, Text, useTheme, VStack, @@ -25,12 +24,6 @@ import React from "react"; import { MaterializeTheme } from "~/theme"; -import { - DEFAULT_COMPARISON, - UtilizationComparison, - UtilizationFilterValue, -} from "./utilizationFilters"; - export interface UtilizationFilterPanelProps { /** The column to filter, whose `filterFn` must be `utilizationFilterFn`. */ column: Column; @@ -39,7 +32,7 @@ export interface UtilizationFilterPanelProps { } /** - * Filters one utilization column by a percentage threshold, for the popover + * 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 @@ -50,31 +43,24 @@ export const UtilizationFilterPanel = ({ label, }: UtilizationFilterPanelProps) => { const { colors } = useTheme(); - const value = column.getFilterValue() as UtilizationFilterValue | undefined; + const filterValue = column.getFilterValue() as number | undefined; - const [comparison, setComparison] = React.useState( - value?.comparison ?? DEFAULT_COMPARISON, - ); const [percent, setPercent] = React.useState( - value ? String(value.percent) : "", + 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(() => { - setComparison(value?.comparison ?? DEFAULT_COMPARISON); - setPercent(value ? String(value.percent) : ""); - }, [value]); - - const parsed = Number.parseFloat(percent); - // NOTE: no upper bound. `heap_percent` reports RAM plus swap against the heap - // limit and can legitimately exceed 100%. - const canApply = Number.isFinite(parsed) && parsed >= 0; + setPercent(filterValue ? String(filterValue) : ""); + }, [filterValue]); const apply = () => { - if (!canApply) return; - column.setFilterValue({ comparison, percent: parsed }); + 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 = () => { @@ -82,7 +68,6 @@ export const UtilizationFilterPanel = ({ // 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. - setComparison(DEFAULT_COMPARISON); setPercent(""); column.setFilterValue(undefined); }; @@ -91,26 +76,12 @@ export const UtilizationFilterPanel = ({ - {label} + {label} ≥ - setPercent(next)} @@ -146,18 +117,12 @@ export const UtilizationFilterPanel = ({ size="sm" variant="secondary" transition="none" - isDisabled={value === undefined && percent === ""} + isDisabled={filterValue === undefined && percent === ""} onClick={clearFilter} > Clear - diff --git a/console/src/platform/clusters/utilizationFilters.test.ts b/console/src/platform/clusters/utilizationFilters.test.ts index ff90f3981ffb0..c2346fb713558 100644 --- a/console/src/platform/clusters/utilizationFilters.test.ts +++ b/console/src/platform/clusters/utilizationFilters.test.ts @@ -14,7 +14,6 @@ import { utilizationFilterFromUrl, utilizationFilterLabel, utilizationFilterToUrl, - UtilizationFilterValue, } from "./utilizationFilters"; /** @@ -26,82 +25,60 @@ const rowReporting = (fraction: number | null | undefined) => getValue: () => fraction, }) as unknown as Row; -const keeps = ( - fraction: number | null | undefined, - filter: UtilizationFilterValue, -) => utilizationFilterFn(rowReporting(fraction), "cpuPercent", filter); +const keeps = (fraction: number | null | undefined, percent: number) => + utilizationFilterFn(rowReporting(fraction), "cpuPercent", percent); describe("utilizationFilterFn", () => { - const above50: UtilizationFilterValue = { comparison: ">", percent: 50 }; - const below50: UtilizationFilterValue = { comparison: "<", percent: 50 }; - it("reads the column as a fraction and the threshold as a percentage", () => { - expect(keeps(0.9, above50)).toBe(true); - expect(keeps(0.1, above50)).toBe(false); - }); - - it("keeps readings below the threshold when comparing with <", () => { - expect(keeps(0.1, below50)).toBe(true); - expect(keeps(0.9, below50)).toBe(false); + expect(keeps(0.9, 50)).toBe(true); + expect(keeps(0.1, 50)).toBe(false); }); - it("excludes a reading exactly on the threshold, either direction", () => { - // Both comparisons are strict, so 50% satisfies neither "> 50" nor "< 50". - expect(keeps(0.5, above50)).toBe(false); - expect(keeps(0.5, below50)).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", () => { - const above80: UtilizationFilterValue = { comparison: ">", percent: 80 }; // Both render as "80.0%" through PercentBar's one decimal place. - expect(keeps(0.7996, above80)).toBe(false); - expect(keeps(0.8004, above80)).toBe(true); + expect(keeps(0.7996, 80)).toBe(false); + expect(keeps(0.8004, 80)).toBe(true); }); - it("excludes a replica with no sample, whichever way the filter points", () => { - expect(keeps(null, above50)).toBe(false); - expect(keeps(null, below50)).toBe(false); - expect(keeps(undefined, above50)).toBe(false); + it("excludes a replica with no sample", () => { + expect(keeps(null, 50)).toBe(false); + expect(keeps(undefined, 50)).toBe(false); }); - it("keeps an idle replica reporting zero when the filter allows it", () => { - // 0 is a real reading, not a missing one. - expect(keeps(0, below50)).toBe(true); - expect(keeps(0, above50)).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, { comparison: ">", percent: 100 })).toBe(true); - expect(keeps(1.4, { comparison: "<", percent: 100 })).toBe(false); + expect(keeps(1.4, 100)).toBe(true); + expect(keeps(0.99, 100)).toBe(false); }); it("accepts a fractional threshold", () => { - expect(keeps(0.08, { comparison: ">", percent: 7.5 })).toBe(true); - expect(keeps(0.07, { comparison: ">", percent: 7.5 })).toBe(false); + expect(keeps(0.08, 7.5)).toBe(true); + expect(keeps(0.07, 7.5)).toBe(false); }); }); describe("utilizationFilterToUrl", () => { - it("spells the comparison as a word", () => { - // A raw ">" percent-encodes to "%3E", which makes a bookmark unreadable. - expect(utilizationFilterToUrl({ comparison: ">", percent: 80 })).toBe( - "gt.80", - ); - expect(utilizationFilterToUrl({ comparison: "<", percent: 80 })).toBe( - "lt.80", - ); + 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 value of [ - { comparison: ">", percent: 0 }, - { comparison: "<", percent: 100 }, - { comparison: ">", percent: 7.5 }, - ] satisfies UtilizationFilterValue[]) { - expect(utilizationFilterFromUrl(utilizationFilterToUrl(value))).toEqual( - value, + for (const percent of [1, 100, 7.5, 250]) { + expect(utilizationFilterFromUrl(utilizationFilterToUrl(percent))).toBe( + percent, ); } }); @@ -109,36 +86,22 @@ describe("utilizationFilterToUrl", () => { describe("utilizationFilterFromUrl", () => { it("reads a well-formed parameter", () => { - expect(utilizationFilterFromUrl("gt.80")).toEqual({ - comparison: ">", - percent: 80, - }); - expect(utilizationFilterFromUrl("lt.5")).toEqual({ - comparison: "<", - percent: 5, - }); - }); - - it("reads a fractional threshold", () => { - expect(utilizationFilterFromUrl("gt.7.5")).toEqual({ - comparison: ">", - percent: 7.5, - }); + 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 control cannot display or clear. + // install a filter the panel cannot show or clear. it.each([ ["absent", null], ["empty", ""], - ["an unknown comparison", "ge.40"], - ["no comparison", "40"], - ["no threshold", "gt."], - ["a non-numeric threshold", "gt.abc"], - ["a negative threshold", "gt.-10"], - ["trailing junk", "gt.40x"], - ["leading junk", "xgt.40"], - ["a comparison alone", "gt"], + ["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(); }); @@ -146,11 +109,7 @@ describe("utilizationFilterFromUrl", () => { describe("utilizationFilterLabel", () => { it("reads as the condition it applies", () => { - expect( - utilizationFilterLabel("CPU", { comparison: ">", percent: 40 }), - ).toBe("CPU > 40%"); - expect( - utilizationFilterLabel("Memory", { comparison: "<", percent: 7.5 }), - ).toBe("Memory < 7.5%"); + 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 index 8bfa69c20ddb4..040c623f73e30 100644 --- a/console/src/platform/clusters/utilizationFilters.ts +++ b/console/src/platform/clusters/utilizationFilters.ts @@ -9,83 +9,51 @@ import { Row } from "@tanstack/react-table"; -/** The side of the threshold a row has to fall on to be kept. */ -export type UtilizationComparison = ">" | "<"; - -export interface UtilizationFilterValue { - comparison: UtilizationComparison; - /** Threshold as a whole percentage, as typed into the control. */ - percent: number; -} - -export const DEFAULT_COMPARISON: UtilizationComparison = ">"; - /** - * Keeps rows whose utilization reading falls on the requested side of the - * threshold. Written for the columns whose accessor returns a fraction of the - * replica's allocation, which the control states as a percentage. + * 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 outside "> 80" when the reading + * 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, - filterValue: UtilizationFilterValue, + percent: number, ) => { const fraction = row.getValue(columnId); - // A replica with no sample in the window cannot be said to sit on either - // side of a threshold, so a filtered list leaves it out rather than - // guessing. + // 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; - const percent = fraction * 100; - return filterValue.comparison === ">" - ? percent > filterValue.percent - : percent < filterValue.percent; + return fraction * 100 >= percent; }; -/** - * How a comparison is spelled in the URL. Words rather than the operators - * themselves: `>` percent-encodes to `%3E`, which makes a bookmarked URL - * unreadable. - */ -const COMPARISON_URL_TOKENS: Record = { - ">": "gt", - "<": "lt", -}; - -/** - * A filter as one URL parameter value, for example `gt.80`. Anchored, so the - * separator is unambiguous even when the threshold carries a decimal point. - */ -const URL_VALUE_PATTERN = /^(gt|lt)\.(\d+(?:\.\d+)?)$/; - -export const utilizationFilterToUrl = (value: UtilizationFilterValue) => - `${COMPARISON_URL_TOKENS[value.comparison]}.${value.percent}`; +/** A threshold as its URL parameter value, for example `80`. */ +export const utilizationFilterToUrl = (percent: number) => String(percent); /** - * The filter a URL parameter asks for, or undefined when it is absent or - * malformed. A hand-edited or stale link must leave the table unfiltered rather - * than install a filter the control cannot show or clear. + * 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, -): UtilizationFilterValue | undefined => { - const match = raw?.match(URL_VALUE_PATTERN); - if (!match) return undefined; - return { - comparison: match[1] === "gt" ? ">" : "<", - percent: parseFloat(match[2]), - }; +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 filter stated for display, for example `CPU > 40%`. Reads as the condition - * it applies, so a chip or a summary needs nothing added around it. + * 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, - value: UtilizationFilterValue, -) => `${heading} ${value.comparison} ${value.percent}%`; +export const utilizationFilterLabel = (heading: string, percent: number) => + `${heading} ≥ ${percent}%`; From ab29297206b9d97f31376ab56ffca68da5d4c729 Mon Sep 17 00:00:00 2001 From: Jeremy Donelson Date: Thu, 27 Aug 2026 14:41:27 -0400 Subject: [PATCH 8/8] console: CNS-144 usage-metrics-in-cluster-list-CNS121 added to disabled flags for felxible deployment. --- console/src/config/flexibleDeploymentFlags.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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( {},