@skyhook-io/radar-app 1.15.0 → 1.16.1
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 +14 -11
- 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.capacity.test.ts +15 -0
- package/src/api/client.trace.test.ts +9 -1
- package/src/api/client.ts +247 -201
- 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 +223 -0
- package/src/api/radarFeatures.test.ts +142 -0
- package/src/api/radarFeatures.ts +148 -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/components/CloudFunnelButton.tsx +9 -9
- package/src/components/NamespaceSwitcher.test.tsx +47 -0
- package/src/components/NamespaceSwitcher.tsx +17 -1
- package/src/components/SelfManagedStart.tsx +5 -6
- 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/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/dock/NodeTerminalTab.tsx +2 -1
- package/src/components/dock/TerminalTab.tsx +2 -1
- package/src/components/dock/TrafficFlowListTab.tsx +2 -2
- package/src/components/gitops/GitOpsView.tsx +12 -0
- package/src/components/home/HomeView.tsx +1 -1
- package/src/components/home/MCPSetupDialog.tsx +37 -37
- 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/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/PrometheusConfigField.tsx +30 -0
- package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
- package/src/components/settings/SettingsDialog.tsx +382 -521
- 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 +20 -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/Omnibar.tsx +1 -1
- package/src/components/ui/UpdateNotification.tsx +57 -12
- package/src/components/workload/WorkloadView.tsx +44 -6
- 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
|
@@ -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).
|