@skyhook-io/k8s-ui 1.0.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 (193) hide show
  1. package/package.json +63 -0
  2. package/src/components/dock/BottomDock.tsx +204 -0
  3. package/src/components/dock/DockContext.tsx +212 -0
  4. package/src/components/dock/TerminalTab.tsx +333 -0
  5. package/src/components/dock/index.ts +3 -0
  6. package/src/components/gitops/GitOpsActions.tsx +227 -0
  7. package/src/components/gitops/GitOpsStatusBadge.tsx +221 -0
  8. package/src/components/gitops/ManagedResourcesList.tsx +210 -0
  9. package/src/components/gitops/SyncCountdown.tsx +146 -0
  10. package/src/components/gitops/index.ts +4 -0
  11. package/src/components/logs/JsonLogLine.tsx +90 -0
  12. package/src/components/logs/LogCore.tsx +553 -0
  13. package/src/components/logs/LogToolbarSelects.tsx +74 -0
  14. package/src/components/logs/LogsViewer.tsx +146 -0
  15. package/src/components/logs/WorkloadLogsViewer.tsx +263 -0
  16. package/src/components/logs/index.ts +13 -0
  17. package/src/components/logs/useLogBuffer.ts +138 -0
  18. package/src/components/logs/useLogSearch.ts +151 -0
  19. package/src/components/logs/useLogStream.ts +61 -0
  20. package/src/components/resources/ResourcesView.tsx +5494 -0
  21. package/src/components/resources/index.ts +17 -0
  22. package/src/components/resources/renderers/AlertRenderer.tsx +166 -0
  23. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +233 -0
  24. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +89 -0
  25. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +194 -0
  26. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +79 -0
  27. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +68 -0
  28. package/src/components/resources/renderers/CertificateRenderer.tsx +209 -0
  29. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +123 -0
  30. package/src/components/resources/renderers/ChallengeRenderer.tsx +111 -0
  31. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +164 -0
  32. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +140 -0
  33. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +153 -0
  34. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +221 -0
  35. package/src/components/resources/renderers/ConfigMapRenderer.tsx +124 -0
  36. package/src/components/resources/renderers/CronJobRenderer.tsx +86 -0
  37. package/src/components/resources/renderers/EventRenderer.tsx +175 -0
  38. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +119 -0
  39. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +172 -0
  40. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +242 -0
  41. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +263 -0
  42. package/src/components/resources/renderers/GatewayClassRenderer.tsx +68 -0
  43. package/src/components/resources/renderers/GatewayRenderer.tsx +218 -0
  44. package/src/components/resources/renderers/GenericRenderer.tsx +476 -0
  45. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +154 -0
  46. package/src/components/resources/renderers/HPARenderer.tsx +164 -0
  47. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +303 -0
  48. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +167 -0
  49. package/src/components/resources/renderers/IngressClassRenderer.tsx +49 -0
  50. package/src/components/resources/renderers/IngressRenderer.tsx +104 -0
  51. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +175 -0
  52. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +176 -0
  53. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +125 -0
  54. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +84 -0
  55. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +112 -0
  56. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +288 -0
  57. package/src/components/resources/renderers/JobRenderer.tsx +103 -0
  58. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +157 -0
  59. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +188 -0
  60. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +148 -0
  61. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +90 -0
  62. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +136 -0
  63. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +151 -0
  64. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +70 -0
  65. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +309 -0
  66. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +142 -0
  67. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +232 -0
  68. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +97 -0
  69. package/src/components/resources/renderers/KnativeRouteRenderer.tsx +74 -0
  70. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +138 -0
  71. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +220 -0
  72. package/src/components/resources/renderers/KustomizationRenderer.tsx +151 -0
  73. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +352 -0
  74. package/src/components/resources/renderers/LeaseRenderer.tsx +93 -0
  75. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +208 -0
  76. package/src/components/resources/renderers/NodeRenderer.tsx +282 -0
  77. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +153 -0
  78. package/src/components/resources/renderers/OrderRenderer.tsx +108 -0
  79. package/src/components/resources/renderers/PVCRenderer.tsx +98 -0
  80. package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +138 -0
  81. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +126 -0
  82. package/src/components/resources/renderers/PodMonitorRenderer.tsx +63 -0
  83. package/src/components/resources/renderers/PodRenderer.tsx +677 -0
  84. package/src/components/resources/renderers/PriorityClassRenderer.tsx +50 -0
  85. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +62 -0
  86. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +71 -0
  87. package/src/components/resources/renderers/RoleBindingRenderer.tsx +97 -0
  88. package/src/components/resources/renderers/RoleRenderer.tsx +192 -0
  89. package/src/components/resources/renderers/RolloutRenderer.tsx +227 -0
  90. package/src/components/resources/renderers/RuntimeClassRenderer.tsx +75 -0
  91. package/src/components/resources/renderers/SbomReportRenderer.tsx +98 -0
  92. package/src/components/resources/renderers/SealedSecretRenderer.tsx +121 -0
  93. package/src/components/resources/renderers/SecretRenderer.tsx +321 -0
  94. package/src/components/resources/renderers/SecretStoreRenderer.tsx +111 -0
  95. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +81 -0
  96. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +65 -0
  97. package/src/components/resources/renderers/ServiceRenderer.tsx +98 -0
  98. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +195 -0
  99. package/src/components/resources/renderers/StorageClassRenderer.tsx +82 -0
  100. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +257 -0
  101. package/src/components/resources/renderers/VPARenderer.tsx +193 -0
  102. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +73 -0
  103. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +193 -0
  104. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +161 -0
  105. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +134 -0
  106. package/src/components/resources/renderers/VeleroVSLRenderer.tsx +36 -0
  107. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +347 -0
  108. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +156 -0
  109. package/src/components/resources/renderers/WorkflowRenderer.tsx +202 -0
  110. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +91 -0
  111. package/src/components/resources/renderers/WorkloadRenderer.tsx +311 -0
  112. package/src/components/resources/renderers/argo-cells.tsx +118 -0
  113. package/src/components/resources/renderers/certmanager-cells.tsx +164 -0
  114. package/src/components/resources/renderers/cnpg-cells.tsx +161 -0
  115. package/src/components/resources/renderers/eso-cells.tsx +115 -0
  116. package/src/components/resources/renderers/flux-cells.tsx +253 -0
  117. package/src/components/resources/renderers/index.ts +105 -0
  118. package/src/components/resources/renderers/istio-cells.tsx +210 -0
  119. package/src/components/resources/renderers/karpenter-cells.tsx +107 -0
  120. package/src/components/resources/renderers/keda-cells.tsx +94 -0
  121. package/src/components/resources/renderers/knative-cells.tsx +396 -0
  122. package/src/components/resources/renderers/kyverno-cells.tsx +85 -0
  123. package/src/components/resources/renderers/prometheus-cells.tsx +74 -0
  124. package/src/components/resources/renderers/traefik-cells.tsx +120 -0
  125. package/src/components/resources/renderers/trivy-cells.tsx +153 -0
  126. package/src/components/resources/renderers/trivy-shared.tsx +67 -0
  127. package/src/components/resources/renderers/velero-cells.tsx +164 -0
  128. package/src/components/resources/resource-utils-argo.ts +155 -0
  129. package/src/components/resources/resource-utils-certmanager.ts +198 -0
  130. package/src/components/resources/resource-utils-cnpg.ts +344 -0
  131. package/src/components/resources/resource-utils-eso.ts +385 -0
  132. package/src/components/resources/resource-utils-flux.ts +186 -0
  133. package/src/components/resources/resource-utils-istio.ts +372 -0
  134. package/src/components/resources/resource-utils-karpenter.ts +150 -0
  135. package/src/components/resources/resource-utils-keda.ts +203 -0
  136. package/src/components/resources/resource-utils-knative.ts +218 -0
  137. package/src/components/resources/resource-utils-kyverno.ts +224 -0
  138. package/src/components/resources/resource-utils-prometheus.ts +159 -0
  139. package/src/components/resources/resource-utils-traefik.ts +180 -0
  140. package/src/components/resources/resource-utils-trivy.ts +146 -0
  141. package/src/components/resources/resource-utils-velero.ts +293 -0
  142. package/src/components/resources/resource-utils.ts +1558 -0
  143. package/src/components/shared/EditableYamlView.tsx +302 -0
  144. package/src/components/shared/ResourceActionsBar.tsx +924 -0
  145. package/src/components/shared/ResourceRendererDispatch.tsx +557 -0
  146. package/src/components/shared/index.ts +3 -0
  147. package/src/components/timeline/index.ts +1 -0
  148. package/src/components/timeline/shared.tsx +614 -0
  149. package/src/components/topology/GroupNode.tsx +198 -0
  150. package/src/components/topology/K8sResourceNode.tsx +440 -0
  151. package/src/components/topology/TopologyControls.tsx +62 -0
  152. package/src/components/topology/TopologyFilterSidebar.tsx +377 -0
  153. package/src/components/topology/TopologyGraph.tsx +748 -0
  154. package/src/components/topology/TopologySearch.tsx +304 -0
  155. package/src/components/topology/index.ts +7 -0
  156. package/src/components/topology/layout.ts +734 -0
  157. package/src/components/topology/layout.worker.ts +272 -0
  158. package/src/components/topology/topology.css +129 -0
  159. package/src/components/ui/CodeViewer.tsx +451 -0
  160. package/src/components/ui/ConfirmDialog.tsx +188 -0
  161. package/src/components/ui/ForceDeleteConfirmDialog.tsx +57 -0
  162. package/src/components/ui/HealthRing.tsx +72 -0
  163. package/src/components/ui/MetricsChart.tsx +259 -0
  164. package/src/components/ui/ResourceBar.tsx +68 -0
  165. package/src/components/ui/Toast.tsx +225 -0
  166. package/src/components/ui/Tooltip.tsx +165 -0
  167. package/src/components/ui/YamlEditor.tsx +280 -0
  168. package/src/components/ui/drawer-components.tsx +792 -0
  169. package/src/components/ui/index.ts +10 -0
  170. package/src/components/workload/ResourceDetailDrawer.tsx +158 -0
  171. package/src/components/workload/WorkloadView.tsx +1109 -0
  172. package/src/components/workload/index.ts +2 -0
  173. package/src/hooks/index.ts +9 -0
  174. package/src/hooks/useAnimatedUnmount.ts +30 -0
  175. package/src/hooks/useKeyboardShortcuts.tsx +336 -0
  176. package/src/hooks/useRefreshAnimation.ts +58 -0
  177. package/src/index.ts +35 -0
  178. package/src/types/core.ts +894 -0
  179. package/src/types/gitops.ts +458 -0
  180. package/src/types/index.ts +2 -0
  181. package/src/utils/animation.ts +40 -0
  182. package/src/utils/api-resources.ts +185 -0
  183. package/src/utils/badge-colors.ts +399 -0
  184. package/src/utils/context-name.ts +72 -0
  185. package/src/utils/download.ts +19 -0
  186. package/src/utils/format.ts +233 -0
  187. package/src/utils/index.ts +10 -0
  188. package/src/utils/log-format.ts +300 -0
  189. package/src/utils/navigation.ts +119 -0
  190. package/src/utils/resource-hierarchy.ts +630 -0
  191. package/src/utils/resource-icons.ts +206 -0
  192. package/src/vite-env.d.ts +1 -0
  193. package/tsconfig.json +24 -0
@@ -0,0 +1,792 @@
1
+ import { useState } from 'react'
2
+ import { ChevronRight, Copy, Check, Tag, AlertTriangle, CheckCircle, ExternalLink, Layers } from 'lucide-react'
3
+ import { clsx } from 'clsx'
4
+ import { formatAge } from '../resources/resource-utils'
5
+ import { Tooltip } from './Tooltip'
6
+
7
+ // ============================================================================
8
+ // UI COMPONENTS
9
+ // ============================================================================
10
+
11
+ export function KeyValueBadge({ k, v }: { k: string; v: string }) {
12
+ return (
13
+ <span className="px-2 py-0.5 bg-theme-elevated rounded text-xs text-theme-text-secondary">
14
+ {k}={v}
15
+ </span>
16
+ )
17
+ }
18
+
19
+ export function KeyValueBadgeList({ items }: { items: Record<string, unknown> | undefined | null }) {
20
+ if (!items || Object.keys(items).length === 0) return null
21
+ return (
22
+ <div className="flex flex-wrap gap-1">
23
+ {Object.entries(items).map(([k, v]) => (
24
+ <KeyValueBadge key={k} k={k} v={String(v)} />
25
+ ))}
26
+ </div>
27
+ )
28
+ }
29
+
30
+ interface SectionProps {
31
+ title: string
32
+ icon?: React.ComponentType<{ className?: string }>
33
+ children: React.ReactNode
34
+ defaultExpanded?: boolean
35
+ }
36
+
37
+ export function Section({ title, icon: Icon, children, defaultExpanded = true }: SectionProps) {
38
+ const [expanded, setExpanded] = useState(defaultExpanded)
39
+
40
+ return (
41
+ <div className="border-b-subtle pb-4 last:border-0">
42
+ <button
43
+ onClick={() => setExpanded(!expanded)}
44
+ className="flex items-center gap-2 w-full text-left mb-2 hover:text-theme-text-primary transition-colors"
45
+ >
46
+ <ChevronRight className={clsx('w-4 h-4 text-theme-text-tertiary transition-transform duration-200', expanded && 'rotate-90')} />
47
+ {Icon && <Icon className="w-4 h-4 text-theme-text-secondary" />}
48
+ <span className="text-sm font-medium text-theme-text-secondary">{title}</span>
49
+ </button>
50
+ <div
51
+ className="grid transition-[grid-template-rows] duration-200 ease-out"
52
+ style={{ gridTemplateRows: expanded ? '1fr' : '0fr' }}
53
+ >
54
+ <div className="overflow-hidden">
55
+ <div className="pl-6">{children}</div>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ )
60
+ }
61
+
62
+ interface ExpandableSectionProps {
63
+ title: string
64
+ children: React.ReactNode
65
+ defaultExpanded?: boolean
66
+ }
67
+
68
+ export function ExpandableSection({ title, children, defaultExpanded = true }: ExpandableSectionProps) {
69
+ const [expanded, setExpanded] = useState(defaultExpanded)
70
+
71
+ return (
72
+ <div>
73
+ <button
74
+ onClick={() => setExpanded(!expanded)}
75
+ className="flex items-center gap-2 text-sm text-theme-text-secondary hover:text-theme-text-primary transition-colors mb-1"
76
+ >
77
+ <ChevronRight className={clsx('w-3.5 h-3.5 transition-transform duration-200', expanded && 'rotate-90')} />
78
+ {title}
79
+ </button>
80
+ <div
81
+ className="grid transition-[grid-template-rows] duration-200 ease-out"
82
+ style={{ gridTemplateRows: expanded ? '1fr' : '0fr' }}
83
+ >
84
+ <div className="overflow-hidden">
85
+ <div className="ml-5">{children}</div>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ )
90
+ }
91
+
92
+ export function PropertyList({ children }: { children: React.ReactNode }) {
93
+ return <div className="space-y-2">{children}</div>
94
+ }
95
+
96
+ interface PropertyProps {
97
+ label: React.ReactNode
98
+ value: React.ReactNode
99
+ copyable?: boolean
100
+ onCopy?: (text: string, key: string) => void
101
+ copied?: string | null
102
+ }
103
+
104
+ // Helper to check if value is a React element
105
+ function isReactElement(value: unknown): value is React.ReactElement {
106
+ return value !== null && typeof value === 'object' && '$$typeof' in (value as object)
107
+ }
108
+
109
+ export function Property({ label, value, copyable, onCopy, copied }: PropertyProps) {
110
+ if (value === undefined || value === null || value === '') return null
111
+ const labelKey = typeof label === 'string' ? label : 'value'
112
+
113
+ // If value is a React element, render it directly; otherwise convert to string
114
+ const displayValue = isReactElement(value) ? value : String(value)
115
+ const strValue = isReactElement(value) ? '' : String(value)
116
+
117
+ return (
118
+ <div className="flex items-start gap-2 text-sm">
119
+ <span className="text-theme-text-tertiary w-28 shrink-0">{label}</span>
120
+ <span className="text-theme-text-primary break-all flex-1">{displayValue}</span>
121
+ {copyable && onCopy && !isReactElement(value) && (
122
+ <button
123
+ onClick={() => onCopy(strValue, labelKey)}
124
+ className="p-0.5 text-theme-text-tertiary hover:text-theme-text-primary shrink-0"
125
+ >
126
+ {copied === label ? <Check className="w-3 h-3 text-green-400" /> : <Copy className="w-3 h-3" />}
127
+ </button>
128
+ )}
129
+ </div>
130
+ )
131
+ }
132
+
133
+ // ============================================================================
134
+ // COMMON SECTIONS
135
+ // ============================================================================
136
+
137
+ export function ConditionsSection({ conditions }: { conditions?: any[] }) {
138
+ if (!conditions || conditions.length === 0) return null
139
+
140
+ return (
141
+ <Section title={`Conditions (${conditions.length})`} defaultExpanded={conditions.length <= 4}>
142
+ <div className="space-y-2">
143
+ {conditions.map((cond: any) => (
144
+ <div key={cond.type} className="flex items-start gap-2 text-sm">
145
+ <span className={clsx(
146
+ 'w-4 h-4 rounded-full flex items-center justify-center text-xs shrink-0 mt-0.5',
147
+ cond.status === 'True' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
148
+ )}>
149
+ {cond.status === 'True' ? '✓' : '✗'}
150
+ </span>
151
+ <div>
152
+ <div className="text-theme-text-primary">{cond.type}</div>
153
+ {cond.message && <div className="text-xs text-theme-text-tertiary">{cond.message}</div>}
154
+ </div>
155
+ </div>
156
+ ))}
157
+ </div>
158
+ </Section>
159
+ )
160
+ }
161
+
162
+ // ============================================================================
163
+ // ALERT BANNER
164
+ // ============================================================================
165
+
166
+ const ALERT_COLORS = {
167
+ error: { bg: 'bg-red-500/10', border: 'border-red-500/30', title: 'text-red-400', message: 'text-red-300/80', list: 'text-red-300', bullet: 'text-red-400/60' },
168
+ warning: { bg: 'bg-yellow-500/10', border: 'border-yellow-500/30', title: 'text-yellow-400', message: 'text-yellow-300/80', list: 'text-yellow-300', bullet: 'text-yellow-400/60' },
169
+ info: { bg: 'bg-blue-500/10', border: 'border-blue-500/30', title: 'text-blue-400', message: 'text-blue-300/80', list: 'text-blue-300', bullet: 'text-blue-400/60' },
170
+ success: { bg: 'bg-green-500/10', border: 'border-green-500/30', title: 'text-green-400', message: 'text-green-300/80', list: 'text-green-300', bullet: 'text-green-400/60' },
171
+ } as const
172
+
173
+ const DEFAULT_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
174
+ error: AlertTriangle,
175
+ warning: AlertTriangle,
176
+ info: AlertTriangle,
177
+ success: CheckCircle,
178
+ }
179
+
180
+ interface AlertBannerProps {
181
+ variant: 'error' | 'warning' | 'info' | 'success'
182
+ icon?: React.ComponentType<{ className?: string }>
183
+ title: string
184
+ message?: React.ReactNode
185
+ items?: string[]
186
+ children?: React.ReactNode
187
+ }
188
+
189
+ export function AlertBanner({ variant, icon, title, message, items, children }: AlertBannerProps) {
190
+ const colors = ALERT_COLORS[variant]
191
+ const Icon = icon || DEFAULT_ICONS[variant]
192
+ const hasBody = message || items || children
193
+
194
+ return (
195
+ <div className={clsx('mb-4 p-3 border rounded-lg', colors.bg, colors.border)}>
196
+ <div className={clsx('flex gap-2', hasBody ? 'items-start' : 'items-center')}>
197
+ <Icon className={clsx('w-4 h-4 shrink-0', colors.title, hasBody && 'mt-0.5')} />
198
+ {hasBody ? (
199
+ <div className="flex-1 min-w-0">
200
+ <div className={clsx('text-sm font-medium', colors.title, items && 'mb-1')}>{title}</div>
201
+ {message && <div className={clsx('text-xs mt-1 break-all', colors.message)}>{message}</div>}
202
+ {items && items.length > 0 && (
203
+ <ul className={clsx('text-xs space-y-1', colors.list)}>
204
+ {items.map((item, i) => (
205
+ <li key={i} className="flex items-start gap-1.5">
206
+ <span className={clsx(colors.bullet, 'mt-0.5')}>•</span>
207
+ <span>{item}</span>
208
+ </li>
209
+ ))}
210
+ </ul>
211
+ )}
212
+ {children}
213
+ </div>
214
+ ) : (
215
+ <div className={clsx('text-sm font-medium', colors.title)}>{title}</div>
216
+ )}
217
+ </div>
218
+ </div>
219
+ )
220
+ }
221
+
222
+ /** KNative "Not Ready" alert banner — shared across all KNative renderers */
223
+ export function KnativeNotReadyBanner({ status, data, resourceType }: { status: { level: string }; data: any; resourceType: string }) {
224
+ if (status.level !== 'unhealthy') return null
225
+ const message = (data.status?.conditions || []).find((c: any) => c.type === 'Ready')?.message
226
+ || `This ${resourceType} is not in a ready state.`
227
+ return <AlertBanner variant="error" title={`${resourceType} Not Ready`} message={message} />
228
+ }
229
+
230
+ /** Problem type for ProblemAlerts component */
231
+ export interface Problem {
232
+ color: 'red' | 'yellow'
233
+ message: string
234
+ }
235
+
236
+ /** Displays a list of problem alerts (warnings and errors) for GitOps resources */
237
+ export function ProblemAlerts({ problems }: { problems: Problem[] }) {
238
+ if (problems.length === 0) return null
239
+
240
+ return (
241
+ <>
242
+ {problems.map((problem, i) => (
243
+ <AlertBanner
244
+ key={i}
245
+ variant={problem.color === 'red' ? 'error' : 'warning'}
246
+ title={problem.color === 'red' ? 'Issue Detected' : 'Warning'}
247
+ message={problem.message}
248
+ />
249
+ ))}
250
+ </>
251
+ )
252
+ }
253
+
254
+ export function LabelsSection({ data }: { data: any }) {
255
+ const labels = data.metadata?.labels
256
+ if (!labels || Object.keys(labels).length === 0) return null
257
+ const count = Object.keys(labels).length
258
+
259
+ return (
260
+ <Section title={`Labels (${count})`} icon={Tag} defaultExpanded={count <= 5}>
261
+ <KeyValueBadgeList items={labels} />
262
+ </Section>
263
+ )
264
+ }
265
+
266
+ export function AnnotationsSection({ data }: { data: any }) {
267
+ const annotations = data.metadata?.annotations
268
+ if (!annotations || Object.keys(annotations).length === 0) return null
269
+ const count = Object.keys(annotations).length
270
+
271
+ return (
272
+ <Section title={`Annotations (${count})`} defaultExpanded={count <= 3}>
273
+ <div className="space-y-1 max-h-48 overflow-y-auto">
274
+ {Object.entries(annotations).map(([k, v]) => (
275
+ <div key={k} className="text-xs">
276
+ <span className="text-theme-text-tertiary">{k}:</span>
277
+ <span className="text-theme-text-secondary ml-1 break-all">{v as string}</span>
278
+ </div>
279
+ ))}
280
+ </div>
281
+ </Section>
282
+ )
283
+ }
284
+
285
+ export function MetadataSection({ data }: { data: any }) {
286
+ const meta = data.metadata
287
+ if (!meta) return null
288
+
289
+ return (
290
+ <Section title="Metadata" defaultExpanded>
291
+ <PropertyList>
292
+ <Property label="UID" value={meta.uid} />
293
+ <Property label="Resource Version" value={meta.resourceVersion} />
294
+ <Property label="Generation" value={meta.generation} />
295
+ <Property label="Created" value={meta.creationTimestamp ? (
296
+ <Tooltip content={new Date(meta.creationTimestamp).toLocaleString()}>
297
+ <span className="border-b border-dotted border-theme-text-tertiary cursor-help">{formatAge(meta.creationTimestamp)}</span>
298
+ </Tooltip>
299
+ ) : '-'} />
300
+ </PropertyList>
301
+ </Section>
302
+ )
303
+ }
304
+
305
+ export function PodTemplateSection({ template }: { template: any }) {
306
+ if (!template) return null
307
+ const initContainers = template.spec?.initContainers || []
308
+ const containers = template.spec?.containers || []
309
+
310
+ return (
311
+ <div className="space-y-2">
312
+ {initContainers.length > 0 && (
313
+ <>
314
+ <div className="text-xs text-theme-text-tertiary font-medium uppercase tracking-wide">Init Containers</div>
315
+ {initContainers.map((c: any) => (
316
+ <div key={c.name} className="bg-theme-elevated/30 rounded p-2 text-sm border-l-2 border-yellow-500/40">
317
+ <div className="font-medium text-theme-text-primary">{c.name}</div>
318
+ <div className="text-xs text-theme-text-secondary truncate" title={c.image}>{c.image}</div>
319
+ {(c.command || c.args) && (
320
+ <div className="text-xs text-theme-text-tertiary font-mono mt-1 truncate" title={[...(c.command || []), ...(c.args || [])].join(' ')}>
321
+ $ {[...(c.command || []), ...(c.args || [])].join(' ')}
322
+ </div>
323
+ )}
324
+ </div>
325
+ ))}
326
+ <div className="text-xs text-theme-text-tertiary font-medium uppercase tracking-wide mt-3">Containers</div>
327
+ </>
328
+ )}
329
+ {containers.map((c: any) => (
330
+ <div key={c.name} className="bg-theme-elevated/30 rounded p-2 text-sm">
331
+ <div className="font-medium text-theme-text-primary">{c.name}</div>
332
+ <div className="text-xs text-theme-text-secondary truncate" title={c.image}>{c.image}</div>
333
+ {c.ports && (
334
+ <div className="text-xs text-theme-text-tertiary mt-1">
335
+ Ports: {c.ports.map((p: any) => `${p.containerPort}/${p.protocol || 'TCP'}`).join(', ')}
336
+ </div>
337
+ )}
338
+ </div>
339
+ ))}
340
+ </div>
341
+ )
342
+ }
343
+
344
+ // ============================================================================
345
+ // EXTERNAL LINKS SECTION
346
+ // ============================================================================
347
+
348
+ const A8R_LINK_KEYS: Record<string, string> = {
349
+ 'a8r.io/runbook': 'Runbook',
350
+ 'a8r.io/documentation': 'Documentation',
351
+ 'a8r.io/repository': 'Repository',
352
+ 'a8r.io/logs': 'Logs',
353
+ 'a8r.io/chat': 'Chat',
354
+ 'a8r.io/incidents': 'Incidents',
355
+ 'a8r.io/bugs': 'Bugs',
356
+ }
357
+
358
+ const A8R_TEXT_KEYS: Record<string, string> = {
359
+ 'a8r.io/owner': 'Owner',
360
+ 'a8r.io/description': 'Description',
361
+ }
362
+
363
+ export function ExternalLinksSection({ data }: { data: any }) {
364
+ const annotations = data.metadata?.annotations
365
+ if (!annotations) return null
366
+
367
+ const links: { label: string; url: string }[] = []
368
+ const textProps: { label: string; value: string }[] = []
369
+
370
+ // ArgoCD external link
371
+ const argoLink = annotations['link.argocd.argoproj.io/external-link']
372
+ if (argoLink) links.push({ label: 'External Link', url: argoLink })
373
+
374
+ // a8r.io links
375
+ for (const [key, label] of Object.entries(A8R_LINK_KEYS)) {
376
+ const val = annotations[key]
377
+ if (val) links.push({ label, url: val })
378
+ }
379
+
380
+ // a8r.io text properties
381
+ for (const [key, label] of Object.entries(A8R_TEXT_KEYS)) {
382
+ const val = annotations[key]
383
+ if (val) textProps.push({ label, value: val })
384
+ }
385
+
386
+ if (links.length === 0 && textProps.length === 0) return null
387
+
388
+ return (
389
+ <Section title={`External Info (${links.length + textProps.length})`} icon={ExternalLink} defaultExpanded>
390
+ <div className="space-y-2">
391
+ {textProps.map(({ label, value }) => (
392
+ <div key={label} className="text-sm">
393
+ <span className="text-theme-text-tertiary">{label}: </span>
394
+ <span className="text-theme-text-primary">{value}</span>
395
+ </div>
396
+ ))}
397
+ {links.map(({ label, url }) => (
398
+ <div key={label} className="text-sm">
399
+ <a
400
+ href={url}
401
+ target="_blank"
402
+ rel="noopener noreferrer"
403
+ className="text-blue-400 hover:text-blue-300 hover:underline inline-flex items-center gap-1"
404
+ >
405
+ {label}
406
+ <ExternalLink className="w-3 h-3" />
407
+ </a>
408
+ </div>
409
+ ))}
410
+ </div>
411
+ </Section>
412
+ )
413
+ }
414
+
415
+ // ============================================================================
416
+ // APP INFO SECTION (app.kubernetes.io/* labels)
417
+ // ============================================================================
418
+
419
+ const APP_LABELS: Record<string, string> = {
420
+ 'app.kubernetes.io/name': 'App Name',
421
+ 'app.kubernetes.io/version': 'Version',
422
+ 'app.kubernetes.io/component': 'Component',
423
+ 'app.kubernetes.io/part-of': 'Part Of',
424
+ 'app.kubernetes.io/managed-by': 'Managed By',
425
+ 'app.kubernetes.io/instance': 'Instance',
426
+ }
427
+
428
+ export function AppInfoSection({ data }: { data: any }) {
429
+ const labels = data.metadata?.labels
430
+ if (!labels) return null
431
+
432
+ const entries = Object.entries(APP_LABELS)
433
+ .map(([key, label]) => ({ label, value: labels[key] }))
434
+ .filter(({ value }) => value)
435
+
436
+ if (entries.length === 0) return null
437
+
438
+ return (
439
+ <Section title="App Info" icon={Layers} defaultExpanded>
440
+ <PropertyList>
441
+ {entries.map(({ label, value }) => (
442
+ <Property key={label} label={label} value={value} />
443
+ ))}
444
+ </PropertyList>
445
+ </Section>
446
+ )
447
+ }
448
+
449
+ // ============================================================================
450
+ // HELPERS
451
+ // ============================================================================
452
+
453
+ export function getKindColor(kind: string): string {
454
+ const k = kind.toLowerCase()
455
+ // Use darker text for light mode contrast, brighter for dark mode
456
+ if (k.includes('pod')) return 'bg-lime-500/20 text-lime-700 dark:text-lime-300 border-lime-500/30'
457
+ if (k.includes('deployment')) return 'bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 border-emerald-500/30'
458
+ if (k.includes('service')) return 'bg-blue-500/20 text-blue-700 dark:text-blue-300 border-blue-500/30'
459
+ if (k.includes('gateway')) return 'bg-violet-500/20 text-violet-700 dark:text-violet-300 border-violet-500/30'
460
+ if (k.includes('route')) return 'bg-purple-500/20 text-purple-700 dark:text-purple-300 border-purple-500/30'
461
+ if (k.includes('ingress')) return 'bg-violet-500/20 text-violet-700 dark:text-violet-300 border-violet-500/30'
462
+ if (k.includes('configmap')) return 'bg-amber-500/20 text-amber-700 dark:text-amber-300 border-amber-500/30'
463
+ if (k.endsWith('report')) return 'bg-rose-500/20 text-rose-700 dark:text-rose-300 border-rose-500/30'
464
+ if (k.includes('secret')) return 'bg-red-500/20 text-red-700 dark:text-red-300 border-red-500/30'
465
+ if (k.includes('daemonset')) return 'bg-teal-500/20 text-teal-700 dark:text-teal-300 border-teal-500/30'
466
+ if (k.includes('statefulset')) return 'bg-cyan-500/20 text-cyan-700 dark:text-cyan-300 border-cyan-500/30'
467
+ if (k.includes('replicaset')) return 'bg-green-500/20 text-green-700 dark:text-green-300 border-green-500/30'
468
+ if (k.includes('hpa') || k.includes('horizontalpodautoscaler')) return 'bg-pink-500/20 text-pink-700 dark:text-pink-300 border-pink-500/30'
469
+ if (k.includes('cronjob')) return 'bg-orange-500/20 text-orange-700 dark:text-orange-300 border-orange-500/30'
470
+ if (k.includes('job')) return 'bg-orange-500/20 text-orange-700 dark:text-orange-300 border-orange-500/30'
471
+ if (k.includes('node')) return 'bg-gray-500/20 text-gray-700 dark:text-gray-300 border-gray-500/30'
472
+ if (k.includes('namespace')) return 'bg-blue-500/20 text-blue-700 dark:text-blue-300 border-blue-500/30'
473
+ if (k.includes('persistentvolume')) return 'bg-purple-500/20 text-purple-700 dark:text-purple-300 border-purple-500/30'
474
+ // Default color for CRDs
475
+ return 'bg-fuchsia-500/20 text-fuchsia-700 dark:text-fuchsia-300 border-fuchsia-500/30'
476
+ }
477
+
478
+ export function formatKindName(kind: string): string {
479
+ const k = kind.toLowerCase()
480
+ const names: Record<string, string> = {
481
+ pods: 'Pod', deployments: 'Deployment', daemonsets: 'DaemonSet', statefulsets: 'StatefulSet',
482
+ replicasets: 'ReplicaSet', services: 'Service', ingresses: 'Ingress',
483
+ gateways: 'Gateway', httproutes: 'HTTPRoute', grpcroutes: 'GRPCRoute',
484
+ tcproutes: 'TCPRoute', tlsroutes: 'TLSRoute', configmaps: 'ConfigMap',
485
+ secrets: 'Secret', jobs: 'Job', cronjobs: 'CronJob', hpas: 'HPA',
486
+ horizontalpodautoscalers: 'HPA', nodes: 'Node', namespaces: 'Namespace',
487
+ persistentvolumeclaims: 'PVC', persistentvolumes: 'PV',
488
+ }
489
+ if (names[k]) return names[k]
490
+
491
+ // For unknown kinds (CRDs), use the original kind name
492
+ // or format it nicely if it's a plural name
493
+ if (kind.endsWith('s') && !kind.endsWith('ss')) {
494
+ // Try to singularize simple plurals
495
+ const singular = kind.slice(0, -1)
496
+ // Capitalize first letter
497
+ return singular.charAt(0).toUpperCase() + singular.slice(1)
498
+ }
499
+ return kind
500
+ }
501
+
502
+ // Type for copy handler
503
+ export type CopyHandler = (text: string, key: string) => void
504
+
505
+ // ============================================================================
506
+ // RELATED RESOURCES SECTION
507
+ // ============================================================================
508
+
509
+ import type { TimelineEvent, Relationships, ResourceRef } from '../../types'
510
+ import { isChangeEvent, isK8sEvent } from '../../types'
511
+ import { Link } from 'lucide-react'
512
+
513
+ interface RelatedResourcesSectionProps {
514
+ relationships: Relationships | undefined
515
+ onNavigate?: (ref: ResourceRef) => void
516
+ }
517
+
518
+ function dedupeRefs(refs: ResourceRef[]): ResourceRef[] {
519
+ const seen = new Set<string>()
520
+ return refs.filter(ref => {
521
+ const key = `${ref.kind}/${ref.namespace}/${ref.name}`
522
+ if (seen.has(key)) return false
523
+ seen.add(key)
524
+ return true
525
+ })
526
+ }
527
+
528
+ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedResourcesSectionProps) {
529
+ if (!relationships) return null
530
+
531
+ const hasRelationships =
532
+ relationships.owner ||
533
+ relationships.deployment ||
534
+ (relationships.children && relationships.children.length > 0) ||
535
+ (relationships.services && relationships.services.length > 0) ||
536
+ (relationships.ingresses && relationships.ingresses.length > 0) ||
537
+ (relationships.gateways && relationships.gateways.length > 0) ||
538
+ (relationships.routes && relationships.routes.length > 0) ||
539
+ (relationships.pods && relationships.pods.length > 0) ||
540
+ (relationships.configRefs && relationships.configRefs.length > 0) ||
541
+ (relationships.consumers && relationships.consumers.length > 0) ||
542
+ (relationships.scalers && relationships.scalers.length > 0) ||
543
+ (relationships.policies && relationships.policies.length > 0) ||
544
+ relationships.scaleTarget
545
+
546
+ if (!hasRelationships) return null
547
+
548
+ return (
549
+ <Section title="Related Resources" icon={Link} defaultExpanded>
550
+ <div className="space-y-3">
551
+ {relationships.owner && (
552
+ <RelationshipGroup label="Owner" refs={[relationships.owner]} onNavigate={onNavigate} />
553
+ )}
554
+ {relationships.deployment && (
555
+ <RelationshipGroup label="Deployment" refs={[relationships.deployment]} onNavigate={onNavigate} />
556
+ )}
557
+ {relationships.children && relationships.children.length > 0 && (
558
+ <RelationshipGroup label="Children" refs={dedupeRefs(relationships.children)} onNavigate={onNavigate} />
559
+ )}
560
+ {relationships.services && relationships.services.length > 0 && (
561
+ <RelationshipGroup label="Services" refs={dedupeRefs(relationships.services)} onNavigate={onNavigate} />
562
+ )}
563
+ {relationships.ingresses && relationships.ingresses.length > 0 && (
564
+ <RelationshipGroup label="Ingresses" refs={dedupeRefs(relationships.ingresses)} onNavigate={onNavigate} />
565
+ )}
566
+ {relationships.gateways && relationships.gateways.length > 0 && (
567
+ <RelationshipGroup label="Gateways" refs={dedupeRefs(relationships.gateways)} onNavigate={onNavigate} />
568
+ )}
569
+ {relationships.routes && relationships.routes.length > 0 && (
570
+ <RelationshipGroup label="Routes" refs={dedupeRefs(relationships.routes)} onNavigate={onNavigate} />
571
+ )}
572
+ {relationships.pods && relationships.pods.length > 0 && (
573
+ <RelationshipGroup label="Pods" refs={dedupeRefs(relationships.pods)} onNavigate={onNavigate} />
574
+ )}
575
+ {relationships.configRefs && relationships.configRefs.length > 0 && (
576
+ <RelationshipGroup label="Configuration" refs={dedupeRefs(relationships.configRefs)} onNavigate={onNavigate} />
577
+ )}
578
+ {relationships.consumers && relationships.consumers.length > 0 && (
579
+ <RelationshipGroup label="Used By" refs={dedupeRefs(relationships.consumers)} onNavigate={onNavigate} />
580
+ )}
581
+ {relationships.scalers && relationships.scalers.length > 0 && (
582
+ <RelationshipGroup label="Autoscaler" refs={dedupeRefs(relationships.scalers)} onNavigate={onNavigate} />
583
+ )}
584
+ {relationships.policies && relationships.policies.length > 0 && (
585
+ <RelationshipGroup label="Disruption Budget" refs={dedupeRefs(relationships.policies)} onNavigate={onNavigate} />
586
+ )}
587
+ {relationships.scaleTarget && (
588
+ <RelationshipGroup label="Scale Target" refs={[relationships.scaleTarget]} onNavigate={onNavigate} />
589
+ )}
590
+ </div>
591
+ </Section>
592
+ )
593
+ }
594
+
595
+ interface RelationshipGroupProps {
596
+ label: string
597
+ refs: ResourceRef[]
598
+ onNavigate?: (ref: ResourceRef) => void
599
+ }
600
+
601
+ const RELATIONSHIP_TRUNCATE_LIMIT = 10
602
+
603
+ function RelationshipGroup({ label, refs, onNavigate }: RelationshipGroupProps) {
604
+ const [showAll, setShowAll] = useState(false)
605
+ if (!refs || refs.length === 0) return null
606
+
607
+ const truncated = !showAll && refs.length > RELATIONSHIP_TRUNCATE_LIMIT
608
+ const visibleRefs = truncated ? refs.slice(0, RELATIONSHIP_TRUNCATE_LIMIT) : refs
609
+
610
+ return (
611
+ <div>
612
+ <div className="text-xs text-theme-text-tertiary mb-1">{label}{refs.length > 1 ? ` (${refs.length})` : ''}</div>
613
+ <div className="flex flex-wrap gap-1">
614
+ {visibleRefs.map((resourceRef, i) => (
615
+ <ResourceRefBadge key={`${resourceRef.kind}-${resourceRef.namespace}-${resourceRef.name}-${i}`} resourceRef={resourceRef} onClick={onNavigate} />
616
+ ))}
617
+ {truncated && (
618
+ <button
619
+ onClick={() => setShowAll(true)}
620
+ className="px-2 py-0.5 text-xs rounded border border-theme-border text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated transition-colors"
621
+ >
622
+ Show all {refs.length}
623
+ </button>
624
+ )}
625
+ </div>
626
+ </div>
627
+ )
628
+ }
629
+
630
+ export interface ResourceRefBadgeProps {
631
+ resourceRef: ResourceRef
632
+ onClick?: (ref: ResourceRef) => void
633
+ }
634
+
635
+ /** Reusable chip/badge for showing a related resource with click-to-navigate */
636
+ export function ResourceRefBadge({ resourceRef, onClick }: ResourceRefBadgeProps) {
637
+ const kindClass = getKindColor(resourceRef.kind)
638
+ const kindName = formatKindForRef(resourceRef.kind)
639
+
640
+ if (onClick) {
641
+ return (
642
+ <button
643
+ onClick={() => onClick(resourceRef)}
644
+ className={clsx(
645
+ 'px-2 py-0.5 text-xs rounded border transition-colors hover:brightness-125',
646
+ kindClass
647
+ )}
648
+ title={`${resourceRef.kind}: ${resourceRef.namespace}/${resourceRef.name}`}
649
+ >
650
+ <span className="opacity-60">{kindName}/</span>
651
+ {resourceRef.name}
652
+ </button>
653
+ )
654
+ }
655
+
656
+ return (
657
+ <span
658
+ className={clsx('px-2 py-0.5 text-xs rounded border', kindClass)}
659
+ title={`${resourceRef.kind}: ${resourceRef.namespace}/${resourceRef.name}`}
660
+ >
661
+ <span className="opacity-60">{kindName}/</span>
662
+ {resourceRef.name}
663
+ </span>
664
+ )
665
+ }
666
+
667
+ /** Inline text link for navigating to a resource. Renders as plain text when onNavigate is absent. */
668
+ export function ResourceLink({ name, kind, namespace = '', group, label, onNavigate }: {
669
+ name: string
670
+ kind: string
671
+ namespace?: string
672
+ group?: string
673
+ label?: React.ReactNode
674
+ onNavigate?: ((ref: { kind: string; namespace: string; name: string; group?: string }) => void) | null
675
+ }) {
676
+ if (!onNavigate) return <>{label || name}</>
677
+ return (
678
+ <button
679
+ onClick={() => onNavigate({ kind, namespace, name, group })}
680
+ className="text-blue-400 hover:text-blue-300 hover:underline"
681
+ >
682
+ {label || name}
683
+ </button>
684
+ )
685
+ }
686
+
687
+ function formatKindForRef(kind: string): string {
688
+ const k = kind.toLowerCase()
689
+ const shortNames: Record<string, string> = {
690
+ deployment: 'deploy',
691
+ daemonset: 'ds',
692
+ statefulset: 'sts',
693
+ replicaset: 'rs',
694
+ configmap: 'cm',
695
+ service: 'svc',
696
+ ingress: 'ing',
697
+ gateway: 'gw',
698
+ httproute: 'hr',
699
+ grpcroute: 'grpc',
700
+ tcproute: 'tcp',
701
+ tlsroute: 'tls',
702
+ secret: 'secret',
703
+ pod: 'pod',
704
+ job: 'job',
705
+ cronjob: 'cj',
706
+ hpa: 'hpa',
707
+ }
708
+ return shortNames[k] || k
709
+ }
710
+
711
+ // ============================================================================
712
+ // EVENTS SECTION
713
+ // ============================================================================
714
+
715
+ interface EventsSectionProps {
716
+ events: TimelineEvent[]
717
+ isLoading?: boolean
718
+ /** Optional hint shown below the event list (e.g. "See Timeline tab for related resources") */
719
+ hint?: React.ReactNode
720
+ }
721
+
722
+ export function EventsSection({ events, isLoading, hint }: EventsSectionProps) {
723
+ if (isLoading) {
724
+ return (
725
+ <Section title="Recent Events" defaultExpanded>
726
+ <div className="text-sm text-theme-text-tertiary">Loading events...</div>
727
+ </Section>
728
+ )
729
+ }
730
+
731
+ if (!events || events.length === 0) {
732
+ return (
733
+ <Section title="Recent Events" defaultExpanded={false}>
734
+ <div className="text-sm text-theme-text-tertiary">No recent events</div>
735
+ {hint && <div className="mt-2">{hint}</div>}
736
+ </Section>
737
+ )
738
+ }
739
+
740
+ return (
741
+ <Section title={`Recent Events (${events.length})`} defaultExpanded>
742
+ <div className="space-y-2 max-h-64 overflow-y-auto">
743
+ {events.map((event, i) => (
744
+ <div
745
+ key={`${event.id}-${i}`}
746
+ className={clsx(
747
+ 'p-2 rounded text-sm border-l-2',
748
+ event.eventType === 'Warning' || (isChangeEvent(event) && event.eventType === 'delete')
749
+ ? 'bg-red-500/10 border-red-500'
750
+ : isK8sEvent(event)
751
+ ? 'bg-blue-500/10 border-blue-500'
752
+ : 'bg-theme-elevated/30 border-theme-border'
753
+ )}
754
+ >
755
+ <div className="flex items-center justify-between gap-2">
756
+ <span className="font-medium text-theme-text-primary">
757
+ {isK8sEvent(event) ? event.reason : event.eventType}
758
+ </span>
759
+ <span className="text-xs text-theme-text-tertiary">
760
+ {formatEventTime(event.timestamp)}
761
+ </span>
762
+ </div>
763
+ {event.message && (
764
+ <div className="text-xs text-theme-text-secondary mt-1 line-clamp-2">
765
+ {event.message}
766
+ </div>
767
+ )}
768
+ {isChangeEvent(event) && event.diff?.summary && (
769
+ <div className="text-xs text-theme-text-secondary mt-1">
770
+ {event.diff.summary}
771
+ </div>
772
+ )}
773
+ </div>
774
+ ))}
775
+ </div>
776
+ {hint && <div className="mt-2">{hint}</div>}
777
+ </Section>
778
+ )
779
+ }
780
+
781
+ function formatEventTime(timestamp: string): string {
782
+ const date = new Date(timestamp)
783
+ const now = new Date()
784
+ const diffMs = now.getTime() - date.getTime()
785
+ const diffMins = Math.floor(diffMs / 60000)
786
+ const diffHours = Math.floor(diffMins / 60)
787
+
788
+ if (diffMins < 1) return 'just now'
789
+ if (diffMins < 60) return `${diffMins}m ago`
790
+ if (diffHours < 24) return `${diffHours}h ago`
791
+ return date.toLocaleDateString()
792
+ }