@skyhook-io/k8s-ui 1.8.9 → 1.8.11

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 (130) hide show
  1. package/package.json +5 -4
  2. package/src/assets/gitops/argocd.png +0 -0
  3. package/src/assets/gitops/flux.svg +7 -0
  4. package/src/components/applications/AppChips.tsx +10 -0
  5. package/src/components/applications/ApplicationDetail.test.tsx +569 -0
  6. package/src/components/applications/ApplicationDetail.tsx +2517 -450
  7. package/src/components/applications/ApplicationsList.tsx +4 -1
  8. package/src/components/applications/ApplicationsView.tsx +101 -18
  9. package/src/components/applications/applications-view.test.tsx +47 -0
  10. package/src/components/applications/index.ts +1 -1
  11. package/src/components/audit/AuditAlerts.tsx +14 -10
  12. package/src/components/checks/ChecksView.tsx +81 -51
  13. package/src/components/checks/severity.ts +39 -45
  14. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  15. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +14 -59
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
  21. package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
  22. package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
  23. package/src/components/gitops/insights/insights-helpers.ts +69 -1
  24. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
  25. package/src/components/issues/IssuesView.tsx +219 -143
  26. package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
  27. package/src/components/issues/ResourceIssuesSection.tsx +28 -128
  28. package/src/components/issues/index.ts +3 -2
  29. package/src/components/issues/issue-timing.ts +53 -0
  30. package/src/components/issues/issues.test.ts +146 -4
  31. package/src/components/issues/severity.ts +29 -32
  32. package/src/components/logs/LogCore.tsx +14 -4
  33. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  34. package/src/components/logs/useLogStream.ts +8 -1
  35. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  36. package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
  37. package/src/components/resources/ResourcesSidebar.tsx +70 -52
  38. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  39. package/src/components/resources/ResourcesView.tsx +243 -53
  40. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  42. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  43. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
  44. package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
  45. package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
  46. package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
  47. package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
  48. package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
  49. package/src/components/resources/renderers/PodRenderer.tsx +81 -70
  50. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  51. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  52. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  53. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  54. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  55. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  56. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  57. package/src/components/resources/renderers/index.ts +1 -0
  58. package/src/components/resources/resource-utils-capi.ts +1 -1
  59. package/src/components/resources/resource-utils.ts +31 -0
  60. package/src/components/shared/DetailShell.tsx +2 -2
  61. package/src/components/shared/ResourceActionsBar.tsx +140 -108
  62. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  63. package/src/components/timeline/TimelineList.tsx +371 -354
  64. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  65. package/src/components/timeline/TimelineStrip.tsx +836 -0
  66. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  67. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  68. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  69. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  70. package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
  71. package/src/components/timeline/index.ts +23 -0
  72. package/src/components/timeline/scrubber-math.test.ts +235 -0
  73. package/src/components/timeline/scrubber-math.ts +249 -0
  74. package/src/components/timeline/shared.test.tsx +219 -0
  75. package/src/components/timeline/shared.tsx +232 -447
  76. package/src/components/timeline/timeline-filters.test.ts +196 -0
  77. package/src/components/timeline/timeline-filters.ts +180 -0
  78. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  79. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  80. package/src/components/timeline/timeline-live.test.ts +131 -0
  81. package/src/components/timeline/timeline-live.ts +77 -0
  82. package/src/components/topology/GroupNode.tsx +9 -2
  83. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  84. package/src/components/topology/K8sResourceNode.tsx +387 -243
  85. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  86. package/src/components/topology/TopologyGraph.tsx +89 -16
  87. package/src/components/topology/TopologySearch.tsx +2 -2
  88. package/src/components/topology/layout.ts +10 -2
  89. package/src/components/topology/topology.css +0 -92
  90. package/src/components/ui/Badge.tsx +5 -0
  91. package/src/components/ui/BoardSkeleton.tsx +47 -0
  92. package/src/components/ui/CardSection.tsx +117 -0
  93. package/src/components/ui/CodeViewer.tsx +2 -2
  94. package/src/components/ui/Collapse.tsx +59 -0
  95. package/src/components/ui/ConfirmDialog.tsx +7 -3
  96. package/src/components/ui/DialogPortal.tsx +31 -9
  97. package/src/components/ui/Input.tsx +24 -0
  98. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  99. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  100. package/src/components/ui/PaneLoader.tsx +10 -2
  101. package/src/components/ui/ResourceBar.tsx +68 -25
  102. package/src/components/ui/SearchBox.tsx +18 -4
  103. package/src/components/ui/SearchPillInput.tsx +2 -2
  104. package/src/components/ui/Toast.tsx +1 -1
  105. package/src/components/ui/drawer-components.tsx +39 -26
  106. package/src/components/ui/index.ts +6 -0
  107. package/src/components/ui/severity-tone.ts +63 -0
  108. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  109. package/src/components/workload/WorkloadView.tsx +2682 -472
  110. package/src/components/workload/index.ts +1 -1
  111. package/src/theme/variables.css +15 -1
  112. package/src/types/core.ts +57 -4
  113. package/src/utils/api-resources.test.ts +54 -0
  114. package/src/utils/api-resources.ts +68 -1
  115. package/src/utils/application-topology.test.ts +198 -0
  116. package/src/utils/application-topology.ts +261 -0
  117. package/src/utils/applications.test.ts +167 -1
  118. package/src/utils/applications.ts +1157 -393
  119. package/src/utils/index.ts +2 -0
  120. package/src/utils/navigation.test.ts +32 -1
  121. package/src/utils/navigation.ts +70 -0
  122. package/src/utils/resource-hierarchy.test.ts +990 -1
  123. package/src/utils/resource-hierarchy.ts +905 -267
  124. package/src/utils/resource-icons.ts +2 -0
  125. package/src/utils/topology-neighborhood.test.ts +202 -1
  126. package/src/utils/topology-neighborhood.ts +194 -13
  127. package/src/utils/workflow-execution.test.ts +556 -0
  128. package/src/utils/workflow-execution.ts +814 -0
  129. package/src/utils/workload-colors.ts +2 -3
  130. package/tsconfig.json +2 -2
@@ -1,8 +1,10 @@
1
- import { memo } from 'react'
2
- import { Handle, Position } from '@xyflow/react'
1
+ import { memo } from "react";
2
+ import { Handle, Position } from "@xyflow/react";
3
3
  import {
4
4
  ChevronDown,
5
5
  ChevronUp,
6
+ Layers,
7
+ Radio,
6
8
  TriangleAlert,
7
9
  } from 'lucide-react'
8
10
  import { clsx } from 'clsx'
@@ -12,132 +14,166 @@ import { healthToSeverity, SEVERITY_DOT, SEVERITY_TEXT } from '../../utils/badge
12
14
  import { workloadHue } from '../../utils/workload-colors'
13
15
  import { ownershipOf } from '../../utils/topology-neighborhood'
14
16
  import { midTruncate } from '../../utils/format'
17
+ import { getTopologyIcon } from '../../utils/resource-icons'
15
18
  import { Tooltip } from '../ui/Tooltip'
16
19
  import { AuditBadgeTooltip, type AuditBadgeMessage } from '../audit/AuditBadgeTooltip'
20
+ import argoCdLogo from '../../assets/gitops/argocd.png'
21
+ import fluxLogo from '../../assets/gitops/flux.svg'
17
22
 
18
23
  // Get actionable tooltip content for health issues
19
24
  function getIssueTooltip(issue: string | undefined): React.ReactNode {
20
- if (!issue) return null
25
+ if (!issue) return null;
21
26
 
22
- const issueDetails: Record<string, { title: string; description: string; action: string }> = {
27
+ const issueDetails: Record<
28
+ string,
29
+ { title: string; description: string; action: string }
30
+ > = {
23
31
  OOMKilled: {
24
- title: 'Out of Memory (OOMKilled)',
25
- description: 'Container exceeded its memory limit and was killed by the kernel.',
26
- action: 'Increase memory limits or optimize memory usage.',
32
+ title: "Out of Memory (OOMKilled)",
33
+ description:
34
+ "Container exceeded its memory limit and was killed by the kernel.",
35
+ action: "Increase memory limits or optimize memory usage.",
27
36
  },
28
37
  CrashLoopBackOff: {
29
- title: 'CrashLoopBackOff',
30
- description: 'Container is repeatedly crashing and Kubernetes is backing off restarts.',
31
- action: 'Check container logs for crash reason.',
38
+ title: "CrashLoopBackOff",
39
+ description:
40
+ "Container is repeatedly crashing and Kubernetes is backing off restarts.",
41
+ action: "Check container logs for crash reason.",
32
42
  },
33
43
  ImagePullBackOff: {
34
- title: 'ImagePullBackOff',
35
- description: 'Kubernetes cannot pull the container image.',
36
- action: 'Verify image name, tag, and registry credentials.',
44
+ title: "ImagePullBackOff",
45
+ description: "Kubernetes cannot pull the container image.",
46
+ action: "Verify image name, tag, and registry credentials.",
37
47
  },
38
48
  ErrImagePull: {
39
- title: 'Image Pull Error',
40
- description: 'Failed to pull the container image.',
41
- action: 'Check image name and registry access.',
49
+ title: "Image Pull Error",
50
+ description: "Failed to pull the container image.",
51
+ action: "Check image name and registry access.",
42
52
  },
43
53
  CreateContainerConfigError: {
44
- title: 'Container Config Error',
45
- description: 'Invalid container configuration (e.g., missing ConfigMap/Secret).',
46
- action: 'Verify referenced ConfigMaps and Secrets exist.',
54
+ title: "Container Config Error",
55
+ description:
56
+ "Invalid container configuration (e.g., missing ConfigMap/Secret).",
57
+ action: "Verify referenced ConfigMaps and Secrets exist.",
47
58
  },
48
59
  Pending: {
49
- title: 'Pending',
50
- description: 'Pod is waiting to be scheduled to a node.',
51
- action: 'Open the pod to see the scheduler verdict (taints, resources, affinity).',
60
+ title: "Pending",
61
+ description: "Pod is waiting to be scheduled to a node.",
62
+ action:
63
+ "Open the pod to see the scheduler verdict (taints, resources, affinity).",
52
64
  },
53
65
  FailedScheduling: {
54
- title: 'Scheduling Failed',
55
- description: 'No suitable node found for this pod.',
56
- action: 'Check node resources, taints, tolerations, and affinity rules.',
66
+ title: "Scheduling Failed",
67
+ description: "No suitable node found for this pod.",
68
+ action: "Check node resources, taints, tolerations, and affinity rules.",
57
69
  },
58
70
  Unschedulable: {
59
- title: 'Unschedulable',
60
- description: 'The scheduler tried every node and none fit.',
61
- action: 'Open the pod for the decomposed reason — arch/OS mismatch, untolerated taint, insufficient resources, or affinity.',
71
+ title: "Unschedulable",
72
+ description: "The scheduler tried every node and none fit.",
73
+ action:
74
+ "Open the pod for the decomposed reason — arch/OS mismatch, untolerated taint, insufficient resources, or affinity.",
62
75
  },
63
76
  QuotaExceeded: {
64
- title: 'ResourceQuota Exceeded',
65
- description: 'A namespace ResourceQuota is at its hard limit, so new pods are rejected at admission.',
66
- action: 'Open the namespace to see quota usage; raise the quota or free usage.',
77
+ title: "ResourceQuota Exceeded",
78
+ description:
79
+ "A namespace ResourceQuota is at its hard limit, so new pods are rejected at admission.",
80
+ action:
81
+ "Open the namespace to see quota usage; raise the quota or free usage.",
67
82
  },
68
83
  QuotaNearLimit: {
69
- title: 'ResourceQuota Near Limit',
70
- description: 'A namespace ResourceQuota is close to its hard limit and will soon block new pods.',
71
- action: 'Open the namespace to see quota usage.',
84
+ title: "ResourceQuota Near Limit",
85
+ description:
86
+ "A namespace ResourceQuota is close to its hard limit and will soon block new pods.",
87
+ action: "Open the namespace to see quota usage.",
72
88
  },
73
89
  IPExhaustion: {
74
- title: 'IP Exhaustion (CNI)',
75
- description: 'The pod was scheduled but the CNI could not assign an IP — the node/subnet pool is exhausted.',
76
- action: 'Free IPs, scale the subnet/ENI pool, or move the pod to a node with capacity.',
90
+ title: "IP Exhaustion (CNI)",
91
+ description:
92
+ "The pod was scheduled but the CNI could not assign an IP — the node/subnet pool is exhausted.",
93
+ action:
94
+ "Free IPs, scale the subnet/ENI pool, or move the pod to a node with capacity.",
77
95
  },
78
96
  SandboxCreationFailed: {
79
- title: 'Sandbox Creation Failed',
80
- description: 'The kubelet could not create the pod sandbox.',
81
- action: 'Check kubelet/CNI events on the node.',
97
+ title: "Sandbox Creation Failed",
98
+ description: "The kubelet could not create the pod sandbox.",
99
+ action: "Check kubelet/CNI events on the node.",
82
100
  },
83
101
  VolumeMount: {
84
- title: 'Volume Mount Failed',
85
- description: 'The pod was scheduled but a volume could not be mounted.',
86
- action: 'Check the PVC/PV binding and the CSI driver on the node.',
102
+ title: "Volume Mount Failed",
103
+ description: "The pod was scheduled but a volume could not be mounted.",
104
+ action: "Check the PVC/PV binding and the CSI driver on the node.",
87
105
  },
88
106
  VolumeAttach: {
89
- title: 'Volume Attach Failed',
90
- description: 'A volume could not be attached to the node.',
91
- action: 'Check the CSI driver and cloud-provider attach limits.',
107
+ title: "Volume Attach Failed",
108
+ description: "A volume could not be attached to the node.",
109
+ action: "Check the CSI driver and cloud-provider attach limits.",
92
110
  },
93
111
  VolumeMultiAttach: {
94
- title: 'Volume Multi-Attach',
95
- description: 'The volume is still attached to another node — a RWO volume cannot attach in two places.',
96
- action: 'Wait for the old pod to terminate, or cordon/drain the stale node.',
112
+ title: "Volume Multi-Attach",
113
+ description:
114
+ "The volume is still attached to another node — a RWO volume cannot attach in two places.",
115
+ action:
116
+ "Wait for the old pod to terminate, or cordon/drain the stale node.",
97
117
  },
98
118
  PodSecurityViolation: {
99
- title: 'Pod Security Violation',
100
- description: 'Pod Security Admission rejected the pod template at admission.',
101
- action: 'Align the pod securityContext with the namespace PSA level.',
119
+ title: "Pod Security Violation",
120
+ description:
121
+ "Pod Security Admission rejected the pod template at admission.",
122
+ action: "Align the pod securityContext with the namespace PSA level.",
102
123
  },
103
124
  WebhookDenied: {
104
- title: 'Admission Webhook Denied',
105
- description: 'A validating/mutating admission webhook rejected pod creation.',
106
- action: 'Check the webhook policy that denied the request.',
125
+ title: "Admission Webhook Denied",
126
+ description:
127
+ "A validating/mutating admission webhook rejected pod creation.",
128
+ action: "Check the webhook policy that denied the request.",
107
129
  },
108
130
  Evicted: {
109
- title: 'Pod Evicted',
110
- description: 'Pod was evicted from the node (usually due to resource pressure).',
111
- action: 'Check node resource usage and set appropriate resource requests.',
131
+ title: "Pod Evicted",
132
+ description:
133
+ "Pod was evicted from the node (usually due to resource pressure).",
134
+ action:
135
+ "Check node resource usage and set appropriate resource requests.",
112
136
  },
113
- }
137
+ };
114
138
 
115
- const details = issueDetails[issue]
139
+ const details = issueDetails[issue];
116
140
  if (!details) {
117
141
  return (
118
142
  <div className="max-w-xs">
119
143
  <div className="font-medium text-red-400">{issue}</div>
120
- <div className="text-theme-text-secondary text-[10px] mt-1">Click to view details</div>
144
+ <div className="text-theme-text-secondary text-[10px] mt-1">
145
+ Click to view details
146
+ </div>
121
147
  </div>
122
- )
148
+ );
123
149
  }
124
150
 
125
151
  return (
126
152
  <div className="max-w-xs">
127
153
  <div className="font-medium text-red-400">{details.title}</div>
128
- <div className="text-theme-text-secondary text-[10px] mt-1">{details.description}</div>
154
+ <div className="text-theme-text-secondary text-[10px] mt-1">
155
+ {details.description}
156
+ </div>
129
157
  <div className="text-blue-400 text-[10px] mt-1.5 border-t border-theme-border pt-1.5">
130
158
  💡 {details.action}
131
159
  </div>
132
160
  </div>
133
- )
161
+ );
134
162
  }
135
163
 
136
164
  // Default dimensions for unknown CRD kinds
137
- export const DEFAULT_NODE_DIMENSIONS = { width: 260, height: 84 }
165
+ export const DEFAULT_NODE_DIMENSIONS = { width: 260, height: 84 };
166
+
167
+ const NODE_NAME_LENGTH_LIMITS: Partial<Record<NodeKind, number>> = {
168
+ Job: 40,
169
+ Workflow: 48,
170
+ };
138
171
 
139
172
  // Node dimensions for ELK layout - sized for typical K8s resource names
140
- export const NODE_DIMENSIONS: Record<NodeKind, { width: number; height: number }> = {
173
+ export const NODE_DIMENSIONS: Record<
174
+ NodeKind,
175
+ { width: number; height: number }
176
+ > = {
141
177
  Internet: { width: 120, height: 52 },
142
178
  Ingress: { width: 300, height: 84 },
143
179
  Gateway: { width: 300, height: 84 },
@@ -159,9 +195,17 @@ export const NODE_DIMENSIONS: Record<NodeKind, { width: number; height: number }
159
195
  PodGroup: { width: 200, height: 64 },
160
196
  ConfigMap: { width: 180, height: 84 },
161
197
  Secret: { width: 180, height: 84 },
198
+ ServiceAccount: { width: 220, height: 84 },
199
+ SealedSecret: { width: 220, height: 84 },
200
+ ServiceMonitor: { width: 240, height: 84 },
201
+ PodMonitor: { width: 240, height: 84 },
162
202
  HorizontalPodAutoscaler: { width: 280, height: 84 },
163
- Job: { width: 180, height: 84 },
203
+ Job: { width: 300, height: 84 },
164
204
  CronJob: { width: 200, height: 84 },
205
+ Workflow: { width: 400, height: 84 },
206
+ CronWorkflow: { width: 240, height: 84 },
207
+ WorkflowTemplate: { width: 240, height: 84 },
208
+ ClusterWorkflowTemplate: { width: 260, height: 84 },
165
209
  PersistentVolumeClaim: { width: 200, height: 84 },
166
210
  Namespace: { width: 180, height: 84 },
167
211
  Node: { width: 280, height: 84 },
@@ -198,183 +242,215 @@ export const NODE_DIMENSIONS: Record<NodeKind, { width: number; height: number }
198
242
  KubeadmControlPlane: { width: 300, height: 84 },
199
243
  ClusterClass: { width: 280, height: 84 },
200
244
  MachineHealthCheck: { width: 300, height: 84 },
201
- }
202
-
245
+ };
203
246
 
204
247
  // Status indicator color (for dot and left bar) - uses centralized severity colors
205
248
  function getStatusDotColor(status: HealthStatus): string {
206
- const severity = healthToSeverity(status)
207
- return SEVERITY_DOT[severity]
249
+ const severity = healthToSeverity(status);
250
+ return SEVERITY_DOT[severity];
208
251
  }
209
252
 
210
253
  // Cached style objects for status states (avoid creating new objects each render)
211
254
  const STATUS_STYLES: Record<HealthStatus, React.CSSProperties> = {
212
255
  degraded: {
213
- border: '2px solid rgb(234 179 8 / 0.6)',
214
- backgroundColor: 'rgb(251 146 60 / 0.12)',
256
+ border: "2px solid rgb(234 179 8 / 0.6)",
257
+ backgroundColor: "rgb(251 146 60 / 0.12)",
215
258
  },
216
259
  unhealthy: {
217
- border: '2px solid rgb(239 68 68 / 0.7)',
218
- backgroundColor: 'rgb(248 113 113 / 0.15)',
260
+ border: "2px solid rgb(239 68 68 / 0.7)",
261
+ backgroundColor: "rgb(248 113 113 / 0.15)",
219
262
  },
220
263
  // neutral = intentional/idle (suspended, scaled-to-0) — sky outline, calm.
221
264
  // Distinct from `unknown`/`healthy` which carry no outline.
222
265
  neutral: {
223
- border: '2px solid rgb(56 189 248 / 0.55)',
224
- backgroundColor: 'rgb(56 189 248 / 0.1)',
266
+ border: "2px solid rgb(56 189 248 / 0.55)",
267
+ backgroundColor: "rgb(56 189 248 / 0.1)",
225
268
  },
226
269
  healthy: {},
227
270
  unknown: {},
228
- }
271
+ };
229
272
 
230
273
  function getStatusStyle(status: HealthStatus): React.CSSProperties {
231
- return STATUS_STYLES[status] || STATUS_STYLES.healthy
274
+ return STATUS_STYLES[status] || STATUS_STYLES.healthy;
232
275
  }
233
276
 
234
-
235
277
  // Format subtitle based on node kind. In summary mode the pod tier is
236
278
  // collapsed, so workload/service nodes carry a podSummary — append it so the
237
279
  // count of pods (and any unhealthy/pending) is still visible without children.
238
- function getSubtitle(kind: NodeKind, nodeData: Record<string, unknown>): string {
239
- const base = baseSubtitle(kind, nodeData)
240
- const ps = nodeData.podSummary as PodSummary | undefined
280
+ function getSubtitle(
281
+ kind: NodeKind,
282
+ nodeData: Record<string, unknown>,
283
+ ): string {
284
+ const base = baseSubtitle(kind, nodeData);
285
+ const ps = nodeData.podSummary as PodSummary | undefined;
241
286
  if (ps && SUMMARY_POD_KINDS.has(kind)) {
242
- let suffix = `${ps.total} pods`
243
- if (ps.unhealthy > 0) suffix += ` (${ps.unhealthy} unhealthy)`
244
- else if (ps.degraded > 0) suffix += ` (${ps.degraded} pending)`
245
- return base ? `${base} • ${suffix}` : suffix
287
+ let suffix = `${ps.total} pods`;
288
+ if (ps.unhealthy > 0) suffix += ` (${ps.unhealthy} unhealthy)`;
289
+ else if (ps.degraded > 0) suffix += ` (${ps.degraded} pending)`;
290
+ return base ? `${base} • ${suffix}` : suffix;
246
291
  }
247
- return base
292
+ return base;
248
293
  }
249
294
 
250
295
  // Kinds that own pods and therefore carry a podSummary in summary mode.
251
296
  const SUMMARY_POD_KINDS = new Set<NodeKind>([
252
- 'Deployment', 'StatefulSet', 'DaemonSet', 'Rollout', 'Job', 'Service',
253
- ])
297
+ "Deployment",
298
+ "StatefulSet",
299
+ "DaemonSet",
300
+ "Rollout",
301
+ "Job",
302
+ "CronJob",
303
+ "Workflow",
304
+ "CronWorkflow",
305
+ "Service",
306
+ ]);
254
307
 
255
- function baseSubtitle(kind: NodeKind, nodeData: Record<string, unknown>): string {
308
+ export function baseSubtitle(kind: NodeKind, nodeData: Record<string, unknown>): string {
309
+ if (nodeData.deploymentMembership === 'source-only') {
310
+ return `Declared by ${nodeData.deploymentSourceLabel ?? 'deployment source'}`
311
+ }
256
312
  switch (kind) {
257
- case 'Deployment':
258
- case 'Rollout':
259
- case 'DaemonSet':
260
- case 'StatefulSet':
261
- case 'ReplicaSet': {
313
+ case "Deployment":
314
+ case "Rollout":
315
+ case "DaemonSet":
316
+ case "StatefulSet":
317
+ case "ReplicaSet": {
262
318
  // Use statusSummary if available (includes issue info like "0/3 OOMKilled")
263
- const statusSummary = nodeData.statusSummary as string
319
+ const statusSummary = nodeData.statusSummary as string;
264
320
  if (statusSummary) {
265
- return statusSummary
321
+ return statusSummary;
266
322
  }
267
- const ready = nodeData.readyReplicas ?? 0
268
- const total = nodeData.totalReplicas ?? 0
269
- return `${ready}/${total} ready`
323
+ const ready = nodeData.readyReplicas ?? 0;
324
+ const total = nodeData.totalReplicas ?? 0;
325
+ return `${ready}/${total} ready`;
270
326
  }
271
- case 'Application': {
327
+ case "Application": {
272
328
  // ArgoCD Application - show sync and health status
273
- const syncStatus = (nodeData.syncStatus as string) || 'Unknown'
274
- const healthStatus = (nodeData.healthStatus as string) || 'Unknown'
275
- return `${syncStatus} • ${healthStatus}`
329
+ const syncStatus = (nodeData.syncStatus as string) || "Unknown";
330
+ const healthStatus = (nodeData.healthStatus as string) || "Unknown";
331
+ return `${syncStatus} • ${healthStatus}`;
276
332
  }
277
- case 'Kustomization': {
333
+ case "Kustomization": {
278
334
  // FluxCD Kustomization - show ready status and resource count
279
- const ready = (nodeData.ready as string) || 'Unknown'
280
- const resources = nodeData.resourceCount as number
281
- return resources ? `${ready} • ${resources} resources` : ready
335
+ const ready = (nodeData.ready as string) || "Unknown";
336
+ const resources = nodeData.resourceCount as number;
337
+ return resources ? `${ready} • ${resources} resources` : ready;
282
338
  }
283
- case 'HelmRelease': {
339
+ case "HelmRelease": {
284
340
  // FluxCD HelmRelease - show ready status and revision
285
- const ready = (nodeData.ready as string) || 'Unknown'
286
- const revision = nodeData.revision as number
287
- return revision ? `${ready} • rev ${revision}` : ready
341
+ const ready = (nodeData.ready as string) || "Unknown";
342
+ const revision = nodeData.revision as number;
343
+ return revision ? `${ready} • rev ${revision}` : ready;
288
344
  }
289
- case 'GitRepository': {
345
+ case "GitRepository": {
290
346
  // FluxCD GitRepository - show ready status and branch/revision
291
- const ready = (nodeData.ready as string) || 'Unknown'
292
- const branch = nodeData.branch as string
293
- return branch ? `${ready} • ${branch}` : ready
347
+ const ready = (nodeData.ready as string) || "Unknown";
348
+ const branch = nodeData.branch as string;
349
+ return branch ? `${ready} • ${branch}` : ready;
294
350
  }
295
- case 'Pod':
296
- return (nodeData.phase as string) || 'Unknown'
297
- case 'Service': {
298
- const svcType = (nodeData.type as string) || 'ClusterIP'
299
- const port = nodeData.port
300
- return port ? `${svcType} :${port}` : svcType
351
+ case "Pod":
352
+ return (nodeData.phase as string) || "Unknown";
353
+ case "Service": {
354
+ const svcType = (nodeData.type as string) || "ClusterIP";
355
+ const port = nodeData.port;
356
+ return port ? `${svcType} :${port}` : svcType;
301
357
  }
302
- case 'Ingress':
303
- return (nodeData.hostname as string) || 'No host'
304
- case 'Gateway': {
305
- const listeners = nodeData.listenerCount as number || 0
306
- const addresses = nodeData.addresses as string[]
307
- const addr = addresses?.length ? addresses[0] : ''
308
- return addr ? `${listeners} listeners • ${addr}` : `${listeners} listeners`
358
+ case "Ingress":
359
+ return (nodeData.hostname as string) || "No host";
360
+ case "Gateway": {
361
+ const listeners = (nodeData.listenerCount as number) || 0;
362
+ const addresses = nodeData.addresses as string[];
363
+ const addr = addresses?.length ? addresses[0] : "";
364
+ return addr
365
+ ? `${listeners} listeners • ${addr}`
366
+ : `${listeners} listeners`;
309
367
  }
310
- case 'HTTPRoute':
311
- case 'GRPCRoute':
312
- case 'TCPRoute':
313
- case 'TLSRoute': {
314
- const hostnames = nodeData.hostnames as string[]
315
- const rulesCount = nodeData.rulesCount as number || 0
316
- const host = hostnames?.length ? hostnames[0] : ''
317
- return host ? `${host} • ${rulesCount} rules` : `${rulesCount} rules`
368
+ case "HTTPRoute":
369
+ case "GRPCRoute":
370
+ case "TCPRoute":
371
+ case "TLSRoute": {
372
+ const hostnames = nodeData.hostnames as string[];
373
+ const rulesCount = (nodeData.rulesCount as number) || 0;
374
+ const host = hostnames?.length ? hostnames[0] : "";
375
+ return host ? `${host} • ${rulesCount} rules` : `${rulesCount} rules`;
318
376
  }
319
- case 'HorizontalPodAutoscaler': {
320
- const min = nodeData.minReplicas ?? 1
321
- const max = nodeData.maxReplicas ?? 10
322
- const current = nodeData.current ?? 0
323
- return `${current} (${min}-${max})`
377
+ case "HorizontalPodAutoscaler": {
378
+ const min = nodeData.minReplicas ?? 1;
379
+ const max = nodeData.maxReplicas ?? 10;
380
+ const current = nodeData.current ?? 0;
381
+ return `${current} (${min}-${max})`;
324
382
  }
325
383
  case 'ConfigMap':
326
384
  return `${nodeData.keys ?? 0} keys`
327
385
  case 'Secret':
328
386
  return `${nodeData.keys ?? 0} keys`
387
+ case 'WorkflowTemplate':
388
+ case 'ClusterWorkflowTemplate': {
389
+ const entrypoint = nodeData.entrypoint as string
390
+ const templateCount = nodeData.templateCount as number
391
+ if (entrypoint && templateCount) return `${entrypoint} • ${templateCount} templates`
392
+ if (entrypoint) return entrypoint
393
+ return templateCount ? `${templateCount} templates` : ''
394
+ }
395
+ case 'ServiceAccount':
396
+ return 'Workload identity'
397
+ case 'SealedSecret':
398
+ return nodeData.targetSecret ? `Creates ${nodeData.targetSecret}` : 'Encrypted secret'
399
+ case 'ServiceMonitor':
400
+ case 'PodMonitor': {
401
+ const count = (nodeData.endpointCount as number) || 0
402
+ return `${count} scrape endpoint${count === 1 ? '' : 's'}`
403
+ }
329
404
  case 'PersistentVolumeClaim': {
330
405
  const storage = (nodeData.storage as string) || ''
331
406
  const phase = (nodeData.phase as string) || ''
332
407
  return storage ? `${storage} (${phase})` : phase
333
408
  }
334
- case 'PodGroup': {
335
- const count = (nodeData.podCount as number) || 0
336
- const healthy = (nodeData.healthy as number) || 0
337
- const unhealthy = (nodeData.unhealthy as number) || 0
409
+ case "PodGroup": {
410
+ const count = (nodeData.podCount as number) || 0;
411
+ const healthy = (nodeData.healthy as number) || 0;
412
+ const unhealthy = (nodeData.unhealthy as number) || 0;
338
413
  if (unhealthy > 0) {
339
- return `${count} pods (${unhealthy} unhealthy)`
414
+ return `${count} pods (${unhealthy} unhealthy)`;
340
415
  }
341
- return `${count} pods (${healthy} healthy)`
416
+ return `${count} pods (${healthy} healthy)`;
342
417
  }
343
- case 'KnativeService': {
344
- const url = nodeData.url as string
418
+ case "KnativeService": {
419
+ const url = nodeData.url as string;
345
420
  if (url) {
346
421
  // Show just the hostname from the URL for compactness
347
422
  try {
348
- return new URL(url).hostname
423
+ return new URL(url).hostname;
349
424
  } catch {
350
- return url
425
+ return url;
351
426
  }
352
427
  }
353
- return (nodeData.latestRevision as string) || ''
428
+ return (nodeData.latestRevision as string) || "";
354
429
  }
355
- case 'Node': {
356
- const instanceType = nodeData.instanceType as string
357
- return instanceType || ''
430
+ case "Node": {
431
+ const instanceType = nodeData.instanceType as string;
432
+ return instanceType || "";
358
433
  }
359
- case 'Internet':
360
- return ''
434
+ case "Internet":
435
+ return "";
361
436
  default:
362
- return ''
437
+ return "";
363
438
  }
364
439
  }
365
440
 
366
441
  interface K8sResourceNodeProps {
367
442
  data: {
368
- kind: NodeKind
369
- name: string
370
- status: HealthStatus
371
- nodeData: Record<string, unknown>
372
- selected?: boolean
443
+ kind: NodeKind;
444
+ name: string;
445
+ status: HealthStatus;
446
+ nodeData: Record<string, unknown>;
447
+ selected?: boolean;
373
448
  /** Hover-focus: when a sibling workload is focused, non-members dim. */
374
449
  dimmed?: boolean
375
450
  onExpand?: (nodeId: string) => void
376
451
  onCollapse?: (nodeId: string) => void
377
452
  isExpanded?: boolean
453
+ onToggleReplicaSets?: (ownerID: string) => void
378
454
  }
379
455
  id: string
380
456
  }
@@ -383,35 +459,37 @@ export const K8sResourceNode = memo(function K8sResourceNode({
383
459
  data,
384
460
  id,
385
461
  }: K8sResourceNodeProps) {
386
- const { kind, name, status, nodeData, selected, dimmed, onExpand, onCollapse, isExpanded } = data
462
+ const { kind, name, status, nodeData, selected, dimmed, onExpand, onCollapse, isExpanded, onToggleReplicaSets } = data
387
463
  // Cluster Audit findings joined onto this node by the host (web/ enriches each
388
464
  // node's data by auditKey). The host only counts "badge-worthy" findings —
389
465
  // reference-integrity / lifecycle, "this resource is actually broken" — not the
390
466
  // posture/best-practice nags that fire near-universally, so the indicator stays
391
467
  // a signal. Colored by worst severity (danger red, else warning amber).
392
- const auditDanger = typeof nodeData.auditDanger === 'number' ? nodeData.auditDanger : 0
393
- const auditWarning = typeof nodeData.auditWarning === 'number' ? nodeData.auditWarning : 0
394
- const auditTotal = auditDanger + auditWarning
395
- const auditMessages = Array.isArray(nodeData.auditMessages) ? (nodeData.auditMessages as AuditBadgeMessage[]) : []
396
- // Workload tint (application graph): a node owned by exactly one workload
397
- // carries that workload's hue. Only on healthy/unknown cards — degraded/
398
- // unhealthy already own the card background for health, which must win.
468
+ const auditDanger =
469
+ typeof nodeData.auditDanger === "number" ? nodeData.auditDanger : 0;
470
+ const auditWarning =
471
+ typeof nodeData.auditWarning === "number" ? nodeData.auditWarning : 0;
472
+ const auditTotal = auditDanger + auditWarning;
473
+ const auditMessages = Array.isArray(nodeData.auditMessages)
474
+ ? (nodeData.auditMessages as AuditBadgeMessage[])
475
+ : [];
476
+ // Workload ownership (application graph): when present, it owns the node fill.
477
+ // Kind is shown by a real icon, not arbitrary color; health remains dot/border.
399
478
  const { ownerColorIndex } = ownershipOf(nodeData)
400
- const hue = ownerColorIndex !== null && (status === 'healthy' || status === 'unknown')
401
- ? workloadHue(ownerColorIndex)
402
- : undefined
479
+ const hue = ownerColorIndex !== null ? workloadHue(ownerColorIndex) : undefined
403
480
  const subtitle = getSubtitle(kind, nodeData)
404
481
  const isInternet = kind === 'Internet'
405
482
  const isPodGroup = kind === 'PodGroup'
406
- const isSmallNode = kind === 'ConfigMap' || kind === 'Secret' || kind === 'HorizontalPodAutoscaler'
483
+ const isSmallNode = kind === 'ConfigMap' || kind === 'Secret' || kind === 'ServiceAccount' || kind === 'SealedSecret' || kind === 'ServiceMonitor' || kind === 'PodMonitor' || kind === 'HorizontalPodAutoscaler'
407
484
  const canExpand = isPodGroup && onExpand && !isExpanded
408
485
  const canCollapse = isPodGroup && onCollapse && isExpanded
409
486
  const statusIssue = nodeData.statusIssue as string | undefined
410
487
  const issueTooltip = getIssueTooltip(statusIssue)
411
488
  const policyStatus = nodeData.policyStatus as string | undefined
489
+ const deploymentMembership = nodeData.deploymentMembership as 'runtime-only' | 'source-only' | undefined
490
+ const deploymentSourceLabel = nodeData.deploymentSourceLabel as string | undefined
412
491
 
413
- // CSS class for icon (replaces Lucide SVG - saves ~5 DOM elements per node)
414
- const iconClass = `topology-icon topology-icon-${kind.toLowerCase()}`
492
+ const Icon = getTopologyIcon(kind);
415
493
 
416
494
  if (isInternet) {
417
495
  return (
@@ -423,13 +501,13 @@ export const K8sResourceNode = memo(function K8sResourceNode({
423
501
  />
424
502
  <div
425
503
  className={clsx(
426
- 'flex items-center gap-2 px-4 py-2 rounded-full',
427
- 'selection border border-skyhook-500/30',
428
- 'shadow-lg shadow-skyhook-500/20',
429
- selected && 'ring-2 ring-skyhook-400'
504
+ "flex items-center gap-2 px-4 py-2 rounded-full",
505
+ "selection border border-skyhook-500/30",
506
+ "shadow-lg shadow-skyhook-500/20",
507
+ selected && "ring-2 ring-skyhook-400",
430
508
  )}
431
509
  >
432
- <span className="topology-icon topology-icon-internet" style={{ width: 20, height: 20 }} />
510
+ <Icon className="h-5 w-5 shrink-0 text-accent-text" aria-hidden />
433
511
  <span className="text-sm font-medium text-skyhook-300">Internet</span>
434
512
  <span className="w-2 h-2 rounded-full bg-green-500" />
435
513
  </div>
@@ -439,7 +517,7 @@ export const K8sResourceNode = memo(function K8sResourceNode({
439
517
  className="!bg-transparent !border-0 !w-0 !h-0"
440
518
  />
441
519
  </>
442
- )
520
+ );
443
521
  }
444
522
 
445
523
  return (
@@ -452,93 +530,159 @@ export const K8sResourceNode = memo(function K8sResourceNode({
452
530
 
453
531
  <div
454
532
  className={clsx(
455
- 'relative rounded-lg overflow-hidden transition-opacity duration-150',
456
- 'bg-theme-surface topology-node-card',
457
- selected && 'topology-node-selected',
458
- !selected && dimmed && 'opacity-30',
459
- isSmallNode && !dimmed && 'opacity-90',
533
+ "relative rounded-lg overflow-hidden transition-opacity duration-150",
534
+ "bg-theme-surface topology-node-card",
535
+ selected && "topology-node-selected",
536
+ !selected && dimmed && "opacity-30",
537
+ isSmallNode && !dimmed && "opacity-90",
460
538
  // Status bar via CSS pseudo-element (defined in index.css)
461
- (status === 'healthy' || status === 'unknown') && 'topology-node-status-bar',
462
- status === 'healthy' && 'topology-node-status-healthy',
463
- status === 'unknown' && 'topology-node-status-unknown'
539
+ (status === "healthy" || status === "unknown") &&
540
+ "topology-node-status-bar",
541
+ status === "healthy" && "topology-node-status-healthy",
542
+ status === "unknown" && "topology-node-status-unknown",
464
543
  )}
465
544
  style={{
466
545
  width: NODE_DIMENSIONS[kind]?.width ?? DEFAULT_NODE_DIMENSIONS.width,
467
546
  ...getStatusStyle(status),
547
+ ...(hue ? { backgroundColor: hue.wash } : undefined),
468
548
  }}
469
549
  >
470
- {/* Workload tint — layered over the surface, inset past the 4px status bar */}
471
- {hue && (
472
- <div
473
- className="pointer-events-none absolute inset-y-0 right-0 rounded-r-lg"
474
- style={{ left: 4, background: hue.wash }}
475
- />
476
- )}
477
-
478
550
  {/* Content */}
479
- <div className={clsx(
480
- 'pl-3 pr-3',
481
- isSmallNode ? 'py-2' : 'py-2.5'
482
- )}>
551
+ <div className={clsx("pl-3 pr-3", isSmallNode ? "py-2" : "py-2.5")}>
483
552
  {/* Header row: icon + kind label + (right-aligned) policy badge + expand/collapse + status dot */}
484
553
  <div className="flex items-center gap-1.5 mb-0.5">
485
- <span className={iconClass} />
554
+ <Icon
555
+ className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
556
+ aria-hidden
557
+ />
486
558
  <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary font-medium">
487
- {isPodGroup ? 'Pod Group' : displayKind(kind)}
559
+ {isPodGroup ? "Pod Group" : displayKind(kind)}
488
560
  </span>
489
561
  <div className="ml-auto flex items-center gap-1.5">
562
+ {deploymentMembership && (
563
+ <Tooltip
564
+ content={deploymentMembership === 'runtime-only'
565
+ ? `Observed in runtime but not present in the visible ${deploymentSourceLabel ?? 'deployment'} inventory.`
566
+ : `Declared by ${deploymentSourceLabel ?? 'the deployment source'} but not connected to the current runtime neighborhood.`}
567
+ position="right"
568
+ >
569
+ <span className="inline-flex cursor-help items-center gap-1 rounded bg-theme-elevated px-1 py-0.5 text-[9px] font-medium normal-case tracking-normal text-theme-text-tertiary">
570
+ {deploymentMembership === 'runtime-only'
571
+ ? <Radio className="h-2.5 w-2.5" aria-hidden />
572
+ : deploymentSourceLabel === 'Argo CD'
573
+ ? <img src={argoCdLogo} alt="" className="h-2.5 w-2.5 object-contain" />
574
+ : deploymentSourceLabel === 'Flux'
575
+ ? <img src={fluxLogo} alt="" className="h-2.5 w-2.5 object-contain" />
576
+ : <Layers className="h-2.5 w-2.5" aria-hidden />}
577
+ {!isSmallNode && (deploymentMembership === 'runtime-only' ? 'Runtime only' : `${deploymentSourceLabel ?? 'Source'} only`)}
578
+ </span>
579
+ </Tooltip>
580
+ )}
581
+ {onToggleReplicaSets && (
582
+ <Tooltip content={nodeData.replicaSetsCollapsed ? 'Show ReplicaSet' : 'Hide stable ReplicaSet'} position="right">
583
+ <button
584
+ type="button"
585
+ onClick={(event) => {
586
+ event.stopPropagation()
587
+ onToggleReplicaSets(id)
588
+ }}
589
+ className="inline-flex items-center gap-1 rounded px-1 py-0.5 text-[9px] font-medium text-theme-text-tertiary hover:bg-theme-elevated hover:text-theme-text-primary"
590
+ >
591
+ {nodeData.replicaSetCount as number} RS
592
+ {nodeData.replicaSetsCollapsed ? <ChevronDown className="h-3 w-3" aria-hidden /> : <ChevronUp className="h-3 w-3" aria-hidden />}
593
+ </button>
594
+ </Tooltip>
595
+ )}
490
596
  {policyStatus && (
491
- <Tooltip content={policyStatus === 'protected' ? 'Protected by NetworkPolicy' : 'No NetworkPolicy coverage'} position="right">
492
- <span className={clsx(
493
- 'inline-flex items-center justify-center w-3 h-3 rounded-sm text-[8px] font-bold cursor-help leading-none',
494
- policyStatus === 'protected'
495
- ? 'bg-green-500/20 text-green-500 border border-green-500/30'
496
- : 'bg-yellow-500/20 text-yellow-500 border border-yellow-500/30',
497
- )}>
498
- {policyStatus === 'protected' ? '✓' : '!'}
597
+ <Tooltip
598
+ content={
599
+ policyStatus === "protected"
600
+ ? "Protected by NetworkPolicy"
601
+ : "No NetworkPolicy coverage"
602
+ }
603
+ position="right"
604
+ >
605
+ <span
606
+ className={clsx(
607
+ "inline-flex items-center justify-center w-3 h-3 rounded-sm text-[8px] font-bold cursor-help leading-none",
608
+ policyStatus === "protected"
609
+ ? "bg-green-500/20 text-green-500 border border-green-500/30"
610
+ : "bg-yellow-500/20 text-yellow-500 border border-yellow-500/30",
611
+ )}
612
+ >
613
+ {policyStatus === "protected" ? "✓" : "!"}
499
614
  </span>
500
615
  </Tooltip>
501
616
  )}
502
617
  {canExpand && (
503
- <button
504
- onClick={(e) => {
505
- e.stopPropagation()
506
- onExpand(id)
507
- }}
508
- className="p-0.5 hover:bg-theme-elevated rounded transition-colors"
509
- title="Expand to show individual pods"
618
+ <Tooltip
619
+ content="Expand to show individual pods"
620
+ position="right"
510
621
  >
511
- <ChevronDown className="w-3.5 h-3.5 text-theme-text-secondary" />
512
- </button>
622
+ <button
623
+ onClick={(e) => {
624
+ e.stopPropagation();
625
+ onExpand(id);
626
+ }}
627
+ className="p-0.5 hover:bg-theme-elevated rounded transition-colors"
628
+ >
629
+ <ChevronDown className="w-3.5 h-3.5 text-theme-text-secondary" />
630
+ </button>
631
+ </Tooltip>
513
632
  )}
514
633
  {canCollapse && (
515
- <button
516
- onClick={(e) => {
517
- e.stopPropagation()
518
- onCollapse(id)
519
- }}
520
- className="p-0.5 hover:bg-theme-elevated rounded transition-colors"
521
- title="Collapse back to group"
522
- >
523
- <ChevronUp className="w-3.5 h-3.5 text-theme-text-secondary" />
524
- </button>
634
+ <Tooltip content="Collapse back to group" position="right">
635
+ <button
636
+ onClick={(e) => {
637
+ e.stopPropagation();
638
+ onCollapse(id);
639
+ }}
640
+ className="p-0.5 hover:bg-theme-elevated rounded transition-colors"
641
+ >
642
+ <ChevronUp className="w-3.5 h-3.5 text-theme-text-secondary" />
643
+ </button>
644
+ </Tooltip>
525
645
  )}
526
646
  {auditTotal > 0 && (
527
647
  <Tooltip
528
- content={auditMessages.length > 0
529
- ? <AuditBadgeTooltip messages={auditMessages} clickHint={false} />
530
- : `${auditTotal} audit ${auditTotal === 1 ? 'finding' : 'findings'}${auditDanger > 0 ? ` · ${auditDanger} danger` : ''}`}
648
+ content={
649
+ auditMessages.length > 0 ? (
650
+ <AuditBadgeTooltip
651
+ messages={auditMessages}
652
+ clickHint={false}
653
+ />
654
+ ) : (
655
+ `${auditTotal} audit ${auditTotal === 1 ? "finding" : "findings"}${auditDanger > 0 ? ` · ${auditDanger} danger` : ""}`
656
+ )
657
+ }
531
658
  position="right"
532
659
  >
533
- <TriangleAlert className={clsx('w-3 h-3 cursor-help', auditDanger > 0 ? SEVERITY_TEXT.error : SEVERITY_TEXT.warning)} />
660
+ <TriangleAlert
661
+ className={clsx(
662
+ "w-3 h-3 cursor-help",
663
+ auditDanger > 0
664
+ ? SEVERITY_TEXT.error
665
+ : SEVERITY_TEXT.warning,
666
+ )}
667
+ />
534
668
  </Tooltip>
535
669
  )}
536
670
  {issueTooltip ? (
537
671
  <Tooltip content={issueTooltip} position="right">
538
- <span className={clsx('w-1.5 h-1.5 rounded-full cursor-help', getStatusDotColor(status))} />
672
+ <span
673
+ className={clsx(
674
+ "w-1.5 h-1.5 rounded-full cursor-help",
675
+ getStatusDotColor(status),
676
+ )}
677
+ />
539
678
  </Tooltip>
540
679
  ) : (
541
- <span className={clsx('w-1.5 h-1.5 rounded-full', getStatusDotColor(status))} />
680
+ <span
681
+ className={clsx(
682
+ "w-1.5 h-1.5 rounded-full",
683
+ getStatusDotColor(status),
684
+ )}
685
+ />
542
686
  )}
543
687
  </div>
544
688
  </div>
@@ -546,7 +690,7 @@ export const K8sResourceNode = memo(function K8sResourceNode({
546
690
  {/* Name — middle-ellipsis so the differentiating suffix survives
547
691
  (a column of pods sharing a long prefix must stay tellable apart). */}
548
692
  <div className="text-sm font-medium text-theme-text-primary truncate pr-1">
549
- {midTruncate(name, 34)}
693
+ {midTruncate(name, NODE_NAME_LENGTH_LIMITS[kind] ?? 34)}
550
694
  </div>
551
695
 
552
696
  {/* Subtitle */}
@@ -564,5 +708,5 @@ export const K8sResourceNode = memo(function K8sResourceNode({
564
708
  className="!bg-transparent !border-0 !w-0 !h-0"
565
709
  />
566
710
  </>
567
- )
568
- })
711
+ );
712
+ });