@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.
- package/package.json +9 -9
- package/src/App.tsx +10 -2
- package/src/api/client.authRedirect.test.ts +194 -0
- package/src/api/client.trace.test.ts +37 -0
- package/src/api/client.ts +315 -1
- package/src/api/config.test.ts +19 -0
- package/src/components/CloudConnectFlow.tsx +605 -0
- package/src/components/CloudFunnelButton.tsx +522 -0
- package/src/components/ConnectionErrorView.tsx +2 -1
- package/src/components/UserMenu.tsx +3 -2
- package/src/components/diagnose/DiagnoseSurface.tsx +2 -1
- package/src/components/home/ClusterHealthCard.tsx +10 -5
- package/src/components/home/mcpToolCatalog.ts +7 -5
- package/src/components/nav/PrimaryNavRail.tsx +4 -1
- package/src/components/resources/ImageFilesystemModal.tsx +4 -1
- package/src/components/settings/SettingsDialog.tsx +11 -4
- package/src/components/traffic/TrafficWizard.tsx +4 -2
- package/src/components/workload/WorkloadView.tsx +558 -12
- package/src/main.tsx +7 -1
- package/src/vite-env.d.ts +8 -0
|
@@ -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
|
-
:
|
|
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:
|
|
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
|