@skyhook-io/k8s-ui 1.8.11 → 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.
Files changed (87) hide show
  1. package/package.json +4 -4
  2. package/src/components/applications/ApplicationDetail.test.tsx +136 -8
  3. package/src/components/applications/ApplicationDetail.tsx +1215 -487
  4. package/src/components/gitops/GitOpsDetailLayout.tsx +13 -3
  5. package/src/components/gitops/GitOpsTableView.tsx +10 -3
  6. package/src/components/gitops/SyncOptionsDialog.test.ts +48 -0
  7. package/src/components/gitops/SyncOptionsDialog.tsx +189 -45
  8. package/src/components/gitops/insights/ArgoResourceDiff.tsx +172 -0
  9. package/src/components/gitops/insights/GitOpsInsightViews.tsx +201 -21
  10. package/src/components/gitops/insights/index.ts +1 -0
  11. package/src/components/gitops/insights/insights-helpers.test.ts +16 -0
  12. package/src/components/gitops/insights/insights-helpers.ts +5 -0
  13. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -0
  14. package/src/components/issues/IssuesView.tsx +55 -29
  15. package/src/components/issues/ResourceIssuesSection.test.tsx +33 -1
  16. package/src/components/issues/ResourceIssuesSection.tsx +21 -1
  17. package/src/components/issues/severity.ts +1 -0
  18. package/src/components/resources/ResourcesView.tsx +109 -19
  19. package/src/components/resources/column-filter-serialization.test.ts +79 -1
  20. package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
  21. package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
  22. package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
  23. package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
  24. package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
  25. package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
  26. package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
  27. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  28. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
  29. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
  30. package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
  31. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
  32. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
  33. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
  34. package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
  35. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
  36. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
  37. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
  38. package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
  39. package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
  40. package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
  41. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
  42. package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
  43. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
  44. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
  45. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
  46. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
  47. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
  48. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
  49. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
  50. package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
  51. package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
  52. package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
  53. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
  54. package/src/components/resources/resource-utils-argo.test.ts +18 -1
  55. package/src/components/resources/resource-utils-argo.ts +8 -0
  56. package/src/components/resources/resource-utils.ts +94 -25
  57. package/src/components/shared/ResourceActionsBar.tsx +54 -22
  58. package/src/components/shared/UnifiedDiff.tsx +51 -0
  59. package/src/components/shared/index.ts +1 -0
  60. package/src/components/timeline/TimelineList.tsx +7 -3
  61. package/src/components/timeline/TimelineToolbarState.test.tsx +24 -0
  62. package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
  63. package/src/components/topology/TopologyControls.tsx +3 -1
  64. package/src/components/topology/TopologyGraph.tsx +76 -90
  65. package/src/components/topology/TopologyOverlayBar.tsx +30 -0
  66. package/src/components/topology/TopologySearch.tsx +23 -15
  67. package/src/components/topology/index.ts +2 -0
  68. package/src/components/ui/Badge.tsx +1 -1
  69. package/src/components/ui/SelectMenu.tsx +81 -0
  70. package/src/components/ui/Tooltip.test.tsx +21 -0
  71. package/src/components/ui/Tooltip.tsx +9 -12
  72. package/src/components/ui/drawer-components.tsx +9 -6
  73. package/src/components/ui/index.ts +2 -0
  74. package/src/components/workload/WorkloadView.tsx +24 -3
  75. package/src/theme/tailwind-theme.css +1 -0
  76. package/src/theme/variables.css +1 -0
  77. package/src/types/gitops-insights.ts +69 -1
  78. package/src/types/gitops.ts +3 -3
  79. package/src/utils/api-resources.test.ts +36 -1
  80. package/src/utils/api-resources.ts +16 -0
  81. package/src/utils/application-history.test.ts +538 -0
  82. package/src/utils/application-history.ts +257 -0
  83. package/src/utils/applications.test.ts +34 -0
  84. package/src/utils/applications.ts +25 -0
  85. package/src/utils/index.ts +1 -0
  86. package/src/utils/resource-hierarchy.test.ts +35 -1
  87. package/src/utils/resource-hierarchy.ts +20 -0
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { renderToString } from 'react-dom/server'
3
- import { ResourceIssuesSection } from './ResourceIssuesSection'
3
+ import { ResourceIssuesSection, sameResource } from './ResourceIssuesSection'
4
+ import { IssueRow } from './IssuesView'
4
5
  import type { Issue } from './types'
5
6
 
6
7
  const issue: Issue = {
@@ -37,4 +38,35 @@ describe('ResourceIssuesSection', () => {
37
38
  expect(html).not.toContain('Context')
38
39
  expect(html).not.toContain('Blocked pods')
39
40
  })
41
+
42
+ it('shows the Subject deep-link by default', () => {
43
+ const html = renderToString(<IssueRow issue={issue} open onToggle={() => {}} onResourceClick={() => {}} />)
44
+ expect(html).toContain('Subject')
45
+ })
46
+
47
+ it('suppresses the Subject deep-link when the issue subject is the embedding resource', () => {
48
+ // apiKind (plural) must still resolve to the same identity as the wire kind.
49
+ const html = renderToString(<IssueRow issue={issue} open onToggle={() => {}} onResourceClick={() => {}} hideSubject />)
50
+ expect(html).not.toContain('Subject')
51
+ })
52
+ })
53
+
54
+ describe('sameResource', () => {
55
+ const subject = { kind: 'PersistentVolumeClaim', namespace: 'demo', name: 'data' }
56
+
57
+ it('matches across singular kind vs plural API name', () => {
58
+ expect(sameResource(subject, { kind: 'persistentvolumeclaims', namespace: 'demo', name: 'data' })).toBe(true)
59
+ })
60
+
61
+ it('treats a missing namespace as empty', () => {
62
+ expect(sameResource({ kind: 'Node', name: 'n1' }, { kind: 'nodes', namespace: '', name: 'n1' })).toBe(true)
63
+ })
64
+
65
+ it('does not match a different name', () => {
66
+ expect(sameResource(subject, { kind: 'persistentvolumeclaims', namespace: 'demo', name: 'other' })).toBe(false)
67
+ })
68
+
69
+ it('does not match a different kind', () => {
70
+ expect(sameResource(subject, { kind: 'configmaps', namespace: 'demo', name: 'data' })).toBe(false)
71
+ })
40
72
  })
@@ -1,15 +1,22 @@
1
1
  import { useMemo, useState } from 'react'
2
2
  import { AlertTriangle } from 'lucide-react'
3
+ import { kindToPlural } from '../../utils/navigation'
3
4
  import { IssueRow } from './IssuesView'
4
- import { compareIssues, type Issue, type IssueResourceRef } from './types'
5
+ import { compareIssues, subjectRef, type Issue, type IssueResourceRef } from './types'
5
6
 
6
7
  export function ResourceIssuesSection({
7
8
  issues,
8
9
  onResourceClick,
10
+ subjectResource,
9
11
  }: {
10
12
  issues: Issue[] | undefined
11
13
  /** When provided, related resources in a causal link become clickable. */
12
14
  onResourceClick?: (ref: IssueResourceRef) => void
15
+ /** The resource this section is embedded under. When an issue's subject IS
16
+ * this resource, its redundant "Subject" deep-link is suppressed — the drawer
17
+ * header already names it. Omit (e.g. the standalone fleet queue) to always
18
+ * show the subject. */
19
+ subjectResource?: IssueResourceRef
13
20
  }) {
14
21
  const sorted = useMemo(() => [...(issues ?? [])].sort(compareIssues), [issues])
15
22
  const [openId, setOpenId] = useState<string | null>(null)
@@ -32,6 +39,7 @@ export function ResourceIssuesSection({
32
39
  open={openId === key}
33
40
  onToggle={() => setOpenId((cur) => (cur === key ? null : key))}
34
41
  onResourceClick={onResourceClick}
42
+ hideSubject={subjectResource ? sameResource(subjectRef(issue), subjectResource) : false}
35
43
  />
36
44
  )
37
45
  })}
@@ -40,6 +48,18 @@ export function ResourceIssuesSection({
40
48
  )
41
49
  }
42
50
 
51
+ // Identity match tolerant of singular/plural + casing on kind (the subject ref
52
+ // carries the wire kind, the host typically passes the plural API name) — group
53
+ // is deliberately ignored: a per-resource issue feed is already scoped to one
54
+ // resource, and kind+namespace+name uniquely identifies it there.
55
+ export function sameResource(a: IssueResourceRef, b: IssueResourceRef): boolean {
56
+ return (
57
+ a.name === b.name &&
58
+ (a.namespace ?? '') === (b.namespace ?? '') &&
59
+ kindToPlural(a.kind).toLowerCase() === kindToPlural(b.kind).toLowerCase()
60
+ )
61
+ }
62
+
43
63
  function issueKey(issue: Issue): string {
44
64
  return `${issue.cluster_id ?? ''}:${issue.id}`
45
65
  }
@@ -101,6 +101,7 @@ const CATEGORY_LABEL: Record<string, string> = {
101
101
  gitops_operation_failed: 'GitOps operation failed',
102
102
  gitops_out_of_sync: 'GitOps out of sync',
103
103
  gitops_health_degraded: 'GitOps health degraded',
104
+ gitops_stale: 'GitOps comparisons stale',
104
105
  helm_release_failed: 'Helm release failed',
105
106
  webhook_backend_down: 'Webhook backend down',
106
107
  control_plane_not_ready: 'Control plane not ready',
@@ -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
  })
@@ -1,6 +1,6 @@
1
1
  import { Cpu, Network, Cloud } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
- import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from '../../ui/drawer-components'
3
+ import { Section, PropertyList, Property, ConditionsSection, AlertBanner, useOperationalIssuesShown} from '../../ui/drawer-components'
4
4
  import { getCAPIConditions } from '../resource-utils-capi'
5
5
  import { getAWSMachineStatus, getAWSMachineInstanceType, getAWSMachineInstanceState, getAWSMachineInstanceID } from '../resource-utils-aws-capi'
6
6
 
@@ -16,6 +16,7 @@ export function AWSMachineRenderer({ data }: Props) {
16
16
 
17
17
  const machineStatus = getAWSMachineStatus(data)
18
18
  const isFailed = machineStatus.level === 'unhealthy'
19
+ const operationalIssuesShown = useOperationalIssuesShown()
19
20
  const readyCond = conditions.find((c: any) => c.type === 'Ready')
20
21
 
21
22
  const instanceState = getAWSMachineInstanceState(data)
@@ -23,7 +24,7 @@ export function AWSMachineRenderer({ data }: Props) {
23
24
 
24
25
  return (
25
26
  <>
26
- {isFailed && (
27
+ {isFailed && !operationalIssuesShown && (
27
28
  <AlertBanner
28
29
  variant="error"
29
30
  title="AWS Machine Not Ready"
@@ -1,5 +1,5 @@
1
1
  import { Globe, Server } from 'lucide-react'
2
- import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from '../../ui/drawer-components'
2
+ import { Section, PropertyList, Property, ConditionsSection, AlertBanner, useOperationalIssuesShown} from '../../ui/drawer-components'
3
3
  import { getCAPIConditions } from '../resource-utils-capi'
4
4
  import { getAWSManagedClusterStatus, getAWSManagedClusterEndpoint, getAWSManagedClusterFailureDomains } from '../resource-utils-aws-capi'
5
5
 
@@ -11,13 +11,14 @@ export function AWSManagedClusterRenderer({ data }: Props) {
11
11
  const conditions = getCAPIConditions(data)
12
12
  const clusterStatus = getAWSManagedClusterStatus(data)
13
13
  const isFailed = clusterStatus.level === 'unhealthy'
14
+ const operationalIssuesShown = useOperationalIssuesShown()
14
15
  const readyCond = conditions.find((c: any) => c.type === 'Ready')
15
16
  const failureDomains = getAWSManagedClusterFailureDomains(data)
16
17
  const endpoint = getAWSManagedClusterEndpoint(data)
17
18
 
18
19
  return (
19
20
  <>
20
- {isFailed && (
21
+ {isFailed && !operationalIssuesShown && (
21
22
  <AlertBanner
22
23
  variant="error"
23
24
  title="Managed Cluster Not Ready"
@@ -1,5 +1,5 @@
1
1
  import { Globe, Network, Shield, Server, Package } from 'lucide-react'
2
- import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from '../../ui/drawer-components'
2
+ import { Section, PropertyList, Property, ConditionsSection, AlertBanner, useOperationalIssuesShown} from '../../ui/drawer-components'
3
3
  import { getCAPIConditions } from '../resource-utils-capi'
4
4
  import {
5
5
  getAWSMCPStatus, getAWSMCPEKSClusterName, getAWSMCPRegion, getAWSMCPVersion,
@@ -18,6 +18,7 @@ export function AWSManagedControlPlaneRenderer({ data }: Props) {
18
18
 
19
19
  const mcpStatus = getAWSMCPStatus(data)
20
20
  const isFailed = mcpStatus.level === 'unhealthy'
21
+ const operationalIssuesShown = useOperationalIssuesShown()
21
22
  const readyCond = conditions.find((c: any) => c.type === 'Ready')
22
23
 
23
24
  const vpc = getAWSMCPVPC(data)
@@ -29,7 +30,7 @@ export function AWSManagedControlPlaneRenderer({ data }: Props) {
29
30
 
30
31
  return (
31
32
  <>
32
- {isFailed && (
33
+ {isFailed && !operationalIssuesShown && (
33
34
  <AlertBanner
34
35
  variant="error"
35
36
  title="EKS Control Plane Not Ready"
@@ -1,6 +1,6 @@
1
1
  import { Server, Settings } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
- import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from '../../ui/drawer-components'
3
+ import { Section, PropertyList, Property, ConditionsSection, AlertBanner, useOperationalIssuesShown} from '../../ui/drawer-components'
4
4
  import { getCAPIConditions } from '../resource-utils-capi'
5
5
  import { getAWSMMPStatus, getAWSMMPInstanceType, getAWSMMPCapacityType, getAWSMMPAMIType, getAWSMMPNodegroupName, getAWSMMPScaling } from '../resource-utils-aws-capi'
6
6
  import { CAPACITY_TYPE_BADGE } from '../../../utils/badge-colors'
@@ -17,6 +17,7 @@ export function AWSManagedMachinePoolRenderer({ data }: Props) {
17
17
 
18
18
  const mmpStatus = getAWSMMPStatus(data)
19
19
  const isFailed = mmpStatus.level === 'unhealthy'
20
+ const operationalIssuesShown = useOperationalIssuesShown()
20
21
  const readyCond = conditions.find((c: any) => c.type === 'Ready')
21
22
 
22
23
  const scaling = getAWSMMPScaling(data)
@@ -26,7 +27,7 @@ export function AWSManagedMachinePoolRenderer({ data }: Props) {
26
27
 
27
28
  return (
28
29
  <>
29
- {isFailed && (
30
+ {isFailed && !operationalIssuesShown && (
30
31
  <AlertBanner
31
32
  variant="error"
32
33
  title="Managed Machine Pool Not Ready"
@@ -1,5 +1,5 @@
1
1
  import { Cloud } from 'lucide-react'
2
- import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from '../../ui/drawer-components'
2
+ import { Section, PropertyList, Property, ConditionsSection, AlertBanner, useOperationalIssuesShown} from '../../ui/drawer-components'
3
3
  import { getCAPIConditions } from '../resource-utils-capi'
4
4
  import { getAzureMachineStatus, getAzureMachineVMSize, getAzureMachineProviderID } from '../resource-utils-azure-capi'
5
5
 
@@ -13,12 +13,13 @@ export function AzureMachineRenderer({ data }: Props) {
13
13
  const conditions = getCAPIConditions(data)
14
14
  const machineStatus = getAzureMachineStatus(data)
15
15
  const isFailed = machineStatus.level === 'unhealthy'
16
+ const operationalIssuesShown = useOperationalIssuesShown()
16
17
  const readyCond = conditions.find((c: any) => c.type === 'Ready')
17
18
  const providerID = getAzureMachineProviderID(data)
18
19
 
19
20
  return (
20
21
  <>
21
- {isFailed && (
22
+ {isFailed && !operationalIssuesShown && (
22
23
  <AlertBanner variant="error" title="Azure Machine Not Ready" message={readyCond?.message || 'AzureMachine is not ready.'} />
23
24
  )}
24
25
 
@@ -1,5 +1,5 @@
1
1
  import { Globe, Shield, Settings } from 'lucide-react'
2
- import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from '../../ui/drawer-components'
2
+ import { Section, PropertyList, Property, ConditionsSection, AlertBanner, useOperationalIssuesShown} from '../../ui/drawer-components'
3
3
  import { getCAPIConditions } from '../resource-utils-capi'
4
4
  import { getAzureMCPStatus, getAzureMCPLocation, getAzureMCPVersion, getAzureMCPResourceGroup, getAzureMCPSKUTier, getAzureMCPNetworkPlugin, getAzureMCPNetworkPolicy, getAzureMCPDNSPrefix, getAzureMCPUpgradeChannel, getAzureMCPPrivateCluster } from '../resource-utils-azure-capi'
5
5
 
@@ -13,13 +13,14 @@ export function AzureManagedControlPlaneRenderer({ data }: Props) {
13
13
  const conditions = getCAPIConditions(data)
14
14
  const mcpStatus = getAzureMCPStatus(data)
15
15
  const isFailed = mcpStatus.level === 'unhealthy'
16
+ const operationalIssuesShown = useOperationalIssuesShown()
16
17
  const readyCond = conditions.find((c: any) => c.type === 'Ready')
17
18
  const apiAccess = spec.apiServerAccessProfile || {}
18
19
  const authorizedRanges = apiAccess.authorizedIPRanges || []
19
20
 
20
21
  return (
21
22
  <>
22
- {isFailed && (
23
+ {isFailed && !operationalIssuesShown && (
23
24
  <AlertBanner variant="error" title="AKS Control Plane Not Ready" message={readyCond?.message || 'AzureManagedControlPlane is not ready.'} />
24
25
  )}
25
26
 
@@ -1,6 +1,6 @@
1
1
  import { Server, Settings } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
- import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from '../../ui/drawer-components'
3
+ import { Section, PropertyList, Property, ConditionsSection, AlertBanner, useOperationalIssuesShown} from '../../ui/drawer-components'
4
4
  import { getCAPIConditions } from '../resource-utils-capi'
5
5
  import { getAzureMMPStatus, getAzureMMPSKU, getAzureMMPMode, getAzureMMPOSDiskInfo, getAzureMMPScaling, getAzureMMPScaleSetPriority, getAzureMMPOSType } from '../resource-utils-azure-capi'
6
6
  import { CAPACITY_TYPE_BADGE, NODEPOOL_MODE_BADGE } from '../../../utils/badge-colors'
@@ -16,6 +16,7 @@ export function AzureManagedMachinePoolRenderer({ data }: Props) {
16
16
  const conditions = getCAPIConditions(data)
17
17
  const mmpStatus = getAzureMMPStatus(data)
18
18
  const isFailed = mmpStatus.level === 'unhealthy'
19
+ const operationalIssuesShown = useOperationalIssuesShown()
19
20
  const readyCond = conditions.find((c: any) => c.type === 'Ready')
20
21
  const scaling = getAzureMMPScaling(data)
21
22
  const mode = getAzureMMPMode(data)
@@ -26,7 +27,7 @@ export function AzureManagedMachinePoolRenderer({ data }: Props) {
26
27
 
27
28
  return (
28
29
  <>
29
- {isFailed && (
30
+ {isFailed && !operationalIssuesShown && (
30
31
  <AlertBanner variant="error" title="AKS Node Pool Not Ready" message={readyCond?.message || 'AzureManagedMachinePool is not ready.'} />
31
32
  )}
32
33