@skyhook-io/k8s-ui 1.3.2 → 1.4.0

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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/components/audit/AuditAlerts.tsx +88 -0
  3. package/src/components/audit/AuditCard.tsx +130 -0
  4. package/src/components/audit/AuditFindingsTable.tsx +526 -0
  5. package/src/components/audit/index.ts +3 -0
  6. package/src/components/dock/DockContext.tsx +5 -2
  7. package/src/components/dock/LocalTerminalTab.tsx +11 -0
  8. package/src/components/resources/ResourcesView.tsx +14 -0
  9. package/src/components/resources/renderers/CiliumNetworkPolicyRenderer.tsx +228 -0
  10. package/src/components/resources/renderers/ClusterNetworkPolicyRenderer.tsx +174 -0
  11. package/src/components/resources/renderers/GenericRenderer.tsx +1 -1
  12. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +277 -0
  13. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +10 -1
  14. package/src/components/resources/renderers/ServiceRenderer.tsx +40 -28
  15. package/src/components/resources/renderers/index.ts +3 -0
  16. package/src/components/shared/CreateResourceDialog.tsx +236 -0
  17. package/src/components/shared/EditableYamlView.tsx +16 -1
  18. package/src/components/shared/ResourceActionsBar.tsx +9 -7
  19. package/src/components/shared/ResourceRendererDispatch.tsx +9 -2
  20. package/src/components/shared/index.ts +1 -0
  21. package/src/components/topology/K8sResourceNode.tsx +15 -0
  22. package/src/components/topology/TopologyControls.tsx +21 -1
  23. package/src/components/topology/TopologyGraph.tsx +1 -1
  24. package/src/components/ui/Badge.tsx +8 -0
  25. package/src/components/ui/drawer-components.tsx +20 -4
  26. package/src/components/workload/WorkloadView.tsx +55 -29
  27. package/src/index.ts +3 -0
  28. package/src/types/core.ts +2 -1
  29. package/src/utils/badge-colors.ts +7 -0
  30. package/src/utils/index.ts +2 -0
  31. package/src/utils/k8s-errors.ts +142 -0
  32. package/src/utils/resource-icons.ts +3 -0
  33. 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
+ }
@@ -0,0 +1,3 @@
1
+ export { AuditCard, type AuditCardData } from './AuditCard'
2
+ export { AuditAlerts, type AuditFinding } from './AuditAlerts'
3
+ export { AuditFindingsTable, type AuditFindingsTableProps, type ResourceGroup, type CheckMeta } from './AuditFindingsTable'
@@ -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) => {
@@ -20,6 +20,7 @@ import {
20
20
  Tag,
21
21
  Copy,
22
22
  Check,
23
+ Plus,
23
24
  } from 'lucide-react'
24
25
  import { clsx } from 'clsx'
25
26
  import { ResourceBar } from '../ui/ResourceBar'
@@ -1421,6 +1422,8 @@ interface ResourcesViewProps {
1421
1422
  onSelectedKindChange?: (kind: { name: string; kind: string; group: string }) => void
1422
1423
  /** When true, the sidebar is not rendered. Useful when a standalone ResourcesSidebar is used externally. */
1423
1424
  hideSidebar?: boolean
1425
+ /** Callback when the [+] create button is clicked. Receives the currently selected kind info. */
1426
+ onCreateResource?: (kind: { name: string; kind: string; group: string } | null) => void
1424
1427
  }
1425
1428
 
1426
1429
  // Default selected kind
@@ -1492,6 +1495,7 @@ export function ResourcesView({
1492
1495
  onOpenWorkloadLogs,
1493
1496
  onSelectedKindChange,
1494
1497
  hideSidebar = false,
1498
+ onCreateResource,
1495
1499
  }: ResourcesViewProps) {
1496
1500
  const location = useMemo(() => ({ search: locationSearch, pathname: locationPathname }), [locationSearch, locationPathname])
1497
1501
  const initialFilters = getInitialFiltersFromURL()
@@ -3157,6 +3161,16 @@ export function ResourcesView({
3157
3161
  : <RefreshCw className={clsx('w-4 h-4', refreshPhase === 'spinning' && 'animate-spin')} />
3158
3162
  }
3159
3163
  </button>
3164
+ {onCreateResource && (
3165
+ <Tooltip content={`Create ${selectedKind.kind || 'resource'}`}>
3166
+ <button
3167
+ onClick={() => onCreateResource(selectedKind)}
3168
+ className="p-2 hover:bg-theme-elevated rounded-lg text-theme-text-secondary hover:text-theme-text-primary transition-colors"
3169
+ >
3170
+ <Plus className="w-4 h-4" />
3171
+ </button>
3172
+ </Tooltip>
3173
+ )}
3160
3174
  </div>
3161
3175
 
3162
3176
  {/* Table */}