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