@skyhook-io/radar-app 1.15.0 → 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 (89) hide show
  1. package/README.md +2 -0
  2. package/package.json +6 -5
  3. package/src/App.tsx +14 -11
  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.capacity.test.ts +15 -0
  8. package/src/api/client.trace.test.ts +9 -1
  9. package/src/api/client.ts +232 -201
  10. package/src/api/diagnose.test.ts +102 -1
  11. package/src/api/diagnose.ts +42 -11
  12. package/src/api/drain-plan.test.ts +0 -10
  13. package/src/api/httpErrors.test.ts +95 -0
  14. package/src/api/httpErrors.ts +101 -0
  15. package/src/api/policy.ts +33 -5
  16. package/src/api/radarFeatures.render.test.tsx +190 -0
  17. package/src/api/radarFeatures.test.ts +133 -0
  18. package/src/api/radarFeatures.ts +147 -0
  19. package/src/api/timelineSource.test.ts +2 -10
  20. package/src/api/timelineSource.ts +3 -2
  21. package/src/api/traffic.ts +4 -3
  22. package/src/components/CloudFunnelButton.tsx +9 -9
  23. package/src/components/NamespaceSwitcher.test.tsx +47 -0
  24. package/src/components/NamespaceSwitcher.tsx +17 -1
  25. package/src/components/SelfManagedStart.tsx +5 -6
  26. package/src/components/applications/ApplicationsView.tsx +20 -6
  27. package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
  28. package/src/components/audit/UpgradeReadinessView.tsx +9 -13
  29. package/src/components/capacity/CapacityView.test.tsx +13 -4
  30. package/src/components/capacity/shared.tsx +14 -13
  31. package/src/components/cost/ApplicationCostTab.tsx +9 -1
  32. package/src/components/cost/CostConnectionAction.tsx +30 -0
  33. package/src/components/cost/CostTrendChart.tsx +2 -0
  34. package/src/components/cost/CostView.tsx +4 -24
  35. package/src/components/cost/WorkloadCostTab.tsx +7 -1
  36. package/src/components/cost/source.test.ts +30 -3
  37. package/src/components/cost/source.ts +37 -2
  38. package/src/components/curl/ServiceCurlButton.tsx +7 -3
  39. package/src/components/dock/NodeTerminalTab.tsx +2 -1
  40. package/src/components/dock/TerminalTab.tsx +2 -1
  41. package/src/components/dock/TrafficFlowListTab.tsx +2 -2
  42. package/src/components/gitops/GitOpsView.tsx +12 -0
  43. package/src/components/home/HomeView.tsx +1 -1
  44. package/src/components/home/MCPSetupDialog.tsx +37 -37
  45. package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
  46. package/src/components/resource/PVCUsageBar.tsx +4 -1
  47. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
  48. package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
  49. package/src/components/resources/ImageFilesystemModal.tsx +2 -1
  50. package/src/components/resources/PodFilePreview.tsx +4 -1
  51. package/src/components/resources/PodFilesystemModal.tsx +3 -2
  52. package/src/components/resources/ResourcesView.tsx +2 -1
  53. package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
  54. package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
  55. package/src/components/settings/FormSaveActions.tsx +76 -0
  56. package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
  57. package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
  58. package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
  59. package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
  60. package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
  61. package/src/components/settings/PrometheusConfigField.tsx +30 -0
  62. package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
  63. package/src/components/settings/SettingsDialog.tsx +382 -521
  64. package/src/components/settings/currency-options.test.ts +1 -1
  65. package/src/components/settings/currency-options.ts +1 -1
  66. package/src/components/settings/settings-state.test.ts +17 -10
  67. package/src/components/settings/settings-state.ts +20 -12
  68. package/src/components/timeline/TimelineList.test.ts +56 -0
  69. package/src/components/timeline/TimelineList.tsx +7 -1
  70. package/src/components/traffic/TrafficFlowList.tsx +5 -21
  71. package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
  72. package/src/components/traffic/TrafficGraph.tsx +79 -48
  73. package/src/components/traffic/TrafficView.tsx +61 -37
  74. package/src/components/traffic/TrafficWizard.tsx +4 -2
  75. package/src/components/traffic/trafficFilters.test.ts +154 -1
  76. package/src/components/traffic/trafficFilters.ts +141 -0
  77. package/src/components/ui/Omnibar.tsx +1 -1
  78. package/src/components/ui/UpdateNotification.tsx +57 -12
  79. package/src/components/workload/WorkloadView.tsx +36 -13
  80. package/src/components/workload/historyPaging.test.ts +72 -0
  81. package/src/components/workload/historyPaging.ts +147 -0
  82. package/src/components/workload/useHistoryPaging.test.tsx +81 -0
  83. package/src/context/ConnectionContext.tsx +2 -1
  84. package/src/context/RadarUpgrade.tsx +30 -0
  85. package/src/context/RadarUpgradeHost.tsx +23 -0
  86. package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
  87. package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
  88. package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
  89. 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).