@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.
Files changed (78) hide show
  1. package/package.json +2 -2
  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 +126 -49
  9. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +190 -28
  10. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +22 -0
  11. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +156 -5
  12. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +28 -1
  13. package/src/components/resources/renderers/CertificateRenderer.tsx +47 -8
  14. package/src/components/resources/renderers/ChallengeRenderer.tsx +22 -3
  15. package/src/components/resources/renderers/CiliumNetworkPolicyRenderer.tsx +445 -0
  16. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +81 -9
  17. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +10 -3
  18. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +59 -1
  19. package/src/components/resources/renderers/ClusterNetworkPolicyRenderer.tsx +160 -0
  20. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -5
  21. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +91 -3
  22. package/src/components/resources/renderers/GenericRenderer.tsx +1 -1
  23. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -42
  24. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -41
  25. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +66 -2
  26. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +31 -1
  27. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +66 -1
  28. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -0
  29. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +55 -1
  30. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +23 -0
  31. package/src/components/resources/renderers/KustomizationRenderer.tsx +38 -3
  32. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +277 -0
  33. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +17 -57
  34. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -42
  35. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +3 -8
  36. package/src/components/resources/renderers/PodMonitorRenderer.tsx +5 -7
  37. package/src/components/resources/renderers/PodRenderer.tsx +22 -74
  38. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +178 -20
  39. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +2 -2
  40. package/src/components/resources/renderers/RolloutRenderer.tsx +105 -3
  41. package/src/components/resources/renderers/SbomReportRenderer.tsx +46 -5
  42. package/src/components/resources/renderers/SealedSecretRenderer.tsx +11 -2
  43. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +5 -7
  44. package/src/components/resources/renderers/ServiceRenderer.tsx +40 -28
  45. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +11 -3
  46. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +11 -3
  47. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +8 -16
  48. package/src/components/resources/renderers/WorkflowRenderer.tsx +71 -17
  49. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +46 -1
  50. package/src/components/resources/renderers/index.ts +3 -0
  51. package/src/components/resources/renderers/karpenter-cells.tsx +18 -3
  52. package/src/components/resources/resource-utils-cnpg.ts +47 -0
  53. package/src/components/resources/resource-utils-karpenter.ts +6 -0
  54. package/src/components/resources/resource-utils-prometheus.ts +50 -6
  55. package/src/components/resources/resource-utils.ts +131 -33
  56. package/src/components/shared/CreateResourceDialog.tsx +236 -0
  57. package/src/components/shared/EditableYamlView.tsx +16 -1
  58. package/src/components/shared/ResourceActionsBar.tsx +9 -7
  59. package/src/components/shared/ResourceRendererDispatch.tsx +13 -6
  60. package/src/components/shared/index.ts +1 -0
  61. package/src/components/timeline/TimelineList.tsx +11 -4
  62. package/src/components/topology/K8sResourceNode.tsx +15 -0
  63. package/src/components/topology/TopologyControls.tsx +21 -1
  64. package/src/components/topology/TopologyGraph.tsx +1 -1
  65. package/src/components/ui/Badge.tsx +8 -0
  66. package/src/components/ui/drawer-components.tsx +68 -5
  67. package/src/components/workload/WorkloadView.tsx +55 -29
  68. package/src/index.ts +3 -0
  69. package/src/theme/variables.css +1 -1
  70. package/src/types/core.ts +2 -1
  71. package/src/types/gitops.ts +8 -0
  72. package/src/utils/badge-colors.ts +7 -0
  73. package/src/utils/index.ts +2 -0
  74. package/src/utils/k8s-errors.ts +142 -0
  75. package/src/utils/navigation.test.ts +29 -2
  76. package/src/utils/navigation.ts +45 -24
  77. package/src/utils/resource-icons.ts +3 -0
  78. package/src/utils/skeleton-yaml.ts +302 -0
@@ -1,6 +1,5 @@
1
- import { Package, AlertTriangle, CheckCircle2 } from 'lucide-react'
2
- import { clsx } from 'clsx'
3
- import { Section, PropertyList, Property, ConditionsSection } from '../../ui/drawer-components'
1
+ import { Package, CheckCircle2 } from 'lucide-react'
2
+ import { Section, PropertyList, Property, ConditionsSection, ProblemAlerts } from '../../ui/drawer-components'
4
3
  import { formatAge } from '../resource-utils'
5
4
  import { formatBytes } from '../../../utils/format'
6
5
  import { GitOpsStatusBadge, SyncCountdown } from '../../gitops'
@@ -36,45 +35,7 @@ export function OCIRepositoryRenderer({ data }: OCIRepositoryRendererProps) {
36
35
 
37
36
  return (
38
37
  <>
39
- {/* Problem alerts */}
40
- {problems.map((problem, i) => (
41
- <div
42
- key={i}
43
- className={clsx(
44
- 'mb-4 p-3 border rounded-lg',
45
- problem.color === 'red'
46
- ? 'bg-red-500/10 border-red-500/30'
47
- : 'bg-yellow-500/10 border-yellow-500/30'
48
- )}
49
- >
50
- <div className="flex items-start gap-2">
51
- <AlertTriangle
52
- className={clsx(
53
- 'w-4 h-4 mt-0.5 shrink-0',
54
- problem.color === 'red' ? 'text-red-400' : 'text-yellow-400'
55
- )}
56
- />
57
- <div className="flex-1 min-w-0">
58
- <div
59
- className={clsx(
60
- 'text-sm font-medium',
61
- problem.color === 'red' ? 'text-red-400' : 'text-yellow-400'
62
- )}
63
- >
64
- {problem.color === 'red' ? 'Issue Detected' : 'Warning'}
65
- </div>
66
- <div
67
- className={clsx(
68
- 'text-xs mt-1',
69
- problem.color === 'red' ? 'text-red-300/80' : 'text-yellow-300/80'
70
- )}
71
- >
72
- {problem.message}
73
- </div>
74
- </div>
75
- </div>
76
- </div>
77
- ))}
38
+ <ProblemAlerts problems={problems} />
78
39
 
79
40
  {/* Status section */}
80
41
  <Section title="Status">
@@ -1,6 +1,6 @@
1
1
  import { Shield, Activity } 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, LabelSelectorDisplay, AlertBanner } from '../../ui/drawer-components'
4
4
 
5
5
  interface PodDisruptionBudgetRendererProps {
6
6
  data: any
@@ -9,8 +9,7 @@ interface PodDisruptionBudgetRendererProps {
9
9
  export function PodDisruptionBudgetRenderer({ data }: PodDisruptionBudgetRendererProps) {
10
10
  const spec = data.spec || {}
11
11
  const status = data.status || {}
12
- const matchLabels = spec.selector?.matchLabels || {}
13
- const hasSelector = Object.keys(matchLabels).length > 0
12
+ const selector = spec.selector
14
13
 
15
14
  // Determine budget type
16
15
  const hasMaxUnavailable = spec.maxUnavailable !== undefined && spec.maxUnavailable !== null
@@ -113,11 +112,7 @@ export function PodDisruptionBudgetRenderer({ data }: PodDisruptionBudgetRendere
113
112
  </Section>
114
113
 
115
114
  <Section title="Selector">
116
- {hasSelector ? (
117
- <KeyValueBadgeList items={matchLabels} />
118
- ) : (
119
- <div className="text-sm text-theme-text-tertiary">All pods in namespace</div>
120
- )}
115
+ <LabelSelectorDisplay selector={selector} emptyText="All pods in namespace" />
121
116
  </Section>
122
117
 
123
118
  <ConditionsSection conditions={status.conditions} />
@@ -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
  getPodMonitorEndpoints,
5
5
  getPodMonitorNamespaceSelector,
@@ -11,7 +11,7 @@ interface PodMonitorRendererProps {
11
11
 
12
12
  export function PodMonitorRenderer({ data }: PodMonitorRendererProps) {
13
13
  const endpoints = getPodMonitorEndpoints(data)
14
- const selector = data.spec?.selector?.matchLabels
14
+ const selector = data.spec?.selector
15
15
  const conditions = data.status?.conditions
16
16
 
17
17
  return (
@@ -51,11 +51,9 @@ export function PodMonitorRenderer({ data }: PodMonitorRendererProps) {
51
51
  </Section>
52
52
  )}
53
53
 
54
- {selector && Object.keys(selector).length > 0 && (
55
- <Section title="Selector">
56
- <KeyValueBadgeList items={selector} />
57
- </Section>
58
- )}
54
+ <Section title="Selector">
55
+ <LabelSelectorDisplay selector={selector} emptyText="No selector" />
56
+ </Section>
59
57
 
60
58
  <ConditionsSection conditions={conditions} />
61
59
  </>
@@ -2,7 +2,7 @@ import { useState, type ReactNode, type JSX } from 'react'
2
2
  import { Server, HardDrive, Terminal as TerminalIcon, FileText, Activity, CirclePlay, FolderOpen, List, Eye, EyeOff } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property, ConditionsSection, CopyHandler, AlertBanner, ResourceLink } from '../../ui/drawer-components'
5
- import { formatResources, formatDuration } from '../resource-utils'
5
+ import { formatResources, formatDuration, getPodProblems, SEVERITY_DOT_COLOR } from '../resource-utils'
6
6
  import { getResourceStatusColor, SEVERITY_BADGE_BORDERED } from '../../../utils/badge-colors'
7
7
  import type { ResolvedEnvFrom } from '../../../types'
8
8
  import { Tooltip } from '../../ui/Tooltip'
@@ -77,70 +77,6 @@ interface PodRendererProps {
77
77
  resolvedEnvFrom?: ResolvedEnvFrom
78
78
  }
79
79
 
80
- // Extract problems from pod status and conditions
81
- function getPodProblems(data: any): string[] {
82
- const problems: string[] = []
83
- const phase = data.status?.phase
84
- const conditions = data.status?.conditions || []
85
- const containerStatuses = data.status?.containerStatuses || []
86
- const initContainerStatuses = data.status?.initContainerStatuses || []
87
-
88
- // Check phase
89
- if (phase === 'Failed' || phase === 'Unknown') {
90
- problems.push(`Pod is in ${phase} state`)
91
- }
92
-
93
- // Check conditions for scheduling/init issues
94
- for (const cond of conditions) {
95
- if (cond.status === 'False') {
96
- if (cond.type === 'PodScheduled' && cond.reason) {
97
- problems.push(`Scheduling: ${cond.reason}${cond.message ? ' - ' + cond.message : ''}`)
98
- } else if (cond.type === 'Initialized' && cond.message) {
99
- problems.push(`Init failed: ${cond.message}`)
100
- } else if (cond.type === 'ContainersReady' && cond.reason === 'ContainersNotReady') {
101
- // Will be covered by container status details
102
- } else if (cond.message) {
103
- problems.push(`${cond.type}: ${cond.message}`)
104
- }
105
- }
106
- }
107
-
108
- // Check init container failures
109
- for (const initStatus of initContainerStatuses) {
110
- if (initStatus.state?.waiting?.reason && initStatus.state.waiting.reason !== 'PodInitializing') {
111
- problems.push(`Init container "${initStatus.name}": ${initStatus.state.waiting.reason}`)
112
- }
113
- if (initStatus.state?.terminated?.exitCode && initStatus.state.terminated.exitCode !== 0) {
114
- problems.push(`Init container "${initStatus.name}" failed with exit code ${initStatus.state.terminated.exitCode}`)
115
- }
116
- }
117
-
118
- // Check container issues (most important)
119
- for (const status of containerStatuses) {
120
- const waiting = status.state?.waiting
121
- const terminated = status.state?.terminated
122
-
123
- if (waiting?.reason && waiting.reason !== 'ContainerCreating') {
124
- const msg = `Container "${status.name}": ${waiting.reason}`
125
- if (waiting.reason === 'ImagePullBackOff' || waiting.reason === 'ErrImagePull') {
126
- problems.push(msg + (waiting.message ? ` - ${waiting.message.slice(0, 100)}` : ''))
127
- } else if (waiting.reason === 'CrashLoopBackOff') {
128
- problems.push(msg + ' (container keeps crashing)')
129
- } else {
130
- problems.push(msg)
131
- }
132
- }
133
-
134
- if (terminated?.reason === 'OOMKilled') {
135
- problems.push(`Container "${status.name}" was OOMKilled - increase memory limit`)
136
- } else if (terminated?.exitCode && terminated.exitCode !== 0) {
137
- problems.push(`Container "${status.name}" exited with code ${terminated.exitCode}`)
138
- }
139
- }
140
-
141
- return problems
142
- }
143
-
144
80
  // ── Env vars section — extracted to use hooks (useState for reveal) ──────────
145
81
 
146
82
  function SecretValueCell({ value }: { value: string }) {
@@ -189,13 +125,13 @@ function resolveEnvValueNode(env: any): JSX.Element {
189
125
  }
190
126
  if (env.valueFrom?.configMapKeyRef) {
191
127
  const { name, key } = env.valueFrom.configMapKeyRef
192
- return <span className="text-blue-400/70 text-[10px] shrink-0 self-center px-1 py-0.5 bg-blue-500/10 rounded">configmap:{name}[{key}]</span>
128
+ return <span className="text-blue-700 dark:text-blue-400/80 text-[10px] shrink-0 self-center px-1 py-0.5 bg-blue-500/10 rounded">configmap:{name}[{key}]</span>
193
129
  }
194
130
  if (env.valueFrom?.fieldRef) {
195
- return <span className="text-purple-400/70 break-all">field:{env.valueFrom.fieldRef.fieldPath}</span>
131
+ return <span className="text-purple-700 dark:text-purple-400/80 break-all">field:{env.valueFrom.fieldRef.fieldPath}</span>
196
132
  }
197
133
  if (env.valueFrom?.resourceFieldRef) {
198
- return <span className="text-purple-400/70 break-all">resource:{env.valueFrom.resourceFieldRef.resource}</span>
134
+ return <span className="text-purple-700 dark:text-purple-400/80 break-all">resource:{env.valueFrom.resourceFieldRef.resource}</span>
199
135
  }
200
136
  return <span className="text-theme-text-primary break-all min-w-0">{env.value ?? ''}</span>
201
137
  }
@@ -218,10 +154,13 @@ function EnvVarsSection({
218
154
  resolvedEnvFrom?: ResolvedEnvFrom
219
155
  }) {
220
156
  const allContainers = [...initContainers, ...containers]
221
- const multiContainer = allContainers.filter((c: any) => c.env?.length > 0 || c.envFrom?.length > 0).length > 1
157
+ const containersWithEnv = allContainers.filter((c: any) => c.env?.length > 0 || c.envFrom?.length > 0)
158
+ const multiContainer = containersWithEnv.length > 1
159
+ const totalVars = allContainers.reduce((sum: number, c: any) => sum + (c.env?.length || 0) + (c.envFrom?.length || 0), 0)
160
+ const subtitle = multiContainer ? `${totalVars} vars across ${containersWithEnv.length} containers` : `${totalVars} vars`
222
161
 
223
162
  return (
224
- <Section title="Environment Variables" icon={List}>
163
+ <Section title={`Environment Variables · ${subtitle}`} icon={List} defaultExpanded={false}>
225
164
  <div className="space-y-4">
226
165
  {allContainers.map((container: any) => {
227
166
  const envVars: any[] = container.env || []
@@ -247,7 +186,7 @@ function EnvVarsSection({
247
186
  <div className="flex items-center gap-1.5 text-xs font-mono py-0.5">
248
187
  <span className={clsx(
249
188
  'shrink-0 px-1 py-0.5 rounded text-[10px]',
250
- isSecret ? 'bg-amber-500/10 dark:bg-yellow-500/10 text-amber-700 dark:text-yellow-400' : 'bg-blue-500/10 text-blue-400'
189
+ isSecret ? 'bg-amber-500/10 dark:bg-yellow-500/10 text-amber-700 dark:text-yellow-400' : 'bg-blue-500/10 text-blue-700 dark:text-blue-400/80'
251
190
  )}>
252
191
  {prefix}
253
192
  </span>
@@ -312,8 +251,8 @@ export function PodRenderer({
312
251
  const isRunning = data.status?.phase === 'Running'
313
252
 
314
253
  // Check for problems
315
- const problems = getPodProblems(data)
316
- const hasProblems = problems.length > 0
254
+ const podProblems = getPodProblems(data)
255
+ const hasProblems = podProblems.length > 0
317
256
 
318
257
  // Image filesystem modal state
319
258
  const [selectedImage, setSelectedImage] = useState<string | null>(null)
@@ -379,7 +318,16 @@ export function PodRenderer({
379
318
  <>
380
319
  {/* Problems alert - shown at top when there are issues */}
381
320
  {hasProblems && (
382
- <AlertBanner variant="error" title="Issues Detected" items={problems} />
321
+ <AlertBanner variant="error" title="Issues Detected">
322
+ <ul className="text-xs space-y-1">
323
+ {podProblems.map((p, i) => (
324
+ <li key={i} className="flex items-center gap-1.5">
325
+ <span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', SEVERITY_DOT_COLOR[p.severity])} />
326
+ <span className="text-red-600 dark:text-red-400">{p.message}</span>
327
+ </li>
328
+ ))}
329
+ </ul>
330
+ </AlertBanner>
383
331
  )}
384
332
 
385
333
  {/* Status section */}
@@ -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>