@skyhook-io/radar-app 1.9.5 → 1.9.7

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.
@@ -1,13 +1,14 @@
1
- import { useMemo, useEffect, useCallback, useState } from 'react'
1
+ import { useMemo, useEffect, useCallback, useRef, useState } from 'react'
2
2
  import { useQueries, useQueryClient } from '@tanstack/react-query'
3
3
  import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'
4
4
  import { workloadPodAwaitsScheduling } from '../capacity/podDemandGate'
5
5
  import { clsx } from 'clsx'
6
- import { Terminal } from 'lucide-react'
6
+ import { Terminal, Stethoscope } from 'lucide-react'
7
7
  import {
8
8
  WorkloadView as BaseWorkloadView,
9
9
  EditableYamlView,
10
10
  FetchResult,
11
+ Section,
11
12
  type WorkloadTabType,
12
13
  type RendererOverrides,
13
14
  type GitOpsOwnerRef,
@@ -20,6 +21,7 @@ import {
20
21
  gitOpsRouteForOwner,
21
22
  gitOpsOwnerFromRelationships,
22
23
  getGitOpsResourceStatus,
24
+ isDiagnoseKind,
23
25
  } from '@skyhook-io/k8s-ui'
24
26
  import type { ServicePortRenderProps } from '@skyhook-io/k8s-ui/components/resources/renderers/ServiceRenderer'
25
27
  import type { SelectedResource, ResourceRef, Relationships } from '../../types'
@@ -27,6 +29,7 @@ import {
27
29
  kindToPlural,
28
30
  pluralToKind,
29
31
  relatedResourcePath,
32
+ buildWorkloadPath,
30
33
  type NavigateToResource,
31
34
  } from '../../utils/navigation'
32
35
  import {
@@ -69,8 +72,8 @@ import { RestartEventLane } from '../resource/RestartChart'
69
72
  import { RightsizingPanel, RightsizingStrip } from '../resource/RightsizingStrip'
70
73
  import { WorkloadCostTab } from '../cost/WorkloadCostTab'
71
74
  import { isOpenCostWorkloadKind } from '../cost/kinds'
72
- import { useResourceAudit, useResourceIssues, useResources } from '../../api/client'
73
- import { AuditAlerts, ResourceIssuesSection } from '@skyhook-io/k8s-ui'
75
+ import { useResourceAudit, useResourceIssues, useResources, useTrace, fetchTraceWithProbes, fetchInClusterCapability, runInClusterMerged } from '../../api/client'
76
+ import { AuditAlerts, ResourceIssuesSection, ReachabilityView, TraceSummary, InClusterConsentDialog, traceFingerprint, staticPollUnreliable, summarizeInClusterTests, type Trace as NetworkTrace, type InClusterCapability, inClusterConsentGiven, consentRequestRows } from '@skyhook-io/k8s-ui'
74
77
  import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer'
75
78
  import { ScheduledWorkloadLogsViewer } from '../logs/ScheduledWorkloadLogsViewer'
76
79
  import { LogsViewer } from '../logs/LogsViewer'
@@ -245,7 +248,13 @@ interface WorkloadViewProps {
245
248
  pushTabHistory?: boolean
246
249
  }
247
250
 
248
- function useActionsBarProps(kind: string, namespace: string, name: string) {
251
+ function useActionsBarProps(
252
+ kind: string,
253
+ namespace: string,
254
+ name: string,
255
+ group: string | undefined,
256
+ cascadeEnabled: boolean,
257
+ ) {
249
258
  const { showCopied } = useToast()
250
259
  const openTerminal = useOpenTerminal()
251
260
  const openLogs = useOpenLogs()
@@ -281,11 +290,16 @@ function useActionsBarProps(kind: string, namespace: string, name: string) {
281
290
  const argoSuspendMutation = useArgoSuspend()
282
291
  const argoResumeMutation = useArgoResume()
283
292
 
284
- const { data: cascadePreview, isLoading: cascadeLoading } = useCascadeDeletePreview(
293
+ const {
294
+ data: cascadePreview,
295
+ isLoading: cascadeLoading,
296
+ isError: cascadeError,
297
+ } = useCascadeDeletePreview(
285
298
  kind,
286
299
  namespace,
287
300
  name,
288
- true,
301
+ group,
302
+ cascadeEnabled,
289
303
  )
290
304
 
291
305
  const canNodeWrite = useCanNodeWrite()
@@ -324,6 +338,7 @@ function useActionsBarProps(kind: string, namespace: string, name: string) {
324
338
  isDeleting: deleteMutation.isPending,
325
339
  cascadeDependents: cascadePreview?.dependents,
326
340
  cascadeLoading,
341
+ cascadeRootResolved: cascadeError ? false : cascadePreview?.rootResolved,
327
342
  onRestart: (params: Parameters<typeof restartWorkloadMutation.mutate>[0]) =>
328
343
  restartWorkloadMutation.mutate(params),
329
344
  isRestarting: restartWorkloadMutation.isPending,
@@ -400,7 +415,9 @@ export function WorkloadView({
400
415
  ? 'overview'
401
416
  : rawTab === 'events'
402
417
  ? 'timeline'
403
- : (rawTab as TabType) || 'overview'
418
+ : rawTab === 'diagnose'
419
+ ? 'reachability' // the network tab was renamed Reachability
420
+ : (rawTab as TabType) || 'overview'
404
421
 
405
422
  const handleTabChange = useCallback(
406
423
  (tab: TabType, opts?: { replace?: boolean }) => {
@@ -446,6 +463,24 @@ export function WorkloadView({
446
463
  } = useResourceWithRelationships<any>(apiKind, namespace, name, rest.group)
447
464
  const resource = resourceResponse?.resource
448
465
  const relationships = resourceResponse?.relationships
466
+ const resourceGroup = useMemo(
467
+ () => (resource?.apiVersion ? apiVersionToGroup(resource.apiVersion) : undefined),
468
+ [resource?.apiVersion],
469
+ )
470
+ // The URL group arrives as '' when ?apiGroup is absent, which fails
471
+ // isDiagnoseKind's group check for Gateway-API kinds (HTTPRoute/GRPCRoute/
472
+ // Gateway) - an HTTPRoute page would then trace its serving Service instead
473
+ // of itself. Fall back to the group derived from the fetched resource, then
474
+ // to undefined (which the gate treats as "group unknown → allow").
475
+ const effectiveGroup = rest.group || resourceGroup || undefined
476
+ // Reachability for a workload IS the reachability of the Services in front of
477
+ // it - a Deployment has no address of its own. Empty for a workload nothing
478
+ // selects, which correctly leaves the tab hidden: there is no path to trace.
479
+ // Entry kinds trace themselves and ignore this.
480
+ const servingServices = useMemo(
481
+ () => (isDiagnoseKind(apiKind, effectiveGroup) ? [] : (relationships?.services ?? [])),
482
+ [apiKind, effectiveGroup, relationships],
483
+ )
449
484
  const refetchResourceAndRuns = useCallback(async () => {
450
485
  await Promise.all([
451
486
  refetchResource(),
@@ -652,13 +687,15 @@ export function WorkloadView({
652
687
  )
653
688
  const updateResource = useUpdateResource()
654
689
  const previewResources = usePreviewResources()
655
- const baseActionsBarProps = useActionsBarProps(apiKind, namespace, name)
690
+ const baseActionsBarProps = useActionsBarProps(
691
+ apiKind,
692
+ namespace,
693
+ name,
694
+ effectiveGroup,
695
+ !resourceLoading && Boolean(resource),
696
+ )
656
697
  const desktopDownload = useDesktopDownload()
657
698
 
658
- const resourceGroup = useMemo(
659
- () => (resource?.apiVersion ? apiVersionToGroup(resource.apiVersion) : undefined),
660
- [resource?.apiVersion],
661
- )
662
699
  // Live Operational Issues for this resource. Fetched here (not inside the lead
663
700
  // render-prop) so the count also gates `hasOperationalIssues` — which tells the
664
701
  // renderers to suppress their own status-derived problems and avoid duplicates.
@@ -681,7 +718,7 @@ export function WorkloadView({
681
718
  // Prefer the URL-supplied group so Compare works even before the resource
682
719
  // fetch completes; fall back to the derived group for callers that don't
683
720
  // pass one.
684
- group: rest.group || resourceGroup || undefined,
721
+ group: effectiveGroup,
685
722
  })
686
723
  const actionsBarProps = useMemo(
687
724
  () => ({ ...baseActionsBarProps, onCompareTo, onCompareAcrossClusters }),
@@ -717,6 +754,17 @@ export function WorkloadView({
717
754
  (path: string) => navigateRouter(path),
718
755
  [navigateRouter],
719
756
  )
757
+ // Drawer TraceSummary CTA → open the full resource view ON the Reachability tab.
758
+ // The generic onExpand navigates to the workload path but drops the query, so we
759
+ // navigate directly to that path WITH ?tab=reachability - the deeplink the
760
+ // expanded view reads to land on the right tab.
761
+ const openReachability = useCallback(() => {
762
+ const base = buildWorkloadPath({ kind: kindProp, namespace, name, group: rest.group })
763
+ // autorun=1 tells the expanded view to immediately run the (proxy) reachability
764
+ // test - the operator clicked "Open Reachability" to SEE results, not to land on
765
+ // a static page and click Run again.
766
+ navigateRouter(`${base}${base.includes('?') ? '&' : '?'}tab=reachability&autorun=1`)
767
+ }, [navigateRouter, kindProp, namespace, name, rest.group])
720
768
  const handleOpenHelmRelease = useCallback(
721
769
  (ref: HelmOwnerRef) => {
722
770
  const params = new URLSearchParams()
@@ -805,6 +853,7 @@ export function WorkloadView({
805
853
  name={name}
806
854
  expanded={expanded}
807
855
  {...rest}
856
+ group={effectiveGroup}
808
857
  // Data
809
858
  resource={resource}
810
859
  relationships={relationships}
@@ -895,6 +944,27 @@ export function WorkloadView({
895
944
  <WorkloadCostTab kind={kind} namespace={ns} name={n} />
896
945
  </div>
897
946
  )}
947
+ reachableVia={servingServices}
948
+ renderDiagnoseTab={({ namespace: ns, name: n }) => (
949
+ // Key by resource identity so the tab REMOUNTS on A→B navigation - without
950
+ // this, the first render with B's identity but A's still-set probeTrace
951
+ // paints A's verdict under B for one commit before the reset effect runs.
952
+ //
953
+ // The kind comes from the loaded resource, else the URL's own singular
954
+ // PascalCase kind - NEVER the base view's re-derived one: before the
955
+ // resource (or CRD discovery) loads, de-pluralizing "httproutes" guessed
956
+ // "Httproute", which fired the auto-probes once under the guessed kind
957
+ // and again under the real one - two live probe runs per tab open.
958
+ <WorkloadReachabilityTab
959
+ key={`${resource?.kind ?? kindProp}/${ns}/${n}`}
960
+ kind={resource?.kind ?? kindProp}
961
+ namespace={ns}
962
+ name={n}
963
+ group={effectiveGroup}
964
+ servingServices={servingServices}
965
+ onNavigate={rest.onNavigateToResource}
966
+ />
967
+ )}
898
968
  isMetricsAvailable={(kind, res) =>
899
969
  isPrometheusSupported(kind) && !(kind === 'Pod' && res?.status?.phase === 'Pending')
900
970
  }
@@ -903,15 +973,31 @@ export function WorkloadView({
903
973
  onDownload={desktopDownload}
904
974
  actionsBarProps={actionsBarProps}
905
975
  rendererOverrides={rendererOverrides}
906
- renderOverviewExtra={({ kind: k, namespace: ns, name: n }) => (
976
+ renderOverviewExtra={({ kind: k, namespace: ns, name: n, group: g, context }) => {
977
+ // Network entry kinds (Service/Ingress/Route/Gateway) ARE the diagnosis
978
+ // target: DiagnoseInlineSection renders in the drawer, no hint. Workload
979
+ // kinds lead with DiagnoseFromWorkloadHint so a developer who opened a
980
+ // failing workload finds the diagnose entry. The group disambiguates a CRD
981
+ // sharing a core kind name (Knative Service, Istio Gateway).
982
+ const isNetworkKind = isDiagnoseKind(k, g)
983
+ const diagnoseInline = context === 'drawer' && isNetworkKind ? (
984
+ <DiagnoseInlineSection kind={k} namespace={ns} name={n} group={g} onOpenReachability={openReachability} />
985
+ ) : null
986
+ const diagnoseHint = context === 'drawer' && !isNetworkKind ? (
987
+ <DiagnoseFromWorkloadHint services={servingServices} onOpenReachability={openReachability} />
988
+ ) : null
989
+ return (
907
990
  <>
991
+ {diagnoseInline}
992
+ {diagnoseHint}
908
993
  <FluxSourceConsumersSection kind={k} namespace={ns} name={n} />
909
994
  <AuditOverviewSection
910
995
  findings={auditFindings ?? []}
911
996
  onViewAll={() => navigateRouter('/checks')}
912
997
  />
913
998
  </>
914
- )}
999
+ )
1000
+ }}
915
1001
  renderOverviewLead={() => (
916
1002
  <ResourceIssuesSection
917
1003
  issues={liveIssues}
@@ -1410,6 +1496,484 @@ function AuditOverviewSection({
1410
1496
  return <AuditAlerts findings={findings} onViewAll={onViewAll} />
1411
1497
  }
1412
1498
 
1499
+ // DiagnoseFromWorkloadHint surfaces the Diagnose entry point for app
1500
+ // developers who open a failing workload (Deployment, StatefulSet, Pod,
1501
+ // etc.) and don't know that diagnosis lives on the fronting Service.
1502
+ // Without this card the operator has to navigate the topology themselves
1503
+ // to find the right Service. Renders only when the workload has at
1504
+ // least one Service in its relationships; on isolated workloads (no
1505
+ // Service in front) the card stays hidden because no entry-point exists
1506
+ // to link to. Services are handed down from the drawer's own relationships
1507
+ // fetch rather than re-fetched here: re-fetching by singular Kind missed
1508
+ // both the plural and the API group, which 404s for a CRD whose plural
1509
+ // collides with another CRD's (CNPG vs CAPI `clusters`).
1510
+ function DiagnoseFromWorkloadHint({
1511
+ services,
1512
+ onOpenReachability,
1513
+ }: {
1514
+ services: ResourceRef[]
1515
+ onOpenReachability: () => void
1516
+ }) {
1517
+ if (services.length === 0) return null
1518
+ return (
1519
+ <Section title="Diagnose network path">
1520
+ <div className="flex items-start gap-2 text-xs text-theme-text-secondary">
1521
+ <Stethoscope className="w-4 h-4 mt-0.5 shrink-0 text-theme-text-tertiary" aria-hidden />
1522
+ <div className="flex-1 min-w-0">
1523
+ {services.length === 1 ? 'Exposed by Service ' : 'Exposed by Services '}
1524
+ {services.map((svc, i) => (
1525
+ <span key={`${svc.namespace ?? ''}/${svc.name}`}>
1526
+ <span className="font-medium text-theme-text-primary">{svc.name}</span>
1527
+ {i < services.length - 1 ? <span className="text-theme-text-tertiary">{', '}</span> : null}
1528
+ </span>
1529
+ ))}
1530
+ {/* Opens the workload's OWN Reachability tab, which traces that
1531
+ Service in place. Linking to the Service instead made the operator
1532
+ navigate away and restart the investigation somewhere else. */}
1533
+ <span className="text-theme-text-tertiary">. </span>
1534
+ <button type="button" onClick={onOpenReachability} className="font-medium text-accent-text hover:underline">
1535
+ Trace the traffic path →
1536
+ </button>
1537
+ </div>
1538
+ </div>
1539
+ </Section>
1540
+ )
1541
+ }
1542
+
1543
+ // DiagnoseTabContent binds the static-trace polling hook + the one-shot probe
1544
+ // fetch to the presentational ReachabilityView. Probe results are held in local
1545
+ // state and keep showing until the resource or the tested path changes - or
1546
+ // until a static poll reports that the underlying cluster state changed since
1547
+ // the test ran, at which point the staleness mask below drops them (with a
1548
+ // notice) so a frozen snapshot is never presented as current truth.
1549
+ // useProbeRun owns the one-shot reachability-probe state for a focused
1550
+ // resource. A per-resource token guards every async resolution: navigating to
1551
+ // a different resource (props change) does NOT unmount this component, so an
1552
+ // aliveRef-only guard would let an in-flight probe for resource A resolve and
1553
+ // paint A's verdict onto resource B - a confident-wrong verdict on the wrong
1554
+ // resource. The token is bumped on every resource change (and unmount); a late
1555
+ // then/catch whose captured token no longer matches simply bails.
1556
+ function useProbeRun(kind: string, namespace: string, name: string) {
1557
+ const [probeTrace, setProbeTrace] = useState<NetworkTrace | undefined>(undefined)
1558
+ const [probeError, setProbeError] = useState<Error | null>(null)
1559
+ const [running, setRunning] = useState(false)
1560
+ const tokenRef = useRef(0)
1561
+ // runningRef mirrors `running` so runProbes' in-flight guard reads LIVE state, not a
1562
+ // value captured at render. applyProbePath calls resetProbe() then runProbes()
1563
+ // synchronously; the closed-over `running` would still be true and bail the new run,
1564
+ // whereas resetProbe clears runningRef immediately so the guard sees it's free.
1565
+ const runningRef = useRef(false)
1566
+ useEffect(() => {
1567
+ tokenRef.current += 1
1568
+ setProbeTrace(undefined)
1569
+ setProbeError(null)
1570
+ runningRef.current = false
1571
+ setRunning(false)
1572
+ }, [kind, namespace, name])
1573
+ useEffect(() => () => { tokenRef.current += 1 }, [])
1574
+ const runProbes = useCallback((path?: string) => {
1575
+ if (runningRef.current) return
1576
+ const token = tokenRef.current
1577
+ runningRef.current = true
1578
+ setRunning(true)
1579
+ setProbeError(null)
1580
+ fetchTraceWithProbes(kind, namespace, name, path)
1581
+ .then((result) => { if (tokenRef.current === token) setProbeTrace(result) })
1582
+ .catch((e: unknown) => { if (tokenRef.current === token) setProbeError(e instanceof Error ? e : new Error(String(e))) })
1583
+ .finally(() => { if (tokenRef.current === token) { runningRef.current = false; setRunning(false) } })
1584
+ }, [kind, namespace, name])
1585
+ // resetProbe drops the current probe trace AND bumps the token so an in-flight
1586
+ // probe for the OLD path can't resolve and repaint. Used when the tested path
1587
+ // changes: the prior path's verdict must not linger under the new path's label.
1588
+ const resetProbe = useCallback(() => {
1589
+ tokenRef.current += 1
1590
+ setProbeTrace(undefined)
1591
+ setProbeError(null)
1592
+ runningRef.current = false
1593
+ setRunning(false)
1594
+ }, [])
1595
+ return { probeTrace, probeError, running, runProbes, resetProbe }
1596
+ }
1597
+
1598
+ // useInClusterTest runs the WHOLE-subject in-cluster test in one click. The server
1599
+ // runs every route's live probe and folds them in via the canonical
1600
+ // trace.ApplyInClusterResults, returning the FINALIZED trace - so this hook just
1601
+ // displays it, never reimplementing a weaker merge that could falsely confirm a
1602
+ // sibling route or leave stale diagnosis/netpol beside a live-verified route. The
1603
+ // result resets whenever the base trace changes (a fresh proxy run), so stale
1604
+ // in-cluster data never lingers.
1605
+ function useInClusterTest(base: NetworkTrace | undefined, kind: string, namespace: string, name: string) {
1606
+ const [running, setRunning] = useState(false)
1607
+ // undefined = the capability SSAR has not answered yet. Starting at `false`
1608
+ // made the in-cluster capsule claim "not permitted" for the first frames of
1609
+ // every load - a definitive denial for a check still in flight, which is the
1610
+ // one thing this view must never do. Consumers gate on `=== false`, so an
1611
+ // unknown answer now reads as unknown.
1612
+ const [allowed, setAllowed] = useState<boolean | undefined>(undefined)
1613
+ const [cap, setCap] = useState<InClusterCapability | undefined>(undefined)
1614
+ const [merged, setMerged] = useState<NetworkTrace | undefined>(undefined)
1615
+ const [error, setError] = useState<string | undefined>(undefined)
1616
+ const [fallback, setFallback] = useState<string | undefined>(undefined)
1617
+ const [partial, setPartial] = useState(false)
1618
+ const [evidenceOnly, setEvidenceOnly] = useState(false)
1619
+ const [evidence, setEvidence] = useState<string | undefined>(undefined)
1620
+ // Per-resource token: bumped whenever the base trace changes (navigation / fresh
1621
+ // proxy run) and on unmount, so an in-flight run that resolves AFTER the operator
1622
+ // navigated to another resource never paints resource A's verdict onto resource B.
1623
+ const tokenRef = useRef(0)
1624
+ useEffect(() => {
1625
+ let alive = true
1626
+ // A fetch ERROR is not a denial: `false` is reserved for the server
1627
+ // definitively saying no, and consumers render `=== false` as "not
1628
+ // permitted". A 503 while the cache warms or a network blip must leave the
1629
+ // answer unknown, not paint a permanent RBAC denial.
1630
+ fetchInClusterCapability(kind, namespace, name).then((c) => { if (alive) { setAllowed(!!c.allowed); setCap(c) } }).catch(() => {})
1631
+ return () => { alive = false }
1632
+ }, [kind, namespace, name])
1633
+ useEffect(() => {
1634
+ tokenRef.current++
1635
+ setMerged(undefined); setError(undefined); setFallback(undefined); setPartial(false); setEvidenceOnly(false); setEvidence(undefined); setRunning(false)
1636
+ }, [base])
1637
+ // Invalidate an in-flight run on unmount. Kept as a separate []-effect: bumping
1638
+ // the ref in a deps-driven cleanup trips react-hooks/exhaustive-deps (the ref
1639
+ // changes between re-runs), and the base-change case is already covered by the
1640
+ // body bump above.
1641
+ useEffect(() => () => { tokenRef.current++ }, [])
1642
+ const run = useCallback(async (path: string = '/') => {
1643
+ if (!base || running) return
1644
+ const token = tokenRef.current
1645
+ setRunning(true)
1646
+ setError(undefined)
1647
+ setFallback(undefined)
1648
+ try {
1649
+ const { trace, inClusterTests } = await runInClusterMerged(kind, namespace, name, path)
1650
+ if (tokenRef.current !== token) return // navigated away / base changed mid-run
1651
+ setMerged(trace)
1652
+ // A per-route in-cluster failure (Job couldn't start, timed out, RBAC) comes
1653
+ // back as HTTP 200 with an error status + a fallback command inside
1654
+ // inClusterTests. A row can also carry a message with NO fallback command -
1655
+ // nothing eligible to test (e.g. a Gateway subject), the per-call pod cap,
1656
+ // or an exhausted request time budget. Surface both - otherwise the run
1657
+ // vanishes as if nothing happened - and mark whether OTHER rows still
1658
+ // produced results, so the banner can say "partially completed" instead of
1659
+ // the false "couldn't run" over a merged trace that folded live results.
1660
+ const summary = summarizeInClusterTests(inClusterTests)
1661
+ setError(summary.error)
1662
+ setFallback(summary.fallback)
1663
+ setPartial(summary.partial)
1664
+ setEvidenceOnly(summary.evidenceOnly)
1665
+ setEvidence(summary.evidence)
1666
+ } catch (e: unknown) {
1667
+ if (tokenRef.current !== token) return
1668
+ setError(e instanceof Error ? e.message : String(e))
1669
+ setFallback(undefined)
1670
+ setPartial(false)
1671
+ setEvidenceOnly(false)
1672
+ setEvidence(undefined)
1673
+ } finally {
1674
+ if (tokenRef.current === token) setRunning(false)
1675
+ }
1676
+ }, [base, running, kind, namespace, name])
1677
+ return { run, running, allowed, cap, merged, error, fallback, partial, evidenceOnly, evidence }
1678
+ }
1679
+
1680
+ /**
1681
+ * Reachability for a resource that has no address of its own.
1682
+ *
1683
+ * A Deployment cannot be dialled; what can be dialled is the Service in front of
1684
+ * it, so this traces that Service while keeping the workload as the thing the
1685
+ * operator opened. Previously a workload offered only a link to the Service,
1686
+ * which meant navigating away and restarting the investigation - the workload
1687
+ * half of "Service/workload reachability" did not exist as a journey.
1688
+ *
1689
+ * An entry kind traces itself and never reaches the picker below.
1690
+ */
1691
+ function WorkloadReachabilityTab({
1692
+ kind,
1693
+ namespace,
1694
+ name,
1695
+ group,
1696
+ servingServices,
1697
+ onNavigate,
1698
+ }: {
1699
+ kind: string
1700
+ namespace: string
1701
+ name: string
1702
+ group?: string
1703
+ servingServices: ResourceRef[]
1704
+ onNavigate?: NavigateToResource
1705
+ }) {
1706
+ const tracesItself = isDiagnoseKind(kind, group)
1707
+ const [pick, setPick] = useState(0)
1708
+ if (tracesItself) {
1709
+ return <DiagnoseTabContent kind={kind} namespace={namespace} name={name} onNavigate={onNavigate} />
1710
+ }
1711
+ const svc = servingServices[pick] ?? servingServices[0]
1712
+ if (!svc) return null
1713
+ return (
1714
+ <div className="flex h-full min-h-0 flex-col gap-2">
1715
+ {/* Only when there is a CHOICE to make. A full-width band to say "this
1716
+ Deployment has no address" spent a row of height on a sentence, and the
1717
+ workload now names the Pods at the end of the path in the graph itself -
1718
+ which is where the reader is already looking. */}
1719
+ {servingServices.length > 1 && (
1720
+ <div className="flex flex-wrap items-center gap-2 px-1 text-[11.5px] text-theme-text-tertiary">
1721
+ <span>Reached through:</span>
1722
+ {servingServices.map((s, i) => (
1723
+ <button
1724
+ key={`${s.namespace ?? ''}/${s.name}`}
1725
+ type="button"
1726
+ onClick={() => setPick(i)}
1727
+ className={`rounded px-1.5 py-0.5 font-mono text-[11px] ${
1728
+ i === pick ? 'bg-theme-hover font-semibold text-theme-text-primary' : 'text-accent-text hover:underline'
1729
+ }`}
1730
+ >
1731
+ {s.name}
1732
+ </button>
1733
+ ))}
1734
+ </div>
1735
+ )}
1736
+ <div className="min-h-0 flex-1">
1737
+ <DiagnoseTabContent
1738
+ key={`${svc.namespace ?? namespace}/${svc.name}`}
1739
+ kind="Service"
1740
+ namespace={svc.namespace ?? namespace}
1741
+ name={svc.name}
1742
+ onNavigate={onNavigate}
1743
+ />
1744
+ </div>
1745
+ </div>
1746
+ )
1747
+ }
1748
+
1749
+ function DiagnoseTabContent({
1750
+ kind,
1751
+ namespace,
1752
+ name,
1753
+ onNavigate,
1754
+ }: {
1755
+ kind: string
1756
+ namespace: string
1757
+ name: string
1758
+ onNavigate?: NavigateToResource
1759
+ }) {
1760
+ const { data: staticTrace, isLoading, error, refetch } = useTrace(kind, namespace, name)
1761
+ const { probeTrace, probeError, running, runProbes, resetProbe } = useProbeRun(kind, namespace, name)
1762
+ const baseTrace = probeTrace ?? staticTrace
1763
+ const { run: runInClusterTest, running: inClusterRunning, allowed: inClusterAllowed, cap: inClusterCap, merged: inClusterTrace, error: inClusterError, fallback: inClusterFallback, partial: inClusterPartial, evidenceOnly: inClusterEvidenceOnly, evidence: inClusterEvidenceNote } = useInClusterTest(baseTrace, kind, namespace, name)
1764
+ // Gate the merged in-cluster trace on a live probe trace: when the staleness
1765
+ // mask below clears probeTrace, useInClusterTest resets `merged` one effect
1766
+ // pass later - without the gate that pass would still paint the stale
1767
+ // in-cluster verdict for a frame.
1768
+ const displayTrace = (probeTrace !== undefined ? inClusterTrace : undefined) ?? baseTrace
1769
+ // Staleness mask for the probe snapshot: probe/in-cluster results are a
1770
+ // snapshot of the moment they ran, while the static trace keeps polling
1771
+ // underneath. The baseline is the fingerprint of THE RESULT TRACE ITSELF:
1772
+ // a ?probe=true response (and the in-cluster merged trace) embeds the same
1773
+ // static-derived content the probes actually ran against, and
1774
+ // traceFingerprint covers only probe-invariant fields - so it exists at the
1775
+ // instant of adoption (no race with the separate static query: a probe that
1776
+ // beats the static fetch, or a cluster change mid-run, can't baseline on
1777
+ // post-change state). When a later static poll fingerprints DIFFERENTLY,
1778
+ // drop the snapshot (the view falls back to the live static trace) and say
1779
+ // why - keeping the old verdict up would present stale evidence as current
1780
+ // truth. An adopted in-cluster merged trace re-baselines: it reflects the
1781
+ // (possibly newer) state its run observed.
1782
+ const staticFp = useMemo(() => (staticTrace ? traceFingerprint(staticTrace) : undefined), [staticTrace])
1783
+ // Only a FULLY-BUILT, healthy static poll is trustworthy staleness evidence. A
1784
+ // budget-timeout partial (fewer hops) or a transient pod-lister failure
1785
+ // (endpointSource=unknown) still returns HTTP 200 but fingerprints DIFFERENTLY -
1786
+ // comparing against it would drop a good snapshot and cry "cluster changed" though
1787
+ // nothing did. Skip the comparison for such polls; a poll we couldn't fully build
1788
+ // is not evidence of change.
1789
+ const staticPollDegraded = useMemo(() => (staticTrace ? staticPollUnreliable(staticTrace) : false), [staticTrace])
1790
+ const resultsTrace = inClusterTrace ?? probeTrace
1791
+ const snapshotFp = useRef<string | undefined>(undefined)
1792
+ const snapshotOf = useRef<NetworkTrace | undefined>(undefined)
1793
+ const [clusterChanged, setClusterChanged] = useState(false)
1794
+ useEffect(() => { setClusterChanged(false) }, [kind, namespace, name])
1795
+ useEffect(() => {
1796
+ if (resultsTrace === undefined) {
1797
+ snapshotOf.current = undefined
1798
+ snapshotFp.current = undefined
1799
+ return
1800
+ }
1801
+ if (resultsTrace !== snapshotOf.current) {
1802
+ // A run just adopted results: baseline on the state embedded in the
1803
+ // result itself.
1804
+ snapshotOf.current = resultsTrace
1805
+ snapshotFp.current = traceFingerprint(resultsTrace)
1806
+ setClusterChanged(false)
1807
+ return
1808
+ }
1809
+ if (staticFp !== undefined && staticFp !== snapshotFp.current) {
1810
+ // A partial/degraded poll fingerprints differently for reasons that aren't a
1811
+ // real cluster change - keep the snapshot rather than fire a false banner.
1812
+ if (staticPollDegraded) return
1813
+ resetProbe()
1814
+ setClusterChanged(true)
1815
+ }
1816
+ }, [resultsTrace, staticFp, staticPollDegraded, resetProbe])
1817
+ // Consent gate for the mutating in-cluster test: it spawns a Job/pod, so the first
1818
+ // run per cluster asks the operator to confirm - naming the cluster it lands in -
1819
+ // unless they chose "don't ask again" for that cluster. Permission is already
1820
+ // enforced upstream (the button only renders when the capability SSAR allows), so
1821
+ // this is a safety confirm, not an authz check.
1822
+ const [pendingRunPath, setPendingRunPath] = useState<string | null>(null)
1823
+ const requestInClusterRun = useCallback((path: string) => {
1824
+ if (inClusterConsentGiven(inClusterCap?.cluster)) runInClusterTest(path)
1825
+ else setPendingRunPath(path)
1826
+ }, [inClusterCap, runInClusterTest])
1827
+ const confirmInClusterRun = useCallback(() => {
1828
+ const path = pendingRunPath ?? '/'
1829
+ setPendingRunPath(null)
1830
+ runInClusterTest(path)
1831
+ }, [pendingRunPath, runInClusterTest])
1832
+ // The HTTP path the probes request (default "/"). Editable via the "what to
1833
+ // test" menu; the buttons re-run with the current path, the form applies a new
1834
+ // one. Applies to BOTH the reachability and in-cluster tests.
1835
+ const [probePath, setProbePath] = useState('/')
1836
+ // When the tested path actually changes, drop the prior path's probe trace
1837
+ // BEFORE re-running so displayTrace falls back to the static trace (config-only)
1838
+ // during the probe window - never the old path's verdict under the new label.
1839
+ const applyProbePath = useCallback((p: string) => {
1840
+ // Reset synchronously BEFORE runProbes - a reset inside the setProbePath updater is
1841
+ // deferred to the next render, so it would bump the token AFTER runProbes captured
1842
+ // the old one (dropping the result) and leave the stale running-guard set.
1843
+ if (p !== probePath) resetProbe()
1844
+ setProbePath(p)
1845
+ runProbes(p)
1846
+ }, [probePath, runProbes, resetProbe])
1847
+ // Bump a nonce every time a run produces a new result object (proxy or in-cluster),
1848
+ // so the view can flash "updated just now" even when the values are unchanged.
1849
+ // testedAt dates the displayed results ("tested HH:MM:SS") so even a kept
1850
+ // snapshot is honestly dated; it clears when the results do.
1851
+ const [runNonce, setRunNonce] = useState(0)
1852
+ const [testedAt, setTestedAt] = useState<Date | undefined>(undefined)
1853
+ // Bump ONLY when a NEW result is adopted (a fresh object reference), never when a
1854
+ // result is DROPPED. Without this, a mask-driven resetProbe() clears probeTrace
1855
+ // while inClusterTrace is still (transiently) truthy, so `probeTrace||inClusterTrace`
1856
+ // stays true and the effect would re-date testedAt + flash "updated just now" at the
1857
+ // exact moment results are being thrown away, beside "Cluster state changed".
1858
+ const prevResultsRef = useRef<{ probe?: NetworkTrace; inCluster?: NetworkTrace }>({})
1859
+ useEffect(() => {
1860
+ const prev = prevResultsRef.current
1861
+ const adopted = (probeTrace !== undefined && probeTrace !== prev.probe) || (inClusterTrace !== undefined && inClusterTrace !== prev.inCluster)
1862
+ prevResultsRef.current = { probe: probeTrace, inCluster: inClusterTrace }
1863
+ if (adopted) {
1864
+ setRunNonce((n) => n + 1)
1865
+ setTestedAt(new Date())
1866
+ } else if (!probeTrace && !inClusterTrace) {
1867
+ setTestedAt(undefined)
1868
+ }
1869
+ }, [probeTrace, inClusterTrace])
1870
+ // Auto-run the (proxy) reachability test once per resource when the tab loads - the
1871
+ // operator opened Reachability to SEE results, not a static page they must click Run
1872
+ // on. Only the proxy test auto-runs; the in-cluster test (which spawns a Job) stays a
1873
+ // deliberate manual action. Keyed by resource so navigating to a new one re-runs; the
1874
+ // stale ?autorun=1 deeplink flag (now redundant) is stripped to keep the URL clean.
1875
+ const [searchParams, setSearchParams] = useSearchParams()
1876
+ const autorunKey = useRef<string>('')
1877
+ useEffect(() => {
1878
+ const key = `${kind}/${namespace}/${name}`
1879
+ if (autorunKey.current === key) return
1880
+ autorunKey.current = key
1881
+ runProbes()
1882
+ if (searchParams.get('autorun')) {
1883
+ const next = new URLSearchParams(searchParams)
1884
+ next.delete('autorun')
1885
+ setSearchParams(next, { replace: true })
1886
+ }
1887
+ }, [kind, namespace, name, runProbes, searchParams, setSearchParams])
1888
+ // What the in-cluster job will ACTUALLY send, mirroring the server: a bare "/"
1889
+ // is the untouched default, so each route keeps its OWN declared path
1890
+ // (internal/server/reachability_run.go) - anything else overrides every route.
1891
+ // The consent dialog previously showed a single "GET /", which was wrong in
1892
+ // precisely the default case, and counted only `routes`, omitting the declared
1893
+ // paths that landed in `notTested`.
1894
+ const pendingPath = pendingRunPath ?? probePath
1895
+ const override = pendingPath && pendingPath !== '/' ? pendingPath : ''
1896
+ const consentRequests = useMemo(
1897
+ () => consentRequestRows(displayTrace?.routes ?? [], override),
1898
+ [displayTrace, override],
1899
+ )
1900
+ const consentUntestedCount = useMemo(() => {
1901
+ const derivable = new Set((displayTrace?.routes ?? []).filter((r) => r.inClusterRequest).map((r) => r.route))
1902
+ const declared = new Set<string>([
1903
+ ...(displayTrace?.routes ?? []).map((r) => r.route),
1904
+ ...(displayTrace?.notTested ?? []).map((s) => s.route).filter((x): x is string => !!x),
1905
+ ])
1906
+ return [...declared].filter((r) => !derivable.has(r)).length
1907
+ }, [displayTrace])
1908
+ // The full-view Reachability tab fills its pane: the shell supplies the
1909
+ // padding and this stays a full-height flex column so the board's three
1910
+ // panes can scroll independently instead of the whole page scrolling.
1911
+ return (
1912
+ <div className="flex h-full min-h-0 flex-col">
1913
+ <ReachabilityView
1914
+ trace={displayTrace}
1915
+ isLoading={isLoading || running}
1916
+ error={error as Error | null}
1917
+ probeError={probeError}
1918
+ onRefresh={() => void refetch()}
1919
+ probeRequested={running}
1920
+ probed={probeTrace !== undefined || inClusterTrace !== undefined}
1921
+ onRunProbes={() => runProbes(probePath)}
1922
+ onRunInCluster={() => requestInClusterRun(probePath)}
1923
+ inClusterRunning={inClusterRunning}
1924
+ // Permission only - never fold readiness in: `allowed && !probeTrace`
1925
+ // evaluated to false, which every consumer renders as a definitive
1926
+ // "not permitted". A missing base trace (probe failed, re-run in
1927
+ // flight, staleness mask) is not a denial; run() already no-ops
1928
+ // harmlessly until the base exists.
1929
+ inClusterAllowed={inClusterAllowed}
1930
+ inClusterDeniedReason={inClusterCap?.reason}
1931
+ inClusterError={inClusterError}
1932
+ inClusterPartial={inClusterPartial}
1933
+ inClusterFallback={inClusterFallback}
1934
+ inClusterEvidenceOnly={inClusterEvidenceOnly}
1935
+ inClusterEvidenceNote={inClusterEvidenceNote}
1936
+ probePath={probePath}
1937
+ onApplyProbePath={applyProbePath}
1938
+ runNonce={runNonce}
1939
+ testedAt={testedAt}
1940
+ clusterChangedSinceTest={clusterChanged}
1941
+ onNavigateToResource={onNavigate ? (ref) => onNavigate({ kind: kindToPlural(ref.kind), namespace: ref.namespace ?? '', name: ref.name, group: ref.group ?? '' }) : undefined}
1942
+ />
1943
+ <InClusterConsentDialog
1944
+ open={pendingRunPath !== null}
1945
+ cluster={inClusterCap?.cluster}
1946
+ namespace={inClusterCap?.namespace ?? namespace}
1947
+ requests={consentRequests}
1948
+ untestedCount={consentUntestedCount}
1949
+ maxProbes={inClusterCap?.maxProbes}
1950
+ onClose={() => setPendingRunPath(null)}
1951
+ onConfirm={confirmInClusterRun}
1952
+ />
1953
+ </div>
1954
+ )
1955
+ }
1956
+
1957
+ // DiagnoseInlineSection is the drawer-mode glance: a passive TraceSummary, NOT the
1958
+ // full panel. The full route matrix, active probes, per-route localization, path
1959
+ // topology and the in-cluster test all live on the Reachability tab - reached via
1960
+ // the "Open Reachability →" CTA, which deeplinks to ?tab=reachability and expands.
1961
+ // The useTrace hook is gated on enabled so non-traceable kinds short-circuit.
1962
+ function DiagnoseInlineSection({ kind, namespace, name, group, onOpenReachability }: { kind: string; namespace: string; name: string; group?: string; onOpenReachability: () => void }) {
1963
+ // Gate on (kind, group) so a CRD sharing a core kind name (Knative Service,
1964
+ // Istio Gateway) never enables the trace against the wrong (core) object.
1965
+ const enabled = isDiagnoseKind(kind, group)
1966
+ const { data: staticTrace } = useTrace(kind, namespace, name, enabled)
1967
+ if (!enabled || !staticTrace) return null
1968
+ // Wrap in Section so the surface matches the rest of the drawer (Ports /
1969
+ // Selector / Related Resources / Metadata) - chevron, title, divider.
1970
+ return (
1971
+ <Section title="Reachability · Network Path">
1972
+ <TraceSummary trace={staticTrace} onOpenReachability={onOpenReachability} />
1973
+ </Section>
1974
+ )
1975
+ }
1976
+
1413
1977
  // FluxSourceConsumersSection lists the reconcilers (Kustomization, HelmRelease)
1414
1978
  // that reference this Flux source CR — the inverse of `spec.sourceRef`. Renders
1415
1979
  // only when the focused resource is a Flux source kind; otherwise null. Sources