@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.
@@ -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 || 90,
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
- const priorityA = kindPriority[a.kind] || 99
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
- const isCollapsed = collapsedGroups.has(groupId)
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
- // Collapsed group is a single node - width based on label length
518
- const collapsedWidth = Math.max(400, groupKey.length * 16 + 180)
519
- children.push({
520
- id: groupId,
521
- width: collapsedWidth,
522
- height: 90,
523
- labels: [{ text: groupKey }],
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 = topologyNodes.find(n => n.id === nodeId)
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
- onToggleCollapse: (groupId: string) => void,
636
- hideGroupHeader: boolean = false
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
- // Add group node
662
- nodes.push({
663
- id: group.groupId,
664
- type: 'group',
665
- position: pos,
666
- data: {
667
- type: groupingMode,
668
- name: group.groupKey,
669
- nodeCount: memberIds.length,
670
- collapsed: group.isCollapsed,
671
- onToggleCollapse,
672
- hideHeader: hideGroupHeader,
673
- },
674
- style: {
675
- width: group.width,
676
- height: group.height,
677
- },
678
- zIndex: -1,
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 with positions relative to group
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 = topologyNodes.find(n => n.id === child.id)
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 = topologyNodes.find(n => n.id === node.id)
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
  }