@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
@@ -2,6 +2,7 @@ import { Globe, ArrowRight, Network, Filter } 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 HTTPRouteRendererProps {
7
8
  data: any
@@ -32,22 +33,8 @@ export function HTTPRouteRenderer({ data, onNavigate }: HTTPRouteRendererProps)
32
33
  ? parentStatuses[0].conditions
33
34
  : undefined
34
35
 
35
- function toGatewayRef(ref: any): ResourceRef {
36
- return {
37
- kind: 'Gateway',
38
- namespace: ref.namespace || routeNs,
39
- name: ref.name,
40
- group: 'gateway.networking.k8s.io',
41
- }
42
- }
43
-
44
- function toServiceRef(backend: any): ResourceRef {
45
- return {
46
- kind: 'Service',
47
- namespace: backend.namespace || routeNs,
48
- name: backend.name,
49
- }
50
- }
36
+ const toParentRef = (ref: any) => gatewayParentResourceRef(ref, routeNs)
37
+ const toBackendRef = (ref: any) => gatewayBackendResourceRef(ref, routeNs)
51
38
 
52
39
  return (
53
40
  <>
@@ -59,8 +46,8 @@ export function HTTPRouteRenderer({ data, onNavigate }: HTTPRouteRendererProps)
59
46
  const cond = (p.conditions || []).find((c: any) => c.type === 'Accepted' && c.status === 'False')
60
47
  const gwName = p.parentRef?.name || 'unknown'
61
48
  return cond?.reason
62
- ? `Gateway "${gwName}": ${cond.reason}${cond.message ? ' — ' + cond.message : ''}`
63
- : `Gateway "${gwName}" has not accepted this route.`
49
+ ? `Parent "${gwName}": ${cond.reason}${cond.message ? ' — ' + cond.message : ''}`
50
+ : `Parent "${gwName}" has not accepted this route.`
64
51
  }).join('; ')}
65
52
  />
66
53
  )}
@@ -90,12 +77,12 @@ export function HTTPRouteRenderer({ data, onNavigate }: HTTPRouteRendererProps)
90
77
  }
91
78
  />
92
79
  <Property
93
- label="Parent Gateways"
80
+ label="Parents"
94
81
  value={
95
82
  parentRefs.length > 0 ? (
96
83
  <div className="flex flex-wrap gap-1">
97
84
  {parentRefs.map((ref: any, i: number) => (
98
- <ResourceRefBadge key={`${ref.namespace || ''}-${ref.name}-${i}`} resourceRef={toGatewayRef(ref)} onClick={onNavigate} />
85
+ <ResourceRefBadge key={`${ref.namespace || ''}-${ref.name}-${i}`} resourceRef={toParentRef(ref)} onClick={onNavigate} />
99
86
  ))}
100
87
  </div>
101
88
  ) : 'None'
@@ -129,7 +116,7 @@ export function HTTPRouteRenderer({ data, onNavigate }: HTTPRouteRendererProps)
129
116
  <div className="text-xs text-theme-text-secondary flex flex-wrap items-center gap-1.5">
130
117
  <span className="italic text-theme-text-tertiary">all</span>
131
118
  <ArrowRight className="w-3 h-3 text-theme-text-tertiary shrink-0" />
132
- <ResourceRefBadge resourceRef={toServiceRef(backendRefs[0])} onClick={onNavigate} />
119
+ <ResourceRefBadge resourceRef={toBackendRef(backendRefs[0])} onClick={onNavigate} />
133
120
  {backendRefs[0].port && <span className="text-theme-text-tertiary">:{backendRefs[0].port}</span>}
134
121
  </div>
135
122
  )}
@@ -141,7 +128,7 @@ export function HTTPRouteRenderer({ data, onNavigate }: HTTPRouteRendererProps)
141
128
  return (
142
129
  <div key={bi} className="text-xs text-theme-text-secondary flex items-center gap-1.5 pl-1">
143
130
  <ArrowRight className="w-3 h-3 text-theme-text-tertiary shrink-0" />
144
- <ResourceRefBadge resourceRef={toServiceRef(b)} onClick={onNavigate} />
131
+ <ResourceRefBadge resourceRef={toBackendRef(b)} onClick={onNavigate} />
145
132
  {b.port && <span className="text-theme-text-tertiary">:{b.port}</span>}
146
133
  {pct !== null && <span className="text-theme-text-tertiary text-[10px]">{pct}%</span>}
147
134
  </div>
@@ -176,7 +163,7 @@ export function HTTPRouteRenderer({ data, onNavigate }: HTTPRouteRendererProps)
176
163
  {matchIdx === 0 && backendRefs.length === 1 && (
177
164
  <>
178
165
  <ArrowRight className="w-3 h-3 text-theme-text-tertiary shrink-0" />
179
- <ResourceRefBadge resourceRef={toServiceRef(backendRefs[0])} onClick={onNavigate} />
166
+ <ResourceRefBadge resourceRef={toBackendRef(backendRefs[0])} onClick={onNavigate} />
180
167
  {backendRefs[0].port && <span className="text-theme-text-tertiary">:{backendRefs[0].port}</span>}
181
168
  </>
182
169
  )}
@@ -189,7 +176,7 @@ export function HTTPRouteRenderer({ data, onNavigate }: HTTPRouteRendererProps)
189
176
  return (
190
177
  <div key={bi} className="text-xs text-theme-text-secondary flex items-center gap-1.5 pl-1">
191
178
  <ArrowRight className="w-3 h-3 text-theme-text-tertiary shrink-0" />
192
- <ResourceRefBadge resourceRef={toServiceRef(b)} onClick={onNavigate} />
179
+ <ResourceRefBadge resourceRef={toBackendRef(b)} onClick={onNavigate} />
193
180
  {b.port && <span className="text-theme-text-tertiary">:{b.port}</span>}
194
181
  {pct !== null && <span className="text-theme-text-tertiary text-[10px]">{pct}%</span>}
195
182
  </div>
@@ -234,7 +221,7 @@ export function HTTPRouteRenderer({ data, onNavigate }: HTTPRouteRendererProps)
234
221
  )}
235
222
  {filter.type === 'RequestMirror' && filter.requestMirror && (
236
223
  <span className="text-theme-text-tertiary">
237
- {filter.requestMirror.backendRef?.name || 'unknown'}
224
+ {filter.requestMirror.backendRef && <ResourceRefBadge resourceRef={toBackendRef(filter.requestMirror.backendRef)} onClick={onNavigate} />}
238
225
  {filter.requestMirror.backendRef?.port ? `:${filter.requestMirror.backendRef.port}` : ''}
239
226
  </span>
240
227
  )}
@@ -1,10 +1,10 @@
1
1
  import { Database, CheckCircle2, Shield } from 'lucide-react'
2
2
  import { Section, PropertyList, Property, ConditionsSection, ProblemAlerts } from '../../ui/drawer-components'
3
3
  import { Badge } from '../../ui/Badge'
4
- import { formatAge } from '../resource-utils'
5
4
  import { formatBytes } from '../../../utils/format'
6
5
  import { GitOpsStatusBadge, SyncCountdown } from '../../gitops'
7
6
  import { fluxConditionsToGitOpsStatus, type FluxCondition } from '../../../types/gitops'
7
+ import { TimeValue } from '../../ui/ScheduleValue'
8
8
 
9
9
  interface HelmRepositoryRendererProps {
10
10
  data: any
@@ -98,7 +98,7 @@ export function HelmRepositoryRenderer({ data }: HelmRepositoryRendererProps) {
98
98
  <Property label="Digest" value={artifact.digest} />
99
99
  <Property
100
100
  label="Last Updated"
101
- value={artifact.lastUpdateTime ? formatAge(artifact.lastUpdateTime) : '-'}
101
+ value={<TimeValue timestamp={artifact.lastUpdateTime} />}
102
102
  />
103
103
  {artifact.size && (
104
104
  <Property label="Size" value={formatBytes(artifact.size)} />
@@ -115,7 +115,7 @@ export function HelmRepositoryRenderer({ data }: HelmRepositoryRendererProps) {
115
115
  {status.lastHandledReconcileAt && (
116
116
  <Property
117
117
  label="Last Reconciled"
118
- value={formatAge(status.lastHandledReconcileAt)}
118
+ value={<TimeValue timestamp={status.lastHandledReconcileAt} />}
119
119
  />
120
120
  )}
121
121
  </PropertyList>
@@ -0,0 +1,52 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+ import { IngressRenderer } from './IngressRenderer'
6
+ import { IngressClassRenderer } from './IngressClassRenderer'
7
+ import { ingressClassParametersResourceRef } from '../../../utils/ingress-class-references'
8
+
9
+ describe('ingressClassParametersResourceRef', () => {
10
+ it('defaults to the core group and cluster scope, and requires a namespace for namespaced parameters', () => {
11
+ expect(ingressClassParametersResourceRef({ kind: 'IngressClassParams', apiGroup: 'elbv2.k8s.aws', name: 'shared' }))
12
+ .toEqual({ kind: 'IngressClassParams', group: 'elbv2.k8s.aws', namespace: '', name: 'shared' })
13
+ expect(ingressClassParametersResourceRef({ kind: 'ConfigMap', scope: 'Namespace', namespace: 'controller', name: 'settings' }))
14
+ .toEqual({ kind: 'ConfigMap', group: '', namespace: 'controller', name: 'settings' })
15
+ expect(ingressClassParametersResourceRef({ kind: 'ConfigMap', scope: 'Namespace', name: 'settings' })).toBeNull()
16
+ })
17
+ })
18
+
19
+ describe('Ingress class navigation', () => {
20
+ it('links the Ingress class and both parameter scopes, but leaves the legacy annotation as text', async () => {
21
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
22
+ const container = document.createElement('div')
23
+ const root = createRoot(container)
24
+ const onNavigate = vi.fn()
25
+ const click = async (name: string) => {
26
+ const button = [...container.querySelectorAll('button')].find(button => button.textContent === name)
27
+ expect(button).toBeDefined()
28
+ await act(async () => button!.click())
29
+ }
30
+ try {
31
+ await act(async () => root.render(<IngressRenderer onNavigate={onNavigate} data={{ metadata: { namespace: 'app', annotations: { 'kubernetes.io/ingress.class': 'old-controller' } }, spec: { ingressClassName: 'public' } }} />))
32
+ await click('public')
33
+ expect(onNavigate).toHaveBeenLastCalledWith({ kind: 'ingressclasses', group: 'networking.k8s.io', namespace: '', name: 'public' })
34
+
35
+ await act(async () => root.render(<IngressRenderer onNavigate={onNavigate} data={{ metadata: { annotations: { 'kubernetes.io/ingress.class': 'old-controller' } }, spec: {} }} />))
36
+ expect(container.textContent).toContain('old-controller')
37
+ expect([...container.querySelectorAll('button')].some(button => button.textContent === 'old-controller')).toBe(false)
38
+
39
+ for (const parameters of [
40
+ { name: 'shared', kind: 'IngressClassParams', apiGroup: 'elbv2.k8s.aws' },
41
+ { name: 'settings', kind: 'ConfigMap', scope: 'Namespace' as const, namespace: 'controller' },
42
+ ]) {
43
+ await act(async () => root.render(<IngressClassRenderer onNavigate={onNavigate} data={{ spec: { controller: 'example.test/ingress', parameters } }} />))
44
+ await click(parameters.name)
45
+ expect(onNavigate).toHaveBeenLastCalledWith(ingressClassParametersResourceRef(parameters))
46
+ }
47
+ } finally {
48
+ await act(async () => root.unmount())
49
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: false })
50
+ }
51
+ })
52
+ })
@@ -1,14 +1,18 @@
1
1
  import { Globe } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
- import { Section, PropertyList, Property } from '../../ui/drawer-components'
3
+ import { Section, PropertyList, Property, ResourceLink } from '../../ui/drawer-components'
4
+ import type { ResourceRef } from '../../../types'
5
+ import { ingressClassParametersResourceRef } from '../../../utils/ingress-class-references'
4
6
  import { BADGE_INACTIVE } from '../../../utils/badge-colors'
5
7
 
6
8
  interface IngressClassRendererProps {
7
9
  data: any
10
+ onNavigate?: (ref: ResourceRef) => void
8
11
  }
9
12
 
10
- export function IngressClassRenderer({ data }: IngressClassRendererProps) {
13
+ export function IngressClassRenderer({ data, onNavigate }: IngressClassRendererProps) {
11
14
  const spec = data.spec || {}
15
+ const parametersRef = spec.parameters ? ingressClassParametersResourceRef(spec.parameters) : null
12
16
  const annotations = data.metadata?.annotations || {}
13
17
 
14
18
  const isDefault = annotations['ingressclass.kubernetes.io/is-default-class'] === 'true'
@@ -39,7 +43,10 @@ export function IngressClassRenderer({ data }: IngressClassRendererProps) {
39
43
  <PropertyList>
40
44
  {spec.parameters.apiGroup && <Property label="API Group" value={spec.parameters.apiGroup} />}
41
45
  <Property label="Kind" value={spec.parameters.kind} />
42
- <Property label="Name" value={spec.parameters.name} />
46
+ <Property label="Name" value={parametersRef
47
+ ? <ResourceLink {...parametersRef} onNavigate={onNavigate} />
48
+ : spec.parameters.name
49
+ } />
43
50
  {spec.parameters.namespace && <Property label="Namespace" value={spec.parameters.namespace} />}
44
51
  {spec.parameters.scope && <Property label="Scope" value={spec.parameters.scope} />}
45
52
  </PropertyList>
@@ -1,9 +1,10 @@
1
1
  import { Globe, Shield, Clock } from 'lucide-react'
2
+ import type { ResourceRef } from '../../../types'
2
3
  import { Section, PropertyList, Property, AlertBanner, ResourceLink } from '../../ui/drawer-components'
3
4
 
4
5
  interface IngressRendererProps {
5
6
  data: any
6
- onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
7
+ onNavigate?: (ref: ResourceRef) => void
7
8
  }
8
9
 
9
10
  export function IngressRenderer({ data, onNavigate }: IngressRendererProps) {
@@ -42,7 +43,10 @@ export function IngressRenderer({ data, onNavigate }: IngressRendererProps) {
42
43
 
43
44
  <Section title="Ingress" icon={Globe}>
44
45
  <PropertyList>
45
- <Property label="Class" value={spec.ingressClassName || data.metadata?.annotations?.['kubernetes.io/ingress.class']} />
46
+ <Property label="Class" value={spec.ingressClassName
47
+ ? <ResourceLink name={spec.ingressClassName} kind="ingressclasses" group="networking.k8s.io" onNavigate={onNavigate} />
48
+ : data.metadata?.annotations?.['kubernetes.io/ingress.class']
49
+ } />
46
50
  {lbIngress.length > 0 && (
47
51
  <Property label="Address" value={lbIngress[0].ip || lbIngress[0].hostname} />
48
52
  )}
@@ -3,6 +3,7 @@ import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink, useOperationalIssuesShown } from '../../ui/drawer-components'
4
4
  import { kindToPlural } from '../../../utils/navigation'
5
5
  import { CAPACITY_TYPE_BADGE, BADGE_INACTIVE } from '../../../utils/badge-colors'
6
+ import { formatRelativeAgeTime } from '../../../utils/format'
6
7
  import {
7
8
  getNodeClaimStatus,
8
9
  getNodeClaimInstanceType,
@@ -14,19 +15,6 @@ import {
14
15
  getNodeClaimExpireAfter,
15
16
  } from '../resource-utils-karpenter'
16
17
 
17
- function formatRelativeTime(isoString: string): string {
18
- const date = new Date(isoString)
19
- if (isNaN(date.getTime())) return ''
20
- const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
21
- if (seconds < 60) return `${seconds}s ago`
22
- const minutes = Math.floor(seconds / 60)
23
- if (minutes < 60) return `${minutes}m ago`
24
- const hours = Math.floor(minutes / 60)
25
- if (hours < 24) return `${hours}h ago`
26
- const days = Math.floor(hours / 24)
27
- return `${days}d ago`
28
- }
29
-
30
18
  interface KarpenterNodeClaimRendererProps {
31
19
  data: any
32
20
  onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
@@ -205,7 +193,7 @@ export function KarpenterNodeClaimRenderer({ data, onNavigate }: KarpenterNodeCl
205
193
  </span>
206
194
  {transitionTime && (
207
195
  <span className="text-xs text-theme-text-tertiary shrink-0">
208
- {formatRelativeTime(transitionTime)}
196
+ {formatRelativeAgeTime(transitionTime, '')}
209
197
  </span>
210
198
  )}
211
199
  </div>
@@ -0,0 +1,85 @@
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 { expect, it, vi } from 'vitest'
6
+ import { KedaTriggerAuthRenderer } from './KedaTriggerAuthRenderer'
7
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
8
+ const spec = {
9
+ secretTargetRef: [{ parameter: 'token', name: 'direct', key: 'token' }],
10
+ gcpSecretManager: {
11
+ credentials: {
12
+ clientSecret: {
13
+ valueFrom: { secretKeyRef: { name: 'iam', key: 'credentials.json' } },
14
+ },
15
+ },
16
+ },
17
+ }
18
+
19
+ it('links the actual nested GCP credential Secret in the authentication namespace', async () => {
20
+ const element = document.createElement('div')
21
+ const root = createRoot(element)
22
+ const onNavigate = vi.fn()
23
+ try {
24
+ await act(async () =>
25
+ root.render(
26
+ <KedaTriggerAuthRenderer
27
+ data={{
28
+ kind: 'TriggerAuthentication',
29
+ metadata: { namespace: 'app' },
30
+ spec,
31
+ }}
32
+ onNavigate={onNavigate}
33
+ />,
34
+ ),
35
+ )
36
+ const button = [...element.querySelectorAll('button')].find(b => b.textContent === 'iam')
37
+ expect(button).toBeDefined()
38
+ await act(async () => button!.click())
39
+ expect(onNavigate).toHaveBeenCalledWith({
40
+ kind: 'secrets',
41
+ group: '',
42
+ namespace: 'app',
43
+ name: 'iam',
44
+ })
45
+ expect(element.textContent).toContain('credentials.json')
46
+ } finally {
47
+ await act(async () => root.unmount())
48
+ }
49
+ })
50
+
51
+ it('retains cluster-auth names without inventing an operator credential namespace', () => {
52
+ const html = renderToStaticMarkup(
53
+ <KedaTriggerAuthRenderer
54
+ data={{
55
+ kind: 'ClusterTriggerAuthentication',
56
+ metadata: { name: 'cluster' },
57
+ spec,
58
+ }}
59
+ onNavigate={() => {}}
60
+ />,
61
+ )
62
+ const doc = new DOMParser().parseFromString(html, 'text/html')
63
+ expect(doc.body.textContent).toContain('iam')
64
+ expect(doc.body.textContent).toContain('direct')
65
+ expect([...doc.querySelectorAll('button')].some(b => b.textContent === 'iam' || b.textContent === 'direct')).toBe(
66
+ false,
67
+ )
68
+ })
69
+
70
+ it('does not accept the previously invented shallow GCP reference shape', () => {
71
+ const html = renderToStaticMarkup(
72
+ <KedaTriggerAuthRenderer
73
+ data={{
74
+ kind: 'TriggerAuthentication',
75
+ metadata: { namespace: 'app' },
76
+ spec: {
77
+ gcpSecretManager: {
78
+ credentials: { clientSecret: { name: 'fictional' } },
79
+ },
80
+ },
81
+ }}
82
+ />,
83
+ )
84
+ expect(html).not.toContain('fictional')
85
+ })
@@ -1,13 +1,13 @@
1
1
  import { Cpu, Settings } from 'lucide-react'
2
2
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink, useOperationalIssuesShown} from '../../ui/drawer-components'
3
3
  import { kindToPlural } from '../../../utils/navigation'
4
+ import { TimeValue } from '../../ui/ScheduleValue'
4
5
  import {
5
6
  getScaledObjectStatus,
6
7
  getScaledObjectTarget,
7
8
  getScaledObjectReplicas,
8
9
  getScaledObjectTriggers,
9
10
  getScaledObjectHpaName,
10
- getScaledObjectLastActiveTime,
11
11
  getScaledObjectPollingInterval,
12
12
  getScaledObjectCooldownPeriod,
13
13
  } from '../resource-utils-keda'
@@ -97,7 +97,7 @@ export function KedaScaledObjectRenderer({ data, onNavigate }: KedaScaledObjectR
97
97
  }
98
98
  return hpaName
99
99
  })()} />
100
- <Property label="Last Active" value={getScaledObjectLastActiveTime(data)} />
100
+ <Property label="Last Active" value={<TimeValue timestamp={data.status?.lastActiveTime} />} />
101
101
  </PropertyList>
102
102
  {fallback && (
103
103
  <div className="mt-2 pt-2 border-t border-theme-border">
@@ -21,6 +21,8 @@ export function KedaTriggerAuthRenderer({ data, onNavigate }: KedaTriggerAuthRen
21
21
  const awsSecretManager = spec.awsSecretManager
22
22
  const gcpSecretManager = spec.gcpSecretManager
23
23
  const podIdentity = spec.podIdentity
24
+ const credentialRef = gcpSecretManager?.credentials?.clientSecret?.valueFrom?.secretKeyRef
25
+ const namespace = data.metadata?.namespace
24
26
 
25
27
  return (
26
28
  <>
@@ -48,7 +50,7 @@ export function KedaTriggerAuthRenderer({ data, onNavigate }: KedaTriggerAuthRen
48
50
  <div className="flex flex-wrap gap-x-4 gap-y-1 text-sm">
49
51
  <span className="text-theme-text-primary font-medium">{ref.parameter}</span>
50
52
  <span className="text-theme-text-secondary">
51
- Secret: <ResourceLink name={ref.name} kind="secrets" namespace={data.metadata?.namespace || ''} onNavigate={onNavigate} /> / {ref.key}
53
+ Secret: {namespace ? <ResourceLink name={ref.name} kind="secrets" group="" namespace={namespace} onNavigate={onNavigate} /> : ref.name} / {ref.key}
52
54
  </span>
53
55
  </div>
54
56
  </div>
@@ -150,9 +152,10 @@ export function KedaTriggerAuthRenderer({ data, onNavigate }: KedaTriggerAuthRen
150
152
  {gcpSecretManager && (
151
153
  <Section title="GCP Secret Manager" icon={Cloud}>
152
154
  <PropertyList>
153
- {gcpSecretManager.credentials?.clientSecret?.name && (
154
- <Property label="Credentials Secret" value={gcpSecretManager.credentials.clientSecret.name} />
155
+ {credentialRef?.name && (
156
+ <Property label="Credentials Secret" value={namespace ? <ResourceLink name={credentialRef.name} kind="secrets" group="" namespace={namespace} onNavigate={onNavigate} /> : credentialRef.name} />
155
157
  )}
158
+ {credentialRef?.key && <Property label="Credentials Key" value={credentialRef.key} />}
156
159
  </PropertyList>
157
160
  {gcpSecretManager.secrets && gcpSecretManager.secrets.length > 0 && (
158
161
  <div className="mt-2 space-y-1">
@@ -1,8 +1,8 @@
1
1
  import { Layers, FolderTree, GitBranch } from 'lucide-react'
2
2
  import { Section, PropertyList, Property, ConditionsSection, ProblemAlerts, ResourceLink } from '../../ui/drawer-components'
3
- import { formatAge } from '../resource-utils'
4
3
  import { GitOpsStatusBadge, ManagedResourcesList, SyncCountdown } from '../../gitops'
5
4
  import { fluxConditionsToGitOpsStatus, parseFluxInventory, type FluxCondition } from '../../../types/gitops'
5
+ import { TimeValue } from '../../ui/ScheduleValue'
6
6
 
7
7
  interface KustomizationRendererProps {
8
8
  data: any
@@ -175,7 +175,7 @@ export function KustomizationRenderer({ data, onNavigate }: KustomizationRendere
175
175
  {status.lastHandledReconcileAt && (
176
176
  <Property
177
177
  label="Last Reconciled"
178
- value={formatAge(status.lastHandledReconcileAt)}
178
+ value={<TimeValue timestamp={status.lastHandledReconcileAt} />}
179
179
  />
180
180
  )}
181
181
  </PropertyList>
@@ -2,8 +2,9 @@ import { Globe, RefreshCw } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, AlertBanner, ResourceLink } from '../../ui/drawer-components'
4
4
  import { HEALTH_BADGE_COLORS } from '../../../utils/badge-colors'
5
- import { formatAge, healthColors } from '../resource-utils'
5
+ import { healthColors } from '../resource-utils'
6
6
  import type { StatusBadge } from '../resource-utils'
7
+ import { TimeValue } from '../../ui/ScheduleValue'
7
8
 
8
9
  type Nav = (ref: { kind: string; namespace: string; name: string; group?: string }) => void
9
10
 
@@ -66,7 +67,7 @@ export function KyvernoGlobalContextRenderer({ data, onNavigate }: { data: any;
66
67
  </span>
67
68
  }
68
69
  />
69
- {refresh.at && <Property label="Last Refreshed" value={`${formatAge(refresh.at)} ago`} />}
70
+ {refresh.at && <Property label="Last Refreshed" value={<TimeValue timestamp={refresh.at} />} />}
70
71
  {api?.refreshInterval && <Property label="Interval" value={api.refreshInterval} />}
71
72
  {api?.retryLimit != null && <Property label="Retry Limit" value={String(api.retryLimit)} />}
72
73
  </PropertyList>
@@ -3,7 +3,8 @@ import { clsx } from 'clsx'
3
3
  import { ListChecks, Workflow, Boxes, FileWarning } from 'lucide-react'
4
4
  import { Section, PropertyList, Property, AlertBanner, ResourceLink } from '../../ui/drawer-components'
5
5
  import { HEALTH_BADGE_COLORS, SEVERITY_BADGE } from '../../../utils/badge-colors'
6
- import { formatAge } from '../resource-utils'
6
+ import { TimeValue } from '../../ui/ScheduleValue'
7
+ import { formatRelativeAgeTime } from '../../../utils/format'
7
8
  import {
8
9
  getKyvernoRequestType,
9
10
  getKyvernoRequestTriggers,
@@ -155,7 +156,7 @@ export function KyvernoUpdateRequestRenderer({ data, onNavigate }: { data: any;
155
156
  }
156
157
  />
157
158
  {data?.metadata?.creationTimestamp && (
158
- <Property label="Queued" value={`${formatAge(data.metadata.creationTimestamp)} ago`} />
159
+ <Property label="Queued" value={<TimeValue timestamp={data.metadata.creationTimestamp} />} />
159
160
  )}
160
161
  </PropertyList>
161
162
  {message && state.level !== 'unhealthy' && (
@@ -290,7 +291,7 @@ export function KyvernoEphemeralReportRenderer({ data, onNavigate }: { data: any
290
291
  </span>
291
292
  )}
292
293
  {at && (
293
- <span className="ml-auto shrink-0 text-theme-text-tertiary">{formatAge(at)} ago</span>
294
+ <span className="ml-auto shrink-0 text-theme-text-tertiary">{formatRelativeAgeTime(at)}</span>
294
295
  )}
295
296
  </div>
296
297
  )
@@ -1,24 +1,12 @@
1
1
  import { Timer } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, AlertBanner } from '../../ui/drawer-components'
4
+ import { formatRelativeAgeTime } from '../../../utils/format'
4
5
 
5
6
  interface LeaseRendererProps {
6
7
  data: any
7
8
  }
8
9
 
9
- function formatRelativeTime(timestamp: string): string {
10
- const diff = Date.now() - new Date(timestamp).getTime()
11
- if (diff < 0) return 'just now'
12
- const seconds = Math.floor(diff / 1000)
13
- if (seconds < 60) return `${seconds}s ago`
14
- const minutes = Math.floor(seconds / 60)
15
- if (minutes < 60) return `${minutes}m ago`
16
- const hours = Math.floor(minutes / 60)
17
- if (hours < 24) return `${hours}h ago`
18
- const days = Math.floor(hours / 24)
19
- return `${days}d ago`
20
- }
21
-
22
10
  export function LeaseRenderer({ data }: LeaseRendererProps) {
23
11
  const spec = data.spec || {}
24
12
 
@@ -68,7 +56,7 @@ export function LeaseRenderer({ data }: LeaseRendererProps) {
68
56
  value={
69
57
  spec.acquireTime ? (
70
58
  <span title={new Date(spec.acquireTime).toLocaleString()}>
71
- {formatRelativeTime(spec.acquireTime)}
59
+ {formatRelativeAgeTime(spec.acquireTime)}
72
60
  </span>
73
61
  ) : undefined
74
62
  }
@@ -81,7 +69,7 @@ export function LeaseRenderer({ data }: LeaseRendererProps) {
81
69
  className={clsx(renewColor)}
82
70
  title={new Date(renewTime).toLocaleString()}
83
71
  >
84
- {formatRelativeTime(renewTime)}
72
+ {formatRelativeAgeTime(renewTime)}
85
73
  </span>
86
74
  ) : undefined
87
75
  }
@@ -1,5 +1,7 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { quotaUsageRatio } from './NamespaceRenderer'
1
+ import { createElement } from 'react'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import { NamespaceRenderer, quotaUsageRatio } from './NamespaceRenderer'
3
5
 
4
6
  describe('quotaUsageRatio', () => {
5
7
  // status.used is computed with quantity arithmetic, so a used pod count of
@@ -28,3 +30,68 @@ describe('quotaUsageRatio', () => {
28
30
  expect(quotaUsageRatio('pods', '5', 'garbage')).toBeNull()
29
31
  })
30
32
  })
33
+
34
+ describe('namespace deletion diagnostics', () => {
35
+ afterEach(() => vi.useRealTimers())
36
+
37
+ it('shows controller blockers, namespace finalizers and time since deletion began', () => {
38
+ vi.useFakeTimers()
39
+ vi.setSystemTime(new Date('2026-10-10T12:00:00Z'))
40
+ const html = renderToString(createElement(NamespaceRenderer, { data: {
41
+ metadata: {
42
+ creationTimestamp: '2023-01-01T00:00:00Z',
43
+ deletionTimestamp: '2026-10-10T10:00:00Z',
44
+ finalizers: ['metadata.example.com/not-the-namespace-finalizer'],
45
+ },
46
+ spec: { finalizers: ['kubernetes'] },
47
+ status: { phase: 'Terminating', conditions: [
48
+ { type: 'NamespaceContentRemaining', status: 'True', reason: 'SomeResourcesRemain', message: 'Some resources are remaining: configmaps. has 1 resource instances' },
49
+ { type: 'NamespaceFinalizersRemaining', status: 'True', reason: 'SomeFinalizersRemain', message: 'Some content has finalizers remaining: example.com/hold in 1 resource instances' },
50
+ { type: 'NamespaceDeletionDiscoveryFailure', status: 'False', reason: 'ResourcesDiscovered', message: 'All resources successfully discovered' },
51
+ ] },
52
+ } }))
53
+ expect(html).toContain('Terminating for')
54
+ expect(html).toContain('>2h<')
55
+ expect(html).toContain('Namespace finalizers')
56
+ expect(html).toContain('kubernetes')
57
+ expect(html).not.toContain('metadata.example.com/not-the-namespace-finalizer')
58
+ expect(html).toContain('Conditions (3) · 2 failing')
59
+ expect(html).toContain('SomeResourcesRemain')
60
+ expect(html).toContain('configmaps. has 1 resource instances')
61
+ expect(html).toContain('example.com/hold in 1 resource instances')
62
+ })
63
+
64
+ it('omits deletion duration, finalizers and empty conditions for an active namespace', () => {
65
+ const html = renderToString(createElement(NamespaceRenderer, { data: {
66
+ metadata: { creationTimestamp: '2023-01-01T00:00:00Z' },
67
+ spec: { finalizers: ['kubernetes'] },
68
+ status: { phase: 'Active' },
69
+ } }))
70
+ expect(html).toContain('Active')
71
+ expect(html).not.toContain('Terminating for')
72
+ expect(html).not.toContain('Conditions (')
73
+ expect(html).not.toContain('Namespace finalizers')
74
+ })
75
+
76
+ it('does not substitute creation age when a terminating namespace lacks a deletion timestamp', () => {
77
+ const html = renderToString(createElement(NamespaceRenderer, { data: {
78
+ metadata: { creationTimestamp: '2023-01-01T00:00:00Z' },
79
+ status: { phase: 'Terminating', conditions: [] },
80
+ } }))
81
+ expect(html).toContain('Terminating')
82
+ expect(html).not.toContain('Terminating for')
83
+ expect(html).not.toContain('Conditions (')
84
+ expect(html).not.toContain('Namespace finalizers')
85
+ })
86
+
87
+ it('retains conditions with unknown status without calling them failures', () => {
88
+ const html = renderToString(createElement(NamespaceRenderer, { data: {
89
+ status: { phase: 'Active', conditions: [
90
+ { type: 'NamespaceDeletionDiscoveryFailure', status: 'Unknown', reason: 'DiscoveryPending', message: 'Waiting for discovery' },
91
+ ] },
92
+ } }))
93
+ expect(html).toContain('Conditions (1)')
94
+ expect(html).toContain('Waiting for discovery')
95
+ expect(html).not.toContain('failing')
96
+ })
97
+ })
@@ -1,7 +1,8 @@
1
1
  import { Shield, Box, Users, Gauge } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
- import { Section, PropertyList, Property, ResourceLink } from '../../ui/drawer-components'
3
+ import { Section, PropertyList, Property, ResourceLink, ConditionsSection } from '../../ui/drawer-components'
4
4
  import type { RBACNamespaceResponse, RBACBindingWithSubjects, RBACSubject, ResourceRef } from '../../../types'
5
+ import { formatAge } from '../resource-utils'
5
6
  import { rbacKindBadgeClass } from '../../../utils/rbac-badges'
6
7
  import { RBACErrorSection } from './RBACErrorSection'
7
8
  import { NamespaceLimitRangesSection } from './LimitRangeRenderer'
@@ -70,12 +71,16 @@ export function NamespaceRenderer({ data, rbacData, rbacLoading, rbacError, quot
70
71
  )}>{phase}</span>
71
72
  ) : undefined
72
73
  } />
74
+ {metadata.deletionTimestamp && <Property label="Terminating for" value={formatAge(metadata.deletionTimestamp)} />}
75
+ {metadata.deletionTimestamp && data.spec?.finalizers?.length > 0 && <Property label="Namespace finalizers" value={data.spec.finalizers.join(', ')} />}
73
76
  {istioInjection && <Property label="Istio injection" value={istioInjection} />}
74
77
  {linkerdInjection && <Property label="Linkerd injection" value={linkerdInjection} />}
75
78
  {managedBy && <Property label="Managed by" value={managedBy} />}
76
79
  </PropertyList>
77
80
  </Section>
78
81
 
82
+ <ConditionsSection conditions={status.conditions} defaultExpanded />
83
+
79
84
  {/* ResourceQuota usage — only when host wired the fetch. */}
80
85
  {(quotaError || (quotaData != null && quotaData.length > 0)) && (
81
86
  <NamespaceQuotaSection quotas={quotaData ?? []} error={quotaError ?? null} />