@skyhook-io/k8s-ui 1.8.10 → 1.8.12

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 (168) hide show
  1. package/package.json +7 -6
  2. package/src/assets/gitops/argocd.png +0 -0
  3. package/src/assets/gitops/flux.svg +7 -0
  4. package/src/components/applications/AppChips.tsx +10 -0
  5. package/src/components/applications/ApplicationDetail.test.tsx +697 -0
  6. package/src/components/applications/ApplicationDetail.tsx +3269 -474
  7. package/src/components/applications/ApplicationsView.tsx +62 -10
  8. package/src/components/applications/applications-view.test.tsx +47 -0
  9. package/src/components/applications/index.ts +1 -1
  10. package/src/components/audit/AuditAlerts.tsx +14 -10
  11. package/src/components/checks/ChecksView.tsx +2 -3
  12. package/src/components/checks/severity.ts +14 -10
  13. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  14. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  15. package/src/components/gitops/GitOpsDetailLayout.tsx +13 -3
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +13 -6
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.test.ts +48 -0
  21. package/src/components/gitops/SyncOptionsDialog.tsx +190 -46
  22. package/src/components/gitops/insights/ArgoResourceDiff.tsx +172 -0
  23. package/src/components/gitops/insights/GitOpsInsightViews.tsx +474 -121
  24. package/src/components/gitops/insights/index.ts +1 -0
  25. package/src/components/gitops/insights/insights-helpers.test.ts +102 -1
  26. package/src/components/gitops/insights/insights-helpers.ts +74 -1
  27. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +10 -2
  28. package/src/components/issues/IssuesView.tsx +91 -52
  29. package/src/components/issues/ResourceIssuesSection.test.tsx +39 -6
  30. package/src/components/issues/ResourceIssuesSection.tsx +46 -144
  31. package/src/components/issues/index.ts +3 -0
  32. package/src/components/issues/issue-timing.ts +53 -0
  33. package/src/components/issues/issues.test.ts +102 -1
  34. package/src/components/issues/severity.ts +3 -0
  35. package/src/components/logs/LogCore.tsx +14 -4
  36. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  37. package/src/components/logs/useLogStream.ts +8 -1
  38. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  39. package/src/components/resources/ResourcesSidebar.test.tsx +71 -0
  40. package/src/components/resources/ResourcesSidebar.tsx +46 -46
  41. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  42. package/src/components/resources/ResourcesView.tsx +233 -51
  43. package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
  44. package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
  45. package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
  46. package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
  47. package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
  48. package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
  49. package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
  50. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  51. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
  52. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
  53. package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
  54. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
  55. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
  56. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
  57. package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
  58. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
  59. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  60. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
  61. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  62. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
  63. package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
  64. package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
  65. package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
  66. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
  67. package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
  68. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
  69. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  70. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
  71. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
  72. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
  73. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
  74. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
  75. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
  76. package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
  77. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  78. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  79. package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
  80. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  81. package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
  82. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  83. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
  84. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  85. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  86. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  87. package/src/components/resources/renderers/index.ts +1 -0
  88. package/src/components/resources/resource-utils-argo.test.ts +18 -1
  89. package/src/components/resources/resource-utils-argo.ts +8 -0
  90. package/src/components/resources/resource-utils-capi.ts +1 -1
  91. package/src/components/resources/resource-utils.ts +31 -0
  92. package/src/components/shared/DetailShell.tsx +2 -2
  93. package/src/components/shared/ResourceActionsBar.tsx +142 -111
  94. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  95. package/src/components/shared/UnifiedDiff.tsx +51 -0
  96. package/src/components/shared/index.ts +1 -0
  97. package/src/components/timeline/TimelineList.tsx +375 -354
  98. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  99. package/src/components/timeline/TimelineStrip.tsx +836 -0
  100. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  101. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  102. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  103. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  104. package/src/components/timeline/TimelineToolbarState.test.tsx +88 -0
  105. package/src/components/timeline/index.ts +23 -0
  106. package/src/components/timeline/scrubber-math.test.ts +235 -0
  107. package/src/components/timeline/scrubber-math.ts +249 -0
  108. package/src/components/timeline/shared.test.tsx +219 -0
  109. package/src/components/timeline/shared.tsx +232 -447
  110. package/src/components/timeline/timeline-filters.test.ts +196 -0
  111. package/src/components/timeline/timeline-filters.ts +180 -0
  112. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  113. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  114. package/src/components/timeline/timeline-live.test.ts +131 -0
  115. package/src/components/timeline/timeline-live.ts +77 -0
  116. package/src/components/topology/GroupNode.tsx +9 -2
  117. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  118. package/src/components/topology/K8sResourceNode.tsx +387 -243
  119. package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
  120. package/src/components/topology/TopologyControls.tsx +3 -1
  121. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  122. package/src/components/topology/TopologyGraph.tsx +149 -90
  123. package/src/components/topology/TopologyOverlayBar.tsx +30 -0
  124. package/src/components/topology/TopologySearch.tsx +25 -17
  125. package/src/components/topology/index.ts +2 -0
  126. package/src/components/topology/layout.ts +10 -2
  127. package/src/components/topology/topology.css +0 -92
  128. package/src/components/ui/Badge.tsx +6 -1
  129. package/src/components/ui/CodeViewer.tsx +2 -2
  130. package/src/components/ui/Collapse.tsx +59 -0
  131. package/src/components/ui/ConfirmDialog.tsx +7 -3
  132. package/src/components/ui/DialogPortal.tsx +31 -9
  133. package/src/components/ui/Input.tsx +24 -0
  134. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  135. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  136. package/src/components/ui/PaneLoader.tsx +10 -2
  137. package/src/components/ui/ResourceBar.tsx +68 -25
  138. package/src/components/ui/SearchBox.tsx +18 -4
  139. package/src/components/ui/SearchPillInput.tsx +2 -2
  140. package/src/components/ui/SelectMenu.tsx +81 -0
  141. package/src/components/ui/drawer-components.tsx +48 -32
  142. package/src/components/ui/index.ts +6 -0
  143. package/src/components/ui/severity-tone.ts +11 -4
  144. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  145. package/src/components/workload/WorkloadView.tsx +2706 -475
  146. package/src/components/workload/index.ts +1 -1
  147. package/src/theme/tailwind-theme.css +1 -0
  148. package/src/theme/variables.css +2 -1
  149. package/src/types/core.ts +57 -4
  150. package/src/types/gitops-insights.ts +69 -1
  151. package/src/types/gitops.ts +3 -3
  152. package/src/utils/application-history.test.ts +538 -0
  153. package/src/utils/application-history.ts +257 -0
  154. package/src/utils/application-topology.test.ts +198 -0
  155. package/src/utils/application-topology.ts +261 -0
  156. package/src/utils/applications.test.ts +201 -1
  157. package/src/utils/applications.ts +1182 -393
  158. package/src/utils/index.ts +3 -0
  159. package/src/utils/navigation.test.ts +32 -1
  160. package/src/utils/navigation.ts +70 -0
  161. package/src/utils/resource-hierarchy.test.ts +1024 -1
  162. package/src/utils/resource-hierarchy.ts +925 -267
  163. package/src/utils/resource-icons.ts +2 -0
  164. package/src/utils/topology-neighborhood.test.ts +202 -1
  165. package/src/utils/topology-neighborhood.ts +194 -13
  166. package/src/utils/workflow-execution.test.ts +556 -0
  167. package/src/utils/workflow-execution.ts +814 -0
  168. package/src/utils/workload-colors.ts +2 -3
@@ -4,3 +4,4 @@ export {
4
4
  GitOpsIssuesBand,
5
5
  GitOpsStatusStrip,
6
6
  } from './GitOpsInsightViews'
7
+ export { ArgoResourceDiff } from './ArgoResourceDiff'
@@ -1,6 +1,23 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { SEVERITY_DOT } from '../../../utils/badge-colors'
3
- import { compactSource, entryTone, gitopsToSeverity, messageToPhase, phaseToTone } from './insights-helpers'
3
+ import type { GitOpsChange } from '../../../types'
4
+ import {
5
+ changeMatchesFacets,
6
+ changeMatchesSearch,
7
+ compactSource,
8
+ entryTone,
9
+ gitopsToSeverity,
10
+ healthStatusRank,
11
+ isArgoResourceSyncEligible,
12
+ messageToPhase,
13
+ phaseToTone,
14
+ resourceStatusCounts,
15
+ syncStatusRank,
16
+ } from './insights-helpers'
17
+
18
+ function change(partial: Partial<GitOpsChange> & { ref: GitOpsChange['ref'] }): GitOpsChange {
19
+ return { category: 'Unknown', hasDesired: true, hasLive: true, partial: false, ...partial }
20
+ }
4
21
 
5
22
  describe('gitopsToSeverity', () => {
6
23
  it.each([
@@ -96,3 +113,87 @@ describe('compactSource', () => {
96
113
  expect(compactSource('')).toBe('')
97
114
  })
98
115
  })
116
+
117
+ describe('changeMatchesSearch', () => {
118
+ const c = change({ ref: { kind: 'Deployment', name: 'guestbook-ui', namespace: 'demo', group: 'apps' } })
119
+ it('matches on kind, name, namespace, and group (case-insensitive)', () => {
120
+ expect(changeMatchesSearch(c, 'deploy')).toBe(true)
121
+ expect(changeMatchesSearch(c, 'GUESTBOOK')).toBe(true)
122
+ expect(changeMatchesSearch(c, 'demo')).toBe(true)
123
+ expect(changeMatchesSearch(c, 'apps')).toBe(true)
124
+ })
125
+ it('empty/whitespace query matches everything', () => {
126
+ expect(changeMatchesSearch(c, '')).toBe(true)
127
+ expect(changeMatchesSearch(c, ' ')).toBe(true)
128
+ })
129
+ it('non-matching query is filtered out', () => {
130
+ expect(changeMatchesSearch(c, 'nomatch')).toBe(false)
131
+ })
132
+ })
133
+
134
+ describe('changeMatchesFacets', () => {
135
+ const outOfSync = change({ ref: { kind: 'Service', name: 'a' }, sync: 'OutOfSync', health: 'Healthy' })
136
+ const degraded = change({ ref: { kind: 'Deployment', name: 'b' }, sync: 'Synced', health: 'Degraded' })
137
+ const missing = change({ ref: { kind: 'Secret', name: 'c' }, sync: 'OutOfSync', health: 'Missing' })
138
+ const healthy = change({ ref: { kind: 'ConfigMap', name: 'd' }, sync: 'Synced', health: 'Healthy' })
139
+
140
+ it('empty facet set matches everything', () => {
141
+ const none = new Set<never>()
142
+ expect(changeMatchesFacets(outOfSync, none)).toBe(true)
143
+ expect(changeMatchesFacets(healthy, none)).toBe(true)
144
+ })
145
+ it('outOfSync keys off sync status', () => {
146
+ const f = new Set(['outOfSync'] as const)
147
+ expect(changeMatchesFacets(outOfSync, f)).toBe(true)
148
+ expect(changeMatchesFacets(degraded, f)).toBe(false)
149
+ })
150
+ it('degraded/missing key off health status', () => {
151
+ expect(changeMatchesFacets(degraded, new Set(['degraded'] as const))).toBe(true)
152
+ expect(changeMatchesFacets(missing, new Set(['missing'] as const))).toBe(true)
153
+ expect(changeMatchesFacets(healthy, new Set(['degraded'] as const))).toBe(false)
154
+ })
155
+ it('multiple facets union (OR)', () => {
156
+ const f = new Set(['degraded', 'missing'] as const)
157
+ expect(changeMatchesFacets(degraded, f)).toBe(true)
158
+ expect(changeMatchesFacets(missing, f)).toBe(true)
159
+ expect(changeMatchesFacets(outOfSync, f)).toBe(false)
160
+ })
161
+ })
162
+
163
+ describe('resourceStatusCounts', () => {
164
+ it('counts each facet independently (a resource can be both OutOfSync and Missing)', () => {
165
+ const counts = resourceStatusCounts([
166
+ change({ ref: { kind: 'Service', name: 'a' }, sync: 'OutOfSync', health: 'Healthy' }),
167
+ change({ ref: { kind: 'Secret', name: 'c' }, sync: 'OutOfSync', health: 'Missing' }),
168
+ change({ ref: { kind: 'Deployment', name: 'b' }, sync: 'Synced', health: 'Degraded' }),
169
+ change({ ref: { kind: 'ConfigMap', name: 'd' }, sync: 'Synced', health: 'Healthy' }),
170
+ ])
171
+ expect(counts).toEqual({ outOfSync: 2, degraded: 1, missing: 1 })
172
+ })
173
+ })
174
+
175
+ describe('isArgoResourceSyncEligible', () => {
176
+ const outOfSync = change({ ref: { group: 'apps', kind: 'Deployment', namespace: 'demo', name: 'web' }, sync: 'OutOfSync' })
177
+
178
+ it('allows declared OutOfSync and Missing resources', () => {
179
+ expect(isArgoResourceSyncEligible(outOfSync, true)).toBe(true)
180
+ expect(isArgoResourceSyncEligible(change({ ref: { kind: 'Service', name: 'missing' }, health: 'Missing' }), true)).toBe(true)
181
+ })
182
+
183
+ it('excludes generated, hook, and already-synced resources', () => {
184
+ expect(isArgoResourceSyncEligible(outOfSync, false)).toBe(false)
185
+ expect(isArgoResourceSyncEligible(outOfSync, true, 'PreSync')).toBe(false)
186
+ expect(isArgoResourceSyncEligible(change({ ref: { kind: 'Service', name: 'ready' }, sync: 'Synced', health: 'Healthy' }), true)).toBe(false)
187
+ })
188
+ })
189
+
190
+ describe('syncStatusRank / healthStatusRank', () => {
191
+ it('ascending sync rank surfaces OutOfSync before Synced', () => {
192
+ expect(syncStatusRank('OutOfSync')).toBeLessThan(syncStatusRank('Synced'))
193
+ expect(syncStatusRank('OutOfSync')).toBeLessThan(syncStatusRank('Unknown'))
194
+ })
195
+ it('ascending health rank surfaces Missing/Degraded before Healthy', () => {
196
+ expect(healthStatusRank('Missing')).toBeLessThan(healthStatusRank('Degraded'))
197
+ expect(healthStatusRank('Degraded')).toBeLessThan(healthStatusRank('Healthy'))
198
+ })
199
+ })
@@ -2,7 +2,7 @@
2
2
  // GitOpsInsightViews.tsx so they can be unit-tested without importing JSX.
3
3
 
4
4
  import { SEVERITY_DOT, type Severity } from '../../../utils/badge-colors'
5
- import type { GitOpsHistoryItem } from '../../../types'
5
+ import type { GitOpsChange, GitOpsHistoryItem } from '../../../types'
6
6
  import type { SyncStatus, GitOpsHealthStatus } from '../../../types/gitops'
7
7
 
8
8
  const SYNC_STATUS_SET = new Set<SyncStatus>(['Synced', 'OutOfSync', 'Reconciling', 'Unknown'])
@@ -23,6 +23,79 @@ export function normalizeHealthStatus(value: string | undefined | null): GitOpsH
23
23
  return 'Unknown'
24
24
  }
25
25
 
26
+ // --- Resources table: filtering + sorting -------------------------------
27
+ // Pure predicates + rank functions backing the Resources list's search box,
28
+ // status facets, and sortable columns. Kept here (not inline in the view) so
29
+ // the sort/filter behavior is unit-testable without rendering.
30
+
31
+ // The status facets an operator filters the Resources list by — the three
32
+ // states worth isolating on a broadly-drifted app. (These replaced the
33
+ // per-resource OutOfSync "issues" that used to restate the whole table.)
34
+ export type ResourceStatusFacet = 'outOfSync' | 'degraded' | 'missing'
35
+
36
+ export type ResourceSortKey = 'order' | 'name' | 'sync' | 'health'
37
+
38
+ // changeSync/changeHealth normalize a change's raw backend strings onto the
39
+ // badge unions. `sync` falls back to `category` to match how ChangeRow and
40
+ // the sync badge resolve it.
41
+ export function changeSync(change: GitOpsChange): SyncStatus {
42
+ return normalizeSyncStatus(change.sync ?? change.category)
43
+ }
44
+
45
+ export function changeHealth(change: GitOpsChange): GitOpsHealthStatus {
46
+ return normalizeHealthStatus(change.health)
47
+ }
48
+
49
+ export function isArgoResourceSyncEligible(change: GitOpsChange, declared: boolean, hook?: string): boolean {
50
+ if (!declared || hook || change.hookPhase) return false
51
+ return changeSync(change) === 'OutOfSync' || changeHealth(change) === 'Missing' || change.category === 'Missing'
52
+ }
53
+
54
+ // Rank sync/health so an ascending sort surfaces the states an operator cares
55
+ // about first (drift, then failures). Mirrors the GitOps application table's
56
+ // syncRank/healthRank so the two tables order status the same way.
57
+ export function syncStatusRank(sync: SyncStatus): number {
58
+ return ({ OutOfSync: 0, Reconciling: 1, Unknown: 2, Synced: 3 } as Record<SyncStatus, number>)[sync]
59
+ }
60
+
61
+ export function healthStatusRank(health: GitOpsHealthStatus): number {
62
+ return ({ Missing: 0, Degraded: 1, Progressing: 2, Unknown: 3, Suspended: 4, Healthy: 5 } as Record<GitOpsHealthStatus, number>)[health]
63
+ }
64
+
65
+ export function changeMatchesSearch(change: GitOpsChange, query: string): boolean {
66
+ const q = query.trim().toLowerCase()
67
+ if (!q) return true
68
+ const { kind, name, namespace, group } = change.ref
69
+ return [kind, name, namespace, group].some((s) => s?.toLowerCase().includes(q))
70
+ }
71
+
72
+ // Union match: a change passes when it satisfies ANY active facet (empty set
73
+ // = no filter). OutOfSync keys off sync status; Degraded/Missing off health.
74
+ export function changeMatchesFacets(change: GitOpsChange, facets: Set<ResourceStatusFacet>): boolean {
75
+ if (facets.size === 0) return true
76
+ if (facets.has('outOfSync') && changeSync(change) === 'OutOfSync') return true
77
+ if (facets.has('degraded') && changeHealth(change) === 'Degraded') return true
78
+ if (facets.has('missing') && changeHealth(change) === 'Missing') return true
79
+ return false
80
+ }
81
+
82
+ export interface ResourceStatusCounts {
83
+ outOfSync: number
84
+ degraded: number
85
+ missing: number
86
+ }
87
+
88
+ export function resourceStatusCounts(changes: GitOpsChange[]): ResourceStatusCounts {
89
+ const counts: ResourceStatusCounts = { outOfSync: 0, degraded: 0, missing: 0 }
90
+ for (const c of changes) {
91
+ if (changeSync(c) === 'OutOfSync') counts.outOfSync++
92
+ const health = changeHealth(c)
93
+ if (health === 'Degraded') counts.degraded++
94
+ if (health === 'Missing') counts.missing++
95
+ }
96
+ return counts
97
+ }
98
+
26
99
  // Map GitOps-flavored vocabulary (Argo/Flux phase strings, insight Issue
27
100
  // severities) onto the canonical Severity tokens used by SEVERITY_BADGE /
28
101
  // SEVERITY_TEXT / SEVERITY_DOT. Centralizing this keeps call sites from
@@ -17,12 +17,14 @@ import {
17
17
  import '@xyflow/react/dist/style.css'
18
18
  import { AlertTriangle, ChevronRight, Maximize, Search, X } from 'lucide-react'
19
19
  import { PaneLoader } from '../../ui/PaneLoader'
20
+ import { Input } from '../../ui/Input'
20
21
  import { clsx } from 'clsx'
21
22
 
22
23
  import type { GitOpsResourceTree, GitOpsTreeNode, GitOpsTreeRef, HealthStatus } from '../../../types'
23
24
  import { displayKind } from '../../../types'
24
25
  import { healthToSeverity, SEVERITY_DOT } from '../../../utils/badge-colors'
25
26
  import { formatCompactAge } from '../../../utils/format'
27
+ import { getTopologyIcon } from '../../../utils/resource-icons'
26
28
  import { Tooltip } from '../../ui/Tooltip'
27
29
  import { hasGitOpsTreeFilters, matchesGitOpsTreeFilters, type GitOpsTreeFilters } from './tree-helpers'
28
30
 
@@ -252,7 +254,7 @@ function GitOpsTreeToolbar({
252
254
  </div>
253
255
  <div className="flex items-center gap-1 rounded-lg border border-theme-border bg-theme-surface/90 px-2 py-1.5 backdrop-blur">
254
256
  <Search className="h-3.5 w-3.5 text-theme-text-tertiary" />
255
- <input
257
+ <Input
256
258
  value={query}
257
259
  onChange={(event) => onQueryChange(event.target.value)}
258
260
  placeholder="Find node..."
@@ -549,6 +551,7 @@ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<
549
551
  const gitopsTool = gitopsToolForNode(node)
550
552
  const chips = buildChips(node)
551
553
  const dim = getNodeDimensions(node)
554
+ const KindIcon = getTopologyIcon(kind)
552
555
 
553
556
  return (
554
557
  <>
@@ -584,7 +587,7 @@ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<
584
587
  >
585
588
  <div className="px-3 py-2.5">
586
589
  <div className="mb-1 flex items-center gap-1.5">
587
- <span className={`topology-icon topology-icon-${kind.toLowerCase()}`} />
590
+ <KindIcon className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" aria-hidden />
588
591
  <span className="truncate text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">
589
592
  {node.role === 'group' ? displayKind((node.data?.groupedKind as string) || kind) : displayKind(kind)}
590
593
  </span>
@@ -655,6 +658,11 @@ function isNodeTerminating(node: GitOpsTreeNode): boolean {
655
658
  // treatment around them (tool icon overlay, "→" affordance, stronger
656
659
  // border) advertises that they're not just leaves.
657
660
  function gitopsToolForNode(node: GitOpsTreeNode): 'argocd' | 'fluxcd' | undefined {
661
+ // The root node IS the resource this detail page is already showing — it is
662
+ // the subject, not a portal to another page. Never give it the portal
663
+ // treatment (tool badge, chevron, click-to-dive ring): clicking the thing
664
+ // you're already viewing would just stack an identical page onto itself.
665
+ if (node.role === 'root') return undefined
658
666
  const t = node.data?.gitopsTool
659
667
  if (t === 'argocd' || t === 'fluxcd') return t
660
668
  return undefined
@@ -1,8 +1,10 @@
1
1
  import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react';
2
2
  import { AlertOctagon, AlertTriangle, ArrowRight, ChevronRight, CircleCheck, Clock, ExternalLink, Layers, Terminal, Workflow } from 'lucide-react';
3
3
  import { CardBody, CardSection, ClusterName, EmptyState, KIND_CHIP_CLASS, TerminalBlock } from '../ui';
4
+ import { Tooltip } from '../ui/Tooltip';
4
5
  import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
5
6
  import { diagnosticRoleLabel, diagnosticFactLabel, confidenceTitle, incidentParentLabel } from './diagnostic';
7
+ import { issueTiming } from './issue-timing';
6
8
  import {
7
9
  ISSUE_SEVERITY_BADGE_CLASS,
8
10
  ISSUE_SEVERITY_HEADER_BAND_CLASS,
@@ -60,12 +62,15 @@ export interface IssuesViewProps {
60
62
  clusterLabel?: (issue: Issue) => string | undefined;
61
63
  /** Empty-state CTA shown when there's no data. */
62
64
  emptyAction?: ReactNode;
65
+ /** Per-row trailing action, rendered after the severity badge — e.g. the
66
+ * "Diagnose with AI" button in OSS. Omit to render no per-row action. */
67
+ renderActions?: (ctx: IssueRowSlotContext) => ReactNode;
63
68
  }
64
69
 
65
70
  // The queue list. Filtering/faceting is the host page's job (FleetPageShell on
66
71
  // the hub, a thin wrapper in OSS) — this renders the rows + the healthy /
67
72
  // no-data terminal states only.
68
- export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clusterLabel, emptyAction }: IssuesViewProps) {
73
+ export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clusterLabel, emptyAction, renderActions }: IssuesViewProps) {
69
74
  // Single-open accordion: opening a row collapses the previous one, so the
70
75
  // queue stays scannable and you never lose your place to a wall of expansions.
71
76
  const [openId, setOpenId] = useState<string | null>(null);
@@ -104,6 +109,7 @@ export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clu
104
109
  onToggle={() => setOpenId((cur) => (cur === rowKey ? null : rowKey))}
105
110
  resourceHref={resourceHref}
106
111
  onResourceClick={onResourceClick}
112
+ renderActions={renderActions}
107
113
  />
108
114
  );
109
115
  })}
@@ -126,6 +132,10 @@ export interface IssueRowProps {
126
132
  as?: 'li' | 'div';
127
133
  className?: string;
128
134
  dimmed?: boolean;
135
+ /** Suppress the "Subject" deep-link in the expanded body — set by hosts that
136
+ * embed the row under the very resource that IS the subject (the drawer header
137
+ * already names it), so it doesn't echo back redundantly. */
138
+ hideSubject?: boolean;
129
139
  renderBadges?: (ctx: IssueRowSlotContext) => ReactNode;
130
140
  renderMeta?: (ctx: IssueRowSlotContext) => ReactNode;
131
141
  renderActions?: (ctx: IssueRowSlotContext) => ReactNode;
@@ -142,6 +152,7 @@ export function IssueRow({
142
152
  as = 'li',
143
153
  className,
144
154
  dimmed,
155
+ hideSubject,
145
156
  renderBadges,
146
157
  renderMeta,
147
158
  renderActions,
@@ -155,6 +166,37 @@ export function IssueRow({
155
166
  const severity = normalizeIssueSeverity(issue.severity);
156
167
  const SeverityIcon = ISSUE_SEVERITY_ICON[severity];
157
168
  const slotCtx = { issue, open };
169
+ const timing = issueTiming(issue);
170
+
171
+ // Severity pill + age + timing chip. Rendered in two positions the container
172
+ // query toggles: inline at the row's right edge on a wide container, and on a
173
+ // line of its own below the resource line once the row is too narrow to hold
174
+ // it beside the title — so the title never has to truncate to make room.
175
+ const metaChips = (wrapperClass: string) => (
176
+ <div className={`items-center gap-3 ${wrapperClass}`}>
177
+ <span className={`badge-sm shrink-0 px-2.5 py-0.5 text-xs font-semibold ${open ? ISSUE_SEVERITY_SOLID_CLASS[severity] : ISSUE_SEVERITY_BADGE_CLASS[severity]}`}>
178
+ {ISSUE_SEVERITY_LABEL[severity]}
179
+ </span>
180
+ {issue.first_seen ? (
181
+ <>
182
+ <Tooltip content={ageTitle(issue)} delay={200} wrapperClassName="shrink-0">
183
+ <time
184
+ dateTime={issue.first_seen}
185
+ className="flex items-center gap-1 text-xs tabular-nums text-theme-text-tertiary"
186
+ >
187
+ <Clock className="h-3 w-3" aria-hidden />
188
+ {formatCompactAge(issue.first_seen)}
189
+ </time>
190
+ </Tooltip>
191
+ {timing ? (
192
+ <Tooltip content={timing.tooltip} delay={200}>
193
+ <span className="badge-sm text-[10px] text-theme-text-secondary">{timing.chip}</span>
194
+ </Tooltip>
195
+ ) : null}
196
+ </>
197
+ ) : null}
198
+ </div>
199
+ );
158
200
 
159
201
  useEffect(() => {
160
202
  if (open) {
@@ -199,13 +241,13 @@ export function IssueRow({
199
241
  onToggle();
200
242
  }
201
243
  }}
202
- className={`group flex cursor-pointer items-center gap-3 border-l-[3px] py-3 pl-3 pr-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent)]/40 ${open ? ISSUE_SEVERITY_HEADER_BAND_CLASS[severity] : ISSUE_SEVERITY_RAIL_CLASS[severity]}`}
244
+ className={`group @container/issue flex cursor-pointer items-center gap-3 border-l-[3px] py-3 pl-3 pr-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent)]/40 ${open ? ISSUE_SEVERITY_HEADER_BAND_CLASS[severity] : ISSUE_SEVERITY_RAIL_CLASS[severity]}`}
203
245
  >
204
246
  <SeverityIcon className={`h-[18px] w-[18px] shrink-0 ${ISSUE_SEVERITY_TEXT_CLASS[severity]}`} aria-hidden />
205
247
 
206
248
  <div className="flex min-w-0 flex-1 flex-col gap-1">
207
249
  <div className="flex min-w-0 items-baseline gap-2">
208
- <span className="shrink-0 text-sm font-medium text-theme-text-primary">{categoryLabel(issue.category)}</span>
250
+ <span className="min-w-0 truncate text-sm font-medium text-theme-text-primary">{categoryLabel(issue.category)}</span>
209
251
  <span className={`shrink-0 self-center ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
210
252
  {renderBadges?.(slotCtx)}
211
253
  {/* The detector reason rides the title row while COLLAPSED so the
@@ -248,35 +290,26 @@ export function IssueRow({
248
290
  <span aria-hidden>·</span>
249
291
  {/* Non-interactive signal (the header is the toggle — a nested
250
292
  button would be invalid); the clickable link lives in the body. */}
251
- <span className="min-w-0 truncate text-theme-text-tertiary" title={confidenceTitle(issue.incident_parent.confidence ?? '')}>
252
- ↳ {incidentParentLabel(issue.incident_parent.fact_type, issue.incident_parent.confidence)}{' '}
253
- <span className="font-medium text-theme-text-secondary">{issue.incident_parent.ref.kind} / {issue.incident_parent.ref.name}</span>
254
- </span>
293
+ <Tooltip content={confidenceTitle(issue.incident_parent.confidence ?? '')} delay={200} wrapperClassName="min-w-0">
294
+ <span className="truncate text-theme-text-tertiary">
295
+ ↳ {incidentParentLabel(issue.incident_parent.fact_type, issue.incident_parent.confidence)}{' '}
296
+ <span className="font-medium text-theme-text-secondary">{issue.incident_parent.ref.kind} / {issue.incident_parent.ref.name}</span>
297
+ </span>
298
+ </Tooltip>
255
299
  </>
256
300
  ) : null}
257
301
  {renderMeta?.(slotCtx)}
258
302
  </div>
303
+ {/* Narrow container: the meta chips drop to their own line here rather
304
+ than stealing width from the title on the row above. Hidden once the
305
+ row is wide enough to hold them inline in the right cluster. */}
306
+ {metaChips('flex @2xl/issue:hidden')}
259
307
  </div>
260
308
 
261
- {/* Right cluster — severity pill THEN age, vertically centered as a group. */}
309
+ {/* Right cluster — the always-visible affordances (per-row action +
310
+ chevron) plus, on a wide container, the inline meta chips. */}
262
311
  <div className="flex shrink-0 items-center gap-3">
263
- <span className={`badge-sm shrink-0 px-2.5 py-0.5 text-xs font-semibold ${open ? ISSUE_SEVERITY_SOLID_CLASS[severity] : ISSUE_SEVERITY_BADGE_CLASS[severity]}`}>
264
- {ISSUE_SEVERITY_LABEL[severity]}
265
- </span>
266
- {/* Age chip: keep recency visible; the deployment-start signal rides as a secondary tag. */}
267
- {issue.first_seen ? (
268
- <time
269
- dateTime={issue.first_seen}
270
- title={ageTitle(issue)}
271
- className="flex shrink-0 items-center gap-1 text-xs tabular-nums text-theme-text-tertiary"
272
- >
273
- <Clock className="h-3 w-3" aria-hidden />
274
- {formatCompactAge(issue.first_seen)}
275
- {issue.issue_timing === 'started_at_resource_creation' ? (
276
- <span className="badge-sm ml-1 text-[10px] text-theme-text-secondary">since deploy</span>
277
- ) : null}
278
- </time>
279
- ) : null}
312
+ {metaChips('hidden @2xl/issue:flex')}
280
313
  {renderActions?.(slotCtx)}
281
314
  <ChevronRight className={`h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
282
315
  </div>
@@ -302,9 +335,11 @@ export function IssueRow({
302
335
  <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
303
336
  {incidentParentLabel(issue.incident_parent.fact_type, issue.incident_parent.confidence)}
304
337
  {issue.incident_parent.confidence ? (
305
- <span className="ml-2 badge-sm text-[10px] font-normal text-theme-text-tertiary" title={confidenceTitle(issue.incident_parent.confidence)}>
306
- {issue.incident_parent.confidence} confidence
307
- </span>
338
+ <Tooltip content={confidenceTitle(issue.incident_parent.confidence)} delay={200}>
339
+ <span className="ml-2 badge-sm text-[10px] font-normal text-theme-text-tertiary">
340
+ {issue.incident_parent.confidence} confidence
341
+ </span>
342
+ </Tooltip>
308
343
  ) : null}
309
344
  </h4>
310
345
  <ul className="flex flex-col gap-px">
@@ -313,7 +348,7 @@ export function IssueRow({
313
348
  </section>
314
349
  ) : null}
315
350
  <DiagnosticContext issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
316
- <AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
351
+ <AffectedResources issue={issue} hideSubject={hideSubject} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
317
352
  </div>
318
353
  </div>
319
354
  </div>
@@ -343,6 +378,7 @@ function Diagnosis({ issue }: { issue: Issue }) {
343
378
  // present, else headline+detail) rather than a string that may never appear.
344
379
  const visibleMessage = [headline, detail].filter(Boolean).join(' ');
345
380
  const shownText = issue.cause ?? visibleMessage;
381
+ const timing = issueTiming(issue);
346
382
  const rawError = [
347
383
  ...new Set(
348
384
  [issue.raw_message ?? (issue.cause ? issue.message : undefined), detail].filter(
@@ -359,15 +395,13 @@ function Diagnosis({ issue }: { issue: Issue }) {
359
395
  else if (issue.operation_retry_count) meta.push('Retrying');
360
396
  if (issue.operation_retry_count) meta.push(`retried ${issue.operation_retry_count}×`);
361
397
  if (crash) meta.push(crash);
398
+ if (timing) {
399
+ meta.push(timing.meta);
400
+ } else if (issue.first_seen) {
401
+ meta.push(`started ${formatRelativeAgeTime(issue.first_seen)}`);
402
+ }
362
403
  if (issue.first_seen) {
363
- meta.push(
364
- issue.issue_timing === 'started_at_resource_creation'
365
- ? 'present since deployment'
366
- : issue.issue_timing === 'started_after_resource_was_healthy'
367
- ? `started ${formatRelativeAgeTime(issue.first_seen)} · was previously healthy`
368
- : `started ${formatRelativeAgeTime(issue.first_seen)}`,
369
- );
370
- if (issue.last_seen && issue.issue_timing !== 'started_at_resource_creation') meta.push(`last seen ${formatRelativeAgeTime(issue.last_seen)}`);
404
+ if (issue.last_seen && timing?.kind !== 'creation') meta.push(`last seen ${formatRelativeAgeTime(issue.last_seen)}`);
371
405
  }
372
406
  if (issue.change_context) meta.push(changeContextText(issue.change_context));
373
407
 
@@ -448,12 +482,11 @@ function DiagnosticContext({
448
482
  <div className="flex min-w-0 items-baseline gap-2">
449
483
  <span className="shrink-0 text-xs font-medium text-theme-text-secondary">{diagnosticFactLabel(fact.type)}</span>
450
484
  {fact.confidence ? (
451
- <span
452
- className="shrink-0 badge-sm text-[10px] text-theme-text-tertiary"
453
- title={confidenceTitle(fact.confidence)}
454
- >
455
- {fact.confidence} confidence
456
- </span>
485
+ <Tooltip content={confidenceTitle(fact.confidence)} delay={200}>
486
+ <span className="shrink-0 badge-sm text-[10px] text-theme-text-tertiary">
487
+ {fact.confidence} confidence
488
+ </span>
489
+ </Tooltip>
457
490
  ) : null}
458
491
  {fact.message ? <span className="min-w-0 break-words text-xs leading-relaxed text-theme-text-tertiary">{fact.message}</span> : null}
459
492
  </div>
@@ -496,11 +529,8 @@ function DiagnosticContext({
496
529
  // freshness, the two facts the compact "2h" hides.
497
530
  function ageTitle(issue: Issue): string {
498
531
  const parts: string[] = [];
499
- if (issue.issue_timing === 'started_at_resource_creation') {
500
- parts.push('Present since deployment — treat as baseline');
501
- } else if (issue.issue_timing === 'started_after_resource_was_healthy') {
502
- parts.push('Resource was previously healthy');
503
- }
532
+ const timing = issueTiming(issue);
533
+ if (timing) parts.push(timing.tooltip);
504
534
  if (issue.first_seen) parts.push(`First seen ${new Date(issue.first_seen).toLocaleString()}`);
505
535
  if (issue.last_seen) parts.push(`Last seen ${formatRelativeAgeTime(issue.last_seen)}`);
506
536
  return parts.join('\n');
@@ -508,11 +538,13 @@ function ageTitle(issue: Issue): string {
508
538
 
509
539
  function AffectedResources({
510
540
  issue,
541
+ hideSubject,
511
542
  resourceHref,
512
543
  onResourceClick,
513
544
  ResourceLinkIcon,
514
545
  }: {
515
546
  issue: Issue;
547
+ hideSubject?: boolean;
516
548
  resourceHref?: (ref: IssueResourceRef) => string;
517
549
  onResourceClick?: (ref: IssueResourceRef) => void;
518
550
  ResourceLinkIcon: ComponentType<{ className?: string }>;
@@ -521,14 +553,19 @@ function AffectedResources({
521
553
  // count is the backend fan-out size (members, subject excluded — see
522
554
  // grouping.go); fall back to the inline member count, not +1.
523
555
  const total = issue.count ?? members.length;
556
+ // With the subject suppressed and no fanned-out members, this section has
557
+ // nothing to show — return null so the divider row above it doesn't render empty.
558
+ if (hideSubject && members.length === 0) return null;
524
559
  return (
525
560
  <section className="flex flex-col gap-3">
526
561
  {/* The subject (the grouped thing — e.g. the Deployment) is always the
527
562
  first deep-link; members (the folded pods) follow. ResourceLine emits
528
563
  an <li>, so it needs a list parent of its own. */}
529
- <ul className="flex flex-col gap-px">
530
- <ResourceLine label="Subject" compact refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
531
- </ul>
564
+ {!hideSubject && (
565
+ <ul className="flex flex-col gap-px">
566
+ <ResourceLine label="Subject" compact refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
567
+ </ul>
568
+ )}
532
569
  {members.length > 0 && (
533
570
  <CardSection icon={Layers} label="Affected resources" labelExtra={`· ${total}`}>
534
571
  <ul className="flex flex-col gap-px">
@@ -589,7 +626,9 @@ function ResourceLine({
589
626
  {r.name}
590
627
  </span>
591
628
  {count && count > 1 ? (
592
- <span className="shrink-0 text-[10px] text-theme-text-tertiary tabular-nums" title={`${count} affected resources grouped under this issue`}>{count} affected</span>
629
+ <Tooltip content={`${count} affected resources grouped under this issue`} delay={200}>
630
+ <span className="shrink-0 text-[10px] text-theme-text-tertiary tabular-nums">{count} affected</span>
631
+ </Tooltip>
593
632
  ) : null}
594
633
  {linkable && <ResourceLinkIcon className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/r:opacity-100" />}
595
634
  </>
@@ -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 = {
@@ -28,12 +29,44 @@ const issue: Issue = {
28
29
  }
29
30
 
30
31
  describe('ResourceIssuesSection', () => {
31
- it('keeps drawer context facts but omits the role badge', () => {
32
+ it('uses the Issues row treatment and defaults collapsed', () => {
32
33
  const html = renderToString(<ResourceIssuesSection issues={[issue]} />)
33
34
 
34
- expect(html).toContain('Context')
35
- expect(html).toContain('Blocked pods')
36
- expect(html).toContain('high<!-- --> confidence')
37
- expect(html).not.toContain('Possible cause')
35
+ expect(html).toContain('Operational issues')
36
+ expect(html).toContain('StorageClassMissing')
37
+ expect(html).not.toContain('What&#x27;s wrong')
38
+ expect(html).not.toContain('Context')
39
+ expect(html).not.toContain('Blocked pods')
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)
38
71
  })
39
72
  })