@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
@@ -1,4 +1,4 @@
1
- import { useState, useMemo, useEffect, useRef, useCallback, useId, forwardRef } from 'react'
1
+ import { useState, useMemo, useEffect, useRef, useCallback, useId, forwardRef, type ComponentType } from 'react'
2
2
  import {
3
3
  Search,
4
4
  Eye,
@@ -29,6 +29,28 @@ export interface PinnedItem {
29
29
  group: string
30
30
  }
31
31
 
32
+ /** A task destination shown inside a category, above its exact kinds. */
33
+ export interface SidebarCategoryDestination {
34
+ id: string
35
+ label: string
36
+ icon?: ComponentType<{ className?: string }>
37
+ /** Problem count. `undefined` renders no badge; `null` renders the unknown dash. */
38
+ count?: number | null
39
+ countTitle?: string
40
+ active?: boolean
41
+ /** The object currently open under this destination, nested beneath it. */
42
+ child?: { label: string; title?: string }
43
+ onSelect: () => void
44
+ }
45
+
46
+ export interface SidebarCategoryWorkspace {
47
+ destinations: SidebarCategoryDestination[]
48
+ /** Kinds start collapsed under a "Resource kinds" disclosure until the user opens them. */
49
+ defaultKindsCollapsed?: boolean
50
+ /** Explains what the destination counts are scoped to. */
51
+ scopeNote?: string
52
+ }
53
+
32
54
  export interface ResourcesSidebarProps {
33
55
  selectedKind: SelectedKindInfo | null
34
56
  onSelectedKindChange: (kind: SelectedKindInfo) => void
@@ -48,10 +70,15 @@ export interface ResourcesSidebarProps {
48
70
  /** Called when a kind is selected via keyboard (Enter in the filter). Parent uses this
49
71
  * to move focus to the next UI level (e.g., the table search input). */
50
72
  onKindNavigated?: () => void
73
+ /** Task destinations keyed by category name (e.g. "CloudNativePG"). A category
74
+ * with a workspace stays visible even when it has no resources. */
75
+ categoryWorkspaces?: Record<string, SidebarCategoryWorkspace>
51
76
  }
52
77
 
53
78
  // Persisted across remounts so collapsed categories survive tab switches
54
79
  let persistedExpandedCategories: Set<string> | null = null
80
+ // Per-category "Resource kinds" disclosure, once the user has toggled it.
81
+ const persistedKindsOpen = new Map<string, boolean>()
55
82
  const COUNT_UNAVAILABLE_MESSAGE = 'Count unavailable. Open to view resources.'
56
83
 
57
84
  // Fallback resource types when API resources aren't loaded yet
@@ -102,10 +129,11 @@ interface ResourceTypeButtonProps {
102
129
  isPinned?: boolean
103
130
  onTogglePin?: () => void
104
131
  onClick: () => void
132
+ indent?: boolean
105
133
  }
106
134
 
107
135
  const ResourceTypeButton = forwardRef<HTMLButtonElement, ResourceTypeButtonProps>(
108
- function ResourceTypeButton({ resource, count, isSelected, isHighlighted, isForbidden: forbidden, isPinned, onTogglePin, onClick }, ref) {
136
+ function ResourceTypeButton({ resource, count, isSelected, isHighlighted, isForbidden: forbidden, isPinned, onTogglePin, onClick, indent }, ref) {
109
137
  const Icon = getResourceIcon(resource.kind, resource.group)
110
138
  return (
111
139
  <button
@@ -113,6 +141,7 @@ const ResourceTypeButton = forwardRef<HTMLButtonElement, ResourceTypeButtonProps
113
141
  onClick={onClick}
114
142
  className={clsx(
115
143
  'w-full flex items-center gap-2 px-2 xl:px-3 py-1.5 rounded-lg text-sm transition-colors group/kind min-w-0',
144
+ indent && 'pl-5 xl:pl-6',
116
145
  isSelected
117
146
  ? 'selection-strong selection-text'
118
147
  : isHighlighted
@@ -193,6 +222,7 @@ export function ResourcesSidebar({
193
222
  onNavigate,
194
223
  basePath,
195
224
  onKindNavigated,
225
+ categoryWorkspaces,
196
226
  }: ResourcesSidebarProps) {
197
227
  // Wraps kind selection to also navigate when basePath/onNavigate are provided
198
228
  const selectKind = (kind: SelectedKindInfo) => {
@@ -229,8 +259,35 @@ export function ResourcesSidebar({
229
259
  }
230
260
  }, [])
231
261
 
232
- // Effective selected kind — fall back to a safe default
233
- const effectiveSelectedKind = selectedKind ?? { name: 'pods', kind: 'Pod', group: '' }
262
+ const activeDestinationCategory = useMemo(() => {
263
+ for (const [name, ws] of Object.entries(categoryWorkspaces ?? {})) {
264
+ if (ws.destinations.some(d => d.active)) return name
265
+ }
266
+ return null
267
+ }, [categoryWorkspaces])
268
+
269
+ // Effective selected kind — fall back to a safe default. While a workspace
270
+ // destination is active no kind is selected.
271
+ const effectiveSelectedKind = activeDestinationCategory
272
+ ? { name: '', kind: '', group: '\u0000' }
273
+ : selectedKind ?? { name: 'pods', kind: 'Pod', group: '' }
274
+
275
+ const [kindsOpenOverrides, setKindsOpenOverrides] = useState<Map<string, boolean>>(() => new Map(persistedKindsOpen))
276
+ const kindsOpen = (categoryName: string) => {
277
+ const ws = categoryWorkspaces?.[categoryName]
278
+ if (!ws) return true
279
+ const override = kindsOpenOverrides.get(categoryName)
280
+ if (override !== undefined) return override
281
+ const selectedHere = !activeDestinationCategory && categories?.find(c => c.name === categoryName)?.resources.some(
282
+ r => r.name === effectiveSelectedKind.name && r.group === effectiveSelectedKind.group,
283
+ )
284
+ return selectedHere || !ws.defaultKindsCollapsed
285
+ }
286
+ const toggleKinds = (categoryName: string) => {
287
+ const next = !kindsOpen(categoryName)
288
+ persistedKindsOpen.set(categoryName, next)
289
+ setKindsOpenOverrides(prev => new Map(prev).set(categoryName, next))
290
+ }
234
291
 
235
292
  // Categorize resources for sidebar
236
293
  const categories = useMemo(() => {
@@ -254,6 +311,12 @@ export function ResourcesSidebar({
254
311
  }
255
312
  }, [categories, effectiveSelectedKind.kind, effectiveSelectedKind.name]) // eslint-disable-line react-hooks/exhaustive-deps
256
313
 
314
+ useEffect(() => {
315
+ if (activeDestinationCategory && !expandedCategories.has(activeDestinationCategory)) {
316
+ setExpandedCategories(prev => new Set([...prev, activeDestinationCategory]))
317
+ }
318
+ }, [activeDestinationCategory]) // eslint-disable-line react-hooks/exhaustive-deps
319
+
257
320
  // Derive counts from resourceCounts prop
258
321
  const resourcesToCount = useMemo(() => {
259
322
  if (categories) {
@@ -333,8 +396,11 @@ export function ResourcesSidebar({
333
396
  return { ...category, total, visibleResources }
334
397
  })
335
398
 
336
- // Sort: categories with resources first, empty ones at bottom
399
+ // Sort: the category whose workspace is open first, then categories with
400
+ // resources, empty ones at bottom
337
401
  const sorted = withTotals.sort((a, b) => {
402
+ if (a.name === activeDestinationCategory) return -1
403
+ if (b.name === activeDestinationCategory) return 1
338
404
  if (a.total === 0 && b.total > 0) return 1
339
405
  if (a.total > 0 && b.total === 0) return -1
340
406
  return 0
@@ -342,13 +408,13 @@ export function ResourcesSidebar({
342
408
 
343
409
  // Filter out empty groups unless they have visible resources or showEmptyKinds is true.
344
410
  const visibleCategories = sorted.filter(category => {
345
- const shouldShow = category.total > 0 || category.visibleResources.length > 0 || showEmptyKinds
411
+ const shouldShow = category.total > 0 || category.visibleResources.length > 0 || showEmptyKinds || !!categoryWorkspaces?.[category.name]
346
412
  if (!shouldShow) totalHiddenGroups++
347
413
  return shouldShow
348
414
  })
349
415
 
350
416
  return { sortedCategories: visibleCategories, hiddenKindsCount: totalHiddenKinds, hiddenGroupsCount: totalHiddenGroups }
351
- }, [categories, counts, showEmptyKinds, effectiveSelectedKind.name, effectiveSelectedKind.kind, effectiveSelectedKind.group])
417
+ }, [categories, counts, showEmptyKinds, effectiveSelectedKind.name, effectiveSelectedKind.kind, effectiveSelectedKind.group, categoryWorkspaces, activeDestinationCategory])
352
418
 
353
419
  // Filter sidebar categories/kinds by the kind search term
354
420
  const filteredCategories = useMemo(() => {
@@ -400,7 +466,7 @@ export function ResourcesSidebar({
400
466
  }
401
467
  if (filteredCategories) {
402
468
  for (const cat of filteredCategories) {
403
- if (effectiveExpandedCategories.has(cat.name)) {
469
+ if (effectiveExpandedCategories.has(cat.name) && (isKindFiltering || kindsOpen(cat.name))) {
404
470
  for (const r of cat.visibleResources) {
405
471
  kinds.push({ name: r.name, kind: r.kind, group: r.group })
406
472
  }
@@ -408,7 +474,7 @@ export function ResourcesSidebar({
408
474
  }
409
475
  }
410
476
  return kinds
411
- }, [favoritesExpanded, pinned, filteredCategories, effectiveExpandedCategories])
477
+ }, [favoritesExpanded, pinned, filteredCategories, effectiveExpandedCategories, kindsOpenOverrides, categoryWorkspaces, activeDestinationCategory]) // eslint-disable-line react-hooks/exhaustive-deps
412
478
 
413
479
  const [highlightedIndex, setHighlightedIndex] = useState(-1)
414
480
  // Reset highlight when the filter or kind list changes
@@ -550,6 +616,8 @@ export function ResourcesSidebar({
550
616
  filteredCategories.map((category) => {
551
617
  const isExpanded = effectiveExpandedCategories.has(category.name)
552
618
  const rawGroupTitle = rawCRDGroupTitle(category.resources)
619
+ const workspace = categoryWorkspaces?.[category.name]
620
+ const showKinds = isKindFiltering || kindsOpen(category.name)
553
621
  return (
554
622
  <div key={category.name} className="mb-2">
555
623
  <button
@@ -567,8 +635,17 @@ export function ResourcesSidebar({
567
635
  )}
568
636
  </button>
569
637
  <Collapse open={isExpanded} id={disclosurePanelId(categoryPanelBase, category.name)}>
638
+ {workspace && !isKindFiltering && (
639
+ <WorkspaceDestinations
640
+ workspace={workspace}
641
+ kindsOpen={showKinds}
642
+ onToggleKinds={() => toggleKinds(category.name)}
643
+ kindsPanelId={disclosurePanelId(categoryPanelBase, `${category.name}-kinds`)}
644
+ />
645
+ )}
646
+ <Collapse open={showKinds} id={disclosurePanelId(categoryPanelBase, `${category.name}-kinds`)}>
570
647
  <div className="space-y-0.5">
571
- {category.visibleResources.map((resource) => {
648
+ {(workspace ? sortByGroup(category.visibleResources) : category.visibleResources).map((resource, index, list) => {
572
649
  const resourceIsPinned = isPinned(resource.name, resource.group)
573
650
  const isResourceSelected =
574
651
  (effectiveSelectedKind.name === resource.name && effectiveSelectedKind.group === resource.group) ||
@@ -576,9 +653,13 @@ export function ResourcesSidebar({
576
653
  // If the resource is pinned, let the Favorites section own the highlight
577
654
  const showSelected = isResourceSelected && !resourceIsPinned
578
655
  const highlighted = isKindHighlighted(resource.name, resource.group)
656
+ const groupLabel = workspace ? groupHeaderBefore(list, index) : null
579
657
  return (
658
+ <div key={`${resource.group}/${resource.name}`}>
659
+ {groupLabel && (
660
+ <div className="pl-6 pr-2 pt-1.5 pb-0.5 text-[10.5px] font-mono text-theme-text-tertiary truncate" title={groupLabel}>{groupLabel}</div>
661
+ )}
580
662
  <ResourceTypeButton
581
- key={resource.name}
582
663
  ref={highlighted ? highlightedRef : (isResourceSelected ? selectedSidebarRef : null)}
583
664
  resource={resource}
584
665
  count={counts[resource.group ? `${resource.group}/${resource.kind}` : resource.kind] ?? null}
@@ -588,10 +669,13 @@ export function ResourcesSidebar({
588
669
  isPinned={resourceIsPinned}
589
670
  onTogglePin={() => togglePin({ name: resource.name, kind: resource.kind, group: resource.group })}
590
671
  onClick={() => selectKind({ name: resource.name, kind: resource.kind, group: resource.group })}
672
+ indent={!!workspace}
591
673
  />
674
+ </div>
592
675
  )
593
676
  })}
594
677
  </div>
678
+ </Collapse>
595
679
  </Collapse>
596
680
  </div>
597
681
  )
@@ -673,3 +757,91 @@ export function ResourcesSidebar({
673
757
  </div>
674
758
  )
675
759
  }
760
+
761
+ function sortByGroup(resources: APIResource[]): APIResource[] {
762
+ const size = new Map<string, number>()
763
+ for (const r of resources) size.set(r.group, (size.get(r.group) ?? 0) + 1)
764
+ return [...resources].sort((a, b) =>
765
+ a.group === b.group
766
+ ? a.kind.localeCompare(b.kind)
767
+ : (size.get(b.group) ?? 0) - (size.get(a.group) ?? 0) || a.group.localeCompare(b.group),
768
+ )
769
+ }
770
+
771
+ function groupHeaderBefore(resources: APIResource[], index: number): string | null {
772
+ const groups = new Set(resources.map(r => r.group))
773
+ if (groups.size < 2) return null
774
+ const group = resources[index]?.group
775
+ if (index > 0 && resources[index - 1]?.group === group) return null
776
+ return group || 'core'
777
+ }
778
+
779
+ function WorkspaceDestinations({
780
+ workspace,
781
+ kindsOpen,
782
+ onToggleKinds,
783
+ kindsPanelId,
784
+ }: {
785
+ workspace: SidebarCategoryWorkspace
786
+ kindsOpen: boolean
787
+ onToggleKinds: () => void
788
+ kindsPanelId: string
789
+ }) {
790
+ return (
791
+ <div className="space-y-0.5">
792
+ <div className="pl-5 pr-2 pt-0.5 pb-0.5 text-[10.5px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Workspace</div>
793
+ {workspace.destinations.map((d) => {
794
+ const Icon = d.icon
795
+ return (
796
+ <div key={d.id}>
797
+ <button
798
+ onClick={d.onSelect}
799
+ aria-current={d.active && !d.child ? 'page' : undefined}
800
+ className={clsx(
801
+ 'w-full flex items-center gap-2 pl-5 xl:pl-6 pr-2 xl:pr-3 py-1.5 rounded-lg text-sm transition-colors min-w-0',
802
+ d.active && !d.child
803
+ ? 'selection-strong selection-text'
804
+ : d.active
805
+ ? 'text-theme-text-primary font-medium hover:bg-theme-elevated'
806
+ : 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary',
807
+ )}
808
+ >
809
+ {Icon && <Icon className="w-4 h-4 shrink-0" />}
810
+ <span className="flex-1 text-left truncate">{d.label}</span>
811
+ {d.count === null ? (
812
+ <Tooltip content={d.countTitle ?? COUNT_UNAVAILABLE_MESSAGE} position="left">
813
+ <span className="text-xs py-0.5 rounded text-center font-mono w-8 bg-theme-elevated text-theme-text-disabled">–</span>
814
+ </Tooltip>
815
+ ) : d.count !== undefined && d.count > 0 ? (
816
+ <Tooltip content={d.countTitle ?? `${d.count}`} position="left">
817
+ <span className="text-xs py-0.5 rounded text-center font-mono w-8 status-degraded">{d.count}</span>
818
+ </Tooltip>
819
+ ) : null}
820
+ </button>
821
+ {d.child && (
822
+ <div
823
+ className="ml-10 xl:ml-11 mr-1 my-0.5 pl-2 pr-2 py-1 rounded-md text-xs font-medium truncate selection-strong selection-text border-l-2 border-skyhook-500"
824
+ title={d.child.title ?? d.child.label}
825
+ aria-current="page"
826
+ >
827
+ {d.child.label}
828
+ </div>
829
+ )}
830
+ </div>
831
+ )
832
+ })}
833
+ {workspace.scopeNote && (
834
+ <div className="pl-6 pr-2 pt-0.5 text-[11px] text-theme-text-tertiary">{workspace.scopeNote}</div>
835
+ )}
836
+ <button
837
+ aria-expanded={kindsOpen}
838
+ aria-controls={kindsPanelId}
839
+ onClick={onToggleKinds}
840
+ className="w-full flex items-center gap-1.5 pl-5 pr-2 pt-2 pb-0.5 text-[10.5px] font-semibold uppercase tracking-wide text-theme-text-tertiary hover:text-theme-text-secondary"
841
+ >
842
+ <CollapseChevron open={kindsOpen} className="w-3 h-3" />
843
+ <span>Resource kinds</span>
844
+ </button>
845
+ </div>
846
+ )
847
+ }
@@ -147,10 +147,10 @@ import {
147
147
  type PrinterColumnDef, type PrinterTable, PRINTER_COLUMN_PREFIX, formatPrinterCell, printerCellSortValue,
148
148
  printerCellTone, printerColumnKey, printerColumnWidth, printerTableKey, printerTableMatchesKind, readPrinterCell,
149
149
  } from './printer-columns'
150
- import { SEVERITY_BADGE, EVENT_TYPE_COLORS } from '../../utils/badge-colors'
150
+ import { SEVERITY_BADGE, SEVERITY_TEXT, healthToSeverity, EVENT_TYPE_COLORS } from '../../utils/badge-colors'
151
151
  import { pluralize } from '../../utils/pluralize'
152
152
  import { getPodGpuCount, getNodeGpuCount } from '../../utils/extended-resources'
153
- import { parseQuantityToNumber } from '../../utils/format'
153
+ import { formatRelativeAgeTime, parseQuantityToNumber } from '../../utils/format'
154
154
  import { type CustomColumnDef, type CustomColumnSource, customColumnKey, readCustomColumnValue, sanitizeCustomColumnDefs } from '../../utils/custom-columns'
155
155
  import { isRolloutActivityVisible } from '../../utils/workload-rollout'
156
156
  import { FreshnessControl, type FreshnessConnection } from '../ui/FreshnessControl'
@@ -205,7 +205,7 @@ import { CalicoInfraCell, CalicoPolicyCell } from './renderers/calico-cells'
205
205
  import { isCalicoPolicyResource, isCoreNetworkPolicyKind } from './resource-utils-calico'
206
206
  import { useRegisterShortcut, useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
207
207
  import { ResourcesSidebar } from './ResourcesSidebar'
208
- import type { SelectedKindInfo } from './ResourcesSidebar'
208
+ import type { SelectedKindInfo, SidebarCategoryWorkspace } from './ResourcesSidebar'
209
209
  import { CompareTray, togglePick, pickIndex, refToParam, SIDE_TONES, type CompareTrayPick, type NamespacedRef } from '../compare'
210
210
  import { ConfirmDialog } from '../ui/ConfirmDialog'
211
211
 
@@ -3411,6 +3411,8 @@ interface ResourcesViewProps {
3411
3411
  onSelectedKindChange?: (kind: { name: string; kind: string; group: string }) => void
3412
3412
  /** When true, the sidebar is not rendered. Useful when a standalone ResourcesSidebar is used externally. */
3413
3413
  hideSidebar?: boolean
3414
+ /** Task destinations rendered inside sidebar categories (see ResourcesSidebar). */
3415
+ sidebarCategoryWorkspaces?: Record<string, SidebarCategoryWorkspace>
3414
3416
  /** Callback when the [+] create button is clicked. Receives the currently selected kind info. */
3415
3417
  onCreateResource?: (kind: { name: string; kind: string; group: string } | null) => void
3416
3418
  /** Default kind when the URL does not include one. */
@@ -3653,6 +3655,7 @@ export function ResourcesView({
3653
3655
  onOpenWorkloadLogs,
3654
3656
  onSelectedKindChange,
3655
3657
  hideSidebar = false,
3658
+ sidebarCategoryWorkspaces,
3656
3659
  onCreateResource,
3657
3660
  defaultKind = DEFAULT_KIND_INFO,
3658
3661
  extraLeadingColumns,
@@ -5867,6 +5870,7 @@ export function ResourcesView({
5867
5870
  pinned={pinned}
5868
5871
  togglePin={togglePin}
5869
5872
  isPinned={isPinned}
5873
+ categoryWorkspaces={sidebarCategoryWorkspaces}
5870
5874
  onKindNavigated={() => {
5871
5875
  // After selecting a kind via keyboard, move focus to the table search
5872
5876
  // so the user can immediately filter within the selected kind.
@@ -6124,7 +6128,7 @@ export function ResourcesView({
6124
6128
  )}
6125
6129
 
6126
6130
  {/* Column picker */}
6127
- <div className="relative" ref={columnPickerRef}>
6131
+ <div className="relative flex" ref={columnPickerRef}>
6128
6132
  <Tooltip content="Configure columns">
6129
6133
  <button
6130
6134
  onClick={() => setShowColumnPicker(prev => !prev)}
@@ -8718,7 +8722,7 @@ function NodeCell({ resource, column, majorityNodeMinorVersion }: { resource: an
8718
8722
  </span>
8719
8723
  {problems.length > 0 && (
8720
8724
  <Tooltip content={problems.join(', ')}>
8721
- <span className="text-red-400">
8725
+ <span className={SEVERITY_TEXT[healthToSeverity(status.level ?? 'unknown')]}>
8722
8726
  <AlertTriangle className="w-3.5 h-3.5" />
8723
8727
  </span>
8724
8728
  </Tooltip>
@@ -8731,7 +8735,8 @@ function NodeCell({ resource, column, majorityNodeMinorVersion }: { resource: an
8731
8735
  return <span className="text-sm text-theme-text-secondary">{roles}</span>
8732
8736
  }
8733
8737
  case 'conditions': {
8734
- const { problems, healthy } = getNodeConditions(resource)
8738
+ const { problems, healthy, readinessLabel } = getNodeConditions(resource)
8739
+ if (problems.length === 0 && !healthy) return <span className="text-theme-text-secondary">{readinessLabel}</span>
8735
8740
  if (healthy) {
8736
8741
  return <span className="text-sm text-green-400">Healthy</span>
8737
8742
  }
@@ -9314,9 +9319,7 @@ function LeaseCell({ resource, column }: { resource: any; column: string }) {
9314
9319
  const elapsed = (Date.now() - new Date(renewTime).getTime()) / 1000
9315
9320
  const duration = resource.spec?.leaseDurationSeconds || 0
9316
9321
  const isStale = duration > 0 && elapsed > duration
9317
- const diff = Date.now() - new Date(renewTime).getTime()
9318
- const seconds = Math.floor(diff / 1000)
9319
- const label = seconds < 60 ? `${seconds}s ago` : seconds < 3600 ? `${Math.floor(seconds / 60)}m ago` : seconds < 86400 ? `${Math.floor(seconds / 3600)}h ago` : `${Math.floor(seconds / 86400)}d ago`
9322
+ const label = formatRelativeAgeTime(renewTime)
9320
9323
  return <span className={clsx('text-sm', isStale ? 'text-red-400' : 'text-green-400')} title={new Date(renewTime).toLocaleString()}>{label}</span>
9321
9324
  }
9322
9325
  default:
@@ -54,7 +54,7 @@ export * from './resource-utils-velero'
54
54
  export { ResourcesView, ResourcesViewDataContext, hasCuratedColumns } from './ResourcesView'
55
55
  export type { ResourceQueryResult, ExtraColumn, LargeListGuardState } from './ResourcesView'
56
56
  export { ResourcesSidebar } from './ResourcesSidebar'
57
- export type { ResourcesSidebarProps, SelectedKindInfo, PinnedItem } from './ResourcesSidebar'
57
+ export type { ResourcesSidebarProps, SelectedKindInfo, PinnedItem, SidebarCategoryDestination, SidebarCategoryWorkspace } from './ResourcesSidebar'
58
58
  export {
59
59
  sanitizePrinterTable,
60
60
  printerTableKey,
@@ -1,7 +1,7 @@
1
1
  import { Activity, BarChart3, Server } from 'lucide-react'
2
2
  import { Section, PropertyList, Property, AlertBanner } from '../../ui/drawer-components'
3
3
  import { Badge, type BadgeSeverity } from '../../ui/Badge'
4
- import { formatAge } from '../resource-utils'
4
+ import { TimeValue } from '../../ui/ScheduleValue'
5
5
 
6
6
  interface AnalysisRunRendererProps {
7
7
  data: any
@@ -60,8 +60,8 @@ export function AnalysisRunRenderer({ data, onNavigate }: AnalysisRunRendererPro
60
60
  <Property label="Phase" value={<Badge severity={phaseSeverity(phase)}>{phase}</Badge>} />
61
61
  {labels['rollout-type'] && <Property label="Triggered as" value={labels['rollout-type']} />}
62
62
  {labels['step-index'] && <Property label="Canary step" value={labels['step-index']} />}
63
- <Property label="Started" value={status.startedAt ? formatAge(status.startedAt) : undefined} />
64
- <Property label="Completed" value={status.completedAt ? formatAge(status.completedAt) : undefined} />
63
+ <Property label="Started" value={status.startedAt ? <TimeValue timestamp={status.startedAt} /> : undefined} />
64
+ <Property label="Completed" value={status.completedAt ? <TimeValue timestamp={status.completedAt} /> : undefined} />
65
65
  <Property label="Message" value={status.message} />
66
66
  {ownerRollout && (
67
67
  <Property
@@ -1,7 +1,6 @@
1
1
  import { GitBranch, FolderTree, Settings, Target, XCircle, History, ListChecks, ExternalLink } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ConditionsSection, ProblemAlerts } from '../../ui/drawer-components'
4
- import { formatAge } from '../resource-utils'
5
4
  import { GitOpsStatusBadge, ManagedResourcesList, SyncCountdown } from '../../gitops'
6
5
  import {
7
6
  argoStatusToGitOpsStatus,
@@ -11,6 +10,8 @@ import {
11
10
  } from '../../../types/gitops'
12
11
  import { BADGE_INACTIVE } from '../../../utils/badge-colors'
13
12
  import { buildRepoBrowseUrl, buildPathBrowseUrl } from '../../../utils/git-provider-urls'
13
+ import { TimeValue } from '../../ui/ScheduleValue'
14
+ import { formatRelativeAgeTime } from '../../../utils/format'
14
15
 
15
16
  const REPO_LINK_CLASS = 'text-blue-400 hover:text-blue-300 hover:underline break-all'
16
17
 
@@ -297,10 +298,10 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
297
298
  <Property label="Message" value={operationState.message} />
298
299
  )}
299
300
  {operationState.startedAt && (
300
- <Property label="Started" value={formatAge(operationState.startedAt)} />
301
+ <Property label="Started" value={<TimeValue timestamp={operationState.startedAt} />} />
301
302
  )}
302
303
  {operationState.finishedAt && (
303
- <Property label="Finished" value={formatAge(operationState.finishedAt)} />
304
+ <Property label="Finished" value={<TimeValue timestamp={operationState.finishedAt} />} />
304
305
  )}
305
306
  {operationState.retryCount != null && (
306
307
  <Property
@@ -402,7 +403,7 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
402
403
  </span>
403
404
  {entry.deployedAt && (
404
405
  <span className="text-xs text-theme-text-tertiary">
405
- {formatAge(entry.deployedAt)}
406
+ {formatRelativeAgeTime(entry.deployedAt)}
406
407
  </span>
407
408
  )}
408
409
  </div>
@@ -425,7 +426,7 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
425
426
  <Property label="Current Revision" value={status.sync.revision} />
426
427
  )}
427
428
  {status.reconciledAt && (
428
- <Property label="Last Reconciled" value={formatAge(status.reconciledAt)} />
429
+ <Property label="Last Reconciled" value={<TimeValue timestamp={status.reconciledAt} />} />
429
430
  )}
430
431
  </PropertyList>
431
432
  </Section>
@@ -2,8 +2,8 @@ import { useState } from 'react'
2
2
  import { Server, Globe, Network, Layers, Download, CheckCircle, AlertCircle } from 'lucide-react'
3
3
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink, useOperationalIssuesShown} from '../../ui/drawer-components'
4
4
  import { kindToPlural } from '../../../utils/navigation'
5
- import { formatAge } from '../resource-utils'
6
5
  import { getClusterStatus, getClusterClass, getClusterVersion, getClusterEndpoint, getProviderFromInfraKind, parseCAPIConditionMessage } from '../resource-utils-capi'
6
+ import { TimeValue } from '../../ui/ScheduleValue'
7
7
 
8
8
  interface Props {
9
9
  data: any
@@ -119,7 +119,7 @@ export function CAPIClusterRenderer({ data, onNavigate, apiBase = '', canConnect
119
119
  {className !== '-' && <Property label="Cluster Class" value={className} />}
120
120
  {endpoint !== '-' && <Property label="Control Plane Endpoint" value={endpoint} />}
121
121
  {readyCond?.lastTransitionTime && (
122
- <Property label="Since" value={formatAge(readyCond.lastTransitionTime)} />
122
+ <Property label="Since" value={<TimeValue timestamp={readyCond.lastTransitionTime} />} />
123
123
  )}
124
124
  </PropertyList>
125
125
  </Section>
@@ -1,8 +1,8 @@
1
1
  import { Server, Shield, 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 { formatAge } from '../resource-utils'
5
4
  import { getKCPStatus, getKCPVersion, getKCPInitialized, getMachineClusterName } from '../resource-utils-capi'
5
+ import { TimeValue } from '../../ui/ScheduleValue'
6
6
 
7
7
  interface Props {
8
8
  data: any
@@ -50,7 +50,7 @@ export function CAPIKubeadmControlPlaneRenderer({ data, onNavigate }: Props) {
50
50
  <Property label="Initialized" value={initialized ? 'Yes' : 'No'} />
51
51
  {updateStrategy && <Property label="Update Strategy" value={updateStrategy} />}
52
52
  {readyCond?.lastTransitionTime && (
53
- <Property label="Since" value={formatAge(readyCond.lastTransitionTime)} />
53
+ <Property label="Since" value={<TimeValue timestamp={readyCond.lastTransitionTime} />} />
54
54
  )}
55
55
  </PropertyList>
56
56
  </Section>
@@ -1,8 +1,8 @@
1
1
  import { Server, 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 { formatAge } from '../resource-utils'
5
4
  import { getMachineDeploymentStatus, getMachineDeploymentVersion, getMachineClusterName } from '../resource-utils-capi'
5
+ import { TimeValue } from '../../ui/ScheduleValue'
6
6
 
7
7
  interface Props {
8
8
  data: any
@@ -56,7 +56,7 @@ export function CAPIMachineDeploymentRenderer({ data, onNavigate }: Props) {
56
56
  <Property label="Cluster" value={clusterName} />
57
57
  <Property label="Version" value={version} />
58
58
  {readyCond?.lastTransitionTime && (
59
- <Property label="Since" value={formatAge(readyCond.lastTransitionTime)} />
59
+ <Property label="Since" value={<TimeValue timestamp={readyCond.lastTransitionTime} />} />
60
60
  )}
61
61
  </PropertyList>
62
62
  </Section>
@@ -1,8 +1,8 @@
1
1
  import { Server, 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 { formatAge } from '../resource-utils'
5
4
  import { getMachinePoolStatus } from '../resource-utils-capi'
5
+ import { TimeValue } from '../../ui/ScheduleValue'
6
6
 
7
7
  interface Props {
8
8
  data: any
@@ -44,7 +44,7 @@ export function CAPIMachinePoolRenderer({ data, onNavigate }: Props) {
44
44
  <Property label="Min Ready Seconds" value={String(spec.minReadySeconds)} />
45
45
  )}
46
46
  {readyCond?.lastTransitionTime && (
47
- <Property label="Since" value={formatAge(readyCond.lastTransitionTime)} />
47
+ <Property label="Since" value={<TimeValue timestamp={readyCond.lastTransitionTime} />} />
48
48
  )}
49
49
  </PropertyList>
50
50
  </Section>
@@ -1,8 +1,8 @@
1
1
  import { Cpu, Server, Network, Cloud } 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 { formatAge } from '../resource-utils'
5
4
  import { getMachineStatus, getMachineRole, getMachineClusterName, getMachineNodeRef, getMachineVersion, getMachineProviderID, parseProviderID, getProviderFromInfraKind, parseCAPIConditionMessage } from '../resource-utils-capi'
5
+ import { TimeValue } from '../../ui/ScheduleValue'
6
6
 
7
7
  interface Props {
8
8
  data: any
@@ -59,7 +59,7 @@ export function CAPIMachineRenderer({ data, onNavigate }: Props) {
59
59
  {infraProvider && <Property label="Provider" value={infraProvider} />}
60
60
  {spec.failureDomain && <Property label="Failure Domain" value={spec.failureDomain} />}
61
61
  {readyCond?.lastTransitionTime && (
62
- <Property label="Since" value={formatAge(readyCond.lastTransitionTime)} />
62
+ <Property label="Since" value={<TimeValue timestamp={readyCond.lastTransitionTime} />} />
63
63
  )}
64
64
  </PropertyList>
65
65
  </Section>
@@ -1,8 +1,8 @@
1
1
  import { Server, 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 { formatAge } from '../resource-utils'
5
4
  import { getMachineSetStatus, getMachineClusterName } from '../resource-utils-capi'
5
+ import { TimeValue } from '../../ui/ScheduleValue'
6
6
 
7
7
  interface Props {
8
8
  data: any
@@ -46,7 +46,7 @@ export function CAPIMachineSetRenderer({ data, onNavigate }: Props) {
46
46
  <Property label="Min Ready Seconds" value={String(spec.minReadySeconds)} />
47
47
  )}
48
48
  {readyCond?.lastTransitionTime && (
49
- <Property label="Since" value={formatAge(readyCond.lastTransitionTime)} />
49
+ <Property label="Since" value={<TimeValue timestamp={readyCond.lastTransitionTime} />} />
50
50
  )}
51
51
  </PropertyList>
52
52
  </Section>
@@ -28,6 +28,7 @@ import {
28
28
  } from '../resource-utils-cnpg'
29
29
  import { formatAge } from '../resource-utils'
30
30
  import { RecoveryTime } from './CNPGShared'
31
+ import { formatRelativeAgeTime } from '../../../utils/format'
31
32
 
32
33
  interface CNPGClusterRendererProps {
33
34
  /** Filled by the host with the Databases, Publications and Subscriptions
@@ -195,7 +196,7 @@ export function CNPGClusterRenderer({ data, onNavigate, declared}: CNPGClusterRe
195
196
  variant="warning"
196
197
  title="Last backup failed"
197
198
  message={
198
- `The most recent backup attempt failed${lastBackupFailure.lastTransitionTime ? ` ${formatAge(lastBackupFailure.lastTransitionTime)} ago` : ''}.` +
199
+ `The most recent backup attempt failed${lastBackupFailure.lastTransitionTime ? ` ${formatRelativeAgeTime(lastBackupFailure.lastTransitionTime)}` : ''}.` +
199
200
  (lastBackupFailure.message ? ` Operator reported: ${lastBackupFailure.message}` : '')
200
201
  }
201
202
  />
@@ -97,3 +97,28 @@ describe('a window that has stopped advancing', () => {
97
97
  expect(html).not.toContain('WAL archiving has stopped')
98
98
  })
99
99
  })
100
+
101
+ describe('ObjectStore failing backups', () => {
102
+ const failing = {
103
+ ...store,
104
+ status: { serverRecoveryWindow: { 'pg-main': { firstRecoverabilityPoint: '2026-08-01T00:00:00Z', lastSuccessfulBackupTime: '2026-08-10T00:00:00Z', lastFailedBackupTime: '2026-08-11T00:00:00Z' } } },
105
+ }
106
+ it('does not say recovery still reaches the newest WAL when archiving has stopped too', () => {
107
+ const html = renderToString(<CNPGObjectStoreRenderer data={failing} archivingFailing={new Set(['pg-main'])} onNavigate={nav} />)
108
+ expect(html).toContain('WAL archiving has stopped for pg-main')
109
+ expect(html).not.toContain('can still replay archived WAL')
110
+ expect(html).toContain('WAL archiving has stopped on the cluster behind this server')
111
+ })
112
+ it('keeps recovery replaying archived WAL when only base backups fail', () => {
113
+ const html = renderToString(<CNPGObjectStoreRenderer data={failing} archivingFailing={new Set()} onNavigate={nav} />)
114
+ expect(html).toContain('can still replay archived WAL written since')
115
+ expect(html).not.toContain('retention')
116
+ })
117
+ it('claims nothing about recovery when no successful backup is recorded', () => {
118
+ const never = { ...store, status: { serverRecoveryWindow: { 'pg-main': { lastFailedBackupTime: '2026-08-11T00:00:00Z' } } } }
119
+ const html = renderToString(<CNPGObjectStoreRenderer data={never} archivingFailing={new Set()} onNavigate={nav} />)
120
+ expect(html).toContain('No successful base backup is recorded for pg-main')
121
+ expect(html).not.toContain('nothing to restore')
122
+ expect(html).not.toContain('replay')
123
+ })
124
+ })