@skyhook-io/k8s-ui 1.7.12 → 1.7.13

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 (31) hide show
  1. package/package.json +1 -1
  2. package/src/components/applications/AppChips.tsx +109 -0
  3. package/src/components/applications/AppTooltips.tsx +199 -0
  4. package/src/components/applications/ApplicationDetail.tsx +671 -0
  5. package/src/components/applications/ApplicationsList.tsx +569 -0
  6. package/src/components/applications/ReadyBar.tsx +22 -0
  7. package/src/components/applications/index.ts +8 -0
  8. package/src/components/audit/AuditFindingsTable.tsx +3 -25
  9. package/src/components/logs/WorkloadLogsViewer.tsx +8 -5
  10. package/src/components/resources/renderers/WorkloadRenderer.tsx +5 -4
  11. package/src/components/shared/DetailShell.tsx +14 -7
  12. package/src/components/shared/EditableYamlView.tsx +37 -17
  13. package/src/components/timeline/TimelineList.tsx +3 -32
  14. package/src/components/timeline/TimelineSwimlanes.tsx +3 -31
  15. package/src/components/topology/K8sResourceNode.tsx +26 -5
  16. package/src/components/topology/TopologyGraph.tsx +102 -3
  17. package/src/components/topology/layout.ts +36 -11
  18. package/src/components/ui/CenteredEmpty.tsx +27 -0
  19. package/src/components/ui/SearchBox.tsx +85 -0
  20. package/src/components/ui/index.ts +1 -0
  21. package/src/components/workload/WorkloadView.tsx +167 -33
  22. package/src/components/workload/index.ts +1 -1
  23. package/src/hooks/useKeyboardShortcuts.tsx +3 -1
  24. package/src/index.ts +4 -0
  25. package/src/utils/applications.test.ts +207 -0
  26. package/src/utils/applications.ts +674 -0
  27. package/src/utils/format.ts +11 -0
  28. package/src/utils/index.ts +2 -0
  29. package/src/utils/topology-neighborhood.test.ts +185 -0
  30. package/src/utils/topology-neighborhood.ts +262 -0
  31. package/src/utils/workload-colors.ts +36 -0
@@ -90,18 +90,21 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
90
90
  setFetchError(null)
91
91
  try {
92
92
  const result = await fetchAll({ container: selectedContainer || undefined, tailLines, sinceSeconds })
93
+ // Older backends marshal empty results as null rather than [].
94
+ const resultPods = result.pods ?? []
95
+ const resultLogs = result.logs ?? []
93
96
 
94
- setPods(result.pods)
97
+ setPods(resultPods)
95
98
 
96
- if (!podsInitialized.current && result.pods.length > 0) {
99
+ if (!podsInitialized.current && resultPods.length > 0) {
97
100
  podsInitialized.current = true
98
- setSelectedPods(new Set(result.pods.map(p => p.name)))
101
+ setSelectedPods(new Set(resultPods.map(p => p.name)))
99
102
  }
100
103
 
101
104
  const indexByPod = new Map<string, number>()
102
- result.pods.forEach((pod, i) => indexByPod.set(pod.name, i))
105
+ resultPods.forEach((pod, i) => indexByPod.set(pod.name, i))
103
106
 
104
- set(result.logs.map(log => ({
107
+ set(resultLogs.map(log => ({
105
108
  timestamp: log.timestamp,
106
109
  content: log.content,
107
110
  container: log.container,
@@ -174,12 +174,13 @@ export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale,
174
174
 
175
175
  return (
176
176
  <>
177
- {/* Scaling in progress banner */}
177
+ {/* Scaling in progress banner — amber: replicas short of desired is an
178
+ attention state, not an info note (it may be a stuck rollout). */}
178
179
  {(scaledTo !== null || progressMessage) && !hasProblems && (
179
- <div className="mb-4 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
180
+ <div className="mb-4 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg">
180
181
  <div className="flex items-center gap-2">
181
- <Loader2 className="w-4 h-4 text-blue-400 animate-spin shrink-0" />
182
- <div className="text-sm text-blue-300">
182
+ <Loader2 className="w-4 h-4 text-amber-500 animate-spin shrink-0" />
183
+ <div className="text-sm text-amber-700 dark:text-amber-300">
183
184
  {progressMessage || `Scaling to ${scaledTo} replicas...`}
184
185
  </div>
185
186
  </div>
@@ -30,6 +30,8 @@ export interface DetailShellProps<TId extends string = string> {
30
30
  onTabChange: (id: TId) => void
31
31
  tabStripEnd?: ReactNode
32
32
  overlay?: ReactNode
33
+ /** Hide breadcrumb/identity/header actions when a host page already owns that chrome. */
34
+ compactHeader?: boolean
33
35
  children: ReactNode
34
36
  }
35
37
 
@@ -44,6 +46,7 @@ export function DetailShell<TId extends string = string>({
44
46
  onTabChange,
45
47
  tabStripEnd,
46
48
  overlay,
49
+ compactHeader = false,
47
50
  children,
48
51
  }: DetailShellProps<TId>) {
49
52
  const visibleTabs = tabs.filter((t) => !t.hidden)
@@ -52,15 +55,19 @@ export function DetailShell<TId extends string = string>({
52
55
  <div className="flex flex-col h-full w-full bg-theme-surface">
53
56
  {/* Header */}
54
57
  <div className="shrink-0 border-b border-theme-border bg-theme-surface">
55
- {breadcrumb && <div className="px-6 pt-2.5">{breadcrumb}</div>}
56
- <div className={clsx('px-6 flex items-start gap-4', breadcrumb ? 'pb-3 pt-1.5' : 'py-3')}>
57
- {nav}
58
- <div className="flex-1 min-w-0">{identity}</div>
59
- {headerActions}
60
- </div>
58
+ {!compactHeader && (
59
+ <>
60
+ {breadcrumb && <div className="px-6 pt-2.5">{breadcrumb}</div>}
61
+ <div className={clsx('px-6 flex items-start gap-4', breadcrumb ? 'pb-3 pt-1.5' : 'py-3')}>
62
+ {nav}
63
+ <div className="flex-1 min-w-0">{identity}</div>
64
+ {headerActions}
65
+ </div>
66
+ </>
67
+ )}
61
68
 
62
69
  {/* Tabs (left) + scope controls / actions (right) */}
63
- <div className="px-6 flex items-center border-t border-theme-border">
70
+ <div className={clsx('flex items-center', compactHeader ? 'px-0' : 'border-t border-theme-border px-6')}>
64
71
  <div className="flex gap-1" role="tablist">
65
72
  {visibleTabs.map((t) => (
66
73
  <DetailShellTabButton key={t.id} active={activeTab === t.id} onClick={() => onTabChange(t.id)}>
@@ -66,19 +66,27 @@ function formatSaveError(error: string): { summary: string; details?: string } {
66
66
  const errorPart = parts[1]?.trim() || ''
67
67
 
68
68
  if (errorPart.includes('Forbidden:')) {
69
- const forbiddenMatch = errorPart.match(/([^:]+):\s*Forbidden:\s*([^.{]+)/)
70
- if (forbiddenMatch) {
69
+ const forbiddenAt = errorPart.indexOf(': Forbidden:')
70
+ if (forbiddenAt > 0) {
71
+ const target = errorPart.slice(0, forbiddenAt).trim()
72
+ const messageStart = forbiddenAt + ': Forbidden:'.length
73
+ const dotAt = errorPart.indexOf('.', messageStart)
74
+ const braceAt = errorPart.indexOf('{', messageStart)
75
+ const endCandidates = [dotAt, braceAt].filter((i) => i >= 0)
76
+ const messageEnd = endCandidates.length ? Math.min(...endCandidates) : errorPart.length
77
+ const message = errorPart.slice(messageStart, messageEnd).trim()
71
78
  return {
72
- summary: `Cannot update ${forbiddenMatch[1]}: ${forbiddenMatch[2].trim()}`,
79
+ summary: `Cannot update ${target}: ${message}`,
73
80
  details: error.length > 200 ? error : undefined
74
81
  }
75
82
  }
76
83
  }
77
84
 
78
- const summaryMatch = errorPart.match(/^([^{]+)/)
79
- if (summaryMatch) {
85
+ const braceAt = errorPart.indexOf('{')
86
+ const summary = (braceAt >= 0 ? errorPart.slice(0, braceAt) : errorPart).trim()
87
+ if (summary) {
80
88
  return {
81
- summary: summaryMatch[1].trim(),
89
+ summary,
82
90
  details: error.length > 200 ? error : undefined
83
91
  }
84
92
  }
@@ -111,6 +119,8 @@ interface EditableYamlViewProps {
111
119
  data: any
112
120
  onCopy: (text: string) => void
113
121
  copied: boolean
122
+ /** Hide edit affordances when the host surface is read-only. */
123
+ readOnly?: boolean
114
124
  /** Called after a successful save so the parent can refetch */
115
125
  onSaved?: () => void
116
126
  /** Save handler — injected by the platform wrapper */
@@ -128,13 +138,13 @@ interface EditableYamlViewProps {
128
138
  onDownload?: (content: string, mime: string, filename: string) => void
129
139
  }
130
140
 
131
- export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSave, isSaving, saveError, onDuplicate, onDownload }: EditableYamlViewProps) {
141
+ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = false, onSaved, onSave, isSaving, saveError, onDuplicate, onDownload }: EditableYamlViewProps) {
132
142
  const draftKey = `radar_yaml_draft:${resource.kind}/${resource.namespace}/${resource.name}`
133
143
 
134
144
  // Restore draft from sessionStorage (e.g., after session-expiry redirect).
135
145
  // All sessionStorage calls are wrapped in try-catch — storage can throw
136
146
  // QuotaExceededError or be blocked by browser security policies.
137
- const savedDraft = useRef(safeSessionGet(draftKey))
147
+ const savedDraft = useRef(readOnly ? null : safeSessionGet(draftKey))
138
148
  const [isEditing, setIsEditing] = useState(savedDraft.current !== null)
139
149
  const [editedYaml, setEditedYaml] = useState(savedDraft.current ?? '')
140
150
  const [yamlErrors, setYamlErrors] = useState<string[]>([])
@@ -153,12 +163,19 @@ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSa
153
163
 
154
164
  // Autosave draft to sessionStorage while editing (best-effort)
155
165
  useEffect(() => {
166
+ if (readOnly) {
167
+ setIsEditing(false)
168
+ setEditedYaml('')
169
+ setYamlErrors([])
170
+ safeSessionRemove(draftKey)
171
+ return
172
+ }
156
173
  if (isEditing && editedYaml) {
157
174
  safeSessionSet(draftKey, editedYaml)
158
175
  } else {
159
176
  safeSessionRemove(draftKey)
160
177
  }
161
- }, [isEditing, editedYaml, draftKey])
178
+ }, [isEditing, editedYaml, draftKey, readOnly])
162
179
 
163
180
  const handleDownload = useCallback(() => {
164
181
  const yaml = resourceToYaml(data)
@@ -170,10 +187,11 @@ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSa
170
187
  }, [data, resource.kind, resource.name, onDownload])
171
188
 
172
189
  const handleStartEdit = useCallback(() => {
190
+ if (readOnly) return
173
191
  setEditedYaml(resourceToYaml(data))
174
192
  setYamlErrors([])
175
193
  setIsEditing(true)
176
- }, [data])
194
+ }, [data, readOnly])
177
195
 
178
196
  const handleCancelEdit = useCallback(() => {
179
197
  setIsEditing(false)
@@ -345,13 +363,15 @@ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSa
345
363
  <div className="flex items-center justify-between mb-2">
346
364
  <span className="text-sm font-medium text-theme-text-secondary">YAML</span>
347
365
  <div className="flex items-center gap-2">
348
- <button
349
- onClick={handleStartEdit}
350
- className="flex items-center gap-1 px-2 py-1 text-xs text-blue-400 hover:text-blue-300 hover:bg-theme-elevated rounded"
351
- >
352
- <Pencil className="w-3.5 h-3.5" />
353
- Edit
354
- </button>
366
+ {!readOnly && (
367
+ <button
368
+ onClick={handleStartEdit}
369
+ className="flex items-center gap-1 px-2 py-1 text-xs text-blue-400 hover:text-blue-300 hover:bg-theme-elevated rounded"
370
+ >
371
+ <Pencil className="w-3.5 h-3.5" />
372
+ Edit
373
+ </button>
374
+ )}
355
375
  <button
356
376
  onClick={() => onCopy(yamlContent)}
357
377
  className="flex items-center gap-1 px-2 py-1 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
@@ -1,11 +1,11 @@
1
- import { useState, useMemo, useRef, useEffect } from 'react'
1
+ import { useState, useMemo, useEffect } from 'react'
2
2
  import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
3
3
  import { PaneLoader } from '../ui/PaneLoader'
4
+ import { SearchBox } from '../ui/SearchBox'
4
5
  import {
5
6
  AlertCircle,
6
7
  CheckCircle,
7
8
  Clock,
8
- Search,
9
9
  RefreshCw,
10
10
  ChevronRight,
11
11
  Filter,
@@ -24,7 +24,6 @@ import { ResourceRefBadge } from '../ui/drawer-components'
24
24
  import type { NavigateToResource } from '../../utils/navigation'
25
25
  import { kindToPlural, refToSelectedResource, apiVersionToGroup } from '../../utils/navigation'
26
26
  import { pluralize } from '../../utils/pluralize'
27
- import { useRegisterShortcut } from '../../hooks/useKeyboardShortcuts'
28
27
 
29
28
  /** Format resource age (e.g., "3d", "5h", "10m") */
30
29
  function formatResourceAge(createdAt: string): string {
@@ -87,29 +86,11 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
87
86
  const [timeRange, setTimeRange] = useState<TimeRange>(initialTimeRange ?? '1h')
88
87
  const [kindFilter, setKindFilter] = useState<string>('')
89
88
  const [expandedItem, setExpandedItem] = useState<string | null>(null)
90
- const searchInputRef = useRef<HTMLInputElement>(null)
91
89
 
92
90
  useEffect(() => {
93
91
  onQueryChange?.({ timeRange, kind: kindFilter || undefined })
94
92
  }, [timeRange, kindFilter, onQueryChange])
95
93
 
96
- // Keyboard shortcut: / to focus search
97
- useRegisterShortcut({
98
- id: 'timeline-list-search',
99
- keys: '/',
100
- description: 'Focus search',
101
- category: 'Search',
102
- scope: 'timeline',
103
- handler: () => searchInputRef.current?.focus(),
104
- })
105
- useRegisterShortcut({
106
- id: 'timeline-list-escape',
107
- keys: 'Escape',
108
- description: 'Blur search',
109
- category: 'Search',
110
- scope: 'timeline',
111
- handler: () => searchInputRef.current?.blur(),
112
- })
113
94
 
114
95
  const [handleRefresh, isRefreshAnimating] = useRefreshAnimation(onRefresh ?? (() => {}))
115
96
 
@@ -278,17 +259,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
278
259
  {/* Toolbar */}
279
260
  <div className="flex items-center gap-4 px-4 py-3 border-b border-theme-border bg-theme-surface/50 flex-wrap">
280
261
  {/* Search */}
281
- <div className="flex-1 relative min-w-[200px]">
282
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
283
- <input
284
- ref={searchInputRef}
285
- type="text"
286
- placeholder="Search... (press /)"
287
- value={searchTerm}
288
- onChange={(e) => setSearchTerm(e.target.value)}
289
- className="w-full max-w-md pl-10 pr-4 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-blue-500"
290
- />
291
- </div>
262
+ <SearchBox value={searchTerm} onChange={setSearchTerm} scope="timeline" shortcutId="timeline-list-search" className="flex-1 min-w-[200px] max-w-md" />
292
263
 
293
264
  {/* Activity type filter */}
294
265
  <div className="flex items-center gap-1 bg-theme-elevated rounded-lg p-1">
@@ -7,7 +7,6 @@ import {
7
7
  ZoomIn,
8
8
  ZoomOut,
9
9
  ChevronRight,
10
- Search,
11
10
  X,
12
11
  List,
13
12
  GanttChart,
@@ -27,6 +26,7 @@ import type { TimelineEvent, Topology } from '../../types'
27
26
  import type { NavigateToResource } from '../../utils/navigation'
28
27
  import { kindToPlural, apiVersionToGroup } from '../../utils/navigation'
29
28
  import { PaneLoader } from '../ui/PaneLoader'
29
+ import { SearchBox } from '../ui/SearchBox'
30
30
  import { pluralize } from '../../utils/pluralize'
31
31
  import { gitOpsRouteForKind } from '../../utils/gitops-route'
32
32
  import { isChangeEvent, isHistoricalEvent, isOperation, displayKind } from '../../types'
@@ -194,7 +194,6 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
194
194
  onResourceClick?.({ kind: kindToPlural(kind), namespace, name, group })
195
195
  }, [onNavigatePath, onResourceClick])
196
196
  const containerRef = useRef<HTMLDivElement>(null)
197
- const searchInputRef = useRef<HTMLInputElement>(null)
198
197
  const [zoom, setZoom] = useState(1)
199
198
  const [panOffset, setPanOffset] = useState(0)
200
199
  const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(null)
@@ -241,23 +240,14 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
241
240
  }, [events, hasAutoZoomed])
242
241
 
243
242
  // Keyboard shortcuts
244
- useRegisterShortcut({
245
- id: 'swimlane-search',
246
- keys: '/',
247
- description: 'Focus search',
248
- category: 'Search',
249
- scope: 'timeline',
250
- handler: () => searchInputRef.current?.focus(),
251
- })
252
243
  useRegisterShortcut({
253
244
  id: 'swimlane-escape',
254
245
  keys: 'Escape',
255
- description: 'Close detail / blur search',
246
+ description: 'Close event detail',
256
247
  category: 'Timeline',
257
248
  scope: 'timeline',
258
249
  handler: () => {
259
250
  if (selectedEvent) setSelectedEvent(null)
260
- else searchInputRef.current?.blur()
261
251
  },
262
252
  })
263
253
 
@@ -467,25 +457,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
467
457
  <div className="flex items-center justify-between px-4 py-2">
468
458
  <div className="flex items-center gap-4">
469
459
  {/* Search */}
470
- <div className="relative">
471
- <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
472
- <input
473
- ref={searchInputRef}
474
- type="text"
475
- value={searchTerm}
476
- onChange={(e) => setSearchTerm(e.target.value)}
477
- placeholder="Search... (press /)"
478
- className="w-80 pl-9 pr-8 py-1.5 text-sm bg-theme-elevated border border-theme-border-light rounded-lg text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
479
- />
480
- {searchTerm && (
481
- <button
482
- onClick={() => setSearchTerm('')}
483
- className="absolute right-2 top-1/2 -translate-y-1/2 text-theme-text-tertiary hover:text-theme-text-primary"
484
- >
485
- <X className="w-4 h-4" />
486
- </button>
487
- )}
488
- </div>
460
+ <SearchBox value={searchTerm} onChange={setSearchTerm} scope="timeline" shortcutId="swimlane-search" className="w-80" />
489
461
  {/* Zoom controls */}
490
462
  <div className="flex items-center gap-2">
491
463
  <button
@@ -8,6 +8,9 @@ import { clsx } from 'clsx'
8
8
  import type { NodeKind, HealthStatus, PodSummary } from '../../types'
9
9
  import { displayKind } from '../../types'
10
10
  import { healthToSeverity, SEVERITY_DOT } from '../../utils/badge-colors'
11
+ import { workloadHue } from '../../utils/workload-colors'
12
+ import { ownershipOf } from '../../utils/topology-neighborhood'
13
+ import { midTruncate } from '../../utils/format'
11
14
  import { Tooltip } from '../ui/Tooltip'
12
15
 
13
16
  // Get actionable tooltip content for health issues
@@ -359,6 +362,8 @@ interface K8sResourceNodeProps {
359
362
  status: HealthStatus
360
363
  nodeData: Record<string, unknown>
361
364
  selected?: boolean
365
+ /** Hover-focus: when a sibling workload is focused, non-members dim. */
366
+ dimmed?: boolean
362
367
  onExpand?: (nodeId: string) => void
363
368
  onCollapse?: (nodeId: string) => void
364
369
  isExpanded?: boolean
@@ -370,7 +375,14 @@ export const K8sResourceNode = memo(function K8sResourceNode({
370
375
  data,
371
376
  id,
372
377
  }: K8sResourceNodeProps) {
373
- const { kind, name, status, nodeData, selected, onExpand, onCollapse, isExpanded } = data
378
+ const { kind, name, status, nodeData, selected, dimmed, onExpand, onCollapse, isExpanded } = data
379
+ // Workload tint (application graph): a node owned by exactly one workload
380
+ // carries that workload's hue. Only on healthy/unknown cards — degraded/
381
+ // unhealthy already own the card background for health, which must win.
382
+ const { ownerColorIndex } = ownershipOf(nodeData)
383
+ const hue = ownerColorIndex !== null && (status === 'healthy' || status === 'unknown')
384
+ ? workloadHue(ownerColorIndex)
385
+ : undefined
374
386
  const subtitle = getSubtitle(kind, nodeData)
375
387
  const isInternet = kind === 'Internet'
376
388
  const isPodGroup = kind === 'PodGroup'
@@ -423,10 +435,11 @@ export const K8sResourceNode = memo(function K8sResourceNode({
423
435
 
424
436
  <div
425
437
  className={clsx(
426
- 'relative rounded-lg overflow-hidden',
438
+ 'relative rounded-lg overflow-hidden transition-opacity duration-150',
427
439
  'bg-theme-surface topology-node-card',
428
440
  selected && 'topology-node-selected',
429
- isSmallNode && 'opacity-90',
441
+ !selected && dimmed && 'opacity-30',
442
+ isSmallNode && !dimmed && 'opacity-90',
430
443
  // Status bar via CSS pseudo-element (defined in index.css)
431
444
  (status === 'healthy' || status === 'unknown') && 'topology-node-status-bar',
432
445
  status === 'healthy' && 'topology-node-status-healthy',
@@ -437,6 +450,13 @@ export const K8sResourceNode = memo(function K8sResourceNode({
437
450
  ...getStatusStyle(status),
438
451
  }}
439
452
  >
453
+ {/* Workload tint — layered over the surface, inset past the 4px status bar */}
454
+ {hue && (
455
+ <div
456
+ className="pointer-events-none absolute inset-y-0 right-0 rounded-r-lg"
457
+ style={{ left: 4, background: hue.wash }}
458
+ />
459
+ )}
440
460
 
441
461
  {/* Content */}
442
462
  <div className={clsx(
@@ -496,9 +516,10 @@ export const K8sResourceNode = memo(function K8sResourceNode({
496
516
  </div>
497
517
  </div>
498
518
 
499
- {/* Name */}
519
+ {/* Name — middle-ellipsis so the differentiating suffix survives
520
+ (a column of pods sharing a long prefix must stay tellable apart). */}
500
521
  <div className="text-sm font-medium text-theme-text-primary truncate pr-1">
501
- {name}
522
+ {midTruncate(name, 34)}
502
523
  </div>
503
524
 
504
525
  {/* Subtitle */}
@@ -20,7 +20,7 @@ import {
20
20
  import '@xyflow/react/dist/style.css'
21
21
  import { toCanvas } from 'html-to-image'
22
22
 
23
- import { AlertTriangle, ChevronsDownUp, ChevronsUpDown, Download, Layers, LayoutGrid, Loader2, Maximize, Minus, Pause, Play, Plus, RotateCw, Shield } from 'lucide-react'
23
+ import { AlertTriangle, ChevronsDownUp, ChevronsUpDown, Download, Info, Layers, LayoutGrid, Loader2, Maximize, Minus, Pause, Play, Plus, RotateCw, Shield } from 'lucide-react'
24
24
  import { PaneLoader } from '../ui/PaneLoader'
25
25
  import { Tooltip } from '../ui/Tooltip'
26
26
  import { useToast } from '../ui/Toast'
@@ -28,6 +28,8 @@ import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
28
28
 
29
29
  import { K8sResourceNode } from './K8sResourceNode'
30
30
  import { GroupNode } from './GroupNode'
31
+ import { NEUTRAL_OWNER, type WorkloadFocus } from '../../utils/workload-colors'
32
+ import { ownershipOf } from '../../utils/topology-neighborhood'
31
33
  import { buildHierarchicalElkGraph, applyHierarchicalLayout, getGroupKey, type GroupDisplayLevel } from './layout'
32
34
  import type { Topology, TopologyNode, TopologyEdge, ViewMode, GroupingMode } from '../../types'
33
35
  import { pluralize } from '../../utils/pluralize'
@@ -51,6 +53,15 @@ function getEdgeColor(type: string, isTrafficView: boolean): string {
51
53
  return EDGE_COLORS[type as keyof typeof EDGE_COLORS] || '#64748b'
52
54
  }
53
55
 
56
+ // Human-readable edge legend for the resources view (traffic view is all-green).
57
+ const EDGE_LEGEND: { label: string; color: string }[] = [
58
+ { label: 'owns', color: EDGE_COLORS['manages'] },
59
+ { label: 'exposes', color: EDGE_COLORS['exposes'] },
60
+ { label: 'configures', color: EDGE_COLORS['configures'] },
61
+ { label: 'scales', color: EDGE_COLORS['uses'] },
62
+ { label: 'routes to', color: EDGE_COLORS['routes-to'] },
63
+ ]
64
+
54
65
  // Memoized edge style cache to avoid creating new objects on every render
55
66
  const edgeStyleCache = new Map<string, React.CSSProperties>()
56
67
 
@@ -179,6 +190,13 @@ interface TopologyGraphProps {
179
190
  focusNodeId?: string
180
191
  /** Increment to request a focus on focusNodeId (lets the same node be re-focused). */
181
192
  focusNonce?: number
193
+ /** Application graph hover-focus (see WorkloadFocus): when set, nodes outside
194
+ * the focused workload's neighborhood dim. Cheap node-data toggle — never
195
+ * re-layouts. */
196
+ focusedOwnerId?: WorkloadFocus
197
+ /** Hover a node → reports its TopologyNode (null on leave). Drives the rail's
198
+ * reciprocal highlight. */
199
+ onNodeHover?: (node: TopologyNode | null) => void
182
200
  }
183
201
 
184
202
  export function TopologyGraph({
@@ -197,6 +215,8 @@ export function TopologyGraph({
197
215
  namespacesKey = '',
198
216
  focusNodeId,
199
217
  focusNonce,
218
+ focusedOwnerId,
219
+ onNodeHover,
200
220
  }: TopologyGraphProps) {
201
221
  const isTrafficView = viewMode === 'traffic'
202
222
  const [nodes, setNodes, onNodesChangeBase] = useNodesState([] as Node[])
@@ -227,6 +247,7 @@ export function TopologyGraph({
227
247
  const [layoutRetryCount, setLayoutRetryCount] = useState(0)
228
248
  const [fitViewCounter, setFitViewCounter] = useState(0)
229
249
  const [isExporting, setIsExporting] = useState(false)
250
+ const [showLegend, setShowLegend] = useState(false)
230
251
  const prevStructureRef = useRef<string>('')
231
252
  const layoutVersionRef = useRef(0) // Used to invalidate stale layout results
232
253
  // Saved node positions for preservation across topology updates.
@@ -298,18 +319,26 @@ export function TopologyGraph({
298
319
  fitAllAfterLayoutRef.current = true
299
320
  }, [groupingMode])
300
321
 
301
- // Expand pod group to show individual pods
322
+ // Expand pod group to show individual pods. Clear saved positions so ELK
323
+ // re-lays out the whole graph from scratch — otherwise existing nodes snap
324
+ // back to their saved spots while the newly-added pods get fresh ELK
325
+ // coordinates, and the two coordinate spaces collide (overlapping nodes).
326
+ // Re-fit afterwards since the expanded pods enlarge the content bounds.
302
327
  const handleExpandPodGroup = useCallback((podGroupId: string) => {
303
328
  setExpandedPodGroups(prev => new Set(prev).add(podGroupId))
329
+ savedPositionsRef.current.clear()
330
+ fitAllAfterLayoutRef.current = true
304
331
  }, [])
305
332
 
306
- // Collapse pod group back
333
+ // Collapse pod group back — same full relayout + re-fit (the graph shrinks).
307
334
  const handleCollapsePodGroup = useCallback((podGroupId: string) => {
308
335
  setExpandedPodGroups(prev => {
309
336
  const next = new Set(prev)
310
337
  next.delete(podGroupId)
311
338
  return next
312
339
  })
340
+ savedPositionsRef.current.clear()
341
+ fitAllAfterLayoutRef.current = true
313
342
  }, [])
314
343
 
315
344
  // Expand PodGroup to individual pods
@@ -348,6 +377,7 @@ export function TopologyGraph({
348
377
  name: pod.name,
349
378
  status: pod.phase === 'Running' ? 'healthy' : pod.phase === 'Pending' ? 'degraded' : 'unhealthy',
350
379
  data: {
380
+ ...podGroupNode.data,
351
381
  namespace: pod.namespace,
352
382
  phase: pod.phase,
353
383
  restarts: pod.restarts,
@@ -625,6 +655,12 @@ export function TopologyGraph({
625
655
  return saved ? { ...node, position: saved } : node
626
656
  })
627
657
 
658
+ // The ReactFlow fitView prop fires against the pre-layout canvas; once
659
+ // the first ELK layout lands the content can sit off-center. Re-frame it.
660
+ if (isInitialLayout) {
661
+ fitAllAfterLayoutRef.current = true
662
+ }
663
+
628
664
  // Update saved positions: add/overwrite with positions from this layout run.
629
665
  // Remove stale entries for nodes no longer in the topology.
630
666
  const currentIds = new Set(positionedNodes.map(n => n.id))
@@ -710,6 +746,17 @@ export function TopologyGraph({
710
746
  [topology, workingNodes, onNodeClick]
711
747
  )
712
748
 
749
+ const handleNodeMouseEnter = useCallback(
750
+ (_e: React.MouseEvent, node: Node) => {
751
+ if (!onNodeHover || node.type === 'group') return
752
+ const topologyNode =
753
+ topology?.nodes.find(n => n.id === node.id) ?? workingNodes.find(n => n.id === node.id)
754
+ if (topologyNode) onNodeHover(topologyNode)
755
+ },
756
+ [topology, workingNodes, onNodeHover]
757
+ )
758
+ const handleNodeMouseLeave = useCallback(() => onNodeHover?.(null), [onNodeHover])
759
+
713
760
  // Update selected state - only update nodes that actually changed
714
761
  useEffect(() => {
715
762
  setNodes(nds => {
@@ -743,6 +790,32 @@ export function TopologyGraph({
743
790
  // functional update returns the same array ref when nothing changed.
744
791
  }, [selectedNodeId, setNodes, nodes])
745
792
 
793
+ // Hover-focus dim (application graph): when a workload is focused, dim every
794
+ // resource node not owned by it. A pure data toggle on the existing nodes —
795
+ // positions are untouched, so it never re-runs the (expensive) ELK layout.
796
+ useEffect(() => {
797
+ setNodes(nds => {
798
+ let changed = false
799
+ const updated = nds.map(node => {
800
+ if (node.type === 'group') return node
801
+ const stamp = ownershipOf(node.data?.nodeData as Record<string, unknown> | undefined)
802
+ // A focused workload lights its whole neighborhood (focusWorkloadIds);
803
+ // the "Shared / unscoped" focus lights every neutral node.
804
+ const inFocus =
805
+ focusedOwnerId == null
806
+ ? true
807
+ : focusedOwnerId === NEUTRAL_OWNER
808
+ ? stamp.ownerWorkloadId == null
809
+ : stamp.focusWorkloadIds.includes(focusedOwnerId)
810
+ const shouldDim = focusedOwnerId != null && !inFocus
811
+ if (!!node.data?.dimmed === shouldDim) return node
812
+ changed = true
813
+ return { ...node, data: { ...node.data, dimmed: shouldDim } }
814
+ })
815
+ return changed ? updated : nds
816
+ })
817
+ }, [focusedOwnerId, setNodes, nodes])
818
+
746
819
  if (!topology) {
747
820
  return <PaneLoader label="Loading topology…" className="absolute inset-0" />
748
821
  }
@@ -884,6 +957,8 @@ export function TopologyGraph({
884
957
  onNodesChange={onNodesChange}
885
958
  onEdgesChange={onEdgesChange}
886
959
  onNodeClick={handleNodeClick}
960
+ onNodeMouseEnter={handleNodeMouseEnter}
961
+ onNodeMouseLeave={handleNodeMouseLeave}
887
962
  nodeTypes={nodeTypes}
888
963
  fitView
889
964
  fitViewOptions={{ padding: 0.2 }}
@@ -935,6 +1010,30 @@ export function TopologyGraph({
935
1010
  onExportingChange={setIsExporting}
936
1011
  />
937
1012
  </div>
1013
+ {!isTrafficView && (
1014
+ <>
1015
+ {showLegend && (
1016
+ <div className="rounded-md border border-theme-border bg-theme-surface/95 px-3 py-2 shadow-theme-md backdrop-blur">
1017
+ <div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Edge colors</div>
1018
+ <div className="flex flex-col gap-1">
1019
+ {EDGE_LEGEND.map((e) => (
1020
+ <div key={e.label} className="flex items-center gap-2 text-[11px] text-theme-text-secondary">
1021
+ <span className="inline-block h-0.5 w-5 rounded-full" style={{ background: e.color }} />
1022
+ {e.label}
1023
+ </div>
1024
+ ))}
1025
+ </div>
1026
+ </div>
1027
+ )}
1028
+ <div className="react-flow__controls overflow-hidden" style={{ position: 'static', margin: 0 }}>
1029
+ <Tooltip content="Edge color legend" delay={100} position="right">
1030
+ <button className="react-flow__controls-button" onClick={() => setShowLegend((v) => !v)}>
1031
+ <Info className="w-3.5 h-3.5" />
1032
+ </button>
1033
+ </Tooltip>
1034
+ </div>
1035
+ </>
1036
+ )}
938
1037
  </Panel>
939
1038
  <ViewportController
940
1039
  viewMode={viewMode}