@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.
- package/package.json +1 -1
- package/src/components/applications/AppChips.tsx +109 -0
- package/src/components/applications/AppTooltips.tsx +199 -0
- package/src/components/applications/ApplicationDetail.tsx +671 -0
- package/src/components/applications/ApplicationsList.tsx +569 -0
- package/src/components/applications/ReadyBar.tsx +22 -0
- package/src/components/applications/index.ts +8 -0
- package/src/components/audit/AuditFindingsTable.tsx +3 -25
- package/src/components/logs/WorkloadLogsViewer.tsx +8 -5
- package/src/components/resources/renderers/WorkloadRenderer.tsx +5 -4
- package/src/components/shared/DetailShell.tsx +14 -7
- package/src/components/shared/EditableYamlView.tsx +37 -17
- package/src/components/timeline/TimelineList.tsx +3 -32
- package/src/components/timeline/TimelineSwimlanes.tsx +3 -31
- package/src/components/topology/K8sResourceNode.tsx +26 -5
- package/src/components/topology/TopologyGraph.tsx +102 -3
- package/src/components/topology/layout.ts +36 -11
- package/src/components/ui/CenteredEmpty.tsx +27 -0
- package/src/components/ui/SearchBox.tsx +85 -0
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +167 -33
- package/src/components/workload/index.ts +1 -1
- package/src/hooks/useKeyboardShortcuts.tsx +3 -1
- package/src/index.ts +4 -0
- package/src/utils/applications.test.ts +207 -0
- package/src/utils/applications.ts +674 -0
- package/src/utils/format.ts +11 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +185 -0
- package/src/utils/topology-neighborhood.ts +262 -0
- 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(
|
|
97
|
+
setPods(resultPods)
|
|
95
98
|
|
|
96
|
-
if (!podsInitialized.current &&
|
|
99
|
+
if (!podsInitialized.current && resultPods.length > 0) {
|
|
97
100
|
podsInitialized.current = true
|
|
98
|
-
setSelectedPods(new Set(
|
|
101
|
+
setSelectedPods(new Set(resultPods.map(p => p.name)))
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
const indexByPod = new Map<string, number>()
|
|
102
|
-
|
|
105
|
+
resultPods.forEach((pod, i) => indexByPod.set(pod.name, i))
|
|
103
106
|
|
|
104
|
-
set(
|
|
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-
|
|
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-
|
|
182
|
-
<div className="text-sm text-
|
|
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
|
-
{
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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=
|
|
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
|
|
70
|
-
if (
|
|
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 ${
|
|
79
|
+
summary: `Cannot update ${target}: ${message}`,
|
|
73
80
|
details: error.length > 200 ? error : undefined
|
|
74
81
|
}
|
|
75
82
|
}
|
|
76
83
|
}
|
|
77
84
|
|
|
78
|
-
const
|
|
79
|
-
|
|
85
|
+
const braceAt = errorPart.indexOf('{')
|
|
86
|
+
const summary = (braceAt >= 0 ? errorPart.slice(0, braceAt) : errorPart).trim()
|
|
87
|
+
if (summary) {
|
|
80
88
|
return {
|
|
81
|
-
summary
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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,
|
|
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
|
-
<
|
|
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
|
|
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
|
-
<
|
|
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
|
-
|
|
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}
|