@skyhook-io/radar-app 1.16.2 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/App.tsx +60 -12
- package/src/RadarApp.tsx +27 -17
- package/src/api/client.goneResource.test.ts +49 -0
- package/src/api/client.ts +168 -55
- package/src/api/cnpg.ts +95 -0
- package/src/api/diagnose.test.ts +40 -0
- package/src/api/diagnose.ts +12 -1
- package/src/api/radarFeatures.render.test.tsx +19 -1
- package/src/api/radarFeatures.ts +3 -0
- package/src/api/timelineSource.ts +1 -1
- package/src/api/traffic.sources.test.ts +55 -0
- package/src/api/traffic.ts +173 -14
- package/src/components/NoClusterAccessBanner.tsx +68 -0
- package/src/components/applications/ApplicationsView.tsx +3 -0
- package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
- package/src/components/capacity/CapacityView.test.tsx +31 -1
- package/src/components/capacity/shared.tsx +12 -21
- package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
- package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
- package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
- package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
- package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
- package/src/components/cnpg/CNPGOperator.tsx +234 -0
- package/src/components/cnpg/CNPGOverview.tsx +307 -0
- package/src/components/cnpg/CNPGPooling.tsx +116 -0
- package/src/components/cnpg/CNPGProtection.tsx +320 -0
- package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
- package/src/components/cnpg/CNPGView.tsx +156 -0
- package/src/components/cnpg/paths.ts +13 -0
- package/src/components/cnpg/routes.test.ts +60 -0
- package/src/components/cnpg/routes.ts +112 -0
- package/src/components/cnpg/shared.tsx +290 -0
- package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
- package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
- package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
- package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
- package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
- package/src/components/diagnose/ApplyDialog.tsx +26 -28
- package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
- package/src/components/diagnose/DiagnoseContext.tsx +136 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
- package/src/components/diagnose/InvestigationView.tsx +39 -2
- package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
- package/src/components/diagnose/agentCatalog.ts +2 -2
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
- package/src/components/dock/TrafficFlowListTab.tsx +27 -6
- package/src/components/execution/BatchExecutionView.tsx +11 -11
- package/src/components/helm/ChartBrowser.tsx +9 -10
- package/src/components/helm/HelmCompareRoute.tsx +108 -115
- package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
- package/src/components/helm/InstallWizard.tsx +5 -2
- package/src/components/helm/RevisionHistory.tsx +3 -2
- package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
- package/src/components/helm/ValuesViewer.tsx +1 -1
- package/src/components/home/ClusterHealthCard.tsx +7 -0
- package/src/components/home/mcpToolCatalog.ts +1 -1
- package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
- package/src/components/resources/ResourcesView.tsx +6 -38
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
- package/src/components/settings/SettingsDialog.tsx +122 -19
- package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
- package/src/components/timeline/TimelineList.test.ts +47 -29
- package/src/components/timeline/TimelineList.tsx +60 -11
- package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
- package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
- package/src/components/timeline/TimelineView.tsx +16 -2
- package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
- package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
- package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
- package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
- package/src/components/traffic/TrafficGraph.tsx +109 -26
- package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
- package/src/components/traffic/TrafficView.tsx +582 -149
- package/src/components/traffic/trafficFilters.test.ts +398 -1
- package/src/components/traffic/trafficFilters.ts +408 -0
- package/src/components/whats-new/WhatsNew.test.ts +107 -28
- package/src/components/whats-new/WhatsNew.tsx +146 -86
- package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
- package/src/components/whats-new/releaseNotes.ts +155 -24
- package/src/components/workload/WorkloadView.test.ts +2 -1
- package/src/components/workload/WorkloadView.tsx +165 -294
- package/src/context/TimelineCustomization.tsx +15 -0
- package/src/contexts/CapabilitiesContext.tsx +9 -1
- package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
- package/src/hooks/useGitOpsWriteGuard.ts +193 -0
- package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
- package/src/hooks/useResourceCounts.ts +49 -0
- package/src/components/helm/RoleGatedPanel.tsx +0 -47
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
|
|
2
|
-
import { useTrafficSources, useTrafficFlows, useTrafficConnect, useSetTrafficSource } from '../../api/traffic'
|
|
3
|
-
import { useClusterInfo } from '../../api/client'
|
|
4
|
-
import
|
|
2
|
+
import { useTrafficSources, useTrafficFlows, useTrafficRecords, useTrafficConnect, useSetTrafficSource, type TrafficEndpointPair } from '../../api/traffic'
|
|
3
|
+
import { useClusterInfo, useNamespaceScope } from '../../api/client'
|
|
4
|
+
import { useSearchParams } from 'react-router-dom'
|
|
5
|
+
import type { TrafficWizardState, AggregatedFlow, TrafficFlow } from '../../types'
|
|
5
6
|
import { TrafficWizard } from './TrafficWizard'
|
|
6
7
|
import { TrafficGraph, type TrafficGraphSelection } from './TrafficGraph'
|
|
7
8
|
import { TrafficFilterSidebar } from './TrafficFilterSidebar'
|
|
8
9
|
import { TrafficFlowListProvider } from './TrafficFlowListContext'
|
|
9
|
-
import { Loader2, Filter, Plug, ChevronDown, List, Activity, AlertTriangle, Clock } from 'lucide-react'
|
|
10
|
+
import { Loader2, Filter, Plug, ChevronDown, List, Activity, AlertTriangle, Clock, Crosshair, X } from 'lucide-react'
|
|
11
|
+
import { TrafficFocusSearch } from './TrafficFocusSearch'
|
|
12
|
+
import { TrafficGraphTooLarge } from './TrafficGraphTooLarge'
|
|
10
13
|
import { clsx } from 'clsx'
|
|
11
14
|
import { useQueryClient } from '@tanstack/react-query'
|
|
12
15
|
import { useDock } from '../dock'
|
|
13
16
|
import { AlertBanner, EmptyState, PaneLoader, FreshnessControl } from '@skyhook-io/k8s-ui'
|
|
14
17
|
import { useConnection } from '../../context/ConnectionContext'
|
|
15
18
|
import { Tooltip } from '../ui/Tooltip'
|
|
16
|
-
import {
|
|
19
|
+
import { SEVERITY_TEXT } from '@skyhook-io/k8s-ui/utils/badge-colors'
|
|
20
|
+
import { matchesStatusRanges, bucketsFromCounts, bucketsFromStatus, isRateBasedSource, keepAvailable, effectiveThreshold, volumeUnit, type VolumeUnit, isExternalKind, mergeFlowVolume, coverageLabel, type GraphFlow, endpointPair, graphEndpoint, graphEndpointId, mergeRawPairs, mergedEdgePort, selectedEdgeMatcher, selectionRawPairs, selectionMatch, graphSize, GRAPH_DRAW_BUDGET, GRAPH_DRAW_CEILING, parseFocus, focusParam, focusId, focusNeighborhood, touchesFocus, endpointSummaries, namespaceSummaries, type TrafficFocus } from './trafficFilters'
|
|
17
21
|
|
|
18
22
|
// Consecutive 2s retries of an empty result that came with a transient warning.
|
|
19
23
|
const MAX_EMPTY_RETRIES = 5
|
|
@@ -166,6 +170,10 @@ const SYSTEM_NAMESPACES = new Set([
|
|
|
166
170
|
'external-secrets', // External Secrets Operator
|
|
167
171
|
])
|
|
168
172
|
|
|
173
|
+
// Sent with each query while system traffic is hidden, so the source drops it
|
|
174
|
+
// before it counts against the source's per-node limits.
|
|
175
|
+
const SYSTEM_NAMESPACE_LIST: readonly string[] = [...SYSTEM_NAMESPACES]
|
|
176
|
+
|
|
169
177
|
// Detect internal load balancer IPs (appear as "external" but are internal)
|
|
170
178
|
function isInternalLoadBalancer(name: string): boolean {
|
|
171
179
|
// GKE internal LB IPs (10.x.x.x range)
|
|
@@ -339,11 +347,35 @@ function isSystemEndpoint(name: string, namespace: string | undefined, kind: str
|
|
|
339
347
|
|
|
340
348
|
const isExternal = isExternalKind
|
|
341
349
|
|
|
350
|
+
|
|
351
|
+
// Why the map covers less than the chosen window. Two limits can cut it: each
|
|
352
|
+
// node returns at most nodeFlowLimit flows, and on a large cluster Radar keeps
|
|
353
|
+
// at most flowLimit in total. Narrowing what is fetched is the way to see more.
|
|
354
|
+
function coverageExplanation(data: { nodeFlowLimit?: number; flowLimit?: number } | undefined, timeRange: string, hideSystem: boolean): string {
|
|
355
|
+
const narrow = hideSystem
|
|
356
|
+
? 'Selecting fewer namespaces fetches less traffic, so the map covers more of the window.'
|
|
357
|
+
: 'Hiding system traffic or selecting fewer namespaces fetches less traffic, so the map covers more of the window.'
|
|
358
|
+
if (data?.flowLimit) {
|
|
359
|
+
return `This cluster produced more flows in the last ${timeRange} than Radar keeps per refresh (${data.flowLimit.toLocaleString()}), so the map shows the newest ones. ${narrow}`
|
|
360
|
+
}
|
|
361
|
+
return `Radar reads at most ${(data?.nodeFlowLimit ?? 0).toLocaleString()} of the newest flows from each node. At least one node reached that limit before the start of the ${timeRange} window, so its earlier traffic is not included. Other nodes may still show older flows. ${narrow}`
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function flowStatsExplanation(stats: { shown: number; total: number; hidden: number; outsideFocus: number; aggregated: number }): string {
|
|
365
|
+
const parts = [`${stats.shown.toLocaleString()} of the ${stats.total.toLocaleString()} connections in this window are shown`]
|
|
366
|
+
if (stats.hidden > 0) parts.push(`${stats.hidden.toLocaleString()} hidden by filters`)
|
|
367
|
+
if (stats.outsideFocus > 0) parts.push(`${stats.outsideFocus.toLocaleString()} outside the focus`)
|
|
368
|
+
if (stats.aggregated > 0) parts.push(`${stats.aggregated.toLocaleString()} merged into others`)
|
|
369
|
+
return parts.join('; ') + '.'
|
|
370
|
+
}
|
|
371
|
+
|
|
342
372
|
interface TrafficViewProps {
|
|
343
373
|
namespaces: string[]
|
|
374
|
+
/** Sets the app-wide namespace selection. */
|
|
375
|
+
onSetNamespaces?: (namespaces: string[]) => void
|
|
344
376
|
}
|
|
345
377
|
|
|
346
|
-
export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
378
|
+
export function TrafficView({ namespaces, onSetNamespaces }: TrafficViewProps) {
|
|
347
379
|
const { connection } = useConnection()
|
|
348
380
|
const [wizardState, setWizardState] = useState<TrafficWizardState>('detecting')
|
|
349
381
|
const [timeRange, setTimeRange] = useState<string>('5m')
|
|
@@ -358,9 +390,43 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
358
390
|
const [aggregateExternal, setAggregateExternal] = useState(true)
|
|
359
391
|
const [detectServices, setDetectServices] = useState(true)
|
|
360
392
|
const [collapseInternet, setCollapseInternet] = useState(true)
|
|
393
|
+
// Focus and grouping live in the URL so a link carries both: a pod focus
|
|
394
|
+
// matches only while pods are drawn. A focus is a history entry; switching
|
|
395
|
+
// the grouping replaces the current one.
|
|
396
|
+
const [searchParams, setSearchParams] = useSearchParams()
|
|
397
|
+
const groupByWorkload = searchParams.get('groupBy') !== 'pod'
|
|
398
|
+
const setGroupByWorkload = useCallback((on: boolean) => {
|
|
399
|
+
setSearchParams(prev => {
|
|
400
|
+
const params = new URLSearchParams(prev)
|
|
401
|
+
if (on) params.delete('groupBy')
|
|
402
|
+
else params.set('groupBy', 'pod')
|
|
403
|
+
return params
|
|
404
|
+
}, { replace: true })
|
|
405
|
+
}, [setSearchParams])
|
|
406
|
+
// A record's endpoint as the graph draws it in the chosen grouping.
|
|
407
|
+
const drawnEndpoint = useCallback(
|
|
408
|
+
(e: TrafficFlow['source']) => (groupByWorkload ? graphEndpoint(e) : e),
|
|
409
|
+
[groupByWorkload])
|
|
361
410
|
const [addonMode, setAddonMode] = useState<AddonMode>('show')
|
|
362
411
|
const [graphSelection, setGraphSelection] = useState<TrafficGraphSelection | null>(null)
|
|
412
|
+
const clearGraphSelection = useCallback(() => setGraphSelection(null), [])
|
|
413
|
+
// The view the graph was last drawn for, and so keeps drawing (up to the
|
|
414
|
+
// ceiling) as refreshes move its size: set by drawing it or by Draw anyway.
|
|
415
|
+
const [drawnViewKey, setDrawnViewKey] = useState<string | null>(null)
|
|
363
416
|
const dock = useDock()
|
|
417
|
+
const graphPaneRef = useRef<HTMLDivElement>(null)
|
|
418
|
+
|
|
419
|
+
const focusValue = searchParams.get('focus')
|
|
420
|
+
const focus = useMemo(() => parseFocus(focusValue), [focusValue])
|
|
421
|
+
const focusKey = focus ? focusId(focus) : undefined
|
|
422
|
+
const setFocus = useCallback((next: TrafficFocus | null) => {
|
|
423
|
+
setSearchParams(prev => {
|
|
424
|
+
const params = new URLSearchParams(prev)
|
|
425
|
+
if (next) params.set('focus', focusParam(next))
|
|
426
|
+
else params.delete('focus')
|
|
427
|
+
return params
|
|
428
|
+
})
|
|
429
|
+
}, [setSearchParams])
|
|
364
430
|
|
|
365
431
|
// Dock: offset past sidebar, close flows tab on unmount
|
|
366
432
|
const flowsTabIdRef = useRef<string | null>(null)
|
|
@@ -376,7 +442,9 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
376
442
|
}
|
|
377
443
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
378
444
|
|
|
379
|
-
const [
|
|
445
|
+
const [chosenHiddenNamespaces, setHiddenNamespaces] = useState<Set<string>>(new Set())
|
|
446
|
+
// Paused while focused: a neighbor in a hidden namespace is still a neighbor.
|
|
447
|
+
const hiddenNamespaces = useMemo(() => (focus ? new Set<string>() : chosenHiddenNamespaces), [focus, chosenHiddenNamespaces])
|
|
380
448
|
// L7 filters (Hubble-only)
|
|
381
449
|
const [l7Protocol, setL7Protocol] = useState<string>('all')
|
|
382
450
|
const [l7Methods, setL7Methods] = useState<Set<string>>(new Set())
|
|
@@ -426,7 +494,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
426
494
|
const {
|
|
427
495
|
data: sourcesData,
|
|
428
496
|
isLoading: sourcesLoading,
|
|
429
|
-
|
|
497
|
+
detectAgain: detectSourcesAgain,
|
|
430
498
|
} = useTrafficSources()
|
|
431
499
|
|
|
432
500
|
const {
|
|
@@ -437,6 +505,9 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
437
505
|
} = useTrafficFlows({
|
|
438
506
|
namespaces,
|
|
439
507
|
since: timeRange,
|
|
508
|
+
excludeNamespaces: hideSystem ? SYSTEM_NAMESPACE_LIST : undefined,
|
|
509
|
+
excludeHost: hideSystem,
|
|
510
|
+
byPod: !groupByWorkload,
|
|
440
511
|
// Only fetch flows when connected (not connecting and no connection error)
|
|
441
512
|
enabled: wizardState === 'ready' && !isConnecting && !connectionError,
|
|
442
513
|
})
|
|
@@ -449,6 +520,21 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
449
520
|
// An 'incomplete' one is a fetch that worked but could not see everything
|
|
450
521
|
// (events lost, nodes unreachable). Not a failure, so not retried at once.
|
|
451
522
|
const warningIsIncomplete = flowsData?.warningKind === 'incomplete'
|
|
523
|
+
const flowsWarning = flowsData?.warning ? (
|
|
524
|
+
<AlertBanner
|
|
525
|
+
variant="warning"
|
|
526
|
+
// The title follows the kind. A partial warning is about values
|
|
527
|
+
// on edges that are shown — a port reported as 0, UDP as TCP, a
|
|
528
|
+
// 5xx rate that failed to load — so it says "unreliable", not
|
|
529
|
+
// "incomplete", which would send the reader looking for missing
|
|
530
|
+
// workloads. An incomplete or transient one beside flows is
|
|
531
|
+
// exactly that: a stream cut short, events lost, a node the
|
|
532
|
+
// relay could not reach, a TCP query that failed — edges may
|
|
533
|
+
// be missing from what is drawn.
|
|
534
|
+
title={warningIsPermanent ? 'Some values on this map are unreliable' : 'Some traffic may be missing from this map'}
|
|
535
|
+
message={flowsData.warning}
|
|
536
|
+
/>
|
|
537
|
+
) : null
|
|
452
538
|
|
|
453
539
|
// Auto-retry when flows return with warning but no data (e.g., port-forward not
|
|
454
540
|
// ready yet). Bounded: a warning that keeps coming back — a node the relay
|
|
@@ -610,74 +696,57 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
610
696
|
})
|
|
611
697
|
}, [flowsData?.aggregated, hideSystem, hideExternal, activeMinConnections, hiddenNamespaces, addonMode, l7Protocol, activeMethods, activeStatusRanges, activeVerdicts, activeDnsPattern])
|
|
612
698
|
|
|
613
|
-
//
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
699
|
+
// The graph's filters, applied to individual records for the list view
|
|
700
|
+
const rawFlowPasses = useCallback((flow: TrafficFlow) => {
|
|
701
|
+
// Name rules (system components, addons) judge an endpoint by the name the
|
|
702
|
+
// graph draws it under, so a hidden workload's pods leave the list too.
|
|
703
|
+
const sourceName = drawnEndpoint(flow.source).name
|
|
704
|
+
const destName = drawnEndpoint(flow.destination).name
|
|
705
|
+
const sourceIsSystem = isSystemEndpoint(sourceName, flow.source.namespace, flow.source.kind)
|
|
706
|
+
const destIsSystem = isSystemEndpoint(destName, flow.destination.namespace, flow.destination.kind)
|
|
707
|
+
if (hideSystem && (sourceIsSystem || destIsSystem)) return false
|
|
708
|
+
|
|
709
|
+
const isAlwaysFiltered = (name: string) =>
|
|
710
|
+
name === 'metadata.google.internal' || name === 'metadata.google.internal.' ||
|
|
711
|
+
name.startsWith('169.254.') || name === 'instance-data.ec2.internal' ||
|
|
712
|
+
name === 'localhost' || name === '127.0.0.1' || name.startsWith('127.') || name === '0.0.0.0'
|
|
713
|
+
if (isAlwaysFiltered(flow.source.name) || isAlwaysFiltered(flow.destination.name)) return false
|
|
714
|
+
|
|
715
|
+
if (hideExternal && (isExternal(flow.source.kind) || isExternal(flow.destination.kind))) return false
|
|
716
|
+
|
|
717
|
+
if (addonMode === 'hide') {
|
|
718
|
+
if (isClusterAddon(sourceName, flow.source.namespace) || isClusterAddon(destName, flow.destination.namespace)) return false
|
|
719
|
+
}
|
|
628
720
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
721
|
+
if (hiddenNamespaces.size > 0) {
|
|
722
|
+
if (flow.source.namespace && hiddenNamespaces.has(flow.source.namespace)) return false
|
|
723
|
+
if (flow.destination.namespace && hiddenNamespaces.has(flow.destination.namespace)) return false
|
|
724
|
+
}
|
|
632
725
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
726
|
+
// Protocol filter
|
|
727
|
+
if (l7Protocol === 'HTTP' && flow.l7Protocol !== 'HTTP') return false
|
|
728
|
+
if (l7Protocol === 'DNS' && flow.l7Protocol !== 'DNS') return false
|
|
729
|
+
if (l7Protocol === 'TCP' && flow.l7Protocol) return false
|
|
637
730
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
if (
|
|
641
|
-
|
|
731
|
+
// L7 sub-filters on individual flow fields
|
|
732
|
+
if (activeMethods.size > 0) {
|
|
733
|
+
if (!flow.httpMethod || !activeMethods.has(flow.httpMethod)) return false
|
|
734
|
+
}
|
|
735
|
+
if (!matchesStatusRanges(activeStatusRanges, bucketsFromStatus(flow.httpStatus), (flow.errorRate ?? 0) > 0)) return false
|
|
736
|
+
if (activeVerdicts.size > 0) {
|
|
737
|
+
if (!flow.verdict || !activeVerdicts.has(flow.verdict)) return false
|
|
738
|
+
}
|
|
739
|
+
if (activeDnsPattern) {
|
|
740
|
+
if (!flow.dnsQuery || !flow.dnsQuery.toLowerCase().includes(activeDnsPattern.toLowerCase())) return false
|
|
741
|
+
}
|
|
642
742
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
if (!flow.httpMethod || !activeMethods.has(flow.httpMethod)) return false
|
|
646
|
-
}
|
|
647
|
-
if (!matchesStatusRanges(activeStatusRanges, bucketsFromStatus(flow.httpStatus), (flow.errorRate ?? 0) > 0)) return false
|
|
648
|
-
if (activeVerdicts.size > 0) {
|
|
649
|
-
if (!flow.verdict || !activeVerdicts.has(flow.verdict)) return false
|
|
650
|
-
}
|
|
651
|
-
if (activeDnsPattern) {
|
|
652
|
-
if (!flow.dnsQuery || !flow.dnsQuery.toLowerCase().includes(activeDnsPattern.toLowerCase())) return false
|
|
653
|
-
}
|
|
743
|
+
return true
|
|
744
|
+
}, [hideSystem, hideExternal, hiddenNamespaces, addonMode, l7Protocol, activeMethods, activeStatusRanges, activeVerdicts, activeDnsPattern, drawnEndpoint])
|
|
654
745
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
746
|
+
const filteredRawFlows = useMemo(
|
|
747
|
+
() => (flowsData?.flows ?? []).filter(rawFlowPasses),
|
|
748
|
+
[flowsData?.flows, rawFlowPasses])
|
|
658
749
|
|
|
659
|
-
// Apply graph selection to filter raw flows for the list panel
|
|
660
|
-
const listFlows = useMemo(() => {
|
|
661
|
-
if (!graphSelection) return filteredRawFlows
|
|
662
|
-
if (graphSelection.type === 'node' && graphSelection.nodeId) {
|
|
663
|
-
const id = graphSelection.nodeId
|
|
664
|
-
return filteredRawFlows.filter(f => {
|
|
665
|
-
const srcId = f.source.namespace ? `${f.source.namespace}/${f.source.name}` : f.source.name
|
|
666
|
-
const dstId = f.destination.namespace ? `${f.destination.namespace}/${f.destination.name}` : f.destination.name
|
|
667
|
-
return srcId === id || dstId === id
|
|
668
|
-
})
|
|
669
|
-
}
|
|
670
|
-
if (graphSelection.type === 'edge' && graphSelection.sourceId && graphSelection.destId) {
|
|
671
|
-
return filteredRawFlows.filter(f => {
|
|
672
|
-
const srcId = f.source.namespace ? `${f.source.namespace}/${f.source.name}` : f.source.name
|
|
673
|
-
const dstId = f.destination.namespace ? `${f.destination.namespace}/${f.destination.name}` : f.destination.name
|
|
674
|
-
// Match either direction (request goes A→B, response goes B→A)
|
|
675
|
-
return (srcId === graphSelection.sourceId && dstId === graphSelection.destId) ||
|
|
676
|
-
(srcId === graphSelection.destId && dstId === graphSelection.sourceId)
|
|
677
|
-
})
|
|
678
|
-
}
|
|
679
|
-
return filteredRawFlows
|
|
680
|
-
}, [filteredRawFlows, graphSelection])
|
|
681
750
|
|
|
682
751
|
// Open flow list in the bottom dock
|
|
683
752
|
const openFlowListDock = useCallback(() => {
|
|
@@ -727,8 +796,12 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
727
796
|
|
|
728
797
|
// Process flows for external service aggregation (Phase 4.2)
|
|
729
798
|
// Also tracks service categories for coloring external nodes
|
|
799
|
+
const graphInputFlows = useMemo<GraphFlow[]>(
|
|
800
|
+
() => filteredFlows.map(flow => ({ ...flow, rawPairs: [endpointPair(flow)] })),
|
|
801
|
+
[filteredFlows])
|
|
802
|
+
|
|
730
803
|
const { processedFlows, serviceCategories } = useMemo<{
|
|
731
|
-
processedFlows:
|
|
804
|
+
processedFlows: GraphFlow[]
|
|
732
805
|
serviceCategories: Map<string, string>
|
|
733
806
|
}>(() => {
|
|
734
807
|
const categories = new Map<string, string>()
|
|
@@ -740,7 +813,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
740
813
|
|
|
741
814
|
if (!aggregateExternal) {
|
|
742
815
|
// Even without aggregation, detect service categories for coloring (destinations only)
|
|
743
|
-
|
|
816
|
+
graphInputFlows.forEach(flow => {
|
|
744
817
|
if (isExternal(flow.destination.kind)) {
|
|
745
818
|
const info = getServiceInfo(flow.destination.name, flow.port)
|
|
746
819
|
if (info.category) {
|
|
@@ -749,13 +822,13 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
749
822
|
}
|
|
750
823
|
// Don't apply port-based detection to sources - port tells us the destination service
|
|
751
824
|
})
|
|
752
|
-
return { processedFlows:
|
|
825
|
+
return { processedFlows: graphInputFlows, serviceCategories: categories }
|
|
753
826
|
}
|
|
754
827
|
|
|
755
828
|
// Aggregate flows to the same external service
|
|
756
|
-
const aggregatedMap = new Map<string,
|
|
829
|
+
const aggregatedMap = new Map<string, GraphFlow>()
|
|
757
830
|
|
|
758
|
-
|
|
831
|
+
graphInputFlows.forEach(flow => {
|
|
759
832
|
// Only aggregate destinations based on port/hostname - sources keep their original name
|
|
760
833
|
// Port-based detection (MongoDB:27017) only makes sense for destinations
|
|
761
834
|
const sourceAgg = isExternal(flow.source.kind)
|
|
@@ -784,12 +857,15 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
784
857
|
const existing = aggregatedMap.get(key)
|
|
785
858
|
if (existing) {
|
|
786
859
|
mergeFlowVolume(existing, flow)
|
|
860
|
+
mergeRawPairs(existing, flow)
|
|
861
|
+
existing.port = mergedEdgePort(existing.port, flow.port)
|
|
787
862
|
// Everything merged here shares the key's direction-known state, so the
|
|
788
863
|
// flag is already correct on the entry that was created first.
|
|
789
864
|
} else {
|
|
790
865
|
// Create new aggregated flow with modified names
|
|
791
866
|
aggregatedMap.set(key, {
|
|
792
867
|
...flow,
|
|
868
|
+
rawPairs: [...(flow.rawPairs ?? [])],
|
|
793
869
|
source: sourceAgg.aggregated
|
|
794
870
|
? { ...flow.source, name: sourceAgg.name }
|
|
795
871
|
: flow.source,
|
|
@@ -801,15 +877,15 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
801
877
|
})
|
|
802
878
|
|
|
803
879
|
return { processedFlows: Array.from(aggregatedMap.values()), serviceCategories: categories }
|
|
804
|
-
}, [
|
|
880
|
+
}, [graphInputFlows, aggregateExternal, detectServices])
|
|
805
881
|
|
|
806
882
|
// Collapse inbound internet traffic (external sources → internal destinations)
|
|
807
|
-
const internetCollapsedFlows = useMemo<
|
|
883
|
+
const internetCollapsedFlows = useMemo<GraphFlow[]>(() => {
|
|
808
884
|
if (!collapseInternet) return processedFlows
|
|
809
885
|
|
|
810
886
|
// Group flows where external sources connect to internal destinations
|
|
811
|
-
const internetFlowsMap = new Map<string,
|
|
812
|
-
const nonInternetFlows:
|
|
887
|
+
const internetFlowsMap = new Map<string, GraphFlow>() // destKey -> aggregated flow
|
|
888
|
+
const nonInternetFlows: GraphFlow[] = []
|
|
813
889
|
|
|
814
890
|
processedFlows.forEach(flow => {
|
|
815
891
|
const sourceIsExternal = isExternal(flow.source.kind)
|
|
@@ -828,10 +904,12 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
828
904
|
const existing = internetFlowsMap.get(destKey)
|
|
829
905
|
if (existing) {
|
|
830
906
|
mergeFlowVolume(existing, flow)
|
|
907
|
+
mergeRawPairs(existing, flow)
|
|
831
908
|
} else {
|
|
832
909
|
// Create new "Internet" → destination flow
|
|
833
910
|
internetFlowsMap.set(destKey, {
|
|
834
911
|
...flow,
|
|
912
|
+
rawPairs: [...(flow.rawPairs ?? [])],
|
|
835
913
|
source: {
|
|
836
914
|
name: 'Internet',
|
|
837
915
|
namespace: '',
|
|
@@ -850,22 +928,41 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
850
928
|
// When grouping addons:
|
|
851
929
|
// 1. Aggregate internet → addon into single edge to group
|
|
852
930
|
// 2. Aggregate addon → kubernetes into single edge from group
|
|
853
|
-
|
|
854
|
-
|
|
931
|
+
// Everything a focus can be put on: the endpoints drawn before addons are
|
|
932
|
+
// grouped, which are real ones.
|
|
933
|
+
const focusableEndpoints = useMemo(() => endpointSummaries(internetCollapsedFlows), [internetCollapsedFlows])
|
|
934
|
+
const focusedFlows = useMemo(
|
|
935
|
+
() => (focus ? focusNeighborhood(internetCollapsedFlows, focus) : internetCollapsedFlows),
|
|
936
|
+
[internetCollapsedFlows, focus])
|
|
937
|
+
// A new focus starts with nothing selected: the flow list follows the focus
|
|
938
|
+
// itself (listSelection), and opening its details would cover the node the
|
|
939
|
+
// view was just fitted around.
|
|
940
|
+
const focusFound = !!focus && focusedFlows.length > 0
|
|
941
|
+
useEffect(() => {
|
|
942
|
+
setGraphSelection(null)
|
|
943
|
+
}, [focusKey])
|
|
944
|
+
|
|
945
|
+
// A focused view draws its endpoints as they are: grouping the addons would
|
|
946
|
+
// fold the focus or its neighbors into the group.
|
|
947
|
+
const groupAddons = addonMode === 'group' && !focus
|
|
948
|
+
const finalFlows = useMemo<GraphFlow[]>(() => {
|
|
949
|
+
if (!groupAddons) return focusedFlows
|
|
855
950
|
|
|
856
951
|
// Track totals for aggregated edges
|
|
857
952
|
let addonInternetTotal = 0
|
|
858
953
|
let addonToK8sTotal = 0
|
|
859
954
|
let addonInternetRate = 0
|
|
860
955
|
let addonToK8sRate = 0
|
|
861
|
-
const
|
|
956
|
+
const addonInternetPairs: TrafficEndpointPair[] = []
|
|
957
|
+
const addonToK8sPairs: TrafficEndpointPair[] = []
|
|
958
|
+
const processedFlows: GraphFlow[] = []
|
|
862
959
|
|
|
863
960
|
// Check if destination is the kubernetes API server
|
|
864
961
|
const isKubernetesAPI = (name: string, namespace: string | undefined) => {
|
|
865
962
|
return name === 'kubernetes' && (!namespace || namespace === 'default')
|
|
866
963
|
}
|
|
867
964
|
|
|
868
|
-
|
|
965
|
+
focusedFlows.forEach(flow => {
|
|
869
966
|
const sourceIsAddon = isClusterAddon(flow.source.name, flow.source.namespace)
|
|
870
967
|
const destIsAddon = isClusterAddon(flow.destination.name, flow.destination.namespace)
|
|
871
968
|
const sourceIsInternet = flow.source.kind === 'Internet'
|
|
@@ -875,6 +972,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
875
972
|
if (sourceIsInternet && destIsAddon) {
|
|
876
973
|
addonInternetTotal += flow.connections
|
|
877
974
|
addonInternetRate += flow.requestRate ?? 0
|
|
975
|
+
for (const pair of flow.rawPairs ?? []) addonInternetPairs.push(pair)
|
|
878
976
|
processedFlows.push({
|
|
879
977
|
...flow,
|
|
880
978
|
source: {
|
|
@@ -888,6 +986,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
888
986
|
else if (sourceIsAddon && destIsK8sAPI) {
|
|
889
987
|
addonToK8sTotal += flow.connections
|
|
890
988
|
addonToK8sRate += flow.requestRate ?? 0
|
|
989
|
+
for (const pair of flow.rawPairs ?? []) addonToK8sPairs.push(pair)
|
|
891
990
|
processedFlows.push({
|
|
892
991
|
...flow,
|
|
893
992
|
destination: {
|
|
@@ -918,6 +1017,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
918
1017
|
port: 0,
|
|
919
1018
|
connections: addonInternetTotal,
|
|
920
1019
|
...(addonInternetRate > 0 && { requestRate: addonInternetRate }),
|
|
1020
|
+
rawPairs: addonInternetPairs,
|
|
921
1021
|
bytesSent: 0,
|
|
922
1022
|
bytesRecv: 0,
|
|
923
1023
|
flowCount: 1,
|
|
@@ -942,6 +1042,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
942
1042
|
port: 443,
|
|
943
1043
|
connections: addonToK8sTotal,
|
|
944
1044
|
...(addonToK8sRate > 0 && { requestRate: addonToK8sRate }),
|
|
1045
|
+
rawPairs: addonToK8sPairs,
|
|
945
1046
|
bytesSent: 0,
|
|
946
1047
|
bytesRecv: 0,
|
|
947
1048
|
flowCount: 1,
|
|
@@ -950,7 +1051,149 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
950
1051
|
}
|
|
951
1052
|
|
|
952
1053
|
return processedFlows
|
|
953
|
-
}, [
|
|
1054
|
+
}, [focusedFlows, groupAddons])
|
|
1055
|
+
|
|
1056
|
+
// The map is laid out on the main thread, so a view too large to draw is
|
|
1057
|
+
// listed as a table instead of freezing the tab.
|
|
1058
|
+
const drawnGraph = useMemo(() => graphSize(finalFlows), [finalFlows])
|
|
1059
|
+
// Everything that makes it a different view. A refresh doesn't, so a view
|
|
1060
|
+
// that was drawn keeps being drawn as its size drifts past the budget.
|
|
1061
|
+
const viewKey = JSON.stringify([
|
|
1062
|
+
sourcesData?.active ?? '', namespaces, timeRange, focusKey ?? '', groupByWorkload, hideSystem, hideExternal, activeMinConnections,
|
|
1063
|
+
[...hiddenNamespaces].sort(), addonMode, aggregateExternal, detectServices, collapseInternet,
|
|
1064
|
+
l7Protocol, [...activeMethods].sort(), [...activeStatusRanges].sort(), [...activeVerdicts].sort(), activeDnsPattern,
|
|
1065
|
+
])
|
|
1066
|
+
const fitsBudget = drawnGraph.score <= GRAPH_DRAW_BUDGET
|
|
1067
|
+
const tooLargeToDraw = drawnGraph.score > GRAPH_DRAW_CEILING || (!fitsBudget && drawnViewKey !== viewKey)
|
|
1068
|
+
useEffect(() => {
|
|
1069
|
+
if (finalFlows.length > 0 && fitsBudget) setDrawnViewKey(viewKey)
|
|
1070
|
+
}, [finalFlows.length, fitsBudget, viewKey])
|
|
1071
|
+
|
|
1072
|
+
// The table lists the endpoints as they are, so a selection made from it is
|
|
1073
|
+
// traced through the same flows.
|
|
1074
|
+
const selectableFlows = tooLargeToDraw ? focusedFlows : finalFlows
|
|
1075
|
+
// The server edges behind the selection, as drawn: what its records are
|
|
1076
|
+
// narrowed to, whichever form the lookup is sent in.
|
|
1077
|
+
// The flow list follows the focus while nothing else is selected: closing
|
|
1078
|
+
// its details must not widen the list to the whole cluster's sample.
|
|
1079
|
+
const listSelection = useMemo<TrafficGraphSelection | null>(
|
|
1080
|
+
() => graphSelection ?? (focusFound && focusKey ? { type: 'node', nodeId: focusKey } : null),
|
|
1081
|
+
[graphSelection, focusFound, focusKey])
|
|
1082
|
+
const selectionPairs = useMemo(
|
|
1083
|
+
() => selectionRawPairs(selectableFlows, listSelection, groupAddons && !tooLargeToDraw ? e => isClusterAddon(e.name, e.namespace) : undefined),
|
|
1084
|
+
[listSelection, selectableFlows, groupAddons, tooLargeToDraw])
|
|
1085
|
+
const selection = useMemo(() => selectionMatch(selectionPairs, listSelection), [selectionPairs, listSelection])
|
|
1086
|
+
// Ungrouped, a workload focus is drawn as its pods, so no node carries its
|
|
1087
|
+
// id; its records are still the workload's.
|
|
1088
|
+
const focusSelected = listSelection?.type === 'node' && listSelection.nodeId === focusKey && !!focus
|
|
1089
|
+
const recordsMatch = useMemo(
|
|
1090
|
+
() => {
|
|
1091
|
+
if (selection) return selection
|
|
1092
|
+
if (!focusSelected || !focus?.namespace) return null
|
|
1093
|
+
// The owner kind lets Hubble select the workload's pods by name.
|
|
1094
|
+
const pod = focusedFlows.flatMap(f => [f.source, f.destination])
|
|
1095
|
+
.find(e => e.namespace === focus.namespace && e.workload === focus.name && e.workloadKind)
|
|
1096
|
+
return { endpoints: [{ namespace: focus.namespace, name: focus.name, kind: 'Workload', ...(pod && { workloadKind: pod.workloadKind }) }] }
|
|
1097
|
+
},
|
|
1098
|
+
[selection, focusSelected, focus, focusedFlows])
|
|
1099
|
+
|
|
1100
|
+
// A selection names something in the representation it was made in: the
|
|
1101
|
+
// table lists endpoints as they are, the graph may group addons. Switching
|
|
1102
|
+
// between them keeps only a focus's selection, which means the same in both.
|
|
1103
|
+
const prevTooLargeRef = useRef(tooLargeToDraw)
|
|
1104
|
+
useEffect(() => {
|
|
1105
|
+
if (prevTooLargeRef.current === tooLargeToDraw) return
|
|
1106
|
+
prevTooLargeRef.current = tooLargeToDraw
|
|
1107
|
+
setGraphSelection(prev => (prev?.type === 'node' && prev.nodeId === focusKey ? prev : null))
|
|
1108
|
+
}, [tooLargeToDraw, focusKey])
|
|
1109
|
+
|
|
1110
|
+
// Namespaces hidden in the sidebar are dropped at the source too, when the
|
|
1111
|
+
// request has room, so their traffic can't fill the records limit. Only
|
|
1112
|
+
// here: the flows query keeps them, since the sidebar lists them from its
|
|
1113
|
+
// data to be shown again.
|
|
1114
|
+
const hiddenNamespaceList = useMemo(() => [...hiddenNamespaces].sort(), [hiddenNamespaces])
|
|
1115
|
+
const records = useTrafficRecords({
|
|
1116
|
+
namespaces,
|
|
1117
|
+
since: timeRange,
|
|
1118
|
+
excludeNamespaces: hideSystem ? SYSTEM_NAMESPACE_LIST : undefined,
|
|
1119
|
+
optionalExcludeNamespaces: hiddenNamespaceList,
|
|
1120
|
+
excludeHost: hideSystem,
|
|
1121
|
+
match: recordsMatch,
|
|
1122
|
+
enabled: wizardState === 'ready' && !isConnecting && !connectionError,
|
|
1123
|
+
})
|
|
1124
|
+
// Records come from their own query when the selection could be sent;
|
|
1125
|
+
// otherwise the selection is applied to the sample the flows response carries.
|
|
1126
|
+
const recordsEligible = recordsMatch !== null && records.supported && !records.tooLarge
|
|
1127
|
+
const useRecords = recordsEligible && !records.isError
|
|
1128
|
+
|
|
1129
|
+
const responsesCallerOriented = flowsData?.l7ResponsesCallerOriented === true
|
|
1130
|
+
const onSelectedEdge = useMemo(
|
|
1131
|
+
() => selectedEdgeMatcher(selectionPairs, responsesCallerOriented, drawnEndpoint),
|
|
1132
|
+
[selectionPairs, responsesCallerOriented, drawnEndpoint])
|
|
1133
|
+
// An endpoint lookup answers with all of its edges, including ones the view
|
|
1134
|
+
// hides (below the connection threshold), so they are dropped here.
|
|
1135
|
+
const filteredRecords = useMemo(
|
|
1136
|
+
() => (records.data?.flows ?? []).filter(f => rawFlowPasses(f) && onSelectedEdge(f)),
|
|
1137
|
+
[records.data?.flows, rawFlowPasses, onSelectedEdge])
|
|
1138
|
+
// Only the records the view's filters drop: an endpoint lookup also returns
|
|
1139
|
+
// edges the graph doesn't draw (outside a focus, or past the flows cap).
|
|
1140
|
+
const recordsHiddenByFilters = useMemo(
|
|
1141
|
+
() => (records.data?.flows ?? []).filter(f => onSelectedEdge(f) && !rawFlowPasses(f)).length,
|
|
1142
|
+
[records.data?.flows, rawFlowPasses, onSelectedEdge])
|
|
1143
|
+
|
|
1144
|
+
const sampleSelection = useMemo(() => {
|
|
1145
|
+
if (!listSelection) return filteredRawFlows
|
|
1146
|
+
if (focusSelected && focus && !selection) {
|
|
1147
|
+
return filteredRawFlows.filter(f => touchesFocus(f.source, focus) || touchesFocus(f.destination, focus))
|
|
1148
|
+
}
|
|
1149
|
+
if (selectionPairs) return filteredRawFlows.filter(onSelectedEdge)
|
|
1150
|
+
if (listSelection.type === 'node' && listSelection.nodeId) {
|
|
1151
|
+
const id = listSelection.nodeId
|
|
1152
|
+
return filteredRawFlows.filter(f => {
|
|
1153
|
+
const srcId = graphEndpointId(drawnEndpoint(f.source))
|
|
1154
|
+
const dstId = graphEndpointId(drawnEndpoint(f.destination))
|
|
1155
|
+
return srcId === id || dstId === id
|
|
1156
|
+
})
|
|
1157
|
+
}
|
|
1158
|
+
if (listSelection.type === 'edge' && listSelection.sourceId && listSelection.destId) {
|
|
1159
|
+
return filteredRawFlows.filter(f => {
|
|
1160
|
+
const srcId = graphEndpointId(drawnEndpoint(f.source))
|
|
1161
|
+
const dstId = graphEndpointId(drawnEndpoint(f.destination))
|
|
1162
|
+
// Match either direction (request goes A→B, response goes B→A)
|
|
1163
|
+
return (srcId === listSelection.sourceId && dstId === listSelection.destId) ||
|
|
1164
|
+
(srcId === listSelection.destId && dstId === listSelection.sourceId)
|
|
1165
|
+
})
|
|
1166
|
+
}
|
|
1167
|
+
return filteredRawFlows
|
|
1168
|
+
}, [filteredRawFlows, listSelection, selectionPairs, onSelectedEdge, drawnEndpoint, focusSelected, focus, selection])
|
|
1169
|
+
|
|
1170
|
+
const listFlows = useRecords ? filteredRecords : sampleSelection
|
|
1171
|
+
|
|
1172
|
+
const sampleTotal = flowsData?.flowsTotal ?? flowsData?.flows?.length ?? 0
|
|
1173
|
+
const sampleSize = flowsData?.flows?.length ?? 0
|
|
1174
|
+
const listNote = useMemo(() => {
|
|
1175
|
+
if (useRecords) {
|
|
1176
|
+
// This query's own account of what it could see: it is a separate fetch
|
|
1177
|
+
// from the graph's, and an empty list beside a warning is not "no traffic".
|
|
1178
|
+
const data = records.data
|
|
1179
|
+
if (!data) return undefined
|
|
1180
|
+
const parts: string[] = []
|
|
1181
|
+
if (data.warning) parts.push(data.warning)
|
|
1182
|
+
if (data.matched > data.flows.length) {
|
|
1183
|
+
parts.push(`Newest ${data.flows.length.toLocaleString()} of ${data.matched.toLocaleString()} records for this selection`)
|
|
1184
|
+
}
|
|
1185
|
+
if (recordsHiddenByFilters > 0) parts.push(`${recordsHiddenByFilters.toLocaleString()} record${recordsHiddenByFilters === 1 ? '' : 's'} hidden by the view's filters`)
|
|
1186
|
+
const covered = coverageLabel(data.coveredSince, data.timestamp)
|
|
1187
|
+
if (covered) parts.push(`Records cover the ${covered} of ${timeRange}`)
|
|
1188
|
+
return parts.length > 0 ? parts.join(' · ') : undefined
|
|
1189
|
+
}
|
|
1190
|
+
if (sampleTotal <= sampleSize) return undefined
|
|
1191
|
+
const sample = `newest ${sampleSize.toLocaleString()} of ${sampleTotal.toLocaleString()} records`
|
|
1192
|
+
if (!listSelection) return `Showing the ${sample} — select a node or edge to load its records`
|
|
1193
|
+
if (records.tooLarge) return `This selection is too large to look up on its own; showing its flows among the ${sample}`
|
|
1194
|
+
if (records.isError) return `Couldn't load this selection's records (${records.error?.message}); showing its flows among the ${sample}`
|
|
1195
|
+
return `Showing this selection's flows among the ${sample}`
|
|
1196
|
+
}, [useRecords, records.data, recordsHiddenByFilters, records.tooLarge, records.isError, records.error, sampleTotal, sampleSize, listSelection, timeRange])
|
|
954
1197
|
|
|
955
1198
|
// Stats for display
|
|
956
1199
|
const flowStats = useMemo(() => {
|
|
@@ -958,9 +1201,10 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
958
1201
|
const filtered = filteredFlows.length
|
|
959
1202
|
const shown = finalFlows.length
|
|
960
1203
|
const hidden = total - filtered
|
|
961
|
-
const
|
|
962
|
-
|
|
963
|
-
|
|
1204
|
+
const outsideFocus = internetCollapsedFlows.length - focusedFlows.length
|
|
1205
|
+
const aggregated = filtered - shown - outsideFocus
|
|
1206
|
+
return { total, filtered, shown, hidden, outsideFocus, aggregated }
|
|
1207
|
+
}, [flowsData?.aggregated?.length, filteredFlows.length, finalFlows.length, internetCollapsedFlows.length, focusedFlows.length])
|
|
964
1208
|
|
|
965
1209
|
// Compute hot path threshold (top 10% of connections)
|
|
966
1210
|
const hotPathThreshold = useMemo(() => {
|
|
@@ -1020,6 +1264,33 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1020
1264
|
}))
|
|
1021
1265
|
}, [flowsData?.aggregated, hideSystem, hideExternal, activeMinConnections])
|
|
1022
1266
|
|
|
1267
|
+
const namespaceScopeQuery = useNamespaceScope()
|
|
1268
|
+
const namespaceLocked = !!namespaceScopeQuery.data?.cacheScoped && !namespaceScopeQuery.data?.namespaceRescope
|
|
1269
|
+
// Narrowing to one namespace keeps the edges with either end in it, as the
|
|
1270
|
+
// server does, so these are what each choice would show. With one namespace
|
|
1271
|
+
// in view already, the others listed are only its peers: picking one would
|
|
1272
|
+
// switch, not narrow, so the panel points at focus and the sidebar instead.
|
|
1273
|
+
const namespaceChoices = useMemo(() => {
|
|
1274
|
+
if (!onSetNamespaces || namespaceLocked || focus || namespaces.length === 1) return null
|
|
1275
|
+
const choices = namespaceSummaries(finalFlows)
|
|
1276
|
+
return choices.length > 0 ? choices : null
|
|
1277
|
+
}, [onSetNamespaces, namespaceLocked, focus, finalFlows, namespaces])
|
|
1278
|
+
|
|
1279
|
+
const focusPartial = !!focus?.namespace && namespaces.length > 0 && !namespaces.includes(focus.namespace)
|
|
1280
|
+
|
|
1281
|
+
const resetFilters = useCallback(() => {
|
|
1282
|
+
setHideSystem(false)
|
|
1283
|
+
setHideExternal(false)
|
|
1284
|
+
chooseMinConnections(0)
|
|
1285
|
+
setHiddenNamespaces(new Set())
|
|
1286
|
+
setAddonMode('show')
|
|
1287
|
+
setL7Protocol('all')
|
|
1288
|
+
setL7Methods(new Set())
|
|
1289
|
+
setL7StatusRanges(new Set())
|
|
1290
|
+
setL7Verdicts(new Set())
|
|
1291
|
+
setDnsPattern('')
|
|
1292
|
+
}, [chooseMinConnections])
|
|
1293
|
+
|
|
1023
1294
|
// Determine wizard state based on sources detection
|
|
1024
1295
|
useEffect(() => {
|
|
1025
1296
|
if (sourcesLoading) {
|
|
@@ -1046,24 +1317,51 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1046
1317
|
// the auto-connect effect, and re-arming it would re-fire auto-connect on
|
|
1047
1318
|
// every failed attempt (unbounded loop). Retries come from the explicit
|
|
1048
1319
|
// Retry button, which calls handleConnect directly.
|
|
1049
|
-
|
|
1320
|
+
//
|
|
1321
|
+
// The page opens on a detection that may be a couple of minutes old, so the
|
|
1322
|
+
// source it names may since have gone. A retry detects again first, which
|
|
1323
|
+
// also picks another available source, and the first failed connect gets
|
|
1324
|
+
// one such retry on its own.
|
|
1325
|
+
const handleConnect = useCallback((redetect: boolean) => {
|
|
1050
1326
|
setIsConnecting(true)
|
|
1051
1327
|
setConnectionError(null)
|
|
1052
1328
|
queryClient.removeQueries({ queryKey: ['traffic-flows'] })
|
|
1053
1329
|
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1330
|
+
const attempt = (fresh: boolean) => {
|
|
1331
|
+
const fail = (message: string) => {
|
|
1332
|
+
if (!fresh) {
|
|
1333
|
+
attempt(true)
|
|
1334
|
+
return
|
|
1059
1335
|
}
|
|
1060
|
-
},
|
|
1061
|
-
onError: (error) => {
|
|
1062
1336
|
setIsConnecting(false)
|
|
1063
|
-
setConnectionError(
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
|
|
1337
|
+
setConnectionError(message)
|
|
1338
|
+
}
|
|
1339
|
+
const detected = fresh ? detectSourcesAgain() : Promise.resolve(undefined)
|
|
1340
|
+
detected.then(
|
|
1341
|
+
() => connectMutation.mutate(undefined, {
|
|
1342
|
+
onSuccess: (data) => {
|
|
1343
|
+
if (!data.connected && data.error) {
|
|
1344
|
+
fail(data.error)
|
|
1345
|
+
return
|
|
1346
|
+
}
|
|
1347
|
+
setIsConnecting(false)
|
|
1348
|
+
},
|
|
1349
|
+
onError: (error) => fail(error.message),
|
|
1350
|
+
}),
|
|
1351
|
+
(error: Error) => {
|
|
1352
|
+
setIsConnecting(false)
|
|
1353
|
+
setConnectionError(error.message)
|
|
1354
|
+
},
|
|
1355
|
+
)
|
|
1356
|
+
}
|
|
1357
|
+
attempt(redetect)
|
|
1358
|
+
}, [connectMutation, queryClient, detectSourcesAgain])
|
|
1359
|
+
|
|
1360
|
+
// The wizard polls without waiting on the answer; a failed detection shows
|
|
1361
|
+
// through the sources query's own state.
|
|
1362
|
+
const detectForWizard = useCallback(() => {
|
|
1363
|
+
detectSourcesAgain().catch(() => {})
|
|
1364
|
+
}, [detectSourcesAgain])
|
|
1067
1365
|
|
|
1068
1366
|
// Auto-connect once when a source is first detected. Strictly one-shot per
|
|
1069
1367
|
// mount / cluster (the ref resets on cluster change); failures surface a
|
|
@@ -1071,7 +1369,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1071
1369
|
useEffect(() => {
|
|
1072
1370
|
if (wizardState === 'ready' && !hasAutoConnectedRef.current && !isConnecting) {
|
|
1073
1371
|
hasAutoConnectedRef.current = true
|
|
1074
|
-
handleConnect()
|
|
1372
|
+
handleConnect(false)
|
|
1075
1373
|
}
|
|
1076
1374
|
}, [wizardState, isConnecting, handleConnect])
|
|
1077
1375
|
|
|
@@ -1083,13 +1381,20 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1083
1381
|
setState={setWizardState}
|
|
1084
1382
|
sourcesData={sourcesData}
|
|
1085
1383
|
sourcesLoading={sourcesLoading}
|
|
1086
|
-
onRefetch={
|
|
1384
|
+
onRefetch={detectForWizard}
|
|
1087
1385
|
/>
|
|
1088
1386
|
)
|
|
1089
1387
|
}
|
|
1090
1388
|
|
|
1091
1389
|
return (
|
|
1092
|
-
<TrafficFlowListProvider
|
|
1390
|
+
<TrafficFlowListProvider
|
|
1391
|
+
flows={listFlows}
|
|
1392
|
+
responsesCallerOriented={(useRecords ? records.data?.l7ResponsesCallerOriented : flowsData?.l7ResponsesCallerOriented) === true}
|
|
1393
|
+
graphSelection={graphSelection}
|
|
1394
|
+
clearSelection={clearGraphSelection}
|
|
1395
|
+
loading={useRecords && records.isLoading}
|
|
1396
|
+
note={listNote}
|
|
1397
|
+
>
|
|
1093
1398
|
<div className="flex h-full w-full">
|
|
1094
1399
|
{/* Sidebar */}
|
|
1095
1400
|
<TrafficFilterSidebar
|
|
@@ -1103,6 +1408,8 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1103
1408
|
setShowNamespaceGroups={setShowNamespaceGroups}
|
|
1104
1409
|
collapseInternet={collapseInternet}
|
|
1105
1410
|
setCollapseInternet={setCollapseInternet}
|
|
1411
|
+
groupByWorkload={groupByWorkload}
|
|
1412
|
+
setGroupByWorkload={setGroupByWorkload}
|
|
1106
1413
|
addonMode={addonMode}
|
|
1107
1414
|
setAddonMode={setAddonMode}
|
|
1108
1415
|
aggregateExternal={aggregateExternal}
|
|
@@ -1128,12 +1435,13 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1128
1435
|
dnsPattern={dnsPattern}
|
|
1129
1436
|
setDnsPattern={setDnsPattern}
|
|
1130
1437
|
namespaces={namespacesWithCounts}
|
|
1131
|
-
hiddenNamespaces={
|
|
1438
|
+
hiddenNamespaces={chosenHiddenNamespaces}
|
|
1132
1439
|
onToggleNamespace={toggleNamespace}
|
|
1440
|
+
namespacesPausedFor={focus?.name}
|
|
1133
1441
|
/>
|
|
1134
1442
|
|
|
1135
1443
|
{/* Main content area */}
|
|
1136
|
-
<div className="flex-1 relative min-w-0">
|
|
1444
|
+
<div ref={graphPaneRef} className="flex-1 relative min-w-0">
|
|
1137
1445
|
{/* Floating controls — overlaid on graph like topology view */}
|
|
1138
1446
|
{(() => {
|
|
1139
1447
|
const availableSources = sourcesData?.detected.filter(s => s.status === 'available') || []
|
|
@@ -1164,8 +1472,42 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1164
1472
|
|
|
1165
1473
|
return (
|
|
1166
1474
|
<>
|
|
1167
|
-
{/* Top-left: source status pill */}
|
|
1475
|
+
{/* Top-left: source status pill, focus */}
|
|
1168
1476
|
<div className="absolute top-3 left-3 z-10 flex items-center gap-2">
|
|
1477
|
+
{focus ? (
|
|
1478
|
+
<div className="flex items-center gap-1.5 pl-2 pr-1 py-1 rounded-lg bg-theme-surface/90 backdrop-blur border border-skyhook-500/40 text-[11px]">
|
|
1479
|
+
<Crosshair className="h-3 w-3 text-skyhook-500" />
|
|
1480
|
+
<span className="text-theme-text-secondary">Focused on</span>
|
|
1481
|
+
<span className="font-medium text-theme-text-primary">{focus.name}</span>
|
|
1482
|
+
{focus.namespace && <span className="text-theme-text-tertiary">({focus.namespace})</span>}
|
|
1483
|
+
{focusPartial && (
|
|
1484
|
+
// Narrowing fetches the edges with an end in the chosen
|
|
1485
|
+
// namespaces, so a focus outside them shows only those.
|
|
1486
|
+
<>
|
|
1487
|
+
<span className={SEVERITY_TEXT.warning}>· only its traffic with {namespaces.join(', ')}</span>
|
|
1488
|
+
{onSetNamespaces && !namespaceLocked && (
|
|
1489
|
+
<button type="button" onClick={() => onSetNamespaces([focus.namespace!])} className="text-blue-400 hover:text-blue-300 font-medium">
|
|
1490
|
+
Show all its traffic
|
|
1491
|
+
</button>
|
|
1492
|
+
)}
|
|
1493
|
+
</>
|
|
1494
|
+
)}
|
|
1495
|
+
<Tooltip content="Leave focus">
|
|
1496
|
+
<button type="button" onClick={() => setFocus(null)} aria-label="Leave focus" className="p-0.5 rounded hover:bg-theme-hover text-theme-text-secondary">
|
|
1497
|
+
<X className="h-3 w-3" />
|
|
1498
|
+
</button>
|
|
1499
|
+
</Tooltip>
|
|
1500
|
+
</div>
|
|
1501
|
+
) : null}
|
|
1502
|
+
{/* The too-large panel carries its own search button. */}
|
|
1503
|
+
{finalFlows.length > 0 && !tooLargeToDraw && !isConnecting && (
|
|
1504
|
+
<TrafficFocusSearch
|
|
1505
|
+
endpoints={focusableEndpoints}
|
|
1506
|
+
onFocus={setFocus}
|
|
1507
|
+
isRateBased={isRateBased}
|
|
1508
|
+
overlayContainer={graphPaneRef.current}
|
|
1509
|
+
/>
|
|
1510
|
+
)}
|
|
1169
1511
|
{activeSource && (
|
|
1170
1512
|
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-theme-surface/90 backdrop-blur border border-theme-border text-[11px]">
|
|
1171
1513
|
{isConnecting ? (
|
|
@@ -1177,7 +1519,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1177
1519
|
<>
|
|
1178
1520
|
<span className="w-2 h-2 rounded-full bg-yellow-500" />
|
|
1179
1521
|
<span className="text-theme-text-secondary">{activeSource.name}</span>
|
|
1180
|
-
<button onClick={handleConnect} className="text-yellow-500 hover:text-yellow-400 font-medium">retry</button>
|
|
1522
|
+
<button onClick={() => handleConnect(true)} className="text-yellow-500 hover:text-yellow-400 font-medium">retry</button>
|
|
1181
1523
|
</>
|
|
1182
1524
|
) : (
|
|
1183
1525
|
<>
|
|
@@ -1220,15 +1562,17 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1220
1562
|
{coverage && (
|
|
1221
1563
|
// Not a warning: a busy cluster reaches the limit on every
|
|
1222
1564
|
// fetch. It says which part of the window the map shows.
|
|
1223
|
-
<Tooltip content={
|
|
1565
|
+
<Tooltip content={coverageExplanation(flowsData, timeRange, hideSystem)}>
|
|
1224
1566
|
<div className="flex items-center gap-1 px-2 py-1 rounded-lg bg-theme-surface/90 backdrop-blur border border-theme-border text-[10px] text-theme-text-secondary tabular-nums">
|
|
1225
1567
|
<Clock className="w-3 h-3" /> {coverage} of {timeRange}
|
|
1226
1568
|
</div>
|
|
1227
1569
|
</Tooltip>
|
|
1228
1570
|
)}
|
|
1229
|
-
<
|
|
1230
|
-
|
|
1231
|
-
|
|
1571
|
+
<Tooltip content={flowStatsExplanation(flowStats)}>
|
|
1572
|
+
<div className="flex items-center px-2 py-1 rounded-lg bg-theme-surface/90 backdrop-blur border border-theme-border text-[10px] text-theme-text-tertiary tabular-nums">
|
|
1573
|
+
{flowStats.shown}/{flowStats.total}
|
|
1574
|
+
</div>
|
|
1575
|
+
</Tooltip>
|
|
1232
1576
|
{/* Flows are a REST snapshot (no poll, no stream), so this is
|
|
1233
1577
|
an honest "Updated N ago" + manual refresh — not "live". */}
|
|
1234
1578
|
<div className="flex items-center rounded-lg bg-theme-surface/90 backdrop-blur border border-theme-border px-1.5 py-0.5">
|
|
@@ -1236,7 +1580,10 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1236
1580
|
mode="snapshot"
|
|
1237
1581
|
dataUpdatedAt={flowsUpdatedAt}
|
|
1238
1582
|
isFetching={flowsFetching}
|
|
1239
|
-
onRefresh={() =>
|
|
1583
|
+
onRefresh={() => {
|
|
1584
|
+
refetchFlowsRaw()
|
|
1585
|
+
if (recordsEligible) records.refetch()
|
|
1586
|
+
}}
|
|
1240
1587
|
connectionState={connection.state}
|
|
1241
1588
|
/>
|
|
1242
1589
|
</div>
|
|
@@ -1252,7 +1599,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1252
1599
|
/>
|
|
1253
1600
|
) : finalFlows.length > 0 ? (
|
|
1254
1601
|
<>
|
|
1255
|
-
{
|
|
1602
|
+
{flowsWarning && !tooLargeToDraw && (
|
|
1256
1603
|
// Sits below the two chip rows (both top-3) rather than beside
|
|
1257
1604
|
// them: centred at that height it would cover the flow count and
|
|
1258
1605
|
// the refresh control at common widths. role/aria-live because it
|
|
@@ -1262,33 +1609,60 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1262
1609
|
aria-live="polite"
|
|
1263
1610
|
className="absolute top-14 left-1/2 z-10 w-[min(40rem,calc(100%-1.5rem))] -translate-x-1/2"
|
|
1264
1611
|
>
|
|
1265
|
-
|
|
1266
|
-
variant="warning"
|
|
1267
|
-
// The title follows the kind. A partial warning is about values
|
|
1268
|
-
// on edges that are shown — a port reported as 0, UDP as TCP, a
|
|
1269
|
-
// 5xx rate that failed to load — so it says "unreliable", not
|
|
1270
|
-
// "incomplete", which would send the reader looking for missing
|
|
1271
|
-
// workloads. An incomplete or transient one beside flows is
|
|
1272
|
-
// exactly that: a stream cut short, events lost, a node the
|
|
1273
|
-
// relay could not reach, a TCP query that failed — edges may
|
|
1274
|
-
// be missing from what is drawn.
|
|
1275
|
-
title={warningIsPermanent ? 'Some values on this map are unreliable' : 'Some traffic may be missing from this map'}
|
|
1276
|
-
message={flowsData.warning}
|
|
1277
|
-
/>
|
|
1612
|
+
{flowsWarning}
|
|
1278
1613
|
</div>
|
|
1279
1614
|
)}
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1615
|
+
{tooLargeToDraw ? (
|
|
1616
|
+
<TrafficGraphTooLarge
|
|
1617
|
+
graph={drawnGraph}
|
|
1618
|
+
focusName={focus?.name}
|
|
1619
|
+
canFilterNamespaces={!focus && namespacesWithCounts.length > 1}
|
|
1620
|
+
onDrawAnyway={drawnGraph.score <= GRAPH_DRAW_CEILING ? () => setDrawnViewKey(viewKey) : undefined}
|
|
1621
|
+
namespaces={namespaceChoices}
|
|
1622
|
+
onPickNamespace={ns => onSetNamespaces?.([ns])}
|
|
1623
|
+
endpoints={focusableEndpoints}
|
|
1624
|
+
onFocus={setFocus}
|
|
1625
|
+
isRateBased={isRateBased}
|
|
1626
|
+
overlayContainer={graphPaneRef.current}
|
|
1627
|
+
flows={focusedFlows}
|
|
1628
|
+
selection={graphSelection}
|
|
1629
|
+
onSelect={setGraphSelection}
|
|
1630
|
+
focusedId={focusKey}
|
|
1631
|
+
notice={flowsWarning && <div role="status" aria-live="polite">{flowsWarning}</div>}
|
|
1632
|
+
/>
|
|
1633
|
+
) : (
|
|
1634
|
+
<TrafficGraph
|
|
1635
|
+
flows={finalFlows}
|
|
1636
|
+
hotPathThreshold={hotPathThreshold}
|
|
1637
|
+
showNamespaceGroups={showNamespaceGroups}
|
|
1638
|
+
serviceCategories={serviceCategories}
|
|
1639
|
+
addonMode={groupAddons ? 'group' : addonMode === 'group' ? 'show' : addonMode}
|
|
1640
|
+
trafficSource={sourcesData?.active || ''}
|
|
1641
|
+
selection={graphSelection}
|
|
1642
|
+
onSelectionChange={setGraphSelection}
|
|
1643
|
+
onFocus={setFocus}
|
|
1644
|
+
focusedId={focusKey}
|
|
1645
|
+
/>
|
|
1646
|
+
)}
|
|
1289
1647
|
</>
|
|
1648
|
+
) : focus && flowsData && !connectionError ? (
|
|
1649
|
+
<div className="absolute inset-0 grid grid-rows-[1fr_auto_2fr] justify-items-center [&>*]:row-start-2 px-4">
|
|
1650
|
+
<FocusNotFound
|
|
1651
|
+
focus={focus}
|
|
1652
|
+
hiddenByFilters={(flowsData?.aggregated ?? []).some(f => touchesFocus(f.source, focus) || touchesFocus(f.destination, focus))}
|
|
1653
|
+
systemHidden={hideSystem && !!focus.namespace && SYSTEM_NAMESPACES.has(focus.namespace)}
|
|
1654
|
+
hideSystem={hideSystem}
|
|
1655
|
+
outsideNamespaces={namespaces.length > 0 && !!focus.namespace && !namespaces.includes(focus.namespace)}
|
|
1656
|
+
window={coverage ? `${coverage} of ${timeRange}` : `last ${timeRange}`}
|
|
1657
|
+
onShowAll={resetFilters}
|
|
1658
|
+
onShowSystem={() => setHideSystem(false)}
|
|
1659
|
+
onSwitchNamespace={onSetNamespaces && !namespaceLocked && focus.namespace ? () => onSetNamespaces([focus.namespace!]) : undefined}
|
|
1660
|
+
warning={flowsData.warning}
|
|
1661
|
+
onLeave={() => setFocus(null)}
|
|
1662
|
+
/>
|
|
1663
|
+
</div>
|
|
1290
1664
|
) : connectionError ? (
|
|
1291
|
-
<div className="absolute inset-0
|
|
1665
|
+
<div className="absolute inset-0 grid grid-rows-[1fr_auto_2fr] justify-items-center [&>*]:row-start-2">
|
|
1292
1666
|
<div className="text-center space-y-3">
|
|
1293
1667
|
<Plug className="h-12 w-12 text-yellow-500 mx-auto" />
|
|
1294
1668
|
<p className="text-theme-text-secondary">Connection failed</p>
|
|
@@ -1296,7 +1670,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1296
1670
|
{connectionError}
|
|
1297
1671
|
</p>
|
|
1298
1672
|
<button
|
|
1299
|
-
onClick={handleConnect}
|
|
1673
|
+
onClick={() => handleConnect(true)}
|
|
1300
1674
|
className="px-3 py-1.5 text-sm btn-brand rounded"
|
|
1301
1675
|
>
|
|
1302
1676
|
Retry Connection
|
|
@@ -1304,7 +1678,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1304
1678
|
</div>
|
|
1305
1679
|
</div>
|
|
1306
1680
|
) : (
|
|
1307
|
-
<div className="absolute inset-0
|
|
1681
|
+
<div className="absolute inset-0 grid grid-rows-[1fr_auto_2fr] justify-items-center [&>*]:row-start-2 px-4">
|
|
1308
1682
|
{flowStats.total > 0 && flowStats.shown === 0 ? (
|
|
1309
1683
|
<EmptyState
|
|
1310
1684
|
tone="filtered"
|
|
@@ -1319,18 +1693,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1319
1693
|
// the user pressing a button that promised everything back and
|
|
1320
1694
|
// changed nothing whenever the cause was a namespace, an addon
|
|
1321
1695
|
// mode or an L7 choice.
|
|
1322
|
-
onClick={
|
|
1323
|
-
setHideSystem(false)
|
|
1324
|
-
setHideExternal(false)
|
|
1325
|
-
chooseMinConnections(0)
|
|
1326
|
-
setHiddenNamespaces(new Set())
|
|
1327
|
-
setAddonMode('show')
|
|
1328
|
-
setL7Protocol('all')
|
|
1329
|
-
setL7Methods(new Set())
|
|
1330
|
-
setL7StatusRanges(new Set())
|
|
1331
|
-
setL7Verdicts(new Set())
|
|
1332
|
-
setDnsPattern('')
|
|
1333
|
-
}}
|
|
1696
|
+
onClick={resetFilters}
|
|
1334
1697
|
className="badge badge-sm border border-theme-border bg-theme-elevated text-theme-text-primary hover:bg-theme-hover transition-colors"
|
|
1335
1698
|
>
|
|
1336
1699
|
Show all
|
|
@@ -1374,3 +1737,73 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1374
1737
|
</TrafficFlowListProvider>
|
|
1375
1738
|
)
|
|
1376
1739
|
}
|
|
1740
|
+
|
|
1741
|
+
/**
|
|
1742
|
+
* A focus with nothing to show. Says which of the reasons it is, since only
|
|
1743
|
+
* one of them means the workload is quiet: Radar may have hidden its traffic,
|
|
1744
|
+
* or never fetched it.
|
|
1745
|
+
*/
|
|
1746
|
+
function FocusNotFound({ focus, hiddenByFilters, systemHidden, hideSystem, outsideNamespaces, window, onShowAll, onShowSystem, onSwitchNamespace, warning, onLeave }: {
|
|
1747
|
+
focus: TrafficFocus
|
|
1748
|
+
/** Hide System is on: traffic with system namespaces was never fetched. */
|
|
1749
|
+
hideSystem: boolean
|
|
1750
|
+
/** The fetch's own warning: with it, absence is not proof of quiet. */
|
|
1751
|
+
warning?: string
|
|
1752
|
+
hiddenByFilters: boolean
|
|
1753
|
+
systemHidden: boolean
|
|
1754
|
+
outsideNamespaces: boolean
|
|
1755
|
+
window: string
|
|
1756
|
+
onShowAll: () => void
|
|
1757
|
+
onShowSystem: () => void
|
|
1758
|
+
onSwitchNamespace?: () => void
|
|
1759
|
+
onLeave: () => void
|
|
1760
|
+
}) {
|
|
1761
|
+
const name = focus.namespace ? `${focus.name} (${focus.namespace})` : focus.name
|
|
1762
|
+
let body: string
|
|
1763
|
+
let action: { label: string; onClick: () => void } | undefined
|
|
1764
|
+
if (hiddenByFilters) {
|
|
1765
|
+
body = 'Its traffic is hidden by the current filters.'
|
|
1766
|
+
action = { label: 'Show all traffic', onClick: onShowAll }
|
|
1767
|
+
} else if (systemHidden) {
|
|
1768
|
+
body = `${focus.namespace} is a system namespace, and Hide System keeps its traffic from being fetched.`
|
|
1769
|
+
action = { label: 'Show system traffic', onClick: onShowSystem }
|
|
1770
|
+
} else if (outsideNamespaces) {
|
|
1771
|
+
body = `${focus.namespace} is outside the namespaces in view, so only its traffic with them was fetched, and there was none.`
|
|
1772
|
+
if (onSwitchNamespace) action = { label: `Switch to ${focus.namespace}`, onClick: onSwitchNamespace }
|
|
1773
|
+
} else if (warning) {
|
|
1774
|
+
body = `Radar found none in the ${window}, but some traffic may be missing.`
|
|
1775
|
+
} else if (hideSystem) {
|
|
1776
|
+
body = `Radar saw no traffic to or from it in the ${window}, other than any with system namespaces, which Hide System keeps from being fetched.`
|
|
1777
|
+
action = { label: 'Show system traffic', onClick: onShowSystem }
|
|
1778
|
+
} else {
|
|
1779
|
+
body = `Radar saw no traffic to or from it in the ${window}.`
|
|
1780
|
+
}
|
|
1781
|
+
// Whatever else applies, a fetch that reported trouble is not proof of quiet.
|
|
1782
|
+
if (warning) body += ` ${warning}`
|
|
1783
|
+
return (
|
|
1784
|
+
<EmptyState
|
|
1785
|
+
tone="neutral"
|
|
1786
|
+
variant="card"
|
|
1787
|
+
icon={Crosshair}
|
|
1788
|
+
headline={`No traffic for ${name}`}
|
|
1789
|
+
body={body}
|
|
1790
|
+
action={
|
|
1791
|
+
<div className="flex flex-wrap items-center justify-center gap-2">
|
|
1792
|
+
{action && (
|
|
1793
|
+
<button type="button" onClick={action.onClick} className="btn-brand-muted text-xs px-2.5 py-1 rounded-md">
|
|
1794
|
+
{action.label}
|
|
1795
|
+
</button>
|
|
1796
|
+
)}
|
|
1797
|
+
<button
|
|
1798
|
+
type="button"
|
|
1799
|
+
onClick={onLeave}
|
|
1800
|
+
className="text-xs px-2.5 py-1 rounded-md border border-theme-border text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-hover"
|
|
1801
|
+
>
|
|
1802
|
+
Leave focus
|
|
1803
|
+
</button>
|
|
1804
|
+
</div>
|
|
1805
|
+
}
|
|
1806
|
+
className="max-w-md"
|
|
1807
|
+
/>
|
|
1808
|
+
)
|
|
1809
|
+
}
|