@skyhook-io/radar-app 1.14.5 → 1.15.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 (117) hide show
  1. package/README.md +2 -9
  2. package/package.json +6 -6
  3. package/src/App.tsx +142 -334
  4. package/src/RadarApp.tsx +3 -8
  5. package/src/api/client.admission.test.ts +34 -0
  6. package/src/api/client.jobset.test.ts +18 -0
  7. package/src/api/client.resource-identity.test.ts +44 -0
  8. package/src/api/client.rightsizing.test.ts +7 -3
  9. package/src/api/client.ts +267 -78
  10. package/src/api/preferences.test.ts +54 -0
  11. package/src/api/preferences.ts +38 -0
  12. package/src/api/usage-data.fixtures.ts +33 -0
  13. package/src/api/usage-data.ts +165 -0
  14. package/src/components/CloudFunnelButton.test.tsx +155 -0
  15. package/src/components/CloudFunnelButton.tsx +113 -63
  16. package/src/components/ContextSwitcher.tsx +13 -132
  17. package/src/components/SelfManagedStart.tsx +96 -0
  18. package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
  19. package/src/components/applications/ApplicationsView.tsx +5 -3
  20. package/src/components/audit/AuditSettingsDialog.tsx +15 -11
  21. package/src/components/cloudConnectHandoff.test.ts +1 -1
  22. package/src/components/cloudConnectHandoff.ts +1 -1
  23. package/src/components/diagnose/ActivityTurn.tsx +3 -2
  24. package/src/components/diagnose/AgentControls.tsx +26 -3
  25. package/src/components/diagnose/ApplyDialog.tsx +4 -2
  26. package/src/components/diagnose/DiagnoseContext.tsx +1 -0
  27. package/src/components/diagnose/agentCatalog.ts +6 -0
  28. package/src/components/diagnose/launch.test.ts +23 -0
  29. package/src/components/diagnose/launch.ts +4 -0
  30. package/src/components/diagnose/parts.test.tsx +18 -0
  31. package/src/components/dialog-names.test.ts +69 -0
  32. package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
  33. package/src/components/dock/NodeTerminalTab.tsx +5 -3
  34. package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
  35. package/src/components/dock/WorkloadLogsTab.tsx +2 -1
  36. package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
  37. package/src/components/execution/BatchExecutionView.test.ts +52 -1
  38. package/src/components/execution/BatchExecutionView.tsx +324 -65
  39. package/src/components/execution/JobSetAdmission.test.tsx +165 -0
  40. package/src/components/execution/JobSetAdmission.tsx +26 -0
  41. package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
  42. package/src/components/execution/JobSetMemberComparison.tsx +88 -0
  43. package/src/components/execution/batch-run-actions.test.ts +1 -0
  44. package/src/components/execution/batch-timeline.test.ts +3 -0
  45. package/src/components/execution/execution-definition.test.ts +15 -1
  46. package/src/components/execution/execution-definition.ts +4 -0
  47. package/src/components/execution/member-collection.render.test.tsx +142 -0
  48. package/src/components/gitops/GitOpsView.tsx +33 -2
  49. package/src/components/gitops/destination-toast.test.ts +33 -0
  50. package/src/components/gitops/destination-toast.ts +46 -0
  51. package/src/components/gitops/useDestinationCluster.ts +30 -0
  52. package/src/components/helm/TrackChartSourceDialog.tsx +25 -14
  53. package/src/components/home/HomeView.tsx +6 -2
  54. package/src/components/home/MCPSetupDialog.tsx +10 -0
  55. package/src/components/home/RadarVersionLine.test.tsx +14 -0
  56. package/src/components/home/RadarVersionLine.tsx +28 -3
  57. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
  58. package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
  59. package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
  60. package/src/components/nav/PrimaryNavRail.tsx +27 -8
  61. package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
  62. package/src/components/resource/PVCUsageBar.tsx +62 -16
  63. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
  64. package/src/components/resources/ResourcesView.tsx +3 -3
  65. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
  66. package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
  67. package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
  68. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
  69. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
  70. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
  71. package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -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/resources/renderers/RayServiceRenderer.test.tsx +69 -0
  75. package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
  76. package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
  77. package/src/components/rightsizing/copy.test.ts +7 -0
  78. package/src/components/rightsizing/model.test.ts +2 -1
  79. package/src/components/rightsizing/notices.test.tsx +4 -3
  80. package/src/components/settings/OperatorManagedNotice.tsx +17 -0
  81. package/src/components/settings/PrivacySection.test.tsx +84 -0
  82. package/src/components/settings/PrivacySection.tsx +140 -0
  83. package/src/components/settings/SettingsDialog.tsx +168 -92
  84. package/src/components/settings/controls.tsx +55 -0
  85. package/src/components/settings/settings-state.test.ts +18 -0
  86. package/src/components/settings/settings-state.ts +19 -0
  87. package/src/components/timeline/TimelineView.tsx +9 -3
  88. package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
  89. package/src/components/ui/ErrorBoundary.tsx +5 -0
  90. package/src/components/ui/Omnibar.tsx +16 -0
  91. package/src/components/ui/UpdateNotification.tsx +13 -9
  92. package/src/components/ui/command-items.ts +14 -3
  93. package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
  94. package/src/components/usage-data/UsageDataAsk.tsx +106 -0
  95. package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
  96. package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
  97. package/src/components/useContextSwitchFlow.tsx +147 -0
  98. package/src/components/whats-new/WhatsNew.test.ts +110 -0
  99. package/src/components/whats-new/WhatsNew.tsx +423 -0
  100. package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
  101. package/src/components/whats-new/check-whats-new.test.ts +65 -0
  102. package/src/components/whats-new/releaseNotes.ts +121 -0
  103. package/src/components/workload/WorkloadView.test.ts +17 -1
  104. package/src/components/workload/WorkloadView.tsx +47 -19
  105. package/src/context/ContextSwitchContext.tsx +18 -2
  106. package/src/context/NavCustomization.tsx +4 -58
  107. package/src/context/ThemeContext.tsx +3 -11
  108. package/src/hooks/useClusterLoadState.ts +2 -2
  109. package/src/hooks/useFavorites.ts +3 -5
  110. package/src/k8s-ui-exports.test.ts +73 -0
  111. package/src/utils/auditBadges.ts +1 -1
  112. package/src/utils/navigation.test.ts +34 -1
  113. package/src/utils/navigation.ts +28 -5
  114. package/src/utils/topology-selection.test.ts +56 -0
  115. package/src/utils/topology-selection.ts +9 -9
  116. package/src/utils/version.ts +11 -0
  117. package/src/components/ui/CommandPalette.tsx +0 -261
@@ -0,0 +1,165 @@
1
+ import { useCallback, useEffect } from 'react'
2
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
3
+ import { fetchJSON } from './client'
4
+ import { apiUrl, getApiBase, getAuthHeaders, getCredentialsMode } from './config'
5
+
6
+ export type UsageDataState = 'undecided' | 'on' | 'off'
7
+ export type UsageDataSource = 'default' | 'user' | 'env' | 'deployment'
8
+
9
+ export interface ClusterShape {
10
+ kubernetesVersion: string
11
+ platform: string
12
+ nodes: string
13
+ integrations: string[]
14
+ }
15
+
16
+ export interface UsageReport {
17
+ schema: number
18
+ version: string
19
+ os: string
20
+ arch: string
21
+ installMethod: string
22
+ mode: string
23
+ periodStart: string
24
+ periodEnd: string
25
+ setup: {
26
+ authMode: string
27
+ timelineStorage: string
28
+ mcpEnabled: boolean
29
+ prometheus: string
30
+ costSource: string
31
+ browsers: string[]
32
+ }
33
+ engagement: { sessions: number; activeMinutes: string }
34
+ views: Record<string, number>
35
+ actions: Record<string, number>
36
+ mcpTools: Record<string, number>
37
+ uiEvents: Record<string, number>
38
+ errors: Record<string, number>
39
+ clusters: { contexts: string; used: number; shapes: ClusterShape[] }
40
+ }
41
+
42
+ export interface UsageDataStatus {
43
+ state: UsageDataState
44
+ source: UsageDataSource
45
+ canChange: boolean
46
+ developmentBuild: boolean
47
+ nextReportAt?: string
48
+ preview: UsageReport
49
+ firstRunPrompt: boolean
50
+ // What's New may ask: never asked, or asked and left unanswered long enough ago.
51
+ ask: boolean
52
+ // Several people use this Radar, so its configuration decides and nobody is asked.
53
+ shared: boolean
54
+ }
55
+
56
+ const usageDataKey = (apiBase: string) => ['usage-data', apiBase]
57
+
58
+ // `fresh` is for the report preview: it must match what would be sent now, so
59
+ // it refetches every time it is shown instead of reusing the app shell's copy.
60
+ export function useUsageData(enabled = true, { fresh = false }: { fresh?: boolean } = {}) {
61
+ const apiBase = getApiBase()
62
+ return useQuery<UsageDataStatus>({
63
+ queryKey: usageDataKey(apiBase),
64
+ queryFn: () => fetchJSON<UsageDataStatus>('/usage-data'),
65
+ enabled,
66
+ staleTime: fresh ? 0 : 60_000,
67
+ refetchOnMount: fresh ? 'always' : true,
68
+ retry: false,
69
+ })
70
+ }
71
+
72
+ export function useSetUsageData() {
73
+ const queryClient = useQueryClient()
74
+ const apiBase = getApiBase()
75
+ return useMutation({
76
+ mutationFn: (enabled: boolean) =>
77
+ fetchJSON<UsageDataStatus>('/usage-data', {
78
+ method: 'PUT',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify({ enabled }),
81
+ }),
82
+ meta: { errorMessage: 'Failed to save usage data choice' },
83
+ // A status read still in flight predates the choice; it must not
84
+ // replace the answer the server just confirmed.
85
+ onSuccess: async (status) => {
86
+ const queryKey = usageDataKey(apiBase)
87
+ await queryClient.cancelQueries({ queryKey })
88
+ queryClient.setQueryData(queryKey, status)
89
+ },
90
+ })
91
+ }
92
+
93
+ // Records that the question was shown, answered or not, and stops this tab
94
+ // offering it again from its cached status. Fire and forget: a failure only
95
+ // means it might show once more.
96
+ export function useMarkUsagePromptShown() {
97
+ const queryClient = useQueryClient()
98
+ const apiBase = getApiBase()
99
+ return useCallback(() => {
100
+ const queryKey = usageDataKey(apiBase)
101
+ void queryClient.cancelQueries({ queryKey })
102
+ queryClient.setQueryData<UsageDataStatus>(queryKey, (prev) => prev && { ...prev, firstRunPrompt: false, ask: false })
103
+ void fetch(apiUrl('/usage-data/prompt-shown'), {
104
+ method: 'POST',
105
+ headers: getAuthHeaders(),
106
+ credentials: getCredentialsMode(),
107
+ }).catch(() => {})
108
+ }, [queryClient, apiBase])
109
+ }
110
+
111
+ export function isRecording(status: UsageDataStatus | undefined): boolean {
112
+ return status?.state === 'on'
113
+ }
114
+
115
+ // Set from the status query so code without hook access (error boundaries,
116
+ // event handlers) can skip the request entirely when usage data is off.
117
+ let recording = false
118
+
119
+ function setUsageRecording(on: boolean) {
120
+ recording = on
121
+ }
122
+
123
+ type UsageEvent =
124
+ | { type: 'view'; name: string }
125
+ | { type: 'ui'; name: string }
126
+ | { type: 'session' }
127
+ | { type: 'active'; minutes: number }
128
+
129
+ // Fire and forget; the server drops anything outside its allow-list.
130
+ export function recordUsageEvent(event: UsageEvent): void {
131
+ if (!recording) return
132
+ void fetch(apiUrl('/usage-data/event'), {
133
+ method: 'POST',
134
+ headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
135
+ credentials: getCredentialsMode(),
136
+ body: JSON.stringify(event),
137
+ keepalive: true,
138
+ }).catch(() => {})
139
+ }
140
+
141
+ const ACTIVE_TICK_MINUTES = 5
142
+
143
+ // Counts views, one session per page load, and visible time in five-minute
144
+ // ticks. Nothing is sent unless usage data is on.
145
+ export function useUsageRecording(view: string, status: UsageDataStatus | undefined) {
146
+ const on = isRecording(status)
147
+ useEffect(() => {
148
+ setUsageRecording(on)
149
+ }, [on])
150
+
151
+ useEffect(() => {
152
+ if (on) recordUsageEvent({ type: 'view', name: view })
153
+ }, [view, on])
154
+
155
+ useEffect(() => {
156
+ if (!on) return
157
+ recordUsageEvent({ type: 'session' })
158
+ const tick = window.setInterval(() => {
159
+ if (document.visibilityState === 'visible') {
160
+ recordUsageEvent({ type: 'active', minutes: ACTIVE_TICK_MINUTES })
161
+ }
162
+ }, ACTIVE_TICK_MINUTES * 60_000)
163
+ return () => window.clearInterval(tick)
164
+ }, [on])
165
+ }
@@ -0,0 +1,155 @@
1
+ // @vitest-environment jsdom
2
+ import { act, type ReactNode } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
+ import type { ConnectionStateType } from '../context/ConnectionContext'
7
+ import { CloudFunnelButton } from './CloudFunnelButton'
8
+
9
+ const connection = vi.hoisted(() => ({ state: 'connecting' as ConnectionStateType }))
10
+ vi.mock('../context/ConnectionContext', () => ({ useConnection: () => ({ connection }) }))
11
+ vi.mock('./ui/Tooltip', () => ({ Tooltip: ({ children }: { children: ReactNode }) => children }))
12
+ vi.mock('./ui/Toast', () => ({ showApiError: vi.fn() }))
13
+ vi.mock('./CloudConnectFlow', () => ({ CloudConnectFlow: () => <div>Install flow</div> }))
14
+
15
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
16
+ let root: Root
17
+ let client: QueryClient
18
+ let lane: 'driver' | 'wizard'
19
+ let discovery: { connected: { namespace: string; deployment: string; clusterUrl: string }[] }
20
+ let discoveryStatus: number
21
+ let flowState: 'idle' | 'awaiting_approval'
22
+ const requests: string[] = []
23
+
24
+ beforeEach(() => {
25
+ connection.state = 'connecting'
26
+ lane = 'driver'
27
+ discovery = { connected: [] }
28
+ discoveryStatus = 200
29
+ flowState = 'idle'
30
+ requests.length = 0
31
+ client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
32
+ const element = document.createElement('div')
33
+ document.body.appendChild(element)
34
+ root = createRoot(element)
35
+ vi.stubGlobal('fetch', vi.fn(async (input: string) => {
36
+ const url = new URL(input, 'http://localhost')
37
+ requests.push(url.pathname + url.search)
38
+ const bodies: Record<string, unknown> = {
39
+ '/api/capabilities': { cloudConnect: { lane, appUrl: 'https://cloud.example', apiUrl: 'https://cloud.example' }, deployment: { mode: 'local' } },
40
+ '/api/cluster-info': { context: 'test-cluster' },
41
+ '/api/cloud/install/status': { state: flowState },
42
+ '/api/cloud/install/discover': discoveryStatus === 200 ? discovery : { error: 'Discovery failed' },
43
+ '/api/cloud/install/prepare': { error: 'Not connected to cluster' },
44
+ '/api/connect/info': {},
45
+ }
46
+ if (!(url.pathname in bodies)) throw new Error(`Unexpected request: ${input}`)
47
+ const status = url.pathname.endsWith('/prepare') ? 503 : url.pathname.endsWith('/discover') ? discoveryStatus : 200
48
+ return new Response(JSON.stringify(bodies[url.pathname]), { status })
49
+ }))
50
+ })
51
+
52
+ afterEach(async () => {
53
+ await act(async () => root.unmount())
54
+ client.clear()
55
+ document.body.replaceChildren()
56
+ vi.unstubAllGlobals()
57
+ })
58
+
59
+ async function render() {
60
+ await act(async () => {
61
+ root.render(<QueryClientProvider client={client}><CloudFunnelButton /></QueryClientProvider>)
62
+ })
63
+ }
64
+
65
+ async function until(assertion: () => void) {
66
+ await vi.waitFor(async () => {
67
+ await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) })
68
+ assertion()
69
+ })
70
+ }
71
+
72
+ function button(text: string) {
73
+ return Array.from(document.querySelectorAll('button')).find(element => element.textContent === text)
74
+ }
75
+
76
+ async function openDialog() {
77
+ await render()
78
+ await until(() => expect(document.querySelector('[aria-label="Radar Cloud"]')).not.toBeNull())
79
+ await act(async () => { (document.querySelector('[aria-label="Radar Cloud"]') as HTMLButtonElement).click() })
80
+ }
81
+
82
+ describe('Cloud dialog connection availability', () => {
83
+ it.each(['connecting', 'disconnected'] as const)('offers Cloud directly while %s without inspecting a cluster', async state => {
84
+ connection.state = state
85
+ await openDialog()
86
+ await until(() => expect(requests).toContain('/api/connect/info?lane=wizard&mode=local'))
87
+ const link = Array.from(document.querySelectorAll('a')).find(element => element.textContent === 'Continue in Radar Cloud')!
88
+ expect(link.href).toBe('https://cloud.example/signup?utm_source=radar-oss&utm_medium=app&utm_campaign=cloud-modal&utm_content=driver-footer-browser-link')
89
+ expect(button('Connect this cluster…')).toBeUndefined()
90
+ expect(button('Try again')).toBeUndefined()
91
+ expect(requests).not.toContain('/api/cloud/install/discover')
92
+ expect(requests).not.toContain('/api/cloud/install/prepare')
93
+ await act(async () => { button('How it works and what it costs')!.click() })
94
+ expect(document.body.textContent).toContain('Radar runs in your cluster and tunnels outward to Radar Cloud')
95
+ expect(document.body.textContent).not.toContain('Setup runs here in the app')
96
+ expect(document.body.textContent).not.toContain('see what gets installed')
97
+ })
98
+
99
+ it('restores cluster discovery and Connect in the open dialog when the connection recovers', async () => {
100
+ await openDialog()
101
+ connection.state = 'connected'
102
+ await render()
103
+ await until(() => expect(button('Connect this cluster…')?.disabled).toBe(false))
104
+ expect(requests).toContain('/api/cloud/install/discover')
105
+ expect(requests).toContain('/api/connect/info?lane=driver&mode=local')
106
+ connection.state = 'disconnected'
107
+ await render()
108
+ expect(button('Connect this cluster…')).toBeUndefined()
109
+ expect(document.body.textContent).toContain('Continue in Radar Cloud')
110
+ connection.state = 'connected'
111
+ await render()
112
+ await until(() => expect(button('Connect this cluster…')?.disabled).toBe(false))
113
+ expect(requests.filter(path => path === '/api/cloud/install/discover')).toHaveLength(2)
114
+ })
115
+
116
+ it('keeps the existing already-connected destination after discovery', async () => {
117
+ connection.state = 'connected'
118
+ discovery.connected = [{ namespace: 'radar', deployment: 'radar', clusterUrl: 'https://cloud.example/clusters/one' }]
119
+ await openDialog()
120
+ await until(() => expect(document.body.textContent).toContain('This cluster is already connected to Radar Cloud.'))
121
+ expect(document.querySelector('a[href="https://cloud.example/clusters/one"]')?.textContent).toBe('Open in Radar Cloud')
122
+ expect(button('Connect this cluster…')).toBeUndefined()
123
+ connection.state = 'disconnected'
124
+ await render()
125
+ expect(document.body.textContent).not.toContain('This cluster is already connected to Radar Cloud.')
126
+ expect(document.body.textContent).toContain('Continue in Radar Cloud')
127
+ })
128
+
129
+ it('still allows inspection after discovery fails and preserves 503 handoff attribution', async () => {
130
+ connection.state = 'connected'
131
+ discoveryStatus = 500
132
+ await openDialog()
133
+ await until(() => expect(button('Connect this cluster…')?.disabled).toBe(false))
134
+ await act(async () => { button('Connect this cluster…')!.click() })
135
+ await until(() => expect(button('Try again')).toBeDefined())
136
+ expect(document.querySelector('a[href*="driver-footer-browser-link"]')?.getAttribute('href')).toContain('radar_outcome=radar_not_connected_to_cluster')
137
+ expect(document.body.textContent).toContain('Not connected to cluster')
138
+ })
139
+
140
+ it('keeps wizard-only deployments on their existing signup path', async () => {
141
+ lane = 'wizard'
142
+ await openDialog()
143
+ expect(document.querySelector('a[href*="wizard-signup-button"]')?.textContent).toBe('Try Cloud free')
144
+ expect(document.body.textContent).not.toContain('Continue in Radar Cloud')
145
+ expect(requests).not.toContain('/api/cloud/install/discover')
146
+ })
147
+
148
+ it('keeps an active server-owned install visible even while disconnected', async () => {
149
+ connection.state = 'disconnected'
150
+ flowState = 'awaiting_approval'
151
+ await openDialog()
152
+ await until(() => expect(document.body.textContent).toContain('Install flow'))
153
+ expect(document.body.textContent).not.toContain('Continue in Radar Cloud')
154
+ })
155
+ })