@skyhook-io/radar-app 1.14.6 → 1.16.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/README.md +2 -0
- package/package.json +6 -5
- package/src/App.tsx +65 -17
- package/src/RadarApp.tsx +51 -21
- package/src/api/apiResources.test.ts +19 -1
- package/src/api/apiResources.ts +18 -3
- package/src/api/client.admission.test.ts +34 -0
- package/src/api/client.capacity.test.ts +15 -0
- package/src/api/client.trace.test.ts +9 -1
- package/src/api/client.ts +269 -204
- package/src/api/diagnose.test.ts +102 -1
- package/src/api/diagnose.ts +42 -11
- package/src/api/drain-plan.test.ts +0 -10
- package/src/api/httpErrors.test.ts +95 -0
- package/src/api/httpErrors.ts +101 -0
- package/src/api/policy.ts +33 -5
- package/src/api/radarFeatures.render.test.tsx +190 -0
- package/src/api/radarFeatures.test.ts +133 -0
- package/src/api/radarFeatures.ts +147 -0
- package/src/api/timelineSource.test.ts +2 -10
- package/src/api/timelineSource.ts +3 -2
- package/src/api/traffic.ts +4 -3
- package/src/api/usage-data.fixtures.ts +33 -0
- package/src/api/usage-data.ts +165 -0
- package/src/components/CloudFunnelButton.test.tsx +2 -2
- package/src/components/CloudFunnelButton.tsx +99 -55
- package/src/components/NamespaceSwitcher.test.tsx +47 -0
- package/src/components/NamespaceSwitcher.tsx +17 -1
- package/src/components/SelfManagedStart.tsx +95 -0
- package/src/components/applications/ApplicationsView.tsx +20 -6
- package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
- package/src/components/audit/UpgradeReadinessView.tsx +9 -13
- package/src/components/capacity/CapacityView.test.tsx +13 -4
- package/src/components/capacity/shared.tsx +14 -13
- package/src/components/cloudConnectHandoff.test.ts +1 -1
- package/src/components/cloudConnectHandoff.ts +1 -1
- package/src/components/cost/ApplicationCostTab.tsx +9 -1
- package/src/components/cost/CostConnectionAction.tsx +30 -0
- package/src/components/cost/CostTrendChart.tsx +2 -0
- package/src/components/cost/CostView.tsx +4 -24
- package/src/components/cost/WorkloadCostTab.tsx +7 -1
- package/src/components/cost/source.test.ts +30 -3
- package/src/components/cost/source.ts +37 -2
- package/src/components/curl/ServiceCurlButton.tsx +7 -3
- package/src/components/diagnose/ActivityTurn.tsx +3 -2
- package/src/components/diagnose/ApplyDialog.tsx +4 -2
- package/src/components/dialog-names.test.ts +69 -0
- package/src/components/dock/NodeTerminalTab.tsx +2 -1
- package/src/components/dock/TerminalTab.tsx +2 -1
- package/src/components/dock/TrafficFlowListTab.tsx +2 -2
- package/src/components/execution/BatchExecutionView.tsx +1 -1
- package/src/components/execution/JobSetAdmission.test.tsx +160 -37
- package/src/components/execution/JobSetAdmission.tsx +16 -7
- package/src/components/gitops/GitOpsView.tsx +12 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
- package/src/components/home/HomeView.tsx +7 -3
- package/src/components/home/MCPSetupDialog.tsx +37 -37
- package/src/components/home/RadarVersionLine.test.tsx +14 -0
- package/src/components/home/RadarVersionLine.tsx +28 -3
- package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
- package/src/components/nav/PrimaryNavRail.tsx +25 -3
- package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
- package/src/components/resource/PVCUsageBar.tsx +4 -1
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
- package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
- package/src/components/resources/ImageFilesystemModal.tsx +2 -1
- package/src/components/resources/PodFilePreview.tsx +4 -1
- package/src/components/resources/PodFilesystemModal.tsx +3 -2
- package/src/components/resources/ResourcesView.tsx +2 -1
- package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
- package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
- package/src/components/settings/FormSaveActions.tsx +76 -0
- package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
- package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
- package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
- package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
- package/src/components/settings/PrivacySection.test.tsx +84 -0
- package/src/components/settings/PrivacySection.tsx +140 -0
- package/src/components/settings/PrometheusConfigField.tsx +30 -0
- package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
- package/src/components/settings/SettingsDialog.tsx +393 -571
- package/src/components/settings/controls.tsx +55 -0
- package/src/components/settings/currency-options.test.ts +1 -1
- package/src/components/settings/currency-options.ts +1 -1
- package/src/components/settings/settings-state.test.ts +17 -10
- package/src/components/settings/settings-state.ts +21 -12
- package/src/components/timeline/TimelineList.test.ts +56 -0
- package/src/components/timeline/TimelineList.tsx +7 -1
- package/src/components/traffic/TrafficFlowList.tsx +5 -21
- package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
- package/src/components/traffic/TrafficGraph.tsx +79 -48
- package/src/components/traffic/TrafficView.tsx +61 -37
- package/src/components/traffic/TrafficWizard.tsx +4 -2
- package/src/components/traffic/trafficFilters.test.ts +154 -1
- package/src/components/traffic/trafficFilters.ts +141 -0
- package/src/components/ui/ErrorBoundary.tsx +5 -0
- package/src/components/ui/Omnibar.tsx +17 -1
- package/src/components/ui/UpdateNotification.tsx +70 -21
- package/src/components/ui/command-items.ts +14 -0
- package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
- package/src/components/usage-data/UsageDataAsk.tsx +106 -0
- package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
- package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
- package/src/components/whats-new/WhatsNew.test.ts +110 -0
- package/src/components/whats-new/WhatsNew.tsx +423 -0
- package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
- package/src/components/whats-new/check-whats-new.test.ts +65 -0
- package/src/components/whats-new/releaseNotes.ts +121 -0
- package/src/components/workload/WorkloadView.tsx +47 -14
- package/src/components/workload/historyPaging.test.ts +72 -0
- package/src/components/workload/historyPaging.ts +147 -0
- package/src/components/workload/useHistoryPaging.test.tsx +81 -0
- package/src/context/ConnectionContext.tsx +2 -1
- package/src/context/RadarUpgrade.tsx +30 -0
- package/src/context/RadarUpgradeHost.tsx +23 -0
- package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
- package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
- package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
- package/src/index.ts +1 -0
- package/src/k8s-ui-exports.test.ts +73 -0
- package/src/utils/navigation.test.ts +17 -1
- package/src/utils/navigation.ts +16 -0
- package/src/utils/version.ts +11 -0
|
@@ -23,7 +23,7 @@ import { isClusterAddon, type AddonMode } from './TrafficView'
|
|
|
23
23
|
import { SEVERITY_BADGE, SEVERITY_DOT, SEVERITY_TEXT } from '@skyhook-io/k8s-ui/utils/badge-colors'
|
|
24
24
|
import { getNamespaceColor } from '../../utils/traffic-colors'
|
|
25
25
|
import { Tooltip } from '../ui/Tooltip'
|
|
26
|
-
import { isRateBasedSource, isExternalKind } from './trafficFilters'
|
|
26
|
+
import { isRateBasedSource, isExternalKind, requestRateOf, errorRateOf, formatRate, displayVolume, latencyWeightOf } from './trafficFilters'
|
|
27
27
|
|
|
28
28
|
const elk = new ELK()
|
|
29
29
|
|
|
@@ -74,6 +74,12 @@ function formatConnections(count: number): string {
|
|
|
74
74
|
return count.toString()
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
// A volume in the source's own unit: a rate to two significant places for a
|
|
78
|
+
// rate-based source, a whole count otherwise.
|
|
79
|
+
function formatVolume(volume: number, isRateBased: boolean): string {
|
|
80
|
+
return isRateBased ? formatRate(volume) : formatConnections(volume)
|
|
81
|
+
}
|
|
82
|
+
|
|
77
83
|
function formatLatency(ms: number): string {
|
|
78
84
|
if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`
|
|
79
85
|
if (ms >= 1) return `${ms.toFixed(1)}ms`
|
|
@@ -293,7 +299,7 @@ function TrafficNode({ data }: { data: TrafficNodeData }) {
|
|
|
293
299
|
? 'text-white/60'
|
|
294
300
|
: 'text-theme-text-tertiary'
|
|
295
301
|
)}>
|
|
296
|
-
{
|
|
302
|
+
{formatVolume(portInfo.connections, data.connLabel === 'req/s')}{data.connLabel === 'req/s' ? '/s' : ''}
|
|
297
303
|
</span>
|
|
298
304
|
)}
|
|
299
305
|
</div>
|
|
@@ -319,7 +325,7 @@ function TrafficNode({ data }: { data: TrafficNodeData }) {
|
|
|
319
325
|
? 'text-white/70'
|
|
320
326
|
: 'text-theme-text-tertiary'
|
|
321
327
|
)}>
|
|
322
|
-
{
|
|
328
|
+
{formatVolume(data.totalConnections, data.connLabel === 'req/s')} {data.connLabel || 'conn'}
|
|
323
329
|
</span>
|
|
324
330
|
</div>
|
|
325
331
|
)}
|
|
@@ -460,7 +466,7 @@ function DetailsPanel({
|
|
|
460
466
|
totalBytes: relatedFlows.reduce((sum, f) => sum + f.bytesSent + f.bytesRecv, 0),
|
|
461
467
|
protocols: relatedFlows.reduce((acc, f) => {
|
|
462
468
|
const proto = f.protocol?.toUpperCase() || 'TCP'
|
|
463
|
-
acc[proto] = (acc[proto] || 0) + f
|
|
469
|
+
acc[proto] = (acc[proto] || 0) + displayVolume(f, isRateBased)
|
|
464
470
|
return acc
|
|
465
471
|
}, {} as Record<string, number>),
|
|
466
472
|
lastSeen: relatedFlows.reduce((latest, f) => {
|
|
@@ -468,22 +474,28 @@ function DetailsPanel({
|
|
|
468
474
|
return !latest || f.lastSeen > latest ? f.lastSeen : latest
|
|
469
475
|
}, null as string | null),
|
|
470
476
|
flowCount: relatedFlows.reduce((sum, f) => sum + (f.flowCount || 1), 0),
|
|
471
|
-
totalRequests: relatedFlows.reduce((sum, f) => sum + (f
|
|
472
|
-
totalErrors: relatedFlows.reduce((sum, f) => sum + (f
|
|
477
|
+
totalRequests: relatedFlows.reduce((sum, f) => sum + requestRateOf(f), 0),
|
|
478
|
+
totalErrors: relatedFlows.reduce((sum, f) => sum + errorRateOf(f), 0),
|
|
473
479
|
l7Protocols: relatedFlows.reduce((acc, f) => {
|
|
474
480
|
if (f.l7Protocol) acc.add(f.l7Protocol)
|
|
475
481
|
return acc
|
|
476
482
|
}, new Set<string>()),
|
|
477
|
-
//
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
+
// Percentiles of separate edges do not combine into a node percentile, so
|
|
484
|
+
// the node shows the slowest edge's P95 as that edge's, named.
|
|
485
|
+
slowestP95: (() => {
|
|
486
|
+
let slowest: AggregatedFlow | undefined
|
|
487
|
+
for (const f of relatedFlows) {
|
|
488
|
+
if (f.latencyP95Ms && (!slowest || f.latencyP95Ms > slowest.latencyP95Ms!)) slowest = f
|
|
489
|
+
}
|
|
490
|
+
return slowest && { ms: slowest.latencyP95Ms!, edge: `${slowest.source.workload || slowest.source.name} → ${slowest.destination.workload || slowest.destination.name}` }
|
|
483
491
|
})(),
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
492
|
+
// A metric-based source reports only an average per edge. The node's is
|
|
493
|
+
// those averages weighted by the traffic each stands for, so a slow trickle
|
|
494
|
+
// does not stand for a node that answers almost everything quickly.
|
|
495
|
+
avgLatencyMs: (() => {
|
|
496
|
+
const total = relatedFlows.reduce((sum, f) => sum + latencyWeightOf(f), 0)
|
|
497
|
+
if (total === 0) return undefined
|
|
498
|
+
return relatedFlows.reduce((sum, f) => sum + (f.avgLatencyMs ?? 0) * latencyWeightOf(f), 0) / total
|
|
487
499
|
})(),
|
|
488
500
|
// Aggregate HTTP status distribution
|
|
489
501
|
httpStatusCounts: relatedFlows.reduce((acc, f) => {
|
|
@@ -572,7 +584,7 @@ function DetailsPanel({
|
|
|
572
584
|
{nodeData.totalConnections != null && nodeData.totalConnections > 0 && (
|
|
573
585
|
<div className="text-xs text-theme-text-secondary">
|
|
574
586
|
{isRateBased ? 'Total request rate' : 'Total connections'}: <span className="text-theme-text-primary font-medium">
|
|
575
|
-
{
|
|
587
|
+
{formatVolume(nodeData.totalConnections, isRateBased)}{isRateBased ? '/s' : ''}
|
|
576
588
|
</span>
|
|
577
589
|
</div>
|
|
578
590
|
)}
|
|
@@ -596,14 +608,14 @@ function DetailsPanel({
|
|
|
596
608
|
{nodeStats.totalRequests > 0 && (
|
|
597
609
|
<div className="p-2 rounded bg-theme-elevated text-xs">
|
|
598
610
|
<div className="text-theme-text-tertiary">Requests</div>
|
|
599
|
-
<div className="text-theme-text-primary font-medium">{
|
|
611
|
+
<div className="text-theme-text-primary font-medium">{formatRate(nodeStats.totalRequests)}/s</div>
|
|
600
612
|
</div>
|
|
601
613
|
)}
|
|
602
614
|
{nodeStats.totalErrors > 0 && (
|
|
603
615
|
<div className="p-2 rounded bg-red-500/10 border border-red-500/30 text-xs">
|
|
604
616
|
<div className="text-red-400">Errors (5xx)</div>
|
|
605
617
|
<div className="text-red-400 font-medium">
|
|
606
|
-
{
|
|
618
|
+
{formatRate(nodeStats.totalErrors)}/s
|
|
607
619
|
{nodeStats.totalRequests > 0 && (
|
|
608
620
|
<span className="text-red-300 ml-1">
|
|
609
621
|
({((nodeStats.totalErrors / nodeStats.totalRequests) * 100).toFixed(1)}%)
|
|
@@ -626,7 +638,7 @@ function DetailsPanel({
|
|
|
626
638
|
.map(([proto, count]) => (
|
|
627
639
|
<span key={proto} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-theme-bg text-theme-text-secondary">
|
|
628
640
|
<span className="font-medium">{proto}</span>
|
|
629
|
-
<span className="text-theme-text-tertiary">{
|
|
641
|
+
<span className="text-theme-text-tertiary">{formatVolume(count, isRateBased)}{isRateBased ? '/s' : ''}</span>
|
|
630
642
|
</span>
|
|
631
643
|
))}
|
|
632
644
|
</div>
|
|
@@ -636,16 +648,17 @@ function DetailsPanel({
|
|
|
636
648
|
)}
|
|
637
649
|
|
|
638
650
|
{/* Node latency */}
|
|
639
|
-
{nodeStats?.
|
|
651
|
+
{nodeStats?.avgLatencyMs && (
|
|
640
652
|
<div className="pt-1">
|
|
641
|
-
<div className="text-[10px] text-theme-text-tertiary mb-1">Latency</div>
|
|
642
|
-
<div className="flex gap-
|
|
643
|
-
<span className={clsx('font-medium', latencyColor(nodeStats.
|
|
644
|
-
|
|
653
|
+
<div className="text-[10px] text-theme-text-tertiary mb-1">Latency across its edges</div>
|
|
654
|
+
<div className="flex flex-col gap-0.5 text-xs">
|
|
655
|
+
<span className={clsx('font-medium', latencyColor(nodeStats.avgLatencyMs))}>
|
|
656
|
+
Avg: {formatLatency(nodeStats.avgLatencyMs)}
|
|
645
657
|
</span>
|
|
646
|
-
{nodeStats.
|
|
647
|
-
<span className=
|
|
648
|
-
P95: {formatLatency(nodeStats.
|
|
658
|
+
{nodeStats.slowestP95 && (
|
|
659
|
+
<span className="text-theme-text-secondary min-w-0 truncate">
|
|
660
|
+
Slowest edge P95: <span className={clsx('font-medium', latencyColor(nodeStats.slowestP95.ms))}>{formatLatency(nodeStats.slowestP95.ms)}</span>
|
|
661
|
+
<span className="text-theme-text-tertiary"> ({nodeStats.slowestP95.edge})</span>
|
|
649
662
|
</span>
|
|
650
663
|
)}
|
|
651
664
|
</div>
|
|
@@ -712,7 +725,7 @@ function DetailsPanel({
|
|
|
712
725
|
</span>
|
|
713
726
|
{flow.connections > 0 && (
|
|
714
727
|
<span className="text-theme-text-secondary">
|
|
715
|
-
{
|
|
728
|
+
{formatVolume(displayVolume(flow, isRateBased), isRateBased)} {isRateBased ? 'req/s' : 'conn'}
|
|
716
729
|
</span>
|
|
717
730
|
)}
|
|
718
731
|
{(flow.bytesSent > 0 || flow.bytesRecv > 0) && (
|
|
@@ -722,7 +735,7 @@ function DetailsPanel({
|
|
|
722
735
|
)}
|
|
723
736
|
{flow.errorCount != null && flow.errorCount > 0 && (
|
|
724
737
|
<span className="text-red-400">
|
|
725
|
-
{
|
|
738
|
+
{isRateBased ? `${formatRate(errorRateOf(flow))} err/s` : `${formatConnections(flow.errorCount)} err`}
|
|
726
739
|
</span>
|
|
727
740
|
)}
|
|
728
741
|
</div>
|
|
@@ -764,7 +777,7 @@ function DetailsPanel({
|
|
|
764
777
|
</span>
|
|
765
778
|
{flow.connections > 0 && (
|
|
766
779
|
<span className="text-theme-text-secondary">
|
|
767
|
-
{
|
|
780
|
+
{formatVolume(displayVolume(flow, isRateBased), isRateBased)} {isRateBased ? 'req/s' : 'conn'}
|
|
768
781
|
</span>
|
|
769
782
|
)}
|
|
770
783
|
{(flow.bytesSent > 0 || flow.bytesRecv > 0) && (
|
|
@@ -774,7 +787,7 @@ function DetailsPanel({
|
|
|
774
787
|
)}
|
|
775
788
|
{flow.errorCount != null && flow.errorCount > 0 && (
|
|
776
789
|
<span className="text-red-400">
|
|
777
|
-
{
|
|
790
|
+
{isRateBased ? `${formatRate(errorRateOf(flow))} err/s` : `${formatConnections(flow.errorCount)} err`}
|
|
778
791
|
</span>
|
|
779
792
|
)}
|
|
780
793
|
</div>
|
|
@@ -827,7 +840,7 @@ function DetailsPanel({
|
|
|
827
840
|
<div className="p-2 rounded bg-theme-elevated">
|
|
828
841
|
<div className="text-theme-text-tertiary">{isRateBased ? 'Request Rate' : 'Connections'}</div>
|
|
829
842
|
<div className="text-theme-text-primary font-medium">
|
|
830
|
-
{
|
|
843
|
+
{formatVolume(edgeData.flow ? displayVolume(edgeData.flow, isRateBased) : edgeData.connections, isRateBased)}{isRateBased ? '/s' : ''}
|
|
831
844
|
</div>
|
|
832
845
|
</div>
|
|
833
846
|
)}
|
|
@@ -839,22 +852,22 @@ function DetailsPanel({
|
|
|
839
852
|
</div>
|
|
840
853
|
</div>
|
|
841
854
|
)}
|
|
842
|
-
{edgeData.flow
|
|
855
|
+
{edgeData.flow && requestRateOf(edgeData.flow) > 0 && (
|
|
843
856
|
<div className="p-2 rounded bg-theme-elevated">
|
|
844
857
|
<div className="text-theme-text-tertiary">Requests</div>
|
|
845
858
|
<div className="text-theme-text-primary font-medium">
|
|
846
|
-
{
|
|
859
|
+
{formatRate(requestRateOf(edgeData.flow))}/s
|
|
847
860
|
</div>
|
|
848
861
|
</div>
|
|
849
862
|
)}
|
|
850
|
-
{edgeData.flow
|
|
863
|
+
{edgeData.flow && errorRateOf(edgeData.flow) > 0 && (
|
|
851
864
|
<div className="p-2 rounded bg-red-500/10 border border-red-500/30">
|
|
852
865
|
<div className="text-red-400">Errors (5xx)</div>
|
|
853
866
|
<div className="text-red-400 font-medium">
|
|
854
|
-
{
|
|
855
|
-
{edgeData.flow
|
|
867
|
+
{formatRate(errorRateOf(edgeData.flow))}/s
|
|
868
|
+
{requestRateOf(edgeData.flow) > 0 && (
|
|
856
869
|
<span className="text-red-300 ml-1">
|
|
857
|
-
({((edgeData.flow
|
|
870
|
+
({((errorRateOf(edgeData.flow) / requestRateOf(edgeData.flow)) * 100).toFixed(1)}%)
|
|
858
871
|
</span>
|
|
859
872
|
)}
|
|
860
873
|
</div>
|
|
@@ -881,6 +894,21 @@ function DetailsPanel({
|
|
|
881
894
|
))}
|
|
882
895
|
</div>
|
|
883
896
|
</div>
|
|
897
|
+
) : edgeData.flow?.avgLatencyMs ? (
|
|
898
|
+
// The source measured an average, not a distribution: there is
|
|
899
|
+
// no P95 to show, and repeating the average under that label
|
|
900
|
+
// would present it as one.
|
|
901
|
+
<div className="pt-2 border-t border-theme-border">
|
|
902
|
+
<div className="text-[10px] text-theme-text-tertiary mb-1.5">Latency</div>
|
|
903
|
+
<div className="grid grid-cols-3 gap-1.5 text-xs">
|
|
904
|
+
<div className="p-1.5 rounded bg-theme-elevated text-center">
|
|
905
|
+
<div className="text-theme-text-tertiary text-[9px]">Avg</div>
|
|
906
|
+
<div className={clsx('font-medium', latencyColor(edgeData.flow.avgLatencyMs))}>
|
|
907
|
+
{formatLatency(edgeData.flow.avgLatencyMs)}
|
|
908
|
+
</div>
|
|
909
|
+
</div>
|
|
910
|
+
</div>
|
|
911
|
+
</div>
|
|
884
912
|
) : null}
|
|
885
913
|
|
|
886
914
|
{/* HTTP status distribution */}
|
|
@@ -1115,13 +1143,15 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1115
1143
|
connectionCounts.set(destId, (connectionCounts.get(destId) || 0) + 1)
|
|
1116
1144
|
|
|
1117
1145
|
// Sum total connections
|
|
1118
|
-
|
|
1119
|
-
|
|
1146
|
+
// Display-only sums: the unrounded rate for a rate-based source.
|
|
1147
|
+
const volume = displayVolume(flow, isRateBased)
|
|
1148
|
+
totalConnections.set(sourceId, (totalConnections.get(sourceId) || 0) + volume)
|
|
1149
|
+
totalConnections.set(destId, (totalConnections.get(destId) || 0) + volume)
|
|
1120
1150
|
|
|
1121
1151
|
// Track ports for destination nodes
|
|
1122
1152
|
if (!nodePorts.has(destId)) nodePorts.set(destId, new Map())
|
|
1123
1153
|
const portCounts = nodePorts.get(destId)!
|
|
1124
|
-
portCounts.set(flow.port, (portCounts.get(flow.port) || 0) +
|
|
1154
|
+
portCounts.set(flow.port, (portCounts.get(flow.port) || 0) + volume)
|
|
1125
1155
|
|
|
1126
1156
|
// Track hot path nodes (Phase 2.3)
|
|
1127
1157
|
if (flow.connections >= hotPathThreshold && hotPathThreshold > 0) {
|
|
@@ -1289,14 +1319,15 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1289
1319
|
|
|
1290
1320
|
// Phase 2.2: Edge label - connection count with unit suffix + L7 details
|
|
1291
1321
|
const connStr = isRateBased
|
|
1292
|
-
? `${
|
|
1322
|
+
? `${formatRate(displayVolume(flow, isRateBased))}/s`
|
|
1293
1323
|
: formatConnections(flow.connections)
|
|
1294
1324
|
const l7Label = flow.l7Protocol ? `${flow.l7Protocol} · ` : ''
|
|
1295
|
-
const
|
|
1325
|
+
const edgeLatency = flow.latencyP50Ms || flow.avgLatencyMs
|
|
1326
|
+
const latencyLabel = edgeLatency ? ` · ${formatLatency(edgeLatency)}` : ''
|
|
1296
1327
|
// errorCount is a rate for a rate-based source, exactly like connections above.
|
|
1297
1328
|
// Without the suffix a destination failing once a second reads as one error.
|
|
1298
1329
|
const errorLabel = hasErrors
|
|
1299
|
-
? ` · ${formatConnections(flow.errorCount ?? 0)} err
|
|
1330
|
+
? ` · ${isRateBased ? `${formatRate(errorRateOf(flow))} err/s` : `${formatConnections(flow.errorCount ?? 0)} err`}`
|
|
1300
1331
|
: ''
|
|
1301
1332
|
// A source that measures rates has no count for an edge with no requests on
|
|
1302
1333
|
// it — a plain TCP conversation, or one whose direction is unknown. Printing
|
|
@@ -1471,7 +1502,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1471
1502
|
|
|
1472
1503
|
// Add edge from addon-internet to addon-group if we have one
|
|
1473
1504
|
if (addonMode === 'group' && groupEdgeInfo) {
|
|
1474
|
-
const { connections } = groupEdgeInfo
|
|
1505
|
+
const { connections, flow: groupFlow } = groupEdgeInfo
|
|
1475
1506
|
const sourceId = 'addon-internet'
|
|
1476
1507
|
const isHotEdge = connections >= hotPathThreshold && hotPathThreshold > 0
|
|
1477
1508
|
|
|
@@ -1481,7 +1512,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1481
1512
|
target: 'addon-group',
|
|
1482
1513
|
type: 'smoothstep',
|
|
1483
1514
|
animated: isHotEdge,
|
|
1484
|
-
label: isRateBased ? `${
|
|
1515
|
+
label: isRateBased ? `${formatRate(displayVolume(groupFlow, isRateBased))}/s` : formatConnections(connections),
|
|
1485
1516
|
labelBgStyle: {
|
|
1486
1517
|
fill: '#581c87', // purple-900
|
|
1487
1518
|
fillOpacity: 0.9,
|
|
@@ -1506,7 +1537,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1506
1537
|
|
|
1507
1538
|
// Add edge from addon-group to kubernetes if we have one
|
|
1508
1539
|
if (addonMode === 'group' && groupOutEdgeInfo) {
|
|
1509
|
-
const { connections, targetId } = groupOutEdgeInfo
|
|
1540
|
+
const { connections, targetId, flow: groupFlow } = groupOutEdgeInfo
|
|
1510
1541
|
const isHotEdge = connections >= hotPathThreshold && hotPathThreshold > 0
|
|
1511
1542
|
|
|
1512
1543
|
finalEdges.push({
|
|
@@ -1515,7 +1546,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1515
1546
|
target: targetId,
|
|
1516
1547
|
type: 'smoothstep',
|
|
1517
1548
|
animated: isHotEdge,
|
|
1518
|
-
label: isRateBased ? `${
|
|
1549
|
+
label: isRateBased ? `${formatRate(displayVolume(groupFlow, isRateBased))}/s` : formatConnections(connections),
|
|
1519
1550
|
labelBgStyle: {
|
|
1520
1551
|
fill: '#581c87', // purple-900
|
|
1521
1552
|
fillOpacity: 0.9,
|
|
@@ -6,14 +6,17 @@ import { TrafficWizard } from './TrafficWizard'
|
|
|
6
6
|
import { TrafficGraph, type TrafficGraphSelection } from './TrafficGraph'
|
|
7
7
|
import { TrafficFilterSidebar } from './TrafficFilterSidebar'
|
|
8
8
|
import { TrafficFlowListProvider } from './TrafficFlowListContext'
|
|
9
|
-
import { Loader2, Filter, Plug, ChevronDown, List, Activity, AlertTriangle } from 'lucide-react'
|
|
9
|
+
import { Loader2, Filter, Plug, ChevronDown, List, Activity, AlertTriangle, Clock } from 'lucide-react'
|
|
10
10
|
import { clsx } from 'clsx'
|
|
11
11
|
import { useQueryClient } from '@tanstack/react-query'
|
|
12
12
|
import { useDock } from '../dock'
|
|
13
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, isExternalKind } from './trafficFilters'
|
|
16
|
+
import { matchesStatusRanges, bucketsFromCounts, bucketsFromStatus, isRateBasedSource, keepAvailable, effectiveThreshold, volumeUnit, type VolumeUnit, isExternalKind, mergeFlowVolume, coverageLabel } from './trafficFilters'
|
|
17
|
+
|
|
18
|
+
// Consecutive 2s retries of an empty result that came with a transient warning.
|
|
19
|
+
const MAX_EMPTY_RETRIES = 5
|
|
17
20
|
|
|
18
21
|
// Addon types for filtering
|
|
19
22
|
export type AddonMode = 'show' | 'group' | 'hide'
|
|
@@ -442,19 +445,34 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
442
445
|
// an attribute it does not export, traffic it cannot orient. Retrying returns
|
|
443
446
|
// the same answer, so retrying forever is all cost and no progress.
|
|
444
447
|
const warningIsPermanent = flowsData?.warningKind === 'partial'
|
|
445
|
-
|
|
446
|
-
//
|
|
448
|
+
const coverage = coverageLabel(flowsData?.coveredSince, flowsData?.timestamp)
|
|
449
|
+
// An 'incomplete' one is a fetch that worked but could not see everything
|
|
450
|
+
// (events lost, nodes unreachable). Not a failure, so not retried at once.
|
|
451
|
+
const warningIsIncomplete = flowsData?.warningKind === 'incomplete'
|
|
452
|
+
|
|
453
|
+
// Auto-retry when flows return with warning but no data (e.g., port-forward not
|
|
454
|
+
// ready yet). Bounded: a warning that keeps coming back — a node the relay
|
|
455
|
+
// cannot reach, a query that keeps failing — is the answer rather than a hiccup,
|
|
456
|
+
// and polling it every 2s forever costs the source a burst of queries each time.
|
|
457
|
+
const emptyRetriesRef = useRef(0)
|
|
458
|
+
// A different question gets its own retries.
|
|
459
|
+
useEffect(() => {
|
|
460
|
+
emptyRetriesRef.current = 0
|
|
461
|
+
}, [namespaces, timeRange])
|
|
447
462
|
useEffect(() => {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
) {
|
|
454
|
-
const timer = setTimeout(() =>
|
|
463
|
+
const empty = !flowsData?.aggregated || flowsData.aggregated.length === 0
|
|
464
|
+
if (!flowsData?.warning || !empty) {
|
|
465
|
+
emptyRetriesRef.current = 0
|
|
466
|
+
return
|
|
467
|
+
}
|
|
468
|
+
if (!warningIsPermanent && !warningIsIncomplete && !flowsFetching && emptyRetriesRef.current < MAX_EMPTY_RETRIES) {
|
|
469
|
+
const timer = setTimeout(() => {
|
|
470
|
+
emptyRetriesRef.current += 1
|
|
471
|
+
refetchFlowsRaw()
|
|
472
|
+
}, 2000)
|
|
455
473
|
return () => clearTimeout(timer)
|
|
456
474
|
}
|
|
457
|
-
}, [flowsData, warningIsPermanent, flowsFetching, refetchFlowsRaw])
|
|
475
|
+
}, [flowsData, warningIsPermanent, warningIsIncomplete, flowsFetching, refetchFlowsRaw])
|
|
458
476
|
|
|
459
477
|
// Filter flows based on user preferences
|
|
460
478
|
// Note: namespace filtering is done server-side via the global namespace selector
|
|
@@ -765,17 +783,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
765
783
|
|
|
766
784
|
const existing = aggregatedMap.get(key)
|
|
767
785
|
if (existing) {
|
|
768
|
-
|
|
769
|
-
existing.connections += flow.connections
|
|
770
|
-
existing.bytesSent += flow.bytesSent
|
|
771
|
-
existing.bytesRecv += flow.bytesRecv
|
|
772
|
-
existing.flowCount += flow.flowCount
|
|
773
|
-
if (flow.requestCount) {
|
|
774
|
-
existing.requestCount = (existing.requestCount || 0) + flow.requestCount
|
|
775
|
-
}
|
|
776
|
-
if (flow.errorCount) {
|
|
777
|
-
existing.errorCount = (existing.errorCount || 0) + flow.errorCount
|
|
778
|
-
}
|
|
786
|
+
mergeFlowVolume(existing, flow)
|
|
779
787
|
// Everything merged here shares the key's direction-known state, so the
|
|
780
788
|
// flag is already correct on the entry that was created first.
|
|
781
789
|
} else {
|
|
@@ -819,11 +827,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
819
827
|
|
|
820
828
|
const existing = internetFlowsMap.get(destKey)
|
|
821
829
|
if (existing) {
|
|
822
|
-
|
|
823
|
-
existing.connections += flow.connections
|
|
824
|
-
existing.bytesSent += flow.bytesSent
|
|
825
|
-
existing.bytesRecv += flow.bytesRecv
|
|
826
|
-
existing.flowCount += flow.flowCount
|
|
830
|
+
mergeFlowVolume(existing, flow)
|
|
827
831
|
} else {
|
|
828
832
|
// Create new "Internet" → destination flow
|
|
829
833
|
internetFlowsMap.set(destKey, {
|
|
@@ -852,6 +856,8 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
852
856
|
// Track totals for aggregated edges
|
|
853
857
|
let addonInternetTotal = 0
|
|
854
858
|
let addonToK8sTotal = 0
|
|
859
|
+
let addonInternetRate = 0
|
|
860
|
+
let addonToK8sRate = 0
|
|
855
861
|
const processedFlows: AggregatedFlow[] = []
|
|
856
862
|
|
|
857
863
|
// Check if destination is the kubernetes API server
|
|
@@ -868,6 +874,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
868
874
|
// Internet → Addon: aggregate into single edge to group
|
|
869
875
|
if (sourceIsInternet && destIsAddon) {
|
|
870
876
|
addonInternetTotal += flow.connections
|
|
877
|
+
addonInternetRate += flow.requestRate ?? 0
|
|
871
878
|
processedFlows.push({
|
|
872
879
|
...flow,
|
|
873
880
|
source: {
|
|
@@ -880,6 +887,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
880
887
|
// Addon → Kubernetes API: aggregate into single edge from group
|
|
881
888
|
else if (sourceIsAddon && destIsK8sAPI) {
|
|
882
889
|
addonToK8sTotal += flow.connections
|
|
890
|
+
addonToK8sRate += flow.requestRate ?? 0
|
|
883
891
|
processedFlows.push({
|
|
884
892
|
...flow,
|
|
885
893
|
destination: {
|
|
@@ -909,6 +917,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
909
917
|
protocol: 'tcp',
|
|
910
918
|
port: 0,
|
|
911
919
|
connections: addonInternetTotal,
|
|
920
|
+
...(addonInternetRate > 0 && { requestRate: addonInternetRate }),
|
|
912
921
|
bytesSent: 0,
|
|
913
922
|
bytesRecv: 0,
|
|
914
923
|
flowCount: 1,
|
|
@@ -932,6 +941,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
932
941
|
protocol: 'tcp',
|
|
933
942
|
port: 443,
|
|
934
943
|
connections: addonToK8sTotal,
|
|
944
|
+
...(addonToK8sRate > 0 && { requestRate: addonToK8sRate }),
|
|
935
945
|
bytesSent: 0,
|
|
936
946
|
bytesRecv: 0,
|
|
937
947
|
flowCount: 1,
|
|
@@ -1079,7 +1089,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1079
1089
|
}
|
|
1080
1090
|
|
|
1081
1091
|
return (
|
|
1082
|
-
<TrafficFlowListProvider flows={listFlows} graphSelection={graphSelection} clearSelection={() => setGraphSelection(null)}>
|
|
1092
|
+
<TrafficFlowListProvider flows={listFlows} responsesCallerOriented={flowsData?.l7ResponsesCallerOriented === true} graphSelection={graphSelection} clearSelection={() => setGraphSelection(null)}>
|
|
1083
1093
|
<div className="flex h-full w-full">
|
|
1084
1094
|
{/* Sidebar */}
|
|
1085
1095
|
<TrafficFilterSidebar
|
|
@@ -1207,6 +1217,15 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1207
1217
|
</button>
|
|
1208
1218
|
</Tooltip>
|
|
1209
1219
|
)}
|
|
1220
|
+
{coverage && (
|
|
1221
|
+
// Not a warning: a busy cluster reaches the limit on every
|
|
1222
|
+
// fetch. It says which part of the window the map shows.
|
|
1223
|
+
<Tooltip content={`Radar reads at most ${(flowsData?.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.`}>
|
|
1224
|
+
<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
|
+
<Clock className="w-3 h-3" /> {coverage} of {timeRange}
|
|
1226
|
+
</div>
|
|
1227
|
+
</Tooltip>
|
|
1228
|
+
)}
|
|
1210
1229
|
<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">
|
|
1211
1230
|
{flowStats.shown}/{flowStats.total}
|
|
1212
1231
|
</div>
|
|
@@ -1233,7 +1252,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1233
1252
|
/>
|
|
1234
1253
|
) : finalFlows.length > 0 ? (
|
|
1235
1254
|
<>
|
|
1236
|
-
{flowsData?.warning &&
|
|
1255
|
+
{flowsData?.warning && (
|
|
1237
1256
|
// Sits below the two chip rows (both top-3) rather than beside
|
|
1238
1257
|
// them: centred at that height it would cover the flow count and
|
|
1239
1258
|
// the refresh control at common widths. role/aria-live because it
|
|
@@ -1245,12 +1264,15 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1245
1264
|
>
|
|
1246
1265
|
<AlertBanner
|
|
1247
1266
|
variant="warning"
|
|
1248
|
-
//
|
|
1249
|
-
//
|
|
1250
|
-
//
|
|
1251
|
-
// "
|
|
1252
|
-
//
|
|
1253
|
-
|
|
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'}
|
|
1254
1276
|
message={flowsData.warning}
|
|
1255
1277
|
/>
|
|
1256
1278
|
</div>
|
|
@@ -1324,7 +1346,9 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1324
1346
|
headline={
|
|
1325
1347
|
warningIsPermanent
|
|
1326
1348
|
? 'No traffic Radar can place on the map'
|
|
1327
|
-
:
|
|
1349
|
+
: warningIsIncomplete
|
|
1350
|
+
? 'No traffic seen, but some may be missing'
|
|
1351
|
+
: 'Unable to fetch traffic data'
|
|
1328
1352
|
}
|
|
1329
1353
|
body={flowsData.warning}
|
|
1330
1354
|
className="max-w-md"
|
|
@@ -288,8 +288,10 @@ export function TrafficWizard({
|
|
|
288
288
|
</div>
|
|
289
289
|
)}
|
|
290
290
|
|
|
291
|
-
{/* No recommendation
|
|
292
|
-
|
|
291
|
+
{/* No recommendation. Not when a source is installed but unusable:
|
|
292
|
+
its banner above is the answer, and "install Cilium with Hubble"
|
|
293
|
+
would tell the user to install what they already have. */}
|
|
294
|
+
{!recommendation && !sourcesData?.detected.some(s => s.status === 'not_found') && (
|
|
293
295
|
<div className="border border-theme-border rounded-lg p-4 space-y-3">
|
|
294
296
|
<p className="text-sm text-theme-text-secondary">
|
|
295
297
|
No traffic source detected. Install Cilium with Hubble for traffic visibility.
|