@skyhook-io/radar-app 1.10.0 → 1.12.2
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 +10 -10
- package/src/App.tsx +5 -8
- package/src/api/client.ts +174 -12
- package/src/api/policy.test.ts +38 -0
- package/src/api/policy.ts +166 -2
- package/src/components/ConnectionErrorView.test.tsx +53 -0
- package/src/components/ConnectionErrorView.tsx +15 -12
- package/src/components/ContextSwitcher.tsx +10 -13
- package/src/components/audit/UpgradeReadinessView.tsx +3 -3
- package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
- package/src/components/capacity/schedulingBar.test.ts +10 -0
- package/src/components/cost/ApplicationCostTab.test.ts +6 -0
- package/src/components/cost/ApplicationCostTab.tsx +28 -16
- package/src/components/cost/CostTrendChart.tsx +20 -10
- package/src/components/cost/CostView.tsx +79 -28
- package/src/components/cost/CurrentAllocationUse.tsx +6 -4
- package/src/components/cost/WorkloadCostTab.test.ts +10 -0
- package/src/components/cost/WorkloadCostTab.tsx +24 -12
- package/src/components/cost/format.test.ts +27 -8
- package/src/components/cost/format.ts +78 -27
- package/src/components/home/ClusterHealthCard.tsx +3 -0
- package/src/components/home/CostCard.tsx +12 -7
- package/src/components/home/HomeView.tsx +15 -3
- package/src/components/home/NetworkPolicyCoverageCard.test.tsx +81 -0
- package/src/components/home/NetworkPolicyCoverageCard.tsx +50 -7
- package/src/components/home/TopologyPreview.tsx +57 -11
- package/src/components/home/mcpToolCatalog.ts +27 -5
- package/src/components/nav/PrimaryNavRail.tsx +2 -2
- package/src/components/resources/ResourcesView.tsx +15 -3
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
- package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
- package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
- package/src/components/settings/SettingsDialog.tsx +160 -36
- package/src/components/settings/currency-options.test.ts +49 -0
- package/src/components/settings/currency-options.ts +38 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +37 -20
- package/src/components/traffic/TrafficFlowList.tsx +16 -2
- package/src/components/traffic/TrafficGraph.tsx +150 -58
- package/src/components/traffic/TrafficView.tsx +168 -52
- package/src/components/traffic/TrafficWizard.tsx +13 -1
- package/src/components/traffic/trafficFilters.test.ts +103 -0
- package/src/components/traffic/trafficFilters.ts +117 -0
- package/src/components/ui/DiagnosticsOverlay.test.ts +115 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +65 -8
- package/src/components/ui/command-items.ts +4 -14
- package/src/components/workload/WorkloadView.tsx +57 -8
- package/src/main.tsx +4 -114
- package/src/utils/context-name.test.ts +63 -0
- package/src/utils/context-name.ts +22 -0
- package/src/utils/navigation.ts +44 -2
- package/src/utils/network-policy-navigation.test.ts +68 -0
- package/src/utils/topology-selection.test.ts +40 -0
- package/src/utils/topology-selection.ts +39 -0
- package/src/utils/wails-clipboard.test.ts +109 -0
- package/src/utils/wails-clipboard.ts +127 -0
|
@@ -10,9 +10,10 @@ import { Loader2, Filter, Plug, ChevronDown, List, Activity, AlertTriangle } fro
|
|
|
10
10
|
import { clsx } from 'clsx'
|
|
11
11
|
import { useQueryClient } from '@tanstack/react-query'
|
|
12
12
|
import { useDock } from '../dock'
|
|
13
|
-
import { EmptyState, PaneLoader, FreshnessControl } from '@skyhook-io/k8s-ui'
|
|
13
|
+
import { AlertBanner, EmptyState, PaneLoader, FreshnessControl } from '@skyhook-io/k8s-ui'
|
|
14
14
|
import { useConnection } from '../../context/ConnectionContext'
|
|
15
15
|
import { Tooltip } from '../ui/Tooltip'
|
|
16
|
+
import { matchesStatusRanges, bucketsFromCounts, bucketsFromStatus, isRateBasedSource, keepAvailable, effectiveThreshold, volumeUnit, type VolumeUnit } from './trafficFilters'
|
|
16
17
|
|
|
17
18
|
// Addon types for filtering
|
|
18
19
|
export type AddonMode = 'show' | 'group' | 'hide'
|
|
@@ -345,6 +346,10 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
345
346
|
const [hideSystem, setHideSystem] = useState(true)
|
|
346
347
|
const [hideExternal, setHideExternal] = useState(false)
|
|
347
348
|
const [minConnections, setMinConnections] = useState(0)
|
|
349
|
+
// The unit the threshold above was picked under. Stored because the number alone
|
|
350
|
+
// is ambiguous: 100 is a valid step in both scales and means something different
|
|
351
|
+
// in each.
|
|
352
|
+
const [minConnectionsUnit, setMinConnectionsUnit] = useState<VolumeUnit>('connections')
|
|
348
353
|
const [showNamespaceGroups, setShowNamespaceGroups] = useState(true)
|
|
349
354
|
const [aggregateExternal, setAggregateExternal] = useState(true)
|
|
350
355
|
const [detectServices, setDetectServices] = useState(true)
|
|
@@ -432,16 +437,80 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
432
437
|
enabled: wizardState === 'ready' && !isConnecting && !connectionError,
|
|
433
438
|
})
|
|
434
439
|
|
|
440
|
+
// A 'partial' warning is the source telling us this is as complete as it gets —
|
|
441
|
+
// an attribute it does not export, traffic it cannot orient. Retrying returns
|
|
442
|
+
// the same answer, so retrying forever is all cost and no progress.
|
|
443
|
+
const warningIsPermanent = flowsData?.warningKind === 'partial'
|
|
444
|
+
|
|
435
445
|
// Auto-retry when flows return with warning but no data (e.g., port-forward not ready yet)
|
|
436
446
|
useEffect(() => {
|
|
437
|
-
if (
|
|
447
|
+
if (
|
|
448
|
+
flowsData?.warning &&
|
|
449
|
+
!warningIsPermanent &&
|
|
450
|
+
(!flowsData.aggregated || flowsData.aggregated.length === 0) &&
|
|
451
|
+
!flowsFetching
|
|
452
|
+
) {
|
|
438
453
|
const timer = setTimeout(() => refetchFlowsRaw(), 2000)
|
|
439
454
|
return () => clearTimeout(timer)
|
|
440
455
|
}
|
|
441
|
-
}, [flowsData, flowsFetching, refetchFlowsRaw])
|
|
456
|
+
}, [flowsData, warningIsPermanent, flowsFetching, refetchFlowsRaw])
|
|
442
457
|
|
|
443
458
|
// Filter flows based on user preferences
|
|
444
459
|
// Note: namespace filtering is done server-side via the global namespace selector
|
|
460
|
+
// Show L7 filters only when flows actually contain L7 data
|
|
461
|
+
const hasL7Data = useMemo(() => {
|
|
462
|
+
if (!flowsData?.aggregated) return false
|
|
463
|
+
return flowsData.aggregated.some(f => f.l7Protocol || f.topHTTPPaths || f.topDNSQueries)
|
|
464
|
+
}, [flowsData?.aggregated])
|
|
465
|
+
|
|
466
|
+
const isRateBased = isRateBasedSource(sourcesData?.active)
|
|
467
|
+
|
|
468
|
+
// Which L7 filters can return something, so the sidebar never offers a control
|
|
469
|
+
// that matches nothing. Sources differ in what they can report: a rate-based one
|
|
470
|
+
// measures a 5xx rate but no status distribution, and has no DNS query names at
|
|
471
|
+
// all, so offering 2xx or a DNS box would be a dead end.
|
|
472
|
+
const l7Capabilities = useMemo(() => {
|
|
473
|
+
const statuses = new Set<string>()
|
|
474
|
+
const verdicts = new Set<string>()
|
|
475
|
+
let hasDNSQueries = false
|
|
476
|
+
const methods = new Set<string>()
|
|
477
|
+
for (const f of flowsData?.aggregated ?? []) {
|
|
478
|
+
for (const [bucket, count] of Object.entries(f.httpStatusCounts ?? {})) {
|
|
479
|
+
if (count > 0) statuses.add(bucket)
|
|
480
|
+
}
|
|
481
|
+
// An error rate is a 5xx signal even where no status distribution exists.
|
|
482
|
+
if ((f.errorCount ?? 0) > 0) statuses.add('5xx')
|
|
483
|
+
for (const [verdict, count] of Object.entries(f.verdictCounts ?? {})) {
|
|
484
|
+
if (count > 0) verdicts.add(verdict)
|
|
485
|
+
}
|
|
486
|
+
if (f.topDNSQueries?.length) hasDNSQueries = true
|
|
487
|
+
for (const path of f.topHTTPPaths ?? []) {
|
|
488
|
+
if (path.method) methods.add(path.method)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
availableStatusRanges: ['2xx', '3xx', '4xx', '5xx'].filter(b => statuses.has(b)),
|
|
493
|
+
availableVerdicts: ['forwarded', 'dropped', 'error'].filter(v => verdicts.has(v)),
|
|
494
|
+
hasDNSQueries,
|
|
495
|
+
availableHTTPMethods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].filter(m => methods.has(m)),
|
|
496
|
+
}
|
|
497
|
+
}, [flowsData?.aggregated])
|
|
498
|
+
|
|
499
|
+
// The controls are built from what the data contains, so a selection can outlive
|
|
500
|
+
// its button. These are what actually filter: a choice with no control left to
|
|
501
|
+
// clear it must not keep hiding traffic.
|
|
502
|
+
const activeStatusRanges = useMemo(
|
|
503
|
+
() => keepAvailable(l7StatusRanges, l7Capabilities.availableStatusRanges),
|
|
504
|
+
[l7StatusRanges, l7Capabilities.availableStatusRanges])
|
|
505
|
+
const activeMethods = useMemo(
|
|
506
|
+
() => keepAvailable(l7Methods, l7Capabilities.availableHTTPMethods),
|
|
507
|
+
[l7Methods, l7Capabilities.availableHTTPMethods])
|
|
508
|
+
const activeVerdicts = useMemo(
|
|
509
|
+
() => keepAvailable(l7Verdicts, l7Capabilities.availableVerdicts),
|
|
510
|
+
[l7Verdicts, l7Capabilities.availableVerdicts])
|
|
511
|
+
const activeDnsPattern = l7Capabilities.hasDNSQueries ? dnsPattern : ''
|
|
512
|
+
const activeMinConnections = effectiveThreshold(minConnections, minConnectionsUnit, volumeUnit(isRateBased))
|
|
513
|
+
|
|
445
514
|
const filteredFlows = useMemo<AggregatedFlow[]>(() => {
|
|
446
515
|
if (!flowsData?.aggregated) return []
|
|
447
516
|
|
|
@@ -488,7 +557,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
488
557
|
}
|
|
489
558
|
|
|
490
559
|
// Connection threshold filter
|
|
491
|
-
if (flow.connections <
|
|
560
|
+
if (flow.connections < activeMinConnections) {
|
|
492
561
|
return false
|
|
493
562
|
}
|
|
494
563
|
|
|
@@ -506,23 +575,21 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
506
575
|
if (l7Protocol === 'TCP' && flow.l7Protocol) return false // TCP = no L7
|
|
507
576
|
|
|
508
577
|
// L7 sub-filters (only apply when active)
|
|
509
|
-
if (
|
|
510
|
-
if (!flow.topHTTPPaths?.some(p =>
|
|
511
|
-
}
|
|
512
|
-
if (l7StatusRanges.size > 0) {
|
|
513
|
-
if (!flow.httpStatusCounts || !Array.from(l7StatusRanges).some(r => (flow.httpStatusCounts?.[r] ?? 0) > 0)) return false
|
|
578
|
+
if (activeMethods.size > 0) {
|
|
579
|
+
if (!flow.topHTTPPaths?.some(p => activeMethods.has(p.method))) return false
|
|
514
580
|
}
|
|
515
|
-
if (
|
|
516
|
-
|
|
581
|
+
if (!matchesStatusRanges(activeStatusRanges, bucketsFromCounts(flow.httpStatusCounts), (flow.errorCount ?? 0) > 0)) return false
|
|
582
|
+
if (activeVerdicts.size > 0) {
|
|
583
|
+
if (!flow.verdictCounts || !Array.from(activeVerdicts).some(v => (flow.verdictCounts?.[v] ?? 0) > 0)) return false
|
|
517
584
|
}
|
|
518
|
-
if (
|
|
519
|
-
const pattern =
|
|
585
|
+
if (activeDnsPattern) {
|
|
586
|
+
const pattern = activeDnsPattern.toLowerCase()
|
|
520
587
|
if (!flow.topDNSQueries?.some(q => q.query.toLowerCase().includes(pattern))) return false
|
|
521
588
|
}
|
|
522
589
|
|
|
523
590
|
return true
|
|
524
591
|
})
|
|
525
|
-
}, [flowsData?.aggregated, hideSystem, hideExternal,
|
|
592
|
+
}, [flowsData?.aggregated, hideSystem, hideExternal, activeMinConnections, hiddenNamespaces, addonMode, l7Protocol, activeMethods, activeStatusRanges, activeVerdicts, activeDnsPattern])
|
|
526
593
|
|
|
527
594
|
// Filter raw flows with the same base filters (for list view)
|
|
528
595
|
const filteredRawFlows = useMemo(() => {
|
|
@@ -555,24 +622,20 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
555
622
|
if (l7Protocol === 'TCP' && flow.l7Protocol) return false
|
|
556
623
|
|
|
557
624
|
// L7 sub-filters on individual flow fields
|
|
558
|
-
if (
|
|
559
|
-
if (!flow.httpMethod || !
|
|
625
|
+
if (activeMethods.size > 0) {
|
|
626
|
+
if (!flow.httpMethod || !activeMethods.has(flow.httpMethod)) return false
|
|
560
627
|
}
|
|
561
|
-
if (
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
if (!l7StatusRanges.has(bucket)) return false
|
|
628
|
+
if (!matchesStatusRanges(activeStatusRanges, bucketsFromStatus(flow.httpStatus), (flow.errorRate ?? 0) > 0)) return false
|
|
629
|
+
if (activeVerdicts.size > 0) {
|
|
630
|
+
if (!flow.verdict || !activeVerdicts.has(flow.verdict)) return false
|
|
565
631
|
}
|
|
566
|
-
if (
|
|
567
|
-
if (!flow.
|
|
568
|
-
}
|
|
569
|
-
if (dnsPattern) {
|
|
570
|
-
if (!flow.dnsQuery || !flow.dnsQuery.toLowerCase().includes(dnsPattern.toLowerCase())) return false
|
|
632
|
+
if (activeDnsPattern) {
|
|
633
|
+
if (!flow.dnsQuery || !flow.dnsQuery.toLowerCase().includes(activeDnsPattern.toLowerCase())) return false
|
|
571
634
|
}
|
|
572
635
|
|
|
573
636
|
return true
|
|
574
637
|
})
|
|
575
|
-
}, [flowsData?.flows, hideSystem, hideExternal, hiddenNamespaces, addonMode, l7Protocol,
|
|
638
|
+
}, [flowsData?.flows, hideSystem, hideExternal, hiddenNamespaces, addonMode, l7Protocol, activeMethods, activeStatusRanges, activeVerdicts, activeDnsPattern])
|
|
576
639
|
|
|
577
640
|
// Apply graph selection to filter raw flows for the list panel
|
|
578
641
|
const listFlows = useMemo(() => {
|
|
@@ -612,11 +675,12 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
612
675
|
}
|
|
613
676
|
}, [flowsData?.flows, openFlowListDock])
|
|
614
677
|
|
|
615
|
-
//
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
678
|
+
// Records which quantity the chosen threshold refers to, so it can be discarded
|
|
679
|
+
// rather than reinterpreted if the active source starts measuring the other one.
|
|
680
|
+
const chooseMinConnections = useCallback((value: number) => {
|
|
681
|
+
setMinConnections(value)
|
|
682
|
+
setMinConnectionsUnit(volumeUnit(isRateBased))
|
|
683
|
+
}, [isRateBased])
|
|
620
684
|
|
|
621
685
|
// Toggle L7 filter helpers
|
|
622
686
|
const toggleL7Method = useCallback((method: string) => {
|
|
@@ -692,8 +756,11 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
692
756
|
const destKey = flow.destination.namespace
|
|
693
757
|
? `${flow.destination.namespace}/${destAgg.name}`
|
|
694
758
|
: destAgg.name
|
|
695
|
-
// Group by service name, not by port (all MongoDB connections become one edge)
|
|
696
|
-
|
|
759
|
+
// Group by service name, not by port (all MongoDB connections become one edge).
|
|
760
|
+
// Traffic whose direction is known is kept apart from traffic whose direction
|
|
761
|
+
// is not, the same way the backend aggregation keys it: merging them would
|
|
762
|
+
// give one edge a single arrowhead answer that is wrong for half its bytes.
|
|
763
|
+
const key = `${sourceKey}->${destKey}|${flow.directionUnknown ? 'u' : 'o'}`
|
|
697
764
|
|
|
698
765
|
const existing = aggregatedMap.get(key)
|
|
699
766
|
if (existing) {
|
|
@@ -708,6 +775,8 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
708
775
|
if (flow.errorCount) {
|
|
709
776
|
existing.errorCount = (existing.errorCount || 0) + flow.errorCount
|
|
710
777
|
}
|
|
778
|
+
// Everything merged here shares the key's direction-known state, so the
|
|
779
|
+
// flag is already correct on the entry that was created first.
|
|
711
780
|
} else {
|
|
712
781
|
// Create new aggregated flow with modified names
|
|
713
782
|
aggregatedMap.set(key, {
|
|
@@ -739,10 +808,13 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
739
808
|
|
|
740
809
|
// Only collapse external → internal flows (inbound internet traffic)
|
|
741
810
|
if (sourceIsExternal && destIsInternal) {
|
|
742
|
-
//
|
|
811
|
+
// Key on destination + port, and on whether the direction is known: this
|
|
812
|
+
// collapse merges genuinely different external clients, so one of them
|
|
813
|
+
// arriving unoriented must not take the arrowhead off another's traffic.
|
|
814
|
+
const orientation = flow.directionUnknown ? '|u' : '|o'
|
|
743
815
|
const destKey = flow.destination.namespace
|
|
744
|
-
? `${flow.destination.namespace}/${flow.destination.name}:${flow.port}`
|
|
745
|
-
: `${flow.destination.name}:${flow.port}`
|
|
816
|
+
? `${flow.destination.namespace}/${flow.destination.name}:${flow.port}${orientation}`
|
|
817
|
+
: `${flow.destination.name}:${flow.port}${orientation}`
|
|
746
818
|
|
|
747
819
|
const existing = internetFlowsMap.get(destKey)
|
|
748
820
|
if (existing) {
|
|
@@ -907,7 +979,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
907
979
|
}
|
|
908
980
|
}
|
|
909
981
|
|
|
910
|
-
if (flow.connections <
|
|
982
|
+
if (flow.connections < activeMinConnections) {
|
|
911
983
|
return false
|
|
912
984
|
}
|
|
913
985
|
|
|
@@ -935,7 +1007,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
935
1007
|
name,
|
|
936
1008
|
nodeCount: nodes.size,
|
|
937
1009
|
}))
|
|
938
|
-
}, [flowsData?.aggregated, hideSystem, hideExternal,
|
|
1010
|
+
}, [flowsData?.aggregated, hideSystem, hideExternal, activeMinConnections])
|
|
939
1011
|
|
|
940
1012
|
// Determine wizard state based on sources detection
|
|
941
1013
|
useEffect(() => {
|
|
@@ -1014,8 +1086,8 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1014
1086
|
setHideSystem={setHideSystem}
|
|
1015
1087
|
hideExternal={hideExternal}
|
|
1016
1088
|
setHideExternal={setHideExternal}
|
|
1017
|
-
minConnections={
|
|
1018
|
-
setMinConnections={
|
|
1089
|
+
minConnections={activeMinConnections}
|
|
1090
|
+
setMinConnections={chooseMinConnections}
|
|
1019
1091
|
showNamespaceGroups={showNamespaceGroups}
|
|
1020
1092
|
setShowNamespaceGroups={setShowNamespaceGroups}
|
|
1021
1093
|
collapseInternet={collapseInternet}
|
|
@@ -1028,7 +1100,12 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1028
1100
|
setDetectServices={setDetectServices}
|
|
1029
1101
|
timeRange={timeRange}
|
|
1030
1102
|
setTimeRange={setTimeRange}
|
|
1031
|
-
|
|
1103
|
+
showL7Filters={hasL7Data}
|
|
1104
|
+
availableStatusRanges={l7Capabilities.availableStatusRanges}
|
|
1105
|
+
availableVerdicts={l7Capabilities.availableVerdicts}
|
|
1106
|
+
hasDNSQueries={l7Capabilities.hasDNSQueries}
|
|
1107
|
+
availableHTTPMethods={l7Capabilities.availableHTTPMethods}
|
|
1108
|
+
isRateBased={isRateBased}
|
|
1032
1109
|
l7Protocol={l7Protocol}
|
|
1033
1110
|
setL7Protocol={setL7Protocol}
|
|
1034
1111
|
l7Methods={l7Methods}
|
|
@@ -1154,15 +1231,39 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1154
1231
|
className="absolute inset-0"
|
|
1155
1232
|
/>
|
|
1156
1233
|
) : finalFlows.length > 0 ? (
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1234
|
+
<>
|
|
1235
|
+
{flowsData?.warning && warningIsPermanent && (
|
|
1236
|
+
// Sits below the two chip rows (both top-3) rather than beside
|
|
1237
|
+
// them: centred at that height it would cover the flow count and
|
|
1238
|
+
// the refresh control at common widths. role/aria-live because it
|
|
1239
|
+
// appears after the graph has already rendered.
|
|
1240
|
+
<div
|
|
1241
|
+
role="status"
|
|
1242
|
+
aria-live="polite"
|
|
1243
|
+
className="absolute top-14 left-1/2 z-10 w-[min(40rem,calc(100%-1.5rem))] -translate-x-1/2"
|
|
1244
|
+
>
|
|
1245
|
+
<AlertBanner
|
|
1246
|
+
variant="warning"
|
|
1247
|
+
// Not "incomplete": every warning that reaches here is about a
|
|
1248
|
+
// value on an edge that is shown being wrong or absent — a port
|
|
1249
|
+
// reported as 0, UDP reported as TCP, received bytes understated.
|
|
1250
|
+
// "Incomplete" sends the reader looking for workloads that are
|
|
1251
|
+
// missing, which is the one thing none of these mean.
|
|
1252
|
+
title="Some values on this map are unreliable"
|
|
1253
|
+
message={flowsData.warning}
|
|
1254
|
+
/>
|
|
1255
|
+
</div>
|
|
1256
|
+
)}
|
|
1257
|
+
<TrafficGraph
|
|
1258
|
+
flows={finalFlows}
|
|
1259
|
+
hotPathThreshold={hotPathThreshold}
|
|
1260
|
+
showNamespaceGroups={showNamespaceGroups}
|
|
1261
|
+
serviceCategories={serviceCategories}
|
|
1262
|
+
addonMode={addonMode}
|
|
1263
|
+
trafficSource={sourcesData?.active || ''}
|
|
1264
|
+
onSelectionChange={setGraphSelection}
|
|
1265
|
+
/>
|
|
1266
|
+
</>
|
|
1166
1267
|
) : connectionError ? (
|
|
1167
1268
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
1168
1269
|
<div className="text-center space-y-3">
|
|
@@ -1191,10 +1292,21 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1191
1292
|
action={
|
|
1192
1293
|
<button
|
|
1193
1294
|
type="button"
|
|
1295
|
+
// Nine filters can empty this view; clearing three of them left
|
|
1296
|
+
// the user pressing a button that promised everything back and
|
|
1297
|
+
// changed nothing whenever the cause was a namespace, an addon
|
|
1298
|
+
// mode or an L7 choice.
|
|
1194
1299
|
onClick={() => {
|
|
1195
1300
|
setHideSystem(false)
|
|
1196
1301
|
setHideExternal(false)
|
|
1197
|
-
|
|
1302
|
+
chooseMinConnections(0)
|
|
1303
|
+
setHiddenNamespaces(new Set())
|
|
1304
|
+
setAddonMode('show')
|
|
1305
|
+
setL7Protocol('all')
|
|
1306
|
+
setL7Methods(new Set())
|
|
1307
|
+
setL7StatusRanges(new Set())
|
|
1308
|
+
setL7Verdicts(new Set())
|
|
1309
|
+
setDnsPattern('')
|
|
1198
1310
|
}}
|
|
1199
1311
|
className="badge badge-sm border border-theme-border bg-theme-elevated text-theme-text-primary hover:bg-theme-hover transition-colors"
|
|
1200
1312
|
>
|
|
@@ -1208,7 +1320,11 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1208
1320
|
tone="neutral"
|
|
1209
1321
|
variant="card"
|
|
1210
1322
|
icon={AlertTriangle}
|
|
1211
|
-
headline=
|
|
1323
|
+
headline={
|
|
1324
|
+
warningIsPermanent
|
|
1325
|
+
? 'No traffic Radar can place on the map'
|
|
1326
|
+
: 'Unable to fetch traffic data'
|
|
1327
|
+
}
|
|
1212
1328
|
body={flowsData.warning}
|
|
1213
1329
|
className="max-w-md"
|
|
1214
1330
|
/>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react'
|
|
2
2
|
import type { TrafficSourcesResponse, TrafficWizardState } from '../../types'
|
|
3
3
|
import { CheckCircle2, XCircle, AlertTriangle, Copy, ExternalLink, ArrowRight, ArrowLeft, Package } from 'lucide-react'
|
|
4
|
-
import { assetUrl, PaneLoader } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { AlertBanner, assetUrl, PaneLoader } from '@skyhook-io/k8s-ui'
|
|
5
5
|
import radarLoadingIcon from '@skyhook-io/k8s-ui/assets/radar/radar-icon-loading.svg'
|
|
6
6
|
import { InstallWizard } from '../helm/InstallWizard'
|
|
7
7
|
|
|
@@ -204,6 +204,18 @@ export function TrafficWizard({
|
|
|
204
204
|
</div>
|
|
205
205
|
))}
|
|
206
206
|
|
|
207
|
+
{/* Installed but unusable — a fixable configuration, not an error */}
|
|
208
|
+
{sourcesData?.detected.filter(s => s.status === 'not_found').map(source => (
|
|
209
|
+
<AlertBanner
|
|
210
|
+
key={source.name}
|
|
211
|
+
variant="warning"
|
|
212
|
+
// Source names are lowercase ids; the block this replaced applied a
|
|
213
|
+
// capitalize class, which an AlertBanner title (a plain string) cannot.
|
|
214
|
+
title={`${source.name.charAt(0).toUpperCase()}${source.name.slice(1)}${source.version ? ` ${source.version}` : ''} is installed but not usable`}
|
|
215
|
+
message={source.message}
|
|
216
|
+
/>
|
|
217
|
+
))}
|
|
218
|
+
|
|
207
219
|
{/* Recommendation */}
|
|
208
220
|
{recommendation && (
|
|
209
221
|
<div className="space-y-4">
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { matchesStatusRanges, bucketsFromCounts, bucketsFromStatus, isRateBasedSource, keepAvailable, effectiveThreshold, volumeUnit } from './trafficFilters'
|
|
3
|
+
|
|
4
|
+
describe('matchesStatusRanges', () => {
|
|
5
|
+
it('does not filter when nothing is selected', () => {
|
|
6
|
+
expect(matchesStatusRanges(new Set(), [], false)).toBe(true)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('matches an event-based flow on its own status bucket', () => {
|
|
10
|
+
const ranges = new Set(['5xx'])
|
|
11
|
+
expect(matchesStatusRanges(ranges, bucketsFromStatus(503), false)).toBe(true)
|
|
12
|
+
expect(matchesStatusRanges(ranges, bucketsFromStatus(200), false)).toBe(false)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('matches an aggregate on the buckets it actually reports', () => {
|
|
16
|
+
const counts = { '2xx': 40, '5xx': 3 }
|
|
17
|
+
expect(matchesStatusRanges(new Set(['5xx']), bucketsFromCounts(counts), false)).toBe(true)
|
|
18
|
+
expect(matchesStatusRanges(new Set(['4xx']), bucketsFromCounts(counts), false)).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('ignores a bucket present but zero', () => {
|
|
22
|
+
expect(matchesStatusRanges(new Set(['5xx']), bucketsFromCounts({ '5xx': 0 }), false)).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// The load-bearing case. A rate-based source measures a 5xx rate rather than
|
|
26
|
+
// observing individual responses, so it has no status code and no bucket. The
|
|
27
|
+
// filter used to require one, which hid exactly the failing traffic the user
|
|
28
|
+
// had asked to see.
|
|
29
|
+
it('finds a failing edge that reports an error signal but no status', () => {
|
|
30
|
+
expect(matchesStatusRanges(new Set(['5xx']), [], true)).toBe(true)
|
|
31
|
+
expect(matchesStatusRanges(new Set(['5xx']), bucketsFromStatus(undefined), true)).toBe(true)
|
|
32
|
+
expect(matchesStatusRanges(new Set(['5xx']), bucketsFromCounts(undefined), true)).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('does not let an error signal satisfy a non-5xx selection', () => {
|
|
36
|
+
expect(matchesStatusRanges(new Set(['2xx']), [], true)).toBe(false)
|
|
37
|
+
expect(matchesStatusRanges(new Set(['4xx']), [], true)).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('still matches a selected bucket when there are no errors', () => {
|
|
41
|
+
expect(matchesStatusRanges(new Set(['2xx', '5xx']), bucketsFromStatus(204), false)).toBe(true)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('isRateBasedSource', () => {
|
|
46
|
+
it('knows which sources report rates rather than counts', () => {
|
|
47
|
+
expect(isRateBasedSource('beyla')).toBe(true)
|
|
48
|
+
expect(isRateBasedSource('istio')).toBe(true)
|
|
49
|
+
expect(isRateBasedSource('hubble')).toBe(false)
|
|
50
|
+
expect(isRateBasedSource('caretta')).toBe(false)
|
|
51
|
+
expect(isRateBasedSource(undefined)).toBe(false)
|
|
52
|
+
expect(isRateBasedSource('')).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('keepAvailable', () => {
|
|
57
|
+
it('drops a choice whose control is no longer offered', () => {
|
|
58
|
+
// The button is gone, so the selection must stop filtering — otherwise the map
|
|
59
|
+
// goes blank with nothing left to clear.
|
|
60
|
+
expect(keepAvailable(new Set(['5xx']), [])).toEqual(new Set())
|
|
61
|
+
expect(keepAvailable(new Set(['2xx', '5xx']), ['5xx'])).toEqual(new Set(['5xx']))
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('leaves an empty selection alone', () => {
|
|
65
|
+
expect(keepAvailable(new Set(), ['5xx'])).toEqual(new Set())
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('keeps everything still on offer', () => {
|
|
69
|
+
expect(keepAvailable(new Set(['GET', 'POST']), ['GET', 'POST', 'PUT'])).toEqual(new Set(['GET', 'POST']))
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('effectiveThreshold', () => {
|
|
74
|
+
it('keeps a threshold chosen under the unit still in use', () => {
|
|
75
|
+
expect(effectiveThreshold(100, 'connections', 'connections')).toBe(100)
|
|
76
|
+
expect(effectiveThreshold(10, 'rate', 'rate')).toBe(10)
|
|
77
|
+
expect(effectiveThreshold(0, 'rate', 'rate')).toBe(0)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('drops one chosen under the other unit', () => {
|
|
81
|
+
// 10000 connections against a rate source would hide every edge.
|
|
82
|
+
expect(effectiveThreshold(10000, 'connections', 'rate')).toBe(0)
|
|
83
|
+
expect(effectiveThreshold(1, 'rate', 'connections')).toBe(0)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// The load-bearing case: these numbers are steps in BOTH scales, so membership in
|
|
87
|
+
// the active list cannot tell them apart. "100+ connections" is a mild filter;
|
|
88
|
+
// reinterpreted as "100+ req/s" it empties a map whose busiest edge runs at 6/s,
|
|
89
|
+
// while the dropdown still shows a deliberate-looking selection.
|
|
90
|
+
it('drops a value that exists in both scales but was chosen under the other', () => {
|
|
91
|
+
expect(effectiveThreshold(100, 'connections', 'rate')).toBe(0)
|
|
92
|
+
expect(effectiveThreshold(1000, 'connections', 'rate')).toBe(0)
|
|
93
|
+
expect(effectiveThreshold(100, 'rate', 'connections')).toBe(0)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('volumeUnit', () => {
|
|
98
|
+
it('names the quantity each kind of source measures', () => {
|
|
99
|
+
expect(volumeUnit(true)).toBe('rate')
|
|
100
|
+
expect(volumeUnit(false)).toBe('connections')
|
|
101
|
+
expect(volumeUnit(undefined)).toBe('connections')
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does a flow fall into one of the selected HTTP status ranges?
|
|
3
|
+
*
|
|
4
|
+
* Shared by the graph's aggregated filter and the flow list's per-row filter.
|
|
5
|
+
* They read different fields — one has a bucket histogram and an error count, the
|
|
6
|
+
* other a single status code and an error rate — but the rule has to be the same,
|
|
7
|
+
* and keeping it in one place is what stops the two from drifting apart.
|
|
8
|
+
*
|
|
9
|
+
* `hasErrors` exists because a rate-based source (Beyla, Istio) measures a 5xx
|
|
10
|
+
* rate rather than observing individual responses. It has no status code to offer
|
|
11
|
+
* and no bucket to match, so a filter that insists on one hides precisely the
|
|
12
|
+
* traffic the user asked to see.
|
|
13
|
+
*
|
|
14
|
+
* @param ranges selected buckets, e.g. {"2xx", "5xx"}; empty means no filtering
|
|
15
|
+
* @param buckets buckets this flow actually reports
|
|
16
|
+
* @param hasErrors whether the flow carries a non-zero error signal
|
|
17
|
+
*/
|
|
18
|
+
export function matchesStatusRanges(
|
|
19
|
+
ranges: Set<string>,
|
|
20
|
+
buckets: string[],
|
|
21
|
+
hasErrors: boolean
|
|
22
|
+
): boolean {
|
|
23
|
+
if (ranges.size === 0) return true
|
|
24
|
+
if (ranges.has('5xx') && hasErrors) return true
|
|
25
|
+
return buckets.some(b => ranges.has(b))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Buckets an aggregated flow reports, from its status histogram. */
|
|
29
|
+
export function bucketsFromCounts(counts: Record<string, number> | undefined): string[] {
|
|
30
|
+
if (!counts) return []
|
|
31
|
+
return Object.keys(counts).filter(k => (counts[k] ?? 0) > 0)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The single bucket a raw flow reports, if it carries a status code at all. */
|
|
35
|
+
export function bucketsFromStatus(httpStatus: number | undefined): string[] {
|
|
36
|
+
if (!httpStatus) return []
|
|
37
|
+
return [`${Math.floor(httpStatus / 100)}xx`]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Does this source measure rates rather than counting individual events?
|
|
42
|
+
*
|
|
43
|
+
* Beyla and Istio read counters out of Prometheus, so what they report per edge is
|
|
44
|
+
* a per-second rate: "connections" is really requests per second, and the error
|
|
45
|
+
* count is errors per second. Hubble and Caretta observe individual flows and
|
|
46
|
+
* report genuine counts. The distinction changes units, labels and the sensible
|
|
47
|
+
* scale of a volume filter, so it lives in one place rather than being re-derived
|
|
48
|
+
* wherever it is needed.
|
|
49
|
+
*/
|
|
50
|
+
export function isRateBasedSource(source: string | undefined): boolean {
|
|
51
|
+
return source === 'istio' || source === 'beyla'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Narrow a set of chosen filter values to the ones still offered.
|
|
56
|
+
*
|
|
57
|
+
* The filter controls are built from what the flows actually contain, so a choice
|
|
58
|
+
* can outlive its button: pick 5xx, let the errors stop, and the toggle disappears
|
|
59
|
+
* while the selection keeps filtering — a blank map with nothing left to clear it.
|
|
60
|
+
* Applying the intersection at the point of use rather than editing the stored
|
|
61
|
+
* selection means the choice is ignored while it is unavailable and takes effect
|
|
62
|
+
* again by itself if the traffic comes back.
|
|
63
|
+
*/
|
|
64
|
+
export function keepAvailable(selected: Set<string>, available: string[]): Set<string> {
|
|
65
|
+
if (selected.size === 0) return selected
|
|
66
|
+
const offered = new Set(available)
|
|
67
|
+
const kept = new Set<string>()
|
|
68
|
+
for (const value of selected) {
|
|
69
|
+
if (offered.has(value)) kept.add(value)
|
|
70
|
+
}
|
|
71
|
+
return kept
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Volume-filter steps for a source that counts events. */
|
|
75
|
+
export const CONNECTION_THRESHOLDS = [
|
|
76
|
+
{ value: 0, label: 'All traffic' },
|
|
77
|
+
{ value: 100, label: '100+ connections' },
|
|
78
|
+
{ value: 1000, label: '1K+ connections' },
|
|
79
|
+
{ value: 10000, label: '10K+ connections' },
|
|
80
|
+
{ value: 100000, label: '100K+ connections' },
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Volume-filter steps for a source that measures rates. Both the unit and the
|
|
85
|
+
* scale differ: a busy service runs at single-digit requests per second, so the
|
|
86
|
+
* connection steps above would filter the whole map away.
|
|
87
|
+
*/
|
|
88
|
+
export const RATE_THRESHOLDS = [
|
|
89
|
+
{ value: 0, label: 'All traffic' },
|
|
90
|
+
{ value: 1, label: '1+ req/s' },
|
|
91
|
+
{ value: 10, label: '10+ req/s' },
|
|
92
|
+
{ value: 100, label: '100+ req/s' },
|
|
93
|
+
{ value: 1000, label: '1K+ req/s' },
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
export function volumeThresholds(isRateBased: boolean | undefined) {
|
|
97
|
+
return isRateBased ? RATE_THRESHOLDS : CONNECTION_THRESHOLDS
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Which quantity the volume filter is counting. */
|
|
101
|
+
export type VolumeUnit = 'connections' | 'rate'
|
|
102
|
+
|
|
103
|
+
export function volumeUnit(isRateBased: boolean | undefined): VolumeUnit {
|
|
104
|
+
return isRateBased ? 'rate' : 'connections'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A volume threshold only means something alongside the unit it was chosen under.
|
|
109
|
+
* 100 appears in both scales — "100+ connections" and "100+ req/s" — so the number
|
|
110
|
+
* alone cannot say whether a stored choice still applies, and carrying it across a
|
|
111
|
+
* source change turns a mild connection filter into a rate filter that hides every
|
|
112
|
+
* edge while the dropdown still looks deliberately set. Falls back to no filtering
|
|
113
|
+
* when the unit has changed, because there is no honest conversion between them.
|
|
114
|
+
*/
|
|
115
|
+
export function effectiveThreshold(value: number, chosenUnit: VolumeUnit, currentUnit: VolumeUnit): number {
|
|
116
|
+
return chosenUnit === currentUnit ? value : 0
|
|
117
|
+
}
|