@skyhook-io/k8s-ui 1.3.0 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/components/dock/BottomDock.tsx +23 -5
- package/src/components/dock/DockContext.tsx +9 -1
- package/src/components/logs/LogsViewer.tsx +8 -3
- package/src/components/logs/WorkloadLogsViewer.tsx +8 -4
- package/src/components/timeline/DiffViewer.tsx +131 -0
- package/src/components/timeline/TimelineList.tsx +817 -0
- package/src/components/timeline/index.ts +2 -0
- package/src/components/topology/GroupNode.tsx +189 -79
- package/src/components/topology/TopologyGraph.tsx +286 -52
- package/src/components/topology/layout.ts +288 -92
- package/src/components/topology/topology.css +111 -0
- package/src/components/ui/Toast.tsx +74 -27
- package/src/theme/variables.css +7 -2
- package/src/types/core.ts +39 -0
- package/src/utils/download.ts +18 -3
- package/tsconfig.json +0 -1
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
import type { Node } from '@xyflow/react'
|
|
2
|
-
import type { TopologyNode, GroupingMode, NodeKind } from '../../types'
|
|
2
|
+
import type { TopologyNode, GroupingMode, NodeKind, HealthStatus } from '../../types'
|
|
3
3
|
import { NODE_DIMENSIONS } from './K8sResourceNode'
|
|
4
4
|
|
|
5
|
+
// --- 3-level display system types ---
|
|
6
|
+
export type GroupDisplayLevel = 'chip' | 'cardGrid' | 'topology'
|
|
7
|
+
|
|
8
|
+
export interface WorkloadCard {
|
|
9
|
+
id: string // Node ID of primary resource (for click handling)
|
|
10
|
+
kind: NodeKind // Kind of primary resource (Deployment, StatefulSet, etc.)
|
|
11
|
+
name: string // Name of primary resource
|
|
12
|
+
status: HealthStatus // Worst health status in the connected subgraph
|
|
13
|
+
subtitle: string // Status info (e.g., "3/3 ready", "ClusterIP", "*/5 * * * *")
|
|
14
|
+
resourceCount: number // Total connected resources in this workload
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Card grid layout constants — must match CSS in GroupNode.tsx cardGrid rendering
|
|
18
|
+
const GRID_CARD_W = 200
|
|
19
|
+
const GRID_CARD_H = 68
|
|
20
|
+
const GRID_GAP = 6
|
|
21
|
+
const GRID_PAD = { top: 48, left: 14, bottom: 14, right: 14 }
|
|
22
|
+
|
|
5
23
|
// Group padding - space for header + internal spacing (must account for border)
|
|
6
24
|
// Top padding accommodates the header at its largest (when zoomed out)
|
|
7
25
|
// Child nodes are translated up dynamically when zoomed in (see TopologyGraph)
|
|
@@ -190,7 +208,7 @@ async function runLayoutOnMainThread(
|
|
|
190
208
|
groupLayouts.push({
|
|
191
209
|
groupId: child.id, groupKey,
|
|
192
210
|
width: Math.max(child.width || 280, minWidth),
|
|
193
|
-
height: child.height ||
|
|
211
|
+
height: child.height || 82,
|
|
194
212
|
children: [], isCollapsed: true,
|
|
195
213
|
})
|
|
196
214
|
} else {
|
|
@@ -399,61 +417,11 @@ function propagateAppLabels(
|
|
|
399
417
|
return nodeGroupLabels
|
|
400
418
|
}
|
|
401
419
|
|
|
402
|
-
// Pick a representative name for a connected component group
|
|
420
|
+
// Pick a representative name for a connected component group (uses shared KIND_PRIORITY)
|
|
403
421
|
function pickGroupName(nodes: TopologyNode[]): string {
|
|
404
|
-
// Priority order for picking the group name
|
|
405
|
-
const kindPriority: Record<string, number> = {
|
|
406
|
-
'Deployment': 1,
|
|
407
|
-
'Rollout': 1,
|
|
408
|
-
'StatefulSet': 2,
|
|
409
|
-
'DaemonSet': 3,
|
|
410
|
-
'CronJob': 4,
|
|
411
|
-
'Job': 5,
|
|
412
|
-
'Service': 6,
|
|
413
|
-
'Gateway': 7,
|
|
414
|
-
'HTTPRoute': 6,
|
|
415
|
-
'GRPCRoute': 6,
|
|
416
|
-
'TCPRoute': 6,
|
|
417
|
-
'TLSRoute': 6,
|
|
418
|
-
'Ingress': 7,
|
|
419
|
-
'ReplicaSet': 8,
|
|
420
|
-
'Pod': 9,
|
|
421
|
-
'PodGroup': 9,
|
|
422
|
-
'ConfigMap': 10,
|
|
423
|
-
'Secret': 10,
|
|
424
|
-
'PersistentVolumeClaim': 10,
|
|
425
|
-
'HorizontalPodAutoscaler': 10,
|
|
426
|
-
'KnativeService': 1,
|
|
427
|
-
'KnativeConfiguration': 3,
|
|
428
|
-
'KnativeRevision': 4,
|
|
429
|
-
'KnativeRoute': 2,
|
|
430
|
-
'Broker': 2,
|
|
431
|
-
'Channel': 2,
|
|
432
|
-
'Trigger': 3,
|
|
433
|
-
'PingSource': 3,
|
|
434
|
-
'ApiServerSource': 3,
|
|
435
|
-
'ContainerSource': 3,
|
|
436
|
-
'SinkBinding': 3,
|
|
437
|
-
'IngressRoute': 1,
|
|
438
|
-
'IngressRouteTCP': 1,
|
|
439
|
-
'IngressRouteUDP': 1,
|
|
440
|
-
'TraefikService': 2,
|
|
441
|
-
'Middleware': 3,
|
|
442
|
-
'MiddlewareTCP': 3,
|
|
443
|
-
'ServersTransport': 4,
|
|
444
|
-
'ServersTransportTCP': 4,
|
|
445
|
-
'TLSOption': 4,
|
|
446
|
-
'TLSStore': 4,
|
|
447
|
-
'HTTPProxy': 1, // Contour
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// Sort by priority and pick the first
|
|
451
422
|
const sorted = [...nodes].sort((a, b) => {
|
|
452
|
-
|
|
453
|
-
const priorityB = kindPriority[b.kind] || 99
|
|
454
|
-
return priorityA - priorityB
|
|
423
|
+
return (KIND_PRIORITY[a.kind] || 99) - (KIND_PRIORITY[b.kind] || 99)
|
|
455
424
|
})
|
|
456
|
-
|
|
457
425
|
return sorted[0].name
|
|
458
426
|
}
|
|
459
427
|
|
|
@@ -462,7 +430,8 @@ export function buildHierarchicalElkGraph(
|
|
|
462
430
|
topologyNodes: TopologyNode[],
|
|
463
431
|
edges: Array<{ id: string; source: string; target: string; type: string }>,
|
|
464
432
|
groupingMode: GroupingMode,
|
|
465
|
-
collapsedGroups: Set<string
|
|
433
|
+
collapsedGroups: Set<string>,
|
|
434
|
+
groupLevels?: Map<string, GroupDisplayLevel>
|
|
466
435
|
): { elkGraph: ElkGraph; groupMap: Map<string, string[]>; nodeToGroup: Map<string, string> } {
|
|
467
436
|
const groupMap = new Map<string, string[]>()
|
|
468
437
|
const nodeToGroup = new Map<string, string>()
|
|
@@ -508,25 +477,67 @@ export function buildHierarchicalElkGraph(
|
|
|
508
477
|
})
|
|
509
478
|
}
|
|
510
479
|
} else {
|
|
480
|
+
// Build node lookup once for O(1) access in dimension/card computation
|
|
481
|
+
const nodeMapForGroups = new Map(topologyNodes.map(n => [n.id, n]))
|
|
511
482
|
// Create group nodes with children
|
|
512
483
|
for (const [groupKey, memberIds] of groupMap) {
|
|
513
484
|
const groupId = `group-${groupingMode}-${groupKey}`
|
|
514
|
-
|
|
485
|
+
// When groupLevels has entries for the CURRENT grouping mode, any group without
|
|
486
|
+
// an explicit 'topology' level is collapsed. This prevents late-arriving namespaces
|
|
487
|
+
// from defaulting to expanded and overlapping with collapsed chips.
|
|
488
|
+
// Only apply when levels exist for the same grouping prefix — don't let namespace
|
|
489
|
+
// levels leak into app/label grouping contexts.
|
|
490
|
+
const groupPrefix = `group-${groupingMode}-`
|
|
491
|
+
const hasLevelsForCurrentMode = groupLevels && groupLevels.size > 0 &&
|
|
492
|
+
[...groupLevels.keys()].some(k => k.startsWith(groupPrefix))
|
|
493
|
+
const isCollapsed = collapsedGroups.has(groupId) ||
|
|
494
|
+
(hasLevelsForCurrentMode && groupLevels!.get(groupId) !== 'topology')
|
|
515
495
|
|
|
516
496
|
if (isCollapsed) {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
height
|
|
523
|
-
|
|
524
|
-
|
|
497
|
+
const displayLevel = groupLevels?.get(groupId) || 'chip'
|
|
498
|
+
|
|
499
|
+
if (displayLevel === 'cardGrid') {
|
|
500
|
+
// Card grid: compute dimensions from workload card count
|
|
501
|
+
const cards = computeWorkloadCards(memberIds, edges, nodeMapForGroups)
|
|
502
|
+
const { width, height } = computeGridDimensions(cards.length, groupKey)
|
|
503
|
+
children.push({
|
|
504
|
+
id: groupId,
|
|
505
|
+
width,
|
|
506
|
+
height,
|
|
507
|
+
labels: [{ text: groupKey }],
|
|
508
|
+
})
|
|
509
|
+
} else {
|
|
510
|
+
// Chip size scales with resource count (log10 tiers)
|
|
511
|
+
const count = memberIds.length
|
|
512
|
+
const tier = count === 0 ? 0 : Math.min(3, Math.floor(Math.log10(count)))
|
|
513
|
+
const tierWidthBonus = [0, 120, 300, 500][tier]
|
|
514
|
+
const collapsedWidth = Math.max(240 + tierWidthBonus, groupKey.length * 16 + 160)
|
|
515
|
+
|
|
516
|
+
// Count unique kinds for pill row height calculation
|
|
517
|
+
const uniqueKinds = new Set<string>()
|
|
518
|
+
for (const nId of memberIds) {
|
|
519
|
+
const n = nodeMapForGroups.get(nId)
|
|
520
|
+
if (n && n.kind !== 'PodGroup') uniqueKinds.add(n.kind)
|
|
521
|
+
}
|
|
522
|
+
const maxPills = [2, 5, 8, 12][tier]
|
|
523
|
+
const pillCount = Math.min(maxPills, uniqueKinds.size)
|
|
524
|
+
const pillWidth = 90
|
|
525
|
+
const pillCols = Math.max(1, Math.floor((collapsedWidth - 24) / (pillWidth + 6)))
|
|
526
|
+
const pillRows = pillCount > 0 ? Math.ceil(pillCount / pillCols) : 0
|
|
527
|
+
const headerHeight = [20, 28, 40, 56][tier]
|
|
528
|
+
const tierHeight = 20 + headerHeight + (pillRows > 0 ? 8 + pillRows * 24 : 0)
|
|
529
|
+
children.push({
|
|
530
|
+
id: groupId,
|
|
531
|
+
width: collapsedWidth,
|
|
532
|
+
height: tierHeight,
|
|
533
|
+
labels: [{ text: groupKey }],
|
|
534
|
+
})
|
|
535
|
+
}
|
|
525
536
|
} else {
|
|
526
537
|
// Expanded group contains its children
|
|
527
538
|
const groupChildren: ElkNode[] = []
|
|
528
539
|
for (const nodeId of memberIds) {
|
|
529
|
-
const node =
|
|
540
|
+
const node = nodeMapForGroups.get(nodeId)
|
|
530
541
|
if (node) {
|
|
531
542
|
const kind = node.kind as NodeKind
|
|
532
543
|
const dims = NODE_DIMENSIONS[kind] || { width: 200, height: 56 }
|
|
@@ -624,16 +635,164 @@ export function buildHierarchicalElkGraph(
|
|
|
624
635
|
}
|
|
625
636
|
}
|
|
626
637
|
|
|
638
|
+
// Lower number = higher severity
|
|
639
|
+
const HEALTH_PRIORITY: Record<HealthStatus, number> = { unhealthy: 0, degraded: 1, unknown: 2, healthy: 3 }
|
|
640
|
+
|
|
641
|
+
function computeGroupHealth(memberIds: string[], nodeMap: Map<string, TopologyNode>): { worstStatus: HealthStatus; unhealthyCount: number } {
|
|
642
|
+
let worstPriority = 3
|
|
643
|
+
let worstStatus: HealthStatus = 'healthy'
|
|
644
|
+
let unhealthyCount = 0
|
|
645
|
+
for (const id of memberIds) {
|
|
646
|
+
const node = nodeMap.get(id)
|
|
647
|
+
if (!node) continue
|
|
648
|
+
const priority = HEALTH_PRIORITY[node.status] ?? 2
|
|
649
|
+
if (priority < worstPriority) {
|
|
650
|
+
worstPriority = priority
|
|
651
|
+
worstStatus = node.status
|
|
652
|
+
}
|
|
653
|
+
if (node.status === 'unhealthy' || node.status === 'degraded') {
|
|
654
|
+
unhealthyCount++
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return { worstStatus, unhealthyCount }
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Compute workload cards for a card-grid group by finding connected subgraphs.
|
|
661
|
+
// Each connected component becomes one card, named after its highest-priority resource.
|
|
662
|
+
function computeWorkloadCards(
|
|
663
|
+
memberIds: string[],
|
|
664
|
+
edges: Array<{ id: string; source: string; target: string; type: string }>,
|
|
665
|
+
nodeMap: Map<string, TopologyNode>
|
|
666
|
+
): WorkloadCard[] {
|
|
667
|
+
const memberSet = new Set(memberIds)
|
|
668
|
+
|
|
669
|
+
// Build adjacency list restricted to this group's members
|
|
670
|
+
const adj = new Map<string, Set<string>>()
|
|
671
|
+
for (const id of memberIds) adj.set(id, new Set())
|
|
672
|
+
for (const edge of edges) {
|
|
673
|
+
if (memberSet.has(edge.source) && memberSet.has(edge.target)) {
|
|
674
|
+
adj.get(edge.source)?.add(edge.target)
|
|
675
|
+
adj.get(edge.target)?.add(edge.source)
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// BFS to find connected components
|
|
680
|
+
const visited = new Set<string>()
|
|
681
|
+
const components: TopologyNode[][] = []
|
|
682
|
+
for (const id of memberIds) {
|
|
683
|
+
if (visited.has(id)) continue
|
|
684
|
+
const component: TopologyNode[] = []
|
|
685
|
+
const queue = [id]
|
|
686
|
+
visited.add(id)
|
|
687
|
+
while (queue.length > 0) {
|
|
688
|
+
const nodeId = queue.shift()!
|
|
689
|
+
const node = nodeMap.get(nodeId)
|
|
690
|
+
if (node) component.push(node)
|
|
691
|
+
for (const neighbor of adj.get(nodeId) || []) {
|
|
692
|
+
if (!visited.has(neighbor)) {
|
|
693
|
+
visited.add(neighbor)
|
|
694
|
+
queue.push(neighbor)
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if (component.length > 0) components.push(component)
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Convert each component to a workload card
|
|
702
|
+
const cards: WorkloadCard[] = components.map(comp => {
|
|
703
|
+
// Pick primary resource using kindPriority (reuse the same map from pickGroupName)
|
|
704
|
+
const sorted = [...comp].sort((a, b) => {
|
|
705
|
+
const pa = KIND_PRIORITY[a.kind] || 99
|
|
706
|
+
const pb = KIND_PRIORITY[b.kind] || 99
|
|
707
|
+
return pa - pb
|
|
708
|
+
})
|
|
709
|
+
const primary = sorted[0]
|
|
710
|
+
|
|
711
|
+
// Compute worst health
|
|
712
|
+
let worstPriority = 3
|
|
713
|
+
let worstStatus: HealthStatus = 'healthy'
|
|
714
|
+
for (const node of comp) {
|
|
715
|
+
const p = HEALTH_PRIORITY[node.status] ?? 2
|
|
716
|
+
if (p < worstPriority) { worstPriority = p; worstStatus = node.status }
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Extract subtitle from primary resource's data
|
|
720
|
+
const d = primary.data as Record<string, unknown>
|
|
721
|
+
let subtitle = ''
|
|
722
|
+
if (['Deployment', 'StatefulSet', 'DaemonSet', 'Rollout', 'ReplicaSet'].includes(primary.kind)) {
|
|
723
|
+
subtitle = (d.statusSummary as string) || `${d.readyReplicas ?? '?'}/${d.totalReplicas ?? '?'} ready`
|
|
724
|
+
} else if (primary.kind === 'Service') {
|
|
725
|
+
subtitle = (d.type as string) || 'ClusterIP'
|
|
726
|
+
} else if (primary.kind === 'CronJob') {
|
|
727
|
+
subtitle = (d.schedule as string) || ''
|
|
728
|
+
} else if (primary.kind === 'Job') {
|
|
729
|
+
subtitle = (d.phase as string) || ''
|
|
730
|
+
} else if (primary.kind === 'Application') {
|
|
731
|
+
const sync = d.syncStatus as string
|
|
732
|
+
const health = d.healthStatus as string
|
|
733
|
+
subtitle = [sync, health].filter(Boolean).join(' · ')
|
|
734
|
+
} else if (primary.kind === 'KnativeService' || primary.kind === 'Kustomization' || primary.kind === 'HelmRelease') {
|
|
735
|
+
subtitle = d.ready ? 'Ready' : 'Not Ready'
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
return {
|
|
739
|
+
id: primary.id,
|
|
740
|
+
kind: primary.kind,
|
|
741
|
+
name: primary.name,
|
|
742
|
+
status: worstStatus,
|
|
743
|
+
subtitle,
|
|
744
|
+
resourceCount: comp.length,
|
|
745
|
+
}
|
|
746
|
+
})
|
|
747
|
+
|
|
748
|
+
// Sort by resource count descending (biggest workloads first)
|
|
749
|
+
cards.sort((a, b) => b.resourceCount - a.resourceCount)
|
|
750
|
+
return cards
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Shared kind priority map — used by both pickGroupName and computeWorkloadCards
|
|
754
|
+
const KIND_PRIORITY: Record<string, number> = {
|
|
755
|
+
'Deployment': 1, 'Rollout': 1, 'StatefulSet': 2, 'DaemonSet': 3,
|
|
756
|
+
'CronJob': 4, 'Job': 5, 'Service': 6, 'Gateway': 7,
|
|
757
|
+
'HTTPRoute': 6, 'GRPCRoute': 6, 'TCPRoute': 6, 'TLSRoute': 6, 'Ingress': 7,
|
|
758
|
+
'ReplicaSet': 8, 'Pod': 9, 'PodGroup': 9,
|
|
759
|
+
'ConfigMap': 10, 'Secret': 10, 'PersistentVolumeClaim': 10, 'HorizontalPodAutoscaler': 10,
|
|
760
|
+
'KnativeService': 1, 'KnativeConfiguration': 3, 'KnativeRevision': 4, 'KnativeRoute': 2,
|
|
761
|
+
'Broker': 2, 'Channel': 2, 'Trigger': 3, 'PingSource': 3, 'ApiServerSource': 3,
|
|
762
|
+
'ContainerSource': 3, 'SinkBinding': 3,
|
|
763
|
+
'IngressRoute': 1, 'IngressRouteTCP': 1, 'IngressRouteUDP': 1, 'TraefikService': 2,
|
|
764
|
+
'Middleware': 3, 'MiddlewareTCP': 3, 'ServersTransport': 4, 'ServersTransportTCP': 4,
|
|
765
|
+
'TLSOption': 4, 'TLSStore': 4, 'HTTPProxy': 1,
|
|
766
|
+
'Application': 1, 'Kustomization': 1, 'HelmRelease': 1, 'GitRepository': 2,
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Compute grid dimensions from workload card count
|
|
770
|
+
function computeGridDimensions(cardCount: number, groupKey: string): { width: number; height: number; columns: number } {
|
|
771
|
+
if (cardCount === 0) return { width: 400, height: 130, columns: 1 }
|
|
772
|
+
const columns = Math.min(4, cardCount)
|
|
773
|
+
const rows = Math.ceil(cardCount / columns)
|
|
774
|
+
const minWidth = Math.max(400, groupKey.length * 16 + 180)
|
|
775
|
+
const width = Math.max(minWidth, columns * (GRID_CARD_W + GRID_GAP) - GRID_GAP + GRID_PAD.left + GRID_PAD.right)
|
|
776
|
+
const height = GRID_PAD.top + rows * (GRID_CARD_H + GRID_GAP) - GRID_GAP + GRID_PAD.bottom
|
|
777
|
+
return { width, height, columns }
|
|
778
|
+
}
|
|
779
|
+
|
|
627
780
|
// Two-phase layout: first layout groups internally, then position groups based on connections
|
|
628
781
|
// Layout is performed in a Web Worker to avoid blocking the main thread
|
|
629
782
|
export async function applyHierarchicalLayout(
|
|
630
783
|
elkGraph: ElkGraph,
|
|
631
784
|
topologyNodes: TopologyNode[],
|
|
785
|
+
topologyEdges: Array<{ id: string; source: string; target: string; type: string }>,
|
|
632
786
|
groupMap: Map<string, string[]>,
|
|
633
787
|
groupingMode: GroupingMode,
|
|
634
788
|
_collapsedGroups: Set<string>,
|
|
635
|
-
|
|
636
|
-
|
|
789
|
+
callbacks: {
|
|
790
|
+
onSetLevel: (groupId: string, level: GroupDisplayLevel) => void
|
|
791
|
+
onCardClick: (nodeId: string) => void
|
|
792
|
+
onMaximizeNamespace?: (namespace: string) => void
|
|
793
|
+
},
|
|
794
|
+
hideGroupHeader: boolean = false,
|
|
795
|
+
groupLevels?: Map<string, GroupDisplayLevel>
|
|
637
796
|
): Promise<{ nodes: Node[]; positions: Map<string, { x: number; y: number }>; error?: string }> {
|
|
638
797
|
try {
|
|
639
798
|
const padding = hideGroupHeader ? GROUP_PADDING_NO_HEADER : GROUP_PADDING
|
|
@@ -651,6 +810,8 @@ export async function applyHierarchicalLayout(
|
|
|
651
810
|
// Build ReactFlow nodes using positions from worker
|
|
652
811
|
const nodes: Node[] = []
|
|
653
812
|
const positions = new Map<string, { x: number; y: number }>()
|
|
813
|
+
// Build node lookup once for O(1) health aggregation across all groups
|
|
814
|
+
const nodeMap = new Map(topologyNodes.map(n => [n.id, n]))
|
|
654
815
|
|
|
655
816
|
for (const group of workerResult.groupLayouts) {
|
|
656
817
|
const pos = groupPositions.get(group.groupId) || { x: 0, y: 0 }
|
|
@@ -658,29 +819,65 @@ export async function applyHierarchicalLayout(
|
|
|
658
819
|
|
|
659
820
|
positions.set(group.groupId, pos)
|
|
660
821
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
822
|
+
const { worstStatus, unhealthyCount } = computeGroupHealth(memberIds, nodeMap)
|
|
823
|
+
// Default to 'chip' when groupLevels has entries for the current grouping mode,
|
|
824
|
+
// so late-arriving namespaces don't render as expanded topology and overlap with chips.
|
|
825
|
+
const hasLevelsForMode = groupLevels && groupLevels.size > 0 &&
|
|
826
|
+
[...groupLevels.keys()].some(k => k.startsWith(`group-${groupingMode}-`))
|
|
827
|
+
const defaultLevel: GroupDisplayLevel = hasLevelsForMode ? 'chip' : 'topology'
|
|
828
|
+
const displayLevel: GroupDisplayLevel = groupLevels?.get(group.groupId) || (group.isCollapsed ? 'chip' : defaultLevel)
|
|
829
|
+
|
|
830
|
+
// Compute kind breakdown for collapsed chips
|
|
831
|
+
const kindCounts: Record<string, number> = {}
|
|
832
|
+
for (const id of memberIds) {
|
|
833
|
+
const node = nodeMap.get(id)
|
|
834
|
+
if (!node || node.kind === 'PodGroup') continue
|
|
835
|
+
kindCounts[node.kind] = (kindCounts[node.kind] || 0) + 1
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Compute workload cards for card-grid groups
|
|
839
|
+
const workloadCards = displayLevel === 'cardGrid'
|
|
840
|
+
? computeWorkloadCards(memberIds, topologyEdges, nodeMap)
|
|
841
|
+
: undefined
|
|
842
|
+
const gridColumns = workloadCards ? Math.min(4, workloadCards.length || 1) : undefined
|
|
843
|
+
|
|
844
|
+
// Skip the group container node entirely in single-namespace topology view —
|
|
845
|
+
// it creates an invisible bounding box that constrains child node movement
|
|
846
|
+
const skipGroupNode = hideGroupHeader && displayLevel === 'topology'
|
|
847
|
+
|
|
848
|
+
if (!skipGroupNode) {
|
|
849
|
+
nodes.push({
|
|
850
|
+
id: group.groupId,
|
|
851
|
+
type: 'group',
|
|
852
|
+
position: pos,
|
|
853
|
+
data: {
|
|
854
|
+
type: groupingMode,
|
|
855
|
+
name: group.groupKey,
|
|
856
|
+
nodeCount: memberIds.length,
|
|
857
|
+
collapsed: group.isCollapsed,
|
|
858
|
+
displayLevel,
|
|
859
|
+
onSetLevel: callbacks.onSetLevel,
|
|
860
|
+
onCardClick: callbacks.onCardClick,
|
|
861
|
+
onMaximizeNamespace: callbacks.onMaximizeNamespace,
|
|
862
|
+
hideHeader: hideGroupHeader,
|
|
863
|
+
worstStatus,
|
|
864
|
+
unhealthyCount,
|
|
865
|
+
kindCounts,
|
|
866
|
+
workloadCards,
|
|
867
|
+
gridColumns,
|
|
868
|
+
},
|
|
869
|
+
style: {
|
|
870
|
+
width: group.width,
|
|
871
|
+
height: group.height,
|
|
872
|
+
},
|
|
873
|
+
zIndex: -1,
|
|
874
|
+
})
|
|
875
|
+
}
|
|
680
876
|
|
|
681
|
-
// Add child nodes
|
|
877
|
+
// Add child nodes — use absolute positions (no parent) when header is hidden
|
|
878
|
+
// (single namespace view), otherwise relative positions inside the group container
|
|
682
879
|
for (const child of group.children) {
|
|
683
|
-
const topoNode =
|
|
880
|
+
const topoNode = nodeMap.get(child.id)
|
|
684
881
|
if (topoNode) {
|
|
685
882
|
const absX = pos.x + child.x
|
|
686
883
|
const absY = pos.y + child.y
|
|
@@ -689,9 +886,8 @@ export async function applyHierarchicalLayout(
|
|
|
689
886
|
nodes.push({
|
|
690
887
|
id: child.id,
|
|
691
888
|
type: 'k8sResource',
|
|
692
|
-
position: { x: child.x, y: child.y },
|
|
693
|
-
parentId: group.groupId,
|
|
694
|
-
extent: 'parent',
|
|
889
|
+
position: hideGroupHeader ? { x: absX, y: absY } : { x: child.x, y: child.y },
|
|
890
|
+
...(hideGroupHeader ? {} : { parentId: group.groupId, extent: 'parent' as const }),
|
|
695
891
|
data: {
|
|
696
892
|
kind: topoNode.kind,
|
|
697
893
|
name: topoNode.name,
|
|
@@ -707,7 +903,7 @@ export async function applyHierarchicalLayout(
|
|
|
707
903
|
// Add ungrouped nodes
|
|
708
904
|
for (const node of workerResult.ungroupedNodes) {
|
|
709
905
|
const pos = groupPositions.get(node.id) || { x: 0, y: 0 }
|
|
710
|
-
const topoNode =
|
|
906
|
+
const topoNode = nodeMap.get(node.id)
|
|
711
907
|
if (topoNode) {
|
|
712
908
|
positions.set(node.id, pos)
|
|
713
909
|
|
|
@@ -121,6 +121,117 @@
|
|
|
121
121
|
top: var(--group-header-offset, 0px);
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/* ── Workload card grid ── */
|
|
125
|
+
|
|
126
|
+
/* Individual workload card in the card-grid namespace view.
|
|
127
|
+
Mirrors K8sResourceNode's left-bar status pattern. */
|
|
128
|
+
.grid-card {
|
|
129
|
+
position: relative;
|
|
130
|
+
display: flex;
|
|
131
|
+
flex-direction: column;
|
|
132
|
+
justify-content: center;
|
|
133
|
+
padding: 8px 10px 8px 14px;
|
|
134
|
+
border-radius: 8px;
|
|
135
|
+
background: var(--bg-surface);
|
|
136
|
+
border: 0.5px solid var(--topology-node-border, var(--border-default));
|
|
137
|
+
box-shadow: var(--topology-node-shadow, none);
|
|
138
|
+
cursor: pointer;
|
|
139
|
+
min-width: 0;
|
|
140
|
+
overflow: hidden;
|
|
141
|
+
transition: filter 0.15s, box-shadow 0.15s;
|
|
142
|
+
}
|
|
143
|
+
.grid-card:hover {
|
|
144
|
+
filter: brightness(1.08);
|
|
145
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* Left status bar (matches topology-node-status-bar pattern) */
|
|
149
|
+
.grid-card::before {
|
|
150
|
+
content: '';
|
|
151
|
+
position: absolute;
|
|
152
|
+
left: 0;
|
|
153
|
+
top: 0;
|
|
154
|
+
bottom: 0;
|
|
155
|
+
width: 3px;
|
|
156
|
+
border-radius: 3px 0 0 3px;
|
|
157
|
+
background-color: rgb(34 197 94); /* green-500 — healthy default */
|
|
158
|
+
}
|
|
159
|
+
.grid-card-degraded {
|
|
160
|
+
border-color: rgb(234 179 8 / 0.5);
|
|
161
|
+
background: light-dark(rgb(254 252 232 / 0.5), rgb(251 146 60 / 0.08));
|
|
162
|
+
}
|
|
163
|
+
.grid-card-degraded::before {
|
|
164
|
+
background-color: rgb(245 158 11); /* amber-500 */
|
|
165
|
+
}
|
|
166
|
+
.grid-card-unhealthy {
|
|
167
|
+
border-color: rgb(239 68 68 / 0.5);
|
|
168
|
+
background: light-dark(rgb(254 242 242 / 0.6), rgb(248 113 113 / 0.1));
|
|
169
|
+
}
|
|
170
|
+
.grid-card-unhealthy::before {
|
|
171
|
+
background-color: rgb(239 68 68); /* red-500 */
|
|
172
|
+
}
|
|
173
|
+
.grid-card-unknown::before {
|
|
174
|
+
background-color: rgb(100 116 139); /* slate-500 */
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* Card header row: icon + kind + resource count */
|
|
178
|
+
.grid-card-header {
|
|
179
|
+
display: flex;
|
|
180
|
+
align-items: center;
|
|
181
|
+
gap: 5px;
|
|
182
|
+
margin-bottom: 2px;
|
|
183
|
+
}
|
|
184
|
+
.grid-card-kind {
|
|
185
|
+
font-size: 10px;
|
|
186
|
+
text-transform: uppercase;
|
|
187
|
+
letter-spacing: 0.05em;
|
|
188
|
+
font-weight: 500;
|
|
189
|
+
opacity: 0.55;
|
|
190
|
+
flex: 1;
|
|
191
|
+
min-width: 0;
|
|
192
|
+
overflow: hidden;
|
|
193
|
+
text-overflow: ellipsis;
|
|
194
|
+
white-space: nowrap;
|
|
195
|
+
}
|
|
196
|
+
.grid-card-count {
|
|
197
|
+
font-size: 10px;
|
|
198
|
+
opacity: 0.4;
|
|
199
|
+
font-variant-numeric: tabular-nums;
|
|
200
|
+
flex-shrink: 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/* Card name — primary visual element */
|
|
204
|
+
.grid-card-name {
|
|
205
|
+
font-size: 12px;
|
|
206
|
+
font-weight: 600;
|
|
207
|
+
line-height: 1.3;
|
|
208
|
+
overflow: hidden;
|
|
209
|
+
text-overflow: ellipsis;
|
|
210
|
+
white-space: nowrap;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* Card subtitle — status info, prominent when unhealthy */
|
|
214
|
+
.grid-card-subtitle {
|
|
215
|
+
font-size: 10px;
|
|
216
|
+
line-height: 1.3;
|
|
217
|
+
margin-top: 1px;
|
|
218
|
+
opacity: 0.5;
|
|
219
|
+
overflow: hidden;
|
|
220
|
+
text-overflow: ellipsis;
|
|
221
|
+
white-space: nowrap;
|
|
222
|
+
}
|
|
223
|
+
.grid-card-degraded .grid-card-subtitle,
|
|
224
|
+
.grid-card-unhealthy .grid-card-subtitle {
|
|
225
|
+
opacity: 0.85;
|
|
226
|
+
font-weight: 500;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* Dividers between custom control buttons in our separate panel */
|
|
230
|
+
.react-flow__panel.react-flow__controls .react-flow__controls-button + .react-flow__controls-button,
|
|
231
|
+
.react-flow__panel.react-flow__controls span + span {
|
|
232
|
+
border-top: 1px solid var(--border-default, rgba(0,0,0,0.08));
|
|
233
|
+
}
|
|
234
|
+
|
|
124
235
|
.react-flow__edge-path {
|
|
125
236
|
stroke-width: 2;
|
|
126
237
|
}
|