@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.
Files changed (66) hide show
  1. package/package.json +10 -10
  2. package/src/App.tsx +5 -8
  3. package/src/api/client.ts +174 -12
  4. package/src/api/policy.test.ts +38 -0
  5. package/src/api/policy.ts +166 -2
  6. package/src/components/ConnectionErrorView.test.tsx +53 -0
  7. package/src/components/ConnectionErrorView.tsx +15 -12
  8. package/src/components/ContextSwitcher.tsx +10 -13
  9. package/src/components/audit/UpgradeReadinessView.tsx +3 -3
  10. package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
  11. package/src/components/capacity/schedulingBar.test.ts +10 -0
  12. package/src/components/cost/ApplicationCostTab.test.ts +6 -0
  13. package/src/components/cost/ApplicationCostTab.tsx +28 -16
  14. package/src/components/cost/CostTrendChart.tsx +20 -10
  15. package/src/components/cost/CostView.tsx +79 -28
  16. package/src/components/cost/CurrentAllocationUse.tsx +6 -4
  17. package/src/components/cost/WorkloadCostTab.test.ts +10 -0
  18. package/src/components/cost/WorkloadCostTab.tsx +24 -12
  19. package/src/components/cost/format.test.ts +27 -8
  20. package/src/components/cost/format.ts +78 -27
  21. package/src/components/home/ClusterHealthCard.tsx +3 -0
  22. package/src/components/home/CostCard.tsx +12 -7
  23. package/src/components/home/HomeView.tsx +15 -3
  24. package/src/components/home/NetworkPolicyCoverageCard.test.tsx +81 -0
  25. package/src/components/home/NetworkPolicyCoverageCard.tsx +50 -7
  26. package/src/components/home/TopologyPreview.tsx +57 -11
  27. package/src/components/home/mcpToolCatalog.ts +27 -5
  28. package/src/components/nav/PrimaryNavRail.tsx +2 -2
  29. package/src/components/resources/ResourcesView.tsx +15 -3
  30. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
  31. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
  32. package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
  33. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
  34. package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
  35. package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
  36. package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
  37. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
  38. package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
  39. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
  40. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
  41. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
  42. package/src/components/resources/renderers/index.ts +1 -0
  43. package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
  44. package/src/components/settings/SettingsDialog.tsx +160 -36
  45. package/src/components/settings/currency-options.test.ts +49 -0
  46. package/src/components/settings/currency-options.ts +38 -0
  47. package/src/components/traffic/TrafficFilterSidebar.tsx +37 -20
  48. package/src/components/traffic/TrafficFlowList.tsx +16 -2
  49. package/src/components/traffic/TrafficGraph.tsx +150 -58
  50. package/src/components/traffic/TrafficView.tsx +168 -52
  51. package/src/components/traffic/TrafficWizard.tsx +13 -1
  52. package/src/components/traffic/trafficFilters.test.ts +103 -0
  53. package/src/components/traffic/trafficFilters.ts +117 -0
  54. package/src/components/ui/DiagnosticsOverlay.test.ts +115 -0
  55. package/src/components/ui/DiagnosticsOverlay.tsx +65 -8
  56. package/src/components/ui/command-items.ts +4 -14
  57. package/src/components/workload/WorkloadView.tsx +57 -8
  58. package/src/main.tsx +4 -114
  59. package/src/utils/context-name.test.ts +63 -0
  60. package/src/utils/context-name.ts +22 -0
  61. package/src/utils/navigation.ts +44 -2
  62. package/src/utils/network-policy-navigation.test.ts +68 -0
  63. package/src/utils/topology-selection.test.ts +40 -0
  64. package/src/utils/topology-selection.ts +39 -0
  65. package/src/utils/wails-clipboard.test.ts +109 -0
  66. 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 (flowsData?.warning && (!flowsData.aggregated || flowsData.aggregated.length === 0) && !flowsFetching) {
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 < minConnections) {
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 (l7Methods.size > 0) {
510
- if (!flow.topHTTPPaths?.some(p => l7Methods.has(p.method))) return false
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 (l7Verdicts.size > 0) {
516
- if (!flow.verdictCounts || !Array.from(l7Verdicts).some(v => (flow.verdictCounts?.[v] ?? 0) > 0)) return false
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 (dnsPattern) {
519
- const pattern = dnsPattern.toLowerCase()
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, minConnections, hiddenNamespaces, addonMode, l7Protocol, l7Methods, l7StatusRanges, l7Verdicts, dnsPattern])
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 (l7Methods.size > 0) {
559
- if (!flow.httpMethod || !l7Methods.has(flow.httpMethod)) return false
625
+ if (activeMethods.size > 0) {
626
+ if (!flow.httpMethod || !activeMethods.has(flow.httpMethod)) return false
560
627
  }
561
- if (l7StatusRanges.size > 0) {
562
- if (!flow.httpStatus) return false
563
- const bucket = `${Math.floor(flow.httpStatus / 100)}xx`
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 (l7Verdicts.size > 0) {
567
- if (!flow.verdict || !l7Verdicts.has(flow.verdict)) return false
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, l7Methods, l7StatusRanges, l7Verdicts, dnsPattern])
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
- // Show L7 filters only when flows actually contain L7 data
616
- const hasL7Data = useMemo(() => {
617
- if (!flowsData?.aggregated) return false
618
- return flowsData.aggregated.some(f => f.l7Protocol || f.topHTTPPaths || f.topDNSQueries)
619
- }, [flowsData?.aggregated])
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
- const key = `${sourceKey}->${destKey}`
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
- // Create a key based on destination + port
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 < minConnections) {
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, minConnections])
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={minConnections}
1018
- setMinConnections={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
- isHubble={sourcesData?.active === 'hubble' && hasL7Data}
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
- <TrafficGraph
1158
- flows={finalFlows}
1159
- hotPathThreshold={hotPathThreshold}
1160
- showNamespaceGroups={showNamespaceGroups}
1161
- serviceCategories={serviceCategories}
1162
- addonMode={addonMode}
1163
- trafficSource={sourcesData?.active || ''}
1164
- onSelectionChange={setGraphSelection}
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
- setMinConnections(0)
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="Unable to fetch traffic data"
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
+ }