@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.
Files changed (59) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +14 -7
  3. package/src/RadarApp.tsx +27 -17
  4. package/src/api/client.goneResource.test.ts +49 -0
  5. package/src/api/client.ts +115 -8
  6. package/src/api/diagnose.test.ts +40 -0
  7. package/src/api/diagnose.ts +12 -1
  8. package/src/api/radarFeatures.render.test.tsx +19 -1
  9. package/src/api/radarFeatures.ts +3 -0
  10. package/src/api/traffic.sources.test.ts +55 -0
  11. package/src/api/traffic.ts +173 -14
  12. package/src/components/applications/ApplicationsView.tsx +3 -0
  13. package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
  14. package/src/components/capacity/CapacityView.test.tsx +31 -1
  15. package/src/components/capacity/shared.tsx +12 -21
  16. package/src/components/cnpg/CNPGProtection.tsx +2 -2
  17. package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
  18. package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
  19. package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
  20. package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
  21. package/src/components/diagnose/ApplyDialog.tsx +26 -28
  22. package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
  23. package/src/components/diagnose/DiagnoseContext.tsx +136 -54
  24. package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
  25. package/src/components/diagnose/InvestigationView.tsx +39 -2
  26. package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
  27. package/src/components/diagnose/agentCatalog.ts +2 -2
  28. package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
  29. package/src/components/dock/TrafficFlowListTab.tsx +27 -6
  30. package/src/components/execution/BatchExecutionView.tsx +11 -11
  31. package/src/components/helm/ChartBrowser.tsx +3 -3
  32. package/src/components/helm/RevisionHistory.tsx +3 -2
  33. package/src/components/home/ClusterHealthCard.tsx +7 -0
  34. package/src/components/home/mcpToolCatalog.ts +1 -1
  35. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
  36. package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
  37. package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
  38. package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
  39. package/src/components/settings/SettingsDialog.tsx +122 -19
  40. package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
  41. package/src/components/timeline/TimelineList.tsx +3 -0
  42. package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
  43. package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
  44. package/src/components/timeline/TimelineView.tsx +7 -0
  45. package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
  46. package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
  47. package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
  48. package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
  49. package/src/components/traffic/TrafficGraph.tsx +109 -26
  50. package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
  51. package/src/components/traffic/TrafficView.tsx +582 -149
  52. package/src/components/traffic/trafficFilters.test.ts +398 -1
  53. package/src/components/traffic/trafficFilters.ts +408 -0
  54. package/src/components/workload/WorkloadView.test.ts +2 -1
  55. package/src/components/workload/WorkloadView.tsx +135 -290
  56. package/src/context/TimelineCustomization.tsx +15 -0
  57. package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
  58. package/src/hooks/useGitOpsWriteGuard.ts +193 -0
  59. 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
- <div className="space-y-0.5">
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 = { flows: [], responsesCallerOriented: false, graphSelection: null, clearSelection: () => {} }
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({ flows: [], responsesCallerOriented: false, graphSelection: null, clearSelection: () => {} })
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 &ldquo;{query}&rdquo; 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
+ }