@skyhook-io/k8s-ui 1.8.9 → 1.8.11

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 (130) hide show
  1. package/package.json +5 -4
  2. package/src/assets/gitops/argocd.png +0 -0
  3. package/src/assets/gitops/flux.svg +7 -0
  4. package/src/components/applications/AppChips.tsx +10 -0
  5. package/src/components/applications/ApplicationDetail.test.tsx +569 -0
  6. package/src/components/applications/ApplicationDetail.tsx +2517 -450
  7. package/src/components/applications/ApplicationsList.tsx +4 -1
  8. package/src/components/applications/ApplicationsView.tsx +101 -18
  9. package/src/components/applications/applications-view.test.tsx +47 -0
  10. package/src/components/applications/index.ts +1 -1
  11. package/src/components/audit/AuditAlerts.tsx +14 -10
  12. package/src/components/checks/ChecksView.tsx +81 -51
  13. package/src/components/checks/severity.ts +39 -45
  14. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  15. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +14 -59
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
  21. package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
  22. package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
  23. package/src/components/gitops/insights/insights-helpers.ts +69 -1
  24. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
  25. package/src/components/issues/IssuesView.tsx +219 -143
  26. package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
  27. package/src/components/issues/ResourceIssuesSection.tsx +28 -128
  28. package/src/components/issues/index.ts +3 -2
  29. package/src/components/issues/issue-timing.ts +53 -0
  30. package/src/components/issues/issues.test.ts +146 -4
  31. package/src/components/issues/severity.ts +29 -32
  32. package/src/components/logs/LogCore.tsx +14 -4
  33. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  34. package/src/components/logs/useLogStream.ts +8 -1
  35. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  36. package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
  37. package/src/components/resources/ResourcesSidebar.tsx +70 -52
  38. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  39. package/src/components/resources/ResourcesView.tsx +243 -53
  40. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  42. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  43. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
  44. package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
  45. package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
  46. package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
  47. package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
  48. package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
  49. package/src/components/resources/renderers/PodRenderer.tsx +81 -70
  50. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  51. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  52. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  53. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  54. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  55. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  56. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  57. package/src/components/resources/renderers/index.ts +1 -0
  58. package/src/components/resources/resource-utils-capi.ts +1 -1
  59. package/src/components/resources/resource-utils.ts +31 -0
  60. package/src/components/shared/DetailShell.tsx +2 -2
  61. package/src/components/shared/ResourceActionsBar.tsx +140 -108
  62. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  63. package/src/components/timeline/TimelineList.tsx +371 -354
  64. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  65. package/src/components/timeline/TimelineStrip.tsx +836 -0
  66. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  67. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  68. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  69. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  70. package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
  71. package/src/components/timeline/index.ts +23 -0
  72. package/src/components/timeline/scrubber-math.test.ts +235 -0
  73. package/src/components/timeline/scrubber-math.ts +249 -0
  74. package/src/components/timeline/shared.test.tsx +219 -0
  75. package/src/components/timeline/shared.tsx +232 -447
  76. package/src/components/timeline/timeline-filters.test.ts +196 -0
  77. package/src/components/timeline/timeline-filters.ts +180 -0
  78. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  79. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  80. package/src/components/timeline/timeline-live.test.ts +131 -0
  81. package/src/components/timeline/timeline-live.ts +77 -0
  82. package/src/components/topology/GroupNode.tsx +9 -2
  83. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  84. package/src/components/topology/K8sResourceNode.tsx +387 -243
  85. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  86. package/src/components/topology/TopologyGraph.tsx +89 -16
  87. package/src/components/topology/TopologySearch.tsx +2 -2
  88. package/src/components/topology/layout.ts +10 -2
  89. package/src/components/topology/topology.css +0 -92
  90. package/src/components/ui/Badge.tsx +5 -0
  91. package/src/components/ui/BoardSkeleton.tsx +47 -0
  92. package/src/components/ui/CardSection.tsx +117 -0
  93. package/src/components/ui/CodeViewer.tsx +2 -2
  94. package/src/components/ui/Collapse.tsx +59 -0
  95. package/src/components/ui/ConfirmDialog.tsx +7 -3
  96. package/src/components/ui/DialogPortal.tsx +31 -9
  97. package/src/components/ui/Input.tsx +24 -0
  98. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  99. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  100. package/src/components/ui/PaneLoader.tsx +10 -2
  101. package/src/components/ui/ResourceBar.tsx +68 -25
  102. package/src/components/ui/SearchBox.tsx +18 -4
  103. package/src/components/ui/SearchPillInput.tsx +2 -2
  104. package/src/components/ui/Toast.tsx +1 -1
  105. package/src/components/ui/drawer-components.tsx +39 -26
  106. package/src/components/ui/index.ts +6 -0
  107. package/src/components/ui/severity-tone.ts +63 -0
  108. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  109. package/src/components/workload/WorkloadView.tsx +2682 -472
  110. package/src/components/workload/index.ts +1 -1
  111. package/src/theme/variables.css +15 -1
  112. package/src/types/core.ts +57 -4
  113. package/src/utils/api-resources.test.ts +54 -0
  114. package/src/utils/api-resources.ts +68 -1
  115. package/src/utils/application-topology.test.ts +198 -0
  116. package/src/utils/application-topology.ts +261 -0
  117. package/src/utils/applications.test.ts +167 -1
  118. package/src/utils/applications.ts +1157 -393
  119. package/src/utils/index.ts +2 -0
  120. package/src/utils/navigation.test.ts +32 -1
  121. package/src/utils/navigation.ts +70 -0
  122. package/src/utils/resource-hierarchy.test.ts +990 -1
  123. package/src/utils/resource-hierarchy.ts +905 -267
  124. package/src/utils/resource-icons.ts +2 -0
  125. package/src/utils/topology-neighborhood.test.ts +202 -1
  126. package/src/utils/topology-neighborhood.ts +194 -13
  127. package/src/utils/workflow-execution.test.ts +556 -0
  128. package/src/utils/workflow-execution.ts +814 -0
  129. package/src/utils/workload-colors.ts +2 -3
  130. package/tsconfig.json +2 -2
@@ -45,13 +45,22 @@ const RESOURCE_KINDS: {
45
45
  { kind: 'PodGroup', label: 'Pod Group', icon: getTopologyIcon('PodGroup'), color: 'text-lime-400', category: 'workloads' },
46
46
  { kind: 'Job', label: 'Job', icon: getTopologyIcon('Job'), color: 'text-orange-400', category: 'workloads' },
47
47
  { kind: 'CronJob', label: 'CronJob', icon: getTopologyIcon('CronJob'), color: 'text-orange-300', category: 'workloads' },
48
+ { kind: 'Workflow', label: 'Workflow', icon: getTopologyIcon('Workflow'), color: 'text-purple-400', category: 'workloads' },
49
+ { kind: 'CronWorkflow', label: 'CronWorkflow', icon: getTopologyIcon('CronWorkflow'), color: 'text-purple-300', category: 'workloads' },
48
50
 
49
51
  // Config
50
52
  { kind: 'ConfigMap', label: 'ConfigMap', icon: getTopologyIcon('ConfigMap'), color: 'text-amber-400', category: 'config' },
51
53
  { kind: 'Secret', label: 'Secret', icon: getTopologyIcon('Secret'), color: 'text-red-400', category: 'config' },
54
+ { kind: 'ServiceAccount', label: 'Service Account', icon: getTopologyIcon('ServiceAccount'), color: 'text-fuchsia-400', category: 'config' },
55
+ { kind: 'SealedSecret', label: 'Sealed Secret', icon: getTopologyIcon('SealedSecret'), color: 'text-violet-400', category: 'config' },
56
+ { kind: 'ServiceMonitor', label: 'Service Monitor', icon: getTopologyIcon('ServiceMonitor'), color: 'text-sky-400', category: 'config' },
57
+ { kind: 'PodMonitor', label: 'Pod Monitor', icon: getTopologyIcon('PodMonitor'), color: 'text-sky-400', category: 'config' },
58
+ { kind: 'WorkflowTemplate', label: 'WorkflowTemplate', icon: getTopologyIcon('WorkflowTemplate'), color: 'text-purple-300', category: 'config' },
59
+ { kind: 'ClusterWorkflowTemplate', label: 'ClusterWorkflowTemplate', icon: getTopologyIcon('ClusterWorkflowTemplate'), color: 'text-purple-300', category: 'config' },
52
60
 
53
61
  // Scaling
54
62
  { kind: 'HorizontalPodAutoscaler', label: 'HPA', icon: getTopologyIcon('HorizontalPodAutoscaler'), color: 'text-pink-400', category: 'scaling' },
63
+ { kind: 'ScaledJob', label: 'ScaledJob', icon: getTopologyIcon('ScaledJob'), color: 'text-pink-300', category: 'scaling' },
55
64
 
56
65
  // Knative
57
66
  { kind: 'KnativeService', label: 'Knative Service', icon: getTopologyIcon('KnativeService'), color: 'text-fuchsia-400', category: 'custom' },
@@ -14,6 +14,7 @@ import {
14
14
  type NodeTypes,
15
15
  type NodeChange,
16
16
  type Viewport,
17
+ type FitViewOptions,
17
18
  BackgroundVariant,
18
19
  MarkerType,
19
20
  } from '@xyflow/react'
@@ -22,6 +23,7 @@ import { toCanvas } from 'html-to-image'
22
23
 
23
24
  import { AlertTriangle, ChevronsDownUp, ChevronsUpDown, Download, Info, Layers, LayoutGrid, Loader2, Maximize, Minus, Pause, Play, Plus, RotateCw, Shield } from 'lucide-react'
24
25
  import { PaneLoader } from '../ui/PaneLoader'
26
+ import { Input } from '../ui/Input'
25
27
  import { Tooltip } from '../ui/Tooltip'
26
28
  import { useToast } from '../ui/Toast'
27
29
  import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
@@ -201,6 +203,10 @@ interface TopologyGraphProps {
201
203
  /** Hover a node → reports its TopologyNode (null on leave). Drives the rail's
202
204
  * reciprocal highlight. */
203
205
  onNodeHover?: (node: TopologyNode | null) => void
206
+ /** Toggle a stable Deployment's normally-collapsed ReplicaSet tier. */
207
+ onToggleReplicaSets?: (ownerID: string) => void
208
+ /** Padding reserved around fit-to-view operations for overlaid UI. */
209
+ fitViewPadding?: FitViewOptions['padding']
204
210
  }
205
211
 
206
212
  export function TopologyGraph({
@@ -221,6 +227,8 @@ export function TopologyGraph({
221
227
  focusNonce,
222
228
  focusedOwnerId,
223
229
  onNodeHover,
230
+ onToggleReplicaSets,
231
+ fitViewPadding = 0.15,
224
232
  }: TopologyGraphProps) {
225
233
  const isTrafficView = viewMode === 'traffic'
226
234
  const [nodes, setNodes, onNodesChangeBase] = useNodesState([] as Node[])
@@ -701,6 +709,7 @@ export function TopologyGraph({
701
709
  onExpand: isExpandablePodGroup ? handleExpandPodGroup : undefined,
702
710
  onCollapse: expandedFromGroup ? handleCollapsePodGroup : undefined,
703
711
  isExpanded: isExpandablePodGroup ? expandedPodGroups.has(node.id) : undefined,
712
+ onToggleReplicaSets: nodeData?.replicaSetsExpandable ? onToggleReplicaSets : undefined,
704
713
  },
705
714
  }
706
715
  })
@@ -736,7 +745,7 @@ export function TopologyGraph({
736
745
  // No cleanup function - we use version-based invalidation instead
737
746
  // This prevents React's effect re-runs from canceling in-flight layouts
738
747
  // when the actual structure hasn't changed
739
- }, [workingNodes, workingEdges, structureKey, groupingMode, hideGroupHeader, collapsedGroups, groupLevels, handleSetLevel, handleCardClick, onMaximizeNamespace, isTrafficView, expandedPodGroups, handleExpandPodGroup, handleCollapsePodGroup, setNodes, setEdges, layoutRetryCount])
748
+ }, [workingNodes, workingEdges, structureKey, groupingMode, hideGroupHeader, collapsedGroups, groupLevels, handleSetLevel, handleCardClick, onMaximizeNamespace, isTrafficView, expandedPodGroups, handleExpandPodGroup, handleCollapsePodGroup, onToggleReplicaSets, setNodes, setEdges, layoutRetryCount])
740
749
 
741
750
  // Handle node click
742
751
  const handleNodeClick = useCallback(
@@ -829,6 +838,55 @@ export function TopologyGraph({
829
838
  })
830
839
  }, [focusedOwnerId, setNodes, nodes])
831
840
 
841
+ // Data-only topology updates must repaint existing nodes without forcing an
842
+ // ELK relayout. Structure hashing deliberately ignores status and metadata;
843
+ // keep the React Flow node payload in sync on that fast path.
844
+ useEffect(() => {
845
+ const topologyById = new Map(workingNodes.map(node => [node.id, node]))
846
+ setNodes(nds => {
847
+ let changed = false
848
+ const updated = nds.map(node => {
849
+ if (node.type === 'group') return node
850
+ const topologyNode = topologyById.get(node.id)
851
+ if (!topologyNode) return node
852
+ const nodeData = topologyNode.data as Record<string, unknown>
853
+ const pods = nodeData.pods
854
+ const isExpandablePodGroup = topologyNode.kind === 'PodGroup' && Array.isArray(pods) && pods.length > 0
855
+ const expandedFromGroup = nodeData.expandedFromGroup as string | undefined
856
+ const onExpand = isExpandablePodGroup ? handleExpandPodGroup : undefined
857
+ const onCollapse = expandedFromGroup ? handleCollapsePodGroup : undefined
858
+ const isExpanded = isExpandablePodGroup ? expandedPodGroups.has(node.id) : undefined
859
+ const toggleReplicaSets = nodeData.replicaSetsExpandable ? onToggleReplicaSets : undefined
860
+ if (
861
+ node.data?.kind === topologyNode.kind &&
862
+ node.data?.name === topologyNode.name &&
863
+ node.data?.status === topologyNode.status &&
864
+ node.data?.nodeData === topologyNode.data &&
865
+ node.data?.onExpand === onExpand &&
866
+ node.data?.onCollapse === onCollapse &&
867
+ node.data?.isExpanded === isExpanded &&
868
+ node.data?.onToggleReplicaSets === toggleReplicaSets
869
+ ) return node
870
+ changed = true
871
+ return {
872
+ ...node,
873
+ data: {
874
+ ...node.data,
875
+ kind: topologyNode.kind,
876
+ name: topologyNode.name,
877
+ status: topologyNode.status,
878
+ nodeData: topologyNode.data,
879
+ onExpand,
880
+ onCollapse,
881
+ isExpanded,
882
+ onToggleReplicaSets: toggleReplicaSets,
883
+ },
884
+ }
885
+ })
886
+ return changed ? updated : nds
887
+ })
888
+ }, [workingNodes, expandedPodGroups, handleExpandPodGroup, handleCollapsePodGroup, onToggleReplicaSets, setNodes])
889
+
832
890
  if (!topology) {
833
891
  return <PaneLoader label="Loading topology…" className="absolute inset-0" />
834
892
  }
@@ -897,10 +955,12 @@ export function TopologyGraph({
897
955
  {/* Warning banner for partial topology data */}
898
956
  {topology?.warnings && topology.warnings.length > 0 && (() => {
899
957
  const rbacWarnings = topology.warnings.filter(w => w.includes('RBAC not granted'))
900
- const otherWarnings = topology.warnings.filter(w => !w.includes('RBAC not granted'))
901
- const isAllRbac = otherWarnings.length === 0
958
+ const viewWarnings = topology.warnings.filter(w => w.startsWith('Topology view:'))
959
+ const otherWarnings = topology.warnings.filter(w => !w.includes('RBAC not granted') && !w.startsWith('Topology view:'))
960
+ const isAllRbac = otherWarnings.length === 0 && viewWarnings.length === 0
961
+ const isViewLimited = otherWarnings.length === 0 && rbacWarnings.length === 0 && viewWarnings.length > 0
902
962
  return (
903
- <div className={`absolute top-2 left-2 right-2 z-10 ${isAllRbac ? 'bg-amber-500/10 border-amber-500/20' : 'bg-amber-500/10 border-amber-500/30'} border rounded-lg p-2 backdrop-blur-sm`}>
963
+ <div className={`absolute top-2 z-10 ${isViewLimited ? 'left-2 max-w-xl' : 'left-2 right-2'} ${isAllRbac ? 'bg-amber-500/10 border-amber-500/20' : 'bg-amber-500/10 border-amber-500/30'} border rounded-lg p-2 backdrop-blur-sm`}>
904
964
  <div className="flex items-start gap-2">
905
965
  {isAllRbac ? (
906
966
  <Shield className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
@@ -909,12 +969,14 @@ export function TopologyGraph({
909
969
  )}
910
970
  <div className="text-sm">
911
971
  <span className="font-medium text-amber-400">
912
- {isAllRbac ? 'Limited Access:' : 'Warning:'}
972
+ {isAllRbac ? 'Limited Access:' : isViewLimited ? 'View limited:' : 'Warning:'}
913
973
  </span>
914
974
  <span className="text-theme-text-secondary ml-1">
915
975
  {isAllRbac
916
976
  ? `${pluralize(rbacWarnings.length, 'resource type')} not accessible due to RBAC restrictions.`
917
- : 'Some resources failed to load. Data may be incomplete.'}
977
+ : isViewLimited
978
+ ? 'Large retained run history is summarized to keep the graph readable.'
979
+ : 'Some resources failed to load. Data may be incomplete.'}
918
980
  </span>
919
981
  <details className="mt-1">
920
982
  <summary className="text-xs text-amber-400/80 hover:text-amber-400">
@@ -927,7 +989,13 @@ export function TopologyGraph({
927
989
  {rbacWarnings.map((w, i) => (
928
990
  <li key={`rbac-${i}`} className="font-mono">{w}</li>
929
991
  ))}
930
- {otherWarnings.length > 0 && rbacWarnings.length > 0 && (
992
+ {viewWarnings.length > 0 && (rbacWarnings.length > 0 || otherWarnings.length > 0) && (
993
+ <li className="text-amber-400/60 font-medium mt-1">View limits:</li>
994
+ )}
995
+ {viewWarnings.map((w, i) => (
996
+ <li key={`view-${i}`} className="font-mono">{w}</li>
997
+ ))}
998
+ {otherWarnings.length > 0 && (rbacWarnings.length > 0 || viewWarnings.length > 0) && (
931
999
  <li className="text-amber-400/60 font-medium mt-1">Other warnings:</li>
932
1000
  )}
933
1001
  {otherWarnings.map((w, i) => (
@@ -974,7 +1042,7 @@ export function TopologyGraph({
974
1042
  onNodeMouseLeave={handleNodeMouseLeave}
975
1043
  nodeTypes={nodeTypes}
976
1044
  fitView
977
- fitViewOptions={{ padding: 0.2 }}
1045
+ fitViewOptions={{ padding: fitViewPadding }}
978
1046
  minZoom={0.1}
979
1047
  maxZoom={2}
980
1048
  proOptions={{ hideAttribution: true }}
@@ -1021,6 +1089,7 @@ export function TopologyGraph({
1021
1089
  paused={paused}
1022
1090
  onTogglePause={onTogglePause}
1023
1091
  onExportingChange={setIsExporting}
1092
+ fitViewPadding={fitViewPadding}
1024
1093
  />
1025
1094
  </div>
1026
1095
  {!isTrafficView && (
@@ -1057,6 +1126,7 @@ export function TopologyGraph({
1057
1126
  focusNodeId={focusNodeId}
1058
1127
  focusNonce={focusNonce}
1059
1128
  onRequestExpandForNode={expandGroupForNode}
1129
+ fitViewPadding={fitViewPadding}
1060
1130
  />
1061
1131
  </ReactFlow>
1062
1132
  </ReactFlowProvider>
@@ -1240,9 +1310,8 @@ function ExportImageButton({ onExportingChange }: { onExportingChange: (v: boole
1240
1310
  >
1241
1311
  <div className="text-sm font-medium text-theme-text-primary mb-3">Export topology</div>
1242
1312
  <label className="block text-xs text-theme-text-secondary mb-1">Filename</label>
1243
- <input
1313
+ <Input
1244
1314
  ref={inputRef}
1245
- type="text"
1246
1315
  value={filename}
1247
1316
  onChange={(e) => setFilename(e.target.value)}
1248
1317
  className="w-full px-2 py-1.5 text-sm bg-theme-base border border-theme-border rounded text-theme-text-primary outline-none focus:border-blue-500 mb-3"
@@ -1340,11 +1409,13 @@ function CustomControlButtons({
1340
1409
  paused,
1341
1410
  onTogglePause,
1342
1411
  onExportingChange,
1412
+ fitViewPadding,
1343
1413
  }: {
1344
1414
  showExportButton: boolean
1345
1415
  paused: boolean
1346
1416
  onTogglePause?: () => void
1347
1417
  onExportingChange: (v: boolean) => void
1418
+ fitViewPadding: FitViewOptions['padding']
1348
1419
  }) {
1349
1420
  const { zoomIn, zoomOut, fitView } = useReactFlow()
1350
1421
  const TIP = 100
@@ -1361,7 +1432,7 @@ function CustomControlButtons({
1361
1432
  </button>
1362
1433
  </Tooltip>
1363
1434
  <Tooltip content="Fit view" delay={TIP} position="right">
1364
- <button className="react-flow__controls-button" onClick={() => fitView({ padding: 0.15, duration: 400 })}>
1435
+ <button className="react-flow__controls-button" onClick={() => fitView({ padding: fitViewPadding, duration: 400 })}>
1365
1436
  <Maximize className="w-3 h-3" />
1366
1437
  </button>
1367
1438
  </Tooltip>
@@ -1394,6 +1465,7 @@ function ViewportController({
1394
1465
  focusNodeId,
1395
1466
  focusNonce = 0,
1396
1467
  onRequestExpandForNode,
1468
+ fitViewPadding,
1397
1469
  }: {
1398
1470
  viewMode: string
1399
1471
  layoutRetryCount: number
@@ -1403,6 +1475,7 @@ function ViewportController({
1403
1475
  focusNodeId?: string
1404
1476
  focusNonce?: number
1405
1477
  onRequestExpandForNode?: (nodeId: string) => void
1478
+ fitViewPadding: FitViewOptions['padding']
1406
1479
  }) {
1407
1480
  const { fitView, zoomIn, zoomOut, setViewport, getViewport, getInternalNode, setCenter } = useReactFlow()
1408
1481
  const nodes = useNodes() // Reactive hook to watch node changes
@@ -1431,7 +1504,7 @@ function ViewportController({
1431
1504
  description: 'Fit graph to screen',
1432
1505
  category: 'Topology',
1433
1506
  scope: 'topology',
1434
- handler: () => fitView({ padding: 0.15, duration: VIEWPORT_ANIMATION_DURATION }),
1507
+ handler: () => fitView({ padding: fitViewPadding, duration: VIEWPORT_ANIMATION_DURATION }),
1435
1508
  },
1436
1509
  {
1437
1510
  id: 'topology-zoom-in',
@@ -1508,14 +1581,14 @@ function ViewportController({
1508
1581
  if (nodesJustPopulated || viewModeChanged || retryRequested || fitViewRequested) {
1509
1582
  const timeoutId = setTimeout(() => {
1510
1583
  fitView({
1511
- padding: 0.15,
1584
+ padding: fitViewPadding,
1512
1585
  duration: nodesJustPopulated ? 0 : VIEWPORT_ANIMATION_DURATION,
1513
1586
  })
1514
1587
  }, 10)
1515
1588
 
1516
1589
  return () => clearTimeout(timeoutId)
1517
1590
  }
1518
- }, [viewMode, layoutRetryCount, fitViewCounter, nodes.length, fitView])
1591
+ }, [viewMode, layoutRetryCount, fitViewCounter, nodes.length, fitView, fitViewPadding])
1519
1592
 
1520
1593
  // Pan/zoom to a single searched node. Gated on focusNonce so the same
1521
1594
  // node can be re-focused, and so this never fires on background updates.
@@ -1563,10 +1636,10 @@ function ViewportController({
1563
1636
  if (!fitAllAfterLayoutRef?.current) return
1564
1637
  const id = setTimeout(() => {
1565
1638
  fitAllAfterLayoutRef.current = false
1566
- fitView({ padding: 0.15, duration: VIEWPORT_ANIMATION_DURATION })
1639
+ fitView({ padding: fitViewPadding, duration: VIEWPORT_ANIMATION_DURATION })
1567
1640
  }, 250)
1568
1641
  return () => clearTimeout(id)
1569
- }, [nodes, fitView, fitAllAfterLayoutRef])
1642
+ }, [nodes, fitView, fitAllAfterLayoutRef, fitViewPadding])
1570
1643
 
1571
1644
  return null
1572
1645
  }
@@ -1,5 +1,6 @@
1
1
  import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
2
2
  import { Search, X, ChevronRight } from 'lucide-react'
3
+ import { Input } from '../ui/Input'
3
4
  import { getTopologyIcon } from '../../utils/resource-icons'
4
5
  import { clsx } from 'clsx'
5
6
  import type { TopologyNode } from '../../types'
@@ -225,9 +226,8 @@ export function TopologySearch({ nodes, onNodeSelect, onZoomToNode, allNodes, vi
225
226
  {/* Search input */}
226
227
  <div className="flex items-center gap-3 px-4 py-3 border-b border-theme-border">
227
228
  <Search className="w-5 h-5 text-theme-text-secondary" />
228
- <input
229
+ <Input
229
230
  ref={inputRef}
230
- type="text"
231
231
  value={query}
232
232
  onChange={(e) => setQuery(e.target.value)}
233
233
  onKeyDown={handleInputKeyDown}
@@ -827,8 +827,16 @@ function computeWorkloadCards(
827
827
  subtitle = (d.type as string) || 'ClusterIP'
828
828
  } else if (primary.kind === 'CronJob') {
829
829
  subtitle = (d.schedule as string) || ''
830
+ } else if (primary.kind === 'CronWorkflow') {
831
+ subtitle = (d.schedule as string) || ''
830
832
  } else if (primary.kind === 'Job') {
831
833
  subtitle = (d.phase as string) || ''
834
+ } else if (primary.kind === 'Workflow') {
835
+ subtitle = (d.progress as string) || (d.phase as string) || ''
836
+ } else if (primary.kind === 'WorkflowTemplate' || primary.kind === 'ClusterWorkflowTemplate') {
837
+ const entrypoint = d.entrypoint as string
838
+ const templateCount = d.templateCount as number
839
+ subtitle = entrypoint || (templateCount ? `${templateCount} templates` : '')
832
840
  } else if (primary.kind === 'Application') {
833
841
  const sync = d.syncStatus as string
834
842
  const health = d.healthStatus as string
@@ -855,10 +863,10 @@ function computeWorkloadCards(
855
863
  // Shared kind priority map — used by both pickGroupName and computeWorkloadCards
856
864
  const KIND_PRIORITY: Record<string, number> = {
857
865
  'Deployment': 1, 'Rollout': 1, 'StatefulSet': 2, 'DaemonSet': 3,
858
- 'CronJob': 4, 'Job': 5, 'Service': 6, 'Gateway': 7,
866
+ 'CronJob': 4, 'CronWorkflow': 4, 'Job': 5, 'Workflow': 5, 'Service': 6, 'Gateway': 7,
859
867
  'HTTPRoute': 6, 'GRPCRoute': 6, 'TCPRoute': 6, 'TLSRoute': 6, 'Ingress': 7,
860
868
  'ReplicaSet': 8, 'Pod': 9, 'PodGroup': 9,
861
- 'ConfigMap': 10, 'Secret': 10, 'PersistentVolumeClaim': 10, 'HorizontalPodAutoscaler': 10,
869
+ 'ConfigMap': 10, 'Secret': 10, 'ServiceAccount': 10, 'SealedSecret': 10, 'ServiceMonitor': 10, 'PodMonitor': 10, 'WorkflowTemplate': 10, 'ClusterWorkflowTemplate': 10, 'PersistentVolumeClaim': 10, 'HorizontalPodAutoscaler': 10,
862
870
  'KnativeService': 1, 'KnativeConfiguration': 3, 'KnativeRevision': 4, 'KnativeRoute': 2,
863
871
  'Broker': 2, 'Channel': 2, 'Trigger': 3, 'PingSource': 3, 'ApiServerSource': 3,
864
872
  'ContainerSource': 3, 'SinkBinding': 3,
@@ -53,98 +53,6 @@
53
53
  background-color: rgb(100 116 139); /* slate-500 */
54
54
  }
55
55
 
56
- /* CSS-based topology icons (replaces Lucide SVGs - saves ~4000 DOM elements) */
57
- .topology-icon {
58
- display: inline-flex;
59
- align-items: center;
60
- justify-content: center;
61
- width: 14px;
62
- height: 14px;
63
- border-radius: 3px;
64
- font-size: 9px;
65
- font-weight: 700;
66
- color: white;
67
- flex-shrink: 0;
68
- background: #6b7280; /* gray-500 - default for unknown CRD kinds */
69
- }
70
- .topology-icon-internet { background: #3b82f6; border-radius: 50%; }
71
- .topology-icon-ingress { background: #8b5cf6; }
72
- .topology-icon-gateway { background: #8b5cf6; }
73
- .topology-icon-httproute { background: #a78bfa; }
74
- .topology-icon-grpcroute { background: #a78bfa; }
75
- .topology-icon-tcproute { background: #a78bfa; }
76
- .topology-icon-tlsroute { background: #a78bfa; }
77
- .topology-icon-service { background: #3b82f6; border-radius: 50%; }
78
- .topology-icon-deployment { background: #10b981; }
79
- .topology-icon-rollout { background: #10b981; }
80
- .topology-icon-daemonset { background: #14b8a6; }
81
- .topology-icon-statefulset { background: #06b6d4; }
82
- .topology-icon-replicaset { background: #22c55e; }
83
- .topology-icon-pod { background: #84cc16; border-radius: 50%; }
84
- .topology-icon-podgroup { background: #84cc16; border-radius: 50%; }
85
- .topology-icon-configmap { background: #f59e0b; }
86
- .topology-icon-secret { background: #ef4444; }
87
- .topology-icon-horizontalpodautoscaler { background: #ec4899; }
88
- .topology-icon-job { background: #a855f7; }
89
- .topology-icon-cronjob { background: #a855f7; }
90
- .topology-icon-persistentvolumeclaim { background: #06b6d4; }
91
- .topology-icon-namespace { background: #6366f1; }
92
- .topology-icon-application { background: #f97316; }
93
- .topology-icon-kustomization { background: #0ea5e9; }
94
- .topology-icon-helmrelease { background: #0ea5e9; }
95
- .topology-icon-gitrepository { background: #14b8a6; }
96
- .topology-icon-knativeservice { background: #d946ef; }
97
- .topology-icon-knativeconfiguration { background: #c026d3; }
98
- .topology-icon-knativerevision { background: #a855f7; }
99
- .topology-icon-knativeroute { background: #d946ef; }
100
- .topology-icon-broker { background: #c026d3; }
101
- .topology-icon-trigger { background: #a855f7; }
102
- .topology-icon-pingsource { background: #d946ef; }
103
- .topology-icon-apiserversource { background: #c026d3; }
104
- .topology-icon-containersource { background: #a855f7; }
105
- .topology-icon-sinkbinding { background: #d946ef; }
106
- .topology-icon-channel { background: #c026d3; }
107
- .topology-icon-inmemorychannel { background: #c026d3; }
108
- .topology-icon-subscription { background: #a855f7; }
109
- .topology-icon-sequence { background: #d946ef; }
110
- .topology-icon-parallel { background: #d946ef; }
111
- .topology-icon-domainmapping { background: #d946ef; }
112
- .topology-icon-serverlessservice { background: #a855f7; }
113
- .topology-icon-node { background: #6366f1; }
114
- .topology-icon-nodepool { background: #6366f1; }
115
- .topology-icon-nodeclaim { background: #6366f1; }
116
- .topology-icon-nodeclass { background: #6366f1; }
117
- .topology-icon-httpproxy { background: #7c3aed; }
118
- .topology-icon-capicluster { background: #4338ca; }
119
- .topology-icon-machinedeployment { background: #5b4fcf; }
120
- .topology-icon-machineset { background: #6d5fd6; }
121
- .topology-icon-machine { background: #7c6fdd; }
122
- .topology-icon-machinepool { background: #5b4fcf; }
123
- .topology-icon-kubeadmcontrolplane { background: #4338ca; }
124
- .topology-icon-clusterclass { background: #3b2fba; }
125
- .topology-icon-machinehealthcheck { background: #8b7fe4; }
126
- /* AWS CAPI infrastructure provider */
127
- .topology-icon-awsmanagedcontrolplane { background: #d97706; }
128
- .topology-icon-awsmanagedmachinepool { background: #d97706; }
129
- .topology-icon-awsmachine { background: #f59e0b; }
130
- .topology-icon-awsmachinetemplate { background: #f59e0b; }
131
- .topology-icon-awsmanagedcluster { background: #d97706; }
132
- .topology-icon-eksconfig { background: #f59e0b; }
133
- .topology-icon-eksconfigtemplate { background: #f59e0b; }
134
-
135
- /* GCP CAPI infrastructure provider */
136
- .topology-icon-gcpmanagedcontrolplane { background: #0d9488; }
137
- .topology-icon-gcpmanagedmachinepool { background: #0d9488; }
138
- .topology-icon-gcpmachine { background: #14b8a6; }
139
- .topology-icon-gcpmachinetemplate { background: #14b8a6; }
140
- .topology-icon-gcpmanagedcluster { background: #0d9488; }
141
- /* Azure CAPI infrastructure provider */
142
- .topology-icon-azuremanagedcontrolplane { background: #2563eb; }
143
- .topology-icon-azuremanagedmachinepool { background: #2563eb; }
144
- .topology-icon-azuremachine { background: #3b82f6; }
145
- .topology-icon-azuremachinetemplate { background: #3b82f6; }
146
- .topology-icon-azuremanagedcluster { background: #2563eb; }
147
-
148
56
  /* Background dots - uses CSS variable for theme awareness */
149
57
  .react-flow__background pattern circle {
150
58
  fill: var(--topology-bg-dots) !important;
@@ -91,6 +91,11 @@ const KIND: Record<string, string> = {
91
91
  // Jobs
92
92
  Job: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
93
93
  CronJob: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
94
+ Workflow: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
95
+ CronWorkflow: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
96
+ WorkflowTemplate: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
97
+ ClusterWorkflowTemplate: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
98
+ ScaledJob: 'bg-pink-100 text-pink-700 border-pink-300 dark:bg-pink-950/50 dark:text-pink-400 dark:border-pink-700/40',
94
99
 
95
100
  // Scaling & Storage
96
101
  HorizontalPodAutoscaler: 'bg-pink-100 text-pink-800 border-pink-300 dark:bg-pink-950/50 dark:text-pink-300 dark:border-pink-700/40',
@@ -0,0 +1,47 @@
1
+ // Shape-stable loading stand-ins for the list-board chassis (GitOps,
2
+ // Applications). Both mirror the loaded anatomy so data resolves in place:
3
+ // rows appear inside the table frame, facets inside the rail — no half-height
4
+ // rail dangling next to a centered spinner, no layout jump when the response
5
+ // lands.
6
+
7
+ export function BoardTableSkeleton({ rows = 10 }: { rows?: number }) {
8
+ return (
9
+ <div aria-live="polite" aria-label="Loading…" className="divide-y divide-theme-border-light">
10
+ {Array.from({ length: rows }, (_, i) => (
11
+ <div key={i} className="flex items-center gap-6 px-4 py-3" style={{ opacity: 1 - i * 0.09 }}>
12
+ <div className="min-w-0 flex-[2] space-y-2">
13
+ <div className="h-4 w-2/3 animate-pulse rounded bg-theme-hover" />
14
+ <div className="h-3 w-1/2 animate-pulse rounded bg-theme-hover" />
15
+ </div>
16
+ <div className="hidden h-4 flex-1 animate-pulse rounded bg-theme-hover md:block" />
17
+ <div className="h-5 w-20 animate-pulse rounded-full bg-theme-hover" />
18
+ <div className="h-5 w-20 animate-pulse rounded-full bg-theme-hover" />
19
+ <div className="hidden h-4 flex-[1.5] animate-pulse rounded bg-theme-hover lg:block" />
20
+ </div>
21
+ ))}
22
+ </div>
23
+ )
24
+ }
25
+
26
+ // Section stubs sized like the loaded rail so it keeps its height while
27
+ // counts are unknown — real facet buttons with "0" counts would be false
28
+ // zeros. `sections` = [title, rowCount] pairs approximating the loaded rail.
29
+ export function BoardRailSkeleton({ sections }: { sections: Array<[string, number]> }) {
30
+ return (
31
+ <div aria-hidden>
32
+ {sections.map(([title, rows]) => (
33
+ <div key={title} className="border-b border-theme-border-light px-3 py-3">
34
+ <div className="mb-2 h-3 w-24 animate-pulse rounded bg-theme-hover" />
35
+ <div className="space-y-1.5">
36
+ {Array.from({ length: rows }, (_, i) => (
37
+ <div key={i} className="flex items-center justify-between px-1.5 py-1">
38
+ <div className="h-3.5 animate-pulse rounded bg-theme-hover" style={{ width: `${52 - i * 6}%` }} />
39
+ <div className="h-3.5 w-6 animate-pulse rounded bg-theme-hover" />
40
+ </div>
41
+ ))}
42
+ </div>
43
+ </div>
44
+ ))}
45
+ </div>
46
+ )
47
+ }
@@ -0,0 +1,117 @@
1
+ import { Fragment, type ComponentType, type ReactNode } from 'react'
2
+
3
+ // =============================================================================
4
+ // CARD SECTION PRIMITIVES — the shared "Sectioned + icons" vocabulary used by
5
+ // the Issue card (IssuesView) and the Check card (ChecksView) so both read as
6
+ // one product: ONE eyebrow style, an icon gutter per section, and a single body
7
+ // contrast floor (body text is never lighter than the secondary token, so it
8
+ // stays legible on the card surface). Severity is the only status color; section
9
+ // icons carry quiet role hues (what's-wrong = amber, fix = emerald, neutral =
10
+ // grey).
11
+ // =============================================================================
12
+
13
+ // Section role → eyebrow + icon hue. Body text always uses the secondary token
14
+ // (the contrast floor), regardless of tone.
15
+ export type CardSectionTone = 'neutral' | 'warn' | 'fix'
16
+
17
+ const SECTION_TONE_CLASS: Record<CardSectionTone, string> = {
18
+ // Eyebrow grey — the default, for neutral sections (Why it matters, Raw error,
19
+ // Affected resources, Context, Subject).
20
+ neutral: 'text-theme-text-tertiary',
21
+ // What's wrong — amber, the diagnosis beat.
22
+ warn: 'text-amber-700 dark:text-amber-300',
23
+ // Next step / How to fix — emerald, the action beat.
24
+ fix: 'text-emerald-700 dark:text-emerald-400',
25
+ }
26
+
27
+ // The single eyebrow style: 11px / 600 / uppercase, with an icon in a fixed
28
+ // gutter so every section's body aligns under the same left edge.
29
+ export function CardSection({
30
+ icon: Icon,
31
+ label,
32
+ tone = 'neutral',
33
+ labelExtra,
34
+ children,
35
+ }: {
36
+ icon: ComponentType<{ className?: string }>
37
+ label: string
38
+ tone?: CardSectionTone
39
+ /** Optional trailing eyebrow text (e.g. "· retried 5×"), rendered muted. */
40
+ labelExtra?: ReactNode
41
+ children: ReactNode
42
+ }) {
43
+ const toneClass = SECTION_TONE_CLASS[tone]
44
+ return (
45
+ <section className="flex items-start gap-2.5">
46
+ {/* Icon sits in a fixed gutter, anchored to the eyebrow line (top-aligned
47
+ with a nudge to its optical center) — sections like Affected resources
48
+ can be dozens of rows tall, and a section-centered glyph would float
49
+ detached mid-list. */}
50
+ <Icon className={`mt-[-1px] h-[18px] w-[18px] shrink-0 ${toneClass}`} aria-hidden />
51
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
52
+ <h4 className={`text-[11px] font-semibold uppercase tracking-[0.06em] ${toneClass}`}>
53
+ {label}
54
+ {labelExtra ? <span className="font-medium normal-case tracking-normal text-theme-text-tertiary"> {labelExtra}</span> : null}
55
+ </h4>
56
+ {children}
57
+ </div>
58
+ </section>
59
+ )
60
+ }
61
+
62
+ // Body paragraph at the contrast floor — 14px, secondary token, never lighter.
63
+ export function CardBody({ children, className }: { children: ReactNode; className?: string }) {
64
+ return <p className={`text-sm leading-relaxed text-theme-text-secondary ${className ?? ''}`}>{children}</p>
65
+ }
66
+
67
+ // The dark terminal block for raw error output. Intentionally dark in BOTH
68
+ // themes (it's a console surface, not chrome), so it draws from the dedicated
69
+ // --terminal-* tokens rather than the theme surface tokens.
70
+ export function TerminalBlock({ label, children }: { label?: ReactNode; children: ReactNode }) {
71
+ return (
72
+ <div className="overflow-hidden rounded-lg border border-[var(--terminal-border)] bg-[var(--terminal-bg)]">
73
+ {label ? (
74
+ <div className="border-b border-[var(--terminal-divider)] px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.06em] text-[var(--terminal-label)]">
75
+ {label}
76
+ </div>
77
+ ) : null}
78
+ <pre className="overflow-x-auto whitespace-pre-wrap break-words px-3 py-2.5 font-mono text-xs leading-relaxed text-[var(--terminal-text)]">
79
+ {children}
80
+ </pre>
81
+ </div>
82
+ )
83
+ }
84
+
85
+ // The neutral category chip — drops the per-category hue so severity is the only
86
+ // status color. Self-contained (don't pair with badge-sm): 11px / 600, 2px×8px
87
+ // padding, no border, neutral grey.
88
+ export const NEUTRAL_CHIP_CLASS =
89
+ 'inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-semibold leading-tight bg-theme-elevated text-theme-text-secondary'
90
+
91
+ // Mono kind chip — the grey tag for an all-caps resource Kind (the Issue row
92
+ // header + the Subject footer row). Mono and tighter than NEUTRAL_CHIP_CLASS,
93
+ // which carries the label/category chips.
94
+ export const KIND_CHIP_CLASS =
95
+ 'shrink-0 rounded bg-theme-elevated px-1.5 py-px font-mono text-[11px] uppercase text-theme-text-secondary'
96
+
97
+ // Render a prose string, turning markdown `inline-code` spans into mono chips
98
+ // via the shared .inline-code class. Plain-text passthrough when the string
99
+ // carries no backticks, so it's a no-op for today's plain catalog copy and
100
+ // lights up automatically once backtick markup is added.
101
+ export function renderProse(text: string | undefined | null): ReactNode {
102
+ if (!text) return null
103
+ if (!text.includes('`')) return text
104
+ const parts = text.split(/(`[^`]+`)/g)
105
+ return parts.map((part, i) => {
106
+ // length > 2 excludes a bare "``" (which the split leaves in a plain part)
107
+ // so it renders as literal text, not an empty chip.
108
+ if (part.length > 2 && part.startsWith('`') && part.endsWith('`')) {
109
+ return (
110
+ <code key={i} className="inline-code">
111
+ {part.slice(1, -1)}
112
+ </code>
113
+ )
114
+ }
115
+ return <Fragment key={i}>{part}</Fragment>
116
+ })
117
+ }
@@ -1,6 +1,7 @@
1
1
  import { useState, useEffect, useRef, useCallback } from 'react'
2
2
  import { Copy, Check, ChevronUp, ChevronDown, X, Search } from 'lucide-react'
3
3
  import { codeToHtml } from 'shiki'
4
+ import { Input } from './Input'
4
5
 
5
6
  interface CodeViewerProps {
6
7
  code: string
@@ -343,9 +344,8 @@ export function CodeViewer({
343
344
  {searchOpen && (
344
345
  <div className="absolute top-2 right-2 z-20 flex items-center gap-1 px-2 py-1.5 bg-theme-surface border border-theme-border rounded-lg shadow-lg backdrop-blur-sm">
345
346
  <Search className="w-3.5 h-3.5 text-theme-text-tertiary shrink-0" />
346
- <input
347
+ <Input
347
348
  ref={searchInputRef}
348
- type="text"
349
349
  value={searchQuery}
350
350
  onChange={(e) => setSearchQuery(e.target.value)}
351
351
  onKeyDown={handleSearchKeyDown}