@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.
- package/README.md +2 -0
- package/package.json +6 -5
- package/src/App.tsx +65 -17
- package/src/RadarApp.tsx +51 -21
- package/src/api/apiResources.test.ts +19 -1
- package/src/api/apiResources.ts +18 -3
- package/src/api/client.admission.test.ts +34 -0
- package/src/api/client.capacity.test.ts +15 -0
- package/src/api/client.trace.test.ts +9 -1
- package/src/api/client.ts +269 -204
- package/src/api/diagnose.test.ts +102 -1
- package/src/api/diagnose.ts +42 -11
- package/src/api/drain-plan.test.ts +0 -10
- package/src/api/httpErrors.test.ts +95 -0
- package/src/api/httpErrors.ts +101 -0
- package/src/api/policy.ts +33 -5
- package/src/api/radarFeatures.render.test.tsx +190 -0
- package/src/api/radarFeatures.test.ts +133 -0
- package/src/api/radarFeatures.ts +147 -0
- package/src/api/timelineSource.test.ts +2 -10
- package/src/api/timelineSource.ts +3 -2
- package/src/api/traffic.ts +4 -3
- package/src/api/usage-data.fixtures.ts +33 -0
- package/src/api/usage-data.ts +165 -0
- package/src/components/CloudFunnelButton.test.tsx +2 -2
- package/src/components/CloudFunnelButton.tsx +99 -55
- package/src/components/NamespaceSwitcher.test.tsx +47 -0
- package/src/components/NamespaceSwitcher.tsx +17 -1
- package/src/components/SelfManagedStart.tsx +95 -0
- package/src/components/applications/ApplicationsView.tsx +20 -6
- package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
- package/src/components/audit/UpgradeReadinessView.tsx +9 -13
- package/src/components/capacity/CapacityView.test.tsx +13 -4
- package/src/components/capacity/shared.tsx +14 -13
- package/src/components/cloudConnectHandoff.test.ts +1 -1
- package/src/components/cloudConnectHandoff.ts +1 -1
- package/src/components/cost/ApplicationCostTab.tsx +9 -1
- package/src/components/cost/CostConnectionAction.tsx +30 -0
- package/src/components/cost/CostTrendChart.tsx +2 -0
- package/src/components/cost/CostView.tsx +4 -24
- package/src/components/cost/WorkloadCostTab.tsx +7 -1
- package/src/components/cost/source.test.ts +30 -3
- package/src/components/cost/source.ts +37 -2
- package/src/components/curl/ServiceCurlButton.tsx +7 -3
- package/src/components/diagnose/ActivityTurn.tsx +3 -2
- package/src/components/diagnose/ApplyDialog.tsx +4 -2
- package/src/components/dialog-names.test.ts +69 -0
- package/src/components/dock/NodeTerminalTab.tsx +2 -1
- package/src/components/dock/TerminalTab.tsx +2 -1
- package/src/components/dock/TrafficFlowListTab.tsx +2 -2
- package/src/components/execution/BatchExecutionView.tsx +1 -1
- package/src/components/execution/JobSetAdmission.test.tsx +160 -37
- package/src/components/execution/JobSetAdmission.tsx +16 -7
- package/src/components/gitops/GitOpsView.tsx +12 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
- package/src/components/home/HomeView.tsx +7 -3
- package/src/components/home/MCPSetupDialog.tsx +37 -37
- package/src/components/home/RadarVersionLine.test.tsx +14 -0
- package/src/components/home/RadarVersionLine.tsx +28 -3
- package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
- package/src/components/nav/PrimaryNavRail.tsx +25 -3
- package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
- package/src/components/resource/PVCUsageBar.tsx +4 -1
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
- package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
- package/src/components/resources/ImageFilesystemModal.tsx +2 -1
- package/src/components/resources/PodFilePreview.tsx +4 -1
- package/src/components/resources/PodFilesystemModal.tsx +3 -2
- package/src/components/resources/ResourcesView.tsx +2 -1
- package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
- package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
- package/src/components/settings/FormSaveActions.tsx +76 -0
- package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
- package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
- package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
- package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
- package/src/components/settings/PrivacySection.test.tsx +84 -0
- package/src/components/settings/PrivacySection.tsx +140 -0
- package/src/components/settings/PrometheusConfigField.tsx +30 -0
- package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
- package/src/components/settings/SettingsDialog.tsx +393 -571
- package/src/components/settings/controls.tsx +55 -0
- package/src/components/settings/currency-options.test.ts +1 -1
- package/src/components/settings/currency-options.ts +1 -1
- package/src/components/settings/settings-state.test.ts +17 -10
- package/src/components/settings/settings-state.ts +21 -12
- package/src/components/timeline/TimelineList.test.ts +56 -0
- package/src/components/timeline/TimelineList.tsx +7 -1
- package/src/components/traffic/TrafficFlowList.tsx +5 -21
- package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
- package/src/components/traffic/TrafficGraph.tsx +79 -48
- package/src/components/traffic/TrafficView.tsx +61 -37
- package/src/components/traffic/TrafficWizard.tsx +4 -2
- package/src/components/traffic/trafficFilters.test.ts +154 -1
- package/src/components/traffic/trafficFilters.ts +141 -0
- package/src/components/ui/ErrorBoundary.tsx +5 -0
- package/src/components/ui/Omnibar.tsx +17 -1
- package/src/components/ui/UpdateNotification.tsx +70 -21
- package/src/components/ui/command-items.ts +14 -0
- package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
- package/src/components/usage-data/UsageDataAsk.tsx +106 -0
- package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
- package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
- package/src/components/whats-new/WhatsNew.test.ts +110 -0
- package/src/components/whats-new/WhatsNew.tsx +423 -0
- package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
- package/src/components/whats-new/check-whats-new.test.ts +65 -0
- package/src/components/whats-new/releaseNotes.ts +121 -0
- package/src/components/workload/WorkloadView.tsx +47 -14
- package/src/components/workload/historyPaging.test.ts +72 -0
- package/src/components/workload/historyPaging.ts +147 -0
- package/src/components/workload/useHistoryPaging.test.tsx +81 -0
- package/src/context/ConnectionContext.tsx +2 -1
- package/src/context/RadarUpgrade.tsx +30 -0
- package/src/context/RadarUpgradeHost.tsx +23 -0
- package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
- package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
- package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
- package/src/index.ts +1 -0
- package/src/k8s-ui-exports.test.ts +73 -0
- package/src/utils/navigation.test.ts +17 -1
- package/src/utils/navigation.ts +16 -0
- 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
|
+
})
|
package/src/utils/navigation.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/version.ts
CHANGED
|
@@ -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
|
|