@skyhook-io/k8s-ui 1.5.13 → 1.6.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 (45) hide show
  1. package/package.json +3 -3
  2. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -3
  3. package/src/components/dock/BottomDock.tsx +24 -17
  4. package/src/components/dock/DockContext.tsx +39 -0
  5. package/src/components/gitops/index.ts +4 -0
  6. package/src/components/gitops/insights/GitOpsInsightViews.tsx +1456 -0
  7. package/src/components/gitops/insights/index.ts +6 -0
  8. package/src/components/gitops/insights/insights-helpers.test.ts +98 -0
  9. package/src/components/gitops/insights/insights-helpers.ts +99 -0
  10. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +799 -0
  11. package/src/components/gitops/tree/index.ts +4 -0
  12. package/src/components/gitops/tree/tree-helpers.ts +42 -0
  13. package/src/components/resources/ResourcesView.tsx +71 -18
  14. package/src/components/resources/index.ts +1 -1
  15. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +1 -1
  16. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -1
  17. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +1 -1
  18. package/src/components/resources/renderers/PodRenderer.tsx +4 -3
  19. package/src/components/resources/renderers/SecretRenderer.tsx +4 -10
  20. package/src/components/shared/EditableYamlView.tsx +28 -17
  21. package/src/components/shared/ManagedByChip.tsx +45 -0
  22. package/src/components/shared/index.ts +1 -0
  23. package/src/components/timeline/TimelineList.tsx +3 -3
  24. package/src/components/topology/TopologyGraph.tsx +3 -2
  25. package/src/components/ui/Tooltip.tsx +10 -1
  26. package/src/components/ui/drawer-components.tsx +1 -1
  27. package/src/components/workload/ResourceDetailDrawer.tsx +5 -3
  28. package/src/components/workload/WorkloadView.tsx +66 -0
  29. package/src/hooks/useKeyboardShortcuts.tsx +3 -2
  30. package/src/index.ts +3 -0
  31. package/src/types/core.ts +19 -2
  32. package/src/types/gitops-insights.ts +193 -0
  33. package/src/types/gitops-tree.ts +57 -0
  34. package/src/types/index.ts +2 -0
  35. package/src/utils/badge-colors.ts +6 -1
  36. package/src/utils/format.ts +28 -0
  37. package/src/utils/gitops-owner.test.ts +136 -0
  38. package/src/utils/gitops-owner.ts +92 -0
  39. package/src/utils/gitops-route.test.ts +78 -0
  40. package/src/utils/gitops-route.ts +104 -0
  41. package/src/utils/index.ts +2 -0
  42. package/src/utils/navigation.ts +14 -0
  43. package/src/utils/resource-hierarchy.ts +47 -3
  44. package/src/utils/yaml.test.ts +101 -0
  45. package/src/utils/yaml.ts +26 -0
@@ -0,0 +1,799 @@
1
+ import { memo, useCallback, useEffect, useMemo, useState } from 'react'
2
+ import {
3
+ Background,
4
+ BackgroundVariant,
5
+ Controls,
6
+ Handle,
7
+ MarkerType,
8
+ Position,
9
+ ReactFlow,
10
+ ReactFlowProvider,
11
+ useReactFlow,
12
+ type Edge,
13
+ type Node,
14
+ type NodeProps,
15
+ type NodeTypes,
16
+ } from '@xyflow/react'
17
+ import '@xyflow/react/dist/style.css'
18
+ import { AlertTriangle, ChevronRight, Loader2, Maximize, Search, X } from 'lucide-react'
19
+ import { clsx } from 'clsx'
20
+
21
+ import type { GitOpsResourceTree, GitOpsTreeNode, GitOpsTreeRef, HealthStatus } from '../../../types'
22
+ import { displayKind } from '../../../types'
23
+ import { healthToSeverity, SEVERITY_DOT } from '../../../utils/badge-colors'
24
+ import { formatCompactAge } from '../../../utils/format'
25
+ import { Tooltip } from '../../ui/Tooltip'
26
+ import { hasGitOpsTreeFilters, matchesGitOpsTreeFilters, type GitOpsTreeFilters } from './tree-helpers'
27
+
28
+ export type GitOpsTreePreset = 'full' | 'compact' | 'workloads' | 'app'
29
+
30
+ const RANK_GAP = 390
31
+ const ROW_GAP = 118
32
+ const NODE_WIDTH = 320
33
+ const NODE_HEIGHT = 84
34
+ const GROUP_WIDTH = 230
35
+ const GROUP_HEIGHT = 64
36
+ const ROOT_Y_OFFSET = 28
37
+
38
+ const WORKLOAD_KINDS = new Set(['Deployment', 'StatefulSet', 'DaemonSet', 'Rollout', 'Job', 'CronJob'])
39
+ const WORKLOAD_CHILD_KINDS = new Set(['ReplicaSet', 'Pod', 'PodGroup'])
40
+ const COMPACT_INFRA_KINDS = new Set([
41
+ 'AppProject',
42
+ 'ClusterRole',
43
+ 'ClusterRoleBinding',
44
+ 'ConfigMap',
45
+ 'CustomResourceDefinition',
46
+ 'Role',
47
+ 'RoleBinding',
48
+ 'Secret',
49
+ 'SealedSecret',
50
+ 'ServiceAccount',
51
+ ])
52
+
53
+ interface GitOpsTreeGraphProps {
54
+ tree: GitOpsResourceTree | null
55
+ loading?: boolean
56
+ error?: Error | null
57
+ onNodeClick?: (ref: GitOpsTreeRef, node: GitOpsTreeNode) => void
58
+ preset?: GitOpsTreePreset
59
+ onPresetChange?: (preset: GitOpsTreePreset) => void
60
+ query?: string
61
+ onQueryChange?: (query: string) => void
62
+ filters?: GitOpsTreeFilters
63
+ showToolbar?: boolean
64
+ }
65
+
66
+ export function GitOpsTreeGraph(props: GitOpsTreeGraphProps) {
67
+ return (
68
+ <ReactFlowProvider>
69
+ <GitOpsTreeGraphInner {...props} />
70
+ </ReactFlowProvider>
71
+ )
72
+ }
73
+
74
+ function GitOpsTreeGraphInner({
75
+ tree,
76
+ loading = false,
77
+ error = null,
78
+ onNodeClick,
79
+ preset: controlledPreset,
80
+ onPresetChange,
81
+ query: controlledQuery,
82
+ onQueryChange,
83
+ filters,
84
+ showToolbar = true,
85
+ }: GitOpsTreeGraphProps) {
86
+ const [internalPreset, setInternalPreset] = useState<GitOpsTreePreset>('compact')
87
+ const [internalQuery, setInternalQuery] = useState('')
88
+ const preset = controlledPreset ?? internalPreset
89
+ const query = controlledQuery ?? internalQuery
90
+ const setPreset = onPresetChange ?? setInternalPreset
91
+ const setQuery = onQueryChange ?? setInternalQuery
92
+ const reactFlow = useReactFlow()
93
+ // Per-group expand state (keyed by group node ID, e.g. "<parent>/compact/<kind>").
94
+ // When a group ID is in this set, compactInfra leaves the children visible
95
+ // instead of collapsing them into a single "N <kind>s" node. Collapses
96
+ // back to the default when the user toggles off or switches preset.
97
+ const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
98
+ // Reset expansion when leaving compact preset — there are no groups to
99
+ // expand in 'full', and stale state would surprise the user when they
100
+ // toggle back.
101
+ useEffect(() => {
102
+ if (preset !== 'compact') setExpandedGroups(new Set())
103
+ }, [preset])
104
+ const { nodes, edges } = useMemo(() => buildFlowGraph(tree, preset, query, filters, expandedGroups), [tree, preset, query, filters, expandedGroups])
105
+
106
+ useEffect(() => {
107
+ if (nodes.length === 0) return
108
+ const id = window.setTimeout(() => reactFlow.fitView({ padding: 0.18, maxZoom: 1.15, duration: 180 }), 0)
109
+ return () => window.clearTimeout(id)
110
+ }, [nodes.length, edges.length, preset, query, reactFlow])
111
+
112
+ const handleNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
113
+ const gitOpsNode = node.data.node as GitOpsTreeNode | undefined
114
+ if (!gitOpsNode) return
115
+ if (gitOpsNode.role === 'group') {
116
+ // Toggle expand/collapse for this group. Re-render replaces the
117
+ // synthetic group node with the original children (or vice versa).
118
+ setExpandedGroups((prev) => {
119
+ const next = new Set(prev)
120
+ if (next.has(gitOpsNode.id)) next.delete(gitOpsNode.id)
121
+ else next.add(gitOpsNode.id)
122
+ return next
123
+ })
124
+ return
125
+ }
126
+ onNodeClick?.(gitOpsNode.ref, gitOpsNode)
127
+ }, [onNodeClick])
128
+
129
+ if (loading) {
130
+ return (
131
+ <div className="flex h-full items-center justify-center text-theme-text-secondary">
132
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
133
+ Loading GitOps resource tree...
134
+ </div>
135
+ )
136
+ }
137
+
138
+ if (error) {
139
+ return (
140
+ <div className="flex h-full items-center justify-center p-6 text-sm text-red-500">
141
+ <AlertTriangle className="mr-2 h-4 w-4" />
142
+ Failed to load GitOps tree: {error.message}
143
+ </div>
144
+ )
145
+ }
146
+
147
+ if (!tree || nodes.length === 0) {
148
+ return (
149
+ <div className="flex h-full items-center justify-center text-sm text-theme-text-secondary">
150
+ No managed resources found for this GitOps object.
151
+ </div>
152
+ )
153
+ }
154
+
155
+ return (
156
+ <div className="relative h-full min-h-0 min-w-0">
157
+ {showToolbar && (
158
+ <GitOpsTreeToolbar
159
+ preset={preset}
160
+ onPresetChange={setPreset}
161
+ query={query}
162
+ onQueryChange={setQuery}
163
+ onFit={() => reactFlow.fitView({ padding: 0.18, maxZoom: 1.15, duration: 180 })}
164
+ />
165
+ )}
166
+ <ReactFlow
167
+ nodes={nodes}
168
+ edges={edges}
169
+ nodeTypes={nodeTypes}
170
+ onNodeClick={handleNodeClick}
171
+ nodesDraggable={false}
172
+ nodesConnectable={false}
173
+ elementsSelectable
174
+ fitView
175
+ fitViewOptions={{ padding: 0.18, maxZoom: 1.15 }}
176
+ minZoom={0.15}
177
+ maxZoom={1.5}
178
+ proOptions={{ hideAttribution: true }}
179
+ className="bg-theme-base"
180
+ >
181
+ <Background
182
+ variant={BackgroundVariant.Dots}
183
+ gap={20}
184
+ size={1}
185
+ className="opacity-40"
186
+ />
187
+ <Controls
188
+ className="!border-theme-border !bg-theme-surface"
189
+ showInteractive={false}
190
+ />
191
+ </ReactFlow>
192
+ <EdgeLegend edges={edges} />
193
+ </div>
194
+ )
195
+ }
196
+
197
+ // EdgeLegend documents the color encoding on the GitOps tree graph. Only
198
+ // renders rows for edge types actually present in the current view —
199
+ // hides the legend entirely when the graph has only `owns` edges (the
200
+ // default for Argo apps without dependencies).
201
+ function EdgeLegend({ edges }: { edges: Edge[] }) {
202
+ const types = new Set(edges.map((e) => e.data?.type as string).filter(Boolean))
203
+ const rows: Array<{ key: string; color: string; label: string }> = []
204
+ if (types.has('source')) rows.push({ key: 'source', color: getEdgeColor('source'), label: 'Source repo' })
205
+ if (types.has('dependsOn')) rows.push({ key: 'dependsOn', color: getEdgeColor('dependsOn'), label: 'Depends on' })
206
+ // Always show the default ownership color when there are 2+ edge kinds —
207
+ // operators need to compare against it. Hide when "owns" is the only kind.
208
+ if (rows.length > 0) rows.unshift({ key: 'owns', color: getEdgeColor('owns'), label: 'Ownership' })
209
+ if (rows.length === 0) return null
210
+ return (
211
+ <div className="absolute bottom-4 left-4 z-10 rounded-md border border-theme-border bg-theme-surface/90 px-2.5 py-1.5 text-[10px] backdrop-blur">
212
+ <div className="mb-1 font-medium uppercase tracking-wide text-theme-text-tertiary">Edges</div>
213
+ <div className="flex flex-col gap-1">
214
+ {rows.map((row) => (
215
+ <div key={row.key} className="flex items-center gap-2 text-theme-text-secondary">
216
+ <span className="inline-block h-[2px] w-5 rounded" style={{ backgroundColor: row.color }} />
217
+ {row.label}
218
+ </div>
219
+ ))}
220
+ </div>
221
+ </div>
222
+ )
223
+ }
224
+
225
+ function GitOpsTreeToolbar({
226
+ preset,
227
+ onPresetChange,
228
+ query,
229
+ onQueryChange,
230
+ onFit,
231
+ }: {
232
+ preset: GitOpsTreePreset
233
+ onPresetChange: (preset: GitOpsTreePreset) => void
234
+ query: string
235
+ onQueryChange: (query: string) => void
236
+ onFit: () => void
237
+ }) {
238
+ return (
239
+ <div className="absolute right-4 top-4 z-10 flex flex-wrap items-center justify-end gap-2">
240
+ <div className="flex items-center gap-1 rounded-lg border border-theme-border bg-theme-surface/90 p-1 backdrop-blur">
241
+ {(['compact', 'workloads', 'app', 'full'] as const).map(value => (
242
+ <button
243
+ key={value}
244
+ type="button"
245
+ onClick={() => onPresetChange(value)}
246
+ className={clsx(
247
+ 'rounded-md px-2.5 py-1 text-xs transition-colors',
248
+ preset === value
249
+ ? 'bg-skyhook-600 text-white'
250
+ : 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
251
+ )}
252
+ >
253
+ {getPresetLabel(value)}
254
+ </button>
255
+ ))}
256
+ </div>
257
+ <div className="flex items-center gap-1 rounded-lg border border-theme-border bg-theme-surface/90 px-2 py-1.5 backdrop-blur">
258
+ <Search className="h-3.5 w-3.5 text-theme-text-tertiary" />
259
+ <input
260
+ value={query}
261
+ onChange={(event) => onQueryChange(event.target.value)}
262
+ placeholder="Find node..."
263
+ className="w-36 bg-transparent text-xs text-theme-text-primary outline-none placeholder:text-theme-text-tertiary"
264
+ />
265
+ {query && (
266
+ <button type="button" onClick={() => onQueryChange('')} className="text-theme-text-tertiary hover:text-theme-text-primary">
267
+ <X className="h-3.5 w-3.5" />
268
+ </button>
269
+ )}
270
+ </div>
271
+ <Tooltip content="Fit tree" delay={120}>
272
+ <button
273
+ type="button"
274
+ onClick={onFit}
275
+ className="flex items-center gap-1.5 rounded-lg border border-theme-border bg-theme-surface/90 px-2.5 py-1.5 text-xs text-theme-text-secondary backdrop-blur transition-colors hover:text-theme-text-primary"
276
+ >
277
+ <Maximize className="h-3.5 w-3.5" />
278
+ Fit
279
+ </button>
280
+ </Tooltip>
281
+ </div>
282
+ )
283
+ }
284
+
285
+ function getPresetLabel(preset: GitOpsTreePreset): string {
286
+ switch (preset) {
287
+ case 'compact': return 'Compact'
288
+ case 'workloads': return 'Workloads'
289
+ case 'app': return 'Declared'
290
+ case 'full': return 'Full'
291
+ }
292
+ }
293
+
294
+ function getEdgeColor(type: string): string {
295
+ switch (type) {
296
+ case 'source': return '#0ea5e9'
297
+ case 'dependsOn': return '#f59e0b'
298
+ default: return '#64748b'
299
+ }
300
+ }
301
+
302
+ function buildFlowGraph(tree: GitOpsResourceTree | null, preset: GitOpsTreePreset, query: string, filters?: GitOpsTreeFilters, expandedGroups?: Set<string>): { nodes: Node[]; edges: Edge[] } {
303
+ if (!tree) return { nodes: [], edges: [] }
304
+ const visibleTree = applyGraphFilters(applyPreset(tree, preset, expandedGroups), filters)
305
+ const byID = new Map(visibleTree.nodes.map(node => [node.id, node]))
306
+ const children = new Map<string, string[]>()
307
+ const incoming = new Map<string, number>()
308
+ for (const edge of visibleTree.edges) {
309
+ if (!byID.has(edge.source) || !byID.has(edge.target)) continue
310
+ children.set(edge.source, [...(children.get(edge.source) ?? []), edge.target])
311
+ incoming.set(edge.target, (incoming.get(edge.target) ?? 0) + 1)
312
+ }
313
+
314
+ const ranks = assignRanks(visibleTree.root.id, byID, children, incoming)
315
+ const positioned = positionRanks(ranks, byID)
316
+ const normalizedQuery = query.trim().toLowerCase()
317
+ const nodes = Array.from(positioned.entries()).map(([id, position]) => {
318
+ const node = byID.get(id)!
319
+ return {
320
+ id,
321
+ type: 'gitopsResource',
322
+ position,
323
+ data: {
324
+ node,
325
+ highlighted: normalizedQuery !== '' && matchesQuery(node, normalizedQuery),
326
+ },
327
+ }
328
+ })
329
+
330
+ const edges = visibleTree.edges
331
+ .filter(edge => positioned.has(edge.source) && positioned.has(edge.target))
332
+ .map(edge => ({
333
+ id: `${edge.source}->${edge.target}`,
334
+ source: edge.source,
335
+ target: edge.target,
336
+ type: 'smoothstep',
337
+ // The legend reads back edge.data?.type to decide which rows to render —
338
+ // ReactFlow doesn't preserve our custom `type` field at the top level
339
+ // because it overloads `type` for the edge component identity ("smoothstep").
340
+ data: { type: edge.type },
341
+ markerEnd: {
342
+ type: MarkerType.ArrowClosed,
343
+ width: 16,
344
+ height: 16,
345
+ color: getEdgeColor(edge.type),
346
+ },
347
+ style: {
348
+ stroke: getEdgeColor(edge.type),
349
+ strokeWidth: edge.type === 'owns' ? 1.5 : 1.75,
350
+ },
351
+ }))
352
+
353
+ return { nodes, edges }
354
+ }
355
+
356
+ function applyPreset(tree: GitOpsResourceTree, preset: GitOpsTreePreset, expandedGroups?: Set<string>): GitOpsResourceTree {
357
+ if (preset === 'full') return tree
358
+ if (preset === 'compact') return compactInfra(tree, expandedGroups)
359
+
360
+ const byID = new Map(tree.nodes.map(node => [node.id, node]))
361
+ const children = new Map<string, string[]>()
362
+ for (const edge of tree.edges) {
363
+ children.set(edge.source, [...(children.get(edge.source) ?? []), edge.target])
364
+ }
365
+
366
+ const keep = new Set<string>([tree.root.id])
367
+ if (preset === 'app') {
368
+ for (const edge of tree.edges) {
369
+ if (edge.source === tree.root.id) keep.add(edge.target)
370
+ }
371
+ } else {
372
+ for (const node of tree.nodes) {
373
+ if (node.role === 'root' || WORKLOAD_KINDS.has(node.ref.kind) || WORKLOAD_CHILD_KINDS.has(node.ref.kind)) {
374
+ keep.add(node.id)
375
+ }
376
+ }
377
+ let changed = true
378
+ while (changed) {
379
+ changed = false
380
+ for (const edge of tree.edges) {
381
+ if (keep.has(edge.target) && !keep.has(edge.source)) {
382
+ keep.add(edge.source)
383
+ changed = true
384
+ }
385
+ }
386
+ }
387
+ }
388
+
389
+ return {
390
+ ...tree,
391
+ nodes: tree.nodes.filter(node => keep.has(node.id)),
392
+ edges: tree.edges.filter(edge => keep.has(edge.source) && keep.has(edge.target)),
393
+ root: byID.get(tree.root.id) ?? tree.root,
394
+ }
395
+ }
396
+
397
+ function applyGraphFilters(tree: GitOpsResourceTree, filters?: GitOpsTreeFilters): GitOpsResourceTree {
398
+ if (!hasGitOpsTreeFilters(filters)) return tree
399
+
400
+ const parent = new Map<string, string>()
401
+ for (const edge of tree.edges) {
402
+ if (!parent.has(edge.target)) parent.set(edge.target, edge.source)
403
+ }
404
+
405
+ const keep = new Set<string>([tree.root.id])
406
+ for (const node of tree.nodes) {
407
+ if (node.id === tree.root.id) continue
408
+ if (matchesGitOpsTreeFilters(node, filters)) {
409
+ let current: string | undefined = node.id
410
+ while (current) {
411
+ keep.add(current)
412
+ current = parent.get(current)
413
+ }
414
+ }
415
+ }
416
+
417
+ return {
418
+ ...tree,
419
+ nodes: tree.nodes.filter(node => keep.has(node.id)),
420
+ edges: tree.edges.filter(edge => keep.has(edge.source) && keep.has(edge.target)),
421
+ }
422
+ }
423
+
424
+ function compactInfra(tree: GitOpsResourceTree, expandedGroups?: Set<string>): GitOpsResourceTree {
425
+ const children = new Map<string, string[]>()
426
+ const parent = new Map<string, string>()
427
+ for (const edge of tree.edges) {
428
+ children.set(edge.source, [...(children.get(edge.source) ?? []), edge.target])
429
+ parent.set(edge.target, edge.source)
430
+ }
431
+
432
+ const groups = new Map<string, GitOpsTreeNode[]>()
433
+ for (const node of tree.nodes) {
434
+ if (node.id === tree.root.id || node.role === 'group' || !COMPACT_INFRA_KINDS.has(node.ref.kind)) continue
435
+ if ((children.get(node.id) ?? []).length > 0) continue
436
+ const p = parent.get(node.id)
437
+ if (!p) continue
438
+ const key = `${p}|${node.ref.kind}`
439
+ groups.set(key, [...(groups.get(key) ?? []), node])
440
+ }
441
+
442
+ const remove = new Set<string>()
443
+ const additions: GitOpsTreeNode[] = []
444
+ const edgeAdditions: GitOpsResourceTree['edges'] = []
445
+ for (const [key, nodes] of groups) {
446
+ if (nodes.length < 2) continue
447
+ const [p, kind] = key.split('|')
448
+ const id = `${p}/compact/${kind}`
449
+ // Per-group expand: when the user has clicked this group's bubble,
450
+ // skip the collapse step for that group only — leave the children
451
+ // visible alongside any other still-collapsed groups in the tree.
452
+ if (expandedGroups?.has(id)) continue
453
+ for (const node of nodes) remove.add(node.id)
454
+ additions.push({
455
+ id,
456
+ ref: { kind, namespace: '', name: `${nodes.length} ${pluralize(kind)}` },
457
+ role: 'group',
458
+ tool: nodes[0].tool,
459
+ topologyStatus: 'unknown',
460
+ groupedNodeIDs: nodes.map(node => node.id),
461
+ count: nodes.length,
462
+ data: { groupedKind: kind },
463
+ })
464
+ edgeAdditions.push({ source: p, target: id, type: 'owns' })
465
+ }
466
+
467
+ if (remove.size === 0) return tree
468
+ return {
469
+ ...tree,
470
+ nodes: [...tree.nodes.filter(node => !remove.has(node.id)), ...additions],
471
+ edges: [
472
+ ...tree.edges.filter(edge => !remove.has(edge.source) && !remove.has(edge.target)),
473
+ ...edgeAdditions,
474
+ ],
475
+ }
476
+ }
477
+
478
+ function assignRanks(
479
+ rootID: string,
480
+ nodes: Map<string, GitOpsTreeNode>,
481
+ children: Map<string, string[]>,
482
+ incoming: Map<string, number>
483
+ ): Map<number, string[]> {
484
+ const rankByID = new Map<string, number>()
485
+ const queue = [{ id: rootID, rank: 0 }]
486
+ // Cycle/explosion guard: GitOps trees are owner-ref DAGs in practice, but
487
+ // the EdgeManages set we feed in could pathologically include a cycle
488
+ // (e.g. a CRD whose owner ref points back at one of its own children, or
489
+ // a malformed inventory). Without this cap, the "previous >= rank" skip
490
+ // never fires for cyclic edges and the loop runs forever, freezing the
491
+ // UI thread. Cap at a large multiple of node count so legitimate trees
492
+ // never hit it; cycles bail with the partial layout we have so far.
493
+ let iterations = 0
494
+ const maxIterations = nodes.size * 8 + 64
495
+
496
+ while (queue.length > 0) {
497
+ if (++iterations > maxIterations) break
498
+ const current = queue.shift()!
499
+ const previous = rankByID.get(current.id)
500
+ if (previous !== undefined && previous >= current.rank) continue
501
+ rankByID.set(current.id, current.rank)
502
+ for (const child of children.get(current.id) ?? []) {
503
+ queue.push({ id: child, rank: current.rank + 1 })
504
+ }
505
+ }
506
+
507
+ for (const id of nodes.keys()) {
508
+ if (!rankByID.has(id)) {
509
+ rankByID.set(id, incoming.get(id) ? 1 : 0)
510
+ }
511
+ }
512
+
513
+ const ranks = new Map<number, string[]>()
514
+ for (const [id, rank] of rankByID.entries()) {
515
+ ranks.set(rank, [...(ranks.get(rank) ?? []), id])
516
+ }
517
+ for (const ids of ranks.values()) {
518
+ ids.sort((a, b) => compareTreeNodes(nodes.get(a), nodes.get(b)))
519
+ }
520
+ return ranks
521
+ }
522
+
523
+ function positionRanks(ranks: Map<number, string[]>, nodes: Map<string, GitOpsTreeNode>): Map<string, { x: number; y: number }> {
524
+ const positioned = new Map<string, { x: number; y: number }>()
525
+ const sortedRanks = Array.from(ranks.keys()).sort((a, b) => a - b)
526
+
527
+ for (const rank of sortedRanks) {
528
+ const ids = ranks.get(rank) ?? []
529
+ ids.forEach((id, row) => {
530
+ positioned.set(id, { x: rank * RANK_GAP, y: row * ROW_GAP })
531
+ })
532
+ }
533
+
534
+ const rootRank = ranks.get(0) ?? []
535
+ if (rootRank.length === 1) {
536
+ const rootID = rootRank[0]
537
+ const nextRank = ranks.get(1) ?? []
538
+ if (nextRank.length > 0) {
539
+ const maxY = (nextRank.length - 1) * ROW_GAP
540
+ const rootHeight = getNodeDimensions(nodes.get(rootID)!).height
541
+ positioned.set(rootID, { x: 0, y: Math.max(0, (maxY - rootHeight) / 2 - ROOT_Y_OFFSET) })
542
+ }
543
+ }
544
+
545
+ return positioned
546
+ }
547
+
548
+ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<Node<{ node: GitOpsTreeNode; highlighted?: boolean }>>) {
549
+ const node = data.node
550
+ const kind = normalizeDisplayKind(node)
551
+ const status = normalizeHealth(node.topologyStatus)
552
+ const terminating = isNodeTerminating(node)
553
+ const gitopsTool = gitopsToolForNode(node)
554
+ const chips = buildChips(node)
555
+ const dim = getNodeDimensions(node)
556
+
557
+ return (
558
+ <>
559
+ <Handle type="target" position={Position.Left} className="!h-0 !w-0 !border-0 !bg-transparent" />
560
+ <div
561
+ className={clsx(
562
+ 'relative overflow-hidden rounded-lg border bg-theme-surface shadow-md transition-colors',
563
+ data.highlighted ? 'border-skyhook-400 ring-2 ring-skyhook-400/40' : 'border-theme-border',
564
+ // Lifecycle dominates the left-stripe color: a Terminating node
565
+ // paints orange regardless of its frozen sync/health state. Same
566
+ // logic as the fleet-row statusStripe — sync/health shouldn't
567
+ // signal "fix me" when the resource is being deleted.
568
+ terminating
569
+ ? 'border-l-orange-500'
570
+ : status === 'healthy' && 'border-l-green-500',
571
+ !terminating && status === 'degraded' && 'border-l-yellow-500',
572
+ !terminating && status === 'unhealthy' && 'border-l-red-500',
573
+ !terminating && status === 'unknown' && 'border-l-slate-500',
574
+ // GitOps portal nodes get a slightly stronger ring so the user's
575
+ // eye picks them out from generic K8s leaves. Combined with the
576
+ // tool-icon overlay + "→ Open" affordance below, this signals
577
+ // "click me to dive into this CR's own GitOps view" without
578
+ // making the node look broken or alarming.
579
+ gitopsTool && !data.highlighted && 'ring-1 ring-skyhook-500/30 hover:ring-skyhook-500/60',
580
+ gitopsTool && 'cursor-pointer',
581
+ // Group nodes are interactive (click toggles expand) but the
582
+ // default ReactFlow handler doesn't add a hover state. A subtle
583
+ // border lift telegraphs "this responds to clicks" without
584
+ // looking like a primary action.
585
+ node.role === 'group' && 'cursor-pointer hover:border-theme-text-tertiary/50 hover:bg-theme-hover',
586
+ )}
587
+ style={{ width: dim.width, minHeight: dim.height, borderLeftWidth: 4 }}
588
+ >
589
+ <div className="px-3 py-2.5">
590
+ <div className="mb-1 flex items-center gap-1.5">
591
+ <span className={`topology-icon topology-icon-${kind.toLowerCase()}`} />
592
+ <span className="truncate text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">
593
+ {node.role === 'group' ? displayKind((node.data?.groupedKind as string) || kind) : displayKind(kind)}
594
+ </span>
595
+ {/* Tool badge for GitOps portal nodes — sits between the kind
596
+ label and the status dot, signaling "this is a managed-
597
+ GitOps-app, not a leaf K8s resource". Keeps the dot's
598
+ position so non-portal nodes look unchanged. */}
599
+ {gitopsTool && (
600
+ <span className="rounded-sm border border-skyhook-500/30 bg-skyhook-500/10 px-1 py-px text-[9px] font-semibold uppercase tracking-wide text-skyhook-300">
601
+ {gitopsTool === 'argocd' ? 'Argo' : 'Flux'}
602
+ </span>
603
+ )}
604
+ <span className={clsx('ml-auto h-1.5 w-1.5 rounded-full', getStatusDotColor(status))} />
605
+ </div>
606
+ <div className="truncate pr-1 text-sm font-medium text-theme-text-primary">{node.ref.name}</div>
607
+ {/* Group nodes get a chevron at the bottom-right to advertise the
608
+ click-to-expand affordance. GitOps portal nodes get a similar
609
+ chevron at the right of the subtitle to signal "→ opens its
610
+ own page". The subtitle text alone wasn't enough — users were
611
+ treating the count as an immutable fact rather than a button. */}
612
+ <div className="mt-0.5 flex items-center gap-1 text-xs text-theme-text-secondary">
613
+ <span className="truncate">{getSubtitle(node)}</span>
614
+ {(node.role === 'group' || gitopsTool) && <ChevronRight className="ml-auto h-3 w-3 shrink-0 text-theme-text-tertiary" />}
615
+ </div>
616
+ {chips.length > 0 && (
617
+ <div className="mt-2 flex flex-wrap gap-1">
618
+ {chips.slice(0, 4).map(chip => (
619
+ <span
620
+ key={`${chip.label}:${chip.value}`}
621
+ className={clsx(
622
+ 'max-w-[145px] truncate rounded border px-1.5 py-0.5 text-[10px] leading-3',
623
+ chip.tone === 'warning'
624
+ ? 'border-yellow-500/30 bg-yellow-500/10 text-yellow-700 dark:text-yellow-300'
625
+ : chip.tone === 'danger'
626
+ ? 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300'
627
+ : 'border-theme-border bg-theme-elevated/70 text-theme-text-secondary'
628
+ )}
629
+ >
630
+ {chip.label ? `${chip.label}: ` : ''}{chip.value}
631
+ </span>
632
+ ))}
633
+ </div>
634
+ )}
635
+ </div>
636
+ </div>
637
+ <Handle type="source" position={Position.Right} className="!h-0 !w-0 !border-0 !bg-transparent" />
638
+ </>
639
+ )
640
+ })
641
+
642
+ const nodeTypes: NodeTypes = {
643
+ gitopsResource: GitOpsResourceNode,
644
+ }
645
+
646
+ // isNodeTerminating returns true when the backend tagged the node with
647
+ // metadata.deletionTimestamp (see enrichNodeFromObject in pkg/gitops/tree).
648
+ // The check tolerates older payloads that don't carry the field — they
649
+ // simply read as not-terminating, which is the safe default.
650
+ function isNodeTerminating(node: GitOpsTreeNode): boolean {
651
+ return Boolean(node.data?.deletionTimestamp)
652
+ }
653
+
654
+ // gitopsToolForNode returns 'argocd' | 'fluxcd' | undefined depending on
655
+ // whether the backend classified this node as itself a GitOps detail-page
656
+ // CR (Argo Application/Set/Project, Flux Kustomization/HelmRelease/source).
657
+ // These nodes are *portals* — clicking them opens that CR's own GitOps
658
+ // detail page rather than the standard resource drawer. The visual
659
+ // treatment around them (tool icon overlay, "→" affordance, stronger
660
+ // border) advertises that they're not just leaves.
661
+ function gitopsToolForNode(node: GitOpsTreeNode): 'argocd' | 'fluxcd' | undefined {
662
+ const t = node.data?.gitopsTool
663
+ if (t === 'argocd' || t === 'fluxcd') return t
664
+ return undefined
665
+ }
666
+
667
+ function buildChips(node: GitOpsTreeNode): Array<{ label?: string; value: string; tone?: 'neutral' | 'warning' | 'danger' }> {
668
+ const data = node.data ?? {}
669
+ const chips: Array<{ label?: string; value: string; tone?: 'neutral' | 'warning' | 'danger' }> = []
670
+ // Terminating dominates: surface a single Terminating chip and skip the
671
+ // sync/health chips entirely. Showing "OutOfSync" or "Degraded" alongside
672
+ // a deletion-in-progress tag makes a stuck zombie look like a routine
673
+ // sync problem — same misleading-state pattern we removed from the
674
+ // detail title row and fleet rows.
675
+ if (isNodeTerminating(node)) {
676
+ chips.push({ value: 'Terminating', tone: 'warning' })
677
+ if (typeof data.createdAt === 'string') chips.push({ label: 'age', value: formatAge(data.createdAt) })
678
+ return chips
679
+ }
680
+ if (typeof data.createdAt === 'string') chips.push({ label: 'age', value: formatAge(data.createdAt) })
681
+ const revision = stringData(data.revision) || stringData(data.lastSyncRevision)
682
+ if (revision) chips.push({ label: 'rev', value: revision })
683
+ const attempted = stringData(data.attemptedRevision)
684
+ if (attempted && attempted !== revision) chips.push({ label: 'attempted', value: attempted, tone: 'warning' })
685
+ const wave = stringData(data.syncWave)
686
+ if (wave) chips.push({ label: 'wave', value: wave })
687
+ const hook = stringData(data.hook)
688
+ if (hook) chips.push({ value: hook })
689
+ const relationship = stringData(data.relationship)
690
+ if (relationship) chips.push({ value: relationship })
691
+ if (node.sync === 'OutOfSync') chips.push({ value: 'OutOfSync', tone: 'warning' })
692
+ if (node.health === 'Degraded' || node.health === 'Missing') chips.push({ value: node.health, tone: 'danger' })
693
+ return chips
694
+ }
695
+
696
+ function getSubtitle(node: GitOpsTreeNode): string {
697
+ if (node.role === 'group') {
698
+ // Action-oriented copy invites the click; "collapsed" alone reads as
699
+ // a state description, not an affordance.
700
+ return 'Click to expand'
701
+ }
702
+ if (isNodeTerminating(node)) {
703
+ return 'Pending deletion'
704
+ }
705
+ if (node.sync || node.health) {
706
+ return [node.sync, node.health].filter(Boolean).join(' • ')
707
+ }
708
+ if (node.info?.[0]?.value) return node.info[0].value
709
+ return node.ref.namespace || ''
710
+ }
711
+
712
+ function normalizeDisplayKind(node: GitOpsTreeNode): string {
713
+ if (node.role === 'group' && node.ref.kind === 'Pod') return 'PodGroup'
714
+ return node.ref.kind || 'PodGroup'
715
+ }
716
+
717
+ function normalizeHealth(status?: string): HealthStatus {
718
+ if (status === 'healthy' || status === 'degraded' || status === 'unhealthy') return status
719
+ return 'unknown'
720
+ }
721
+
722
+ function getStatusDotColor(status: HealthStatus): string {
723
+ return SEVERITY_DOT[healthToSeverity(status)]
724
+ }
725
+
726
+ function getNodeDimensions(node: GitOpsTreeNode): { width: number; height: number } {
727
+ if (node.role === 'group') return { width: GROUP_WIDTH, height: GROUP_HEIGHT }
728
+ return { width: NODE_WIDTH, height: NODE_HEIGHT }
729
+ }
730
+
731
+ function matchesQuery(node: GitOpsTreeNode, query: string): boolean {
732
+ return [
733
+ node.ref.kind,
734
+ node.ref.name,
735
+ node.ref.namespace,
736
+ node.ref.group,
737
+ node.sync,
738
+ node.health,
739
+ ].some(value => String(value ?? '').toLowerCase().includes(query))
740
+ }
741
+
742
+ function compareTreeNodes(a?: GitOpsTreeNode, b?: GitOpsTreeNode): number {
743
+ if (!a || !b) return 0
744
+ const roleDiff = rolePriority(a.role) - rolePriority(b.role)
745
+ if (roleDiff !== 0) return roleDiff
746
+ const kindDiff = kindPriority(a.ref.kind) - kindPriority(b.ref.kind)
747
+ if (kindDiff !== 0) return kindDiff
748
+ return `${a.ref.namespace}/${a.ref.name}`.localeCompare(`${b.ref.namespace}/${b.ref.name}`)
749
+ }
750
+
751
+ function rolePriority(role: string): number {
752
+ switch (role) {
753
+ case 'root': return 0
754
+ case 'declared': return 1
755
+ case 'generated': return 2
756
+ case 'group': return 3
757
+ default: return 4
758
+ }
759
+ }
760
+
761
+ function kindPriority(kind: string): number {
762
+ const priorities: Record<string, number> = {
763
+ Namespace: 0,
764
+ AppProject: 1,
765
+ ServiceAccount: 2,
766
+ Secret: 3,
767
+ SealedSecret: 3,
768
+ ConfigMap: 4,
769
+ CustomResourceDefinition: 5,
770
+ ClusterRole: 6,
771
+ ClusterRoleBinding: 7,
772
+ Role: 8,
773
+ RoleBinding: 9,
774
+ Service: 10,
775
+ Deployment: 11,
776
+ StatefulSet: 11,
777
+ DaemonSet: 11,
778
+ ReplicaSet: 12,
779
+ Pod: 13,
780
+ Ingress: 14,
781
+ Gateway: 14,
782
+ HTTPRoute: 15,
783
+ }
784
+ return priorities[kind] ?? 20
785
+ }
786
+
787
+ function stringData(value: unknown): string {
788
+ return typeof value === 'string' ? value : ''
789
+ }
790
+
791
+ function pluralize(kind: string): string {
792
+ if (kind.endsWith('s')) return kind
793
+ if (kind.endsWith('y')) return `${kind.slice(0, -1)}ies`
794
+ return `${kind}s`
795
+ }
796
+
797
+ function formatAge(timestamp: string): string {
798
+ return formatCompactAge(timestamp)
799
+ }