@skyhook-io/radar-app 1.13.4 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/package.json +8 -8
  2. package/src/App.tsx +60 -24
  3. package/src/RadarApp.tsx +5 -0
  4. package/src/api/client.authRedirect.test.ts +12 -1
  5. package/src/api/client.prometheusStatus.test.ts +24 -0
  6. package/src/api/client.ts +325 -16
  7. package/src/api/diagnose.ts +62 -15
  8. package/src/api/drain-plan.test.ts +86 -0
  9. package/src/api/quotas.test.ts +26 -0
  10. package/src/api/quotas.ts +20 -0
  11. package/src/api/workloadMetrics.test.ts +40 -0
  12. package/src/api/workloadMetrics.ts +68 -0
  13. package/src/components/CloudConnectFlow.tsx +179 -42
  14. package/src/components/CloudFunnelButton.tsx +107 -36
  15. package/src/components/ConnectionErrorView.test.tsx +30 -0
  16. package/src/components/ConnectionErrorView.tsx +31 -19
  17. package/src/components/SyncProgressPanel.tsx +73 -0
  18. package/src/components/audit/AuditView.tsx +2 -0
  19. package/src/components/cloudConnectHandoff.test.ts +140 -0
  20. package/src/components/cloudConnectHandoff.ts +124 -0
  21. package/src/components/diagnose/AgentCase.tsx +131 -0
  22. package/src/components/diagnose/DiagnoseContext.tsx +2 -1
  23. package/src/components/diagnose/DiagnoseSurface.test.tsx +0 -20
  24. package/src/components/diagnose/DiagnoseSurface.tsx +32 -195
  25. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +783 -7
  26. package/src/components/diagnose/InvestigationEvidencePane.tsx +774 -133
  27. package/src/components/diagnose/InvestigationView.tsx +222 -5
  28. package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
  29. package/src/components/diagnose/investigationCase.test.tsx +1568 -0
  30. package/src/components/diagnose/investigationCase.ts +439 -0
  31. package/src/components/diagnose/investigationEvidence/adapters/changes.ts +60 -0
  32. package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +573 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +935 -0
  34. package/src/components/diagnose/investigationEvidence/adapters/events.ts +96 -0
  35. package/src/components/diagnose/investigationEvidence/adapters/helm.test.ts +242 -0
  36. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +208 -0
  37. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +172 -0
  38. package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +283 -0
  39. package/src/components/diagnose/investigationEvidence/adapters/logs.ts +183 -0
  40. package/src/components/diagnose/investigationEvidence/adapters/permissions.test.ts +528 -0
  41. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +292 -0
  42. package/src/components/diagnose/investigationEvidence/adapters/prometheus.test.ts +1641 -0
  43. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +769 -0
  44. package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +54 -0
  45. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +257 -0
  46. package/src/components/diagnose/investigationEvidence/adapters/topology.ts +207 -0
  47. package/src/components/diagnose/investigationEvidence/builder.ts +418 -0
  48. package/src/components/diagnose/investigationEvidence/evidenceFixtures.ts +179 -0
  49. package/src/components/diagnose/investigationEvidence/identity.test.ts +614 -0
  50. package/src/components/diagnose/investigationEvidence/identity.ts +261 -0
  51. package/src/components/diagnose/investigationEvidence/index.ts +55 -0
  52. package/src/components/diagnose/investigationEvidence/observations.ts +954 -0
  53. package/src/components/diagnose/investigationEvidence/parse.ts +467 -0
  54. package/src/components/diagnose/investigationEvidence/projection.test.ts +2703 -0
  55. package/src/components/diagnose/investigationEvidence/types.ts +512 -0
  56. package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
  57. package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
  58. package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
  59. package/src/components/diagnose/investigationMetrics.test.ts +712 -0
  60. package/src/components/diagnose/investigationMetrics.ts +393 -0
  61. package/src/components/diagnose/investigationSourceFocus.ts +2 -0
  62. package/src/components/diagnose/investigationState.test.ts +322 -0
  63. package/src/components/diagnose/investigationState.ts +147 -25
  64. package/src/components/diagnose/parts.test.tsx +482 -0
  65. package/src/components/diagnose/parts.tsx +418 -41
  66. package/src/components/diagnose/target.test.ts +19 -0
  67. package/src/components/diagnose/target.ts +3 -2
  68. package/src/components/gitops/GitOpsView.tsx +7 -0
  69. package/src/components/gitops/RemoteDestinationCloudHint.tsx +26 -0
  70. package/src/components/resource/PrometheusCharts.overlays.test.ts +22 -0
  71. package/src/components/resource/PrometheusCharts.tsx +4 -3
  72. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +99 -0
  73. package/src/components/resource/PrometheusChartsGrid.tsx +245 -103
  74. package/src/components/resource/RightsizingStrip.test.ts +38 -0
  75. package/src/components/resource/RightsizingStrip.tsx +15 -3
  76. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +79 -0
  77. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +145 -0
  78. package/src/components/resource/WorkloadMetricsSection.render.test.tsx +351 -0
  79. package/src/components/resource/WorkloadMetricsSection.tsx +486 -0
  80. package/src/components/resource/workloadMetricValues.test.ts +65 -0
  81. package/src/components/resource/workloadMetricValues.ts +30 -0
  82. package/src/components/resources/PodFilePreview.test.tsx +131 -0
  83. package/src/components/resources/PodFilePreview.tsx +394 -0
  84. package/src/components/resources/PodFilesystemModal.tsx +157 -67
  85. package/src/components/resources/ResourcesView.tsx +44 -6
  86. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -1
  87. package/src/components/resources/renderers/PodRenderer.tsx +4 -0
  88. package/src/components/resources/renderers/RolloutRenderer.tsx +15 -1
  89. package/src/components/resources/renderers/WorkloadRenderer.tsx +3 -0
  90. package/src/components/rightsizing/RightsizingScanView.tsx +21 -0
  91. package/src/components/settings/SettingsDialog.tsx +94 -14
  92. package/src/components/traffic/TrafficFlowList.tsx +229 -70
  93. package/src/components/traffic/TrafficGraph.tsx +6 -6
  94. package/src/components/traffic/TrafficView.tsx +10 -9
  95. package/src/components/traffic/trafficFilters.test.ts +28 -1
  96. package/src/components/traffic/trafficFilters.ts +31 -0
  97. package/src/components/workload/WorkloadView.tsx +19 -8
  98. package/src/context/ConnectionContext.tsx +48 -3
  99. package/src/context/DiagnoseCustomization.test.tsx +26 -0
  100. package/src/context/DiagnoseCustomization.tsx +15 -2
  101. package/src/index.css +17 -0
  102. package/src/index.ts +1 -0
  103. package/src/utils/navigation.ts +1 -1
  104. package/src/utils/shell-safe.test.ts +25 -1
  105. package/src/utils/shell-safe.ts +16 -0
  106. package/src/utils/topology-namespace.test.ts +64 -0
  107. package/src/utils/topology-namespace.ts +18 -0
  108. package/src/components/diagnose/investigationEvidence.test.ts +0 -5202
  109. package/src/components/diagnose/investigationEvidence.ts +0 -4784
  110. package/src/components/gitops/GitOpsActions.tsx +0 -1
  111. package/src/components/gitops/GitOpsStatusBadge.tsx +0 -1
  112. package/src/components/gitops/ManagedResourcesList.tsx +0 -1
  113. package/src/components/gitops/SyncCountdown.tsx +0 -1
  114. package/src/components/gitops/index.ts +0 -4
  115. package/src/components/helm/ManifestDiffViewer.tsx +0 -69
  116. package/src/components/logs/JsonLogLine.tsx +0 -1
  117. package/src/components/logs/LogCore.tsx +0 -2
  118. package/src/components/logs/useLogBuffer.ts +0 -2
  119. package/src/components/logs/useLogSearch.ts +0 -1
  120. package/src/components/resource-drawer/ResourceDrawer.tsx +0 -216
  121. package/src/components/resources/drawer-components.tsx +0 -1
  122. package/src/components/resources/renderers/AlertRenderer.tsx +0 -1
  123. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +0 -1
  124. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +0 -1
  125. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +0 -1
  126. package/src/components/resources/renderers/CertificateRenderer.tsx +0 -1
  127. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +0 -1
  128. package/src/components/resources/renderers/ChallengeRenderer.tsx +0 -1
  129. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +0 -1
  130. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +0 -1
  131. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +0 -1
  132. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +0 -1
  133. package/src/components/resources/renderers/ConfigMapRenderer.tsx +0 -1
  134. package/src/components/resources/renderers/CronJobRenderer.tsx +0 -1
  135. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +0 -1
  136. package/src/components/resources/renderers/EventRenderer.tsx +0 -1
  137. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +0 -1
  138. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +0 -1
  139. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +0 -1
  140. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +0 -1
  141. package/src/components/resources/renderers/GatewayClassRenderer.tsx +0 -1
  142. package/src/components/resources/renderers/GatewayRenderer.tsx +0 -1
  143. package/src/components/resources/renderers/GenericRenderer.tsx +0 -1
  144. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +0 -1
  145. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +0 -1
  146. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +0 -1
  147. package/src/components/resources/renderers/IngressClassRenderer.tsx +0 -1
  148. package/src/components/resources/renderers/IngressRenderer.tsx +0 -1
  149. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +0 -1
  150. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +0 -1
  151. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +0 -1
  152. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +0 -1
  153. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +0 -1
  154. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +0 -1
  155. package/src/components/resources/renderers/JobRenderer.tsx +0 -1
  156. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +0 -1
  157. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +0 -1
  158. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +0 -1
  159. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +0 -1
  160. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +0 -1
  161. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +0 -1
  162. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +0 -1
  163. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +0 -1
  164. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +0 -1
  165. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +0 -1
  166. package/src/components/resources/renderers/KnativeRouteRenderer.tsx +0 -1
  167. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +0 -1
  168. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +0 -1
  169. package/src/components/resources/renderers/KustomizationRenderer.tsx +0 -1
  170. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +0 -1
  171. package/src/components/resources/renderers/LeaseRenderer.tsx +0 -1
  172. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +0 -1
  173. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +0 -1
  174. package/src/components/resources/renderers/OrderRenderer.tsx +0 -1
  175. package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +0 -1
  176. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +0 -1
  177. package/src/components/resources/renderers/PodMonitorRenderer.tsx +0 -1
  178. package/src/components/resources/renderers/PriorityClassRenderer.tsx +0 -1
  179. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +0 -1
  180. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +0 -1
  181. package/src/components/resources/renderers/RuntimeClassRenderer.tsx +0 -1
  182. package/src/components/resources/renderers/SbomReportRenderer.tsx +0 -1
  183. package/src/components/resources/renderers/SealedSecretRenderer.tsx +0 -1
  184. package/src/components/resources/renderers/SecretRenderer.tsx +0 -1
  185. package/src/components/resources/renderers/SecretStoreRenderer.tsx +0 -1
  186. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +0 -1
  187. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +0 -1
  188. package/src/components/resources/renderers/StorageClassRenderer.tsx +0 -1
  189. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +0 -1
  190. package/src/components/resources/renderers/VPARenderer.tsx +0 -1
  191. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +0 -1
  192. package/src/components/resources/renderers/VeleroVSLRenderer.tsx +0 -1
  193. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +0 -1
  194. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +0 -1
  195. package/src/components/resources/renderers/WorkflowRenderer.tsx +0 -1
  196. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +0 -1
  197. package/src/components/resources/renderers/argo-cells.tsx +0 -1
  198. package/src/components/resources/renderers/certmanager-cells.tsx +0 -1
  199. package/src/components/resources/renderers/cnpg-cells.tsx +0 -1
  200. package/src/components/resources/renderers/eso-cells.tsx +0 -1
  201. package/src/components/resources/renderers/flux-cells.tsx +0 -1
  202. package/src/components/resources/renderers/index.ts +0 -94
  203. package/src/components/resources/renderers/istio-cells.tsx +0 -1
  204. package/src/components/resources/renderers/karpenter-cells.tsx +0 -1
  205. package/src/components/resources/renderers/keda-cells.tsx +0 -1
  206. package/src/components/resources/renderers/knative-cells.tsx +0 -1
  207. package/src/components/resources/renderers/kyverno-cells.tsx +0 -1
  208. package/src/components/resources/renderers/prometheus-cells.tsx +0 -1
  209. package/src/components/resources/renderers/traefik-cells.tsx +0 -1
  210. package/src/components/resources/renderers/trivy-cells.tsx +0 -1
  211. package/src/components/resources/renderers/trivy-shared.tsx +0 -1
  212. package/src/components/resources/renderers/velero-cells.tsx +0 -1
  213. package/src/components/resources/resource-utils-argo.ts +0 -2
  214. package/src/components/resources/resource-utils-certmanager.ts +0 -2
  215. package/src/components/resources/resource-utils-eso.ts +0 -2
  216. package/src/components/resources/resource-utils-flux.ts +0 -2
  217. package/src/components/resources/resource-utils-istio.ts +0 -2
  218. package/src/components/resources/resource-utils-karpenter.ts +0 -2
  219. package/src/components/resources/resource-utils-knative.ts +0 -2
  220. package/src/components/resources/resource-utils-kyverno.ts +0 -2
  221. package/src/components/resources/resource-utils-prometheus.ts +0 -2
  222. package/src/components/resources/resource-utils-traefik.ts +0 -1
  223. package/src/components/resources/resource-utils-trivy.ts +0 -2
  224. package/src/components/resources/resource-utils-velero.ts +0 -2
  225. package/src/components/shared/EditableYamlView.tsx +0 -24
  226. package/src/components/timeline/DiffViewer.tsx +0 -1
  227. package/src/components/traffic/index.ts +0 -3
  228. package/src/components/ui/ForceDeleteConfirmDialog.tsx +0 -1
  229. package/src/components/ui/MetricsChart.tsx +0 -1
  230. package/src/components/ui/ResourceBar.tsx +0 -1
@@ -23,7 +23,7 @@ import { isClusterAddon, type AddonMode } from './TrafficView'
23
23
  import { SEVERITY_BADGE, SEVERITY_DOT, SEVERITY_TEXT } from '@skyhook-io/k8s-ui/utils/badge-colors'
24
24
  import { getNamespaceColor } from '../../utils/traffic-colors'
25
25
  import { Tooltip } from '../ui/Tooltip'
26
- import { isRateBasedSource } from './trafficFilters'
26
+ import { isRateBasedSource, isExternalKind } from './trafficFilters'
27
27
 
28
28
  const elk = new ELK()
29
29
 
@@ -172,7 +172,7 @@ const MAX_VISIBLE_PORTS = 4 // Maximum ports to show before "+N more"
172
172
 
173
173
  // Custom node component
174
174
  function TrafficNode({ data }: { data: TrafficNodeData }) {
175
- const isExternal = data.kind.toLowerCase() === 'external'
175
+ const isExternal = isExternalKind(data.kind)
176
176
  const isInternet = data.kind === 'Internet'
177
177
  const isAddonInternet = data.kind === 'AddonInternet' // Separate internet for addon traffic
178
178
  const isAddon = data.kind === 'Addon'
@@ -515,7 +515,7 @@ function DetailsPanel({
515
515
  <Globe className="h-4 w-4 text-sky-400" />
516
516
  ) : nodeData?.kind === 'Addon' ? (
517
517
  <Server className="h-4 w-4 text-purple-400" />
518
- ) : nodeData?.kind.toLowerCase() === 'external' ? (
518
+ ) : nodeData && isExternalKind(nodeData.kind) ? (
519
519
  <Globe className="h-4 w-4 text-yellow-500" />
520
520
  ) : (
521
521
  <Server className="h-4 w-4 text-blue-500" />
@@ -554,7 +554,7 @@ function DetailsPanel({
554
554
  ? 'bg-sky-500/20 text-sky-400'
555
555
  : nodeData.kind === 'Addon'
556
556
  ? 'bg-purple-500/20 text-purple-400'
557
- : nodeData.kind.toLowerCase() === 'external'
557
+ : isExternalKind(nodeData.kind)
558
558
  ? 'bg-yellow-500/20 text-yellow-400'
559
559
  : 'bg-blue-500/20 text-blue-400'
560
560
  )}>{nodeData.kind === 'Addon' ? 'Cluster Addons' : nodeData.kind}</span>
@@ -1192,7 +1192,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
1192
1192
  namespaceColor: showNamespaceGroups && !sourceIsAddon ? getNamespaceColor(flow.source.namespace) : undefined,
1193
1193
  isHotPath: hotNodes.has(sourceId),
1194
1194
  isAddonNode: sourceIsAddon, // AddonInternet is NOT an addon node
1195
- serviceCategory: flow.source.kind.toLowerCase() === 'external' ? serviceCategories?.get(flow.source.name) : undefined,
1195
+ serviceCategory: isExternalKind(flow.source.kind) ? serviceCategories?.get(flow.source.name) : undefined,
1196
1196
  nodeHeight: NODE_BASE_HEIGHT,
1197
1197
  connLabel,
1198
1198
  },
@@ -1254,7 +1254,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
1254
1254
  namespaceColor: showNamespaceGroups && !destIsAddon ? getNamespaceColor(flow.destination.namespace) : undefined,
1255
1255
  isHotPath: hotNodes.has(destId),
1256
1256
  isAddonNode: destIsAddon,
1257
- serviceCategory: flow.destination.kind.toLowerCase() === 'external' ? serviceCategories?.get(flow.destination.name) : undefined,
1257
+ serviceCategory: isExternalKind(flow.destination.kind) ? serviceCategories?.get(flow.destination.name) : undefined,
1258
1258
  ports: destPorts,
1259
1259
  nodeHeight: getNodeHeight(destPorts),
1260
1260
  connLabel,
@@ -13,7 +13,7 @@ import { useDock } from '../dock'
13
13
  import { AlertBanner, EmptyState, PaneLoader, FreshnessControl } from '@skyhook-io/k8s-ui'
14
14
  import { useConnection } from '../../context/ConnectionContext'
15
15
  import { Tooltip } from '../ui/Tooltip'
16
- import { matchesStatusRanges, bucketsFromCounts, bucketsFromStatus, isRateBasedSource, keepAvailable, effectiveThreshold, volumeUnit, type VolumeUnit } from './trafficFilters'
16
+ import { matchesStatusRanges, bucketsFromCounts, bucketsFromStatus, isRateBasedSource, keepAvailable, effectiveThreshold, volumeUnit, type VolumeUnit, isExternalKind } from './trafficFilters'
17
17
 
18
18
  // Addon types for filtering
19
19
  export type AddonMode = 'show' | 'group' | 'hide'
@@ -271,8 +271,12 @@ function isSystemEndpoint(name: string, namespace: string | undefined, kind: str
271
271
  return true
272
272
  }
273
273
 
274
- // Cilium reserved identities (show up as External kind with reserved names)
275
- if (kind === 'External' && CILIUM_RESERVED_IDENTITIES.has(name)) {
274
+ // Cilium reserved identities: nodes and the host network arrive as Host;
275
+ // health, init and unmanaged endpoints carry no usable identity.
276
+ if (kind === 'Host') {
277
+ return true
278
+ }
279
+ if ((kind === 'External' || kind === 'Unknown') && CILIUM_RESERVED_IDENTITIES.has(name)) {
276
280
  return true
277
281
  }
278
282
 
@@ -330,10 +334,7 @@ function isSystemEndpoint(name: string, namespace: string | undefined, kind: str
330
334
  return false
331
335
  }
332
336
 
333
- // Helper to check if endpoint is external (case-insensitive)
334
- function isExternal(kind: string): boolean {
335
- return kind.toLowerCase() === 'external'
336
- }
337
+ const isExternal = isExternalKind
337
338
 
338
339
  interface TrafficViewProps {
339
340
  namespaces: string[]
@@ -988,14 +989,14 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
988
989
 
989
990
  flows.forEach(flow => {
990
991
  // Count source nodes
991
- if (flow.source.namespace && flow.source.kind.toLowerCase() !== 'external') {
992
+ if (flow.source.namespace && !isExternalKind(flow.source.kind)) {
992
993
  if (!nsCounts.has(flow.source.namespace)) {
993
994
  nsCounts.set(flow.source.namespace, new Set())
994
995
  }
995
996
  nsCounts.get(flow.source.namespace)!.add(flow.source.name)
996
997
  }
997
998
  // Count destination nodes
998
- if (flow.destination.namespace && flow.destination.kind.toLowerCase() !== 'external') {
999
+ if (flow.destination.namespace && !isExternalKind(flow.destination.kind)) {
999
1000
  if (!nsCounts.has(flow.destination.namespace)) {
1000
1001
  nsCounts.set(flow.destination.namespace, new Set())
1001
1002
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { matchesStatusRanges, bucketsFromCounts, bucketsFromStatus, isRateBasedSource, keepAvailable, effectiveThreshold, volumeUnit } from './trafficFilters'
2
+ import { matchesStatusRanges, bucketsFromCounts, bucketsFromStatus, isRateBasedSource, keepAvailable, effectiveThreshold, volumeUnit, isExternalKind, isPolicyDropReason } from './trafficFilters'
3
3
 
4
4
  describe('matchesStatusRanges', () => {
5
5
  it('does not filter when nothing is selected', () => {
@@ -101,3 +101,30 @@ describe('volumeUnit', () => {
101
101
  expect(volumeUnit(undefined)).toBe('connections')
102
102
  })
103
103
  })
104
+
105
+ describe('isExternalKind', () => {
106
+ it('places the world, nodes and unidentified endpoints outside the workloads', () => {
107
+ expect(isExternalKind('External')).toBe(true)
108
+ expect(isExternalKind('Host')).toBe(true)
109
+ expect(isExternalKind('Unknown')).toBe(true)
110
+ })
111
+ it('keeps pods and services inside', () => {
112
+ expect(isExternalKind('Pod')).toBe(false)
113
+ expect(isExternalKind('Service')).toBe(false)
114
+ })
115
+ })
116
+
117
+ describe('isPolicyDropReason', () => {
118
+ it('recognises both of Hubble\'s policy drop codes', () => {
119
+ expect(isPolicyDropReason('POLICY_DENIED', 0)).toBe(true)
120
+ expect(isPolicyDropReason('POLICY_DENY', 0)).toBe(true)
121
+ })
122
+ it('takes the plugin naming a policy as evidence even without a code', () => {
123
+ expect(isPolicyDropReason(undefined, 1)).toBe(true)
124
+ })
125
+ it('does not assume policy for other or missing reasons', () => {
126
+ expect(isPolicyDropReason('STALE_OR_UNROUTABLE_IP', 0)).toBe(false)
127
+ expect(isPolicyDropReason(undefined, 0)).toBe(false)
128
+ expect(isPolicyDropReason('', 0)).toBe(false)
129
+ })
130
+ })
@@ -115,3 +115,34 @@ export function volumeUnit(isRateBased: boolean | undefined): VolumeUnit {
115
115
  export function effectiveThreshold(value: number, chosenUnit: VolumeUnit, currentUnit: VolumeUnit): number {
116
116
  return chosenUnit === currentUnit ? value : 0
117
117
  }
118
+
119
+ /**
120
+ * Is this endpoint kind drawn outside the cluster's workloads?
121
+ *
122
+ * "External" is what every source reports for the world outside; Hubble also
123
+ * tells nodes (`Host`) and endpoints it could not identify (`Unknown`) apart,
124
+ * because a policy evaluation treats each differently. The graph does not:
125
+ * none of them is a workload, so they take the external node's place and
126
+ * styling, and stay out of namespace counts.
127
+ */
128
+ export function isExternalKind(kind: string): boolean {
129
+ const k = kind.toLowerCase()
130
+ return k === 'external' || k === 'host' || k === 'unknown'
131
+ }
132
+
133
+ /**
134
+ * Is a drop a NetworkPolicy question at all?
135
+ *
136
+ * Only on positive evidence: Hubble's two policy drop reasons — `POLICY_DENIED`
137
+ * (no rule allowed it) and `POLICY_DENY` (an explicit deny rule) — or the
138
+ * plugin naming the policy itself. A drop with no reported reason is not
139
+ * assumed to be one. The two codes are spelled differently enough that a
140
+ * substring test on either misses the other.
141
+ */
142
+ export function isPolicyDropReason(dropReasonDesc: string | undefined, deniedByCount: number): boolean {
143
+ // deniedByCount includes references withheld for permissions: the plugin
144
+ // still named a policy.
145
+ if (deniedByCount > 0) return true
146
+ const code = (dropReasonDesc ?? '').toUpperCase()
147
+ return code === 'POLICY_DENIED' || code === 'POLICY_DENY'
148
+ }
@@ -70,6 +70,8 @@ import {
70
70
  useCordonNode,
71
71
  useUncordonNode,
72
72
  useDrainNode,
73
+ useDrainPlan,
74
+ DrainPlanUnsupportedError,
73
75
  useCascadeDeletePreview,
74
76
  useResourceEvents,
75
77
  useResource,
@@ -81,7 +83,7 @@ import {
81
83
  import { PrometheusCharts, isPrometheusSupported } from '../resource/PrometheusCharts'
82
84
  import { PrometheusChartsGrid } from '../resource/PrometheusChartsGrid'
83
85
  import { RestartEventLane } from '../resource/RestartChart'
84
- import { RightsizingPanel, RightsizingStrip } from '../resource/RightsizingStrip'
86
+ import { RightsizingPanel } from '../resource/RightsizingStrip'
85
87
  import { WorkloadCostTab } from '../cost/WorkloadCostTab'
86
88
  import { isOpenCostWorkloadKind } from '../cost/kinds'
87
89
  import { useResourceAudit, useResourceIssues, useResources, useTrace, fetchTraceWithProbes, fetchInClusterCapability, runInClusterMerged } from '../../api/client'
@@ -381,6 +383,7 @@ function useActionsBarProps(
381
383
  const cordonMutation = useCordonNode()
382
384
  const uncordonMutation = useUncordonNode()
383
385
  const drainMutation = useDrainNode()
386
+ const drainPlanMutation = useDrainPlan()
384
387
 
385
388
  const { renderAction: renderDiagnose } = useDiagnoseCustomization()
386
389
 
@@ -485,6 +488,13 @@ function useActionsBarProps(
485
488
  onDrainNode: (params: Parameters<typeof drainMutation.mutate>[0]) =>
486
489
  drainMutation.mutate(params),
487
490
  isDrainingNode: drainMutation.isPending,
491
+ onPlanDrain: (params: Parameters<typeof drainPlanMutation.mutate>[0]) =>
492
+ drainPlanMutation.mutate(params),
493
+ onPlanDrainReset: () => drainPlanMutation.reset(),
494
+ drainPlan: drainPlanMutation.data ?? null,
495
+ isPlanningDrain: drainPlanMutation.isPending,
496
+ drainPlanError: drainPlanMutation.error?.message ?? null,
497
+ drainPlanUnsupported: drainPlanMutation.error instanceof DrainPlanUnsupportedError,
488
498
  }
489
499
  }
490
500
 
@@ -2411,16 +2421,9 @@ function MetricsTabContent({
2411
2421
  resource: any
2412
2422
  expanded: boolean
2413
2423
  }) {
2414
- const showRightsizing = expanded && ['Deployment', 'StatefulSet', 'DaemonSet'].includes(kind)
2415
-
2416
2424
  if (expanded) {
2417
2425
  return (
2418
2426
  <div className="flex flex-col h-full">
2419
- {showRightsizing && (
2420
- <div className="px-4 pt-4">
2421
- <RightsizingStrip kind={kind} namespace={namespace} name={name} />
2422
- </div>
2423
- )}
2424
2427
  <div className="flex-1 min-h-0">
2425
2428
  <PrometheusChartsGrid kind={kind} namespace={namespace} name={name} resource={resource} />
2426
2429
  </div>
@@ -2446,9 +2449,17 @@ function DrawerMetricsContent({
2446
2449
  }) {
2447
2450
  const [chartRange, setChartRange] = useState<import('../../api/client').PrometheusTimeRange>('1h')
2448
2451
  const showRestartLane = isPrometheusSupported(kind) && kind !== 'Node'
2452
+ const navigate = useNavigate()
2449
2453
 
2450
2454
  return (
2451
2455
  <div className="flex flex-col h-full">
2456
+ {['Deployment', 'StatefulSet', 'DaemonSet'].includes(kind) && (
2457
+ <div className="px-4 pt-3">
2458
+ <button className="text-xs text-accent hover:underline" onClick={() => navigate(`${buildWorkloadPath({ kind, namespace, name })}?tab=metrics&metricsRange=${chartRange}`)}>
2459
+ Open request and resource dashboard →
2460
+ </button>
2461
+ </div>
2462
+ )}
2452
2463
  <div className="flex-1 min-h-0">
2453
2464
  <PrometheusCharts
2454
2465
  kind={kind}
@@ -1,7 +1,7 @@
1
1
  import { createContext, useContext, useState, useCallback, useEffect, useRef, ReactNode } from 'react'
2
2
  import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
3
3
  import { getApiBase } from '../api/config'
4
- import { apiFetch } from '../api/client'
4
+ import { apiFetch, type SyncStatusSnapshot } from '../api/client'
5
5
 
6
6
  export type ConnectionStateType = 'connected' | 'disconnected' | 'connecting'
7
7
 
@@ -12,6 +12,10 @@ export interface ConnectionState {
12
12
  error?: string
13
13
  errorType?: string // config, auth, auth-rejected, auth-plugin-stuck, rbac, network, timeout, tls, unknown
14
14
  progressMessage?: string
15
+ // Per-kind readiness while the initial informer sync runs ('connecting'
16
+ // with informers started). Drives the progressive app shell — absent once
17
+ // connected or before informers exist.
18
+ syncStatus?: SyncStatusSnapshot
15
19
  }
16
20
 
17
21
  interface ConnectionStatusResponse extends ConnectionState {
@@ -157,7 +161,15 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
157
161
  if (!firstConnect || cacheWarmAtMountRef.current) {
158
162
  queryClient.invalidateQueries()
159
163
  } else {
160
- queryClient.invalidateQueries({ predicate: (q) => q.state.status === 'error' })
164
+ // Detail responses served during the progressive shell are partial —
165
+ // the server skips relationship computation while the topology cache
166
+ // is unavailable — and they resolve as successes, so the error-only
167
+ // sweep would leave an open drawer without its Related Resources
168
+ // forever (focus refetch is globally off). Refetching the resource
169
+ // detail keys is at most the open drawer, so it stays cheap.
170
+ queryClient.invalidateQueries({
171
+ predicate: (q) => q.state.status === 'error' || q.queryKey[0] === 'resource',
172
+ })
161
173
  }
162
174
  }, [queryClient])
163
175
 
@@ -195,12 +207,35 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
195
207
  // while the retry is still running; the retry's own result supersedes it.
196
208
  if (manualRetryPendingRef.current || autoRetryInFlightRef.current) return
197
209
  const current = connectionRef.current
210
+ // The progressive shell renders real resource lists while still
211
+ // 'connecting'. A query client shared across cluster mounts may hold
212
+ // another cluster's data under identical keys — drop it before the
213
+ // shell can show it (the 'connected' path repeats this for the
214
+ // full-refresh case). Deliberately ahead of the SSE-generation guard:
215
+ // live connection_state frames bump the generation and drop the poll,
216
+ // and the shell must not spend the whole sync on another cluster's rows.
217
+ if (current.state === 'connecting' && data.state === 'connecting' &&
218
+ data.syncStatus && cacheWarmAtMountRef.current) {
219
+ cacheWarmAtMountRef.current = false
220
+ // Drop only cluster-scoped data. App chrome (auth identity, kubeconfig
221
+ // contexts) is not another cluster's data, and purging it flips
222
+ // authMePending back on — which holds the whole shell on the splash
223
+ // until /auth/me refetches, delaying the first progressive paint.
224
+ const keep = new Set(['connection-status', 'auth-me', 'contexts'])
225
+ queryClient.removeQueries({ predicate: (q) => !keep.has(String(q.queryKey[0])) })
226
+ }
198
227
  if (!shouldApplyPolledConnection(
199
228
  current.state,
200
229
  data.state,
201
230
  data.sseGenerationAtStart,
202
231
  sseGenerationRef.current,
203
232
  )) {
233
+ // SSE owns the state fields, but connection_state frames don't carry
234
+ // per-kind sync progress — merge it from the poll so the progressive
235
+ // shell keeps ticking while still connecting.
236
+ if (data.state === 'connecting' && data.syncStatus) {
237
+ setConnection(prev => prev.state === 'connecting' ? { ...prev, syncStatus: data.syncStatus } : prev)
238
+ }
204
239
  return
205
240
  }
206
241
  const becameConnected = current.state !== 'connected' && data.state === 'connected'
@@ -211,11 +246,12 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
211
246
  error: data.error,
212
247
  errorType: data.errorType,
213
248
  progressMessage: data.progressMessage,
249
+ syncStatus: data.syncStatus,
214
250
  })
215
251
  if (becameConnected) {
216
252
  refreshCachesOnConnect()
217
253
  }
218
- }, [data, dataUpdatedAt, refreshCachesOnConnect])
254
+ }, [data, dataUpdatedAt, refreshCachesOnConnect, queryClient])
219
255
 
220
256
  // Retry mutation
221
257
  const retryMutation = useMutation({
@@ -231,6 +267,9 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
231
267
  state: 'connecting',
232
268
  error: undefined,
233
269
  progressMessage: 'Connecting to cluster...',
270
+ // A retry is a fresh attempt — per-kind progress from the previous
271
+ // attempt would mislabel readiness until the next poll.
272
+ syncStatus: undefined,
234
273
  }))
235
274
  },
236
275
  onSuccess: (result) => {
@@ -371,6 +410,12 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
371
410
  if (prev.state === 'connected' && status.state === 'connecting') {
372
411
  return prev
373
412
  }
413
+ // connection_state frames don't carry per-kind sync progress — keep the
414
+ // last polled snapshot while still connecting, or each SSE progress
415
+ // frame would flicker the progressive shell back to the splash.
416
+ if (status.state === 'connecting' && !status.syncStatus && prev.state === 'connecting') {
417
+ return { ...status, syncStatus: prev.syncStatus }
418
+ }
374
419
  return status
375
420
  })
376
421
 
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { renderToStaticMarkup } from 'react-dom/server';
3
+ import { DiagnoseCustomizationProvider, useDiagnoseCustomization } from './DiagnoseCustomization';
4
+ import type { RunSummary } from '../api/diagnose';
5
+
6
+ const run = { id: 'r1' } as RunSummary;
7
+ const onRunUpdated = vi.fn();
8
+ function Actions() {
9
+ const { renderRunActions } = useDiagnoseCustomization();
10
+ return <>{renderRunActions?.({ run, onRunUpdated })}</>;
11
+ }
12
+
13
+ describe('investigation action slot', () => {
14
+ it('has no default host actions in OSS', () => {
15
+ expect(renderToStaticMarkup(<Actions />)).toBe('');
16
+ });
17
+ it('passes the run and update callback to the host', () => {
18
+ const renderRunActions = vi.fn(() => <button>Host action</button>);
19
+ expect(renderToStaticMarkup(
20
+ <DiagnoseCustomizationProvider value={undefined} renderRunActions={renderRunActions}>
21
+ <Actions />
22
+ </DiagnoseCustomizationProvider>,
23
+ )).toContain('Host action');
24
+ expect(renderRunActions).toHaveBeenCalledWith({ run, onRunUpdated });
25
+ });
26
+ });
@@ -11,9 +11,19 @@
11
11
  // agent-free.
12
12
  import { createContext, useContext, useMemo } from "react";
13
13
  import type { ReactNode } from "react";
14
+ import type { RunSummary } from "../api/diagnose";
15
+
16
+ /** Optional host controls beside shared run actions. Return a component element
17
+ * if hooks are needed: this callback is invoked conditionally. Call onRunUpdated
18
+ * after a mutation to refresh the shared run summary. */
19
+ export type RenderInvestigationRunActions = (props: {
20
+ run: RunSummary;
21
+ onRunUpdated: (run: RunSummary) => void;
22
+ }) => ReactNode;
14
23
 
15
24
  /** Render prop for the resource-level Investigate action. */
16
25
  export type RenderDiagnoseAction = (ctx: {
26
+ /** Kubernetes Kind when loaded or discovered; otherwise its API resource name. */
17
27
  kind: string;
18
28
  /** Kubernetes API group; empty means core. */
19
29
  group?: string;
@@ -52,6 +62,7 @@ export type DiagnoseConsentCopy = {
52
62
  // set once at mount, so per-value re-render isolation buys nothing.
53
63
  export interface DiagnoseCustomization {
54
64
  renderAction: RenderDiagnoseAction | undefined;
65
+ renderRunActions?: RenderInvestigationRunActions;
55
66
  consentCopy: DiagnoseConsentCopy | undefined;
56
67
  // undefined = default (CustomEvent → Radar's own Settings dialog);
57
68
  // null = hide the settings affordances.
@@ -70,19 +81,21 @@ const DiagnoseCustomizationContext =
70
81
  export function DiagnoseCustomizationProvider({
71
82
  value,
72
83
  consentCopy,
84
+ renderRunActions,
73
85
  onOpenSettings,
74
86
  children,
75
87
  }: {
76
88
  value: RenderDiagnoseAction | undefined;
77
89
  consentCopy?: DiagnoseConsentCopy;
90
+ renderRunActions?: RenderInvestigationRunActions;
78
91
  /** Where "AI settings" affordances lead. Omit for Radar's own Settings
79
92
  * dialog; pass `null` to hide them. */
80
93
  onOpenSettings?: (() => void) | null;
81
94
  children: ReactNode;
82
95
  }) {
83
96
  const ctx = useMemo(
84
- () => ({ renderAction: value, consentCopy, onOpenSettings }),
85
- [value, consentCopy, onOpenSettings],
97
+ () => ({ renderAction: value, consentCopy, onOpenSettings, renderRunActions }),
98
+ [value, consentCopy, onOpenSettings, renderRunActions],
86
99
  );
87
100
  return (
88
101
  <DiagnoseCustomizationContext.Provider value={ctx}>
package/src/index.css CHANGED
@@ -21,6 +21,23 @@
21
21
  }
22
22
 
23
23
  @layer components {
24
+ .metrics-layout {
25
+ container-type: inline-size;
26
+ container-name: metrics;
27
+ }
28
+ .metrics-chart-grid {
29
+ display: grid;
30
+ grid-template-columns: minmax(0, 1fr);
31
+ gap: 0.75rem;
32
+ }
33
+ @container metrics (min-width: 760px) {
34
+ .metrics-chart-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
35
+ .metrics-chart-wide { grid-column: 1 / -1; }
36
+ }
37
+ @container metrics (min-width: 1200px) {
38
+ .metrics-chart-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
39
+ .metrics-chart-wide { grid-column: auto; }
40
+ }
24
41
  .investigation-assessment {
25
42
  background: var(--color-investigation-assessment);
26
43
  border-color: var(--color-investigation-assessment-border);
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ export type {
28
28
  } from './api/timelineSource';
29
29
  export type {
30
30
  RenderDiagnoseAction,
31
+ RenderInvestigationRunActions,
31
32
  DiagnoseConsentCopy,
32
33
  } from './context/DiagnoseCustomization';
33
34
 
@@ -14,7 +14,7 @@ export function searchHitToSelectedResource(hit: SearchHit): SelectedResource {
14
14
  }
15
15
 
16
16
  // Re-export shared navigation utilities from @skyhook-io/k8s-ui.
17
- export { kindToPlural, kindToPluralWithGroup, pluralToKind, refToSelectedResource, apiVersionToGroup } from '@skyhook-io/k8s-ui/utils/navigation'
17
+ export { kindToPlural, kindToPluralWithGroup, pluralToKind, knownKindForPluralWithGroup, refToSelectedResource, apiVersionToGroup } from '@skyhook-io/k8s-ui/utils/navigation'
18
18
  export type { NavigateToResource } from '@skyhook-io/k8s-ui/utils/navigation'
19
19
 
20
20
  const NETWORK_POLICY_TOPOLOGY_KINDS = new Set([
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
 
3
- import { allShellSafe, isShellSafeValue } from './shell-safe'
3
+ import { allShellSafe, isShellSafeAWSProfile, isShellSafeValue } from './shell-safe'
4
4
 
5
5
  describe('isShellSafeValue', () => {
6
6
  it('accepts the values real provider context names produce', () => {
@@ -53,3 +53,27 @@ describe('allShellSafe', () => {
53
53
  expect(allShellSafe('prod', null)).toBe(false)
54
54
  })
55
55
  })
56
+
57
+ describe('isShellSafeAWSProfile', () => {
58
+ it('accepts simple and slash-separated profile names', () => {
59
+ expect(isShellSafeAWSProfile('default')).toBe(true)
60
+ expect(isShellSafeAWSProfile('myorg/my-account/my-role')).toBe(true)
61
+ expect(isShellSafeAWSProfile('prod-account')).toBe(true)
62
+ })
63
+
64
+ it('rejects shell metacharacters', () => {
65
+ expect(isShellSafeAWSProfile('prod; id')).toBe(false)
66
+ expect(isShellSafeAWSProfile('prod$(id)')).toBe(false)
67
+ expect(isShellSafeAWSProfile('prod profile')).toBe(false)
68
+ })
69
+
70
+ it('rejects leading dash', () => {
71
+ expect(isShellSafeAWSProfile('-rf')).toBe(false)
72
+ })
73
+
74
+ it('rejects empty and absent values', () => {
75
+ expect(isShellSafeAWSProfile('')).toBe(false)
76
+ expect(isShellSafeAWSProfile(null)).toBe(false)
77
+ expect(isShellSafeAWSProfile(undefined)).toBe(false)
78
+ })
79
+ })
@@ -19,3 +19,19 @@ export function isShellSafeValue(value: string | null | undefined): value is str
19
19
  export function allShellSafe(...values: (string | null | undefined)[]): boolean {
20
20
  return values.every(isShellSafeValue)
21
21
  }
22
+
23
+ // AWS SSO profile names may contain `/` as a path separator
24
+ // (e.g. "myorg/my-account/my-role") — safe in shell arguments since `/`
25
+ // has no special meaning to the shell itself.
26
+ const SHELL_SAFE_AWS_PROFILE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/
27
+
28
+ export function isShellSafeAWSProfile(value: string | null | undefined): value is string {
29
+ return typeof value === 'string' && SHELL_SAFE_AWS_PROFILE.test(value)
30
+ }
31
+
32
+ // Returns ` --profile <name>` for embedding in an aws CLI hint, or '' when no
33
+ // profile is pinned or the name fails the allowlist — the hint then falls
34
+ // back to the ambient profile rather than offering nothing.
35
+ export function awsProfileFlag(profile: string | null | undefined): string {
36
+ return isShellSafeAWSProfile(profile) ? ` --profile ${profile}` : ''
37
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { TopologyNode } from '@skyhook-io/k8s-ui/types/core'
3
+ import { isInNamespaceScope, scopeNodesToNamespaces } from './topology-namespace'
4
+
5
+ function node(id: string, kind: string, namespace?: string): TopologyNode {
6
+ return {
7
+ id,
8
+ kind,
9
+ name: id,
10
+ status: 'healthy',
11
+ data: namespace === undefined ? {} : { namespace },
12
+ } as TopologyNode
13
+ }
14
+
15
+ const nodes = [
16
+ node('web', 'Deployment', 'prod'),
17
+ node('ing', 'Ingress', 'staging'),
18
+ node('db', 'StatefulSet', 'prod'),
19
+ node('node-1', 'Node'), // cluster-scoped
20
+ node('pv-1', 'PersistentVolume'), // cluster-scoped
21
+ ]
22
+
23
+ describe('scopeNodesToNamespaces', () => {
24
+ it('returns every node when no namespace is selected', () => {
25
+ expect(scopeNodesToNamespaces(nodes, [])).toHaveLength(5)
26
+ })
27
+
28
+ it('keeps only the selected namespace, plus cluster-scoped nodes', () => {
29
+ const scoped = scopeNodesToNamespaces(nodes, ['prod'])
30
+ expect(scoped.map(n => n.id)).toEqual(['web', 'db', 'node-1', 'pv-1'])
31
+ })
32
+
33
+ it('drops a kind that exists only outside the scope', () => {
34
+ const scoped = scopeNodesToNamespaces(nodes, ['prod'])
35
+ expect(scoped.some(n => n.kind === 'Ingress')).toBe(false)
36
+ })
37
+
38
+ it('accepts several namespaces at once', () => {
39
+ const scoped = scopeNodesToNamespaces(nodes, ['prod', 'staging'])
40
+ expect(scoped).toHaveLength(5)
41
+ })
42
+
43
+ it('returns the same array identity when the scope is empty', () => {
44
+ expect(scopeNodesToNamespaces(nodes, [])).toBe(nodes)
45
+ })
46
+ })
47
+
48
+ describe('isInNamespaceScope', () => {
49
+ it('passes everything for a null scope', () => {
50
+ expect(isInNamespaceScope(node('x', 'Pod', 'any'), null)).toBe(true)
51
+ })
52
+
53
+ it('always passes a node with no namespace', () => {
54
+ expect(isInNamespaceScope(node('node-1', 'Node'), new Set(['prod']))).toBe(true)
55
+ })
56
+
57
+ it('treats an empty-string namespace as cluster-scoped', () => {
58
+ expect(isInNamespaceScope(node('n', 'Node', ''), new Set(['prod']))).toBe(true)
59
+ })
60
+
61
+ it('rejects a namespaced node outside the scope', () => {
62
+ expect(isInNamespaceScope(node('ing', 'Ingress', 'staging'), new Set(['prod']))).toBe(false)
63
+ })
64
+ })
@@ -0,0 +1,18 @@
1
+ import type { TopologyNode } from '@skyhook-io/k8s-ui/types/core'
2
+
3
+ // A node with no namespace is cluster-scoped (Node, PersistentVolume,
4
+ // Namespace, ...) and stays visible in every scope.
5
+ export function isInNamespaceScope(node: TopologyNode, nsSet: Set<string> | null): boolean {
6
+ if (!nsSet) return true
7
+ const ns = node.data?.namespace as string | undefined
8
+ return !ns || nsSet.has(ns)
9
+ }
10
+
11
+ // Shared by the topology graph and the filter sidebar so the two cannot
12
+ // disagree about which nodes are in scope. Namespace carve-out only — kind
13
+ // filtering stays on the graph.
14
+ export function scopeNodesToNamespaces(nodes: TopologyNode[], namespaces: string[]): TopologyNode[] {
15
+ if (namespaces.length === 0) return nodes
16
+ const nsSet = new Set(namespaces)
17
+ return nodes.filter(node => isInNamespaceScope(node, nsSet))
18
+ }