@skyhook-io/k8s-ui 1.8.11 → 1.8.12

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 (80) 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/renderers/AWSMachineRenderer.tsx +3 -2
  19. package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
  20. package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
  21. package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
  22. package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
  23. package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
  24. package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
  25. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  26. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
  27. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
  28. package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
  29. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
  30. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
  31. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
  32. package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
  33. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
  34. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
  35. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
  36. package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
  37. package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
  38. package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
  39. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
  40. package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
  41. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
  42. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
  43. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
  44. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
  45. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
  46. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
  47. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
  48. package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
  49. package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
  50. package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
  51. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
  52. package/src/components/resources/resource-utils-argo.test.ts +18 -1
  53. package/src/components/resources/resource-utils-argo.ts +8 -0
  54. package/src/components/shared/ResourceActionsBar.tsx +2 -3
  55. package/src/components/shared/UnifiedDiff.tsx +51 -0
  56. package/src/components/shared/index.ts +1 -0
  57. package/src/components/timeline/TimelineList.tsx +7 -3
  58. package/src/components/timeline/TimelineToolbarState.test.tsx +24 -0
  59. package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
  60. package/src/components/topology/TopologyControls.tsx +3 -1
  61. package/src/components/topology/TopologyGraph.tsx +76 -90
  62. package/src/components/topology/TopologyOverlayBar.tsx +30 -0
  63. package/src/components/topology/TopologySearch.tsx +23 -15
  64. package/src/components/topology/index.ts +2 -0
  65. package/src/components/ui/Badge.tsx +1 -1
  66. package/src/components/ui/SelectMenu.tsx +81 -0
  67. package/src/components/ui/drawer-components.tsx +9 -6
  68. package/src/components/ui/index.ts +2 -0
  69. package/src/components/workload/WorkloadView.tsx +24 -3
  70. package/src/theme/tailwind-theme.css +1 -0
  71. package/src/theme/variables.css +1 -0
  72. package/src/types/gitops-insights.ts +69 -1
  73. package/src/types/gitops.ts +3 -3
  74. package/src/utils/application-history.test.ts +538 -0
  75. package/src/utils/application-history.ts +257 -0
  76. package/src/utils/applications.test.ts +34 -0
  77. package/src/utils/applications.ts +25 -0
  78. package/src/utils/index.ts +1 -0
  79. package/src/utils/resource-hierarchy.test.ts +35 -1
  80. 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
+ }
@@ -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'
@@ -29,6 +29,7 @@ import {
29
29
  X,
30
30
  BarChart3,
31
31
  Network,
32
+ DollarSign,
32
33
  } from 'lucide-react'
33
34
  import type { TimelineEvent, ResourceRef, Relationships, SelectedResource, ResolvedEnvFrom, Topology, TopologyNode, HPADiagnosis, WorkloadPodInfo } from '../../types'
34
35
  import type { GitOpsStatus } from '../../types/gitops'
@@ -64,7 +65,7 @@ import {
64
65
  } from '../resources/resource-utils'
65
66
  import { ServicePortCards, type ServicePortRenderProps } from '../resources/renderers/ServiceRenderer'
66
67
 
67
- export type WorkloadTabType = 'overview' | 'topology' | 'timeline' | 'logs' | 'metrics' | 'yaml'
68
+ export type WorkloadTabType = 'overview' | 'topology' | 'timeline' | 'logs' | 'metrics' | 'cost' | 'yaml'
68
69
  type TabType = WorkloadTabType
69
70
 
70
71
  export interface ResourceOwnershipContext {
@@ -257,6 +258,8 @@ interface WorkloadViewProps {
257
258
  }) => ReactNode
258
259
  /** Render the metrics tab content */
259
260
  renderMetricsTab?: (props: { kind: string; namespace: string; name: string }) => ReactNode
261
+ /** Render the cost tab content */
262
+ renderCostTab?: (props: { kind: string; namespace: string; name: string }) => ReactNode
260
263
  /** Render a read-only YAML view for a related object from the workload's
261
264
  * neighborhood. Providing this turns the YAML tab into an object explorer
262
265
  * (rail of the workload + its Services/config/policies/pods); omitting it
@@ -265,6 +268,8 @@ interface WorkloadViewProps {
265
268
  renderRelatedYaml?: (ref: { kind: string; namespace: string; name: string; group?: string }) => ReactNode
266
269
  /** Whether metrics are available for this resource kind */
267
270
  isMetricsAvailable?: (kind: string, resource: any) => boolean
271
+ /** Whether cost is available for this resource kind */
272
+ isCostAvailable?: (kind: string, resource: any) => boolean
268
273
  /** Render extra content at the bottom of the overview tab (e.g. audit findings) */
269
274
  renderOverviewExtra?: (props: { kind: string; namespace: string; name: string }) => ReactNode
270
275
  /** Render lightweight overview intro content before the default renderer. */
@@ -280,6 +285,10 @@ interface WorkloadViewProps {
280
285
  * live issues for this resource). Avoids showing the same failure twice.
281
286
  * Also gates the `renderOverviewLead` wrapper (see above). */
282
287
  hasOperationalIssues?: boolean
288
+ /** True while the live-issues fetch is still pending. Renderers suppress their
289
+ * banners during this window too — a banner whose failure the pipeline covers
290
+ * would otherwise flash on first paint before the issues arrive and hide it. */
291
+ operationalIssuesPending?: boolean
283
292
 
284
293
  // ── Duplicate ────────────────────────────────────────────────────────────
285
294
  /** Duplicate handler — opens create dialog with this resource's YAML */
@@ -359,7 +368,9 @@ export function WorkloadView({
359
368
  renderExpandedOverview,
360
369
  renderRelatedYaml,
361
370
  renderMetricsTab,
371
+ renderCostTab,
362
372
  isMetricsAvailable,
373
+ isCostAvailable,
363
374
  // Duplicate
364
375
  onDuplicate,
365
376
  onDownload,
@@ -367,6 +378,7 @@ export function WorkloadView({
367
378
  renderOverviewIntro,
368
379
  renderOverviewLead,
369
380
  hasOperationalIssues,
381
+ operationalIssuesPending,
370
382
  // Actions bar
371
383
  actionsBarProps,
372
384
  // Renderer overrides
@@ -644,8 +656,10 @@ export function WorkloadView({
644
656
  })
645
657
 
646
658
  const showMetricsTab = isMetricsAvailable ? isMetricsAvailable(kind, resource) : false
659
+ const showCostTab = isCostAvailable ? isCostAvailable(kind, resource) : false
647
660
  const logsTabVisible = Boolean(renderLogsTab) && (allPods.length > 0 || LOGS_TAB_WITHOUT_PODS_KINDS.has(kindToPlural(kind).toLowerCase()))
648
661
  const metricsTabVisible = Boolean(showMetricsTab && renderMetricsTab)
662
+ const costTabVisible = Boolean(showCostTab && renderCostTab)
649
663
  const podEvidenceLoading = resourceLoading || workloadPodsLoading || eventsLoading
650
664
  const logsFallbackReady = !renderLogsTab || (!logsTabVisible && !podEvidenceLoading)
651
665
  const requestedTab: TabType = activeTab
@@ -660,6 +674,7 @@ export function WorkloadView({
660
674
  },
661
675
  { id: 'logs', label: 'Logs', icon: <Terminal className="w-4 h-4" />, hidden: !logsTabVisible },
662
676
  { id: 'metrics', label: 'Metrics', icon: <BarChart3 className="w-4 h-4" />, hidden: !metricsTabVisible },
677
+ { id: 'cost', label: 'Cost', icon: <DollarSign className="w-4 h-4" />, hidden: !costTabVisible },
663
678
  { id: 'yaml', label: 'YAML', icon: <FileText className="w-4 h-4" /> },
664
679
  ]
665
680
  const requestedTabAvailable = tabs.some((tab) => tab.id === requestedTab && !tab.hidden)
@@ -670,6 +685,7 @@ export function WorkloadView({
670
685
  (
671
686
  (requestedTab === 'topology' && topologyTabHidden) ||
672
687
  (requestedTab === 'metrics' && (!renderMetricsTab || (!!resource && !resourceLoading && !showMetricsTab))) ||
688
+ (requestedTab === 'cost' && (!renderCostTab || (!!resource && !resourceLoading && !showCostTab))) ||
673
689
  (requestedTab === 'logs' && logsFallbackReady)
674
690
  )
675
691
  useEffect(() => {
@@ -794,7 +810,7 @@ export function WorkloadView({
794
810
  onDownload={onDownload}
795
811
  />
796
812
  ) : (
797
- <OperationalIssuesShownContext.Provider value={!!hasOperationalIssues}>
813
+ <OperationalIssuesShownContext.Provider value={!!hasOperationalIssues || !!operationalIssuesPending}>
798
814
  {renderOverviewLead && hasOperationalIssues && (
799
815
  <div className="px-4 pt-4">
800
816
  {renderOverviewLead({ kind, namespace, name })}
@@ -831,7 +847,7 @@ export function WorkloadView({
831
847
 
832
848
  // ── Expanded (full) mode ─────────────────────────────────────────────────
833
849
  return (
834
- <OperationalIssuesShownContext.Provider value={!!hasOperationalIssues}>
850
+ <OperationalIssuesShownContext.Provider value={!!hasOperationalIssues || !!operationalIssuesPending}>
835
851
  <DetailShell
836
852
  breadcrumb={breadcrumb}
837
853
  nav={
@@ -1038,6 +1054,11 @@ export function WorkloadView({
1038
1054
  {renderMetricsTab({ kind: resource?.kind || kind, namespace, name })}
1039
1055
  </div>
1040
1056
  )}
1057
+ {effectiveTab === 'cost' && renderCostTab && (
1058
+ <div className="h-full overflow-auto p-4">
1059
+ {renderCostTab({ kind: resource?.kind || kind, namespace, name })}
1060
+ </div>
1061
+ )}
1041
1062
  {effectiveTab === 'yaml' && (
1042
1063
  <div className="flex h-full min-h-0">
1043
1064
  {renderRelatedYaml && yamlObjects.length > 1 && (
@@ -20,6 +20,7 @@
20
20
  --color-theme-text-secondary: var(--text-secondary);
21
21
  --color-theme-text-tertiary: var(--text-tertiary);
22
22
  --color-theme-text-disabled: var(--text-disabled);
23
+ --color-warning-text: var(--warning-text);
23
24
 
24
25
  /* Borders */
25
26
  --color-theme-border: var(--border-default);
@@ -49,6 +49,7 @@
49
49
  --text-secondary: light-dark(#555860, #D8DAE0);
50
50
  --text-tertiary: light-dark(#6B7080, #B0B5C0);
51
51
  --text-disabled: light-dark(#A0A4AC, #6A7080);
52
+ --warning-text: light-dark(#92400E, #FBBF24);
52
53
 
53
54
  /* ── Borders (lightly blue-tinted in light mode) ── */
54
55
  --border-default: light-dark(#D8E0EE, #222840);