@skyhook-io/radar-app 1.16.2 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/App.tsx +60 -12
- package/src/RadarApp.tsx +27 -17
- package/src/api/client.goneResource.test.ts +49 -0
- package/src/api/client.ts +168 -55
- package/src/api/cnpg.ts +95 -0
- package/src/api/diagnose.test.ts +40 -0
- package/src/api/diagnose.ts +12 -1
- package/src/api/radarFeatures.render.test.tsx +19 -1
- package/src/api/radarFeatures.ts +3 -0
- package/src/api/timelineSource.ts +1 -1
- package/src/api/traffic.sources.test.ts +55 -0
- package/src/api/traffic.ts +173 -14
- package/src/components/NoClusterAccessBanner.tsx +68 -0
- package/src/components/applications/ApplicationsView.tsx +3 -0
- package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
- package/src/components/capacity/CapacityView.test.tsx +31 -1
- package/src/components/capacity/shared.tsx +12 -21
- package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
- package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
- package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
- package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
- package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
- package/src/components/cnpg/CNPGOperator.tsx +234 -0
- package/src/components/cnpg/CNPGOverview.tsx +307 -0
- package/src/components/cnpg/CNPGPooling.tsx +116 -0
- package/src/components/cnpg/CNPGProtection.tsx +320 -0
- package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
- package/src/components/cnpg/CNPGView.tsx +156 -0
- package/src/components/cnpg/paths.ts +13 -0
- package/src/components/cnpg/routes.test.ts +60 -0
- package/src/components/cnpg/routes.ts +112 -0
- package/src/components/cnpg/shared.tsx +290 -0
- package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
- package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
- package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
- package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
- package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
- package/src/components/diagnose/ApplyDialog.tsx +26 -28
- package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
- package/src/components/diagnose/DiagnoseContext.tsx +136 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
- package/src/components/diagnose/InvestigationView.tsx +39 -2
- package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
- package/src/components/diagnose/agentCatalog.ts +2 -2
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
- package/src/components/dock/TrafficFlowListTab.tsx +27 -6
- package/src/components/execution/BatchExecutionView.tsx +11 -11
- package/src/components/helm/ChartBrowser.tsx +9 -10
- package/src/components/helm/HelmCompareRoute.tsx +108 -115
- package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
- package/src/components/helm/InstallWizard.tsx +5 -2
- package/src/components/helm/RevisionHistory.tsx +3 -2
- package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
- package/src/components/helm/ValuesViewer.tsx +1 -1
- package/src/components/home/ClusterHealthCard.tsx +7 -0
- package/src/components/home/mcpToolCatalog.ts +1 -1
- package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
- package/src/components/resources/ResourcesView.tsx +6 -38
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
- package/src/components/settings/SettingsDialog.tsx +122 -19
- package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
- package/src/components/timeline/TimelineList.test.ts +47 -29
- package/src/components/timeline/TimelineList.tsx +60 -11
- package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
- package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
- package/src/components/timeline/TimelineView.tsx +16 -2
- package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
- package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
- package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
- package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
- package/src/components/traffic/TrafficGraph.tsx +109 -26
- package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
- package/src/components/traffic/TrafficView.tsx +582 -149
- package/src/components/traffic/trafficFilters.test.ts +398 -1
- package/src/components/traffic/trafficFilters.ts +408 -0
- package/src/components/whats-new/WhatsNew.test.ts +107 -28
- package/src/components/whats-new/WhatsNew.tsx +146 -86
- package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
- package/src/components/whats-new/releaseNotes.ts +155 -24
- package/src/components/workload/WorkloadView.test.ts +2 -1
- package/src/components/workload/WorkloadView.tsx +165 -294
- package/src/context/TimelineCustomization.tsx +15 -0
- package/src/contexts/CapabilitiesContext.tsx +9 -1
- package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
- package/src/hooks/useGitOpsWriteGuard.ts +193 -0
- package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
- package/src/hooks/useResourceCounts.ts +49 -0
- package/src/components/helm/RoleGatedPanel.tsx +0 -47
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
import { Crosshair, X } from 'lucide-react'
|
|
5
|
+
import { Badge, Input } from '@skyhook-io/k8s-ui'
|
|
6
|
+
import { useNavCustomization } from '../../context/NavCustomization'
|
|
7
|
+
import { useRegisterShortcut } from '../../hooks/useKeyboardShortcuts'
|
|
8
|
+
import { formatRate, searchEndpoints, type EndpointSummary, type TrafficFocus } from './trafficFilters'
|
|
9
|
+
|
|
10
|
+
interface TrafficFocusSearchProps {
|
|
11
|
+
endpoints: EndpointSummary[]
|
|
12
|
+
onFocus: (focus: TrafficFocus) => void
|
|
13
|
+
isRateBased: boolean
|
|
14
|
+
/** The pane the overlay covers; the viewport when absent. */
|
|
15
|
+
overlayContainer?: HTMLElement | null
|
|
16
|
+
/** 'toolbar' is the floating chip over the graph; 'panel' the wider button
|
|
17
|
+
* in the too-large panel. Both open the same overlay. */
|
|
18
|
+
variant?: 'toolbar' | 'panel'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function TrafficFocusSearch({ endpoints, onFocus, isRateBased, overlayContainer, variant = 'toolbar' }: TrafficFocusSearchProps) {
|
|
22
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
23
|
+
const [query, setQuery] = useState('')
|
|
24
|
+
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
25
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
26
|
+
const resultsRef = useRef<HTMLDivElement>(null)
|
|
27
|
+
|
|
28
|
+
const results = useMemo(() => (isOpen ? searchEndpoints(endpoints, query) : []), [isOpen, endpoints, query])
|
|
29
|
+
|
|
30
|
+
useEffect(() => setSelectedIndex(0), [results])
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const el = resultsRef.current?.children[selectedIndex] as HTMLElement | undefined
|
|
33
|
+
el?.scrollIntoView({ block: 'nearest' })
|
|
34
|
+
}, [selectedIndex])
|
|
35
|
+
|
|
36
|
+
const open = useCallback(() => {
|
|
37
|
+
setIsOpen(true)
|
|
38
|
+
setTimeout(() => inputRef.current?.focus(), 0)
|
|
39
|
+
}, [])
|
|
40
|
+
const close = useCallback(() => {
|
|
41
|
+
setIsOpen(false)
|
|
42
|
+
setQuery('')
|
|
43
|
+
}, [])
|
|
44
|
+
const choose = useCallback((e: EndpointSummary) => {
|
|
45
|
+
onFocus({ namespace: e.namespace, name: e.name })
|
|
46
|
+
close()
|
|
47
|
+
}, [onFocus, close])
|
|
48
|
+
|
|
49
|
+
// The view renders one of the two variants at a time, so whichever is shown
|
|
50
|
+
// owns the shortcut. An embedding host (Radar Hub) binds / to its own search.
|
|
51
|
+
const { embedded } = useNavCustomization()
|
|
52
|
+
useRegisterShortcut({
|
|
53
|
+
id: 'traffic-focus-search',
|
|
54
|
+
keys: '/',
|
|
55
|
+
description: 'Focus on a workload',
|
|
56
|
+
category: 'Search',
|
|
57
|
+
scope: 'traffic',
|
|
58
|
+
handler: open,
|
|
59
|
+
enabled: !embedded,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
63
|
+
if (e.key === 'Escape') {
|
|
64
|
+
e.preventDefault()
|
|
65
|
+
close()
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
if (results.length === 0) return
|
|
69
|
+
if (e.key === 'ArrowDown') {
|
|
70
|
+
e.preventDefault()
|
|
71
|
+
setSelectedIndex(i => Math.min(i + 1, results.length - 1))
|
|
72
|
+
} else if (e.key === 'ArrowUp') {
|
|
73
|
+
e.preventDefault()
|
|
74
|
+
setSelectedIndex(i => Math.max(i - 1, 0))
|
|
75
|
+
} else if (e.key === 'Enter') {
|
|
76
|
+
e.preventDefault()
|
|
77
|
+
if (results[selectedIndex]) choose(results[selectedIndex])
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const portalTarget = overlayContainer ?? (typeof document !== 'undefined' ? document.body : null)
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<>
|
|
85
|
+
{variant === 'toolbar' ? (
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
onClick={open}
|
|
89
|
+
className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-theme-surface/90 backdrop-blur border border-theme-border text-[11px] text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
90
|
+
>
|
|
91
|
+
<Crosshair className="w-3 h-3" />
|
|
92
|
+
Focus on a workload
|
|
93
|
+
{!embedded && <kbd className="hidden sm:inline-flex items-center px-1 text-[10px] bg-theme-elevated rounded border border-theme-border-light">/</kbd>}
|
|
94
|
+
</button>
|
|
95
|
+
) : (
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
onClick={open}
|
|
99
|
+
className="btn-brand-muted inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md"
|
|
100
|
+
>
|
|
101
|
+
<Crosshair className="w-3.5 h-3.5" />
|
|
102
|
+
Focus on a workload…
|
|
103
|
+
</button>
|
|
104
|
+
)}
|
|
105
|
+
|
|
106
|
+
{isOpen && portalTarget && createPortal(
|
|
107
|
+
<div className={clsx(overlayContainer ? 'absolute' : 'fixed', 'inset-0 z-50 flex items-start justify-center pt-[10vh]')}>
|
|
108
|
+
<div className="absolute inset-0 bg-theme-base/60 backdrop-blur-sm" onClick={close} />
|
|
109
|
+
<div role="dialog" aria-label="Focus on a workload" className="relative w-full max-w-lg mx-4 bg-theme-surface border border-theme-border rounded-xl shadow-theme-lg overflow-hidden">
|
|
110
|
+
<div className="flex items-center gap-3 px-4 py-3 border-b border-theme-border">
|
|
111
|
+
<Crosshair className="w-5 h-5 text-theme-text-secondary" />
|
|
112
|
+
<Input
|
|
113
|
+
ref={inputRef}
|
|
114
|
+
value={query}
|
|
115
|
+
onChange={e => setQuery(e.target.value)}
|
|
116
|
+
onKeyDown={onKeyDown}
|
|
117
|
+
placeholder="Workload, service or external name…"
|
|
118
|
+
className="flex-1 bg-transparent text-theme-text-primary placeholder-theme-text-disabled outline-none text-sm"
|
|
119
|
+
autoFocus
|
|
120
|
+
/>
|
|
121
|
+
{query && (
|
|
122
|
+
<button type="button" onClick={() => setQuery('')} aria-label="Clear" className="p-1 text-theme-text-secondary hover:text-theme-text-primary">
|
|
123
|
+
<X className="w-4 h-4" />
|
|
124
|
+
</button>
|
|
125
|
+
)}
|
|
126
|
+
<kbd className="px-1.5 py-0.5 text-xs text-theme-text-tertiary bg-theme-elevated rounded border border-theme-border-light">ESC</kbd>
|
|
127
|
+
</div>
|
|
128
|
+
<div ref={resultsRef} className="max-h-[50vh] overflow-y-auto">
|
|
129
|
+
{results.map((e, index) => (
|
|
130
|
+
<button
|
|
131
|
+
key={e.id}
|
|
132
|
+
type="button"
|
|
133
|
+
onClick={() => choose(e)}
|
|
134
|
+
// Not onMouseEnter: results re-rendering under a resting
|
|
135
|
+
// pointer would move the highlight away from what was typed.
|
|
136
|
+
onMouseMove={() => { if (index !== selectedIndex) setSelectedIndex(index) }}
|
|
137
|
+
className={clsx(
|
|
138
|
+
'w-full flex items-center gap-3 px-4 py-2 text-left transition-colors',
|
|
139
|
+
index === selectedIndex ? 'bg-theme-elevated/60' : 'hover:bg-theme-elevated/30',
|
|
140
|
+
)}
|
|
141
|
+
>
|
|
142
|
+
<div className="flex-1 min-w-0">
|
|
143
|
+
<div className="flex items-center gap-2">
|
|
144
|
+
<span className="text-sm font-medium text-theme-text-primary truncate">{e.name}</span>
|
|
145
|
+
<span className="px-1.5 py-0.5 text-[10px] rounded bg-theme-elevated text-theme-text-secondary">{e.workloadKind || e.kind}</span>
|
|
146
|
+
</div>
|
|
147
|
+
{e.namespace && <div className="text-xs text-theme-text-tertiary truncate">{e.namespace}</div>}
|
|
148
|
+
</div>
|
|
149
|
+
<div className="flex items-center gap-1.5 shrink-0 text-[10px] tabular-nums">
|
|
150
|
+
{e.drops > 0 && <Badge severity="error" size="sm">{e.drops.toLocaleString()} dropped</Badge>}
|
|
151
|
+
{e.errors > 0 && <Badge severity="error" size="sm">{isRateBased ? `${formatRate(e.errors)}/s` : e.errors.toLocaleString()} 5xx</Badge>}
|
|
152
|
+
</div>
|
|
153
|
+
</button>
|
|
154
|
+
))}
|
|
155
|
+
{results.length === 0 && (
|
|
156
|
+
<div className="px-4 py-6 text-center text-sm text-theme-text-tertiary">
|
|
157
|
+
{query
|
|
158
|
+
? <>No traffic for “{query}” in this view</>
|
|
159
|
+
: 'Nothing in this view has traffic yet'}
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
<div className="px-4 py-2 border-t border-theme-border text-xs text-theme-text-tertiary">
|
|
164
|
+
Shows the workload and everything it talks to. Only endpoints with traffic in this view are listed.
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
</div>,
|
|
168
|
+
portalTarget,
|
|
169
|
+
)}
|
|
170
|
+
</>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
@@ -18,12 +18,12 @@ import '@xyflow/react/dist/style.css'
|
|
|
18
18
|
import ELK from 'elkjs/lib/elk.bundled.js'
|
|
19
19
|
import type { AggregatedFlow } from '../../types'
|
|
20
20
|
import { clsx } from 'clsx'
|
|
21
|
-
import { X, ArrowRight, Globe, Server, Activity, Puzzle } from 'lucide-react'
|
|
21
|
+
import { X, ArrowRight, Globe, Server, Activity, Puzzle, Crosshair } from 'lucide-react'
|
|
22
22
|
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, requestRateOf, errorRateOf, formatRate, displayVolume, latencyWeightOf } from './trafficFilters'
|
|
26
|
+
import { isRateBasedSource, isExternalKind, requestRateOf, errorRateOf, formatRate, displayVolume, latencyWeightOf, edgesOnPort, type TrafficFocus } from './trafficFilters'
|
|
27
27
|
|
|
28
28
|
const elk = new ELK()
|
|
29
29
|
|
|
@@ -48,6 +48,8 @@ export interface TrafficGraphSelection {
|
|
|
48
48
|
sourceId?: string
|
|
49
49
|
destId?: string
|
|
50
50
|
port?: number
|
|
51
|
+
// The graph draws traffic of unknown direction as its own edge.
|
|
52
|
+
directionUnknown?: boolean
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
interface TrafficGraphProps {
|
|
@@ -57,7 +59,13 @@ interface TrafficGraphProps {
|
|
|
57
59
|
serviceCategories?: Map<string, string>
|
|
58
60
|
addonMode?: AddonMode
|
|
59
61
|
trafficSource?: string
|
|
62
|
+
/** The selection, owned by the view: the graph shows it and reports clicks. */
|
|
63
|
+
selection?: TrafficGraphSelection | null
|
|
60
64
|
onSelectionChange?: (selection: TrafficGraphSelection | null) => void
|
|
65
|
+
/** Narrows the view to a node and its neighbors. */
|
|
66
|
+
onFocus?: (focus: TrafficFocus) => void
|
|
67
|
+
/** The focused node, which is not offered as a focus again. */
|
|
68
|
+
focusedId?: string
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
// Phase 2.1: Calculate edge width based on connection count (log scale)
|
|
@@ -146,10 +154,12 @@ interface TrafficNodeData extends Record<string, unknown> {
|
|
|
146
154
|
namespace?: string
|
|
147
155
|
kind: string
|
|
148
156
|
workload?: string
|
|
157
|
+
workloadKind?: string
|
|
149
158
|
connections?: number
|
|
150
159
|
totalConnections?: number // Total connections for this node
|
|
151
160
|
namespaceColor?: string // Background color for namespace grouping
|
|
152
161
|
isHotPath?: boolean // Whether this node is on a hot path
|
|
162
|
+
isFocused?: boolean // The endpoint the view is focused on
|
|
153
163
|
isAddonNode?: boolean // Whether this is a cluster addon node
|
|
154
164
|
serviceCategory?: string // For external nodes: database, cloud, etc.
|
|
155
165
|
ports?: PortInfo[] // All inbound ports sorted by connection count
|
|
@@ -205,7 +215,7 @@ function TrafficNode({ data }: { data: TrafficNodeData }) {
|
|
|
205
215
|
: hasNamespaceColor
|
|
206
216
|
? 'border-white/20'
|
|
207
217
|
: 'bg-theme-surface border-theme-border',
|
|
208
|
-
data.isHotPath && 'ring-2 ring-orange-500/50'
|
|
218
|
+
data.isFocused ? 'ring-2 ring-skyhook-500' : data.isHotPath && 'ring-2 ring-orange-500/50'
|
|
209
219
|
)}
|
|
210
220
|
style={{
|
|
211
221
|
width: NODE_WIDTH,
|
|
@@ -426,11 +436,13 @@ function formatBytes(bytes: number): string {
|
|
|
426
436
|
function DetailsPanel({
|
|
427
437
|
selection,
|
|
428
438
|
onClose,
|
|
439
|
+
onFocus,
|
|
429
440
|
flows,
|
|
430
441
|
isRateBased,
|
|
431
442
|
}: {
|
|
432
443
|
selection: Selection
|
|
433
444
|
onClose: () => void
|
|
445
|
+
onFocus?: () => void
|
|
434
446
|
flows: AggregatedFlow[]
|
|
435
447
|
// True for sources that measure a rate rather than counting connections —
|
|
436
448
|
// Istio and Beyla both do. Changes the wording and the /s suffix.
|
|
@@ -539,12 +551,26 @@ function DetailsPanel({
|
|
|
539
551
|
{isNode ? (nodeData?.kind === 'Internet' ? 'Internet Traffic' : nodeData?.kind === 'Addon' ? 'Cluster Addons' : 'Service Details') : 'Connection Details'}
|
|
540
552
|
</span>
|
|
541
553
|
</div>
|
|
542
|
-
<
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
554
|
+
<div className="flex items-center gap-1">
|
|
555
|
+
{onFocus && (
|
|
556
|
+
<Tooltip content="Show only this and what it talks to">
|
|
557
|
+
<button
|
|
558
|
+
type="button"
|
|
559
|
+
onClick={onFocus}
|
|
560
|
+
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-hover"
|
|
561
|
+
>
|
|
562
|
+
<Crosshair className="h-3.5 w-3.5" /> Focus
|
|
563
|
+
</button>
|
|
564
|
+
</Tooltip>
|
|
565
|
+
)}
|
|
566
|
+
<button
|
|
567
|
+
onClick={onClose}
|
|
568
|
+
aria-label="Close"
|
|
569
|
+
className="p-1 rounded hover:bg-theme-hover text-theme-text-secondary"
|
|
570
|
+
>
|
|
571
|
+
<X className="h-4 w-4" />
|
|
572
|
+
</button>
|
|
573
|
+
</div>
|
|
548
574
|
</div>
|
|
549
575
|
|
|
550
576
|
{/* Content */}
|
|
@@ -569,7 +595,7 @@ function DetailsPanel({
|
|
|
569
595
|
: isExternalKind(nodeData.kind)
|
|
570
596
|
? 'bg-yellow-500/20 text-yellow-400'
|
|
571
597
|
: 'bg-blue-500/20 text-blue-400'
|
|
572
|
-
)}>{nodeData.kind === 'Addon' ? 'Cluster Addons' : nodeData.kind}</span>
|
|
598
|
+
)}>{nodeData.kind === 'Addon' ? 'Cluster Addons' : nodeData.workloadKind || nodeData.kind}</span>
|
|
573
599
|
</div>
|
|
574
600
|
{nodeData.workload && nodeData.workload !== nodeData.label && (
|
|
575
601
|
<div className="text-xs text-theme-text-secondary">
|
|
@@ -1085,6 +1111,26 @@ function FitViewOnChange({
|
|
|
1085
1111
|
return null
|
|
1086
1112
|
}
|
|
1087
1113
|
|
|
1114
|
+
function edgeSelection(edge: Edge, flow: AggregatedFlow | undefined): Selection {
|
|
1115
|
+
return {
|
|
1116
|
+
type: 'edge',
|
|
1117
|
+
id: edge.id,
|
|
1118
|
+
data: {
|
|
1119
|
+
source: edge.source,
|
|
1120
|
+
target: edge.target,
|
|
1121
|
+
port: flow?.port || 0,
|
|
1122
|
+
connections: flow?.connections || 0,
|
|
1123
|
+
protocol: flow?.protocol || 'tcp',
|
|
1124
|
+
flow,
|
|
1125
|
+
},
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// The Internet node and the addon group stand for many endpoints at once.
|
|
1130
|
+
function isFocusable(id: string, data: TrafficNodeData | undefined): boolean {
|
|
1131
|
+
return id !== 'addon-group' && !!data?.kind && data.kind !== 'Internet' && data.kind !== 'Addon' && data.kind !== 'AddonInternet'
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1088
1134
|
// A pair can carry both oriented and unoriented traffic at the same port, and the
|
|
1089
1135
|
// backend keeps those as separate aggregates because no single answer about the
|
|
1090
1136
|
// direction is right for both. Without the suffix they would share an edge id and
|
|
@@ -1094,7 +1140,7 @@ function trafficEdgeId(sourceId: string, destId: string, flow: AggregatedFlow):
|
|
|
1094
1140
|
return `${sourceId}->${destId}:${flow.port}${flow.directionUnknown ? ':unoriented' : ''}`
|
|
1095
1141
|
}
|
|
1096
1142
|
|
|
1097
|
-
export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups = false, serviceCategories, addonMode = 'show', trafficSource = '', onSelectionChange }: TrafficGraphProps) {
|
|
1143
|
+
export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups = false, serviceCategories, addonMode = 'show', trafficSource = '', selection: viewSelection, onSelectionChange, onFocus, focusedId }: TrafficGraphProps) {
|
|
1098
1144
|
// Beyla is rate-based too: its Connections field carries requests per second,
|
|
1099
1145
|
// not a connection count, so it needs the same label Istio gets.
|
|
1100
1146
|
const isRateBased = isRateBasedSource(trafficSource)
|
|
@@ -1217,6 +1263,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1217
1263
|
namespace: flow.source.namespace,
|
|
1218
1264
|
kind: isAddonInternet ? 'AddonInternet' : flow.source.kind, // Keep AddonInternet kind for styling
|
|
1219
1265
|
workload: flow.source.workload,
|
|
1266
|
+
workloadKind: flow.source.workloadKind,
|
|
1220
1267
|
connections: connectionCounts.get(sourceId),
|
|
1221
1268
|
totalConnections: totalConnections.get(sourceId),
|
|
1222
1269
|
namespaceColor: showNamespaceGroups && !sourceIsAddon ? getNamespaceColor(flow.source.namespace) : undefined,
|
|
@@ -1249,6 +1296,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1249
1296
|
namespace: flow.destination.namespace,
|
|
1250
1297
|
kind: flow.destination.kind,
|
|
1251
1298
|
workload: flow.destination.workload,
|
|
1299
|
+
workloadKind: flow.destination.workloadKind,
|
|
1252
1300
|
connections: connectionCounts.get(destId),
|
|
1253
1301
|
totalConnections: totalConnections.get(destId),
|
|
1254
1302
|
namespaceColor: showNamespaceGroups ? getNamespaceColor(flow.destination.namespace) : undefined,
|
|
@@ -1279,6 +1327,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1279
1327
|
namespace: flow.destination.namespace,
|
|
1280
1328
|
kind: flow.destination.kind,
|
|
1281
1329
|
workload: flow.destination.workload,
|
|
1330
|
+
workloadKind: flow.destination.workloadKind,
|
|
1282
1331
|
connections: connectionCounts.get(destId),
|
|
1283
1332
|
totalConnections: totalConnections.get(destId),
|
|
1284
1333
|
namespaceColor: showNamespaceGroups && !destIsAddon ? getNamespaceColor(flow.destination.namespace) : undefined,
|
|
@@ -1599,6 +1648,43 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1599
1648
|
const shouldFitViewRef = useRef(false)
|
|
1600
1649
|
const prevFlowCountRef = useRef(flows.length)
|
|
1601
1650
|
|
|
1651
|
+
// Drawn per pod, a focused workload is each of its pods.
|
|
1652
|
+
const displayNodes = useMemo(() => {
|
|
1653
|
+
if (!focusedId) return nodes
|
|
1654
|
+
const isFocus = (n: Node<TrafficNodeData>) => n.id === focusedId ||
|
|
1655
|
+
(n.data.kind === 'Pod' && !!n.data.workload && `${n.data.namespace ?? ''}/${n.data.workload}` === focusedId)
|
|
1656
|
+
return nodes.map(n => (isFocus(n) ? { ...n, data: { ...n.data, isFocused: true } } : n))
|
|
1657
|
+
}, [nodes, focusedId])
|
|
1658
|
+
|
|
1659
|
+
// A new focus is a different graph even when it has as many edges.
|
|
1660
|
+
const prevFocusRef = useRef(focusedId)
|
|
1661
|
+
if (prevFocusRef.current !== focusedId) {
|
|
1662
|
+
prevFocusRef.current = focusedId
|
|
1663
|
+
shouldFitViewRef.current = true
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// The view owns the selection: it is set from outside the graph too (the
|
|
1667
|
+
// connections table selects an edge). The details are read
|
|
1668
|
+
// from the current layout, so a refresh updates them and a node that is no
|
|
1669
|
+
// longer drawn closes them.
|
|
1670
|
+
useEffect(() => {
|
|
1671
|
+
if (viewSelection === undefined) return
|
|
1672
|
+
if (!viewSelection) {
|
|
1673
|
+
setSelection(null)
|
|
1674
|
+
return
|
|
1675
|
+
}
|
|
1676
|
+
if (viewSelection.type === 'node' && viewSelection.nodeId) {
|
|
1677
|
+
const node = layoutedNodes.find(n => n.id === viewSelection.nodeId)
|
|
1678
|
+
setSelection(node ? { type: 'node', id: node.id, data: node.data } : null)
|
|
1679
|
+
return
|
|
1680
|
+
}
|
|
1681
|
+
const between = layoutedEdges.filter(e =>
|
|
1682
|
+
e.source === viewSelection.sourceId && e.target === viewSelection.destId &&
|
|
1683
|
+
!!flowByEdgeId.get(e.id)?.directionUnknown === !!viewSelection.directionUnknown)
|
|
1684
|
+
const [edge] = edgesOnPort(between, e => flowByEdgeId.get(e.id), viewSelection.port)
|
|
1685
|
+
setSelection(edge ? edgeSelection(edge, flowByEdgeId.get(edge.id)) : null)
|
|
1686
|
+
}, [viewSelection, layoutedNodes, layoutedEdges, flowByEdgeId])
|
|
1687
|
+
|
|
1602
1688
|
// Update nodes and edges when layout changes
|
|
1603
1689
|
useEffect(() => {
|
|
1604
1690
|
// Check if flow count changed (filter/namespace change)
|
|
@@ -1622,19 +1708,8 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1622
1708
|
|
|
1623
1709
|
const onEdgeClick: EdgeMouseHandler<Edge> = useCallback((_event, edge) => {
|
|
1624
1710
|
const flow = flowByEdgeId.get(edge.id)
|
|
1625
|
-
setSelection(
|
|
1626
|
-
|
|
1627
|
-
id: edge.id,
|
|
1628
|
-
data: {
|
|
1629
|
-
source: edge.source,
|
|
1630
|
-
target: edge.target,
|
|
1631
|
-
port: flow?.port || 0,
|
|
1632
|
-
connections: flow?.connections || 0,
|
|
1633
|
-
protocol: flow?.protocol || 'tcp',
|
|
1634
|
-
flow,
|
|
1635
|
-
},
|
|
1636
|
-
})
|
|
1637
|
-
onSelectionChange?.({ type: 'edge', sourceId: edge.source, destId: edge.target, port: flow?.port })
|
|
1711
|
+
setSelection(edgeSelection(edge, flow))
|
|
1712
|
+
onSelectionChange?.({ type: 'edge', sourceId: edge.source, destId: edge.target, port: flow?.port, directionUnknown: !!flow?.directionUnknown })
|
|
1638
1713
|
}, [flowByEdgeId, onSelectionChange])
|
|
1639
1714
|
|
|
1640
1715
|
const onPaneClick = useCallback(() => {
|
|
@@ -1645,7 +1720,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1645
1720
|
return (
|
|
1646
1721
|
<div className="w-full h-full relative">
|
|
1647
1722
|
<ReactFlow
|
|
1648
|
-
nodes={
|
|
1723
|
+
nodes={displayNodes}
|
|
1649
1724
|
edges={edges}
|
|
1650
1725
|
onNodesChange={onNodesChange}
|
|
1651
1726
|
onEdgesChange={onEdgesChange}
|
|
@@ -1677,7 +1752,15 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1677
1752
|
{selection && (
|
|
1678
1753
|
<DetailsPanel
|
|
1679
1754
|
selection={selection}
|
|
1680
|
-
onClose={
|
|
1755
|
+
onClose={onPaneClick}
|
|
1756
|
+
onFocus={onFocus && selection.type === 'node' && selection.id !== focusedId && isFocusable(selection.id, selection.data as TrafficNodeData)
|
|
1757
|
+
? () => {
|
|
1758
|
+
// The id is "ns/name"; an endpoint with no namespace is its
|
|
1759
|
+
// name alone, which may hold a slash of its own.
|
|
1760
|
+
const ns = (selection.data as TrafficNodeData).namespace || undefined
|
|
1761
|
+
onFocus({ namespace: ns, name: ns ? selection.id.slice(ns.length + 1) : selection.id })
|
|
1762
|
+
}
|
|
1763
|
+
: undefined}
|
|
1681
1764
|
flows={flows}
|
|
1682
1765
|
isRateBased={isRateBased}
|
|
1683
1766
|
/>
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import { ChevronDown, Network } from 'lucide-react'
|
|
3
|
+
import { pluralNoun } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { SEVERITY_TEXT } from '@skyhook-io/k8s-ui/utils/badge-colors'
|
|
5
|
+
import type { AggregatedFlow } from '../../types'
|
|
6
|
+
import { LargeClusterNamespacePicker } from '../shared/LargeClusterNamespacePicker'
|
|
7
|
+
import { TrafficConnectionsTable } from './TrafficConnectionsTable'
|
|
8
|
+
import { TrafficFocusSearch } from './TrafficFocusSearch'
|
|
9
|
+
import type { TrafficGraphSelection } from './TrafficGraph'
|
|
10
|
+
import { formatRate, namespaceOverBudget, type EndpointSummary, type NamespaceSummary, type TrafficFocus } from './trafficFilters'
|
|
11
|
+
|
|
12
|
+
const count = (n: number, noun: string) => `${n.toLocaleString()} ${pluralNoun(n, noun)}`
|
|
13
|
+
|
|
14
|
+
interface TrafficGraphTooLargeProps {
|
|
15
|
+
graph: { nodes: number; edges: number }
|
|
16
|
+
/** The focused endpoint's name, when the view is a neighborhood. */
|
|
17
|
+
focusName?: string
|
|
18
|
+
/** The sidebar can hide other namespaces' endpoints from this view. */
|
|
19
|
+
canFilterNamespaces?: boolean
|
|
20
|
+
onDrawAnyway?: () => void
|
|
21
|
+
/** Null when the namespace can't be changed from here (a single namespace
|
|
22
|
+
* in view, or a deployment scoped to one). */
|
|
23
|
+
namespaces: NamespaceSummary[] | null
|
|
24
|
+
onPickNamespace: (namespace: string) => void
|
|
25
|
+
endpoints: EndpointSummary[]
|
|
26
|
+
onFocus: (focus: TrafficFocus) => void
|
|
27
|
+
isRateBased: boolean
|
|
28
|
+
overlayContainer?: HTMLElement | null
|
|
29
|
+
flows: AggregatedFlow[]
|
|
30
|
+
selection: TrafficGraphSelection | null
|
|
31
|
+
onSelect: (selection: TrafficGraphSelection | null) => void
|
|
32
|
+
focusedId?: string
|
|
33
|
+
/** Shown above the explanation, in the layout rather than over it. */
|
|
34
|
+
notice?: ReactNode
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function nextStep(focused: boolean, canPickNamespace: boolean, canFilterNamespaces: boolean): string {
|
|
38
|
+
if (focused) return 'Its connections are listed below, problems first. Focus on one of them, or narrow them with the traffic filters on the left.'
|
|
39
|
+
if (canPickNamespace) return 'They are listed below, problems first. Narrow to a namespace or focus on a workload to see a map.'
|
|
40
|
+
if (canFilterNamespaces) return 'They are listed below, problems first. Focus on a workload, or hide other namespaces in the Namespaces filter on the left.'
|
|
41
|
+
return 'They are listed below, problems first. Focus on a workload to see a map.'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Stands in for the map when it is too large to lay out: says why, offers the
|
|
46
|
+
* ways to narrow it, and lists the connections so the view still answers
|
|
47
|
+
* something.
|
|
48
|
+
*/
|
|
49
|
+
export function TrafficGraphTooLarge(props: TrafficGraphTooLargeProps) {
|
|
50
|
+
const { graph, focusName, canFilterNamespaces, onDrawAnyway, namespaces, onPickNamespace, endpoints, onFocus, isRateBased, overlayContainer, notice } = props
|
|
51
|
+
const [pickerOpen, setPickerOpen] = useState(false)
|
|
52
|
+
const pickerRef = useRef<HTMLDivElement>(null)
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!pickerOpen) return
|
|
55
|
+
const handler = (e: MouseEvent) => {
|
|
56
|
+
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) setPickerOpen(false)
|
|
57
|
+
}
|
|
58
|
+
document.addEventListener('mousedown', handler, true)
|
|
59
|
+
return () => document.removeEventListener('mousedown', handler, true)
|
|
60
|
+
}, [pickerOpen])
|
|
61
|
+
|
|
62
|
+
const statsByName = useMemo(() => new Map((namespaces ?? []).map(ns => [ns.name, ns])), [namespaces])
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="absolute inset-x-0 bottom-0 top-12 flex flex-col px-3 pb-3 gap-2">
|
|
66
|
+
{notice}
|
|
67
|
+
<div className="rounded-lg border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm">
|
|
68
|
+
<div className="flex items-start gap-3">
|
|
69
|
+
<Network className="mt-0.5 h-5 w-5 shrink-0 text-theme-text-tertiary" />
|
|
70
|
+
<div className="min-w-0 flex-1">
|
|
71
|
+
<div className="text-sm font-medium text-theme-text-primary">
|
|
72
|
+
{focusName ? `${focusName} has too many connections to draw` : 'Too much traffic to draw at once'}
|
|
73
|
+
</div>
|
|
74
|
+
<p className="mt-0.5 text-xs text-theme-text-secondary">
|
|
75
|
+
{count(graph.nodes, 'endpoint')} and {count(graph.edges, 'connection')} — more than the map can lay out without freezing this tab.
|
|
76
|
+
{' '}{nextStep(!!focusName, !!namespaces?.length, !!canFilterNamespaces)}
|
|
77
|
+
</p>
|
|
78
|
+
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
79
|
+
{namespaces && namespaces.length > 0 && (
|
|
80
|
+
<div className="relative" ref={pickerRef}>
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
onClick={() => setPickerOpen(o => !o)}
|
|
84
|
+
aria-expanded={pickerOpen}
|
|
85
|
+
className="btn-brand-muted inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded-md"
|
|
86
|
+
>
|
|
87
|
+
Narrow to a namespace <ChevronDown className="h-3.5 w-3.5" />
|
|
88
|
+
</button>
|
|
89
|
+
{pickerOpen && (
|
|
90
|
+
<div className="absolute left-0 top-full z-50 mt-1 w-80 rounded-lg border border-theme-border bg-theme-surface p-2 shadow-theme-lg">
|
|
91
|
+
<LargeClusterNamespacePicker
|
|
92
|
+
namespaces={namespaces}
|
|
93
|
+
ranked
|
|
94
|
+
onSelect={ns => {
|
|
95
|
+
setPickerOpen(false)
|
|
96
|
+
onPickNamespace(ns)
|
|
97
|
+
}}
|
|
98
|
+
renderMeta={name => {
|
|
99
|
+
const ns = statsByName.get(name)
|
|
100
|
+
if (!ns) return null
|
|
101
|
+
return (
|
|
102
|
+
<span className="flex shrink-0 items-center gap-1.5 text-[10px] tabular-nums text-theme-text-tertiary">
|
|
103
|
+
{ns.drops > 0 && <span className={SEVERITY_TEXT.error}>{ns.drops.toLocaleString()} dropped</span>}
|
|
104
|
+
{ns.errors > 0 && <span className={SEVERITY_TEXT.error}>{isRateBased ? `${formatRate(ns.errors)}/s` : ns.errors.toLocaleString()} 5xx</span>}
|
|
105
|
+
<span>{count(ns.endpoints, 'endpoint')}</span>
|
|
106
|
+
{namespaceOverBudget(ns) && <span className={SEVERITY_TEXT.warning}>too large to map</span>}
|
|
107
|
+
</span>
|
|
108
|
+
)
|
|
109
|
+
}}
|
|
110
|
+
/>
|
|
111
|
+
<p className="mt-1 px-1 text-[10px] text-theme-text-tertiary">
|
|
112
|
+
Sets the namespace for all of Radar. Traffic to and from other namespaces stays in view.
|
|
113
|
+
</p>
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
<TrafficFocusSearch
|
|
119
|
+
endpoints={endpoints}
|
|
120
|
+
onFocus={onFocus}
|
|
121
|
+
isRateBased={isRateBased}
|
|
122
|
+
overlayContainer={overlayContainer}
|
|
123
|
+
variant="panel"
|
|
124
|
+
/>
|
|
125
|
+
{onDrawAnyway && (
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
onClick={onDrawAnyway}
|
|
129
|
+
className="text-xs px-2.5 py-1 rounded-md border border-theme-border text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-hover"
|
|
130
|
+
>
|
|
131
|
+
Draw anyway
|
|
132
|
+
</button>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
<div className="min-h-0 flex-1 overflow-hidden rounded-lg border border-theme-border bg-theme-surface">
|
|
139
|
+
<TrafficConnectionsTable
|
|
140
|
+
flows={props.flows}
|
|
141
|
+
isRateBased={isRateBased}
|
|
142
|
+
selection={props.selection}
|
|
143
|
+
onSelect={props.onSelect}
|
|
144
|
+
onFocus={onFocus}
|
|
145
|
+
focusedId={props.focusedId}
|
|
146
|
+
/>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
)
|
|
150
|
+
}
|