@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
@@ -19,6 +19,7 @@ export interface DrainPlanPod {
19
19
  pdb?: string
20
20
  pdbChecked: boolean
21
21
  terminating?: boolean
22
+ expectedSkip?: boolean
22
23
  }
23
24
 
24
25
  export interface DrainPlan {
@@ -95,7 +96,8 @@ const OUTCOME_LABEL: Record<DrainOutcome, string> = {
95
96
 
96
97
  const ESTIMATE_CAVEAT =
97
98
  'An estimate, not a guarantee: the drain re-lists live state when it runs, and pods, budgets and permissions can change until then. ' +
98
- 'Evictions a budget refuses are retried until the drain deadline (60 seconds, shared by all evictions); a pod covered by more than one budget is refused outright.'
99
+ 'Drain normally waits up to 2 minutes for evictions and pod deletion, retrying when a disruption budget temporarily blocks eviction. ' +
100
+ 'Older Radar versions may return before pods are deleted. A pod covered by more than one budget is refused outright.'
99
101
 
100
102
  interface DrainPlanContentProps {
101
103
  nodeName: string
@@ -217,7 +219,9 @@ export function DrainPlanContent({
217
219
  {current.pods.map((p) => (
218
220
  <tr key={`${p.namespace}/${p.name}`} className="border-b border-theme-border last:border-b-0 align-top">
219
221
  <td className="px-2 py-1.5 whitespace-nowrap">
220
- <Badge severity={OUTCOME_SEVERITY[p.outcome]} size="sm">{OUTCOME_LABEL[p.outcome]}</Badge>
222
+ <Badge severity={p.outcome === 'skip' && p.expectedSkip === false ? 'warning' : OUTCOME_SEVERITY[p.outcome]} size="sm">
223
+ {p.outcome === 'skip' && p.expectedSkip !== undefined ? p.expectedSkip ? 'left by design' : 'requires attention' : OUTCOME_LABEL[p.outcome]}
224
+ </Badge>
221
225
  </td>
222
226
  <td className="px-2 py-1.5 font-mono whitespace-nowrap text-theme-text-primary">
223
227
  {p.namespace}/{p.name}
@@ -266,3 +266,10 @@ it("drops a refresh from an apply made before going back to a newer review", asy
266
266
  expect.objectContaining({ reviewedResourceVersion: "3" }),
267
267
  );
268
268
  });
269
+
270
+ it("uses the accent token for a generic ConfigMap Edit control", () => {
271
+ render({ onSave: async () => {}, onPreview: async () => reviewed("1") });
272
+ const edit = [...host.querySelectorAll("button")].find((b) => b.textContent?.trim() === "Edit")!;
273
+ expect(edit.classList.contains("text-accent-text")).toBe(true);
274
+ expect(edit.className).not.toContain("text-blue-");
275
+ });
@@ -552,7 +552,7 @@ export function EditableYamlView({
552
552
  {!readOnly && (
553
553
  <button
554
554
  onClick={handleStartEdit}
555
- className="flex items-center gap-1 px-2 py-1 text-xs text-blue-400 hover:text-blue-300 hover:bg-theme-elevated rounded"
555
+ className="flex items-center gap-1 px-2 py-1 text-xs text-accent-text hover:text-accent-light hover:bg-theme-elevated rounded"
556
556
  >
557
557
  <Pencil className="w-3.5 h-3.5" />
558
558
  Edit
@@ -28,7 +28,7 @@ import { DialogPortal } from '../ui/DialogPortal'
28
28
  import type { SelectedResource, WorkloadRevision } from '../../types'
29
29
  import type { RadarUpgradeRequirement } from '../../types/fetch-error'
30
30
  import { displayKindName } from '../ui/drawer-components'
31
- import { getDefaultContainerName } from '../resources/resource-utils'
31
+ import { getDefaultContainerName, getExecContainerNames } from '../resources/resource-utils'
32
32
  import { SetImageDialog, type SetImageOwnership } from './SetImageDialog'
33
33
  import { DrainPlanDialog, DEFAULT_DRAIN_DIALOG_OPTIONS, type DrainDialogOptions, type DrainPlan } from './DrainPlanDialog'
34
34
  import type { WorkloadImageInventory, WorkloadImageUpdate } from '../../types/core'
@@ -42,6 +42,7 @@ import { formatRelativeAgeTime } from '../../utils/format'
42
42
 
43
43
  interface ResourceActionsBarProps {
44
44
  resource: SelectedResource
45
+ leadingActions?: React.ReactNode
45
46
  data: any
46
47
  onClose?: () => void
47
48
  hideLogs?: boolean
@@ -80,6 +81,7 @@ interface ResourceActionsBarProps {
80
81
  cascadeDependents?: CascadeDependent[]
81
82
  cascadeLoading?: boolean
82
83
  cascadeRootResolved?: boolean
84
+ cascadePreviewFailed?: boolean
83
85
 
84
86
  // Workload restart
85
87
  onRestart?: (params: { kind: string; namespace: string; name: string }, callbacks?: { onSuccess?: () => void; onError?: (err: unknown) => void }) => void
@@ -155,13 +157,13 @@ interface ResourceActionsBarProps {
155
157
  }
156
158
 
157
159
  export function ResourceActionsBar({
158
- resource, data, onClose, hideLogs, showYaml, onToggleYaml,
160
+ resource, data, leadingActions, onClose, hideLogs, showYaml, onToggleYaml,
159
161
  onCompareTo,
160
162
  onCompareAcrossClusters,
161
163
  canExec, canViewLogs, canPortForward,
162
164
  onOpenTerminal, onOpenLogs: openLogs, onOpenWorkloadLogs: openWorkloadLogs, onCopyCommand,
163
165
  renderPortForward,
164
- onDelete, isDeleting, cascadeDependents, cascadeLoading, cascadeRootResolved,
166
+ onDelete, isDeleting, cascadeDependents, cascadeLoading, cascadeRootResolved, cascadePreviewFailed,
165
167
  onRestart, isRestarting,
166
168
  onLoadImages, onSetImages, isSettingImages, imageOwnership,
167
169
  revisions: revisionsList, revisionsLoading, revisionsError, onRollback, isRollingBack,
@@ -252,7 +254,7 @@ export function ResourceActionsBar({
252
254
  namespace: resource.namespace,
253
255
  podName: resource.name,
254
256
  containerName: getDefaultContainerName(data) || containers[0],
255
- containers,
257
+ containers: getExecContainerNames(data),
256
258
  })
257
259
  }
258
260
  }
@@ -291,6 +293,8 @@ export function ResourceActionsBar({
291
293
 
292
294
  return (
293
295
  <div className="flex items-center gap-1.5 px-4 py-2 flex-wrap">
296
+ <div className="flex min-w-0 flex-wrap items-center gap-1.5 empty:hidden" data-action-group="object">
297
+ {leadingActions}
294
298
  {/* Kind-specific actions (left) */}
295
299
  {kind === 'pods' && (
296
300
  <>
@@ -366,7 +370,7 @@ export function ResourceActionsBar({
366
370
  onUncordonNode && (
367
371
  <button
368
372
  onClick={() => onUncordonNode({ name: resource.name })}
369
- disabled={isUncordoningNode}
373
+ disabled={isUncordoningNode || isDrainingNode}
370
374
  className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition-colors disabled:opacity-50"
371
375
  >
372
376
  <Play className={`w-3.5 h-3.5 ${isUncordoningNode ? 'animate-pulse' : ''}`} />
@@ -586,8 +590,8 @@ export function ResourceActionsBar({
586
590
  </button>
587
591
  )}
588
592
 
589
- {/* Spacer pushes universal actions to the right */}
590
- <div className="flex-1" />
593
+ </div>
594
+ <div className="ml-auto flex flex-wrap items-center gap-1.5" data-action-group="utility">
591
595
 
592
596
  {/* Universal actions (right-aligned). The AI/Diagnose action is NOT here — it
593
597
  lives in the detail header chrome (see WorkloadView), set apart from these
@@ -673,6 +677,8 @@ export function ResourceActionsBar({
673
677
  </Tooltip>
674
678
  )}
675
679
 
680
+ </div>
681
+
676
682
  <ForceDeleteConfirmDialog
677
683
  open={showDeleteConfirm}
678
684
  onClose={() => setShowDeleteConfirm(false)}
@@ -684,6 +690,7 @@ export function ResourceActionsBar({
684
690
  cascadeDependents={cascadeDependents}
685
691
  cascadeLoading={cascadeLoading}
686
692
  cascadeRootResolved={cascadeRootResolved}
693
+ cascadePreviewFailed={cascadePreviewFailed}
687
694
  />
688
695
 
689
696
  {onLoadImages && onSetImages && (
@@ -193,6 +193,7 @@ import {
193
193
  CNPGDatabaseRenderer,
194
194
  CNPGPublicationRenderer,
195
195
  CNPGSubscriptionRenderer,
196
+ CNPGDatabaseRoleRenderer,
196
197
  CNPGImageCatalogRenderer,
197
198
  KyvernoGlobalContextRenderer,
198
199
  KyvernoUpdateRequestRenderer,
@@ -313,6 +314,7 @@ import type { ScalerDiagnosis } from '../resources/renderers/WorkloadRenderer'
313
314
  * When an override is not provided, the base (shared) renderer is used.
314
315
  */
315
316
  export interface RendererOverrides {
317
+ FluxHelmReleaseRenderer?: React.ComponentType<React.ComponentProps<typeof FluxHelmReleaseRenderer>>
316
318
  JobRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
317
319
  JobSetRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
318
320
  RayJobRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
@@ -454,7 +456,7 @@ const KNOWN_KINDS = new Set([
454
456
  'rollouts', 'analysisruns', 'analysistemplates', 'clusteranalysistemplates', 'experiments', 'certificates', 'workflows', 'persistentvolumes',
455
457
  'storageclasses', 'certificaterequests', 'clusterissuers', 'issuers',
456
458
  'orders', 'challenges',
457
- 'gateways', 'gatewayclasses', 'httproutes', 'grpcroutes', 'tcproutes', 'tlsroutes', 'sealedsecrets', 'workflowtemplates', 'clusterworkflowtemplates',
459
+ 'gateways', 'gatewayclasses', 'httproutes', 'grpcroutes', 'tcproutes', 'tlsroutes', 'udproutes', 'sealedsecrets', 'workflowtemplates', 'clusterworkflowtemplates',
458
460
  'networkpolicies', 'networkpolicy',
459
461
  'globalnetworkpolicies', 'globalnetworkpolicy',
460
462
  'stagednetworkpolicies', 'stagednetworkpolicy',
@@ -494,7 +496,7 @@ const KNOWN_KINDS = new Set([
494
496
  'externalsecrets', 'clusterexternalsecrets', 'secretstores', 'clustersecretstores',
495
497
  'clusters', 'scheduledbackups', 'poolers', 'objectstores',
496
498
  'globalcontextentries', 'updaterequests', 'ephemeralreports', 'clusterephemeralreports',
497
- 'databases', 'publications', 'imagecatalogs', 'clusterimagecatalogs',
499
+ 'databases', 'publications', 'imagecatalogs', 'clusterimagecatalogs', 'databaseroles',
498
500
  'virtualservices', 'destinationrules', 'serviceentries',
499
501
  'peerauthentications', 'authorizationpolicies',
500
502
  'mutatingwebhookconfigurations', 'validatingwebhookconfigurations',
@@ -702,7 +704,7 @@ export function ResourceRendererDispatch({
702
704
  const isGroupGatedKind =
703
705
  kind === 'clusters' || kind === 'backups' || kind === 'scheduledbackups' || kind === 'poolers'
704
706
  || kind === 'objectstores' || kind === 'databases' || kind === 'publications'
705
- || kind === 'subscriptions' || kind === 'imagecatalogs' || kind === 'clusterimagecatalogs' || kind === 'jobsets'
707
+ || kind === 'subscriptions' || kind === 'imagecatalogs' || kind === 'clusterimagecatalogs' || kind === 'databaseroles' || kind === 'jobsets'
706
708
  || kind === 'policies' || kind === 'rollouts' || kind === 'experiments'
707
709
  || kind === 'rayjobs' || kind === 'rayclusters' || kind === 'rayservices' || kind === 'admissionchecks' || kind === 'provisioningrequests'
708
710
  || kind === 'machines' || kind === 'machinesets' || kind === 'workloads' || kind === 'localqueues' || kind === 'clusterqueues'
@@ -713,7 +715,7 @@ export function ResourceRendererDispatch({
713
715
  || ((kind === 'scheduledbackups' || kind === 'poolers') && isCNPGApiVersion)
714
716
  || (kind === 'objectstores' && isApiGroup(data?.apiVersion, 'barmancloud.cnpg.io'))
715
717
  || ((kind === 'databases' || kind === 'publications' || kind === 'imagecatalogs'
716
- || kind === 'clusterimagecatalogs') && isCNPGApiVersion)
718
+ || kind === 'clusterimagecatalogs' || kind === 'databaseroles') && isCNPGApiVersion)
717
719
  || (kind === 'subscriptions'
718
720
  && (isCNPGApiVersion || isApiGroup(data?.apiVersion, 'messaging.knative.dev')))
719
721
  || (kind === 'policies' && isApiGroup(data?.apiVersion, 'kyverno.io'))
@@ -754,6 +756,7 @@ export function ResourceRendererDispatch({
754
756
  const isKnownKind = KNOWN_KINDS.has(kind) || isCrossplaneMR || isCrossplaneClaim || isCrossplaneXR
755
757
 
756
758
  const JobComp = rendererOverrides?.JobRenderer ?? JobRenderer
759
+ const FluxHelmReleaseComp = rendererOverrides?.FluxHelmReleaseRenderer ?? FluxHelmReleaseRenderer
757
760
  const JobSetComp = rendererOverrides?.JobSetRenderer ?? JobSetRenderer
758
761
  const RayJobComp = rendererOverrides?.RayJobRenderer ?? RayJobRenderer
759
762
  const RayClusterComp = rendererOverrides?.RayClusterRenderer ?? RayClusterRenderer
@@ -800,7 +803,7 @@ export function ResourceRendererDispatch({
800
803
  {kind !== 'events' && <EventsSection fullscreen={!!renderSidebar} events={events || []} updates={updates || []} isLoading={eventsLoading ?? false} eventsError={eventsError ?? null} updatesError={updatesError ?? null} hint={eventsHint} />}
801
804
  <LabelsSection data={data} />
802
805
  <AnnotationsSection data={data} />
803
- <MetadataSection data={data} />
806
+ <MetadataSection data={data} onNavigate={onNavigate} />
804
807
  </>
805
808
  )
806
809
 
@@ -860,6 +863,7 @@ export function ResourceRendererDispatch({
860
863
  {kind === 'grpcroutes' && <GRPCRouteRenderer data={data} onNavigate={onNavigate} />}
861
864
  {kind === 'tcproutes' && <SimpleRouteRenderer data={data} kind="TCPRoute" onNavigate={onNavigate} />}
862
865
  {kind === 'tlsroutes' && <SimpleRouteRenderer data={data} kind="TLSRoute" onNavigate={onNavigate} />}
866
+ {kind === 'udproutes' && <SimpleRouteRenderer data={data} kind="UDPRoute" onNavigate={onNavigate} />}
863
867
  {kind === 'sealedsecrets' && <SealedSecretRenderer data={data} onNavigate={onNavigate} />}
864
868
  {(kind === 'workflowtemplates' || kind === 'clusterworkflowtemplates') && <WorkflowTemplateRenderer data={data} />}
865
869
  {isCoreNetworkPolicy && <NetworkPolicyRenderer data={data} />}
@@ -877,7 +881,7 @@ export function ResourceRendererDispatch({
877
881
  {kind === 'ocirepositories' && <OCIRepositoryRenderer data={data} />}
878
882
  {kind === 'helmrepositories' && <HelmRepositoryRenderer data={data} />}
879
883
  {kind === 'kustomizations' && <KustomizationRenderer data={data} onNavigate={onNavigate} />}
880
- {kind === 'helmreleases' && <FluxHelmReleaseRenderer data={data} onNavigate={onNavigate} />}
884
+ {kind === 'helmreleases' && <FluxHelmReleaseComp data={data} onNavigate={onNavigate} />}
881
885
  {kind === 'alerts' && <AlertRenderer data={data} />}
882
886
  {kind === 'applications' && <ArgoApplicationRenderer data={data} />}
883
887
  {kind === 'nodepools' && <KarpenterNodePoolComp data={data} onNavigate={onNavigate} />}
@@ -926,6 +930,7 @@ export function ResourceRendererDispatch({
926
930
  {kind === 'databases' && isApiGroup(data.apiVersion, CNPG_GROUP) && <CNPGDatabaseComp data={data} onNavigate={onNavigate} />}
927
931
  {kind === 'publications' && isApiGroup(data.apiVersion, CNPG_GROUP) && <CNPGPublicationComp data={data} onNavigate={onNavigate} />}
928
932
  {kind === 'subscriptions' && isApiGroup(data.apiVersion, CNPG_GROUP) && <CNPGSubscriptionComp data={data} onNavigate={onNavigate} />}
933
+ {kind === 'databaseroles' && isApiGroup(data.apiVersion, CNPG_GROUP) && <CNPGDatabaseRoleRenderer data={data} onNavigate={onNavigate} />}
929
934
  {(kind === 'imagecatalogs' || kind === 'clusterimagecatalogs') && isApiGroup(data.apiVersion, CNPG_GROUP) && <CNPGImageCatalogComp data={data} onNavigate={onNavigate} />}
930
935
  {kind === 'backups' && isApiGroup(data.apiVersion, CNPG_GROUP) && <CNPGBackupRenderer data={data} onNavigate={onNavigate} />}
931
936
  {kind === 'backups' && isApiGroup(data.apiVersion, 'velero.io') && <VeleroBackupComp data={data} onNavigate={onNavigate} />}
@@ -971,8 +976,8 @@ export function ResourceRendererDispatch({
971
976
  {kind === 'azuremanagedcontrolplanes' && <AzureManagedControlPlaneRenderer data={data} onNavigate={onNavigate} />}
972
977
  {kind === 'azuremanagedmachinepools' && <AzureManagedMachinePoolRenderer data={data} onNavigate={onNavigate} />}
973
978
  {kind === 'azuremachines' && <AzureMachineRenderer data={data} onNavigate={onNavigate} />}
974
- {kind === 'virtualservices' && <IstioVirtualServiceRenderer data={data} onNavigate={onNavigate} />}
975
- {kind === 'destinationrules' && <IstioDestinationRuleRenderer data={data} onNavigate={onNavigate} />}
979
+ {kind === 'virtualservices' && <IstioVirtualServiceRenderer data={data} onNavigate={onNavigate} serviceDomain={relationships?.istioServiceDomain} />}
980
+ {kind === 'destinationrules' && <IstioDestinationRuleRenderer data={data} onNavigate={onNavigate} serviceDomain={relationships?.istioServiceDomain} />}
976
981
  {kind === 'serviceentries' && <IstioServiceEntryRenderer data={data} />}
977
982
  {kind === 'peerauthentications' && <IstioPeerAuthenticationRenderer data={data} />}
978
983
  {kind === 'authorizationpolicies' && <IstioAuthorizationPolicyRenderer data={data} />}
@@ -1190,7 +1195,7 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
1190
1195
  return getGatewayStatus(data)
1191
1196
  }
1192
1197
  if (k === 'gatewayclasses') return getGatewayClassStatus(data)
1193
- if (k === 'httproutes' || k === 'grpcroutes' || k === 'tcproutes' || k === 'tlsroutes') return getRouteStatus(data)
1198
+ if (k === 'httproutes' || k === 'grpcroutes' || k === 'tcproutes' || k === 'tlsroutes' || k === 'udproutes') return getRouteStatus(data)
1194
1199
  if (k === 'sealedsecrets') return getSealedSecretStatus(data)
1195
1200
  if (k === 'poddisruptionbudgets') return getPDBStatus(data)
1196
1201
  if (k === 'gitrepositories') return getGitRepositoryStatus(data)
@@ -1247,7 +1252,7 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
1247
1252
  }
1248
1253
  // Group-guarded for the same reason as the renderers: these plurals are
1249
1254
  // generic enough that another operator can serve them.
1250
- if ((k === 'databases' || k === 'publications' || k === 'subscriptions') && isApiGroup(data.apiVersion, CNPG_GROUP)) {
1255
+ if ((k === 'databases' || k === 'publications' || k === 'subscriptions' || k === 'databaseroles') && isApiGroup(data.apiVersion, CNPG_GROUP)) {
1251
1256
  return getCNPGDeclarativeStatus(data)
1252
1257
  }
1253
1258
  if (k === 'globalcontextentries') return getKyvernoGlobalContextStatus(data)
@@ -11,3 +11,5 @@ export { CreateResourceDialog, type CreateResourceDialogProps, type ApplyResult
11
11
  export { HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from './ManagedByChip'
12
12
  export { DetailShell, type DetailShellProps, type DetailShellTab } from './DetailShell'
13
13
  export { classifyDiffLine, hasDiffBodyChange, DiffLine } from './UnifiedDiff'
14
+
15
+ export { ActionConfirmDialog, type ActionConfirmDialogProps, type ActionConfirmResult, type ActionWrite } from './ActionConfirmDialog'
@@ -23,4 +23,73 @@ describe("DiffViewer", () => {
23
23
  expect(html).toContain("Old value:");
24
24
  expect(html).toContain("New value:");
25
25
  });
26
+
27
+ it("renders values kept out of the feed as a badge, not as struck-through text", () => {
28
+ const html = renderToStaticMarkup(
29
+ <DiffViewer
30
+ diff={{
31
+ summary: "pod template annotation checksum/config changed",
32
+ fields: [
33
+ { path: "spec.template.metadata.annotations[checksum/config]", oldValue: "(value hidden)", newValue: "(value hidden)" },
34
+ { path: "spec.template.metadata.annotations[example.com/new]", oldValue: null, newValue: "(set)" },
35
+ { path: "spec.template.metadata.annotations[example.com/old]", oldValue: "(set)", newValue: null },
36
+ { path: "spec.template.spec.affinity", oldValue: "changed", newValue: "changed" },
37
+ ],
38
+ }}
39
+ />,
40
+ );
41
+
42
+ expect(html).not.toContain("line-through");
43
+ expect(html).not.toContain("(value hidden)");
44
+ expect(html).not.toContain("(set)");
45
+ expect(html.match(/value hidden/g)).toHaveLength(4);
46
+ expect(html).toContain("Set");
47
+ expect(html).toContain("Removed");
48
+ });
49
+
50
+ it("names key changes instead of striking through an unchanged key list", () => {
51
+ const html = renderToStaticMarkup(
52
+ <DiffViewer
53
+ diff={{
54
+ summary: "data modified keys: [password]",
55
+ fields: [{ path: "data (modified keys)", oldValue: ["password"], newValue: ["password"] }],
56
+ }}
57
+ />,
58
+ );
59
+ expect(html).toContain("keys changed: <span class=\"font-mono\">password</span>");
60
+ expect(html).not.toContain("line-through");
61
+ expect(html).not.toContain("Old value:");
62
+ });
63
+
64
+ it("merges added, removed and modified key lists of one field into one row", () => {
65
+ const html = renderToStaticMarkup(
66
+ <DiffViewer
67
+ diff={{
68
+ summary: "",
69
+ fields: [
70
+ { path: "data (added keys)", oldValue: null, newValue: ["token"] },
71
+ { path: "data (removed keys)", oldValue: ["old"], newValue: null },
72
+ { path: "data (modified keys)", oldValue: ["a", "b"], newValue: ["a", "b"] },
73
+ { path: "stringData (added keys)", oldValue: null, newValue: ["extra"] },
74
+ { path: "type", oldValue: "Opaque", newValue: "kubernetes.io/tls" },
75
+ ],
76
+ }}
77
+ />,
78
+ );
79
+ expect(html.match(/>data</g)).toHaveLength(1);
80
+ expect(html).toMatch(/keys changed: <span[^>]*>a, b<\/span>.*added: <span[^>]*>token<\/span>.*removed: <span[^>]*>old<\/span>/);
81
+ expect(html).toContain(">stringData<");
82
+ expect(html).toContain("added: <span class=\"font-mono\">extra</span>");
83
+ // Ordinary fields keep the old→new rendering.
84
+ expect(html).toContain("Old value:");
85
+ expect(html).toContain("kubernetes.io/tls");
86
+ });
87
+
88
+ it("keeps the old→new rendering when a key-list path does not carry a list", () => {
89
+ const html = renderToStaticMarkup(
90
+ <DiffViewer diff={{ summary: "", fields: [{ path: "data (modified keys)", oldValue: "x", newValue: "y" }] }} />,
91
+ );
92
+ expect(html).toContain("Old value:");
93
+ expect(html).not.toContain("keys changed");
94
+ });
26
95
  });
@@ -1,6 +1,6 @@
1
1
  import { memo } from 'react'
2
2
  import { clsx } from 'clsx'
3
- import { ArrowRight, Plus, Minus, RefreshCw } from 'lucide-react'
3
+ import { ArrowRight, EyeOff, Plus, Minus, RefreshCw } from 'lucide-react'
4
4
  import type { DiffInfo, FieldChange } from '../../types'
5
5
 
6
6
  interface DiffViewerProps {
@@ -34,19 +34,128 @@ export const DiffViewer = memo(function DiffViewer({ diff, compact = false }: Di
34
34
 
35
35
  {/* Field changes */}
36
36
  <div className="space-y-1.5">
37
- {diff.fields.map((field, idx) => (
38
- <FieldChangeRow key={`${field.path}-${idx}`} field={field} />
39
- ))}
37
+ {diffRows(diff.fields).map((row, idx) =>
38
+ row.kind === 'keys' ? (
39
+ <KeyListRow key={`${row.field}-${idx}`} row={row} />
40
+ ) : (
41
+ <FieldChangeRow key={`${row.field.path}-${idx}`} field={row.field} />
42
+ ),
43
+ )}
40
44
  </div>
41
45
  </div>
42
46
  )
43
47
  })
44
48
 
49
+ // Secret and ConfigMap diffs report which keys changed as name lists, under
50
+ // paths like "data (modified keys)", never values. Rendered as an old→new
51
+ // pair, a modified key reads as removed and re-added, so key lists of one
52
+ // field are merged into a single row that names what happened to each key.
53
+ const KEY_LIST_PATH = /^(.+) \((added|removed|modified) keys\)$/
54
+
55
+ type KeyChange = 'added' | 'removed' | 'modified'
56
+
57
+ interface KeyListDiffRow {
58
+ kind: 'keys'
59
+ field: string
60
+ keys: Record<KeyChange, string[]>
61
+ }
62
+
63
+ type DiffRow = KeyListDiffRow | { kind: 'field'; field: FieldChange }
64
+
65
+ function keyNames(value: unknown): string[] | null {
66
+ if (value === null || value === undefined) return []
67
+ return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : null
68
+ }
69
+
70
+ function diffRows(fields: FieldChange[]): DiffRow[] {
71
+ const rows: DiffRow[] = []
72
+ const byField = new Map<string, KeyListDiffRow>()
73
+ for (const field of fields) {
74
+ const match = KEY_LIST_PATH.exec(field.path)
75
+ const oldKeys = keyNames(field.oldValue)
76
+ const newKeys = keyNames(field.newValue)
77
+ if (!match || !oldKeys || !newKeys) {
78
+ rows.push({ kind: 'field', field })
79
+ continue
80
+ }
81
+ const [, base, change] = match
82
+ let row = byField.get(base)
83
+ if (!row) {
84
+ row = { kind: 'keys', field: base, keys: { added: [], removed: [], modified: [] } }
85
+ byField.set(base, row)
86
+ rows.push(row)
87
+ }
88
+ const list = row.keys[change as KeyChange]
89
+ for (const key of [...oldKeys, ...newKeys]) {
90
+ if (!list.includes(key)) list.push(key)
91
+ }
92
+ }
93
+ return rows
94
+ }
95
+
96
+ const KEY_CHANGE_LABEL: Record<KeyChange, string> = {
97
+ modified: 'keys changed',
98
+ added: 'added',
99
+ removed: 'removed',
100
+ }
101
+
102
+ function KeyListRow({ row }: { row: KeyListDiffRow }) {
103
+ const parts = (['modified', 'added', 'removed'] as const).filter((change) => row.keys[change].length > 0)
104
+ return (
105
+ <div className="rounded bg-theme-surface/50 border border-theme-border px-3 py-2">
106
+ <div className="text-xs font-mono text-theme-text-tertiary mb-1 break-words [overflow-wrap:anywhere]">{row.field}</div>
107
+ <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-sm text-theme-text-secondary">
108
+ {parts.map((change, idx) => (
109
+ <span key={change} className="min-w-0 [overflow-wrap:anywhere]">
110
+ {idx > 0 ? <span aria-hidden className="mr-2 text-theme-text-tertiary">·</span> : null}
111
+ {KEY_CHANGE_LABEL[change]}: <span className="font-mono">{row.keys[change].join(', ')}</span>
112
+ </span>
113
+ ))}
114
+ </div>
115
+ </div>
116
+ )
117
+ }
118
+
45
119
  interface FieldChangeRowProps {
46
120
  field: FieldChange
47
121
  }
48
122
 
123
+ // The backend never sends values it keeps out of the feed (secrets, config
124
+ // blobs, large structures); it sends one of these markers instead. Older
125
+ // events carry a bare "changed" on both sides.
126
+ const HIDDEN_VALUE = '(value hidden)'
127
+ const HIDDEN_SET = '(set)'
128
+
129
+ function hiddenChange(field: FieldChange): 'changed' | 'set' | 'removed' | null {
130
+ const { oldValue, newValue } = field
131
+ const absent = (v: unknown) => v === null || v === undefined
132
+ if ((oldValue === HIDDEN_VALUE && newValue === HIDDEN_VALUE) || (oldValue === 'changed' && newValue === 'changed')) {
133
+ return 'changed'
134
+ }
135
+ if (absent(oldValue) && newValue === HIDDEN_SET) return 'set'
136
+ if (oldValue === HIDDEN_SET && absent(newValue)) return 'removed'
137
+ return null
138
+ }
139
+
49
140
  function FieldChangeRow({ field }: FieldChangeRowProps) {
141
+ const hidden = hiddenChange(field)
142
+ if (hidden) {
143
+ const Icon = hidden === 'set' ? Plus : hidden === 'removed' ? Minus : RefreshCw
144
+ return (
145
+ <div className="rounded bg-theme-surface/50 border border-theme-border px-3 py-2">
146
+ <div className="text-xs font-mono text-theme-text-tertiary mb-1 break-words [overflow-wrap:anywhere]">{field.path}</div>
147
+ <div className="flex flex-wrap items-center gap-2 text-sm text-theme-text-secondary">
148
+ <Icon className="w-3.5 h-3.5 shrink-0" aria-hidden />
149
+ <span>{hidden === 'set' ? 'Set' : hidden === 'removed' ? 'Removed' : 'Changed'}</span>
150
+ <span className="inline-flex items-center gap-1 rounded-full bg-theme-elevated px-2 py-0.5 text-xs text-theme-text-tertiary">
151
+ <EyeOff className="w-3 h-3" aria-hidden />
152
+ value hidden
153
+ </span>
154
+ </div>
155
+ </div>
156
+ )
157
+ }
158
+
50
159
  const isAdded = field.oldValue === null || field.oldValue === undefined
51
160
  const isRemoved = field.newValue === null || field.newValue === undefined
52
161
  const isModified = !isAdded && !isRemoved
@@ -675,7 +675,7 @@ function calculateInterestingnessWithBreakdown(lane: ResourceLane): ScoreBreakdo
675
675
  // Core workloads
676
676
  Deployment: 50, Rollout: 50, StatefulSet: 50, DaemonSet: 50,
677
677
  Service: 45, Ingress: 45, Gateway: 45,
678
- HTTPRoute: 42, GRPCRoute: 42, TCPRoute: 42, TLSRoute: 42,
678
+ HTTPRoute: 42, GRPCRoute: 42, TCPRoute: 42, TLSRoute: 42, UDPRoute: 42,
679
679
  Job: 40, CronJob: 40, Workflow: 40, CronWorkflow: 40,
680
680
  Pod: 30,
681
681
  HorizontalPodAutoscaler: 25,
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import {
3
+ isRolloutEvent,
3
4
  buildHealthSpans,
4
5
  sweepAggregateHealth,
5
6
  formatAggregateHealthTooltip,
@@ -217,3 +218,50 @@ describe('buildLaneMemberSpans (leaf gathering)', () => {
217
218
  expect(members.map((m) => m.name)).toEqual(['web'])
218
219
  })
219
220
  })
221
+
222
+ describe('isRolloutEvent', () => {
223
+ type F = { path: string; oldValue?: unknown; newValue?: unknown }
224
+ const ev = (kind: string, fields: F[], summary = '') => ({
225
+ id: 'e', source: 'informer', eventType: 'update', kind, namespace: 'shop', name: 'web',
226
+ timestamp: new Date(0).toISOString(),
227
+ diff: { summary, fields: fields.map((f) => ({ oldValue: 1, newValue: 2, ...f })) },
228
+ }) as any
229
+
230
+ it('does not treat a pod-template edit alone as a rollout (OnDelete and paused workloads do not roll)', () => {
231
+ expect(isRolloutEvent(ev('Deployment', [{ path: 'spec.template.metadata.annotations[kubectl.kubernetes.io/restartedAt]' }], 'pod template annotation kubectl.kubernetes.io/restartedAt added (new pod template revision)'))).toBe(false)
232
+ expect(isRolloutEvent(ev('DaemonSet', [{ path: 'spec.template.metadata.annotations[checksum/config]' }]))).toBe(false)
233
+ expect(isRolloutEvent(ev('Rollout', [{ path: 'spec.template.spec.containers[].image' }], 'image: web:2'))).toBe(false)
234
+ })
235
+
236
+ it('treats updated-count movement with steady readiness as a rollout', () => {
237
+ expect(isRolloutEvent(ev('Deployment', [{ path: 'status.updatedReplicas', oldValue: 1, newValue: 2 }]))).toBe(true)
238
+ expect(isRolloutEvent(ev('DaemonSet', [{ path: 'status.updatedNumberScheduled', oldValue: 1, newValue: 2 }]))).toBe(true)
239
+ })
240
+
241
+ it('keeps degraded health visible when readiness moves in the same update', () => {
242
+ // A pod swap, a lost pod and a scale-up under failing readiness are
243
+ // indistinguishable from the counts alone.
244
+ expect(isRolloutEvent(ev('DaemonSet', [
245
+ { path: 'status.numberReady', oldValue: 4, newValue: 3 },
246
+ { path: 'status.updatedNumberScheduled', oldValue: 1, newValue: 2 },
247
+ ]))).toBe(false)
248
+ expect(isRolloutEvent(ev('Deployment', [
249
+ { path: 'status.readyReplicas', oldValue: 3, newValue: 2 },
250
+ { path: 'status.updatedReplicas', oldValue: 3, newValue: 5 },
251
+ ]))).toBe(false)
252
+ })
253
+
254
+ it('treats the reset at the start of a rollout as a rollout', () => {
255
+ expect(isRolloutEvent(ev('Deployment', [{ path: 'status.updatedReplicas', oldValue: 3, newValue: 0 }], 'updated: 3→0'))).toBe(true)
256
+ })
257
+
258
+ it('does not treat a lost pod or unrelated changes as a rollout', () => {
259
+ expect(isRolloutEvent(ev('Deployment', [
260
+ { path: 'status.readyReplicas', oldValue: 3, newValue: 2 },
261
+ { path: 'status.updatedReplicas', oldValue: 3, newValue: 2 },
262
+ ]))).toBe(false)
263
+ expect(isRolloutEvent(ev('Deployment', [{ path: 'status.readyReplicas', oldValue: 3, newValue: 1 }]))).toBe(false)
264
+ expect(isRolloutEvent(ev('Deployment', [{ path: 'spec.strategy.rollingUpdate.maxUnavailable' }], 'updated: wording without the field'))).toBe(false)
265
+ expect(isRolloutEvent(ev('Service', [{ path: 'status.updatedReplicas', oldValue: 1, newValue: 2 }]))).toBe(false)
266
+ })
267
+ })
@@ -57,28 +57,23 @@ export interface HealthSpanResult {
57
57
  }
58
58
 
59
59
  /**
60
- * Check if an event indicates a rollout is in progress (expected degradation).
61
- * Rollout signals include:
62
- * - Diff summary contains "updated:" (indicating replica count or image changes)
63
- * - Diff summary mentions image changes
64
- * - Event is for a deployment-like workload with degraded health
60
+ * Check if an event shows a rollout in progress (expected degradation): a
61
+ * deployment-like workload whose updated-replica count moved while its ready
62
+ * count held. Decided from the diff's field paths, not from summary wording.
63
+ *
64
+ * The rule errs toward keeping degraded health visible. A pod-template edit
65
+ * alone does not count (OnDelete and paused workloads take it without
66
+ * rolling), and an update that also moves the ready count does not count
67
+ * either: a lost pod and a scale-up under failing readiness look the same as
68
+ * a pod swap from the counts alone.
65
69
  */
66
- function isRolloutEvent(event: TimelineEvent): boolean {
70
+ export function isRolloutEvent(event: TimelineEvent): boolean {
67
71
  // Only deployment-like workload controllers can have rollout transitions.
68
72
  if (!isDeploymentLikeWorkloadKind(event.kind)) return false
69
-
70
- // Check diff summary for rollout signals
71
- if (event.diff?.summary) {
72
- const summary = event.diff.summary.toLowerCase()
73
- // "updated:" typically indicates replica count changes during rollout
74
- if (summary.includes('updated:')) return true
75
- // Image changes trigger rollouts
76
- if (summary.includes('image(') || summary.includes('image:')) return true
77
- // Spec template changes often indicate rollout
78
- if (summary.includes('template')) return true
79
- }
80
-
81
- return false
73
+ const paths = new Set((event.diff?.fields ?? []).map((field) => field.path))
74
+ const updatedMoved = paths.has('status.updatedReplicas') || paths.has('status.updatedNumberScheduled')
75
+ const readyMoved = paths.has('status.readyReplicas') || paths.has('status.numberReady')
76
+ return updatedMoved && !readyMoved
82
77
  }
83
78
 
84
79
  /**
@@ -172,6 +172,7 @@ export const TIMELINE_RESOURCE_KINDS = [
172
172
  'HTTPRoute',
173
173
  'GRPCRoute',
174
174
  'TCPRoute',
175
+ 'UDPRoute',
175
176
  'TLSRoute',
176
177
  'ReplicaSet',
177
178
  'DaemonSet',
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { baseSubtitle } from './K8sResourceNode'
2
+ import { baseSubtitle, knativeDomainsTooltipLines } from './K8sResourceNode'
3
3
 
4
4
  describe('baseSubtitle', () => {
5
5
  it('describes workflow templates without dropping newer topology kinds', () => {
@@ -18,6 +18,23 @@ describe('baseSubtitle', () => {
18
18
  })).toBe('ClusterIP :80 +2 more')
19
19
  })
20
20
 
21
+ it('describes a domain-mapped Knative Service by its custom domain', () => {
22
+ expect(baseSubtitle('KnativeService', { url: 'http://api.team.svc.cluster.local' })).toBe('api.team.svc.cluster.local')
23
+ expect(baseSubtitle('KnativeService', { url: 'http://api.team.svc.cluster.local', mappedDomains: ['api.example.com'] })).toBe('api.example.com')
24
+ expect(baseSubtitle('KnativeService', { mappedDomains: ['a.example.com', 'b.example.com'] })).toBe('a.example.com +1')
25
+ })
26
+
27
+ it('labels a mapped Knative Service URL by what it is', () => {
28
+ const text = (data: Record<string, unknown>) => knativeDomainsTooltipLines(data).map((l) => l.text)
29
+ // Private: the Service URL is the internal address, shown once.
30
+ expect(text({ mappedDomains: ['api.example.com'], url: 'http://api.team.svc.cluster.local', internalUrl: 'http://api.team.svc.cluster.local' }))
31
+ .toEqual(['api.example.com', 'Internal: http://api.team.svc.cluster.local'])
32
+ // Public: its own URL isn't internal.
33
+ expect(text({ mappedDomains: ['shop.example.com'], url: 'https://shop.team.example.org', internalUrl: 'http://shop.team.svc.cluster.local' }))
34
+ .toEqual(['shop.example.com', 'Service URL: https://shop.team.example.org', 'Internal: http://shop.team.svc.cluster.local'])
35
+ expect(text({ url: 'https://shop.team.example.org' })).toEqual([])
36
+ })
37
+
21
38
  it('describes a Calico staged deletion as a removal, not as selecting everything', () => {
22
39
  // A staged deletion carries no selector, because the Calico API forbids one.
23
40
  // Falling through to the empty-selector default would label the broadest