@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 +1 -1
- package/src/components/applications/ApplicationsView.tsx +33 -29
- package/src/components/checks/ChecksView.tsx +25 -14
- package/src/components/gitops/GitOpsTableView.tsx +117 -43
- package/src/components/ui/SummaryTile.tsx +9 -1
- package/src/filter-state/filter-state-core.test.ts +98 -0
- package/src/filter-state/filter-state-core.ts +138 -0
- package/src/filter-state/filter-state.tsx +127 -0
- package/src/filter-state/index.ts +16 -0
- package/src/index.ts +4 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
const
|
|
111
|
-
const
|
|
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(
|
|
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 =
|
|
285
|
+
const anyFilterActive = filters.isActive
|
|
276
286
|
const clearAllFilters = () => {
|
|
277
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
332
|
-
<Facet icon={Globe} title="Environment" info={<EnvHint />} options={envOptions} selected={fEnv} onToggle={(v) => toggle(
|
|
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(
|
|
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) =>
|
|
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={
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 =
|
|
226
|
+
const hasFilters = filters.isActive || clusterFilter.size > 0
|
|
213
227
|
const clearAll = () => {
|
|
214
|
-
|
|
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) =>
|
|
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={() =>
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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: () =>
|
|
556
|
-
clear: () =>
|
|
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: () =>
|
|
565
|
-
clear: () =>
|
|
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: () =>
|
|
574
|
-
clear: () =>
|
|
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: () =>
|
|
583
|
-
clear: () =>
|
|
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) =>
|
|
651
|
+
onToggleSync={(value) => filters.toggle('sync', value)}
|
|
641
652
|
healthCounts={healthCounts}
|
|
642
653
|
healthFilters={healthFilters}
|
|
643
|
-
onToggleHealth={(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={
|
|
659
|
+
onLifecycleFilterChange={(v) => filters.setString('lifecycle', v)}
|
|
649
660
|
terminatingCount={terminatingCount}
|
|
650
661
|
projects={projects}
|
|
651
662
|
projectFilters={projectFilters}
|
|
652
|
-
onToggleProject={(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) =>
|
|
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) =>
|
|
702
|
-
onClear={() =>
|
|
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
|
-
) :
|
|
758
|
-
<
|
|
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$ =
|
|
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'
|