Skip to content

Commit 86ae0f5

Browse files
Remember filters across kind and section switches (#2022)
## Description Refs #1864. Filters were lost when you moved around. Switching resource kinds cleared the column and problem filters. Leaving Resources and coming back through the nav rail opened a bare Pods list. A reload lost everything. **Each kind keeps its filters, per cluster** - Each kind's column and problem filters are saved in localStorage, keyed by API base + context, so clusters never share them. In Radar Hub each cluster mounts with its own API base, so each cluster keeps its own filters there too. Pods → Services → Pods brings the Pods filters back, and so does a reload or a new tab. - The URL stays the source of truth. A URL that carries its own list state (`filters`, `problems`, `search`, `labels`, owner, `resource`, …) is shown as is. Only a bare `/resources/<kind>` restores the saved filters, and it writes them into the URL with replace. - What gets saved is the last filters chosen for that kind, either by the user or by a link that carries filters. A restore never saves again. A link without filters (one resource, an owner's pods, a search) leaves the saved filters alone. Clear filters removes them. - Each tab keeps what it has shown, in sessionStorage: a reload or a duplicated tab keeps the tab's own filters, while a new tab opens with the last saved ones. Filters saved in another tab only seed a kind the first time this tab opens it. - Bare `/resources` and the Resources rail item go to the last kind. This also covers Radar Hub's rail, which links to bare `/resources`. - Sort is unchanged: it was already remembered per kind. **Each section returns to its last list (per tab)** - The nav rail and section shortcuts go back to the section's last list URL. Detail sub-routes are not remembered, so the rail always lands on the list. - Only the params that shape each section's list are kept, from a per-section allowlist (`viewMemory.ts`). Everything else is dropped: the namespace pick, the investigation focus, drawers and details opened on top of the list (e.g. an open Application). A param a view adds later is forgotten until it is listed, never replayed by mistake. - It is kept per tab and per cluster in sessionStorage: it survives a reload and is copied to a duplicated tab, a new tab starts without it, and switching context keeps each cluster's own. **Active filters are visible** - Clear filters is highlighted and shows a count. - A line says when filters were restored from the last visit, with a Clear link. - Active filters show as removable chips above the table, as well as in the empty state. **"Apply to all sections"** in the Namespace column filter - It is offered when at least one namespace is included. It replaces the header namespace picker's selection with those namespaces and briefly highlights the picker. - Once the change lands, the header picker owns the namespace scope: the Namespace column filter is cleared on every kind of that cluster, not just the current one. Other column and problem filters stay. If the change fails, nothing is cleared. - It also works in Radar Hub: the pick reaches Hub's namespace chip through `?namespaces=`. It is not offered with cache-scoped namespaces, where the scope is locked. **Other changes** - A kind switch that restores filters adds one history entry instead of two. - The URL sync now also reads problem filters, so Back keeps them. **Notes** - This stores filter values in localStorage, unlike the `filter-state` contract used by Applications, GitOps and Checks. The per-cluster key, the URL-wins rule and the "restored" line are there so saved filters never show up unexplained. - New optional props on `ResourcesView`: `rememberedKindSearch`, `onKindFiltersChange`, `onApplyNamespacesToAllSections`. Hosts that don't pass them keep today's behavior. Radar Hub gets them through `@skyhook-io/radar-app` with no change on its side. ## Type of change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update ## How has this been tested? - Unit tests: - `kindFilterStore.test.ts`: per-cluster and per-group keys, tab isolation, reload / duplicated tab / new tab, unavailable storage, clearing one column on every kind. - `viewMemory.test.ts`: the per-section allowlist, list-only routes, reload per cluster, never shared with a new tab. - `ResourcesView.kind-filter-memory.test.ts`: restore of filters, excludes and problems. - `ActiveFilterBar.test.tsx`. - `make tsc`, web `npm test` and the k8s-ui suite pass. - By hand on a remote EKS cluster: - Pods filter → Topology → rail back to filtered Pods. - Kind switch shows the restored line and chips. - Reload of bare `/resources/pods` restores the filters. - An open Application → leave → rail returns to the list, not the app. - The Timeline view survives a rail round-trip. - Apply to all sections sets the picker and clears a Namespace filter saved on another kind. - Tabs: a duplicated tab keeps the source tab's filters and section memory; after another tab saves different filters, a reload keeps the tab's own and a new tab gets the latest saved. - Same flow embedded in a local Radar Hub (local `radar-app` linked into radar-hub-web, kind cluster): Hub chip set, Namespace filter cleared on Pods and Deployments, kept after reload. - [ ] Tested locally with minikube/kind - [x] Tested against a remote cluster - [x] Added/updated unit tests ## Checklist - [x] My code follows the project's coding standards - [x] I have performed a self-review of my code - [x] I have added comments where necessary - [x] My changes generate no new warnings - [x] Any dependent changes have been merged
1 parent fa3f01f commit 86ae0f5

13 files changed

Lines changed: 936 additions & 85 deletions
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// @vitest-environment jsdom
2+
import { act } from 'react'
3+
import { createRoot, type Root } from 'react-dom/client'
4+
import { afterEach, describe, expect, it, vi } from 'vitest'
5+
import { ActiveFilterBar, type ActiveFilterBarProps } from './ActiveFilterBar'
6+
7+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
8+
9+
let root: Root | null = null
10+
let element: HTMLDivElement
11+
12+
async function render(overrides: Partial<ActiveFilterBarProps> = {}) {
13+
const props: ActiveFilterBarProps = {
14+
columnFilters: {},
15+
columnFilterExcludes: {},
16+
problemFilters: [],
17+
labelSelector: '',
18+
onClearColumn: vi.fn(),
19+
onClearProblems: vi.fn(),
20+
onClearLabels: vi.fn(),
21+
...overrides,
22+
}
23+
element = document.createElement('div')
24+
root = createRoot(element)
25+
await act(async () => root!.render(<ActiveFilterBar {...props} />))
26+
return props
27+
}
28+
29+
function buttonWithText(text: string): HTMLButtonElement {
30+
const button = [...element.querySelectorAll('button')].find((b) => b.textContent?.includes(text))
31+
if (!button) throw new Error(`no button containing "${text}"`)
32+
return button
33+
}
34+
35+
afterEach(async () => {
36+
await act(async () => root?.unmount())
37+
root = null
38+
})
39+
40+
describe('ActiveFilterBar', () => {
41+
it('renders nothing without active filters or a restore', async () => {
42+
await render({ columnFilters: { status: [] } })
43+
expect(element.innerHTML).toBe('')
44+
})
45+
46+
it('renders one chip per active filter, marking excluded columns', async () => {
47+
await render({
48+
columnFilters: { status: ['Running', 'Pending'], node: ['n1'], empty: [] },
49+
columnFilterExcludes: { node: true },
50+
problemFilters: ['crashloop'],
51+
labelSelector: 'app=nginx',
52+
})
53+
expect(element.querySelectorAll('button')).toHaveLength(4)
54+
expect(element.textContent).toContain('status: Running, Pending')
55+
expect(element.textContent).toContain('node: not n1')
56+
expect(element.textContent).toContain('Problems: crashloop')
57+
expect(element.textContent).toContain('app=nginx')
58+
expect(element.textContent).not.toContain('restored')
59+
})
60+
61+
it('calls the matching clear handler when a chip is clicked', async () => {
62+
const props = await render({ columnFilters: { status: ['Running'] }, problemFilters: ['oom'], labelSelector: 'tier=web' })
63+
await act(async () => buttonWithText('status:').click())
64+
await act(async () => buttonWithText('Problems:').click())
65+
await act(async () => buttonWithText('tier=web').click())
66+
expect(props.onClearColumn).toHaveBeenCalledWith('status')
67+
expect(props.onClearProblems).toHaveBeenCalledOnce()
68+
expect(props.onClearLabels).toHaveBeenCalledOnce()
69+
})
70+
71+
it('shows the restored line only when restored, with Clear calling onClearAll', async () => {
72+
const onClearAll = vi.fn()
73+
await render({ restored: true, onClearAll, columnFilters: { status: ['Running'] } })
74+
expect(element.textContent).toContain('Filters restored from your last visit')
75+
await act(async () => buttonWithText('Clear').click())
76+
expect(onClearAll).toHaveBeenCalledOnce()
77+
})
78+
})
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { clsx } from 'clsx'
2+
import { AlertTriangle, History, ListFilter, Tag, X } from 'lucide-react'
3+
4+
export interface ActiveFilterBarProps {
5+
columnFilters: Record<string, string[]>
6+
columnFilterExcludes: Record<string, boolean>
7+
problemFilters: string[]
8+
labelSelector: string
9+
onClearColumn: (key: string) => void
10+
onClearProblems: () => void
11+
onClearLabels: () => void
12+
restored?: boolean
13+
onClearAll?: () => void
14+
className?: string
15+
}
16+
17+
export function ActiveFilterBar({
18+
columnFilters,
19+
columnFilterExcludes,
20+
problemFilters,
21+
labelSelector,
22+
onClearColumn,
23+
onClearProblems,
24+
onClearLabels,
25+
restored = false,
26+
onClearAll,
27+
className,
28+
}: ActiveFilterBarProps) {
29+
const activeColEntries = Object.entries(columnFilters).filter(([, vals]) => vals.length > 0)
30+
const hasChips = activeColEntries.length > 0 || problemFilters.length > 0 || !!labelSelector
31+
if (!hasChips && !restored) return null
32+
33+
return (
34+
<div className={clsx('flex flex-wrap items-center gap-1.5', className)}>
35+
{restored && (
36+
<span className="flex items-center gap-1 text-xs text-theme-text-tertiary">
37+
<History className="w-3 h-3" />
38+
<span>Filters restored from your last visit.</span>
39+
{onClearAll && (
40+
<button
41+
type="button"
42+
onClick={onClearAll}
43+
className="text-theme-text-secondary hover:text-theme-text-primary underline underline-offset-2"
44+
>
45+
Clear
46+
</button>
47+
)}
48+
</span>
49+
)}
50+
{activeColEntries.map(([key, vals]) => (
51+
<button
52+
type="button"
53+
key={key}
54+
onClick={() => onClearColumn(key)}
55+
className="flex items-center gap-1 px-2 py-1 text-xs selection selection-text rounded-md hover:selection-strong transition-colors"
56+
>
57+
<ListFilter className="w-3 h-3" />
58+
<span>{key}: {columnFilterExcludes[key] ? 'not ' : ''}{vals.join(', ')}</span>
59+
<X className="w-3 h-3" />
60+
</button>
61+
))}
62+
{problemFilters.length > 0 && (
63+
<button
64+
type="button"
65+
onClick={onClearProblems}
66+
className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/15 text-red-700 dark:text-red-300 rounded-md hover:bg-red-500/25 transition-colors"
67+
>
68+
<AlertTriangle className="w-3 h-3" />
69+
<span>Problems: {problemFilters.join(', ')}</span>
70+
<X className="w-3 h-3" />
71+
</button>
72+
)}
73+
{labelSelector && (
74+
<button
75+
type="button"
76+
onClick={onClearLabels}
77+
className="flex items-center gap-1 px-2 py-1 text-xs bg-green-500/15 text-green-700 dark:text-green-300 rounded-md hover:bg-green-500/25 transition-colors"
78+
>
79+
<Tag className="w-3 h-3" />
80+
<span>{labelSelector}</span>
81+
<X className="w-3 h-3" />
82+
</button>
83+
)}
84+
</div>
85+
)
86+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { hasFilterIntent } from './ResourcesView'
3+
import { kindFiltersFromSearch, kindFiltersToSearch, serializeColumnFilters } from './resource-utils'
4+
5+
describe('kindFiltersFromSearch', () => {
6+
it('restores column filters, their exclude operator and problem filters', () => {
7+
const filters = serializeColumnFilters({ namespace: ['a', 'b'], status: ['Running'] }, { status: true })
8+
const search = `?${new URLSearchParams({ filters, problems: 'failed,pending', search: 'web' })}`
9+
10+
expect(kindFiltersFromSearch(search)).toEqual({
11+
columnFilters: { namespace: ['a', 'b'], status: ['Running'] },
12+
columnFilterExcludes: { status: true },
13+
problemFilters: ['failed', 'pending'],
14+
})
15+
})
16+
17+
it('starts empty for a kind with nothing remembered', () => {
18+
expect(kindFiltersFromSearch(undefined)).toEqual({ columnFilters: {}, columnFilterExcludes: {}, problemFilters: [] })
19+
})
20+
})
21+
22+
describe('kindFiltersToSearch', () => {
23+
it('round-trips through kindFiltersFromSearch and keeps only the per-kind filters', () => {
24+
const filters = serializeColumnFilters({ namespace: ['a'], status: ['Running'] }, { status: true })
25+
const saved = kindFiltersToSearch(kindFiltersFromSearch(`?${new URLSearchParams({ filters, problems: 'failed', search: 'web', namespaces: 'x' })}`))
26+
27+
expect(new URLSearchParams(saved).get('search')).toBeNull()
28+
expect(kindFiltersFromSearch(saved)).toEqual({
29+
columnFilters: { namespace: ['a'], status: ['Running'] },
30+
columnFilterExcludes: { status: true },
31+
problemFilters: ['failed'],
32+
})
33+
})
34+
35+
it('is empty when there is nothing to save, ignoring an exclude flag with no values', () => {
36+
expect(kindFiltersToSearch({ columnFilters: { status: [] }, columnFilterExcludes: { status: true }, problemFilters: [] })).toBe('')
37+
})
38+
})
39+
40+
describe('hasFilterIntent', () => {
41+
it('is false for a bare list URL, even with the namespace pick or a kind group', () => {
42+
expect(hasFilterIntent('')).toBe(false)
43+
expect(hasFilterIntent('?namespaces=a,b&apiGroup=serving.knative.dev&ai-run=r1')).toBe(false)
44+
})
45+
46+
it.each(['filters=status:Running', 'problems=failed', 'search=web', 'regex=true', 'labels=app%3Dweb', 'ownerKind=DaemonSet', 'ownerName=x', 'resource=a/web', 'showInactive=true'])(
47+
'is true when the URL carries %s',
48+
(param) => {
49+
expect(hasFilterIntent(`?namespaces=a&${param}`)).toBe(true)
50+
},
51+
)
52+
})

0 commit comments

Comments
 (0)