@skyhook-io/k8s-ui 1.8.12 → 1.8.14

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.8.12",
3
+ "version": "1.8.14",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -69,7 +69,7 @@
69
69
  "dependencies": {
70
70
  "@monaco-editor/react": "^4.7.0",
71
71
  "html-to-image": "^1.11.0",
72
- "react-virtuoso": "^4.18.10",
72
+ "react-virtuoso": "^4.18.11",
73
73
  "shiki": "^4.2.0"
74
74
  },
75
75
  "peerDependencies": {
@@ -86,7 +86,7 @@
86
86
  "yaml": ">=2.0.0"
87
87
  },
88
88
  "devDependencies": {
89
- "@types/node": "^26.0.1",
89
+ "@types/node": "^26.1.1",
90
90
  "@types/react": "^19.2.17",
91
91
  "@types/react-dom": "^19.2.3",
92
92
  "@xterm/addon-fit": "^0.11.0",
@@ -33,7 +33,7 @@ import { ResourceBar } from '../ui/ResourceBar'
33
33
  import type { SelectedResource, APIResource } from '../../types'
34
34
  import { isForbiddenError } from '../../types/fetch-error'
35
35
  import type { NavigateToResource } from '../../utils/navigation'
36
- import { categorizeResources, CORE_RESOURCES } from '../../utils/api-resources'
36
+ import { categorizeResources, CORE_RESOURCES, findAPIResourceForRoute } from '../../utils/api-resources'
37
37
  import {
38
38
  getPodStatus,
39
39
  getPodRestarts,
@@ -129,6 +129,7 @@ import {
129
129
  getCellFilterValue,
130
130
  parseColumnFilters,
131
131
  serializeColumnFilters,
132
+ parseColumnFilterExcludes,
132
133
  podMatchesProblemCategory,
133
134
  SEVERITY_DOT_COLOR,
134
135
  } from './resource-utils'
@@ -2036,13 +2037,9 @@ function getInitialKindFromURL(
2036
2037
  }
2037
2038
  const group = new URLSearchParams(search).get('apiGroup') || ''
2038
2039
  if (kind) {
2039
- // Find matching resource from CORE_RESOURCES or use as-is
2040
- // Only match core resources when no apiGroup is specified (avoids collisions like KNative Service)
2041
- if (!group) {
2042
- const coreMatch = CORE_RESOURCES.find(r => r.kind === kind || r.name === kind)
2043
- if (coreMatch) {
2044
- return { name: coreMatch.name, kind: coreMatch.kind, group: coreMatch.group }
2045
- }
2040
+ const coreMatch = findAPIResourceForRoute(undefined, kind, group)
2041
+ if (coreMatch) {
2042
+ return { name: coreMatch.name, kind: coreMatch.kind, group: coreMatch.group }
2046
2043
  }
2047
2044
  return { name: kind, kind: kind, group }
2048
2045
  }
@@ -2100,11 +2097,14 @@ export function deriveSidebarResourceCounts(
2100
2097
  function getInitialFiltersFromURL() {
2101
2098
  const params = new URLSearchParams(window.location.search)
2102
2099
  // Parse generic column filters
2103
- const columnFilters = parseColumnFilters(params.get('filters'))
2100
+ const filtersParam = params.get('filters')
2101
+ const columnFilters = parseColumnFilters(filtersParam)
2102
+ const columnFilterExcludes = parseColumnFilterExcludes(filtersParam)
2104
2103
  const result = {
2105
2104
  search: params.get('search') || '',
2106
2105
  regex: params.get('regex') === 'true',
2107
2106
  columnFilters,
2107
+ columnFilterExcludes,
2108
2108
  problemFilters: params.get('problems')?.split(',').filter(Boolean) || [],
2109
2109
  showInactive: params.get('showInactive') === 'true',
2110
2110
  labelSelector: params.get('labels') || '', // e.g., "app=caretta,version=v1"
@@ -2188,6 +2188,7 @@ export function ResourcesView({
2188
2188
  const [sortDirection, setSortDirection] = useState<SortDirection>(null)
2189
2189
  // Filter state
2190
2190
  const [columnFilters, setColumnFilters] = useState<Record<string, string[]>>(initialFilters.columnFilters)
2191
+ const [columnFilterExcludes, setColumnFilterExcludes] = useState<Record<string, boolean>>(initialFilters.columnFilterExcludes)
2191
2192
  const [problemFilters, setProblemFilters] = useState<string[]>(initialFilters.problemFilters)
2192
2193
  const [openColumnFilter, setOpenColumnFilter] = useState<string | null>(null)
2193
2194
  const [columnFilterSearch, setColumnFilterSearch] = useState('')
@@ -2237,6 +2238,25 @@ export function ResourcesView({
2237
2238
  delete next[key]
2238
2239
  return next
2239
2240
  })
2241
+ setColumnFilterExcludes(prev => {
2242
+ if (!prev[key]) return prev
2243
+ const next = { ...prev }
2244
+ delete next[key]
2245
+ return next
2246
+ })
2247
+ }, [])
2248
+
2249
+ const setColumnFilterMode = useCallback((key: string, exclude: boolean) => {
2250
+ setColumnFilterExcludes(prev => {
2251
+ if (Boolean(prev[key]) === exclude) return prev
2252
+ const next = { ...prev }
2253
+ if (exclude) {
2254
+ next[key] = true
2255
+ } else {
2256
+ delete next[key]
2257
+ }
2258
+ return next
2259
+ })
2240
2260
  }, [])
2241
2261
 
2242
2262
  const toggleColumnFilterValue = useCallback((key: string, value: string) => {
@@ -2248,6 +2268,14 @@ export function ResourcesView({
2248
2268
  const updated = current.filter(v => v !== value)
2249
2269
  if (updated.length === 0) {
2250
2270
  delete next[key]
2271
+ // The operator is meaningless with no selected values — drop it so a
2272
+ // stale exclude flag doesn't linger in state or re-arm on reselect.
2273
+ setColumnFilterExcludes(inv => {
2274
+ if (!inv[key]) return inv
2275
+ const nextInv = { ...inv }
2276
+ delete nextInv[key]
2277
+ return nextInv
2278
+ })
2251
2279
  } else {
2252
2280
  next[key] = updated
2253
2281
  }
@@ -2550,6 +2578,12 @@ export function ResourcesView({
2550
2578
  delete next[key]
2551
2579
  return next
2552
2580
  })
2581
+ setColumnFilterExcludes(prev => {
2582
+ if (!(key in prev)) return prev
2583
+ const next = { ...prev }
2584
+ delete next[key]
2585
+ return next
2586
+ })
2553
2587
  if (sortColumn === key) {
2554
2588
  setSortColumn(null)
2555
2589
  setSortDirection(null)
@@ -2944,6 +2978,12 @@ export function ResourcesView({
2944
2978
  setColumnFilters(newFilters.columnFilters)
2945
2979
  }
2946
2980
 
2981
+ // Update column filter operators (include/exclude) if changed
2982
+ const excludeKeys = (m: Record<string, boolean>) => Object.keys(m).filter(k => m[k]).sort().join(',')
2983
+ if (excludeKeys(newFilters.columnFilterExcludes) !== excludeKeys(columnFilterExcludes)) {
2984
+ setColumnFilterExcludes(newFilters.columnFilterExcludes)
2985
+ }
2986
+
2947
2987
  // Reset the flag after a tick to allow normal URL updates
2948
2988
  requestAnimationFrame(() => {
2949
2989
  isSyncingFromURL.current = false
@@ -2969,6 +3009,7 @@ export function ResourcesView({
2969
3009
  search: string,
2970
3010
  regex: boolean,
2971
3011
  colFilters: Record<string, string[]>,
3012
+ colExcludes: Record<string, boolean>,
2972
3013
  problems: string[],
2973
3014
  showInactive: boolean,
2974
3015
  resourceNs?: string,
@@ -2995,8 +3036,13 @@ export function ResourcesView({
2995
3036
  } else {
2996
3037
  params.delete('regex')
2997
3038
  }
2998
- // Write column filters as `filters` param; remove legacy `status` param
2999
- const filtersStr = serializeColumnFilters(colFilters)
3039
+ // Write column filters as `filters` param; the exclude operator is folded
3040
+ // into each column entry, so it can never drift from the values it negates.
3041
+ // Guard against a stale exclude flag on a column with no active values.
3042
+ const activeExcludes = Object.fromEntries(
3043
+ Object.entries(colExcludes).filter(([k, on]) => on && (colFilters[k]?.length ?? 0) > 0)
3044
+ )
3045
+ const filtersStr = serializeColumnFilters(colFilters, activeExcludes)
3000
3046
  if (filtersStr) {
3001
3047
  params.set('filters', filtersStr)
3002
3048
  } else {
@@ -3058,6 +3104,7 @@ export function ResourcesView({
3058
3104
  setSearchTerm('')
3059
3105
  setRegexMode(false)
3060
3106
  setColumnFilters({})
3107
+ setColumnFilterExcludes({})
3061
3108
  setProblemFilters([])
3062
3109
  setLabelSelector('')
3063
3110
  setOwnerKind('')
@@ -3124,8 +3171,8 @@ export function ResourcesView({
3124
3171
  shouldPushHistory.current = false
3125
3172
  prevSelectedResourceRef.current = current
3126
3173
 
3127
- updateURL(selectedKind, searchTerm, regexMode, columnFilters, problemFilters, showInactiveReplicaSets, selectedResource?.namespace, selectedResource?.name, pushHistory)
3128
- }, [selectedKind, searchTerm, regexMode, columnFilters, problemFilters, showInactiveReplicaSets, selectedResource, updateURL, basePath, locationPathname])
3174
+ updateURL(selectedKind, searchTerm, regexMode, columnFilters, columnFilterExcludes, problemFilters, showInactiveReplicaSets, selectedResource?.namespace, selectedResource?.name, pushHistory)
3175
+ }, [selectedKind, searchTerm, regexMode, columnFilters, columnFilterExcludes, problemFilters, showInactiveReplicaSets, selectedResource, updateURL, basePath, locationPathname])
3129
3176
 
3130
3177
  // Handle resource click from URL on mount
3131
3178
  useEffect(() => {
@@ -3425,6 +3472,7 @@ export function ResourcesView({
3425
3472
  setOpenColumnFilter(null)
3426
3473
  if (!isSyncingFromURL.current) {
3427
3474
  setColumnFilters({})
3475
+ setColumnFilterExcludes({})
3428
3476
  }
3429
3477
  setProblemFilters([])
3430
3478
  }, [selectedKind.name])
@@ -3598,7 +3646,8 @@ export function ResourcesView({
3598
3646
  activeColFilters.every(([col, vals]) => {
3599
3647
  const extra = extraColumnsByKey.get(col)
3600
3648
  const cellVal = extra?.getFilterValue ? extra.getFilterValue(r) : getCellFilterValue(r, col, kindLower)
3601
- return vals.includes(cellVal)
3649
+ const match = vals.includes(cellVal)
3650
+ return columnFilterExcludes[col] ? !match : match
3602
3651
  })
3603
3652
  )
3604
3653
  }
@@ -3741,7 +3790,7 @@ export function ResourcesView({
3741
3790
  }
3742
3791
 
3743
3792
  return result
3744
- }, [resources, searchTerm, regexMode, searchRegex, columnFilters, problemFilters, showInactiveReplicaSets, labelSelector, ownerKind, ownerName, selectedKind.name, sortColumn, sortDirection, getSortValue, extraColumnsByKey, podMatchesProblemFilter])
3793
+ }, [resources, searchTerm, regexMode, searchRegex, columnFilters, columnFilterExcludes, problemFilters, showInactiveReplicaSets, labelSelector, ownerKind, ownerName, selectedKind.name, sortColumn, sortDirection, getSortValue, extraColumnsByKey, podMatchesProblemFilter])
3745
3794
 
3746
3795
  // For nodes table: compute the majority minor version so outliers can be highlighted
3747
3796
  const majorityNodeMinorVersion = useMemo(() => {
@@ -4693,7 +4742,7 @@ export function ResourcesView({
4693
4742
  className="flex items-center gap-1 px-2 py-1 text-xs selection selection-text rounded-md hover:selection-strong transition-colors"
4694
4743
  >
4695
4744
  <ListFilter className="w-3 h-3" />
4696
- <span>{key}: {vals.join(', ')}</span>
4745
+ <span>{key}: {columnFilterExcludes[key] ? 'not ' : ''}{vals.join(', ')}</span>
4697
4746
  <X className="w-3 h-3" />
4698
4747
  </button>
4699
4748
  ))}
@@ -4831,7 +4880,14 @@ export function ResourcesView({
4831
4880
  )}
4832
4881
  >
4833
4882
  <ListFilter className="w-3 h-3" />
4834
- {hasActiveFilter && <span className="text-[10px] leading-none font-semibold">{activeFilterValues.length}</span>}
4883
+ {hasActiveFilter && (
4884
+ <span
4885
+ className="text-[10px] leading-none font-semibold"
4886
+ aria-label={`${activeFilterValues.length} ${columnFilterExcludes[col.key] ? 'excluded' : 'included'} value${activeFilterValues.length === 1 ? '' : 's'}`}
4887
+ >
4888
+ {columnFilterExcludes[col.key] ? `≠ ${activeFilterValues.length}` : activeFilterValues.length}
4889
+ </span>
4890
+ )}
4835
4891
  </button>
4836
4892
  {hasActiveFilter && (
4837
4893
  <button
@@ -4865,6 +4921,34 @@ export function ResourcesView({
4865
4921
  )}
4866
4922
  onClick={(e) => e.stopPropagation()}
4867
4923
  >
4924
+ <div className="flex items-center gap-1 p-1.5 border-b border-theme-border" role="group" aria-label={`${col.label} filter mode`}>
4925
+ <button
4926
+ onClick={() => setColumnFilterMode(col.key, false)}
4927
+ aria-pressed={!columnFilterExcludes[col.key]}
4928
+ className={clsx(
4929
+ 'flex-1 px-2 py-1 text-xs rounded transition-colors',
4930
+ !columnFilterExcludes[col.key]
4931
+ ? 'selection-strong selection-text'
4932
+ : 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
4933
+ )}
4934
+ title="Show rows matching the selected values"
4935
+ >
4936
+ Include
4937
+ </button>
4938
+ <button
4939
+ onClick={() => setColumnFilterMode(col.key, true)}
4940
+ aria-pressed={Boolean(columnFilterExcludes[col.key])}
4941
+ className={clsx(
4942
+ 'flex-1 px-2 py-1 text-xs rounded transition-colors',
4943
+ columnFilterExcludes[col.key]
4944
+ ? 'selection-strong selection-text'
4945
+ : 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
4946
+ )}
4947
+ title="Show rows that do NOT match the selected values"
4948
+ >
4949
+ Exclude
4950
+ </button>
4951
+ </div>
4868
4952
  {values.length > 5 ? (
4869
4953
  <div className="flex items-center gap-2 p-2 border-b border-theme-border">
4870
4954
  <div className="relative flex-1">
@@ -5922,11 +6006,17 @@ function PodCell({ resource, column }: { resource: any; column: string }) {
5922
6006
  <button
5923
6007
  onClick={(e) => {
5924
6008
  e.stopPropagation()
5925
- // Merge node filter into existing column filters via URL
6009
+ // Merge node filter into existing column filters via URL,
6010
+ // preserving any exclude operators already set on other columns.
5926
6011
  const params = new URLSearchParams(window.location.search)
5927
6012
  const existing = parseColumnFilters(params.get('filters'))
6013
+ const existingExcludes = parseColumnFilterExcludes(params.get('filters'))
5928
6014
  existing['node'] = [nodeVal]
5929
- params.set('filters', serializeColumnFilters(existing))
6015
+ // Clicking a node means "show pods on this node", so force the
6016
+ // node column back to include mode even if it was previously
6017
+ // set to exclude — otherwise the shortcut would hide those pods.
6018
+ delete existingExcludes['node']
6019
+ params.set('filters', serializeColumnFilters(existing, existingExcludes))
5930
6020
  navigate?.(`/resources/pods?${params.toString()}`)
5931
6021
  }}
5932
6022
  className="text-sm text-blue-400 hover:text-blue-300 hover:underline truncate block text-left"
@@ -1,5 +1,9 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { parseColumnFilters, serializeColumnFilters } from './resource-utils'
2
+ import {
3
+ parseColumnFilters,
4
+ serializeColumnFilters,
5
+ parseColumnFilterExcludes,
6
+ } from './resource-utils'
3
7
 
4
8
  describe('column filter serialization round-trip', () => {
5
9
  it('round-trips built-in keys', () => {
@@ -23,4 +27,78 @@ describe('column filter serialization round-trip', () => {
23
27
  it('parses legacy unencoded built-in keys', () => {
24
28
  expect(parseColumnFilters('status:Running')).toEqual({ status: ['Running'] })
25
29
  })
30
+
31
+ it('treats a two-part filter as implicit include (no excludes)', () => {
32
+ expect(parseColumnFilterExcludes('status:Running')).toEqual({})
33
+ })
34
+ })
35
+
36
+ describe('column filter include/exclude operator', () => {
37
+ it('serializes excluded columns with the explicit exclude operator', () => {
38
+ const filters = { status: ['Running', 'Completed'], namespace: ['default'] }
39
+ const excludes = { status: true }
40
+ expect(serializeColumnFilters(filters, excludes)).toBe(
41
+ 'status:exclude:Running,Completed|namespace:default'
42
+ )
43
+ })
44
+
45
+ it('round-trips values through an excluded column', () => {
46
+ const filters = { status: ['Running', 'Completed'] }
47
+ const serialized = serializeColumnFilters(filters, { status: true })
48
+ expect(parseColumnFilters(serialized)).toEqual(filters)
49
+ expect(parseColumnFilterExcludes(serialized)).toEqual({ status: true })
50
+ })
51
+
52
+ it('parses the explicit include operator as non-excluded', () => {
53
+ expect(parseColumnFilters('namespace:include:default')).toEqual({ namespace: ['default'] })
54
+ expect(parseColumnFilterExcludes('namespace:include:default')).toEqual({})
55
+ })
56
+
57
+ it('keeps a value literally named "exclude" in the two-part form', () => {
58
+ expect(parseColumnFilters('reason:exclude')).toEqual({ reason: ['exclude'] })
59
+ expect(parseColumnFilterExcludes('reason:exclude')).toEqual({})
60
+ })
61
+
62
+ it('handles a value literally named "exclude" under the exclude operator', () => {
63
+ const serialized = serializeColumnFilters({ reason: ['exclude'] }, { reason: true })
64
+ expect(serialized).toBe('reason:exclude:exclude')
65
+ expect(parseColumnFilters(serialized)).toEqual({ reason: ['exclude'] })
66
+ expect(parseColumnFilterExcludes(serialized)).toEqual({ reason: true })
67
+ })
68
+
69
+ it('does not emit an operator for an excluded column with no values', () => {
70
+ expect(serializeColumnFilters({ status: [] }, { status: true })).toBe('')
71
+ expect(parseColumnFilterExcludes('')).toEqual({})
72
+ })
73
+
74
+ it('preserves the exclude operator for custom-column keys', () => {
75
+ const filters = { 'label:tier': ['control-plane'] }
76
+ const serialized = serializeColumnFilters(filters, { 'label:tier': true })
77
+ expect(serialized).toBe('label%3Atier:exclude:control-plane')
78
+ expect(parseColumnFilters(serialized)).toEqual(filters)
79
+ expect(parseColumnFilterExcludes(serialized)).toEqual({ 'label:tier': true })
80
+ })
81
+
82
+ it('returns empty structures for empty/absent params', () => {
83
+ expect(parseColumnFilters('')).toEqual({})
84
+ expect(parseColumnFilters(null)).toEqual({})
85
+ expect(parseColumnFilterExcludes(null)).toEqual({})
86
+ expect(serializeColumnFilters({})).toBe('')
87
+ })
88
+
89
+ it('ignores prototype-polluting keys from a crafted param', () => {
90
+ const parsed = parseColumnFilters('__proto__:Running|constructor:x|status:Running')
91
+ expect(parsed).toEqual({ status: ['Running'] })
92
+ expect(Object.prototype.hasOwnProperty.call(parsed, '__proto__')).toBe(false)
93
+ expect(({} as Record<string, unknown>).polluted).toBeUndefined()
94
+ expect(parseColumnFilterExcludes('__proto__:exclude:Running')).toEqual({})
95
+ })
96
+
97
+ it('lets the last segment win the mode when a column repeats', () => {
98
+ expect(parseColumnFilters('status:exclude:Running|status:Completed')).toEqual({ status: ['Completed'] })
99
+ expect(parseColumnFilterExcludes('status:exclude:Running|status:Completed')).toEqual({})
100
+
101
+ expect(parseColumnFilters('status:Running|status:exclude:Completed')).toEqual({ status: ['Completed'] })
102
+ expect(parseColumnFilterExcludes('status:Running|status:exclude:Completed')).toEqual({ status: true })
103
+ })
26
104
  })
@@ -1827,40 +1827,109 @@ export function formatResources(resources: any): string {
1827
1827
  * Used by the generic column filter system to match resources against filter values.
1828
1828
  * Reuses existing utility functions for kind-specific columns.
1829
1829
  */
1830
- // Parse column filters from URL `filters` param (format: "col:val1,val2|col2:val3")
1831
- // Uses `|` as pair separator between columns, `,` between values within a column.
1832
- // Multi-select: each column key maps to an array of selected values.
1830
+ // Parse column filters from URL `filters` param. Each column is either
1831
+ // "col:val1,val2" (implicit include) or "col:exclude:val1,val2" (explicit
1832
+ // operator). `|` separates columns, `,` separates values within a column.
1833
+ // Keys and values are URI-encoded so their own delimiters survive; the operator,
1834
+ // when present, is the literal token "include" or "exclude".
1833
1835
  export function parseColumnFilters(filtersParam: string | null): Record<string, string[]> {
1834
- if (!filtersParam) return {}
1835
- const filters: Record<string, string[]> = {}
1836
+ // Prototype-less accumulator: the keys come from the URL, so a plain object
1837
+ // would expose Object.prototype as a write target (js/remote-property-injection).
1838
+ const filters: Record<string, string[]> = Object.create(null)
1839
+ if (!filtersParam) return filters
1836
1840
  for (const pair of filtersParam.split('|')) {
1837
- const colonIdx = pair.indexOf(':')
1838
- if (colonIdx > 0) {
1839
- const rawKey = pair.slice(0, colonIdx).trim()
1840
- const valStr = pair.slice(colonIdx + 1).trim()
1841
- // Keys are URI-encoded so a custom-column key's own colon (e.g.
1842
- // "label:tier") doesn't collide with the key:value delimiter.
1843
- let key: string
1844
- try { key = decodeURIComponent(rawKey) } catch { key = rawKey }
1845
- if (key && valStr) {
1846
- filters[key] = valStr.split(',').map(v => {
1847
- try { return decodeURIComponent(v.trim()) } catch { return v.trim() }
1848
- }).filter(Boolean)
1849
- }
1841
+ const parsed = parseColumnFilterPair(pair)
1842
+ // Guard the dynamic write: the key comes from the URL, so reject the
1843
+ // prototype-polluting names right at the assignment (js/remote-property-injection).
1844
+ if (parsed && parsed.key !== '__proto__' && parsed.key !== 'prototype' && parsed.key !== 'constructor') {
1845
+ filters[parsed.key] = parsed.values
1850
1846
  }
1851
1847
  }
1852
1848
  return filters
1853
1849
  }
1854
1850
 
1855
- // Serialize column filters to URL param format. Keys and values are both
1856
- // URI-encoded so a colon inside a custom-column key (e.g. "label:tier") or a
1857
- // comma inside a value (e.g. "Ready,SchedulingDisabled") survives the round-trip.
1858
- export function serializeColumnFilters(filters: Record<string, string[]>): string {
1859
- const result = Object.entries(filters)
1851
+ // Serialize column filters to URL param format. Columns listed in `excludes`
1852
+ // emit the explicit "exclude" operator; all others keep the backwards-compatible
1853
+ // two-part shape. Keys and values are both URI-encoded so a colon inside a
1854
+ // custom-column key (e.g. "label:tier") or a comma inside a value (e.g.
1855
+ // "Ready,SchedulingDisabled") survives the round-trip.
1856
+ export function serializeColumnFilters(
1857
+ filters: Record<string, string[]>,
1858
+ excludes?: Record<string, boolean>,
1859
+ ): string {
1860
+ return Object.entries(filters)
1860
1861
  .filter(([, v]) => v.length > 0)
1861
- .map(([k, vals]) => `${encodeURIComponent(k)}:${vals.map(v => encodeURIComponent(v)).join(',')}`)
1862
+ .map(([k, vals]) => {
1863
+ const op = excludes?.[k] ? 'exclude:' : ''
1864
+ return `${encodeURIComponent(k)}:${op}${vals.map(v => encodeURIComponent(v)).join(',')}`
1865
+ })
1862
1866
  .join('|')
1863
- return result
1867
+ }
1868
+
1869
+ // Which columns are in exclude mode (show non-matching rows). Read from the same
1870
+ // `filters` param so the operator can't drift out of sync with the values it
1871
+ // negates — a lone exclude operator with no values is structurally impossible.
1872
+ export function parseColumnFilterExcludes(filtersParam: string | null): Record<string, boolean> {
1873
+ // Prototype-less accumulator: see parseColumnFilters (js/remote-property-injection).
1874
+ const excludes: Record<string, boolean> = Object.create(null)
1875
+ if (!filtersParam) return excludes
1876
+ for (const pair of filtersParam.split('|')) {
1877
+ const parsed = parseColumnFilterPair(pair)
1878
+ // Guard the dynamic write against prototype-polluting keys from the URL
1879
+ // right at the assignment (js/remote-property-injection).
1880
+ if (
1881
+ parsed && parsed.values.length &&
1882
+ parsed.key !== '__proto__' && parsed.key !== 'prototype' && parsed.key !== 'constructor'
1883
+ ) {
1884
+ // Values are last-write-wins per key in parseColumnFilters, so the mode
1885
+ // must track the same final segment: a later include overrides an
1886
+ // earlier exclude for the same column, otherwise the two disagree.
1887
+ if (parsed.exclude) {
1888
+ excludes[parsed.key] = true
1889
+ } else {
1890
+ delete excludes[parsed.key]
1891
+ }
1892
+ }
1893
+ }
1894
+ return excludes
1895
+ }
1896
+
1897
+ // Split a single "col[:operator]:values" pair into its decoded key, values, and
1898
+ // whether the explicit exclude operator was present. The first literal colon
1899
+ // delimits key from the rest (keys are encoded, so their own colons don't
1900
+ // count); an optional leading "include"/"exclude" token in the remainder is the
1901
+ // operator. Any other leading token is treated as a value (two-part form), so a
1902
+ // value literally named "exclude" without a following colon round-trips.
1903
+ function parseColumnFilterPair(
1904
+ pair: string,
1905
+ ): { key: string; values: string[]; exclude: boolean } | null {
1906
+ const colonIdx = pair.indexOf(':')
1907
+ if (colonIdx <= 0) return null
1908
+ const rawKey = pair.slice(0, colonIdx).trim()
1909
+ let rest = pair.slice(colonIdx + 1).trim()
1910
+ let key: string
1911
+ try { key = decodeURIComponent(rawKey) } catch { key = rawKey }
1912
+ if (!key) return null
1913
+ // Guard against prototype-pollution: the key becomes an object property name
1914
+ // downstream, and it comes straight from the URL. Reject the well-known
1915
+ // dangerous names so a crafted `filters` param can't touch the prototype.
1916
+ if (key === '__proto__' || key === 'prototype' || key === 'constructor') return null
1917
+
1918
+ let exclude = false
1919
+ const opIdx = rest.indexOf(':')
1920
+ if (opIdx >= 0) {
1921
+ const op = rest.slice(0, opIdx).trim().toLowerCase()
1922
+ if (op === 'exclude' || op === 'include') {
1923
+ exclude = op === 'exclude'
1924
+ rest = rest.slice(opIdx + 1).trim()
1925
+ }
1926
+ }
1927
+ if (!rest) return null
1928
+ const values = rest.split(',').map(v => {
1929
+ try { return decodeURIComponent(v.trim()) } catch { return v.trim() }
1930
+ }).filter(Boolean)
1931
+ if (!values.length) return null
1932
+ return { key, values, exclude }
1864
1933
  }
1865
1934
 
1866
1935
  export function getCellFilterValue(resource: any, column: string, kind: string): string {
@@ -1,4 +1,4 @@
1
- import { useState, useRef } from 'react'
1
+ import { useEffect, useState, useRef } from 'react'
2
2
  import {
3
3
  RefreshCw,
4
4
  Terminal,
@@ -848,6 +848,35 @@ function ArgoActions({ resource, data, onSync, isSyncing, onRefresh, isRefreshin
848
848
  // REVISION HISTORY DIALOG
849
849
  // ============================================================================
850
850
 
851
+ function RevisionImage({ image, displayImage }: { image: string; displayImage: string }) {
852
+ const ref = useRef<HTMLSpanElement>(null)
853
+ const [isTruncated, setIsTruncated] = useState(false)
854
+
855
+ useEffect(() => {
856
+ const element = ref.current
857
+ if (!element) return
858
+
859
+ const measure = () => setIsTruncated(element.scrollWidth > element.clientWidth)
860
+ measure()
861
+
862
+ const observer = new ResizeObserver(measure)
863
+ observer.observe(element)
864
+ return () => observer.disconnect()
865
+ }, [displayImage])
866
+
867
+ return (
868
+ <Tooltip
869
+ content={image}
870
+ delay={300}
871
+ disabled={!isTruncated}
872
+ preserveWrapperWhenDisabled
873
+ wrapperClassName="w-full min-w-0"
874
+ >
875
+ <span ref={ref} className="block truncate">{displayImage}</span>
876
+ </Tooltip>
877
+ )
878
+ }
879
+
851
880
  export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, revisions, isLoading, error, onRollback, isRollingBack }: {
852
881
  kind: string
853
882
  namespace: string
@@ -908,10 +937,7 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
908
937
  open={open}
909
938
  onClose={handleClose}
910
939
  closable={!isRollingBack}
911
- className={clsx(
912
- "flex flex-col",
913
- diffRevision ? "max-w-5xl w-full max-h-[85vh]" : "max-w-lg w-full"
914
- )}
940
+ className="flex max-h-[85vh] w-[calc(100vw-2rem)] max-w-5xl flex-col"
915
941
  >
916
942
  <div className="flex items-center justify-between p-4 border-b border-theme-border shrink-0">
917
943
  <div className="flex items-center gap-2">
@@ -924,17 +950,20 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
924
950
  </span>
925
951
  )}
926
952
  </div>
927
- <button
928
- onClick={handleClose}
929
- disabled={isRollingBack}
930
- className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded disabled:opacity-50"
931
- >
932
- <X className="w-5 h-5" />
933
- </button>
953
+ <Tooltip content="Close" delay={150}>
954
+ <button
955
+ onClick={handleClose}
956
+ disabled={isRollingBack}
957
+ aria-label="Close revision history"
958
+ className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded disabled:opacity-50"
959
+ >
960
+ <X className="w-5 h-5" />
961
+ </button>
962
+ </Tooltip>
934
963
  </div>
935
964
 
936
965
  <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
937
- <div className={clsx("p-4 overflow-y-auto", diffRevision ? "max-h-48 shrink-0" : "max-h-80")}>
966
+ <div className={clsx("min-h-0 overflow-x-hidden overflow-y-auto p-4", diffRevision ? "max-h-48 shrink-0" : "max-h-[65vh]")}>
938
967
  {isLoading && (
939
968
  <div className="flex items-center justify-center py-8 text-theme-text-secondary text-sm">
940
969
  Loading revisions…
@@ -954,7 +983,13 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
954
983
  )}
955
984
 
956
985
  {revisions && revisions.length > 0 && (
957
- <table className="w-full text-sm">
986
+ <table className="w-full table-fixed text-sm">
987
+ <colgroup>
988
+ <col className="w-16" />
989
+ <col />
990
+ <col className="w-24" />
991
+ <col className="w-44" />
992
+ </colgroup>
958
993
  <thead>
959
994
  <tr className="text-theme-text-secondary text-left text-xs uppercase tracking-wider">
960
995
  <th className="pb-2 pr-3 font-medium">Rev</th>
@@ -975,17 +1010,15 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
975
1010
  <td className="py-2 pr-3 text-theme-text-primary font-mono">
976
1011
  #{rev.number}
977
1012
  </td>
978
- <td className="py-2 pr-3 text-theme-text-secondary font-mono max-w-[180px]">
979
- <Tooltip content={rev.image} delay={300} wrapperClassName="min-w-0">
980
- <span className="truncate">{getImageTag(rev.image)}</span>
981
- </Tooltip>
1013
+ <td className="min-w-0 py-2 pr-3 text-theme-text-secondary font-mono">
1014
+ <RevisionImage image={rev.image} displayImage={getImageTag(rev.image)} />
982
1015
  </td>
983
1016
  <td className="py-2 pr-3 text-theme-text-secondary whitespace-nowrap">
984
1017
  {formatTimeAgo(rev.createdAt)}
985
1018
  </td>
986
1019
  <td className="py-2 text-right">
987
1020
  <div className="flex items-center gap-1 justify-end">
988
- {!rev.isCurrent && rev.template && currentRevision?.template && (
1021
+ {!rev.isCurrent && confirmRevision !== rev.number && rev.template && currentRevision?.template && (
989
1022
  <Tooltip content="Compare with current revision" delay={150}>
990
1023
  <button
991
1024
  onClick={() => setDiffRevision(diffRevision === rev.number ? null : rev.number)}
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { Tooltip } from './Tooltip'
4
+
5
+ function render(preserveWrapperWhenDisabled = false) {
6
+ return renderToString(
7
+ <Tooltip content="Full value" disabled preserveWrapperWhenDisabled={preserveWrapperWhenDisabled}>
8
+ <span>Visible value</span>
9
+ </Tooltip>,
10
+ )
11
+ }
12
+
13
+ describe('Tooltip', () => {
14
+ it('omits its wrapper when disabled by default', () => {
15
+ expect(render()).not.toContain('inline-flex max-w-full')
16
+ })
17
+
18
+ it('can preserve its wrapper while disabled for layout-sensitive children', () => {
19
+ expect(render(true)).toContain('inline-flex max-w-full')
20
+ })
21
+ })
@@ -29,6 +29,8 @@ interface TooltipProps {
29
29
  className?: string
30
30
  /** Whether tooltip is disabled */
31
31
  disabled?: boolean
32
+ /** Keep the wrapper mounted while disabled when child layout measurement requires stable DOM ancestry. */
33
+ preserveWrapperWhenDisabled?: boolean
32
34
  /** Additional class for the wrapper span (useful for positioning) */
33
35
  wrapperClassName?: string
34
36
  /** Inline styles for the wrapper span (useful for absolute positioning) */
@@ -42,6 +44,7 @@ export function Tooltip({
42
44
  position = 'top',
43
45
  className,
44
46
  disabled = false,
47
+ preserveWrapperWhenDisabled = false,
45
48
  wrapperClassName,
46
49
  wrapperStyle,
47
50
  }: TooltipProps) {
@@ -99,6 +102,9 @@ export function Tooltip({
99
102
  clearTimeout(hideTimeoutRef.current)
100
103
  hideTimeoutRef.current = null
101
104
  }
105
+ if (activeHide === hideRef.current) {
106
+ activeHide = null
107
+ }
102
108
  setIsVisible(false)
103
109
  setCoords(null)
104
110
  }
@@ -194,16 +200,7 @@ export function Tooltip({
194
200
  // pointer-events-none and never fires mouseleave.
195
201
  useEffect(() => {
196
202
  if (disabled) {
197
- if (timeoutRef.current) {
198
- clearTimeout(timeoutRef.current)
199
- timeoutRef.current = null
200
- }
201
- if (hideTimeoutRef.current) {
202
- clearTimeout(hideTimeoutRef.current)
203
- hideTimeoutRef.current = null
204
- }
205
- setIsVisible(false)
206
- setCoords(null)
203
+ hideRef.current()
207
204
  }
208
205
  }, [disabled])
209
206
 
@@ -224,7 +221,7 @@ export function Tooltip({
224
221
  }
225
222
  }, [isVisible])
226
223
 
227
- if (disabled || !content) {
224
+ if ((disabled && !preserveWrapperWhenDisabled) || !content) {
228
225
  return <>{children}</>
229
226
  }
230
227
 
@@ -245,7 +242,7 @@ export function Tooltip({
245
242
  >
246
243
  {children}
247
244
  </span>
248
- {isVisible &&
245
+ {isVisible && !disabled &&
249
246
  createPortal(
250
247
  <span
251
248
  ref={tooltipRef}
@@ -1,5 +1,40 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { categorizeResources, formatGroupName, shortenGroupName } from './api-resources'
2
+ import { categorizeResources, findAPIResourceForRoute, formatGroupName, shortenGroupName } from './api-resources'
3
+
4
+ describe('findAPIResourceForRoute', () => {
5
+ const resources = [
6
+ { group: 'metrics.k8s.io', version: 'v1beta1', kind: 'PodMetrics', name: 'pods', namespaced: true, isCrd: false, verbs: ['get'] },
7
+ ]
8
+
9
+ it('prefers the canonical core resource when a discovered API has the same plural name', () => {
10
+ expect(findAPIResourceForRoute(resources, 'pods')?.kind).toBe('Pod')
11
+ })
12
+
13
+ it('respects an explicit API group for a colliding resource', () => {
14
+ expect(findAPIResourceForRoute(resources, 'pods', 'metrics.k8s.io')?.kind).toBe('PodMetrics')
15
+ })
16
+
17
+ it('resolves a discovered resource without a core collision', () => {
18
+ const crd = { group: 'example.io', version: 'v1', kind: 'Widget', name: 'widgets', namespaced: true, isCrd: true, verbs: ['list'] }
19
+ expect(findAPIResourceForRoute([crd], 'widgets')).toBe(crd)
20
+ })
21
+
22
+ it('resolves core resources before discovery loads', () => {
23
+ expect(findAPIResourceForRoute(undefined, 'pods')?.kind).toBe('Pod')
24
+ })
25
+
26
+ it('falls back to an exact built-in API group before discovery loads', () => {
27
+ expect(findAPIResourceForRoute(undefined, 'storageclasses', 'storage.k8s.io')).toMatchObject({
28
+ kind: 'StorageClass',
29
+ namespaced: false,
30
+ })
31
+ })
32
+
33
+ it('supports the legacy Kind route form without weakening group matching', () => {
34
+ expect(findAPIResourceForRoute(undefined, 'Pod')?.name).toBe('pods')
35
+ expect(findAPIResourceForRoute(undefined, 'Pod', 'metrics.k8s.io')).toBeUndefined()
36
+ })
37
+ })
3
38
 
4
39
  describe('formatGroupName', () => {
5
40
  it('uses friendly names for common CRD groups seen in clusters', () => {
@@ -47,9 +47,25 @@ export const CORE_RESOURCES: APIResource[] = [
47
47
  { group: 'scheduling.k8s.io', version: 'v1', kind: 'PriorityClass', name: 'priorityclasses', namespaced: false, isCrd: false, verbs: ['list', 'get', 'watch'] },
48
48
  { group: 'node.k8s.io', version: 'v1', kind: 'RuntimeClass', name: 'runtimeclasses', namespaced: false, isCrd: false, verbs: ['list', 'get', 'watch'] },
49
49
  { group: 'coordination.k8s.io', version: 'v1', kind: 'Lease', name: 'leases', namespaced: true, isCrd: false, verbs: ['list', 'get', 'watch'] },
50
+ { group: 'storage.k8s.io', version: 'v1', kind: 'StorageClass', name: 'storageclasses', namespaced: false, isCrd: false, verbs: ['list', 'get', 'watch'] },
50
51
  { group: 'storage.k8s.io', version: 'v1', kind: 'VolumeAttachment', name: 'volumeattachments', namespaced: false, isCrd: false, verbs: ['list', 'get', 'watch'] },
51
52
  ]
52
53
 
54
+ export function findAPIResourceForRoute(
55
+ resources: APIResource[] | undefined,
56
+ routeSlug: string,
57
+ group = '',
58
+ ): APIResource | undefined {
59
+ const matchesRoute = (resource: APIResource) =>
60
+ resource.name === routeSlug || resource.kind === routeSlug
61
+ if (group) {
62
+ return resources?.find(r => matchesRoute(r) && r.group === group)
63
+ ?? CORE_RESOURCES.find(r => matchesRoute(r) && r.group === group)
64
+ }
65
+ return CORE_RESOURCES.find(matchesRoute)
66
+ ?? resources?.find(matchesRoute)
67
+ }
68
+
53
69
  // Resources that should be hidden from the sidebar
54
70
  const HIDDEN_KINDS = ['PodMetrics', 'NodeMetrics']
55
71