@skyhook-io/k8s-ui 1.17.1 → 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 (55) hide show
  1. package/package.json +4 -4
  2. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -2
  3. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +27 -0
  4. package/src/components/gitops/insights/GitOpsInsightViews.tsx +16 -10
  5. package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
  6. package/src/components/gitops/insights/insights-helpers.ts +7 -0
  7. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +73 -9
  8. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +45 -28
  9. package/src/components/gitops/tree/tree-helpers.test.ts +6 -0
  10. package/src/components/gitops/tree/tree-helpers.ts +5 -1
  11. package/src/components/issues/IssuesView.tsx +4 -1
  12. package/src/components/issues/ResourceIssuesSection.tsx +5 -2
  13. package/src/components/issues/diagnostic.ts +2 -0
  14. package/src/components/issues/index.ts +1 -1
  15. package/src/components/issues/issues.test.ts +26 -0
  16. package/src/components/issues/severity.ts +9 -2
  17. package/src/components/issues/types.ts +32 -4
  18. package/src/components/logs/LogCore.theme.test.tsx +126 -1
  19. package/src/components/logs/LogCore.tsx +159 -40
  20. package/src/components/logs/LogsViewer.test.tsx +80 -2
  21. package/src/components/logs/LogsViewer.tsx +21 -4
  22. package/src/components/logs/StructuredLogLine.tsx +1 -1
  23. package/src/components/logs/WorkloadLogsViewer.tsx +7 -1
  24. package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
  25. package/src/components/logs/useLogBuffer.ts +48 -18
  26. package/src/components/logs/useLogSearch.ts +3 -1
  27. package/src/components/resources/ColumnPickerList.test.tsx +46 -0
  28. package/src/components/resources/ColumnPickerList.tsx +174 -0
  29. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -2
  30. package/src/components/resources/ResourcesView.tsx +93 -75
  31. package/src/components/resources/column-order.test.ts +104 -0
  32. package/src/components/resources/column-order.ts +64 -0
  33. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
  34. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
  35. package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
  36. package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
  37. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
  38. package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
  39. package/src/components/resources/renderers/PodRenderer.tsx +40 -11
  40. package/src/components/resources/resource-utils.ts +15 -2
  41. package/src/components/resources/summarize-scheduler-message.test.ts +1 -0
  42. package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
  43. package/src/components/shared/DrainPlanDialog.tsx +6 -2
  44. package/src/components/shared/ResourceActionsBar.tsx +3 -3
  45. package/src/components/shared/ResourceRendererDispatch.tsx +3 -1
  46. package/src/components/timeline/DiffViewer.test.tsx +69 -0
  47. package/src/components/timeline/DiffViewer.tsx +113 -4
  48. package/src/components/timeline/shared.test.tsx +48 -0
  49. package/src/components/timeline/shared.tsx +14 -19
  50. package/src/components/ui/Toast.tsx +15 -4
  51. package/src/components/ui/index.ts +1 -1
  52. package/src/components/workload/WorkloadView.tsx +14 -8
  53. package/src/types/core.ts +25 -1
  54. package/src/types/gitops-insights.ts +1 -0
  55. package/src/types/gitops-tree.ts +2 -0
@@ -4,7 +4,7 @@ import { clsx } from 'clsx'
4
4
  import { PolicySection } from './PolicySection'
5
5
  import type { PolicyResourceResponse } from '../../../types/policy'
6
6
  import { Section, PropertyList, Property, ConditionsSection, CopyHandler, AlertBanner, ResourceLink, useOperationalIssuesShown } from '../../ui/drawer-components'
7
- import { formatResources, formatDuration, getPodProblems, getPodPhaseDisplay, healthColors, SEVERITY_DOT_COLOR, getDefaultContainerName } from '../resource-utils'
7
+ import { formatResources, formatDuration, getPodProblems, getPodPhaseDisplay, healthColors, SEVERITY_DOT_COLOR, getDefaultContainerName, getExecContainerNames } from '../resource-utils'
8
8
  import { getResourceStatusColor, SEVERITY_BORDER } from '../../../utils/badge-colors'
9
9
  import {
10
10
  rbacVerbBadgeClass,
@@ -91,7 +91,7 @@ interface PodRendererProps {
91
91
  hideMetricsServer?: boolean
92
92
  // Filesystem browser render props
93
93
  renderImageBrowser?: (props: { image: string; namespace: string; podName: string; pullSecrets: string[]; onClose: () => void; onSwitchToPodFiles?: () => void }) => ReactNode
94
- renderPodBrowser?: (props: { namespace: string; podName: string; containers: string[]; initialContainer: string; onClose: () => void; onSwitchToImageFiles: () => void }) => ReactNode
94
+ renderPodBrowser?: (props: { namespace: string; podName: string; containers: string[]; initialContainer: string; onClose: () => void; onSwitchToImageFiles: (containerName?: string) => void }) => ReactNode
95
95
  /**
96
96
  * Resolved content for envFrom references.
97
97
  * When provided, expands ConfigMap/Secret keys inline instead of showing "(all keys)".
@@ -340,6 +340,16 @@ export function PodRenderer({
340
340
  // Pod filesystem modal state
341
341
  const [podFilesContainer, setPodFilesContainer] = useState<string | null>(null)
342
342
 
343
+ // A sidecar can be running while the pod is still Pending, and a stopped
344
+ // container has no live files, so match the image against running containers.
345
+ const runningImageContainer = selectedImage
346
+ ? getExecContainerNames(data).find((name) => {
347
+ const spec = [...containers, ...initContainers].find((c: any) => c.name === name)
348
+ const status = [...containerStatuses, ...initContainerStatuses].find((st: any) => st.name === name)
349
+ return spec?.image === selectedImage && !!status?.state?.running
350
+ })
351
+ : undefined
352
+
343
353
  const handleOpenTerminal = (containerName?: string) => {
344
354
  const container = containerName || getDefaultContainerName(data)
345
355
  if (namespace && podName && container) {
@@ -347,7 +357,7 @@ export function PodRenderer({
347
357
  namespace,
348
358
  podName,
349
359
  containerName: container,
350
- containers: containers.map((c: { name: string }) => c.name),
360
+ containers: getExecContainerNames(data),
351
361
  })
352
362
  }
353
363
  }
@@ -512,6 +522,7 @@ export function PodRenderer({
512
522
  const isFailed = stateKey === 'terminated' && exitCode != null && exitCode !== 0
513
523
  const isWaiting = stateKey === 'waiting'
514
524
  const isInitRunning = stateKey === 'running'
525
+ const isSidecar = container.restartPolicy === 'Always'
515
526
 
516
527
  // Status label and color
517
528
  let statusLabel: string
@@ -553,6 +564,26 @@ export function PodRenderer({
553
564
  <span className="text-sm font-medium text-theme-text-primary">{container.name}</span>
554
565
  </div>
555
566
  <div className="flex items-center gap-2">
567
+ {isSidecar && isInitRunning && canExec && onOpenTerminal && (
568
+ <Tooltip content="Terminal" delay={150}>
569
+ <button
570
+ onClick={() => handleOpenTerminal(container.name)}
571
+ className="p-1 text-slate-400 hover:text-blue-400 hover:bg-slate-600/50 rounded transition-colors"
572
+ >
573
+ <TerminalIcon className="w-4 h-4" />
574
+ </button>
575
+ </Tooltip>
576
+ )}
577
+ {isSidecar && isInitRunning && canExec && renderPodBrowser && (
578
+ <Tooltip content="Browse files" delay={150}>
579
+ <button
580
+ onClick={() => setPodFilesContainer(container.name)}
581
+ className="p-1 text-slate-400 hover:text-blue-400 hover:bg-slate-600/50 rounded transition-colors"
582
+ >
583
+ <FolderOpen className="w-4 h-4" />
584
+ </button>
585
+ </Tooltip>
586
+ )}
556
587
  {canViewLogs && (
557
588
  <Tooltip content="Logs" delay={150}>
558
589
  <button
@@ -973,10 +1004,8 @@ export function PodRenderer({
973
1004
  podName: podName || '',
974
1005
  pullSecrets: imagePullSecrets,
975
1006
  onClose: () => setSelectedImage(null),
976
- onSwitchToPodFiles: isRunning && canExec && renderPodBrowser ? () => {
977
- // Find which container uses this image and open pod files for it
978
- const match = containers.find((c: any) => c.image === selectedImage)
979
- setPodFilesContainer(match?.name || containers[0]?.name)
1007
+ onSwitchToPodFiles: canExec && renderPodBrowser && (isRunning || runningImageContainer) ? () => {
1008
+ setPodFilesContainer(runningImageContainer || containers[0]?.name)
980
1009
  } : undefined,
981
1010
  })}
982
1011
 
@@ -984,12 +1013,12 @@ export function PodRenderer({
984
1013
  {podFilesContainer && renderPodBrowser && renderPodBrowser({
985
1014
  namespace: namespace || '',
986
1015
  podName: podName || '',
987
- containers: containers.map((c: { name: string }) => c.name),
1016
+ containers: getExecContainerNames(data),
988
1017
  initialContainer: podFilesContainer,
989
1018
  onClose: () => setPodFilesContainer(null),
990
- onSwitchToImageFiles: () => {
991
- // Find which image this container uses and open image filesystem
992
- const match = containers.find((c: any) => c.name === podFilesContainer)
1019
+ onSwitchToImageFiles: (containerName?: string) => {
1020
+ const selected = containerName || podFilesContainer
1021
+ const match = [...containers, ...initContainers].find((c: any) => c.name === selected)
993
1022
  if (match?.image) setSelectedImage(match.image)
994
1023
  },
995
1024
  })}
@@ -106,7 +106,7 @@ export function summarizeSchedulerMessage(message?: string, options: { plain?: b
106
106
  [/^(\d+) (?:node\(s\) )?Insufficient memory(?: \(0\/\d+ nodes available\))?$/i, 'do not have enough memory'],
107
107
  [/^(\d+) node\(s\) (?:didn.t match Pod.s node affinity\/selector|node affinity\/selector mismatch)(?: \(0\/\d+ nodes available\))?$/i, 'do not match the Pod’s node affinity or selector'],
108
108
  [/^(\d+) node\(s\) (?:had )?untolerated taint(?:\(s\))?(?: \{[^}]*\}| [^\s]+)?(?: \(0\/\d+ nodes available\))?$/i, 'have taints the Pod does not tolerate'],
109
- [/^(\d+) node\(s\) (?:had )?volume node[- ]affinity conflict(?: \(0\/\d+ nodes available\))?$/i, 'do not match the volume’s node affinity'],
109
+ [/^(\d+) node\(s\) (?:(?:had )?volume node[- ]affinity conflict|didn.t match PersistentVolume.s node affinity)(?: \(0\/\d+ nodes available\))?$/i, 'do not match the volume’s node affinity'],
110
110
  ]
111
111
  return splitSchedulerClauses(m).filter((part) => part.trim()).map((part) => {
112
112
  const clause = part.trim()
@@ -738,6 +738,19 @@ export function getDefaultContainerName(pod: any): string | undefined {
738
738
  return containers[0]?.name
739
739
  }
740
740
 
741
+ /**
742
+ * The containers a terminal can target: the regular containers, then native
743
+ * sidecars (init containers with restartPolicy: Always, Kubernetes 1.29+),
744
+ * which keep running for the pod's lifetime like regular containers.
745
+ */
746
+ export function getExecContainerNames(pod: any): string[] {
747
+ const regular = (pod?.spec?.containers || []).map((c: any) => c.name)
748
+ const sidecars = (pod?.spec?.initContainers || [])
749
+ .filter((c: any) => c.restartPolicy === 'Always')
750
+ .map((c: any) => c.name)
751
+ return [...regular, ...sidecars].filter(Boolean)
752
+ }
753
+
741
754
  export function getContainerSquareStates(pod: any): ContainerSquareState[] {
742
755
  const result: ContainerSquareState[] = []
743
756
  const initStatuses = pod.status?.initContainerStatuses || []
@@ -1314,7 +1327,7 @@ export function getCronJobStatus(cj: any): StatusBadge {
1314
1327
  if (activeJobs > 0) {
1315
1328
  return { text: `Active (${activeJobs})`, color: healthColors.neutral, level: 'neutral' }
1316
1329
  }
1317
- return { text: 'Scheduled', color: healthColors.healthy, level: 'healthy' }
1330
+ return { text: 'Enabled', color: healthColors.neutral, level: 'neutral' }
1318
1331
  }
1319
1332
 
1320
1333
  export function getCronJobSchedule(cj: any): { cron: string; readable: string } {
@@ -44,6 +44,7 @@ it.each([
44
44
  ['2 node(s) node affinity/selector mismatch (0/2 nodes available)', 'both nodes do not match the Pod’s node affinity or selector'],
45
45
  ['unbound PersistentVolumeClaim, 1 node(s) volume node-affinity conflict (0/2 nodes available)', 'a required volume claim is not bound; 1 node does not match the volume’s node affinity'],
46
46
  ['0/2 nodes are available: 1 node(s) had volume node affinity conflict, 1 Insufficient memory.', '1 node does not match the volume’s node affinity; 1 node does not have enough memory'],
47
+ ["0/3 nodes are available: 1 node(s) didn't satisfy existing pods anti-affinity rules, 2 node(s) didn't match PersistentVolume's node affinity.", "1 node(s) didn't satisfy existing pods anti-affinity rules; 2 nodes do not match the volume’s node affinity"],
47
48
  ['no node has kubernetes.io/arch=arm64 — 2 node(s) carry kubernetes.io/arch: [amd64]; 1 node(s) insufficient cpu (0/3 nodes available)', 'no node has kubernetes.io/arch=arm64 — 2 node(s) carry kubernetes.io/arch: [amd64]; 1 node does not have enough CPU'],
48
49
  ['custom scheduler says 2 Insufficient cpu, 1 Insufficient memory', 'custom scheduler says 2 Insufficient cpu; 1 node does not have enough memory'],
49
50
  ['1 node(s) pod anti-affinity conflict, 1 node(s) insufficient memory, no node has zone=west — 2 node(s) carry zone: [east, north]', '1 node(s) pod anti-affinity conflict; 1 node does not have enough memory; no node has zone=west — 2 node(s) carry zone: [east, north]'],
@@ -108,6 +108,13 @@ describe('DrainPlanContent', () => {
108
108
  expect(html).toContain('About this estimate')
109
109
  })
110
110
 
111
+ it('distinguishes expected exclusions from protected workloads before draining', () => {
112
+ const html = render({ plan: plan([pod('agent', 'skip', { expectedSkip: true }), pod('bare', 'skip', { expectedSkip: false })]) })
113
+ expect(html).toContain('left by design')
114
+ expect(html).toContain('requires attention')
115
+ expect(html).toContain('bare reason')
116
+ })
117
+
111
118
  it('offers a refresh only when the host can recompute the plan', () => {
112
119
  const p = plan([pod('web', 'evict')])
113
120
  expect(render({ plan: p, onRefreshPlan: noop })).toContain('Recompute the plan')
@@ -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}
@@ -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'
@@ -254,7 +254,7 @@ export function ResourceActionsBar({
254
254
  namespace: resource.namespace,
255
255
  podName: resource.name,
256
256
  containerName: getDefaultContainerName(data) || containers[0],
257
- containers,
257
+ containers: getExecContainerNames(data),
258
258
  })
259
259
  }
260
260
  }
@@ -370,7 +370,7 @@ export function ResourceActionsBar({
370
370
  onUncordonNode && (
371
371
  <button
372
372
  onClick={() => onUncordonNode({ name: resource.name })}
373
- disabled={isUncordoningNode}
373
+ disabled={isUncordoningNode || isDrainingNode}
374
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"
375
375
  >
376
376
  <Play className={`w-3.5 h-3.5 ${isUncordoningNode ? 'animate-pulse' : ''}`} />
@@ -314,6 +314,7 @@ import type { ScalerDiagnosis } from '../resources/renderers/WorkloadRenderer'
314
314
  * When an override is not provided, the base (shared) renderer is used.
315
315
  */
316
316
  export interface RendererOverrides {
317
+ FluxHelmReleaseRenderer?: React.ComponentType<React.ComponentProps<typeof FluxHelmReleaseRenderer>>
317
318
  JobRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
318
319
  JobSetRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
319
320
  RayJobRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
@@ -755,6 +756,7 @@ export function ResourceRendererDispatch({
755
756
  const isKnownKind = KNOWN_KINDS.has(kind) || isCrossplaneMR || isCrossplaneClaim || isCrossplaneXR
756
757
 
757
758
  const JobComp = rendererOverrides?.JobRenderer ?? JobRenderer
759
+ const FluxHelmReleaseComp = rendererOverrides?.FluxHelmReleaseRenderer ?? FluxHelmReleaseRenderer
758
760
  const JobSetComp = rendererOverrides?.JobSetRenderer ?? JobSetRenderer
759
761
  const RayJobComp = rendererOverrides?.RayJobRenderer ?? RayJobRenderer
760
762
  const RayClusterComp = rendererOverrides?.RayClusterRenderer ?? RayClusterRenderer
@@ -879,7 +881,7 @@ export function ResourceRendererDispatch({
879
881
  {kind === 'ocirepositories' && <OCIRepositoryRenderer data={data} />}
880
882
  {kind === 'helmrepositories' && <HelmRepositoryRenderer data={data} />}
881
883
  {kind === 'kustomizations' && <KustomizationRenderer data={data} onNavigate={onNavigate} />}
882
- {kind === 'helmreleases' && <FluxHelmReleaseRenderer data={data} onNavigate={onNavigate} />}
884
+ {kind === 'helmreleases' && <FluxHelmReleaseComp data={data} onNavigate={onNavigate} />}
883
885
  {kind === 'alerts' && <AlertRenderer data={data} />}
884
886
  {kind === 'applications' && <ArgoApplicationRenderer data={data} />}
885
887
  {kind === 'nodepools' && <KarpenterNodePoolComp data={data} onNavigate={onNavigate} />}
@@ -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
@@ -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
  /**
@@ -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
  )
@@ -33,7 +33,7 @@ export { Disclosure } from './Disclosure'
33
33
  export { ResourceBar } from './ResourceBar'
34
34
  export { ForceDeleteConfirmDialog } from './ForceDeleteConfirmDialog'
35
35
  export { InClusterConsentDialog } from './InClusterConsentDialog'
36
- export { ToastProvider, useToast, showApiError, showApiSuccess } from './Toast'
36
+ export { ToastProvider, useToast, showApiError, showApiSuccess, showApiNotice } from './Toast'
37
37
  export { CodeViewer } from './CodeViewer'
38
38
  export { YamlEditor, YamlDiffEditor } from './YamlEditor'
39
39
  // Boots the self-hosted Monaco runtime (bundled editor, local workers) without