@skyhook-io/k8s-ui 1.4.0 → 1.4.2

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 (55) hide show
  1. package/package.json +2 -2
  2. package/src/components/resources/ResourcesSidebar.tsx +152 -57
  3. package/src/components/resources/ResourcesView.tsx +141 -49
  4. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +190 -28
  5. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +22 -0
  6. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +156 -5
  7. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +28 -1
  8. package/src/components/resources/renderers/CertificateRenderer.tsx +47 -8
  9. package/src/components/resources/renderers/ChallengeRenderer.tsx +22 -3
  10. package/src/components/resources/renderers/CiliumNetworkPolicyRenderer.tsx +262 -45
  11. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +81 -9
  12. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +10 -3
  13. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +59 -1
  14. package/src/components/resources/renderers/ClusterNetworkPolicyRenderer.tsx +7 -21
  15. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -5
  16. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +91 -3
  17. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -42
  18. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -41
  19. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +66 -2
  20. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +31 -1
  21. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +66 -1
  22. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -0
  23. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +55 -1
  24. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +23 -0
  25. package/src/components/resources/renderers/KustomizationRenderer.tsx +38 -3
  26. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +7 -56
  27. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -42
  28. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +3 -8
  29. package/src/components/resources/renderers/PodMonitorRenderer.tsx +5 -7
  30. package/src/components/resources/renderers/PodRenderer.tsx +22 -74
  31. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +178 -20
  32. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +2 -2
  33. package/src/components/resources/renderers/RolloutRenderer.tsx +105 -3
  34. package/src/components/resources/renderers/SbomReportRenderer.tsx +46 -5
  35. package/src/components/resources/renderers/SealedSecretRenderer.tsx +11 -2
  36. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +5 -7
  37. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +11 -3
  38. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +11 -3
  39. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +8 -16
  40. package/src/components/resources/renderers/WorkflowRenderer.tsx +71 -17
  41. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +46 -1
  42. package/src/components/resources/renderers/karpenter-cells.tsx +18 -3
  43. package/src/components/resources/resource-utils-cnpg.ts +47 -0
  44. package/src/components/resources/resource-utils-karpenter.ts +6 -0
  45. package/src/components/resources/resource-utils-prometheus.ts +50 -6
  46. package/src/components/resources/resource-utils.ts +131 -33
  47. package/src/components/shared/EditableYamlView.tsx +38 -3
  48. package/src/components/shared/ResourceRendererDispatch.tsx +4 -4
  49. package/src/components/timeline/TimelineList.tsx +11 -4
  50. package/src/components/ui/Tooltip.tsx +16 -0
  51. package/src/components/ui/drawer-components.tsx +48 -1
  52. package/src/theme/variables.css +1 -1
  53. package/src/types/gitops.ts +8 -0
  54. package/src/utils/navigation.test.ts +29 -2
  55. package/src/utils/navigation.ts +45 -24
@@ -1,15 +1,168 @@
1
- import { Bell } from 'lucide-react'
1
+ import { useState, useMemo } from 'react'
2
+ import { Bell, Search, ChevronRight } from 'lucide-react'
3
+ import { clsx } from 'clsx'
2
4
  import { Section, PropertyList, Property, ConditionsSection } from '../../ui/drawer-components'
5
+ import { BADGE_SEVERITY_COLORS } from '../../ui/Badge'
3
6
  import {
4
7
  getPrometheusRuleGroups,
5
8
  getPrometheusRuleTotalRules,
6
9
  getPrometheusRuleGroupCount,
7
10
  } from '../resource-utils-prometheus'
11
+ import type { PrometheusRuleGroup, PrometheusRule, PrometheusAlertRule, PrometheusRecordingRule } from '../resource-utils-prometheus'
8
12
 
9
13
  interface PrometheusRuleRendererProps {
10
14
  data: any
11
15
  }
12
16
 
17
+ // Map Prometheus severity names to centralized badge colors
18
+ const SEVERITY_BADGE: Record<string, string> = {
19
+ critical: BADGE_SEVERITY_COLORS.error,
20
+ warning: BADGE_SEVERITY_COLORS.warning,
21
+ info: BADGE_SEVERITY_COLORS.info,
22
+ }
23
+
24
+ function matchesSearch(rule: PrometheusRule, term: string): boolean {
25
+ if (rule.type === 'alert') {
26
+ return (
27
+ rule.alert.toLowerCase().includes(term) ||
28
+ rule.expr.toLowerCase().includes(term) ||
29
+ (rule.severity || '').toLowerCase().includes(term) ||
30
+ (rule.summary || '').toLowerCase().includes(term) ||
31
+ (rule.description || '').toLowerCase().includes(term)
32
+ )
33
+ }
34
+ return (
35
+ (rule.record || '').toLowerCase().includes(term) ||
36
+ rule.expr.toLowerCase().includes(term)
37
+ )
38
+ }
39
+
40
+ const EXPR_TRUNCATE_LEN = 200
41
+ const SUMMARY_TRUNCATE_LEN = 100
42
+
43
+ function TruncatedExpr({ expr }: { expr: string }) {
44
+ const [expanded, setExpanded] = useState(false)
45
+ const needsTruncation = expr.length > EXPR_TRUNCATE_LEN
46
+
47
+ return (
48
+ <div className="mt-1">
49
+ <pre className="text-xs font-mono text-theme-text-secondary bg-theme-elevated rounded px-2 py-1.5 whitespace-pre-wrap break-all">
50
+ {expanded || !needsTruncation ? expr : expr.slice(0, EXPR_TRUNCATE_LEN) + '...'}
51
+ </pre>
52
+ {needsTruncation && (
53
+ <button
54
+ onClick={() => setExpanded(!expanded)}
55
+ className="text-[10px] text-accent-text hover:underline mt-0.5"
56
+ >
57
+ {expanded ? 'Show less' : 'Show more'}
58
+ </button>
59
+ )}
60
+ </div>
61
+ )
62
+ }
63
+
64
+ function AlertRuleCard({ rule }: { rule: PrometheusAlertRule }) {
65
+ const severityClass = rule.severity ? SEVERITY_BADGE[rule.severity] : undefined
66
+ const summaryText = rule.summary || rule.description || ''
67
+ const truncatedSummary = summaryText.length > SUMMARY_TRUNCATE_LEN
68
+ ? summaryText.slice(0, SUMMARY_TRUNCATE_LEN) + '...'
69
+ : summaryText
70
+
71
+ return (
72
+ <div className="card-inner text-sm">
73
+ <div className="flex items-center gap-2 flex-wrap">
74
+ <span className="text-theme-text-primary font-medium">{rule.alert}</span>
75
+ {rule.severity && (
76
+ <span className={clsx('badge-sm', severityClass || 'bg-theme-hover text-theme-text-secondary')}>
77
+ {rule.severity}
78
+ </span>
79
+ )}
80
+ {rule.for && (
81
+ <span className="badge-sm bg-theme-hover text-theme-text-secondary">
82
+ for: {rule.for}
83
+ </span>
84
+ )}
85
+ </div>
86
+ {truncatedSummary && (
87
+ <div className="text-xs text-theme-text-tertiary mt-1">{truncatedSummary}</div>
88
+ )}
89
+ <TruncatedExpr expr={rule.expr} />
90
+ </div>
91
+ )
92
+ }
93
+
94
+ function RecordingRuleCard({ rule }: { rule: PrometheusRecordingRule }) {
95
+ return (
96
+ <div className="card-inner text-sm">
97
+ <div className="flex items-center gap-2">
98
+ <span className="text-theme-text-primary font-medium">{rule.record}</span>
99
+ <span className="badge-sm bg-theme-hover text-theme-text-tertiary">recording</span>
100
+ </div>
101
+ <TruncatedExpr expr={rule.expr} />
102
+ </div>
103
+ )
104
+ }
105
+
106
+ function RuleGroupSection({ group, searchTerm }: { group: PrometheusRuleGroup; searchTerm: string }) {
107
+ const [manualExpanded, setManualExpanded] = useState(group.ruleCount <= 10)
108
+
109
+ const filteredRules = useMemo(() => {
110
+ if (!searchTerm) return group.rules
111
+ const term = searchTerm.toLowerCase()
112
+ return group.rules.filter((rule) => matchesSearch(rule, term))
113
+ }, [group.rules, searchTerm])
114
+
115
+ // Auto-expand when searching so matched rules are visible
116
+ const expanded = searchTerm ? filteredRules.length > 0 : manualExpanded
117
+
118
+ // If searching and no matches, hide the group entirely
119
+ if (searchTerm && filteredRules.length === 0) return null
120
+
121
+ return (
122
+ <div className="card-inner">
123
+ <button
124
+ onClick={() => setManualExpanded(!manualExpanded)}
125
+ className="flex items-center justify-between w-full text-left"
126
+ >
127
+ <div className="flex items-center gap-2">
128
+ <ChevronRight className={clsx('w-3.5 h-3.5 text-theme-text-tertiary transition-transform duration-200', expanded && 'rotate-90')} />
129
+ <span className="text-sm text-theme-text-primary font-medium">{group.name}</span>
130
+ </div>
131
+ <div className="flex items-center gap-2">
132
+ {group.interval && (
133
+ <span className="badge-sm bg-theme-hover text-theme-text-secondary">
134
+ {group.interval}
135
+ </span>
136
+ )}
137
+ <span className="text-xs text-theme-text-tertiary">
138
+ {searchTerm ? `${filteredRules.length}/${group.ruleCount}` : group.ruleCount} rule{group.ruleCount !== 1 ? 's' : ''}
139
+ </span>
140
+ </div>
141
+ </button>
142
+ {!expanded && (
143
+ <div className="text-xs text-theme-text-secondary mt-1 ml-5.5 flex gap-3">
144
+ {group.alertCount > 0 && <span>{group.alertCount} alert{group.alertCount !== 1 ? 's' : ''}</span>}
145
+ {group.recordCount > 0 && <span>{group.recordCount} recording</span>}
146
+ </div>
147
+ )}
148
+ <div
149
+ className="grid transition-[grid-template-rows] duration-200 ease-out"
150
+ style={{ gridTemplateRows: expanded ? '1fr' : '0fr' }}
151
+ >
152
+ <div className="overflow-hidden">
153
+ <div className="mt-2 space-y-2">
154
+ {filteredRules.map((rule, i) => (
155
+ rule.type === 'alert'
156
+ ? <AlertRuleCard key={`alert-${rule.alert}-${i}`} rule={rule} />
157
+ : <RecordingRuleCard key={`rec-${rule.record}-${i}`} rule={rule} />
158
+ ))}
159
+ </div>
160
+ </div>
161
+ </div>
162
+ </div>
163
+ )
164
+ }
165
+
13
166
  export function PrometheusRuleRenderer({ data }: PrometheusRuleRendererProps) {
14
167
  const groups = getPrometheusRuleGroups(data)
15
168
  const totalRules = getPrometheusRuleTotalRules(data)
@@ -17,6 +170,8 @@ export function PrometheusRuleRenderer({ data }: PrometheusRuleRendererProps) {
17
170
  const totalRecords = groups.reduce((sum, g) => sum + g.recordCount, 0)
18
171
  const conditions = data.status?.conditions
19
172
 
173
+ const [searchTerm, setSearchTerm] = useState('')
174
+
20
175
  return (
21
176
  <>
22
177
  <Section title="PrometheusRule" icon={Bell}>
@@ -30,28 +185,31 @@ export function PrometheusRuleRenderer({ data }: PrometheusRuleRendererProps) {
30
185
 
31
186
  {groups.length > 0 && (
32
187
  <Section title={`Rule Groups (${groups.length})`} defaultExpanded>
188
+ {/* Search bar */}
189
+ {totalRules > 5 && (
190
+ <div className="relative mb-3">
191
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-theme-text-tertiary" />
192
+ <input
193
+ type="text"
194
+ placeholder="Filter rules by name or expression..."
195
+ value={searchTerm}
196
+ onChange={(e) => setSearchTerm(e.target.value)}
197
+ className="w-full pl-7 pr-2 py-1.5 text-xs bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:ring-1 focus:ring-accent/50"
198
+ />
199
+ </div>
200
+ )}
33
201
  <div className="space-y-2">
34
202
  {groups.map((group, i) => (
35
- <div key={i} className="card-inner text-sm">
36
- <div className="flex items-center justify-between">
37
- <span className="text-theme-text-primary font-medium">{group.name}</span>
38
- <div className="flex items-center gap-2">
39
- {group.interval && (
40
- <span className="badge-sm bg-theme-hover text-theme-text-secondary">
41
- {group.interval}
42
- </span>
43
- )}
44
- <span className="text-xs text-theme-text-tertiary">
45
- {group.ruleCount} rule{group.ruleCount !== 1 ? 's' : ''}
46
- </span>
47
- </div>
48
- </div>
49
- <div className="text-xs text-theme-text-secondary mt-1 flex gap-3">
50
- {group.alertCount > 0 && <span>{group.alertCount} alert{group.alertCount !== 1 ? 's' : ''}</span>}
51
- {group.recordCount > 0 && <span>{group.recordCount} recording</span>}
52
- </div>
53
- </div>
203
+ <RuleGroupSection key={i} group={group} searchTerm={searchTerm} />
54
204
  ))}
205
+ {searchTerm && groups.every(g => {
206
+ const term = searchTerm.toLowerCase()
207
+ return g.rules.every(rule => !matchesSearch(rule, term))
208
+ }) && (
209
+ <div className="text-xs text-theme-text-tertiary py-3 text-center">
210
+ No rules match "{searchTerm}".
211
+ </div>
212
+ )}
55
213
  </div>
56
214
  </Section>
57
215
  )}
@@ -1,5 +1,5 @@
1
1
  import { Server } from 'lucide-react'
2
- import { Section, PropertyList, Property, KeyValueBadgeList, ConditionsSection, AlertBanner } from '../../ui/drawer-components'
2
+ import { Section, PropertyList, Property, LabelSelectorDisplay, ConditionsSection, AlertBanner } from '../../ui/drawer-components'
3
3
 
4
4
  interface ReplicaSetRendererProps {
5
5
  data: any
@@ -62,7 +62,7 @@ export function ReplicaSetRenderer({ data }: ReplicaSetRendererProps) {
62
62
  </Section>
63
63
 
64
64
  <Section title="Selector">
65
- <KeyValueBadgeList items={data.spec?.selector?.matchLabels} />
65
+ <LabelSelectorDisplay selector={data.spec?.selector} />
66
66
  </Section>
67
67
 
68
68
  <ConditionsSection conditions={data.status?.conditions} />
@@ -1,6 +1,7 @@
1
- import { Server, GitBranch, AlertTriangle } from 'lucide-react'
1
+ import { Server, GitBranch, AlertTriangle, Network } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ConditionsSection, PodTemplateSection } from '../../ui/drawer-components'
4
+ import { formatAge } from '../resource-utils'
4
5
 
5
6
  interface RolloutRendererProps {
6
7
  data: any
@@ -17,10 +18,29 @@ export function RolloutRenderer({ data }: RolloutRendererProps) {
17
18
  const isCanary = !!canaryStrategy
18
19
  const steps = canaryStrategy?.steps || []
19
20
  const currentStepIndex = status.currentStepIndex
21
+ const trafficRouting = canaryStrategy?.trafficRouting
22
+
23
+ // Detect traffic routing provider
24
+ const trafficProvider = trafficRouting
25
+ ? (() => {
26
+ if (trafficRouting.istio) return { name: 'Istio', details: trafficRouting.istio }
27
+ if (trafficRouting.nginx) return { name: 'Nginx', details: trafficRouting.nginx }
28
+ if (trafficRouting.alb) return { name: 'ALB', details: trafficRouting.alb }
29
+ if (trafficRouting.smi) return { name: 'SMI', details: trafficRouting.smi }
30
+ if (trafficRouting.traefik) return { name: 'Traefik', details: trafficRouting.traefik }
31
+ if (trafficRouting.ambassador) return { name: 'Ambassador', details: trafficRouting.ambassador }
32
+ return null
33
+ })()
34
+ : null
20
35
 
21
36
  // Problem detection
22
37
  const problems: Array<{ color: 'red' | 'yellow'; message: string }> = []
23
38
 
39
+ // Aborted rollout detection — must come before generic paused check
40
+ if (status.abort === true) {
41
+ problems.push({ color: 'red', message: status.message || 'Rollout was aborted' })
42
+ }
43
+
24
44
  if (phase === 'Degraded') {
25
45
  problems.push({ color: 'red', message: status.message || 'Rollout is degraded' })
26
46
  }
@@ -39,8 +59,19 @@ export function RolloutRenderer({ data }: RolloutRendererProps) {
39
59
  problems.push({ color: 'red', message: invalidSpecCond.message || 'Invalid rollout spec' })
40
60
  }
41
61
 
42
- if (phase === 'Paused') {
43
- problems.push({ color: 'yellow', message: 'Rollout is paused' })
62
+ // Pause conditions show specific reasons instead of generic "Rollout is paused"
63
+ const pauseConditions: Array<{ reason: string; startTime?: string }> = status.pauseConditions || []
64
+ if (phase === 'Paused' && !status.abort) {
65
+ if (pauseConditions.length > 0) {
66
+ const reasons = pauseConditions.map((pc: any) => {
67
+ const reason = pc.reason || 'Unknown'
68
+ const since = pc.startTime ? ` (since ${formatAge(pc.startTime)})` : ''
69
+ return `${reason}${since}`
70
+ })
71
+ problems.push({ color: 'yellow', message: `Rollout is paused: ${reasons.join('; ')}` })
72
+ } else {
73
+ problems.push({ color: 'yellow', message: 'Rollout is paused' })
74
+ }
44
75
  }
45
76
 
46
77
  // Phase badge color
@@ -153,6 +184,77 @@ export function RolloutRenderer({ data }: RolloutRendererProps) {
153
184
  </PropertyList>
154
185
  </Section>
155
186
 
187
+ {/* Traffic Routing */}
188
+ {trafficProvider && (
189
+ <Section title="Traffic Routing" icon={Network}>
190
+ <PropertyList>
191
+ <Property
192
+ label="Provider"
193
+ value={
194
+ <span className="badge-sm status-neutral">{trafficProvider.name}</span>
195
+ }
196
+ />
197
+ {trafficProvider.name === 'Istio' && (
198
+ <>
199
+ {trafficProvider.details.virtualService?.name && (
200
+ <Property label="VirtualService" value={trafficProvider.details.virtualService.name} />
201
+ )}
202
+ {trafficProvider.details.destinationRule?.name && (
203
+ <Property label="DestinationRule" value={trafficProvider.details.destinationRule.name} />
204
+ )}
205
+ </>
206
+ )}
207
+ {trafficProvider.name === 'ALB' && (
208
+ <>
209
+ {trafficProvider.details.ingress && (
210
+ <Property label="Ingress" value={trafficProvider.details.ingress} />
211
+ )}
212
+ {trafficProvider.details.servicePort != null && (
213
+ <Property label="Service Port" value={trafficProvider.details.servicePort} />
214
+ )}
215
+ </>
216
+ )}
217
+ {trafficProvider.name === 'Nginx' && (
218
+ <>
219
+ {trafficProvider.details.stableIngress && (
220
+ <Property label="Stable Ingress" value={trafficProvider.details.stableIngress} />
221
+ )}
222
+ {trafficProvider.details.additionalIngressAnnotations && (
223
+ <Property
224
+ label="Annotations"
225
+ value={Object.keys(trafficProvider.details.additionalIngressAnnotations).length + ' annotations'}
226
+ />
227
+ )}
228
+ </>
229
+ )}
230
+ {trafficProvider.name === 'SMI' && (
231
+ <>
232
+ {trafficProvider.details.rootService && (
233
+ <Property label="Root Service" value={trafficProvider.details.rootService} />
234
+ )}
235
+ {trafficProvider.details.trafficSplitName && (
236
+ <Property label="TrafficSplit" value={trafficProvider.details.trafficSplitName} />
237
+ )}
238
+ </>
239
+ )}
240
+ {trafficProvider.name === 'Traefik' && (
241
+ <>
242
+ {trafficProvider.details.weightedTraefikServiceName && (
243
+ <Property label="Weighted Service" value={trafficProvider.details.weightedTraefikServiceName} />
244
+ )}
245
+ </>
246
+ )}
247
+ {trafficProvider.name === 'Ambassador' && (
248
+ <>
249
+ {trafficProvider.details.mappings && (
250
+ <Property label="Mappings" value={trafficProvider.details.mappings.join(', ')} />
251
+ )}
252
+ </>
253
+ )}
254
+ </PropertyList>
255
+ </Section>
256
+ )}
257
+
156
258
  {/* Canary Steps visual */}
157
259
  {isCanary && steps.length > 0 && (
158
260
  <Section title={`Canary Steps (${steps.length})`} defaultExpanded>
@@ -1,5 +1,5 @@
1
- import { useState } from 'react'
2
- import { Package, ChevronDown, ChevronRight } from 'lucide-react'
1
+ import { useState, useMemo } from 'react'
2
+ import { Package, ChevronDown, ChevronRight, Search } from 'lucide-react'
3
3
  import { Section, PropertyList, Property } from '../../ui/drawer-components'
4
4
  import { formatAge } from '../resource-utils'
5
5
  import { formatTrivyImage } from './trivy-shared'
@@ -13,6 +13,7 @@ const INITIAL_SHOW_COUNT = 100
13
13
  export function SbomReportRenderer({ data }: SbomReportRendererProps) {
14
14
  const [showAll, setShowAll] = useState(false)
15
15
  const [expanded, setExpanded] = useState(true)
16
+ const [searchTerm, setSearchTerm] = useState('')
16
17
 
17
18
  const report = data.report || {}
18
19
  const scanner = report.scanner || {}
@@ -29,7 +30,18 @@ export function SbomReportRenderer({ data }: SbomReportRendererProps) {
29
30
  const bomFormat = components.bomFormat || '-'
30
31
  const specVersion = components.specVersion || '-'
31
32
 
32
- const displayedComponents = showAll ? bom : bom.slice(0, INITIAL_SHOW_COUNT)
33
+ const filteredBom = useMemo(() => {
34
+ if (!searchTerm) return bom
35
+ const term = searchTerm.toLowerCase()
36
+ return bom.filter((comp: any) =>
37
+ (comp.name || '').toLowerCase().includes(term) ||
38
+ (comp.purl || '').toLowerCase().includes(term) ||
39
+ (comp.type || '').toLowerCase().includes(term)
40
+ )
41
+ }, [bom, searchTerm])
42
+
43
+ const hasActiveFilter = searchTerm !== ''
44
+ const displayedComponents = showAll ? filteredBom : filteredBom.slice(0, INITIAL_SHOW_COUNT)
33
45
 
34
46
  return (
35
47
  <>
@@ -58,6 +70,30 @@ export function SbomReportRenderer({ data }: SbomReportRendererProps) {
58
70
  </button>
59
71
  {expanded && (
60
72
  <div className="overflow-x-auto -mx-1">
73
+ {/* Search */}
74
+ <div className="flex items-center gap-2 mb-2 px-1">
75
+ <div className="relative flex-1">
76
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-theme-text-tertiary" />
77
+ <input
78
+ type="text"
79
+ placeholder="Filter by name, type, or purl..."
80
+ value={searchTerm}
81
+ onChange={(e) => { setSearchTerm(e.target.value); setShowAll(false) }}
82
+ className="w-full pl-6 pr-2 py-1 text-xs bg-theme-bg border border-theme-border rounded text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:ring-1 focus:ring-blue-500/50"
83
+ />
84
+ </div>
85
+ </div>
86
+ {hasActiveFilter && (
87
+ <div className="flex items-center gap-1 mb-2 px-1 text-xs text-theme-text-tertiary">
88
+ Showing {filteredBom.length} of {bom.length}
89
+ <button
90
+ onClick={() => { setSearchTerm(''); setShowAll(false) }}
91
+ className="ml-1 text-blue-400 hover:text-blue-300 hover:underline"
92
+ >
93
+ Clear
94
+ </button>
95
+ </div>
96
+ )}
61
97
  <table className="w-full text-xs">
62
98
  <thead>
63
99
  <tr className="border-b border-theme-border text-theme-text-tertiary">
@@ -81,12 +117,17 @@ export function SbomReportRenderer({ data }: SbomReportRendererProps) {
81
117
  })}
82
118
  </tbody>
83
119
  </table>
84
- {!showAll && bom.length > INITIAL_SHOW_COUNT && (
120
+ {displayedComponents.length === 0 && (
121
+ <div className="py-4 text-center text-xs text-theme-text-tertiary">
122
+ No components match the current filter.
123
+ </div>
124
+ )}
125
+ {!showAll && filteredBom.length > INITIAL_SHOW_COUNT && (
85
126
  <button
86
127
  onClick={() => setShowAll(true)}
87
128
  className="mt-2 text-xs text-blue-400 hover:text-blue-300 hover:underline"
88
129
  >
89
- Show all {bom.length} components
130
+ Show all {filteredBom.length} components
90
131
  </button>
91
132
  )}
92
133
  </div>
@@ -1,9 +1,10 @@
1
1
  import { Lock, Key, FileText } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
- import { Section, PropertyList, Property, ConditionsSection, KeyValueBadgeList, AlertBanner } from '../../ui/drawer-components'
3
+ import { Section, PropertyList, Property, ConditionsSection, KeyValueBadgeList, AlertBanner, ResourceLink } from '../../ui/drawer-components'
4
4
 
5
5
  interface SealedSecretRendererProps {
6
6
  data: any
7
+ onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
7
8
  }
8
9
 
9
10
  function getScope(annotations: Record<string, string> | undefined): string {
@@ -13,7 +14,7 @@ function getScope(annotations: Record<string, string> | undefined): string {
13
14
  return 'strict'
14
15
  }
15
16
 
16
- export function SealedSecretRenderer({ data }: SealedSecretRendererProps) {
17
+ export function SealedSecretRenderer({ data, onNavigate }: SealedSecretRendererProps) {
17
18
  const spec = data.spec || {}
18
19
  const status = data.status || {}
19
20
  const conditions = status.conditions || []
@@ -65,6 +66,14 @@ export function SealedSecretRenderer({ data }: SealedSecretRendererProps) {
65
66
  </span>
66
67
  }
67
68
  />
69
+ <Property label="Target Secret" value={
70
+ <ResourceLink
71
+ name={data.spec?.template?.metadata?.name || data.metadata?.name || ''}
72
+ kind="secrets"
73
+ namespace={data.metadata?.namespace || ''}
74
+ onNavigate={onNavigate}
75
+ />
76
+ } />
68
77
  <Property label="Secret Type" value={secretType} />
69
78
  <Property label="Scope" value={scope} />
70
79
  <Property label="Observed Gen" value={status.observedGeneration} />
@@ -1,5 +1,5 @@
1
1
  import { Radio } from 'lucide-react'
2
- import { Section, PropertyList, Property, ConditionsSection, KeyValueBadgeList } from '../../ui/drawer-components'
2
+ import { Section, PropertyList, Property, ConditionsSection, LabelSelectorDisplay } from '../../ui/drawer-components'
3
3
  import {
4
4
  getServiceMonitorEndpoints,
5
5
  getServiceMonitorJobLabel,
@@ -12,7 +12,7 @@ interface ServiceMonitorRendererProps {
12
12
 
13
13
  export function ServiceMonitorRenderer({ data }: ServiceMonitorRendererProps) {
14
14
  const endpoints = getServiceMonitorEndpoints(data)
15
- const selector = data.spec?.selector?.matchLabels
15
+ const selector = data.spec?.selector
16
16
  const conditions = data.status?.conditions
17
17
 
18
18
  return (
@@ -53,11 +53,9 @@ export function ServiceMonitorRenderer({ data }: ServiceMonitorRendererProps) {
53
53
  </Section>
54
54
  )}
55
55
 
56
- {selector && Object.keys(selector).length > 0 && (
57
- <Section title="Selector">
58
- <KeyValueBadgeList items={selector} />
59
- </Section>
60
- )}
56
+ <Section title="Selector">
57
+ <LabelSelectorDisplay selector={selector} emptyText="No selector" />
58
+ </Section>
61
59
 
62
60
  <ConditionsSection conditions={conditions} />
63
61
  </>
@@ -56,7 +56,7 @@ export function VeleroBackupRenderer({ data }: VeleroBackupRendererProps) {
56
56
  <AlertBanner
57
57
  variant="error"
58
58
  title={isFailed ? 'Backup Failed' : 'Backup Partially Failed'}
59
- message={`${errors} error(s) occurred during backup.`}
59
+ message={status.failureReason || `${errors} error(s) occurred during backup.`}
60
60
  items={validationErrors.length > 0 ? validationErrors : undefined}
61
61
  />
62
62
  )}
@@ -84,8 +84,16 @@ export function VeleroBackupRenderer({ data }: VeleroBackupRendererProps) {
84
84
  )}
85
85
  <Property label="Duration" value={getBackupDuration(data)} />
86
86
  <Property label="Expiration" value={getBackupExpiry(data)} />
87
- {errors > 0 && <Property label="Errors" value={String(errors)} />}
88
- {warnings > 0 && <Property label="Warnings" value={String(warnings)} />}
87
+ <Property label="Errors" value={
88
+ errors > 0
89
+ ? <span className="text-red-500 dark:text-red-400 font-medium">{errors}</span>
90
+ : '0'
91
+ } />
92
+ <Property label="Warnings" value={
93
+ warnings > 0
94
+ ? <span className="text-amber-500 dark:text-amber-400 font-medium">{warnings}</span>
95
+ : '0'
96
+ } />
89
97
  </PropertyList>
90
98
  </Section>
91
99
 
@@ -49,7 +49,7 @@ export function VeleroRestoreRenderer({ data }: VeleroRestoreRendererProps) {
49
49
  <AlertBanner
50
50
  variant="error"
51
51
  title={isFailed ? 'Restore Failed' : 'Restore Partially Failed'}
52
- message={`${errors} error(s) occurred during restore.`}
52
+ message={status.failureReason || `${errors} error(s) occurred during restore.`}
53
53
  />
54
54
  )}
55
55
  {warnings > 0 && !isFailed && (
@@ -76,8 +76,16 @@ export function VeleroRestoreRenderer({ data }: VeleroRestoreRendererProps) {
76
76
  <Property label="Completed" value={formatAge(status.completionTimestamp) + ' ago'} />
77
77
  )}
78
78
  <Property label="Duration" value={getRestoreDuration(data)} />
79
- {errors > 0 && <Property label="Errors" value={String(errors)} />}
80
- {warnings > 0 && <Property label="Warnings" value={String(warnings)} />}
79
+ <Property label="Errors" value={
80
+ errors > 0
81
+ ? <span className="text-red-500 dark:text-red-400 font-medium">{errors}</span>
82
+ : '0'
83
+ } />
84
+ <Property label="Warnings" value={
85
+ warnings > 0
86
+ ? <span className="text-amber-500 dark:text-amber-400 font-medium">{warnings}</span>
87
+ : '0'
88
+ } />
81
89
  </PropertyList>
82
90
  </Section>
83
91
 
@@ -1,6 +1,6 @@
1
1
  import { Shield } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
- import { Section, PropertyList, Property, AlertBanner } from '../../ui/drawer-components'
3
+ import { Section, PropertyList, Property, AlertBanner, LabelSelectorDisplay } from '../../ui/drawer-components'
4
4
 
5
5
  interface WebhookConfigRendererProps {
6
6
  data: any
@@ -65,8 +65,8 @@ export function WebhookConfigRenderer({ data, isMutating }: WebhookConfigRendere
65
65
  ? `URL: ${url}`
66
66
  : 'No target'
67
67
 
68
- const nsLabels = wh.namespaceSelector?.matchLabels
69
- const objLabels = wh.objectSelector?.matchLabels
68
+ const nsSelector = wh.namespaceSelector
69
+ const objSelector = wh.objectSelector
70
70
 
71
71
  return (
72
72
  <div key={i} className="card-inner-lg">
@@ -119,26 +119,18 @@ export function WebhookConfigRenderer({ data, isMutating }: WebhookConfigRendere
119
119
  )}
120
120
 
121
121
  {/* Selectors */}
122
- {(nsLabels || objLabels) && (
122
+ {(nsSelector || objSelector) && (
123
123
  <div className="mt-2 space-y-1">
124
- {nsLabels && Object.keys(nsLabels).length > 0 && (
124
+ {nsSelector && (
125
125
  <div>
126
126
  <div className="text-xs text-theme-text-tertiary mb-1">Namespace Selector</div>
127
- <div className="flex flex-wrap gap-1">
128
- {Object.entries(nsLabels).map(([k, v]) => (
129
- <span key={k} className="badge-sm bg-theme-elevated text-theme-text-secondary">{k}={String(v)}</span>
130
- ))}
131
- </div>
127
+ <LabelSelectorDisplay selector={nsSelector} />
132
128
  </div>
133
129
  )}
134
- {objLabels && Object.keys(objLabels).length > 0 && (
130
+ {objSelector && (
135
131
  <div>
136
132
  <div className="text-xs text-theme-text-tertiary mb-1">Object Selector</div>
137
- <div className="flex flex-wrap gap-1">
138
- {Object.entries(objLabels).map(([k, v]) => (
139
- <span key={k} className="badge-sm bg-theme-elevated text-theme-text-secondary">{k}={String(v)}</span>
140
- ))}
141
- </div>
133
+ <LabelSelectorDisplay selector={objSelector} />
142
134
  </div>
143
135
  )}
144
136
  </div>