@skyhook-io/k8s-ui 1.3.2 → 1.4.1
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 +2 -2
- package/src/components/audit/AuditAlerts.tsx +88 -0
- package/src/components/audit/AuditCard.tsx +130 -0
- package/src/components/audit/AuditFindingsTable.tsx +526 -0
- package/src/components/audit/index.ts +3 -0
- package/src/components/dock/DockContext.tsx +5 -2
- package/src/components/dock/LocalTerminalTab.tsx +11 -0
- package/src/components/resources/ResourcesView.tsx +126 -49
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +190 -28
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +22 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +156 -5
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +28 -1
- package/src/components/resources/renderers/CertificateRenderer.tsx +47 -8
- package/src/components/resources/renderers/ChallengeRenderer.tsx +22 -3
- package/src/components/resources/renderers/CiliumNetworkPolicyRenderer.tsx +445 -0
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +81 -9
- package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +10 -3
- package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +59 -1
- package/src/components/resources/renderers/ClusterNetworkPolicyRenderer.tsx +160 -0
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -5
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +91 -3
- package/src/components/resources/renderers/GenericRenderer.tsx +1 -1
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -42
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -41
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +66 -2
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +31 -1
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +66 -1
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -0
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +55 -1
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +23 -0
- package/src/components/resources/renderers/KustomizationRenderer.tsx +38 -3
- package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +277 -0
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +17 -57
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -42
- package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +3 -8
- package/src/components/resources/renderers/PodMonitorRenderer.tsx +5 -7
- package/src/components/resources/renderers/PodRenderer.tsx +22 -74
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +178 -20
- package/src/components/resources/renderers/ReplicaSetRenderer.tsx +2 -2
- package/src/components/resources/renderers/RolloutRenderer.tsx +105 -3
- package/src/components/resources/renderers/SbomReportRenderer.tsx +46 -5
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +11 -2
- package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +5 -7
- package/src/components/resources/renderers/ServiceRenderer.tsx +40 -28
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +11 -3
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +11 -3
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +8 -16
- package/src/components/resources/renderers/WorkflowRenderer.tsx +71 -17
- package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +46 -1
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/renderers/karpenter-cells.tsx +18 -3
- package/src/components/resources/resource-utils-cnpg.ts +47 -0
- package/src/components/resources/resource-utils-karpenter.ts +6 -0
- package/src/components/resources/resource-utils-prometheus.ts +50 -6
- package/src/components/resources/resource-utils.ts +131 -33
- package/src/components/shared/CreateResourceDialog.tsx +236 -0
- package/src/components/shared/EditableYamlView.tsx +16 -1
- package/src/components/shared/ResourceActionsBar.tsx +9 -7
- package/src/components/shared/ResourceRendererDispatch.tsx +13 -6
- package/src/components/shared/index.ts +1 -0
- package/src/components/timeline/TimelineList.tsx +11 -4
- package/src/components/topology/K8sResourceNode.tsx +15 -0
- package/src/components/topology/TopologyControls.tsx +21 -1
- package/src/components/topology/TopologyGraph.tsx +1 -1
- package/src/components/ui/Badge.tsx +8 -0
- package/src/components/ui/drawer-components.tsx +68 -5
- package/src/components/workload/WorkloadView.tsx +55 -29
- package/src/index.ts +3 -0
- package/src/theme/variables.css +1 -1
- package/src/types/core.ts +2 -1
- package/src/types/gitops.ts +8 -0
- package/src/utils/badge-colors.ts +7 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/k8s-errors.ts +142 -0
- package/src/utils/navigation.test.ts +29 -2
- package/src/utils/navigation.ts +45 -24
- package/src/utils/resource-icons.ts +3 -0
- package/src/utils/skeleton-yaml.ts +302 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
import { useState, useMemo, useRef, useEffect } from 'react'
|
|
2
|
+
import { ShieldAlert, AlertTriangle, ChevronRight, Search, ExternalLink, MoreHorizontal, EyeOff, Layers } from 'lucide-react'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
import type { AuditFinding } from './AuditAlerts'
|
|
5
|
+
import { SEVERITY_TEXT, BP_CATEGORY_BADGE, DEFAULT_BADGE_COLOR } from '../../utils/badge-colors'
|
|
6
|
+
|
|
7
|
+
const CATEGORIES = ['Security', 'Reliability', 'Efficiency'] as const
|
|
8
|
+
const SEVERITIES = ['danger', 'warning'] as const
|
|
9
|
+
|
|
10
|
+
export interface ResourceGroup {
|
|
11
|
+
kind: string
|
|
12
|
+
namespace: string
|
|
13
|
+
name: string
|
|
14
|
+
warning: number
|
|
15
|
+
danger: number
|
|
16
|
+
findings: AuditFinding[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CheckMeta {
|
|
20
|
+
id: string
|
|
21
|
+
title: string
|
|
22
|
+
description: string
|
|
23
|
+
remediation: string
|
|
24
|
+
frameworks?: string[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AuditFindingsTableProps {
|
|
28
|
+
groups?: ResourceGroup[]
|
|
29
|
+
findings?: AuditFinding[]
|
|
30
|
+
checks?: Record<string, CheckMeta>
|
|
31
|
+
onResourceClick?: (kind: string, namespace: string, name: string) => void
|
|
32
|
+
onHideCheck?: (checkID: string, title: string) => void
|
|
33
|
+
onHideCategory?: (category: string) => void
|
|
34
|
+
onHideNamespace?: (namespace: string) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function AuditFindingsTable({ groups, findings, checks, onResourceClick, onHideCheck, onHideCategory, onHideNamespace }: AuditFindingsTableProps) {
|
|
38
|
+
const [categoryFilter, setCategoryFilter] = useState<string | null>(null)
|
|
39
|
+
const [severityFilter, setSeverityFilter] = useState<string | null>(null)
|
|
40
|
+
const [frameworkFilter, setFrameworkFilter] = useState<string | null>(null)
|
|
41
|
+
const [searchTerm, setSearchTerm] = useState('')
|
|
42
|
+
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
|
43
|
+
const [expandedNS, setExpandedNS] = useState<Set<string>>(new Set())
|
|
44
|
+
const [groupByNS, setGroupByNS] = useState(false)
|
|
45
|
+
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
46
|
+
|
|
47
|
+
// "/" keyboard shortcut to focus search
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const handler = (e: KeyboardEvent) => {
|
|
50
|
+
if (e.key === '/' && !e.ctrlKey && !e.metaKey && document.activeElement?.tagName !== 'INPUT') {
|
|
51
|
+
e.preventDefault()
|
|
52
|
+
searchInputRef.current?.focus()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
document.addEventListener('keydown', handler)
|
|
56
|
+
return () => document.removeEventListener('keydown', handler)
|
|
57
|
+
}, [])
|
|
58
|
+
|
|
59
|
+
// Compute totals from whichever data source we have
|
|
60
|
+
const allFindings = useMemo(() => {
|
|
61
|
+
if (groups) return groups.flatMap(g => g.findings)
|
|
62
|
+
return findings ?? []
|
|
63
|
+
}, [groups, findings])
|
|
64
|
+
|
|
65
|
+
const totalDangerCount = allFindings.filter(f => f.severity === 'danger').length
|
|
66
|
+
const totalWarningCount = allFindings.filter(f => f.severity === 'warning').length
|
|
67
|
+
|
|
68
|
+
// Derive available frameworks from checks metadata
|
|
69
|
+
const frameworks = useMemo(() => {
|
|
70
|
+
if (!checks) return []
|
|
71
|
+
const set = new Set<string>()
|
|
72
|
+
Object.values(checks).forEach(c => c.frameworks?.forEach(f => set.add(f)))
|
|
73
|
+
return Array.from(set).sort()
|
|
74
|
+
}, [checks])
|
|
75
|
+
|
|
76
|
+
const searchLower = searchTerm.toLowerCase()
|
|
77
|
+
|
|
78
|
+
// Match a finding against category/severity/framework filters
|
|
79
|
+
const matchesFinding = (f: AuditFinding) => {
|
|
80
|
+
if (categoryFilter && f.category !== categoryFilter) return false
|
|
81
|
+
if (severityFilter && f.severity !== severityFilter) return false
|
|
82
|
+
if (frameworkFilter && checks) {
|
|
83
|
+
const meta = checks[f.checkID]
|
|
84
|
+
if (!meta?.frameworks?.includes(frameworkFilter)) return false
|
|
85
|
+
}
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Match a resource group against search term
|
|
90
|
+
const matchesSearch = (g: ResourceGroup) => {
|
|
91
|
+
if (!searchLower) return true
|
|
92
|
+
if (g.name.toLowerCase().includes(searchLower)) return true
|
|
93
|
+
if (g.namespace.toLowerCase().includes(searchLower)) return true
|
|
94
|
+
if (g.kind.toLowerCase().includes(searchLower)) return true
|
|
95
|
+
return g.findings.some(f => f.message.toLowerCase().includes(searchLower))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Filter groups: a group is visible if it matches search AND has findings matching filters
|
|
99
|
+
const filteredGroups = useMemo(() => {
|
|
100
|
+
if (!groups) return undefined
|
|
101
|
+
return groups
|
|
102
|
+
.filter(g => matchesSearch(g))
|
|
103
|
+
.map(g => {
|
|
104
|
+
const filtered = g.findings.filter(matchesFinding)
|
|
105
|
+
if (filtered.length === 0) return null
|
|
106
|
+
return { ...g, findings: filtered, danger: filtered.filter(f => f.severity === 'danger').length, warning: filtered.filter(f => f.severity === 'warning').length }
|
|
107
|
+
})
|
|
108
|
+
.filter((g): g is ResourceGroup => g !== null)
|
|
109
|
+
}, [groups, categoryFilter, severityFilter, frameworkFilter, searchLower]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
110
|
+
|
|
111
|
+
// Filter flat findings (fallback mode)
|
|
112
|
+
const filteredFindings = useMemo(() => {
|
|
113
|
+
if (groups) return undefined
|
|
114
|
+
return (findings ?? []).filter(f => {
|
|
115
|
+
if (!matchesFinding(f)) return false
|
|
116
|
+
if (searchLower && !f.message.toLowerCase().includes(searchLower) && !f.name.toLowerCase().includes(searchLower)) return false
|
|
117
|
+
return true
|
|
118
|
+
})
|
|
119
|
+
}, [groups, findings, categoryFilter, severityFilter, frameworkFilter, checks, searchLower]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
120
|
+
|
|
121
|
+
const toggle = (key: string) => {
|
|
122
|
+
setExpanded(prev => {
|
|
123
|
+
const next = new Set(prev)
|
|
124
|
+
if (next.has(key)) next.delete(key)
|
|
125
|
+
else next.add(key)
|
|
126
|
+
return next
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Compute counts from filtered results (so summary reflects active filters)
|
|
131
|
+
const hasActiveFilters = !!(categoryFilter || severityFilter || frameworkFilter || searchTerm)
|
|
132
|
+
const filteredAllFindings = filteredGroups
|
|
133
|
+
? filteredGroups.flatMap(g => g.findings)
|
|
134
|
+
: filteredFindings ?? []
|
|
135
|
+
const dangerCount = hasActiveFilters ? filteredAllFindings.filter(f => f.severity === 'danger').length : totalDangerCount
|
|
136
|
+
const warningCount = hasActiveFilters ? filteredAllFindings.filter(f => f.severity === 'warning').length : totalWarningCount
|
|
137
|
+
|
|
138
|
+
// Group resources by namespace when enabled
|
|
139
|
+
const namespacedGroups = useMemo(() => {
|
|
140
|
+
if (!groupByNS || !filteredGroups) return undefined
|
|
141
|
+
const nsMap = new Map<string, ResourceGroup[]>()
|
|
142
|
+
for (const g of filteredGroups) {
|
|
143
|
+
const ns = g.namespace || '(cluster-scoped)'
|
|
144
|
+
const list = nsMap.get(ns) || []
|
|
145
|
+
list.push(g)
|
|
146
|
+
nsMap.set(ns, list)
|
|
147
|
+
}
|
|
148
|
+
// Sort namespaces: most severe first
|
|
149
|
+
return Array.from(nsMap.entries()).sort((a, b) => {
|
|
150
|
+
const aDanger = a[1].reduce((n, g) => n + g.danger, 0)
|
|
151
|
+
const bDanger = b[1].reduce((n, g) => n + g.danger, 0)
|
|
152
|
+
if (aDanger !== bDanger) return bDanger - aDanger
|
|
153
|
+
return a[0].localeCompare(b[0])
|
|
154
|
+
})
|
|
155
|
+
}, [groupByNS, filteredGroups])
|
|
156
|
+
|
|
157
|
+
const toggleNS = (ns: string) => {
|
|
158
|
+
const isOpening = !expandedNS.has(ns)
|
|
159
|
+
setExpandedNS(prev => {
|
|
160
|
+
const next = new Set(prev)
|
|
161
|
+
if (next.has(ns)) next.delete(ns)
|
|
162
|
+
else next.add(ns)
|
|
163
|
+
return next
|
|
164
|
+
})
|
|
165
|
+
// When opening a namespace, auto-expand all its resource groups
|
|
166
|
+
if (isOpening && namespacedGroups) {
|
|
167
|
+
const nsEntry = namespacedGroups.find(([n]) => n === ns)
|
|
168
|
+
if (nsEntry) {
|
|
169
|
+
setExpanded(prev => {
|
|
170
|
+
const next = new Set(prev)
|
|
171
|
+
for (const g of nsEntry[1]) {
|
|
172
|
+
next.add(`${g.kind}/${g.namespace}/${g.name}`)
|
|
173
|
+
}
|
|
174
|
+
return next
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Auto-enable grouping for large result sets; auto-expand all in flat view
|
|
181
|
+
const resourceCount = filteredGroups?.length ?? 0
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (resourceCount > 20) {
|
|
184
|
+
setGroupByNS(true)
|
|
185
|
+
} else if (filteredGroups) {
|
|
186
|
+
// Small result set — start with all resource groups expanded
|
|
187
|
+
setExpanded(new Set(filteredGroups.map(g => `${g.kind}/${g.namespace}/${g.name}`)))
|
|
188
|
+
}
|
|
189
|
+
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
190
|
+
|
|
191
|
+
const isEmpty = filteredGroups ? filteredGroups.length === 0 : (filteredFindings?.length ?? 0) === 0
|
|
192
|
+
const totalEmpty = allFindings.length === 0
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<div className="flex flex-col gap-4">
|
|
196
|
+
{/* Toolbar */}
|
|
197
|
+
<div className="flex flex-col gap-2 px-4 py-3 border-b border-theme-border bg-theme-base rounded-xl shrink-0">
|
|
198
|
+
{/* Row 1: Counts + Search + View toggle */}
|
|
199
|
+
<div className="flex items-center gap-4">
|
|
200
|
+
<SummaryBadge label="Critical" count={dangerCount} color={SEVERITY_TEXT.error} />
|
|
201
|
+
<SummaryBadge label="Warning" count={warningCount} color={SEVERITY_TEXT.warning} />
|
|
202
|
+
|
|
203
|
+
<div className="relative">
|
|
204
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
|
|
205
|
+
<input
|
|
206
|
+
ref={searchInputRef}
|
|
207
|
+
type="text"
|
|
208
|
+
placeholder="Search... (press /)"
|
|
209
|
+
value={searchTerm}
|
|
210
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
211
|
+
className="w-56 pl-10 pr-4 py-1.5 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-skyhook-500"
|
|
212
|
+
/>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<div className="flex-1" />
|
|
216
|
+
|
|
217
|
+
{groups && (
|
|
218
|
+
<div className="flex items-center gap-1.5">
|
|
219
|
+
<button
|
|
220
|
+
onClick={() => setGroupByNS(!groupByNS)}
|
|
221
|
+
className={clsx(
|
|
222
|
+
'flex items-center gap-1 px-2.5 py-1 text-xs rounded-md border transition-colors',
|
|
223
|
+
groupByNS ? 'bg-theme-text-primary/10 text-theme-text-primary font-medium border-theme-border' : 'text-theme-text-tertiary hover:text-theme-text-secondary border-transparent'
|
|
224
|
+
)}
|
|
225
|
+
>
|
|
226
|
+
<Layers className="w-3.5 h-3.5" />
|
|
227
|
+
Group by namespace
|
|
228
|
+
</button>
|
|
229
|
+
</div>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
{/* Row 2: Filter chips */}
|
|
234
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
235
|
+
<FilterChip label="All" active={!categoryFilter && !severityFilter && !frameworkFilter} onClick={() => { setCategoryFilter(null); setSeverityFilter(null); setFrameworkFilter(null) }} />
|
|
236
|
+
<span className="w-px h-4 bg-theme-text-tertiary/30 mx-3" />
|
|
237
|
+
{CATEGORIES.map(cat => (
|
|
238
|
+
<FilterChip key={cat} label={cat} active={categoryFilter === cat} onClick={() => setCategoryFilter(categoryFilter === cat ? null : cat)} />
|
|
239
|
+
))}
|
|
240
|
+
<span className="w-px h-4 bg-theme-text-tertiary/30 mx-3" />
|
|
241
|
+
{SEVERITIES.map(sev => (
|
|
242
|
+
<FilterChip key={sev} label={sev === 'danger' ? 'Critical' : 'Warning'} active={severityFilter === sev} onClick={() => setSeverityFilter(severityFilter === sev ? null : sev)} />
|
|
243
|
+
))}
|
|
244
|
+
{frameworks.length > 0 && (
|
|
245
|
+
<>
|
|
246
|
+
<span className="w-px h-4 bg-theme-text-tertiary/30 mx-3" />
|
|
247
|
+
{frameworks.map(fw => (
|
|
248
|
+
<FilterChip key={fw} label={fw} active={frameworkFilter === fw} onClick={() => setFrameworkFilter(frameworkFilter === fw ? null : fw)} />
|
|
249
|
+
))}
|
|
250
|
+
</>
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
{/* Content */}
|
|
256
|
+
{isEmpty ? (
|
|
257
|
+
<div className="flex items-center justify-center py-12 text-theme-text-tertiary text-sm">
|
|
258
|
+
{totalEmpty ? 'All checks passing — no issues found' : 'No findings match the current filters'}
|
|
259
|
+
</div>
|
|
260
|
+
) : namespacedGroups ? (
|
|
261
|
+
/* Namespace-grouped view */
|
|
262
|
+
<div className="flex flex-col gap-1">
|
|
263
|
+
{namespacedGroups.map(([ns, nsGroups]) => {
|
|
264
|
+
const nsExpanded = expandedNS.has(ns)
|
|
265
|
+
const nsDanger = nsGroups.reduce((n, g) => n + g.danger, 0)
|
|
266
|
+
const nsWarning = nsGroups.reduce((n, g) => n + g.warning, 0)
|
|
267
|
+
return (
|
|
268
|
+
<div key={ns}>
|
|
269
|
+
<button
|
|
270
|
+
onClick={() => toggleNS(ns)}
|
|
271
|
+
className="group flex items-center gap-3 w-full px-4 py-2 rounded-lg hover:bg-theme-hover/30 transition-colors text-left"
|
|
272
|
+
>
|
|
273
|
+
<ChevronRight className={clsx('w-4 h-4 text-theme-text-tertiary shrink-0 transition-transform duration-200', nsExpanded && 'rotate-90')} />
|
|
274
|
+
<span className="text-sm font-semibold text-theme-text-primary">{ns}</span>
|
|
275
|
+
<span className="text-xs text-theme-text-tertiary">{nsGroups.length} resource{nsGroups.length !== 1 ? 's' : ''}</span>
|
|
276
|
+
<span className="flex-1" />
|
|
277
|
+
<div className="flex items-center gap-3 shrink-0">
|
|
278
|
+
{nsDanger > 0 && <span className={clsx('text-xs font-semibold tabular-nums', SEVERITY_TEXT.error)}>{nsDanger} critical</span>}
|
|
279
|
+
{nsWarning > 0 && <span className={clsx('text-xs font-semibold tabular-nums', SEVERITY_TEXT.warning)}>{nsWarning} warning</span>}
|
|
280
|
+
</div>
|
|
281
|
+
{onHideNamespace && ns !== '(cluster-scoped)' && (
|
|
282
|
+
<ContextMenu items={[{ label: `Hide ${ns} namespace`, onClick: () => onHideNamespace(ns) }]} />
|
|
283
|
+
)}
|
|
284
|
+
</button>
|
|
285
|
+
<div
|
|
286
|
+
className="grid transition-[grid-template-rows] duration-200 ease-out"
|
|
287
|
+
style={{ gridTemplateRows: nsExpanded ? '1fr' : '0fr' }}
|
|
288
|
+
>
|
|
289
|
+
<div className="overflow-hidden">
|
|
290
|
+
<div className="pl-4">
|
|
291
|
+
{nsGroups.map(g => (
|
|
292
|
+
<ResourceGroupRow key={`${g.kind}/${g.namespace}/${g.name}`} group={g} checks={checks} expanded={expanded} onToggle={toggle} onResourceClick={onResourceClick} onHideCheck={onHideCheck} onHideCategory={onHideCategory} />
|
|
293
|
+
))}
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
)
|
|
299
|
+
})}
|
|
300
|
+
</div>
|
|
301
|
+
) : filteredGroups ? (
|
|
302
|
+
/* Flat grouped view */
|
|
303
|
+
<div className="flex flex-col gap-0.5">
|
|
304
|
+
{filteredGroups.map(g => (
|
|
305
|
+
<ResourceGroupRow key={`${g.kind}/${g.namespace}/${g.name}`} group={g} checks={checks} expanded={expanded} onToggle={toggle} onResourceClick={onResourceClick} onHideCheck={onHideCheck} onHideCategory={onHideCategory} onHideNamespace={onHideNamespace} showNamespace />
|
|
306
|
+
))}
|
|
307
|
+
</div>
|
|
308
|
+
) : (
|
|
309
|
+
/* Flat fallback (per-resource view) */
|
|
310
|
+
<div className="flex flex-col gap-1">
|
|
311
|
+
{filteredFindings?.map((f, i) => (
|
|
312
|
+
<FlatFindingRow key={`${f.checkID}-${i}`} finding={f} onResourceClick={onResourceClick} />
|
|
313
|
+
))}
|
|
314
|
+
</div>
|
|
315
|
+
)}
|
|
316
|
+
</div>
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function FindingDetail({ finding, meta, onHideCheck, onHideCategory }: {
|
|
321
|
+
finding: AuditFinding
|
|
322
|
+
meta?: CheckMeta
|
|
323
|
+
onHideCheck?: (checkID: string, title: string) => void
|
|
324
|
+
onHideCategory?: (category: string) => void
|
|
325
|
+
}) {
|
|
326
|
+
const isDanger = finding.severity === 'danger'
|
|
327
|
+
const menuItems: ContextMenuItem[] = []
|
|
328
|
+
if (onHideCheck) {
|
|
329
|
+
menuItems.push({ label: `Hide "${meta?.title || finding.checkID}" check`, onClick: () => onHideCheck(finding.checkID, meta?.title || finding.checkID) })
|
|
330
|
+
}
|
|
331
|
+
if (onHideCategory) {
|
|
332
|
+
menuItems.push({ label: `Hide all ${finding.category} checks`, onClick: () => onHideCategory(finding.category) })
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return (
|
|
336
|
+
<div className="flex flex-col gap-0.5 px-3 py-2 rounded group/finding">
|
|
337
|
+
<div className="flex items-center gap-3">
|
|
338
|
+
{isDanger ? (
|
|
339
|
+
<ShieldAlert className={clsx('w-4 h-4 shrink-0', SEVERITY_TEXT.error)} />
|
|
340
|
+
) : (
|
|
341
|
+
<AlertTriangle className={clsx('w-4 h-4 shrink-0', SEVERITY_TEXT.warning)} />
|
|
342
|
+
)}
|
|
343
|
+
<span className="text-sm text-theme-text-primary flex-1 min-w-0">{finding.message}</span>
|
|
344
|
+
<span className={clsx('badge-sm text-[10px]', BP_CATEGORY_BADGE[finding.category] || DEFAULT_BADGE_COLOR)}>
|
|
345
|
+
{finding.category}
|
|
346
|
+
</span>
|
|
347
|
+
{menuItems.length > 0 && <ContextMenu items={menuItems} />}
|
|
348
|
+
</div>
|
|
349
|
+
{meta && (
|
|
350
|
+
<div className="pl-7 flex flex-col gap-0.5">
|
|
351
|
+
<span className="text-xs text-theme-text-tertiary">{meta.description}</span>
|
|
352
|
+
<span className="text-xs text-theme-text-secondary">Fix: {meta.remediation}</span>
|
|
353
|
+
</div>
|
|
354
|
+
)}
|
|
355
|
+
</div>
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function ResourceGroupRow({ group: g, checks, expanded, onToggle, onResourceClick, onHideCheck, onHideCategory, onHideNamespace, showNamespace = false }: {
|
|
360
|
+
group: ResourceGroup
|
|
361
|
+
checks?: Record<string, CheckMeta>
|
|
362
|
+
expanded: Set<string>
|
|
363
|
+
onToggle: (key: string) => void
|
|
364
|
+
onResourceClick?: (kind: string, namespace: string, name: string) => void
|
|
365
|
+
onHideCheck?: (checkID: string, title: string) => void
|
|
366
|
+
onHideCategory?: (category: string) => void
|
|
367
|
+
onHideNamespace?: (namespace: string) => void
|
|
368
|
+
showNamespace?: boolean
|
|
369
|
+
}) {
|
|
370
|
+
const key = `${g.kind}/${g.namespace}/${g.name}`
|
|
371
|
+
const isExpanded = expanded.has(key)
|
|
372
|
+
const hasDanger = g.danger > 0
|
|
373
|
+
|
|
374
|
+
return (
|
|
375
|
+
<div>
|
|
376
|
+
<button
|
|
377
|
+
onClick={() => onToggle(key)}
|
|
378
|
+
className="group flex items-center gap-3 w-full px-4 py-2.5 rounded-lg hover:bg-theme-hover/50 transition-colors text-left focus-visible:ring-2 focus-visible:ring-theme-text-primary/20 focus-visible:outline-none"
|
|
379
|
+
>
|
|
380
|
+
<ChevronRight className={clsx('w-3.5 h-3.5 text-theme-text-tertiary shrink-0 transition-transform duration-200', isExpanded && 'rotate-90')} />
|
|
381
|
+
{hasDanger ? (
|
|
382
|
+
<ShieldAlert className={clsx('w-4 h-4 shrink-0', SEVERITY_TEXT.error)} />
|
|
383
|
+
) : (
|
|
384
|
+
<AlertTriangle className={clsx('w-4 h-4 shrink-0', SEVERITY_TEXT.warning)} />
|
|
385
|
+
)}
|
|
386
|
+
<span className="text-xs text-theme-text-tertiary shrink-0">{g.kind}</span>
|
|
387
|
+
{onResourceClick ? (
|
|
388
|
+
<span
|
|
389
|
+
role="link"
|
|
390
|
+
tabIndex={0}
|
|
391
|
+
onClick={(e) => { e.stopPropagation(); onResourceClick(g.kind, g.namespace, g.name) }}
|
|
392
|
+
onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); onResourceClick(g.kind, g.namespace, g.name) } }}
|
|
393
|
+
className="text-sm font-medium text-skyhook-500 hover:text-skyhook-400 hover:underline cursor-pointer truncate max-w-[300px] inline-flex items-center gap-1"
|
|
394
|
+
>
|
|
395
|
+
{showNamespace && g.namespace ? `${g.namespace} / ` : ''}{g.name}
|
|
396
|
+
<ExternalLink className="w-3 h-3 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
397
|
+
</span>
|
|
398
|
+
) : (
|
|
399
|
+
<span className="text-sm font-medium text-theme-text-primary truncate max-w-[300px]">
|
|
400
|
+
{showNamespace && g.namespace ? `${g.namespace} / ` : ''}{g.name}
|
|
401
|
+
</span>
|
|
402
|
+
)}
|
|
403
|
+
<span className="flex-1" />
|
|
404
|
+
<div className="flex items-center gap-3 shrink-0">
|
|
405
|
+
{g.danger > 0 && <span className={clsx('text-xs font-semibold tabular-nums', SEVERITY_TEXT.error)}>{g.danger} critical</span>}
|
|
406
|
+
{g.warning > 0 && <span className={clsx('text-xs font-semibold tabular-nums', SEVERITY_TEXT.warning)}>{g.warning} warning</span>}
|
|
407
|
+
</div>
|
|
408
|
+
{showNamespace && onHideNamespace && g.namespace && (
|
|
409
|
+
<ContextMenu items={[{ label: `Hide ${g.namespace} namespace`, onClick: () => onHideNamespace(g.namespace) }]} />
|
|
410
|
+
)}
|
|
411
|
+
</button>
|
|
412
|
+
<div
|
|
413
|
+
className="grid transition-[grid-template-rows] duration-200 ease-out"
|
|
414
|
+
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
|
|
415
|
+
>
|
|
416
|
+
<div className="overflow-hidden">
|
|
417
|
+
<div className="pl-11 pb-1">
|
|
418
|
+
{g.findings.map((f, i) => (
|
|
419
|
+
<FindingDetail key={`${f.checkID}-${i}`} finding={f} meta={checks?.[f.checkID]} onHideCheck={onHideCheck} onHideCategory={onHideCategory} />
|
|
420
|
+
))}
|
|
421
|
+
</div>
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function FlatFindingRow({ finding, onResourceClick }: { finding: AuditFinding; onResourceClick?: (kind: string, namespace: string, name: string) => void }) {
|
|
429
|
+
const isDanger = finding.severity === 'danger'
|
|
430
|
+
const severityColor = isDanger ? SEVERITY_TEXT.error : SEVERITY_TEXT.warning
|
|
431
|
+
|
|
432
|
+
return (
|
|
433
|
+
<div className="flex items-center gap-3 px-4 py-2.5 rounded-lg hover:bg-theme-hover/50 transition-colors">
|
|
434
|
+
{isDanger ? (
|
|
435
|
+
<ShieldAlert className={clsx('w-4 h-4 shrink-0', severityColor)} />
|
|
436
|
+
) : (
|
|
437
|
+
<AlertTriangle className={clsx('w-4 h-4 shrink-0', severityColor)} />
|
|
438
|
+
)}
|
|
439
|
+
{onResourceClick ? (
|
|
440
|
+
<button
|
|
441
|
+
onClick={() => onResourceClick(finding.kind, finding.namespace, finding.name)}
|
|
442
|
+
className="text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary transition-colors shrink-0 max-w-[200px] truncate text-left focus-visible:ring-1 focus-visible:ring-theme-text-primary/30 focus-visible:outline-none rounded"
|
|
443
|
+
>
|
|
444
|
+
{finding.kind}/{finding.namespace ? `${finding.namespace}/` : ''}{finding.name}
|
|
445
|
+
</button>
|
|
446
|
+
) : (
|
|
447
|
+
<span className="text-xs font-medium text-theme-text-secondary shrink-0 max-w-[200px] truncate">
|
|
448
|
+
{finding.kind}/{finding.namespace ? `${finding.namespace}/` : ''}{finding.name}
|
|
449
|
+
</span>
|
|
450
|
+
)}
|
|
451
|
+
<span className="text-xs text-theme-text-primary flex-1 min-w-0">{finding.message}</span>
|
|
452
|
+
<span className={clsx('badge-sm text-[10px]', BP_CATEGORY_BADGE[finding.category] || DEFAULT_BADGE_COLOR)}>
|
|
453
|
+
{finding.category}
|
|
454
|
+
</span>
|
|
455
|
+
</div>
|
|
456
|
+
)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function SummaryBadge({ label, count, color }: { label: string; count: number; color: string }) {
|
|
460
|
+
return (
|
|
461
|
+
<div className="flex items-center gap-2">
|
|
462
|
+
<span className={clsx('text-2xl font-bold tabular-nums', count > 0 ? color : 'text-theme-text-tertiary')}>{count}</span>
|
|
463
|
+
<span className="text-xs text-theme-text-secondary">{label}</span>
|
|
464
|
+
</div>
|
|
465
|
+
)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function FilterChip({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
|
|
469
|
+
return (
|
|
470
|
+
<button
|
|
471
|
+
onClick={onClick}
|
|
472
|
+
className={clsx(
|
|
473
|
+
'px-2 py-0.5 text-xs rounded-md transition-colors focus-visible:ring-2 focus-visible:ring-theme-text-primary/20 focus-visible:outline-none',
|
|
474
|
+
active
|
|
475
|
+
? 'bg-theme-text-primary/10 text-theme-text-primary font-medium'
|
|
476
|
+
: 'text-theme-text-tertiary hover:text-theme-text-secondary hover:bg-theme-hover'
|
|
477
|
+
)}
|
|
478
|
+
>
|
|
479
|
+
{label}
|
|
480
|
+
</button>
|
|
481
|
+
)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
interface ContextMenuItem {
|
|
485
|
+
label: string
|
|
486
|
+
onClick: () => void
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function ContextMenu({ items }: { items: ContextMenuItem[] }) {
|
|
490
|
+
const [open, setOpen] = useState(false)
|
|
491
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
492
|
+
|
|
493
|
+
useEffect(() => {
|
|
494
|
+
if (!open) return
|
|
495
|
+
const handler = (e: MouseEvent) => {
|
|
496
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
|
497
|
+
}
|
|
498
|
+
document.addEventListener('mousedown', handler)
|
|
499
|
+
return () => document.removeEventListener('mousedown', handler)
|
|
500
|
+
}, [open])
|
|
501
|
+
|
|
502
|
+
return (
|
|
503
|
+
<div ref={ref} className="relative">
|
|
504
|
+
<button
|
|
505
|
+
onClick={(e) => { e.stopPropagation(); setOpen(!open) }}
|
|
506
|
+
className="p-1 rounded hover:bg-theme-hover text-theme-text-tertiary hover:text-theme-text-secondary opacity-0 group-hover:opacity-100 group-hover/finding:opacity-100 transition-opacity"
|
|
507
|
+
>
|
|
508
|
+
<MoreHorizontal className="w-4 h-4" />
|
|
509
|
+
</button>
|
|
510
|
+
{open && (
|
|
511
|
+
<div className="absolute right-0 top-full mt-1 min-w-48 bg-theme-surface border border-theme-border rounded-lg shadow-xl z-50 py-1">
|
|
512
|
+
{items.map((item, i) => (
|
|
513
|
+
<button
|
|
514
|
+
key={i}
|
|
515
|
+
onClick={(e) => { e.stopPropagation(); item.onClick(); setOpen(false) }}
|
|
516
|
+
className="w-full text-left px-3 py-1.5 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-hover transition-colors flex items-center gap-2"
|
|
517
|
+
>
|
|
518
|
+
<EyeOff className="w-3.5 h-3.5 shrink-0" />
|
|
519
|
+
{item.label}
|
|
520
|
+
</button>
|
|
521
|
+
))}
|
|
522
|
+
</div>
|
|
523
|
+
)}
|
|
524
|
+
</div>
|
|
525
|
+
)
|
|
526
|
+
}
|
|
@@ -20,6 +20,8 @@ export interface DockTab {
|
|
|
20
20
|
workloadName?: string
|
|
21
21
|
// Node terminal props
|
|
22
22
|
nodeName?: string
|
|
23
|
+
// Local terminal props
|
|
24
|
+
initialCommand?: string
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export interface DockContextValue {
|
|
@@ -250,10 +252,11 @@ export function useOpenNodeTerminal() {
|
|
|
250
252
|
export function useOpenLocalTerminal() {
|
|
251
253
|
const { addTab } = useDock()
|
|
252
254
|
|
|
253
|
-
return () => {
|
|
255
|
+
return (opts?: { initialCommand?: string; title?: string }) => {
|
|
254
256
|
addTab({
|
|
255
257
|
type: 'local-terminal',
|
|
256
|
-
title: 'Terminal',
|
|
258
|
+
title: opts?.title || 'Terminal',
|
|
259
|
+
initialCommand: opts?.initialCommand,
|
|
257
260
|
})
|
|
258
261
|
}
|
|
259
262
|
}
|
|
@@ -10,11 +10,14 @@ export interface LocalTerminalTabProps {
|
|
|
10
10
|
isActive?: boolean
|
|
11
11
|
/** Returns the WebSocket URL for the local terminal session */
|
|
12
12
|
createSession: () => Promise<{ wsUrl: string }>
|
|
13
|
+
/** Command to auto-execute after the terminal connects */
|
|
14
|
+
initialCommand?: string
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export function LocalTerminalTab({
|
|
16
18
|
isActive = true,
|
|
17
19
|
createSession,
|
|
20
|
+
initialCommand,
|
|
18
21
|
}: LocalTerminalTabProps) {
|
|
19
22
|
const terminalRef = useRef<HTMLDivElement>(null)
|
|
20
23
|
const xtermRef = useRef<XTerm | null>(null)
|
|
@@ -104,6 +107,14 @@ export function LocalTerminalTab({
|
|
|
104
107
|
setIsConnecting(false)
|
|
105
108
|
doFit(ws)
|
|
106
109
|
xterm.focus()
|
|
110
|
+
if (initialCommand) {
|
|
111
|
+
// Small delay to let the shell prompt initialize
|
|
112
|
+
setTimeout(() => {
|
|
113
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
114
|
+
ws.send(JSON.stringify({ type: 'input', data: initialCommand + '\n' }))
|
|
115
|
+
}
|
|
116
|
+
}, 300)
|
|
117
|
+
}
|
|
107
118
|
}
|
|
108
119
|
|
|
109
120
|
ws.onmessage = (event) => {
|