@skyhook-io/radar-app 1.14.6 → 1.16.0

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 (128) hide show
  1. package/README.md +2 -0
  2. package/package.json +6 -5
  3. package/src/App.tsx +65 -17
  4. package/src/RadarApp.tsx +51 -21
  5. package/src/api/apiResources.test.ts +19 -1
  6. package/src/api/apiResources.ts +18 -3
  7. package/src/api/client.admission.test.ts +34 -0
  8. package/src/api/client.capacity.test.ts +15 -0
  9. package/src/api/client.trace.test.ts +9 -1
  10. package/src/api/client.ts +269 -204
  11. package/src/api/diagnose.test.ts +102 -1
  12. package/src/api/diagnose.ts +42 -11
  13. package/src/api/drain-plan.test.ts +0 -10
  14. package/src/api/httpErrors.test.ts +95 -0
  15. package/src/api/httpErrors.ts +101 -0
  16. package/src/api/policy.ts +33 -5
  17. package/src/api/radarFeatures.render.test.tsx +190 -0
  18. package/src/api/radarFeatures.test.ts +133 -0
  19. package/src/api/radarFeatures.ts +147 -0
  20. package/src/api/timelineSource.test.ts +2 -10
  21. package/src/api/timelineSource.ts +3 -2
  22. package/src/api/traffic.ts +4 -3
  23. package/src/api/usage-data.fixtures.ts +33 -0
  24. package/src/api/usage-data.ts +165 -0
  25. package/src/components/CloudFunnelButton.test.tsx +2 -2
  26. package/src/components/CloudFunnelButton.tsx +99 -55
  27. package/src/components/NamespaceSwitcher.test.tsx +47 -0
  28. package/src/components/NamespaceSwitcher.tsx +17 -1
  29. package/src/components/SelfManagedStart.tsx +95 -0
  30. package/src/components/applications/ApplicationsView.tsx +20 -6
  31. package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
  32. package/src/components/audit/UpgradeReadinessView.tsx +9 -13
  33. package/src/components/capacity/CapacityView.test.tsx +13 -4
  34. package/src/components/capacity/shared.tsx +14 -13
  35. package/src/components/cloudConnectHandoff.test.ts +1 -1
  36. package/src/components/cloudConnectHandoff.ts +1 -1
  37. package/src/components/cost/ApplicationCostTab.tsx +9 -1
  38. package/src/components/cost/CostConnectionAction.tsx +30 -0
  39. package/src/components/cost/CostTrendChart.tsx +2 -0
  40. package/src/components/cost/CostView.tsx +4 -24
  41. package/src/components/cost/WorkloadCostTab.tsx +7 -1
  42. package/src/components/cost/source.test.ts +30 -3
  43. package/src/components/cost/source.ts +37 -2
  44. package/src/components/curl/ServiceCurlButton.tsx +7 -3
  45. package/src/components/diagnose/ActivityTurn.tsx +3 -2
  46. package/src/components/diagnose/ApplyDialog.tsx +4 -2
  47. package/src/components/dialog-names.test.ts +69 -0
  48. package/src/components/dock/NodeTerminalTab.tsx +2 -1
  49. package/src/components/dock/TerminalTab.tsx +2 -1
  50. package/src/components/dock/TrafficFlowListTab.tsx +2 -2
  51. package/src/components/execution/BatchExecutionView.tsx +1 -1
  52. package/src/components/execution/JobSetAdmission.test.tsx +160 -37
  53. package/src/components/execution/JobSetAdmission.tsx +16 -7
  54. package/src/components/gitops/GitOpsView.tsx +12 -0
  55. package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
  56. package/src/components/home/HomeView.tsx +7 -3
  57. package/src/components/home/MCPSetupDialog.tsx +37 -37
  58. package/src/components/home/RadarVersionLine.test.tsx +14 -0
  59. package/src/components/home/RadarVersionLine.tsx +28 -3
  60. package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
  61. package/src/components/nav/PrimaryNavRail.tsx +25 -3
  62. package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
  63. package/src/components/resource/PVCUsageBar.tsx +4 -1
  64. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
  65. package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
  66. package/src/components/resources/ImageFilesystemModal.tsx +2 -1
  67. package/src/components/resources/PodFilePreview.tsx +4 -1
  68. package/src/components/resources/PodFilesystemModal.tsx +3 -2
  69. package/src/components/resources/ResourcesView.tsx +2 -1
  70. package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
  71. package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
  72. package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
  73. package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
  74. package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
  75. package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
  76. package/src/components/settings/FormSaveActions.tsx +76 -0
  77. package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
  78. package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
  79. package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
  80. package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
  81. package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
  82. package/src/components/settings/PrivacySection.test.tsx +84 -0
  83. package/src/components/settings/PrivacySection.tsx +140 -0
  84. package/src/components/settings/PrometheusConfigField.tsx +30 -0
  85. package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
  86. package/src/components/settings/SettingsDialog.tsx +393 -571
  87. package/src/components/settings/controls.tsx +55 -0
  88. package/src/components/settings/currency-options.test.ts +1 -1
  89. package/src/components/settings/currency-options.ts +1 -1
  90. package/src/components/settings/settings-state.test.ts +17 -10
  91. package/src/components/settings/settings-state.ts +21 -12
  92. package/src/components/timeline/TimelineList.test.ts +56 -0
  93. package/src/components/timeline/TimelineList.tsx +7 -1
  94. package/src/components/traffic/TrafficFlowList.tsx +5 -21
  95. package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
  96. package/src/components/traffic/TrafficGraph.tsx +79 -48
  97. package/src/components/traffic/TrafficView.tsx +61 -37
  98. package/src/components/traffic/TrafficWizard.tsx +4 -2
  99. package/src/components/traffic/trafficFilters.test.ts +154 -1
  100. package/src/components/traffic/trafficFilters.ts +141 -0
  101. package/src/components/ui/ErrorBoundary.tsx +5 -0
  102. package/src/components/ui/Omnibar.tsx +17 -1
  103. package/src/components/ui/UpdateNotification.tsx +70 -21
  104. package/src/components/ui/command-items.ts +14 -0
  105. package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
  106. package/src/components/usage-data/UsageDataAsk.tsx +106 -0
  107. package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
  108. package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
  109. package/src/components/whats-new/WhatsNew.test.ts +110 -0
  110. package/src/components/whats-new/WhatsNew.tsx +423 -0
  111. package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
  112. package/src/components/whats-new/check-whats-new.test.ts +65 -0
  113. package/src/components/whats-new/releaseNotes.ts +121 -0
  114. package/src/components/workload/WorkloadView.tsx +47 -14
  115. package/src/components/workload/historyPaging.test.ts +72 -0
  116. package/src/components/workload/historyPaging.ts +147 -0
  117. package/src/components/workload/useHistoryPaging.test.tsx +81 -0
  118. package/src/context/ConnectionContext.tsx +2 -1
  119. package/src/context/RadarUpgrade.tsx +30 -0
  120. package/src/context/RadarUpgradeHost.tsx +23 -0
  121. package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
  122. package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
  123. package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
  124. package/src/index.ts +1 -0
  125. package/src/k8s-ui-exports.test.ts +73 -0
  126. package/src/utils/navigation.test.ts +17 -1
  127. package/src/utils/navigation.ts +16 -0
  128. package/src/utils/version.ts +11 -0
@@ -0,0 +1,30 @@
1
+ import { useMemo, type ReactNode } from 'react'
2
+ import { RadarUpgradeContext, type RadarUpgradeActions } from '@skyhook-io/k8s-ui'
3
+ import { useCapabilities, useVersionCheck } from '../api/client'
4
+ import { IN_CLUSTER_UPGRADE_URL } from '../utils/version'
5
+ import { useRadarUpgradeHost } from './RadarUpgradeHost'
6
+
7
+ /**
8
+ * Supplies the "Upgrade Radar" action to the inline notes k8s-ui renders for
9
+ * features the connected Radar is too old to serve. A host that owns upgrades
10
+ * (Radar Cloud) gets the request; standalone Radar links to the instructions
11
+ * for how it was installed.
12
+ */
13
+ export function RadarUpgradeProvider({ children }: { children: ReactNode }) {
14
+ const { onRequestRadarUpgrade } = useRadarUpgradeHost()
15
+ const { data: capabilities } = useCapabilities()
16
+ const { data: versionInfo } = useVersionCheck()
17
+ const mode = capabilities?.deployment?.mode ?? 'local'
18
+ // A local binary upgrades from its release page; an in-cluster or Cloud
19
+ // agent from the docs section that covers Helm, Argo CD and Flux installs.
20
+ const upgradeHref = mode === 'local' && versionInfo?.releaseUrl
21
+ ? versionInfo.releaseUrl
22
+ : `${IN_CLUSTER_UPGRADE_URL}#upgrading`
23
+
24
+ const value = useMemo<RadarUpgradeActions>(
25
+ () => (onRequestRadarUpgrade ? { onRequestUpgrade: onRequestRadarUpgrade } : { upgradeHref }),
26
+ [onRequestRadarUpgrade, upgradeHref],
27
+ )
28
+
29
+ return <RadarUpgradeContext.Provider value={value}>{children}</RadarUpgradeContext.Provider>
30
+ }
@@ -0,0 +1,23 @@
1
+ import { createContext, useContext } from 'react'
2
+ import type { RadarUpgradeRequirement } from '@skyhook-io/k8s-ui'
3
+
4
+ /** What the user is missing and which Radar serves it; see RadarUpgradeRequirement. */
5
+ export type RadarUpgradeRequest = RadarUpgradeRequirement
6
+
7
+ /**
8
+ * What an embedding host knows about the connected Radar and how it upgrades
9
+ * it: RadarApp's `radarVersion` and `onRequestRadarUpgrade` props. Kept apart
10
+ * from RadarUpgrade.tsx so api/client.ts can read it without an import cycle.
11
+ */
12
+ export interface RadarUpgradeHost {
13
+ radarVersion?: string
14
+ onRequestRadarUpgrade?: (request: RadarUpgradeRequest) => void
15
+ }
16
+
17
+ const RadarUpgradeHostContext = createContext<RadarUpgradeHost>({})
18
+
19
+ export const RadarUpgradeHostProvider = RadarUpgradeHostContext.Provider
20
+
21
+ export function useRadarUpgradeHost(): RadarUpgradeHost {
22
+ return useContext(RadarUpgradeHostContext)
23
+ }
@@ -0,0 +1,62 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
5
+ import { afterEach, expect, it, vi } from 'vitest'
6
+ import { usePreviousIntegrationSettings, previousIntegrationSettingsKey } from './usePreviousIntegrationSettings'
7
+
8
+ const state = vi.hoisted(() => ({ context: 'first', switching: false, embedded: false, management: 'local', connected: true }))
9
+ vi.mock('../context/ConnectionContext', () => ({ useConnection: () => ({ connection: { context: state.context, state: state.connected ? 'connected' : 'disconnected' } }) }))
10
+ vi.mock('../context/ContextSwitchContext', () => ({ useContextSwitch: () => ({ isSwitching: state.switching }) }))
11
+ vi.mock('../context/NavCustomization', () => ({ useNavCustomization: () => ({ embedded: state.embedded }) }))
12
+ vi.mock('../contexts/CapabilitiesContext', () => ({ useCapabilitiesContext: () => ({ configManagement: state.management }) }))
13
+ vi.mock('../api/client', () => ({ fetchJSON: vi.fn() }))
14
+ import { fetchJSON } from '../api/client'
15
+
16
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
17
+ const element = document.createElement('div')
18
+ let root = createRoot(element)
19
+ let client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
20
+ function Subject({ relevant = true }: { relevant?: boolean }) {
21
+ const offers = usePreviousIntegrationSettings(relevant)
22
+ return <span>{offers.metrics ? 'offer' : 'ordinary'}</span>
23
+ }
24
+ async function render(relevant = true) {
25
+ await act(async () => { root.render(<QueryClientProvider client={client}><Subject relevant={relevant} /></QueryClientProvider>) })
26
+ }
27
+ afterEach(async () => {
28
+ await act(async () => root.unmount())
29
+ client.clear()
30
+ client = new QueryClient()
31
+ root = createRoot(element)
32
+ Object.assign(state, { context: 'first', switching: false, embedded: false, management: 'local', connected: true })
33
+ vi.mocked(fetchJSON).mockReset()
34
+ })
35
+
36
+ it.each(['embedded', 'operator', 'cloud', 'loading', 'switching', 'disconnected', 'irrelevant'])('does not fetch or display cached offers when %s', async gate => {
37
+ if (gate === 'embedded') state.embedded = true
38
+ if (['operator', 'cloud', 'loading'].includes(gate)) state.management = gate === 'loading' ? '' : gate
39
+ if (gate === 'switching') state.switching = true
40
+ if (gate === 'disconnected') state.connected = false
41
+ client.setQueryData([previousIntegrationSettingsKey, '/api', 'first'], { metrics: 'previous' })
42
+ await render(gate !== 'irrelevant')
43
+ expect(element.textContent).toBe('ordinary')
44
+ expect(fetchJSON).not.toHaveBeenCalled()
45
+ })
46
+
47
+ it('drops a cached offer immediately while switching and aborts the old fetch', async () => {
48
+ const signals: AbortSignal[] = []
49
+ vi.mocked(fetchJSON).mockImplementation((_path, signal) => { signals.push(signal as AbortSignal); return new Promise(() => {}) })
50
+ client.setQueryData([previousIntegrationSettingsKey, '/api', 'first'], { metrics: 'previous' }, { updatedAt: 1 })
51
+ await render()
52
+ expect(element.textContent).toBe('offer')
53
+ state.switching = true
54
+ await render()
55
+ expect(element.textContent).toBe('ordinary')
56
+ state.context = 'second'
57
+ state.switching = false
58
+ await render()
59
+ expect(element.textContent).toBe('ordinary')
60
+ expect(signals[0].aborted).toBe(true)
61
+ expect(signals).toHaveLength(2)
62
+ })
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { previousIntegrationOffers, type IntegrationSettingsResponse } from './usePreviousIntegrationSettings'
3
+ import type { ConnectionResponse, IntegrationProfile, IntegrationProfiles } from '../components/settings/LocalConnectionSettings'
4
+
5
+ function response(overrides: Partial<IntegrationProfile> = {}): IntegrationSettingsResponse {
6
+ const profile = { target: { context: 'development' }, state: 'auto', legacy: { url: 'https://backend.example', mode: 'auto' }, ...overrides } as IntegrationProfile
7
+ return { management: 'local', integrationProfiles: { metrics: profile, argocd: profile, cost: profile } as IntegrationProfiles }
8
+ }
9
+
10
+ describe('previous integration eligibility projection', () => {
11
+ it('keeps only eligibility and routing facts, never URLs or credentials', () => {
12
+ expect(previousIntegrationOffers(response(), 'development')).toEqual({ metrics: 'previous', argocd: 'previous', cost: 'previous', explicitCostBackend: true })
13
+ })
14
+ it.each(['operator', 'cloud'] as const)('rejects %s management', management => {
15
+ expect(Object.values(previousIntegrationOffers({ ...response(), management }, 'development')).some(Boolean)).toBe(false)
16
+ })
17
+ it.each(['saved', 'launch', 'target_changed', 'error'] as const)('rejects %s profiles', state => {
18
+ expect(previousIntegrationOffers(response({ state }), 'development').metrics).toBeUndefined()
19
+ })
20
+ it('rejects missing offers, wrong context and errored bundles', () => {
21
+ expect(previousIntegrationOffers(response({ legacy: undefined }), 'development').metrics).toBeUndefined()
22
+ expect(previousIntegrationOffers(response(), 'staging').metrics).toBeUndefined()
23
+ expect(previousIntegrationOffers(response(), '').metrics).toBeUndefined()
24
+ const data = response()
25
+ data.integrationProfiles!.metrics.legacy!.error = 'invalid credentials binding'
26
+ expect(previousIntegrationOffers(data, 'development').metrics).toBeUndefined()
27
+ })
28
+ it('offers another cluster\'s saved settings once no previous settings apply', () => {
29
+ const entry = { integration: 'metrics', binding: 'staging', url: 'https://mimir.example' } as ConnectionResponse['connections'][number]
30
+ const data = response({ legacy: undefined, target: { context: 'development', binding: 'development' } as IntegrationProfile['target'] })
31
+ expect(previousIntegrationOffers(data, 'development', [entry]).metrics).toBe('copy')
32
+ expect(previousIntegrationOffers(data, 'development', [{ ...entry, binding: 'development' }]).metrics).toBeUndefined()
33
+ expect(previousIntegrationOffers(data, 'development', [{ ...entry, url: '' }]).metrics).toBeUndefined()
34
+ expect(previousIntegrationOffers(data, 'development', [{ ...entry, error: 'invalid' }]).metrics).toBeUndefined()
35
+ expect(previousIntegrationOffers(response(), 'development', [entry]).metrics).toBe('previous')
36
+ expect(previousIntegrationOffers(response({ state: 'saved', legacy: undefined }), 'development', [entry]).metrics).toBeUndefined()
37
+ })
38
+ it('stops reminding after the notice is dismissed, falling back to copy offers', () => {
39
+ const entry = { integration: 'metrics', binding: 'staging', url: 'https://mimir.example' } as ConnectionResponse['connections'][number]
40
+ const data = response({ target: { context: 'development', binding: 'development' } as IntegrationProfile['target'] })
41
+ for (const kind of ['metrics', 'argocd', 'cost'] as const)
42
+ data.integrationProfiles![kind] = { ...data.integrationProfiles![kind], legacy: { ...data.integrationProfiles![kind].legacy!, noticeDismissed: true } }
43
+ expect(previousIntegrationOffers(data, 'development').metrics).toBeUndefined()
44
+ expect(previousIntegrationOffers(data, 'development', [entry]).metrics).toBe('copy')
45
+ })
46
+ it('never nudges Argo CD toward another cluster\'s server', () => {
47
+ const entry = { integration: 'argocd', binding: 'staging', url: 'https://argo.example' } as ConnectionResponse['connections'][number]
48
+ const data = response({ legacy: undefined, target: { context: 'development', binding: 'development' } as IntegrationProfile['target'] })
49
+ expect(previousIntegrationOffers(data, 'development', [entry]).argocd).toBeUndefined()
50
+ })
51
+ it.each([
52
+ ['auto', 'https://cost.example', true],
53
+ ['kubecost', 'https://cost.example', true],
54
+ ['prometheus', 'https://cost.example', false],
55
+ ['auto', '', false],
56
+ ['kubecost', '', false],
57
+ ])('identifies the independent Cost alternative for %s / %s', (mode, url, expected) => {
58
+ const data = response()
59
+ Object.assign(data.integrationProfiles!.cost.legacy!, { mode, url })
60
+ expect(previousIntegrationOffers(data, 'development').explicitCostBackend).toBe(expected)
61
+ })
62
+ })
@@ -0,0 +1,89 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { fetchJSON } from '../api/client'
3
+ import { getApiBase } from '../api/config'
4
+ import { useConnection } from '../context/ConnectionContext'
5
+ import { useContextSwitch } from '../context/ContextSwitchContext'
6
+ import { useNavCustomization } from '../context/NavCustomization'
7
+ import { useCapabilitiesContext } from '../contexts/CapabilitiesContext'
8
+ import type { ConnectionResponse, IntegrationKind, IntegrationProfiles } from '../components/settings/LocalConnectionSettings'
9
+
10
+ export interface IntegrationSettingsResponse {
11
+ management: 'local' | 'operator' | 'cloud'
12
+ integrationProfiles?: IntegrationProfiles
13
+ }
14
+
15
+ // 'previous' offers this context the old global settings; 'copy' points to
16
+ // another cluster's saved settings once those are the way to reuse them.
17
+ export type SettingsOffer = 'previous' | 'copy'
18
+
19
+ export interface PreviousIntegrationSettings {
20
+ metrics?: SettingsOffer
21
+ argocd?: SettingsOffer
22
+ cost?: SettingsOffer
23
+ explicitCostBackend: boolean
24
+ }
25
+
26
+ const noOffers: PreviousIntegrationSettings = { explicitCostBackend: false }
27
+ export const previousIntegrationSettingsKey = 'previous-integration-settings'
28
+
29
+ export function previousIntegrationOffers(
30
+ response: IntegrationSettingsResponse,
31
+ context: string,
32
+ catalog?: ConnectionResponse['connections'],
33
+ ): PreviousIntegrationSettings {
34
+ if (response.management !== 'local' || !context) return noOffers
35
+ const offer = (kind: IntegrationKind): SettingsOffer | undefined => {
36
+ const profile = response.integrationProfiles?.[kind]
37
+ if (profile?.target.context !== context || profile.state !== 'auto') return undefined
38
+ if (profile.legacy && !profile.legacy.error && !profile.legacy.noticeDismissed) return 'previous'
39
+ // Another cluster's Argo CD server manages that cluster's Applications, so
40
+ // copying it is rarely the fix for this one; Settings still offers it.
41
+ if (kind === 'argocd') return undefined
42
+ return catalog?.some(entry => entry.integration === kind && !entry.error && entry.url && entry.binding !== profile.target.binding)
43
+ ? 'copy' : undefined
44
+ }
45
+ const cost = offer('cost')
46
+ const legacyCost = response.integrationProfiles?.cost.legacy
47
+ return {
48
+ metrics: offer('metrics'), argocd: offer('argocd'), cost,
49
+ explicitCostBackend: cost === 'copy' ||
50
+ (cost === 'previous' && !!legacyCost?.url && (legacyCost.mode === 'auto' || legacyCost.mode === 'kubecost')),
51
+ }
52
+ }
53
+
54
+ async function loadOffers(context: string, signal: AbortSignal): Promise<PreviousIntegrationSettings> {
55
+ const response = await fetchJSON<IntegrationSettingsResponse>('/config', signal)
56
+ const offers = previousIntegrationOffers(response, context)
57
+ const unoffered = (['metrics', 'argocd', 'cost'] as const).some(kind =>
58
+ !offers[kind] && response.integrationProfiles?.[kind]?.state === 'auto')
59
+ if (response.management !== 'local' || !unoffered) return offers
60
+ const catalog = await fetchJSON<ConnectionResponse>('/integrations/connections', signal)
61
+ .then(data => data.connections, () => undefined)
62
+ return previousIntegrationOffers(response, context, catalog)
63
+ }
64
+
65
+ export function usePreviousIntegrationSettings(relevant: boolean): PreviousIntegrationSettings {
66
+ const { connection } = useConnection()
67
+ const { isSwitching } = useContextSwitch()
68
+ const { embedded } = useNavCustomization()
69
+ const { configManagement } = useCapabilitiesContext()
70
+ const enabled = relevant && !embedded && configManagement === 'local' && connection.state === 'connected' && !isSwitching && !!connection.context
71
+ const query = useQuery({
72
+ queryKey: [previousIntegrationSettingsKey, getApiBase(), connection.context],
73
+ queryFn: ({ signal }) => loadOffers(connection.context, signal),
74
+ enabled,
75
+ retry: false,
76
+ staleTime: 30_000,
77
+ })
78
+ return enabled && !query.isError ? query.data ?? noOffers : noOffers
79
+ }
80
+
81
+ const settingsTabs: Record<IntegrationKind, string> = { metrics: 'Metrics', argocd: 'Argo CD', cost: 'Cost' }
82
+
83
+ // Only a note: call sites keep their neutral action, because another cluster's
84
+ // backend connects fine while showing the wrong cluster's data.
85
+ export function previousSettingsNote(kind: IntegrationKind, offer: SettingsOffer): string {
86
+ return offer === 'copy'
87
+ ? 'Settings saved for another cluster are available to copy if that backend also serves this cluster.'
88
+ : `Connections are now saved per cluster. You can copy your previous settings in Settings → ${settingsTabs[kind]}.`
89
+ }
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ export {
16
16
  getCredentialsMode,
17
17
  } from './api/config';
18
18
  export type { NavCustomization, FleetTakeoverTarget } from './context/NavCustomization';
19
+ export type { RadarUpgradeRequest } from './context/RadarUpgradeHost';
19
20
  // Timeline data-source selection — lets an embedder back the timeline with a
20
21
  // retained-history endpoint instead of Radar's local event store. Additive;
21
22
  // absent = local (standalone behavior).
@@ -0,0 +1,73 @@
1
+ /// <reference types="node" />
2
+ import { describe, expect, test } from 'vitest'
3
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
4
+ import { dirname, join, resolve } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ // Radar's own build aliases @skyhook-io/k8s-ui to its source, so a deep
8
+ // import that the package's `exports` map can't serve still builds here and
9
+ // only fails in a consumer that installs the published packages (Radar Hub).
10
+ // This resolves every deep import the way Node and bundlers do: the most
11
+ // specific matching key wins, and an array target resolves to its first entry
12
+ // without falling back when that file doesn't exist.
13
+
14
+ const here = dirname(fileURLToPath(import.meta.url))
15
+ const k8sUiRoot = resolve(here, '../../packages/k8s-ui')
16
+ const exportsMap: Record<string, string | string[]> = JSON.parse(
17
+ readFileSync(join(k8sUiRoot, 'package.json'), 'utf8'),
18
+ ).exports
19
+
20
+ function resolveExport(subpath: string): string | undefined {
21
+ const key = `./${subpath}`
22
+ let target = exportsMap[key]
23
+ let star: string | undefined
24
+ if (target === undefined) {
25
+ let best = ''
26
+ for (const pattern of Object.keys(exportsMap)) {
27
+ const i = pattern.indexOf('*')
28
+ if (i < 0) continue
29
+ const prefix = pattern.slice(0, i)
30
+ const suffix = pattern.slice(i + 1)
31
+ if (key.startsWith(prefix) && key.endsWith(suffix) && key.length >= prefix.length + suffix.length && prefix.length > best.length) {
32
+ best = prefix
33
+ target = exportsMap[pattern]
34
+ star = key.slice(prefix.length, key.length - suffix.length)
35
+ }
36
+ }
37
+ }
38
+ if (target === undefined) return undefined
39
+ const first = Array.isArray(target) ? target[0] : target
40
+ return join(k8sUiRoot, star === undefined ? first : first.split('*').join(star))
41
+ }
42
+
43
+ // Everything radar-app publishes that can import from k8s-ui, stylesheets
44
+ // included (vitest doesn't load CSS, so these are read from disk).
45
+ function sourceFiles(dir: string): string[] {
46
+ return readdirSync(dir).flatMap((name) => {
47
+ const path = join(dir, name)
48
+ if (statSync(path).isDirectory()) return sourceFiles(path)
49
+ return /\.(ts|tsx|css)$/.test(name) && !/\.test\.(ts|tsx)$/.test(name) ? [path] : []
50
+ })
51
+ }
52
+
53
+ describe('@skyhook-io/k8s-ui deep imports', () => {
54
+ const imports = new Set<string>()
55
+ for (const file of sourceFiles(here)) {
56
+ // Any quoted specifier: `from` imports, side-effect and dynamic imports,
57
+ // and stylesheet @imports all resolve through the same map.
58
+ for (const m of readFileSync(file, 'utf8').matchAll(/['"]@skyhook-io\/k8s-ui\/([^'"]+)['"]/g)) {
59
+ imports.add(m[1])
60
+ }
61
+ }
62
+
63
+ test('finds the deep imports it checks, stylesheets included', () => {
64
+ expect(imports.size).toBeGreaterThan(10)
65
+ expect(imports).toContain('theme/variables.css')
66
+ })
67
+
68
+ test.each([...imports].sort())('%s resolves through the published exports map', (subpath) => {
69
+ const file = resolveExport(subpath)
70
+ expect(file, `no exports entry serves "@skyhook-io/k8s-ui/${subpath}"`).toBeDefined()
71
+ expect(existsSync(file!), `"@skyhook-io/k8s-ui/${subpath}" resolves to missing ${file}`).toBe(true)
72
+ })
73
+ })
@@ -4,7 +4,7 @@ import {
4
4
  initNavigationMap,
5
5
  resetNavigationMap,
6
6
  } from '@skyhook-io/k8s-ui/utils/navigation'
7
- import { getNetworkPolicyResourceTarget, relatedResourcePath, resourcePath } from './navigation'
7
+ import { getNetworkPolicyResourceTarget, relatedResourcePath, resourcePath, withCrossViewParams } from './navigation'
8
8
 
9
9
  afterEach(resetNavigationMap)
10
10
 
@@ -60,3 +60,19 @@ it('uses the Kubernetes kind for a Calico aggregate target', () => {
60
60
  expect(getNetworkPolicyResourceTarget({nodes: [{id: 'policy', kind: 'CalicoNetworkPolicy', name: 'allow', status: 'healthy', data: {apiVersion: 'crd.projectcalico.org/v1', resourceKind: 'NetworkPolicy'}}], edges: []} as unknown as Topology))
61
61
  .toEqual({kind: 'networkpolicies', group: 'crd.projectcalico.org'})
62
62
  })
63
+
64
+ describe('withCrossViewParams', () => {
65
+ it('carries the namespace pick and open investigation onto a bare path', () => {
66
+ expect(withCrossViewParams('/gitops/argo/argocd/app', '?namespaces=a,b&ai-run=r1&tab=yaml'))
67
+ .toBe('/gitops/argo/argocd/app?namespaces=a%2Cb&ai-run=r1')
68
+ })
69
+
70
+ it('keeps what the path sets itself, and its hash', () => {
71
+ expect(withCrossViewParams('/resources/pods?namespaces=x#top', '?namespaces=a&ai-run=r1'))
72
+ .toBe('/resources/pods?namespaces=x&ai-run=r1#top')
73
+ })
74
+
75
+ it('leaves the path alone when nothing is scoped', () => {
76
+ expect(withCrossViewParams('/helm', '')).toBe('/helm')
77
+ })
78
+ })
@@ -133,3 +133,19 @@ export function openExternal(url: string): void {
133
133
  window.open(url, '_blank')
134
134
  })
135
135
  }
136
+
137
+ // setMainView's cross-view params. A navigation whose search omits
138
+ // ?namespaces= reads as an empty pick to App's URL sync, which clears the
139
+ // user's namespace scope.
140
+ const CROSS_VIEW_PARAMS = ['namespaces', 'ai-run'] as const
141
+
142
+ /** Carries the current cross-view params onto an in-app path that doesn't set them itself. */
143
+ export function withCrossViewParams(path: string, currentSearch: string): string {
144
+ const destination = new URL(path, 'http://radar.invalid')
145
+ const current = new URLSearchParams(currentSearch)
146
+ for (const key of CROSS_VIEW_PARAMS) {
147
+ const value = current.get(key)
148
+ if (value && !destination.searchParams.has(key)) destination.searchParams.set(key, value)
149
+ }
150
+ return `${destination.pathname}${destination.search}${destination.hash}`
151
+ }
@@ -32,6 +32,17 @@ function parseVersion(version: string): [major: number, minor: number, patch: nu
32
32
  return [Number(match[1]), Number(match[2]), Number(match[3]), !!match[4]]
33
33
  }
34
34
 
35
+ /** Semver order (a prerelease sorts before its release); null when either side isn't a version. */
36
+ export function compareVersions(a: string, b: string): number | null {
37
+ const x = parseVersion(a)
38
+ const y = parseVersion(b)
39
+ if (!x || !y) return null
40
+ for (let i = 0; i < 3; i++) {
41
+ if (x[i] !== y[i]) return (x[i] as number) - (y[i] as number)
42
+ }
43
+ return Number(y[3]) - Number(x[3])
44
+ }
45
+
35
46
  export function getVersionUpdateStatus(current: string, latest?: string): VersionUpdateStatus {
36
47
  if (!latest) return { tier: 'none' }
37
48