@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.9.4",
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.10.0",
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.17",
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.6.0",
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.7.0",
73
- "lucide-react": "^1.24.0",
74
- "postcss": "^8.5.16",
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.7",
77
- "react-dom": "^19.2.7",
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="/images/radar/radar-icon.svg"
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
- window.location.href = routePath("/auth/login");
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(