@skyhook-io/k8s-ui 1.2.4 → 1.3.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 (119) hide show
  1. package/package.json +3 -2
  2. package/src/components/dock/BottomDock.tsx +1 -1
  3. package/src/components/dock/LocalTerminalTab.tsx +1 -1
  4. package/src/components/dock/NodeTerminalTab.tsx +1 -1
  5. package/src/components/dock/TerminalTab.tsx +2 -2
  6. package/src/components/gitops/GitOpsActions.tsx +12 -11
  7. package/src/components/gitops/GitOpsStatusBadge.tsx +6 -6
  8. package/src/components/logs/LogCore.tsx +8 -8
  9. package/src/components/logs/WorkloadLogsViewer.tsx +29 -5
  10. package/src/components/logs/useLogStream.ts +20 -0
  11. package/src/components/resources/ResourcesSidebar.tsx +10 -10
  12. package/src/components/resources/ResourcesView.tsx +49 -48
  13. package/src/components/resources/renderers/AlertRenderer.tsx +2 -2
  14. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +2 -2
  15. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +1 -1
  16. package/src/components/resources/renderers/CertificateRenderer.tsx +3 -3
  17. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -3
  18. package/src/components/resources/renderers/ChallengeRenderer.tsx +2 -2
  19. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +3 -3
  20. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +3 -3
  21. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
  22. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +1 -1
  23. package/src/components/resources/renderers/EventRenderer.tsx +1 -1
  24. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
  25. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
  26. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +1 -1
  27. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +5 -5
  28. package/src/components/resources/renderers/GatewayClassRenderer.tsx +1 -1
  29. package/src/components/resources/renderers/GatewayRenderer.tsx +3 -3
  30. package/src/components/resources/renderers/GenericRenderer.tsx +1 -1
  31. package/src/components/resources/renderers/HPARenderer.tsx +1 -1
  32. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +5 -5
  33. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +1 -1
  34. package/src/components/resources/renderers/IngressClassRenderer.tsx +1 -1
  35. package/src/components/resources/renderers/IngressRenderer.tsx +1 -1
  36. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +10 -10
  37. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +1 -1
  38. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +2 -2
  39. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +2 -2
  40. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +6 -6
  41. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -5
  42. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +5 -5
  43. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -2
  44. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +4 -4
  45. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -3
  46. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -3
  47. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +5 -5
  48. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +2 -2
  49. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +5 -5
  50. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +4 -4
  51. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +8 -8
  52. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +3 -3
  53. package/src/components/resources/renderers/KnativeRouteRenderer.tsx +1 -1
  54. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +2 -2
  55. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +6 -6
  56. package/src/components/resources/renderers/KustomizationRenderer.tsx +1 -1
  57. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +4 -4
  58. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -6
  59. package/src/components/resources/renderers/NodeRenderer.tsx +5 -5
  60. package/src/components/resources/renderers/OrderRenderer.tsx +3 -3
  61. package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +1 -1
  62. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +1 -1
  63. package/src/components/resources/renderers/PodMonitorRenderer.tsx +3 -3
  64. package/src/components/resources/renderers/PodRenderer.tsx +10 -10
  65. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  66. package/src/components/resources/renderers/RoleBindingRenderer.tsx +3 -3
  67. package/src/components/resources/renderers/RoleRenderer.tsx +8 -8
  68. package/src/components/resources/renderers/RolloutRenderer.tsx +1 -1
  69. package/src/components/resources/renderers/RuntimeClassRenderer.tsx +2 -2
  70. package/src/components/resources/renderers/SealedSecretRenderer.tsx +2 -2
  71. package/src/components/resources/renderers/SecretRenderer.tsx +3 -3
  72. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +1 -1
  73. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +3 -3
  74. package/src/components/resources/renderers/ServiceRenderer.tsx +1 -1
  75. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +5 -5
  76. package/src/components/resources/renderers/StorageClassRenderer.tsx +1 -1
  77. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +1 -1
  78. package/src/components/resources/renderers/VPARenderer.tsx +2 -2
  79. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -2
  80. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +6 -6
  81. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +5 -5
  82. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +6 -6
  83. package/src/components/resources/renderers/VeleroVSLRenderer.tsx +1 -1
  84. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +10 -10
  85. package/src/components/resources/renderers/WorkflowRenderer.tsx +2 -2
  86. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +2 -2
  87. package/src/components/resources/renderers/WorkloadRenderer.tsx +1 -1
  88. package/src/components/resources/renderers/argo-cells.tsx +3 -3
  89. package/src/components/resources/renderers/certmanager-cells.tsx +6 -6
  90. package/src/components/resources/renderers/cnpg-cells.tsx +5 -5
  91. package/src/components/resources/renderers/eso-cells.tsx +4 -4
  92. package/src/components/resources/renderers/flux-cells.tsx +6 -6
  93. package/src/components/resources/renderers/istio-cells.tsx +9 -9
  94. package/src/components/resources/renderers/karpenter-cells.tsx +3 -3
  95. package/src/components/resources/renderers/keda-cells.tsx +2 -2
  96. package/src/components/resources/renderers/knative-cells.tsx +2 -2
  97. package/src/components/resources/renderers/kyverno-cells.tsx +3 -3
  98. package/src/components/resources/renderers/prometheus-cells.tsx +3 -3
  99. package/src/components/resources/renderers/trivy-cells.tsx +4 -4
  100. package/src/components/resources/renderers/velero-cells.tsx +4 -4
  101. package/src/components/shared/EditableYamlView.tsx +1 -1
  102. package/src/components/shared/ResourceActionsBar.tsx +17 -17
  103. package/src/components/shared/ResourceRendererDispatch.tsx +8 -7
  104. package/src/components/topology/K8sResourceNode.tsx +5 -5
  105. package/src/components/topology/TopologyControls.tsx +2 -2
  106. package/src/components/topology/TopologyGraph.tsx +89 -23
  107. package/src/components/topology/topology.css +1 -1
  108. package/src/components/ui/Badge.tsx +178 -0
  109. package/src/components/ui/HealthRing.tsx +70 -35
  110. package/src/components/ui/MetricsChart.tsx +2 -2
  111. package/src/components/ui/drawer-components.tsx +12 -31
  112. package/src/components/workload/ResourceDetailDrawer.tsx +4 -4
  113. package/src/components/workload/WorkloadView.tsx +10 -10
  114. package/src/theme/components.css +195 -0
  115. package/src/theme/tailwind-theme.css +13 -13
  116. package/src/theme/variables.css +46 -29
  117. package/src/types/core.ts +2 -0
  118. package/src/utils/badge-colors.ts +54 -205
  119. package/src/utils/log-format.ts +0 -8
@@ -1,4 +1,5 @@
1
1
  import { clsx } from 'clsx'
2
+ import { SEVERITY_BADGE } from '../../utils/badge-colors'
2
3
  import {
3
4
  getPodStatus,
4
5
  getWorkloadStatus,
@@ -550,9 +551,9 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
550
551
  // Contour HTTPProxy
551
552
  if (k === 'httpproxies') {
552
553
  const s = getHTTPProxyStatus(data)
553
- if (s.status === 'healthy') return { text: s.label, color: 'bg-green-500/20 text-green-400' }
554
- if (s.status === 'unhealthy') return { text: s.label, color: 'bg-red-500/20 text-red-400' }
555
- if (s.status === 'degraded') return { text: s.label, color: 'bg-yellow-500/20 text-yellow-400' }
554
+ if (s.status === 'healthy') return { text: s.label, color: SEVERITY_BADGE.success }
555
+ if (s.status === 'unhealthy') return { text: s.label, color: SEVERITY_BADGE.error }
556
+ if (s.status === 'degraded') return { text: s.label, color: SEVERITY_BADGE.warning }
556
557
  return null
557
558
  }
558
559
 
@@ -587,9 +588,9 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
587
588
  const isWarning = warningPhases.includes(phase)
588
589
  return {
589
590
  text: phase,
590
- color: isHealthy ? 'bg-green-500/20 text-green-400' :
591
- isWarning ? 'bg-yellow-500/20 text-yellow-400' :
592
- 'bg-red-500/20 text-red-400'
591
+ color: isHealthy ? SEVERITY_BADGE.success :
592
+ isWarning ? SEVERITY_BADGE.warning :
593
+ SEVERITY_BADGE.error
593
594
  }
594
595
  }
595
596
 
@@ -601,7 +602,7 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
601
602
  const isReady = readyCondition.status === 'True'
602
603
  return {
603
604
  text: isReady ? 'Ready' : 'Not Ready',
604
- color: isReady ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'
605
+ color: isReady ? SEVERITY_BADGE.success : SEVERITY_BADGE.warning
605
606
  }
606
607
  }
607
608
  }
@@ -317,13 +317,13 @@ export const K8sResourceNode = memo(function K8sResourceNode({
317
317
  <div
318
318
  className={clsx(
319
319
  'flex items-center gap-2 px-4 py-2 rounded-full',
320
- 'bg-blue-500/10 border border-blue-500/30',
321
- 'shadow-lg shadow-blue-500/20',
322
- selected && 'ring-2 ring-blue-400'
320
+ 'selection border border-skyhook-500/30',
321
+ 'shadow-lg shadow-skyhook-500/20',
322
+ selected && 'ring-2 ring-skyhook-400'
323
323
  )}
324
324
  >
325
325
  <span className="topology-icon topology-icon-internet" style={{ width: 20, height: 20 }} />
326
- <span className="text-sm font-medium text-blue-300">Internet</span>
326
+ <span className="text-sm font-medium text-skyhook-300">Internet</span>
327
327
  <span className="w-2 h-2 rounded-full bg-green-500" />
328
328
  </div>
329
329
  <Handle
@@ -347,7 +347,7 @@ export const K8sResourceNode = memo(function K8sResourceNode({
347
347
  className={clsx(
348
348
  'relative rounded-lg overflow-hidden',
349
349
  'bg-theme-surface topology-node-card',
350
- selected && 'ring-2 ring-blue-400',
350
+ selected && 'ring-2 ring-skyhook-400',
351
351
  isSmallNode && 'opacity-90',
352
352
  // Status bar via CSS pseudo-element (defined in index.css)
353
353
  (status === 'healthy' || status === 'unknown') && 'topology-node-status-bar',
@@ -40,7 +40,7 @@ export function TopologyControls({
40
40
  onClick={() => onViewModeChange('resources')}
41
41
  className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
42
42
  viewMode === 'resources'
43
- ? 'bg-blue-500 text-theme-text-primary'
43
+ ? 'bg-skyhook-600 text-white'
44
44
  : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated'
45
45
  }`}
46
46
  >
@@ -50,7 +50,7 @@ export function TopologyControls({
50
50
  onClick={() => onViewModeChange('traffic')}
51
51
  className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
52
52
  viewMode === 'traffic'
53
- ? 'bg-blue-500 text-theme-text-primary'
53
+ ? 'bg-skyhook-600 text-white'
54
54
  : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated'
55
55
  }`}
56
56
  >
@@ -12,6 +12,7 @@ import {
12
12
  type Node,
13
13
  type Edge,
14
14
  type NodeTypes,
15
+ type NodeChange,
15
16
  type Viewport,
16
17
  BackgroundVariant,
17
18
  MarkerType,
@@ -19,7 +20,7 @@ import {
19
20
  import '@xyflow/react/dist/style.css'
20
21
  import { toCanvas } from 'html-to-image'
21
22
 
22
- import { AlertTriangle, Download, Loader2, RotateCw, Shield } from 'lucide-react'
23
+ import { AlertTriangle, Download, Loader2, Pause, Play, RotateCw, Shield } from 'lucide-react'
23
24
  import { useToast } from '../ui/Toast'
24
25
  import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
25
26
 
@@ -156,6 +157,8 @@ interface TopologyGraphProps {
156
157
  selectedNodeId?: string
157
158
  /** Show image export button in controls. Default: true */
158
159
  showExportButton?: boolean
160
+ paused?: boolean
161
+ onTogglePause?: () => void
159
162
  }
160
163
 
161
164
  export function TopologyGraph({
@@ -166,10 +169,22 @@ export function TopologyGraph({
166
169
  onNodeClick,
167
170
  selectedNodeId,
168
171
  showExportButton = true,
172
+ paused = false,
173
+ onTogglePause,
169
174
  }: TopologyGraphProps) {
170
175
  const isTrafficView = viewMode === 'traffic'
171
- const [nodes, setNodes, onNodesChange] = useNodesState([] as Node[])
176
+ const [nodes, setNodes, onNodesChangeBase] = useNodesState([] as Node[])
172
177
  const [edges, setEdges, onEdgesChange] = useEdgesState([] as Edge[])
178
+
179
+ // Wrap onNodesChange to track user-dragged positions so they survive re-layouts.
180
+ const onNodesChange = useCallback((changes: NodeChange<Node>[]) => {
181
+ onNodesChangeBase(changes)
182
+ for (const change of changes) {
183
+ if (change.type === 'position' && change.position) {
184
+ savedPositionsRef.current.set(change.id, change.position)
185
+ }
186
+ }
187
+ }, [onNodesChangeBase])
173
188
  const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
174
189
  const [expandedPodGroups, setExpandedPodGroups] = useState<Set<string>>(new Set())
175
190
  const [layoutError, setLayoutError] = useState<string | null>(null)
@@ -177,6 +192,10 @@ export function TopologyGraph({
177
192
  const [isExporting, setIsExporting] = useState(false)
178
193
  const prevStructureRef = useRef<string>('')
179
194
  const layoutVersionRef = useRef(0) // Used to invalidate stale layout results
195
+ // Saved node positions for preservation across topology updates.
196
+ // Prevents every cluster change from re-running a full ELK layout (which shifts all nodes).
197
+ const savedPositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map())
198
+ const prevRetryCountRef = useRef(0)
180
199
 
181
200
  // Toggle group collapse
182
201
  const handleToggleCollapse = useCallback((groupId: string) => {
@@ -373,6 +392,7 @@ export function TopologyGraph({
373
392
  setNodes([])
374
393
  setEdges([])
375
394
  prevStructureRef.current = ''
395
+ savedPositionsRef.current.clear() // Clear on context switch / topology reset
376
396
  return
377
397
  }
378
398
 
@@ -382,6 +402,20 @@ export function TopologyGraph({
382
402
  return
383
403
  }
384
404
 
405
+ // Detect explicit re-layout request (Retry button) — clear saved positions so
406
+ // ELK computes a fresh layout from scratch instead of preserving old positions.
407
+ const isRetry = layoutRetryCount !== prevRetryCountRef.current
408
+ if (isRetry) {
409
+ prevRetryCountRef.current = layoutRetryCount
410
+ savedPositionsRef.current.clear()
411
+ }
412
+
413
+ // For the initial layout (no saved positions yet) run ELK for all nodes.
414
+ // For subsequent updates, preserve existing positions — only new nodes get
415
+ // ELK-computed positions. This prevents the whole graph from shifting every
416
+ // time the cluster adds a new resource.
417
+ const isInitialLayout = savedPositionsRef.current.size === 0
418
+
385
419
  prevStructureRef.current = structureKey
386
420
 
387
421
  // Increment version to invalidate any previous in-flight layout
@@ -418,8 +452,29 @@ export function TopologyGraph({
418
452
  }
419
453
  setLayoutError(null)
420
454
 
455
+ // Preserve positions for nodes that already have a saved position (i.e. were
456
+ // present in a previous layout). New nodes use the ELK-computed position.
457
+ // This prevents the whole graph from shifting every time the topology changes.
458
+ // isInitialLayout is captured in the outer effect scope.
459
+ const positionedNodes = isInitialLayout
460
+ ? layoutedNodes
461
+ : layoutedNodes.map(node => {
462
+ const saved = savedPositionsRef.current.get(node.id)
463
+ return saved ? { ...node, position: saved } : node
464
+ })
465
+
466
+ // Update saved positions: add/overwrite with positions from this layout run.
467
+ // Remove stale entries for nodes no longer in the topology.
468
+ const currentIds = new Set(positionedNodes.map(n => n.id))
469
+ for (const id of savedPositionsRef.current.keys()) {
470
+ if (!currentIds.has(id)) savedPositionsRef.current.delete(id)
471
+ }
472
+ for (const node of positionedNodes) {
473
+ savedPositionsRef.current.set(node.id, node.position)
474
+ }
475
+
421
476
  // Add expand/collapse handlers to pod-related nodes
422
- const nodesWithHandlers = layoutedNodes.map(node => {
477
+ const nodesWithHandlers = positionedNodes.map(node => {
423
478
  const isPodGroup = node.data?.kind === 'PodGroup'
424
479
  const nodeData = node.data?.nodeData as Record<string, unknown> | undefined
425
480
  const expandedFromGroup = nodeData?.expandedFromGroup as string | undefined
@@ -622,8 +677,17 @@ export function TopologyGraph({
622
677
  showInteractive={false}
623
678
  >
624
679
  {showExportButton && <ExportImageButton onExportingChange={setIsExporting} />}
680
+ {onTogglePause && (
681
+ <button
682
+ className={`react-flow__controls-button ${paused ? 'text-amber-400' : ''}`}
683
+ onClick={onTogglePause}
684
+ title={paused ? 'Resume live updates' : 'Pause live updates'}
685
+ >
686
+ {paused ? <Play className="w-3 h-3" /> : <Pause className="w-3 h-3" />}
687
+ </button>
688
+ )}
625
689
  </Controls>
626
- <ViewportController structureKey={structureKey} />
690
+ <ViewportController viewMode={viewMode} layoutRetryCount={layoutRetryCount} />
627
691
  </ReactFlow>
628
692
  </ReactFlowProvider>
629
693
  )
@@ -818,7 +882,7 @@ function ExportImageButton({ onExportingChange }: { onExportingChange: (v: boole
818
882
  <button
819
883
  key={mode}
820
884
  onClick={() => setCaptureMode(mode)}
821
- className={`flex-1 px-2 py-1.5 text-xs rounded transition-colors ${captureMode === mode ? 'bg-blue-600 text-white' : 'bg-theme-base text-theme-text-secondary hover:text-theme-text-primary border border-theme-border'}`}
885
+ className={`flex-1 px-2 py-1.5 text-xs rounded transition-colors ${captureMode === mode ? 'btn-brand' : 'bg-theme-base text-theme-text-secondary hover:text-theme-text-primary border border-theme-border'}`}
822
886
  >
823
887
  {mode === 'full' ? 'Entire graph' : 'Visible area'}
824
888
  </button>
@@ -832,7 +896,7 @@ function ExportImageButton({ onExportingChange }: { onExportingChange: (v: boole
832
896
  <button
833
897
  key={f}
834
898
  onClick={() => setFormat(f)}
835
- className={`flex-1 px-2 py-1.5 text-xs rounded transition-colors ${format === f ? 'bg-blue-600 text-white' : 'bg-theme-base text-theme-text-secondary hover:text-theme-text-primary border border-theme-border'}`}
899
+ className={`flex-1 px-2 py-1.5 text-xs rounded transition-colors ${format === f ? 'btn-brand' : 'bg-theme-base text-theme-text-secondary hover:text-theme-text-primary border border-theme-border'}`}
836
900
  >
837
901
  {FORMAT_LABELS[f]}
838
902
  </button>
@@ -880,7 +944,7 @@ function ExportImageButton({ onExportingChange }: { onExportingChange: (v: boole
880
944
  <button
881
945
  onClick={doExport}
882
946
  disabled={exporting}
883
- className="flex-1 px-3 py-1.5 text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors disabled:opacity-50 flex items-center justify-center gap-1.5"
947
+ className="flex-1 px-3 py-1.5 text-sm font-medium btn-brand rounded flex items-center justify-center gap-1.5"
884
948
  >
885
949
  {exporting ? <Loader2 className="w-3 h-3 animate-spin" /> : <Download className="w-3 h-3" />}
886
950
  Export
@@ -904,10 +968,17 @@ const VIEWPORT_ANIMATION_DURATION = 400
904
968
 
905
969
  // Inner component to handle animated viewport transitions and zoom-based CSS variables
906
970
  // Must be inside ReactFlow to use useReactFlow hook
907
- function ViewportController({ structureKey }: { structureKey: string }) {
971
+ function ViewportController({
972
+ viewMode,
973
+ layoutRetryCount,
974
+ }: {
975
+ viewMode: string
976
+ layoutRetryCount: number
977
+ }) {
908
978
  const { fitView, zoomIn, zoomOut, setViewport, getViewport } = useReactFlow()
909
979
  const nodes = useNodes() // Reactive hook to watch node changes
910
- const prevStructureKeyRef = useRef<string>('')
980
+ const prevViewModeRef = useRef<string>(viewMode)
981
+ const prevRetryCountRef = useRef(layoutRetryCount)
911
982
  const prevNodesLengthRef = useRef(0)
912
983
 
913
984
  // Topology keyboard shortcuts
@@ -978,34 +1049,29 @@ function ViewportController({ structureKey }: { structureKey: string }) {
978
1049
  updateZoomOffset(getViewport())
979
1050
  }, [updateZoomOffset, getViewport])
980
1051
 
981
- // Fit view when nodes become available or structure changes
982
- // This handles both initial mount and view switching scenarios
1052
+ // Fit view only on intentional changes: initial load, namespace/view switch, explicit retry.
1053
+ // Background topology updates (new pods, status changes) must NOT trigger fitView —
1054
+ // doing so would cause the viewport to zoom/pan every few seconds in active clusters.
983
1055
  useEffect(() => {
984
- const structureChanged = structureKey !== prevStructureKeyRef.current
985
1056
  const nodesJustPopulated = prevNodesLengthRef.current === 0 && nodes.length > 0
1057
+ const viewModeChanged = viewMode !== prevViewModeRef.current
1058
+ const retryRequested = layoutRetryCount !== prevRetryCountRef.current
986
1059
 
987
- // Update refs
988
1060
  prevNodesLengthRef.current = nodes.length
989
- if (structureChanged) {
990
- prevStructureKeyRef.current = structureKey
991
- }
1061
+ prevViewModeRef.current = viewMode
1062
+ prevRetryCountRef.current = layoutRetryCount
992
1063
 
993
- // Fit view when:
994
- // 1. Nodes just became available (were 0, now > 0) - handles initial mount/view switch
995
- // 2. Structure changed AND nodes already exist - handles topology changes
996
- if (nodesJustPopulated || (structureChanged && nodes.length > 0)) {
997
- // Small delay to ensure DOM is updated
1064
+ if (nodesJustPopulated || viewModeChanged || retryRequested) {
998
1065
  const timeoutId = setTimeout(() => {
999
1066
  fitView({
1000
1067
  padding: 0.15,
1001
- // No animation when nodes first appear, animate on subsequent structure changes
1002
1068
  duration: nodesJustPopulated ? 0 : VIEWPORT_ANIMATION_DURATION,
1003
1069
  })
1004
1070
  }, 10)
1005
1071
 
1006
1072
  return () => clearTimeout(timeoutId)
1007
1073
  }
1008
- }, [structureKey, nodes.length, fitView])
1074
+ }, [viewMode, layoutRetryCount, nodes.length, fitView])
1009
1075
 
1010
1076
  return null
1011
1077
  }
@@ -14,7 +14,7 @@
14
14
 
15
15
  /* Topology node card styling */
16
16
  .topology-node-card {
17
- border: 1px solid var(--topology-node-border);
17
+ border: 0.5px solid var(--topology-node-border);
18
18
  box-shadow: var(--topology-node-shadow);
19
19
  }
20
20
 
@@ -0,0 +1,178 @@
1
+ import { clsx } from 'clsx'
2
+ import type { ReactNode } from 'react'
3
+
4
+ // =============================================================================
5
+ // BADGE COMPONENT — single source of truth for all badge rendering
6
+ // All class names are STATIC STRINGS (required for Tailwind content scanning)
7
+ // =============================================================================
8
+
9
+ export type BadgeSeverity = 'success' | 'warning' | 'error' | 'info' | 'neutral'
10
+ export type BadgeSize = 'sm' | 'default'
11
+
12
+ interface BadgeProps {
13
+ /** Severity-based coloring (status badges) */
14
+ severity?: BadgeSeverity
15
+ /** K8s resource kind coloring (kind badges) */
16
+ kind?: string
17
+ /** Explicit color class override (bypasses severity/kind lookup) */
18
+ colorClass?: string
19
+ /** Size variant */
20
+ size?: BadgeSize
21
+ /** Additional classes */
22
+ className?: string
23
+ /** Click handler (renders as button) */
24
+ onClick?: () => void
25
+ /** Title/tooltip */
26
+ title?: string
27
+ children: ReactNode
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // SEVERITY COLORS — static strings for Tailwind scanning
32
+ // ---------------------------------------------------------------------------
33
+ const SEVERITY: Record<BadgeSeverity, string> = {
34
+ success: 'bg-emerald-100 text-emerald-700 border-emerald-300 dark:bg-emerald-950/50 dark:text-emerald-400 dark:border-emerald-700/40',
35
+ warning: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-400 dark:border-amber-700/40',
36
+ error: 'bg-red-100 text-red-700 border-red-300 dark:bg-red-950/50 dark:text-red-400 dark:border-red-700/40',
37
+ info: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
38
+ neutral: 'bg-theme-hover/50 text-theme-text-secondary border-theme-border',
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // KIND COLORS — static strings, every K8s resource type
43
+ // ---------------------------------------------------------------------------
44
+ const KIND: Record<string, string> = {
45
+ // Workloads
46
+ Deployment: 'bg-emerald-100 text-emerald-700 border-emerald-300 dark:bg-emerald-950/50 dark:text-emerald-400 dark:border-emerald-700/40',
47
+ StatefulSet: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
48
+ DaemonSet: 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-950/50 dark:text-teal-400 dark:border-teal-700/40',
49
+ ReplicaSet: 'bg-green-100 text-green-800 border-green-300 dark:bg-green-950/50 dark:text-green-400 dark:border-green-700/40',
50
+ Pod: 'bg-lime-100 text-lime-800 border-lime-300 dark:bg-lime-950/50 dark:text-lime-300 dark:border-lime-700/40',
51
+ PodGroup: 'bg-lime-100 text-lime-800 border-lime-300 dark:bg-lime-950/50 dark:text-lime-300 dark:border-lime-700/40',
52
+ Rollout: 'bg-emerald-100 text-emerald-700 border-emerald-300 dark:bg-emerald-950/50 dark:text-emerald-400 dark:border-emerald-700/40',
53
+
54
+ // Networking
55
+ Service: 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/50 dark:text-blue-400 dark:border-blue-700/40',
56
+ Internet: 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/50 dark:text-blue-400 dark:border-blue-700/40',
57
+ Ingress: 'bg-violet-100 text-violet-700 border-violet-300 dark:bg-violet-950/50 dark:text-violet-400 dark:border-violet-700/40',
58
+ Gateway: 'bg-violet-100 text-violet-700 border-violet-300 dark:bg-violet-950/50 dark:text-violet-400 dark:border-violet-700/40',
59
+ HTTPRoute: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
60
+ GRPCRoute: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
61
+ TCPRoute: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
62
+ TLSRoute: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
63
+
64
+ // Config
65
+ ConfigMap: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-300 dark:border-amber-700/40',
66
+ Secret: 'bg-red-100 text-red-700 border-red-300 dark:bg-red-950/50 dark:text-red-400 dark:border-red-700/40',
67
+
68
+ // Jobs
69
+ Job: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
70
+ CronJob: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
71
+
72
+ // Scaling & Storage
73
+ HorizontalPodAutoscaler: 'bg-pink-100 text-pink-800 border-pink-300 dark:bg-pink-950/50 dark:text-pink-300 dark:border-pink-700/40',
74
+ PersistentVolumeClaim: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
75
+ PodDisruptionBudget: 'bg-orange-100 text-orange-800 border-orange-300 dark:bg-orange-950/50 dark:text-orange-300 dark:border-orange-700/40',
76
+
77
+ // Cluster-scoped
78
+ Node: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
79
+ Namespace: 'bg-gray-100 text-gray-700 border-gray-300 dark:bg-gray-950/50 dark:text-gray-400 dark:border-gray-700/40',
80
+
81
+ // GitOps
82
+ Application: 'bg-orange-100 text-orange-800 border-orange-300 dark:bg-orange-950/50 dark:text-orange-300 dark:border-orange-700/40',
83
+ Kustomization: 'bg-indigo-100 text-indigo-700 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-400 dark:border-indigo-700/40',
84
+ GitRepository: 'bg-indigo-100 text-indigo-700 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-400 dark:border-indigo-700/40',
85
+ HelmRelease: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
86
+
87
+ // Knative
88
+ KnativeService: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
89
+ KnativeConfiguration: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
90
+ KnativeRevision: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
91
+ KnativeRoute: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
92
+ Broker: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
93
+ Trigger: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
94
+ PingSource: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
95
+ ApiServerSource: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
96
+ ContainerSource: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
97
+ SinkBinding: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
98
+ Channel: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
99
+
100
+ // Traefik
101
+ IngressRoute: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
102
+ IngressRouteTCP: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
103
+ IngressRouteUDP: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
104
+ Middleware: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
105
+ MiddlewareTCP: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
106
+ TraefikService: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
107
+ ServersTransport: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
108
+ ServersTransportTCP: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
109
+ TLSOption: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
110
+ TLSStore: 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-950/50 dark:text-cyan-400 dark:border-cyan-700/40',
111
+
112
+ // Contour
113
+ HTTPProxy: 'bg-violet-100 text-violet-700 border-violet-300 dark:bg-violet-950/50 dark:text-violet-400 dark:border-violet-700/40',
114
+
115
+ // Events
116
+ Event: 'bg-slate-100 text-slate-700 border-slate-300 dark:bg-slate-950/50 dark:text-slate-400 dark:border-slate-700/40',
117
+ }
118
+
119
+ const DEFAULT_KIND_COLOR = 'bg-fuchsia-50 text-fuchsia-700 border-fuchsia-200 dark:bg-fuchsia-950/40 dark:text-fuchsia-400 dark:border-fuchsia-800/40'
120
+
121
+ // Structure classes
122
+ const SIZE_CLASSES: Record<BadgeSize, string> = {
123
+ default: 'badge',
124
+ sm: 'badge-sm',
125
+ }
126
+
127
+ /** Resolve kind color with fuzzy matching for plural/lowercase forms */
128
+ export function getKindColorClass(kind: string): string {
129
+ // Direct match
130
+ if (KIND[kind]) return KIND[kind]
131
+
132
+ // Fuzzy: try capitalizing
133
+ const capitalized = kind.charAt(0).toUpperCase() + kind.slice(1)
134
+ if (KIND[capitalized]) return KIND[capitalized]
135
+
136
+ // Fuzzy: strip trailing 's' for plural
137
+ const singular = kind.endsWith('s') && !kind.endsWith('ss') ? kind.slice(0, -1) : kind
138
+ const singularCap = singular.charAt(0).toUpperCase() + singular.slice(1)
139
+ if (KIND[singularCap]) return KIND[singularCap]
140
+
141
+ // Fuzzy: known plurals
142
+ const pluralMap: Record<string, string> = {
143
+ pods: 'Pod', deployments: 'Deployment', services: 'Service', ingresses: 'Ingress',
144
+ gateways: 'Gateway', configmaps: 'ConfigMap', secrets: 'Secret',
145
+ daemonsets: 'DaemonSet', statefulsets: 'StatefulSet', replicasets: 'ReplicaSet',
146
+ jobs: 'Job', cronjobs: 'CronJob', nodes: 'Node', namespaces: 'Namespace',
147
+ horizontalpodautoscalers: 'HorizontalPodAutoscaler',
148
+ persistentvolumeclaims: 'PersistentVolumeClaim',
149
+ poddisruptionbudgets: 'PodDisruptionBudget',
150
+ rollouts: 'Rollout', httproutes: 'HTTPRoute', grpcroutes: 'GRPCRoute',
151
+ events: 'Event', helmreleases: 'HelmRelease',
152
+ }
153
+ const mapped = pluralMap[kind.toLowerCase()]
154
+ if (mapped && KIND[mapped]) return KIND[mapped]
155
+
156
+ return DEFAULT_KIND_COLOR
157
+ }
158
+
159
+ export function getSeverityColorClass(severity: BadgeSeverity): string {
160
+ return SEVERITY[severity]
161
+ }
162
+
163
+ /**
164
+ * Badge component — the ONE source of truth for badge rendering.
165
+ * Use severity for status badges, kind for resource type badges, or colorClass for custom.
166
+ */
167
+ export function Badge({ severity, kind, colorClass, size = 'default', className, onClick, title, children }: BadgeProps) {
168
+ const color = colorClass ?? (severity ? SEVERITY[severity] : kind ? getKindColorClass(kind) : '')
169
+ const cls = clsx(SIZE_CLASSES[size], color, className)
170
+
171
+ if (onClick) {
172
+ return <button onClick={onClick} className={cls} title={title}>{children}</button>
173
+ }
174
+ return <span className={cls} title={title}>{children}</span>
175
+ }
176
+
177
+ // Re-export the raw color maps for backwards compat (used by badge-colors.ts consumers)
178
+ export { SEVERITY as BADGE_SEVERITY_COLORS, KIND as BADGE_KIND_COLORS }
@@ -1,3 +1,5 @@
1
+ import { useId } from 'react'
2
+
1
3
  interface Segment {
2
4
  value: number
3
5
  color: string
@@ -11,13 +13,29 @@ interface HealthRingProps {
11
13
  }
12
14
 
13
15
  export function HealthRing({ segments, size = 48, strokeWidth = 5, label }: HealthRingProps) {
16
+ const filterId = useId()
14
17
  const total = segments.reduce((sum, s) => sum + s.value, 0)
18
+
19
+ const radius = 40
20
+ const circumference = 2 * Math.PI * radius
21
+ // 270° arc = 75% of circumference
22
+ const arcLength = 0.75 * circumference
23
+ const gapLength = circumference - arcLength
24
+
15
25
  if (total === 0) {
16
26
  return (
17
27
  <svg width={size} height={size} viewBox="0 0 100 100" className="shrink-0">
18
- <circle cx="50" cy="50" r="40" fill="none" stroke="currentColor" strokeWidth={strokeWidth} className="text-theme-border" />
28
+ {/* Background track (270° arc) */}
29
+ <circle
30
+ cx="50" cy="50" r={radius}
31
+ fill="none" stroke="currentColor" strokeWidth={strokeWidth}
32
+ strokeDasharray={`${arcLength} ${gapLength}`}
33
+ strokeLinecap="round"
34
+ transform="rotate(135 50 50)"
35
+ className="text-theme-border"
36
+ />
19
37
  {label && (
20
- <text x="50" y="50" textAnchor="middle" dominantBaseline="central" className="fill-theme-text-tertiary text-[22px] font-semibold">
38
+ <text x="50" y="48" textAnchor="middle" dominantBaseline="central" className="fill-theme-text-tertiary text-[22px] font-semibold font-mono">
21
39
  0
22
40
  </text>
23
41
  )}
@@ -25,45 +43,62 @@ export function HealthRing({ segments, size = 48, strokeWidth = 5, label }: Heal
25
43
  )
26
44
  }
27
45
 
28
- const radius = 40
29
- const circumference = 2 * Math.PI * radius
30
- let accumulatedOffset = 0
46
+ let accumulatedFraction = 0
31
47
 
32
- // Rotate -90deg so arcs start at 12 o'clock
33
- const arcs = segments
34
- .filter(s => s.value > 0)
35
- .map((seg) => {
36
- const fraction = seg.value / total
37
- const dashLength = fraction * circumference
38
- const gapLength = circumference - dashLength
39
- const offset = -accumulatedOffset * circumference
40
- accumulatedOffset += fraction
41
- return { color: seg.color, dashLength, gapLength, offset }
42
- })
48
+ const visibleSegments = segments.filter(s => s.value > 0)
49
+ const arcs = visibleSegments.map((seg, i) => {
50
+ const fraction = seg.value / total
51
+ // Scale to 270° arc (75% of circumference)
52
+ const dashLength = fraction * arcLength
53
+ // Offset within the 270° arc range
54
+ const offset = -accumulatedFraction * arcLength
55
+ const isFirst = i === 0
56
+ const isLast = i === visibleSegments.length - 1
57
+ accumulatedFraction += fraction
58
+ return { color: seg.color, dashLength, offset, isFirst, isLast }
59
+ })
43
60
 
44
61
  return (
45
62
  <svg width={size} height={size} viewBox="0 0 100 100" className="shrink-0">
46
- {/* Background ring */}
47
- <circle cx="50" cy="50" r={radius} fill="none" stroke="currentColor" strokeWidth={strokeWidth} className="text-theme-border opacity-30" />
63
+ <defs>
64
+ <filter id={`arcglow-${filterId}`}>
65
+ <feGaussianBlur stdDeviation="2.5" result="blur"/>
66
+ <feMerge>
67
+ <feMergeNode in="blur"/>
68
+ <feMergeNode in="SourceGraphic"/>
69
+ </feMerge>
70
+ </filter>
71
+ </defs>
72
+ {/* Background track (270° arc) */}
73
+ <circle
74
+ cx="50" cy="50" r={radius}
75
+ fill="none" stroke="currentColor" strokeWidth={strokeWidth}
76
+ strokeDasharray={`${arcLength} ${gapLength}`}
77
+ strokeLinecap="round"
78
+ transform="rotate(135 50 50)"
79
+ className="text-theme-border opacity-30"
80
+ />
48
81
  {/* Segments */}
49
- {arcs.map((arc, i) => (
50
- <circle
51
- key={i}
52
- cx="50"
53
- cy="50"
54
- r={radius}
55
- fill="none"
56
- stroke={arc.color}
57
- strokeWidth={strokeWidth}
58
- strokeDasharray={`${arc.dashLength} ${arc.gapLength}`}
59
- strokeDashoffset={arc.offset}
60
- strokeLinecap="butt"
61
- transform="rotate(-90 50 50)"
62
- />
63
- ))}
64
- {/* Center label */}
82
+ <g filter={`url(#arcglow-${filterId})`}>
83
+ {arcs.map((arc, i) => (
84
+ <circle
85
+ key={i}
86
+ cx="50"
87
+ cy="50"
88
+ r={radius}
89
+ fill="none"
90
+ stroke={arc.color}
91
+ strokeWidth={strokeWidth}
92
+ strokeDasharray={`${arc.dashLength} ${circumference - arc.dashLength}`}
93
+ strokeDashoffset={arc.offset}
94
+ strokeLinecap={arc.isFirst || arc.isLast ? 'round' : 'butt'}
95
+ transform="rotate(135 50 50)"
96
+ />
97
+ ))}
98
+ </g>
99
+ {/* Center label — nudged up slightly for visual centering in 270° arc */}
65
100
  {label && (
66
- <text x="50" y="50" textAnchor="middle" dominantBaseline="central" className="fill-theme-text-primary text-[22px] font-semibold">
101
+ <text x="50" y="48" textAnchor="middle" dominantBaseline="central" className="fill-theme-text-primary text-[22px] font-semibold font-mono">
67
102
  {label}
68
103
  </text>
69
104
  )}