@skyhook-io/k8s-ui 1.8.11 → 1.8.13

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 (87) hide show
  1. package/package.json +4 -4
  2. package/src/components/applications/ApplicationDetail.test.tsx +136 -8
  3. package/src/components/applications/ApplicationDetail.tsx +1215 -487
  4. package/src/components/gitops/GitOpsDetailLayout.tsx +13 -3
  5. package/src/components/gitops/GitOpsTableView.tsx +10 -3
  6. package/src/components/gitops/SyncOptionsDialog.test.ts +48 -0
  7. package/src/components/gitops/SyncOptionsDialog.tsx +189 -45
  8. package/src/components/gitops/insights/ArgoResourceDiff.tsx +172 -0
  9. package/src/components/gitops/insights/GitOpsInsightViews.tsx +201 -21
  10. package/src/components/gitops/insights/index.ts +1 -0
  11. package/src/components/gitops/insights/insights-helpers.test.ts +16 -0
  12. package/src/components/gitops/insights/insights-helpers.ts +5 -0
  13. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -0
  14. package/src/components/issues/IssuesView.tsx +55 -29
  15. package/src/components/issues/ResourceIssuesSection.test.tsx +33 -1
  16. package/src/components/issues/ResourceIssuesSection.tsx +21 -1
  17. package/src/components/issues/severity.ts +1 -0
  18. package/src/components/resources/ResourcesView.tsx +109 -19
  19. package/src/components/resources/column-filter-serialization.test.ts +79 -1
  20. package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
  21. package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
  22. package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
  23. package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
  24. package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
  25. package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
  26. package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
  27. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  28. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
  29. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
  30. package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
  31. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
  32. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
  33. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
  34. package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
  35. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
  36. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
  37. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
  38. package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
  39. package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
  40. package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
  41. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
  42. package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
  43. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
  44. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
  45. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
  46. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
  47. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
  48. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
  49. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
  50. package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
  51. package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
  52. package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
  53. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
  54. package/src/components/resources/resource-utils-argo.test.ts +18 -1
  55. package/src/components/resources/resource-utils-argo.ts +8 -0
  56. package/src/components/resources/resource-utils.ts +94 -25
  57. package/src/components/shared/ResourceActionsBar.tsx +54 -22
  58. package/src/components/shared/UnifiedDiff.tsx +51 -0
  59. package/src/components/shared/index.ts +1 -0
  60. package/src/components/timeline/TimelineList.tsx +7 -3
  61. package/src/components/timeline/TimelineToolbarState.test.tsx +24 -0
  62. package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
  63. package/src/components/topology/TopologyControls.tsx +3 -1
  64. package/src/components/topology/TopologyGraph.tsx +76 -90
  65. package/src/components/topology/TopologyOverlayBar.tsx +30 -0
  66. package/src/components/topology/TopologySearch.tsx +23 -15
  67. package/src/components/topology/index.ts +2 -0
  68. package/src/components/ui/Badge.tsx +1 -1
  69. package/src/components/ui/SelectMenu.tsx +81 -0
  70. package/src/components/ui/Tooltip.test.tsx +21 -0
  71. package/src/components/ui/Tooltip.tsx +9 -12
  72. package/src/components/ui/drawer-components.tsx +9 -6
  73. package/src/components/ui/index.ts +2 -0
  74. package/src/components/workload/WorkloadView.tsx +24 -3
  75. package/src/theme/tailwind-theme.css +1 -0
  76. package/src/theme/variables.css +1 -0
  77. package/src/types/gitops-insights.ts +69 -1
  78. package/src/types/gitops.ts +3 -3
  79. package/src/utils/api-resources.test.ts +36 -1
  80. package/src/utils/api-resources.ts +16 -0
  81. package/src/utils/application-history.test.ts +538 -0
  82. package/src/utils/application-history.ts +257 -0
  83. package/src/utils/applications.test.ts +34 -0
  84. package/src/utils/applications.ts +25 -0
  85. package/src/utils/index.ts +1 -0
  86. package/src/utils/resource-hierarchy.test.ts +35 -1
  87. package/src/utils/resource-hierarchy.ts +20 -0
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
1
+ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import {
3
3
  ReactFlow,
4
4
  ReactFlowProvider,
@@ -23,6 +23,7 @@ import { toCanvas } from 'html-to-image'
23
23
 
24
24
  import { AlertTriangle, ChevronsDownUp, ChevronsUpDown, Download, Info, Layers, LayoutGrid, Loader2, Maximize, Minus, Pause, Play, Plus, RotateCw, Shield } from 'lucide-react'
25
25
  import { PaneLoader } from '../ui/PaneLoader'
26
+ import { TopologyOverlayBar } from './TopologyOverlayBar'
26
27
  import { Input } from '../ui/Input'
27
28
  import { Tooltip } from '../ui/Tooltip'
28
29
  import { useToast } from '../ui/Toast'
@@ -186,10 +187,6 @@ interface TopologyGraphProps {
186
187
  onTogglePause?: () => void
187
188
  /** Called when user clicks "maximize" on a namespace group — sets namespace filter to just that namespace */
188
189
  onMaximizeNamespace?: (namespace: string) => void
189
- /** Shown as a breadcrumb label when viewing a single namespace */
190
- namespaceBreadcrumb?: string
191
- /** Called when breadcrumb "back" is clicked to return to all-namespace view */
192
- onClearNamespace?: () => void
193
190
  /** Serialized namespace filter — when this changes, reset groupLevels for fresh smart default */
194
191
  namespacesKey?: string
195
192
  /** Node to pan/zoom the canvas to. Bump focusNonce to re-trigger for the same id. */
@@ -207,6 +204,9 @@ interface TopologyGraphProps {
207
204
  onToggleReplicaSets?: (ownerID: string) => void
208
205
  /** Padding reserved around fit-to-view operations for overlaid UI. */
209
206
  fitViewPadding?: FitViewOptions['padding']
207
+ /** Host overlay content (e.g. a search + controls row), stacked above the
208
+ * topology's own status banners in the overlay bar. */
209
+ children?: ReactNode
210
210
  }
211
211
 
212
212
  export function TopologyGraph({
@@ -220,8 +220,6 @@ export function TopologyGraph({
220
220
  paused = false,
221
221
  onTogglePause,
222
222
  onMaximizeNamespace,
223
- namespaceBreadcrumb,
224
- onClearNamespace,
225
223
  namespacesKey = '',
226
224
  focusNodeId,
227
225
  focusNonce,
@@ -229,6 +227,7 @@ export function TopologyGraph({
229
227
  onNodeHover,
230
228
  onToggleReplicaSets,
231
229
  fitViewPadding = 0.15,
230
+ children,
232
231
  }: TopologyGraphProps) {
233
232
  const isTrafficView = viewMode === 'traffic'
234
233
  const [nodes, setNodes, onNodesChangeBase] = useNodesState([] as Node[])
@@ -933,96 +932,83 @@ export function TopologyGraph({
933
932
 
934
933
  return (
935
934
  <ReactFlowProvider>
936
- {/* Namespace breadcrumb shown when viewing a single namespace */}
937
- {namespaceBreadcrumb && (
938
- <div className="absolute top-3 left-3 z-10 flex items-center gap-1.5">
939
- {onClearNamespace && (
940
- <button
941
- onClick={onClearNamespace}
942
- className="text-xs text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
943
- >
944
- All Namespaces
945
- </button>
946
- )}
947
- {onClearNamespace && (
948
- <span className="text-xs text-theme-text-tertiary">/</span>
949
- )}
950
- <span className="text-xs font-medium text-theme-text-secondary bg-theme-surface/80 backdrop-blur-sm border border-theme-border/50 rounded-md px-2 py-0.5">
951
- {namespaceBreadcrumb}
952
- </span>
953
- </div>
954
- )}
955
- {/* Warning banner for partial topology data */}
956
- {topology?.warnings && topology.warnings.length > 0 && (() => {
957
- const rbacWarnings = topology.warnings.filter(w => w.includes('RBAC not granted'))
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
962
- return (
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`}>
935
+ {/* Overlay bar: status banners lead (a partial-data/RBAC warning must read
936
+ before the user trusts the graph), host children stack below. Flex
937
+ column so nothing overlaps. Banners de-absoluted the bar owns position/z. */}
938
+ <TopologyOverlayBar>
939
+ {/* Warning banner for partial topology data */}
940
+ {topology?.warnings && topology.warnings.length > 0 && (() => {
941
+ const rbacWarnings = topology.warnings.filter(w => w.includes('RBAC not granted'))
942
+ const viewWarnings = topology.warnings.filter(w => w.startsWith('Topology view:'))
943
+ const otherWarnings = topology.warnings.filter(w => !w.includes('RBAC not granted') && !w.startsWith('Topology view:'))
944
+ const isAllRbac = otherWarnings.length === 0 && viewWarnings.length === 0
945
+ const isViewLimited = otherWarnings.length === 0 && rbacWarnings.length === 0 && viewWarnings.length > 0
946
+ return (
947
+ <div className={`pointer-events-auto ${isViewLimited ? 'max-w-xl' : 'w-full'} ${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`}>
948
+ <div className="flex items-start gap-2">
949
+ {isAllRbac ? (
950
+ <Shield className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
951
+ ) : (
952
+ <AlertTriangle className="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
953
+ )}
954
+ <div className="text-sm">
955
+ <span className="font-medium text-amber-400">
956
+ {isAllRbac ? 'Limited Access:' : isViewLimited ? 'View limited:' : 'Warning:'}
957
+ </span>
958
+ <span className="text-theme-text-secondary ml-1">
959
+ {isAllRbac
960
+ ? `${pluralize(rbacWarnings.length, 'resource type')} not accessible due to RBAC restrictions.`
961
+ : isViewLimited
962
+ ? 'Large retained run history is summarized to keep the graph readable.'
963
+ : 'Some resources failed to load. Data may be incomplete.'}
964
+ </span>
965
+ <details className="mt-1">
966
+ <summary className="text-xs text-amber-400/80 hover:text-amber-400">
967
+ Show details ({topology.warnings.length})
968
+ </summary>
969
+ <ul className="mt-1 text-xs text-theme-text-tertiary space-y-0.5">
970
+ {rbacWarnings.length > 0 && otherWarnings.length > 0 && (
971
+ <li className="text-amber-400/60 font-medium mt-1">RBAC restrictions:</li>
972
+ )}
973
+ {rbacWarnings.map((w, i) => (
974
+ <li key={`rbac-${i}`} className="font-mono">{w}</li>
975
+ ))}
976
+ {viewWarnings.length > 0 && (rbacWarnings.length > 0 || otherWarnings.length > 0) && (
977
+ <li className="text-amber-400/60 font-medium mt-1">View limits:</li>
978
+ )}
979
+ {viewWarnings.map((w, i) => (
980
+ <li key={`view-${i}`} className="font-mono">{w}</li>
981
+ ))}
982
+ {otherWarnings.length > 0 && (rbacWarnings.length > 0 || viewWarnings.length > 0) && (
983
+ <li className="text-amber-400/60 font-medium mt-1">Other warnings:</li>
984
+ )}
985
+ {otherWarnings.map((w, i) => (
986
+ <li key={`other-${i}`} className="font-mono">{w}</li>
987
+ ))}
988
+ </ul>
989
+ </details>
990
+ </div>
991
+ </div>
992
+ </div>
993
+ )
994
+ })()}
995
+ {/* Layout error banner - shown even when stale nodes exist */}
996
+ {layoutError && nodes.length > 0 && (
997
+ <div className="w-full pointer-events-auto bg-red-500/10 border border-red-500/30 rounded-lg p-2 backdrop-blur-sm">
964
998
  <div className="flex items-start gap-2">
965
- {isAllRbac ? (
966
- <Shield className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
967
- ) : (
968
- <AlertTriangle className="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
969
- )}
999
+ <AlertTriangle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
970
1000
  <div className="text-sm">
971
- <span className="font-medium text-amber-400">
972
- {isAllRbac ? 'Limited Access:' : isViewLimited ? 'View limited:' : 'Warning:'}
973
- </span>
1001
+ <span className="font-medium text-red-400">Layout Error:</span>
974
1002
  <span className="text-theme-text-secondary ml-1">
975
- {isAllRbac
976
- ? `${pluralize(rbacWarnings.length, 'resource type')} not accessible due to RBAC restrictions.`
977
- : isViewLimited
978
- ? 'Large retained run history is summarized to keep the graph readable.'
979
- : 'Some resources failed to load. Data may be incomplete.'}
1003
+ Failed to update layout. Showing previous view.
980
1004
  </span>
981
- <details className="mt-1">
982
- <summary className="text-xs text-amber-400/80 hover:text-amber-400">
983
- Show details ({topology.warnings.length})
984
- </summary>
985
- <ul className="mt-1 text-xs text-theme-text-tertiary space-y-0.5">
986
- {rbacWarnings.length > 0 && otherWarnings.length > 0 && (
987
- <li className="text-amber-400/60 font-medium mt-1">RBAC restrictions:</li>
988
- )}
989
- {rbacWarnings.map((w, i) => (
990
- <li key={`rbac-${i}`} className="font-mono">{w}</li>
991
- ))}
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) && (
999
- <li className="text-amber-400/60 font-medium mt-1">Other warnings:</li>
1000
- )}
1001
- {otherWarnings.map((w, i) => (
1002
- <li key={`other-${i}`} className="font-mono">{w}</li>
1003
- ))}
1004
- </ul>
1005
- </details>
1005
+ <p className="mt-1 text-xs text-theme-text-tertiary font-mono">{layoutError}</p>
1006
1006
  </div>
1007
1007
  </div>
1008
1008
  </div>
1009
- )
1010
- })()}
1011
- {/* Layout error banner - shown even when stale nodes exist */}
1012
- {layoutError && nodes.length > 0 && (
1013
- <div className="absolute top-2 left-2 right-2 z-10 bg-red-500/10 border border-red-500/30 rounded-lg p-2 backdrop-blur-sm">
1014
- <div className="flex items-start gap-2">
1015
- <AlertTriangle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
1016
- <div className="text-sm">
1017
- <span className="font-medium text-red-400">Layout Error:</span>
1018
- <span className="text-theme-text-secondary ml-1">
1019
- Failed to update layout. Showing previous view.
1020
- </span>
1021
- <p className="mt-1 text-xs text-theme-text-tertiary font-mono">{layoutError}</p>
1022
- </div>
1023
- </div>
1024
- </div>
1025
- )}
1009
+ )}
1010
+ {children}
1011
+ </TopologyOverlayBar>
1026
1012
  {/* Summary-mode pill — pod tier collapsed to per-workload/service counts */}
1027
1013
  {topology?.summaryMode && (
1028
1014
  <div className="absolute bottom-3 left-1/2 -translate-x-1/2 z-10 flex items-center gap-1.5 bg-blue-500/10 border border-blue-500/30 rounded-full px-3 py-1 backdrop-blur-sm">
@@ -0,0 +1,30 @@
1
+ import type { ReactNode } from 'react'
2
+ import { clsx } from 'clsx'
3
+
4
+ interface TopologyOverlayBarProps {
5
+ children: ReactNode
6
+ /** Extra classes merged onto the bar container. */
7
+ className?: string
8
+ }
9
+
10
+ /**
11
+ * Top-anchored overlay bar for the topology canvas. Stacks children top-to-
12
+ * bottom (flex column, source order); wrap items in your own `flex` div to put
13
+ * them on one line. Everything stacks, so overlays never collide.
14
+ *
15
+ * `pointer-events-none` so the canvas stays pannable in the gaps — interactive
16
+ * children opt back in with `pointer-events-auto`. Children are `items-start`
17
+ * (content width); use `w-full` for a full-width row.
18
+ */
19
+ export function TopologyOverlayBar({ children, className }: TopologyOverlayBarProps) {
20
+ return (
21
+ <div
22
+ className={clsx(
23
+ 'pointer-events-none absolute inset-x-2 top-2 z-10 flex flex-col items-start gap-2',
24
+ className,
25
+ )}
26
+ >
27
+ {children}
28
+ </div>
29
+ )
30
+ }
@@ -1,4 +1,5 @@
1
1
  import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
2
+ import { createPortal } from 'react-dom'
2
3
  import { Search, X, ChevronRight } from 'lucide-react'
3
4
  import { Input } from '../ui/Input'
4
5
  import { getTopologyIcon } from '../../utils/resource-icons'
@@ -24,12 +25,12 @@ interface TopologySearchProps {
24
25
  * empty-state hint above. Defaults to "current view".
25
26
  */
26
27
  viewModeLabel?: string
27
- /**
28
- * Position utilities for the trigger button (default "top-4 left-4").
29
- * The host overrides this to avoid colliding with other canvas overlays
30
- * (e.g. stacking below the namespace breadcrumb).
31
- */
32
- triggerClassName?: string
28
+ /** Element the search overlay portals into — typically the topology pane. Set
29
+ * it when the trigger lives inside a `pointer-events-none` overlay bar: the
30
+ * overlay then renders OUTSIDE that bar (so its backdrop is clickable) and is
31
+ * `absolute inset-0` scoped to this container (dims only the pane). Omit to
32
+ * fall back to a viewport-level `fixed` overlay on document.body. */
33
+ overlayContainer?: HTMLElement | null
33
34
  }
34
35
 
35
36
  // Icon mapping for different resource kinds
@@ -71,7 +72,7 @@ function getKindColor(kind: string): string {
71
72
  }
72
73
  }
73
74
 
74
- export function TopologySearch({ nodes, onNodeSelect, onZoomToNode, allNodes, viewModeLabel, triggerClassName }: TopologySearchProps) {
75
+ export function TopologySearch({ nodes, onNodeSelect, onZoomToNode, allNodes, viewModeLabel, overlayContainer }: TopologySearchProps) {
75
76
  const [isOpen, setIsOpen] = useState(false)
76
77
  const [query, setQuery] = useState('')
77
78
  const [selectedIndex, setSelectedIndex] = useState(0)
@@ -195,15 +196,20 @@ export function TopologySearch({ nodes, onNodeSelect, onZoomToNode, allNodes, vi
195
196
  setTimeout(() => inputRef.current?.focus(), 0)
196
197
  }, [])
197
198
 
199
+ // Portal the overlay OUT of the (pointer-events-none) overlay bar the trigger
200
+ // sits in. With a container → `absolute inset-0` scoped to it (the pane);
201
+ // without → a viewport `fixed` overlay on document.body.
202
+ const portalTarget = overlayContainer ?? (typeof document !== 'undefined' ? document.body : null)
203
+ const overlayPositionClass = overlayContainer ? 'absolute' : 'fixed'
204
+
198
205
  return (
199
206
  <>
200
207
  {/* Search trigger button */}
201
208
  <button
202
209
  onClick={handleOpen}
203
- className={clsx(
204
- 'absolute z-10 flex items-center gap-2 px-3 py-2 bg-theme-surface/90 backdrop-blur border border-theme-border rounded-lg text-theme-text-secondary hover:text-theme-text-primary hover:border-theme-border-light transition-colors',
205
- triggerClassName ?? 'top-4 left-4'
206
- )}
210
+ // Layout-neutral (positioned by the host's overlay bar); pointer-events-
211
+ // auto inside the pointer-events-none bar.
212
+ className="pointer-events-auto flex items-center gap-2 px-3 py-2 bg-theme-surface/90 backdrop-blur border border-theme-border rounded-lg text-theme-text-secondary hover:text-theme-text-primary hover:border-theme-border-light transition-colors"
207
213
  >
208
214
  <Search className="w-4 h-4" />
209
215
  <span className="text-sm">Search</span>
@@ -212,9 +218,10 @@ export function TopologySearch({ nodes, onNodeSelect, onZoomToNode, allNodes, vi
212
218
  </kbd>
213
219
  </button>
214
220
 
215
- {/* Search modal */}
216
- {isOpen && (
217
- <div className="absolute inset-0 z-50 flex items-start justify-center pt-[10vh]">
221
+ {/* Portaled out of the overlay bar (see portalTarget above) so the
222
+ backdrop is clickable and the dim covers only the pane, not the app. */}
223
+ {isOpen && portalTarget && createPortal(
224
+ <div className={`${overlayPositionClass} inset-0 z-50 flex items-start justify-center pt-[10vh]`}>
218
225
  {/* Backdrop */}
219
226
  <div
220
227
  className="absolute inset-0 bg-theme-base/60 backdrop-blur-sm"
@@ -339,7 +346,8 @@ export function TopologySearch({ nodes, onNodeSelect, onZoomToNode, allNodes, vi
339
346
  </div>
340
347
  )}
341
348
  </div>
342
- </div>
349
+ </div>,
350
+ portalTarget,
343
351
  )}
344
352
  </>
345
353
  )
@@ -4,4 +4,6 @@ export { GroupNode } from './GroupNode'
4
4
  export { TopologySearch } from './TopologySearch'
5
5
  export { TopologyFilterSidebar } from './TopologyFilterSidebar'
6
6
  export { TopologyControls } from './TopologyControls'
7
+ export { TopologyOverlayBar } from './TopologyOverlayBar'
8
+ export { TopologyBreadcrumb } from './TopologyBreadcrumb'
7
9
  export { buildHierarchicalElkGraph, applyHierarchicalLayout, getGroupKey, setLayoutEngine } from './layout'
@@ -52,7 +52,7 @@ interface BadgeProps {
52
52
  // ---------------------------------------------------------------------------
53
53
  const SEVERITY: Record<BadgeSeverity, string> = {
54
54
  success: 'bg-emerald-100 text-emerald-700 border-emerald-300 dark:bg-emerald-950/50 dark:text-emerald-400 dark:border-emerald-700/40',
55
- warning: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-400 dark:border-amber-700/40',
55
+ warning: 'bg-amber-100 text-warning-text border-amber-300 dark:bg-amber-950/50 dark:border-amber-700/40',
56
56
  alert: 'bg-orange-100 text-orange-800 border-orange-300 dark:bg-orange-950/50 dark:text-orange-400 dark:border-orange-700/40',
57
57
  error: 'bg-red-100 text-red-700 border-red-300 dark:bg-red-950/50 dark:text-red-400 dark:border-red-700/40',
58
58
  info: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
@@ -0,0 +1,81 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { Check, ChevronDown } from 'lucide-react'
3
+ import { clsx } from 'clsx'
4
+
5
+ export interface SelectMenuOption {
6
+ value: string
7
+ label: string
8
+ }
9
+
10
+ export function SelectMenu({
11
+ value,
12
+ options,
13
+ onChange,
14
+ ariaLabel,
15
+ className,
16
+ }: {
17
+ value: string
18
+ options: SelectMenuOption[]
19
+ onChange: (value: string) => void
20
+ ariaLabel: string
21
+ className?: string
22
+ }) {
23
+ const [open, setOpen] = useState(false)
24
+ const rootRef = useRef<HTMLDivElement>(null)
25
+ const selected = options.find((option) => option.value === value) ?? options[0]
26
+
27
+ useEffect(() => {
28
+ if (!open) return
29
+ const onPointerDown = (event: MouseEvent) => {
30
+ if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
31
+ }
32
+ const onKeyDown = (event: KeyboardEvent) => {
33
+ if (event.key === 'Escape') setOpen(false)
34
+ }
35
+ document.addEventListener('mousedown', onPointerDown)
36
+ document.addEventListener('keydown', onKeyDown)
37
+ return () => {
38
+ document.removeEventListener('mousedown', onPointerDown)
39
+ document.removeEventListener('keydown', onKeyDown)
40
+ }
41
+ }, [open])
42
+
43
+ return (
44
+ <div ref={rootRef} className={clsx('relative', className)}>
45
+ <button
46
+ type="button"
47
+ aria-label={ariaLabel}
48
+ aria-haspopup="listbox"
49
+ aria-expanded={open}
50
+ onClick={() => setOpen((current) => !current)}
51
+ className="flex h-8 w-full items-center justify-between gap-2 rounded-md border border-theme-border bg-theme-elevated px-2.5 text-xs text-theme-text-primary transition-colors hover:bg-theme-hover"
52
+ >
53
+ <span className="truncate">{selected?.label}</span>
54
+ <ChevronDown className={clsx('h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform', open && 'rotate-180')} />
55
+ </button>
56
+ {open && (
57
+ <div role="listbox" className="absolute right-0 top-full z-50 mt-1 min-w-full overflow-hidden rounded-md border border-theme-border bg-theme-surface py-1 shadow-theme-lg">
58
+ {options.map((option) => {
59
+ const active = option.value === value
60
+ return (
61
+ <button
62
+ key={option.value}
63
+ type="button"
64
+ role="option"
65
+ aria-selected={active}
66
+ onClick={() => {
67
+ onChange(option.value)
68
+ setOpen(false)
69
+ }}
70
+ className="flex w-full items-center gap-2 whitespace-nowrap px-2.5 py-1.5 text-left text-xs text-theme-text-secondary transition-colors hover:bg-theme-hover hover:text-theme-text-primary"
71
+ >
72
+ <Check className={clsx('h-3.5 w-3.5 shrink-0 text-accent', !active && 'opacity-0')} />
73
+ {option.label}
74
+ </button>
75
+ )
76
+ })}
77
+ </div>
78
+ )}
79
+ </div>
80
+ )
81
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { Tooltip } from './Tooltip'
4
+
5
+ function render(preserveWrapperWhenDisabled = false) {
6
+ return renderToString(
7
+ <Tooltip content="Full value" disabled preserveWrapperWhenDisabled={preserveWrapperWhenDisabled}>
8
+ <span>Visible value</span>
9
+ </Tooltip>,
10
+ )
11
+ }
12
+
13
+ describe('Tooltip', () => {
14
+ it('omits its wrapper when disabled by default', () => {
15
+ expect(render()).not.toContain('inline-flex max-w-full')
16
+ })
17
+
18
+ it('can preserve its wrapper while disabled for layout-sensitive children', () => {
19
+ expect(render(true)).toContain('inline-flex max-w-full')
20
+ })
21
+ })
@@ -29,6 +29,8 @@ interface TooltipProps {
29
29
  className?: string
30
30
  /** Whether tooltip is disabled */
31
31
  disabled?: boolean
32
+ /** Keep the wrapper mounted while disabled when child layout measurement requires stable DOM ancestry. */
33
+ preserveWrapperWhenDisabled?: boolean
32
34
  /** Additional class for the wrapper span (useful for positioning) */
33
35
  wrapperClassName?: string
34
36
  /** Inline styles for the wrapper span (useful for absolute positioning) */
@@ -42,6 +44,7 @@ export function Tooltip({
42
44
  position = 'top',
43
45
  className,
44
46
  disabled = false,
47
+ preserveWrapperWhenDisabled = false,
45
48
  wrapperClassName,
46
49
  wrapperStyle,
47
50
  }: TooltipProps) {
@@ -99,6 +102,9 @@ export function Tooltip({
99
102
  clearTimeout(hideTimeoutRef.current)
100
103
  hideTimeoutRef.current = null
101
104
  }
105
+ if (activeHide === hideRef.current) {
106
+ activeHide = null
107
+ }
102
108
  setIsVisible(false)
103
109
  setCoords(null)
104
110
  }
@@ -194,16 +200,7 @@ export function Tooltip({
194
200
  // pointer-events-none and never fires mouseleave.
195
201
  useEffect(() => {
196
202
  if (disabled) {
197
- if (timeoutRef.current) {
198
- clearTimeout(timeoutRef.current)
199
- timeoutRef.current = null
200
- }
201
- if (hideTimeoutRef.current) {
202
- clearTimeout(hideTimeoutRef.current)
203
- hideTimeoutRef.current = null
204
- }
205
- setIsVisible(false)
206
- setCoords(null)
203
+ hideRef.current()
207
204
  }
208
205
  }, [disabled])
209
206
 
@@ -224,7 +221,7 @@ export function Tooltip({
224
221
  }
225
222
  }, [isVisible])
226
223
 
227
- if (disabled || !content) {
224
+ if ((disabled && !preserveWrapperWhenDisabled) || !content) {
228
225
  return <>{children}</>
229
226
  }
230
227
 
@@ -245,7 +242,7 @@ export function Tooltip({
245
242
  >
246
243
  {children}
247
244
  </span>
248
- {isVisible &&
245
+ {isVisible && !disabled &&
249
246
  createPortal(
250
247
  <span
251
248
  ref={tooltipRef}
@@ -410,7 +410,7 @@ export function AlertBanner({ variant, icon, title, message, items, children }:
410
410
  {hasBody ? (
411
411
  <div className="flex-1 min-w-0">
412
412
  <div className={clsx('text-sm font-medium', colors.title, items && 'mb-1')}>{title}</div>
413
- {message && <div className={clsx('text-xs mt-1 break-all', colors.message)}>{message}</div>}
413
+ {message && <div className={clsx('text-xs mt-1 break-words', colors.message)}>{message}</div>}
414
414
  {items && items.length > 0 && (
415
415
  <ul className={clsx('text-xs space-y-1', colors.list)}>
416
416
  {items.map((item, i) => (
@@ -446,11 +446,14 @@ export interface Problem {
446
446
  }
447
447
 
448
448
  /** Displays a list of problem alerts (warnings and errors) */
449
- // True when the resource detail is already rendering a dedicated, authoritative
450
- // "Operational Issues" section (the Issues pipeline — richer cause/action). Only
451
- // renderers whose problems the pipeline COMPREHENSIVELY covers read this and drop
452
- // their own problem array so the same failure isn't shown twice — today that's
453
- // PodRenderer and WorkloadRenderer (workload + pod runtime). It is deliberately
449
+ // True when the resource detail is rendering (or still fetching) a dedicated,
450
+ // authoritative "Operational Issues" section (the Issues pipeline — richer
451
+ // cause/action). Renderers whose problems the pipeline COMPREHENSIVELY covers read
452
+ // this and drop their own condition banner so the same failure isn't shown twice.
453
+ // It stays true WHILE the live-issues fetch is pending: a banner the pipeline will
454
+ // cover would otherwise flash on first paint before the issues arrive and hide it;
455
+ // once the fetch settles with no issues (e.g. a CRD the pipeline doesn't watch),
456
+ // it flips false and the renderer's banner appears cleanly. It is deliberately
454
457
  // NOT wired into ProblemAlerts: that component is used only by GitOps renderers
455
458
  // (Argo/Flux), whose Degraded/OutOfSync/revision-mismatch banners the pipeline
456
459
  // does not fully emit (e.g. OutOfSync only for automated Argo apps) — suppressing
@@ -10,6 +10,8 @@ export type { EmptyStateTone, EmptyStateVariant } from './EmptyState'
10
10
  export { RestrictedState } from './RestrictedState'
11
11
  export { FetchResult } from './FetchResult'
12
12
  export { SearchBox } from './SearchBox'
13
+ export { SelectMenu } from './SelectMenu'
14
+ export type { SelectMenuOption } from './SelectMenu'
13
15
  export { Input } from './Input'
14
16
  export { FilterPill } from './FilterPill'
15
17
  export type { FilterPillTone } from './FilterPill'