@skyhook-io/radar-app 1.9.5 → 1.9.7
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 +10 -10
- package/src/api/client.authRedirect.test.ts +194 -0
- package/src/api/client.trace.test.ts +37 -0
- package/src/api/client.ts +136 -3
- package/src/api/diagnose.ts +12 -0
- package/src/components/CloudConnectFlow.tsx +7 -7
- package/src/components/CloudFunnelButton.tsx +87 -64
- package/src/components/diagnose/DiagnoseContext.tsx +38 -6
- package/src/components/diagnose/DiagnoseSurface.test.tsx +70 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +50 -0
- package/src/components/diagnose/InvestigationView.tsx +44 -7
- package/src/components/diagnose/parts.test.tsx +39 -0
- package/src/components/diagnose/parts.tsx +22 -1
- package/src/components/home/HomeView.tsx +6 -1
- package/src/components/home/mcpToolCatalog.ts +7 -5
- package/src/components/workload/WorkloadView.tsx +580 -16
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.7",
|
|
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,22 +60,22 @@
|
|
|
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.
|
|
78
|
-
"react-router-dom": "^7.18.
|
|
76
|
+
"react": "^19.2.8",
|
|
77
|
+
"react-dom": "^19.2.8",
|
|
78
|
+
"react-router-dom": "^7.18.2",
|
|
79
79
|
"tailwind-merge": "^3.6.0",
|
|
80
80
|
"tailwindcss": "^4.3.3",
|
|
81
81
|
"typescript": "^6.0.2",
|
|
@@ -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).
|
|
@@ -262,6 +313,7 @@ export interface DashboardProblem {
|
|
|
262
313
|
ageSeconds: number;
|
|
263
314
|
duration: string;
|
|
264
315
|
durationSeconds: number;
|
|
316
|
+
onsetUnknown?: boolean;
|
|
265
317
|
podCount?: number;
|
|
266
318
|
}
|
|
267
319
|
|
|
@@ -626,6 +678,85 @@ export function useResourceIssues(
|
|
|
626
678
|
});
|
|
627
679
|
}
|
|
628
680
|
|
|
681
|
+
import type { Trace as NetworkTrace, InClusterCapability } from '@skyhook-io/k8s-ui'
|
|
682
|
+
|
|
683
|
+
// useTrace polls the static path-shaped diagnosis for one network entry
|
|
684
|
+
// kind. 5s refetch + 15s staleTime keeps the drawer feeling live without
|
|
685
|
+
// burning request budget; probes are deliberately excluded - they run via
|
|
686
|
+
// fetchTraceWithProbes on operator click.
|
|
687
|
+
export function useTrace(kind: string, namespace: string, name: string, enabled = true) {
|
|
688
|
+
return useQuery<NetworkTrace>({
|
|
689
|
+
queryKey: ['trace', kind, namespace, name, 'static'],
|
|
690
|
+
queryFn: () => fetchJSON(`/trace/${kind}/${namespace}/${name}`),
|
|
691
|
+
staleTime: 15000,
|
|
692
|
+
refetchInterval: enabled ? 5000 : false,
|
|
693
|
+
enabled: enabled && Boolean(kind) && Boolean(namespace) && Boolean(name),
|
|
694
|
+
})
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// fetchTraceWithProbes is one-shot rather than polled: probes generate real
|
|
698
|
+
// network traffic that observability systems can see, so they are NOT polled.
|
|
699
|
+
// They fire on an explicit, scoped trigger - the operator clicking Run, or
|
|
700
|
+
// opening the Reachability tab for a resource (auto-run once per resource, so the
|
|
701
|
+
// tab shows results instead of a blank "click Run" page) - never on every render
|
|
702
|
+
// or background refresh. The in-cluster probe (which spawns a Job) stays
|
|
703
|
+
// click-only.
|
|
704
|
+
export function fetchTraceWithProbes(kind: string, namespace: string, name: string, path?: string): Promise<NetworkTrace> {
|
|
705
|
+
const q = path && path !== '/' ? `&path=${encodeURIComponent(path)}` : ''
|
|
706
|
+
return fetchJSON(`/trace/${kind}/${namespace}/${name}?probe=true${q}`)
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// fetchInClusterCapability tells the UI whether the active "test from inside the
|
|
710
|
+
// cluster" probe can run for this caller (and names the cluster + namespace).
|
|
711
|
+
export function fetchInClusterCapability(kind: string, namespace: string, name: string): Promise<InClusterCapability> {
|
|
712
|
+
return fetchJSON(`/trace/${kind}/${namespace}/${name}/probe-in-cluster/capability`)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// InClusterMergedResult is the WHOLE-subject in-cluster test: the server runs every
|
|
716
|
+
// route's live probe, folds them in via the canonical trace.ApplyInClusterResults,
|
|
717
|
+
// and returns the FINALIZED trace - so the frontend displays it directly instead of
|
|
718
|
+
// reimplementing a weaker merge that could falsely confirm a sibling route.
|
|
719
|
+
// InClusterTestOutcome is one route's in-cluster result. A failure (the Job
|
|
720
|
+
// couldn't start / timed out / RBAC) comes back as HTTP 200 with a human status
|
|
721
|
+
// and a copyable fallbackCommand - it MUST be surfaced, not dropped.
|
|
722
|
+
export interface InClusterTestOutcome {
|
|
723
|
+
route: string
|
|
724
|
+
target?: string
|
|
725
|
+
status?: string
|
|
726
|
+
fallbackCommand?: string
|
|
727
|
+
// Raw probe results when the run produced any. A status WITH results is
|
|
728
|
+
// informational context; a status WITHOUT results means the route (or the
|
|
729
|
+
// whole subject) was not tested and the status must be surfaced.
|
|
730
|
+
results?: unknown[]
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
export interface InClusterMergedResult {
|
|
734
|
+
trace: NetworkTrace
|
|
735
|
+
inClusterTests?: InClusterTestOutcome[]
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// runInClusterMerged triggers the whole-subject in-cluster test and returns the
|
|
739
|
+
// server-finalized trace. The endpoint returns a JSON body on denial too, so
|
|
740
|
+
// parse the body either way and surface the error field.
|
|
741
|
+
export async function runInClusterMerged(kind: string, namespace: string, name: string, path?: string): Promise<InClusterMergedResult> {
|
|
742
|
+
const response = await apiFetch(`${getApiBase()}/trace/${kind}/${namespace}/${name}/in-cluster`, {
|
|
743
|
+
method: 'POST',
|
|
744
|
+
headers: { 'Content-Type': 'application/json' },
|
|
745
|
+
body: JSON.stringify({ path: path ?? '/' }),
|
|
746
|
+
})
|
|
747
|
+
// A gateway/proxy failure (chi's 60s timeout, a Hub ingress 502) returns
|
|
748
|
+
// HTML - an unguarded .json() surfaced "Unexpected token <" instead of the
|
|
749
|
+
// status. Mirror fetchJSON's tolerant parse.
|
|
750
|
+
const body = await response.json().catch(() => undefined)
|
|
751
|
+
if (!response.ok || body?.error) {
|
|
752
|
+
throw new Error(body?.error || `In-cluster test failed (${response.status})`)
|
|
753
|
+
}
|
|
754
|
+
if (body === undefined) {
|
|
755
|
+
throw new Error('In-cluster test returned an unreadable response')
|
|
756
|
+
}
|
|
757
|
+
return body
|
|
758
|
+
}
|
|
759
|
+
|
|
629
760
|
// Audit settings
|
|
630
761
|
export interface AuditSettings {
|
|
631
762
|
ignoredNamespaces: string[];
|
|
@@ -3491,6 +3622,7 @@ export function useUpdateResource() {
|
|
|
3491
3622
|
// Cascade delete preview — shows resources that will be garbage-collected
|
|
3492
3623
|
export interface CascadeDeletePreview {
|
|
3493
3624
|
root: { kind: string; namespace: string; name: string; group?: string };
|
|
3625
|
+
rootResolved: boolean;
|
|
3494
3626
|
dependents: {
|
|
3495
3627
|
kind: string;
|
|
3496
3628
|
namespace: string;
|
|
@@ -3503,13 +3635,14 @@ export function useCascadeDeletePreview(
|
|
|
3503
3635
|
kind: string,
|
|
3504
3636
|
namespace: string,
|
|
3505
3637
|
name: string,
|
|
3638
|
+
group: string | undefined,
|
|
3506
3639
|
enabled: boolean,
|
|
3507
3640
|
) {
|
|
3508
3641
|
return useQuery<CascadeDeletePreview>({
|
|
3509
|
-
queryKey: ["cascade-preview", kind, namespace, name],
|
|
3642
|
+
queryKey: ["cascade-preview", kind, group, namespace, name],
|
|
3510
3643
|
queryFn: () =>
|
|
3511
3644
|
fetchJSON<CascadeDeletePreview>(
|
|
3512
|
-
`/resources/${kind}/${namespace}/${name}/cascade-preview`,
|
|
3645
|
+
`/resources/${kind}/${namespace}/${name}/cascade-preview${group ? `?group=${encodeURIComponent(group)}` : ""}`,
|
|
3513
3646
|
),
|
|
3514
3647
|
enabled,
|
|
3515
3648
|
staleTime: 30_000,
|
package/src/api/diagnose.ts
CHANGED
|
@@ -91,6 +91,9 @@ export interface RunSummary {
|
|
|
91
91
|
kind: string;
|
|
92
92
|
namespace: string;
|
|
93
93
|
name: string;
|
|
94
|
+
/** The issue this session is for, on hosts that key sessions by issue. Always
|
|
95
|
+
* absent from Radar's own backend, which records no issue. */
|
|
96
|
+
issueId?: string;
|
|
94
97
|
context: string;
|
|
95
98
|
agent?: string; // backend CLI that drove this run ("claude"/"codex")
|
|
96
99
|
profile?: ExecutionProfile;
|
|
@@ -144,6 +147,15 @@ export async function createRun(
|
|
|
144
147
|
kind: string;
|
|
145
148
|
namespace: string;
|
|
146
149
|
name: string;
|
|
150
|
+
// Associates the session with the issue it was started from, for hosts that
|
|
151
|
+
// group sessions that way. Inert for Radar's own backend, which neither reads
|
|
152
|
+
// it on start nor emits it on RunSummary — carried so both hosts share one
|
|
153
|
+
// request shape.
|
|
154
|
+
issueId?: string;
|
|
155
|
+
// Start a new session rather than continuing whatever the backend would
|
|
156
|
+
// otherwise hand back for this target. Inert for Radar's own backend, which
|
|
157
|
+
// only ever continues an in-flight run — and that one is never bypassed.
|
|
158
|
+
fresh?: boolean;
|
|
147
159
|
},
|
|
148
160
|
opts?: {
|
|
149
161
|
agent?: string;
|
|
@@ -37,7 +37,7 @@ export function CloudConnectFlow({
|
|
|
37
37
|
switch (status.state) {
|
|
38
38
|
case 'preparing':
|
|
39
39
|
return (
|
|
40
|
-
<div className="px-
|
|
40
|
+
<div className="px-8 py-10 flex flex-col items-center gap-3 text-center">
|
|
41
41
|
<Loader2 className="w-5 h-5 animate-spin text-emerald-600 dark:text-emerald-400" />
|
|
42
42
|
<p className="text-[13px] text-theme-text-secondary">
|
|
43
43
|
Checking this cluster and preparing the install — this can take a moment on a slow link.
|
|
@@ -87,7 +87,7 @@ function BlockedView({
|
|
|
87
87
|
? 'Your Kubernetes identity can’t install this'
|
|
88
88
|
: 'This cluster can’t be connected from here'
|
|
89
89
|
return (
|
|
90
|
-
<div className="px-
|
|
90
|
+
<div className="px-8 pt-6 pb-5">
|
|
91
91
|
<div className="card-inner-lg flex gap-2.5">
|
|
92
92
|
{icon}
|
|
93
93
|
<div className="min-w-0">
|
|
@@ -198,7 +198,7 @@ function PlanCard({
|
|
|
198
198
|
(!!plan.sharedListener && !ackShared)
|
|
199
199
|
|
|
200
200
|
return (
|
|
201
|
-
<div className="px-
|
|
201
|
+
<div className="px-8 pt-6 pb-5">
|
|
202
202
|
<h4 className="text-[15px] font-semibold text-theme-text-primary mb-3">
|
|
203
203
|
{adopt ? 'Adopt and connect this cluster' : 'Connect this cluster'}
|
|
204
204
|
</h4>
|
|
@@ -356,7 +356,7 @@ function ApprovalCard({ status, onStatus }: { status: CloudInstallStatus; onStat
|
|
|
356
356
|
const cancel = useCancelButton(status, onStatus)
|
|
357
357
|
const starting = status.state === 'starting'
|
|
358
358
|
return (
|
|
359
|
-
<div className="px-
|
|
359
|
+
<div className="px-8 pt-6 pb-5">
|
|
360
360
|
<div className="flex items-center gap-2.5 mb-3">
|
|
361
361
|
<Loader2 className="w-4 h-4 animate-spin text-emerald-600 dark:text-emerald-400" />
|
|
362
362
|
<h4 className="text-[15px] font-semibold text-theme-text-primary">
|
|
@@ -394,7 +394,7 @@ function ProgressCard({ status, onStatus }: { status: CloudInstallStatus; onStat
|
|
|
394
394
|
{ label: 'Waiting for the agent to connect', state: provisioning ? 'todo' : 'active' },
|
|
395
395
|
]
|
|
396
396
|
return (
|
|
397
|
-
<div className="px-
|
|
397
|
+
<div className="px-8 pt-6 pb-5">
|
|
398
398
|
<h4 className="text-[15px] font-semibold text-theme-text-primary mb-3.5">
|
|
399
399
|
Connecting {status.clusterName}
|
|
400
400
|
</h4>
|
|
@@ -459,7 +459,7 @@ function ConnectedCard({
|
|
|
459
459
|
const connected = status.connected
|
|
460
460
|
if (!connected) return null
|
|
461
461
|
return (
|
|
462
|
-
<div className="px-
|
|
462
|
+
<div className="px-8 pt-6 pb-5">
|
|
463
463
|
<div className="flex items-center gap-2.5 mb-3">
|
|
464
464
|
<span className="w-7 h-7 rounded-full bg-emerald-500/20 grid place-items-center">
|
|
465
465
|
<Check className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
|
|
@@ -507,7 +507,7 @@ function FailedCard({
|
|
|
507
507
|
const failure = status.failure
|
|
508
508
|
if (!failure) return null
|
|
509
509
|
return (
|
|
510
|
-
<div className="px-
|
|
510
|
+
<div className="px-8 pt-6 pb-5">
|
|
511
511
|
<div className="flex items-start gap-2.5 mb-3">
|
|
512
512
|
<AlertTriangle className="w-4 h-4 shrink-0 mt-1 text-amber-500" />
|
|
513
513
|
<h4 className="text-[14px] font-semibold leading-snug text-theme-text-primary">{failure.message}</h4>
|