@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.
- package/package.json +3 -3
- 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 +246 -28
- package/src/components/resources/ResourcesView.tsx +61 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +2 -5
- package/src/components/resources/renderers/PodRenderer.tsx +6 -7
- package/src/components/resources/renderers/RBACErrorSection.test.tsx +62 -0
- package/src/components/resources/renderers/RBACErrorSection.tsx +72 -0
- package/src/components/resources/renderers/RoleRenderer.tsx +2 -7
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +2 -7
- package/src/components/resources/renderers/WorkloadRenderer.tsx +6 -5
- package/src/components/ui/ClusterName.tsx +15 -7
- package/src/index.ts +5 -0
- package/src/utils/resource-icons.ts +1 -1
|
@@ -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,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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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({
|
|
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
|
-
<
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
|