@skyhook-io/k8s-ui 1.16.2 → 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 (152) hide show
  1. package/package.json +3 -3
  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/CNPGBackupSummary.tsx +223 -0
  6. package/src/components/cnpg/CNPGClusterSummary.tsx +222 -0
  7. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +264 -0
  8. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +80 -0
  9. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +176 -0
  10. package/src/components/cnpg/CNPGObjectSummary.test.tsx +236 -0
  11. package/src/components/cnpg/CNPGPoolerSummary.tsx +70 -0
  12. package/src/components/cnpg/CNPGSharedSummary.tsx +88 -0
  13. package/src/components/cnpg/index.ts +14 -0
  14. package/src/components/cnpg/primitives.tsx +134 -0
  15. package/src/components/cnpg/relations.test.ts +304 -0
  16. package/src/components/cnpg/relations.ts +417 -0
  17. package/src/components/cnpg/workspace.test.ts +285 -0
  18. package/src/components/cnpg/workspace.ts +700 -0
  19. package/src/components/gitops/GitOpsTableView.tsx +9 -24
  20. package/src/components/gitops/detail-helpers.ts +1 -1
  21. package/src/components/gitops/insights/GitOpsInsightViews.tsx +5 -12
  22. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +2 -5
  23. package/src/components/issues/IssuesView.tsx +10 -1
  24. package/src/components/issues/issue-timing.ts +2 -2
  25. package/src/components/issues/issues.test.ts +1 -1
  26. package/src/components/issues/types.ts +24 -0
  27. package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
  28. package/src/components/logs/WorkloadLogsViewer.tsx +17 -3
  29. package/src/components/logs/useLogBuffer.test.ts +17 -0
  30. package/src/components/logs/useLogStream.ts +11 -0
  31. package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
  32. package/src/components/resources/ResourcesSidebar.tsx +183 -11
  33. package/src/components/resources/ResourcesView.tsx +12 -9
  34. package/src/components/resources/index.ts +1 -1
  35. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
  36. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
  37. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +2 -2
  38. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +2 -2
  39. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +2 -2
  40. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +2 -2
  42. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +2 -2
  43. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +2 -1
  44. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
  45. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
  46. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +2 -2
  47. package/src/components/resources/renderers/CNPGShared.tsx +2 -2
  48. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  49. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
  50. package/src/components/resources/renderers/CronJobRenderer.tsx +6 -4
  51. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +3 -2
  52. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +24 -0
  53. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +1 -1
  54. package/src/components/resources/renderers/EventRenderer.tsx +3 -3
  55. package/src/components/resources/renderers/ExperimentRenderer.tsx +2 -2
  56. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
  57. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
  58. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -4
  59. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +11 -24
  60. package/src/components/resources/renderers/GatewayRouteReferences.test.tsx +63 -0
  61. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -3
  62. package/src/components/resources/renderers/HPARenderer.tsx +2 -2
  63. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +12 -25
  64. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -3
  65. package/src/components/resources/renderers/IngressClassReferences.test.tsx +52 -0
  66. package/src/components/resources/renderers/IngressClassRenderer.tsx +10 -3
  67. package/src/components/resources/renderers/IngressRenderer.tsx +6 -2
  68. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -14
  69. package/src/components/resources/renderers/KedaCredentials.test.tsx +85 -0
  70. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
  71. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
  72. package/src/components/resources/renderers/KustomizationRenderer.tsx +2 -2
  73. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +3 -2
  74. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +4 -3
  75. package/src/components/resources/renderers/LeaseRenderer.tsx +3 -15
  76. package/src/components/resources/renderers/NamespaceRenderer.test.ts +69 -2
  77. package/src/components/resources/renderers/NamespaceRenderer.tsx +6 -1
  78. package/src/components/resources/renderers/NodeRenderer.test.tsx +81 -1
  79. package/src/components/resources/renderers/NodeRenderer.tsx +56 -15
  80. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -3
  81. package/src/components/resources/renderers/PodClassReferences.test.tsx +42 -0
  82. package/src/components/resources/renderers/PodNomination.test.tsx +57 -0
  83. package/src/components/resources/renderers/PodRenderer.tsx +21 -7
  84. package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
  85. package/src/components/resources/renderers/RolloutRenderer.tsx +3 -2
  86. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  87. package/src/components/resources/renderers/SecretRenderer.test.tsx +88 -0
  88. package/src/components/resources/renderers/SecretRenderer.tsx +34 -5
  89. package/src/components/resources/renderers/ServiceRenderer.test.tsx +43 -0
  90. package/src/components/resources/renderers/ServiceRenderer.tsx +11 -3
  91. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +8 -21
  92. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +5 -7
  93. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +3 -3
  94. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +2 -2
  95. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +3 -3
  96. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +2 -2
  97. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  98. package/src/components/resources/renderers/WorkflowRenderer.tsx +4 -3
  99. package/src/components/resources/renderers/kyverno-modern-cells.tsx +1 -1
  100. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +2 -2
  101. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +1 -1
  102. package/src/components/resources/resource-utils-cnpg.ts +3 -7
  103. package/src/components/resources/resource-utils-keda.ts +1 -7
  104. package/src/components/resources/resource-utils-kyverno.ts +1 -7
  105. package/src/components/resources/resource-utils-velero.ts +0 -12
  106. package/src/components/resources/resource-utils.ts +17 -33
  107. package/src/components/resources/status-overclaim.test.ts +1 -1
  108. package/src/components/shared/DrainPlanDialog.tsx +1 -0
  109. package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
  110. package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
  111. package/src/components/shared/ResourceActionsBar.tsx +9 -20
  112. package/src/components/shared/ResourceRendererDispatch.tsx +1 -1
  113. package/src/components/shared/SetImageDialog.test.ts +75 -6
  114. package/src/components/shared/SetImageDialog.tsx +92 -84
  115. package/src/components/shared/index.ts +2 -1
  116. package/src/components/timeline/TimelineList.tsx +187 -38
  117. package/src/components/timeline/TimelineListFold.test.tsx +56 -0
  118. package/src/components/timeline/TimelineSwimlanes.tsx +2 -10
  119. package/src/components/timeline/TimelineToolbarState.test.tsx +98 -0
  120. package/src/components/timeline/index.ts +1 -0
  121. package/src/components/timeline/timeline-filters.ts +7 -0
  122. package/src/components/topology/TopologyGraph.tsx +5 -3
  123. package/src/components/ui/ScheduleValue.test.tsx +5 -0
  124. package/src/components/ui/ScheduleValue.tsx +3 -2
  125. package/src/components/ui/drawer-components.test.tsx +66 -1
  126. package/src/components/ui/drawer-components.tsx +43 -29
  127. package/src/components/workload/WorkloadView.deleted.test.tsx +58 -0
  128. package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
  129. package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
  130. package/src/components/workload/WorkloadView.tsx +216 -48
  131. package/src/components/workload/index.ts +1 -0
  132. package/src/components/workload/workload-logs-availability.test.ts +6 -0
  133. package/src/index.ts +3 -0
  134. package/src/types/capacity.ts +10 -0
  135. package/src/types/core.ts +32 -1
  136. package/src/utils/format.test.ts +40 -2
  137. package/src/utils/format.ts +5 -1
  138. package/src/utils/gateway-references.test.ts +17 -0
  139. package/src/utils/gateway-references.ts +29 -0
  140. package/src/utils/gitops-write-guard.test.ts +401 -0
  141. package/src/utils/gitops-write-guard.ts +509 -0
  142. package/src/utils/index.ts +2 -0
  143. package/src/utils/ingress-class-references.ts +22 -0
  144. package/src/utils/log-level.ts +14 -0
  145. package/src/utils/navigation.test.ts +13 -0
  146. package/src/utils/navigation.ts +5 -0
  147. package/src/utils/node-lifecycle.test.ts +36 -0
  148. package/src/utils/node-lifecycle.ts +77 -0
  149. package/src/utils/resource-hierarchy.test.ts +32 -0
  150. package/src/utils/resource-hierarchy.ts +28 -2
  151. package/src/utils/resource-refs.test.ts +23 -0
  152. package/src/utils/resource-refs.ts +30 -0
@@ -44,6 +44,8 @@ export function CNPGObjectStoreRenderer({
44
44
  const credentialSecret = getCNPGObjectStoreCredentialSecret(data)
45
45
  const cfg = data?.spec?.configuration ?? {}
46
46
  const failing = windows.filter((w) => w.failingSinceLastSuccess)
47
+ const archiveStopped = failing.filter((w) => archivingFailing?.has(w.server)).map((w) => w.server)
48
+ const neverSucceeded = failing.filter((w) => !w.lastSuccessfulBackupTime).map((w) => w.server)
47
49
 
48
50
  return (
49
51
  <>
@@ -51,7 +53,13 @@ export function CNPGObjectStoreRenderer({
51
53
  <AlertBanner
52
54
  variant="error"
53
55
  title={`Backups failing for ${failing.length === 1 ? failing[0].server : `${failing.length} servers`}`}
54
- message="The most recent backup attempt failed after the last success, so the recoverable range has stopped moving forward."
56
+ message={
57
+ neverSucceeded.length > 0
58
+ ? `No successful base backup is recorded for ${neverSucceeded.join(', ')}, so recoverability is not established.`
59
+ : archiveStopped.length > 0
60
+ ? `A base backup failure is recorded after the last recorded success, and WAL archiving has stopped for ${archiveStopped.join(', ')}: nothing written since the last archived WAL can be recovered.`
61
+ : 'A base backup failure is recorded after the last recorded success. While WAL archiving works, recovery from that backup can still replay archived WAL written since.'
62
+ }
55
63
  />
56
64
  )}
57
65
 
@@ -60,7 +68,7 @@ export function CNPGObjectStoreRenderer({
60
68
  // Configured but empty is NOT the same as healthy. Saying nothing here
61
69
  // would read as "backups are fine" on a store holding nothing.
62
70
  <div className="text-sm text-theme-text-secondary">
63
- No server has reported a backup yet, so there is nothing to restore from this store.
71
+ No server has recorded a backup in this store's status, so recoverability is not established.
64
72
  </div>
65
73
  ) : (
66
74
  <div className="space-y-2">
@@ -194,10 +202,10 @@ function RecoveryWindowRow({
194
202
  ? 'Not advancing'
195
203
  : w.lastSuccessfulBackupTime
196
204
  ? 'Recoverable'
197
- : 'No backups yet'}
205
+ : 'No backup recorded'}
198
206
  </span>
199
207
  </div>
200
- {stalled && !w.failingSinceLastSuccess && (
208
+ {stalled && (
201
209
  <div className="text-xs text-warning-text mb-1">
202
210
  {/* The timestamps below are real and still describe the last backup that
203
211
  worked. What they no longer describe is a window still growing, and
@@ -217,7 +225,7 @@ function RecoveryWindowRow({
217
225
  {w.lastSuccessfulBackupTime ? (
218
226
  <Property label="Last Successful Backup" value={<RecoveryTime at={w.lastSuccessfulBackupTime} />} />
219
227
  ) : (
220
- <Property label="Last Successful Backup" value="never" />
228
+ <Property label="Last Successful Backup" value="none recorded" />
221
229
  )}
222
230
  {w.lastFailedBackupTime && (
223
231
  <Property label="Last Failed Attempt" value={<RecoveryTime at={w.lastFailedBackupTime} />} />
@@ -226,8 +234,9 @@ function RecoveryWindowRow({
226
234
  </PropertyList>
227
235
  {w.failingSinceLastSuccess && (
228
236
  <div className="mt-2 pt-2 border-t border-theme-border text-xs text-theme-text-secondary">
229
- Every backup since the last success has failed. The oldest restorable point will still age
230
- out under the retention policy, so the window is shrinking from both ends.
237
+ {w.lastSuccessfulBackupTime
238
+ ? 'A backup failure is recorded after the last recorded success. Restoring from that success replays the WAL archived since, which takes longer the longer this lasts.'
239
+ : 'No successful backup is recorded for this server, so recoverability is not established.'}
231
240
  </div>
232
241
  )}
233
242
  </div>
@@ -1,11 +1,11 @@
1
1
  import { Clock, Database } from 'lucide-react'
2
2
  import { Section, PropertyList, Property, AlertBanner, ResourceLink } from '../../ui/drawer-components'
3
3
  import { CronValue } from '../../ui/ScheduleValue'
4
+ import { TimeValue } from '../../ui/ScheduleValue'
4
5
  import {
5
6
  getCNPGScheduledBackupCluster,
6
7
  getCNPGScheduleCron,
7
8
  getCNPGScheduledBackupMethod,
8
- getCNPGScheduledBackupLastSchedule,
9
9
  getCNPGScheduledBackupNextSchedule,
10
10
  getCNPGScheduledBackupIsSuspended,
11
11
  getCNPGScheduledBackupIsImmediate,
@@ -39,7 +39,7 @@ export function CNPGScheduledBackupRenderer({ data, onNavigate }: CNPGScheduledB
39
39
  <Section title="Schedule" icon={Clock} defaultExpanded>
40
40
  <PropertyList>
41
41
  <Property label="Cron Expression" value={<CronValue cron={getCNPGScheduleCron(data)} dialect="seconds" />} />
42
- <Property label="Last Schedule" value={getCNPGScheduledBackupLastSchedule(data)} />
42
+ <Property label="Last Schedule" value={<TimeValue timestamp={data.status?.lastScheduleTime} />} />
43
43
  <Property label="Next Schedule" value={getCNPGScheduledBackupNextSchedule(data)} />
44
44
  <Property label="Suspended" value={isSuspended ? 'Yes' : 'No'} />
45
45
  <Property label="Immediate" value={getCNPGScheduledBackupIsImmediate(data) ? 'Yes' : 'No'} />
@@ -1,4 +1,4 @@
1
- import { formatAge } from '../resource-utils'
1
+ import { formatRelativeAgeTime } from '../../../utils/format'
2
2
 
3
3
  /**
4
4
  * A recovery point, stated absolutely with the interval beside it.
@@ -21,7 +21,7 @@ export function RecoveryTime({ at }: { at: string }) {
21
21
  <span>{d.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}</span>
22
22
  {/* One interpolation, so the age and its unit stay a single text node
23
23
  rather than being split by a renderer comment marker. */}
24
- <span className="text-xs text-theme-text-tertiary shrink-0">{`${formatAge(at)} ago`}</span>
24
+ <span className="text-xs text-theme-text-tertiary shrink-0">{formatRelativeAgeTime(at)}</span>
25
25
  </span>
26
26
  )
27
27
  }
@@ -4,10 +4,10 @@ import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property, AlertBanner } from '../../ui/drawer-components'
5
5
  import { Collapse, CollapseChevron, useDisclosure, disclosurePanelId } from '../../ui/Collapse'
6
6
  import { Input } from '../../ui/Input'
7
- import { formatAge } from '../resource-utils'
8
7
  import { SEVERITY_BADGE_COLORS, SEVERITY_ORDER } from './trivy-shared'
9
8
  import { pluralize } from '../../../utils/pluralize'
10
9
  import { BADGE_INACTIVE } from '../../../utils/badge-colors'
10
+ import { TimeValue } from '../../ui/ScheduleValue'
11
11
 
12
12
  interface ClusterComplianceReportRendererProps {
13
13
  data: any
@@ -97,7 +97,7 @@ export function ClusterComplianceReportRenderer({ data }: ClusterComplianceRepor
97
97
  {compliance.description && <Property label="Description" value={compliance.description} />}
98
98
  {compliance.version && <Property label="Version" value={compliance.version} />}
99
99
  {compliance.platform && <Property label="Platform" value={compliance.platform} />}
100
- <Property label="Last Updated" value={status.updateTimestamp ? formatAge(status.updateTimestamp) + ' ago' : '-'} />
100
+ <Property label="Last Updated" value={<TimeValue timestamp={status.updateTimestamp} />} />
101
101
  </PropertyList>
102
102
  </Section>
103
103
 
@@ -3,9 +3,9 @@ import { ShieldCheck, CheckCircle2, XCircle } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property } from '../../ui/drawer-components'
5
5
  import { Collapse, CollapseChevron, useDisclosure, disclosurePanelId } from '../../ui/Collapse'
6
- import { formatAge } from '../resource-utils'
7
6
  import { SEVERITY_BADGE_COLORS, SEVERITY_ORDER, TrivyAlertBanner } from './trivy-shared'
8
7
  import { BADGE_INACTIVE } from '../../../utils/badge-colors'
8
+ import { TimeValue } from '../../ui/ScheduleValue'
9
9
 
10
10
  interface ConfigAuditReportRendererProps {
11
11
  data: any
@@ -115,7 +115,7 @@ export function ConfigAuditReportRenderer({ data }: ConfigAuditReportRendererPro
115
115
  <Section title="Report Overview" icon={ShieldCheck}>
116
116
  <PropertyList>
117
117
  <Property label="Scanner" value={scanner.name ? `${scanner.name} ${scanner.version || ''}`.trim() : '-'} />
118
- <Property label="Scanned" value={report.updateTimestamp ? formatAge(report.updateTimestamp) + ' ago' : '-'} />
118
+ <Property label="Scanned" value={<TimeValue timestamp={report.updateTimestamp} />} />
119
119
  </PropertyList>
120
120
  </Section>
121
121
 
@@ -1,6 +1,8 @@
1
1
  import { Clock, Pause } from 'lucide-react'
2
2
  import { Section, PropertyList, Property, AlertBanner, ResourceLink } from '../../ui/drawer-components'
3
- import { formatAge, cronToHuman } from '../resource-utils'
3
+ import { cronToHuman } from '../resource-utils'
4
+ import { TimeValue } from '../../ui/ScheduleValue'
5
+ import { formatRelativeAgeTime } from '../../../utils/format'
4
6
 
5
7
  interface CronJobRendererProps {
6
8
  data: any
@@ -53,7 +55,7 @@ export function CronJobRenderer({ data, onNavigate }: CronJobRendererProps) {
53
55
  <AlertBanner
54
56
  variant="error"
55
57
  title="Recent Jobs Failing"
56
- message={<>Jobs have been scheduled but haven't succeeded recently. Last success: {formatAge(status.lastSuccessfulTime)}. Check job history and pod logs.</>}
58
+ message={<>Jobs have been scheduled but haven't succeeded recently. Last success: {formatRelativeAgeTime(status.lastSuccessfulTime, 'never')}. Check job history and pod logs.</>}
57
59
  />
58
60
  )}
59
61
 
@@ -62,8 +64,8 @@ export function CronJobRenderer({ data, onNavigate }: CronJobRendererProps) {
62
64
  <Property label="Schedule" value={spec.schedule} />
63
65
  <Property label="Human" value={cronToHuman(spec.schedule)} />
64
66
  <Property label="Suspend" value={spec.suspend ? 'Yes' : 'No'} />
65
- <Property label="Last Schedule" value={status.lastScheduleTime ? formatAge(status.lastScheduleTime) : 'Never'} />
66
- <Property label="Last Success" value={status.lastSuccessfulTime ? formatAge(status.lastSuccessfulTime) : 'Never'} />
67
+ <Property label="Last Schedule" value={<TimeValue timestamp={status.lastScheduleTime} fallback="Never" />} />
68
+ <Property label="Last Success" value={<TimeValue timestamp={status.lastSuccessfulTime} fallback="Never" />} />
67
69
  <Property label="Active Jobs" value={status.active?.length || 0} />
68
70
  </PropertyList>
69
71
  </Section>
@@ -1,6 +1,7 @@
1
1
  import { Clock, Pause } from 'lucide-react'
2
2
  import { Section, PropertyList, Property, AlertBanner, ResourceLink, ConditionsSection } from '../../ui/drawer-components'
3
- import { formatAge, cronToHuman } from '../resource-utils'
3
+ import { cronToHuman } from '../resource-utils'
4
+ import { TimeValue } from '../../ui/ScheduleValue'
4
5
 
5
6
  interface CronWorkflowRendererProps {
6
7
  data: any
@@ -45,7 +46,7 @@ export function CronWorkflowRenderer({ data, onNavigate }: CronWorkflowRendererP
45
46
  <Property label="Timezone" value={spec.timezone || 'Cluster default'} />
46
47
  <Property label="Suspend" value={isSuspended ? 'Yes' : 'No'} />
47
48
  <Property label="Concurrency" value={spec.concurrencyPolicy || 'Allow'} />
48
- <Property label="Last Scheduled" value={status.lastScheduledTime ? formatAge(status.lastScheduledTime) : 'Never'} />
49
+ <Property label="Last Scheduled" value={<TimeValue timestamp={status.lastScheduledTime} fallback="Never" />} />
49
50
  <Property label="Active Workflows" value={active.length} />
50
51
  </PropertyList>
51
52
  </Section>
@@ -0,0 +1,24 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { expect, it, vi } from 'vitest'
5
+ import { EndpointSliceRenderer } from './EndpointSliceRenderer'
6
+
7
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
8
+
9
+ it('navigates to the labeled Service as a core-group resource', async () => {
10
+ const onNavigate = vi.fn()
11
+ const element = document.createElement('div')
12
+ const root = createRoot(element)
13
+ const slice = {
14
+ apiVersion: 'discovery.k8s.io/v1',
15
+ kind: 'EndpointSlice',
16
+ metadata: { name: 'web-abc', namespace: 'team', labels: { 'kubernetes.io/service-name': 'web' } },
17
+ }
18
+ await act(async () => { root.render(<EndpointSliceRenderer data={slice} onNavigate={onNavigate} />) })
19
+ const button = [...element.querySelectorAll('button')].find(b => b.textContent === 'web')
20
+ expect(button).toBeDefined()
21
+ await act(async () => { button!.click() })
22
+ expect(onNavigate).toHaveBeenCalledWith({ kind: 'Service', group: '', namespace: 'team', name: 'web' })
23
+ await act(async () => root.unmount())
24
+ })
@@ -47,7 +47,7 @@ export function EndpointSliceRenderer({ data, onNavigate }: EndpointSliceRendere
47
47
  <button
48
48
  type="button"
49
49
  className="text-sm text-accent-text hover:underline font-medium"
50
- onClick={() => onNavigate({ kind: 'Service', namespace: metadata.namespace, name: serviceName })}
50
+ onClick={() => onNavigate({ kind: 'Service', group: '', namespace: metadata.namespace, name: serviceName })}
51
51
  >
52
52
  {serviceName}
53
53
  </button>
@@ -2,9 +2,9 @@ import { AlertTriangle, Info, Clock, Target, Server, Hash } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Badge } from '../../ui/Badge'
4
4
  import { Section, PropertyList, Property } from '../../ui/drawer-components'
5
- import { formatAge } from '../resource-utils'
6
5
  import type { NavigateToResource } from '../../../utils/navigation'
7
6
  import { apiVersionToGroup, kindToPluralWithGroup } from '../../../utils/navigation'
7
+ import { TimeValue } from '../../ui/ScheduleValue'
8
8
 
9
9
  interface EventRendererProps {
10
10
  data: any
@@ -118,10 +118,10 @@ export function EventRenderer({ data, onNavigate }: EventRendererProps) {
118
118
  <PropertyList>
119
119
  {count > 1 && <Property label="Count" value={count} />}
120
120
  {firstTimestamp && (
121
- <Property label="First Seen" value={formatAge(firstTimestamp)} />
121
+ <Property label="First Seen" value={<TimeValue timestamp={firstTimestamp} />} />
122
122
  )}
123
123
  {lastTimestamp && (
124
- <Property label="Last Seen" value={formatAge(lastTimestamp)} />
124
+ <Property label="Last Seen" value={<TimeValue timestamp={lastTimestamp} />} />
125
125
  )}
126
126
  {firstTimestamp && lastTimestamp && firstTimestamp !== lastTimestamp && (
127
127
  <Property label="Duration" value={calculateDuration(firstTimestamp, lastTimestamp)} />
@@ -1,8 +1,8 @@
1
1
  import { Activity, Boxes, Server } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, AlertBanner, ConditionsSection } from '../../ui/drawer-components'
4
- import { formatAge } from '../resource-utils'
5
4
  import { canaryStepTemplateRefs } from './RolloutRenderer'
5
+ import { TimeValue } from '../../ui/ScheduleValue'
6
6
 
7
7
  interface ExperimentRendererProps {
8
8
  data: any
@@ -72,7 +72,7 @@ export function ExperimentRenderer({ data, onNavigate }: ExperimentRendererProps
72
72
  <Property label="Phase" value={<span className={clsx('badge', phaseClass(phase))}>{phase}</span>} />
73
73
  <Property label="Duration" value={spec.duration} />
74
74
  <Property label="Message" value={status.message} />
75
- <Property label="Available At" value={status.availableAt ? formatAge(status.availableAt) : undefined} />
75
+ <Property label="Available At" value={status.availableAt ? <TimeValue timestamp={status.availableAt} /> : undefined} />
76
76
  {ownerRollout && (
77
77
  <Property
78
78
  label="Rollout"
@@ -3,10 +3,10 @@ import { KeyRound } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property } from '../../ui/drawer-components'
5
5
  import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
6
- import { formatAge } from '../resource-utils'
7
6
  import { SEVERITY_BADGE_COLORS, TrivyAlertBanner, formatTrivyImage } from './trivy-shared'
8
7
  import { pluralize } from '../../../utils/pluralize'
9
8
  import { BADGE_INACTIVE } from '../../../utils/badge-colors'
9
+ import { TimeValue } from '../../ui/ScheduleValue'
10
10
 
11
11
  interface ExposedSecretReportRendererProps {
12
12
  data: any
@@ -45,7 +45,7 @@ export function ExposedSecretReportRenderer({ data }: ExposedSecretReportRendere
45
45
  <Property label="Container" value={containerName} />
46
46
  <Property label="Image" value={image} />
47
47
  <Property label="Scanner" value={scanner.name ? `${scanner.name} ${scanner.version || ''}`.trim() : '-'} />
48
- <Property label="Scanned" value={report.updateTimestamp ? formatAge(report.updateTimestamp) + ' ago' : '-'} />
48
+ <Property label="Scanned" value={<TimeValue timestamp={report.updateTimestamp} />} />
49
49
  </PropertyList>
50
50
  </Section>
51
51
 
@@ -1,10 +1,10 @@
1
1
  import { KeyRound, RefreshCw, CloudCog } from 'lucide-react'
2
2
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink, useOperationalIssuesShown } from '../../ui/drawer-components'
3
+ import { TimeValue } from '../../ui/ScheduleValue'
3
4
  import {
4
5
  getExternalSecretStatus,
5
6
  getExternalSecretStore,
6
7
  getExternalSecretRefreshInterval,
7
- getExternalSecretLastSync,
8
8
  getExternalSecretTargetName,
9
9
  getExternalSecretTargetCreationPolicy,
10
10
  getExternalSecretTargetDeletionPolicy,
@@ -47,7 +47,7 @@ export function ExternalSecretRenderer({ data, onNavigate }: ExternalSecretRende
47
47
  <Section title="Sync Status" icon={RefreshCw} defaultExpanded>
48
48
  <PropertyList>
49
49
  <Property label="Status" value={esStatus.text} />
50
- <Property label="Last Sync" value={getExternalSecretLastSync(data)} />
50
+ <Property label="Last Sync" value={<TimeValue timestamp={data.status?.refreshTime} />} />
51
51
  <Property label="Refresh Interval" value={getExternalSecretRefreshInterval(data)} />
52
52
  <Property label="Target Secret" value={(() => {
53
53
  const targetName = getExternalSecretTargetName(data)
@@ -1,9 +1,9 @@
1
1
  import { Package, Settings, CheckCircle2, History, FileCode2, GitBranch } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ConditionsSection, ProblemAlerts, ResourceLink } from '../../ui/drawer-components'
4
- import { formatAge } from '../resource-utils'
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 FluxHelmReleaseRendererProps {
9
9
  data: any
@@ -249,13 +249,13 @@ export function FluxHelmReleaseRenderer({ data, onNavigate }: FluxHelmReleaseRen
249
249
  {lastRelease.firstDeployed && (
250
250
  <Property
251
251
  label="First Deployed"
252
- value={formatAge(lastRelease.firstDeployed)}
252
+ value={<TimeValue timestamp={lastRelease.firstDeployed} />}
253
253
  />
254
254
  )}
255
255
  {lastRelease.lastDeployed && (
256
256
  <Property
257
257
  label="Last Deployed"
258
- value={formatAge(lastRelease.lastDeployed)}
258
+ value={<TimeValue timestamp={lastRelease.lastDeployed} />}
259
259
  />
260
260
  )}
261
261
  </PropertyList>
@@ -319,7 +319,7 @@ export function FluxHelmReleaseRenderer({ data, onNavigate }: FluxHelmReleaseRen
319
319
  {status.lastHandledReconcileAt && (
320
320
  <Property
321
321
  label="Last Reconciled"
322
- value={formatAge(status.lastHandledReconcileAt)}
322
+ value={<TimeValue timestamp={status.lastHandledReconcileAt} />}
323
323
  />
324
324
  )}
325
325
  </PropertyList>
@@ -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 GRPCRouteRendererProps {
7
8
  data: any
@@ -32,22 +33,8 @@ export function GRPCRouteRenderer({ data, onNavigate }: GRPCRouteRendererProps)
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 GRPCRouteRenderer({ data, onNavigate }: GRPCRouteRendererProps)
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 GRPCRouteRenderer({ data, onNavigate }: GRPCRouteRendererProps)
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 GRPCRouteRenderer({ data, onNavigate }: GRPCRouteRendererProps)
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 GRPCRouteRenderer({ data, onNavigate }: GRPCRouteRendererProps)
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 GRPCRouteRenderer({ data, onNavigate }: GRPCRouteRendererProps)
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 GRPCRouteRenderer({ data, onNavigate }: GRPCRouteRendererProps)
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>
@@ -0,0 +1,63 @@
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 { HTTPRouteRenderer } from './HTTPRouteRenderer'
6
+ import { GRPCRouteRenderer } from './GRPCRouteRenderer'
7
+ import { SimpleRouteRenderer } from './SimpleRouteRenderer'
8
+
9
+ const backend = { name: 'custom-backend', kind: 'Widget', group: 'relationships.radar.test', namespace: 'backends' }
10
+ const parent = { name: 'mesh-parent', kind: 'Service', group: '', namespace: 'mesh' }
11
+
12
+ function routeData(kind: string) {
13
+ return {
14
+ metadata: { name: 'route', namespace: 'app' },
15
+ status: { parents: [{ parentRef: parent, conditions: [{ type: 'Accepted', status: 'False', reason: 'Rejected' }] }] },
16
+ spec: {
17
+ parentRefs: [parent],
18
+ rules: [{
19
+ backendRefs: [backend, { name: 'default-service', port: 8080 }],
20
+ ...(kind === 'HTTPRoute' ? { filters: [{ type: 'RequestMirror', requestMirror: { backendRef: { ...backend, name: 'mirror' } } }] } : {}),
21
+ }],
22
+ },
23
+ }
24
+ }
25
+
26
+ describe('Gateway route reference navigation', () => {
27
+ it.each(['HTTPRoute', 'GRPCRoute', 'TCPRoute', 'TLSRoute'] as const)('preserves backend and parent identities in %s', async kind => {
28
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
29
+ const container = document.createElement('div')
30
+ const root = createRoot(container)
31
+ const onNavigate = vi.fn()
32
+ const data = routeData(kind)
33
+ const click = async (name: string) => {
34
+ const button = [...container.querySelectorAll('button')].find(button => button.textContent?.includes(name))
35
+ expect(button, `button ${name}`).toBeDefined()
36
+ await act(async () => button!.click())
37
+ }
38
+ try {
39
+ await act(async () => root.render(
40
+ kind === 'HTTPRoute' ? <HTTPRouteRenderer data={data} onNavigate={onNavigate} />
41
+ : kind === 'GRPCRoute' ? <GRPCRouteRenderer data={data} onNavigate={onNavigate} />
42
+ : <SimpleRouteRenderer kind={kind} data={data} onNavigate={onNavigate} />,
43
+ ))
44
+ expect(container.textContent).toContain('Parents')
45
+ expect(container.textContent).toContain('Parent "mesh-parent"')
46
+ expect(container.textContent).not.toContain('Gateway "mesh-parent"')
47
+
48
+ await click('custom-backend')
49
+ expect(onNavigate).toHaveBeenLastCalledWith(backend)
50
+ await click('default-service')
51
+ expect(onNavigate).toHaveBeenLastCalledWith({ kind: 'Service', group: '', namespace: 'app', name: 'default-service' })
52
+ await click('mesh-parent')
53
+ expect(onNavigate).toHaveBeenLastCalledWith(parent)
54
+ if (kind === 'HTTPRoute') {
55
+ await click('mirror')
56
+ expect(onNavigate).toHaveBeenLastCalledWith({ ...backend, name: 'mirror' })
57
+ }
58
+ } finally {
59
+ await act(async () => root.unmount())
60
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: false })
61
+ }
62
+ })
63
+ })
@@ -1,9 +1,9 @@
1
1
  import { GitBranch, FolderGit, 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 GitRepositoryRendererProps {
9
9
  data: any
@@ -87,7 +87,7 @@ export function GitRepositoryRenderer({ data }: GitRepositoryRendererProps) {
87
87
  <Property label="Digest" value={artifact.digest} />
88
88
  <Property
89
89
  label="Last Updated"
90
- value={artifact.lastUpdateTime ? formatAge(artifact.lastUpdateTime) : '-'}
90
+ value={<TimeValue timestamp={artifact.lastUpdateTime} />}
91
91
  />
92
92
  {artifact.size && (
93
93
  <Property label="Size" value={formatBytes(artifact.size)} />
@@ -104,7 +104,7 @@ export function GitRepositoryRenderer({ data }: GitRepositoryRendererProps) {
104
104
  {status.lastHandledReconcileAt && (
105
105
  <Property
106
106
  label="Last Reconciled"
107
- value={formatAge(status.lastHandledReconcileAt)}
107
+ value={<TimeValue timestamp={status.lastHandledReconcileAt} />}
108
108
  />
109
109
  )}
110
110
  </PropertyList>
@@ -4,9 +4,9 @@ import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property, ConditionsSection, ResourceLink, type ConditionTone } from '../../ui/drawer-components'
5
5
  import { Badge } from '../../ui/Badge'
6
6
  import { kindToPlural } from '../../../utils/navigation'
7
- import { formatAge } from '../resource-utils'
8
7
  import { HPADiagnosisSummary } from '../HPADiagnosisSummary'
9
8
  import type { HPADiagnosis } from '../../../types'
9
+ import { TimeValue } from '../../ui/ScheduleValue'
10
10
 
11
11
  interface HPARendererProps {
12
12
  data: any
@@ -63,7 +63,7 @@ export function HPARenderer({ data, onNavigate, hpaDiagnosis, extraSections }: H
63
63
  <Property label="Desired" value={status.desiredReplicas} />
64
64
  <Property label="Min" value={spec.minReplicas ?? 1} />
65
65
  <Property label="Max" value={spec.maxReplicas} />
66
- {status.lastScaleTime && <Property label="Last Scale" value={formatAge(status.lastScaleTime)} />}
66
+ {status.lastScaleTime && <Property label="Last Scale" value={<TimeValue timestamp={status.lastScaleTime} />} />}
67
67
  </PropertyList>
68
68
  </Section>
69
69