@skyhook-io/k8s-ui 1.7.2 → 1.7.4

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.
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { resourceKey, resourceRefKey, checkFindingKey, mapRadarSeverity, SOURCE_RADAR_BUILTIN, type CheckResourceRef } from './types'
3
+
4
+ describe('resourceKey', () => {
5
+ // Same kind/ns/name across two API groups must not collide. Mirrors
6
+ // radar/pkg/audit helpers_test.go's group-aware key.
7
+ it('disambiguates the same kind/ns/name across API groups', () => {
8
+ expect(resourceKey('', 'Service', 'prod', 'api')).not.toBe(resourceKey('serving.knative.dev', 'Service', 'prod', 'api'))
9
+ })
10
+ })
11
+
12
+ describe('checkFindingKey', () => {
13
+ const ref = (cluster_id: string): CheckResourceRef => ({ cluster_id, group: 'apps', kind: 'Deployment', namespace: 'prod', name: 'api' })
14
+
15
+ // The same resource identity + check on two clusters must produce distinct
16
+ // keys — even when the clusters' display names collapse to the same label.
17
+ it('disambiguates identical findings across cluster IDs', () => {
18
+ const a = checkFindingKey('cl_aaa', SOURCE_RADAR_BUILTIN, resourceRefKey(ref('cl_aaa')), 'run-as-root')
19
+ const b = checkFindingKey('cl_bbb', SOURCE_RADAR_BUILTIN, resourceRefKey(ref('cl_bbb')), 'run-as-root')
20
+ expect(a).not.toBe(b)
21
+ })
22
+
23
+ it('disambiguates by the optional detail discriminator', () => {
24
+ const rk = resourceRefKey(ref('cl_aaa'))
25
+ const noDetail = checkFindingKey('cl_aaa', SOURCE_RADAR_BUILTIN, rk, 'container-checks')
26
+ const a = checkFindingKey('cl_aaa', SOURCE_RADAR_BUILTIN, rk, 'container-checks', 'sidecar')
27
+ const b = checkFindingKey('cl_aaa', SOURCE_RADAR_BUILTIN, rk, 'container-checks', 'app')
28
+ expect(new Set([noDetail, a, b]).size).toBe(3)
29
+ })
30
+ })
31
+
32
+ describe('mapRadarSeverity', () => {
33
+ it('maps danger→high and warning→medium', () => {
34
+ expect(mapRadarSeverity('danger')).toBe('high')
35
+ expect(mapRadarSeverity('warning')).toBe('medium')
36
+ })
37
+ it('falls back to medium for an unrecognized severity', () => {
38
+ expect(mapRadarSeverity('unknown')).toBe('medium')
39
+ })
40
+ })
@@ -0,0 +1,3 @@
1
+ export { ChecksView, type ChecksViewProps } from './ChecksView'
2
+ export * from './types'
3
+ export * from './severity'
@@ -0,0 +1,63 @@
1
+ import type { CheckSeverity } from './types'
2
+
3
+ // The visual language for the 4-tier Checks severity ladder. Pale-pastel tints
4
+ // with hand-rolled dark variants (no theme token covers these). One hue per
5
+ // tier: red=critical, orange=high, amber=medium, slate=low — read the queue's
6
+ // left rail top-to-bottom and severity is obvious without reading a word.
7
+ //
8
+ // Class strings are literal so each consuming app's Tailwind @source scan emits
9
+ // them.
10
+
11
+ export const SEVERITY_LABEL: Record<CheckSeverity, string> = {
12
+ critical: 'Critical',
13
+ high: 'High',
14
+ medium: 'Medium',
15
+ low: 'Low',
16
+ }
17
+
18
+ // Pill badge — the loud, explicit severity signal on rows + drawer header.
19
+ export const SEVERITY_BADGE_CLASS: Record<CheckSeverity, string> = {
20
+ critical: 'bg-red-50 text-red-700 ring-1 ring-red-200 dark:bg-red-950/50 dark:text-red-300 dark:ring-red-900',
21
+ high: 'bg-orange-50 text-orange-700 ring-1 ring-orange-200 dark:bg-orange-950/50 dark:text-orange-300 dark:ring-orange-900',
22
+ medium: 'bg-amber-50 text-amber-700 ring-1 ring-amber-200 dark:bg-amber-950/50 dark:text-amber-300 dark:ring-amber-900',
23
+ low: 'bg-slate-100 text-slate-600 ring-1 ring-slate-200 dark:bg-slate-800/60 dark:text-slate-300 dark:ring-slate-700',
24
+ }
25
+
26
+ // Solid fill — dots + the proportional distribution bar segments.
27
+ export const SEVERITY_FILL_CLASS: Record<CheckSeverity, string> = {
28
+ critical: 'bg-red-500',
29
+ high: 'bg-orange-500',
30
+ medium: 'bg-amber-500',
31
+ low: 'bg-slate-400',
32
+ }
33
+
34
+ export const SEVERITY_TEXT_CLASS: Record<CheckSeverity, string> = {
35
+ critical: 'text-red-600 dark:text-red-400',
36
+ high: 'text-orange-600 dark:text-orange-400',
37
+ medium: 'text-amber-600 dark:text-amber-400',
38
+ low: 'text-slate-500 dark:text-slate-400',
39
+ }
40
+
41
+ // Left accent rail on a queue row — the scan-down severity cue. Pairs a colored
42
+ // 2px border with a faint severity-tinted background that deepens on hover.
43
+ export const SEVERITY_RAIL_CLASS: Record<CheckSeverity, string> = {
44
+ critical: 'border-l-red-500 hover:bg-red-50/40 dark:hover:bg-red-950/20',
45
+ high: 'border-l-orange-500 hover:bg-orange-50/40 dark:hover:bg-orange-950/20',
46
+ medium: 'border-l-amber-500 hover:bg-amber-50/30 dark:hover:bg-amber-950/15',
47
+ low: 'border-l-slate-300 dark:border-l-slate-600 hover:bg-theme-hover/40',
48
+ }
49
+
50
+ // Category accent — a quiet tag (severity is the loud one). Security is the
51
+ // headline beat, so it gets the most distinct hue.
52
+ const CATEGORY_BADGE_CLASS: Record<string, string> = {
53
+ Security: 'bg-violet-50 text-violet-700 ring-1 ring-violet-200 dark:bg-violet-950/40 dark:text-violet-300 dark:ring-violet-900',
54
+ Reliability: 'bg-sky-50 text-sky-700 ring-1 ring-sky-200 dark:bg-sky-950/40 dark:text-sky-300 dark:ring-sky-900',
55
+ Efficiency: 'bg-teal-50 text-teal-700 ring-1 ring-teal-200 dark:bg-teal-950/40 dark:text-teal-300 dark:ring-teal-900',
56
+ }
57
+
58
+ export function categoryBadgeClass(category: string): string {
59
+ return (
60
+ CATEGORY_BADGE_CLASS[category] ??
61
+ 'bg-theme-elevated text-theme-text-secondary ring-1 ring-theme-border'
62
+ )
63
+ }
@@ -0,0 +1,138 @@
1
+ // Shared Checks identity contract + data shapes + severity vocabulary.
2
+ //
3
+ // k8s-ui owns these because the Checks queue presentation (ChecksView) is
4
+ // host-agnostic: Radar Hub feeds it fleet-resolved data, and OSS Radar can feed
5
+ // a single-cluster ("fleet of one") resolve. Hosts map their wire payloads onto
6
+ // these types; the components render against them.
7
+ //
8
+ // Mirrors Radar OSS's resource-key convention (radar/pkg/audit.ResourceKey).
9
+
10
+ /** Canonical Checks severity ladder — distinct from the raw detector severity
11
+ * (danger/warning) so operational criticality and compliance risk stay
12
+ * separate axes. */
13
+ export type CheckSeverity = 'critical' | 'high' | 'medium' | 'low';
14
+
15
+ /** Raw detector severity Radar emits. */
16
+ export type RadarSeverity = 'danger' | 'warning';
17
+
18
+ /** Ordered worst→least, for rendering severity filters/sorts consistently. */
19
+ export const CHECK_SEVERITIES: CheckSeverity[] = ['critical', 'high', 'medium', 'low'];
20
+
21
+ /** Worst-first ordering rank for the ladder. */
22
+ export const CHECK_SEVERITY_RANK: Record<CheckSeverity, number> = {
23
+ critical: 4,
24
+ high: 3,
25
+ medium: 2,
26
+ low: 1,
27
+ };
28
+
29
+ export function isCheckSeverity(s: string): s is CheckSeverity {
30
+ return s === 'critical' || s === 'high' || s === 'medium' || s === 'low';
31
+ }
32
+
33
+ /** mapRadarSeverity maps a raw detector severity to the Checks ladder
34
+ * (danger→high, warning→medium). critical/low are only reachable via an org
35
+ * severity override; the detector never emits them. */
36
+ export function mapRadarSeverity(raw: RadarSeverity | string): CheckSeverity {
37
+ switch (raw) {
38
+ case 'danger':
39
+ return 'high';
40
+ case 'warning':
41
+ return 'medium';
42
+ default:
43
+ return 'medium';
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Canonical resource identity. `group` is '' for the core API group;
49
+ * `namespace` is '' for cluster-scoped resources. Both are always present.
50
+ * `cluster_id` scopes the ref to its source cluster — the disambiguator when
51
+ * two clusters' display names collapse to the same label.
52
+ */
53
+ export interface CheckResourceRef {
54
+ cluster_id: string;
55
+ group: string;
56
+ kind: string;
57
+ namespace: string;
58
+ name: string;
59
+ }
60
+
61
+ /** Built-in (Radar-detected) finding source. The only V1 source. */
62
+ export const SOURCE_RADAR_BUILTIN = 'radar_builtin';
63
+
64
+ /**
65
+ * resourceKey mirrors Go `audit.ResourceKey(group, kind, namespace, name)`:
66
+ * `group|Kind|namespace|name`. Group first because group and namespace can each
67
+ * independently be empty; `|` is delimiter-safe (K8s API groups follow
68
+ * DNS-subdomain rules and can't contain it).
69
+ */
70
+ export function resourceKey(group: string, kind: string, namespace: string, name: string): string {
71
+ return `${group}|${kind}|${namespace}|${name}`;
72
+ }
73
+
74
+ export function resourceRefKey(ref: CheckResourceRef): string {
75
+ return resourceKey(ref.group, ref.kind, ref.namespace, ref.name);
76
+ }
77
+
78
+ /**
79
+ * checkFindingKey is the canonical built-in finding key:
80
+ * `cluster_id + source + resourceKey + checkID + optional detail`. cluster_id is
81
+ * part of the key so identical resources across two clusters never collide,
82
+ * even when display names render the same.
83
+ */
84
+ export function checkFindingKey(
85
+ clusterId: string,
86
+ source: string,
87
+ resKey: string,
88
+ checkID: string,
89
+ detail?: string,
90
+ ): string {
91
+ const base = `${clusterId} ${source} ${resKey} ${checkID}`;
92
+ return detail ? `${base} ${detail}` : base;
93
+ }
94
+
95
+ /** Explains how org config shaped a finding. */
96
+ export interface EffectiveFindingState {
97
+ visibility: 'visible' | 'hidden';
98
+ source: 'detector_default' | 'org_config';
99
+ scoreImpact: 'counts' | 'excluded';
100
+ alertImpact: 'alerts' | 'muted';
101
+ complianceImpact: 'counts' | 'excluded_by_config';
102
+ reason?: string;
103
+ }
104
+
105
+ export interface EffectiveCheckFinding {
106
+ source: 'radar_builtin';
107
+ resource: CheckResourceRef;
108
+ checkID: string;
109
+ category: string;
110
+ originalSeverity: RadarSeverity;
111
+ effectiveSeverity: CheckSeverity;
112
+ message: string;
113
+ state: EffectiveFindingState;
114
+ }
115
+
116
+ /**
117
+ * A failing check, rolled up across every resource that fails it — one row of
118
+ * the remediation queue. `subject` is the most-severe representative resource;
119
+ * `findings` holds the per-resource detail underneath. (Distinct from CheckMeta,
120
+ * the check's static definition.)
121
+ */
122
+ export interface Check {
123
+ id: string;
124
+ source: 'radar_builtin';
125
+ subject: CheckResourceRef;
126
+ checkID: string;
127
+ category: string;
128
+ effectiveSeverity: CheckSeverity;
129
+ title: string;
130
+ message: string;
131
+ affectedFindings: number;
132
+ affectedResources: number;
133
+ representativeFinding: EffectiveCheckFinding;
134
+ findings: EffectiveCheckFinding[];
135
+ /** Source cluster's environment label (e.g. "prod"), shown as a context tag.
136
+ * Empty for OSS single-cluster and unlabeled clusters. */
137
+ environment?: string;
138
+ }
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode } from 'react'
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode } from 'react'
2
2
  import { clsx } from 'clsx'
3
3
  import {
4
4
  AlertTriangle,
@@ -11,6 +11,7 @@ import {
11
11
  List,
12
12
  Loader2,
13
13
  RefreshCw,
14
+ RotateCcw,
14
15
  Search,
15
16
  Tag,
16
17
  Trash2,
@@ -117,6 +118,17 @@ export interface GitOpsRow {
117
118
 
118
119
  export type DestinationFilter = 'all' | 'this-cluster' | 'cross-cluster' | 'unmatched'
119
120
 
121
+ type SummaryTone = 'neutral' | 'warning' | 'error' | 'info'
122
+
123
+ interface SummaryTileSpec {
124
+ key: string
125
+ label: string
126
+ value: number
127
+ tone: SummaryTone
128
+ active: boolean
129
+ apply?: () => void
130
+ }
131
+
120
132
  // ----- Component props -------------------------------------------------------
121
133
 
122
134
  export interface GitOpsTableViewProps {
@@ -153,6 +165,18 @@ export interface GitOpsTableViewProps {
153
165
  // detected" copy. Hub passes "No GitOps resources across the fleet".
154
166
  emptyStateTitle?: string
155
167
  emptyStateBody?: string
168
+ /**
169
+ * Global namespace pick from the host's NamespaceSwitcher. Used to
170
+ * surface "viewing in namespace: X" context and to power the Clear
171
+ * filters affordance when no rows match. Host owns the state; shared
172
+ * component is read-only.
173
+ */
174
+ globalNamespaces?: string[]
175
+ /**
176
+ * Resets the global namespace pick. When wired, the "Clear filters"
177
+ * button drops it alongside view-local filter state.
178
+ */
179
+ onClearNamespaces?: () => void
156
180
  }
157
181
 
158
182
  // ----- Main component --------------------------------------------------------
@@ -172,6 +196,8 @@ export function GitOpsTableView({
172
196
  searchHotkey,
173
197
  emptyStateTitle,
174
198
  emptyStateBody,
199
+ globalNamespaces,
200
+ onClearNamespaces,
175
201
  }: GitOpsTableViewProps) {
176
202
  const searchInputRef = useRef<HTMLInputElement>(null)
177
203
  const [mode, setMode] = useState<GitOpsMode>('applications')
@@ -186,8 +212,37 @@ export function GitOpsTableView({
186
212
  const [labelSearch, setLabelSearch] = useState('')
187
213
  const [automationFilter, setAutomationFilter] = useState<'all' | 'auto' | 'manual' | 'suspended'>('all')
188
214
  const [lifecycleFilter, setLifecycleFilter] = useState<'all' | 'terminating' | 'active'>('all')
215
+ const [reconcilingOnly, setReconcilingOnly] = useState(false)
189
216
  const [sortKey, setSortKey] = useState<SortKey>('health')
190
217
 
218
+ const hasLocalFilters =
219
+ !!search ||
220
+ syncFilters.size > 0 ||
221
+ healthFilters.size > 0 ||
222
+ projectFilters.size > 0 ||
223
+ namespaceFilters.size > 0 ||
224
+ labelFilters.size > 0 ||
225
+ automationFilter !== 'all' ||
226
+ lifecycleFilter !== 'all'
227
+ const hasGlobalNamespaceFilter = !!onClearNamespaces && (globalNamespaces?.length ?? 0) > 0
228
+ const hasAnyFilter = hasLocalFilters || hasGlobalNamespaceFilter
229
+
230
+ const clearLocalFilters = () => {
231
+ setSearch('')
232
+ setSyncFilters(new Set())
233
+ setHealthFilters(new Set())
234
+ setProjectFilters(new Set())
235
+ setNamespaceFilters(new Set())
236
+ setLabelFilters(new Set())
237
+ setAutomationFilter('all')
238
+ setLifecycleFilter('all')
239
+ }
240
+
241
+ const clearAllFilters = () => {
242
+ clearLocalFilters()
243
+ onClearNamespaces?.()
244
+ }
245
+
191
246
  // Optional '/' keyboard shortcut to focus search. Avoided as a default to
192
247
  // not collide with other surfaces' keyboard maps; OSS opts in via prop.
193
248
  useEffect(() => {
@@ -260,6 +315,7 @@ export function GitOpsTableView({
260
315
  if (automationFilter === 'suspended' && !row.suspended) return false
261
316
  if (lifecycleFilter === 'terminating' && !row.terminating) return false
262
317
  if (lifecycleFilter === 'active' && row.terminating) return false
318
+ if (reconcilingOnly && row.sync !== 'Reconciling' && row.health !== 'Progressing') return false
263
319
  if (destinationFilter && destinationFilter !== 'all') {
264
320
  const match = row._destination?.match
265
321
  if (destinationFilter === 'this-cluster' && match !== 'in_cluster') return false
@@ -273,12 +329,59 @@ export function GitOpsTableView({
273
329
  return true
274
330
  })
275
331
  return [...rows].sort((a, b) => compareRows(a, b, sortKey))
276
- }, [allRows, automationFilter, healthFilters, labelFilters, lifecycleFilter, mode, namespaceFilters, projectFilters, search, sortKey, syncFilters, destinationFilter])
332
+ }, [allRows, automationFilter, healthFilters, labelFilters, lifecycleFilter, mode, namespaceFilters, projectFilters, search, sortKey, syncFilters, destinationFilter, reconcilingOnly])
277
333
 
278
334
  const terminatingCount = useMemo(() => allRows.filter((row) => row.terminating).length, [allRows])
279
335
 
336
+ const clearAllFilters = useCallback(() => {
337
+ setSearch('')
338
+ setSyncFilters(new Set())
339
+ setHealthFilters(new Set())
340
+ setProjectFilters(new Set())
341
+ setNamespaceFilters(new Set())
342
+ setLabelFilters(new Set())
343
+ setAutomationFilter('all')
344
+ setLifecycleFilter('all')
345
+ setReconcilingOnly(false)
346
+ onDestinationFilterChange?.('all')
347
+ }, [onDestinationFilterChange])
348
+
349
+ const noOtherFiltersActive = useCallback(
350
+ (
351
+ exclude: 'sync' | 'health' | 'automation' | 'destination' | 'reconciling' | null = null,
352
+ ) => {
353
+ if (search !== '') return false
354
+ if (exclude !== 'sync' && syncFilters.size > 0) return false
355
+ if (exclude !== 'health' && healthFilters.size > 0) return false
356
+ if (projectFilters.size > 0) return false
357
+ if (namespaceFilters.size > 0) return false
358
+ if (labelFilters.size > 0) return false
359
+ if (exclude !== 'automation' && automationFilter !== 'all') return false
360
+ if (lifecycleFilter !== 'all') return false
361
+ if (exclude !== 'destination' && destinationFilter && destinationFilter !== 'all') return false
362
+ if (exclude !== 'reconciling' && reconcilingOnly) return false
363
+ return true
364
+ },
365
+ [
366
+ search,
367
+ syncFilters,
368
+ healthFilters,
369
+ projectFilters,
370
+ namespaceFilters,
371
+ labelFilters,
372
+ automationFilter,
373
+ lifecycleFilter,
374
+ destinationFilter,
375
+ reconcilingOnly,
376
+ ],
377
+ )
378
+
280
379
  // Empty-state — when there's truly nothing to show across all kinds.
281
- if (totalGitOps === 0 && !loading) {
380
+ // `counts` is server-filtered by the global namespace pick, so a
381
+ // namespace-scoped zero is NOT the same as cluster-empty. Fall through
382
+ // to the actionable empty state below when the host owns a namespace
383
+ // pick we can clear; otherwise the user lands here with no escape hatch.
384
+ if (totalGitOps === 0 && !loading && !hasGlobalNamespaceFilter) {
282
385
  return (
283
386
  <div className="flex h-full min-h-0 flex-1 items-center justify-center bg-theme-base p-4">
284
387
  <div className="rounded-lg border border-theme-border bg-theme-surface p-8 text-center">
@@ -296,6 +399,62 @@ export function GitOpsTableView({
296
399
 
297
400
  const showCrossClusterTile = typeof crossClusterCount === 'number' && mode === 'applications'
298
401
 
402
+ const summaryTiles: SummaryTileSpec[] = [
403
+ {
404
+ key: 'total',
405
+ label: 'Total Applications',
406
+ value: allRows.length,
407
+ tone: 'neutral',
408
+ active: noOtherFiltersActive(),
409
+ },
410
+ {
411
+ key: 'outOfSync',
412
+ label: 'Out of sync',
413
+ value: statusSummary.outOfSync,
414
+ tone: 'warning',
415
+ active:
416
+ syncFilters.size === 1 && syncFilters.has('OutOfSync') && noOtherFiltersActive('sync'),
417
+ apply: () => setSyncFilters(new Set(['OutOfSync'])),
418
+ },
419
+ {
420
+ key: 'degraded',
421
+ label: 'Degraded',
422
+ value: statusSummary.degraded,
423
+ tone: 'error',
424
+ active:
425
+ healthFilters.size === 1 && healthFilters.has('Degraded') && noOtherFiltersActive('health'),
426
+ apply: () => setHealthFilters(new Set(['Degraded'])),
427
+ },
428
+ {
429
+ key: 'suspended',
430
+ label: 'Suspended',
431
+ value: statusSummary.suspended,
432
+ tone: 'warning',
433
+ active: automationFilter === 'suspended' && noOtherFiltersActive('automation'),
434
+ apply: () => setAutomationFilter('suspended'),
435
+ },
436
+ {
437
+ key: 'reconciling',
438
+ label: 'Reconciling',
439
+ value: statusSummary.reconciling,
440
+ tone: 'info',
441
+ active: reconcilingOnly && noOtherFiltersActive('reconciling'),
442
+ apply: () => setReconcilingOnly(true),
443
+ },
444
+ ...(showCrossClusterTile
445
+ ? [
446
+ {
447
+ key: 'crossCluster',
448
+ label: 'Cross-cluster',
449
+ value: crossClusterCount!,
450
+ tone: 'info' as const,
451
+ active: destinationFilter === 'cross-cluster' && noOtherFiltersActive('destination'),
452
+ apply: () => onDestinationFilterChange?.('cross-cluster'),
453
+ },
454
+ ]
455
+ : []),
456
+ ]
457
+
299
458
  return (
300
459
  <div className="flex h-full min-w-0 flex-1 overflow-hidden bg-theme-base max-lg:flex-col">
301
460
  <GitOpsFilterSidebar
@@ -319,16 +478,7 @@ export function GitOpsTableView({
319
478
  namespaces={rowNamespaces}
320
479
  namespaceFilters={namespaceFilters}
321
480
  onToggleNamespace={(value) => toggleSet(namespaceFilters, setNamespaceFilters, value)}
322
- onClear={() => {
323
- setSearch('')
324
- setSyncFilters(new Set())
325
- setHealthFilters(new Set())
326
- setProjectFilters(new Set())
327
- setNamespaceFilters(new Set())
328
- setLabelFilters(new Set())
329
- setAutomationFilter('all')
330
- setLifecycleFilter('all')
331
- }}
481
+ onClear={clearAllFilters}
332
482
  />
333
483
 
334
484
  <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
@@ -341,14 +491,19 @@ export function GitOpsTableView({
341
491
  </p>
342
492
  </div>
343
493
  <div className="flex shrink-0 flex-wrap justify-end gap-2">
344
- <SummaryTile label="Applications" value={allRows.length} />
345
- <SummaryTile label="Out of sync" value={statusSummary.outOfSync} tone="warning" />
346
- <SummaryTile label="Degraded" value={statusSummary.degraded} tone="error" />
347
- <SummaryTile label="Suspended" value={statusSummary.suspended} tone="warning" />
348
- <SummaryTile label="Reconciling" value={statusSummary.reconciling} tone="info" />
349
- {showCrossClusterTile && (
350
- <SummaryTile label="Cross-cluster" value={crossClusterCount!} tone="info" />
351
- )}
494
+ {summaryTiles.map((tile) => (
495
+ <SummaryTile
496
+ key={tile.key}
497
+ label={tile.label}
498
+ value={tile.value}
499
+ tone={tile.tone}
500
+ active={tile.active}
501
+ onClick={() => {
502
+ clearAllFilters()
503
+ if (!tile.active && tile.apply) tile.apply()
504
+ }}
505
+ />
506
+ ))}
352
507
  </div>
353
508
  </div>
354
509
  </div>
@@ -426,6 +581,18 @@ export function GitOpsTableView({
426
581
  ))}
427
582
  </div>
428
583
  )}
584
+ {hasAnyFilter && (
585
+ <Tooltip content={hasGlobalNamespaceFilter ? 'Reset all filters and the active namespace' : 'Reset all filters'}>
586
+ <button
587
+ type="button"
588
+ onClick={clearAllFilters}
589
+ className="inline-flex h-8 items-center gap-1.5 rounded-md border border-theme-border bg-theme-base px-2.5 text-xs text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
590
+ >
591
+ <RotateCcw className="h-3.5 w-3.5" />
592
+ Clear filters
593
+ </button>
594
+ </Tooltip>
595
+ )}
429
596
  <div className="flex shrink-0 items-center gap-0 overflow-hidden rounded-md border border-theme-border">
430
597
  <GitOpsIconToggle active={viewMode === 'table'} label="Table view" icon={List} onClick={() => setViewMode('table')} />
431
598
  <GitOpsIconToggle active={viewMode === 'tiles'} label="Tiles view" icon={LayoutGrid} onClick={() => setViewMode('tiles')} />
@@ -456,8 +623,23 @@ export function GitOpsTableView({
456
623
  ) : error ? (
457
624
  <div className="p-4 text-sm text-red-500">Failed to load GitOps applications: {error.message}</div>
458
625
  ) : filteredRows.length === 0 ? (
459
- <div className="flex h-full items-center justify-center text-sm text-theme-text-secondary">
460
- No applications match the current filters.
626
+ <div className="flex h-full flex-col items-center justify-center gap-3 text-sm text-theme-text-secondary">
627
+ <p>{allRows.length === 0 && !hasGlobalNamespaceFilter ? 'No applications found.' : 'No applications match the current filters.'}</p>
628
+ {hasGlobalNamespaceFilter && globalNamespaces && (
629
+ <p className="text-xs text-theme-text-tertiary">
630
+ Viewing {globalNamespaces.length === 1 ? `namespace: ${globalNamespaces[0]}` : `${globalNamespaces.length} namespaces`}
631
+ </p>
632
+ )}
633
+ {(hasGlobalNamespaceFilter || (allRows.length > 0 && hasLocalFilters)) && (
634
+ <button
635
+ type="button"
636
+ onClick={clearAllFilters}
637
+ className="inline-flex items-center gap-1.5 rounded-md bg-theme-elevated px-3 py-1.5 text-sm text-theme-text-secondary transition-colors hover:bg-theme-border hover:text-theme-text-primary"
638
+ >
639
+ <RotateCcw className="h-3.5 w-3.5" />
640
+ Clear filters
641
+ </button>
642
+ )}
461
643
  </div>
462
644
  ) : viewMode === 'tiles' ? (
463
645
  <GitOpsTiles rows={filteredRows} onOpen={onRowClick} />
@@ -1021,18 +1203,54 @@ function GitOpsTile({
1021
1203
  )
1022
1204
  }
1023
1205
 
1024
- function SummaryTile({ label, value, tone = 'neutral' }: { label: string; value: number; tone?: 'neutral' | 'warning' | 'error' | 'info' }) {
1206
+ function SummaryTile({
1207
+ label,
1208
+ value,
1209
+ tone = 'neutral',
1210
+ onClick,
1211
+ active = false,
1212
+ }: {
1213
+ label: string
1214
+ value: number
1215
+ tone?: SummaryTone
1216
+ onClick?: () => void
1217
+ active?: boolean
1218
+ }) {
1025
1219
  const toneClass = {
1026
1220
  neutral: 'text-theme-text-primary',
1027
1221
  warning: 'text-amber-600 dark:text-amber-300',
1028
1222
  error: 'text-red-600 dark:text-red-300',
1029
1223
  info: 'text-sky-600 dark:text-sky-300',
1030
1224
  }[tone]
1225
+ const activeBorderClass = {
1226
+ neutral: 'border-skyhook-500',
1227
+ warning: 'border-amber-500',
1228
+ error: 'border-red-500',
1229
+ info: 'border-sky-500',
1230
+ }[tone]
1231
+ const value$ = <div className={`text-sm font-semibold ${toneClass}`}>{value}</div>
1232
+ const label$ = <div className="text-xs text-theme-text-tertiary">{label}</div>
1233
+ if (!onClick) {
1234
+ return (
1235
+ <div className="rounded-md border border-theme-border bg-theme-base px-3 py-2">
1236
+ {value$}
1237
+ {label$}
1238
+ </div>
1239
+ )
1240
+ }
1031
1241
  return (
1032
- <div className="rounded-md border border-theme-border bg-theme-base px-3 py-2">
1033
- <div className={`text-sm font-semibold ${toneClass}`}>{value}</div>
1034
- <div className="text-xs text-theme-text-tertiary">{label}</div>
1035
- </div>
1242
+ <button
1243
+ type="button"
1244
+ onClick={onClick}
1245
+ aria-pressed={active}
1246
+ className={clsx(
1247
+ 'cursor-pointer rounded-md border bg-theme-base px-3 py-2 text-left transition-colors hover:bg-theme-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-skyhook-500',
1248
+ active ? activeBorderClass : 'border-theme-border',
1249
+ )}
1250
+ >
1251
+ {value$}
1252
+ {label$}
1253
+ </button>
1036
1254
  )
1037
1255
  }
1038
1256