@skyhook-io/radar-app 1.13.5 → 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 (201) hide show
  1. package/package.json +8 -8
  2. package/src/App.tsx +60 -24
  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 +321 -16
  6. package/src/api/diagnose.ts +1 -0
  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 +179 -42
  13. package/src/components/CloudFunnelButton.tsx +107 -36
  14. package/src/components/SyncProgressPanel.tsx +73 -0
  15. package/src/components/audit/AuditView.tsx +2 -0
  16. package/src/components/cloudConnectHandoff.test.ts +140 -0
  17. package/src/components/cloudConnectHandoff.ts +124 -0
  18. package/src/components/diagnose/DiagnoseContext.tsx +2 -1
  19. package/src/components/diagnose/investigationEvidence/adapters/changes.ts +60 -0
  20. package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +573 -0
  21. package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +935 -0
  22. package/src/components/diagnose/investigationEvidence/adapters/events.ts +96 -0
  23. package/src/components/diagnose/investigationEvidence/adapters/helm.test.ts +242 -0
  24. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +208 -0
  25. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +172 -0
  26. package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +283 -0
  27. package/src/components/diagnose/investigationEvidence/adapters/logs.ts +183 -0
  28. package/src/components/diagnose/investigationEvidence/adapters/permissions.test.ts +528 -0
  29. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +292 -0
  30. package/src/components/diagnose/investigationEvidence/adapters/prometheus.test.ts +1641 -0
  31. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +769 -0
  32. package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +54 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +257 -0
  34. package/src/components/diagnose/investigationEvidence/adapters/topology.ts +207 -0
  35. package/src/components/diagnose/investigationEvidence/builder.ts +418 -0
  36. package/src/components/diagnose/investigationEvidence/evidenceFixtures.ts +179 -0
  37. package/src/components/diagnose/investigationEvidence/identity.test.ts +614 -0
  38. package/src/components/diagnose/investigationEvidence/identity.ts +261 -0
  39. package/src/components/diagnose/investigationEvidence/index.ts +55 -0
  40. package/src/components/diagnose/investigationEvidence/observations.ts +954 -0
  41. package/src/components/diagnose/investigationEvidence/parse.ts +467 -0
  42. package/src/components/diagnose/investigationEvidence/projection.test.ts +2703 -0
  43. package/src/components/diagnose/investigationEvidence/types.ts +512 -0
  44. package/src/components/diagnose/target.test.ts +19 -0
  45. package/src/components/diagnose/target.ts +3 -2
  46. package/src/components/gitops/GitOpsView.tsx +7 -0
  47. package/src/components/gitops/RemoteDestinationCloudHint.tsx +26 -0
  48. package/src/components/resource/PrometheusCharts.overlays.test.ts +22 -0
  49. package/src/components/resource/PrometheusCharts.tsx +4 -3
  50. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +99 -0
  51. package/src/components/resource/PrometheusChartsGrid.tsx +75 -35
  52. package/src/components/resource/RightsizingStrip.test.ts +38 -0
  53. package/src/components/resource/RightsizingStrip.tsx +15 -3
  54. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +79 -0
  55. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +145 -0
  56. package/src/components/resource/WorkloadMetricsSection.render.test.tsx +351 -0
  57. package/src/components/resource/WorkloadMetricsSection.tsx +486 -0
  58. package/src/components/resource/workloadMetricValues.test.ts +65 -0
  59. package/src/components/resource/workloadMetricValues.ts +30 -0
  60. package/src/components/resources/ResourcesView.tsx +44 -6
  61. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -1
  62. package/src/components/resources/renderers/PodRenderer.tsx +4 -0
  63. package/src/components/resources/renderers/RolloutRenderer.tsx +15 -1
  64. package/src/components/resources/renderers/WorkloadRenderer.tsx +3 -0
  65. package/src/components/rightsizing/RightsizingScanView.tsx +21 -0
  66. package/src/components/settings/SettingsDialog.tsx +94 -14
  67. package/src/components/traffic/TrafficFlowList.tsx +229 -70
  68. package/src/components/traffic/TrafficGraph.tsx +6 -6
  69. package/src/components/traffic/TrafficView.tsx +10 -9
  70. package/src/components/traffic/trafficFilters.test.ts +28 -1
  71. package/src/components/traffic/trafficFilters.ts +31 -0
  72. package/src/components/workload/WorkloadView.tsx +19 -8
  73. package/src/context/ConnectionContext.tsx +48 -3
  74. package/src/context/DiagnoseCustomization.tsx +1 -0
  75. package/src/index.css +17 -0
  76. package/src/utils/navigation.ts +1 -1
  77. package/src/utils/topology-namespace.test.ts +64 -0
  78. package/src/utils/topology-namespace.ts +18 -0
  79. package/src/components/diagnose/investigationEvidence.test.ts +0 -6617
  80. package/src/components/diagnose/investigationEvidence.ts +0 -5572
  81. package/src/components/gitops/GitOpsActions.tsx +0 -1
  82. package/src/components/gitops/GitOpsStatusBadge.tsx +0 -1
  83. package/src/components/gitops/ManagedResourcesList.tsx +0 -1
  84. package/src/components/gitops/SyncCountdown.tsx +0 -1
  85. package/src/components/gitops/index.ts +0 -4
  86. package/src/components/helm/ManifestDiffViewer.tsx +0 -69
  87. package/src/components/logs/JsonLogLine.tsx +0 -1
  88. package/src/components/logs/LogCore.tsx +0 -2
  89. package/src/components/logs/useLogBuffer.ts +0 -2
  90. package/src/components/logs/useLogSearch.ts +0 -1
  91. package/src/components/resource-drawer/ResourceDrawer.tsx +0 -216
  92. package/src/components/resources/drawer-components.tsx +0 -1
  93. package/src/components/resources/renderers/AlertRenderer.tsx +0 -1
  94. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +0 -1
  95. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +0 -1
  96. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +0 -1
  97. package/src/components/resources/renderers/CertificateRenderer.tsx +0 -1
  98. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +0 -1
  99. package/src/components/resources/renderers/ChallengeRenderer.tsx +0 -1
  100. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +0 -1
  101. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +0 -1
  102. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +0 -1
  103. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +0 -1
  104. package/src/components/resources/renderers/ConfigMapRenderer.tsx +0 -1
  105. package/src/components/resources/renderers/CronJobRenderer.tsx +0 -1
  106. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +0 -1
  107. package/src/components/resources/renderers/EventRenderer.tsx +0 -1
  108. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +0 -1
  109. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +0 -1
  110. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +0 -1
  111. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +0 -1
  112. package/src/components/resources/renderers/GatewayClassRenderer.tsx +0 -1
  113. package/src/components/resources/renderers/GatewayRenderer.tsx +0 -1
  114. package/src/components/resources/renderers/GenericRenderer.tsx +0 -1
  115. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +0 -1
  116. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +0 -1
  117. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +0 -1
  118. package/src/components/resources/renderers/IngressClassRenderer.tsx +0 -1
  119. package/src/components/resources/renderers/IngressRenderer.tsx +0 -1
  120. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +0 -1
  121. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +0 -1
  122. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +0 -1
  123. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +0 -1
  124. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +0 -1
  125. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +0 -1
  126. package/src/components/resources/renderers/JobRenderer.tsx +0 -1
  127. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +0 -1
  128. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +0 -1
  129. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +0 -1
  130. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +0 -1
  131. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +0 -1
  132. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +0 -1
  133. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +0 -1
  134. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +0 -1
  135. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +0 -1
  136. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +0 -1
  137. package/src/components/resources/renderers/KnativeRouteRenderer.tsx +0 -1
  138. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +0 -1
  139. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +0 -1
  140. package/src/components/resources/renderers/KustomizationRenderer.tsx +0 -1
  141. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +0 -1
  142. package/src/components/resources/renderers/LeaseRenderer.tsx +0 -1
  143. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +0 -1
  144. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +0 -1
  145. package/src/components/resources/renderers/OrderRenderer.tsx +0 -1
  146. package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +0 -1
  147. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +0 -1
  148. package/src/components/resources/renderers/PodMonitorRenderer.tsx +0 -1
  149. package/src/components/resources/renderers/PriorityClassRenderer.tsx +0 -1
  150. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +0 -1
  151. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +0 -1
  152. package/src/components/resources/renderers/RuntimeClassRenderer.tsx +0 -1
  153. package/src/components/resources/renderers/SbomReportRenderer.tsx +0 -1
  154. package/src/components/resources/renderers/SealedSecretRenderer.tsx +0 -1
  155. package/src/components/resources/renderers/SecretRenderer.tsx +0 -1
  156. package/src/components/resources/renderers/SecretStoreRenderer.tsx +0 -1
  157. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +0 -1
  158. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +0 -1
  159. package/src/components/resources/renderers/StorageClassRenderer.tsx +0 -1
  160. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +0 -1
  161. package/src/components/resources/renderers/VPARenderer.tsx +0 -1
  162. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +0 -1
  163. package/src/components/resources/renderers/VeleroVSLRenderer.tsx +0 -1
  164. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +0 -1
  165. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +0 -1
  166. package/src/components/resources/renderers/WorkflowRenderer.tsx +0 -1
  167. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +0 -1
  168. package/src/components/resources/renderers/argo-cells.tsx +0 -1
  169. package/src/components/resources/renderers/certmanager-cells.tsx +0 -1
  170. package/src/components/resources/renderers/cnpg-cells.tsx +0 -1
  171. package/src/components/resources/renderers/eso-cells.tsx +0 -1
  172. package/src/components/resources/renderers/flux-cells.tsx +0 -1
  173. package/src/components/resources/renderers/index.ts +0 -94
  174. package/src/components/resources/renderers/istio-cells.tsx +0 -1
  175. package/src/components/resources/renderers/karpenter-cells.tsx +0 -1
  176. package/src/components/resources/renderers/keda-cells.tsx +0 -1
  177. package/src/components/resources/renderers/knative-cells.tsx +0 -1
  178. package/src/components/resources/renderers/kyverno-cells.tsx +0 -1
  179. package/src/components/resources/renderers/prometheus-cells.tsx +0 -1
  180. package/src/components/resources/renderers/traefik-cells.tsx +0 -1
  181. package/src/components/resources/renderers/trivy-cells.tsx +0 -1
  182. package/src/components/resources/renderers/trivy-shared.tsx +0 -1
  183. package/src/components/resources/renderers/velero-cells.tsx +0 -1
  184. package/src/components/resources/resource-utils-argo.ts +0 -2
  185. package/src/components/resources/resource-utils-certmanager.ts +0 -2
  186. package/src/components/resources/resource-utils-eso.ts +0 -2
  187. package/src/components/resources/resource-utils-flux.ts +0 -2
  188. package/src/components/resources/resource-utils-istio.ts +0 -2
  189. package/src/components/resources/resource-utils-karpenter.ts +0 -2
  190. package/src/components/resources/resource-utils-knative.ts +0 -2
  191. package/src/components/resources/resource-utils-kyverno.ts +0 -2
  192. package/src/components/resources/resource-utils-prometheus.ts +0 -2
  193. package/src/components/resources/resource-utils-traefik.ts +0 -1
  194. package/src/components/resources/resource-utils-trivy.ts +0 -2
  195. package/src/components/resources/resource-utils-velero.ts +0 -2
  196. package/src/components/shared/EditableYamlView.tsx +0 -24
  197. package/src/components/timeline/DiffViewer.tsx +0 -1
  198. package/src/components/traffic/index.ts +0 -3
  199. package/src/components/ui/ForceDeleteConfirmDialog.tsx +0 -1
  200. package/src/components/ui/MetricsChart.tsx +0 -1
  201. 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
+ }
@@ -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}