@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,924 @@
1
+ import { useState, useRef } from 'react'
2
+ import {
3
+ RefreshCw,
4
+ Terminal,
5
+ FileText,
6
+ Trash2,
7
+ Play,
8
+ Pause,
9
+ Box,
10
+ ChevronDown,
11
+ History,
12
+ GitCompare,
13
+ Code,
14
+ FileCode2,
15
+ X,
16
+ } from 'lucide-react'
17
+ import { createTwoFilesPatch } from 'diff'
18
+ import { clsx } from 'clsx'
19
+ import { ForceDeleteConfirmDialog } from '../ui/ForceDeleteConfirmDialog'
20
+ import type { SelectedResource, WorkloadRevision } from '../../types'
21
+ import { formatKindName } from '../ui/drawer-components'
22
+
23
+ // ============================================================================
24
+ // ACTIONS BAR - Interactive buttons that change based on resource kind
25
+ // ============================================================================
26
+
27
+ interface ResourceActionsBarProps {
28
+ resource: SelectedResource
29
+ data: any
30
+ onClose?: () => void
31
+ hideLogs?: boolean
32
+ showYaml?: boolean
33
+ onToggleYaml?: () => void
34
+
35
+ // Capabilities (injected by platform)
36
+ canExec?: boolean
37
+ canViewLogs?: boolean
38
+ canPortForward?: boolean
39
+
40
+ // Dock actions (injected by platform)
41
+ onOpenTerminal?: (params: { namespace: string; podName: string; containerName: string; containers: string[] }) => void
42
+ onOpenLogs?: (params: { namespace: string; podName: string; containers: string[]; containerName?: string }) => void
43
+ onOpenWorkloadLogs?: (params: { namespace: string; workloadKind: string; workloadName: string }) => void
44
+ onCopyCommand?: (text: string, message: string, event: React.MouseEvent) => void
45
+
46
+ // Port forward render prop (injected by platform)
47
+ renderPortForward?: (props: { type: 'pod' | 'service'; namespace: string; name: string; className?: string }) => React.ReactNode
48
+
49
+ // Delete
50
+ onDelete?: (params: { kind: string; namespace: string; name: string; force: boolean }, callbacks?: { onSuccess?: () => void; onError?: (err: unknown) => void }) => void
51
+ isDeleting?: boolean
52
+
53
+ // Workload restart
54
+ onRestart?: (params: { kind: string; namespace: string; name: string }, callbacks?: { onSuccess?: () => void; onError?: (err: unknown) => void }) => void
55
+ isRestarting?: boolean
56
+
57
+ // Rollback
58
+ revisions?: WorkloadRevision[]
59
+ revisionsLoading?: boolean
60
+ revisionsError?: Error | null
61
+ onRollback?: (params: { kind: string; namespace: string; name: string; revision: number }, callbacks?: { onSuccess?: () => void; onError?: (err: unknown) => void }) => void
62
+ isRollingBack?: boolean
63
+
64
+ // CronJob actions
65
+ onTriggerCronJob?: (params: { namespace: string; name: string }) => void
66
+ isTriggeringCronJob?: boolean
67
+ onSuspendCronJob?: (params: { namespace: string; name: string }) => void
68
+ isSuspendingCronJob?: boolean
69
+ onResumeCronJob?: (params: { namespace: string; name: string }) => void
70
+ isResumingCronJob?: boolean
71
+
72
+ // Flux actions
73
+ onFluxReconcile?: (params: { kind: string; namespace: string; name: string }) => void
74
+ isFluxReconciling?: boolean
75
+ onFluxSyncWithSource?: (params: { kind: string; namespace: string; name: string }) => void
76
+ isFluxSyncing?: boolean
77
+ onFluxSuspend?: (params: { kind: string; namespace: string; name: string }) => void
78
+ isFluxSuspending?: boolean
79
+ onFluxResume?: (params: { kind: string; namespace: string; name: string }) => void
80
+ isFluxResuming?: boolean
81
+
82
+ // Argo actions
83
+ onArgoSync?: (params: { namespace: string; name: string }) => void
84
+ isArgoSyncing?: boolean
85
+ onArgoRefresh?: (params: { namespace: string; name: string; hard: boolean }) => void
86
+ isArgoRefreshing?: boolean
87
+ onArgoSuspend?: (params: { namespace: string; name: string }) => void
88
+ isArgoSuspending?: boolean
89
+ onArgoResume?: (params: { namespace: string; name: string }) => void
90
+ isArgoResuming?: boolean
91
+ }
92
+
93
+ export function ResourceActionsBar({
94
+ resource, data, onClose, hideLogs, showYaml, onToggleYaml,
95
+ canExec, canViewLogs, canPortForward,
96
+ onOpenTerminal, onOpenLogs: openLogs, onOpenWorkloadLogs: openWorkloadLogs, onCopyCommand,
97
+ renderPortForward,
98
+ onDelete, isDeleting,
99
+ onRestart, isRestarting,
100
+ revisions: revisionsList, revisionsLoading, revisionsError, onRollback, isRollingBack,
101
+ onTriggerCronJob, isTriggeringCronJob,
102
+ onSuspendCronJob, isSuspendingCronJob,
103
+ onResumeCronJob, isResumingCronJob,
104
+ onFluxReconcile, isFluxReconciling,
105
+ onFluxSyncWithSource, isFluxSyncing,
106
+ onFluxSuspend, isFluxSuspending,
107
+ onFluxResume, isFluxResuming,
108
+ onArgoSync, isArgoSyncing,
109
+ onArgoRefresh, isArgoRefreshing,
110
+ onArgoSuspend, isArgoSuspending,
111
+ onArgoResume, isArgoResuming,
112
+ }: ResourceActionsBarProps) {
113
+ const kind = resource.kind.toLowerCase()
114
+
115
+ // Delete confirmation state
116
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
117
+
118
+ // Rollback dialog state
119
+ const [showRevisions, setShowRevisions] = useState(false)
120
+ const isRollbackKind = ['deployments', 'statefulsets', 'daemonsets'].includes(kind)
121
+ const hasMultipleRevisions = (revisionsList?.length ?? 0) > 1
122
+
123
+ function handleDeleteConfirm(force: boolean) {
124
+ onDelete?.(
125
+ { kind: resource.kind, namespace: resource.namespace, name: resource.name, force },
126
+ {
127
+ onSuccess: () => {
128
+ setShowDeleteConfirm(false)
129
+ onClose?.()
130
+ },
131
+ }
132
+ )
133
+ }
134
+
135
+ const isRunning = kind === 'pods' ? data?.status?.phase === 'Running' : true
136
+ const containers = data?.spec?.containers?.map((c: any) => c.name) || []
137
+
138
+ const handleOpenTerminal = () => {
139
+ if (resource.namespace && resource.name && containers.length > 0) {
140
+ onOpenTerminal?.({
141
+ namespace: resource.namespace,
142
+ podName: resource.name,
143
+ containerName: containers[0],
144
+ containers,
145
+ })
146
+ }
147
+ }
148
+
149
+ const handleOpenLogs = (containerName?: string) => {
150
+ if (resource.namespace && resource.name && containers.length > 0) {
151
+ openLogs?.({
152
+ namespace: resource.namespace,
153
+ podName: resource.name,
154
+ containers,
155
+ containerName,
156
+ })
157
+ }
158
+ }
159
+
160
+ const [showLogsMenu, setShowLogsMenu] = useState(false)
161
+ const logsMenuTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
162
+
163
+ const handleLogsMouseEnter = () => {
164
+ if (logsMenuTimeout.current) clearTimeout(logsMenuTimeout.current)
165
+ if (containers.length > 1) setShowLogsMenu(true)
166
+ }
167
+ const handleLogsMouseLeave = () => {
168
+ logsMenuTimeout.current = setTimeout(() => setShowLogsMenu(false), 150)
169
+ }
170
+
171
+ return (
172
+ <div className="flex items-center gap-2 px-4 py-2 flex-wrap">
173
+ {/* Kind-specific actions (left) */}
174
+ {kind === 'pods' && (
175
+ <>
176
+ {isRunning && canExec && onOpenTerminal && (
177
+ <button
178
+ onClick={handleOpenTerminal}
179
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
180
+ >
181
+ <Terminal className="w-3.5 h-3.5" />
182
+ Terminal
183
+ </button>
184
+ )}
185
+ {canViewLogs && !hideLogs && openLogs && (
186
+ <div
187
+ className="relative"
188
+ onMouseEnter={handleLogsMouseEnter}
189
+ onMouseLeave={handleLogsMouseLeave}
190
+ >
191
+ <button
192
+ onClick={() => handleOpenLogs(containers.length === 1 ? containers[0] : undefined)}
193
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-slate-600 hover:bg-slate-500 rounded-lg transition-colors"
194
+ >
195
+ <FileText className="w-3.5 h-3.5" />
196
+ Logs
197
+ {containers.length > 1 && <ChevronDown className="w-3 h-3 ml-0.5" />}
198
+ </button>
199
+ {showLogsMenu && containers.length > 1 && (
200
+ <div className="absolute top-full left-0 mt-1 min-w-[160px] py-1 bg-theme-surface border border-theme-border rounded-lg shadow-lg z-50">
201
+ {containers.map((container: string) => (
202
+ <button
203
+ key={container}
204
+ onClick={() => {
205
+ handleOpenLogs(container)
206
+ setShowLogsMenu(false)
207
+ }}
208
+ className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-theme-text-primary hover:bg-theme-hover transition-colors text-left"
209
+ >
210
+ <Box className="w-3 h-3 text-theme-text-tertiary shrink-0" />
211
+ <span className="truncate">{container}</span>
212
+ </button>
213
+ ))}
214
+ </div>
215
+ )}
216
+ </div>
217
+ )}
218
+ {isRunning && canPortForward && resource.namespace && resource.name && renderPortForward && (
219
+ renderPortForward({
220
+ type: 'pod',
221
+ namespace: resource.namespace,
222
+ name: resource.name,
223
+ className: '!px-3 !py-1.5 !text-xs',
224
+ })
225
+ )}
226
+ </>
227
+ )}
228
+
229
+ {/* Service actions */}
230
+ {kind === 'services' && !data?.apiVersion?.includes('serving.knative.dev') && canPortForward && resource.namespace && resource.name && renderPortForward && (
231
+ renderPortForward({
232
+ type: 'service',
233
+ namespace: resource.namespace,
234
+ name: resource.name,
235
+ className: '!px-3 !py-1.5 !text-xs',
236
+ })
237
+ )}
238
+
239
+ {/* Workload actions - restart, rollback, and logs */}
240
+ {['deployments', 'statefulsets', 'daemonsets', 'rollouts'].includes(kind) && (
241
+ <>
242
+ {onRestart && (
243
+ <button
244
+ onClick={() => onRestart({
245
+ kind: resource.kind,
246
+ namespace: resource.namespace,
247
+ name: resource.name,
248
+ })}
249
+ disabled={isRestarting}
250
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-slate-600 hover:bg-slate-500 rounded-lg transition-colors disabled:opacity-50"
251
+ >
252
+ <RefreshCw className={`w-3.5 h-3.5 ${isRestarting ? 'animate-spin' : ''}`} />
253
+ {isRestarting ? 'Restarting...' : 'Restart'}
254
+ </button>
255
+ )}
256
+ {isRollbackKind && onRollback && (
257
+ <button
258
+ onClick={() => setShowRevisions(true)}
259
+ disabled={!hasMultipleRevisions}
260
+ title={hasMultipleRevisions ? 'View revision history and rollback' : 'Only one revision exists'}
261
+ className={clsx(
262
+ "flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors",
263
+ hasMultipleRevisions
264
+ ? "text-white bg-amber-600 hover:bg-amber-700"
265
+ : "text-theme-text-disabled bg-theme-elevated"
266
+ )}
267
+ >
268
+ <History className="w-3.5 h-3.5" />
269
+ Rollback
270
+ </button>
271
+ )}
272
+ {canViewLogs && !hideLogs && ['deployments', 'statefulsets', 'daemonsets'].includes(kind) && openWorkloadLogs && (
273
+ <button
274
+ onClick={() => openWorkloadLogs({
275
+ namespace: resource.namespace,
276
+ workloadKind: kind,
277
+ workloadName: resource.name,
278
+ })}
279
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-slate-600 hover:bg-slate-500 rounded-lg transition-colors"
280
+ >
281
+ <FileText className="w-3.5 h-3.5" />
282
+ Logs
283
+ </button>
284
+ )}
285
+ </>
286
+ )}
287
+
288
+ {/* CronJob actions */}
289
+ {kind === 'cronjobs' && (
290
+ <>
291
+ {onTriggerCronJob && (
292
+ <button
293
+ onClick={() => onTriggerCronJob({
294
+ namespace: resource.namespace,
295
+ name: resource.name,
296
+ })}
297
+ disabled={isTriggeringCronJob}
298
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50"
299
+ >
300
+ <Play className={`w-3.5 h-3.5 ${isTriggeringCronJob ? 'animate-pulse' : ''}`} />
301
+ {isTriggeringCronJob ? 'Triggering...' : 'Trigger'}
302
+ </button>
303
+ )}
304
+ {data?.spec?.suspend ? (
305
+ onResumeCronJob && (
306
+ <button
307
+ onClick={() => onResumeCronJob({
308
+ namespace: resource.namespace,
309
+ name: resource.name,
310
+ })}
311
+ disabled={isResumingCronJob}
312
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors disabled:opacity-50"
313
+ >
314
+ <Play className="w-3.5 h-3.5" />
315
+ {isResumingCronJob ? 'Resuming...' : 'Resume'}
316
+ </button>
317
+ )
318
+ ) : (
319
+ onSuspendCronJob && (
320
+ <button
321
+ onClick={() => onSuspendCronJob({
322
+ namespace: resource.namespace,
323
+ name: resource.name,
324
+ })}
325
+ disabled={isSuspendingCronJob}
326
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-slate-600 hover:bg-slate-500 rounded-lg transition-colors disabled:opacity-50"
327
+ >
328
+ <Pause className="w-3.5 h-3.5" />
329
+ {isSuspendingCronJob ? 'Suspending...' : 'Suspend'}
330
+ </button>
331
+ )
332
+ )}
333
+ </>
334
+ )}
335
+
336
+ {/* FluxCD actions */}
337
+ {['gitrepositories', 'ocirepositories', 'helmrepositories', 'kustomizations', 'helmreleases', 'alerts'].includes(kind) && (
338
+ <FluxActions
339
+ resource={resource}
340
+ data={data}
341
+ onReconcile={onFluxReconcile}
342
+ isReconciling={isFluxReconciling}
343
+ onSyncWithSource={onFluxSyncWithSource}
344
+ isSyncing={isFluxSyncing}
345
+ onSuspend={onFluxSuspend}
346
+ isSuspending={isFluxSuspending}
347
+ onResume={onFluxResume}
348
+ isResuming={isFluxResuming}
349
+ />
350
+ )}
351
+
352
+ {/* ArgoCD actions */}
353
+ {kind === 'applications' && (
354
+ <ArgoActions
355
+ resource={resource}
356
+ data={data}
357
+ onSync={onArgoSync}
358
+ isSyncing={isArgoSyncing}
359
+ onRefresh={onArgoRefresh}
360
+ isRefreshing={isArgoRefreshing}
361
+ onSuspend={onArgoSuspend}
362
+ isSuspending={isArgoSuspending}
363
+ onResume={onArgoResume}
364
+ isResuming={isArgoResuming}
365
+ />
366
+ )}
367
+
368
+ {/* Job logs */}
369
+ {kind === 'jobs' && onCopyCommand && (
370
+ <button
371
+ onClick={(e) => onCopyCommand(
372
+ `kubectl logs job/${resource.name} -n ${resource.namespace} -f`,
373
+ 'Logs command copied',
374
+ e
375
+ )}
376
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-slate-600 hover:bg-slate-500 rounded-lg transition-colors"
377
+ >
378
+ <FileText className="w-3.5 h-3.5" />
379
+ Logs
380
+ </button>
381
+ )}
382
+
383
+ {/* Spacer pushes universal actions to the right */}
384
+ <div className="flex-1" />
385
+
386
+ {/* Universal actions (right-aligned) */}
387
+ {onToggleYaml && (
388
+ <button
389
+ onClick={onToggleYaml}
390
+ className={clsx(
391
+ 'flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors',
392
+ showYaml
393
+ ? 'text-white bg-blue-600 hover:bg-blue-700'
394
+ : 'text-theme-text-secondary hover:text-theme-text-primary border border-theme-border hover:bg-theme-elevated'
395
+ )}
396
+ title="Toggle YAML view"
397
+ >
398
+ <FileCode2 className="w-3.5 h-3.5" />
399
+ YAML
400
+ </button>
401
+ )}
402
+
403
+ {onDelete && (
404
+ <button
405
+ onClick={() => setShowDeleteConfirm(true)}
406
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-red-400 hover:text-white hover:bg-red-600 border border-red-400/50 hover:border-red-600 rounded-lg transition-colors"
407
+ >
408
+ <Trash2 className="w-3.5 h-3.5" />
409
+ Delete
410
+ </button>
411
+ )}
412
+
413
+ <ForceDeleteConfirmDialog
414
+ open={showDeleteConfirm}
415
+ onClose={() => setShowDeleteConfirm(false)}
416
+ onConfirm={handleDeleteConfirm}
417
+ resourceName={resource.name}
418
+ resourceKind={formatKindName(resource.kind)}
419
+ namespaceName={resource.namespace}
420
+ isLoading={isDeleting ?? false}
421
+ />
422
+
423
+ {showRevisions && ['deployments', 'statefulsets', 'daemonsets'].includes(kind) && (
424
+ <RevisionHistoryDialog
425
+ kind={resource.kind}
426
+ namespace={resource.namespace}
427
+ name={resource.name}
428
+ open={showRevisions}
429
+ onClose={() => setShowRevisions(false)}
430
+ revisions={revisionsList}
431
+ isLoading={revisionsLoading}
432
+ error={revisionsError}
433
+ onRollback={onRollback}
434
+ isRollingBack={isRollingBack}
435
+ />
436
+ )}
437
+ </div>
438
+ )
439
+ }
440
+
441
+ // ============================================================================
442
+ // FLUX ACTIONS
443
+ // ============================================================================
444
+
445
+ function FluxActions({ resource, data, onReconcile, isReconciling, onSyncWithSource, isSyncing, onSuspend, isSuspending, onResume, isResuming }: {
446
+ resource: SelectedResource; data: any
447
+ onReconcile?: (params: { kind: string; namespace: string; name: string }) => void; isReconciling?: boolean
448
+ onSyncWithSource?: (params: { kind: string; namespace: string; name: string }) => void; isSyncing?: boolean
449
+ onSuspend?: (params: { kind: string; namespace: string; name: string }) => void; isSuspending?: boolean
450
+ onResume?: (params: { kind: string; namespace: string; name: string }) => void; isResuming?: boolean
451
+ }) {
452
+ const isSuspended = data?.spec?.suspend === true
453
+ const hasSource = resource.kind === 'kustomizations' || resource.kind === 'helmreleases'
454
+
455
+ return (
456
+ <>
457
+ {onReconcile && (
458
+ <button
459
+ onClick={() => onReconcile({
460
+ kind: resource.kind,
461
+ namespace: resource.namespace,
462
+ name: resource.name,
463
+ })}
464
+ disabled={isReconciling || isSuspended}
465
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50"
466
+ title={isSuspended ? 'Cannot reconcile while suspended' : 'Trigger reconciliation'}
467
+ >
468
+ <RefreshCw className={`w-3.5 h-3.5 ${isReconciling ? 'animate-spin' : ''}`} />
469
+ {isReconciling ? 'Reconciling...' : 'Reconcile'}
470
+ </button>
471
+ )}
472
+
473
+ {hasSource && onSyncWithSource && (
474
+ <button
475
+ onClick={() => onSyncWithSource({
476
+ kind: resource.kind,
477
+ namespace: resource.namespace,
478
+ name: resource.name,
479
+ })}
480
+ disabled={isSyncing || isSuspended}
481
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-purple-600 hover:bg-purple-700 rounded-lg transition-colors disabled:opacity-50"
482
+ title={isSuspended ? 'Cannot sync while suspended' : 'Fetch latest from source, then reconcile'}
483
+ >
484
+ <RefreshCw className={`w-3.5 h-3.5 ${isSyncing ? 'animate-spin' : ''}`} />
485
+ {isSyncing ? 'Syncing...' : 'Sync with Source'}
486
+ </button>
487
+ )}
488
+
489
+ {isSuspended ? (
490
+ onResume && (
491
+ <button
492
+ onClick={() => onResume({
493
+ kind: resource.kind,
494
+ namespace: resource.namespace,
495
+ name: resource.name,
496
+ })}
497
+ disabled={isResuming}
498
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors disabled:opacity-50"
499
+ >
500
+ <Play className="w-3.5 h-3.5" />
501
+ {isResuming ? 'Resuming...' : 'Resume'}
502
+ </button>
503
+ )
504
+ ) : (
505
+ onSuspend && (
506
+ <button
507
+ onClick={() => onSuspend({
508
+ kind: resource.kind,
509
+ namespace: resource.namespace,
510
+ name: resource.name,
511
+ })}
512
+ disabled={isSuspending}
513
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-slate-600 hover:bg-slate-500 rounded-lg transition-colors disabled:opacity-50"
514
+ >
515
+ <Pause className="w-3.5 h-3.5" />
516
+ {isSuspending ? 'Suspending...' : 'Suspend'}
517
+ </button>
518
+ )
519
+ )}
520
+ </>
521
+ )
522
+ }
523
+
524
+ // ============================================================================
525
+ // ARGO ACTIONS
526
+ // ============================================================================
527
+
528
+ function ArgoActions({ resource, data, onSync, isSyncing, onRefresh, isRefreshing, onSuspend, isSuspending, onResume, isResuming }: {
529
+ resource: SelectedResource; data: any
530
+ onSync?: (params: { namespace: string; name: string }) => void; isSyncing?: boolean
531
+ onRefresh?: (params: { namespace: string; name: string; hard: boolean }) => void; isRefreshing?: boolean
532
+ onSuspend?: (params: { namespace: string; name: string }) => void; isSuspending?: boolean
533
+ onResume?: (params: { namespace: string; name: string }) => void; isResuming?: boolean
534
+ }) {
535
+ const hasAutomatedSync = !!data?.spec?.syncPolicy?.automated
536
+
537
+ return (
538
+ <>
539
+ {onSync && (
540
+ <button
541
+ onClick={() => onSync({
542
+ namespace: resource.namespace,
543
+ name: resource.name,
544
+ })}
545
+ disabled={isSyncing}
546
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50"
547
+ title="Sync application"
548
+ >
549
+ <RefreshCw className={`w-3.5 h-3.5 ${isSyncing ? 'animate-spin' : ''}`} />
550
+ {isSyncing ? 'Syncing...' : 'Sync'}
551
+ </button>
552
+ )}
553
+
554
+ {onRefresh && (
555
+ <button
556
+ onClick={() => onRefresh({
557
+ namespace: resource.namespace,
558
+ name: resource.name,
559
+ hard: false,
560
+ })}
561
+ disabled={isRefreshing}
562
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-slate-600 hover:bg-slate-500 rounded-lg transition-colors disabled:opacity-50"
563
+ title="Refresh (re-read from git)"
564
+ >
565
+ <RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
566
+ {isRefreshing ? 'Refreshing...' : 'Refresh'}
567
+ </button>
568
+ )}
569
+
570
+ {hasAutomatedSync ? (
571
+ onSuspend && (
572
+ <button
573
+ onClick={() => onSuspend({
574
+ namespace: resource.namespace,
575
+ name: resource.name,
576
+ })}
577
+ disabled={isSuspending}
578
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-slate-600 hover:bg-slate-500 rounded-lg transition-colors disabled:opacity-50"
579
+ >
580
+ <Pause className="w-3.5 h-3.5" />
581
+ {isSuspending ? 'Suspending...' : 'Suspend'}
582
+ </button>
583
+ )
584
+ ) : (
585
+ onResume && (
586
+ <button
587
+ onClick={() => onResume({
588
+ namespace: resource.namespace,
589
+ name: resource.name,
590
+ })}
591
+ disabled={isResuming}
592
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors disabled:opacity-50"
593
+ >
594
+ <Play className="w-3.5 h-3.5" />
595
+ {isResuming ? 'Enabling...' : 'Enable Auto-Sync'}
596
+ </button>
597
+ )
598
+ )}
599
+ </>
600
+ )
601
+ }
602
+
603
+ // ============================================================================
604
+ // REVISION HISTORY DIALOG
605
+ // ============================================================================
606
+
607
+ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, revisions, isLoading, error, onRollback, isRollingBack }: {
608
+ kind: string
609
+ namespace: string
610
+ name: string
611
+ open: boolean
612
+ onClose: () => void
613
+ revisions?: WorkloadRevision[]
614
+ isLoading?: boolean
615
+ error?: Error | null
616
+ onRollback?: (params: { kind: string; namespace: string; name: string; revision: number }, callbacks?: { onSuccess?: () => void; onError?: (err: unknown) => void }) => void
617
+ isRollingBack?: boolean
618
+ }) {
619
+ const [confirmRevision, setConfirmRevision] = useState<number | null>(null)
620
+ const [diffRevision, setDiffRevision] = useState<number | null>(null)
621
+
622
+ if (!open) return null
623
+
624
+ const currentRevision = revisions?.find(r => r.isCurrent)
625
+ const selectedRevision = revisions?.find(r => r.number === diffRevision)
626
+ const hasDiffData = currentRevision?.template && selectedRevision?.template
627
+
628
+ function handleRollback(revision: number) {
629
+ onRollback?.(
630
+ { kind, namespace, name, revision },
631
+ {
632
+ onSuccess: () => {
633
+ setConfirmRevision(null)
634
+ setDiffRevision(null)
635
+ onClose()
636
+ },
637
+ }
638
+ )
639
+ }
640
+
641
+ function formatTimeAgo(dateStr: string): string {
642
+ const date = new Date(dateStr)
643
+ const now = new Date()
644
+ const seconds = Math.floor((now.getTime() - date.getTime()) / 1000)
645
+ if (seconds < 60) return `${seconds}s ago`
646
+ const minutes = Math.floor(seconds / 60)
647
+ if (minutes < 60) return `${minutes}m ago`
648
+ const hours = Math.floor(minutes / 60)
649
+ if (hours < 24) return `${hours}h ago`
650
+ const days = Math.floor(hours / 24)
651
+ return `${days}d ago`
652
+ }
653
+
654
+ function getImageTag(image: string): string {
655
+ if (!image) return '-'
656
+ const parts = image.split(':')
657
+ if (parts.length > 1) return parts[parts.length - 1]
658
+ const slashParts = image.split('/')
659
+ return slashParts[slashParts.length - 1]
660
+ }
661
+
662
+ return (
663
+ <div className="fixed inset-0 z-50 flex items-center justify-center">
664
+ <div
665
+ className="absolute inset-0 bg-black/60 backdrop-blur-sm"
666
+ onClick={isRollingBack ? undefined : () => { setDiffRevision(null); onClose() }}
667
+ />
668
+
669
+ <div className={clsx(
670
+ "relative bg-theme-surface border border-theme-border rounded-lg shadow-2xl mx-4 outline-none flex flex-col",
671
+ diffRevision ? "max-w-5xl w-full max-h-[85vh]" : "max-w-lg w-full"
672
+ )}>
673
+ <div className="flex items-center justify-between p-4 border-b border-theme-border shrink-0">
674
+ <div className="flex items-center gap-2">
675
+ <History className="w-5 h-5 text-amber-500" />
676
+ <h3 className="text-lg font-semibold text-theme-text-primary">Revision History</h3>
677
+ {diffRevision && currentRevision && (
678
+ <span className="flex items-center gap-1 ml-2 px-2 py-0.5 text-xs bg-blue-500/15 text-blue-400 rounded">
679
+ <GitCompare className="w-3 h-3" />
680
+ #{currentRevision.number} vs #{diffRevision}
681
+ </span>
682
+ )}
683
+ </div>
684
+ <button
685
+ onClick={() => { setDiffRevision(null); onClose() }}
686
+ disabled={isRollingBack}
687
+ className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded disabled:opacity-50"
688
+ >
689
+ <X className="w-5 h-5" />
690
+ </button>
691
+ </div>
692
+
693
+ <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
694
+ <div className={clsx("p-4 overflow-y-auto", diffRevision ? "max-h-48 shrink-0" : "max-h-80")}>
695
+ {isLoading && (
696
+ <div className="flex items-center justify-center py-8 text-theme-text-secondary text-sm">
697
+ Loading revisions...
698
+ </div>
699
+ )}
700
+
701
+ {error && (
702
+ <div className="flex items-center justify-center py-8 text-red-400 text-sm">
703
+ Failed to load revisions: {error instanceof Error ? error.message : 'Unknown error'}
704
+ </div>
705
+ )}
706
+
707
+ {revisions && revisions.length === 0 && (
708
+ <div className="flex items-center justify-center py-8 text-theme-text-secondary text-sm">
709
+ No revisions found
710
+ </div>
711
+ )}
712
+
713
+ {revisions && revisions.length > 0 && (
714
+ <table className="w-full text-sm">
715
+ <thead>
716
+ <tr className="text-theme-text-secondary text-left text-xs uppercase tracking-wider">
717
+ <th className="pb-2 pr-3 font-medium">Rev</th>
718
+ <th className="pb-2 pr-3 font-medium">Image</th>
719
+ <th className="pb-2 pr-3 font-medium">Age</th>
720
+ <th className="pb-2 font-medium text-right">Action</th>
721
+ </tr>
722
+ </thead>
723
+ <tbody>
724
+ {revisions.map((rev: WorkloadRevision) => (
725
+ <tr
726
+ key={rev.number}
727
+ className={clsx(
728
+ "border-t border-theme-border/50",
729
+ diffRevision === rev.number && "bg-blue-500/10"
730
+ )}
731
+ >
732
+ <td className="py-2 pr-3 text-theme-text-primary font-mono">
733
+ #{rev.number}
734
+ </td>
735
+ <td className="py-2 pr-3 text-theme-text-secondary font-mono truncate max-w-[180px]" title={rev.image}>
736
+ {getImageTag(rev.image)}
737
+ </td>
738
+ <td className="py-2 pr-3 text-theme-text-secondary whitespace-nowrap">
739
+ {formatTimeAgo(rev.createdAt)}
740
+ </td>
741
+ <td className="py-2 text-right">
742
+ <div className="flex items-center gap-1 justify-end">
743
+ {!rev.isCurrent && rev.template && currentRevision?.template && (
744
+ <button
745
+ onClick={() => setDiffRevision(diffRevision === rev.number ? null : rev.number)}
746
+ className={clsx(
747
+ "px-2 py-0.5 text-xs font-medium rounded transition-colors flex items-center gap-1",
748
+ diffRevision === rev.number
749
+ ? "bg-blue-500/20 text-blue-400 border border-blue-400/50"
750
+ : "text-blue-400 hover:text-blue-300 hover:bg-blue-500/10 border border-transparent"
751
+ )}
752
+ title={`Compare with current revision`}
753
+ >
754
+ <GitCompare className="w-3 h-3" />
755
+ Diff
756
+ </button>
757
+ )}
758
+ {rev.isCurrent ? (
759
+ <span className="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-green-500/20 text-green-400 rounded">
760
+ Current
761
+ </span>
762
+ ) : confirmRevision === rev.number ? (
763
+ <>
764
+ <button
765
+ onClick={() => handleRollback(rev.number)}
766
+ disabled={isRollingBack}
767
+ className="px-2 py-0.5 text-xs font-medium text-white bg-amber-600 hover:bg-amber-700 rounded transition-colors disabled:opacity-50"
768
+ >
769
+ {isRollingBack ? 'Rolling back...' : 'Confirm'}
770
+ </button>
771
+ <button
772
+ onClick={() => setConfirmRevision(null)}
773
+ disabled={isRollingBack}
774
+ className="px-2 py-0.5 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary rounded transition-colors disabled:opacity-50"
775
+ >
776
+ Cancel
777
+ </button>
778
+ </>
779
+ ) : (
780
+ <button
781
+ onClick={() => setConfirmRevision(rev.number)}
782
+ className="px-2 py-0.5 text-xs font-medium text-amber-400 hover:text-white hover:bg-amber-600 border border-amber-400/50 hover:border-amber-600 rounded transition-colors"
783
+ >
784
+ Rollback
785
+ </button>
786
+ )}
787
+ </div>
788
+ </td>
789
+ </tr>
790
+ ))}
791
+ </tbody>
792
+ </table>
793
+ )}
794
+ </div>
795
+
796
+ {diffRevision && hasDiffData && (
797
+ <RevisionDiffView
798
+ currentTemplate={currentRevision!.template!}
799
+ selectedTemplate={selectedRevision!.template!}
800
+ currentRevision={currentRevision!.number}
801
+ selectedRevision={diffRevision}
802
+ />
803
+ )}
804
+ </div>
805
+
806
+ <div className="flex items-center justify-end p-4 border-t border-theme-border shrink-0">
807
+ <button
808
+ onClick={() => { setDiffRevision(null); onClose() }}
809
+ disabled={isRollingBack}
810
+ className="px-4 py-2 text-sm font-medium text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg transition-colors disabled:opacity-50"
811
+ >
812
+ Close
813
+ </button>
814
+ </div>
815
+ </div>
816
+ </div>
817
+ )
818
+ }
819
+
820
+ // ============================================================================
821
+ // REVISION DIFF VIEW
822
+ // ============================================================================
823
+
824
+ function stripAutoLabels(templateYaml: string): string {
825
+ return templateYaml
826
+ .split('\n')
827
+ .filter(line => !line.match(/^\s+pod-template-hash:/))
828
+ .join('\n')
829
+ }
830
+
831
+ function RevisionDiffView({ currentTemplate, selectedTemplate, currentRevision, selectedRevision }: {
832
+ currentTemplate: string
833
+ selectedTemplate: string
834
+ currentRevision: number
835
+ selectedRevision: number
836
+ }) {
837
+ const [expanded, setExpanded] = useState(false)
838
+
839
+ const cleanCurrent = stripAutoLabels(currentTemplate)
840
+ const cleanSelected = stripAutoLabels(selectedTemplate)
841
+
842
+ const patch = createTwoFilesPatch(
843
+ `Revision #${currentRevision} (current)`,
844
+ `Revision #${selectedRevision}`,
845
+ cleanCurrent,
846
+ cleanSelected,
847
+ '', '',
848
+ expanded ? { context: 999999 } : { context: 3 }
849
+ )
850
+
851
+ const lines = patch.split('\n')
852
+ const diffLines = lines.filter(line =>
853
+ !line.startsWith('===') && !line.startsWith('Index:')
854
+ )
855
+
856
+ const hasChanges = diffLines.some(l => (l.startsWith('+') && !l.startsWith('+++')) || (l.startsWith('-') && !l.startsWith('---')))
857
+
858
+ return (
859
+ <div className="border-t border-theme-border flex flex-col shrink-0">
860
+ <div className="flex items-center justify-between px-4 py-2 bg-theme-elevated/50 text-xs text-theme-text-secondary shrink-0">
861
+ <div className="flex items-center gap-4">
862
+ <span className="flex items-center gap-1">
863
+ <span className="w-3 h-3 bg-red-500/20 border border-red-500/50 rounded" /> Revision #{currentRevision} (current)
864
+ </span>
865
+ <span className="flex items-center gap-1">
866
+ <span className="w-3 h-3 bg-green-500/20 border border-green-500/50 rounded" /> Revision #{selectedRevision}
867
+ </span>
868
+ </div>
869
+ {hasChanges && (
870
+ <button
871
+ onClick={() => setExpanded(!expanded)}
872
+ className="flex items-center gap-1 px-2 py-0.5 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded transition-colors"
873
+ >
874
+ <Code className="w-3 h-3" />
875
+ {expanded ? 'Show changes only' : 'Show full spec'}
876
+ </button>
877
+ )}
878
+ </div>
879
+ <div className="overflow-auto max-h-[400px]">
880
+ {hasChanges ? (
881
+ <pre className="text-xs font-mono p-0 m-0">
882
+ {diffLines.map((line, index) => {
883
+ const isAddition = line.startsWith('+') && !line.startsWith('+++')
884
+ const isDeletion = line.startsWith('-') && !line.startsWith('---')
885
+ const isHeader = line.startsWith('@@') || line.startsWith('---') || line.startsWith('+++')
886
+
887
+ return (
888
+ <div
889
+ key={index}
890
+ className={clsx(
891
+ 'flex',
892
+ isAddition && 'bg-green-500/10',
893
+ isDeletion && 'bg-red-500/10',
894
+ isHeader && 'bg-blue-500/10'
895
+ )}
896
+ >
897
+ <span className="w-10 shrink-0 text-right pr-2 py-0.5 text-theme-text-disabled select-none border-r border-theme-border/50">
898
+ {index + 1}
899
+ </span>
900
+ <span
901
+ className={clsx(
902
+ 'flex-1 px-3 py-0.5 whitespace-pre',
903
+ isAddition && 'text-green-400',
904
+ isDeletion && 'text-red-400',
905
+ isHeader && 'text-blue-400 font-medium',
906
+ !isAddition && !isDeletion && !isHeader && 'text-theme-text-secondary'
907
+ )}
908
+ >
909
+ {line || ' '}
910
+ </span>
911
+ </div>
912
+ )
913
+ })}
914
+ </pre>
915
+ ) : (
916
+ <div className="flex flex-col items-center justify-center py-12 text-theme-text-tertiary">
917
+ <GitCompare className="w-8 h-8 mb-2 text-theme-text-disabled" />
918
+ <span className="text-sm">Templates are identical</span>
919
+ </div>
920
+ )}
921
+ </div>
922
+ </div>
923
+ )
924
+ }