@skyhook-io/k8s-ui 1.7.3 → 1.7.5

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,21 @@ 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
+
191
230
  // Optional '/' keyboard shortcut to focus search. Avoided as a default to
192
231
  // not collide with other surfaces' keyboard maps; OSS opts in via prop.
193
232
  useEffect(() => {
@@ -260,6 +299,7 @@ export function GitOpsTableView({
260
299
  if (automationFilter === 'suspended' && !row.suspended) return false
261
300
  if (lifecycleFilter === 'terminating' && !row.terminating) return false
262
301
  if (lifecycleFilter === 'active' && row.terminating) return false
302
+ if (reconcilingOnly && row.sync !== 'Reconciling' && row.health !== 'Progressing') return false
263
303
  if (destinationFilter && destinationFilter !== 'all') {
264
304
  const match = row._destination?.match
265
305
  if (destinationFilter === 'this-cluster' && match !== 'in_cluster') return false
@@ -273,12 +313,60 @@ export function GitOpsTableView({
273
313
  return true
274
314
  })
275
315
  return [...rows].sort((a, b) => compareRows(a, b, sortKey))
276
- }, [allRows, automationFilter, healthFilters, labelFilters, lifecycleFilter, mode, namespaceFilters, projectFilters, search, sortKey, syncFilters, destinationFilter])
316
+ }, [allRows, automationFilter, healthFilters, labelFilters, lifecycleFilter, mode, namespaceFilters, projectFilters, search, sortKey, syncFilters, destinationFilter, reconcilingOnly])
277
317
 
278
318
  const terminatingCount = useMemo(() => allRows.filter((row) => row.terminating).length, [allRows])
279
319
 
320
+ const clearAllFilters = useCallback(() => {
321
+ setSearch('')
322
+ setSyncFilters(new Set())
323
+ setHealthFilters(new Set())
324
+ setProjectFilters(new Set())
325
+ setNamespaceFilters(new Set())
326
+ setLabelFilters(new Set())
327
+ setAutomationFilter('all')
328
+ setLifecycleFilter('all')
329
+ setReconcilingOnly(false)
330
+ onClearNamespaces?.()
331
+ onDestinationFilterChange?.('all')
332
+ }, [onClearNamespaces, onDestinationFilterChange])
333
+
334
+ const noOtherFiltersActive = useCallback(
335
+ (
336
+ exclude: 'sync' | 'health' | 'automation' | 'destination' | 'reconciling' | null = null,
337
+ ) => {
338
+ if (search !== '') return false
339
+ if (exclude !== 'sync' && syncFilters.size > 0) return false
340
+ if (exclude !== 'health' && healthFilters.size > 0) return false
341
+ if (projectFilters.size > 0) return false
342
+ if (namespaceFilters.size > 0) return false
343
+ if (labelFilters.size > 0) return false
344
+ if (exclude !== 'automation' && automationFilter !== 'all') return false
345
+ if (lifecycleFilter !== 'all') return false
346
+ if (exclude !== 'destination' && destinationFilter && destinationFilter !== 'all') return false
347
+ if (exclude !== 'reconciling' && reconcilingOnly) return false
348
+ return true
349
+ },
350
+ [
351
+ search,
352
+ syncFilters,
353
+ healthFilters,
354
+ projectFilters,
355
+ namespaceFilters,
356
+ labelFilters,
357
+ automationFilter,
358
+ lifecycleFilter,
359
+ destinationFilter,
360
+ reconcilingOnly,
361
+ ],
362
+ )
363
+
280
364
  // Empty-state — when there's truly nothing to show across all kinds.
281
- if (totalGitOps === 0 && !loading) {
365
+ // `counts` is server-filtered by the global namespace pick, so a
366
+ // namespace-scoped zero is NOT the same as cluster-empty. Fall through
367
+ // to the actionable empty state below when the host owns a namespace
368
+ // pick we can clear; otherwise the user lands here with no escape hatch.
369
+ if (totalGitOps === 0 && !loading && !hasGlobalNamespaceFilter) {
282
370
  return (
283
371
  <div className="flex h-full min-h-0 flex-1 items-center justify-center bg-theme-base p-4">
284
372
  <div className="rounded-lg border border-theme-border bg-theme-surface p-8 text-center">
@@ -296,6 +384,62 @@ export function GitOpsTableView({
296
384
 
297
385
  const showCrossClusterTile = typeof crossClusterCount === 'number' && mode === 'applications'
298
386
 
387
+ const summaryTiles: SummaryTileSpec[] = [
388
+ {
389
+ key: 'total',
390
+ label: 'Total Applications',
391
+ value: allRows.length,
392
+ tone: 'neutral',
393
+ active: noOtherFiltersActive(),
394
+ },
395
+ {
396
+ key: 'outOfSync',
397
+ label: 'Out of sync',
398
+ value: statusSummary.outOfSync,
399
+ tone: 'warning',
400
+ active:
401
+ syncFilters.size === 1 && syncFilters.has('OutOfSync') && noOtherFiltersActive('sync'),
402
+ apply: () => setSyncFilters(new Set(['OutOfSync'])),
403
+ },
404
+ {
405
+ key: 'degraded',
406
+ label: 'Degraded',
407
+ value: statusSummary.degraded,
408
+ tone: 'error',
409
+ active:
410
+ healthFilters.size === 1 && healthFilters.has('Degraded') && noOtherFiltersActive('health'),
411
+ apply: () => setHealthFilters(new Set(['Degraded'])),
412
+ },
413
+ {
414
+ key: 'suspended',
415
+ label: 'Suspended',
416
+ value: statusSummary.suspended,
417
+ tone: 'warning',
418
+ active: automationFilter === 'suspended' && noOtherFiltersActive('automation'),
419
+ apply: () => setAutomationFilter('suspended'),
420
+ },
421
+ {
422
+ key: 'reconciling',
423
+ label: 'Reconciling',
424
+ value: statusSummary.reconciling,
425
+ tone: 'info',
426
+ active: reconcilingOnly && noOtherFiltersActive('reconciling'),
427
+ apply: () => setReconcilingOnly(true),
428
+ },
429
+ ...(showCrossClusterTile
430
+ ? [
431
+ {
432
+ key: 'crossCluster',
433
+ label: 'Cross-cluster',
434
+ value: crossClusterCount!,
435
+ tone: 'info' as const,
436
+ active: destinationFilter === 'cross-cluster' && noOtherFiltersActive('destination'),
437
+ apply: () => onDestinationFilterChange?.('cross-cluster'),
438
+ },
439
+ ]
440
+ : []),
441
+ ]
442
+
299
443
  return (
300
444
  <div className="flex h-full min-w-0 flex-1 overflow-hidden bg-theme-base max-lg:flex-col">
301
445
  <GitOpsFilterSidebar
@@ -319,16 +463,7 @@ export function GitOpsTableView({
319
463
  namespaces={rowNamespaces}
320
464
  namespaceFilters={namespaceFilters}
321
465
  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
- }}
466
+ onClear={clearAllFilters}
332
467
  />
333
468
 
334
469
  <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
@@ -341,14 +476,19 @@ export function GitOpsTableView({
341
476
  </p>
342
477
  </div>
343
478
  <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
- )}
479
+ {summaryTiles.map((tile) => (
480
+ <SummaryTile
481
+ key={tile.key}
482
+ label={tile.label}
483
+ value={tile.value}
484
+ tone={tile.tone}
485
+ active={tile.active}
486
+ onClick={() => {
487
+ clearAllFilters()
488
+ if (!tile.active && tile.apply) tile.apply()
489
+ }}
490
+ />
491
+ ))}
352
492
  </div>
353
493
  </div>
354
494
  </div>
@@ -426,6 +566,18 @@ export function GitOpsTableView({
426
566
  ))}
427
567
  </div>
428
568
  )}
569
+ {hasAnyFilter && (
570
+ <Tooltip content={hasGlobalNamespaceFilter ? 'Reset all filters and the active namespace' : 'Reset all filters'}>
571
+ <button
572
+ type="button"
573
+ onClick={clearAllFilters}
574
+ 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"
575
+ >
576
+ <RotateCcw className="h-3.5 w-3.5" />
577
+ Clear filters
578
+ </button>
579
+ </Tooltip>
580
+ )}
429
581
  <div className="flex shrink-0 items-center gap-0 overflow-hidden rounded-md border border-theme-border">
430
582
  <GitOpsIconToggle active={viewMode === 'table'} label="Table view" icon={List} onClick={() => setViewMode('table')} />
431
583
  <GitOpsIconToggle active={viewMode === 'tiles'} label="Tiles view" icon={LayoutGrid} onClick={() => setViewMode('tiles')} />
@@ -456,8 +608,23 @@ export function GitOpsTableView({
456
608
  ) : error ? (
457
609
  <div className="p-4 text-sm text-red-500">Failed to load GitOps applications: {error.message}</div>
458
610
  ) : 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.
611
+ <div className="flex h-full flex-col items-center justify-center gap-3 text-sm text-theme-text-secondary">
612
+ <p>{allRows.length === 0 && !hasGlobalNamespaceFilter ? 'No applications found.' : 'No applications match the current filters.'}</p>
613
+ {hasGlobalNamespaceFilter && globalNamespaces && (
614
+ <p className="text-xs text-theme-text-tertiary">
615
+ Viewing {globalNamespaces.length === 1 ? `namespace: ${globalNamespaces[0]}` : `${globalNamespaces.length} namespaces`}
616
+ </p>
617
+ )}
618
+ {(hasGlobalNamespaceFilter || (allRows.length > 0 && hasLocalFilters)) && (
619
+ <button
620
+ type="button"
621
+ onClick={clearAllFilters}
622
+ 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"
623
+ >
624
+ <RotateCcw className="h-3.5 w-3.5" />
625
+ Clear filters
626
+ </button>
627
+ )}
461
628
  </div>
462
629
  ) : viewMode === 'tiles' ? (
463
630
  <GitOpsTiles rows={filteredRows} onOpen={onRowClick} />
@@ -1021,18 +1188,54 @@ function GitOpsTile({
1021
1188
  )
1022
1189
  }
1023
1190
 
1024
- function SummaryTile({ label, value, tone = 'neutral' }: { label: string; value: number; tone?: 'neutral' | 'warning' | 'error' | 'info' }) {
1191
+ function SummaryTile({
1192
+ label,
1193
+ value,
1194
+ tone = 'neutral',
1195
+ onClick,
1196
+ active = false,
1197
+ }: {
1198
+ label: string
1199
+ value: number
1200
+ tone?: SummaryTone
1201
+ onClick?: () => void
1202
+ active?: boolean
1203
+ }) {
1025
1204
  const toneClass = {
1026
1205
  neutral: 'text-theme-text-primary',
1027
1206
  warning: 'text-amber-600 dark:text-amber-300',
1028
1207
  error: 'text-red-600 dark:text-red-300',
1029
1208
  info: 'text-sky-600 dark:text-sky-300',
1030
1209
  }[tone]
1210
+ const activeBorderClass = {
1211
+ neutral: 'border-skyhook-500',
1212
+ warning: 'border-amber-500',
1213
+ error: 'border-red-500',
1214
+ info: 'border-sky-500',
1215
+ }[tone]
1216
+ const value$ = <div className={`text-sm font-semibold ${toneClass}`}>{value}</div>
1217
+ const label$ = <div className="text-xs text-theme-text-tertiary">{label}</div>
1218
+ if (!onClick) {
1219
+ return (
1220
+ <div className="rounded-md border border-theme-border bg-theme-base px-3 py-2">
1221
+ {value$}
1222
+ {label$}
1223
+ </div>
1224
+ )
1225
+ }
1031
1226
  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>
1227
+ <button
1228
+ type="button"
1229
+ onClick={onClick}
1230
+ aria-pressed={active}
1231
+ className={clsx(
1232
+ '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',
1233
+ active ? activeBorderClass : 'border-theme-border',
1234
+ )}
1235
+ >
1236
+ {value$}
1237
+ {label$}
1238
+ </button>
1036
1239
  )
1037
1240
  }
1038
1241