@skyhook-io/radar-app 1.16.3 → 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 +2 -2
- package/src/App.tsx +14 -7
- package/src/RadarApp.tsx +27 -17
- package/src/api/client.goneResource.test.ts +49 -0
- package/src/api/client.ts +115 -8
- 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/traffic.sources.test.ts +55 -0
- package/src/api/traffic.ts +173 -14
- 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/CNPGProtection.tsx +2 -2
- 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 +3 -3
- package/src/components/helm/RevisionHistory.tsx +3 -2
- package/src/components/home/ClusterHealthCard.tsx +7 -0
- package/src/components/home/mcpToolCatalog.ts +1 -1
- 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.tsx +3 -0
- package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
- package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
- package/src/components/timeline/TimelineView.tsx +7 -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/workload/WorkloadView.test.ts +2 -1
- package/src/components/workload/WorkloadView.tsx +135 -290
- package/src/context/TimelineCustomization.tsx +15 -0
- package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
- package/src/hooks/useGitOpsWriteGuard.ts +193 -0
- package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { Virtuoso } from 'react-virtuoso'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
import { ArrowRight, Crosshair, Minus } from 'lucide-react'
|
|
5
|
+
import type { AggregatedFlow } from '../../types'
|
|
6
|
+
import { SEVERITY_TEXT } from '@skyhook-io/k8s-ui/utils/badge-colors'
|
|
7
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
8
|
+
import type { TrafficGraphSelection } from './TrafficGraph'
|
|
9
|
+
import {
|
|
10
|
+
connectionRows, displayVolume, edgesOnPort, flowDrops, flowErrors, formatRate, graphEndpointId, hasStatusData, isExternalKind,
|
|
11
|
+
type GraphFlow, type TrafficFocus,
|
|
12
|
+
} from './trafficFilters'
|
|
13
|
+
|
|
14
|
+
interface TrafficConnectionsTableProps {
|
|
15
|
+
flows: AggregatedFlow[]
|
|
16
|
+
isRateBased: boolean
|
|
17
|
+
selection: TrafficGraphSelection | null
|
|
18
|
+
onSelect: (selection: TrafficGraphSelection | null) => void
|
|
19
|
+
onFocus: (focus: TrafficFocus) => void
|
|
20
|
+
/** The focused endpoint, whose name is not offered as a focus again. */
|
|
21
|
+
focusedId?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const GRID = 'grid grid-cols-[minmax(0,1fr)_1rem_minmax(0,1fr)_6.5rem_5rem_4.5rem_4.5rem] items-center gap-x-3'
|
|
25
|
+
// Without HTTP status anywhere in view (plain TCP) a 5xx column would read as
|
|
26
|
+
// "no errors" rather than "not measured".
|
|
27
|
+
const GRID_NO_STATUS = 'grid grid-cols-[minmax(0,1fr)_1rem_minmax(0,1fr)_6.5rem_5rem_4.5rem] items-center gap-x-3'
|
|
28
|
+
|
|
29
|
+
function rowSelection(flow: AggregatedFlow): TrafficGraphSelection {
|
|
30
|
+
return {
|
|
31
|
+
type: 'edge',
|
|
32
|
+
sourceId: graphEndpointId(flow.source),
|
|
33
|
+
destId: graphEndpointId(flow.destination),
|
|
34
|
+
port: flow.port,
|
|
35
|
+
directionUnknown: !!flow.directionUnknown,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function selectedRows(rows: GraphFlow[], selection: TrafficGraphSelection | null): Set<GraphFlow> {
|
|
40
|
+
if (selection?.type !== 'edge' || selection.port === undefined) return new Set()
|
|
41
|
+
const between = rows.filter(flow =>
|
|
42
|
+
graphEndpointId(flow.source) === selection.sourceId && graphEndpointId(flow.destination) === selection.destId &&
|
|
43
|
+
!!flow.directionUnknown === !!selection.directionUnknown)
|
|
44
|
+
return new Set(edgesOnPort(between, f => f, selection.port))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The view's connections as rows, for when there are too many to draw. Where
|
|
49
|
+
* the problems are comes first. Selecting a row loads its records into the
|
|
50
|
+
* flow list, as selecting an edge does; a name narrows the view to it.
|
|
51
|
+
*/
|
|
52
|
+
export function TrafficConnectionsTable({ flows, isRateBased, selection, onSelect, onFocus, focusedId }: TrafficConnectionsTableProps) {
|
|
53
|
+
const rows = useMemo(() => connectionRows(flows), [flows])
|
|
54
|
+
const selected = useMemo(() => selectedRows(rows, selection), [rows, selection])
|
|
55
|
+
const showStatus = useMemo(() => flows.some(hasStatusData), [flows])
|
|
56
|
+
const grid = showStatus ? GRID : GRID_NO_STATUS
|
|
57
|
+
|
|
58
|
+
const endpoint = (e: AggregatedFlow['source']) => {
|
|
59
|
+
const id = graphEndpointId(e)
|
|
60
|
+
const label = (
|
|
61
|
+
<span className="min-w-0">
|
|
62
|
+
<span className="block truncate text-theme-text-primary">{e.name}</span>
|
|
63
|
+
{e.namespace && <span className="block truncate text-[10px] text-theme-text-tertiary">{e.namespace}</span>}
|
|
64
|
+
</span>
|
|
65
|
+
)
|
|
66
|
+
if (id === focusedId || e.kind === 'Internet') return <span className="flex min-w-0 items-center">{label}</span>
|
|
67
|
+
return (
|
|
68
|
+
<Tooltip content={`Focus on ${e.name}`} wrapperClassName="min-w-0">
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
onClick={ev => {
|
|
72
|
+
ev.stopPropagation()
|
|
73
|
+
onFocus({ namespace: e.namespace || undefined, name: e.name })
|
|
74
|
+
}}
|
|
75
|
+
className="group flex min-w-0 max-w-full items-center gap-1 text-left hover:underline"
|
|
76
|
+
>
|
|
77
|
+
{label}
|
|
78
|
+
<Crosshair className="h-3 w-3 shrink-0 opacity-0 group-hover:opacity-70" />
|
|
79
|
+
</button>
|
|
80
|
+
</Tooltip>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="flex h-full min-h-0 flex-col">
|
|
86
|
+
<div className={clsx(grid, 'px-3 py-1.5 text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary border-b border-theme-border')}>
|
|
87
|
+
<span>Source</span>
|
|
88
|
+
<span />
|
|
89
|
+
<span>Destination</span>
|
|
90
|
+
<span>Port</span>
|
|
91
|
+
<span className="text-right">{isRateBased ? 'Req/s' : 'Conns'}</span>
|
|
92
|
+
{showStatus && <span className="text-right">5xx</span>}
|
|
93
|
+
<span className="text-right">Dropped</span>
|
|
94
|
+
</div>
|
|
95
|
+
<Virtuoso
|
|
96
|
+
className="flex-1"
|
|
97
|
+
data={rows}
|
|
98
|
+
computeItemKey={(_, flow) => `${graphEndpointId(flow.source)}->${graphEndpointId(flow.destination)}:${flow.port}${flow.directionUnknown ? ':u' : ''}`}
|
|
99
|
+
itemContent={(_, flow) => {
|
|
100
|
+
const sel = rowSelection(flow)
|
|
101
|
+
const isSelected = selected.has(flow)
|
|
102
|
+
const errors = flowErrors(flow)
|
|
103
|
+
const drops = flowDrops(flow)
|
|
104
|
+
return (
|
|
105
|
+
<div
|
|
106
|
+
role="row"
|
|
107
|
+
aria-selected={isSelected}
|
|
108
|
+
onClick={() => onSelect(isSelected ? null : sel)}
|
|
109
|
+
className={clsx(
|
|
110
|
+
grid,
|
|
111
|
+
'cursor-pointer px-3 py-1.5 text-xs border-b border-theme-border/50 transition-colors',
|
|
112
|
+
isSelected ? 'bg-theme-elevated' : 'hover:bg-theme-hover',
|
|
113
|
+
)}
|
|
114
|
+
>
|
|
115
|
+
{endpoint(flow.source)}
|
|
116
|
+
{flow.directionUnknown
|
|
117
|
+
? <Tooltip content="Direction unknown"><Minus className="h-3 w-3 text-theme-text-tertiary" /></Tooltip>
|
|
118
|
+
: <ArrowRight className="h-3 w-3 text-theme-text-tertiary" />}
|
|
119
|
+
{endpoint(flow.destination)}
|
|
120
|
+
<span className="truncate text-theme-text-secondary tabular-nums">
|
|
121
|
+
{flow.port > 0 ? flow.port : '—'}
|
|
122
|
+
{(flow.l7Protocol || flow.protocol) && (
|
|
123
|
+
<span className="ml-1 text-theme-text-tertiary">{flow.l7Protocol || flow.protocol.toUpperCase()}</span>
|
|
124
|
+
)}
|
|
125
|
+
{isExternalKind(flow.destination.kind) && <span className="ml-1 text-theme-text-tertiary">ext</span>}
|
|
126
|
+
</span>
|
|
127
|
+
<span className="text-right tabular-nums text-theme-text-secondary">
|
|
128
|
+
{isRateBased ? formatRate(displayVolume(flow, true)) : flow.connections.toLocaleString()}
|
|
129
|
+
</span>
|
|
130
|
+
{showStatus && (
|
|
131
|
+
<span className={clsx('text-right tabular-nums', errors > 0 ? clsx(SEVERITY_TEXT.error, 'font-medium') : 'text-theme-text-tertiary')}>
|
|
132
|
+
{errors > 0 ? (isRateBased ? formatRate(errors) : errors.toLocaleString()) : hasStatusData(flow) ? '0' : '—'}
|
|
133
|
+
</span>
|
|
134
|
+
)}
|
|
135
|
+
<span className={clsx('text-right tabular-nums', drops > 0 ? clsx(SEVERITY_TEXT.error, 'font-medium') : 'text-theme-text-tertiary')}>
|
|
136
|
+
{drops > 0 ? drops.toLocaleString() : '—'}
|
|
137
|
+
</span>
|
|
138
|
+
</div>
|
|
139
|
+
)
|
|
140
|
+
}}
|
|
141
|
+
/>
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
Filter,
|
|
11
11
|
Info,
|
|
12
12
|
Puzzle,
|
|
13
|
+
Boxes,
|
|
13
14
|
} from 'lucide-react'
|
|
14
15
|
import { clsx } from 'clsx'
|
|
15
16
|
import { SEVERITY_BADGE } from '@skyhook-io/k8s-ui/utils/badge-colors'
|
|
@@ -41,6 +42,8 @@ interface TrafficFilterSidebarProps {
|
|
|
41
42
|
setShowNamespaceGroups: (v: boolean) => void
|
|
42
43
|
collapseInternet: boolean
|
|
43
44
|
setCollapseInternet: (v: boolean) => void
|
|
45
|
+
groupByWorkload: boolean
|
|
46
|
+
setGroupByWorkload: (v: boolean) => void
|
|
44
47
|
addonMode: AddonMode
|
|
45
48
|
setAddonMode: (v: AddonMode) => void
|
|
46
49
|
|
|
@@ -83,6 +86,9 @@ interface TrafficFilterSidebarProps {
|
|
|
83
86
|
namespaces: Array<{ name: string; nodeCount: number }>
|
|
84
87
|
hiddenNamespaces: Set<string>
|
|
85
88
|
onToggleNamespace: (ns: string) => void
|
|
89
|
+
/** Set while the view is focused on an endpoint: the namespace choices are
|
|
90
|
+
* kept but not applied, so a neighbor in a hidden namespace still shows. */
|
|
91
|
+
namespacesPausedFor?: string
|
|
86
92
|
|
|
87
93
|
}
|
|
88
94
|
|
|
@@ -150,6 +156,8 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
|
|
|
150
156
|
setShowNamespaceGroups,
|
|
151
157
|
collapseInternet,
|
|
152
158
|
setCollapseInternet,
|
|
159
|
+
groupByWorkload,
|
|
160
|
+
setGroupByWorkload,
|
|
153
161
|
addonMode,
|
|
154
162
|
setAddonMode,
|
|
155
163
|
aggregateExternal,
|
|
@@ -177,6 +185,7 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
|
|
|
177
185
|
namespaces,
|
|
178
186
|
hiddenNamespaces,
|
|
179
187
|
onToggleNamespace,
|
|
188
|
+
namespacesPausedFor,
|
|
180
189
|
}: TrafficFilterSidebarProps) {
|
|
181
190
|
const [namespacesExpanded, setNamespacesExpanded] = useState(false)
|
|
182
191
|
|
|
@@ -298,6 +307,13 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
|
|
|
298
307
|
onToggle={() => setCollapseInternet(!collapseInternet)}
|
|
299
308
|
icon={Globe}
|
|
300
309
|
/>
|
|
310
|
+
<ToggleOption
|
|
311
|
+
label="Group by Workload"
|
|
312
|
+
description="Draw a workload's pods as one node. Off, each pod gets its own node"
|
|
313
|
+
enabled={groupByWorkload}
|
|
314
|
+
onToggle={() => setGroupByWorkload(!groupByWorkload)}
|
|
315
|
+
icon={Boxes}
|
|
316
|
+
/>
|
|
301
317
|
</div>
|
|
302
318
|
</div>
|
|
303
319
|
|
|
@@ -494,7 +510,12 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
|
|
|
494
510
|
</button>
|
|
495
511
|
</div>
|
|
496
512
|
</div>
|
|
497
|
-
|
|
513
|
+
{namespacesPausedFor && (
|
|
514
|
+
<p className="mb-1.5 text-[10px] text-theme-text-tertiary">
|
|
515
|
+
Paused while focused on {namespacesPausedFor}: its neighbors show whatever their namespace.
|
|
516
|
+
</p>
|
|
517
|
+
)}
|
|
518
|
+
<div className={clsx('space-y-0.5', namespacesPausedFor && 'opacity-50 pointer-events-none')} aria-disabled={!!namespacesPausedFor}>
|
|
498
519
|
{visibleNamespaces.map(({ name, nodeCount }) => {
|
|
499
520
|
const isHidden = hiddenNamespaces.has(name)
|
|
500
521
|
return (
|
|
@@ -7,10 +7,16 @@ interface TrafficFlowListContextValue {
|
|
|
7
7
|
responsesCallerOriented: boolean
|
|
8
8
|
graphSelection: TrafficGraphSelection | null
|
|
9
9
|
clearSelection: () => void
|
|
10
|
+
/** The records for the current selection are still being fetched. */
|
|
11
|
+
loading?: boolean
|
|
12
|
+
/** Says which records the list holds when it is not all of them. */
|
|
13
|
+
note?: string
|
|
10
14
|
}
|
|
11
15
|
|
|
16
|
+
const emptyValue: TrafficFlowListContextValue = { flows: [], responsesCallerOriented: false, graphSelection: null, clearSelection: () => {} }
|
|
17
|
+
|
|
12
18
|
// Module-level store — TrafficView writes, dock tab reads.
|
|
13
|
-
let currentValue: TrafficFlowListContextValue =
|
|
19
|
+
let currentValue: TrafficFlowListContextValue = emptyValue
|
|
14
20
|
const listeners = new Set<() => void>()
|
|
15
21
|
|
|
16
22
|
function setValue(val: TrafficFlowListContextValue) {
|
|
@@ -53,15 +59,17 @@ export function TrafficFlowListProvider({
|
|
|
53
59
|
responsesCallerOriented,
|
|
54
60
|
graphSelection,
|
|
55
61
|
clearSelection,
|
|
62
|
+
loading,
|
|
63
|
+
note,
|
|
56
64
|
children,
|
|
57
65
|
}: TrafficFlowListContextValue & { children: ReactNode }) {
|
|
58
66
|
useEffect(() => {
|
|
59
|
-
setValue({ flows, responsesCallerOriented, graphSelection, clearSelection })
|
|
60
|
-
}, [flows, responsesCallerOriented, graphSelection, clearSelection])
|
|
67
|
+
setValue({ flows, responsesCallerOriented, graphSelection, clearSelection, loading, note })
|
|
68
|
+
}, [flows, responsesCallerOriented, graphSelection, clearSelection, loading, note])
|
|
61
69
|
|
|
62
70
|
useEffect(() => {
|
|
63
71
|
return () => {
|
|
64
|
-
setValue(
|
|
72
|
+
setValue(emptyValue)
|
|
65
73
|
setFlowSearch('')
|
|
66
74
|
}
|
|
67
75
|
}, [])
|
|
@@ -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
|
+
}
|