@skyhook-io/k8s-ui 1.7.11 → 1.7.13

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 (70) hide show
  1. package/package.json +1 -1
  2. package/src/components/applications/AppChips.tsx +109 -0
  3. package/src/components/applications/AppTooltips.tsx +199 -0
  4. package/src/components/applications/ApplicationDetail.tsx +671 -0
  5. package/src/components/applications/ApplicationsList.tsx +569 -0
  6. package/src/components/applications/ReadyBar.tsx +22 -0
  7. package/src/components/applications/index.ts +8 -0
  8. package/src/components/audit/AuditFindingsTable.tsx +3 -25
  9. package/src/components/dock/TerminalTab.tsx +4 -2
  10. package/src/components/gitops/insights/GitOpsInsightViews.tsx +1 -0
  11. package/src/components/issues/IssuesView.tsx +64 -17
  12. package/src/components/issues/index.ts +1 -1
  13. package/src/components/issues/issues.test.ts +4 -4
  14. package/src/components/issues/severity.ts +5 -0
  15. package/src/components/issues/types.ts +43 -0
  16. package/src/components/logs/LogCore.tsx +13 -2
  17. package/src/components/logs/LogToolbarSelects.tsx +6 -2
  18. package/src/components/logs/LogsViewer.tsx +66 -10
  19. package/src/components/logs/WorkloadLogsViewer.tsx +68 -13
  20. package/src/components/logs/useLogStream.ts +41 -3
  21. package/src/components/resources/ResourcesView.tsx +550 -52
  22. package/src/components/resources/column-filter-serialization.test.ts +26 -0
  23. package/src/components/resources/get-default-container-name.test.ts +31 -0
  24. package/src/components/resources/renderers/DeviceClassRenderer.tsx +49 -0
  25. package/src/components/resources/renderers/NodeRenderer.tsx +7 -0
  26. package/src/components/resources/renderers/NvidiaClusterPolicyRenderer.tsx +57 -0
  27. package/src/components/resources/renderers/NvidiaDriverRenderer.tsx +47 -0
  28. package/src/components/resources/renderers/PodRenderer.tsx +2 -2
  29. package/src/components/resources/renderers/ResourceClaimRenderer.tsx +118 -0
  30. package/src/components/resources/renderers/ResourceClaimTemplateRenderer.tsx +39 -0
  31. package/src/components/resources/renderers/ResourceSliceRenderer.tsx +72 -0
  32. package/src/components/resources/renderers/WorkloadRenderer.tsx +5 -4
  33. package/src/components/resources/renderers/dra-cells.tsx +80 -0
  34. package/src/components/resources/renderers/index.ts +8 -0
  35. package/src/components/resources/renderers/nvidia-cells.tsx +43 -0
  36. package/src/components/resources/resource-utils-dra.ts +90 -0
  37. package/src/components/resources/resource-utils-nvidia.ts +63 -0
  38. package/src/components/resources/resource-utils.ts +62 -4
  39. package/src/components/shared/DetailShell.tsx +14 -7
  40. package/src/components/shared/EditableYamlView.tsx +37 -17
  41. package/src/components/shared/ResourceActionsBar.tsx +5 -4
  42. package/src/components/shared/ResourceRendererDispatch.test.tsx +103 -0
  43. package/src/components/shared/ResourceRendererDispatch.tsx +27 -2
  44. package/src/components/timeline/TimelineList.tsx +3 -32
  45. package/src/components/timeline/TimelineSwimlanes.tsx +3 -31
  46. package/src/components/topology/K8sResourceNode.tsx +26 -5
  47. package/src/components/topology/TopologyGraph.tsx +102 -3
  48. package/src/components/topology/layout.ts +36 -11
  49. package/src/components/ui/CenteredEmpty.tsx +27 -0
  50. package/src/components/ui/ConfirmDialog.tsx +1 -1
  51. package/src/components/ui/SearchBox.tsx +85 -0
  52. package/src/components/ui/drawer-components.tsx +23 -1
  53. package/src/components/ui/index.ts +1 -0
  54. package/src/components/workload/WorkloadView.tsx +167 -33
  55. package/src/components/workload/index.ts +1 -1
  56. package/src/hooks/useKeyboardShortcuts.tsx +3 -1
  57. package/src/index.ts +4 -0
  58. package/src/types/core.ts +1 -0
  59. package/src/utils/api-resources.ts +21 -0
  60. package/src/utils/applications.test.ts +207 -0
  61. package/src/utils/applications.ts +674 -0
  62. package/src/utils/custom-columns.test.ts +111 -0
  63. package/src/utils/custom-columns.ts +49 -0
  64. package/src/utils/extended-resources.test.ts +152 -0
  65. package/src/utils/extended-resources.ts +121 -0
  66. package/src/utils/format.ts +11 -0
  67. package/src/utils/index.ts +3 -0
  68. package/src/utils/topology-neighborhood.test.ts +185 -0
  69. package/src/utils/topology-neighborhood.ts +262 -0
  70. package/src/utils/workload-colors.ts +36 -0
@@ -1,4 +1,4 @@
1
- import React, { useState, useMemo, useEffect, useCallback, useRef, useContext } from 'react'
1
+ import React, { useState, useMemo, useEffect, useCallback, useRef, useContext, useId } from 'react'
2
2
  import { TableVirtuoso, type TableVirtuosoHandle } from 'react-virtuoso'
3
3
  import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
4
4
  import { PaneLoader } from '../ui/PaneLoader'
@@ -24,6 +24,7 @@ import {
24
24
  Plus,
25
25
  GitCompare,
26
26
  Regex,
27
+ ListChecks,
27
28
  } from 'lucide-react'
28
29
  import { clsx } from 'clsx'
29
30
  import { ResourceBar } from '../ui/ResourceBar'
@@ -127,6 +128,8 @@ import {
127
128
  } from './resource-utils'
128
129
  import { SEVERITY_BADGE, EVENT_TYPE_COLORS } from '../../utils/badge-colors'
129
130
  import { pluralize } from '../../utils/pluralize'
131
+ import { getPodGpuCount, getNodeGpuCount } from '../../utils/extended-resources'
132
+ import { type CustomColumnDef, type CustomColumnSource, customColumnKey, readCustomColumnValue, sanitizeCustomColumnDefs } from '../../utils/custom-columns'
130
133
  import { Tooltip } from '../ui/Tooltip'
131
134
  // CRD-specific cell components (extracted)
132
135
  import { GitRepositoryCell, OCIRepositoryCell, HelmRepositoryCell, KustomizationCell, FluxHelmReleaseCell, FluxAlertCell } from './renderers/flux-cells'
@@ -135,6 +138,8 @@ import { VulnerabilityReportCell, ConfigAuditReportCell, ExposedSecretReportCell
135
138
  import { CertificateCell, CertificateRequestCell, ClusterIssuerCell, IssuerCell, OrderCell, ChallengeCell } from './renderers/certmanager-cells'
136
139
  import { NodePoolCell, NodeClaimCell, EC2NodeClassCell } from './renderers/karpenter-cells'
137
140
  import { ScaledObjectCell, ScaledJobCell, TriggerAuthenticationCell, ClusterTriggerAuthenticationCell } from './renderers/keda-cells'
141
+ import { ResourceClaimCell, ResourceClaimTemplateCell, DeviceClassCell, ResourceSliceCell } from './renderers/dra-cells'
142
+ import { NvidiaClusterPolicyCell, NvidiaDriverCell } from './renderers/nvidia-cells'
138
143
  import { ServiceMonitorCell, PrometheusRuleCell, PodMonitorCell } from './renderers/prometheus-cells'
139
144
  import { PolicyReportCell, ClusterPolicyReportCell, KyvernoPolicyCell, ClusterPolicyCell } from './renderers/kyverno-cells'
140
145
  import { ExternalSecretCell, ClusterExternalSecretCell, SecretStoreCell, ClusterSecretStoreCell } from './renderers/eso-cells'
@@ -154,6 +159,7 @@ import { useRegisterShortcut, useRegisterShortcuts } from '../../hooks/useKeyboa
154
159
  import { ResourcesSidebar } from './ResourcesSidebar'
155
160
  import type { SelectedKindInfo } from './ResourcesSidebar'
156
161
  import { CompareTray, togglePick, pickIndex, refToParam, SIDE_TONES, type CompareTrayPick, type NamespacedRef } from '../compare'
162
+ import { ConfirmDialog } from '../ui/ConfirmDialog'
157
163
 
158
164
  // Pod problem filter options (special multi-select, not a single column value)
159
165
  const POD_PROBLEMS = ['CrashLoopBackOff', 'ImagePullBackOff', 'OOMKilled', 'Unschedulable', 'Not Ready', 'High Restarts', 'Init Failed', 'Exit Code Error', 'Failed', 'Other'] as const
@@ -222,6 +228,31 @@ export interface ExtraColumn extends Column {
222
228
  getFilterValue?: (resource: any) => string
223
229
  }
224
230
 
231
+ // Materializes a custom-column def into a self-contained ExtraColumn so it
232
+ // rides the existing render/sort/filter override rails (extraColumnsByKey).
233
+ // The pure parts (key encoding, value read, load-boundary sanitizer) live in
234
+ // utils/custom-columns so they're unit-tested; only the JSX render stays here.
235
+ function buildCustomColumn(d: CustomColumnDef): ExtraColumn {
236
+ return {
237
+ key: customColumnKey(d),
238
+ label: d.path,
239
+ width: 'w-40',
240
+ defaultVisible: true,
241
+ tooltip: d.source === 'label' ? `Label: ${d.path}` : `Annotation: ${d.path}`,
242
+ render: (resource: any) => {
243
+ const v = readCustomColumnValue(resource, d)
244
+ if (!v) return <span className="text-sm text-theme-text-secondary">-</span>
245
+ return (
246
+ <Tooltip content={v}>
247
+ <span className="text-sm text-theme-text-secondary truncate block">{v}</span>
248
+ </Tooltip>
249
+ )
250
+ },
251
+ getSortValue: (resource: any) => readCustomColumnValue(resource, d),
252
+ getFilterValue: (resource: any) => readCustomColumnValue(resource, d),
253
+ }
254
+ }
255
+
225
256
  // Tailwind width class → pixel minimum mapping for CSS Grid column sizing
226
257
  const TAILWIND_WIDTH_TO_PX: Record<string, number> = {
227
258
  'w-12': 48, 'w-14': 56, 'w-16': 64, 'w-20': 80, 'w-24': 96,
@@ -261,6 +292,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
261
292
  { key: 'cpu', label: 'CPU', width: 'w-40', tooltip: 'CPU usage / limit (marker = request)' },
262
293
  { key: 'memory', label: 'Memory', width: 'w-40', tooltip: 'Memory usage / limit (marker = request)' },
263
294
  { key: 'restarts', label: 'Restarts', width: 'w-28' },
295
+ { key: 'gpu', label: 'GPU', width: 'w-20', tooltip: 'Effective GPU request (requests, falling back to limits) summed across containers', defaultVisible: false },
264
296
  { key: 'podIP', label: 'Pod IP', width: 'w-32', defaultVisible: false },
265
297
  { key: 'node', label: 'Node', width: 'w-44', hideOnMobile: true },
266
298
  { key: 'age', label: 'Age', width: 'w-24' },
@@ -337,6 +369,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
337
369
  { key: 'cpu', label: 'CPU', width: 'w-40', tooltip: 'Current CPU usage / allocatable' },
338
370
  { key: 'memory', label: 'Memory', width: 'w-40', tooltip: 'Current memory usage / allocatable' },
339
371
  { key: 'pods', label: 'Pods', width: 'w-28', tooltip: 'Pods running / allocatable' },
372
+ { key: 'gpu', label: 'GPUs', width: 'w-20', tooltip: 'Allocatable GPUs', defaultVisible: false },
340
373
  { key: 'conditions', label: 'Conditions', width: 'w-40', hideOnMobile: true },
341
374
  { key: 'taints', label: 'Taints', width: 'w-24', hideOnMobile: true },
342
375
  { key: 'version', label: 'Version', width: 'w-28' },
@@ -548,6 +581,48 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
548
581
  { key: 'triggerTypes', label: 'Trigger Types', width: 'w-40' },
549
582
  { key: 'age', label: 'Age', width: 'w-24' },
550
583
  ],
584
+ resourceclaims: [
585
+ { key: 'name', label: 'Name' },
586
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
587
+ { key: 'status', label: 'Status', width: 'w-28', tooltip: 'Allocated + reserved, allocated-but-unreserved, or pending' },
588
+ { key: 'deviceClass', label: 'Device Class', width: 'w-44' },
589
+ { key: 'allocated', label: 'Allocated Driver', width: 'w-44' },
590
+ { key: 'reservedFor', label: 'Reserved For', width: 'w-44' },
591
+ { key: 'age', label: 'Age', width: 'w-24' },
592
+ ],
593
+ resourceclaimtemplates: [
594
+ { key: 'name', label: 'Name' },
595
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
596
+ { key: 'deviceClass', label: 'Device Class', width: 'w-44' },
597
+ { key: 'age', label: 'Age', width: 'w-24' },
598
+ ],
599
+ deviceclasses: [
600
+ { key: 'name', label: 'Name' },
601
+ { key: 'selectors', label: 'Selectors', width: 'w-28', tooltip: 'CEL device selector count' },
602
+ { key: 'age', label: 'Age', width: 'w-24' },
603
+ ],
604
+ resourceslices: [
605
+ { key: 'name', label: 'Name' },
606
+ { key: 'driver', label: 'Driver', width: 'w-44' },
607
+ { key: 'pool', label: 'Pool', width: 'w-36' },
608
+ { key: 'node', label: 'Node', width: 'w-44' },
609
+ { key: 'devices', label: 'Devices', width: 'w-24' },
610
+ { key: 'age', label: 'Age', width: 'w-24' },
611
+ ],
612
+ nvidiaclusterpolicies: [
613
+ { key: 'name', label: 'Name' },
614
+ { key: 'status', label: 'Status', width: 'w-28' },
615
+ { key: 'components', label: 'Components', width: 'w-64', tooltip: 'Enabled GPU Operator components' },
616
+ { key: 'mig', label: 'MIG', width: 'w-24' },
617
+ { key: 'age', label: 'Age', width: 'w-24' },
618
+ ],
619
+ nvidiadrivers: [
620
+ { key: 'name', label: 'Name' },
621
+ { key: 'status', label: 'Status', width: 'w-28' },
622
+ { key: 'driverType', label: 'Type', width: 'w-28' },
623
+ { key: 'version', label: 'Version', width: 'w-32' },
624
+ { key: 'age', label: 'Age', width: 'w-24' },
625
+ ],
551
626
  scaledjobs: [
552
627
  { key: 'name', label: 'Name' },
553
628
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
@@ -1589,6 +1664,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1589
1664
  // Map (plural, group) → KNOWN_COLUMNS key for kinds that collide with core K8s
1590
1665
  const GROUP_QUALIFIED_COLUMN_KEYS: Record<string, Record<string, string>> = {
1591
1666
  clusters: { 'postgresql.cnpg.io': 'cnpgclusters', 'cluster.x-k8s.io': 'capiclusters' },
1667
+ clusterpolicies: { 'nvidia.com': 'nvidiaclusterpolicies' },
1592
1668
  services: { 'serving.knative.dev': 'knativeservices' },
1593
1669
  configurations: { 'serving.knative.dev': 'knativeconfigurations' },
1594
1670
  revisions: { 'serving.knative.dev': 'knativerevisions' },
@@ -1649,12 +1725,36 @@ function getDefaultVisibleColumns(columns: Column[]): Set<string> {
1649
1725
  return new Set(columns.filter(c => c.defaultVisible !== false).map(c => c.key))
1650
1726
  }
1651
1727
 
1728
+ // Host-injected leading columns minus any whose key collides with a built-in.
1729
+ // Rendering two columns under one key duplicates React keys and corrupts the
1730
+ // visibleColumns / columnWidths maps, so a colliding extra is dropped (dev-warn).
1731
+ // Shared by the allColumns memo and the settings-load effect so the collision
1732
+ // rule can't drift between them.
1733
+ function filterHostExtras(
1734
+ extraLeadingColumns: ExtraColumn[] | undefined,
1735
+ builtinKeys: Set<string>,
1736
+ kindName: string,
1737
+ ): ExtraColumn[] {
1738
+ if (!extraLeadingColumns?.length) return []
1739
+ return extraLeadingColumns.filter(c => {
1740
+ if (builtinKeys.has(c.key)) {
1741
+ if (import.meta.env?.DEV) {
1742
+ // eslint-disable-next-line no-console
1743
+ console.warn(`[ResourcesView] extraLeadingColumns key "${c.key}" collides with a built-in column for kind "${kindName}" — extra ignored`)
1744
+ }
1745
+ return false
1746
+ }
1747
+ return true
1748
+ })
1749
+ }
1750
+
1652
1751
  // localStorage helpers for column settings
1653
1752
  const COLUMN_SETTINGS_PREFIX = 'radar-columns-'
1654
1753
 
1655
1754
  interface ColumnSettings {
1656
1755
  visible: string[]
1657
1756
  widths: Record<string, number>
1757
+ custom?: CustomColumnDef[]
1658
1758
  }
1659
1759
 
1660
1760
  function loadColumnSettings(kind: string, group?: string): ColumnSettings | null {
@@ -1805,6 +1905,9 @@ interface ResourcesViewProps {
1805
1905
  * the switcher lives outside this component and may persist server-side.
1806
1906
  */
1807
1907
  onClearNamespaces?: () => void
1908
+ // Bulk operations
1909
+ onBulkDelete?: (items: Array<{ kind: string; group?: string; namespace: string; name: string }>, options?: { force?: boolean; onSuccess?: () => void }) => void
1910
+ isBulkDeleting?: boolean
1808
1911
  }
1809
1912
 
1810
1913
  // Default selected kind
@@ -1953,6 +2056,8 @@ export function ResourcesView({
1953
2056
  onCompareSubmit,
1954
2057
  resolveRowCluster,
1955
2058
  onClearNamespaces,
2059
+ onBulkDelete,
2060
+ isBulkDeleting = false,
1956
2061
  }: ResourcesViewProps) {
1957
2062
  const initialFilters = getInitialFiltersFromURL()
1958
2063
  const [selectedKind, setSelectedKind] = useState<SelectedKindInfo>(() => getInitialKindFromURL(basePath, defaultKind, locationPathname, locationSearch))
@@ -1970,6 +2075,8 @@ export function ResourcesView({
1970
2075
  // Notify parent of selected kind changes (including initial mount)
1971
2076
  useEffect(() => {
1972
2077
  onSelectedKindChange?.(selectedKind)
2078
+ setBulkMode(false)
2079
+ setCheckedResources(new Set())
1973
2080
  }, [selectedKind.name, selectedKind.group]) // eslint-disable-line react-hooks/exhaustive-deps
1974
2081
  const [searchTerm, setSearchTerm] = useState(initialFilters.search)
1975
2082
  const [regexMode, setRegexMode] = useState(false)
@@ -1993,12 +2100,30 @@ export function ResourcesView({
1993
2100
  const [visibleColumns, setVisibleColumns] = useState<Set<string>>(new Set())
1994
2101
  const [columnWidths, setColumnWidths] = useState<Record<string, number>>({})
1995
2102
  const [showColumnPicker, setShowColumnPicker] = useState(false)
2103
+ const [customColumns, setCustomColumns] = useState<CustomColumnDef[]>([])
2104
+ const [customColumnDraft, setCustomColumnDraft] = useState<CustomColumnDef>({ source: 'label', path: '' })
2105
+ // Per-instance so two ResourcesView tables on one page (e.g. fleet compare)
2106
+ // don't share a datalist id and cross-suggest each other's keys.
2107
+ const customColKeysListId = useId()
1996
2108
  const columnPickerRef = useRef<HTMLDivElement>(null)
1997
2109
  // Label/owner filtering for deep-linking from workload details
1998
2110
  const [labelSelector, setLabelSelector] = useState<string>(initialFilters.labelSelector)
1999
2111
  const [ownerKind, setOwnerKind] = useState<string>(initialFilters.ownerKind)
2000
2112
  const [ownerName, setOwnerName] = useState<string>(initialFilters.ownerName)
2001
2113
 
2114
+ // Multi-select state for bulk operations. Checkboxes only render while
2115
+ // bulk mode is active — entered via the toolbar toggle — so the risky
2116
+ // bulk-delete surface stays out of the way during normal browsing.
2117
+ const [bulkMode, setBulkMode] = useState(false)
2118
+ const [checkedResources, setCheckedResources] = useState<Set<string>>(new Set())
2119
+ const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
2120
+ const [bulkForceDelete, setBulkForceDelete] = useState(false)
2121
+
2122
+ const exitBulkMode = useCallback(() => {
2123
+ setBulkMode(false)
2124
+ setCheckedResources(new Set())
2125
+ }, [])
2126
+
2002
2127
  // Column filter helpers
2003
2128
  const clearColumnFilter = useCallback((key: string) => {
2004
2129
  setColumnFilters(prev => {
@@ -2064,6 +2189,12 @@ export function ResourcesView({
2064
2189
  return { pods, nodes }
2065
2190
  }, [topPodMetrics, topNodeMetrics])
2066
2191
 
2192
+ // User-defined label/annotation columns materialized as self-contained
2193
+ // ExtraColumns — appended after the built-ins and wired through the same
2194
+ // render/sort/filter override rails (extraColumnsByKey).
2195
+ const builtCustomColumns = useMemo(() => customColumns.map(buildCustomColumn), [customColumns])
2196
+ const customColumnKeySet = useMemo(() => new Set(builtCustomColumns.map(c => c.key)), [builtCustomColumns])
2197
+
2067
2198
  // Prepend extraLeadingColumns (host-injected leading columns) before
2068
2199
  // the kind-specific KNOWN_COLUMNS entries. When extras are undefined,
2069
2200
  // this collapses to single-cluster behavior. Built-in keys win on
@@ -2072,48 +2203,74 @@ export function ResourcesView({
2072
2203
  // duplicate React keys and corrupt visibleColumns / columnWidths state.
2073
2204
  const allColumns = useMemo(() => {
2074
2205
  const kindColumns = getColumnsForKind(selectedKind.name, selectedKind.group)
2075
- if (!extraLeadingColumns?.length) return kindColumns
2206
+ if (!extraLeadingColumns?.length && !builtCustomColumns.length) return kindColumns
2076
2207
  const builtinKeys = new Set(kindColumns.map(c => c.key))
2077
- const filteredExtras = extraLeadingColumns.filter(c => {
2078
- if (builtinKeys.has(c.key)) {
2079
- if (import.meta.env?.DEV) {
2080
- // eslint-disable-next-line no-console
2081
- console.warn(`[ResourcesView] extraLeadingColumns key "${c.key}" collides with a built-in column for kind "${selectedKind.name}" — extra ignored`)
2082
- }
2083
- return false
2084
- }
2085
- return true
2086
- })
2087
- return [...filteredExtras, ...kindColumns]
2088
- }, [selectedKind.name, selectedKind.group, extraLeadingColumns])
2208
+ const filteredExtras = filterHostExtras(extraLeadingColumns, builtinKeys, selectedKind.name)
2209
+ return [...filteredExtras, ...kindColumns, ...builtCustomColumns]
2210
+ }, [selectedKind.name, selectedKind.group, extraLeadingColumns, builtCustomColumns])
2089
2211
 
2090
2212
  // Map of extra column keys for fast O(1) lookup on each render path
2091
2213
  // (cell render, sort, column-filter unique-values).
2092
2214
  const extraColumnsByKey = useMemo(() => {
2093
2215
  const m = new Map<string, ExtraColumn>()
2094
2216
  extraLeadingColumns?.forEach(c => m.set(c.key, c))
2217
+ builtCustomColumns.forEach(c => m.set(c.key, c))
2095
2218
  return m
2096
- }, [extraLeadingColumns])
2219
+ }, [extraLeadingColumns, builtCustomColumns])
2097
2220
 
2221
+ // Guards the save effect from persisting on the initial load of each kind
2222
+ // (set false by the load effect, flipped true on its first skipped save).
2223
+ const isColumnSettingsLoaded = useRef(false)
2224
+ // Whether the user has a persisted column blob for this kind — data-driven
2225
+ // column defaults (GPU auto-show) must never override explicit user choices.
2226
+ const hadSavedColumnSettings = useRef(false)
2227
+ // Kinds where GPU auto-show already fired this mount. Firing is one-shot:
2228
+ // without this, hiding the auto-shown column re-triggers the effect (it
2229
+ // depends on visibleColumns) and instantly reverts the user's hide.
2230
+ const gpuAutoShownKinds = useRef<Set<string>>(new Set())
2098
2231
  useEffect(() => {
2232
+ // Re-arm the skip-initial-save guard per kind. This effect repopulates
2233
+ // visible/widths/custom for the new kind via async setState, so the save
2234
+ // effect (also keyed to selectedKind) that runs in this same commit still
2235
+ // sees the PREVIOUS kind's columns in state and would persist them under
2236
+ // the new kind's storage key. Skipping the first save after each load
2237
+ // prevents that cross-kind write.
2238
+ isColumnSettingsLoaded.current = false
2099
2239
  const saved = loadColumnSettings(selectedKind.name, selectedKind.group)
2240
+ hadSavedColumnSettings.current = !!saved
2241
+ // Reconstruct the effective column list locally rather than reading
2242
+ // `allColumns` — keying this effect to the kind (not allColumns) keeps
2243
+ // runtime custom-column add/remove from re-running it and clobbering the
2244
+ // in-session edit (and avoids a setCustomColumns → allColumns → re-run loop).
2245
+ // sanitize guards the localStorage boundary: a corrupted/hand-edited blob
2246
+ // (non-array, blank path, bad source) must not crash the later .map or add dead columns.
2247
+ const savedCustom = sanitizeCustomColumnDefs(saved?.custom)
2248
+ setCustomColumns(savedCustom)
2249
+ const kindColumns = getColumnsForKind(selectedKind.name, selectedKind.group)
2250
+ const builtinKeys = new Set(kindColumns.map(c => c.key))
2251
+ const extras = filterHostExtras(extraLeadingColumns, builtinKeys, selectedKind.name)
2252
+ const effective = [...extras, ...kindColumns, ...savedCustom.map(buildCustomColumn)]
2100
2253
  // Host-injected extra columns (e.g. fleet Cluster) default to visible
2101
2254
  // even when the saved column-visibility blob predates them — a naive
2102
2255
  // Set(saved.visible) would silently hide host columns the user has
2103
2256
  // never been shown. Trade-off: a user can't permanently hide a host
2104
2257
  // extra column via the column picker — next mount re-adds it. Track
2105
2258
  // a sibling "hidden-extras" set if this becomes a real complaint.
2106
- const extraKeys = extraLeadingColumns?.map(c => c.key) ?? []
2259
+ const extraKeys = extras.map(c => c.key)
2107
2260
  if (saved) {
2108
2261
  // If saved columns are just the defaults but this kind has specialized columns,
2109
- // discard the stale save and use the specialized columns instead
2262
+ // discard the stale save and use the specialized columns instead. User-added
2263
+ // custom columns mean the blob isn't a stale pure-defaults save — keep it, or
2264
+ // the migration would clear them from storage (and un-hide hidden ones).
2110
2265
  const defaultKeys = DEFAULT_COLUMNS.map(c => c.key)
2111
- const isStaleDefaults = allColumns !== DEFAULT_COLUMNS &&
2266
+ const isStaleDefaults = kindColumns !== DEFAULT_COLUMNS &&
2267
+ !savedCustom.length &&
2112
2268
  saved.visible.length === defaultKeys.length &&
2113
2269
  saved.visible.every(v => defaultKeys.includes(v))
2114
2270
  if (isStaleDefaults) {
2115
2271
  clearColumnSettings(selectedKind.name, selectedKind.group)
2116
- setVisibleColumns(getDefaultVisibleColumns(allColumns))
2272
+ hadSavedColumnSettings.current = false
2273
+ setVisibleColumns(getDefaultVisibleColumns(effective))
2117
2274
  setColumnWidths({})
2118
2275
  } else {
2119
2276
  const merged = new Set(saved.visible)
@@ -2122,13 +2279,12 @@ export function ResourcesView({
2122
2279
  setColumnWidths(saved.widths || {})
2123
2280
  }
2124
2281
  } else {
2125
- setVisibleColumns(getDefaultVisibleColumns(allColumns))
2282
+ setVisibleColumns(getDefaultVisibleColumns(effective))
2126
2283
  setColumnWidths({})
2127
2284
  }
2128
- }, [selectedKind.name, selectedKind.group, allColumns, extraLeadingColumns])
2285
+ }, [selectedKind.name, selectedKind.group, extraLeadingColumns])
2129
2286
 
2130
- // Save column settings when they change (skip initial load)
2131
- const isColumnSettingsLoaded = useRef(false)
2287
+ // Save column settings when they change (skip the initial load of each kind)
2132
2288
  useEffect(() => {
2133
2289
  if (visibleColumns.size === 0) return // not loaded yet
2134
2290
  if (!isColumnSettingsLoaded.current) {
@@ -2138,8 +2294,12 @@ export function ResourcesView({
2138
2294
  saveColumnSettings(selectedKind.name, selectedKind.group, {
2139
2295
  visible: Array.from(visibleColumns),
2140
2296
  widths: columnWidths,
2297
+ custom: customColumns,
2141
2298
  })
2142
- }, [visibleColumns, columnWidths, selectedKind.name, selectedKind.group])
2299
+ // A persisted blob blocks GPU auto-show from here on — same rule in-session
2300
+ // as across sessions, so a later data refresh can't override user choices.
2301
+ hadSavedColumnSettings.current = true
2302
+ }, [visibleColumns, columnWidths, customColumns, selectedKind.name, selectedKind.group])
2143
2303
 
2144
2304
  // Close column picker on outside click or Escape
2145
2305
  useEffect(() => {
@@ -2239,13 +2399,56 @@ export function ResourcesView({
2239
2399
  })
2240
2400
  }, [])
2241
2401
 
2242
- // Reset column settings to defaults
2402
+ // Reset column settings to defaults (also drops user-added custom columns)
2243
2403
  const resetColumnSettings = useCallback(() => {
2244
2404
  clearColumnSettings(selectedKind.name, selectedKind.group)
2245
- setVisibleColumns(getDefaultVisibleColumns(allColumns))
2405
+ setCustomColumns([])
2406
+ const customKeys = new Set(builtCustomColumns.map(c => c.key))
2407
+ setVisibleColumns(getDefaultVisibleColumns(allColumns.filter(c => !customKeys.has(c.key))))
2246
2408
  setColumnWidths({})
2247
2409
  isColumnSettingsLoaded.current = false
2248
- }, [selectedKind.name, selectedKind.group, allColumns])
2410
+ // Back to data-driven defaults: re-arm GPU auto-show for this kind.
2411
+ hadSavedColumnSettings.current = false
2412
+ gpuAutoShownKinds.current.delete(selectedKind.name.toLowerCase())
2413
+ }, [selectedKind.name, selectedKind.group, allColumns, builtCustomColumns])
2414
+
2415
+ // Add a user-defined label/annotation column (dedupe by key) and show it.
2416
+ const addCustomColumn = useCallback((def: CustomColumnDef) => {
2417
+ const path = def.path.trim()
2418
+ if (!path) return
2419
+ const key = customColumnKey({ ...def, path })
2420
+ setCustomColumns(prev => prev.some(c => customColumnKey(c) === key) ? prev : [...prev, { ...def, path }])
2421
+ setVisibleColumns(prev => {
2422
+ if (prev.has(key)) return prev
2423
+ const next = new Set(prev)
2424
+ next.add(key)
2425
+ return next
2426
+ })
2427
+ }, [])
2428
+
2429
+ // Remove a user-defined column entirely (not just hide it).
2430
+ const removeCustomColumn = useCallback((key: string) => {
2431
+ setCustomColumns(prev => prev.filter(c => customColumnKey(c) !== key))
2432
+ setVisibleColumns(prev => {
2433
+ if (!prev.has(key)) return prev
2434
+ const next = new Set(prev)
2435
+ next.delete(key)
2436
+ return next
2437
+ })
2438
+ // Drop any filter/sort that targeted the removed column — otherwise the
2439
+ // table keeps filtering (and syncing to the URL) on a key with no column
2440
+ // and no UI to clear it, and sort silently references a gone column.
2441
+ setColumnFilters(prev => {
2442
+ if (!(key in prev)) return prev
2443
+ const next = { ...prev }
2444
+ delete next[key]
2445
+ return next
2446
+ })
2447
+ if (sortColumn === key) {
2448
+ setSortColumn(null)
2449
+ setSortDirection(null)
2450
+ }
2451
+ }, [sortColumn])
2249
2452
 
2250
2453
  // Keyboard shortcut: / to focus search
2251
2454
  useRegisterShortcut({
@@ -2510,6 +2713,7 @@ export function ResourcesView({
2510
2713
  if (showProblemsDropdown) { setShowProblemsDropdown(false); return }
2511
2714
  if (showLabelsDropdown) { setShowLabelsDropdown(false); return }
2512
2715
  if (compareMode) { exitCompareMode(); return }
2716
+ if (bulkMode) { exitBulkMode(); return }
2513
2717
  if (highlightedIndex >= 0) setHighlightedIndex(-1)
2514
2718
  else searchInputRef.current?.blur()
2515
2719
  },
@@ -2934,6 +3138,46 @@ export function ResourcesView({
2934
3138
 
2935
3139
  const selectedQuery = selectedKindQueryProp ?? resourceQueries[selectedQueryIndex]
2936
3140
  const resources = selectedQuery?.data
3141
+
3142
+ // Auto-surface the GPU column the first time GPU-bearing rows load for a kind
3143
+ // the user has never customized — a static default can't know whether the
3144
+ // cluster has GPUs, and hiding the signal on GPU clusters buries the point.
3145
+ // The resulting save persists it; explicit user hides are never overridden
3146
+ // (hadSavedColumnSettings guard).
3147
+ useEffect(() => {
3148
+ if (hadSavedColumnSettings.current) return
3149
+ const kindLower = selectedKind.name.toLowerCase()
3150
+ if (kindLower !== 'pods' && kindLower !== 'nodes') return
3151
+ if (gpuAutoShownKinds.current.has(kindLower)) return
3152
+ if (visibleColumns.size === 0 || visibleColumns.has('gpu')) return
3153
+ const rows = (resources as any[] | undefined) ?? []
3154
+ if (rows.length === 0) return
3155
+ const hasGpu = kindLower === 'pods'
3156
+ ? rows.some(r => getPodGpuCount(r) > 0)
3157
+ : rows.some(r => getNodeGpuCount(r) > 0)
3158
+ if (hasGpu) {
3159
+ gpuAutoShownKinds.current.add(kindLower)
3160
+ setVisibleColumns(prev => new Set([...prev, 'gpu']))
3161
+ }
3162
+ }, [resources, selectedKind.name, visibleColumns])
3163
+
3164
+ // Label/annotation keys present on the loaded rows — powers the
3165
+ // add-custom-column autocomplete so users don't type keys blind. Sampled
3166
+ // to bound cost on large lists; keys converge fast across rows of one kind.
3167
+ const customColumnKeySuggestions = useMemo(() => {
3168
+ const labels = new Set<string>()
3169
+ const annotations = new Set<string>()
3170
+ const rows = (resources as any[] | undefined) ?? []
3171
+ for (let i = 0; i < rows.length && i < 500; i++) {
3172
+ const meta = rows[i]?.metadata
3173
+ if (meta?.labels) for (const k of Object.keys(meta.labels)) labels.add(k)
3174
+ if (meta?.annotations) for (const k of Object.keys(meta.annotations)) annotations.add(k)
3175
+ }
3176
+ return {
3177
+ label: Array.from(labels).sort(),
3178
+ annotation: Array.from(annotations).sort(),
3179
+ }
3180
+ }, [resources])
2937
3181
  const isLoading = selectedQuery?.isLoading ?? true
2938
3182
  const selectedQueryError = selectedQuery?.error
2939
3183
  const isSelectedForbidden = isForbiddenError(selectedQueryError)
@@ -3111,6 +3355,11 @@ export function ResourcesView({
3111
3355
  }
3112
3356
  return 0
3113
3357
  }
3358
+ case 'gpu': {
3359
+ if (kindLower === 'pods') return getPodGpuCount(resource)
3360
+ if (kindLower === 'nodes') return getNodeGpuCount(resource)
3361
+ return 0
3362
+ }
3114
3363
  default:
3115
3364
  return ''
3116
3365
  }
@@ -3315,7 +3564,7 @@ export function ResourcesView({
3315
3564
  }
3316
3565
 
3317
3566
  return result
3318
- }, [resources, searchTerm, regexMode, searchRegex, columnFilters, problemFilters, showInactiveReplicaSets, labelSelector, ownerKind, ownerName, selectedKind.name, sortColumn, sortDirection, getSortValue, podMatchesProblemFilter])
3567
+ }, [resources, searchTerm, regexMode, searchRegex, columnFilters, problemFilters, showInactiveReplicaSets, labelSelector, ownerKind, ownerName, selectedKind.name, sortColumn, sortDirection, getSortValue, extraColumnsByKey, podMatchesProblemFilter])
3319
3568
 
3320
3569
  // For nodes table: compute the majority minor version so outliers can be highlighted
3321
3570
  const majorityNodeMinorVersion = useMemo(() => {
@@ -3378,6 +3627,39 @@ export function ResourcesView({
3378
3627
  flatKindListRef.current = list
3379
3628
  }, [pinned, categories])
3380
3629
 
3630
+ // Multi-select helpers
3631
+ const getResourceKey = useCallback((resource: any) => {
3632
+ return resource.metadata?.uid || `${resource.metadata?.namespace || ''}/${resource.metadata?.name || ''}`
3633
+ }, [])
3634
+
3635
+ const toggleChecked = useCallback((resource: any) => {
3636
+ const key = getResourceKey(resource)
3637
+ setCheckedResources(prev => {
3638
+ const next = new Set(prev)
3639
+ if (next.has(key)) next.delete(key)
3640
+ else next.add(key)
3641
+ return next
3642
+ })
3643
+ }, [getResourceKey])
3644
+
3645
+ // Selection state is keyed by resource, but everything user-facing
3646
+ // (count, select-all, the delete payload) derives from checkedItems —
3647
+ // the checked ∩ currently-visible intersection. Rows checked and then
3648
+ // hidden by a filter are excluded, so the bar never promises more
3649
+ // deletions than the confirm dialog will perform.
3650
+ const checkedItems = useMemo(() => {
3651
+ if (checkedResources.size === 0) return []
3652
+ return filteredResources.filter(r => checkedResources.has(getResourceKey(r)))
3653
+ }, [filteredResources, checkedResources, getResourceKey])
3654
+
3655
+ const allVisibleChecked = filteredResources.length > 0 && checkedItems.length === filteredResources.length
3656
+
3657
+ const toggleCheckAll = useCallback(() => {
3658
+ setCheckedResources(allVisibleChecked ? new Set() : new Set(filteredResources.map(getResourceKey)))
3659
+ }, [allVisibleChecked, filteredResources, getResourceKey])
3660
+
3661
+ const isCheckboxMode = onBulkDelete != null && bulkMode
3662
+
3381
3663
  // Filter columns by visibility
3382
3664
  const columns = useMemo(() => {
3383
3665
  if (visibleColumns.size === 0) return allColumns.filter(c => c.defaultVisible !== false)
@@ -3415,6 +3697,7 @@ export function ResourcesView({
3415
3697
  — typically blowing this narrow column out to ~200px.
3416
3698
  */}
3417
3699
  {compareMode && <col style={{ width: COMPARE_COLUMN_WIDTH }} />}
3700
+ {isCheckboxMode && <col style={{ width: '40px' }} />}
3418
3701
  {columns.map(col => (
3419
3702
  <col
3420
3703
  key={col.key}
@@ -3432,7 +3715,7 @@ export function ResourcesView({
3432
3715
  )
3433
3716
  }),
3434
3717
  TableRow: VirtuosoTableRow,
3435
- }), [columns, columnWidths, hasResizedColumns, compareMode, tableMinWidth])
3718
+ }), [columns, columnWidths, hasResizedColumns, compareMode, tableMinWidth, isCheckboxMode])
3436
3719
 
3437
3720
  // Calculate filter options with counts based on current resources (before filtering)
3438
3721
  const filterOptions = useMemo(() => {
@@ -3899,8 +4182,8 @@ export function ResourcesView({
3899
4182
  <Columns3 className="w-4 h-4" />
3900
4183
  </button>
3901
4184
  {showColumnPicker && (
3902
- <div className="absolute right-0 top-full mt-1 z-50 bg-theme-surface border border-theme-border rounded-lg shadow-lg py-1 min-w-[200px] max-h-[400px] overflow-auto">
3903
- <div className="px-3 py-2 border-b border-theme-border flex items-center justify-between">
4185
+ <div className="absolute right-0 top-full mt-1 z-50 bg-theme-surface border border-theme-border rounded-lg shadow-lg py-1 min-w-[200px] max-h-[400px] flex flex-col">
4186
+ <div className="shrink-0 px-3 py-2 border-b border-theme-border flex items-center justify-between">
3904
4187
  <span className="text-xs font-medium text-theme-text-secondary uppercase">Columns</span>
3905
4188
  <button
3906
4189
  onClick={resetColumnSettings}
@@ -3911,26 +4194,95 @@ export function ResourcesView({
3911
4194
  Reset
3912
4195
  </button>
3913
4196
  </div>
3914
- {allColumns.map(col => (
3915
- <label
3916
- key={col.key}
3917
- className="flex items-center gap-2 px-3 py-1.5 hover:bg-theme-elevated"
4197
+ {/* Column list scrolls; the add-column footer stays pinned so
4198
+ its input isn't pushed below the fold on kinds with many columns. */}
4199
+ <div className="overflow-y-auto flex-1 min-h-0">
4200
+ {allColumns.map(col => {
4201
+ const isCustom = customColumnKeySet.has(col.key)
4202
+ return (
4203
+ <div
4204
+ key={col.key}
4205
+ className="group flex items-center gap-2 px-3 py-1.5 hover:bg-theme-elevated"
4206
+ >
4207
+ <label className="flex items-center gap-2 flex-1 min-w-0 cursor-pointer">
4208
+ <input
4209
+ type="checkbox"
4210
+ checked={visibleColumns.has(col.key)}
4211
+ onChange={() => toggleColumnVisibility(col.key)}
4212
+ disabled={col.key === 'name'}
4213
+ className="rounded border-theme-border"
4214
+ />
4215
+ <span className={clsx(
4216
+ 'text-sm truncate',
4217
+ col.key === 'name' ? 'text-theme-text-tertiary' : 'text-theme-text-primary'
4218
+ )} title={col.tooltip || col.label}>
4219
+ {col.label}
4220
+ </span>
4221
+ </label>
4222
+ {isCustom && (
4223
+ <button
4224
+ type="button"
4225
+ onClick={() => removeCustomColumn(col.key)}
4226
+ className="shrink-0 p-0.5 text-theme-text-tertiary hover:text-red-500 opacity-0 group-hover:opacity-100 focus:opacity-100"
4227
+ title="Remove column"
4228
+ >
4229
+ <X className="w-3.5 h-3.5" />
4230
+ </button>
4231
+ )}
4232
+ </div>
4233
+ )
4234
+ })}
4235
+ </div>
4236
+ {/* Add a label/annotation column */}
4237
+ <div className="shrink-0 border-t border-theme-border pt-2 px-3 pb-1">
4238
+ <div className="flex items-center gap-1 mb-1.5">
4239
+ {(['label', 'annotation'] as CustomColumnSource[]).map(src => (
4240
+ <button
4241
+ key={src}
4242
+ type="button"
4243
+ onClick={() => setCustomColumnDraft(d => ({ ...d, source: src }))}
4244
+ className={clsx(
4245
+ 'px-2 py-0.5 text-xs rounded capitalize',
4246
+ customColumnDraft.source === src
4247
+ ? 'bg-skyhook-500/20 text-skyhook-400'
4248
+ : 'text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated'
4249
+ )}
4250
+ >
4251
+ {src}
4252
+ </button>
4253
+ ))}
4254
+ </div>
4255
+ <form
4256
+ className="flex items-center gap-1"
4257
+ onSubmit={e => {
4258
+ e.preventDefault()
4259
+ addCustomColumn(customColumnDraft)
4260
+ setCustomColumnDraft(d => ({ ...d, path: '' }))
4261
+ }}
3918
4262
  >
3919
4263
  <input
3920
- type="checkbox"
3921
- checked={visibleColumns.has(col.key)}
3922
- onChange={() => toggleColumnVisibility(col.key)}
3923
- disabled={col.key === 'name'}
3924
- className="rounded border-theme-border"
4264
+ type="text"
4265
+ list={customColKeysListId}
4266
+ value={customColumnDraft.path}
4267
+ onChange={e => setCustomColumnDraft(d => ({ ...d, path: e.target.value }))}
4268
+ placeholder={`Add ${customColumnDraft.source} key…`}
4269
+ className="flex-1 min-w-0 px-2 py-1 text-xs rounded bg-theme-base border border-theme-border text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-500"
3925
4270
  />
3926
- <span className={clsx(
3927
- 'text-sm',
3928
- col.key === 'name' ? 'text-theme-text-tertiary' : 'text-theme-text-primary'
3929
- )}>
3930
- {col.label}
3931
- </span>
3932
- </label>
3933
- ))}
4271
+ <datalist id={customColKeysListId}>
4272
+ {customColumnKeySuggestions[customColumnDraft.source].map(k => (
4273
+ <option key={k} value={k} />
4274
+ ))}
4275
+ </datalist>
4276
+ <button
4277
+ type="submit"
4278
+ disabled={!customColumnDraft.path.trim()}
4279
+ className="shrink-0 p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded disabled:opacity-40 disabled:hover:bg-transparent"
4280
+ title="Add column"
4281
+ >
4282
+ <Plus className="w-4 h-4" />
4283
+ </button>
4284
+ </form>
4285
+ </div>
3934
4286
  </div>
3935
4287
  )}
3936
4288
  </div>
@@ -3961,7 +4313,11 @@ export function ResourcesView({
3961
4313
  {compareEnabled && (
3962
4314
  <Tooltip content={compareMode ? 'Exit compare mode' : 'Compare two resources side-by-side'}>
3963
4315
  <button
3964
- onClick={() => (compareMode ? exitCompareMode() : setCompareMode(true))}
4316
+ onClick={() => {
4317
+ if (compareMode) { exitCompareMode(); return }
4318
+ exitBulkMode()
4319
+ setCompareMode(true)
4320
+ }}
3965
4321
  aria-pressed={compareMode}
3966
4322
  className={clsx(
3967
4323
  'p-2 rounded-lg transition-colors',
@@ -3974,8 +4330,51 @@ export function ResourcesView({
3974
4330
  </button>
3975
4331
  </Tooltip>
3976
4332
  )}
4333
+ {onBulkDelete && (
4334
+ <Tooltip content={bulkMode ? 'Exit bulk select mode' : 'Select multiple resources'}>
4335
+ <button
4336
+ onClick={() => {
4337
+ if (bulkMode) { exitBulkMode(); return }
4338
+ exitCompareMode()
4339
+ setBulkMode(true)
4340
+ }}
4341
+ aria-pressed={bulkMode}
4342
+ className={clsx(
4343
+ 'p-2 rounded-lg transition-colors',
4344
+ bulkMode
4345
+ ? 'bg-skyhook-500/15 text-skyhook-300 border border-skyhook-400/50'
4346
+ : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated',
4347
+ )}
4348
+ >
4349
+ <ListChecks className="w-4 h-4" />
4350
+ </button>
4351
+ </Tooltip>
4352
+ )}
3977
4353
  </div>
3978
4354
 
4355
+ {/* Bulk actions bar */}
4356
+ {isCheckboxMode && (
4357
+ <div className="flex items-center gap-3 px-4 py-2 bg-skyhook-500/10 border-b border-skyhook-400/20 shrink-0">
4358
+ <span className="text-sm font-medium text-theme-text-primary">
4359
+ {checkedItems.length} selected
4360
+ </span>
4361
+ <button
4362
+ onClick={() => setShowBulkDeleteConfirm(true)}
4363
+ disabled={checkedItems.length === 0}
4364
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-red-600 hover:bg-red-700 disabled:opacity-50 disabled:pointer-events-none text-white rounded-lg transition-colors"
4365
+ >
4366
+ <Trash2 className="w-3.5 h-3.5" />
4367
+ Delete
4368
+ </button>
4369
+ <button
4370
+ onClick={exitBulkMode}
4371
+ 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"
4372
+ >
4373
+ Cancel
4374
+ </button>
4375
+ </div>
4376
+ )}
4377
+
3979
4378
  {/* Table */}
3980
4379
  <div
3981
4380
  className="flex-1 overflow-auto relative"
@@ -4081,8 +4480,22 @@ export function ResourcesView({
4081
4480
  <GitCompare className="w-3.5 h-3.5 inline-block opacity-70" />
4082
4481
  </th>
4083
4482
  )}
4483
+ {isCheckboxMode && (
4484
+ <th className="bg-theme-surface border-b border-theme-border w-10 text-center px-0 py-3">
4485
+ <input
4486
+ type="checkbox"
4487
+ checked={allVisibleChecked}
4488
+ ref={(el) => { if (el) el.indeterminate = checkedItems.length > 0 && checkedItems.length < filteredResources.length }}
4489
+ onChange={toggleCheckAll}
4490
+ className="w-3.5 h-3.5 rounded border-theme-border accent-skyhook-500 cursor-pointer"
4491
+ title={checkedItems.length > 0 ? 'Deselect all' : 'Select all'}
4492
+ />
4493
+ </th>
4494
+ )}
4084
4495
  {columns.map((col, colIdx) => {
4496
+ // Built-in sortable keys, plus any extra/custom column that carries its own getSortValue.
4085
4497
  const isSortable = ['name', 'namespace', 'age', 'status', 'ready', 'restarts', 'type', 'version', 'desired', 'available', 'upToDate', 'lastSeen', 'count', 'reason', 'object', 'cpu', 'memory', 'containers'].includes(col.key)
4498
+ || !!extraColumnsByKey.get(col.key)?.getSortValue
4086
4499
  const isSorted = sortColumn === col.key
4087
4500
  const isLastCol = colIdx === columns.length - 1
4088
4501
  const filterCol = filterableColumnMap.get(col.key)
@@ -4259,6 +4672,7 @@ export function ResourcesView({
4259
4672
  clusterId: resolveRowCluster?.(resource)?.id,
4260
4673
  })
4261
4674
  : -1
4675
+ const resourceKey = getResourceKey(resource)
4262
4676
  return (
4263
4677
  <ResourceRowCells
4264
4678
  resource={resource}
@@ -4269,12 +4683,15 @@ export function ResourcesView({
4269
4683
  hasSpacerColumn={hasResizedColumns}
4270
4684
  isSelected={isSelected}
4271
4685
  isHighlighted={isHighlighted}
4686
+ isChecked={checkedResources.has(resourceKey)}
4687
+ showCheckbox={isCheckboxMode}
4272
4688
  majorityNodeMinorVersion={majorityNodeMinorVersion}
4273
- onClick={() => compareMode ? toggleComparePick(resource) : selectResource(resource, isSelected)}
4689
+ onClick={() => compareMode ? toggleComparePick(resource) : isCheckboxMode ? toggleChecked(resource) : selectResource(resource, isSelected)}
4274
4690
  onMouseEnter={() => setHighlightedIndex(-1)}
4275
4691
  compareMode={compareMode}
4276
4692
  comparePickIndex={pickIdx}
4277
4693
  rowHref={rowHrefFor?.(resource)}
4694
+ onCheckToggle={() => toggleChecked(resource)}
4278
4695
  />
4279
4696
  )
4280
4697
  }}
@@ -4301,6 +4718,45 @@ export function ResourcesView({
4301
4718
  )}
4302
4719
  </div>
4303
4720
  </div>
4721
+
4722
+ {/* Bulk delete confirmation */}
4723
+ <ConfirmDialog
4724
+ open={showBulkDeleteConfirm}
4725
+ onClose={() => { setShowBulkDeleteConfirm(false); setBulkForceDelete(false) }}
4726
+ onConfirm={() => {
4727
+ const items = checkedItems.map(r => ({
4728
+ kind: selectedKind.name,
4729
+ group: selectedKind.group,
4730
+ namespace: r.metadata?.namespace || '',
4731
+ name: r.metadata?.name || '',
4732
+ }))
4733
+ onBulkDelete?.(items, {
4734
+ force: bulkForceDelete,
4735
+ onSuccess: () => {
4736
+ exitBulkMode()
4737
+ setShowBulkDeleteConfirm(false)
4738
+ setBulkForceDelete(false)
4739
+ },
4740
+ })
4741
+ }}
4742
+ title={`Delete ${checkedItems.length} ${selectedKind.kind}${checkedItems.length > 1 ? 's' : ''}?`}
4743
+ message={`You are about to delete ${checkedItems.length} resource${checkedItems.length > 1 ? 's' : ''}. This action cannot be undone.`}
4744
+ details={checkedItems.map(r => `${r.metadata?.namespace ? r.metadata.namespace + '/' : ''}${r.metadata?.name}`).join('\n')}
4745
+ confirmLabel={bulkForceDelete ? `Force Delete ${checkedItems.length} resource${checkedItems.length > 1 ? 's' : ''}` : `Delete ${checkedItems.length} resource${checkedItems.length > 1 ? 's' : ''}`}
4746
+ variant="danger"
4747
+ isLoading={isBulkDeleting}
4748
+ isClosable
4749
+ >
4750
+ <label className="flex items-center gap-2 text-sm text-theme-text-secondary">
4751
+ <input
4752
+ type="checkbox"
4753
+ checked={bulkForceDelete}
4754
+ onChange={(e) => setBulkForceDelete(e.target.checked)}
4755
+ className="w-4 h-4 rounded border-theme-border bg-theme-base text-red-600 focus:ring-red-500 focus:ring-offset-0"
4756
+ />
4757
+ <span>Force delete (strips finalizers and bypasses grace period)</span>
4758
+ </label>
4759
+ </ConfirmDialog>
4304
4760
  </ResourcesViewDataContext.Provider>
4305
4761
  )
4306
4762
  }
@@ -4314,6 +4770,8 @@ interface ResourceRowCellsProps {
4314
4770
  hasSpacerColumn: boolean
4315
4771
  isSelected?: boolean
4316
4772
  isHighlighted?: boolean
4773
+ isChecked?: boolean
4774
+ showCheckbox?: boolean
4317
4775
  majorityNodeMinorVersion?: string
4318
4776
  onClick?: () => void
4319
4777
  onMouseEnter?: () => void
@@ -4324,6 +4782,7 @@ interface ResourceRowCellsProps {
4324
4782
  * data cells drop their click handlers. The compare-mode chip column
4325
4783
  * is unaffected (still toggles picks). */
4326
4784
  rowHref?: string
4785
+ onCheckToggle?: () => void
4327
4786
  }
4328
4787
 
4329
4788
  function rowHighlightClass(
@@ -4331,6 +4790,7 @@ function rowHighlightClass(
4331
4790
  comparePickIndex: number,
4332
4791
  isSelected: boolean | undefined,
4333
4792
  isHighlighted: boolean | undefined,
4793
+ isChecked?: boolean,
4334
4794
  ): string {
4335
4795
  if (compareMode) {
4336
4796
  if (comparePickIndex === 0) return `${SIDE_TONES.a.rowBg} ${SIDE_TONES.a.rowBgHover}`
@@ -4339,12 +4799,13 @@ function rowHighlightClass(
4339
4799
  return 'group-hover/row:bg-theme-surface/50'
4340
4800
  }
4341
4801
  if (isSelected) return 'selection-strong group-hover/row:bg-skyhook-500/30'
4802
+ if (isChecked) return 'selection group-hover/row:bg-skyhook-500/20'
4342
4803
  if (isHighlighted) return 'selection selection-ring'
4343
4804
  return 'group-hover/row:bg-theme-surface/50'
4344
4805
  }
4345
4806
 
4346
- function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, hasSpacerColumn, isSelected, isHighlighted, majorityNodeMinorVersion, onClick, onMouseEnter, compareMode, comparePickIndex = -1, rowHref }: ResourceRowCellsProps) {
4347
- const rowHighlight = rowHighlightClass(compareMode, comparePickIndex, isSelected, isHighlighted)
4807
+ function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, hasSpacerColumn, isSelected, isHighlighted, isChecked, showCheckbox, majorityNodeMinorVersion, onClick, onMouseEnter, compareMode, comparePickIndex = -1, rowHref, onCheckToggle }: ResourceRowCellsProps) {
4808
+ const rowHighlight = rowHighlightClass(compareMode, comparePickIndex, isSelected, isHighlighted, isChecked)
4348
4809
  const pickedSide = comparePickIndex === 0 ? 'a' : comparePickIndex === 1 ? 'b' : null
4349
4810
  // When the host supplies an anchor, drop per-cell onClick for the data
4350
4811
  // columns: the anchor is the only navigation surface. The compare-mode
@@ -4377,6 +4838,19 @@ function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, h
4377
4838
  )}
4378
4839
  </td>
4379
4840
  )}
4841
+ {showCheckbox && (
4842
+ <td
4843
+ className={clsx('border-b-subtle text-center px-0 py-3 w-10 cursor-pointer transition-colors', rowHighlight)}
4844
+ onClick={(e) => { e.stopPropagation(); onCheckToggle?.() }}
4845
+ >
4846
+ <input
4847
+ type="checkbox"
4848
+ checked={!!isChecked}
4849
+ onChange={() => {}}
4850
+ className="w-3.5 h-3.5 rounded border-theme-border accent-skyhook-500 cursor-pointer"
4851
+ />
4852
+ </td>
4853
+ )}
4380
4854
  {columns.map((col) => (
4381
4855
  <td
4382
4856
  key={col.key}
@@ -4636,6 +5110,20 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
4636
5110
  return <TriggerAuthenticationCell resource={resource} column={column} />
4637
5111
  case 'clustertriggerauthentications':
4638
5112
  return <ClusterTriggerAuthenticationCell resource={resource} column={column} />
5113
+ // DRA (resource.k8s.io)
5114
+ case 'resourceclaims':
5115
+ return <ResourceClaimCell resource={resource} column={column} />
5116
+ case 'resourceclaimtemplates':
5117
+ return <ResourceClaimTemplateCell resource={resource} column={column} />
5118
+ case 'deviceclasses':
5119
+ return <DeviceClassCell resource={resource} column={column} />
5120
+ case 'resourceslices':
5121
+ return <ResourceSliceCell resource={resource} column={column} />
5122
+ // NVIDIA GPU Operator (nvidiaclusterpolicies is the group-qualified key for nvidia.com clusterpolicies)
5123
+ case 'nvidiaclusterpolicies':
5124
+ return <NvidiaClusterPolicyCell resource={resource} column={column} />
5125
+ case 'nvidiadrivers':
5126
+ return <NvidiaDriverCell resource={resource} column={column} />
4639
5127
  // ArgoCD GitOps resources
4640
5128
  case 'applications':
4641
5129
  return <ArgoApplicationCell resource={resource} column={column} />
@@ -5117,6 +5605,11 @@ function PodCell({ resource, column }: { resource: any; column: string }) {
5117
5605
  const tip = buildResourceTooltip('Memory', m.memory, m.memoryRequest, m.memoryLimit, formatMemoryShort)
5118
5606
  return <ResourceBar used={formatMemoryShort(m.memory)} total={formatMemoryShort(denom)} percent={pct} colorScheme={getBulletBarScheme(pct, marker)} markerPercent={marker} tooltip={tip} />
5119
5607
  }
5608
+ case 'gpu': {
5609
+ const count = getPodGpuCount(resource)
5610
+ if (!count) return <span className="text-sm text-theme-text-tertiary">-</span>
5611
+ return <span className="text-sm text-theme-text-secondary font-mono">{count}</span>
5612
+ }
5120
5613
  default:
5121
5614
  return <span className="text-sm text-theme-text-tertiary">-</span>
5122
5615
  }
@@ -5759,6 +6252,11 @@ function NodeCell({ resource, column, majorityNodeMinorVersion }: { resource: an
5759
6252
  const pct = (podCount / max) * 100
5760
6253
  return <ResourceBar used={String(podCount)} total={String(max)} percent={pct} colorScheme="count" />
5761
6254
  }
6255
+ case 'gpu': {
6256
+ const count = getNodeGpuCount(resource)
6257
+ if (!count) return <span className="text-sm text-theme-text-tertiary">-</span>
6258
+ return <span className="text-sm text-theme-text-secondary font-mono">{count}</span>
6259
+ }
5762
6260
  default:
5763
6261
  return <span className="text-sm text-theme-text-tertiary">-</span>
5764
6262
  }