@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.
- package/README.md +22 -0
- package/package.json +3 -3
- package/src/components/cnpg/CNPGBackupSummary.tsx +223 -0
- package/src/components/cnpg/CNPGClusterSummary.tsx +222 -0
- package/src/components/cnpg/CNPGDeclarativeSummary.tsx +264 -0
- package/src/components/cnpg/CNPGImageCatalogSummary.tsx +80 -0
- package/src/components/cnpg/CNPGObjectStoreSummary.tsx +176 -0
- package/src/components/cnpg/CNPGObjectSummary.test.tsx +236 -0
- package/src/components/cnpg/CNPGPoolerSummary.tsx +70 -0
- package/src/components/cnpg/CNPGSharedSummary.tsx +88 -0
- package/src/components/cnpg/index.ts +14 -0
- package/src/components/cnpg/primitives.tsx +134 -0
- package/src/components/cnpg/relations.test.ts +304 -0
- package/src/components/cnpg/relations.ts +417 -0
- package/src/components/cnpg/workspace.test.ts +285 -0
- package/src/components/cnpg/workspace.ts +700 -0
- package/src/components/dock/BottomDock.tsx +8 -1
- package/src/components/dock/DockContext.tsx +8 -0
- package/src/components/dock/LocalTerminalTab.test.tsx +189 -0
- package/src/components/dock/LocalTerminalTab.tsx +56 -18
- package/src/components/dock/TerminalTab.tsx +2 -4
- package/src/components/issues/IssuesView.tsx +10 -1
- package/src/components/issues/types.ts +24 -0
- package/src/components/logs/LogCore.theme.test.tsx +77 -0
- package/src/components/logs/LogCore.tsx +23 -2
- package/src/components/logs/LogsViewer.tsx +5 -1
- package/src/components/logs/WorkloadLogsViewer.tsx +16 -4
- package/src/components/logs/useLogBuffer.test.ts +17 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
- package/src/components/resources/ResourcesSidebar.tsx +183 -11
- package/src/components/resources/ResourcesView.tsx +5 -1
- package/src/components/resources/index.ts +1 -1
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
- package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +1 -1
- package/src/components/resources/resource-utils-cnpg.ts +3 -3
- package/src/components/resources/status-overclaim.test.ts +1 -1
- package/src/components/shared/EditableYamlView.test.tsx +268 -0
- package/src/components/shared/EditableYamlView.tsx +25 -1
- package/src/components/timeline/TimelineList.tsx +185 -23
- package/src/components/timeline/TimelineListFold.test.tsx +56 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +98 -0
- package/src/components/timeline/index.ts +1 -0
- package/src/components/timeline/timeline-filters.ts +7 -0
- package/src/components/ui/SelectMenu.test.tsx +25 -0
- package/src/components/ui/SelectMenu.tsx +11 -9
- package/src/components/ui/YamlReview.test.tsx +40 -0
- package/src/components/ui/YamlReview.tsx +22 -1
- package/src/components/workload/WorkloadView.tsx +96 -5
- package/src/components/workload/index.ts +1 -0
- package/src/components/workload/workload-logs-availability.test.ts +6 -0
- package/src/index.ts +3 -0
- 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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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 ?
|
|
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
|
-
|
|
233
|
-
|
|
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:
|
|
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=
|
|
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
|
|
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
|
|
205
|
+
: 'No backup recorded'}
|
|
198
206
|
</span>
|
|
199
207
|
</div>
|
|
200
|
-
{stalled &&
|
|
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="
|
|
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
|
-
|
|
230
|
-
|
|
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
|
|
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', () => {
|