@skyhook-io/k8s-ui 1.1.0 → 1.2.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 (58) hide show
  1. package/package.json +8 -2
  2. package/src/components/dock/BottomDock.tsx +14 -15
  3. package/src/components/dock/DockContext.tsx +40 -1
  4. package/src/components/dock/LocalTerminalTab.tsx +241 -0
  5. package/src/components/dock/NodeTerminalTab.tsx +129 -0
  6. package/src/components/dock/TerminalTab.tsx +1 -1
  7. package/src/components/dock/index.ts +2 -0
  8. package/src/components/gitops/GitOpsStatusBadge.tsx +18 -67
  9. package/src/components/logs/LogCore.tsx +30 -4
  10. package/src/components/logs/StructuredLogLine.tsx +168 -0
  11. package/src/components/logs/index.ts +1 -1
  12. package/src/components/logs/useLogBuffer.ts +16 -10
  13. package/src/components/resources/ResourcesView.tsx +186 -67
  14. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +207 -0
  15. package/src/components/resources/renderers/HPARenderer.tsx +2 -1
  16. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -1
  17. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +2 -1
  18. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -1
  19. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +7 -6
  20. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +2 -1
  21. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +3 -2
  22. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +3 -2
  23. package/src/components/resources/renderers/PodRenderer.tsx +164 -8
  24. package/src/components/resources/renderers/VPARenderer.tsx +2 -1
  25. package/src/components/resources/renderers/WorkloadRenderer.tsx +53 -55
  26. package/src/components/resources/renderers/contour-cells.tsx +48 -0
  27. package/src/components/resources/renderers/index.ts +2 -0
  28. package/src/components/resources/renderers/trivy-shared.tsx +9 -25
  29. package/src/components/resources/resource-utils-contour.ts +34 -0
  30. package/src/components/resources/resource-utils.ts +91 -0
  31. package/src/components/shared/ResourceActionsBar.tsx +276 -144
  32. package/src/components/shared/ResourceRendererDispatch.tsx +59 -5
  33. package/src/components/shared/index.ts +1 -1
  34. package/src/components/timeline/shared.tsx +8 -10
  35. package/src/components/topology/K8sResourceNode.tsx +1 -0
  36. package/src/components/topology/TopologyFilterSidebar.tsx +3 -0
  37. package/src/components/topology/TopologyGraph.tsx +2 -17
  38. package/src/components/topology/layout.ts +1 -0
  39. package/src/components/topology/topology.css +1 -0
  40. package/src/components/ui/CodeViewer.tsx +1 -2
  41. package/src/components/ui/ConfirmDialog.tsx +85 -130
  42. package/src/components/ui/DialogPortal.tsx +82 -0
  43. package/src/components/ui/ForceDeleteConfirmDialog.tsx +84 -10
  44. package/src/components/ui/Toast.tsx +5 -5
  45. package/src/components/ui/drawer-components.tsx +28 -22
  46. package/src/components/ui/index.ts +1 -0
  47. package/src/components/workload/WorkloadView.tsx +30 -2
  48. package/src/theme/index.ts +3 -0
  49. package/src/theme/tailwind-theme.css +61 -0
  50. package/src/theme/variables.css +121 -0
  51. package/src/types/core.ts +13 -2
  52. package/src/utils/animation.ts +1 -1
  53. package/src/utils/badge-colors.ts +158 -123
  54. package/src/utils/log-format.ts +127 -35
  55. package/src/utils/navigation.test.ts +142 -0
  56. package/src/utils/navigation.ts +52 -47
  57. package/src/utils/resource-hierarchy.ts +5 -0
  58. package/src/utils/resource-icons.ts +3 -0
@@ -0,0 +1,34 @@
1
+ // Contour HTTPProxy CRD utility functions for resource list cells and detail renderers
2
+
3
+ export function getHTTPProxyFQDN(resource: any): string {
4
+ return resource.spec?.virtualhost?.fqdn || '-'
5
+ }
6
+
7
+ export function getHTTPProxyStatus(resource: any): { status: string; label: string } {
8
+ const currentStatus = resource.status?.currentStatus?.toLowerCase()
9
+ if (currentStatus === 'valid') return { status: 'healthy', label: 'Valid' }
10
+ if (currentStatus === 'invalid') return { status: 'unhealthy', label: 'Invalid' }
11
+ if (currentStatus === 'orphaned') return { status: 'degraded', label: 'Orphaned' }
12
+ return { status: 'unknown', label: '-' }
13
+ }
14
+
15
+ export function getHTTPProxyRouteCount(resource: any): number {
16
+ return resource.spec?.routes?.length || 0
17
+ }
18
+
19
+ export function getHTTPProxyServiceCount(resource: any): number {
20
+ let count = 0
21
+ for (const route of resource.spec?.routes || []) {
22
+ count += (route.services || []).length
23
+ }
24
+ count += (resource.spec?.tcpproxy?.services || []).length
25
+ return count
26
+ }
27
+
28
+ export function getHTTPProxyIncludeCount(resource: any): number {
29
+ return resource.spec?.includes?.length || 0
30
+ }
31
+
32
+ export function hasHTTPProxyTLS(resource: any): boolean {
33
+ return !!resource.spec?.virtualhost?.tls
34
+ }
@@ -197,6 +197,97 @@ export function getPodRestarts(pod: any): number {
197
197
  return containerStatuses.reduce((sum: number, c: any) => sum + (c.restartCount || 0), 0)
198
198
  }
199
199
 
200
+ export interface ContainerSquareState {
201
+ name: string
202
+ status: 'ready' | 'running' | 'waiting' | 'completed' | 'terminated' | 'unknown'
203
+ restarts: number
204
+ reason?: string
205
+ message?: string
206
+ exitCode?: number
207
+ startedAt?: string
208
+ finishedAt?: string
209
+ isInit?: boolean
210
+ /** Last termination info — crucial for debugging CrashLoopBackOff */
211
+ lastTermination?: {
212
+ reason?: string
213
+ exitCode?: number
214
+ startedAt?: string
215
+ finishedAt?: string
216
+ }
217
+ }
218
+
219
+ export function getContainerSquareStates(pod: any): ContainerSquareState[] {
220
+ const result: ContainerSquareState[] = []
221
+ const initStatuses = pod.status?.initContainerStatuses || []
222
+ const containerStatuses = pod.status?.containerStatuses || []
223
+ const specContainers = pod.spec?.containers || []
224
+
225
+ for (const cs of initStatuses) {
226
+ const stateKey = cs.state ? Object.keys(cs.state)[0] : 'unknown'
227
+ let status: ContainerSquareState['status'] = 'unknown'
228
+ if (stateKey === 'terminated' && cs.state?.terminated?.exitCode === 0) {
229
+ status = 'completed'
230
+ } else if (stateKey === 'running') {
231
+ status = cs.ready ? 'ready' : 'running'
232
+ } else if (stateKey === 'waiting') {
233
+ status = 'waiting'
234
+ } else if (stateKey === 'terminated') {
235
+ status = 'terminated'
236
+ }
237
+ const stateDetail = cs.state?.[stateKey]
238
+ const lastTerm = cs.lastState?.terminated
239
+ result.push({
240
+ name: cs.name,
241
+ status,
242
+ restarts: cs.restartCount || 0,
243
+ reason: stateDetail?.reason,
244
+ message: stateDetail?.message,
245
+ exitCode: stateDetail?.exitCode,
246
+ startedAt: stateDetail?.startedAt,
247
+ finishedAt: stateDetail?.finishedAt,
248
+ isInit: true,
249
+ lastTermination: lastTerm ? { reason: lastTerm.reason, exitCode: lastTerm.exitCode, startedAt: lastTerm.startedAt, finishedAt: lastTerm.finishedAt } : undefined,
250
+ })
251
+ }
252
+
253
+ if (containerStatuses.length > 0) {
254
+ for (const cs of containerStatuses) {
255
+ const stateKey = cs.state ? Object.keys(cs.state)[0] : 'unknown'
256
+ let status: ContainerSquareState['status'] = 'unknown'
257
+ if (stateKey === 'running' && cs.ready) {
258
+ status = 'ready'
259
+ } else if (stateKey === 'running') {
260
+ status = 'running'
261
+ } else if (stateKey === 'terminated' && cs.state?.terminated?.exitCode === 0) {
262
+ status = 'completed'
263
+ } else if (stateKey === 'terminated') {
264
+ status = 'terminated'
265
+ } else if (stateKey === 'waiting') {
266
+ status = 'waiting'
267
+ }
268
+ const stateDetail = cs.state?.[stateKey]
269
+ const lastTerm = cs.lastState?.terminated
270
+ result.push({
271
+ name: cs.name,
272
+ status,
273
+ restarts: cs.restartCount || 0,
274
+ reason: stateDetail?.reason,
275
+ message: stateDetail?.message,
276
+ exitCode: stateDetail?.exitCode,
277
+ startedAt: stateDetail?.startedAt,
278
+ finishedAt: stateDetail?.finishedAt,
279
+ lastTermination: lastTerm ? { reason: lastTerm.reason, exitCode: lastTerm.exitCode, startedAt: lastTerm.startedAt, finishedAt: lastTerm.finishedAt } : undefined,
280
+ })
281
+ }
282
+ } else {
283
+ for (const c of specContainers) {
284
+ result.push({ name: c.name, status: 'unknown', restarts: 0 })
285
+ }
286
+ }
287
+
288
+ return result
289
+ }
290
+
200
291
  // ============================================================================
201
292
  // WORKLOAD UTILITIES (Deployment, StatefulSet, DaemonSet, ReplicaSet)
202
293
  // ============================================================================
@@ -6,6 +6,8 @@ import {
6
6
  Trash2,
7
7
  Play,
8
8
  Pause,
9
+ Ban,
10
+ AlertTriangle,
9
11
  Box,
10
12
  ChevronDown,
11
13
  History,
@@ -16,7 +18,9 @@ import {
16
18
  } from 'lucide-react'
17
19
  import { createTwoFilesPatch } from 'diff'
18
20
  import { clsx } from 'clsx'
19
- import { ForceDeleteConfirmDialog } from '../ui/ForceDeleteConfirmDialog'
21
+ import { ForceDeleteConfirmDialog, type CascadeDependent } from '../ui/ForceDeleteConfirmDialog'
22
+ import { ConfirmDialog } from '../ui/ConfirmDialog'
23
+ import { DialogPortal } from '../ui/DialogPortal'
20
24
  import type { SelectedResource, WorkloadRevision } from '../../types'
21
25
  import { formatKindName } from '../ui/drawer-components'
22
26
 
@@ -49,6 +53,8 @@ interface ResourceActionsBarProps {
49
53
  // Delete
50
54
  onDelete?: (params: { kind: string; namespace: string; name: string; force: boolean }, callbacks?: { onSuccess?: () => void; onError?: (err: unknown) => void }) => void
51
55
  isDeleting?: boolean
56
+ cascadeDependents?: CascadeDependent[]
57
+ cascadeLoading?: boolean
52
58
 
53
59
  // Workload restart
54
60
  onRestart?: (params: { kind: string; namespace: string; name: string }, callbacks?: { onSuccess?: () => void; onError?: (err: unknown) => void }) => void
@@ -88,6 +94,18 @@ interface ResourceActionsBarProps {
88
94
  isArgoSuspending?: boolean
89
95
  onArgoResume?: (params: { namespace: string; name: string }) => void
90
96
  isArgoResuming?: boolean
97
+
98
+ // Node debug shell
99
+ onOpenNodeTerminal?: (params: { nodeName: string }) => void
100
+
101
+ // Node operations (cordon/uncordon/drain)
102
+ canNodeWrite?: boolean
103
+ onCordonNode?: (params: { name: string }) => void
104
+ isCordoningNode?: boolean
105
+ onUncordonNode?: (params: { name: string }) => void
106
+ isUncordoningNode?: boolean
107
+ onDrainNode?: (params: { name: string; options?: { deleteEmptyDirData?: boolean; force?: boolean } }) => void
108
+ isDrainingNode?: boolean
91
109
  }
92
110
 
93
111
  export function ResourceActionsBar({
@@ -95,7 +113,7 @@ export function ResourceActionsBar({
95
113
  canExec, canViewLogs, canPortForward,
96
114
  onOpenTerminal, onOpenLogs: openLogs, onOpenWorkloadLogs: openWorkloadLogs, onCopyCommand,
97
115
  renderPortForward,
98
- onDelete, isDeleting,
116
+ onDelete, isDeleting, cascadeDependents, cascadeLoading,
99
117
  onRestart, isRestarting,
100
118
  revisions: revisionsList, revisionsLoading, revisionsError, onRollback, isRollingBack,
101
119
  onTriggerCronJob, isTriggeringCronJob,
@@ -109,12 +127,22 @@ export function ResourceActionsBar({
109
127
  onArgoRefresh, isArgoRefreshing,
110
128
  onArgoSuspend, isArgoSuspending,
111
129
  onArgoResume, isArgoResuming,
130
+ onOpenNodeTerminal,
131
+ canNodeWrite,
132
+ onCordonNode, isCordoningNode,
133
+ onUncordonNode, isUncordoningNode,
134
+ onDrainNode, isDrainingNode,
112
135
  }: ResourceActionsBarProps) {
113
136
  const kind = resource.kind.toLowerCase()
114
137
 
115
138
  // Delete confirmation state
116
139
  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
117
140
 
141
+ // Node operation confirmation state
142
+ const [showCordonConfirm, setShowCordonConfirm] = useState(false)
143
+ const [showDrainConfirm, setShowDrainConfirm] = useState(false)
144
+ const [drainForce, setDrainForce] = useState(false)
145
+
118
146
  // Rollback dialog state
119
147
  const [showRevisions, setShowRevisions] = useState(false)
120
148
  const isRollbackKind = ['deployments', 'statefulsets', 'daemonsets'].includes(kind)
@@ -226,6 +254,60 @@ export function ResourceActionsBar({
226
254
  </>
227
255
  )}
228
256
 
257
+ {/* Node actions */}
258
+ {kind === 'nodes' && (
259
+ <>
260
+ {canExec && onOpenNodeTerminal && (
261
+ <button
262
+ onClick={() => onOpenNodeTerminal({ nodeName: resource.name })}
263
+ 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"
264
+ >
265
+ <Terminal className="w-3.5 h-3.5" />
266
+ Debug Shell
267
+ </button>
268
+ )}
269
+
270
+ {canNodeWrite && (
271
+ <>
272
+ {data?.spec?.unschedulable ? (
273
+ onUncordonNode && (
274
+ <button
275
+ onClick={() => onUncordonNode({ name: resource.name })}
276
+ disabled={isUncordoningNode}
277
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition-colors disabled:opacity-50"
278
+ >
279
+ <Play className={`w-3.5 h-3.5 ${isUncordoningNode ? 'animate-pulse' : ''}`} />
280
+ {isUncordoningNode ? 'Uncordoning...' : 'Uncordon'}
281
+ </button>
282
+ )
283
+ ) : (
284
+ onCordonNode && (
285
+ <button
286
+ onClick={() => setShowCordonConfirm(true)}
287
+ disabled={isCordoningNode}
288
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-amber-600 hover:bg-amber-700 rounded-lg transition-colors disabled:opacity-50"
289
+ >
290
+ <Ban className={`w-3.5 h-3.5 ${isCordoningNode ? 'animate-pulse' : ''}`} />
291
+ {isCordoningNode ? 'Cordoning...' : 'Cordon'}
292
+ </button>
293
+ )
294
+ )}
295
+
296
+ {onDrainNode && (
297
+ <button
298
+ onClick={() => setShowDrainConfirm(true)}
299
+ disabled={isDrainingNode}
300
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors disabled:opacity-50"
301
+ >
302
+ <AlertTriangle className={`w-3.5 h-3.5 ${isDrainingNode ? 'animate-pulse' : ''}`} />
303
+ {isDrainingNode ? 'Draining...' : 'Drain'}
304
+ </button>
305
+ )}
306
+ </>
307
+ )}
308
+ </>
309
+ )}
310
+
229
311
  {/* Service actions */}
230
312
  {kind === 'services' && !data?.apiVersion?.includes('serving.knative.dev') && canPortForward && resource.namespace && resource.name && renderPortForward && (
231
313
  renderPortForward({
@@ -418,8 +500,60 @@ export function ResourceActionsBar({
418
500
  resourceKind={formatKindName(resource.kind)}
419
501
  namespaceName={resource.namespace}
420
502
  isLoading={isDeleting ?? false}
503
+ cascadeDependents={cascadeDependents}
504
+ cascadeLoading={cascadeLoading}
505
+ />
506
+
507
+ {/* Node cordon confirmation */}
508
+ <ConfirmDialog
509
+ open={showCordonConfirm}
510
+ onClose={() => setShowCordonConfirm(false)}
511
+ onConfirm={() => {
512
+ onCordonNode?.({ name: resource.name })
513
+ setShowCordonConfirm(false)
514
+ }}
515
+ title="Cordon Node"
516
+ message={`Mark node "${resource.name}" as unschedulable? No new pods will be scheduled on this node.`}
517
+ confirmLabel="Cordon"
518
+ variant="warning"
519
+ isLoading={isCordoningNode}
421
520
  />
422
521
 
522
+ {/* Node drain confirmation */}
523
+ <ConfirmDialog
524
+ open={showDrainConfirm}
525
+ onClose={() => {
526
+ setShowDrainConfirm(false)
527
+ setDrainForce(false)
528
+ }}
529
+ onConfirm={() => {
530
+ onDrainNode?.({
531
+ name: resource.name,
532
+ options: { deleteEmptyDirData: true, force: drainForce || undefined },
533
+ })
534
+ setShowDrainConfirm(false)
535
+ setDrainForce(false)
536
+ }}
537
+ title="Drain Node"
538
+ message={`Cordon and evict all pods from node "${resource.name}"? DaemonSet pods will be skipped.`}
539
+ confirmLabel={isDrainingNode ? 'Draining...' : 'Drain'}
540
+ variant="danger"
541
+ isLoading={isDrainingNode}
542
+ isClosable
543
+ >
544
+ <div className="flex flex-col gap-2 text-sm text-theme-text-secondary">
545
+ <label className="flex items-center gap-2 cursor-pointer">
546
+ <input
547
+ type="checkbox"
548
+ checked={drainForce}
549
+ onChange={(e) => setDrainForce(e.target.checked)}
550
+ className="rounded border-theme-border"
551
+ />
552
+ Force (evict pods not managed by a controller)
553
+ </label>
554
+ </div>
555
+ </ConfirmDialog>
556
+
423
557
  {showRevisions && ['deployments', 'statefulsets', 'daemonsets'].includes(kind) && (
424
558
  <RevisionHistoryDialog
425
559
  kind={resource.kind}
@@ -619,7 +753,7 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
619
753
  const [confirmRevision, setConfirmRevision] = useState<number | null>(null)
620
754
  const [diffRevision, setDiffRevision] = useState<number | null>(null)
621
755
 
622
- if (!open) return null
756
+ const handleClose = () => { setDiffRevision(null); onClose() }
623
757
 
624
758
  const currentRevision = revisions?.find(r => r.isCurrent)
625
759
  const selectedRevision = revisions?.find(r => r.number === diffRevision)
@@ -660,160 +794,158 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
660
794
  }
661
795
 
662
796
  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",
797
+ <DialogPortal
798
+ open={open}
799
+ onClose={handleClose}
800
+ closable={!isRollingBack}
801
+ className={clsx(
802
+ "flex flex-col",
671
803
  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>
804
+ )}
805
+ >
806
+ <div className="flex items-center justify-between p-4 border-b border-theme-border shrink-0">
807
+ <div className="flex items-center gap-2">
808
+ <History className="w-5 h-5 text-amber-500" />
809
+ <h3 className="text-lg font-semibold text-theme-text-primary">Revision History</h3>
810
+ {diffRevision && currentRevision && (
811
+ <span className="flex items-center gap-1 ml-2 px-2 py-0.5 text-xs bg-blue-500/15 text-blue-400 rounded">
812
+ <GitCompare className="w-3 h-3" />
813
+ #{currentRevision.number} vs #{diffRevision}
814
+ </span>
815
+ )}
691
816
  </div>
817
+ <button
818
+ onClick={handleClose}
819
+ disabled={isRollingBack}
820
+ className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded disabled:opacity-50"
821
+ >
822
+ <X className="w-5 h-5" />
823
+ </button>
824
+ </div>
692
825
 
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 && (
826
+ <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
827
+ <div className={clsx("p-4 overflow-y-auto", diffRevision ? "max-h-48 shrink-0" : "max-h-80")}>
828
+ {isLoading && (
829
+ <div className="flex items-center justify-center py-8 text-theme-text-secondary text-sm">
830
+ Loading revisions...
831
+ </div>
832
+ )}
833
+
834
+ {error && (
835
+ <div className="flex items-center justify-center py-8 text-red-400 text-sm">
836
+ Failed to load revisions: {error instanceof Error ? error.message : 'Unknown error'}
837
+ </div>
838
+ )}
839
+
840
+ {revisions && revisions.length === 0 && (
841
+ <div className="flex items-center justify-center py-8 text-theme-text-secondary text-sm">
842
+ No revisions found
843
+ </div>
844
+ )}
845
+
846
+ {revisions && revisions.length > 0 && (
847
+ <table className="w-full text-sm">
848
+ <thead>
849
+ <tr className="text-theme-text-secondary text-left text-xs uppercase tracking-wider">
850
+ <th className="pb-2 pr-3 font-medium">Rev</th>
851
+ <th className="pb-2 pr-3 font-medium">Image</th>
852
+ <th className="pb-2 pr-3 font-medium">Age</th>
853
+ <th className="pb-2 font-medium text-right">Action</th>
854
+ </tr>
855
+ </thead>
856
+ <tbody>
857
+ {revisions.map((rev: WorkloadRevision) => (
858
+ <tr
859
+ key={rev.number}
860
+ className={clsx(
861
+ "border-t border-theme-border/50",
862
+ diffRevision === rev.number && "bg-blue-500/10"
863
+ )}
864
+ >
865
+ <td className="py-2 pr-3 text-theme-text-primary font-mono">
866
+ #{rev.number}
867
+ </td>
868
+ <td className="py-2 pr-3 text-theme-text-secondary font-mono truncate max-w-[180px]" title={rev.image}>
869
+ {getImageTag(rev.image)}
870
+ </td>
871
+ <td className="py-2 pr-3 text-theme-text-secondary whitespace-nowrap">
872
+ {formatTimeAgo(rev.createdAt)}
873
+ </td>
874
+ <td className="py-2 text-right">
875
+ <div className="flex items-center gap-1 justify-end">
876
+ {!rev.isCurrent && rev.template && currentRevision?.template && (
877
+ <button
878
+ onClick={() => setDiffRevision(diffRevision === rev.number ? null : rev.number)}
879
+ className={clsx(
880
+ "px-2 py-0.5 text-xs font-medium rounded transition-colors flex items-center gap-1",
881
+ diffRevision === rev.number
882
+ ? "bg-blue-500/20 text-blue-400 border border-blue-400/50"
883
+ : "text-blue-400 hover:text-blue-300 hover:bg-blue-500/10 border border-transparent"
884
+ )}
885
+ title={`Compare with current revision`}
886
+ >
887
+ <GitCompare className="w-3 h-3" />
888
+ Diff
889
+ </button>
890
+ )}
891
+ {rev.isCurrent ? (
892
+ <span className="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-green-500/20 text-green-400 rounded">
893
+ Current
894
+ </span>
895
+ ) : confirmRevision === rev.number ? (
896
+ <>
744
897
  <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`}
898
+ onClick={() => handleRollback(rev.number)}
899
+ disabled={isRollingBack}
900
+ 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"
753
901
  >
754
- <GitCompare className="w-3 h-3" />
755
- Diff
902
+ {isRollingBack ? 'Rolling back...' : 'Confirm'}
756
903
  </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
904
  <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"
905
+ onClick={() => setConfirmRevision(null)}
906
+ disabled={isRollingBack}
907
+ 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"
783
908
  >
784
- Rollback
909
+ Cancel
785
910
  </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
- />
911
+ </>
912
+ ) : (
913
+ <button
914
+ onClick={() => setConfirmRevision(rev.number)}
915
+ 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"
916
+ >
917
+ Rollback
918
+ </button>
919
+ )}
920
+ </div>
921
+ </td>
922
+ </tr>
923
+ ))}
924
+ </tbody>
925
+ </table>
803
926
  )}
804
927
  </div>
805
928
 
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>
929
+ {diffRevision && hasDiffData && (
930
+ <RevisionDiffView
931
+ currentTemplate={currentRevision!.template!}
932
+ selectedTemplate={selectedRevision!.template!}
933
+ currentRevision={currentRevision!.number}
934
+ selectedRevision={diffRevision}
935
+ />
936
+ )}
815
937
  </div>
816
- </div>
938
+
939
+ <div className="flex items-center justify-end p-4 border-t border-theme-border shrink-0">
940
+ <button
941
+ onClick={handleClose}
942
+ disabled={isRollingBack}
943
+ 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"
944
+ >
945
+ Close
946
+ </button>
947
+ </div>
948
+ </DialogPortal>
817
949
  )
818
950
  }
819
951