@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.
- package/package.json +1 -1
- package/src/components/audit/AuditFindingsTable.tsx +7 -0
- package/src/components/checks/ChecksView.tsx +899 -0
- package/src/components/checks/checks.test.ts +40 -0
- package/src/components/checks/index.ts +3 -0
- package/src/components/checks/severity.ts +63 -0
- package/src/components/checks/types.ts +138 -0
- package/src/components/gitops/GitOpsTableView.tsx +231 -28
- package/src/components/resources/ResourcesView.tsx +61 -0
- package/src/index.ts +5 -0
|
@@ -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,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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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({
|
|
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
|
-
<
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
|