@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.
Files changed (55) hide show
  1. package/package.json +4 -4
  2. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -2
  3. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +27 -0
  4. package/src/components/gitops/insights/GitOpsInsightViews.tsx +16 -10
  5. package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
  6. package/src/components/gitops/insights/insights-helpers.ts +7 -0
  7. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +73 -9
  8. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +45 -28
  9. package/src/components/gitops/tree/tree-helpers.test.ts +6 -0
  10. package/src/components/gitops/tree/tree-helpers.ts +5 -1
  11. package/src/components/issues/IssuesView.tsx +4 -1
  12. package/src/components/issues/ResourceIssuesSection.tsx +5 -2
  13. package/src/components/issues/diagnostic.ts +2 -0
  14. package/src/components/issues/index.ts +1 -1
  15. package/src/components/issues/issues.test.ts +26 -0
  16. package/src/components/issues/severity.ts +9 -2
  17. package/src/components/issues/types.ts +32 -4
  18. package/src/components/logs/LogCore.theme.test.tsx +126 -1
  19. package/src/components/logs/LogCore.tsx +159 -40
  20. package/src/components/logs/LogsViewer.test.tsx +80 -2
  21. package/src/components/logs/LogsViewer.tsx +21 -4
  22. package/src/components/logs/StructuredLogLine.tsx +1 -1
  23. package/src/components/logs/WorkloadLogsViewer.tsx +7 -1
  24. package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
  25. package/src/components/logs/useLogBuffer.ts +48 -18
  26. package/src/components/logs/useLogSearch.ts +3 -1
  27. package/src/components/resources/ColumnPickerList.test.tsx +46 -0
  28. package/src/components/resources/ColumnPickerList.tsx +174 -0
  29. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -2
  30. package/src/components/resources/ResourcesView.tsx +93 -75
  31. package/src/components/resources/column-order.test.ts +104 -0
  32. package/src/components/resources/column-order.ts +64 -0
  33. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
  34. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
  35. package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
  36. package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
  37. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
  38. package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
  39. package/src/components/resources/renderers/PodRenderer.tsx +40 -11
  40. package/src/components/resources/resource-utils.ts +15 -2
  41. package/src/components/resources/summarize-scheduler-message.test.ts +1 -0
  42. package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
  43. package/src/components/shared/DrainPlanDialog.tsx +6 -2
  44. package/src/components/shared/ResourceActionsBar.tsx +3 -3
  45. package/src/components/shared/ResourceRendererDispatch.tsx +3 -1
  46. package/src/components/timeline/DiffViewer.test.tsx +69 -0
  47. package/src/components/timeline/DiffViewer.tsx +113 -4
  48. package/src/components/timeline/shared.test.tsx +48 -0
  49. package/src/components/timeline/shared.tsx +14 -19
  50. package/src/components/ui/Toast.tsx +15 -4
  51. package/src/components/ui/index.ts +1 -1
  52. package/src/components/workload/WorkloadView.tsx +14 -8
  53. package/src/types/core.ts +25 -1
  54. package/src/types/gitops-insights.ts +1 -0
  55. 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"]')).toBeNull()
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
- // Multi-select state for bulk operations. Checkboxes only render while
3824
- // bulk mode is active — entered via the toolbar toggle — so mutating
3825
- // actions stay out of the way during normal browsing.
3826
- const [bulkMode, setBulkMode] = useState(false)
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 exitBulkMode = useCallback(() => {
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 allColumns = useMemo(() => {
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: 'Toggle bulk select',
4578
+ description: 'Show or hide selection checkboxes',
4556
4579
  category: 'Table',
4557
4580
  scope: 'resources',
4558
4581
  handler: () => {
4559
- if (bulkMode) { exitBulkMode(); return }
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 (bulkMode) { exitBulkMode(); return }
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 getCellFilterValue(resource, 'status', kindLower)
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) : getCellFilterValue(r, col, cellFilterKind)
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 isCheckboxMode = canBulkSelect && bulkMode
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, isCheckboxMode, toggleComparePick, toggleChecked, selectResource])
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) : getCellFilterValue(r, col.key, cellFilterKind)
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
- {allColumns.map(col => {
6320
- const isCustom = customColumnKeySet.has(col.key)
6321
- return (
6322
- <div
6323
- key={col.key}
6324
- className="group flex items-center gap-2 px-3 py-1.5 hover:bg-theme-elevated"
6325
- >
6326
- <label className="flex items-center gap-2 flex-1 min-w-0 cursor-pointer">
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
- exitBulkMode()
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={bulkMode ? 'Exit bulk select mode' : 'Select multiple resources'}>
6452
+ <Tooltip content={isCheckboxMode ? 'Hide selection checkboxes' : 'Show selection checkboxes'}>
6440
6453
  <button
6441
- onClick={() => {
6442
- if (bulkMode) { exitBulkMode(); return }
6443
- exitCompareMode()
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
- bulkMode
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
- {isCheckboxMode && (
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={exitBulkMode}
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
- Cancel
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
- exitBulkMode()
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
- exitBulkMode()
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
- exitBulkMode()
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)}>