@skyhook-io/k8s-ui 1.16.1 → 1.16.3

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 (53) hide show
  1. package/README.md +22 -0
  2. package/package.json +3 -3
  3. package/src/components/cnpg/CNPGBackupSummary.tsx +223 -0
  4. package/src/components/cnpg/CNPGClusterSummary.tsx +222 -0
  5. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +264 -0
  6. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +80 -0
  7. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +176 -0
  8. package/src/components/cnpg/CNPGObjectSummary.test.tsx +236 -0
  9. package/src/components/cnpg/CNPGPoolerSummary.tsx +70 -0
  10. package/src/components/cnpg/CNPGSharedSummary.tsx +88 -0
  11. package/src/components/cnpg/index.ts +14 -0
  12. package/src/components/cnpg/primitives.tsx +134 -0
  13. package/src/components/cnpg/relations.test.ts +304 -0
  14. package/src/components/cnpg/relations.ts +417 -0
  15. package/src/components/cnpg/workspace.test.ts +285 -0
  16. package/src/components/cnpg/workspace.ts +700 -0
  17. package/src/components/dock/BottomDock.tsx +8 -1
  18. package/src/components/dock/DockContext.tsx +8 -0
  19. package/src/components/dock/LocalTerminalTab.test.tsx +189 -0
  20. package/src/components/dock/LocalTerminalTab.tsx +56 -18
  21. package/src/components/dock/TerminalTab.tsx +2 -4
  22. package/src/components/issues/IssuesView.tsx +10 -1
  23. package/src/components/issues/types.ts +24 -0
  24. package/src/components/logs/LogCore.theme.test.tsx +77 -0
  25. package/src/components/logs/LogCore.tsx +23 -2
  26. package/src/components/logs/LogsViewer.tsx +5 -1
  27. package/src/components/logs/WorkloadLogsViewer.tsx +16 -4
  28. package/src/components/logs/useLogBuffer.test.ts +17 -0
  29. package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
  30. package/src/components/resources/ResourcesSidebar.tsx +183 -11
  31. package/src/components/resources/ResourcesView.tsx +5 -1
  32. package/src/components/resources/index.ts +1 -1
  33. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
  34. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
  35. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +1 -1
  36. package/src/components/resources/resource-utils-cnpg.ts +3 -3
  37. package/src/components/resources/status-overclaim.test.ts +1 -1
  38. package/src/components/shared/EditableYamlView.test.tsx +268 -0
  39. package/src/components/shared/EditableYamlView.tsx +25 -1
  40. package/src/components/timeline/TimelineList.tsx +185 -23
  41. package/src/components/timeline/TimelineListFold.test.tsx +56 -0
  42. package/src/components/timeline/TimelineToolbarState.test.tsx +98 -0
  43. package/src/components/timeline/index.ts +1 -0
  44. package/src/components/timeline/timeline-filters.ts +7 -0
  45. package/src/components/ui/SelectMenu.test.tsx +25 -0
  46. package/src/components/ui/SelectMenu.tsx +11 -9
  47. package/src/components/ui/YamlReview.test.tsx +40 -0
  48. package/src/components/ui/YamlReview.tsx +22 -1
  49. package/src/components/workload/WorkloadView.tsx +96 -5
  50. package/src/components/workload/index.ts +1 -0
  51. package/src/components/workload/workload-logs-availability.test.ts +6 -0
  52. package/src/index.ts +3 -0
  53. package/src/utils/log-level.ts +14 -0
@@ -28,8 +28,10 @@ export interface LogsViewerProps {
28
28
  createStream?: (params: Omit<LogsFetchParams, 'previous'>) => EventSource
29
29
  /** Override the download mechanism (e.g. for desktop apps where blob URLs fail). */
30
30
  overrideDownload?: (content: string, mime: string, filename: string) => void
31
- /** Force dark mode on the logs container (default: true) */
31
+ /** Pin the logs container to dark (true) or light (false) and hide the toggle */
32
32
  forceDark?: boolean
33
+ /** Palette used until the user toggles it, e.g. the app theme (default: true) */
34
+ defaultDark?: boolean
33
35
  /**
34
36
  * Open the stream automatically on mount (and on container switch) instead of
35
37
  * loading a static snapshot. The user can still Stop, and a manual Stop is not
@@ -47,6 +49,7 @@ export function LogsViewer({
47
49
  createStream,
48
50
  overrideDownload,
49
51
  forceDark,
52
+ defaultDark,
50
53
  autoStream = false,
51
54
  }: LogsViewerProps) {
52
55
  const [selectedContainer, setSelectedContainer] = useState(initialContainer || containers[0] || '')
@@ -190,6 +193,7 @@ export function LogsViewer({
190
193
  onClear={clear}
191
194
  toolbarExtra={renderToolbarExtra}
192
195
  forceDark={forceDark}
196
+ defaultDark={defaultDark}
193
197
  />
194
198
  )
195
199
  }
@@ -53,17 +53,25 @@ export interface WorkloadLogsViewerProps {
53
53
  createStream?: (params: WorkloadLogsFetchParams) => EventSource
54
54
  /** Override the download mechanism (e.g. for desktop apps where blob URLs fail). */
55
55
  overrideDownload?: (content: string, mime: string, filename: string) => void
56
- /** Force dark mode on the logs container (default: true) */
56
+ /** Pin the logs container to dark (true) or light (false) and hide the toggle */
57
57
  forceDark?: boolean
58
+ /** Palette used until the user toggles it, e.g. the app theme (default: true) */
59
+ defaultDark?: boolean
58
60
  /**
59
61
  * Open the stream automatically on mount (and on container switch) instead of
60
62
  * loading a static snapshot. The user can still Stop, and a manual Stop is not
61
63
  * re-armed. Requires `createStream`. Default: false.
62
64
  */
63
65
  autoStream?: boolean
66
+ /** Pods selected when the pod list first loads; all pods when empty or none match. */
67
+ initialPods?: string[]
64
68
  }
65
69
 
66
- export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownload, forceDark, autoStream = false }: WorkloadLogsViewerProps) {
70
+ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownload, forceDark, defaultDark, autoStream = false, initialPods }: WorkloadLogsViewerProps) {
71
+ const initialSelection = (names: string[]) => {
72
+ const wanted = names.filter((n) => initialPods?.includes(n))
73
+ return new Set(wanted.length > 0 ? wanted : names)
74
+ }
67
75
  const [selectedContainer, setSelectedContainer] = useState<string>('')
68
76
  const [pods, setPods] = useState<WorkloadPodInfo[]>([])
69
77
  const [selectedPods, setSelectedPods] = useState<Set<string>>(new Set())
@@ -127,7 +135,9 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
127
135
  const previousPods = previousSnapshotPods.current
128
136
  const nextPods = resultPods.map(p => p.name)
129
137
  previousSnapshotPods.current = nextPods
130
- setSelectedPods(selected => previousPods === null || previousPods.every(pod => selected.has(pod))
138
+ setSelectedPods(selected => previousPods === null
139
+ ? initialSelection(nextPods)
140
+ : previousPods.every(pod => selected.has(pod))
131
141
  ? new Set(nextPods)
132
142
  : new Set(nextPods.filter(pod => selected.has(pod))))
133
143
 
@@ -192,7 +202,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
192
202
  setEmptyMessage(data.emptyMessage || null)
193
203
  setEmptyCommand(data.command || null)
194
204
  setSelectedPods(prev => (
195
- prev.size === 0 ? new Set(nextPods.map((p: WorkloadPodInfo) => p.name)) : prev
205
+ prev.size === 0 ? initialSelection(nextPods.map((p: WorkloadPodInfo) => p.name)) : prev
196
206
  ))
197
207
  }
198
208
  },
@@ -203,6 +213,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
203
213
  content: data.content || '',
204
214
  container: data.container || '',
205
215
  pod: data.pod || '',
216
+ sourceLabel: data.sourceLabel,
206
217
  podColorIndex: podColorIndexRef.current.get(data.pod || ''),
207
218
  })
208
219
  }
@@ -400,6 +411,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
400
411
  emptyCommand={emptyCommand}
401
412
  errorMessage={entries.length === 0 ? fetchError || streamError : null}
402
413
  forceDark={forceDark}
414
+ defaultDark={defaultDark}
403
415
  /></div></div>
404
416
  )
405
417
  }
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { detectLogLevel } from './useLogBuffer'
3
+
4
+ describe('detectLogLevel with CloudNativePG records', () => {
5
+ it('uses the nested PostgreSQL severity over the instance manager level', () => {
6
+ const line = (sev: string) => JSON.stringify({ level: 'info', logger: 'postgres', msg: 'record', record: { error_severity: sev, message: 'x' } })
7
+ expect(detectLogLevel(line('ERROR'))).toBe('error')
8
+ expect(detectLogLevel(line('FATAL'))).toBe('error')
9
+ expect(detectLogLevel(line('WARNING'))).toBe('warn')
10
+ expect(detectLogLevel(line('LOG'))).toBe('info')
11
+ expect(detectLogLevel(line('DEBUG'))).toBe('debug')
12
+ })
13
+ it('ignores a record that is not a PostgreSQL one', () => {
14
+ expect(detectLogLevel(JSON.stringify({ level: 'error', msg: 'request failed', record: { error_severity: 20 } }))).toBe('error')
15
+ expect(detectLogLevel(JSON.stringify({ level: 'error', msg: 'x', record: { error_severity: 'minor' } }))).toBe('error')
16
+ })
17
+ })
@@ -105,3 +105,79 @@ describe('ResourcesSidebar count visibility', () => {
105
105
  expect(html).toContain('HorizontalPodAutoscaler')
106
106
  })
107
107
  })
108
+
109
+ describe('ResourcesSidebar category workspaces', () => {
110
+ const cnpgCluster: APIResource = {
111
+ group: 'postgresql.cnpg.io',
112
+ version: 'v1',
113
+ kind: 'Cluster',
114
+ name: 'clusters',
115
+ namespaced: true,
116
+ isCrd: true,
117
+ verbs: ['list'],
118
+ }
119
+ const objectStore: APIResource = {
120
+ group: 'barmancloud.cnpg.io',
121
+ version: 'v1',
122
+ kind: 'ObjectStore',
123
+ name: 'objectstores',
124
+ namespaced: true,
125
+ isCrd: true,
126
+ verbs: ['list'],
127
+ }
128
+
129
+ it('renders destinations above the kinds, with the active object nested and no kind selected', () => {
130
+ const html = renderToString(
131
+ <ResourcesSidebar
132
+ selectedKind={null}
133
+ onSelectedKindChange={() => {}}
134
+ apiResources={[cnpgCluster, objectStore]}
135
+ resourceCounts={{ 'postgresql.cnpg.io/Cluster': 2, 'barmancloud.cnpg.io/ObjectStore': 1 }}
136
+ categoryWorkspaces={{
137
+ CloudNativePG: {
138
+ destinations: [
139
+ { id: 'overview', label: 'Overview', count: 3, countTitle: '3 clusters need attention', active: true, child: { label: 'pg-orders' }, onSelect: () => {} },
140
+ ],
141
+ defaultKindsCollapsed: true,
142
+ scopeNote: 'Counts for namespace payments',
143
+ },
144
+ }}
145
+ />
146
+ )
147
+ expect(html).toContain('Workspace')
148
+ expect(html).toContain('Overview')
149
+ expect(html).toContain('pg-orders')
150
+ expect(html).toContain('Counts for namespace payments')
151
+ expect(html).toContain('Resource kinds')
152
+ expect(html).toMatch(/aria-expanded="false"[^>]*>(?:(?!<\/button>).)*Resource kinds/)
153
+ expect(html).not.toContain('selection-strong selection-text">Pod')
154
+ })
155
+
156
+ it('keeps a workspace category visible when it has no resources', () => {
157
+ const html = renderToString(
158
+ <ResourcesSidebar
159
+ selectedKind={null}
160
+ onSelectedKindChange={() => {}}
161
+ apiResources={[cnpgCluster]}
162
+ resourceCounts={{ 'postgresql.cnpg.io/Cluster': 0 }}
163
+ categoryWorkspaces={{ CloudNativePG: { destinations: [{ id: 'overview', label: 'Overview', onSelect: () => {} }] } }}
164
+ />
165
+ )
166
+ expect(html).toContain('CloudNativePG')
167
+ expect(html).toContain('Overview')
168
+ })
169
+
170
+ it('labels API groups when a workspace category spans several', () => {
171
+ const html = renderToString(
172
+ <ResourcesSidebar
173
+ selectedKind={{ name: 'clusters', kind: 'Cluster', group: 'postgresql.cnpg.io' }}
174
+ onSelectedKindChange={() => {}}
175
+ apiResources={[cnpgCluster, objectStore]}
176
+ resourceCounts={{ 'postgresql.cnpg.io/Cluster': 2, 'barmancloud.cnpg.io/ObjectStore': 1 }}
177
+ categoryWorkspaces={{ CloudNativePG: { destinations: [{ id: 'overview', label: 'Overview', onSelect: () => {} }], defaultKindsCollapsed: true } }}
178
+ />
179
+ )
180
+ expect(html).toContain('postgresql.cnpg.io')
181
+ expect(html).toContain('barmancloud.cnpg.io')
182
+ })
183
+ })
@@ -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
+ }
@@ -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.
@@ -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,
@@ -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
+ })
@@ -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>
@@ -77,7 +77,7 @@ describe('getCNPGObjectStoreStatus', () => {
77
77
  it('is unknown, not healthy, when no server has reported', () => {
78
78
  const s = getCNPGObjectStoreStatus(store({}))
79
79
  expect(s.level).toBe('unknown')
80
- expect(s.text).toBe('No backups yet')
80
+ expect(s.text).toBe('No backup recorded')
81
81
  })
82
82
 
83
83
  it('is unhealthy when any server is failing since its last success', () => {