@skyhook-io/k8s-ui 1.17.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/README.md +41 -0
  2. package/package.json +6 -6
  3. package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
  4. package/src/components/charts/AreaChart.test.tsx +25 -0
  5. package/src/components/charts/AreaChart.tsx +141 -12
  6. package/src/components/charts/index.ts +1 -0
  7. package/src/components/checks/ChecksView.tsx +18 -1
  8. package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
  9. package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
  10. package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
  11. package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
  12. package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
  13. package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
  14. package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
  15. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
  16. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
  17. package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
  18. package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
  19. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
  20. package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
  21. package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
  22. package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
  23. package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
  24. package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
  25. package/src/components/cnpg/backupRuns.test.ts +17 -0
  26. package/src/components/cnpg/backupRuns.ts +19 -0
  27. package/src/components/cnpg/connect.test.ts +124 -0
  28. package/src/components/cnpg/connect.ts +155 -0
  29. package/src/components/cnpg/databaseRole.test.ts +38 -0
  30. package/src/components/cnpg/databaseRole.ts +80 -0
  31. package/src/components/cnpg/ha.test.ts +446 -0
  32. package/src/components/cnpg/ha.ts +621 -0
  33. package/src/components/cnpg/index.ts +18 -0
  34. package/src/components/cnpg/logicalReplication.test.ts +121 -0
  35. package/src/components/cnpg/logicalReplication.ts +252 -0
  36. package/src/components/cnpg/pooler.test.ts +84 -0
  37. package/src/components/cnpg/pooler.ts +225 -0
  38. package/src/components/cnpg/primitives.tsx +21 -117
  39. package/src/components/cnpg/problems.ts +12 -0
  40. package/src/components/cnpg/relations.test.ts +63 -13
  41. package/src/components/cnpg/relations.ts +61 -52
  42. package/src/components/cnpg/schedule.ts +35 -0
  43. package/src/components/cnpg/workspace-disk.test.ts +97 -0
  44. package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
  45. package/src/components/cnpg/workspace-problems.test.ts +175 -0
  46. package/src/components/cnpg/workspace.test.ts +380 -9
  47. package/src/components/cnpg/workspace.ts +1137 -136
  48. package/src/components/dock/DockContext.test.tsx +32 -0
  49. package/src/components/dock/DockContext.tsx +18 -4
  50. package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
  51. package/src/components/dock/LocalTerminalTab.tsx +4 -3
  52. package/src/components/dock/TerminalTab.tsx +20 -8
  53. package/src/components/facts/certainty.tsx +36 -0
  54. package/src/components/facts/facts.test.tsx +24 -0
  55. package/src/components/facts/facts.tsx +62 -0
  56. package/src/components/facts/index.ts +6 -0
  57. package/src/components/facts/managed-by.test.tsx +17 -0
  58. package/src/components/facts/managed-by.tsx +36 -0
  59. package/src/components/facts/public-exports.test.tsx +35 -0
  60. package/src/components/gitops/GitOpsGraphFilterRail.tsx +7 -3
  61. package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
  62. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
  63. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +142 -0
  64. package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
  65. package/src/components/gitops/insights/DriftPanel.tsx +62 -0
  66. package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -81
  67. package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
  68. package/src/components/gitops/insights/insights-helpers.ts +7 -0
  69. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +91 -2
  70. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +76 -76
  71. package/src/components/gitops/tree/layout.test.ts +111 -0
  72. package/src/components/gitops/tree/layout.ts +128 -0
  73. package/src/components/gitops/tree/tree-helpers.test.ts +23 -0
  74. package/src/components/gitops/tree/tree-helpers.ts +12 -2
  75. package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
  76. package/src/components/issues/IssuesView.tsx +44 -6
  77. package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
  78. package/src/components/issues/ResourceIssuesSection.tsx +8 -2
  79. package/src/components/issues/diagnostic.ts +2 -0
  80. package/src/components/issues/index.ts +3 -1
  81. package/src/components/issues/issue-timing.ts +12 -1
  82. package/src/components/issues/issues.test.ts +75 -1
  83. package/src/components/issues/severity.ts +30 -2
  84. package/src/components/issues/types.ts +55 -4
  85. package/src/components/logs/LogCore.theme.test.tsx +206 -3
  86. package/src/components/logs/LogCore.tsx +180 -53
  87. package/src/components/logs/LogToolbarSelects.tsx +3 -1
  88. package/src/components/logs/LogsViewer.test.tsx +369 -0
  89. package/src/components/logs/LogsViewer.tsx +91 -13
  90. package/src/components/logs/StructuredLogLine.test.tsx +76 -0
  91. package/src/components/logs/StructuredLogLine.tsx +23 -31
  92. package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
  93. package/src/components/logs/WorkloadLogsViewer.tsx +46 -11
  94. package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
  95. package/src/components/logs/useLogBuffer.test.ts +8 -0
  96. package/src/components/logs/useLogBuffer.ts +48 -18
  97. package/src/components/logs/useLogSearch.ts +3 -1
  98. package/src/components/logs/useLogStream.ts +17 -1
  99. package/src/components/problems/index.ts +1 -0
  100. package/src/components/problems/problems.test.tsx +63 -0
  101. package/src/components/problems/problems.tsx +165 -0
  102. package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
  103. package/src/components/resources/ActiveFilterBar.tsx +86 -0
  104. package/src/components/resources/ColumnPickerList.test.tsx +46 -0
  105. package/src/components/resources/ColumnPickerList.tsx +174 -0
  106. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
  107. package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
  108. package/src/components/resources/ResourcesSidebar.tsx +92 -41
  109. package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
  110. package/src/components/resources/ResourcesView.kind-view.test.tsx +296 -0
  111. package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
  112. package/src/components/resources/ResourcesView.tsx +392 -181
  113. package/src/components/resources/column-order.test.ts +104 -0
  114. package/src/components/resources/column-order.ts +64 -0
  115. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  116. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
  117. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
  118. package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
  119. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  120. package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
  121. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
  122. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
  123. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
  124. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
  125. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
  126. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
  127. package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
  128. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
  129. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  130. package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
  131. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
  132. package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
  133. package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
  134. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
  135. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
  136. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
  137. package/src/components/resources/renderers/HPARenderer.tsx +56 -16
  138. package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
  139. package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
  140. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
  141. package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
  142. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
  143. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
  144. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
  145. package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
  146. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
  147. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
  148. package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
  149. package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
  150. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
  151. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
  152. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
  153. package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
  154. package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
  155. package/src/components/resources/renderers/PodRenderer.tsx +81 -40
  156. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
  157. package/src/components/resources/renderers/VPARenderer.tsx +17 -11
  158. package/src/components/resources/resource-utils-capi.ts +1 -1
  159. package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
  160. package/src/components/resources/resource-utils-cnpg.ts +51 -20
  161. package/src/components/resources/resource-utils-contour.test.ts +44 -0
  162. package/src/components/resources/resource-utils-contour.ts +16 -0
  163. package/src/components/resources/resource-utils-istio.ts +12 -8
  164. package/src/components/resources/resource-utils-knative.ts +18 -0
  165. package/src/components/resources/resource-utils.ts +96 -7
  166. package/src/components/resources/rules-column-width.test.ts +1 -1
  167. package/src/components/resources/summarize-scheduler-message.test.ts +31 -0
  168. package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
  169. package/src/components/shared/ActionConfirmDialog.tsx +246 -0
  170. package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
  171. package/src/components/shared/CreateResourceDialog.tsx +126 -80
  172. package/src/components/shared/DetailShell.test.tsx +80 -0
  173. package/src/components/shared/DetailShell.tsx +85 -10
  174. package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
  175. package/src/components/shared/DrainPlanDialog.tsx +6 -2
  176. package/src/components/shared/EditableYamlView.test.tsx +7 -0
  177. package/src/components/shared/EditableYamlView.tsx +1 -1
  178. package/src/components/shared/ResourceActionsBar.tsx +14 -7
  179. package/src/components/shared/ResourceRendererDispatch.tsx +15 -10
  180. package/src/components/shared/index.ts +2 -0
  181. package/src/components/timeline/DiffViewer.test.tsx +69 -0
  182. package/src/components/timeline/DiffViewer.tsx +113 -4
  183. package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
  184. package/src/components/timeline/shared.test.tsx +48 -0
  185. package/src/components/timeline/shared.tsx +14 -19
  186. package/src/components/timeline/timeline-filters.ts +1 -0
  187. package/src/components/topology/K8sResourceNode.test.ts +18 -1
  188. package/src/components/topology/K8sResourceNode.tsx +44 -2
  189. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  190. package/src/components/topology/TopologyGraph.tsx +3 -71
  191. package/src/components/topology/TopologySearch.tsx +1 -0
  192. package/src/components/topology/internet-groups.test.ts +35 -0
  193. package/src/components/topology/internet-groups.ts +55 -0
  194. package/src/components/topology/layout.ts +1 -1
  195. package/src/components/ui/Badge.tsx +1 -0
  196. package/src/components/ui/FoldSection.tsx +68 -0
  197. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
  198. package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
  199. package/src/components/ui/RefLink.tsx +20 -0
  200. package/src/components/ui/RowActionMenu.tsx +8 -3
  201. package/src/components/ui/Toast.tsx +15 -4
  202. package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
  203. package/src/components/ui/drawer-components.test.tsx +110 -1
  204. package/src/components/ui/drawer-components.tsx +62 -9
  205. package/src/components/ui/index.ts +4 -2
  206. package/src/components/ui/monacoEditorFeatures.ts +77 -0
  207. package/src/components/ui/monacoRuntime.ts +14 -19
  208. package/src/components/ui/status-tone.test.ts +10 -1
  209. package/src/components/ui/status-tone.tsx +29 -0
  210. package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
  211. package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
  212. package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
  213. package/src/components/workload/WorkloadView.tsx +193 -71
  214. package/src/index.ts +14 -1
  215. package/src/monaco-deep.d.ts +19 -12
  216. package/src/theme/components.css +13 -0
  217. package/src/types/capacity.ts +11 -2
  218. package/src/types/core.ts +53 -2
  219. package/src/types/gitops-flux-status.test.ts +32 -1
  220. package/src/types/gitops-insights.ts +20 -2
  221. package/src/types/gitops-tree.ts +12 -0
  222. package/src/types/gitops.ts +26 -6
  223. package/src/utils/api-resources.ts +7 -0
  224. package/src/utils/application-history.ts +1 -0
  225. package/src/utils/cnpg-backup.test.ts +26 -0
  226. package/src/utils/cnpg-backup.ts +70 -0
  227. package/src/utils/format.test.ts +9 -0
  228. package/src/utils/format.ts +4 -0
  229. package/src/utils/gitops-owner.ts +3 -2
  230. package/src/utils/grant.test.ts +19 -0
  231. package/src/utils/grant.ts +35 -0
  232. package/src/utils/index.ts +4 -0
  233. package/src/utils/k8s-errors.ts +7 -7
  234. package/src/utils/log-format.test.ts +41 -0
  235. package/src/utils/log-format.ts +71 -15
  236. package/src/utils/log-level.ts +12 -8
  237. package/src/utils/navigation.test.ts +57 -0
  238. package/src/utils/navigation.ts +86 -8
  239. package/src/utils/object-reference-navigation.test.ts +43 -0
  240. package/src/utils/resource-hierarchy.test.ts +37 -0
  241. package/src/utils/resource-hierarchy.ts +15 -7
  242. package/src/utils/resource-icons.ts +2 -0
  243. package/src/utils/skeleton-yaml.test.ts +21 -0
  244. package/src/utils/skeleton-yaml.ts +16 -0
  245. package/src/utils/url-path.test.ts +35 -0
  246. package/src/utils/url-path.ts +21 -0
  247. package/src/utils/validators.test.ts +4 -13
  248. package/src/utils/validators.ts +5 -19
  249. package/src/utils/view-transition.test.ts +35 -1
  250. package/src/utils/view-transition.ts +16 -1
@@ -200,6 +200,32 @@ export function servicePortsTooltip(ports: ServicePortEntry[]): React.ReactNode
200
200
  }
201
201
 
202
202
  // Default dimensions for unknown CRD kinds
203
+ // A domain-mapped Knative Service shows its custom domain as the subtitle; the
204
+ // tooltip lists every mapped domain, then the Service's own URL when it is
205
+ // public, and its cluster-internal address.
206
+ export function knativeDomainsTooltipLines(nodeData: Record<string, unknown>): { text: string; secondary?: boolean }[] {
207
+ const mapped = (nodeData.mappedDomains as string[] | undefined) || [];
208
+ if (mapped.length === 0) return [];
209
+ const url = nodeData.url as string | undefined;
210
+ const internalUrl = nodeData.internalUrl as string | undefined;
211
+ const lines: { text: string; secondary?: boolean }[] = mapped.map((domain) => ({ text: domain }));
212
+ if (url && url !== internalUrl) lines.push({ text: `Service URL: ${url}`, secondary: true });
213
+ if (internalUrl) lines.push({ text: `Internal: ${internalUrl}`, secondary: true });
214
+ return lines;
215
+ }
216
+
217
+ function knativeDomainsTooltip(nodeData: Record<string, unknown>): React.ReactNode | null {
218
+ const lines = knativeDomainsTooltipLines(nodeData);
219
+ if (lines.length === 0) return null;
220
+ return (
221
+ <div className="space-y-0.5">
222
+ {lines.map((line) => (
223
+ <div key={line.text} className={line.secondary ? 'text-theme-text-tertiary' : undefined}>{line.text}</div>
224
+ ))}
225
+ </div>
226
+ );
227
+ }
228
+
203
229
  export const DEFAULT_NODE_DIMENSIONS = { width: 260, height: 84 };
204
230
 
205
231
  const NODE_NAME_LENGTH_LIMITS: Partial<Record<NodeKind, number>> = {
@@ -218,6 +244,7 @@ export const NODE_DIMENSIONS: Record<
218
244
  HTTPRoute: { width: 300, height: 84 },
219
245
  GRPCRoute: { width: 300, height: 84 },
220
246
  TCPRoute: { width: 300, height: 84 },
247
+ UDPRoute: { width: 300, height: 84 },
221
248
  TLSRoute: { width: 300, height: 84 },
222
249
  Service: { width: 260, height: 84 },
223
250
  Deployment: { width: 280, height: 84 },
@@ -440,7 +467,8 @@ export function baseSubtitle(kind: NodeKind, nodeData: Record<string, unknown>):
440
467
  case "HTTPRoute":
441
468
  case "GRPCRoute":
442
469
  case "TCPRoute":
443
- case "TLSRoute": {
470
+ case "TLSRoute":
471
+ case "UDPRoute": {
444
472
  const hostnames = nodeData.hostnames as string[];
445
473
  const rulesCount = (nodeData.rulesCount as number) || 0;
446
474
  const host = hostnames?.length ? hostnames[0] : "";
@@ -488,6 +516,10 @@ export function baseSubtitle(kind: NodeKind, nodeData: Record<string, unknown>):
488
516
  return `${count} pods (${healthy} healthy)`;
489
517
  }
490
518
  case "KnativeService": {
519
+ const mapped = (nodeData.mappedDomains as string[] | undefined) || [];
520
+ if (mapped.length > 0) {
521
+ return mapped.length > 1 ? `${mapped[0]} +${mapped.length - 1}` : mapped[0];
522
+ }
491
523
  const url = nodeData.url as string;
492
524
  if (url) {
493
525
  // Show just the hostname from the URL for compactness
@@ -558,7 +590,10 @@ export const K8sResourceNode = memo(function K8sResourceNode({
558
590
  const subtitle = getSubtitle(kind, nodeData)
559
591
  const portsTooltip = kind === 'Service'
560
592
  ? servicePortsTooltip((nodeData.ports as ServicePortEntry[] | undefined) || [])
561
- : null
593
+ : kind === 'KnativeService'
594
+ ? knativeDomainsTooltip(nodeData)
595
+ : null
596
+ const clusterLocal = kind === 'KnativeService' && nodeData.visibility === 'cluster-local'
562
597
  const isInternet = kind === 'Internet'
563
598
  const isPodGroup = kind === 'PodGroup'
564
599
  const isSmallNode = kind === 'ConfigMap' || kind === 'Secret' || kind === 'ServiceAccount' || kind === 'SealedSecret' || kind === 'ServiceMonitor' || kind === 'PodMonitor' || kind === 'HorizontalPodAutoscaler'
@@ -664,6 +699,13 @@ export const K8sResourceNode = memo(function K8sResourceNode({
664
699
  </span>
665
700
  </Tooltip>
666
701
  )}
702
+ {clusterLocal && (
703
+ <Tooltip content="Reachable only inside the cluster, so it has no Internet edge" position="right">
704
+ <Badge tone="note" size="sm" className="!text-[9px] !px-1 !py-0.5 normal-case tracking-normal cursor-help">
705
+ Cluster-local
706
+ </Badge>
707
+ </Tooltip>
708
+ )}
667
709
  {trafficRole && (
668
710
  <Badge
669
711
  tone={trafficRole === 'canary' || trafficRole === 'preview' ? 'accent1' : 'accent2'}
@@ -33,6 +33,7 @@ const RESOURCE_KINDS: {
33
33
  { kind: 'GRPCRoute', label: 'GRPCRoute', icon: getTopologyIcon('GRPCRoute'), color: 'text-purple-300', category: 'networking' },
34
34
  { kind: 'TCPRoute', label: 'TCPRoute', icon: getTopologyIcon('TCPRoute'), color: 'text-purple-300', category: 'networking' },
35
35
  { kind: 'TLSRoute', label: 'TLSRoute', icon: getTopologyIcon('TLSRoute'), color: 'text-purple-300', category: 'networking' },
36
+ { kind: 'UDPRoute', label: 'UDPRoute', icon: getTopologyIcon('UDPRoute'), color: 'text-purple-300', category: 'networking' },
36
37
  { kind: 'Service', label: 'Service', icon: getTopologyIcon('Service'), color: 'text-blue-400', category: 'networking' },
37
38
  { kind: 'CalicoNetworkPolicy', label: 'Calico NetworkPolicy', icon: getTopologyIcon('CalicoNetworkPolicy'), color: 'text-teal-400', category: 'custom' },
38
39
  { kind: 'CalicoGlobalNetworkPolicy', label: 'Calico GlobalNetworkPolicy', icon: getTopologyIcon('CalicoGlobalNetworkPolicy'), color: 'text-teal-400', category: 'custom' },
@@ -32,6 +32,7 @@ import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
32
32
 
33
33
  import { K8sResourceNode } from './K8sResourceNode'
34
34
  import { GroupNode } from './GroupNode'
35
+ import { splitInternetByGroup } from './internet-groups'
35
36
  import { NEUTRAL_OWNER, type WorkloadFocus } from '../../utils/workload-colors'
36
37
  import { ownershipOf } from '../../utils/topology-neighborhood'
37
38
  import { buildHierarchicalElkGraph, applyHierarchicalLayout, getGroupKey, isGroupEffectivelyCollapsed, type GroupDisplayLevel } from './layout'
@@ -548,75 +549,6 @@ export function TopologyGraph({
548
549
  return { nodes: newNodes, edges: newEdges }
549
550
  }, [])
550
551
 
551
- // Transform to per-group Internet nodes in traffic view with grouping
552
- const createPerGroupInternetNodes = useCallback((
553
- nodes: TopologyNode[],
554
- edges: TopologyEdge[],
555
- groupMode: GroupingMode
556
- ): { nodes: TopologyNode[]; edges: TopologyEdge[] } => {
557
- if (groupMode === 'none') {
558
- return { nodes, edges }
559
- }
560
-
561
- // Find the single Internet node
562
- const internetNode = nodes.find(n => n.kind === 'Internet')
563
- if (!internetNode) {
564
- return { nodes, edges }
565
- }
566
-
567
- // Find all ingresses/gateways and group them
568
- const ingresses = nodes.filter(n => n.kind === 'Ingress' || n.kind === 'Gateway')
569
- const groupsWithIngresses = new Map<string, TopologyNode[]>()
570
-
571
- for (const ingress of ingresses) {
572
- const groupKey = getGroupKey(ingress, groupMode)
573
- if (groupKey) {
574
- if (!groupsWithIngresses.has(groupKey)) {
575
- groupsWithIngresses.set(groupKey, [])
576
- }
577
- groupsWithIngresses.get(groupKey)!.push(ingress)
578
- }
579
- }
580
-
581
- // If no groups with ingresses, keep original
582
- if (groupsWithIngresses.size === 0) {
583
- return { nodes, edges }
584
- }
585
-
586
- // Remove original Internet node and its edges
587
- const newNodes = nodes.filter(n => n.id !== internetNode.id)
588
- const newEdges = edges.filter(e => e.source !== internetNode.id)
589
-
590
- // Create per-group Internet nodes
591
- for (const [groupKey, groupIngresses] of groupsWithIngresses) {
592
- const internetId = `internet-${groupMode}-${groupKey}`
593
-
594
- // Add Internet node for this group with group metadata
595
- newNodes.push({
596
- id: internetId,
597
- kind: 'Internet',
598
- name: 'Internet',
599
- status: 'healthy',
600
- data: {
601
- // Add group metadata so it gets grouped with its ingresses
602
- namespace: groupMode === 'namespace' ? groupKey : groupIngresses[0]?.data?.namespace,
603
- labels: groupMode === 'app' ? { 'app.kubernetes.io/name': groupKey } : {},
604
- },
605
- })
606
-
607
- // Add edges from this Internet node to its ingresses
608
- for (const ingress of groupIngresses) {
609
- newEdges.push({
610
- id: `${internetId}-to-${ingress.id}`,
611
- source: internetId,
612
- target: ingress.id,
613
- type: 'routes-to',
614
- })
615
- }
616
- }
617
-
618
- return { nodes: newNodes, edges: newEdges }
619
- }, [])
620
552
 
621
553
  // Prepare topology data with expanded pod groups
622
554
  const { workingNodes, workingEdges } = useMemo(() => {
@@ -636,13 +568,13 @@ export function TopologyGraph({
636
568
 
637
569
  // In traffic view with grouping, create per-group Internet nodes
638
570
  if (isTrafficView && groupingMode !== 'none') {
639
- const result = createPerGroupInternetNodes(nodes, edges, groupingMode)
571
+ const result = splitInternetByGroup(nodes, edges, groupingMode)
640
572
  nodes = result.nodes
641
573
  edges = result.edges
642
574
  }
643
575
 
644
576
  return { workingNodes: nodes, workingEdges: edges }
645
- }, [topology, expandedPodGroups, expandPodGroup, isTrafficView, groupingMode, createPerGroupInternetNodes])
577
+ }, [topology, expandedPodGroups, expandPodGroup, isTrafficView, groupingMode])
646
578
 
647
579
  // Handle card click in card-grid view — find the topology node and open drawer
648
580
  const handleCardClick = useCallback((nodeId: string) => {
@@ -48,6 +48,7 @@ function getKindColor(kind: string): string {
48
48
  case 'GRPCRoute':
49
49
  case 'TCPRoute':
50
50
  case 'TLSRoute':
51
+ case 'UDPRoute':
51
52
  return 'text-purple-300'
52
53
  case 'Service':
53
54
  return 'text-blue-400'
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { TopologyEdge, TopologyNode } from '../../types'
3
+ import { splitInternetByGroup } from './internet-groups'
4
+
5
+ const node = (id: string, kind: string, namespace?: string): TopologyNode =>
6
+ ({ id, kind, name: id, status: 'healthy', data: namespace ? { namespace } : {} }) as TopologyNode
7
+ const fromInternet = (target: string): TopologyEdge => ({ id: `internet-to-${target}`, source: 'internet', target, type: 'routes-to' })
8
+
9
+ describe('splitInternetByGroup', () => {
10
+ it('keeps every entrypoint connected, not only Ingresses and Gateways', () => {
11
+ const nodes = [
12
+ node('internet', 'Internet'),
13
+ node('ingress/shop/web', 'Ingress', 'shop'),
14
+ node('knativeservice/shop/api', 'KnativeService', 'shop'),
15
+ node('ingressroute/edge/dash', 'IngressRoute', 'edge'),
16
+ ]
17
+ const edges = nodes.slice(1).map(n => fromInternet(n.id))
18
+ const out = splitInternetByGroup(nodes, edges, 'namespace')
19
+
20
+ const reached = out.edges.filter(e => out.nodes.find(n => n.id === e.source)?.kind === 'Internet').map(e => `${e.source} -> ${e.target}`)
21
+ expect(reached.sort()).toEqual([
22
+ 'internet-namespace-edge -> ingressroute/edge/dash',
23
+ 'internet-namespace-shop -> ingress/shop/web',
24
+ 'internet-namespace-shop -> knativeservice/shop/api',
25
+ ])
26
+ expect(out.nodes.some(n => n.id === 'internet')).toBe(false)
27
+ })
28
+
29
+ it('leaves an ungrouped entrypoint on the original Internet node', () => {
30
+ const nodes = [node('internet', 'Internet'), node('ingress/shop/web', 'Ingress', 'shop'), node('gateway//shared', 'Gateway')]
31
+ const out = splitInternetByGroup(nodes, nodes.slice(1).map(n => fromInternet(n.id)), 'namespace')
32
+ expect(out.edges.some(e => e.source === 'internet' && e.target === 'gateway//shared')).toBe(true)
33
+ expect(out.nodes.some(n => n.id === 'internet')).toBe(true)
34
+ })
35
+ })
@@ -0,0 +1,55 @@
1
+ import type { GroupingMode, TopologyEdge, TopologyNode } from '../../types'
2
+ import { getGroupKey } from './layout'
3
+
4
+ /**
5
+ * Splits the traffic view's single Internet node into one per group, so each
6
+ * namespace or app shows its own entry from outside. Every original Internet
7
+ * edge is kept, whatever kind its entrypoint is (Ingress, Gateway, Istio
8
+ * Gateway, Knative Service, Traefik route, Contour HTTPProxy); an entrypoint
9
+ * with no group stays connected to the original Internet node.
10
+ */
11
+ export function splitInternetByGroup(
12
+ nodes: TopologyNode[],
13
+ edges: TopologyEdge[],
14
+ groupMode: GroupingMode,
15
+ ): { nodes: TopologyNode[]; edges: TopologyEdge[] } {
16
+ if (groupMode === 'none') return { nodes, edges }
17
+ const internetNode = nodes.find(n => n.kind === 'Internet')
18
+ if (!internetNode) return { nodes, edges }
19
+
20
+ const nodeById = new Map(nodes.map(n => [n.id, n]))
21
+ const groups = new Map<string, { entry: TopologyNode; edges: TopologyEdge[] }>()
22
+ const regrouped = new Set<TopologyEdge>()
23
+ for (const edge of edges) {
24
+ if (edge.source !== internetNode.id) continue
25
+ const target = nodeById.get(edge.target)
26
+ const groupKey = target ? getGroupKey(target, groupMode) : null
27
+ if (!target || !groupKey) continue
28
+ if (!groups.has(groupKey)) groups.set(groupKey, { entry: target, edges: [] })
29
+ groups.get(groupKey)!.edges.push(edge)
30
+ regrouped.add(edge)
31
+ }
32
+ if (groups.size === 0) return { nodes, edges }
33
+
34
+ const keepOriginal = edges.some(e => e.source === internetNode.id && !regrouped.has(e))
35
+ const newNodes = keepOriginal ? [...nodes] : nodes.filter(n => n.id !== internetNode.id)
36
+ const newEdges = edges.filter(e => !regrouped.has(e))
37
+ for (const [groupKey, group] of groups) {
38
+ const internetId = `internet-${groupMode}-${groupKey}`
39
+ newNodes.push({
40
+ id: internetId,
41
+ kind: 'Internet',
42
+ name: 'Internet',
43
+ status: 'healthy',
44
+ data: {
45
+ // Group metadata so the node lands inside its entrypoints' group.
46
+ namespace: groupMode === 'namespace' ? groupKey : group.entry.data?.namespace,
47
+ labels: groupMode === 'app' ? { 'app.kubernetes.io/name': groupKey } : {},
48
+ },
49
+ })
50
+ for (const edge of group.edges) {
51
+ newEdges.push({ ...edge, id: `${internetId}-to-${edge.target}`, source: internetId })
52
+ }
53
+ }
54
+ return { nodes: newNodes, edges: newEdges }
55
+ }
@@ -864,7 +864,7 @@ function computeWorkloadCards(
864
864
  const KIND_PRIORITY: Record<string, number> = {
865
865
  'Deployment': 1, 'Rollout': 1, 'StatefulSet': 2, 'DaemonSet': 3,
866
866
  'CronJob': 4, 'CronWorkflow': 4, 'Job': 5, 'Workflow': 5, 'Service': 6, 'Gateway': 7,
867
- 'HTTPRoute': 6, 'GRPCRoute': 6, 'TCPRoute': 6, 'TLSRoute': 6, 'Ingress': 7,
867
+ 'HTTPRoute': 6, 'GRPCRoute': 6, 'TCPRoute': 6, 'TLSRoute': 6, 'UDPRoute': 6, 'Ingress': 7,
868
868
  'CalicoNetworkPolicy': 10, 'CalicoGlobalNetworkPolicy': 10,
869
869
  'CalicoStagedNetworkPolicy': 10, 'CalicoStagedGlobalNetworkPolicy': 10,
870
870
  'CalicoStagedKubernetesNetworkPolicy': 10,
@@ -85,6 +85,7 @@ const KIND: Record<string, string> = {
85
85
  HTTPRoute: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
86
86
  GRPCRoute: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
87
87
  TCPRoute: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
88
+ UDPRoute: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
88
89
  TLSRoute: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
89
90
 
90
91
  // Config
@@ -0,0 +1,68 @@
1
+ import { useEffect, useState, type ReactNode } from 'react'
2
+ import { clsx } from 'clsx'
3
+ import { toneTextClass } from './status-tone'
4
+ import { Collapse, CollapseChevron, useDisclosure } from './Collapse'
5
+
6
+ /** A folded section's one-line summary, and whether it opens on its own. */
7
+ export interface FoldSummary {
8
+ text: string
9
+ /** Something in the section needs a look: it opens itself. */
10
+ attention: boolean
11
+ }
12
+
13
+ export function SectionHeading({ children, hint }: { children: ReactNode; hint?: ReactNode }) {
14
+ return (
15
+ <div className="mb-2 mt-5 flex items-baseline gap-2 first:mt-0">
16
+ <h3 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{children}</h3>
17
+ {hint && <span className="text-[11px] text-theme-text-tertiary">{hint}</span>}
18
+ </div>
19
+ )
20
+ }
21
+
22
+ /**
23
+ * A section folded to one summary line. It opens itself when `attention`
24
+ * turns true (data arriving after the first render included), and stays as
25
+ * the reader left it otherwise.
26
+ */
27
+ export function FoldSection({
28
+ title,
29
+ hint,
30
+ summary,
31
+ attention,
32
+ children,
33
+ }: {
34
+ title: ReactNode
35
+ hint?: ReactNode
36
+ summary: ReactNode
37
+ attention: boolean
38
+ children: ReactNode
39
+ }) {
40
+ const [open, setOpen] = useState(attention)
41
+ useEffect(() => {
42
+ if (attention) setOpen(true)
43
+ }, [attention])
44
+ const d = useDisclosure(open)
45
+ return (
46
+ <div className="mt-5 first:mt-0">
47
+ <h3>
48
+ <button
49
+ type="button"
50
+ {...d.buttonProps}
51
+ onClick={() => setOpen((v) => !v)}
52
+ className="flex w-full items-start gap-2 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
53
+ >
54
+ <CollapseChevron open={open} className="mt-1 h-3 w-3 shrink-0 text-theme-text-tertiary" />
55
+ {/* Wrapped parts line up under the title, not under the chevron. */}
56
+ <span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
57
+ <span className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{title}</span>
58
+ {hint && <span className="text-[11px] text-theme-text-tertiary">{hint}</span>}
59
+ {!open && <span className={clsx('min-w-0 text-sm', attention ? toneTextClass('degraded') : 'text-theme-text-secondary')}>{summary}</span>}
60
+ </span>
61
+ </button>
62
+ </h3>
63
+ <Collapse open={open} id={d.panelId}>
64
+ <div className="pt-2">{children}</div>
65
+ </Collapse>
66
+ </div>
67
+ )
68
+ }
@@ -0,0 +1,62 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+ import { ForceDeleteConfirmDialog } from './ForceDeleteConfirmDialog'
6
+
7
+ let root: Root
8
+ const base = { open: true, onClose: vi.fn(), onConfirm: vi.fn(), resourceName: 'web', resourceKind: 'Deployment', namespaceName: 'demo', isLoading: false }
9
+ beforeEach(() => {
10
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
11
+ const host = document.createElement('div')
12
+ document.body.appendChild(host)
13
+ root = createRoot(host)
14
+ })
15
+ afterEach(async () => {
16
+ await act(async () => root.unmount())
17
+ document.body.replaceChildren()
18
+ vi.clearAllMocks()
19
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: false })
20
+ })
21
+
22
+ describe('Deletion preview states', () => {
23
+ it('states the namespace-wide deletion consequence without relying on topology', async () => {
24
+ await act(async () => root.render(<ForceDeleteConfirmDialog {...base} resourceKind="Namespace" resourceName="demo" namespaceName="" cascadeRootResolved={false} />))
25
+ expect(document.body.textContent).toContain('delete the namespace "demo" and the resources in it')
26
+ expect(document.body.textContent).not.toContain("not in Radar's topology")
27
+ })
28
+ it('shows one advisory statement for a resolved preview', async () => {
29
+ await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeRootResolved cascadeDependents={[{ kind: 'Pod', namespace: 'demo', name: 'web-pod' }]} />))
30
+ const toggle = [...document.querySelectorAll('button')].find(b => b.textContent?.includes('May affect'))!
31
+ await act(async () => toggle.click())
32
+ expect(document.body.textContent).toContain('web-pod')
33
+ expect(document.body.textContent?.match(/does not predict what will be deleted/g)).toHaveLength(1)
34
+ expect(document.body.textContent).not.toContain('Other resources may be affected')
35
+ })
36
+ it.each([[], undefined])('omits the advisory statement for an empty resolved preview (%j)', async (dependents) => {
37
+ await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeRootResolved cascadeDependents={dependents} />))
38
+ expect(document.body.textContent).not.toContain('May affect')
39
+ expect(document.body.textContent).not.toContain('does not predict')
40
+ expect(document.body.textContent).not.toContain('unavailable')
41
+ expect(document.body.textContent).not.toContain('Other resources may be affected')
42
+ })
43
+ it('distinguishes an observed unresolved root from a fetch failure', async () => {
44
+ await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeRootResolved={false} />))
45
+ expect(document.body.textContent).toContain("This resource is not in Radar's topology")
46
+ await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeRootResolved={false} cascadePreviewFailed cascadeDependents={[{ kind: 'Pod', namespace: 'demo', name: 'stale' }]} />))
47
+ expect(document.body.textContent).toContain('Related-resource preview unavailable.')
48
+ expect(document.body.textContent).not.toContain('This resource is not in')
49
+ expect(document.body.textContent).not.toContain('Other resources may be affected')
50
+ expect(document.body.textContent).not.toContain('May affect')
51
+ const confirm = [...document.querySelectorAll('button')].find(b => b.textContent === 'Delete')!
52
+ expect(confirm.disabled).toBe(false)
53
+ await act(async () => confirm.click())
54
+ expect(base.onConfirm).toHaveBeenCalledWith(false)
55
+ })
56
+ it('shows loading without presenting stale failure or success as a result', async () => {
57
+ await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeLoading cascadePreviewFailed cascadeRootResolved />))
58
+ expect(document.body.textContent).toContain('Checking related resources')
59
+ expect(document.body.textContent).not.toContain('unavailable')
60
+ expect(document.body.textContent).not.toContain('does not predict')
61
+ })
62
+ })
@@ -1,5 +1,5 @@
1
1
  import { useState, useMemo } from 'react'
2
- import { AlertTriangle, Loader2 } from 'lucide-react'
2
+ import { Loader2 } from 'lucide-react'
3
3
  import { Collapse, CollapseChevron, useDisclosure } from './Collapse'
4
4
  import { ConfirmDialog } from './ConfirmDialog'
5
5
  import { pluralize } from '../../utils/pluralize'
@@ -22,6 +22,7 @@ interface ForceDeleteConfirmDialogProps {
22
22
  cascadeDependents?: CascadeDependent[]
23
23
  cascadeLoading?: boolean
24
24
  cascadeRootResolved?: boolean
25
+ cascadePreviewFailed?: boolean
25
26
  }
26
27
 
27
28
  export function ForceDeleteConfirmDialog({
@@ -35,8 +36,10 @@ export function ForceDeleteConfirmDialog({
35
36
  cascadeDependents,
36
37
  cascadeLoading,
37
38
  cascadeRootResolved,
39
+ cascadePreviewFailed,
38
40
  }: ForceDeleteConfirmDialogProps) {
39
41
  const [forceDelete, setForceDelete] = useState(false)
42
+ const isNamespace = resourceKind.toLowerCase() === 'namespace'
40
43
 
41
44
  function handleClose() {
42
45
  onClose()
@@ -54,7 +57,7 @@ export function ForceDeleteConfirmDialog({
54
57
  onConfirm={handleConfirm}
55
58
  title="Delete Resource"
56
59
  message={`Are you sure you want to delete "${resourceName}"?`}
57
- details={`This will permanently delete the ${resourceKind} "${resourceName}" from the "${namespaceName}" namespace.`}
60
+ details={isNamespace ? `This will permanently delete the namespace "${resourceName}" and the resources in it.` : `This will permanently delete the ${resourceKind} "${resourceName}" from the "${namespaceName}" namespace.`}
58
61
  confirmLabel={forceDelete ? 'Force Delete' : 'Delete'}
59
62
  variant="danger"
60
63
  isLoading={isLoading}
@@ -63,21 +66,30 @@ export function ForceDeleteConfirmDialog({
63
66
  {cascadeLoading && (
64
67
  <div className="flex items-center gap-2 text-xs text-theme-text-tertiary">
65
68
  <Loader2 className="w-3.5 h-3.5 animate-spin" />
66
- Checking for dependent resources...
69
+ Checking related resources...
67
70
  </div>
68
71
  )}
69
72
 
70
- {!cascadeLoading && cascadeDependents && cascadeDependents.length > 0 && (
73
+ {!cascadeLoading && !cascadePreviewFailed && cascadeDependents && cascadeDependents.length > 0 && (
71
74
  <CascadeDependentsList dependents={cascadeDependents} />
72
75
  )}
73
76
 
74
- {!cascadeLoading && cascadeRootResolved === false && (
75
- <div className="flex items-start gap-2 rounded border border-theme-border bg-theme-elevated px-3 py-2 text-xs text-warning-text">
76
- <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
77
- <span>
78
- Radar couldn&apos;t verify which dependent resources Kubernetes will also delete. Additional resources may be deleted but aren&apos;t shown here.
79
- </span>
80
- </div>
77
+ {!cascadeLoading && cascadePreviewFailed && (
78
+ <p className="text-xs text-theme-text-tertiary">
79
+ Related-resource preview unavailable.
80
+ </p>
81
+ )}
82
+
83
+ {!isNamespace && !cascadeLoading && !cascadePreviewFailed && cascadeRootResolved === false && (
84
+ <p className="text-xs text-theme-text-tertiary">
85
+ This resource is not in Radar&apos;s topology, so its related resources cannot be previewed.
86
+ </p>
87
+ )}
88
+
89
+ {!cascadeLoading && !cascadePreviewFailed && cascadeRootResolved === true && cascadeDependents && cascadeDependents.length > 0 && (
90
+ <p className="text-xs text-theme-text-secondary">
91
+ This list shows visible related resources; it does not predict what will be deleted.
92
+ </p>
81
93
  )}
82
94
 
83
95
  <label className="flex items-center gap-2 text-sm text-theme-text-secondary">
@@ -120,7 +132,7 @@ function CascadeDependentsList({ dependents }: { dependents: CascadeDependent[]
120
132
  >
121
133
  <CollapseChevron open={expanded} inheritColor className="w-3.5 h-3.5" />
122
134
  <span>
123
- Will also delete {pluralize(dependents.length, 'dependent resource')}
135
+ May affect {pluralize(dependents.length, 'related resource')}
124
136
  </span>
125
137
  </button>
126
138
 
@@ -0,0 +1,20 @@
1
+ import type { ReactNode } from 'react'
2
+ import { clsx } from 'clsx'
3
+ import type { ResourceRef } from '../../types/core'
4
+
5
+ export type NavigateToRef = (ref: ResourceRef) => void
6
+
7
+ /** A reference to another object: a link when the host can navigate, plain text otherwise. */
8
+ export function RefLink({ refTo, onNavigate, children, mono }: { refTo: ResourceRef; onNavigate?: NavigateToRef; children?: ReactNode; mono?: boolean }) {
9
+ const label = children ?? refTo.name
10
+ if (!onNavigate) return <span className={clsx(mono && 'font-mono')}>{label}</span>
11
+ return (
12
+ <button
13
+ type="button"
14
+ onClick={() => onNavigate(refTo)}
15
+ className={clsx('text-accent-text hover:underline text-left break-all', mono && 'font-mono')}
16
+ >
17
+ {label}
18
+ </button>
19
+ )
20
+ }
@@ -56,14 +56,19 @@ export function RowActionMenu({ items, ariaLabel = 'Row actions', compact = true
56
56
  const onDown = (e: MouseEvent) => {
57
57
  if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
58
58
  }
59
+ // Capture phase, consumed: Escape that closes the menu must not also run
60
+ // the page's own Escape (close the drawer, leave the full view).
59
61
  const onKey = (e: KeyboardEvent) => {
60
- if (e.key === 'Escape') setOpen(false)
62
+ if (e.key !== 'Escape') return
63
+ e.preventDefault()
64
+ e.stopPropagation()
65
+ setOpen(false)
61
66
  }
62
67
  document.addEventListener('mousedown', onDown)
63
- document.addEventListener('keydown', onKey)
68
+ document.addEventListener('keydown', onKey, true)
64
69
  return () => {
65
70
  document.removeEventListener('mousedown', onDown)
66
- document.removeEventListener('keydown', onKey)
71
+ document.removeEventListener('keydown', onKey, true)
67
72
  }
68
73
  }, [open])
69
74
 
@@ -36,6 +36,7 @@ class ToastManager {
36
36
  private static instance: ToastManager
37
37
  private showErrorFn: ((message: string, detail?: string) => void) | null = null
38
38
  private showSuccessFn: ((message: string, detail?: string, action?: Toast['action'], onDetailClick?: () => void) => void) | null = null
39
+ private showToastFn: ToastContextType['showToast'] | null = null
39
40
 
40
41
  static getInstance(): ToastManager {
41
42
  if (!ToastManager.instance) {
@@ -44,14 +45,16 @@ class ToastManager {
44
45
  return ToastManager.instance
45
46
  }
46
47
 
47
- register(showError: typeof this.showErrorFn, showSuccess: typeof this.showSuccessFn) {
48
+ register(showError: typeof this.showErrorFn, showSuccess: typeof this.showSuccessFn, showToast: ToastContextType['showToast']) {
48
49
  this.showErrorFn = showError
49
50
  this.showSuccessFn = showSuccess
51
+ this.showToastFn = showToast
50
52
  }
51
53
 
52
54
  unregister() {
53
55
  this.showErrorFn = null
54
56
  this.showSuccessFn = null
57
+ this.showToastFn = null
55
58
  }
56
59
 
57
60
  showError(message: string, detail?: string) {
@@ -61,6 +64,10 @@ class ToastManager {
61
64
  showSuccess(message: string, detail?: string, action?: Toast['action'], onDetailClick?: () => void) {
62
65
  this.showSuccessFn ? this.showSuccessFn(message, detail, action, onDetailClick) : console.log('[Toast]', message, detail)
63
66
  }
67
+
68
+ showNotice(message: string, detail: string | undefined, type: 'warning' | 'info') {
69
+ this.showToastFn ? this.showToastFn(message, { detail, type }) : console.warn('[Toast]', message, detail)
70
+ }
64
71
  }
65
72
 
66
73
  const toastManager = ToastManager.getInstance()
@@ -73,6 +80,10 @@ export function showApiSuccess(message: string, detail?: string, action?: Toast[
73
80
  toastManager.showSuccess(message, detail, action, onDetailClick)
74
81
  }
75
82
 
83
+ export function showApiNotice(message: string, detail: string | undefined, type: 'warning' | 'info') {
84
+ toastManager.showNotice(message, detail, type)
85
+ }
86
+
76
87
  export function useToast() {
77
88
  const context = useContext(ToastContext)
78
89
  if (!context) {
@@ -138,9 +149,9 @@ export function ToastProvider({ children }: { children: ReactNode }) {
138
149
 
139
150
  // Wire up singleton for use outside React components
140
151
  useEffect(() => {
141
- toastManager.register(showError, showSuccess)
152
+ toastManager.register(showError, showSuccess, showToast)
142
153
  return () => toastManager.unregister()
143
- }, [showError, showSuccess])
154
+ }, [showError, showSuccess, showToast])
144
155
 
145
156
  return (
146
157
  <ToastContext.Provider value={{ showToast, showCopied, showError, showSuccess }}>
@@ -233,7 +244,7 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
233
244
  {toast.detail}
234
245
  </button>
235
246
  ) : (
236
- <p className={clsx('mt-1 text-xs break-words', isError ? 'text-red-300/80' : isSuccess ? 'text-emerald-300/80' : 'text-theme-text-secondary')}>
247
+ <p className={clsx('mt-1 text-xs break-words whitespace-pre-line', isError ? 'text-red-300/80' : isSuccess ? 'text-emerald-300/80' : 'text-theme-text-secondary')}>
237
248
  {toast.detail}
238
249
  </p>
239
250
  )