@skyhook-io/radar-app 1.13.5 → 1.14.1

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 (242) hide show
  1. package/package.json +9 -9
  2. package/src/App.tsx +66 -28
  3. package/src/api/client.authRedirect.test.ts +12 -1
  4. package/src/api/client.prometheusStatus.test.ts +24 -0
  5. package/src/api/client.ts +324 -16
  6. package/src/api/diagnose.ts +32 -1
  7. package/src/api/drain-plan.test.ts +86 -0
  8. package/src/api/quotas.test.ts +26 -0
  9. package/src/api/quotas.ts +20 -0
  10. package/src/api/workloadMetrics.test.ts +40 -0
  11. package/src/api/workloadMetrics.ts +68 -0
  12. package/src/components/CloudConnectFlow.tsx +343 -46
  13. package/src/components/CloudFunnelButton.tsx +110 -37
  14. package/src/components/ConnectionErrorView.tsx +9 -10
  15. package/src/components/DebugOverlay.tsx +10 -6
  16. package/src/components/SyncProgressPanel.tsx +73 -0
  17. package/src/components/audit/AuditView.tsx +2 -0
  18. package/src/components/cloudConnectHandoff.test.ts +245 -0
  19. package/src/components/cloudConnectHandoff.ts +276 -0
  20. package/src/components/curl/ServiceCurlButton.tsx +19 -26
  21. package/src/components/diagnose/AgentCase.tsx +18 -5
  22. package/src/components/diagnose/AnalysisStory.test.tsx +308 -0
  23. package/src/components/diagnose/AnalysisStory.tsx +564 -0
  24. package/src/components/diagnose/DiagnoseContext.test.ts +10 -0
  25. package/src/components/diagnose/DiagnoseContext.tsx +26 -8
  26. package/src/components/diagnose/DiagnoseSurface.tsx +20 -14
  27. package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +771 -0
  28. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +8 -33
  29. package/src/components/diagnose/InvestigationEvidencePane.tsx +809 -168
  30. package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +30 -0
  31. package/src/components/diagnose/InvestigationResourceEvidence.tsx +20 -0
  32. package/src/components/diagnose/InvestigationView.tsx +493 -529
  33. package/src/components/diagnose/investigationCase.test.tsx +267 -129
  34. package/src/components/diagnose/investigationCase.ts +122 -77
  35. package/src/components/diagnose/investigationEvidence/adapters/changes.ts +60 -0
  36. package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +573 -0
  37. package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +935 -0
  38. package/src/components/diagnose/investigationEvidence/adapters/events.ts +96 -0
  39. package/src/components/diagnose/investigationEvidence/adapters/helm.test.ts +242 -0
  40. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +208 -0
  41. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +172 -0
  42. package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +283 -0
  43. package/src/components/diagnose/investigationEvidence/adapters/logs.ts +183 -0
  44. package/src/components/diagnose/investigationEvidence/adapters/permissions.test.ts +528 -0
  45. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +292 -0
  46. package/src/components/diagnose/investigationEvidence/adapters/prometheus.test.ts +1641 -0
  47. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +769 -0
  48. package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +81 -0
  49. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +285 -0
  50. package/src/components/diagnose/investigationEvidence/adapters/topology.ts +207 -0
  51. package/src/components/diagnose/investigationEvidence/builder.ts +427 -0
  52. package/src/components/diagnose/investigationEvidence/evidenceFixtures.ts +179 -0
  53. package/src/components/diagnose/investigationEvidence/identity.test.ts +632 -0
  54. package/src/components/diagnose/investigationEvidence/identity.ts +261 -0
  55. package/src/components/diagnose/investigationEvidence/index.ts +55 -0
  56. package/src/components/diagnose/investigationEvidence/observations.ts +978 -0
  57. package/src/components/diagnose/investigationEvidence/parse.ts +467 -0
  58. package/src/components/diagnose/investigationEvidence/projection.test.ts +2736 -0
  59. package/src/components/diagnose/investigationEvidence/types.ts +514 -0
  60. package/src/components/diagnose/investigationEvidencePresentation.test.ts +2 -0
  61. package/src/components/diagnose/investigationEvidencePresentation.ts +3 -0
  62. package/src/components/diagnose/investigationState.test.ts +316 -58
  63. package/src/components/diagnose/investigationState.ts +257 -44
  64. package/src/components/diagnose/investigationStory.test.ts +161 -0
  65. package/src/components/diagnose/investigationStory.ts +207 -0
  66. package/src/components/diagnose/parts.test.tsx +9 -6
  67. package/src/components/diagnose/parts.tsx +977 -193
  68. package/src/components/diagnose/storyParts.test.tsx +426 -0
  69. package/src/components/diagnose/target.test.ts +19 -0
  70. package/src/components/diagnose/target.ts +3 -2
  71. package/src/components/diagnose/toolCallLabel.test.ts +51 -0
  72. package/src/components/diagnose/toolCallLabel.ts +135 -0
  73. package/src/components/diagnose/useDisclosureReveal.ts +10 -11
  74. package/src/components/gitops/GitOpsView.tsx +7 -0
  75. package/src/components/gitops/RemoteDestinationCloudHint.tsx +26 -0
  76. package/src/components/helm/HelmCompareRoute.tsx +18 -4
  77. package/src/components/helm/HelmReleaseDrawer.tsx +11 -26
  78. package/src/components/helm/InstallWizard.tsx +8 -3
  79. package/src/components/home/MCPSetupDialog.tsx +15 -9
  80. package/src/components/portforward/PortForwardManager.tsx +6 -13
  81. package/src/components/resource/PrometheusCharts.overlays.test.ts +22 -0
  82. package/src/components/resource/PrometheusCharts.tsx +4 -3
  83. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +99 -0
  84. package/src/components/resource/PrometheusChartsGrid.tsx +75 -35
  85. package/src/components/resource/RightsizingStrip.test.ts +38 -0
  86. package/src/components/resource/RightsizingStrip.tsx +15 -3
  87. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +79 -0
  88. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +145 -0
  89. package/src/components/resource/WorkloadMetricsSection.render.test.tsx +351 -0
  90. package/src/components/resource/WorkloadMetricsSection.tsx +480 -0
  91. package/src/components/resource/workloadMetricValues.test.ts +65 -0
  92. package/src/components/resource/workloadMetricValues.ts +30 -0
  93. package/src/components/resources/PodFilePreview.tsx +3 -3
  94. package/src/components/resources/ResourcesView.tsx +44 -6
  95. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -1
  96. package/src/components/resources/renderers/PodRenderer.tsx +4 -0
  97. package/src/components/resources/renderers/RolloutRenderer.tsx +15 -1
  98. package/src/components/resources/renderers/ServiceRenderer.tsx +2 -1
  99. package/src/components/resources/renderers/WorkloadRenderer.tsx +3 -0
  100. package/src/components/rightsizing/RightsizingScanView.tsx +21 -0
  101. package/src/components/settings/SettingsDialog.tsx +98 -16
  102. package/src/components/traffic/TrafficFlowList.tsx +229 -70
  103. package/src/components/traffic/TrafficGraph.tsx +6 -6
  104. package/src/components/traffic/TrafficView.tsx +10 -9
  105. package/src/components/traffic/trafficFilters.test.ts +28 -1
  106. package/src/components/traffic/trafficFilters.ts +31 -0
  107. package/src/components/ui/CommandPalette.tsx +10 -6
  108. package/src/components/ui/DiagnosticsOverlay.tsx +10 -6
  109. package/src/components/ui/Disclosure.tsx +47 -0
  110. package/src/components/ui/Markdown.tsx +23 -11
  111. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -1
  112. package/src/components/ui/UpdateNotification.tsx +18 -2
  113. package/src/components/workload/WorkloadView.tsx +19 -8
  114. package/src/context/ConnectionContext.tsx +48 -3
  115. package/src/context/DiagnoseCustomization.tsx +1 -0
  116. package/src/index.css +36 -6
  117. package/src/utils/navigation.ts +1 -1
  118. package/src/utils/topology-namespace.test.ts +64 -0
  119. package/src/utils/topology-namespace.ts +18 -0
  120. package/src/components/diagnose/investigationEvidence.test.ts +0 -6617
  121. package/src/components/diagnose/investigationEvidence.ts +0 -5572
  122. package/src/components/gitops/GitOpsActions.tsx +0 -1
  123. package/src/components/gitops/GitOpsStatusBadge.tsx +0 -1
  124. package/src/components/gitops/ManagedResourcesList.tsx +0 -1
  125. package/src/components/gitops/SyncCountdown.tsx +0 -1
  126. package/src/components/gitops/index.ts +0 -4
  127. package/src/components/helm/ManifestDiffViewer.tsx +0 -69
  128. package/src/components/logs/JsonLogLine.tsx +0 -1
  129. package/src/components/logs/LogCore.tsx +0 -2
  130. package/src/components/logs/useLogBuffer.ts +0 -2
  131. package/src/components/logs/useLogSearch.ts +0 -1
  132. package/src/components/resource-drawer/ResourceDrawer.tsx +0 -216
  133. package/src/components/resources/drawer-components.tsx +0 -1
  134. package/src/components/resources/renderers/AlertRenderer.tsx +0 -1
  135. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +0 -1
  136. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +0 -1
  137. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +0 -1
  138. package/src/components/resources/renderers/CertificateRenderer.tsx +0 -1
  139. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +0 -1
  140. package/src/components/resources/renderers/ChallengeRenderer.tsx +0 -1
  141. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +0 -1
  142. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +0 -1
  143. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +0 -1
  144. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +0 -1
  145. package/src/components/resources/renderers/ConfigMapRenderer.tsx +0 -1
  146. package/src/components/resources/renderers/CronJobRenderer.tsx +0 -1
  147. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +0 -1
  148. package/src/components/resources/renderers/EventRenderer.tsx +0 -1
  149. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +0 -1
  150. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +0 -1
  151. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +0 -1
  152. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +0 -1
  153. package/src/components/resources/renderers/GatewayClassRenderer.tsx +0 -1
  154. package/src/components/resources/renderers/GatewayRenderer.tsx +0 -1
  155. package/src/components/resources/renderers/GenericRenderer.tsx +0 -1
  156. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +0 -1
  157. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +0 -1
  158. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +0 -1
  159. package/src/components/resources/renderers/IngressClassRenderer.tsx +0 -1
  160. package/src/components/resources/renderers/IngressRenderer.tsx +0 -1
  161. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +0 -1
  162. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +0 -1
  163. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +0 -1
  164. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +0 -1
  165. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +0 -1
  166. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +0 -1
  167. package/src/components/resources/renderers/JobRenderer.tsx +0 -1
  168. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +0 -1
  169. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +0 -1
  170. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +0 -1
  171. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +0 -1
  172. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +0 -1
  173. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +0 -1
  174. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +0 -1
  175. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +0 -1
  176. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +0 -1
  177. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +0 -1
  178. package/src/components/resources/renderers/KnativeRouteRenderer.tsx +0 -1
  179. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +0 -1
  180. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +0 -1
  181. package/src/components/resources/renderers/KustomizationRenderer.tsx +0 -1
  182. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +0 -1
  183. package/src/components/resources/renderers/LeaseRenderer.tsx +0 -1
  184. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +0 -1
  185. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +0 -1
  186. package/src/components/resources/renderers/OrderRenderer.tsx +0 -1
  187. package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +0 -1
  188. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +0 -1
  189. package/src/components/resources/renderers/PodMonitorRenderer.tsx +0 -1
  190. package/src/components/resources/renderers/PriorityClassRenderer.tsx +0 -1
  191. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +0 -1
  192. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +0 -1
  193. package/src/components/resources/renderers/RuntimeClassRenderer.tsx +0 -1
  194. package/src/components/resources/renderers/SbomReportRenderer.tsx +0 -1
  195. package/src/components/resources/renderers/SealedSecretRenderer.tsx +0 -1
  196. package/src/components/resources/renderers/SecretRenderer.tsx +0 -1
  197. package/src/components/resources/renderers/SecretStoreRenderer.tsx +0 -1
  198. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +0 -1
  199. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +0 -1
  200. package/src/components/resources/renderers/StorageClassRenderer.tsx +0 -1
  201. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +0 -1
  202. package/src/components/resources/renderers/VPARenderer.tsx +0 -1
  203. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +0 -1
  204. package/src/components/resources/renderers/VeleroVSLRenderer.tsx +0 -1
  205. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +0 -1
  206. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +0 -1
  207. package/src/components/resources/renderers/WorkflowRenderer.tsx +0 -1
  208. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +0 -1
  209. package/src/components/resources/renderers/argo-cells.tsx +0 -1
  210. package/src/components/resources/renderers/certmanager-cells.tsx +0 -1
  211. package/src/components/resources/renderers/cnpg-cells.tsx +0 -1
  212. package/src/components/resources/renderers/eso-cells.tsx +0 -1
  213. package/src/components/resources/renderers/flux-cells.tsx +0 -1
  214. package/src/components/resources/renderers/index.ts +0 -94
  215. package/src/components/resources/renderers/istio-cells.tsx +0 -1
  216. package/src/components/resources/renderers/karpenter-cells.tsx +0 -1
  217. package/src/components/resources/renderers/keda-cells.tsx +0 -1
  218. package/src/components/resources/renderers/knative-cells.tsx +0 -1
  219. package/src/components/resources/renderers/kyverno-cells.tsx +0 -1
  220. package/src/components/resources/renderers/prometheus-cells.tsx +0 -1
  221. package/src/components/resources/renderers/traefik-cells.tsx +0 -1
  222. package/src/components/resources/renderers/trivy-cells.tsx +0 -1
  223. package/src/components/resources/renderers/trivy-shared.tsx +0 -1
  224. package/src/components/resources/renderers/velero-cells.tsx +0 -1
  225. package/src/components/resources/resource-utils-argo.ts +0 -2
  226. package/src/components/resources/resource-utils-certmanager.ts +0 -2
  227. package/src/components/resources/resource-utils-eso.ts +0 -2
  228. package/src/components/resources/resource-utils-flux.ts +0 -2
  229. package/src/components/resources/resource-utils-istio.ts +0 -2
  230. package/src/components/resources/resource-utils-karpenter.ts +0 -2
  231. package/src/components/resources/resource-utils-knative.ts +0 -2
  232. package/src/components/resources/resource-utils-kyverno.ts +0 -2
  233. package/src/components/resources/resource-utils-prometheus.ts +0 -2
  234. package/src/components/resources/resource-utils-traefik.ts +0 -1
  235. package/src/components/resources/resource-utils-trivy.ts +0 -2
  236. package/src/components/resources/resource-utils-velero.ts +0 -2
  237. package/src/components/shared/EditableYamlView.tsx +0 -24
  238. package/src/components/timeline/DiffViewer.tsx +0 -1
  239. package/src/components/traffic/index.ts +0 -3
  240. package/src/components/ui/ForceDeleteConfirmDialog.tsx +0 -1
  241. package/src/components/ui/MetricsChart.tsx +0 -1
  242. package/src/components/ui/ResourceBar.tsx +0 -1
@@ -1,11 +1,13 @@
1
1
  import { useState, useMemo } from 'react'
2
2
  import { Virtuoso } from 'react-virtuoso'
3
- import type { TrafficFlow } from '../../types'
3
+ import type { TrafficFlow, TrafficEndpoint } from '../../types'
4
4
  import { clsx } from 'clsx'
5
5
  import { ChevronDown, ChevronUp, ShieldCheck } from 'lucide-react'
6
6
  import { SEVERITY_BADGE, SEVERITY_TEXT } from '@skyhook-io/k8s-ui/utils/badge-colors'
7
- import { pluralize } from '@skyhook-io/k8s-ui'
7
+ import { pluralize, StatusDot } from '@skyhook-io/k8s-ui'
8
+ import type { StatusTone } from '@skyhook-io/k8s-ui'
8
9
  import { useFlowSearch } from './TrafficFlowListContext'
10
+ import { isPolicyDropReason } from './trafficFilters'
9
11
  import { useQuery } from '@tanstack/react-query'
10
12
  import { fetchJSON } from '../../api/client'
11
13
  import { Tooltip } from '../ui/Tooltip'
@@ -250,14 +252,14 @@ export function TrafficFlowList({ flows }: TrafficFlowListProps) {
250
252
  <div className="grid grid-cols-2 gap-x-6 gap-y-1">
251
253
  <div>
252
254
  <span className="text-theme-text-tertiary">Source: </span>
253
- <span className="text-theme-text-primary">{flow.source.name}</span>
254
- {flow.source.namespace && <span className="text-theme-text-tertiary"> ({flow.source.namespace})</span>}
255
+ <span className="text-theme-text-primary">{endpointLabel(flow.source)}</span>
256
+ {endpointNote(flow.source) && <span className="text-theme-text-tertiary"> ({endpointNote(flow.source)})</span>}
255
257
  {flow.sourceService && <span className="text-theme-text-tertiary"> via {flow.sourceService}</span>}
256
258
  </div>
257
259
  <div>
258
260
  <span className="text-theme-text-tertiary">Destination: </span>
259
- <span className="text-theme-text-primary">{flow.destination.name}</span>
260
- {flow.destination.namespace && <span className="text-theme-text-tertiary"> ({flow.destination.namespace})</span>}
261
+ <span className="text-theme-text-primary">{endpointLabel(flow.destination)}</span>
262
+ {endpointNote(flow.destination) && <span className="text-theme-text-tertiary"> ({endpointNote(flow.destination)})</span>}
261
263
  {flow.destService && <span className="text-theme-text-tertiary"> via {flow.destService}</span>}
262
264
  </div>
263
265
  <div>
@@ -334,7 +336,8 @@ export function TrafficFlowList({ flows }: TrafficFlowListProps) {
334
336
  {/* Drop reason + policy correlation */}
335
337
  {flow.dropReasonDesc && (
336
338
  <div className="pt-1 border-t border-theme-border/50">
337
- <span className={SEVERITY_TEXT.error}>Drop reason: {flow.dropReasonDesc}</span>
339
+ <span className={SEVERITY_TEXT.error}>Dropped: {describeDropReason(flow.dropReasonDesc)}</span>
340
+ <span className="text-theme-text-tertiary font-mono text-[10px]"> · {flow.dropReasonDesc}</span>
338
341
  </div>
339
342
  )}
340
343
  {flow.verdict === 'dropped' && (
@@ -357,88 +360,244 @@ export function TrafficFlowList({ flows }: TrafficFlowListProps) {
357
360
  )
358
361
  }
359
362
 
363
+ type PolicyVerdictWord = 'admitted' | 'denied' | 'undecidable' | 'no-policy'
364
+
360
365
  interface PolicyEvaluation {
361
366
  selectingPolicies: { name: string; namespace?: string; kind: string; effect: string; reason: string }[]
362
- verdict: string
367
+ verdict: PolicyVerdictWord
368
+ reason?: string
369
+ /** What Kubernetes NetworkPolicies alone said, when the verdict was capped
370
+ * because policies of another kind also govern the pod. */
371
+ coreVerdict?: PolicyVerdictWord
372
+ evaluated?: { direction?: string; pod?: string; peer?: string; port?: number; protocol?: string }
373
+ }
374
+
375
+ function isPolicyDrop(flow: TrafficFlow): boolean {
376
+ return isPolicyDropReason(flow.dropReasonDesc, (flow.policyVerdict?.deniedBy?.length ?? 0) + (flow.policyVerdict?.withheld ?? 0))
377
+ }
378
+
379
+ const EFFECT_TONE: Record<string, StatusTone> = {
380
+ admits: 'healthy',
381
+ does_not_admit: 'neutral',
382
+ undecidable: 'unknown',
383
+ }
384
+
385
+ const EFFECT_LABEL: Record<string, string> = {
386
+ admits: 'allows this traffic',
387
+ does_not_admit: 'no matching allow rule',
388
+ undecidable: "can't evaluate",
389
+ }
390
+
391
+ /** Plain words for the drop reasons an operator meets most; anything else is
392
+ * the code itself, humanized. The code stays beside it either way. */
393
+ const DROP_REASON_TEXT: Record<string, string> = {
394
+ POLICY_DENIED: 'blocked by network policy — no rule allowed it',
395
+ POLICY_DENY: 'blocked by an explicit deny rule',
396
+ AUTH_REQUIRED: 'mutual authentication required and not established',
397
+ NO_CONFIGURATION_AVAILABLE_TO_PERFORM_POLICY_DECISION: 'policy not yet loaded for this endpoint',
398
+ STALE_OR_UNROUTABLE_IP: 'no route to the destination address',
399
+ UNSUPPORTED_L3_PROTOCOL: 'unsupported network protocol',
400
+ INVALID_SOURCE_IP: 'the source address is not valid here',
401
+ NO_TUNNEL_OR_ENCAPSULATION_ENDPOINT: 'no tunnel endpoint for the destination node',
402
+ }
403
+
404
+ function describeDropReason(code: string | undefined): string {
405
+ if (!code) return 'reason not reported'
406
+ return DROP_REASON_TEXT[code] ?? code.toLowerCase().replace(/_/g, ' ')
407
+ }
408
+
409
+ /** A pod is named; anything else is best identified by the address the user
410
+ * can act on, with the classification as the note. */
411
+ function endpointLabel(ep: TrafficEndpoint): string {
412
+ if (ep.kind === 'Pod' || ep.kind === 'Service' || !ep.ip) return ep.name
413
+ return ep.ip
414
+ }
415
+
416
+ function endpointNote(ep: TrafficEndpoint): string {
417
+ if (ep.kind === 'Pod' || ep.kind === 'Service') return ep.namespace ?? ''
418
+ if (ep.kind === 'External') return 'outside the cluster'
419
+ if (ep.kind === 'Host') return ep.name === 'kube-apiserver' ? 'the API server' : 'a node or the host network'
420
+ return ep.ip ? 'not identified' : ''
421
+ }
422
+
423
+ function policyRef(p: { kind: string; namespace?: string; name: string }): string {
424
+ return p.namespace ? `${p.namespace}/${p.name}` : p.name
363
425
  }
364
426
 
365
427
  function PolicyCorrelation({ flow }: { flow: TrafficFlow }) {
366
- const destLabels = flow.destination?.labels
367
- const srcLabels = flow.source?.labels
368
- const destNs = flow.destination?.namespace || ''
369
- const destName = flow.destination?.name || ''
370
- const srcNs = flow.source?.namespace || ''
371
- const srcName = flow.source?.name || ''
372
-
373
- const labelsParam = destLabels ? Object.entries(destLabels).map(([k, v]) => `${k}=${v}`).join(',') : ''
374
- const srcLabelsParam = srcLabels ? Object.entries(srcLabels).map(([k, v]) => `${k}=${v}`).join(',') : ''
375
-
376
- const direction = flow.trafficDirection || 'ingress'
377
- // For egress: the evaluated pod is the source. For ingress: the destination.
378
- const evalNs = direction === 'egress' ? srcNs : destNs
379
- const evalName = direction === 'egress' ? srcName : destName
380
- const evalLabels = direction === 'egress' ? srcLabelsParam : labelsParam
381
-
382
- // Need either labels or pod name to resolve the evaluated pod
383
- const canQuery = !!evalNs && (!!evalLabels || !!evalName)
384
-
385
- const { data, isLoading, isError } = useQuery<PolicyEvaluation>({
386
- queryKey: ['policy-evaluate', destNs, destName, labelsParam, srcNs, srcName, srcLabelsParam, direction],
428
+ const src = flow.source
429
+ const dst = flow.destination
430
+ const direction = flow.trafficDirection === 'ingress' || flow.trafficDirection === 'egress' ? flow.trafficDirection : ''
431
+ const evaluated = direction === 'egress' ? src : dst
432
+ const evaluatedRef = evaluated ? `${evaluated.namespace}/${evaluated.name}` : ''
433
+ const intoOutOf = direction === 'egress' ? 'out of' : 'into'
434
+ const observed = flow.lastSeen ? new Date(flow.lastSeen).toLocaleTimeString() : ''
435
+
436
+ // Only a pod has policies applied to it; a drop at a node or an external
437
+ // endpoint is not a NetworkPolicy question.
438
+ const canQuery = isPolicyDrop(flow) && (!direction || (evaluated?.kind === 'Pod' && !!evaluated.namespace && !!evaluated.name))
439
+
440
+ const { data, isLoading, error, dataUpdatedAt } = useQuery<PolicyEvaluation, Error>({
441
+ queryKey: ['policy-evaluate', direction, dst?.namespace, dst?.name, dst?.kind, dst?.ip, src?.namespace, src?.name, src?.kind, src?.ip, flow.port, flow.protocol],
387
442
  queryFn: () => {
388
- const params = new URLSearchParams({ namespace: destNs })
389
- if (labelsParam) params.set('labels', labelsParam)
390
- if (!labelsParam && destName) params.set('podName', destName)
391
- if (srcNs) params.set('sourceNamespace', srcNs)
392
- if (srcLabelsParam) params.set('sourceLabels', srcLabelsParam)
393
- else if (srcName && srcNs) params.set('sourcePodName', srcName)
394
- if (direction === 'egress') params.set('direction', 'egress')
443
+ const params = new URLSearchParams()
444
+ if (direction) params.set('direction', direction)
445
+ if (dst?.namespace) params.set('namespace', dst.namespace)
446
+ if (dst?.name) params.set('podName', dst.name)
447
+ if (dst?.kind) params.set('destinationKind', dst.kind)
448
+ if (dst?.ip) params.set('destinationIP', dst.ip)
449
+ if (src?.namespace) params.set('sourceNamespace', src.namespace)
450
+ if (src?.name) params.set('sourcePodName', src.name)
451
+ if (src?.kind) params.set('sourceKind', src.kind)
452
+ if (src?.ip) params.set('sourceIP', src.ip)
453
+ if (flow.port) params.set('port', String(flow.port))
454
+ if (flow.protocol) params.set('protocol', flow.protocol)
395
455
  return fetchJSON(`/network-policies/evaluate?${params}`)
396
456
  },
397
457
  enabled: canQuery,
398
458
  staleTime: 30000,
459
+ // "As they are now" has to stay true while the row is open.
460
+ refetchInterval: 30000,
399
461
  })
462
+ const checkedAt = dataUpdatedAt ? new Date(dataUpdatedAt).toLocaleTimeString() : ''
463
+
464
+ const hubble = flow.policyVerdict
465
+ const hubbleDenied = hubble?.deniedBy ?? []
466
+ const hubbleAllowed = hubble?.allowedBy ?? []
467
+ const withheld = hubble?.withheld ?? 0
468
+
469
+ if (!isPolicyDrop(flow)) {
470
+ return (
471
+ <div className="pt-1 border-t border-theme-border/50 text-[10px] text-theme-text-tertiary">
472
+ {flow.dropReasonDesc
473
+ ? `Radar checks NetworkPolicies only for drops the plugin reports as policy drops; this one was reported as ${flow.dropReasonDesc}`
474
+ : "The plugin did not report why this flow was dropped, so Radar can't tell whether a policy was involved"}
475
+ </div>
476
+ )
477
+ }
400
478
 
401
- if (!canQuery) return null
402
- if (isLoading) return (
403
- <div className="pt-1 border-t border-theme-border/50 text-theme-text-tertiary text-[10px]">
404
- Evaluating policies...
405
- </div>
406
- )
407
- if (isError) return (
408
- <div className="pt-1 border-t border-theme-border/50 text-theme-text-tertiary text-[10px]">
409
- Unable to evaluate policies
410
- </div>
411
- )
412
- if (!data || !data.selectingPolicies || data.selectingPolicies.length === 0) return (
413
- <div className="pt-1 border-t border-theme-border/50">
414
- <div className="flex items-center gap-1 text-theme-text-tertiary">
415
- <ShieldCheck className="w-3 h-3" />
416
- <span className="text-[10px]">No NetworkPolicy selects this destination</span>
479
+ // What the plugin recorded when the packet was dropped is the ground
480
+ // truth; the current-policy check below is a reading of the policies as
481
+ // they are now, and is presented as a note under it rather than as a
482
+ // second verdict.
483
+ //
484
+ // Only a named deny is the account of a drop. Hubble can also record an
485
+ // allow on a dropped flow — at another layer (L3 allowed, then dropped at
486
+ // L7) or at the other end — and that is not why the packet was dropped.
487
+ const attribution = hubbleDenied.length > 0
488
+ ? { text: `Blocked by ${hubbleDenied.map((p) => `${p.kind} ${policyRef(p)}`).join(', ')}`, tone: 'unhealthy' as StatusTone }
489
+ : withheld > 0
490
+ ? { text: `Blocked by ${withheld === 1 ? 'a policy' : `${withheld} policies`} you don't have permission to read`, tone: 'unhealthy' as StatusTone }
491
+ : null
492
+ const alsoAllowed = hubbleAllowed.length > 0
493
+ ? `The plugin also recorded ${hubbleAllowed.map((p) => `${p.kind} ${policyRef(p)}`).join(', ')} allowing this flow at another layer or at the other end — not the reason for the drop.`
494
+ : ''
495
+
496
+ if (!canQuery && !attribution) {
497
+ return (
498
+ <div className="pt-1 border-t border-theme-border/50 text-[10px] text-theme-text-tertiary">
499
+ {evaluated?.kind && evaluated.kind !== 'Pod'
500
+ ? `NetworkPolicies apply to pods, and the ${direction === 'egress' ? 'source' : 'destination'} here is ${endpointNote(evaluated) || 'not a pod'}`
501
+ : 'Not enough is known about this flow to check policies'}
417
502
  </div>
418
- </div>
419
- )
503
+ )
504
+ }
505
+
506
+ // The current check, scoped to what it can establish: which policies
507
+ // allow this traffic at the evaluated pod, now. Never "the connection
508
+ // works" — the flow was dropped, and policies may have changed since.
509
+ // When another policy kind governs the pod, the Kubernetes reading is
510
+ // still worth stating — as a reading, not a verdict.
511
+ const v = data?.verdict
512
+ const core = data?.coreVerdict
513
+ const capped = v === 'undecidable' && !!core && core !== 'undecidable'
514
+ const current = v === 'admitted'
515
+ ? { text: `A current NetworkPolicy allows this traffic ${intoOutOf} ${evaluatedRef}`, tone: 'healthy' as StatusTone,
516
+ note: "Policies may have changed since the drop; this doesn't confirm a new connection will succeed." }
517
+ : v === 'denied'
518
+ ? { text: `No current NetworkPolicy allows this traffic ${intoOutOf} ${evaluatedRef}`, tone: 'unhealthy' as StatusTone, note: '' }
519
+ : v === 'no-policy'
520
+ ? { text: `No NetworkPolicy selects ${evaluatedRef} for ${direction === 'egress' ? 'outgoing' : 'incoming'} traffic`, tone: 'neutral' as StatusTone, note: '' }
521
+ : capped
522
+ ? { text: core === 'admitted'
523
+ ? `Kubernetes NetworkPolicies alone would allow this traffic ${intoOutOf} ${evaluatedRef} — but another policy kind also applies and was not evaluated`
524
+ : core === 'denied'
525
+ ? `No Kubernetes NetworkPolicy allows this traffic ${intoOutOf} ${evaluatedRef} — but another policy kind also applies and could`
526
+ : `No Kubernetes NetworkPolicy selects ${evaluatedRef} — but another policy kind applies and was not evaluated`,
527
+ tone: 'unknown' as StatusTone, note: '' }
528
+ : v === 'undecidable'
529
+ ? { text: "Radar can't complete the current NetworkPolicy check", tone: 'unknown' as StatusTone, note: '' }
530
+ : null
531
+ const peerNote = data?.evaluated?.peer && data.evaluated.peer.includes('(') ? `Peer: ${data.evaluated.peer}.` : ''
532
+ const currentNote = [current?.note, data?.reason, peerNote].filter(Boolean).join(' ')
533
+ const checkedText = checkedAt ? ` · checked ${checkedAt}` : ''
534
+
535
+ const rows = data?.selectingPolicies ?? []
536
+ const explainAdditive = rows.length > 1 && rows.some((r) => r.effect === 'does_not_admit')
420
537
 
421
538
  return (
422
539
  <div className="pt-1 border-t border-theme-border/50 space-y-1">
423
- <div className="flex items-center gap-1 text-theme-text-secondary">
424
- <ShieldCheck className="w-3 h-3" />
425
- <span className="text-[10px] font-medium">
426
- {data.selectingPolicies.length} selecting {data.selectingPolicies.length === 1 ? 'policy' : 'policies'}
427
- </span>
428
- </div>
429
- {data.selectingPolicies.map((p, i) => (
430
- <div key={i} className="flex items-start gap-1.5 ml-4">
431
- <span className={clsx(
432
- 'shrink-0 mt-0.5 w-1.5 h-1.5 rounded-full',
433
- p.effect === 'allow' ? 'bg-green-500' : p.effect === 'unknown' ? 'bg-yellow-500' : 'bg-red-500',
434
- )} />
435
- <div className="min-w-0">
436
- <span className="text-[10px] text-theme-text-primary font-medium">{p.name}</span>
437
- <span className="text-[10px] text-theme-text-tertiary ml-1">({p.kind})</span>
438
- <div className="text-[10px] text-theme-text-tertiary">{p.reason}</div>
540
+ {attribution && (
541
+ <div className="flex items-start gap-1.5">
542
+ <StatusDot tone={attribution.tone} className="mt-1 shrink-0" />
543
+ <div className="min-w-0 text-[10px]">
544
+ <span className="text-theme-text-primary font-medium">{attribution.text}</span>
545
+ <span className="text-theme-text-tertiary"> · reported by the network plugin when the packet was dropped</span>
546
+ </div>
547
+ </div>
548
+ )}
549
+ {alsoAllowed && (
550
+ <div className={clsx('text-[10px] text-theme-text-tertiary', attribution && 'ml-4')}>{alsoAllowed}</div>
551
+ )}
552
+ {canQuery && isLoading && (
553
+ <div className="text-[10px] text-theme-text-tertiary">Checking current NetworkPolicies…</div>
554
+ )}
555
+ {canQuery && error && (
556
+ <div className="text-[10px] text-theme-text-tertiary">Couldn't check current NetworkPolicies: {error.message}</div>
557
+ )}
558
+ {current && (attribution ? (
559
+ <div className="ml-4 text-[10px]">
560
+ <span className="text-theme-text-secondary">Current check: </span>
561
+ <span className="text-theme-text-primary">{current.text}</span>
562
+ <span className="text-theme-text-tertiary">{observed ? ` · flow seen ${observed}` : ''}{checkedText}</span>
563
+ {currentNote && <div className="text-theme-text-tertiary">{currentNote}</div>}
564
+ </div>
565
+ ) : (
566
+ <div className="flex items-start gap-1.5">
567
+ <StatusDot tone={current.tone} className="mt-1 shrink-0" />
568
+ <div className="min-w-0 text-[10px]">
569
+ <span className="text-theme-text-primary font-medium">{current.text}</span>
570
+ <span className="text-theme-text-tertiary"> · against policies as they are now{observed ? `, flow seen ${observed}` : ''}{checkedText}</span>
571
+ {currentNote && <div className="text-theme-text-tertiary">{currentNote}</div>}
439
572
  </div>
440
573
  </div>
441
574
  ))}
575
+ {rows.length > 0 && (
576
+ <div className="ml-4 space-y-1">
577
+ <div className="flex items-center gap-1 text-theme-text-secondary">
578
+ <ShieldCheck className="w-3 h-3" />
579
+ <span className="text-[10px] font-medium">
580
+ {rows.length} {rows.length === 1 ? 'policy applies' : 'policies apply'} to {evaluatedRef}
581
+ </span>
582
+ </div>
583
+ {explainAdditive && (
584
+ <div className="text-[10px] text-theme-text-tertiary">
585
+ NetworkPolicies combine their allow rules: a policy with no matching rule doesn't override another policy's allow.
586
+ </div>
587
+ )}
588
+ {rows.map((p, i) => (
589
+ <div key={i} className="flex items-start gap-1.5">
590
+ <StatusDot tone={EFFECT_TONE[p.effect] ?? 'unknown'} className="mt-1 shrink-0" />
591
+ <div className="min-w-0">
592
+ <span className="text-[10px] text-theme-text-primary font-medium">{p.name}</span>
593
+ <span className="text-[10px] text-theme-text-tertiary ml-1">({p.kind})</span>
594
+ <span className="text-[10px] text-theme-text-secondary ml-1">— {EFFECT_LABEL[p.effect] ?? p.effect}</span>
595
+ <div className="text-[10px] text-theme-text-tertiary">{p.reason}</div>
596
+ </div>
597
+ </div>
598
+ ))}
599
+ </div>
600
+ )}
442
601
  </div>
443
602
  )
444
603
  }
@@ -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
+ }
@@ -1,5 +1,5 @@
1
1
  import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
2
- import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
2
+ import { TRANSITION_BACKDROP, TRANSITION_PANEL, overlayTransitionStyle } from '../../utils/animation'
3
3
  import { Search, X, ChevronRight } from 'lucide-react'
4
4
  import { clsx } from 'clsx'
5
5
  import { useCommandItems, bestScore, type CommandItem, type CommandItemCallbacks } from './command-items'
@@ -130,15 +130,19 @@ export function CommandPalette({ onClose, isOpen = true, ...callbacks }: Command
130
130
  TRANSITION_BACKDROP,
131
131
  isOpen ? 'opacity-100' : 'opacity-0'
132
132
  )}
133
+ style={overlayTransitionStyle(isOpen, 'dialog')}
133
134
  onClick={onClose}
134
135
  />
135
136
 
136
137
  {/* Panel */}
137
- <div className={clsx(
138
- 'relative w-full max-w-lg mx-4 dialog overflow-hidden',
139
- TRANSITION_PANEL,
140
- isOpen ? 'opacity-100 scale-100 translate-y-0' : 'opacity-0 scale-[0.97] translate-y-3'
141
- )}>
138
+ <div
139
+ className={clsx(
140
+ 'relative w-full max-w-lg mx-4 dialog overflow-hidden',
141
+ TRANSITION_PANEL,
142
+ isOpen ? 'opacity-100 scale-100 translate-y-0' : 'opacity-0 scale-[0.97] translate-y-3'
143
+ )}
144
+ style={overlayTransitionStyle(isOpen, 'dialog')}
145
+ >
142
146
  {/* Search input */}
143
147
  <div className="flex items-center gap-3 px-4 py-3 border-b border-theme-border">
144
148
  <Search className="w-5 h-5 text-theme-text-secondary shrink-0" />
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useState, useCallback } from 'react'
2
2
  import { X, Copy, Check, ExternalLink } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
- import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
4
+ import { TRANSITION_BACKDROP, TRANSITION_PANEL, overlayTransitionStyle } from '../../utils/animation'
5
5
  import { openExternal } from '../../utils/navigation'
6
6
  import { useDiagnostics } from '../../api/client'
7
7
  import type { DiagnosticsSnapshot, DiagEnvVar, DiagMetricsSourceHealth, DiagDropRecord, DiagErrorEntry, DiagCacheSyncStatus, DiagInformerSyncStatus, DiagSyncPhase, DiagSampleWindow } from '../../api/client'
@@ -72,15 +72,19 @@ export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverla
72
72
  TRANSITION_BACKDROP,
73
73
  isOpen ? 'opacity-100' : 'opacity-0'
74
74
  )}
75
+ style={overlayTransitionStyle(isOpen, 'dialog')}
75
76
  onClick={onClose}
76
77
  />
77
78
 
78
79
  {/* Panel */}
79
- <div className={clsx(
80
- 'relative w-full max-w-2xl mx-4 dialog overflow-hidden flex flex-col max-h-[84vh]',
81
- TRANSITION_PANEL,
82
- isOpen ? 'opacity-100 scale-100 translate-y-0' : 'opacity-0 scale-[0.97] translate-y-3'
83
- )}>
80
+ <div
81
+ className={clsx(
82
+ 'relative w-full max-w-2xl mx-4 dialog overflow-hidden flex flex-col max-h-[84vh]',
83
+ TRANSITION_PANEL,
84
+ isOpen ? 'opacity-100 scale-100 translate-y-0' : 'opacity-0 scale-[0.97] translate-y-3'
85
+ )}
86
+ style={overlayTransitionStyle(isOpen, 'dialog')}
87
+ >
84
88
  {/* Header */}
85
89
  <div className="flex items-center justify-between px-5 py-3.5 border-b border-theme-border shrink-0">
86
90
  <div className="flex items-center gap-3">