@skyhook-io/k8s-ui 1.16.3 → 1.17.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 (121) hide show
  1. package/package.json +1 -1
  2. package/src/components/applications/ApplicationDetail.test.tsx +15 -0
  3. package/src/components/applications/ApplicationDetail.tsx +17 -15
  4. package/src/components/checks/types.ts +2 -13
  5. package/src/components/cnpg/primitives.tsx +3 -3
  6. package/src/components/gitops/GitOpsTableView.tsx +9 -24
  7. package/src/components/gitops/detail-helpers.ts +1 -1
  8. package/src/components/gitops/insights/GitOpsInsightViews.tsx +5 -12
  9. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +2 -5
  10. package/src/components/issues/issue-timing.ts +2 -2
  11. package/src/components/issues/issues.test.ts +1 -1
  12. package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
  13. package/src/components/logs/WorkloadLogsViewer.tsx +5 -0
  14. package/src/components/logs/useLogStream.ts +11 -0
  15. package/src/components/resources/ResourcesView.tsx +7 -8
  16. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
  17. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
  18. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +2 -2
  19. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +2 -2
  20. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +2 -2
  21. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +2 -2
  22. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +2 -2
  23. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +2 -2
  24. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +2 -1
  25. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +2 -2
  26. package/src/components/resources/renderers/CNPGShared.tsx +2 -2
  27. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  28. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
  29. package/src/components/resources/renderers/CronJobRenderer.tsx +6 -4
  30. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +3 -2
  31. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +24 -0
  32. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +1 -1
  33. package/src/components/resources/renderers/EventRenderer.tsx +3 -3
  34. package/src/components/resources/renderers/ExperimentRenderer.tsx +2 -2
  35. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
  36. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
  37. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -4
  38. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +11 -24
  39. package/src/components/resources/renderers/GatewayRouteReferences.test.tsx +63 -0
  40. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -3
  41. package/src/components/resources/renderers/HPARenderer.tsx +2 -2
  42. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +12 -25
  43. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -3
  44. package/src/components/resources/renderers/IngressClassReferences.test.tsx +52 -0
  45. package/src/components/resources/renderers/IngressClassRenderer.tsx +10 -3
  46. package/src/components/resources/renderers/IngressRenderer.tsx +6 -2
  47. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -14
  48. package/src/components/resources/renderers/KedaCredentials.test.tsx +85 -0
  49. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
  50. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
  51. package/src/components/resources/renderers/KustomizationRenderer.tsx +2 -2
  52. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +3 -2
  53. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +4 -3
  54. package/src/components/resources/renderers/LeaseRenderer.tsx +3 -15
  55. package/src/components/resources/renderers/NamespaceRenderer.test.ts +69 -2
  56. package/src/components/resources/renderers/NamespaceRenderer.tsx +6 -1
  57. package/src/components/resources/renderers/NodeRenderer.test.tsx +81 -1
  58. package/src/components/resources/renderers/NodeRenderer.tsx +56 -15
  59. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -3
  60. package/src/components/resources/renderers/PodClassReferences.test.tsx +42 -0
  61. package/src/components/resources/renderers/PodNomination.test.tsx +57 -0
  62. package/src/components/resources/renderers/PodRenderer.tsx +21 -7
  63. package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
  64. package/src/components/resources/renderers/RolloutRenderer.tsx +3 -2
  65. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  66. package/src/components/resources/renderers/SecretRenderer.test.tsx +88 -0
  67. package/src/components/resources/renderers/SecretRenderer.tsx +34 -5
  68. package/src/components/resources/renderers/ServiceRenderer.test.tsx +43 -0
  69. package/src/components/resources/renderers/ServiceRenderer.tsx +11 -3
  70. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +8 -21
  71. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +5 -7
  72. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +3 -3
  73. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +2 -2
  74. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +3 -3
  75. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +2 -2
  76. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  77. package/src/components/resources/renderers/WorkflowRenderer.tsx +4 -3
  78. package/src/components/resources/renderers/kyverno-modern-cells.tsx +1 -1
  79. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +2 -2
  80. package/src/components/resources/resource-utils-cnpg.ts +0 -4
  81. package/src/components/resources/resource-utils-keda.ts +1 -7
  82. package/src/components/resources/resource-utils-kyverno.ts +1 -7
  83. package/src/components/resources/resource-utils-velero.ts +0 -12
  84. package/src/components/resources/resource-utils.ts +17 -33
  85. package/src/components/shared/DrainPlanDialog.tsx +1 -0
  86. package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
  87. package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
  88. package/src/components/shared/ResourceActionsBar.tsx +9 -20
  89. package/src/components/shared/ResourceRendererDispatch.tsx +1 -1
  90. package/src/components/shared/SetImageDialog.test.ts +75 -6
  91. package/src/components/shared/SetImageDialog.tsx +92 -84
  92. package/src/components/shared/index.ts +2 -1
  93. package/src/components/timeline/TimelineList.tsx +2 -15
  94. package/src/components/timeline/TimelineSwimlanes.tsx +2 -10
  95. package/src/components/topology/TopologyGraph.tsx +5 -3
  96. package/src/components/ui/ScheduleValue.test.tsx +5 -0
  97. package/src/components/ui/ScheduleValue.tsx +3 -2
  98. package/src/components/ui/drawer-components.test.tsx +66 -1
  99. package/src/components/ui/drawer-components.tsx +43 -29
  100. package/src/components/workload/WorkloadView.deleted.test.tsx +58 -0
  101. package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
  102. package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
  103. package/src/components/workload/WorkloadView.tsx +120 -43
  104. package/src/types/capacity.ts +10 -0
  105. package/src/types/core.ts +32 -1
  106. package/src/utils/format.test.ts +40 -2
  107. package/src/utils/format.ts +5 -1
  108. package/src/utils/gateway-references.test.ts +17 -0
  109. package/src/utils/gateway-references.ts +29 -0
  110. package/src/utils/gitops-write-guard.test.ts +401 -0
  111. package/src/utils/gitops-write-guard.ts +509 -0
  112. package/src/utils/index.ts +2 -0
  113. package/src/utils/ingress-class-references.ts +22 -0
  114. package/src/utils/navigation.test.ts +13 -0
  115. package/src/utils/navigation.ts +5 -0
  116. package/src/utils/node-lifecycle.test.ts +36 -0
  117. package/src/utils/node-lifecycle.ts +77 -0
  118. package/src/utils/resource-hierarchy.test.ts +32 -0
  119. package/src/utils/resource-hierarchy.ts +28 -2
  120. package/src/utils/resource-refs.test.ts +23 -0
  121. package/src/utils/resource-refs.ts +30 -0
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { renderToString } from 'react-dom/server'
2
+ import { renderToString, renderToStaticMarkup } from 'react-dom/server'
3
3
  import { NodeRenderer } from './NodeRenderer'
4
4
 
5
5
  const node = {
@@ -101,3 +101,83 @@ describe('NodeRenderer metrics', () => {
101
101
  expect(html).toContain('CPU')
102
102
  })
103
103
  })
104
+
105
+ describe('NodeRenderer removal advice', () => {
106
+ const removal = {
107
+ ...node,
108
+ spec: { unschedulable: true, taints: [{ key: 'ToBeDeletedByClusterAutoscaler', value: String(Math.floor(Date.now() / 1000) - 60), effect: 'NoSchedule' }] },
109
+ status: { ...node.status, conditions: [{ type: 'Ready', status: 'Unknown', lastTransitionTime: new Date(Date.now() - 1000).toISOString(), message: 'Kubelet stopped posting' }] },
110
+ }
111
+ it('does not give an autoscaler removal an error or uncordon advice', () => {
112
+ const html = renderToStaticMarkup(<NodeRenderer data={removal} />)
113
+ expect(html).toContain('Removing (cluster autoscaler)')
114
+ expect(html).not.toContain('Issues Detected')
115
+ expect(html).not.toContain('Uncordon')
116
+ expect(html).toContain('Pod and disruption-budget checks are not available')
117
+ })
118
+ it('does not assert who caused a bare cordon', () => {
119
+ const html = renderToStaticMarkup(<NodeRenderer data={{ ...node, spec: { unschedulable: true } }} />)
120
+ expect(html).toContain('Cordoned')
121
+ expect(html).toContain('who cordoned it is not recorded')
122
+ expect(html).not.toContain('Uncordon to resume')
123
+ })
124
+ it('keeps observed readiness False neutral when it follows the removal start', () => {
125
+ const html = renderToStaticMarkup(<NodeRenderer data={{ ...removal, status: { ...removal.status, conditions: [{ ...removal.status.conditions[0], status: 'False' }] } }} />)
126
+ expect(html).toContain('Removing (cluster autoscaler)')
127
+ expect(html).toContain('Kubelet stopped posting')
128
+ expect(html).not.toContain('1 failing')
129
+ expect(html).not.toContain('Issues Detected')
130
+ })
131
+ it('shows named possible PDB blockers and terminating pods without invented progress', () => {
132
+ const html = renderToStaticMarkup(<NodeRenderer data={removal} removalPlan={{
133
+ node: 'kind-worker', generatedAt: new Date().toISOString(), estimate: true,
134
+ options: { ignoreDaemonSets: true, deleteEmptyDirData: true, force: true },
135
+ summary: { evict: 1, skip: 0, mayBlock: 1 }, pdbsEvaluated: true,
136
+ pods: [
137
+ { namespace: 'shop', name: 'web', outcome: 'may-block', reason: 'PodDisruptionBudget shop/web allows no disruptions', pdb: 'shop/web', pdbChecked: true, emptyDir: false, terminating: false },
138
+ { namespace: 'shop', name: 'old', outcome: 'evict', reason: 'already terminating', pdbChecked: false, emptyDir: false, terminating: true },
139
+ ],
140
+ }} />)
141
+ expect(html).toContain('2 pods in the current snapshot')
142
+ expect(html).toContain('1 terminating')
143
+ expect(html).toContain('shop/web')
144
+ expect(html).toContain('may block removal')
145
+ expect(html).not.toContain('evicted')
146
+ })
147
+ it('uses warning tone for independent network failure during removal', () => {
148
+ const html = renderToStaticMarkup(<NodeRenderer data={{ ...removal, status: { conditions: [
149
+ ...removal.status.conditions,
150
+ { type: 'NetworkUnavailable', status: 'True', lastTransitionTime: new Date(Date.now() - 180_000).toISOString() },
151
+ ] } }} />)
152
+ expect(html).toContain('Removing (cluster autoscaler) · Network unavailable')
153
+ expect(html).toContain('bg-amber-50')
154
+ expect(html).not.toContain('rounded-lg bg-red-50')
155
+ expect(html).not.toContain('rounded-lg bg-sky-50')
156
+ })
157
+ it('describes skip outcomes as drain estimates', () => {
158
+ const html = renderToStaticMarkup(<NodeRenderer data={removal} removalPlan={{
159
+ node: 'kind-worker', generatedAt: new Date().toISOString(), estimate: true,
160
+ options: { ignoreDaemonSets: true, deleteEmptyDirData: true, force: true },
161
+ summary: { evict: 0, skip: 1, mayBlock: 0 }, pdbsEvaluated: true,
162
+ pods: [{ namespace: 'shop', name: 'daemon', outcome: 'skip', reason: 'DaemonSet', pdbChecked: false, emptyDir: false, terminating: false }],
163
+ }} />)
164
+ expect(html).toContain('Skipped by drain estimate')
165
+ expect(html).toContain('1 skipped by drain estimate')
166
+ expect(html).not.toContain('Retained')
167
+ })
168
+ it('reports unavailable drain evidence as unavailable', () => {
169
+ const html = renderToStaticMarkup(<NodeRenderer data={removal} removalPlanError="forbidden: cannot list pods" />)
170
+ expect(html).toContain('Drain details unavailable')
171
+ expect(html).toContain('forbidden: cannot list pods')
172
+ expect(html).not.toContain('0 pods')
173
+ })
174
+ it('does not present a cached snapshot as current after a refresh fails', () => {
175
+ const html = renderToStaticMarkup(<NodeRenderer data={removal} removalPlanError="forbidden: cannot list pods" removalPlan={{
176
+ node: 'kind-worker', generatedAt: new Date().toISOString(), estimate: true,
177
+ options: { ignoreDaemonSets: true, deleteEmptyDirData: true, force: true },
178
+ summary: { evict: 0, skip: 0, mayBlock: 0 }, pdbsEvaluated: true, pods: [],
179
+ }} />)
180
+ expect(html).toContain('Drain details unavailable')
181
+ expect(html).not.toContain('0 pods in the current snapshot')
182
+ })
183
+ })
@@ -4,6 +4,9 @@ import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from
4
4
  import { MetricsChart } from '../../ui/MetricsChart'
5
5
  import { formatMemoryString } from '../../../utils/format'
6
6
  import { getExtendedCapacityRows } from '../../../utils/extended-resources'
7
+ import { getNodeLifecycle } from '../../../utils/node-lifecycle'
8
+ import { formatRelativeAgeTime } from '../../../utils/format'
9
+ import type { DrainPlan } from '../../shared/DrainPlanDialog'
7
10
  import type { MetricsDataPoint } from '../../../types/core'
8
11
  import { MetricsUnavailableNotice } from './MetricsUnavailableNotice'
9
12
 
@@ -15,6 +18,9 @@ interface NodeRendererProps {
15
18
  metricsHistory?: { dataPoints?: MetricsDataPoint[]; collectionError?: string; metricsUnavailableReason?: string; metricsUnavailableDiagnosis?: string }
16
19
  metricsUnavailable?: boolean
17
20
  hideMetricsServer?: boolean
21
+ removalPlan?: DrainPlan
22
+ removalPlanLoading?: boolean
23
+ removalPlanError?: string
18
24
  }
19
25
 
20
26
  // Helper to handle undefined values
@@ -31,18 +37,18 @@ function formatStorage(value: string | undefined): string {
31
37
  // Extract genuine problems from node status. Cordoned (unschedulable) is
32
38
  // deliberately NOT included here — it's an intentional operator action
33
39
  // (cordon/drain), surfaced separately as a calm advisory, not a red error.
34
- function getNodeProblems(data: any): string[] {
40
+ function getNodeProblems(data: any, lifecycle: ReturnType<typeof getNodeLifecycle>): string[] {
35
41
  const problems: string[] = []
36
42
  const conditions = data.status?.conditions || []
37
43
 
38
44
  for (const cond of conditions) {
39
45
  // NotReady is a problem when status is not True
40
- if (cond.type === 'Ready' && cond.status !== 'True') {
46
+ if (cond.type === 'Ready' && lifecycle.readinessFailed) {
41
47
  problems.push(`Node is NotReady${cond.message ? ': ' + cond.message : ''}`)
42
48
  }
43
49
 
44
50
  // These conditions are problems when True
45
- if (cond.status === 'True') {
51
+ if (cond.status === 'True' && lifecycle.problems.includes(cond.type)) {
46
52
  if (cond.type === 'DiskPressure') {
47
53
  problems.push(`Disk pressure${cond.message ? ': ' + cond.message : ''}`)
48
54
  }
@@ -61,7 +67,7 @@ function getNodeProblems(data: any): string[] {
61
67
  return problems
62
68
  }
63
69
 
64
- export function NodeRenderer({ data, relationships, onViewPods, metrics, metricsHistory, metricsUnavailable, hideMetricsServer }: NodeRendererProps) {
70
+ export function NodeRenderer({ data, relationships, onViewPods, metrics, metricsHistory, metricsUnavailable, hideMetricsServer, removalPlan, removalPlanLoading, removalPlanError }: NodeRendererProps) {
65
71
  const status = data.status || {}
66
72
  const spec = data.spec || {}
67
73
  const metadata = data.metadata || {}
@@ -73,9 +79,14 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
73
79
  const taints = spec.taints || []
74
80
 
75
81
  // Check for problems
76
- const problems = getNodeProblems(data)
82
+ const lifecycle = getNodeLifecycle(data)
83
+ const problems = getNodeProblems(data, lifecycle)
77
84
  const hasProblems = problems.length > 0
78
85
  const isCordoned = !!spec.unschedulable
86
+ const currentPlan = !removalPlanError && removalPlan?.node === metadata.name ? removalPlan : undefined
87
+ const possibleBlockers = currentPlan?.pods.filter(pod => pod.outcome === 'may-block') ?? []
88
+ const terminatingPods = currentPlan?.pods.filter(pod => pod.terminating).length
89
+ const terminationKnown = currentPlan?.pods.every(pod => pod.terminating !== undefined)
79
90
  const showMetricsUnavailable = !!metricsUnavailable && !metricsHistory?.collectionError
80
91
  const hasMetricsHistory = !!metricsHistory?.dataPoints?.length
81
92
  const currentMetrics = metricsUnavailable ? undefined : metrics
@@ -92,20 +103,50 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
92
103
  <>
93
104
  {/* Problems alert - shown at top when there are genuine issues */}
94
105
  {hasProblems && (
95
- <AlertBanner variant="error" title="Issues Detected" items={problems} />
106
+ <AlertBanner variant={lifecycle.level === 'unhealthy' ? 'error' : 'warning'} title="Issues Detected" items={problems} />
96
107
  )}
97
108
 
98
- {/* Cordoned is intentional but consequential — it removes scheduling
99
- capacity and a forgotten cordon strands a node. So it's a warning (amber),
100
- matching the node table badge + the Cordoned audit check — NOT the calm
101
- sky of a no-op intentional state (suspended/idle), and not a red error. */}
102
- {isCordoned && (
109
+ {lifecycle.removing ? (
110
+ <AlertBanner
111
+ variant={lifecycle.level === 'unhealthy' ? 'error' : lifecycle.level === 'degraded' ? 'warning' : 'info'}
112
+ title={lifecycle.delayed ? `${lifecycle.label} — taking longer than expected` : lifecycle.label}
113
+ message={lifecycle.delayed
114
+ ? 'Check remaining pods, disruption budgets and controller events. Do not uncordon during removal.'
115
+ : 'This node is marked for removal. New pods are kept off it while the controller prepares or completes removal.'}
116
+ >
117
+ <div className="mt-2 space-y-2 text-xs">
118
+ <p>{lifecycle.startedAt !== undefined ? `Marked for removal ${formatRelativeAgeTime(new Date(lifecycle.startedAt).toISOString())}.` : 'Removal start time is not reported.'}</p>
119
+ {removalPlanLoading && <p>Checking remaining pods and disruption budgets…</p>}
120
+ {removalPlanError && <p>Drain details unavailable: {removalPlanError}</p>}
121
+ {!removalPlanLoading && !removalPlanError && !currentPlan && <p>Pod and disruption-budget checks are not available from this host.</p>}
122
+ {currentPlan && (
123
+ <>
124
+ <p>{currentPlan.pods.length} pods in the current snapshot · {terminationKnown ? `${terminatingPods} terminating` : 'termination state unavailable from this Radar'} · {currentPlan.summary.skip} skipped by drain estimate (DaemonSet, static or completed).</p>
125
+ <p className="text-theme-text-secondary">Checked {formatRelativeAgeTime(currentPlan.generatedAt)}. This is a snapshot, not an eviction history. Disruption-budget checks estimate what the Eviction API would allow now.</p>
126
+ <p className="text-theme-text-secondary">Removal controllers can also hold pods marked karpenter.sh/do-not-disrupt or cluster-autoscaler.kubernetes.io/safe-to-evict=false. Those policies are not evaluated by this drain estimate.</p>
127
+ <ul className="max-h-48 overflow-y-auto space-y-1">
128
+ {currentPlan.pods.map(pod => (
129
+ <li key={`${pod.namespace}/${pod.name}`} className="flex justify-between gap-2">
130
+ <span className="break-all font-mono">{pod.namespace}/{pod.name}</span>
131
+ <span className="shrink-0 text-theme-text-secondary">{pod.terminating ? 'Terminating' : pod.outcome === 'may-block' ? 'Budget may block' : pod.outcome === 'skip' ? 'Skipped by drain estimate' : 'Remaining'}</span>
132
+ </li>
133
+ ))}
134
+ </ul>
135
+ {!currentPlan.pdbsEvaluated && <p>Disruption-budget checks are incomplete: {currentPlan.pdbError}</p>}
136
+ {possibleBlockers.length > 0 && (
137
+ <AlertBanner variant="warning" title="Disruption budgets may block removal" items={possibleBlockers.map(pod => `${pod.namespace}/${pod.name}: ${pod.reason}`)} />
138
+ )}
139
+ </>
140
+ )}
141
+ </div>
142
+ </AlertBanner>
143
+ ) : isCordoned ? (
103
144
  <AlertBanner
104
145
  variant="warning"
105
- title="Cordoned (unschedulable)"
106
- message="New pods won't be scheduled here. Uncordon to resume scheduling."
146
+ title="Cordoned"
147
+ message="New pods won't be scheduled here. The node may be under maintenance; who cordoned it is not recorded. Resume scheduling only after confirming maintenance has finished."
107
148
  />
108
- )}
149
+ ) : null}
109
150
 
110
151
  {/* Node Info */}
111
152
  <Section title="Node Info" icon={Server}>
@@ -314,7 +355,7 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
314
355
  )}
315
356
 
316
357
  {/* Conditions */}
317
- <ConditionsSection conditions={status.conditions} />
358
+ <ConditionsSection conditions={status.conditions} getConditionTone={condition => condition.type === 'Ready' && lifecycle.removing && !lifecycle.readinessFailed && condition.status !== 'True' ? 'unknown' : undefined} />
318
359
  </>
319
360
  )
320
361
  }
@@ -1,9 +1,9 @@
1
1
  import { Package, CheckCircle2 } from 'lucide-react'
2
2
  import { Section, PropertyList, Property, ConditionsSection, ProblemAlerts } from '../../ui/drawer-components'
3
- import { formatAge } from '../resource-utils'
4
3
  import { formatBytes } from '../../../utils/format'
5
4
  import { GitOpsStatusBadge, SyncCountdown } from '../../gitops'
6
5
  import { fluxConditionsToGitOpsStatus, type FluxCondition } from '../../../types/gitops'
6
+ import { TimeValue } from '../../ui/ScheduleValue'
7
7
 
8
8
  interface OCIRepositoryRendererProps {
9
9
  data: any
@@ -80,7 +80,7 @@ export function OCIRepositoryRenderer({ data }: OCIRepositoryRendererProps) {
80
80
  <Property label="Digest" value={artifact.digest} />
81
81
  <Property
82
82
  label="Last Updated"
83
- value={artifact.lastUpdateTime ? formatAge(artifact.lastUpdateTime) : '-'}
83
+ value={<TimeValue timestamp={artifact.lastUpdateTime} />}
84
84
  />
85
85
  {artifact.size && (
86
86
  <Property label="Size" value={formatBytes(artifact.size)} />
@@ -103,7 +103,7 @@ export function OCIRepositoryRenderer({ data }: OCIRepositoryRendererProps) {
103
103
  {status.lastHandledReconcileAt && (
104
104
  <Property
105
105
  label="Last Reconciled"
106
- value={formatAge(status.lastHandledReconcileAt)}
106
+ value={<TimeValue timestamp={status.lastHandledReconcileAt} />}
107
107
  />
108
108
  )}
109
109
  </PropertyList>
@@ -0,0 +1,42 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { renderToStaticMarkup } from 'react-dom/server'
5
+ import { describe, expect, it, vi } from 'vitest'
6
+ import { PodRenderer } from './PodRenderer'
7
+
8
+ const data = {
9
+ metadata: { name: 'worker', namespace: 'app' },
10
+ spec: { containers: [], priority: 1000, priorityClassName: 'urgent', runtimeClassName: 'sandbox' },
11
+ status: { phase: 'Pending' },
12
+ }
13
+
14
+ describe('PodRenderer class references', () => {
15
+ it('links explicit class names with their API groups and cluster scope', async () => {
16
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
17
+ const container = document.createElement('div')
18
+ const root = createRoot(container)
19
+ const onNavigate = vi.fn()
20
+ try {
21
+ await act(async () => root.render(<PodRenderer data={data} onCopy={() => {}} copied={null} onNavigate={onNavigate} />))
22
+ for (const [name, kind, group] of [
23
+ ['urgent', 'priorityclasses', 'scheduling.k8s.io'],
24
+ ['sandbox', 'runtimeclasses', 'node.k8s.io'],
25
+ ]) {
26
+ const button = [...container.querySelectorAll('button')].find(button => button.textContent === name)
27
+ expect(button).toBeDefined()
28
+ await act(async () => button!.click())
29
+ expect(onNavigate).toHaveBeenLastCalledWith({ kind, group, namespace: '', name })
30
+ }
31
+ } finally {
32
+ await act(async () => root.unmount())
33
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: false })
34
+ }
35
+ })
36
+
37
+ it('does not infer a class from numeric priority alone', () => {
38
+ const html = renderToStaticMarkup(<PodRenderer data={{ ...data, spec: { containers: [], priority: 1000 } }} onCopy={() => {}} copied={null} />)
39
+ expect(html).not.toContain('Priority Class')
40
+ expect(html).not.toContain('Runtime Class')
41
+ })
42
+ })
@@ -0,0 +1,57 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { renderToStaticMarkup } from 'react-dom/server'
5
+ import { describe, expect, it, vi } from 'vitest'
6
+ import { PodRenderer } from './PodRenderer'
7
+
8
+ describe('PodRenderer nominated Node', () => {
9
+ it('links the nominated Node of an unbound Pod', async () => {
10
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
11
+ const container = document.createElement('div')
12
+ const root = createRoot(container)
13
+ const onNavigate = vi.fn()
14
+ try {
15
+ await act(async () => root.render(
16
+ <PodRenderer
17
+ data={{ metadata: { name: 'pending', namespace: 'app' }, spec: { containers: [] }, status: { phase: 'Pending', nominatedNodeName: 'candidate' } }}
18
+ onCopy={() => {}}
19
+ copied={null}
20
+ onNavigate={onNavigate}
21
+ />,
22
+ ))
23
+ expect(container.textContent).toContain('Nominated Node')
24
+ const button = [...container.querySelectorAll('button')].find(button => button.textContent === 'candidate')
25
+ expect(button).toBeDefined()
26
+ await act(async () => button!.click())
27
+ expect(onNavigate).toHaveBeenCalledWith({ kind: 'nodes', namespace: '', name: 'candidate', group: undefined })
28
+ } finally {
29
+ await act(async () => root.unmount())
30
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: false })
31
+ }
32
+ })
33
+
34
+ it('hides a nomination on a terminal or terminating unbound Pod', () => {
35
+ for (const data of [
36
+ { metadata: { name: 'failed', namespace: 'app' }, spec: { containers: [] }, status: { phase: 'Failed', nominatedNodeName: 'stale' } },
37
+ { metadata: { name: 'deleting', namespace: 'app', deletionTimestamp: '2026-10-07T00:00:00Z' }, spec: { containers: [] }, status: { phase: 'Pending', nominatedNodeName: 'stale' } },
38
+ ]) {
39
+ const html = renderToStaticMarkup(<PodRenderer data={data} onCopy={() => {}} copied={null} />)
40
+ expect(html).not.toContain('Nominated Node')
41
+ expect(html).not.toContain('stale')
42
+ }
43
+ })
44
+
45
+ it('hides a nomination once the Pod is bound, even when it names another node', () => {
46
+ const html = renderToStaticMarkup(
47
+ <PodRenderer
48
+ data={{ metadata: { name: 'running', namespace: 'app' }, spec: { nodeName: 'assigned', containers: [] }, status: { phase: 'Running', nominatedNodeName: 'stale' } }}
49
+ onCopy={() => {}}
50
+ copied={null}
51
+ />,
52
+ )
53
+ expect(html).not.toContain('Nominated Node')
54
+ expect(html).not.toContain('stale')
55
+ expect(html).toContain('assigned')
56
+ })
57
+ })
@@ -28,6 +28,7 @@ import { getRadarUpgradeRequirement, type RadarUpgradeRequirement } from '../../
28
28
  import { MetricsChart } from '../../ui/MetricsChart'
29
29
  import { MetricsUnavailableNotice } from './MetricsUnavailableNotice'
30
30
  import { ContainerEnvironmentSection } from './ContainerEnvironmentSection'
31
+ import { formatRelativeAgeTime } from '../../../utils/format'
31
32
 
32
33
  function parseValidDate(dateStr: string): Date | null {
33
34
  const d = new Date(dateStr)
@@ -35,13 +36,7 @@ function parseValidDate(dateStr: string): Date | null {
35
36
  }
36
37
 
37
38
  function formatMsAgo(dateStr: string): string | null {
38
- const d = parseValidDate(dateStr)
39
- if (!d) return null
40
- const ms = Date.now() - d.getTime()
41
- if (ms < 60000) return 'just now'
42
- if (ms < 3600000) return `${Math.floor(ms / 60000)}m ago`
43
- if (ms < 86400000) return `${Math.floor(ms / 3600000)}h ago`
44
- return `${Math.floor(ms / 86400000)}d ago`
39
+ return parseValidDate(dateStr) ? formatRelativeAgeTime(dateStr) : null
45
40
  }
46
41
 
47
42
  function getRestartRecency(finishedAt: string | undefined, restarts: number): { color: string; label: string | null } {
@@ -453,6 +448,25 @@ export function PodRenderer({
453
448
  <Property label="Node" value={
454
449
  data.spec?.nodeName ? <ResourceLink name={data.spec.nodeName} kind="nodes" onNavigate={onNavigate} /> : undefined
455
450
  } copyable onCopy={onCopy} copied={copied} />
451
+ {/* A nomination outlives its meaning: the API server clears it on binding by
452
+ default only from Kubernetes 1.35, and pod GC fails a terminating unbound
453
+ Pod without clearing it. Only a Pending, unbound, live Pod can still use it. */}
454
+ {data.status?.nominatedNodeName && !data.spec?.nodeName && data.status?.phase === 'Pending' && !data.metadata?.deletionTimestamp && (
455
+ <Property
456
+ label={
457
+ <Tooltip content="The node this Pod is expected to run on, set by the scheduler or another component. It can change before the Pod is bound." position="right">
458
+ <span className="border-b border-dotted border-theme-text-tertiary cursor-help">Nominated Node</span>
459
+ </Tooltip>
460
+ }
461
+ value={<ResourceLink name={data.status.nominatedNodeName} kind="nodes" onNavigate={onNavigate} />}
462
+ />
463
+ )}
464
+ {data.spec?.priorityClassName && <Property label="Priority Class" value={
465
+ <ResourceLink name={data.spec.priorityClassName} kind="priorityclasses" group="scheduling.k8s.io" onNavigate={onNavigate} />
466
+ } />}
467
+ {data.spec?.runtimeClassName && <Property label="Runtime Class" value={
468
+ <ResourceLink name={data.spec.runtimeClassName} kind="runtimeclasses" group="node.k8s.io" onNavigate={onNavigate} />
469
+ } />}
456
470
  <Property label="Pod IP" value={data.status?.podIP} copyable onCopy={onCopy} copied={copied} />
457
471
  <Property label="Host IP" value={data.status?.hostIP} />
458
472
  <Property
@@ -269,7 +269,7 @@ describe('rolloutProblems', () => {
269
269
  )
270
270
  expect(problems).toHaveLength(1)
271
271
  expect(problems[0].color).toBe('yellow')
272
- expect(problems[0].message).toMatch(/^Rollout is paused: CanaryPauseStep \(since .+\)$/)
272
+ expect(problems[0].message).toMatch(/^Rollout is paused: CanaryPauseStep \(for \d+d\)$/)
273
273
  })
274
274
 
275
275
  it('does not report a paused rollout that is actually aborted', () => {
@@ -9,6 +9,7 @@ import { CanaryStepTimeline, BlueGreenTimeline } from './rollout/CanaryStepTimel
9
9
  import { ReplicaSetProgression } from './rollout/ReplicaSetProgression'
10
10
  import { LookupFailureNote } from './LookupFailureNote'
11
11
  import type { WorkloadRevision, WorkloadPodInfo } from '../../../types'
12
+ import { formatRelativeAgeTime } from '../../../utils/format'
12
13
 
13
14
  export type RolloutAction = 'abort' | 'retry' | 'promote' | 'promote-full' | 'skip-step'
14
15
 
@@ -214,7 +215,7 @@ export function rolloutProblems(data: any): RolloutProblem[] {
214
215
 
215
216
  if (phase === 'Paused' && !status.abort) {
216
217
  const reasons = (status.pauseConditions || []).map((pc: any) => {
217
- const since = pc.startTime ? ` (since ${formatAge(pc.startTime)})` : ''
218
+ const since = pc.startTime ? ` (for ${formatAge(pc.startTime)})` : ''
218
219
  return `${pc.reason || 'Unknown'}${since}`
219
220
  })
220
221
  problems.push({
@@ -741,7 +742,7 @@ export function RolloutRenderer({ data, onNavigate, capabilities, onAction, pend
741
742
  </span>
742
743
  )}
743
744
  <span className={clsx('text-xs text-theme-text-tertiary', run.metricsTotal === 0 && 'ml-auto')}>
744
- {formatAge(run.createdAt)}
745
+ {formatRelativeAgeTime(run.createdAt)}
745
746
  </span>
746
747
  </div>
747
748
  ))}
@@ -3,8 +3,8 @@ import { Package, Search } from 'lucide-react'
3
3
  import { Section, PropertyList, Property } from '../../ui/drawer-components'
4
4
  import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
5
5
  import { Input } from '../../ui/Input'
6
- import { formatAge } from '../resource-utils'
7
6
  import { formatTrivyImage } from './trivy-shared'
7
+ import { TimeValue } from '../../ui/ScheduleValue'
8
8
 
9
9
  interface SbomReportRendererProps {
10
10
  data: any
@@ -57,7 +57,7 @@ export function SbomReportRenderer({ data }: SbomReportRendererProps) {
57
57
  <Property label="Components" value={String(componentsCount)} />
58
58
  {depsCount > 0 && <Property label="Dependencies" value={String(depsCount)} />}
59
59
  <Property label="Scanner" value={scanner.name ? `${scanner.name} ${scanner.version || ''}`.trim() : '-'} />
60
- <Property label="Scanned" value={report.updateTimestamp ? formatAge(report.updateTimestamp) + ' ago' : '-'} />
60
+ <Property label="Scanned" value={<TimeValue timestamp={report.updateTimestamp} />} />
61
61
  </PropertyList>
62
62
  </Section>
63
63
 
@@ -0,0 +1,88 @@
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
+
6
+ import { SecretRenderer } from './SecretRenderer'
7
+
8
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
9
+
10
+ const secret = {
11
+ apiVersion: 'v1',
12
+ kind: 'Secret',
13
+ metadata: { name: 'db', namespace: 'shop', resourceVersion: '7' },
14
+ type: 'Opaque',
15
+ data: { password: btoa('old') },
16
+ }
17
+
18
+ let element: HTMLDivElement
19
+ let root: Root
20
+
21
+ beforeEach(() => {
22
+ element = document.createElement('div')
23
+ document.body.append(element)
24
+ root = createRoot(element)
25
+ })
26
+
27
+ afterEach(async () => {
28
+ await act(async () => root.unmount())
29
+ element.remove()
30
+ })
31
+
32
+ async function render(onSaveSecretValue?: (yaml: string) => Promise<void>) {
33
+ await act(async () => root.render(<SecretRenderer data={secret} resourceData={secret} onSaveSecretValue={onSaveSecretValue} />))
34
+ }
35
+
36
+ function button(label: string): HTMLButtonElement {
37
+ const match = [...document.querySelectorAll('button')].find(b => b.textContent?.trim() === label || b.title === label)
38
+ if (!match) throw new Error(`no button "${label}"`)
39
+ return match
40
+ }
41
+
42
+ async function editAndSave(value: string) {
43
+ await act(async () => button('Reveal').click())
44
+ await act(async () => button('Edit value').click())
45
+ const textarea = element.querySelector('textarea')!
46
+ await act(async () => {
47
+ Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(textarea, value)
48
+ textarea.dispatchEvent(new Event('input', { bubbles: true }))
49
+ })
50
+ await act(async () => button('Save').click())
51
+ await act(async () => button('Update').click())
52
+ }
53
+
54
+ const httpError = (status: number, message: string) => Object.assign(new Error(message), { status })
55
+
56
+ describe('SecretRenderer value edit', () => {
57
+ it('keeps the edit open and says why when the Secret changed since it loaded', async () => {
58
+ await render(vi.fn().mockRejectedValue(httpError(409, 'resource changed after review')))
59
+ await editAndSave('new')
60
+ expect(element.textContent).toContain('Not saved')
61
+ expect(element.textContent).toContain('This Secret changed after it loaded. Once the view reloads it, save again')
62
+ expect(element.querySelector('textarea')?.value).toBe('new')
63
+ })
64
+
65
+ it('says the Secret is gone when the save finds it deleted', async () => {
66
+ await render(vi.fn().mockRejectedValue(httpError(404, 'secrets "db" not found')))
67
+ await editAndSave('new')
68
+ expect(element.textContent).toContain('Not saved')
69
+ expect(element.textContent).toContain('This Secret no longer exists in the cluster.')
70
+ })
71
+
72
+ it('shows the server message for any other failure', async () => {
73
+ await render(vi.fn().mockRejectedValue(httpError(403, 'secrets "db" is forbidden')))
74
+ await editAndSave('new')
75
+ expect(element.textContent).toContain('Not saved')
76
+ expect(element.textContent).toContain('secrets "db" is forbidden')
77
+ })
78
+
79
+ it('closes the editor once the save handler is taken away', async () => {
80
+ await render(vi.fn().mockResolvedValue(undefined))
81
+ await act(async () => button('Reveal').click())
82
+ await act(async () => button('Edit value').click())
83
+ expect(element.querySelector('textarea')).not.toBeNull()
84
+ await render(undefined)
85
+ expect(element.querySelector('textarea')).toBeNull()
86
+ expect(element.textContent).not.toContain('Save')
87
+ })
88
+ })