@skyhook-io/k8s-ui 1.17.1 → 1.18.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.
- package/package.json +4 -4
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -2
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +27 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +16 -10
- package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
- package/src/components/gitops/insights/insights-helpers.ts +7 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +73 -9
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +45 -28
- package/src/components/gitops/tree/tree-helpers.test.ts +6 -0
- package/src/components/gitops/tree/tree-helpers.ts +5 -1
- package/src/components/issues/IssuesView.tsx +4 -1
- package/src/components/issues/ResourceIssuesSection.tsx +5 -2
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/index.ts +1 -1
- package/src/components/issues/issues.test.ts +26 -0
- package/src/components/issues/severity.ts +9 -2
- package/src/components/issues/types.ts +32 -4
- package/src/components/logs/LogCore.theme.test.tsx +126 -1
- package/src/components/logs/LogCore.tsx +159 -40
- package/src/components/logs/LogsViewer.test.tsx +80 -2
- package/src/components/logs/LogsViewer.tsx +21 -4
- package/src/components/logs/StructuredLogLine.tsx +1 -1
- package/src/components/logs/WorkloadLogsViewer.tsx +7 -1
- package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
- package/src/components/logs/useLogBuffer.ts +48 -18
- package/src/components/logs/useLogSearch.ts +3 -1
- package/src/components/resources/ColumnPickerList.test.tsx +46 -0
- package/src/components/resources/ColumnPickerList.tsx +174 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -2
- package/src/components/resources/ResourcesView.tsx +93 -75
- package/src/components/resources/column-order.test.ts +104 -0
- package/src/components/resources/column-order.ts +64 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
- package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
- package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
- package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
- package/src/components/resources/renderers/PodRenderer.tsx +40 -11
- package/src/components/resources/resource-utils.ts +15 -2
- package/src/components/resources/summarize-scheduler-message.test.ts +1 -0
- package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
- package/src/components/shared/DrainPlanDialog.tsx +6 -2
- package/src/components/shared/ResourceActionsBar.tsx +3 -3
- package/src/components/shared/ResourceRendererDispatch.tsx +3 -1
- package/src/components/timeline/DiffViewer.test.tsx +69 -0
- package/src/components/timeline/DiffViewer.tsx +113 -4
- package/src/components/timeline/shared.test.tsx +48 -0
- package/src/components/timeline/shared.tsx +14 -19
- package/src/components/ui/Toast.tsx +15 -4
- package/src/components/ui/index.ts +1 -1
- package/src/components/workload/WorkloadView.tsx +14 -8
- package/src/types/core.ts +25 -1
- package/src/types/gitops-insights.ts +1 -0
- package/src/types/gitops-tree.ts +2 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
-
import { act } from 'react'
|
|
2
|
+
import { act, type ComponentProps } from 'react'
|
|
3
3
|
import { createRoot } from 'react-dom/client'
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
5
5
|
import { ResourcesView } from './ResourcesView'
|
|
@@ -83,7 +83,7 @@ it('closes bulk dialogs when the same kind switches to a custom view and keeps t
|
|
|
83
83
|
expect(document.querySelector('[role="dialog"]')).toBeNull()
|
|
84
84
|
render(false)
|
|
85
85
|
expect(host.textContent).not.toContain('1 selected')
|
|
86
|
-
expect(host.querySelector('input[title="Select all"]')).
|
|
86
|
+
expect(host.querySelector<HTMLInputElement>('input[title="Select all"]')!.checked).toBe(false)
|
|
87
87
|
expect(document.querySelector('[role="dialog"]')).toBeNull()
|
|
88
88
|
act(() => host.querySelector<SVGElement>('button .lucide-git-compare')!.closest<HTMLButtonElement>('button')!.click())
|
|
89
89
|
act(() => document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'j', bubbles: true })))
|
|
@@ -147,3 +147,150 @@ it('skips hidden-table sort and filter aggregation for a loaded list, then resum
|
|
|
147
147
|
expect(getSortValue).toHaveBeenCalled()
|
|
148
148
|
expect(getFilterValue).toHaveBeenCalled()
|
|
149
149
|
})
|
|
150
|
+
|
|
151
|
+
const deployments: APIResource = { group: 'apps', version: 'v1', kind: 'Deployment', name: 'deployments', namespaced: true, isCrd: false, verbs: ['get', 'list'] }
|
|
152
|
+
const widgets: APIResource = { group: 'example.com', version: 'v1', kind: 'Widget', name: 'widgets', namespaced: true, isCrd: true, verbs: ['get', 'list'] }
|
|
153
|
+
const checkboxKey = 'radar-resource-checkboxes'
|
|
154
|
+
const key = (value: string) => act(() => document.body.dispatchEvent(new KeyboardEvent('keydown', { key: value, bubbles: true })))
|
|
155
|
+
const required = <T extends Element>(selector: string, parent: ParentNode = host): T => {
|
|
156
|
+
const element = parent.querySelector<T>(selector)
|
|
157
|
+
if (!element) throw new Error(`Missing element: ${selector}`)
|
|
158
|
+
return element
|
|
159
|
+
}
|
|
160
|
+
const clickButton = (label: string, parent: ParentNode = host) => {
|
|
161
|
+
const button = [...parent.querySelectorAll('button')].find(candidate => candidate.textContent?.trim() === label)
|
|
162
|
+
if (!button) throw new Error(`Missing button: ${label}`)
|
|
163
|
+
act(() => button.click())
|
|
164
|
+
}
|
|
165
|
+
const renderBulkTable = (initialKind = pods, bulkEnabled = true) => {
|
|
166
|
+
const selected = vi.fn()
|
|
167
|
+
const remove = vi.fn<NonNullable<ComponentProps<typeof ResourcesView>['onBulkDelete']>>()
|
|
168
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host)
|
|
169
|
+
const render = (kind = initialKind, items = [pod]) => act(() => root.render(<KeyboardShortcutProvider><ResourcesView
|
|
170
|
+
namespaces={['test']}
|
|
171
|
+
apiResources={[pods, deployments, widgets]}
|
|
172
|
+
resourceCounts={{ [kind.kind]: items.length }}
|
|
173
|
+
selectedKindQuery={{ isLoading: false, data: items, resourceName: kind.name, group: kind.group }}
|
|
174
|
+
locationPathname={`/resources/${kind.name}`}
|
|
175
|
+
locationSearch={kind.group ? `?apiGroup=${kind.group}` : ''}
|
|
176
|
+
onNavigate={() => {}}
|
|
177
|
+
onResourceClick={selected}
|
|
178
|
+
onBulkDelete={bulkEnabled ? remove : undefined}
|
|
179
|
+
/></KeyboardShortcutProvider>))
|
|
180
|
+
render()
|
|
181
|
+
return { selected, remove, render }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
it.each([pods, deployments, widgets])('hides selection by default on $kind and enables it without an empty action bar', kind => {
|
|
185
|
+
renderBulkTable(kind)
|
|
186
|
+
expect(host.querySelector('input[title="Select all"]')).toBeNull()
|
|
187
|
+
act(() => required<HTMLButtonElement>('button[aria-label="Show selection checkboxes"]').click())
|
|
188
|
+
expect(host.querySelector('input[title="Select all"]')).not.toBeNull()
|
|
189
|
+
expect(host.textContent).not.toContain('0 selected')
|
|
190
|
+
expect(localStorage.getItem(checkboxKey)).toBe('true')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('remembers both visibility choices across remounts without remembering selected rows', () => {
|
|
194
|
+
renderBulkTable()
|
|
195
|
+
key('b')
|
|
196
|
+
act(() => required<HTMLInputElement>('input[title="Select all"]').click())
|
|
197
|
+
expect(host.textContent).toContain('1 selected')
|
|
198
|
+
act(() => root.unmount()); host.remove()
|
|
199
|
+
renderBulkTable(deployments)
|
|
200
|
+
expect(host.querySelector('input[title="Select all"]')).not.toBeNull()
|
|
201
|
+
expect(host.textContent).not.toContain('1 selected')
|
|
202
|
+
key('b')
|
|
203
|
+
expect(localStorage.getItem(checkboxKey)).toBe('false')
|
|
204
|
+
act(() => root.unmount()); host.remove()
|
|
205
|
+
renderBulkTable()
|
|
206
|
+
expect(host.querySelector('input[title="Select all"]')).toBeNull()
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('keeps keyboard activation for inspection while checkboxes select', () => {
|
|
210
|
+
const { selected } = renderBulkTable()
|
|
211
|
+
key('b')
|
|
212
|
+
act(() => required<HTMLInputElement>('input[title="Select all"]').click())
|
|
213
|
+
expect(selected).not.toHaveBeenCalled()
|
|
214
|
+
expect(host.textContent).toContain('1 selected')
|
|
215
|
+
key('j'); key('Enter')
|
|
216
|
+
expect(selected).toHaveBeenCalledTimes(1)
|
|
217
|
+
expect(host.textContent).toContain('1 selected')
|
|
218
|
+
key('Escape')
|
|
219
|
+
expect(host.textContent).not.toContain('1 selected')
|
|
220
|
+
expect(host.querySelector('input[title="Select all"]')).not.toBeNull()
|
|
221
|
+
act(() => required<HTMLInputElement>('input[title="Select all"]').click())
|
|
222
|
+
clickButton('Clear selection')
|
|
223
|
+
expect(host.textContent).not.toContain('1 selected')
|
|
224
|
+
expect(localStorage.getItem(checkboxKey)).toBe('true')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('temporarily hides checkboxes for compare and clears selection on kind changes', () => {
|
|
228
|
+
const { render } = renderBulkTable()
|
|
229
|
+
key('b')
|
|
230
|
+
act(() => required<HTMLInputElement>('input[title="Select all"]').click())
|
|
231
|
+
act(() => required<HTMLButtonElement>('button:has(.lucide-git-compare)').click())
|
|
232
|
+
expect(host.querySelector('input[title="Select all"]')).toBeNull()
|
|
233
|
+
key('Escape')
|
|
234
|
+
expect(host.querySelector('input[title="Select all"]')).not.toBeNull()
|
|
235
|
+
expect(host.textContent).not.toContain('1 selected')
|
|
236
|
+
act(() => required<HTMLInputElement>('input[title="Select all"]').click())
|
|
237
|
+
render(deployments)
|
|
238
|
+
expect(host.querySelector('input[title="Select all"]')).not.toBeNull()
|
|
239
|
+
expect(host.textContent).not.toContain('1 selected')
|
|
240
|
+
render(widgets)
|
|
241
|
+
expect(host.querySelector('input[title="Select all"]')).not.toBeNull()
|
|
242
|
+
expect(localStorage.getItem(checkboxKey)).toBe('true')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('can show selection from compare without turning off the saved preference', () => {
|
|
246
|
+
localStorage.setItem(checkboxKey, 'true')
|
|
247
|
+
renderBulkTable()
|
|
248
|
+
act(() => required<HTMLButtonElement>('button:has(.lucide-git-compare)').click())
|
|
249
|
+
key('b')
|
|
250
|
+
expect(host.querySelector('[aria-label="Compare resources tray"]')).toBeNull()
|
|
251
|
+
expect(host.querySelector('input[title="Select all"]')).not.toBeNull()
|
|
252
|
+
expect(localStorage.getItem(checkboxKey)).toBe('true')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('keeps delete confirmation and clears successful selection without hiding checkboxes', () => {
|
|
256
|
+
const { remove } = renderBulkTable()
|
|
257
|
+
key('b')
|
|
258
|
+
act(() => required<HTMLInputElement>('input[title="Select all"]').click())
|
|
259
|
+
clickButton('Delete')
|
|
260
|
+
const dialog = required<HTMLElement>('[role="dialog"]', document)
|
|
261
|
+
expect(dialog.textContent).toContain('Delete 1 Pod?')
|
|
262
|
+
expect(remove).not.toHaveBeenCalled()
|
|
263
|
+
clickButton('Delete 1 resource', dialog)
|
|
264
|
+
expect(remove).toHaveBeenCalledWith([{ kind: 'pods', group: '', namespace: 'test', name: 'example' }], expect.objectContaining({ force: false }))
|
|
265
|
+
act(() => remove.mock.calls[0]?.[1]?.onSuccess?.())
|
|
266
|
+
expect(host.textContent).not.toContain('1 selected')
|
|
267
|
+
expect(host.querySelector('input[title="Select all"]')).not.toBeNull()
|
|
268
|
+
expect(localStorage.getItem(checkboxKey)).toBe('true')
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it.each(['false', 'garbage', 'null', '1'])('ignores invalid or disabled visibility value %s', value => {
|
|
272
|
+
localStorage.setItem(checkboxKey, value)
|
|
273
|
+
renderBulkTable()
|
|
274
|
+
expect(host.querySelector('input[title="Select all"]')).toBeNull()
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('keeps toggling usable when browser storage is unavailable', () => {
|
|
278
|
+
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('Storage unavailable') })
|
|
279
|
+
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('Storage unavailable') })
|
|
280
|
+
renderBulkTable()
|
|
281
|
+
expect(host.querySelector('input[title="Select all"]')).toBeNull()
|
|
282
|
+
key('b')
|
|
283
|
+
expect(host.querySelector('input[title="Select all"]')).not.toBeNull()
|
|
284
|
+
key('b')
|
|
285
|
+
expect(host.querySelector('input[title="Select all"]')).toBeNull()
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('does not offer selection without a bulk action callback even with saved visibility', () => {
|
|
289
|
+
localStorage.setItem(checkboxKey, 'true')
|
|
290
|
+
renderBulkTable(pods, false)
|
|
291
|
+
expect(host.querySelector('input[title="Select all"]')).toBeNull()
|
|
292
|
+
expect(host.querySelector('button .lucide-list-checks')).toBeNull()
|
|
293
|
+
key('b')
|
|
294
|
+
expect(host.querySelector('input[title="Select all"]')).toBeNull()
|
|
295
|
+
expect(localStorage.getItem(checkboxKey)).toBe('true')
|
|
296
|
+
})
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import React, { useState, useMemo, useEffect, useCallback, useDeferredValue, useRef, useContext, useId } from 'react'
|
|
2
2
|
import { TableVirtuoso, type TableVirtuosoHandle } from 'react-virtuoso'
|
|
3
3
|
import { useDebouncedValue } from '../../hooks/useDebouncedValue'
|
|
4
|
+
import { Badge } from '../ui/Badge'
|
|
4
5
|
import { PaneLoader } from '../ui/PaneLoader'
|
|
5
6
|
import { RestrictedState } from '../ui/RestrictedState'
|
|
6
7
|
import { FetchResult } from '../ui/FetchResult'
|
|
7
8
|
import { Input } from '../ui/Input'
|
|
9
|
+
import { ColumnPickerList } from './ColumnPickerList'
|
|
10
|
+
import { applyColumnOrder, sanitizeColumnOrder } from './column-order'
|
|
8
11
|
import type { TopPodMetrics, TopNodeMetrics, ContainerResourceMetrics } from '../../types'
|
|
9
12
|
import {
|
|
10
13
|
Search,
|
|
@@ -161,6 +164,8 @@ import { isRolloutActivityVisible } from '../../utils/workload-rollout'
|
|
|
161
164
|
import { FreshnessControl, type FreshnessConnection } from '../ui/FreshnessControl'
|
|
162
165
|
import { Tooltip } from '../ui/Tooltip'
|
|
163
166
|
import { AuditBadgeTooltip, type AuditBadgeMessage } from '../audit/AuditBadgeTooltip'
|
|
167
|
+
import type { Issue } from '../issues/types'
|
|
168
|
+
import { issueTitle } from '../issues/severity'
|
|
164
169
|
import { SEVERITY_TEXT_CLASS } from '../checks/severity'
|
|
165
170
|
// CRD-specific cell components (extracted)
|
|
166
171
|
import { GitRepositoryCell, OCIRepositoryCell, HelmRepositoryCell, KustomizationCell, FluxHelmReleaseCell, FluxAlertCell } from './renderers/flux-cells'
|
|
@@ -3255,6 +3260,7 @@ function filterHostExtras(
|
|
|
3255
3260
|
|
|
3256
3261
|
// localStorage helpers for column settings
|
|
3257
3262
|
const COLUMN_SETTINGS_PREFIX = 'radar-columns-'
|
|
3263
|
+
const CHECKBOX_VISIBILITY_KEY = 'radar-resource-checkboxes'
|
|
3258
3264
|
|
|
3259
3265
|
// Storage identity for a kind's column settings. normalizeKindToPlural falls
|
|
3260
3266
|
// through to the bare plural for anything outside the curated collision
|
|
@@ -3280,6 +3286,8 @@ interface ColumnSettings {
|
|
|
3280
3286
|
* load tell a column they hid from one that did not exist yet. Absent on
|
|
3281
3287
|
* blobs written before this field. */
|
|
3282
3288
|
known?: string[]
|
|
3289
|
+
/** Optional display order for movable columns, including hidden ones. */
|
|
3290
|
+
order?: string[]
|
|
3283
3291
|
}
|
|
3284
3292
|
|
|
3285
3293
|
function loadColumnSettings(kind: string, group?: string): ColumnSettings | null {
|
|
@@ -3342,6 +3350,9 @@ interface ResourcesViewData {
|
|
|
3342
3350
|
// cluster-scoped kind. An ExternalSecret names a store; only the store
|
|
3343
3351
|
// knows which backend it reads from.
|
|
3344
3352
|
storeProviders?: Record<string, string>
|
|
3353
|
+
/** Current CronJob Issues keyed by "namespace/name", supplied by the host. */
|
|
3354
|
+
operationalIssuesIncomplete?: boolean
|
|
3355
|
+
operationalIssues?: Record<string, Issue>
|
|
3345
3356
|
/** Cluster Audit findings keyed by "namespace/name". Raw compatibility
|
|
3346
3357
|
* counts render danger as High and warning as Medium. */
|
|
3347
3358
|
auditBadges?: Record<string, { danger: number; warning: number; messages?: AuditBadgeMessage[] }>
|
|
@@ -3406,6 +3417,9 @@ interface ResourcesViewProps {
|
|
|
3406
3417
|
// cluster-scoped kind. An ExternalSecret names a store; only the store
|
|
3407
3418
|
// knows which backend it reads from.
|
|
3408
3419
|
storeProviders?: Record<string, string>
|
|
3420
|
+
/** Current CronJob Issues keyed by "namespace/name", supplied by the host. */
|
|
3421
|
+
operationalIssuesIncomplete?: boolean
|
|
3422
|
+
operationalIssues?: Record<string, Issue>
|
|
3409
3423
|
/** Cluster Audit findings keyed by "namespace/name". Raw compatibility
|
|
3410
3424
|
* counts render danger as High and warning as Medium. */
|
|
3411
3425
|
auditBadges?: Record<string, { danger: number; warning: number; messages?: AuditBadgeMessage[] }>
|
|
@@ -3692,6 +3706,8 @@ export function ResourcesView({
|
|
|
3692
3706
|
certExpiryError,
|
|
3693
3707
|
storeProviders,
|
|
3694
3708
|
auditBadges,
|
|
3709
|
+
operationalIssues,
|
|
3710
|
+
operationalIssuesIncomplete,
|
|
3695
3711
|
pinned = [],
|
|
3696
3712
|
togglePin = () => {},
|
|
3697
3713
|
isPinned = () => false,
|
|
@@ -3747,7 +3763,6 @@ export function ResourcesView({
|
|
|
3747
3763
|
}, [selectedKind.name, selectedKind.group]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
3748
3764
|
|
|
3749
3765
|
useEffect(() => {
|
|
3750
|
-
setBulkMode(false)
|
|
3751
3766
|
setCheckedResources(new Set())
|
|
3752
3767
|
setShowBulkDeleteConfirm(false)
|
|
3753
3768
|
setShowBulkRestartConfirm(false)
|
|
@@ -3808,6 +3823,7 @@ export function ResourcesView({
|
|
|
3808
3823
|
// Column visibility and resize state
|
|
3809
3824
|
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(new Set())
|
|
3810
3825
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({})
|
|
3826
|
+
const [columnOrder, setColumnOrder] = useState<string[] | null>(null)
|
|
3811
3827
|
const [showColumnPicker, setShowColumnPicker] = useState(false)
|
|
3812
3828
|
const [customColumns, setCustomColumns] = useState<CustomColumnDef[]>([])
|
|
3813
3829
|
const [customColumnDraft, setCustomColumnDraft] = useState<CustomColumnDef>({ source: 'label', path: '' })
|
|
@@ -3820,10 +3836,10 @@ export function ResourcesView({
|
|
|
3820
3836
|
const [ownerKind, setOwnerKind] = useState<string>(initialFilters.ownerKind)
|
|
3821
3837
|
const [ownerName, setOwnerName] = useState<string>(initialFilters.ownerName)
|
|
3822
3838
|
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3839
|
+
const [showCheckboxes, setShowCheckboxes] = useState(() => {
|
|
3840
|
+
try { return localStorage.getItem(CHECKBOX_VISIBILITY_KEY) === 'true' }
|
|
3841
|
+
catch { return false }
|
|
3842
|
+
})
|
|
3827
3843
|
const [checkedResources, setCheckedResources] = useState<Set<string>>(new Set())
|
|
3828
3844
|
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
|
3829
3845
|
const [showBulkRestartConfirm, setShowBulkRestartConfirm] = useState(false)
|
|
@@ -3831,8 +3847,7 @@ export function ResourcesView({
|
|
|
3831
3847
|
const [bulkForceDelete, setBulkForceDelete] = useState(false)
|
|
3832
3848
|
const [bulkScaleReplicas, setBulkScaleReplicas] = useState(0)
|
|
3833
3849
|
|
|
3834
|
-
const
|
|
3835
|
-
setBulkMode(false)
|
|
3850
|
+
const clearBulkSelection = useCallback(() => {
|
|
3836
3851
|
setCheckedResources(new Set())
|
|
3837
3852
|
}, [])
|
|
3838
3853
|
|
|
@@ -3949,13 +3964,16 @@ export function ResourcesView({
|
|
|
3949
3964
|
[printerTable, builtPrinterColumns],
|
|
3950
3965
|
)
|
|
3951
3966
|
|
|
3952
|
-
const
|
|
3967
|
+
const definedColumns = useMemo(() => {
|
|
3953
3968
|
const kindColumns = columnsForKindWithPrinter(selectedKind.name, selectedKind.group, builtPrinterColumns)
|
|
3954
3969
|
if (!extraLeadingColumns?.length && !builtCustomColumns.length) return kindColumns
|
|
3955
3970
|
const builtinKeys = new Set(kindColumns.map(c => c.key))
|
|
3956
3971
|
const filteredExtras = filterHostExtras(extraLeadingColumns, builtinKeys, selectedKind.name)
|
|
3957
3972
|
return [...filteredExtras, ...kindColumns, ...builtCustomColumns]
|
|
3958
3973
|
}, [selectedKind.name, selectedKind.group, extraLeadingColumns, builtCustomColumns, builtPrinterColumns])
|
|
3974
|
+
// Display order. Everything downstream reads this, so the picker, the header
|
|
3975
|
+
// and the resize width snapshot (which maps <th> index to key) agree.
|
|
3976
|
+
const allColumns = useMemo(() => applyColumnOrder(definedColumns, columnOrder), [definedColumns, columnOrder])
|
|
3959
3977
|
|
|
3960
3978
|
// The offered key set, and a stable signature for it. allColumns is a new
|
|
3961
3979
|
// identity on every printer-column refetch; depending on the array itself
|
|
@@ -4090,15 +4108,18 @@ export function ResourcesView({
|
|
|
4090
4108
|
hadSavedColumnSettings.current = false
|
|
4091
4109
|
setVisibleColumns(getDefaultVisibleColumns(effective))
|
|
4092
4110
|
setColumnWidths({})
|
|
4111
|
+
setColumnOrder(null)
|
|
4093
4112
|
} else {
|
|
4094
4113
|
setVisibleColumns(mergeSavedVisibleColumns(
|
|
4095
4114
|
saved.visible, extraKeys, builtPrinterColumns, effective, savedKnown,
|
|
4096
4115
|
))
|
|
4097
4116
|
setColumnWidths(saved.widths || {})
|
|
4117
|
+
setColumnOrder(sanitizeColumnOrder(saved.order))
|
|
4098
4118
|
}
|
|
4099
4119
|
} else {
|
|
4100
4120
|
setVisibleColumns(getDefaultVisibleColumns(effective))
|
|
4101
4121
|
setColumnWidths({})
|
|
4122
|
+
setColumnOrder(null)
|
|
4102
4123
|
}
|
|
4103
4124
|
// printerColumnsKey, not builtPrinterColumns: the built array is a new
|
|
4104
4125
|
// identity on every refetch, and re-running this effect would reset the
|
|
@@ -4120,12 +4141,13 @@ export function ResourcesView({
|
|
|
4120
4141
|
widths: columnWidths,
|
|
4121
4142
|
custom: customColumns,
|
|
4122
4143
|
known: Array.from(knownColumnKeys.current),
|
|
4144
|
+
order: columnOrder ?? undefined,
|
|
4123
4145
|
})
|
|
4124
4146
|
// A persisted blob blocks GPU auto-show from here on — same rule in-session
|
|
4125
4147
|
// as across sessions, so a later data refresh can't override user choices.
|
|
4126
4148
|
hadSavedColumnSettings.current = true
|
|
4127
4149
|
// allColumnKeysSig, not allColumnKeys: see the memo above.
|
|
4128
|
-
}, [visibleColumns, columnWidths, customColumns, selectedKind.name, selectedKind.group, allColumnKeysSig, tableActive])
|
|
4150
|
+
}, [visibleColumns, columnWidths, customColumns, columnOrder, selectedKind.name, selectedKind.group, allColumnKeysSig, tableActive])
|
|
4129
4151
|
|
|
4130
4152
|
// Close column picker on outside click or Escape
|
|
4131
4153
|
useEffect(() => {
|
|
@@ -4233,6 +4255,7 @@ export function ResourcesView({
|
|
|
4233
4255
|
const customKeys = new Set(builtCustomColumns.map(c => c.key))
|
|
4234
4256
|
setVisibleColumns(getDefaultVisibleColumns(allColumns.filter(c => !customKeys.has(c.key))))
|
|
4235
4257
|
setColumnWidths({})
|
|
4258
|
+
setColumnOrder(null)
|
|
4236
4259
|
setSortColumn(null)
|
|
4237
4260
|
setSortDirection(null)
|
|
4238
4261
|
saveSavedSort(selectedKind.name, selectedKind.group, null)
|
|
@@ -4552,14 +4575,11 @@ export function ResourcesView({
|
|
|
4552
4575
|
{
|
|
4553
4576
|
id: 'resources-bulk-select',
|
|
4554
4577
|
keys: 'b',
|
|
4555
|
-
description: '
|
|
4578
|
+
description: 'Show or hide selection checkboxes',
|
|
4556
4579
|
category: 'Table',
|
|
4557
4580
|
scope: 'resources',
|
|
4558
4581
|
handler: () => {
|
|
4559
|
-
if (
|
|
4560
|
-
if (!canBulkSelect) return
|
|
4561
|
-
exitCompareMode()
|
|
4562
|
-
setBulkMode(true)
|
|
4582
|
+
if (canBulkSelect) toggleCheckboxVisibility()
|
|
4563
4583
|
},
|
|
4564
4584
|
},
|
|
4565
4585
|
{
|
|
@@ -4574,7 +4594,7 @@ export function ResourcesView({
|
|
|
4574
4594
|
if (showProblemsDropdown) { setShowProblemsDropdown(false); return }
|
|
4575
4595
|
if (showLabelsDropdown) { setShowLabelsDropdown(false); return }
|
|
4576
4596
|
if (compareMode) { exitCompareMode(); return }
|
|
4577
|
-
if (
|
|
4597
|
+
if (checkedResources.size > 0) { clearBulkSelection(); return }
|
|
4578
4598
|
if (highlightedIndex >= 0) setHighlightedIndex(-1)
|
|
4579
4599
|
else searchInputRef.current?.blur()
|
|
4580
4600
|
},
|
|
@@ -5292,6 +5312,15 @@ export function ResourcesView({
|
|
|
5292
5312
|
saveSavedSort(selectedKind.name, selectedKind.group, newColumn && newDirection ? { column: newColumn, direction: newDirection } : null)
|
|
5293
5313
|
}, [sortColumn, sortDirection, canSortBy, selectedKind.name, selectedKind.group])
|
|
5294
5314
|
|
|
5315
|
+
const getDisplayFilterValue = useCallback((resource: { metadata?: { namespace?: string; name?: string } }, column: string, kind: string): string => {
|
|
5316
|
+
if (column === 'status' && selectedKind.group === 'batch' && kind === 'cronjobs') {
|
|
5317
|
+
const issue = operationalIssues?.[`${resource.metadata?.namespace ?? ''}/${resource.metadata?.name ?? ''}`]
|
|
5318
|
+
if (issue) return issueTitle(issue)
|
|
5319
|
+
if (operationalIssuesIncomplete) return 'Unknown'
|
|
5320
|
+
}
|
|
5321
|
+
return getCellFilterValue(resource, column, kind)
|
|
5322
|
+
}, [operationalIssues, operationalIssuesIncomplete, selectedKind.group])
|
|
5323
|
+
|
|
5295
5324
|
// Get sortable value from a resource for a given column
|
|
5296
5325
|
const getSortValue = useCallback((resource: any, column: string, kind?: string): string | number => {
|
|
5297
5326
|
const meta = resource.metadata || {}
|
|
@@ -5320,7 +5349,7 @@ export function ResourcesView({
|
|
|
5320
5349
|
if (hasCuratedColumns(selectedKind.name, selectedKind.group) && status.phase) {
|
|
5321
5350
|
return status.phase
|
|
5322
5351
|
}
|
|
5323
|
-
return
|
|
5352
|
+
return getDisplayFilterValue(resource, 'status', kindLower)
|
|
5324
5353
|
case 'servers':
|
|
5325
5354
|
// Numeric so 10 does not sort before 9.
|
|
5326
5355
|
if (kindLower === 'istiogateways') return getIstioGatewayServerCount(resource)
|
|
@@ -5410,7 +5439,7 @@ export function ResourcesView({
|
|
|
5410
5439
|
default:
|
|
5411
5440
|
return ''
|
|
5412
5441
|
}
|
|
5413
|
-
}, [metricsLookup, selectedKind.name, selectedKind.group])
|
|
5442
|
+
}, [metricsLookup, selectedKind.name, selectedKind.group, getDisplayFilterValue])
|
|
5414
5443
|
|
|
5415
5444
|
// Helper to check if a pod matches problem filters
|
|
5416
5445
|
const podMatchesProblemFilter = useCallback((pod: any, filters: string[]): boolean => {
|
|
@@ -5467,7 +5496,7 @@ export function ResourcesView({
|
|
|
5467
5496
|
result = result.filter((r: any) =>
|
|
5468
5497
|
activeColFilters.every(([col, vals]) => {
|
|
5469
5498
|
const extra = extraColumnsByKey.get(col)
|
|
5470
|
-
const cellVal = extra?.getFilterValue ? extra.getFilterValue(r) :
|
|
5499
|
+
const cellVal = extra?.getFilterValue ? extra.getFilterValue(r) : getDisplayFilterValue(r, col, cellFilterKind)
|
|
5471
5500
|
const match = vals.includes(cellVal)
|
|
5472
5501
|
return columnFilterExcludes[col] ? !match : match
|
|
5473
5502
|
})
|
|
@@ -5621,7 +5650,7 @@ export function ResourcesView({
|
|
|
5621
5650
|
}
|
|
5622
5651
|
|
|
5623
5652
|
return result
|
|
5624
|
-
}, [tableActive, resources, deferredSearchTerm, regexMode, searchRegex, columnFilters, columnFilterExcludes, problemFilters, showInactiveReplicaSets, labelSelector, ownerKind, ownerName, selectedKind.name, effectiveSortColumn, sortDirection, getSortValue, extraColumnsByKey, podMatchesProblemFilter])
|
|
5653
|
+
}, [tableActive, resources, deferredSearchTerm, regexMode, searchRegex, columnFilters, columnFilterExcludes, problemFilters, showInactiveReplicaSets, labelSelector, ownerKind, ownerName, selectedKind.name, effectiveSortColumn, sortDirection, getSortValue, getDisplayFilterValue, extraColumnsByKey, podMatchesProblemFilter])
|
|
5625
5654
|
|
|
5626
5655
|
// For nodes table: compute the majority minor version so outliers can be highlighted
|
|
5627
5656
|
const majorityNodeMinorVersion = useMemo(() => {
|
|
@@ -5746,16 +5775,25 @@ export function ResourcesView({
|
|
|
5746
5775
|
setCheckedResources(allVisibleChecked ? new Set() : new Set(filteredResources.map(getResourceKey)))
|
|
5747
5776
|
}, [allVisibleChecked, filteredResources, getResourceKey])
|
|
5748
5777
|
|
|
5749
|
-
const
|
|
5778
|
+
const toggleCheckboxVisibility = useCallback(() => {
|
|
5779
|
+
const next = compareMode || !showCheckboxes
|
|
5780
|
+
exitCompareMode()
|
|
5781
|
+
clearBulkSelection()
|
|
5782
|
+
setShowCheckboxes(next)
|
|
5783
|
+
try { localStorage.setItem(CHECKBOX_VISIBILITY_KEY, String(next)) }
|
|
5784
|
+
catch { /* Keep the choice for this session when storage is unavailable. */ }
|
|
5785
|
+
}, [compareMode, showCheckboxes, exitCompareMode, clearBulkSelection])
|
|
5786
|
+
|
|
5787
|
+
const isCheckboxMode = canBulkSelect && !compareMode && showCheckboxes
|
|
5788
|
+
const showBulkBar = isCheckboxMode && checkedItems.length > 0
|
|
5750
5789
|
|
|
5751
5790
|
// Stable row handlers so the memoized ResourceRowCells skips re-rendering
|
|
5752
5791
|
// rows whose data didn't change on a refetch (React Query's structural
|
|
5753
5792
|
// sharing keeps unchanged resources referentially identical).
|
|
5754
5793
|
const handleRowClick = useCallback((resource: any, isSelected: boolean) => {
|
|
5755
5794
|
if (compareMode) toggleComparePick(resource)
|
|
5756
|
-
else if (isCheckboxMode) toggleChecked(resource)
|
|
5757
5795
|
else selectResource(resource, isSelected)
|
|
5758
|
-
}, [compareMode,
|
|
5796
|
+
}, [compareMode, toggleComparePick, selectResource])
|
|
5759
5797
|
const handleRowMouseEnter = useCallback(() => setHighlightedIndex(-1), [])
|
|
5760
5798
|
|
|
5761
5799
|
// Filter columns by visibility
|
|
@@ -5847,7 +5885,7 @@ export function ResourcesView({
|
|
|
5847
5885
|
// Count distinct values for this column
|
|
5848
5886
|
const valueCounts: Record<string, number> = {}
|
|
5849
5887
|
for (const r of resources) {
|
|
5850
|
-
const val = extra?.getFilterValue ? extra.getFilterValue(r) :
|
|
5888
|
+
const val = extra?.getFilterValue ? extra.getFilterValue(r) : getDisplayFilterValue(r, col.key, cellFilterKind)
|
|
5851
5889
|
if (val) {
|
|
5852
5890
|
valueCounts[val] = (valueCounts[val] || 0) + 1
|
|
5853
5891
|
}
|
|
@@ -5930,7 +5968,7 @@ export function ResourcesView({
|
|
|
5930
5968
|
|
|
5931
5969
|
if (filterableColumns.length === 0 && !problems && labelValues.length === 0) return null
|
|
5932
5970
|
return { columns: filterableColumns, problems, labels: labelValues }
|
|
5933
|
-
}, [tableActive, resources, selectedKind.name, selectedKind.group, allColumns])
|
|
5971
|
+
}, [tableActive, resources, selectedKind.name, selectedKind.group, allColumns, getDisplayFilterValue])
|
|
5934
5972
|
|
|
5935
5973
|
// Map filterable columns by key for O(1) lookup in header rendering
|
|
5936
5974
|
const filterableColumnMap = useMemo(() => {
|
|
@@ -6005,9 +6043,11 @@ export function ResourcesView({
|
|
|
6005
6043
|
certExpiry,
|
|
6006
6044
|
certExpiryError,
|
|
6007
6045
|
auditBadges,
|
|
6046
|
+
operationalIssues,
|
|
6047
|
+
operationalIssuesIncomplete,
|
|
6008
6048
|
onOpenLogs,
|
|
6009
6049
|
onOpenWorkloadLogs,
|
|
6010
|
-
}), [onNavigate, certExpiry, certExpiryError, auditBadges, onOpenLogs, onOpenWorkloadLogs])
|
|
6050
|
+
}), [onNavigate, certExpiry, certExpiryError, auditBadges, operationalIssues, operationalIssuesIncomplete, onOpenLogs, onOpenWorkloadLogs])
|
|
6011
6051
|
|
|
6012
6052
|
return (
|
|
6013
6053
|
<ResourcesViewDataContext.Provider value={resourcesViewDataContextValue}>
|
|
@@ -6316,41 +6356,14 @@ export function ResourcesView({
|
|
|
6316
6356
|
{/* Column list scrolls; the add-column footer stays pinned so
|
|
6317
6357
|
its input isn't pushed below the fold on kinds with many columns. */}
|
|
6318
6358
|
<div className="overflow-y-auto flex-1 min-h-0">
|
|
6319
|
-
|
|
6320
|
-
|
|
6321
|
-
|
|
6322
|
-
|
|
6323
|
-
|
|
6324
|
-
|
|
6325
|
-
|
|
6326
|
-
|
|
6327
|
-
<input
|
|
6328
|
-
type="checkbox"
|
|
6329
|
-
checked={visibleColumns.has(col.key)}
|
|
6330
|
-
onChange={() => toggleColumnVisibility(col.key)}
|
|
6331
|
-
disabled={col.key === 'name'}
|
|
6332
|
-
className="rounded border-theme-border"
|
|
6333
|
-
/>
|
|
6334
|
-
<span className={clsx(
|
|
6335
|
-
'text-sm truncate',
|
|
6336
|
-
col.key === 'name' ? 'text-theme-text-tertiary' : 'text-theme-text-primary'
|
|
6337
|
-
)} title={col.tooltip || col.label}>
|
|
6338
|
-
{col.label}
|
|
6339
|
-
</span>
|
|
6340
|
-
</label>
|
|
6341
|
-
{isCustom && (
|
|
6342
|
-
<button
|
|
6343
|
-
type="button"
|
|
6344
|
-
onClick={() => removeCustomColumn(col.key)}
|
|
6345
|
-
className="shrink-0 p-0.5 text-theme-text-tertiary hover:text-red-500 opacity-0 group-hover:opacity-100 focus:opacity-100"
|
|
6346
|
-
title="Remove column"
|
|
6347
|
-
>
|
|
6348
|
-
<X className="w-3.5 h-3.5" />
|
|
6349
|
-
</button>
|
|
6350
|
-
)}
|
|
6351
|
-
</div>
|
|
6352
|
-
)
|
|
6353
|
-
})}
|
|
6359
|
+
<ColumnPickerList
|
|
6360
|
+
columns={allColumns}
|
|
6361
|
+
visibleColumns={visibleColumns}
|
|
6362
|
+
customColumnKeys={customColumnKeySet}
|
|
6363
|
+
onToggle={toggleColumnVisibility}
|
|
6364
|
+
onRemoveCustom={removeCustomColumn}
|
|
6365
|
+
onReorder={setColumnOrder}
|
|
6366
|
+
/>
|
|
6354
6367
|
</div>
|
|
6355
6368
|
{/* Add a label/annotation column */}
|
|
6356
6369
|
<div className="shrink-0 border-t border-theme-border pt-2 px-3 pb-1">
|
|
@@ -6420,7 +6433,7 @@ export function ResourcesView({
|
|
|
6420
6433
|
<button
|
|
6421
6434
|
onClick={() => {
|
|
6422
6435
|
if (compareMode) { exitCompareMode(); return }
|
|
6423
|
-
|
|
6436
|
+
clearBulkSelection()
|
|
6424
6437
|
setCompareMode(true)
|
|
6425
6438
|
}}
|
|
6426
6439
|
aria-pressed={compareMode}
|
|
@@ -6436,17 +6449,14 @@ export function ResourcesView({
|
|
|
6436
6449
|
</Tooltip>
|
|
6437
6450
|
)}
|
|
6438
6451
|
{canBulkSelect && (
|
|
6439
|
-
<Tooltip content={
|
|
6452
|
+
<Tooltip content={isCheckboxMode ? 'Hide selection checkboxes' : 'Show selection checkboxes'}>
|
|
6440
6453
|
<button
|
|
6441
|
-
onClick={
|
|
6442
|
-
|
|
6443
|
-
|
|
6444
|
-
setBulkMode(true)
|
|
6445
|
-
}}
|
|
6446
|
-
aria-pressed={bulkMode}
|
|
6454
|
+
onClick={toggleCheckboxVisibility}
|
|
6455
|
+
aria-label={isCheckboxMode ? 'Hide selection checkboxes' : 'Show selection checkboxes'}
|
|
6456
|
+
aria-pressed={isCheckboxMode}
|
|
6447
6457
|
className={clsx(
|
|
6448
6458
|
'p-2 rounded-lg transition-colors',
|
|
6449
|
-
|
|
6459
|
+
isCheckboxMode
|
|
6450
6460
|
? 'bg-skyhook-500/15 text-skyhook-300 border border-skyhook-400/50'
|
|
6451
6461
|
: 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated',
|
|
6452
6462
|
)}
|
|
@@ -6466,7 +6476,7 @@ export function ResourcesView({
|
|
|
6466
6476
|
</div>
|
|
6467
6477
|
|
|
6468
6478
|
{/* Bulk actions bar */}
|
|
6469
|
-
{
|
|
6479
|
+
{showBulkBar && (
|
|
6470
6480
|
<div className="flex items-center gap-3 px-4 py-2 bg-skyhook-500/10 border-b border-skyhook-400/20 shrink-0">
|
|
6471
6481
|
<span className="text-sm font-medium text-theme-text-primary">
|
|
6472
6482
|
{checkedItems.length} selected
|
|
@@ -6506,10 +6516,10 @@ export function ResourcesView({
|
|
|
6506
6516
|
)}
|
|
6507
6517
|
<button
|
|
6508
6518
|
type="button"
|
|
6509
|
-
onClick={
|
|
6519
|
+
onClick={clearBulkSelection}
|
|
6510
6520
|
className="px-3 py-1.5 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg transition-colors"
|
|
6511
6521
|
>
|
|
6512
|
-
|
|
6522
|
+
Clear selection
|
|
6513
6523
|
</button>
|
|
6514
6524
|
</div>
|
|
6515
6525
|
)}
|
|
@@ -6948,7 +6958,7 @@ export function ResourcesView({
|
|
|
6948
6958
|
onBulkDelete?.(checkedBulkItems, {
|
|
6949
6959
|
force: bulkForceDelete,
|
|
6950
6960
|
onSuccess: () => {
|
|
6951
|
-
|
|
6961
|
+
clearBulkSelection()
|
|
6952
6962
|
setShowBulkDeleteConfirm(false)
|
|
6953
6963
|
setBulkForceDelete(false)
|
|
6954
6964
|
},
|
|
@@ -6978,7 +6988,7 @@ export function ResourcesView({
|
|
|
6978
6988
|
onConfirm={() => {
|
|
6979
6989
|
onBulkRestart?.(checkedBulkItems, {
|
|
6980
6990
|
onSuccess: () => {
|
|
6981
|
-
|
|
6991
|
+
clearBulkSelection()
|
|
6982
6992
|
setShowBulkRestartConfirm(false)
|
|
6983
6993
|
},
|
|
6984
6994
|
})
|
|
@@ -6997,7 +7007,7 @@ export function ResourcesView({
|
|
|
6997
7007
|
onConfirm={() => {
|
|
6998
7008
|
onBulkScale?.(checkedBulkItems, bulkScaleReplicas, {
|
|
6999
7009
|
onSuccess: () => {
|
|
7000
|
-
|
|
7010
|
+
clearBulkSelection()
|
|
7001
7011
|
setShowBulkScaleDialog(false)
|
|
7002
7012
|
},
|
|
7003
7013
|
})
|
|
@@ -8629,6 +8639,7 @@ function JobCell({ resource, column }: { resource: any; column: string }) {
|
|
|
8629
8639
|
}
|
|
8630
8640
|
|
|
8631
8641
|
function CronJobCell({ resource, column }: { resource: any; column: string }) {
|
|
8642
|
+
const { operationalIssues, operationalIssuesIncomplete } = useContext(ResourcesViewDataContext)
|
|
8632
8643
|
switch (column) {
|
|
8633
8644
|
case 'schedule': {
|
|
8634
8645
|
const { cron, readable } = getCronJobSchedule(resource)
|
|
@@ -8640,6 +8651,13 @@ function CronJobCell({ resource, column }: { resource: any; column: string }) {
|
|
|
8640
8651
|
)
|
|
8641
8652
|
}
|
|
8642
8653
|
case 'status': {
|
|
8654
|
+
const issue = operationalIssues?.[`${resource.metadata?.namespace ?? ''}/${resource.metadata?.name ?? ''}`]
|
|
8655
|
+
if (issue) {
|
|
8656
|
+
return <Tooltip content={issue.message}><Badge severity={issue.severity === 'critical' ? 'error' : 'warning'}>{issueTitle(issue)}</Badge></Tooltip>
|
|
8657
|
+
}
|
|
8658
|
+
if (operationalIssuesIncomplete) {
|
|
8659
|
+
return <Tooltip content="The issue list is incomplete. This CronJob's status could not be checked."><Badge severity="neutral">Unknown</Badge></Tooltip>
|
|
8660
|
+
}
|
|
8643
8661
|
const status = getCronJobStatus(resource)
|
|
8644
8662
|
return (
|
|
8645
8663
|
<span className={clsx('badge', status.color)}>
|