@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
|
@@ -17,15 +17,7 @@ import type { AddonMode } from './TrafficView'
|
|
|
17
17
|
import { getNamespaceColor } from '../../utils/traffic-colors'
|
|
18
18
|
import { Tooltip } from '../ui/Tooltip'
|
|
19
19
|
import { Input } from '@skyhook-io/k8s-ui'
|
|
20
|
-
|
|
21
|
-
// Connection threshold options
|
|
22
|
-
const CONNECTION_THRESHOLDS = [
|
|
23
|
-
{ value: 0, label: 'All traffic' },
|
|
24
|
-
{ value: 100, label: '100+ connections' },
|
|
25
|
-
{ value: 1000, label: '1K+ connections' },
|
|
26
|
-
{ value: 10000, label: '10K+ connections' },
|
|
27
|
-
{ value: 100000, label: '100K+ connections' },
|
|
28
|
-
]
|
|
20
|
+
import { volumeThresholds } from './trafficFilters'
|
|
29
21
|
|
|
30
22
|
// Time range options
|
|
31
23
|
const TIME_RANGES = [
|
|
@@ -63,7 +55,19 @@ interface TrafficFilterSidebarProps {
|
|
|
63
55
|
setTimeRange: (v: string) => void
|
|
64
56
|
|
|
65
57
|
// L7 filters (Hubble-only)
|
|
66
|
-
|
|
58
|
+
/** Whether the flows carry any L7 detail at all. Not "is this Hubble": Beyla and
|
|
59
|
+
* Istio report L7 too, and gating on the source name hid these filters from them. */
|
|
60
|
+
showL7Filters?: boolean
|
|
61
|
+
/** Status buckets that can actually match. A source reporting no status
|
|
62
|
+
* distribution still reports an error rate, so 5xx can be offered on the strength
|
|
63
|
+
* of that while the others would match nothing. */
|
|
64
|
+
availableStatusRanges?: string[]
|
|
65
|
+
availableVerdicts?: string[]
|
|
66
|
+
hasDNSQueries?: boolean
|
|
67
|
+
availableHTTPMethods?: string[]
|
|
68
|
+
/** The active source measures rates rather than counting events, which changes
|
|
69
|
+
* both the unit and the useful scale of the volume filter. */
|
|
70
|
+
isRateBased?: boolean
|
|
67
71
|
l7Protocol: string // 'all' | 'HTTP' | 'DNS' | 'TCP'
|
|
68
72
|
setL7Protocol: (v: string) => void
|
|
69
73
|
l7Methods: Set<string>
|
|
@@ -154,7 +158,12 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
|
|
|
154
158
|
setDetectServices,
|
|
155
159
|
timeRange,
|
|
156
160
|
setTimeRange,
|
|
157
|
-
|
|
161
|
+
showL7Filters,
|
|
162
|
+
availableStatusRanges = [],
|
|
163
|
+
availableVerdicts = [],
|
|
164
|
+
hasDNSQueries,
|
|
165
|
+
availableHTTPMethods = [],
|
|
166
|
+
isRateBased,
|
|
158
167
|
l7Protocol,
|
|
159
168
|
setL7Protocol,
|
|
160
169
|
l7Methods,
|
|
@@ -209,7 +218,7 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
|
|
|
209
218
|
onChange={(e) => setMinConnections(Number(e.target.value))}
|
|
210
219
|
className="flex-1 bg-theme-elevated text-theme-text-primary text-xs rounded px-2 py-1.5 border border-theme-border focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
211
220
|
>
|
|
212
|
-
{
|
|
221
|
+
{volumeThresholds(isRateBased).map(({ value, label }) => (
|
|
213
222
|
<option key={value} value={value}>{label}</option>
|
|
214
223
|
))}
|
|
215
224
|
</select>
|
|
@@ -316,8 +325,10 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
|
|
|
316
325
|
</div>
|
|
317
326
|
</div>
|
|
318
327
|
|
|
319
|
-
{/* L7
|
|
320
|
-
|
|
328
|
+
{/* L7 filters, shown whenever the flows carry L7 detail. Every control below
|
|
329
|
+
is gated on data that exists, so the panel never offers one that cannot
|
|
330
|
+
return anything. */}
|
|
331
|
+
{showL7Filters && (
|
|
321
332
|
<div className="space-y-2 px-3 py-2 border-t border-theme-border">
|
|
322
333
|
<div className="flex items-center gap-1.5">
|
|
323
334
|
<Filter className="w-3 h-3 text-theme-text-tertiary" />
|
|
@@ -346,12 +357,13 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
|
|
|
346
357
|
</div>
|
|
347
358
|
|
|
348
359
|
{/* HTTP sub-filters (visible when protocol is All or HTTP) */}
|
|
349
|
-
{(l7Protocol === 'all' || l7Protocol === 'HTTP') && (
|
|
360
|
+
{(l7Protocol === 'all' || l7Protocol === 'HTTP') && (availableHTTPMethods.length > 0 || availableStatusRanges.length > 0) && (
|
|
350
361
|
<>
|
|
362
|
+
{availableHTTPMethods.length > 0 && (
|
|
351
363
|
<div>
|
|
352
364
|
<div className="text-[10px] text-theme-text-tertiary mb-1">HTTP Method</div>
|
|
353
365
|
<div className="flex flex-wrap gap-1">
|
|
354
|
-
{
|
|
366
|
+
{availableHTTPMethods.map(method => (
|
|
355
367
|
<button
|
|
356
368
|
key={method}
|
|
357
369
|
onClick={() => onToggleL7Method(method)}
|
|
@@ -367,7 +379,9 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
|
|
|
367
379
|
))}
|
|
368
380
|
</div>
|
|
369
381
|
</div>
|
|
382
|
+
)}
|
|
370
383
|
|
|
384
|
+
{availableStatusRanges.length > 0 && (
|
|
371
385
|
<div>
|
|
372
386
|
<div className="text-[10px] text-theme-text-tertiary mb-1">Status Code</div>
|
|
373
387
|
<div className="flex flex-wrap gap-1">
|
|
@@ -376,7 +390,7 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
|
|
|
376
390
|
{ label: '3xx', active: SEVERITY_BADGE.info },
|
|
377
391
|
{ label: '4xx', active: SEVERITY_BADGE.warning },
|
|
378
392
|
{ label: '5xx', active: SEVERITY_BADGE.error },
|
|
379
|
-
] as const).map(({ label, active }) => (
|
|
393
|
+
] as const).filter(({ label }) => availableStatusRanges.includes(label)).map(({ label, active }) => (
|
|
380
394
|
<button
|
|
381
395
|
key={label}
|
|
382
396
|
onClick={() => onToggleL7StatusRange(label)}
|
|
@@ -392,11 +406,12 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
|
|
|
392
406
|
))}
|
|
393
407
|
</div>
|
|
394
408
|
</div>
|
|
409
|
+
)}
|
|
395
410
|
</>
|
|
396
411
|
)}
|
|
397
412
|
|
|
398
413
|
{/* DNS sub-filter (visible when protocol is All or DNS) */}
|
|
399
|
-
{(l7Protocol === 'all' || l7Protocol === 'DNS') && (
|
|
414
|
+
{(l7Protocol === 'all' || l7Protocol === 'DNS') && hasDNSQueries && (
|
|
400
415
|
<div>
|
|
401
416
|
<div className="text-[10px] text-theme-text-tertiary mb-1">DNS Query</div>
|
|
402
417
|
<Input
|
|
@@ -408,7 +423,8 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
|
|
|
408
423
|
</div>
|
|
409
424
|
)}
|
|
410
425
|
|
|
411
|
-
{/* Verdict
|
|
426
|
+
{/* Verdict, limited to the verdicts the data actually contains. */}
|
|
427
|
+
{availableVerdicts.length > 0 && (
|
|
412
428
|
<div>
|
|
413
429
|
<div className="text-[10px] text-theme-text-tertiary mb-1">Verdict</div>
|
|
414
430
|
<div className="flex flex-wrap gap-1">
|
|
@@ -416,7 +432,7 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
|
|
|
416
432
|
{ label: 'forwarded', active: SEVERITY_BADGE.success },
|
|
417
433
|
{ label: 'dropped', active: SEVERITY_BADGE.error },
|
|
418
434
|
{ label: 'error', active: SEVERITY_BADGE.warning },
|
|
419
|
-
] as const).map(({ label, active }) => (
|
|
435
|
+
] as const).filter(({ label }) => availableVerdicts.includes(label)).map(({ label, active }) => (
|
|
420
436
|
<button
|
|
421
437
|
key={label}
|
|
422
438
|
onClick={() => onToggleL7Verdict(label)}
|
|
@@ -432,6 +448,7 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
|
|
|
432
448
|
))}
|
|
433
449
|
</div>
|
|
434
450
|
</div>
|
|
451
|
+
)}
|
|
435
452
|
</div>
|
|
436
453
|
)}
|
|
437
454
|
|
|
@@ -181,10 +181,24 @@ export function TrafficFlowList({ flows }: TrafficFlowListProps) {
|
|
|
181
181
|
</span>
|
|
182
182
|
</Tooltip>
|
|
183
183
|
|
|
184
|
-
{/* Destination
|
|
185
|
-
|
|
184
|
+
{/* Destination. An unoriented conversation has no caller and no
|
|
185
|
+
callee — the two ends are ordered arbitrarily — so it is marked
|
|
186
|
+
rather than presented as a direction the source established. */}
|
|
187
|
+
<Tooltip
|
|
188
|
+
content={
|
|
189
|
+
flow.directionUnknown
|
|
190
|
+
? 'Direction unknown: this source could not determine which end opened the conversation'
|
|
191
|
+
: flow.destination.namespace
|
|
192
|
+
? `${flow.destination.namespace}/${flow.destination.name}`
|
|
193
|
+
: flow.destination.name
|
|
194
|
+
}
|
|
195
|
+
wrapperClassName="min-w-0"
|
|
196
|
+
>
|
|
186
197
|
<span className="truncate text-theme-text-primary">
|
|
187
198
|
{flow.destination.name}
|
|
199
|
+
{flow.directionUnknown && (
|
|
200
|
+
<span className="ml-1 text-theme-text-tertiary">(direction unknown)</span>
|
|
201
|
+
)}
|
|
188
202
|
</span>
|
|
189
203
|
</Tooltip>
|
|
190
204
|
|
|
@@ -23,6 +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 } from './trafficFilters'
|
|
26
27
|
|
|
27
28
|
const elk = new ELK()
|
|
28
29
|
|
|
@@ -280,16 +281,21 @@ function TrafficNode({ data }: { data: TrafficNodeData }) {
|
|
|
280
281
|
)}>
|
|
281
282
|
:{portInfo.port}
|
|
282
283
|
</span>
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
284
|
+
{/* A port serving traffic with no HTTP has no request rate to show, and
|
|
285
|
+
"0" beside the port reads as an idle port rather than an unmeasured
|
|
286
|
+
one. The port itself is still worth listing. */}
|
|
287
|
+
{portInfo.connections > 0 && (
|
|
288
|
+
<span className={clsx(
|
|
289
|
+
'truncate',
|
|
290
|
+
data.isHotPath
|
|
291
|
+
? 'text-orange-400'
|
|
292
|
+
: (hasNamespaceColor || isAddonNode)
|
|
293
|
+
? 'text-white/60'
|
|
294
|
+
: 'text-theme-text-tertiary'
|
|
295
|
+
)}>
|
|
296
|
+
{formatConnections(portInfo.connections)}{data.connLabel === 'req/s' ? '/s' : ''}
|
|
297
|
+
</span>
|
|
298
|
+
)}
|
|
293
299
|
</div>
|
|
294
300
|
))}
|
|
295
301
|
{data.ports.filter(p => p.port !== 0).length > MAX_VISIBLE_PORTS && (
|
|
@@ -303,7 +309,7 @@ function TrafficNode({ data }: { data: TrafficNodeData }) {
|
|
|
303
309
|
</div>
|
|
304
310
|
)}
|
|
305
311
|
{/* Total connections (only if no ports shown, or all ports are 0) */}
|
|
306
|
-
{(!data.ports || data.ports.filter(p => p.port !== 0).length === 0) && data.totalConnections && data.totalConnections > 0 && (
|
|
312
|
+
{(!data.ports || data.ports.filter(p => p.port !== 0).length === 0) && data.totalConnections != null && data.totalConnections > 0 && (
|
|
307
313
|
<div className="mt-1">
|
|
308
314
|
<span className={clsx(
|
|
309
315
|
'text-xs truncate',
|
|
@@ -339,6 +345,19 @@ function TrafficLegend() {
|
|
|
339
345
|
</svg>
|
|
340
346
|
<span className="text-theme-text-secondary">HTTP / gRPC</span>
|
|
341
347
|
</div>
|
|
348
|
+
<div className="flex items-center gap-2">
|
|
349
|
+
<svg width="24" height="8" className="shrink-0" aria-hidden="true">
|
|
350
|
+
<line x1="0" y1="4" x2="20" y2="4" stroke="currentColor" strokeWidth="2" className="text-theme-text-tertiary" />
|
|
351
|
+
<polygon points="20,1 24,4 20,7" className="fill-theme-text-tertiary" />
|
|
352
|
+
</svg>
|
|
353
|
+
<span className="text-theme-text-secondary">Arrow = who opened it</span>
|
|
354
|
+
</div>
|
|
355
|
+
<div className="flex items-center gap-2">
|
|
356
|
+
<svg width="24" height="8" className="shrink-0" aria-hidden="true">
|
|
357
|
+
<line x1="0" y1="4" x2="24" y2="4" stroke="currentColor" strokeWidth="2" className="text-theme-text-tertiary" />
|
|
358
|
+
</svg>
|
|
359
|
+
<span className="text-theme-text-secondary">No arrow = direction unknown</span>
|
|
360
|
+
</div>
|
|
342
361
|
<div className="flex items-center gap-2">
|
|
343
362
|
<svg width="24" height="8" className="shrink-0">
|
|
344
363
|
<line x1="0" y1="4" x2="24" y2="4" stroke="#06b6d4" strokeWidth="2" />
|
|
@@ -402,12 +421,14 @@ function DetailsPanel({
|
|
|
402
421
|
selection,
|
|
403
422
|
onClose,
|
|
404
423
|
flows,
|
|
405
|
-
|
|
424
|
+
isRateBased,
|
|
406
425
|
}: {
|
|
407
426
|
selection: Selection
|
|
408
427
|
onClose: () => void
|
|
409
428
|
flows: AggregatedFlow[]
|
|
410
|
-
|
|
429
|
+
// True for sources that measure a rate rather than counting connections —
|
|
430
|
+
// Istio and Beyla both do. Changes the wording and the /s suffix.
|
|
431
|
+
isRateBased: boolean
|
|
411
432
|
}) {
|
|
412
433
|
if (!selection) return null
|
|
413
434
|
|
|
@@ -548,10 +569,10 @@ function DetailsPanel({
|
|
|
548
569
|
Service: <span className="text-theme-text-primary capitalize">{nodeData.serviceCategory}</span>
|
|
549
570
|
</div>
|
|
550
571
|
)}
|
|
551
|
-
{nodeData.totalConnections && (
|
|
572
|
+
{nodeData.totalConnections != null && nodeData.totalConnections > 0 && (
|
|
552
573
|
<div className="text-xs text-theme-text-secondary">
|
|
553
|
-
{
|
|
554
|
-
{formatConnections(nodeData.totalConnections)}{
|
|
574
|
+
{isRateBased ? 'Total request rate' : 'Total connections'}: <span className="text-theme-text-primary font-medium">
|
|
575
|
+
{formatConnections(nodeData.totalConnections)}{isRateBased ? '/s' : ''}
|
|
555
576
|
</span>
|
|
556
577
|
</div>
|
|
557
578
|
)}
|
|
@@ -605,7 +626,7 @@ function DetailsPanel({
|
|
|
605
626
|
.map(([proto, count]) => (
|
|
606
627
|
<span key={proto} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-theme-bg text-theme-text-secondary">
|
|
607
628
|
<span className="font-medium">{proto}</span>
|
|
608
|
-
<span className="text-theme-text-tertiary">{formatConnections(count)}</span>
|
|
629
|
+
<span className="text-theme-text-tertiary">{formatConnections(count)}{isRateBased ? '/s' : ''}</span>
|
|
609
630
|
</span>
|
|
610
631
|
))}
|
|
611
632
|
</div>
|
|
@@ -672,7 +693,15 @@ function DetailsPanel({
|
|
|
672
693
|
<span className="text-theme-text-primary truncate flex-1">
|
|
673
694
|
{flow.source.name}
|
|
674
695
|
</span>
|
|
675
|
-
|
|
696
|
+
{/* Same rule as the canvas and the edge panel: an edge nobody
|
|
697
|
+
could orient is not drawn with an arrow. */}
|
|
698
|
+
{flow.directionUnknown ? (
|
|
699
|
+
<Tooltip content="Neither end was reported as the initiator">
|
|
700
|
+
<span className="text-theme-text-tertiary shrink-0">—</span>
|
|
701
|
+
</Tooltip>
|
|
702
|
+
) : (
|
|
703
|
+
<ArrowRight className="h-3 w-3 text-theme-text-tertiary shrink-0" />
|
|
704
|
+
)}
|
|
676
705
|
{flow.port !== 0 && (
|
|
677
706
|
<span className="text-blue-400 font-mono">:{flow.port}</span>
|
|
678
707
|
)}
|
|
@@ -681,17 +710,19 @@ function DetailsPanel({
|
|
|
681
710
|
<span className="px-1 py-0.5 rounded bg-theme-bg text-theme-text-tertiary uppercase">
|
|
682
711
|
{flow.protocol || 'tcp'}
|
|
683
712
|
</span>
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
713
|
+
{flow.connections > 0 && (
|
|
714
|
+
<span className="text-theme-text-secondary">
|
|
715
|
+
{formatConnections(flow.connections)} {isRateBased ? 'req/s' : 'conn'}
|
|
716
|
+
</span>
|
|
717
|
+
)}
|
|
687
718
|
{(flow.bytesSent > 0 || flow.bytesRecv > 0) && (
|
|
688
719
|
<span className="text-theme-text-tertiary">
|
|
689
720
|
{formatBytes(flow.bytesSent + flow.bytesRecv)}
|
|
690
721
|
</span>
|
|
691
722
|
)}
|
|
692
|
-
{flow.errorCount && flow.errorCount > 0 && (
|
|
723
|
+
{flow.errorCount != null && flow.errorCount > 0 && (
|
|
693
724
|
<span className="text-red-400">
|
|
694
|
-
{formatConnections(flow.errorCount)} err
|
|
725
|
+
{formatConnections(flow.errorCount)} err{isRateBased ? '/s' : ''}
|
|
695
726
|
</span>
|
|
696
727
|
)}
|
|
697
728
|
</div>
|
|
@@ -713,7 +744,13 @@ function DetailsPanel({
|
|
|
713
744
|
.map((flow, i) => (
|
|
714
745
|
<div key={i} className="text-xs p-2 rounded bg-theme-elevated space-y-1">
|
|
715
746
|
<div className="flex items-center gap-1.5">
|
|
716
|
-
|
|
747
|
+
{flow.directionUnknown ? (
|
|
748
|
+
<Tooltip content="Neither end was reported as the initiator">
|
|
749
|
+
<span className="text-theme-text-tertiary shrink-0">—</span>
|
|
750
|
+
</Tooltip>
|
|
751
|
+
) : (
|
|
752
|
+
<ArrowRight className="h-3 w-3 text-theme-text-tertiary shrink-0" />
|
|
753
|
+
)}
|
|
717
754
|
<span className="text-theme-text-primary truncate flex-1">
|
|
718
755
|
{flow.destination.name}
|
|
719
756
|
</span>
|
|
@@ -725,17 +762,19 @@ function DetailsPanel({
|
|
|
725
762
|
<span className="px-1 py-0.5 rounded bg-theme-bg text-theme-text-tertiary uppercase">
|
|
726
763
|
{flow.protocol || 'tcp'}
|
|
727
764
|
</span>
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
765
|
+
{flow.connections > 0 && (
|
|
766
|
+
<span className="text-theme-text-secondary">
|
|
767
|
+
{formatConnections(flow.connections)} {isRateBased ? 'req/s' : 'conn'}
|
|
768
|
+
</span>
|
|
769
|
+
)}
|
|
731
770
|
{(flow.bytesSent > 0 || flow.bytesRecv > 0) && (
|
|
732
771
|
<span className="text-theme-text-tertiary">
|
|
733
772
|
{formatBytes(flow.bytesSent + flow.bytesRecv)}
|
|
734
773
|
</span>
|
|
735
774
|
)}
|
|
736
|
-
{flow.errorCount && flow.errorCount > 0 && (
|
|
775
|
+
{flow.errorCount != null && flow.errorCount > 0 && (
|
|
737
776
|
<span className="text-red-400">
|
|
738
|
-
{formatConnections(flow.errorCount)} err
|
|
777
|
+
{formatConnections(flow.errorCount)} err{isRateBased ? '/s' : ''}
|
|
739
778
|
</span>
|
|
740
779
|
)}
|
|
741
780
|
</div>
|
|
@@ -753,7 +792,16 @@ function DetailsPanel({
|
|
|
753
792
|
<div className="space-y-2">
|
|
754
793
|
<div className="flex items-center gap-2 text-sm">
|
|
755
794
|
<span className="text-theme-text-primary truncate">{edgeData.source.split('/').pop()}</span>
|
|
756
|
-
|
|
795
|
+
{edgeData.flow?.directionUnknown ? (
|
|
796
|
+
// The graph omits the arrowhead on this edge because nothing
|
|
797
|
+
// established which end opened the conversation. An arrow here
|
|
798
|
+
// would assert exactly what the graph is declining to claim.
|
|
799
|
+
<Tooltip content="Neither end was reported as the initiator">
|
|
800
|
+
<span className="text-theme-text-tertiary shrink-0">—</span>
|
|
801
|
+
</Tooltip>
|
|
802
|
+
) : (
|
|
803
|
+
<ArrowRight className="h-4 w-4 text-theme-text-tertiary shrink-0" />
|
|
804
|
+
)}
|
|
757
805
|
<span className="text-theme-text-primary truncate">{edgeData.target.split('/').pop()}</span>
|
|
758
806
|
</div>
|
|
759
807
|
|
|
@@ -772,12 +820,17 @@ function DetailsPanel({
|
|
|
772
820
|
: edgeData.protocol}
|
|
773
821
|
</div>
|
|
774
822
|
</div>
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
823
|
+
{(!isRateBased || edgeData.connections > 0) && (
|
|
824
|
+
// A rate-based source reports no request rate for traffic with no
|
|
825
|
+
// HTTP, and "0/s" beside a data figure in megabytes reads as "no
|
|
826
|
+
// traffic" rather than "not measured".
|
|
827
|
+
<div className="p-2 rounded bg-theme-elevated">
|
|
828
|
+
<div className="text-theme-text-tertiary">{isRateBased ? 'Request Rate' : 'Connections'}</div>
|
|
829
|
+
<div className="text-theme-text-primary font-medium">
|
|
830
|
+
{formatConnections(edgeData.connections)}{isRateBased ? '/s' : ''}
|
|
831
|
+
</div>
|
|
779
832
|
</div>
|
|
780
|
-
|
|
833
|
+
)}
|
|
781
834
|
{edgeData.flow && (edgeData.flow.bytesSent > 0 || edgeData.flow.bytesRecv > 0) && (
|
|
782
835
|
<div className="p-2 rounded bg-theme-elevated">
|
|
783
836
|
<div className="text-theme-text-tertiary">Data</div>
|
|
@@ -786,7 +839,7 @@ function DetailsPanel({
|
|
|
786
839
|
</div>
|
|
787
840
|
</div>
|
|
788
841
|
)}
|
|
789
|
-
{edgeData.flow?.requestCount && edgeData.flow.requestCount > 0 && (
|
|
842
|
+
{edgeData.flow?.requestCount != null && edgeData.flow.requestCount > 0 && (
|
|
790
843
|
<div className="p-2 rounded bg-theme-elevated">
|
|
791
844
|
<div className="text-theme-text-tertiary">Requests</div>
|
|
792
845
|
<div className="text-theme-text-primary font-medium">
|
|
@@ -794,12 +847,12 @@ function DetailsPanel({
|
|
|
794
847
|
</div>
|
|
795
848
|
</div>
|
|
796
849
|
)}
|
|
797
|
-
{edgeData.flow?.errorCount && edgeData.flow.errorCount > 0 && (
|
|
850
|
+
{edgeData.flow?.errorCount != null && edgeData.flow.errorCount > 0 && (
|
|
798
851
|
<div className="p-2 rounded bg-red-500/10 border border-red-500/30">
|
|
799
852
|
<div className="text-red-400">Errors (5xx)</div>
|
|
800
853
|
<div className="text-red-400 font-medium">
|
|
801
854
|
{formatConnections(edgeData.flow.errorCount)}/s
|
|
802
|
-
{edgeData.flow.requestCount && edgeData.flow.requestCount > 0 && (
|
|
855
|
+
{edgeData.flow.requestCount != null && edgeData.flow.requestCount > 0 && (
|
|
803
856
|
<span className="text-red-300 ml-1">
|
|
804
857
|
({((edgeData.flow.errorCount / edgeData.flow.requestCount) * 100).toFixed(1)}%)
|
|
805
858
|
</span>
|
|
@@ -910,17 +963,23 @@ function DetailsPanel({
|
|
|
910
963
|
{edgeData.flow && (
|
|
911
964
|
<div className="space-y-1 pt-2 border-t border-theme-border">
|
|
912
965
|
<div className="text-xs text-theme-text-secondary">
|
|
913
|
-
Source: <span className="text-theme-text-primary">{edgeData.flow.source.name}</span>
|
|
966
|
+
{edgeData.flow.directionUnknown ? 'Endpoint' : 'Source'}: <span className="text-theme-text-primary">{edgeData.flow.source.name}</span>
|
|
914
967
|
{edgeData.flow.source.namespace && (
|
|
915
968
|
<span className="text-theme-text-tertiary"> ({edgeData.flow.source.namespace})</span>
|
|
916
969
|
)}
|
|
917
970
|
</div>
|
|
918
971
|
<div className="text-xs text-theme-text-secondary">
|
|
919
|
-
Destination: <span className="text-theme-text-primary">{edgeData.flow.destination.name}</span>
|
|
972
|
+
{edgeData.flow.directionUnknown ? 'Endpoint' : 'Destination'}: <span className="text-theme-text-primary">{edgeData.flow.destination.name}</span>
|
|
920
973
|
{edgeData.flow.destination.namespace && (
|
|
921
974
|
<span className="text-theme-text-tertiary"> ({edgeData.flow.destination.namespace})</span>
|
|
922
975
|
)}
|
|
923
976
|
</div>
|
|
977
|
+
{edgeData.flow.directionUnknown && (
|
|
978
|
+
<div className="text-xs text-theme-text-tertiary mt-1">
|
|
979
|
+
Which endpoint opened this conversation was not reported, so the
|
|
980
|
+
two are listed in a fixed order rather than as caller and callee.
|
|
981
|
+
</div>
|
|
982
|
+
)}
|
|
924
983
|
{edgeData.flow.destination.kind.toLowerCase() === 'external' && (
|
|
925
984
|
<div className="text-xs text-yellow-400 mt-1">
|
|
926
985
|
External service
|
|
@@ -998,9 +1057,20 @@ function FitViewOnChange({
|
|
|
998
1057
|
return null
|
|
999
1058
|
}
|
|
1000
1059
|
|
|
1060
|
+
// A pair can carry both oriented and unoriented traffic at the same port, and the
|
|
1061
|
+
// backend keeps those as separate aggregates because no single answer about the
|
|
1062
|
+
// direction is right for both. Without the suffix they would share an edge id and
|
|
1063
|
+
// one would silently replace the other. Only unoriented edges are suffixed, so
|
|
1064
|
+
// every id a source that always orients its flows produces is unchanged.
|
|
1065
|
+
function trafficEdgeId(sourceId: string, destId: string, flow: AggregatedFlow): string {
|
|
1066
|
+
return `${sourceId}->${destId}:${flow.port}${flow.directionUnknown ? ':unoriented' : ''}`
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1001
1069
|
export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups = false, serviceCategories, addonMode = 'show', trafficSource = '', onSelectionChange }: TrafficGraphProps) {
|
|
1002
|
-
|
|
1003
|
-
|
|
1070
|
+
// Beyla is rate-based too: its Connections field carries requests per second,
|
|
1071
|
+
// not a connection count, so it needs the same label Istio gets.
|
|
1072
|
+
const isRateBased = isRateBasedSource(trafficSource)
|
|
1073
|
+
const connLabel = isRateBased ? 'req/s' : 'conn'
|
|
1004
1074
|
const [layoutedNodes, setLayoutedNodes] = useState<Node<TrafficNodeData>[]>([])
|
|
1005
1075
|
const [layoutedEdges, setLayoutedEdges] = useState<Edge[]>([])
|
|
1006
1076
|
const [selection, setSelection] = useState<Selection | null>(null)
|
|
@@ -1015,7 +1085,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1015
1085
|
const destId = flow.destination.namespace
|
|
1016
1086
|
? `${flow.destination.namespace}/${flow.destination.name}`
|
|
1017
1087
|
: flow.destination.name
|
|
1018
|
-
const edgeId =
|
|
1088
|
+
const edgeId = trafficEdgeId(sourceId, destId, flow)
|
|
1019
1089
|
map.set(edgeId, flow)
|
|
1020
1090
|
})
|
|
1021
1091
|
return map
|
|
@@ -1198,7 +1268,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1198
1268
|
}
|
|
1199
1269
|
|
|
1200
1270
|
// Create edge with visual encoding (Phase 2.1, 2.2, 2.3)
|
|
1201
|
-
const edgeId =
|
|
1271
|
+
const edgeId = trafficEdgeId(sourceId, destId, flow)
|
|
1202
1272
|
const isHotEdge = flow.connections >= hotPathThreshold && hotPathThreshold > 0
|
|
1203
1273
|
const hasErrors = (flow.errorCount ?? 0) > 0
|
|
1204
1274
|
|
|
@@ -1218,20 +1288,34 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1218
1288
|
const strokeWidth = getEdgeWidth(flow.connections)
|
|
1219
1289
|
|
|
1220
1290
|
// Phase 2.2: Edge label - connection count with unit suffix + L7 details
|
|
1221
|
-
const connStr =
|
|
1291
|
+
const connStr = isRateBased
|
|
1222
1292
|
? `${formatConnections(flow.connections)}/s`
|
|
1223
1293
|
: formatConnections(flow.connections)
|
|
1224
1294
|
const l7Label = flow.l7Protocol ? `${flow.l7Protocol} · ` : ''
|
|
1225
1295
|
const latencyLabel = flow.latencyP50Ms ? ` · ${formatLatency(flow.latencyP50Ms)}` : ''
|
|
1296
|
+
// errorCount is a rate for a rate-based source, exactly like connections above.
|
|
1297
|
+
// Without the suffix a destination failing once a second reads as one error.
|
|
1226
1298
|
const errorLabel = hasErrors
|
|
1227
|
-
? ` · ${formatConnections(flow.errorCount ?? 0)} err`
|
|
1299
|
+
? ` · ${formatConnections(flow.errorCount ?? 0)} err${isRateBased ? '/s' : ''}`
|
|
1228
1300
|
: ''
|
|
1229
|
-
|
|
1301
|
+
// A source that measures rates has no count for an edge with no requests on
|
|
1302
|
+
// it — a plain TCP conversation, or one whose direction is unknown. Printing
|
|
1303
|
+
// "0/s" there reads as no traffic, on an edge that may be carrying megabytes.
|
|
1304
|
+
const countLabel = isRateBased && !flow.connections ? '' : connStr
|
|
1305
|
+
const edgeLabel = [l7Label + countLabel, latencyLabel, errorLabel]
|
|
1306
|
+
.join('')
|
|
1307
|
+
.replace(/^ · /, '')
|
|
1230
1308
|
|
|
1231
1309
|
edgeList.push({
|
|
1232
1310
|
id: edgeId,
|
|
1233
1311
|
source: sourceId,
|
|
1234
1312
|
target: destId,
|
|
1313
|
+
// React Flow's default is "Edge from A to B", which states a direction this
|
|
1314
|
+
// edge is deliberately drawn without. Assistive technology should hear the
|
|
1315
|
+
// same uncertainty the missing arrowhead conveys.
|
|
1316
|
+
ariaLabel: flow.directionUnknown
|
|
1317
|
+
? `Connection between ${sourceId} and ${destId}, direction unknown`
|
|
1318
|
+
: `Edge from ${sourceId} to ${destId}`,
|
|
1235
1319
|
type: 'smoothstep',
|
|
1236
1320
|
animated: isHotEdge, // Animate hot paths
|
|
1237
1321
|
label: edgeLabel,
|
|
@@ -1249,12 +1333,20 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1249
1333
|
stroke: strokeColor,
|
|
1250
1334
|
...(hasDrops && { strokeDasharray: '6 3' }),
|
|
1251
1335
|
},
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1336
|
+
// No arrowhead when the initiator is unknown. Beyla labels both halves of a
|
|
1337
|
+
// UDP conversation the same way, so an arrow would be asserting something
|
|
1338
|
+
// the data does not say; the edge still carries the traffic in both
|
|
1339
|
+
// directions.
|
|
1340
|
+
...(flow.directionUnknown
|
|
1341
|
+
? {}
|
|
1342
|
+
: {
|
|
1343
|
+
markerEnd: {
|
|
1344
|
+
type: MarkerType.ArrowClosed,
|
|
1345
|
+
width: 16,
|
|
1346
|
+
height: 16,
|
|
1347
|
+
color: strokeColor,
|
|
1348
|
+
},
|
|
1349
|
+
}),
|
|
1258
1350
|
})
|
|
1259
1351
|
})
|
|
1260
1352
|
|
|
@@ -1264,7 +1356,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1264
1356
|
addonGroupEdge, // Pass this for adding after group is created
|
|
1265
1357
|
addonGroupOutEdge, // Pass this for adding group → kubernetes edge
|
|
1266
1358
|
}
|
|
1267
|
-
}, [flows, hotPathThreshold, showNamespaceGroups, serviceCategories, addonMode,
|
|
1359
|
+
}, [flows, hotPathThreshold, showNamespaceGroups, serviceCategories, addonMode, isRateBased, connLabel])
|
|
1268
1360
|
|
|
1269
1361
|
// Apply ELK layout
|
|
1270
1362
|
const applyLayout = useCallback(async () => {
|
|
@@ -1389,7 +1481,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1389
1481
|
target: 'addon-group',
|
|
1390
1482
|
type: 'smoothstep',
|
|
1391
1483
|
animated: isHotEdge,
|
|
1392
|
-
label: formatConnections(connections),
|
|
1484
|
+
label: isRateBased ? `${formatConnections(connections)}/s` : formatConnections(connections),
|
|
1393
1485
|
labelBgStyle: {
|
|
1394
1486
|
fill: '#581c87', // purple-900
|
|
1395
1487
|
fillOpacity: 0.9,
|
|
@@ -1423,7 +1515,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1423
1515
|
target: targetId,
|
|
1424
1516
|
type: 'smoothstep',
|
|
1425
1517
|
animated: isHotEdge,
|
|
1426
|
-
label: formatConnections(connections),
|
|
1518
|
+
label: isRateBased ? `${formatConnections(connections)}/s` : formatConnections(connections),
|
|
1427
1519
|
labelBgStyle: {
|
|
1428
1520
|
fill: '#581c87', // purple-900
|
|
1429
1521
|
fillOpacity: 0.9,
|
|
@@ -1462,7 +1554,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1462
1554
|
setLayoutedNodes(positionedNodes)
|
|
1463
1555
|
setLayoutedEdges(rawEdges)
|
|
1464
1556
|
}
|
|
1465
|
-
}, [rawNodes, rawEdges, addonMode, addonGroupEdge, addonGroupOutEdge, hotPathThreshold])
|
|
1557
|
+
}, [rawNodes, rawEdges, addonMode, addonGroupEdge, addonGroupOutEdge, hotPathThreshold, isRateBased])
|
|
1466
1558
|
|
|
1467
1559
|
// Run layout when flows change
|
|
1468
1560
|
useEffect(() => {
|
|
@@ -1556,7 +1648,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1556
1648
|
selection={selection}
|
|
1557
1649
|
onClose={() => setSelection(null)}
|
|
1558
1650
|
flows={flows}
|
|
1559
|
-
|
|
1651
|
+
isRateBased={isRateBased}
|
|
1560
1652
|
/>
|
|
1561
1653
|
)}
|
|
1562
1654
|
</div>
|