@skyhook-io/k8s-ui 1.8.8 → 1.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.8.8",
3
+ "version": "1.8.9",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -6,6 +6,7 @@ import { StatusDot, mapHealthToTone } from '../ui/status-tone'
6
6
  import { Tooltip } from '../ui/Tooltip'
7
7
  import { EmptyState } from '../ui/EmptyState'
8
8
  import { SearchBox } from '../ui/SearchBox'
9
+ import { useFilterState, defineFilterSchema } from '../../filter-state'
9
10
  import { PageHeader } from '../ui/PageHeader'
10
11
  import { SummaryTile, type SummaryTone } from '../ui/SummaryTile'
11
12
  import { Facet, type FacetTone } from '../ui/Facet'
@@ -101,14 +102,29 @@ export interface ApplicationsViewProps {
101
102
  headerActions?: ReactNode
102
103
  }
103
104
 
105
+ const APPS_FILTER_SCHEMA = defineFilterSchema({
106
+ health: { param: 'health', type: 'set' },
107
+ class: { param: 'class', type: 'set' },
108
+ type: { param: 'type', type: 'set' },
109
+ env: { param: 'env', type: 'set' },
110
+ source: { param: 'source', type: 'set' },
111
+ q: { param: 'q', type: 'text' },
112
+ system: { param: 'system', type: 'boolean' },
113
+ })
114
+
104
115
  export function ApplicationsView({ entries: allEntries, variant, onSelect, title = 'Applications', description, emptySlot, headerActions }: ApplicationsViewProps) {
105
- const [textFilter, setTextFilter] = useState('')
106
- const [fHealth, setFHealth] = useState<Set<AppHealth>>(new Set())
107
- const [fEnv, setFEnv] = useState<Set<string>>(new Set())
108
- const [fSource, setFSource] = useState<Set<AppSource>>(new Set())
109
- const [fClass, setFClass] = useState<Set<AppWorkloadClass>>(new Set())
110
- const [fType, setFType] = useState<Set<AppCategory>>(new Set())
111
- const [showSystem, setShowSystem] = useState(false)
116
+ // Facets + search + show-system live in the URL (shareable, bookmarkable) via
117
+ // the shared filter-state contract. Sort stays local: it's a compound
118
+ // {key, dir} view-preference, not a result-narrowing filter, so it isn't
119
+ // forced into the filter vocabulary.
120
+ const filters = useFilterState(APPS_FILTER_SCHEMA)
121
+ const textFilter = filters.values.q
122
+ const fHealth = filters.values.health as Set<AppHealth>
123
+ const fEnv = filters.values.env
124
+ const fSource = filters.values.source as Set<AppSource>
125
+ const fClass = filters.values.class as Set<AppWorkloadClass>
126
+ const fType = filters.values.type as Set<AppCategory>
127
+ const showSystem = filters.values.system
112
128
  const [sort, setSort] = useState<{ key: SortKey; dir: SortDir } | null>(null)
113
129
 
114
130
  // The Show-system toggle keys off per-entry workload namespaces, which only
@@ -244,12 +260,6 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
244
260
  return { health, env, source, workloadClass, category }
245
261
  }, [all])
246
262
 
247
- const toggle = <T,>(set: Set<T>, setter: (s: Set<T>) => void, v: T) => {
248
- const next = new Set(set)
249
- next.has(v) ? next.delete(v) : next.add(v)
250
- setter(next)
251
- }
252
-
253
263
  // asc → desc → off (null = default health-worst-first sort).
254
264
  const onSort = (key: SortKey) => {
255
265
  setSort((prev) => {
@@ -267,20 +277,14 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
267
277
  // Clickable status tile wired to the health facet — tap to filter to that tier.
268
278
  const healthTile = (h: AppHealth, tone: SummaryTone) =>
269
279
  counts.health[h] ? (
270
- <SummaryTile key={h} label={HEALTH_META[h].label} value={counts.health[h]} tone={tone} active={fHealth.has(h)} onClick={() => toggle(fHealth, setFHealth, h)} />
280
+ <SummaryTile key={h} label={HEALTH_META[h].label} value={counts.health[h]} tone={tone} active={fHealth.has(h)} onClick={() => filters.toggle('health', h)} />
271
281
  ) : null
272
282
 
273
283
  // showSystem lives in the Filters rail, so Clear resets it too (and its
274
284
  // non-default ON state counts as an active filter that surfaces the button).
275
- const anyFilterActive = !!(textFilter || fHealth.size || fClass.size || fType.size || fSource.size || fEnv.size || showSystem)
285
+ const anyFilterActive = filters.isActive
276
286
  const clearAllFilters = () => {
277
- setTextFilter('')
278
- setFHealth(new Set())
279
- setFClass(new Set())
280
- setFType(new Set())
281
- setFSource(new Set())
282
- setFEnv(new Set())
283
- setShowSystem(false)
287
+ filters.clearAll()
284
288
  }
285
289
 
286
290
  return (
@@ -326,14 +330,14 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
326
330
  )}
327
331
  </div>
328
332
  <div className="flex-1 overflow-y-auto">
329
- <Facet icon={HeartPulse} title="Availability" options={HEALTH_ORDER.map((h) => ({ value: h, label: HEALTH_META[h].label, count: counts.health[h] ?? 0, tone: HEALTH_TONE[h] }))} selected={fHealth} onToggle={(v) => toggle(fHealth, setFHealth, v)} />
330
- <Facet icon={Layers} title="Class" options={CLASS_ORDER.map((c) => ({ value: c, label: CLASS_META[c].label, count: counts.workloadClass[c] ?? 0 }))} selected={fClass} onToggle={(v) => toggle(fClass, setFClass, v)} />
331
- <Facet icon={Shapes} title="Type" options={CATEGORY_ORDER.map((c) => ({ value: c, label: CATEGORY_META[c].label, count: counts.category[c] ?? 0, tooltip: CATEGORY_META[c].tooltip }))} selected={fType} onToggle={(v) => toggle(fType, setFType, v)} />
332
- <Facet icon={Globe} title="Environment" info={<EnvHint />} options={envOptions} selected={fEnv} onToggle={(v) => toggle(fEnv, setFEnv, v)} />
333
- <Facet icon={Tag} title="Source" options={SOURCE_ORDER.map((s) => ({ value: s, label: SOURCE_META[s].label, count: counts.source[s] ?? 0 }))} selected={fSource} onToggle={(v) => toggle(fSource, setFSource, v)} />
333
+ <Facet icon={HeartPulse} title="Availability" options={HEALTH_ORDER.map((h) => ({ value: h, label: HEALTH_META[h].label, count: counts.health[h] ?? 0, tone: HEALTH_TONE[h] }))} selected={fHealth} onToggle={(v) => filters.toggle('health', v)} />
334
+ <Facet icon={Layers} title="Class" options={CLASS_ORDER.map((c) => ({ value: c, label: CLASS_META[c].label, count: counts.workloadClass[c] ?? 0 }))} selected={fClass} onToggle={(v) => filters.toggle('class', v)} />
335
+ <Facet icon={Shapes} title="Type" options={CATEGORY_ORDER.map((c) => ({ value: c, label: CATEGORY_META[c].label, count: counts.category[c] ?? 0, tooltip: CATEGORY_META[c].tooltip }))} selected={fType} onToggle={(v) => filters.toggle('type', v)} />
336
+ <Facet icon={Globe} title="Environment" info={<EnvHint />} options={envOptions} selected={fEnv} onToggle={(v) => filters.toggle('env', v)} />
337
+ <Facet icon={Tag} title="Source" options={SOURCE_ORDER.map((s) => ({ value: s, label: SOURCE_META[s].label, count: counts.source[s] ?? 0 }))} selected={fSource} onToggle={(v) => filters.toggle('source', v)} />
334
338
  {systemCount > 0 && (
335
339
  <label className="flex cursor-pointer items-center gap-2 border-b border-theme-border px-3 py-2 text-[11px] text-theme-text-secondary hover:bg-theme-hover">
336
- <input type="checkbox" checked={showSystem} onChange={(e) => setShowSystem(e.target.checked)} className="accent-skyhook-500" />
340
+ <input type="checkbox" checked={showSystem} onChange={(e) => filters.setBoolean('system', e.target.checked)} className="accent-skyhook-500" />
337
341
  <span>Show system namespaces</span>
338
342
  <span className="ml-auto tabular-nums text-theme-text-tertiary">{systemCount}</span>
339
343
  </label>
@@ -346,7 +350,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
346
350
  <div className="flex shrink-0 items-center gap-3 border-b border-theme-border px-4 py-3">
347
351
  <SearchBox
348
352
  value={textFilter}
349
- onChange={setTextFilter}
353
+ onChange={(v) => filters.setString('q', v)}
350
354
  scope="applications"
351
355
  shortcutId="applications-search"
352
356
  className="max-w-md flex-1"
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { ChevronDown, ChevronRight, ExternalLink, EyeOff, MoreHorizontal, Search, ShieldCheck, Wrench, X } from 'lucide-react'
4
4
  import { ClusterName, EmptyState, FilterPill, DistributionBar, DistributionLegendChip } from '../ui'
5
+ import { useFilterState, defineFilterSchema } from '../../filter-state'
5
6
  import type { CheckMeta, CheckReference } from '../audit'
6
7
  import { CHECK_SEVERITIES, CHECK_SEVERITY_RANK, type Check, type CheckSeverity, type EffectiveCheckFinding, type CheckResourceRef } from './types'
7
8
  import {
@@ -79,11 +80,24 @@ interface FleetCheck {
79
80
  clusters: Check[]
80
81
  }
81
82
 
83
+ const CHECKS_FILTER_SCHEMA = defineFilterSchema({
84
+ severity: { param: 'severity', type: 'set' },
85
+ category: { param: 'category', type: 'set' },
86
+ framework: { param: 'framework', type: 'set' },
87
+ q: { param: 'q', type: 'text' },
88
+ })
89
+
82
90
  export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceClick, clusterLabel, clusterLabelById, clusterFilter: clusterFilterProp, onClusterFilterChange, emptyAction, onHideCheck, onHideCategory }: ChecksViewProps) {
83
- const [severityFilter, setSeverityFilter] = useState<Set<CheckSeverity>>(new Set())
84
- const [categoryFilter, setCategoryFilter] = useState<Set<string>>(new Set())
85
- const [frameworkFilter, setFrameworkFilter] = useState<Set<string>>(new Set())
86
- const [search, setSearch] = useState('')
91
+ // Severity / category / framework / search live in the URL (shareable,
92
+ // bookmarkable audit links) via the shared filter-state contract. The cluster
93
+ // facet is deliberately NOT here — it's a host-controlled seam (see
94
+ // onClusterFilterChange) for the multi-cluster/fleet case and isn't shown in
95
+ // single-cluster OSS, so it stays on its own controlled/internal path.
96
+ const filters = useFilterState(CHECKS_FILTER_SCHEMA)
97
+ const severityFilter = filters.values.severity as Set<CheckSeverity>
98
+ const categoryFilter = filters.values.category
99
+ const frameworkFilter = filters.values.framework
100
+ const search = filters.values.q
87
101
  const [openId, setOpenId] = useState<string | null>(null)
88
102
 
89
103
  // Cluster facet is controlled when the host opts in (onClusterFilterChange);
@@ -209,13 +223,10 @@ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceC
209
223
  return next
210
224
  })
211
225
 
212
- const hasFilters = severityFilter.size > 0 || categoryFilter.size > 0 || frameworkFilter.size > 0 || clusterFilter.size > 0 || search !== ''
226
+ const hasFilters = filters.isActive || clusterFilter.size > 0
213
227
  const clearAll = () => {
214
- setSeverityFilter(new Set())
215
- setCategoryFilter(new Set())
216
- setFrameworkFilter(new Set())
228
+ filters.clearAll()
217
229
  setClusterFilter(new Set())
218
- setSearch('')
219
230
  }
220
231
 
221
232
  return (
@@ -239,13 +250,13 @@ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceC
239
250
  type="text"
240
251
  placeholder="Search checks…"
241
252
  value={search}
242
- onChange={(e) => setSearch(e.target.value)}
253
+ onChange={(e) => filters.setString('q', e.target.value)}
243
254
  className="w-64 rounded-lg border border-theme-border-light bg-theme-base py-1.5 pl-9 pr-8 text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-[var(--color-radar-accent)]"
244
255
  />
245
256
  {search && (
246
257
  <button
247
258
  type="button"
248
- onClick={() => setSearch('')}
259
+ onClick={() => filters.setString('q', '')}
249
260
  aria-label="Clear search"
250
261
  className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-theme-text-tertiary hover:text-theme-text-primary"
251
262
  >
@@ -259,17 +270,17 @@ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceC
259
270
 
260
271
  <div className="flex flex-wrap items-center gap-1.5">
261
272
  {CHECK_SEVERITIES.map((s) => (
262
- <CheckSeverityChip key={s} severity={s} count={totals[s]} active={severityFilter.has(s)} onClick={() => toggle(setSeverityFilter, s)} />
273
+ <CheckSeverityChip key={s} severity={s} count={totals[s]} active={severityFilter.has(s)} onClick={() => filters.toggle('severity', s)} />
263
274
  ))}
264
275
  <span className="mx-1.5 h-5 w-px bg-theme-border" />
265
276
  {CATEGORIES.map((c) => (
266
- <FilterPill key={c} label={c} active={categoryFilter.has(c)} onClick={() => toggle(setCategoryFilter, c)} />
277
+ <FilterPill key={c} label={c} active={categoryFilter.has(c)} onClick={() => filters.toggle('category', c)} />
267
278
  ))}
268
279
  {frameworks.length > 0 && (
269
280
  <>
270
281
  <span className="mx-1.5 h-5 w-px bg-theme-border" />
271
282
  {frameworks.map((fw) => (
272
- <FilterPill key={fw} label={fw} active={frameworkFilter.has(fw)} onClick={() => toggle(setFrameworkFilter, fw)} />
283
+ <FilterPill key={fw} label={fw} active={frameworkFilter.has(fw)} onClick={() => filters.toggle('framework', fw)} />
273
284
  ))}
274
285
  </>
275
286
  )}
@@ -32,10 +32,10 @@ import { FacetSection, FacetButton } from '../ui/Facet'
32
32
  import { SortableTh, TH_CLASS, type SortDir } from '../ui/SortableTh'
33
33
  import { DistributionBar } from '../ui/DistributionBar'
34
34
  import { RowActionMenu, type RowActionItem } from '../ui/RowActionMenu'
35
- import { PaneLoader } from '../ui/PaneLoader'
36
35
  import { getGitOpsResourceStatus } from './detail-helpers'
37
36
  import { isArgoSuspendedByRadar } from '../resources/resource-utils-argo'
38
37
  import { toggleSet } from './GitOpsGraphFilterRail'
38
+ import { useFilterState, defineFilterSchema } from '../../filter-state'
39
39
  import { parseContextName } from '../../utils/context-name'
40
40
 
41
41
  // =============================================================================
@@ -273,6 +273,16 @@ export interface GitOpsTableViewProps {
273
273
 
274
274
  // ----- Main component --------------------------------------------------------
275
275
 
276
+ const GITOPS_FILTER_SCHEMA = defineFilterSchema({
277
+ q: { param: 'q', type: 'text' },
278
+ sync: { param: 'sync', type: 'set' },
279
+ health: { param: 'health', type: 'set' },
280
+ project: { param: 'project', type: 'set' },
281
+ automation: { param: 'automation', type: 'set' },
282
+ labels: { param: 'labels', type: 'set' },
283
+ lifecycle: { param: 'lifecycle', type: 'single', default: 'all' },
284
+ })
285
+
276
286
  export function GitOpsTableView({
277
287
  rows: allRowsInput,
278
288
  loading,
@@ -300,27 +310,24 @@ export function GitOpsTableView({
300
310
  const searchInputRef = useRef<HTMLInputElement>(null)
301
311
  const [mode, setMode] = useState<GitOpsMode>('applications')
302
312
  const [viewMode, setViewMode] = useState<GitOpsViewMode>('table')
303
- const [search, setSearch] = useState('')
304
- const [syncFilters, setSyncFilters] = useState<Set<string>>(new Set())
305
- const [healthFilters, setHealthFilters] = useState<Set<string>>(new Set())
306
- const [projectFilters, setProjectFilters] = useState<Set<string>>(new Set())
313
+ // Sync / health / project / automation / labels / lifecycle + search live in
314
+ // the URL (shareable, bookmarkable) via the shared filter-state contract.
315
+ // Deliberate divergences kept on their own state: `namespace` (entangled with
316
+ // the host globalNamespaces seam + the header scope pill — a separate product
317
+ // decision), `sort` (a compound view-preference, not a filter), and the
318
+ // destination filter (host-controlled, below).
319
+ const filters = useFilterState(GITOPS_FILTER_SCHEMA)
320
+ const search = filters.values.q
321
+ const syncFilters = filters.values.sync
322
+ const healthFilters = filters.values.health
323
+ const projectFilters = filters.values.project
324
+ const labelFilters = filters.values.labels
325
+ const automationFilters = filters.values.automation as Set<'auto' | 'manual' | 'suspended'>
326
+ const lifecycleFilter = filters.values.lifecycle as 'all' | 'terminating' | 'active'
327
+ const toggleAutomation = useCallback((value: 'auto' | 'manual' | 'suspended') => filters.toggle('automation', value), [filters.toggle])
307
328
  const [namespaceFilters, setNamespaceFilters] = useState<Set<string>>(new Set())
308
- const [labelFilters, setLabelFilters] = useState<Set<string>>(new Set())
309
329
  const [showLabelsDropdown, setShowLabelsDropdown] = useState(false)
310
330
  const [labelSearch, setLabelSearch] = useState('')
311
- // Auto-sync / Manual / Suspended are INDEPENDENT row attributes (a Flux app can
312
- // be auto-reconciling AND suspended), so this is a multi-select facet like Sync
313
- // and Health — not the old single-select that conflated the mode (auto vs
314
- // manual) with the orthogonal suspended state.
315
- const [automationFilters, setAutomationFilters] = useState<Set<'auto' | 'manual' | 'suspended'>>(new Set())
316
- const toggleAutomation = useCallback((value: 'auto' | 'manual' | 'suspended') => {
317
- setAutomationFilters((prev) => {
318
- const next = new Set(prev)
319
- next.has(value) ? next.delete(value) : next.add(value)
320
- return next
321
- })
322
- }, [])
323
- const [lifecycleFilter, setLifecycleFilter] = useState<'all' | 'terminating' | 'active'>('all')
324
331
  const [sort, setSort] = useState<{ key: SortKey; dir: SortDir } | null>({ key: 'urgency', dir: 'asc' })
325
332
  // 3-state cycle: natural direction → reversed → off. The first click uses each
326
333
  // column's natural direction (SORT_DEFAULT_DIR — e.g. Last Sync is newest-first)
@@ -468,16 +475,12 @@ export function GitOpsTableView({
468
475
  const terminatingCount = useMemo(() => allRows.filter((row) => row.terminating).length, [allRows])
469
476
 
470
477
  const clearAllFilters = useCallback(() => {
471
- setSearch('')
472
- setSyncFilters(new Set())
473
- setHealthFilters(new Set())
474
- setProjectFilters(new Set())
478
+ filters.clearAll()
475
479
  setNamespaceFilters(new Set())
476
- setLabelFilters(new Set())
477
- setAutomationFilters(new Set())
478
- setLifecycleFilter('all')
479
480
  onClearNamespaces?.()
480
481
  onDestinationFilterChange?.('all')
482
+ // filters.clearAll is a stable ref from the hook.
483
+ // eslint-disable-next-line react-hooks/exhaustive-deps
481
484
  }, [onClearNamespaces, onDestinationFilterChange])
482
485
 
483
486
  // True when nothing is filtered at all — backs the Total tile's active state.
@@ -531,6 +534,12 @@ export function GitOpsTableView({
531
534
 
532
535
  const showCrossClusterTile = typeof crossClusterCount === 'number' && mode === 'applications'
533
536
 
537
+ // First fetch, nothing to show yet. Drives the shape-stable loading
538
+ // treatment: tiles pulse instead of reading "0" (a false zero), the table
539
+ // area renders skeleton rows, and the filter rail holds its loaded height
540
+ // with section stubs — so the settled layout doesn't reflow into place.
541
+ const initialLoading = Boolean(loading) && allRowsInput.length === 0
542
+
534
543
  // Header tiles unify with the facet rail: each STATUS tile toggles its own
535
544
  // dimension and composes with the other facets + search (clicking "Out of
536
545
  // sync" adds sync=OutOfSync without wiping an active health filter or your
@@ -552,8 +561,8 @@ export function GitOpsTableView({
552
561
  value: statusSummary.outOfSync,
553
562
  tone: 'warning',
554
563
  active: syncFilters.size === 1 && syncFilters.has('OutOfSync'),
555
- apply: () => setSyncFilters(new Set(['OutOfSync'])),
556
- clear: () => setSyncFilters(new Set()),
564
+ apply: () => filters.setSet('sync', ['OutOfSync']),
565
+ clear: () => filters.setSet('sync', []),
557
566
  },
558
567
  {
559
568
  key: 'degraded',
@@ -561,8 +570,8 @@ export function GitOpsTableView({
561
570
  value: statusSummary.degraded,
562
571
  tone: 'error',
563
572
  active: healthFilters.size === 1 && healthFilters.has('Degraded'),
564
- apply: () => setHealthFilters(new Set(['Degraded'])),
565
- clear: () => setHealthFilters(new Set()),
573
+ apply: () => filters.setSet('health', ['Degraded']),
574
+ clear: () => filters.setSet('health', []),
566
575
  },
567
576
  {
568
577
  key: 'suspended',
@@ -570,8 +579,8 @@ export function GitOpsTableView({
570
579
  value: statusSummary.suspended,
571
580
  tone: 'warning',
572
581
  active: automationFilters.size === 1 && automationFilters.has('suspended'),
573
- apply: () => setAutomationFilters(new Set(['suspended'])),
574
- clear: () => setAutomationFilters(new Set()),
582
+ apply: () => filters.setSet('automation', ['suspended']),
583
+ clear: () => filters.setSet('automation', []),
575
584
  },
576
585
  {
577
586
  key: 'reconciling',
@@ -579,8 +588,8 @@ export function GitOpsTableView({
579
588
  value: syncCounts.get('Reconciling') ?? 0,
580
589
  tone: 'info',
581
590
  active: syncFilters.size === 1 && syncFilters.has('Reconciling'),
582
- apply: () => setSyncFilters(new Set(['Reconciling'])),
583
- clear: () => setSyncFilters(new Set()),
591
+ apply: () => filters.setSet('sync', ['Reconciling']),
592
+ clear: () => filters.setSet('sync', []),
584
593
  },
585
594
  ...(showCrossClusterTile
586
595
  ? [
@@ -615,6 +624,7 @@ export function GitOpsTableView({
615
624
  value={tile.value}
616
625
  tone={tile.tone}
617
626
  active={tile.active}
627
+ loading={initialLoading}
618
628
  onClick={() => {
619
629
  if (tile.active) tile.clear?.()
620
630
  else tile.apply?.()
@@ -631,25 +641,26 @@ export function GitOpsTableView({
631
641
  }`}
632
642
  >
633
643
  <GitOpsFilterSidebar
644
+ loading={initialLoading}
634
645
  side={filtersSide}
635
646
  mode={mode}
636
647
  onModeChange={setMode}
637
648
  modeCounts={modeCounts}
638
649
  syncCounts={syncCounts}
639
650
  syncFilters={syncFilters}
640
- onToggleSync={(value) => toggleSet(syncFilters, setSyncFilters, value)}
651
+ onToggleSync={(value) => filters.toggle('sync', value)}
641
652
  healthCounts={healthCounts}
642
653
  healthFilters={healthFilters}
643
- onToggleHealth={(value) => toggleSet(healthFilters, setHealthFilters, value)}
654
+ onToggleHealth={(value) => filters.toggle('health', value)}
644
655
  automationFilters={automationFilters}
645
656
  automationCounts={automationCounts}
646
657
  onToggleAutomation={toggleAutomation}
647
658
  lifecycleFilter={lifecycleFilter}
648
- onLifecycleFilterChange={setLifecycleFilter}
659
+ onLifecycleFilterChange={(v) => filters.setString('lifecycle', v)}
649
660
  terminatingCount={terminatingCount}
650
661
  projects={projects}
651
662
  projectFilters={projectFilters}
652
- onToggleProject={(value) => toggleSet(projectFilters, setProjectFilters, value)}
663
+ onToggleProject={(value) => filters.toggle('project', value)}
653
664
  namespaces={rowNamespaces}
654
665
  namespaceFilters={namespaceFilters}
655
666
  onToggleNamespace={(value) => toggleSet(namespaceFilters, setNamespaceFilters, value)}
@@ -678,7 +689,7 @@ export function GitOpsTableView({
678
689
  <input
679
690
  ref={searchInputRef}
680
691
  value={search}
681
- onChange={(e) => setSearch(e.target.value)}
692
+ onChange={(e) => filters.setString('q', e.target.value)}
682
693
  placeholder="Search applications, repos, paths..."
683
694
  className="h-8 w-full rounded-md border border-theme-border bg-theme-base pl-8 pr-3 text-sm text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:ring-1 focus:ring-blue-500/50"
684
695
  />
@@ -698,8 +709,8 @@ export function GitOpsTableView({
698
709
  <LabelsDropdown
699
710
  labels={labels}
700
711
  activeLabels={labelFilters}
701
- onToggle={(value) => toggleSet(labelFilters, setLabelFilters, value)}
702
- onClear={() => setLabelFilters(new Set())}
712
+ onToggle={(value) => filters.toggle('labels', value)}
713
+ onClear={() => filters.setSet('labels', [])}
703
714
  open={showLabelsDropdown}
704
715
  onOpenChange={setShowLabelsDropdown}
705
716
  search={labelSearch}
@@ -754,8 +765,8 @@ export function GitOpsTableView({
754
765
  <div className="flex h-full items-center justify-center text-sm text-theme-text-secondary">
755
766
  {modeLabel(mode)} view is queued behind the application list.
756
767
  </div>
757
- ) : loading && filteredRows.length === 0 ? (
758
- <PaneLoader label="Loading GitOps applications…" className="h-full" />
768
+ ) : initialLoading ? (
769
+ <GitOpsTableSkeleton />
759
770
  ) : error ? (
760
771
  <div className="p-4 text-sm text-red-500">Failed to load GitOps applications: {error.message}</div>
761
772
  ) : filteredRows.length === 0 ? (
@@ -805,7 +816,62 @@ export function GitOpsTableView({
805
816
  // GitOpsTableView's visual language and not generally useful elsewhere.
806
817
  // =============================================================================
807
818
 
819
+ // Shape-stable loading stand-ins. Both mirror the loaded anatomy so data
820
+ // resolves in place: rows appear inside the table frame, facets inside the
821
+ // rail — no half-height rail dangling next to a centered spinner, no layout
822
+ // jump when the response lands.
823
+ function GitOpsTableSkeleton() {
824
+ return (
825
+ <div aria-live="polite" aria-label="Loading GitOps applications…" className="divide-y divide-theme-border-light">
826
+ {Array.from({ length: 10 }, (_, i) => (
827
+ <div key={i} className="flex items-center gap-6 px-4 py-3" style={{ opacity: 1 - i * 0.09 }}>
828
+ <div className="min-w-0 flex-[2] space-y-2">
829
+ <div className="h-4 w-2/3 animate-pulse rounded bg-theme-hover" />
830
+ <div className="h-3 w-1/2 animate-pulse rounded bg-theme-hover" />
831
+ </div>
832
+ <div className="hidden h-4 flex-1 animate-pulse rounded bg-theme-hover md:block" />
833
+ <div className="h-5 w-20 animate-pulse rounded-full bg-theme-hover" />
834
+ <div className="h-5 w-20 animate-pulse rounded-full bg-theme-hover" />
835
+ <div className="hidden h-4 flex-[1.5] animate-pulse rounded bg-theme-hover lg:block" />
836
+ </div>
837
+ ))}
838
+ </div>
839
+ )
840
+ }
841
+
842
+ function GitOpsSidebarSkeleton() {
843
+ // Section stubs sized like the loaded rail (Sync ~4 rows, Health ~5,
844
+ // Automation ~3, Projects/Namespaces a few each) so the rail keeps its
845
+ // height while counts are unknown — showing real facet buttons with "0"
846
+ // counts would be false zeros.
847
+ const sections: Array<[string, number]> = [
848
+ ['Sync', 4],
849
+ ['Health', 5],
850
+ ['Automation', 3],
851
+ ['Projects', 4],
852
+ ['Namespaces', 4],
853
+ ]
854
+ return (
855
+ <div aria-hidden>
856
+ {sections.map(([title, rows]) => (
857
+ <div key={title} className="border-b border-theme-border-light px-3 py-3">
858
+ <div className="mb-2 h-3 w-24 animate-pulse rounded bg-theme-hover" />
859
+ <div className="space-y-1.5">
860
+ {Array.from({ length: rows }, (_, i) => (
861
+ <div key={i} className="flex items-center justify-between px-1.5 py-1">
862
+ <div className="h-3.5 animate-pulse rounded bg-theme-hover" style={{ width: `${52 - i * 6}%` }} />
863
+ <div className="h-3.5 w-6 animate-pulse rounded bg-theme-hover" />
864
+ </div>
865
+ ))}
866
+ </div>
867
+ </div>
868
+ ))}
869
+ </div>
870
+ )
871
+ }
872
+
808
873
  function GitOpsFilterSidebar({
874
+ loading,
809
875
  side,
810
876
  mode,
811
877
  onModeChange,
@@ -853,6 +919,8 @@ function GitOpsFilterSidebar({
853
919
  namespaceFilters: Set<string>
854
920
  onToggleNamespace: (value: string) => void
855
921
  onClear: () => void
922
+ /** Initial fetch in flight — hold the rail's shape with section stubs. */
923
+ loading?: boolean
856
924
  }) {
857
925
  return (
858
926
  <aside
@@ -867,6 +935,10 @@ function GitOpsFilterSidebar({
867
935
  </button>
868
936
  </div>
869
937
  <div className="flex-1 overflow-y-auto">
938
+ {loading ? (
939
+ <GitOpsSidebarSkeleton />
940
+ ) : (
941
+ <>
870
942
  {AVAILABLE_MODES.length > 1 && (
871
943
  <GitOpsFilterSection icon={GitBranch} title="Scope">
872
944
  <div className="grid grid-cols-2 gap-1">
@@ -960,6 +1032,8 @@ function GitOpsFilterSidebar({
960
1032
  />
961
1033
  ))}
962
1034
  </GitOpsFilterSection>
1035
+ </>
1036
+ )}
963
1037
  </div>
964
1038
  </aside>
965
1039
  )
@@ -12,12 +12,16 @@ export function SummaryTile({
12
12
  tone = 'neutral',
13
13
  onClick,
14
14
  active = false,
15
+ loading = false,
15
16
  }: {
16
17
  label: string
17
18
  value: number
18
19
  tone?: SummaryTone
19
20
  onClick?: () => void
20
21
  active?: boolean
22
+ /** First fetch in flight — render a pulse instead of the value. A tile
23
+ * that reads "0" while loading is a false zero, not a count. */
24
+ loading?: boolean
21
25
  }) {
22
26
  const toneClass = {
23
27
  neutral: 'text-theme-text-primary',
@@ -33,7 +37,11 @@ export function SummaryTile({
33
37
  error: 'border-red-500',
34
38
  info: 'border-sky-500',
35
39
  }[tone]
36
- const value$ = <div className={`text-sm font-semibold ${toneClass}`}>{value}</div>
40
+ const value$ = loading ? (
41
+ <div className="my-1 h-3.5 w-8 animate-pulse rounded bg-theme-hover" aria-hidden />
42
+ ) : (
43
+ <div className={`text-sm font-semibold ${toneClass}`}>{value}</div>
44
+ )
37
45
  const label$ = <div className="text-xs text-theme-text-tertiary">{label}</div>
38
46
  if (!onClick) {
39
47
  return (
@@ -0,0 +1,98 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ decodeFilters,
4
+ defineFilterSchema,
5
+ withField,
6
+ withToggle,
7
+ withCleared,
8
+ isFilterActive,
9
+ } from './filter-state-core'
10
+
11
+ const schema = defineFilterSchema({
12
+ ns: { param: 'namespace', type: 'set' },
13
+ sev: { param: 'severity', type: 'set' },
14
+ q: { param: 'q', type: 'text' },
15
+ group: { param: 'group', type: 'single', default: 'none' },
16
+ managed: { param: 'managed', type: 'boolean' },
17
+ })
18
+
19
+ const p = (s = '') => new URLSearchParams(s)
20
+
21
+ describe('filter-state core', () => {
22
+ it('decodes params by type', () => {
23
+ const v = decodeFilters(schema, p('namespace=argocd,default&severity=high&q=foo&managed=1'))
24
+ expect([...v.ns]).toEqual(['argocd', 'default'])
25
+ expect([...v.sev]).toEqual(['high'])
26
+ expect(v.q).toBe('foo')
27
+ expect(v.managed).toBe(true)
28
+ })
29
+
30
+ it('applies string defaults when the param is absent', () => {
31
+ const v = decodeFilters(schema, p(''))
32
+ expect(v.group).toBe('none')
33
+ expect(v.q).toBe('')
34
+ expect(v.managed).toBe(false)
35
+ expect(v.ns.size).toBe(0)
36
+ })
37
+
38
+ it('toggles a set member and omits the param when empty (empty = all)', () => {
39
+ const added = withToggle(schema, p(''), 'ns', 'argocd')
40
+ expect(added.toString()).toBe('namespace=argocd')
41
+ const removed = withToggle(schema, added, 'ns', 'argocd')
42
+ expect(removed.toString()).toBe('')
43
+ })
44
+
45
+ it('composes successive toggles (each sees the previous result)', () => {
46
+ let params = p('')
47
+ params = withToggle(schema, params, 'ns', 'a')
48
+ params = withToggle(schema, params, 'ns', 'b')
49
+ expect([...decodeFilters(schema, params).ns].sort()).toEqual(['a', 'b'])
50
+ })
51
+
52
+ it('withField replaces a set wholesale and clears when empty', () => {
53
+ const set = withField(schema, p('namespace=x'), 'ns', new Set(['a', 'b']))
54
+ expect([...decodeFilters(schema, set).ns]).toEqual(['a', 'b'])
55
+ expect(withField(schema, set, 'ns', new Set<string>()).toString()).toBe('')
56
+ })
57
+
58
+ it('omits a string param when equal to its default', () => {
59
+ expect(withField(schema, p(''), 'group', 'namespace').toString()).toBe('group=namespace')
60
+ expect(withField(schema, p('group=namespace'), 'group', 'none').toString()).toBe('')
61
+ })
62
+
63
+ it('encodes a boolean as =1 and omits false', () => {
64
+ expect(withField(schema, p(''), 'managed', true).toString()).toBe('managed=1')
65
+ expect(withField(schema, p('managed=1'), 'managed', false).toString()).toBe('')
66
+ })
67
+
68
+ it('clears one field or all schema fields, leaving unrelated params', () => {
69
+ const one = withCleared(schema, p('namespace=a&severity=high&keep=yes'), ['ns'])
70
+ const oneV = decodeFilters(schema, one)
71
+ expect(oneV.ns.size).toBe(0)
72
+ expect(oneV.sev.size).toBe(1)
73
+ expect(one.get('keep')).toBe('yes')
74
+ expect(withCleared(schema, p('namespace=a&severity=high&keep=yes')).toString()).toBe('keep=yes')
75
+ })
76
+
77
+ it('isFilterActive is true only when a field diverges from its default', () => {
78
+ expect(isFilterActive(schema, decodeFilters(schema, p('q=foo')))).toBe(true)
79
+ expect(isFilterActive(schema, decodeFilters(schema, p('group=none')))).toBe(false)
80
+ expect(isFilterActive(schema, decodeFilters(schema, p('')))).toBe(false)
81
+ })
82
+
83
+ it('writes set values sorted, so the URL is canonical regardless of order', () => {
84
+ const a = withToggle(schema, withToggle(schema, p(''), 'ns', 'zebra'), 'ns', 'alpha')
85
+ expect(a.get('namespace')).toBe('alpha,zebra')
86
+ })
87
+
88
+ it('decodes an empty string param (?group=) as the default', () => {
89
+ expect(decodeFilters(schema, p('group=')).group).toBe('none')
90
+ expect(decodeFilters(schema, p('q=')).q).toBe('')
91
+ })
92
+
93
+ it('defineFilterSchema rejects duplicate URL params', () => {
94
+ expect(() =>
95
+ defineFilterSchema({ a: { param: 'x', type: 'set' }, b: { param: 'x', type: 'set' } }),
96
+ ).toThrow(/duplicate/)
97
+ })
98
+ })
@@ -0,0 +1,138 @@
1
+ // Pure URL <-> filter-state transforms. No React, no router, no window — just
2
+ // URLSearchParams in, URLSearchParams / typed values out. This is the behavioral
3
+ // contract every list view shares; keeping it pure makes it exhaustively
4
+ // testable and keeps the React hook a thin wrapper.
5
+
6
+ export interface FilterFieldDef {
7
+ /** URL query-param name. Stable — this is a durable, shareable public API. */
8
+ param: string
9
+ /**
10
+ * - 'set' multi-select; comma-list. Empty ⇒ param omitted ⇒ "all" (no
11
+ * narrowing). Values must not contain commas (k8s identifiers and
12
+ * controlled enums never do). Written sorted, so the URL is
13
+ * canonical regardless of selection order. Value: Set<string>.
14
+ * - 'text' free-text search; written with history `replace` (no entry per
15
+ * keystroke). Omitted when empty / equal to `default`. Value: string.
16
+ * - 'single' single choice (enum / dropdown, incl. tri-state all|yes|no);
17
+ * pushes a history entry. Omitted when equal to `default`. Value:
18
+ * string.
19
+ * - 'boolean' two-state flag; `param=1` when true, omitted when false. For
20
+ * three states use 'single' with an enum default. Value: boolean.
21
+ */
22
+ type: 'set' | 'text' | 'single' | 'boolean'
23
+ /** Default for 'text'/'single' (omitted from the URL when the value equals it). */
24
+ default?: string
25
+ }
26
+
27
+ export type FilterSchema = Record<string, FilterFieldDef>
28
+
29
+ type FieldValue<F extends FilterFieldDef> = F['type'] extends 'set'
30
+ ? Set<string>
31
+ : F['type'] extends 'boolean'
32
+ ? boolean
33
+ : string
34
+
35
+ export type FilterValues<S extends FilterSchema> = { [K in keyof S]: FieldValue<S[K]> }
36
+
37
+ /** Keys of a schema whose field is one of the given kinds — for type-safe setters. */
38
+ export type FieldKeysOfType<S extends FilterSchema, T extends FilterFieldDef['type']> = {
39
+ [K in keyof S]: S[K]['type'] extends T ? K : never
40
+ }[keyof S]
41
+
42
+ /**
43
+ * Identity helper that preserves literal types (so `values.x` is precisely typed)
44
+ * and validates the schema — duplicate URL params would silently clobber each
45
+ * other, so we fail loudly at module load.
46
+ */
47
+ export function defineFilterSchema<const S extends FilterSchema>(schema: S): S {
48
+ const seen = new Set<string>()
49
+ for (const key in schema) {
50
+ const { param } = schema[key]
51
+ if (seen.has(param)) throw new Error(`defineFilterSchema: duplicate URL param "${param}"`)
52
+ seen.add(param)
53
+ }
54
+ return schema
55
+ }
56
+
57
+ export function decodeFilters<S extends FilterSchema>(schema: S, params: URLSearchParams): FilterValues<S> {
58
+ const out = {} as FilterValues<S>
59
+ for (const key in schema) {
60
+ const def = schema[key]
61
+ const raw = params.get(def.param)
62
+ if (def.type === 'set') {
63
+ ;(out[key] as Set<string>) = new Set((raw ?? '').split(',').filter(Boolean))
64
+ } else if (def.type === 'boolean') {
65
+ ;(out[key] as boolean) = raw === '1'
66
+ } else {
67
+ // text / single: an empty param (?f=) reads as the default, same as omission.
68
+ ;(out[key] as string) = raw ? raw : (def.default ?? '')
69
+ }
70
+ }
71
+ return out
72
+ }
73
+
74
+ /** Write one field into `params` in place. Read (decode) and write agree exactly. */
75
+ function writeField(params: URLSearchParams, def: FilterFieldDef, value: Set<string> | string | boolean): void {
76
+ if (def.type === 'set') {
77
+ const set = value as Set<string>
78
+ if (set.size === 0) params.delete(def.param)
79
+ else params.set(def.param, [...set].sort().join(','))
80
+ } else if (def.type === 'boolean') {
81
+ if (value) params.set(def.param, '1')
82
+ else params.delete(def.param)
83
+ } else {
84
+ const str = value as string
85
+ if (!str || str === (def.default ?? '')) params.delete(def.param)
86
+ else params.set(def.param, str)
87
+ }
88
+ }
89
+
90
+ /** Return new params with one field set to `value`. */
91
+ export function withField<S extends FilterSchema>(
92
+ schema: S,
93
+ params: URLSearchParams,
94
+ key: keyof S,
95
+ value: Set<string> | string | boolean,
96
+ ): URLSearchParams {
97
+ const next = new URLSearchParams(params)
98
+ writeField(next, schema[key], value)
99
+ return next
100
+ }
101
+
102
+ /** Return new params with one member added to / removed from a 'set' field. */
103
+ export function withToggle<S extends FilterSchema>(
104
+ schema: S,
105
+ params: URLSearchParams,
106
+ key: keyof S,
107
+ value: string,
108
+ ): URLSearchParams {
109
+ const next = new URLSearchParams(params)
110
+ const cur = new Set((next.get(schema[key].param) ?? '').split(',').filter(Boolean))
111
+ if (cur.has(value)) cur.delete(value)
112
+ else cur.add(value)
113
+ writeField(next, schema[key], cur)
114
+ return next
115
+ }
116
+
117
+ /** Default (empty) value for a field, used to reset it. */
118
+ export function emptyValue(def: FilterFieldDef): Set<string> | string | boolean {
119
+ return def.type === 'set' ? new Set<string>() : def.type === 'boolean' ? false : (def.default ?? '')
120
+ }
121
+
122
+ /** Return new params with the given schema fields cleared (all of them if omitted). */
123
+ export function withCleared<S extends FilterSchema>(schema: S, params: URLSearchParams, keys?: (keyof S)[]): URLSearchParams {
124
+ const next = new URLSearchParams(params)
125
+ for (const key of keys ?? (Object.keys(schema) as (keyof S)[])) next.delete(schema[key].param)
126
+ return next
127
+ }
128
+
129
+ export function isFilterActive<S extends FilterSchema>(schema: S, values: FilterValues<S>): boolean {
130
+ for (const key in schema) {
131
+ const def = schema[key]
132
+ const v = values[key]
133
+ if (def.type === 'set' && (v as Set<string>).size > 0) return true
134
+ if (def.type === 'boolean' && (v as boolean)) return true
135
+ if ((def.type === 'text' || def.type === 'single') && (v as string) !== (def.default ?? '')) return true
136
+ }
137
+ return false
138
+ }
@@ -0,0 +1,127 @@
1
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
+ import {
3
+ decodeFilters,
4
+ isFilterActive,
5
+ withCleared,
6
+ withField,
7
+ withToggle,
8
+ emptyValue,
9
+ type FieldKeysOfType,
10
+ type FilterSchema,
11
+ type FilterValues,
12
+ } from './filter-state-core'
13
+
14
+ // Shared filter-state contract for Radar's list views (OSS + Hub).
15
+ //
16
+ // The consolidation center is BEHAVIOR, not components: one place owns how a
17
+ // view's filters serialize to the URL, what "cleared" means, and how defaults
18
+ // encode — so every list view is shareable/bookmarkable and behaves the same,
19
+ // whether it renders as a facet sidebar or a compact bar.
20
+ //
21
+ // URL is the single source of truth (no localStorage of filter values — that
22
+ // goes stale across clusters/orgs). Router-agnostic + app-agnostic: this never
23
+ // imports react-router or reads window; the host injects a FilterLocation
24
+ // adapter over its own router. All transforms live in ./filter-state-core (pure,
25
+ // exhaustively tested); this file is only the React glue.
26
+
27
+ /** A reactive view of the URL query string, injected by the host app. */
28
+ export interface FilterLocation {
29
+ /** Current query params — reactive (sourced from the app's router hook). */
30
+ searchParams: URLSearchParams
31
+ /**
32
+ * Apply an update. The updater receives the LATEST params (not a captured
33
+ * snapshot), so rapid successive toggles compose instead of clobbering.
34
+ * `replace` avoids a history entry per keystroke (used for text fields).
35
+ */
36
+ update: (updater: (prev: URLSearchParams) => URLSearchParams, opts?: { replace?: boolean }) => void
37
+ }
38
+
39
+ const FilterLocationContext = createContext<FilterLocation | null>(null)
40
+
41
+ /**
42
+ * Wrap the app subtree once with the host's router adapter, e.g.
43
+ * const [searchParams, setSearchParams] = useSearchParams()
44
+ * <FilterLocationProvider value={{ searchParams, update: setSearchParams }}>
45
+ */
46
+ export function FilterLocationProvider({ value, children }: { value: FilterLocation; children: ReactNode }) {
47
+ return <FilterLocationContext.Provider value={value}>{children}</FilterLocationContext.Provider>
48
+ }
49
+
50
+ export function useFilterLocation(): FilterLocation {
51
+ const ctx = useContext(FilterLocationContext)
52
+ if (!ctx) throw new Error('useFilterState requires a <FilterLocationProvider> ancestor')
53
+ return ctx
54
+ }
55
+
56
+ export interface FilterState<S extends FilterSchema> {
57
+ values: FilterValues<S>
58
+ /** Any field diverges from its default (⇒ show a "Clear all" affordance). */
59
+ isActive: boolean
60
+ /** Add/remove one value in a 'set' field. */
61
+ toggle: (key: FieldKeysOfType<S, 'set'>, value: string) => void
62
+ /** Replace a 'set' field wholesale. */
63
+ setSet: (key: FieldKeysOfType<S, 'set'>, values: string[]) => void
64
+ /** Set a 'text' or 'single' field. 'text' replaces history; 'single' pushes it. */
65
+ setString: (key: FieldKeysOfType<S, 'text' | 'single'>, value: string) => void
66
+ setBoolean: (key: FieldKeysOfType<S, 'boolean'>, value: boolean) => void
67
+ /** Reset one field to its default. */
68
+ clear: (key: keyof S) => void
69
+ /** Reset every field in the schema (leaves unrelated params untouched). */
70
+ clearAll: () => void
71
+ }
72
+
73
+ export function useFilterState<S extends FilterSchema>(schema: S): FilterState<S> {
74
+ const ctx = useContext(FilterLocationContext)
75
+
76
+ // Graceful fallback: without a provider, keep filter state locally (still
77
+ // works, just not URL-synced). This lets a shared view adopt the contract
78
+ // before every host has wired a FilterLocationBridge — a host that hasn't
79
+ // (e.g. Radar Hub, until it picks up a published build) keeps its prior local
80
+ // behavior instead of crashing. A one-time warning flags the missing wiring.
81
+ const [localParams, setLocalParams] = useState(() => new URLSearchParams())
82
+ const warned = useRef(false)
83
+ useEffect(() => {
84
+ if (!ctx && !warned.current) {
85
+ warned.current = true
86
+ console.warn('[k8s-ui] useFilterState: no <FilterLocationProvider> ancestor — filters are local, not URL-synced.')
87
+ }
88
+ }, [ctx])
89
+ const fallback = useMemo<FilterLocation>(
90
+ () => ({ searchParams: localParams, update: (u) => setLocalParams((prev) => u(new URLSearchParams(prev))) }),
91
+ [localParams],
92
+ )
93
+ const { searchParams, update } = ctx ?? fallback
94
+
95
+ // Depend on the serialized string, not the URLSearchParams object identity, so
96
+ // a router adapter that returns a mutated/unstable instance can't stale the memo.
97
+ const paramsKey = searchParams.toString()
98
+ const values = useMemo(() => decodeFilters(schema, new URLSearchParams(paramsKey)), [schema, paramsKey])
99
+ const isActive = useMemo(() => isFilterActive(schema, values), [schema, values])
100
+
101
+ const toggle = useCallback(
102
+ (key: FieldKeysOfType<S, 'set'>, value: string) => update((prev) => withToggle(schema, prev, key, value)),
103
+ [schema, update],
104
+ )
105
+ const setSet = useCallback(
106
+ (key: FieldKeysOfType<S, 'set'>, vals: string[]) => update((prev) => withField(schema, prev, key, new Set(vals))),
107
+ [schema, update],
108
+ )
109
+ const setString = useCallback(
110
+ (key: FieldKeysOfType<S, 'text' | 'single'>, v: string) =>
111
+ // Typing into a search box shouldn't spam history; picking an enum should be
112
+ // a real back-step. 'text' replaces, 'single' pushes.
113
+ update((prev) => withField(schema, prev, key, v), { replace: schema[key].type === 'text' }),
114
+ [schema, update],
115
+ )
116
+ const setBoolean = useCallback(
117
+ (key: FieldKeysOfType<S, 'boolean'>, v: boolean) => update((prev) => withField(schema, prev, key, v)),
118
+ [schema, update],
119
+ )
120
+ const clear = useCallback(
121
+ (key: keyof S) => update((prev) => withField(schema, prev, key, emptyValue(schema[key]))),
122
+ [schema, update],
123
+ )
124
+ const clearAll = useCallback(() => update((prev) => withCleared(schema, prev)), [schema, update])
125
+
126
+ return { values, isActive, toggle, setSet, setString, setBoolean, clear, clearAll }
127
+ }
@@ -0,0 +1,16 @@
1
+ export {
2
+ FilterLocationProvider,
3
+ useFilterLocation,
4
+ useFilterState,
5
+ } from './filter-state'
6
+ export type { FilterLocation, FilterState } from './filter-state'
7
+ export {
8
+ defineFilterSchema,
9
+ decodeFilters,
10
+ withField,
11
+ withToggle,
12
+ withCleared,
13
+ emptyValue,
14
+ isFilterActive,
15
+ } from './filter-state-core'
16
+ export type { FilterFieldDef, FilterSchema, FilterValues, FieldKeysOfType } from './filter-state-core'
package/src/index.ts CHANGED
@@ -61,6 +61,10 @@ export * from './components/namespace-switcher'
61
61
  // segments into one unit (OSS header + Radar Hub cluster top bar)
62
62
  export * from './components/scope-pill'
63
63
 
64
+ // Filter-state contract — shared URL-synced filter state for list views (OSS +
65
+ // Hub), router-agnostic via an injected FilterLocation adapter
66
+ export * from './filter-state'
67
+
64
68
  // Applications (shared host-agnostic list + detail shell for the deployable-
65
69
  // software surface; OSS renders single-cluster, Cloud adds the fleet layer)
66
70
  export * from './components/applications'