Skip to content

console: CNS-144 - Filter controls for Cluster List table - #38470

Merged
jdonelson merged 8 commits into
mainfrom
jdonelson/CNS-144_filters-for-cluster-table
Aug 28, 2026
Merged

console: CNS-144 - Filter controls for Cluster List table#38470
jdonelson merged 8 commits into
mainfrom
jdonelson/CNS-144_filters-for-cluster-table

Conversation

@jdonelson

@jdonelson jdonelson commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Motivation

Closes: https://linear.app/materializeinc/issue/CNS-144/add-filters-to-cluster-replica-table
Context: https://linear.app/materializeinc/project/improved-cluster-overview-page-792c294165da/overview

Also fixes a bug that was found during development - a stale bookmark could render a header-only table with no pagination control if the bookmark pointed to a page that no longer exists in a new result set. For example, page=2 might be valid on a result set of 21 rows, but the next day that set might only have 18 rows. This fix touches the Maintained Objects table, which had also been affected by the bug.

Description

The Clusters list can now be filtered by a threshold on any replica utilization metric (CPU, memory, disk, heap). Filters live in the column headers, appear as removable chips, and are reflected in the URL so a filtered view can be bookmarked or shared.
Screenshot 2026-08-26 at 2 58 29 PM
Screenshot 2026-08-26 at 2 58 48 PM

Verification

Automated tests added for filter and related URL functionality. Verified locally.

…n be filtered by usage metric thresholds, e.g. >70% Memory.
@jdonelson
jdonelson requested a review from a team as a code owner August 25, 2026 18:29
@jdonelson
jdonelson requested a review from leedqin August 25, 2026 18:29
@linear-code

linear-code Bot commented Aug 25, 2026

Copy link
Copy Markdown

CNS-144

@def-

def- commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- A restored page can hide every matching replica

console/src/platform/clusters/ClusterUsageTable.tsx:327

The URL's page index is restored without clamping it to the filtered row count. Opening a stale bookmark or carrying the URL to a smaller environment can land beyond the last page and render a header-only table even though matching replicas exist, while the pagination controls can disappear entirely.

Details

The new URL writer persists every nonzero page at console/src/platform/clusters/ClusterUsageTable.tsx:365, and environment switching preserves the query string at console/src/layouts/EnvironmentSelect.tsx:50. A previously valid ?page=2 therefore becomes invalid when there are 20 or fewer matching rows, including when the utilization poll changes which rows satisfy a filter. useUniversalTable deliberately disables automatic page resets on data refresh, and TanStack slices the paginated rows starting at the unchanged index. Because noMatches checks the pre-pagination row model, the empty-state message is not shown, and TablePagination returns null when pageCount <= 1 at console/src/components/Table/TablePagination.tsx:28, removing the Previous button that could recover. Clamp pageIndex to the last valid page whenever the filtered page count shrinks.

…nation had the potential to display tables with zero rows. That case is now caught and the table displays its last available page instead.
@def-

def- commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- Clamping while the rows are still loading throws away the bookmarked page

console/src/components/Table/useUniversalTable.ts:147

The new clamp also fires on the first commit, when a table's row model is still empty because its data has not arrived, so it rewrites a URL-restored page index to 0 and drops page from the query string before the rows that would have made it valid exist. /objects?page=2 and /clusters?cpu=gt.50&page=2 both land on page 1 on this commit, where they restored correctly before it.

Details

MaintainedObjects builds its table with data: objects ?? [] and a pagination state seeded from ?page= (console/src/platform/maintained-objects/MaintainedObjects.tsx:393, :407), above its isLoading early return, and useMaintainedObjectsList reports data: [] until the subscribe snapshot lands. getPageCount() is therefore 0 on mount and the layout effect clamps to page 0. Rendering that page at ?page=2 with 26 objects that arrive after mount shows "page 1 of 2" at 11b8ec1 and "page 2 of 2" at be16bdf.

This PR's own table takes the same path. useReplicaUtilization is a plain useQuery, so data is undefined on the first render and every row carries NO_UTILIZATION, and utilizationFilterFn drops a row whose reading is null (console/src/platform/clusters/utilizationFilters.ts:41). A link that carries both a filter and a page therefore has zero matching rows at mount. Same experiment on /clusters?cpu=gt.50&page=2 with 40 replicas over the threshold: "page 1 of 2" here, "page 2 of 2" before, and the URL writer then rewrites the query string to ?cpu=gt.50&sort=cluster&dir=asc, so a reload cannot recover the page either.

An empty row model does not distinguish "there is no page 2" from "there are no rows yet", so it is not a safe thing to clamp against:

     if (tableOptions.manualPagination) return;
+    // An empty row model is also what a table shows before its data (or the
+    // query a filter reads) has arrived, so clamping here would discard a page
+    // restored from the URL.
+    if (pageCount === 0) return;
     const lastPage = Math.max(0, pageCount - 1);

With that guard both URLs restore, and UniversalTable.test.tsx still passes in full, including the four new clamp tests. It also reintroduces no header-only table: both call sites render their own empty-state message when the filtered row count is 0.

Copy link
Copy Markdown
Contributor Author

both issues fixed & pushed to branch

@def-

def- commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- The filter panel can show a threshold that is not the one in force

console/src/platform/clusters/UtilizationFilterPanel.tsx:65

The panel's draft is only re-seeded when the applied filter value changes, but the header popover keeps the panel mounted between opens, so an edit that is never applied outlives every close. Blank the threshold of an applied CPU > 40 without pressing Apply, and from then on the panel reads "no threshold" on each open while the column keeps filtering rows out, and Clear cannot reset it.

Details

UniversalTable's header popover (console/src/components/Table/UniversalTable.tsx:66) is not isLazy, so UtilizationFilterPanel mounts with the table and never unmounts. The useState seeds therefore run once, and the effect at line 65 fires only when column.getFilterValue() changes identity, so nothing rebuilds the draft when the panel opens. The control this replaced ran isLazy lazyBehavior="unmount" precisely so that each open rebuilt the draft from the filter in force.

Confirmed against this commit with a minimal table wired through meta.renderFilter: apply CPU > 40, reopen, clear the field without applying, close, reopen. The field reads "" while the row model still holds only the replica above 40%. The reopens showing the filter in force test at console/src/platform/clusters/ClustersList.test.tsx:1302 passes because it only checks the state immediately after Apply, when the value did change.

The same root causes a second symptom: clearFilter calls column.setFilterValue(undefined) and nothing else. With no filter applied, TanStack auto-removes and getFilterValue() stays undefined, so the effect never runs and a typed draft stays put. Clear renders enabled and does nothing. FreshnessFilterPanel resets its own draft for this reason (console/src/platform/maintained-objects/filterPanels.tsx:205).

Fix: give the header popover isLazy lazyBehavior="unmount" in UniversalTable, restoring the per-open remount the panel's own comment assumes. That also removes the same class of drift from the Objects freshness panel, which seeds once and never re-syncs after its chip is removed. The local alternative is to reset the draft in clearFilter and re-seed it when the popover opens, which needs the open state plumbed into the panel.

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.

@leedqin leedqin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is a filter caret styling with the ChevronDownIcon and secondary foreground color, and if you drop the fontWeight="700" then it would look a lot simpler.
image


import {
Button,
HStack,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
HStack,
HStack,
Icon,

import { Column } from "@tanstack/react-table";
import React from "react";

import { MaterializeTheme } from "~/theme";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
import { MaterializeTheme } from "~/theme";
import { ChevronDownIcon } from "~/icons";
import { MaterializeTheme } from "~/theme";

<Select
size="sm"
maxW="16"
fontWeight="700"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
fontWeight="700"

Comment on lines +119 to +126
<NumberInputStepper>
<NumberIncrementStepper
aria-label={`Increase ${label} threshold`}
/>
<NumberDecrementStepper
aria-label={`Decrease ${label} threshold`}
/>
</NumberInputStepper>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<NumberInputStepper>
<NumberIncrementStepper
aria-label={`Increase ${label} threshold`}
/>
<NumberDecrementStepper
aria-label={`Decrease ${label} threshold`}
/>
</NumberInputStepper>
<NumberInputStepper>
<NumberIncrementStepper
aria-label={`Increase ${label} threshold`}
border="none"
color={colors.foreground.secondary}
>
<Icon
as={ChevronDownIcon}
boxSize="3"
transform="rotate(180deg)"
/>
</NumberIncrementStepper>
<NumberDecrementStepper
aria-label={`Decrease ${label} threshold`}
border="none"
color={colors.foreground.secondary}
>
<Icon as={ChevronDownIcon} boxSize="3" />
</NumberDecrementStepper>
</NumberInputStepper>

@leedqin leedqin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this looks good. The URL round-trip and the pagination clamp are well reasoned and thoroughly tested, and the filter state follows the guide-tanstack-table pattern. The URL sync is safe too: nothing else on this route reads search params, and TanStack ignores unknown sort ids from stale URLs.

Couple of nits:

nit: UtilizationFilterPanel and FreshnessFilterPanel duplicate the same Clear/Apply footer, worth extracting it in a common component

Comment on lines +80 to +82
const clearFilter = () => {
column.setFilterValue(undefined);
};

@leedqin leedqin Aug 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you type a value and click Clear without applying, the field keeps the value: no filter was ever set, so nothing resets the draft. FreshnessFilterPanel resets its draft explicitly, and the same works here:

FreshnessFilterPanel resets its draft explicitly in its clearFilter; the same works here (and is harmless in the applied case, where the effect resets the drafts anyway):

Suggested change
const clearFilter = () => {
column.setFilterValue(undefined);
};
const clearFilter = () => {
setComparison(DEFAULT_COMPARISON);
setPercent("");
column.setFilterValue(undefined);
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Percentage_not_clearing.mov

Here is the bug to be clear

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in latest commit. Thanks!

Comment on lines +1867 to +1868

// The panel stays mounted between opens, so it has to follow the filter

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale since the lazy-unmount commit: the panel now remounts on each open, so what this covers is the filter changing while the panel is open (or the seed on remount).

Suggested change
// The panel stays mounted between opens, so it has to follow the filter
// The panel has to follow the filter rather than hold the value the chip
// just removed.

@leedqin
leedqin self-requested a review August 27, 2026 14:51
@jdonelson
jdonelson merged commit 235a034 into main Aug 28, 2026
81 checks passed
@jdonelson
jdonelson deleted the jdonelson/CNS-144_filters-for-cluster-table branch August 28, 2026 15:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants