@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
@@ -8,6 +8,7 @@ import { ReflectorSummary, ReflectorSection, isReflectorMirror, reflectorEditNot
8
8
  import type { SecretCertificateInfo, CertificateInfo, Relationships, ResourceRef } from '../../../types'
9
9
  import { pluralize } from '../../../utils/pluralize'
10
10
  import { cleanResourceForYaml } from '../../../utils/yaml'
11
+ import { isFetchError } from '../../../types/fetch-error'
11
12
 
12
13
  interface SecretRendererProps {
13
14
  data: any
@@ -31,6 +32,7 @@ export function SecretRenderer({ data, relationships, onNavigate, certificateInf
31
32
  const [editingKey, setEditingKey] = useState<string | null>(null)
32
33
  const [editValue, setEditValue] = useState('')
33
34
  const [showSaveConfirm, setShowSaveConfirm] = useState(false)
35
+ const [saveError, setSaveError] = useState<{ reason?: string } | null>(null)
34
36
  const textareaRef = useRef<HTMLTextAreaElement>(null)
35
37
  const dataKeys = Object.keys(data.data || {})
36
38
  const isImmutable = data.immutable === true
@@ -68,12 +70,14 @@ export function SecretRenderer({ data, relationships, onNavigate, certificateInf
68
70
  function startEdit(key: string, decoded: string) {
69
71
  setEditingKey(key)
70
72
  setEditValue(decoded)
73
+ setSaveError(null)
71
74
  }
72
75
 
73
76
  function cancelEdit() {
74
77
  setEditingKey(null)
75
78
  setEditValue('')
76
79
  setShowSaveConfirm(false)
80
+ setSaveError(null)
77
81
  }
78
82
 
79
83
  const handleSave = useCallback(async (key: string, newValue: string) => {
@@ -83,14 +87,16 @@ export function SecretRenderer({ data, relationships, onNavigate, certificateInf
83
87
  // btoa is byte-only; round-trip through encodeURIComponent/unescape so non-ASCII secret values survive base64 encoding.
84
88
  cleaned.data[key] = btoa(unescape(encodeURIComponent(newValue)))
85
89
  const yaml = yamlStringify(cleaned, { lineWidth: 0, indent: 2 })
90
+ setSaveError(null)
86
91
  try {
87
92
  await onSaveSecretValue(yaml)
88
93
  setEditingKey(null)
89
94
  setEditValue('')
90
95
  setShowSaveConfirm(false)
91
- } catch {
92
- // Error is handled by the mutation (toast)
96
+ } catch (error) {
97
+ // The edit stays open with the value as typed, so it can be saved again.
93
98
  setShowSaveConfirm(false)
99
+ setSaveError(secretSaveErrorMessage(error))
94
100
  }
95
101
  }, [onSaveSecretValue, resourceData])
96
102
 
@@ -171,8 +177,10 @@ export function SecretRenderer({ data, relationships, onNavigate, certificateInf
171
177
  {dataKeys.map((key) => {
172
178
  const decoded = decodeBase64(data.data[key])
173
179
  const isBinary = decoded === '[binary data]'
174
- const isEditing = editingKey === key
175
180
  const canEdit = onSaveSecretValue && !isImmutable && !isBinary
181
+ // Without a save handler (the Secret was deleted, say) an open
182
+ // editor would have a Save button that does nothing.
183
+ const isEditing = editingKey === key && Boolean(canEdit)
176
184
 
177
185
  return (
178
186
  <div key={key} className="card-inner">
@@ -221,13 +229,21 @@ export function SecretRenderer({ data, relationships, onNavigate, certificateInf
221
229
  <textarea
222
230
  ref={textareaRef}
223
231
  value={editValue}
224
- onChange={(e) => setEditValue(e.target.value)}
232
+ onChange={(e) => {
233
+ setEditValue(e.target.value)
234
+ setSaveError(null)
235
+ }}
225
236
  autoCorrect="off"
226
237
  autoCapitalize="off"
227
238
  className="w-full bg-theme-base rounded p-2 text-xs text-theme-text-secondary font-mono border border-blue-500/50 focus:border-blue-500 focus:outline-none resize-none overflow-hidden whitespace-pre-wrap"
228
239
  style={{ minHeight: '60px' }}
229
240
  disabled={isSaving}
230
241
  />
242
+ {saveError && (
243
+ <div role="alert" className="mt-2">
244
+ <AlertBanner variant="error" title="Not saved" message={saveError.reason} />
245
+ </div>
246
+ )}
231
247
  <div className="flex items-center gap-2 mt-2">
232
248
  <button
233
249
  onClick={() => setShowSaveConfirm(true)}
@@ -267,7 +283,7 @@ export function SecretRenderer({ data, relationships, onNavigate, certificateInf
267
283
 
268
284
  <ReflectorSection data={data} reflection={relationships?.reflection} onNavigate={onNavigate} />
269
285
 
270
- {editingKey && (
286
+ {editingKey && onSaveSecretValue && (
271
287
  <ConfirmDialog
272
288
  open={showSaveConfirm}
273
289
  onClose={() => setShowSaveConfirm(false)}
@@ -284,6 +300,19 @@ export function SecretRenderer({ data, relationships, onNavigate, certificateInf
284
300
  )
285
301
  }
286
302
 
303
+ function secretSaveErrorMessage(error: unknown): { reason?: string } {
304
+ if (isFetchError(error) && error.status === 404) {
305
+ return { reason: 'This Secret no longer exists in the cluster.' }
306
+ }
307
+ // The view reloads the Secret after a failed save, but that read can trail
308
+ // the cluster by a moment, so the message can't promise it already shows
309
+ // the change.
310
+ if (isFetchError(error) && error.status === 409) {
311
+ return { reason: 'This Secret changed after it loaded. Once the view reloads it, save again to apply your value to the latest version.' }
312
+ }
313
+ return { reason: error instanceof Error && error.message ? error.message : undefined }
314
+ }
315
+
287
316
  function CertificateInfoSection({ cert, index, total }: { cert: CertificateInfo; index: number; total: number }) {
288
317
  const expiryTextColor = cert.expired || cert.daysLeft <= 7
289
318
  ? 'text-red-400'
@@ -0,0 +1,43 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { expect, it } from 'vitest'
3
+ import { ServiceRenderer } from './ServiceRenderer'
4
+
5
+ const data = { metadata: { name: 'web', namespace: 'team' }, spec: { selector: { app: 'web' } } }
6
+ const props = { data, onCopy: () => {}, copied: null }
7
+ const slice = (name: string, ready = true) => ({
8
+ metadata: { name },
9
+ addressType: 'IPv4',
10
+ endpoints: [{ addresses: ['10.0.0.1'], conditions: { ready } }],
11
+ })
12
+
13
+ it('renders supplied slices for a selector Service when the host enables the inventory', () => {
14
+ const html = renderToStaticMarkup(<ServiceRenderer {...props} endpointSlicesEnabled endpointSlices={[slice('published', false)]} />)
15
+ expect(html).toContain('EndpointSlices')
16
+ expect(html).toContain('published')
17
+ expect(html).toContain('0/1')
18
+ expect(html).not.toContain('Showing the first')
19
+ })
20
+
21
+ it('keeps the section hidden for selector Services unless the host enables it', () => {
22
+ expect(renderToStaticMarkup(<ServiceRenderer {...props} endpointSlices={[slice('published')]} />)).not.toContain('EndpointSlices')
23
+ const selectorless = { ...data, spec: {} }
24
+ expect(renderToStaticMarkup(<ServiceRenderer {...props} data={selectorless} endpointSlices={[slice('manual')]} />)).toContain('manual')
25
+ })
26
+
27
+ it('separates unavailable, loading and ready-empty inventory', () => {
28
+ const error = renderToStaticMarkup(<ServiceRenderer {...props} endpointSlicesEnabled endpointSlices={[]} endpointSlicesError="Read denied" />)
29
+ expect(error).toContain('EndpointSlice inventory unavailable')
30
+ expect(error).toContain('Read denied')
31
+ expect(error).not.toContain('No EndpointSlices found')
32
+ expect(renderToStaticMarkup(<ServiceRenderer {...props} endpointSlicesEnabled endpointSlicesLoading />)).toContain('Loading EndpointSlices')
33
+ expect(renderToStaticMarkup(<ServiceRenderer {...props} endpointSlicesEnabled endpointSlices={[]} />)).toContain('No EndpointSlices found')
34
+ })
35
+
36
+ it('says when the inventory was cut off', () => {
37
+ const html = renderToStaticMarkup(<ServiceRenderer {...props} endpointSlicesEnabled endpointSlicesTruncated endpointSlices={[slice('a'), slice('b')]} />)
38
+ expect(html).toContain('Showing the first 2 EndpointSlices.')
39
+ })
40
+
41
+ it('does not show slice inventory for ExternalName even when supplied', () => {
42
+ expect(renderToStaticMarkup(<ServiceRenderer {...props} data={{ ...data, spec: { type: 'ExternalName', externalName: 'example.org' } }} endpointSlicesEnabled endpointSlices={[]} />)).not.toContain('EndpointSlices')
43
+ })
@@ -18,6 +18,9 @@ interface ServiceRendererProps {
18
18
  copied: string | null
19
19
  endpointSlices?: any[]
20
20
  endpointSlicesLoading?: boolean
21
+ endpointSlicesEnabled?: boolean
22
+ endpointSlicesError?: string
23
+ endpointSlicesTruncated?: boolean
21
24
  onNavigate?: (ref: ResourceRef) => void
22
25
  renderPortAction?: (props: ServicePortRenderProps) => ReactNode
23
26
  renderPortPanel?: (props: ServicePortRenderProps) => ReactNode
@@ -38,7 +41,7 @@ function endpointSliceReadyClass(ready: number, total: number): string {
38
41
  return 'status-unhealthy'
39
42
  }
40
43
 
41
- export function ServiceRenderer({ data, onCopy, copied, endpointSlices, endpointSlicesLoading, onNavigate, renderPortAction, renderPortPanel }: ServiceRendererProps) {
44
+ export function ServiceRenderer({ data, onCopy, copied, endpointSlices, endpointSlicesLoading, endpointSlicesEnabled, endpointSlicesError, endpointSlicesTruncated, onNavigate, renderPortAction, renderPortPanel }: ServiceRendererProps) {
42
45
  const spec = data.spec || {}
43
46
  const ports = spec.ports || []
44
47
  const lbIngress = data.status?.loadBalancer?.ingress || []
@@ -111,10 +114,12 @@ export function ServiceRenderer({ data, onCopy, copied, endpointSlices, endpoint
111
114
  </Section>
112
115
  )}
113
116
 
114
- {hasNoSelector && !isExternalName && (
115
- <Section title="EndpointSlices" icon={Radio}>
117
+ {!isExternalName && (hasNoSelector || endpointSlicesEnabled) && (
118
+ <Section title="EndpointSlices" icon={Radio} defaultExpanded>
116
119
  {endpointSlicesLoading ? (
117
120
  <div className="text-sm text-theme-text-tertiary">Loading EndpointSlices…</div>
121
+ ) : endpointSlicesError ? (
122
+ <AlertBanner variant="warning" title="EndpointSlice inventory unavailable" message={endpointSlicesError} />
118
123
  ) : endpointSlices && endpointSlices.length > 0 ? (
119
124
  <div className="space-y-2">
120
125
  {endpointSlices.map((slice: any) => {
@@ -147,6 +152,9 @@ export function ServiceRenderer({ data, onCopy, copied, endpointSlices, endpoint
147
152
  </button>
148
153
  )
149
154
  })}
155
+ {endpointSlicesTruncated && (
156
+ <div className="text-xs text-theme-text-tertiary">Showing the first {endpointSlices.length} EndpointSlices.</div>
157
+ )}
150
158
  </div>
151
159
  ) : (
152
160
  <div className="text-sm text-theme-text-tertiary">No EndpointSlices found for this Service.</div>
@@ -2,6 +2,7 @@ import { Globe, ArrowRight, Network } from 'lucide-react'
2
2
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceRefBadge, useOperationalIssuesShown } from '../../ui/drawer-components'
3
3
  import { Badge } from '../../ui/Badge'
4
4
  import type { ResourceRef } from '../../../types'
5
+ import { gatewayBackendResourceRef, gatewayParentResourceRef } from '../../../utils/gateway-references'
5
6
 
6
7
  interface SimpleRouteRendererProps {
7
8
  data: any
@@ -35,22 +36,8 @@ export function SimpleRouteRenderer({ data, kind, onNavigate }: SimpleRouteRende
35
36
  ? parentStatuses[0].conditions
36
37
  : undefined
37
38
 
38
- function toGatewayRef(ref: any): ResourceRef {
39
- return {
40
- kind: 'Gateway',
41
- namespace: ref.namespace || routeNs,
42
- name: ref.name,
43
- group: 'gateway.networking.k8s.io',
44
- }
45
- }
46
-
47
- function toServiceRef(backend: any): ResourceRef {
48
- return {
49
- kind: 'Service',
50
- namespace: backend.namespace || routeNs,
51
- name: backend.name,
52
- }
53
- }
39
+ const toParentRef = (ref: any) => gatewayParentResourceRef(ref, routeNs)
40
+ const toBackendRef = (ref: any) => gatewayBackendResourceRef(ref, routeNs)
54
41
 
55
42
  return (
56
43
  <>
@@ -62,8 +49,8 @@ export function SimpleRouteRenderer({ data, kind, onNavigate }: SimpleRouteRende
62
49
  const cond = (p.conditions || []).find((c: any) => c.type === 'Accepted' && c.status === 'False')
63
50
  const gwName = p.parentRef?.name || 'unknown'
64
51
  return cond?.reason
65
- ? `Gateway "${gwName}": ${cond.reason}${cond.message ? ' — ' + cond.message : ''}`
66
- : `Gateway "${gwName}" has not accepted this route.`
52
+ ? `Parent "${gwName}": ${cond.reason}${cond.message ? ' — ' + cond.message : ''}`
53
+ : `Parent "${gwName}" has not accepted this route.`
67
54
  }).join('; ')}
68
55
  />
69
56
  )}
@@ -95,12 +82,12 @@ export function SimpleRouteRenderer({ data, kind, onNavigate }: SimpleRouteRende
95
82
  />
96
83
  )}
97
84
  <Property
98
- label="Parent Gateways"
85
+ label="Parents"
99
86
  value={
100
87
  parentRefs.length > 0 ? (
101
88
  <div className="flex flex-wrap gap-1">
102
89
  {parentRefs.map((ref: any, i: number) => (
103
- <ResourceRefBadge key={`${ref.namespace || ''}-${ref.name}-${i}`} resourceRef={toGatewayRef(ref)} onClick={onNavigate} />
90
+ <ResourceRefBadge key={`${ref.namespace || ''}-${ref.name}-${i}`} resourceRef={toParentRef(ref)} onClick={onNavigate} />
104
91
  ))}
105
92
  </div>
106
93
  ) : 'None'
@@ -131,7 +118,7 @@ export function SimpleRouteRenderer({ data, kind, onNavigate }: SimpleRouteRende
131
118
  const pct = hasWeights ? Math.round(((b.weight ?? 1) / totalWeight) * 100) : null
132
119
  return (
133
120
  <span key={bi} className="flex items-center gap-1">
134
- <ResourceRefBadge resourceRef={toServiceRef(b)} onClick={onNavigate} />
121
+ <ResourceRefBadge resourceRef={toBackendRef(b)} onClick={onNavigate} />
135
122
  {b.port && <span className="text-theme-text-tertiary">:{b.port}</span>}
136
123
  {pct !== null && (
137
124
  <span className="text-theme-text-tertiary text-[10px]">{pct}%</span>
@@ -9,8 +9,6 @@ import {
9
9
  getBSLRegion,
10
10
  getBSLDefault,
11
11
  getBSLAccessMode,
12
- getBSLLastValidation,
13
- getBSLLastSynced,
14
12
  getBSLMessage,
15
13
  } from '../resource-utils-velero'
16
14
  import { VeleroPhaseValue } from './velero-cells'
@@ -111,8 +109,8 @@ export function VeleroBSLRenderer({ data, storedBackups, storedTotal, restorable
111
109
  <Property label="Phase" value={
112
110
  <VeleroPhaseValue status={bslStatus} phase={status.phase || ''} />
113
111
  } />
114
- <Property label="Last Validation" value={getBSLLastValidation(data)} />
115
- <Property label="Last Synced" value={getBSLLastSynced(data)} />
112
+ <Property label="Last Validation" value={<TimeValue timestamp={data.status?.lastValidationTime} />} />
113
+ <Property label="Last Synced" value={<TimeValue timestamp={data.status?.lastSyncedTime} />} />
116
114
  {status.lastSyncedRevision && (
117
115
  <Property label="Last Synced Revision" value={
118
116
  <span className="text-sm font-mono text-theme-text-secondary break-all">{status.lastSyncedRevision}</span>
@@ -235,18 +233,18 @@ export function VeleroBSLRenderer({ data, storedBackups, storedTotal, restorable
235
233
  recovers. */}
236
234
  {bslStatus.level === 'healthy' ? (
237
235
  <>
238
- Most recent restorable point: <TimeValue timestamp={newest} /> ago
236
+ Most recent restorable point: <TimeValue timestamp={newest} />
239
237
  </>
240
238
  ) : bslStatus.level === 'unhealthy' ? (
241
239
  <>
242
240
  Most recent point held here, once this location is reachable again:{' '}
243
- <TimeValue timestamp={newest} /> ago
241
+ <TimeValue timestamp={newest} />
244
242
  </>
245
243
  ) : (
246
244
  // Unknown: do not imply it is unreachable, and do not call it
247
245
  // restorable either. Say what the timestamp is.
248
246
  <>
249
- Most recent point held here: <TimeValue timestamp={newest} /> ago
247
+ Most recent point held here: <TimeValue timestamp={newest} />
250
248
  </>
251
249
  )}
252
250
  </div>
@@ -26,7 +26,7 @@ import {
26
26
  } from '../resource-utils-velero'
27
27
  import { VeleroPhaseValue } from './velero-cells'
28
28
  import { veleroPhaseLabel } from '../resource-utils-velero'
29
- import { formatAge } from '../resource-utils'
29
+ import { TimeValue } from '../../ui/ScheduleValue'
30
30
 
31
31
  interface VeleroBackupRendererProps {
32
32
  data: any
@@ -140,10 +140,10 @@ export function VeleroBackupRenderer({ data, storageLocationPhase, storageLocati
140
140
  <Property label="Queue Position" value={String(queuePosition)} />
141
141
  )}
142
142
  {status.startTimestamp && (
143
- <Property label="Started" value={formatAge(status.startTimestamp) + ' ago'} />
143
+ <Property label="Started" value={<TimeValue timestamp={status.startTimestamp} />} />
144
144
  )}
145
145
  {status.completionTimestamp && (
146
- <Property label="Completed" value={formatAge(status.completionTimestamp) + ' ago'} />
146
+ <Property label="Completed" value={<TimeValue timestamp={status.completionTimestamp} />} />
147
147
  )}
148
148
  <Property label="Duration" value={getBackupDuration(data)} />
149
149
  <Property label="Expiration" value={getBackupExpiry(data)} />
@@ -6,10 +6,10 @@ import {
6
6
  getBackupRepositoryVolumeNamespace,
7
7
  getBackupRepositoryLocation,
8
8
  getBackupRepositoryMaintenanceFrequency,
9
- getBackupRepositoryLastMaintenance,
10
9
  getBackupRepositoryMessage,
11
10
  } from '../resource-utils-velero'
12
11
  import { VeleroPhaseValue } from './velero-cells'
12
+ import { TimeValue } from '../../ui/ScheduleValue'
13
13
 
14
14
  interface VeleroBackupRepositoryRendererProps {
15
15
  data: any
@@ -52,7 +52,7 @@ export function VeleroBackupRepositoryRenderer({ data, onNavigate }: VeleroBacku
52
52
  last run is far older than its frequency is the shape of a
53
53
  controller that has stopped. The two are shown together so that
54
54
  comparison does not need a second screen. */}
55
- <Property label="Last Maintenance" value={getBackupRepositoryLastMaintenance(data)} />
55
+ <Property label="Last Maintenance" value={<TimeValue timestamp={data.status?.lastMaintenanceTime} />} />
56
56
  <Property label="Maintenance Frequency" value={getBackupRepositoryMaintenanceFrequency(data)} />
57
57
  </PropertyList>
58
58
  </Section>
@@ -22,8 +22,8 @@ import {
22
22
  } from '../resource-utils-velero'
23
23
  import { VeleroPhaseValue } from './velero-cells'
24
24
  import { veleroPhaseLabel } from '../resource-utils-velero'
25
- import { formatAge } from '../resource-utils'
26
25
  import { VeleroRunMessages, type VeleroRunMessagesFetch } from './VeleroRunMessages'
26
+ import { TimeValue } from '../../ui/ScheduleValue'
27
27
 
28
28
  interface VeleroRestoreRendererProps {
29
29
  data: any
@@ -125,10 +125,10 @@ export function VeleroRestoreRenderer({ data, messages, onNavigate }: VeleroRest
125
125
  />
126
126
  } />
127
127
  {status.startTimestamp && (
128
- <Property label="Started" value={formatAge(status.startTimestamp) + ' ago'} />
128
+ <Property label="Started" value={<TimeValue timestamp={status.startTimestamp} />} />
129
129
  )}
130
130
  {status.completionTimestamp && (
131
- <Property label="Completed" value={formatAge(status.completionTimestamp) + ' ago'} />
131
+ <Property label="Completed" value={<TimeValue timestamp={status.completionTimestamp} />} />
132
132
  )}
133
133
  <Property label="Duration" value={getRestoreDuration(data)} />
134
134
  <Property label="Errors" value={
@@ -3,13 +3,13 @@ import { Section, PropertyList, Property, ConditionsSection, AlertBanner, Resour
3
3
  import {
4
4
  getScheduleStatus,
5
5
  getScheduleCron,
6
- getScheduleLastBackup,
7
6
  getSchedulePaused,
8
7
  getScheduleTemplate,
9
8
  getScheduleUseOwnerReferences,
10
9
  getScheduleValidationErrors,
11
10
  } from '../resource-utils-velero'
12
11
  import { VeleroPhaseValue } from './velero-cells'
12
+ import { TimeValue } from '../../ui/ScheduleValue'
13
13
 
14
14
  interface VeleroScheduleRendererProps {
15
15
  data: any
@@ -64,7 +64,7 @@ export function VeleroScheduleRenderer({ data, onNavigate }: VeleroScheduleRende
64
64
  <Property label="Cron Schedule" value={
65
65
  <span className="font-mono text-sm">{getScheduleCron(data)}</span>
66
66
  } />
67
- <Property label="Last Backup" value={getScheduleLastBackup(data)} />
67
+ <Property label="Last Backup" value={<TimeValue timestamp={data.status?.lastBackup} fallback="Never" />} />
68
68
  <Property label="Paused" value={isPaused ? 'Yes' : 'No'} />
69
69
  <Property label="Use Owner References" value={getScheduleUseOwnerReferences(data) ? 'Yes' : 'No'} />
70
70
  </PropertyList>
@@ -4,8 +4,8 @@ import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property } from '../../ui/drawer-components'
5
5
  import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
6
6
  import { Input } from '../../ui/Input'
7
- import { formatAge } from '../resource-utils'
8
7
  import { SEVERITY_BADGE_COLORS, SEVERITY_BAR_COLORS, SEVERITY_TEXT_COLORS, SEVERITY_ORDER, TrivyAlertBanner, formatTrivyImage } from './trivy-shared'
8
+ import { TimeValue } from '../../ui/ScheduleValue'
9
9
 
10
10
  interface VulnerabilityReportRendererProps {
11
11
  data: any
@@ -161,7 +161,7 @@ export function VulnerabilityReportRenderer({ data }: VulnerabilityReportRendere
161
161
  } />
162
162
  )}
163
163
  <Property label="Scanner" value={scanner.name ? `${scanner.name} ${scanner.version || ''}`.trim() : '-'} />
164
- <Property label="Scanned" value={report.updateTimestamp ? formatAge(report.updateTimestamp) + ' ago' : '-'} />
164
+ <Property label="Scanned" value={<TimeValue timestamp={report.updateTimestamp} />} />
165
165
  </PropertyList>
166
166
  </Section>
167
167
 
@@ -1,9 +1,10 @@
1
1
  import { Play, Clock, CheckCircle, XCircle, Loader2, SkipForward, PauseCircle } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink } from '../../ui/drawer-components'
4
- import { formatAge, formatDuration } from '../resource-utils'
4
+ import { formatDuration } from '../resource-utils'
5
5
  import { buildWorkflowExecutionModel, flattenWorkflowExecution, WorkflowExecutionModel, WorkflowExecutionNode, WorkflowTemplateReference } from '../../../utils/workflow-execution'
6
6
  import { SEVERITY_TEXT } from '../../../utils/badge-colors'
7
+ import { TimeValue } from '../../ui/ScheduleValue'
7
8
 
8
9
  interface WorkflowRendererProps {
9
10
  data: any
@@ -145,8 +146,8 @@ export function WorkflowRenderer({ data, onNavigate }: WorkflowRendererProps) {
145
146
  </span>
146
147
  } />
147
148
  {duration && <Property label="Duration" value={duration} />}
148
- {status.startedAt && <Property label="Started" value={formatAge(status.startedAt)} />}
149
- <Property label="Finished" value={status.finishedAt ? formatAge(status.finishedAt) : 'Running...'} />
149
+ {status.startedAt && <Property label="Started" value={<TimeValue timestamp={status.startedAt} />} />}
150
+ <Property label="Finished" value={<TimeValue timestamp={status.finishedAt} fallback="Running..." />} />
150
151
  {workflowTemplateRef && <Property label="Definition" value={<TemplateRefLink refInfo={workflowTemplateRef} onNavigate={onNavigate} />} />}
151
152
  {status.progress && (
152
153
  <Property label="Progress" value={status.progress} />
@@ -71,7 +71,7 @@ export function KyvernoModernPolicyCell({ resource, column }: { resource: any; c
71
71
  </span>
72
72
  )
73
73
  }
74
- return <span className="text-sm text-theme-text-secondary">{formatAge(last)} ago</span>
74
+ return <span className="text-sm text-theme-text-secondary">{formatAge(last)}</span>
75
75
  }
76
76
  case 'schedule': {
77
77
  const schedule = getKyvernoSchedule(resource)
@@ -1,10 +1,10 @@
1
1
  import { useState, useId } from 'react'
2
2
  import { Box } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
- import { formatAge } from '../../resource-utils'
5
4
  import { Collapse, CollapseChevron, disclosurePanelId } from '../../../ui/Collapse'
6
5
  import { revisionRoleBadges } from '../../../shared/ResourceActionsBar'
7
6
  import type { WorkloadRevision, WorkloadPodInfo } from '../../../../types'
7
+ import { formatRelativeAgeTime } from '../../../../utils/format'
8
8
 
9
9
  interface ReplicaSetProgressionProps {
10
10
  revisions: WorkloadRevision[]
@@ -90,7 +90,7 @@ export function ReplicaSetProgression({ revisions, pods, namespace, onNavigate }
90
90
  ))}
91
91
  </div>
92
92
  <span className="shrink-0 text-xs tabular-nums text-theme-text-tertiary">{rev.replicas ?? 0} replicas</span>
93
- <span className="shrink-0 text-xs text-theme-text-tertiary">{formatAge(rev.createdAt)}</span>
93
+ <span className="shrink-0 text-xs text-theme-text-tertiary">{formatRelativeAgeTime(rev.createdAt)}</span>
94
94
  </button>
95
95
  <Collapse open={isOpen} mountLazily id={disclosurePanelId(panelBase, rev.number)}>
96
96
  <div className="space-y-0.5 border-t border-theme-border/50 px-2 py-1.5 pl-8">
@@ -865,10 +865,6 @@ export function getCNPGBackupStartedAt(resource: any): string {
865
865
  return resource.status?.startedAt ? formatAge(resource.status.startedAt) : '-'
866
866
  }
867
867
 
868
- export function getCNPGBackupStoppedAt(resource: any): string {
869
- return resource.status?.stoppedAt ? formatAge(resource.status.stoppedAt) : '-'
870
- }
871
-
872
868
  export function getCNPGBackupName(resource: any): string {
873
869
  return resource.status?.backupName || '-'
874
870
  }
@@ -1,7 +1,7 @@
1
1
  // KEDA CRD utility functions
2
2
 
3
3
  import type { StatusBadge } from './resource-utils'
4
- import { healthColors, formatAge } from './resource-utils'
4
+ import { healthColors } from './resource-utils'
5
5
 
6
6
  // ============================================================================
7
7
  // SHARED HELPERS
@@ -108,12 +108,6 @@ export function getScaledObjectHpaName(resource: any): string {
108
108
  return resource.status?.hpaName || '-'
109
109
  }
110
110
 
111
- export function getScaledObjectLastActiveTime(resource: any): string {
112
- const lastActive = resource.status?.lastActiveTime
113
- if (!lastActive) return '-'
114
- return formatAge(lastActive)
115
- }
116
-
117
111
  export function getScaledObjectPollingInterval(resource: any): number {
118
112
  return resource.spec?.pollingInterval ?? 30
119
113
  }
@@ -1,7 +1,7 @@
1
1
  // Kyverno / Policy Report CRD utility functions
2
2
 
3
3
  import type { StatusBadge } from './resource-utils'
4
- import { healthColors, formatAge } from './resource-utils'
4
+ import { healthColors } from './resource-utils'
5
5
 
6
6
  // ============================================================================
7
7
  // SHARED HELPERS
@@ -286,12 +286,6 @@ export function getKyvernoPolicyAutogenRules(resource: any): string[] {
286
286
  return autogen.map((r: any) => r.name).filter(Boolean)
287
287
  }
288
288
 
289
- export function getKyvernoPolicyLastScheduleTime(resource: any): string {
290
- const lastSchedule = resource.status?.lastScheduleTime
291
- if (!lastSchedule) return '-'
292
- return formatAge(lastSchedule)
293
- }
294
-
295
289
  // ============================================================================
296
290
  // KYVERNO CLUSTERPOLICY UTILITIES (kyverno.io/v1)
297
291
  // ============================================================================
@@ -504,12 +504,6 @@ export function getBSLLastValidation(resource: any): string {
504
504
  return formatAge(lastValidation)
505
505
  }
506
506
 
507
- export function getBSLLastSynced(resource: any): string {
508
- const lastSynced = resource.status?.lastSyncedTime
509
- if (!lastSynced) return '-'
510
- return formatAge(lastSynced)
511
- }
512
-
513
507
  // Why the location is unavailable, in the validation controller's own words.
514
508
  // A BackupStorageLocation carries no conditions, so status.message is the only
515
509
  // place the reason exists — without it the phase is the whole story a reader
@@ -571,12 +565,6 @@ export function getBackupRepositoryMaintenanceFrequency(resource: any): string {
571
565
  return resource.spec?.maintenanceFrequency || '-'
572
566
  }
573
567
 
574
- export function getBackupRepositoryLastMaintenance(resource: any): string {
575
- const at = resource.status?.lastMaintenanceTime
576
- if (!at) return '-'
577
- return formatAge(at)
578
- }
579
-
580
568
  // Why the repository is not ready, in the controller's own words. Velero writes
581
569
  // it on the object and nowhere else, so without it the phase is the whole story
582
570
  // a reader gets.