@skyhook-io/radar-app 1.9.4 → 1.9.6

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