@skyhook-io/radar-app 1.9.4 → 1.9.6
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/package.json +9 -9
- package/src/App.tsx +10 -2
- package/src/api/client.authRedirect.test.ts +194 -0
- package/src/api/client.trace.test.ts +37 -0
- package/src/api/client.ts +315 -1
- package/src/api/config.test.ts +19 -0
- package/src/components/CloudConnectFlow.tsx +605 -0
- package/src/components/CloudFunnelButton.tsx +522 -0
- package/src/components/ConnectionErrorView.tsx +2 -1
- package/src/components/UserMenu.tsx +3 -2
- package/src/components/diagnose/DiagnoseSurface.tsx +2 -1
- package/src/components/home/ClusterHealthCard.tsx +10 -5
- package/src/components/home/mcpToolCatalog.ts +7 -5
- package/src/components/nav/PrimaryNavRail.tsx +4 -1
- package/src/components/resources/ImageFilesystemModal.tsx +4 -1
- package/src/components/settings/SettingsDialog.tsx +11 -4
- package/src/components/traffic/TrafficWizard.tsx +4 -2
- package/src/components/workload/WorkloadView.tsx +558 -12
- package/src/main.tsx +7 -1
- package/src/vite-env.d.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.6",
|
|
4
4
|
"description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"yaml": "^2.9.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@skyhook-io/k8s-ui": ">=1.
|
|
44
|
+
"@skyhook-io/k8s-ui": ">=1.11.0",
|
|
45
45
|
"@tanstack/react-query": ">=5",
|
|
46
46
|
"@xyflow/react": ">=12.0.0",
|
|
47
47
|
"clsx": ">=2",
|
|
@@ -60,21 +60,21 @@
|
|
|
60
60
|
"@tailwindcss/vite": "^4.3.3",
|
|
61
61
|
"@tanstack/react-query": "^5.101.4",
|
|
62
62
|
"@types/node": "^26.1.1",
|
|
63
|
-
"@types/react": "^19.2.
|
|
63
|
+
"@types/react": "^19.2.18",
|
|
64
64
|
"@types/react-dom": "^19.2.3",
|
|
65
65
|
"@vitejs/plugin-react": "^6.0.4",
|
|
66
66
|
"@xyflow/react": "^12.11.2",
|
|
67
67
|
"clsx": "^2.1.1",
|
|
68
68
|
"elkjs": "^0.11.1",
|
|
69
|
-
"eslint": "^10.
|
|
69
|
+
"eslint": "^10.8.0",
|
|
70
70
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
71
71
|
"eslint-plugin-react-refresh": "^0.5.3",
|
|
72
|
-
"globals": "^17.
|
|
73
|
-
"lucide-react": "^1.
|
|
74
|
-
"postcss": "^8.5.
|
|
72
|
+
"globals": "^17.9.0",
|
|
73
|
+
"lucide-react": "^1.28.0",
|
|
74
|
+
"postcss": "^8.5.25",
|
|
75
75
|
"prettier": "^3.9.5",
|
|
76
|
-
"react": "^19.2.
|
|
77
|
-
"react-dom": "^19.2.
|
|
76
|
+
"react": "^19.2.8",
|
|
77
|
+
"react-dom": "^19.2.8",
|
|
78
78
|
"react-router-dom": "^7.18.1",
|
|
79
79
|
"tailwind-merge": "^3.6.0",
|
|
80
80
|
"tailwindcss": "^4.3.3",
|
package/src/App.tsx
CHANGED
|
@@ -9,7 +9,7 @@ import { DebugOverlay } from './components/DebugOverlay'
|
|
|
9
9
|
import { GlobalDiagnoseButton } from './components/diagnose/LocalDiagnoseAction'
|
|
10
10
|
import { useDiagnoseLayout } from './components/diagnose/DiagnoseContext'
|
|
11
11
|
import { DiagnoseSurface } from './components/diagnose/DiagnoseSurface'
|
|
12
|
-
import { TopologyGraph, TopologySearch, TopologyBreadcrumb, TopologyFilterSidebar, TopologyControls, FreshnessControl, gitOpsRouteForKind, gitOpsRouteForResource, ScopePill, PaneLoader } from '@skyhook-io/k8s-ui'
|
|
12
|
+
import { TopologyGraph, TopologySearch, TopologyBreadcrumb, TopologyFilterSidebar, TopologyControls, FreshnessControl, gitOpsRouteForKind, gitOpsRouteForResource, ScopePill, PaneLoader, assetUrl } from '@skyhook-io/k8s-ui'
|
|
13
13
|
import { initNavigationMap } from '@skyhook-io/k8s-ui/utils/navigation'
|
|
14
14
|
import { useAPIResources, findAPIResourceForRoute } from './api/apiResources'
|
|
15
15
|
import { TimelineView } from './components/timeline/TimelineView'
|
|
@@ -33,6 +33,7 @@ import { DockProvider, BottomDock, useDock, useDockReservedHeight, useOpenLocalT
|
|
|
33
33
|
import { DURATION_DOCK } from '@skyhook-io/k8s-ui/utils/animation'
|
|
34
34
|
import { ContextSwitcher } from './components/ContextSwitcher'
|
|
35
35
|
import { NamespaceSwitcher, type NamespaceSwitcherHandle } from './components/NamespaceSwitcher'
|
|
36
|
+
import { CloudFunnelButton } from './components/CloudFunnelButton'
|
|
36
37
|
import { useNavCustomization } from './context/NavCustomization'
|
|
37
38
|
import type { FleetTakeoverTarget } from './context/NavCustomization'
|
|
38
39
|
import { PrimaryNavRail } from './components/nav/PrimaryNavRail'
|
|
@@ -68,6 +69,8 @@ import { type OmnibarHandle } from './components/ui/Omnibar'
|
|
|
68
69
|
import { RadarOmnibar } from './components/ui/RadarOmnibar'
|
|
69
70
|
import type { ContextSwitcherHandle } from './components/ContextSwitcher'
|
|
70
71
|
|
|
72
|
+
const radarLogoUrl = assetUrl('/images/radar/radar-icon.svg')
|
|
73
|
+
|
|
71
74
|
// All possible node kinds (core + GitOps)
|
|
72
75
|
const ALL_NODE_KINDS: NodeKind[] = [
|
|
73
76
|
'Internet', 'Ingress', 'Gateway', 'HTTPRoute', 'GRPCRoute', 'TCPRoute', 'TLSRoute',
|
|
@@ -1878,6 +1881,11 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1878
1881
|
{/* AI investigations (self-hides when no agent CLI is present) */}
|
|
1879
1882
|
<GlobalDiagnoseButton />
|
|
1880
1883
|
|
|
1884
|
+
{/* Radar Cloud funnel — OSS-only chrome, same gate as the star.
|
|
1885
|
+
Cloud embeds render chromeless anyway; the explicit gate is
|
|
1886
|
+
belt-and-braces for future chrome-bearing embedders. */}
|
|
1887
|
+
{!navCustomization.embedded && <CloudFunnelButton />}
|
|
1888
|
+
|
|
1881
1889
|
{/* Local terminal */}
|
|
1882
1890
|
{capabilities.localTerminal && (
|
|
1883
1891
|
<Tooltip content="Open local terminal">
|
|
@@ -2566,7 +2574,7 @@ function Logo() {
|
|
|
2566
2574
|
<div className="flex items-center gap-2.5">
|
|
2567
2575
|
<div className="relative w-7 h-7 rounded-lg overflow-hidden flex-shrink-0 bg-emerald-500/10 border border-emerald-500/20">
|
|
2568
2576
|
<img
|
|
2569
|
-
src=
|
|
2577
|
+
src={radarLogoUrl}
|
|
2570
2578
|
alt=""
|
|
2571
2579
|
aria-hidden
|
|
2572
2580
|
className="w-full h-full p-0.5"
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// Deterministic config so the 401 handler's basename/routePath math is fixed and
|
|
4
|
+
// independent of window/env. client.ts only consumes these six exports.
|
|
5
|
+
vi.mock('./config', () => ({
|
|
6
|
+
getApiBase: () => '/api',
|
|
7
|
+
getAuthHeaders: () => ({}),
|
|
8
|
+
getCredentialsMode: () => 'include' as RequestCredentials,
|
|
9
|
+
getBasename: () => '',
|
|
10
|
+
routePath: (p: string) => p,
|
|
11
|
+
stripBasename: (p: string) => p,
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
interface NavRecorder {
|
|
15
|
+
count: number
|
|
16
|
+
urls: string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Stub window.location (counting href assignments + reloads) and sessionStorage.
|
|
20
|
+
// Runs in vitest's node environment — there is no real DOM.
|
|
21
|
+
function installDom(pathname = '/traffic'): NavRecorder {
|
|
22
|
+
const rec: NavRecorder = { count: 0, urls: [] }
|
|
23
|
+
const location: Record<string, unknown> = {
|
|
24
|
+
pathname,
|
|
25
|
+
search: '',
|
|
26
|
+
_href: `http://radar.local${pathname}`,
|
|
27
|
+
reload: () => {
|
|
28
|
+
rec.count++
|
|
29
|
+
rec.urls.push('[reload]')
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
Object.defineProperty(location, 'href', {
|
|
33
|
+
get() {
|
|
34
|
+
return location._href as string
|
|
35
|
+
},
|
|
36
|
+
set(v: string) {
|
|
37
|
+
rec.count++
|
|
38
|
+
rec.urls.push(v)
|
|
39
|
+
location._href = v
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
vi.stubGlobal('window', { location })
|
|
43
|
+
|
|
44
|
+
const store = new Map<string, string>()
|
|
45
|
+
vi.stubGlobal('sessionStorage', {
|
|
46
|
+
getItem: (k: string) => (store.has(k) ? (store.get(k) as string) : null),
|
|
47
|
+
setItem: (k: string, v: string) => {
|
|
48
|
+
store.set(k, String(v))
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
return rec
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A 401 whose body advertises the auth mode, matching what the backend returns
|
|
55
|
+
// on a protected /api/* call with no session.
|
|
56
|
+
function make401(authMode: string): Response {
|
|
57
|
+
const body = JSON.stringify({ authMode })
|
|
58
|
+
return {
|
|
59
|
+
status: 401,
|
|
60
|
+
ok: false,
|
|
61
|
+
clone() {
|
|
62
|
+
return make401(authMode)
|
|
63
|
+
},
|
|
64
|
+
async json() {
|
|
65
|
+
return JSON.parse(body)
|
|
66
|
+
},
|
|
67
|
+
} as unknown as Response
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
// Reset module state so the redirect gate starts fresh.
|
|
72
|
+
vi.resetModules()
|
|
73
|
+
vi.unstubAllGlobals()
|
|
74
|
+
vi.restoreAllMocks()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('apiFetch OIDC 401 -> login redirect (single-flight)', () => {
|
|
78
|
+
it('navigates to /auth/login exactly once for a burst of concurrent 401s', async () => {
|
|
79
|
+
const rec = installDom('/traffic')
|
|
80
|
+
vi.stubGlobal(
|
|
81
|
+
'fetch',
|
|
82
|
+
vi.fn(async () => make401('oidc')),
|
|
83
|
+
)
|
|
84
|
+
const { apiFetch } = await import('./client')
|
|
85
|
+
|
|
86
|
+
// Mirror first paint / mid-session expiry: many protected calls 401 together.
|
|
87
|
+
const paths = [
|
|
88
|
+
'/connection',
|
|
89
|
+
'/capabilities',
|
|
90
|
+
'/namespaces',
|
|
91
|
+
'/portforwards',
|
|
92
|
+
'/dashboard',
|
|
93
|
+
'/issues',
|
|
94
|
+
'/auth/me',
|
|
95
|
+
'/topology',
|
|
96
|
+
]
|
|
97
|
+
await Promise.all(paths.map((p) => apiFetch(`/api${p}`)))
|
|
98
|
+
|
|
99
|
+
expect(rec.count).toBe(1)
|
|
100
|
+
expect(rec.urls).toEqual(['/auth/login'])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('still redirects on the first 401 (guard does not suppress the initial navigation)', async () => {
|
|
104
|
+
const rec = installDom('/traffic')
|
|
105
|
+
vi.stubGlobal(
|
|
106
|
+
'fetch',
|
|
107
|
+
vi.fn(async () => make401('oidc')),
|
|
108
|
+
)
|
|
109
|
+
const { apiFetch } = await import('./client')
|
|
110
|
+
|
|
111
|
+
await apiFetch('/api/connection')
|
|
112
|
+
|
|
113
|
+
expect(rec.count).toBe(1)
|
|
114
|
+
expect(rec.urls).toEqual(['/auth/login'])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('re-redirects after the throttle window (self-heals canceled nav / bfcache Back)', async () => {
|
|
118
|
+
const rec = installDom('/traffic')
|
|
119
|
+
vi.stubGlobal(
|
|
120
|
+
'fetch',
|
|
121
|
+
vi.fn(async () => make401('oidc')),
|
|
122
|
+
)
|
|
123
|
+
const nowSpy = vi.spyOn(Date, 'now')
|
|
124
|
+
const { apiFetch } = await import('./client')
|
|
125
|
+
|
|
126
|
+
nowSpy.mockReturnValue(1_000_000)
|
|
127
|
+
await apiFetch('/api/connection')
|
|
128
|
+
expect(rec.count).toBe(1)
|
|
129
|
+
|
|
130
|
+
// Within the window a repeat 401 is suppressed (no state rotation).
|
|
131
|
+
nowSpy.mockReturnValue(1_000_000 + 2_000)
|
|
132
|
+
await apiFetch('/api/connection')
|
|
133
|
+
expect(rec.count).toBe(1)
|
|
134
|
+
|
|
135
|
+
// Past the window it redirects again instead of stalling until a hard reload.
|
|
136
|
+
nowSpy.mockReturnValue(1_000_000 + 6_000)
|
|
137
|
+
await apiFetch('/api/connection')
|
|
138
|
+
expect(rec.count).toBe(2)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('still redirects (once) when sessionStorage is blocked', async () => {
|
|
142
|
+
// Simulate private-mode / sandboxed storage where every access throws.
|
|
143
|
+
const rec = installDom('/traffic')
|
|
144
|
+
vi.stubGlobal('sessionStorage', {
|
|
145
|
+
getItem: () => {
|
|
146
|
+
throw new DOMException('blocked', 'SecurityError')
|
|
147
|
+
},
|
|
148
|
+
setItem: () => {
|
|
149
|
+
throw new DOMException('blocked', 'SecurityError')
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
vi.stubGlobal(
|
|
153
|
+
'fetch',
|
|
154
|
+
vi.fn(async () => make401('oidc')),
|
|
155
|
+
)
|
|
156
|
+
const { apiFetch } = await import('./client')
|
|
157
|
+
|
|
158
|
+
// Fail-open: the throwing read must not abort the redirect...
|
|
159
|
+
await Promise.all(
|
|
160
|
+
['/connection', '/capabilities', '/namespaces'].map((p) => apiFetch(`/api${p}`)),
|
|
161
|
+
)
|
|
162
|
+
// ...and the in-memory fallback still collapses the burst to one navigation.
|
|
163
|
+
expect(rec.count).toBe(1)
|
|
164
|
+
expect(rec.urls).toEqual(['/auth/login'])
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('does not navigate to /auth/login when already on an /auth path', async () => {
|
|
168
|
+
const rec = installDom('/auth/login')
|
|
169
|
+
vi.stubGlobal(
|
|
170
|
+
'fetch',
|
|
171
|
+
vi.fn(async () => make401('oidc')),
|
|
172
|
+
)
|
|
173
|
+
const { apiFetch } = await import('./client')
|
|
174
|
+
|
|
175
|
+
await apiFetch('/api/connection')
|
|
176
|
+
|
|
177
|
+
expect(rec.count).toBe(0)
|
|
178
|
+
expect(rec.urls).toEqual([])
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('proxy/unknown mode reloads (once) instead of hitting /auth/login', async () => {
|
|
182
|
+
const rec = installDom('/traffic')
|
|
183
|
+
vi.stubGlobal(
|
|
184
|
+
'fetch',
|
|
185
|
+
vi.fn(async () => make401('proxy')),
|
|
186
|
+
)
|
|
187
|
+
const { apiFetch } = await import('./client')
|
|
188
|
+
|
|
189
|
+
await apiFetch('/api/connection')
|
|
190
|
+
|
|
191
|
+
expect(rec.urls).toEqual(['[reload]'])
|
|
192
|
+
expect(rec.urls).not.toContain('/auth/login')
|
|
193
|
+
})
|
|
194
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { runInClusterMerged } from './client'
|
|
3
|
+
|
|
4
|
+
// The in-cluster endpoint returns a JSON body on denial too, and a Hub
|
|
5
|
+
// ingress/chi timeout returns HTML. Every branch must surface as a thrown
|
|
6
|
+
// error with the truthful message - a partial body painted as a
|
|
7
|
+
// server-finalized trace would be a fabricated result.
|
|
8
|
+
const respond = (status: number, body: string, contentType = 'application/json') =>
|
|
9
|
+
vi.stubGlobal('fetch', () =>
|
|
10
|
+
Promise.resolve(new Response(body, { status, headers: { 'Content-Type': contentType } })),
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.unstubAllGlobals()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('runInClusterMerged error surfacing', () => {
|
|
18
|
+
it('rejects a denial with the server message', async () => {
|
|
19
|
+
respond(403, JSON.stringify({ error: 'your Radar Cloud role cannot run an in-cluster reachability test' }))
|
|
20
|
+
await expect(runInClusterMerged('Service', 'prod', 'web')).rejects.toThrow(/Cloud role/)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('rejects a 200 that carries an error field', async () => {
|
|
24
|
+
respond(200, JSON.stringify({ error: 'no eligible routes' }))
|
|
25
|
+
await expect(runInClusterMerged('Service', 'prod', 'web')).rejects.toThrow('no eligible routes')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('rejects a non-JSON gateway failure with the status, not a parse error', async () => {
|
|
29
|
+
respond(504, '<html>Gateway Timeout</html>', 'text/html')
|
|
30
|
+
await expect(runInClusterMerged('Service', 'prod', 'web')).rejects.toThrow('In-cluster test failed (504)')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('resolves a clean run with the finalized payload', async () => {
|
|
34
|
+
respond(200, JSON.stringify({ trace: { subject: { kind: 'Service', name: 'web' } }, inClusterTests: [] }))
|
|
35
|
+
await expect(runInClusterMerged('Service', 'prod', 'web')).resolves.toMatchObject({ inClusterTests: [] })
|
|
36
|
+
})
|
|
37
|
+
})
|
package/src/api/client.ts
CHANGED
|
@@ -68,6 +68,49 @@ const COST_TREND_REFRESH_INTERVAL_MS = 120_000;
|
|
|
68
68
|
const CHANGES_REFRESH_INTERVAL_MS = 60_000;
|
|
69
69
|
const APPLICATIONS_REFRESH_INTERVAL_MS = 60_000;
|
|
70
70
|
|
|
71
|
+
// Throttle window for the OIDC login redirect. On first paint (and again on
|
|
72
|
+
// mid-session session expiry) many protected /api/* requests can 401 in the same
|
|
73
|
+
// tick; without a guard each 401 independently navigates to /auth/login, and each
|
|
74
|
+
// /auth/login regenerates the single radar_oidc_state cookie — so callbacks from
|
|
75
|
+
// earlier redirects fail state validation (or get their token exchange canceled)
|
|
76
|
+
// and the login loops until one happens to win. A short time gate collapses the
|
|
77
|
+
// burst to one navigation while still self-recovering if that navigation is
|
|
78
|
+
// canceled or the page is restored from bfcache (module/JS-realm state survives
|
|
79
|
+
// bfcache, so a plain latch would stick). Mirrors the proxy branch's reload
|
|
80
|
+
// throttle below.
|
|
81
|
+
const OIDC_LOGIN_REDIRECT_THROTTLE_MS = 5000;
|
|
82
|
+
const OIDC_LOGIN_REDIRECT_KEY = "radar_oidc_login_redirect";
|
|
83
|
+
|
|
84
|
+
// In-memory fallback for when sessionStorage is unavailable (private mode,
|
|
85
|
+
// sandboxed/blocked storage). A timestamp, not a boolean latch, so it still
|
|
86
|
+
// self-heals within the throttle window instead of sticking.
|
|
87
|
+
let lastOidcLoginRedirectAt = 0;
|
|
88
|
+
|
|
89
|
+
// Fail open: if reading storage throws, fall back to the in-memory timestamp so
|
|
90
|
+
// the redirect still fires. Otherwise the 401 handler would reject before
|
|
91
|
+
// redirecting and strand the user on a "not signed in" screen.
|
|
92
|
+
function lastOidcLoginRedirect(): number {
|
|
93
|
+
try {
|
|
94
|
+
const stored = sessionStorage.getItem(OIDC_LOGIN_REDIRECT_KEY);
|
|
95
|
+
if (stored) {
|
|
96
|
+
const parsed = parseInt(stored);
|
|
97
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
/* storage blocked — use the in-memory fallback */
|
|
101
|
+
}
|
|
102
|
+
return lastOidcLoginRedirectAt;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function markOidcLoginRedirect(now: number): void {
|
|
106
|
+
lastOidcLoginRedirectAt = now;
|
|
107
|
+
try {
|
|
108
|
+
sessionStorage.setItem(OIDC_LOGIN_REDIRECT_KEY, String(now));
|
|
109
|
+
} catch {
|
|
110
|
+
/* best-effort — the in-memory fallback still throttles this realm */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
71
114
|
// Wrapper around fetch that always includes credentials (for session cookies)
|
|
72
115
|
// and handles 401 responses globally. Merges caller-provided headers with
|
|
73
116
|
// auth headers from the config module so library consumers (Radar Hub) can
|
|
@@ -112,7 +155,15 @@ export function apiFetch(
|
|
|
112
155
|
}
|
|
113
156
|
|
|
114
157
|
if (authMode === "oidc") {
|
|
115
|
-
|
|
158
|
+
// Only the first 401 in a burst navigates; concurrent 401s within the
|
|
159
|
+
// window are suppressed so they don't rotate radar_oidc_state. The gate
|
|
160
|
+
// is time-based, so a canceled navigation or bfcache Back re-auths on the
|
|
161
|
+
// next 401 instead of stalling until a hard reload.
|
|
162
|
+
const now = Date.now();
|
|
163
|
+
if (now - lastOidcLoginRedirect() > OIDC_LOGIN_REDIRECT_THROTTLE_MS) {
|
|
164
|
+
markOidcLoginRedirect(now);
|
|
165
|
+
window.location.href = routePath("/auth/login");
|
|
166
|
+
}
|
|
116
167
|
} else {
|
|
117
168
|
// Proxy mode or unknown — reload is safe for both (proxy re-injects headers,
|
|
118
169
|
// unknown avoids redirecting to /auth/login which doesn't exist in proxy mode).
|
|
@@ -626,6 +677,85 @@ export function useResourceIssues(
|
|
|
626
677
|
});
|
|
627
678
|
}
|
|
628
679
|
|
|
680
|
+
import type { Trace as NetworkTrace, InClusterCapability } from '@skyhook-io/k8s-ui'
|
|
681
|
+
|
|
682
|
+
// useTrace polls the static path-shaped diagnosis for one network entry
|
|
683
|
+
// kind. 5s refetch + 15s staleTime keeps the drawer feeling live without
|
|
684
|
+
// burning request budget; probes are deliberately excluded - they run via
|
|
685
|
+
// fetchTraceWithProbes on operator click.
|
|
686
|
+
export function useTrace(kind: string, namespace: string, name: string, enabled = true) {
|
|
687
|
+
return useQuery<NetworkTrace>({
|
|
688
|
+
queryKey: ['trace', kind, namespace, name, 'static'],
|
|
689
|
+
queryFn: () => fetchJSON(`/trace/${kind}/${namespace}/${name}`),
|
|
690
|
+
staleTime: 15000,
|
|
691
|
+
refetchInterval: enabled ? 5000 : false,
|
|
692
|
+
enabled: enabled && Boolean(kind) && Boolean(namespace) && Boolean(name),
|
|
693
|
+
})
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// fetchTraceWithProbes is one-shot rather than polled: probes generate real
|
|
697
|
+
// network traffic that observability systems can see, so they are NOT polled.
|
|
698
|
+
// They fire on an explicit, scoped trigger - the operator clicking Run, or
|
|
699
|
+
// opening the Reachability tab for a resource (auto-run once per resource, so the
|
|
700
|
+
// tab shows results instead of a blank "click Run" page) - never on every render
|
|
701
|
+
// or background refresh. The in-cluster probe (which spawns a Job) stays
|
|
702
|
+
// click-only.
|
|
703
|
+
export function fetchTraceWithProbes(kind: string, namespace: string, name: string, path?: string): Promise<NetworkTrace> {
|
|
704
|
+
const q = path && path !== '/' ? `&path=${encodeURIComponent(path)}` : ''
|
|
705
|
+
return fetchJSON(`/trace/${kind}/${namespace}/${name}?probe=true${q}`)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// fetchInClusterCapability tells the UI whether the active "test from inside the
|
|
709
|
+
// cluster" probe can run for this caller (and names the cluster + namespace).
|
|
710
|
+
export function fetchInClusterCapability(kind: string, namespace: string, name: string): Promise<InClusterCapability> {
|
|
711
|
+
return fetchJSON(`/trace/${kind}/${namespace}/${name}/probe-in-cluster/capability`)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// InClusterMergedResult is the WHOLE-subject in-cluster test: the server runs every
|
|
715
|
+
// route's live probe, folds them in via the canonical trace.ApplyInClusterResults,
|
|
716
|
+
// and returns the FINALIZED trace - so the frontend displays it directly instead of
|
|
717
|
+
// reimplementing a weaker merge that could falsely confirm a sibling route.
|
|
718
|
+
// InClusterTestOutcome is one route's in-cluster result. A failure (the Job
|
|
719
|
+
// couldn't start / timed out / RBAC) comes back as HTTP 200 with a human status
|
|
720
|
+
// and a copyable fallbackCommand - it MUST be surfaced, not dropped.
|
|
721
|
+
export interface InClusterTestOutcome {
|
|
722
|
+
route: string
|
|
723
|
+
target?: string
|
|
724
|
+
status?: string
|
|
725
|
+
fallbackCommand?: string
|
|
726
|
+
// Raw probe results when the run produced any. A status WITH results is
|
|
727
|
+
// informational context; a status WITHOUT results means the route (or the
|
|
728
|
+
// whole subject) was not tested and the status must be surfaced.
|
|
729
|
+
results?: unknown[]
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export interface InClusterMergedResult {
|
|
733
|
+
trace: NetworkTrace
|
|
734
|
+
inClusterTests?: InClusterTestOutcome[]
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// runInClusterMerged triggers the whole-subject in-cluster test and returns the
|
|
738
|
+
// server-finalized trace. The endpoint returns a JSON body on denial too, so
|
|
739
|
+
// parse the body either way and surface the error field.
|
|
740
|
+
export async function runInClusterMerged(kind: string, namespace: string, name: string, path?: string): Promise<InClusterMergedResult> {
|
|
741
|
+
const response = await apiFetch(`${getApiBase()}/trace/${kind}/${namespace}/${name}/in-cluster`, {
|
|
742
|
+
method: 'POST',
|
|
743
|
+
headers: { 'Content-Type': 'application/json' },
|
|
744
|
+
body: JSON.stringify({ path: path ?? '/' }),
|
|
745
|
+
})
|
|
746
|
+
// A gateway/proxy failure (chi's 60s timeout, a Hub ingress 502) returns
|
|
747
|
+
// HTML - an unguarded .json() surfaced "Unexpected token <" instead of the
|
|
748
|
+
// status. Mirror fetchJSON's tolerant parse.
|
|
749
|
+
const body = await response.json().catch(() => undefined)
|
|
750
|
+
if (!response.ok || body?.error) {
|
|
751
|
+
throw new Error(body?.error || `In-cluster test failed (${response.status})`)
|
|
752
|
+
}
|
|
753
|
+
if (body === undefined) {
|
|
754
|
+
throw new Error('In-cluster test returned an unreadable response')
|
|
755
|
+
}
|
|
756
|
+
return body
|
|
757
|
+
}
|
|
758
|
+
|
|
629
759
|
// Audit settings
|
|
630
760
|
export interface AuditSettings {
|
|
631
761
|
ignoredNamespaces: string[];
|
|
@@ -1545,6 +1675,190 @@ export function useCapabilities() {
|
|
|
1545
1675
|
});
|
|
1546
1676
|
}
|
|
1547
1677
|
|
|
1678
|
+
// ============================================================================
|
|
1679
|
+
// In-product Cloud Connect (driver lane)
|
|
1680
|
+
// ============================================================================
|
|
1681
|
+
|
|
1682
|
+
export interface CloudInstallRecoveryGuidance {
|
|
1683
|
+
summary: string
|
|
1684
|
+
lines?: string[]
|
|
1685
|
+
inspect?: string[]
|
|
1686
|
+
clusterUrl?: string
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
export interface CloudInstallPlanSummary {
|
|
1690
|
+
mode: 'fresh' | 'adopt'
|
|
1691
|
+
contextName: string
|
|
1692
|
+
namespace: string
|
|
1693
|
+
release: string
|
|
1694
|
+
defaultClusterName: string
|
|
1695
|
+
targetChartVersion: string
|
|
1696
|
+
targetAppVersion: string
|
|
1697
|
+
currentChartVersion?: string
|
|
1698
|
+
currentRevision?: number
|
|
1699
|
+
currentImageTag?: string
|
|
1700
|
+
preservedImageRepository?: string
|
|
1701
|
+
uncertainty?: string
|
|
1702
|
+
advisories?: string[]
|
|
1703
|
+
sharedListener?: boolean
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
export interface CloudInstallConnected {
|
|
1707
|
+
clusterId: string
|
|
1708
|
+
clusterUrl: string
|
|
1709
|
+
trackCommand: string
|
|
1710
|
+
rollback?: CloudInstallRecoveryGuidance
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
export interface CloudInstallFailure {
|
|
1714
|
+
kind: string
|
|
1715
|
+
message: string
|
|
1716
|
+
guidance?: CloudInstallRecoveryGuidance
|
|
1717
|
+
retrySafe: boolean
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
export interface CloudInstallBlocked {
|
|
1721
|
+
reason: 'gitops' | 'preflight' | 'unsupported'
|
|
1722
|
+
message: string
|
|
1723
|
+
blocking?: string[]
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
export type CloudInstallState =
|
|
1727
|
+
| 'idle'
|
|
1728
|
+
| 'preparing'
|
|
1729
|
+
| 'ready'
|
|
1730
|
+
| 'starting'
|
|
1731
|
+
| 'awaiting_approval'
|
|
1732
|
+
| 'provisioning'
|
|
1733
|
+
| 'waiting_tunnel'
|
|
1734
|
+
| 'connected'
|
|
1735
|
+
| 'failed'
|
|
1736
|
+
| 'blocked'
|
|
1737
|
+
|
|
1738
|
+
export interface CloudInstallStatus {
|
|
1739
|
+
flowId?: string
|
|
1740
|
+
state: CloudInstallState
|
|
1741
|
+
plan?: CloudInstallPlanSummary
|
|
1742
|
+
clusterName?: string
|
|
1743
|
+
connectUrl?: string
|
|
1744
|
+
connected?: CloudInstallConnected
|
|
1745
|
+
failure?: CloudInstallFailure
|
|
1746
|
+
blocked?: CloudInstallBlocked
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
export const cloudInstallActive = (state: CloudInstallState | undefined): boolean =>
|
|
1750
|
+
state === 'preparing' ||
|
|
1751
|
+
state === 'ready' ||
|
|
1752
|
+
state === 'starting' ||
|
|
1753
|
+
state === 'awaiting_approval' ||
|
|
1754
|
+
state === 'provisioning' ||
|
|
1755
|
+
state === 'waiting_tunnel';
|
|
1756
|
+
|
|
1757
|
+
// Poll while a flow is live so progress renders in near-real-time; the flow
|
|
1758
|
+
// itself is server-owned, so this hook merely observes (and re-attaches after
|
|
1759
|
+
// a page reload). gcTime 0: never cache connect-flow payloads.
|
|
1760
|
+
// What the Hub says about connecting to it. Fetched from the Hub itself, not
|
|
1761
|
+
// from Radar's API, so the dialog states current terms instead of whatever was
|
|
1762
|
+
// compiled into this binary months ago — a stale free-tier claim is a promise
|
|
1763
|
+
// Radar can't keep. Every field is optional and falls back per-field, so a Hub
|
|
1764
|
+
// that doesn't serve this (self-hosted, older, offline) renders exactly what
|
|
1765
|
+
// Radar renders today.
|
|
1766
|
+
export interface CloudConnectInfo {
|
|
1767
|
+
assurances?: string[]
|
|
1768
|
+
notice?: string
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
// Deliberately a bare cross-origin GET: no credentials, no identifiers, no
|
|
1772
|
+
// params. The Hub learns only what any HTTP request reveals.
|
|
1773
|
+
export function useCloudConnectInfo(apiUrl: string | undefined, enabled: boolean) {
|
|
1774
|
+
return useQuery<CloudConnectInfo>({
|
|
1775
|
+
queryKey: ["cloud-connect-info", apiUrl],
|
|
1776
|
+
queryFn: async () => {
|
|
1777
|
+
const res = await fetch(`${apiUrl}/api/connect/info`, {
|
|
1778
|
+
credentials: "omit",
|
|
1779
|
+
signal: AbortSignal.timeout(4000),
|
|
1780
|
+
});
|
|
1781
|
+
if (!res.ok) throw new Error(`connect info: ${res.status}`);
|
|
1782
|
+
return res.json();
|
|
1783
|
+
},
|
|
1784
|
+
// No apiUrl means the server decided this deployment must not fetch.
|
|
1785
|
+
enabled: enabled && !!apiUrl,
|
|
1786
|
+
staleTime: 5 * 60 * 1000,
|
|
1787
|
+
retry: false,
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
export function useCloudInstallStatus(enabled: boolean) {
|
|
1792
|
+
const queryClient = useQueryClient()
|
|
1793
|
+
const query = useQuery<CloudInstallStatus>({
|
|
1794
|
+
queryKey: ['cloud-install-status'],
|
|
1795
|
+
queryFn: () => fetchJSON('/cloud/install/status'),
|
|
1796
|
+
enabled,
|
|
1797
|
+
gcTime: 0,
|
|
1798
|
+
staleTime: 0,
|
|
1799
|
+
refetchInterval: (q) => (cloudInstallActive(q.state.data?.state) ? 1500 : false),
|
|
1800
|
+
})
|
|
1801
|
+
return {
|
|
1802
|
+
...query,
|
|
1803
|
+
invalidate: () => queryClient.invalidateQueries({ queryKey: ['cloud-install-status'] }),
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// In-cluster Radar describing its own installation, so the modal can route to
|
|
1808
|
+
// the right handoff instead of a generic signup link.
|
|
1809
|
+
export interface CloudConnectSelf {
|
|
1810
|
+
ownership: 'helm' | 'gitops' | 'ambiguous' | 'unknown'
|
|
1811
|
+
namespace?: string
|
|
1812
|
+
release?: string
|
|
1813
|
+
deploymentName?: string
|
|
1814
|
+
chart?: string
|
|
1815
|
+
controller?: string
|
|
1816
|
+
wizardUrl?: string
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
export function useCloudConnectSelf(enabled: boolean) {
|
|
1820
|
+
return useQuery<CloudConnectSelf>({
|
|
1821
|
+
queryKey: ['cloud-connect-self'],
|
|
1822
|
+
queryFn: () => fetchJSON('/cloud/connect/self'),
|
|
1823
|
+
enabled,
|
|
1824
|
+
staleTime: 60000,
|
|
1825
|
+
})
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
export function prepareCloudInstall(): Promise<CloudInstallStatus> {
|
|
1829
|
+
return fetchJSON('/cloud/install/prepare', { method: 'POST' })
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
export function startCloudInstall(body: {
|
|
1833
|
+
flowId: string
|
|
1834
|
+
clusterName: string
|
|
1835
|
+
acceptAdoption?: boolean
|
|
1836
|
+
acknowledgeIncompleteDiscovery?: boolean
|
|
1837
|
+
acknowledgeSharedListener?: boolean
|
|
1838
|
+
}): Promise<CloudInstallStatus> {
|
|
1839
|
+
return fetchJSON('/cloud/install/start', {
|
|
1840
|
+
method: 'POST',
|
|
1841
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1842
|
+
body: JSON.stringify(body),
|
|
1843
|
+
})
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
export function cancelCloudInstall(flowId: string): Promise<CloudInstallStatus> {
|
|
1847
|
+
return fetchJSON('/cloud/install/cancel', {
|
|
1848
|
+
method: 'POST',
|
|
1849
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1850
|
+
body: JSON.stringify({ flowId }),
|
|
1851
|
+
})
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
export function dismissCloudInstall(flowId: string): Promise<CloudInstallStatus> {
|
|
1855
|
+
return fetchJSON('/cloud/install/dismiss', {
|
|
1856
|
+
method: 'POST',
|
|
1857
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1858
|
+
body: JSON.stringify({ flowId }),
|
|
1859
|
+
})
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1548
1862
|
// Namespace-scoped capabilities. Users with namespace-scoped RoleBindings may
|
|
1549
1863
|
// have these permissions in specific namespaces.
|
|
1550
1864
|
export function useNamespaceCapabilities(
|