@skyhook-io/radar-app 1.11.0 → 1.12.3

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 (67) hide show
  1. package/package.json +7 -7
  2. package/src/App.tsx +37 -17
  3. package/src/api/client.images.test.ts +63 -0
  4. package/src/api/client.ts +244 -39
  5. package/src/api/client.yaml.test.ts +3 -3
  6. package/src/api/version-check.test.ts +78 -0
  7. package/src/components/CloudConnectFlow.tsx +46 -26
  8. package/src/components/CloudFunnelButton.tsx +166 -129
  9. package/src/components/ConnectionErrorView.test.tsx +74 -1
  10. package/src/components/ConnectionErrorView.tsx +22 -20
  11. package/src/components/ContextSwitcher.tsx +10 -13
  12. package/src/components/applications/ApplicationsView.tsx +10 -9
  13. package/src/components/audit/AuditView.tsx +6 -3
  14. package/src/components/audit/UpgradeReadinessView.test.ts +26 -2
  15. package/src/components/audit/UpgradeReadinessView.tsx +22 -12
  16. package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
  17. package/src/components/capacity/schedulingBar.test.ts +10 -0
  18. package/src/components/cost/ApplicationCostTab.test.ts +6 -0
  19. package/src/components/cost/ApplicationCostTab.tsx +28 -16
  20. package/src/components/cost/CostTrendChart.tsx +20 -10
  21. package/src/components/cost/CostView.tsx +79 -28
  22. package/src/components/cost/CurrentAllocationUse.tsx +6 -4
  23. package/src/components/cost/WorkloadCostTab.test.ts +10 -0
  24. package/src/components/cost/WorkloadCostTab.tsx +24 -12
  25. package/src/components/cost/format.test.ts +27 -8
  26. package/src/components/cost/format.ts +78 -27
  27. package/src/components/diagnose/DiagnoseSurface.tsx +14 -10
  28. package/src/components/gitops/GitOpsView.tsx +8 -3
  29. package/src/components/helm/HelmReleaseDrawer.tsx +4 -3
  30. package/src/components/helm/OwnedResources.tsx +10 -2
  31. package/src/components/home/ClusterHealthCard.test.ts +31 -0
  32. package/src/components/home/ClusterHealthCard.tsx +63 -1
  33. package/src/components/home/CostCard.tsx +12 -7
  34. package/src/components/home/HomeView.tsx +36 -11
  35. package/src/components/home/MCPSetupDialog.tsx +5 -4
  36. package/src/components/home/RadarVersionLine.test.tsx +145 -0
  37. package/src/components/home/RadarVersionLine.tsx +137 -0
  38. package/src/components/home/mcpToolCatalog.ts +12 -0
  39. package/src/components/nav/PrimaryNavRail.tsx +2 -2
  40. package/src/components/resources/PodFilesystemModal.tsx +54 -2
  41. package/src/components/resources/ResourcesView.tsx +31 -8
  42. package/src/components/resources/renderers/WorkloadRenderer.tsx +13 -5
  43. package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
  44. package/src/components/settings/SettingsDialog.tsx +181 -40
  45. package/src/components/settings/currency-options.test.ts +49 -0
  46. package/src/components/settings/currency-options.ts +38 -0
  47. package/src/components/ui/DiagnosticsOverlay.test.ts +40 -0
  48. package/src/components/ui/DiagnosticsOverlay.tsx +18 -6
  49. package/src/components/ui/ErrorBoundary.test.tsx +55 -0
  50. package/src/components/ui/ErrorBoundary.tsx +17 -2
  51. package/src/components/ui/UpdateNotification.test.tsx +49 -0
  52. package/src/components/ui/UpdateNotification.tsx +6 -2
  53. package/src/components/ui/command-items.ts +4 -14
  54. package/src/components/workload/WorkloadView.test.ts +60 -0
  55. package/src/components/workload/WorkloadView.tsx +272 -30
  56. package/src/contexts/CapabilitiesContext.test.tsx +29 -0
  57. package/src/contexts/CapabilitiesContext.tsx +7 -3
  58. package/src/main.tsx +4 -114
  59. package/src/utils/context-name.test.ts +63 -0
  60. package/src/utils/context-name.ts +22 -0
  61. package/src/utils/navigation.test.ts +45 -0
  62. package/src/utils/navigation.ts +5 -5
  63. package/src/utils/topology-selection.ts +3 -2
  64. package/src/utils/version.test.ts +37 -0
  65. package/src/utils/version.ts +56 -0
  66. package/src/utils/wails-clipboard.test.ts +109 -0
  67. package/src/utils/wails-clipboard.ts +127 -0
@@ -0,0 +1,49 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { CURRENCY_OPTIONS, currencyOptionsForValue } from './currency-options'
3
+
4
+ afterEach(() => {
5
+ vi.restoreAllMocks()
6
+ })
7
+
8
+ describe('currency options', () => {
9
+ it('offers Auto followed by named ISO 4217 currencies', () => {
10
+ expect(CURRENCY_OPTIONS[0]).toEqual({
11
+ value: '',
12
+ label: 'Auto (detect from OpenCost/Kubecost)',
13
+ })
14
+ expect(CURRENCY_OPTIONS).toContainEqual({
15
+ value: 'EUR',
16
+ label: 'Euro (EUR)',
17
+ })
18
+ expect(CURRENCY_OPTIONS).toContainEqual({
19
+ value: 'USD',
20
+ label: 'US Dollar (USD)',
21
+ })
22
+ for (const code of ['MRU', 'SLE', 'UYW', 'VED', 'VES', 'XAD', 'XCG', 'ZWG']) {
23
+ expect(CURRENCY_OPTIONS.some((option) => option.value === code)).toBe(true)
24
+ }
25
+ })
26
+
27
+ it('contains unique uppercase three-letter currency codes', () => {
28
+ const codes = CURRENCY_OPTIONS.slice(1).map((option) => option.value)
29
+ expect(new Set(codes).size).toBe(codes.length)
30
+ expect(codes.every((code) => /^[A-Z]{3}$/.test(code))).toBe(true)
31
+ expect(codes).not.toContain('XTS')
32
+ expect(codes).not.toContain('XXX')
33
+ })
34
+
35
+ it('supplements current codes when browser currency data lags', async () => {
36
+ vi.spyOn(Intl, 'supportedValuesOf').mockReturnValue(['EUR', 'USD'])
37
+ vi.resetModules()
38
+ const { CURRENCY_OPTIONS: options } = await import('./currency-options')
39
+
40
+ for (const code of ['MRU', 'SLE', 'UYW', 'VED', 'VES', 'XAD', 'XCG', 'ZWG']) {
41
+ expect(options.some((option) => option.value === code)).toBe(true)
42
+ }
43
+ })
44
+
45
+ it('shows an existing saved code instead of misrepresenting it as Auto', () => {
46
+ expect(currencyOptionsForValue('EUR')).toBe(CURRENCY_OPTIONS)
47
+ expect(currencyOptionsForValue('ADP')).toContainEqual({ value: 'ADP', label: 'ADP' })
48
+ })
49
+ })
@@ -0,0 +1,38 @@
1
+ import type { SelectMenuOption } from '@skyhook-io/k8s-ui'
2
+
3
+ const currencyNames = new Intl.DisplayNames(['en'], { type: 'currency' })
4
+ const currentISO4217CurrenciesAddedSinceCLDR32: Record<string, string> = {
5
+ MRU: 'Mauritanian Ouguiya',
6
+ SLE: 'Sierra Leonean Leone',
7
+ UYW: 'Uruguayan Nominal Wage Index Unit',
8
+ VED: 'Bolívar Soberano',
9
+ VES: 'Venezuelan Bolívar',
10
+ XAD: 'Arab Accounting Dinar',
11
+ XCG: 'Caribbean Guilder',
12
+ ZWG: 'Zimbabwean Gold',
13
+ }
14
+ const currencyCodes = [
15
+ ...new Set([
16
+ ...Intl.supportedValuesOf('currency'),
17
+ ...Object.keys(currentISO4217CurrenciesAddedSinceCLDR32),
18
+ ]),
19
+ ]
20
+
21
+ export const CURRENCY_OPTIONS: SelectMenuOption[] = [
22
+ { value: '', label: 'Auto (detect from OpenCost/Kubecost)' },
23
+ ...currencyCodes
24
+ .filter((code) => code !== 'XTS' && code !== 'XXX')
25
+ .map((code) => {
26
+ const name = currentISO4217CurrenciesAddedSinceCLDR32[code] ?? currencyNames.of(code)
27
+ return {
28
+ value: code,
29
+ label: name && name !== code ? `${name} (${code})` : code,
30
+ }
31
+ })
32
+ .sort((a, b) => a.label.localeCompare(b.label, 'en')),
33
+ ]
34
+
35
+ export function currencyOptionsForValue(value: string): SelectMenuOption[] {
36
+ if (!value || CURRENCY_OPTIONS.some((option) => option.value === value)) return CURRENCY_OPTIONS
37
+ return [...CURRENCY_OPTIONS, { value, label: value }]
38
+ }
@@ -73,3 +73,43 @@ describe('formatForGitHub desktop section', () => {
73
73
  expect(md).toContain('Desktop: `(unset)`')
74
74
  })
75
75
  })
76
+
77
+ describe('formatForGitHub kubeconfig section', () => {
78
+ it('reports combined source counts and ignored ambient configuration', () => {
79
+ const md = formatForGitHub({
80
+ ...baseSnapshot,
81
+ kubeconfig: {
82
+ mode: 'multi-source',
83
+ fileCount: 3,
84
+ directoryFileCount: 2,
85
+ contextCount: 7,
86
+ enrichedFromShell: true,
87
+ kubeconfigEnvIgnored: true,
88
+ kubeconfigEnvIgnoredReason: 'directories-only configuration',
89
+ currentContextUsesExec: false,
90
+ },
91
+ }, undefined, false)
92
+
93
+ expect(md).toContain('Mode: `multi-source` | Files: 3 | Directory Files: 2 | Contexts (after source resolution): 7')
94
+ expect(md).toContain('KUBECONFIG Captured From Shell: Yes | Ignored: Yes — directories-only configuration')
95
+ })
96
+
97
+ it('omits directory counts for environment path lists', () => {
98
+ const md = formatForGitHub({
99
+ ...baseSnapshot,
100
+ kubeconfig: {
101
+ mode: 'multi-env',
102
+ fileCount: 2,
103
+ directoryFileCount: 0,
104
+ contextCount: 4,
105
+ enrichedFromShell: false,
106
+ kubeconfigEnvIgnored: false,
107
+ kubeconfigEnvIgnoredReason: '',
108
+ currentContextUsesExec: true,
109
+ },
110
+ }, undefined, false)
111
+
112
+ expect(md).toContain('Mode: `multi-env` | Files: 2 | Contexts (after source resolution): 4')
113
+ expect(md).not.toContain('Directory Files:')
114
+ })
115
+ })
@@ -171,10 +171,10 @@ function Section({ title, children, warn }: { title: string; children: React.Rea
171
171
 
172
172
  function Row({ label, value, warn }: { label: string; value: React.ReactNode; warn?: boolean }) {
173
173
  return (
174
- <div className="flex items-baseline justify-between gap-4 text-xs">
174
+ <div className="flex items-start justify-between gap-4 text-xs">
175
175
  <span className="text-theme-text-secondary shrink-0">{label}</span>
176
176
  <span className={clsx(
177
- 'text-right truncate',
177
+ 'min-w-0 text-right break-words',
178
178
  warn ? 'text-yellow-400' : 'text-theme-text-primary'
179
179
  )}>{value}</span>
180
180
  </div>
@@ -219,11 +219,19 @@ function KubeconfigSection({ data }: { data: DiagnosticsSnapshot }) {
219
219
  const present = k.execPluginsPresent ?? []
220
220
  const hasMissing = missing.length > 0
221
221
  return (
222
- <Section title="Kubeconfig" warn={hasMissing}>
222
+ <Section title="Kubeconfig" warn={hasMissing || k.kubeconfigEnvIgnored}>
223
223
  <Row label="Mode" value={k.mode || '(not initialized)'} />
224
224
  <Row label="Files Loaded" value={k.fileCount} />
225
- <Row label="Contexts (post-merge)" value={k.contextCount} />
226
- <Row label="Enriched From Shell" value={k.enrichedFromShell ? 'Yes' : 'No'} />
225
+ {(k.mode === 'multi-dir' || k.mode === 'multi-source') && (
226
+ <Row label="Directory Files Loaded" value={k.directoryFileCount} />
227
+ )}
228
+ <Row label="Contexts (after source resolution)" value={k.contextCount} />
229
+ <Row label="KUBECONFIG Captured From Shell" value={k.enrichedFromShell ? 'Yes' : 'No'} />
230
+ <Row
231
+ label="KUBECONFIG Ignored"
232
+ value={k.kubeconfigEnvIgnored ? `Yes — ${k.kubeconfigEnvIgnoredReason}` : 'No'}
233
+ warn={k.kubeconfigEnvIgnored}
234
+ />
227
235
  <Row
228
236
  label="Current Context Uses Exec"
229
237
  value={k.currentContextUsesExec ? 'Yes' : 'No'}
@@ -631,7 +639,11 @@ export function formatForGitHub(data: DiagnosticsSnapshot, frontendPerf?: K8sUIP
631
639
  if (data.kubeconfig) {
632
640
  const k = data.kubeconfig
633
641
  lines.push(`### Kubeconfig`)
634
- lines.push(`- Mode: \`${k.mode || '(not initialized)'}\` | Files: ${k.fileCount} | Contexts (post-merge): ${k.contextCount} | Enriched From Shell: ${k.enrichedFromShell ? 'Yes' : 'No'}`)
642
+ const directoryFiles = k.mode === 'multi-dir' || k.mode === 'multi-source'
643
+ ? ` | Directory Files: ${k.directoryFileCount}`
644
+ : ''
645
+ lines.push(`- Mode: \`${k.mode || '(not initialized)'}\` | Files: ${k.fileCount}${directoryFiles} | Contexts (after source resolution): ${k.contextCount}`)
646
+ lines.push(`- KUBECONFIG Captured From Shell: ${k.enrichedFromShell ? 'Yes' : 'No'} | Ignored: ${k.kubeconfigEnvIgnored ? `Yes — ${k.kubeconfigEnvIgnoredReason}` : 'No'}`)
635
647
  lines.push(`- Current Context Uses Exec: ${k.currentContextUsesExec ? 'Yes' : 'No'}`)
636
648
  if (k.execPluginsPresent && k.execPluginsPresent.length > 0) {
637
649
  lines.push(`- Exec Plugins on PATH: \`${k.execPluginsPresent.join('`, `')}\``)
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { ErrorBoundary } from './ErrorBoundary'
4
+
5
+ // Navigating re-renders this boundary rather than remounting it, so a boundary
6
+ // that only latched would keep the fallback on screen for the rest of the
7
+ // session. These drive the lifecycle hooks directly - a caught error needs a
8
+ // real render loop, which the SSR-string tests used elsewhere here cannot give.
9
+ const caught = (resetKey: string) => ({
10
+ hasError: true,
11
+ error: new Error("Cannot read properties of undefined (reading 'nodeCount')"),
12
+ resetKey,
13
+ })
14
+
15
+ describe('ErrorBoundary reset', () => {
16
+ it('clears a caught error when resetKey changes', () => {
17
+ expect(ErrorBoundary.getDerivedStateFromProps({ children: null, resetKey: '/topology' }, caught('/'))).toEqual({
18
+ hasError: false,
19
+ error: null,
20
+ resetKey: '/topology',
21
+ })
22
+ })
23
+
24
+ it('clears it when only a later path segment changes', () => {
25
+ // The view is just the first path segment, so /resources/pods and
26
+ // /resources/services are the same view. Resetting per view would leave a
27
+ // crash under /resources trapping the user as they switch kinds.
28
+ const next = ErrorBoundary.getDerivedStateFromProps(
29
+ { children: null, resetKey: '/resources/services' },
30
+ caught('/resources/pods'),
31
+ )
32
+ expect(next?.hasError).toBe(false)
33
+ })
34
+
35
+ it('clears it when only the query changes', () => {
36
+ // Selection rides in the query, so a path-only key would strand a crash on
37
+ // the view that produced it.
38
+ const next = ErrorBoundary.getDerivedStateFromProps(
39
+ { children: null, resetKey: '/resources/pods?resource=kube-system/coredns' },
40
+ caught('/resources/pods'),
41
+ )
42
+ expect(next?.hasError).toBe(false)
43
+ })
44
+
45
+ it('holds the error while resetKey is unchanged', () => {
46
+ expect(ErrorBoundary.getDerivedStateFromProps({ children: null, resetKey: '/' }, caught('/'))).toBeNull()
47
+ })
48
+
49
+ it('records the error', () => {
50
+ expect(ErrorBoundary.getDerivedStateFromError(new Error('boom'))).toEqual({
51
+ hasError: true,
52
+ error: new Error('boom'),
53
+ })
54
+ })
55
+ })
@@ -3,20 +3,35 @@ import { AlertTriangle, RefreshCw } from 'lucide-react'
3
3
 
4
4
  interface Props {
5
5
  children: ReactNode
6
+ /**
7
+ * Clears a caught error whenever this value changes - pass whatever selects
8
+ * the children, typically the route. A boundary latches: nothing resets
9
+ * hasError on its own, so without this a view that throws keeps the fallback
10
+ * on screen and navigating away cannot escape it. Preferred over remounting
11
+ * via `key`, which would also discard the children's state on every change
12
+ * while nothing is wrong.
13
+ */
14
+ resetKey: string | number
6
15
  }
7
16
 
8
17
  interface State {
9
18
  hasError: boolean
10
19
  error: Error | null
20
+ resetKey: string | number
11
21
  }
12
22
 
13
23
  export class ErrorBoundary extends Component<Props, State> {
14
- state: State = { hasError: false, error: null }
24
+ state: State = { hasError: false, error: null, resetKey: this.props.resetKey }
15
25
 
16
- static getDerivedStateFromError(error: Error): State {
26
+ static getDerivedStateFromError(error: Error): Pick<State, 'hasError' | 'error'> {
17
27
  return { hasError: true, error }
18
28
  }
19
29
 
30
+ static getDerivedStateFromProps(props: Props, state: State): State | null {
31
+ if (Object.is(props.resetKey, state.resetKey)) return null
32
+ return { hasError: false, error: null, resetKey: props.resetKey }
33
+ }
34
+
20
35
  componentDidCatch(error: Error, info: React.ErrorInfo) {
21
36
  console.error('[ErrorBoundary]', error, info.componentStack)
22
37
  }
@@ -0,0 +1,49 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ let deploymentMode = 'local'
6
+
7
+ vi.mock('../../api/client', () => ({
8
+ useCapabilities: () => ({ data: { deployment: { mode: deploymentMode } } }),
9
+ useVersionCheck: () => ({
10
+ data: {
11
+ currentVersion: '1.2.3',
12
+ latestVersion: '1.3.0',
13
+ updateAvailable: true,
14
+ installMethod: 'direct',
15
+ releaseUrl: 'https://github.com/skyhook-io/radar/releases/tag/v1.3.0',
16
+ },
17
+ }),
18
+ useStartDesktopUpdate: () => ({ mutate: vi.fn(), isPending: false }),
19
+ useDesktopUpdateStatus: () => ({ data: undefined }),
20
+ useApplyDesktopUpdate: () => ({ mutate: vi.fn() }),
21
+ }))
22
+
23
+ import { UpdateNotification } from './UpdateNotification'
24
+
25
+ function renderNotification() {
26
+ const client = new QueryClient()
27
+ return renderToString(
28
+ <QueryClientProvider client={client}>
29
+ <UpdateNotification />
30
+ </QueryClientProvider>,
31
+ )
32
+ }
33
+
34
+ describe('UpdateNotification', () => {
35
+ beforeEach(() => {
36
+ deploymentMode = 'local'
37
+ })
38
+
39
+ it('keeps the update popup for local installations', () => {
40
+ const html = renderNotification()
41
+ expect(html).toContain('Update Available')
42
+ expect(html).toContain('1.3.0')
43
+ })
44
+
45
+ it('suppresses the update popup for shared in-cluster viewers', () => {
46
+ deploymentMode = 'in-cluster'
47
+ expect(renderNotification()).toBe('')
48
+ })
49
+ })
@@ -3,6 +3,7 @@ import { Download, X, Copy, Check, RotateCw, ArrowDownToLine, Loader2 } from 'lu
3
3
  import { useQueryClient } from '@tanstack/react-query'
4
4
  import {
5
5
  useVersionCheck,
6
+ useCapabilities,
6
7
  useStartDesktopUpdate,
7
8
  useDesktopUpdateStatus,
8
9
  useApplyDesktopUpdate,
@@ -14,6 +15,8 @@ const DISMISSED_KEY = 'radar-update-dismissed'
14
15
 
15
16
  export function UpdateNotification() {
16
17
  const queryClient = useQueryClient()
18
+ const { data: capabilities } = useCapabilities()
19
+ const deploymentMode = capabilities ? (capabilities.deployment?.mode ?? 'local') : undefined
17
20
  const { data: versionInfo } = useVersionCheck()
18
21
  const [dismissed, setDismissed] = useState(false)
19
22
  const [copied, setCopied] = useState(false)
@@ -98,8 +101,9 @@ export function UpdateNotification() {
98
101
  })
99
102
  }
100
103
 
101
- // Don't show if no update available, dismissed, or error
102
- if (!versionInfo?.updateAvailable || dismissed) {
104
+ // Shared in-cluster viewers get a persistent Home notice instead of a
105
+ // floating action prompt they may not be able to act on.
106
+ if (!versionInfo?.updateAvailable || dismissed || deploymentMode === undefined || deploymentMode === 'in-cluster' || deploymentMode === 'cloud') {
103
107
  return null
104
108
  }
105
109
 
@@ -8,7 +8,7 @@ import {
8
8
  Activity,
9
9
  Sun,
10
10
  Stethoscope,
11
- DollarSign,
11
+ Coins,
12
12
  Gauge,
13
13
  ShieldCheck,
14
14
  GitBranch,
@@ -19,15 +19,7 @@ import {
19
19
  import { useNamespaces, useContexts } from "../../api/client";
20
20
  import { CORE_RESOURCES, useAPIResources } from "../../api/apiResources";
21
21
  import { getResourceIcon } from "../../utils/resource-icons";
22
- import { parseContextName } from "../../utils/context-name";
23
-
24
- // Drop the disambiguating " (source)" suffix the context list appends, so the
25
- // GKE/EKS/AKS parser sees the bare context name (mirrors the cluster picker).
26
- function stripSourceSuffix(name: string, source?: string): string {
27
- if (!source) return name;
28
- const escaped = source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
29
- return name.replace(new RegExp(`\\s+\\(${escaped}(?:\\s+#\\d+)?\\)$`), "");
30
- }
22
+ import { parseContextForSwitcher, parseContextName } from "../../utils/context-name";
31
23
 
32
24
  export type MainView =
33
25
  | "home"
@@ -151,7 +143,7 @@ const VIEW_ENTRIES: {
151
143
  { view: "traffic", label: "Live Traffic", icon: Activity, shortcut: "g f" },
152
144
  { view: "checks", label: "Checks", icon: ShieldCheck, shortcut: "g u" },
153
145
  { view: "capacity", label: "Capacity", icon: Gauge, shortcut: "g p" },
154
- { view: "cost", label: "Cost", icon: DollarSign, shortcut: "g c" },
146
+ { view: "cost", label: "Cost", icon: Coins, shortcut: "g c" },
155
147
  ];
156
148
 
157
149
  // The static command-palette items (Views, Resource Kinds, Contexts,
@@ -204,9 +196,7 @@ export function useCommandItems(cb: CommandItemCallbacks): CommandItem[] {
204
196
  // it. Count display names so genuine duplicates (same cluster name from
205
197
  // different kubeconfig sources) stay distinguishable; unique ones stay clean.
206
198
  const parsedCtx = contexts.map((ctx) => {
207
- const parsed = parseContextName(
208
- stripSourceSuffix(ctx.name, ctx.source),
209
- );
199
+ const parsed = parseContextForSwitcher(ctx);
210
200
  const fromCluster = ctx.cluster ? parseContextName(ctx.cluster) : null;
211
201
  const meta = [
212
202
  parsed.provider ?? fromCluster?.provider,
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { gitOpsOwnerFromRelationships } from '@skyhook-io/k8s-ui'
4
+ import type { Relationships } from '../../types'
5
+ import { findInheritedGitOpsLookupRef } from './WorkloadView'
6
+
7
+ describe('findInheritedGitOpsLookupRef', () => {
8
+ it('follows a referenced ReplicaSet to its parent workload for inherited ownership', () => {
9
+ const relationships: Relationships = {
10
+ managedBy: [
11
+ {
12
+ kind: 'Deployment',
13
+ group: 'apps',
14
+ namespace: 'prod',
15
+ name: 'web',
16
+ },
17
+ ],
18
+ }
19
+
20
+ expect(
21
+ findInheritedGitOpsLookupRef(relationships, null, {
22
+ kind: 'ReplicaSet',
23
+ group: 'apps',
24
+ namespace: 'prod',
25
+ name: 'web-7b8d9',
26
+ }),
27
+ ).toEqual({
28
+ kind: 'Deployment',
29
+ group: 'apps',
30
+ namespace: 'prod',
31
+ name: 'web',
32
+ })
33
+ })
34
+
35
+ it('does not fetch a parent when the target has a direct GitOps owner', () => {
36
+ const relationships: Relationships = {
37
+ managedBy: [
38
+ {
39
+ kind: 'Application',
40
+ group: 'argoproj.io',
41
+ namespace: 'argocd',
42
+ name: 'web',
43
+ },
44
+ ],
45
+ }
46
+
47
+ expect(
48
+ findInheritedGitOpsLookupRef(
49
+ relationships,
50
+ gitOpsOwnerFromRelationships(relationships),
51
+ {
52
+ kind: 'Deployment',
53
+ group: 'apps',
54
+ namespace: 'prod',
55
+ name: 'web',
56
+ },
57
+ ),
58
+ ).toBeNull()
59
+ })
60
+ })