@skyhook-io/k8s-ui 1.8.12 → 1.8.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.
- package/package.json +1 -1
- package/src/components/resources/ResourcesView.tsx +109 -19
- package/src/components/resources/column-filter-serialization.test.ts +79 -1
- package/src/components/resources/resource-utils.ts +94 -25
- package/src/components/shared/ResourceActionsBar.tsx +52 -19
- package/src/components/ui/Tooltip.test.tsx +21 -0
- package/src/components/ui/Tooltip.tsx +9 -12
- package/src/utils/api-resources.test.ts +36 -1
- package/src/utils/api-resources.ts +16 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
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
|
|
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;
|
|
2999
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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 {
|
|
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
|
|
1831
|
-
//
|
|
1832
|
-
//
|
|
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
|
-
|
|
1835
|
-
|
|
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
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
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.
|
|
1856
|
-
//
|
|
1857
|
-
//
|
|
1858
|
-
|
|
1859
|
-
|
|
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]) =>
|
|
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
|
-
|
|
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=
|
|
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
|
-
<
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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("
|
|
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
|
|
979
|
-
<
|
|
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
|
-
|
|
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
|
|