@skyhook-io/radar-app 0.2.1 → 0.3.1

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 (180) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/assets/platform-icons/aws_eks.png +0 -0
  19. package/src/assets/platform-icons/azure-aks.svg +2 -0
  20. package/src/assets/platform-icons/google_kubernetes_engine.png +0 -0
  21. package/src/components/ConnectionErrorView.tsx +186 -70
  22. package/src/components/ContextSwitcher.tsx +63 -18
  23. package/src/components/DebugOverlay.tsx +5 -3
  24. package/src/components/NamespaceSwitcher.tsx +41 -0
  25. package/src/components/UserMenu.tsx +69 -21
  26. package/src/components/applications/ApplicationsView.tsx +936 -0
  27. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  28. package/src/components/audit/AuditView.tsx +65 -62
  29. package/src/components/compare/CompareViewRoute.tsx +124 -0
  30. package/src/components/compare/useCompareCandidates.ts +27 -0
  31. package/src/components/compare/useCompareLauncher.tsx +79 -0
  32. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  33. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  34. package/src/components/cost/CostTrendChart.tsx +106 -75
  35. package/src/components/cost/CostView.test.ts +12 -0
  36. package/src/components/cost/CostView.tsx +507 -223
  37. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  38. package/src/components/cost/CostViewTabs.tsx +40 -0
  39. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  40. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  41. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  42. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  43. package/src/components/cost/cloud-console.test.ts +39 -0
  44. package/src/components/cost/cloud-console.ts +81 -0
  45. package/src/components/cost/errors.ts +8 -0
  46. package/src/components/cost/format.test.ts +27 -0
  47. package/src/components/cost/format.ts +46 -0
  48. package/src/components/cost/kinds.ts +5 -0
  49. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  50. package/src/components/diagnose/AISettings.tsx +147 -0
  51. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  52. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  53. package/src/components/diagnose/Home.tsx +163 -0
  54. package/src/components/diagnose/InvestigationView.tsx +622 -0
  55. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  56. package/src/components/diagnose/launch.ts +65 -0
  57. package/src/components/diagnose/parts.tsx +1756 -0
  58. package/src/components/dock/BottomDock.tsx +2 -3
  59. package/src/components/dock/DockContext.tsx +1 -0
  60. package/src/components/dock/TerminalTab.tsx +1 -1
  61. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  62. package/src/components/dock/index.ts +1 -1
  63. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  64. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  65. package/src/components/execution/batch-run-actions.test.ts +48 -0
  66. package/src/components/execution/batch-run-actions.ts +24 -0
  67. package/src/components/execution/batch-timeline.test.ts +57 -0
  68. package/src/components/execution/batch-timeline.ts +46 -0
  69. package/src/components/execution/execution-definition.test.ts +208 -0
  70. package/src/components/execution/execution-definition.ts +245 -0
  71. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  72. package/src/components/gitops/GitOpsView.tsx +1042 -0
  73. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  74. package/src/components/helm/ChartBrowser.tsx +87 -31
  75. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  76. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  77. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  78. package/src/components/helm/HelmView.tsx +237 -96
  79. package/src/components/helm/InstallWizard.tsx +94 -38
  80. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  81. package/src/components/helm/OwnedResources.tsx +34 -59
  82. package/src/components/helm/RevisionHistory.tsx +52 -3
  83. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  84. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  85. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  86. package/src/components/helm/ValuesViewer.tsx +49 -53
  87. package/src/components/helm/helm-utils.ts +4 -0
  88. package/src/components/home/ActivitySummary.tsx +4 -1
  89. package/src/components/home/ClusterHealthCard.tsx +116 -52
  90. package/src/components/home/CostCard.tsx +21 -36
  91. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  92. package/src/components/home/HelmSummary.tsx +3 -1
  93. package/src/components/home/HomeView.tsx +339 -105
  94. package/src/components/home/MCPSetupDialog.tsx +29 -87
  95. package/src/components/home/TrafficSummary.tsx +2 -2
  96. package/src/components/home/mcpToolCatalog.ts +333 -0
  97. package/src/components/issues/IssuesPane.tsx +151 -0
  98. package/src/components/logs/LogsViewer.tsx +4 -1
  99. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  100. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  101. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  102. package/src/components/portforward/PortForwardButton.tsx +118 -47
  103. package/src/components/portforward/PortForwardManager.tsx +253 -131
  104. package/src/components/resource/HPACharts.tsx +237 -0
  105. package/src/components/resource/PVCUsageBar.tsx +59 -0
  106. package/src/components/resource/PrometheusCharts.tsx +160 -584
  107. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  108. package/src/components/resource/RestartChart.tsx +133 -0
  109. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  110. package/src/components/resource/RightsizingStrip.tsx +363 -0
  111. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  112. package/src/components/resources/CompositeRenderer.tsx +101 -0
  113. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  114. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  115. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  116. package/src/components/resources/ResourcesView.tsx +194 -17
  117. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  118. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  119. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  120. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  121. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  122. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  123. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  124. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  125. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  126. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  127. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  128. package/src/components/resources/renderers/index.ts +2 -0
  129. package/src/components/resources/resource-utils.ts +2 -1
  130. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  131. package/src/components/rightsizing/copy.test.ts +56 -0
  132. package/src/components/rightsizing/model.test.ts +227 -0
  133. package/src/components/rightsizing/model.ts +158 -0
  134. package/src/components/rightsizing/presentation.test.ts +104 -0
  135. package/src/components/rightsizing/presentation.ts +94 -0
  136. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  137. package/src/components/settings/SettingsDialog.tsx +1505 -165
  138. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  139. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  140. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  141. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  142. package/src/components/timeline/TimelineList.tsx +86 -13
  143. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  144. package/src/components/timeline/TimelineView.tsx +873 -24
  145. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  146. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  147. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  148. package/src/components/traffic/TrafficGraph.tsx +42 -24
  149. package/src/components/traffic/TrafficView.tsx +32 -19
  150. package/src/components/ui/CommandPalette.tsx +8 -215
  151. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  152. package/src/components/ui/Markdown.tsx +3 -3
  153. package/src/components/ui/Omnibar.tsx +602 -0
  154. package/src/components/ui/RadarOmnibar.tsx +52 -0
  155. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  156. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  157. package/src/components/ui/UpdateNotification.tsx +48 -36
  158. package/src/components/ui/command-items.ts +178 -0
  159. package/src/components/workload/WorkloadView.tsx +1342 -158
  160. package/src/context/ConnectionContext.tsx +146 -21
  161. package/src/context/DiagnoseCustomization.tsx +93 -0
  162. package/src/context/NavCustomization.tsx +75 -0
  163. package/src/context/TimelineSource.tsx +50 -0
  164. package/src/contexts/CapabilitiesContext.tsx +38 -8
  165. package/src/filter/FilterLocationBridge.tsx +30 -0
  166. package/src/hooks/useClusterLoadState.ts +73 -0
  167. package/src/hooks/useDocumentTitle.ts +25 -0
  168. package/src/hooks/useEventSource.ts +6 -0
  169. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  170. package/src/hooks/useMediaQuery.ts +21 -0
  171. package/src/hooks/useNavRailPinned.ts +46 -0
  172. package/src/hooks/useRecentResources.ts +49 -0
  173. package/src/index.css +162 -1
  174. package/src/index.ts +73 -1
  175. package/src/main.tsx +7 -5
  176. package/src/types/clusterLoadState.ts +33 -0
  177. package/src/types.ts +2 -0
  178. package/src/utils/auditBadges.ts +53 -0
  179. package/src/utils/navigation.ts +64 -1
  180. package/src/components/ui/NamespaceSelector.tsx +0 -436
@@ -1,9 +1,11 @@
1
- import { XCircle, RefreshCw, Loader2, Copy, Check, TerminalSquare } from 'lucide-react'
2
- import { useState } from 'react'
1
+ import { ServerOff, RefreshCw, Loader2, Copy, Check, TerminalSquare, ChevronRight } from 'lucide-react'
2
+ import { useEffect, useState } from 'react'
3
3
  import type { ConnectionState } from '../context/ConnectionContext'
4
4
  import { ContextSwitcher } from './ContextSwitcher'
5
5
  import { parseContextName } from '../utils/context-name'
6
6
  import { useOpenLocalTerminal, ClusterName } from '@skyhook-io/k8s-ui'
7
+ import { useAuthMe } from '../api/client'
8
+ import { Tooltip } from './ui/Tooltip'
7
9
 
8
10
  interface ConnectionErrorViewProps {
9
11
  connection: ConnectionState
@@ -27,8 +29,8 @@ function getAuthHints(context: string): AuthHints {
27
29
  case 'GKE': {
28
30
  const result: AuthHints = {
29
31
  title: 'GKE Authentication Failed',
30
- hints: ['Your Google Cloud credentials have expired.'],
31
- authCommand: { label: 'Re-authenticate with Google Cloud:', command: 'gcloud auth login' },
32
+ hints: ['Radar could not get Google Cloud credentials for this context.'],
33
+ authCommand: { label: 'Refresh Google Cloud credentials:', command: 'gcloud auth login' },
32
34
  }
33
35
  if (parsed.region && parsed.account) {
34
36
  const isZone = /^[a-z]+-[a-z]+\d+-[a-z]$/.test(parsed.region)
@@ -43,8 +45,11 @@ function getAuthHints(context: string): AuthHints {
43
45
  case 'EKS': {
44
46
  const result: AuthHints = {
45
47
  title: 'EKS Authentication Failed',
46
- hints: ['Your AWS credentials have expired.'],
47
- authCommand: { label: 'Re-authenticate with AWS:', command: 'aws sso login' },
48
+ hints: [
49
+ 'Radar could not get AWS credentials for this context.',
50
+ 'For AWS SSO contexts, the SSO session may need login.',
51
+ ],
52
+ authCommand: { label: 'If this context uses AWS SSO, refresh credentials:', command: 'aws sso login' },
48
53
  }
49
54
  if (parsed.region) {
50
55
  result.fallbackCommand = {
@@ -57,21 +62,71 @@ function getAuthHints(context: string): AuthHints {
57
62
  case 'AKS':
58
63
  return {
59
64
  title: 'AKS Authentication Failed',
60
- hints: ['Your Azure credentials have expired.'],
61
- authCommand: { label: 'Re-authenticate with Azure:', command: 'az login' },
65
+ hints: ['Radar could not get Azure credentials for this context.'],
66
+ authCommand: { label: 'Refresh Azure credentials:', command: 'az login' },
62
67
  fallbackCommand: { label: 'If that doesn\'t work, refresh cluster credentials:', command: 'az aks get-credentials --name <cluster> --resource-group <rg>' },
63
68
  }
64
69
  default:
65
70
  return {
66
71
  title: 'Authentication Failed',
67
72
  hints: [
68
- 'Your credentials may have expired',
73
+ 'Radar could not get Kubernetes credentials for this context',
69
74
  'Re-authenticate with your cloud provider and try again',
70
75
  ],
71
76
  }
72
77
  }
73
78
  }
74
79
 
80
+ function getTimeoutHints(context: string): AuthHints | null {
81
+ const parsed = parseContextName(context)
82
+ const baseHints = [
83
+ 'The Kubernetes API did not respond before the deadline.',
84
+ 'Check VPN, firewall rules, and whether the cluster endpoint is reachable.',
85
+ ]
86
+
87
+ switch (parsed.provider) {
88
+ case 'GKE': {
89
+ const result: AuthHints = {
90
+ title: 'Connection Timed Out',
91
+ hints: [...baseHints, 'If the endpoint is reachable, Google Cloud credentials may need refresh.'],
92
+ authCommand: { label: 'If network access looks healthy, refresh Google Cloud credentials:', command: 'gcloud auth login' },
93
+ }
94
+ if (parsed.region && parsed.account) {
95
+ const isZone = /^[a-z]+-[a-z]+\d+-[a-z]$/.test(parsed.region)
96
+ const flag = isZone ? '--zone' : '--region'
97
+ result.fallbackCommand = {
98
+ label: 'If that does not work, refresh cluster credentials:',
99
+ command: `gcloud container clusters get-credentials ${parsed.clusterName} ${flag} ${parsed.region} --project ${parsed.account}`,
100
+ }
101
+ }
102
+ return result
103
+ }
104
+ case 'EKS': {
105
+ const result: AuthHints = {
106
+ title: 'Connection Timed Out',
107
+ hints: [...baseHints, 'If the endpoint is reachable, AWS credentials or SSO may need refresh.'],
108
+ authCommand: { label: 'If this context uses AWS SSO and network access looks healthy, refresh credentials:', command: 'aws sso login' },
109
+ }
110
+ if (parsed.region) {
111
+ result.fallbackCommand = {
112
+ label: 'If that does not work, refresh cluster credentials:',
113
+ command: `aws eks update-kubeconfig --name ${parsed.clusterName} --region ${parsed.region}`,
114
+ }
115
+ }
116
+ return result
117
+ }
118
+ case 'AKS':
119
+ return {
120
+ title: 'Connection Timed Out',
121
+ hints: [...baseHints, 'If the endpoint is reachable, Azure credentials may need refresh.'],
122
+ 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>' },
124
+ }
125
+ default:
126
+ return null
127
+ }
128
+ }
129
+
75
130
  const errorHints: Record<string, { title: string; hints: string[] }> = {
76
131
  config: {
77
132
  title: 'No Kubeconfig Found',
@@ -99,6 +154,14 @@ const errorHints: Record<string, { title: string; hints: string[] }> = {
99
154
  'Confirm the cluster is running',
100
155
  ],
101
156
  },
157
+ tls: {
158
+ title: 'Certificate Error',
159
+ 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',
162
+ 'If this cluster intentionally uses a private CA, refresh the kubeconfig for this context',
163
+ ],
164
+ },
102
165
  timeout: {
103
166
  title: 'Connection Timed Out',
104
167
  hints: [
@@ -120,6 +183,7 @@ const errorHints: Record<string, { title: string; hints: string[] }> = {
120
183
 
121
184
  function CopyableCommand({ command, onRunInTerminal }: { command: string; onRunInTerminal?: (command: string) => void }) {
122
185
  const [copied, setCopied] = useState(false)
186
+ const commandParts = command.split(/(\s+)/)
123
187
 
124
188
  const handleCopy = () => {
125
189
  navigator.clipboard.writeText(command).then(() => {
@@ -132,29 +196,37 @@ function CopyableCommand({ command, onRunInTerminal }: { command: string; onRunI
132
196
 
133
197
  return (
134
198
  <div className="mt-2 flex items-center gap-2 bg-theme-elevated border border-theme-border rounded-md px-3 py-2 group">
135
- <code className="text-xs font-mono text-theme-text-primary flex-1 select-all break-all">
136
- {command}
199
+ <code className="text-xs font-mono text-theme-text-primary flex-1 min-w-0 select-all whitespace-pre-wrap break-normal">
200
+ {commandParts.map((part, index) => (
201
+ /\s+/.test(part) ? part : <span key={index} className="inline-block whitespace-nowrap">{part}</span>
202
+ ))}
137
203
  </code>
138
204
  {onRunInTerminal && (
205
+ <Tooltip content="Run in terminal" wrapperClassName="shrink-0">
206
+ <button
207
+ type="button"
208
+ onClick={() => onRunInTerminal(command)}
209
+ aria-label="Run command in terminal"
210
+ className="shrink-0 text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
211
+ >
212
+ <TerminalSquare className="w-3.5 h-3.5" />
213
+ </button>
214
+ </Tooltip>
215
+ )}
216
+ <Tooltip content="Copy to clipboard" wrapperClassName="shrink-0">
139
217
  <button
140
- onClick={() => onRunInTerminal(command)}
218
+ type="button"
219
+ onClick={handleCopy}
220
+ aria-label="Copy command to clipboard"
141
221
  className="shrink-0 text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
142
- title="Run in terminal"
143
222
  >
144
- <TerminalSquare className="w-3.5 h-3.5" />
223
+ {copied ? (
224
+ <Check className="w-3.5 h-3.5 text-green-400" />
225
+ ) : (
226
+ <Copy className="w-3.5 h-3.5" />
227
+ )}
145
228
  </button>
146
- )}
147
- <button
148
- onClick={handleCopy}
149
- className="shrink-0 text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
150
- title="Copy to clipboard"
151
- >
152
- {copied ? (
153
- <Check className="w-3.5 h-3.5 text-green-400" />
154
- ) : (
155
- <Copy className="w-3.5 h-3.5" />
156
- )}
157
- </button>
229
+ </Tooltip>
158
230
  </div>
159
231
  )
160
232
  }
@@ -162,17 +234,36 @@ function CopyableCommand({ command, onRunInTerminal }: { command: string; onRunI
162
234
  export function ConnectionErrorView({ connection, onRetry, isRetrying }: ConnectionErrorViewProps) {
163
235
  // For auth errors, generate context-aware hints with a specific re-auth command
164
236
  const isAuth = connection.errorType === 'auth'
165
- const authInfo = isAuth ? getAuthHints(connection.context || '') : null
166
- const errorInfo = authInfo || errorHints[connection.errorType || 'unknown'] || errorHints.unknown
237
+ const isTimeout = connection.errorType === 'timeout'
238
+ const commandInfo = isAuth
239
+ ? getAuthHints(connection.context || '')
240
+ : isTimeout
241
+ ? getTimeoutHints(connection.context || '')
242
+ : null
243
+ const errorInfo = commandInfo || errorHints[connection.errorType || 'unknown'] || errorHints.unknown
167
244
  const openLocalTerminal = useOpenLocalTerminal()
245
+ const { data: authMe } = useAuthMe()
246
+ const rawErrorDefaultOpen = !connection.errorType || connection.errorType === 'unknown'
247
+ const [showRawError, setShowRawError] = useState(rawErrorDefaultOpen)
168
248
 
169
- // Build a command that auto-retries connection after successful auth
249
+ useEffect(() => {
250
+ setShowRawError(rawErrorDefaultOpen)
251
+ }, [connection.error, rawErrorDefaultOpen])
252
+
253
+ // Auto-retry after successful auth. The terminal shell runs on the server
254
+ // host, so the auth command itself fixes the server's credentials in every
255
+ // mode — but the chained retry curl carries no session cookie, so it 401s
256
+ // once /api/connection is auth-gated. Only chain it when auth is *known*
257
+ // disabled (authMe still loading → don't chain a doomed call).
170
258
  const retryCmd = `curl -s -X POST http://${window.location.host}/api/connection/retry > /dev/null`
171
259
 
172
260
  const handleAuthInTerminal = () => {
173
- if (!authInfo?.authCommand) return
261
+ if (!commandInfo?.authCommand) return
262
+ const cmd = authMe?.authEnabled === false
263
+ ? `${commandInfo.authCommand.command} && ${retryCmd}`
264
+ : commandInfo.authCommand.command
174
265
  openLocalTerminal({
175
- initialCommand: `${authInfo.authCommand.command} && ${retryCmd}`,
266
+ initialCommand: cmd,
176
267
  title: 'Auth',
177
268
  })
178
269
  }
@@ -182,32 +273,34 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
182
273
  }
183
274
 
184
275
  return (
185
- <div className="flex-1 flex items-start justify-center pt-16 px-8">
186
- <div className="max-w-lg w-full">
276
+ <div className="flex-1 flex items-start justify-center pt-12 px-8">
277
+ <div className="max-w-xl w-full">
187
278
  <div className="flex flex-col items-center text-center">
188
- <div className="w-16 h-16 rounded-full bg-red-500/10 flex items-center justify-center mb-6">
189
- <XCircle className="w-10 h-10 text-red-400" />
279
+ <div className="w-14 h-14 rounded-full bg-red-500/10 flex items-center justify-center mb-5">
280
+ <ServerOff className="w-8 h-8 text-red-400" />
190
281
  </div>
191
282
 
192
283
  <h2 className="text-xl font-semibold text-theme-text-primary mb-2">
193
284
  {connection.errorType === 'config' ? 'No Cluster Configuration' : 'Cannot Connect to Cluster'}
194
285
  </h2>
195
286
 
196
- <p className="text-sm text-theme-text-secondary mb-1 inline-flex items-center gap-1.5">
197
- Context: {connection.context ? (
198
- <ClusterName name={connection.context} />
199
- ) : (
200
- <span className="font-mono text-theme-text-primary">(none)</span>
201
- )}
202
- </p>
203
-
204
- {connection.clusterName && (
205
- <p className="text-sm text-theme-text-secondary mb-4">
206
- Cluster: <span className="font-mono text-theme-text-primary">{connection.clusterName}</span>
287
+ <div className="mb-6 space-y-1">
288
+ <p className="text-sm text-theme-text-secondary inline-flex items-center gap-1.5">
289
+ Context: {connection.context ? (
290
+ <ClusterName name={connection.context} />
291
+ ) : (
292
+ <span className="inline-code">(none)</span>
293
+ )}
207
294
  </p>
208
- )}
209
295
 
210
- <div className="w-full bg-theme-surface border border-theme-border rounded-lg p-4 mb-6 text-left">
296
+ {connection.clusterName && (
297
+ <p className="text-sm text-theme-text-secondary">
298
+ Cluster: <span className="inline-code">{connection.clusterName}</span>
299
+ </p>
300
+ )}
301
+ </div>
302
+
303
+ <div className="w-full bg-theme-surface border border-theme-border rounded-lg p-4 mb-5 text-left">
211
304
  <h3 className="text-sm font-medium text-theme-text-primary mb-2">
212
305
  {errorInfo.title}
213
306
  </h3>
@@ -219,35 +312,52 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
219
312
  </li>
220
313
  ))}
221
314
  </ul>
222
- {authInfo?.authCommand && (
315
+ {commandInfo?.authCommand && (
223
316
  <div className="mt-3">
224
- <p className="text-xs text-theme-text-tertiary">{authInfo.authCommand.label}</p>
225
- <CopyableCommand command={authInfo.authCommand.command} onRunInTerminal={handleRunInTerminal} />
226
- <button
227
- onClick={handleAuthInTerminal}
228
- 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"
229
- >
230
- <TerminalSquare className="w-3.5 h-3.5" />
231
- Authenticate in terminal
232
- </button>
317
+ <p className="text-xs text-theme-text-tertiary">{commandInfo.authCommand.label}</p>
318
+ <CopyableCommand command={commandInfo.authCommand.command} onRunInTerminal={handleRunInTerminal} />
319
+ {isAuth && (
320
+ <button
321
+ onClick={handleAuthInTerminal}
322
+ 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"
323
+ >
324
+ <TerminalSquare className="w-3.5 h-3.5" />
325
+ Authenticate in terminal
326
+ </button>
327
+ )}
233
328
  </div>
234
329
  )}
235
- {authInfo?.fallbackCommand && (
330
+ {commandInfo?.fallbackCommand && (
236
331
  <div className="mt-4 pt-3 border-t border-theme-border/50">
237
- <p className="text-xs text-theme-text-tertiary">{authInfo.fallbackCommand.label}</p>
238
- <CopyableCommand command={authInfo.fallbackCommand.command} onRunInTerminal={handleRunInTerminal} />
332
+ <p className="text-xs text-theme-text-tertiary">{commandInfo.fallbackCommand.label}</p>
333
+ <CopyableCommand command={commandInfo.fallbackCommand.command} onRunInTerminal={handleRunInTerminal} />
334
+ </div>
335
+ )}
336
+ {connection.error && (
337
+ <div className="mt-4 pt-3 border-t border-theme-border/50">
338
+ <button
339
+ type="button"
340
+ aria-expanded={showRawError}
341
+ aria-controls="connection-raw-error"
342
+ onClick={() => setShowRawError((open) => !open)}
343
+ className="flex items-center gap-1 text-xs font-medium text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
344
+ >
345
+ <ChevronRight className={`h-3.5 w-3.5 transition-transform duration-200 ${showRawError ? 'rotate-90' : ''}`} />
346
+ Raw error
347
+ </button>
348
+ <div className={`issue-details-motion ${showRawError ? 'issue-details-motion-open' : ''}`}>
349
+ <div className="overflow-hidden">
350
+ <div id="connection-raw-error" className="mt-2 bg-theme-elevated border border-theme-border rounded-md p-3 overflow-auto max-h-32">
351
+ <code className="text-xs text-theme-text-tertiary font-mono whitespace-pre-wrap break-words">
352
+ {connection.error}
353
+ </code>
354
+ </div>
355
+ </div>
356
+ </div>
239
357
  </div>
240
358
  )}
241
359
  </div>
242
360
 
243
- {connection.error && (
244
- <div className="w-full bg-theme-elevated border border-theme-border rounded-lg p-3 mb-6 overflow-auto max-h-32">
245
- <code className="text-xs text-red-400 font-mono whitespace-pre-wrap break-all">
246
- {connection.error}
247
- </code>
248
- </div>
249
- )}
250
-
251
361
  <div className="flex items-center gap-5">
252
362
  <button
253
363
  onClick={onRetry}
@@ -267,8 +377,14 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
267
377
  )}
268
378
  </button>
269
379
 
270
- {connection.errorType !== 'config' && <ContextSwitcher />}
380
+ {connection.errorType !== 'config' && <ContextSwitcher triggerName="Switch context" />}
271
381
  </div>
382
+
383
+ {isAuth && (
384
+ <p className="mt-4 text-xs text-theme-text-tertiary">
385
+ Radar will keep retrying after credentials are refreshed.
386
+ </p>
387
+ )}
272
388
  </div>
273
389
  </div>
274
390
  </div>
@@ -1,4 +1,4 @@
1
- import { useMemo, useState } from 'react'
1
+ import { useMemo, useState, forwardRef } from 'react'
2
2
  import { AlertTriangle } from 'lucide-react'
3
3
  import {
4
4
  ClusterSwitcher,
@@ -14,13 +14,25 @@ import { parseContextName, type ParsedContextName } from '../utils/context-name'
14
14
 
15
15
  interface ContextSwitcherProps {
16
16
  className?: string
17
+ variant?: 'chip' | 'segment'
18
+ label?: string
19
+ triggerName?: string
20
+ }
21
+
22
+ export interface ContextSwitcherHandle {
23
+ open: () => void
17
24
  }
18
25
 
19
26
  interface ParsedContext extends ParsedContextName {
20
27
  context: ContextInfo
21
28
  }
22
29
 
23
- export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
30
+ function shouldSuppressSwitchErrorToast(error: unknown): boolean {
31
+ const message = error instanceof Error ? error.message : ''
32
+ return message.includes('cluster connection failed:')
33
+ }
34
+
35
+ export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcherProps>(({ className = '', variant, label, triggerName }, ref) => {
24
36
  const [showConfirm, setShowConfirm] = useState(false)
25
37
  const [pendingSwitch, setPendingSwitch] = useState<ParsedContext | null>(null)
26
38
  const [sessionCounts, setSessionCounts] = useState<SessionCounts | null>(null)
@@ -33,13 +45,37 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
33
45
  const { tabs } = useDock()
34
46
 
35
47
  // Parse contexts and decide whether to render group headers (multi-account only).
36
- const { parsedById, hasMultipleAccounts } = useMemo(() => {
37
- if (!contexts) return { parsedById: new Map<string, ParsedContext>(), hasMultipleAccounts: false }
38
- const parsed: ParsedContext[] = contexts.map(ctx => ({ context: ctx, ...parseContextName(ctx.name) }))
48
+ // hasMultipleSources gates the kubeconfig-source chip only useful when 2+
49
+ // distinct kubeconfig files are in play. Single-source setups (the common
50
+ // case) skip the chip entirely so the dropdown stays clean.
51
+ const { parsedById, hasMultipleAccounts, hasMultipleSources } = useMemo(() => {
52
+ if (!contexts) return {
53
+ parsedById: new Map<string, ParsedContext>(),
54
+ hasMultipleAccounts: false,
55
+ hasMultipleSources: false,
56
+ }
57
+ // Strip the disambiguation suffix (" (<source>)" or " (<source> #N)")
58
+ // before parsing — qualified names won't match the GKE/EKS/AKS regexes
59
+ // otherwise, and the suffix is redundant with the source chip we
60
+ // render separately.
61
+ const stripSourceSuffix = (name: string, source?: string): string => {
62
+ if (!source) return name
63
+ const escaped = source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
64
+ return name.replace(new RegExp(`\\s+\\(${escaped}(?:\\s+#\\d+)?\\)$`), '')
65
+ }
66
+ const parsed: ParsedContext[] = contexts.map(ctx => ({
67
+ context: ctx,
68
+ ...parseContextName(stripSourceSuffix(ctx.name, ctx.source)),
69
+ }))
39
70
  const accounts = new Set(parsed.map(p => `${p.provider}:${p.account}`))
71
+ const sources = new Set(contexts.map(c => c.source).filter(Boolean))
40
72
  const byId = new Map<string, ParsedContext>()
41
73
  for (const p of parsed) byId.set(p.context.name, p)
42
- return { parsedById: byId, hasMultipleAccounts: accounts.size > 1 }
74
+ return {
75
+ parsedById: byId,
76
+ hasMultipleAccounts: accounts.size > 1,
77
+ hasMultipleSources: sources.size > 1,
78
+ }
43
79
  }, [contexts])
44
80
 
45
81
  // Map parsed contexts → generic ClusterSwitcher items, sorted GKE/EKS/AKS/Other → account → name.
@@ -61,20 +97,16 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
61
97
  : hasMultipleAccounts
62
98
  ? 'Other'
63
99
  : undefined
64
- // `name` is the raw context — ClusterSwitcher renders it through
65
- // ClusterName, which collapses GKE/EKS/AKS shapes to the meaningful
66
- // tail. `secondary` shows the original raw when we collapsed it,
67
- // so users always see the full context at a glance (rather than
68
- // having to hover to reveal it).
69
100
  return {
70
101
  id: p.context.name,
71
- name: p.context.name,
102
+ name: p.raw,
72
103
  secondary: p.provider ? p.raw : undefined,
73
104
  badge: p.region || undefined,
105
+ sourceLabel: hasMultipleSources ? p.context.source : undefined,
74
106
  group: { key: groupKey, label: groupLabel },
75
107
  }
76
108
  })
77
- }, [parsedById, hasMultipleAccounts])
109
+ }, [parsedById, hasMultipleAccounts, hasMultipleSources])
78
110
 
79
111
  const performSwitch = async (parsed: ParsedContext) => {
80
112
  startSwitch({
@@ -91,8 +123,10 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
91
123
  endSwitch()
92
124
  // Backend may not transition to StateDisconnected on client-side errors
93
125
  // (network, timeout) — without this toast the user gets no feedback.
94
- const message = error instanceof Error ? error.message : 'Unknown error'
95
- showError('Failed to switch context', message)
126
+ if (!shouldSuppressSwitchErrorToast(error)) {
127
+ const message = error instanceof Error ? error.message : 'Unknown error'
128
+ showError('Failed to switch context', message)
129
+ }
96
130
  }
97
131
  }
98
132
 
@@ -152,15 +186,26 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
152
186
  )
153
187
  }
154
188
 
155
- const currentRaw = clusterInfo?.context || contexts?.find(c => c.isCurrent)?.name || 'Unknown'
156
- const currentId = contexts?.find(c => c.isCurrent)?.name
189
+ const currentCtx = contexts?.find(c => c.isCurrent)
190
+ const currentId = currentCtx?.name
191
+ // Use parsed.raw (the source-stripped form) for the trigger so the
192
+ // disambiguation suffix doesn't double up with the source chip.
193
+ // Fall back to clusterInfo.context for the very-early window before
194
+ // /api/contexts has resolved.
195
+ const currentParsed = currentId ? parsedById.get(currentId) : undefined
196
+ const currentRaw = triggerName || currentParsed?.raw || clusterInfo?.context || currentCtx?.name || 'Unknown'
197
+ const currentSourceLabel = triggerName ? undefined : hasMultipleSources ? currentCtx?.source || undefined : undefined
157
198
 
158
199
  return (
159
200
  <>
160
201
  <ClusterSwitcher
202
+ ref={ref}
161
203
  className={className}
204
+ variant={variant}
205
+ label={label}
162
206
  currentId={currentId}
163
207
  currentName={currentRaw}
208
+ currentSourceLabel={currentSourceLabel}
164
209
  items={items}
165
210
  onSelect={handleSelect}
166
211
  loading={switchContext.isPending}
@@ -222,4 +267,4 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
222
267
  )}
223
268
  </>
224
269
  )
225
- }
270
+ })
@@ -1,6 +1,7 @@
1
1
  import { useState } from 'react'
2
2
  import { Bug, X, ChevronDown, ChevronUp } from 'lucide-react'
3
3
  import { useRuntimeStats } from '../api/client'
4
+ import { Tooltip } from './ui/Tooltip'
4
5
 
5
6
  function formatUptime(seconds: number): string {
6
7
  if (seconds < 60) return `${seconds}s`
@@ -17,13 +18,14 @@ export function DebugOverlay() {
17
18
 
18
19
  if (!visible) {
19
20
  return (
21
+ <Tooltip content="Show debug stats" position="left" wrapperClassName="fixed bottom-3 right-3 z-50">
20
22
  <button
21
23
  onClick={() => setVisible(true)}
22
- className="fixed bottom-3 right-3 z-50 p-2 bg-theme-surface/90 border border-theme-border rounded-lg text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
23
- title="Show debug stats"
24
+ className="p-2 bg-theme-surface/90 border border-theme-border rounded-lg text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
24
25
  >
25
26
  <Bug className="w-4 h-4" />
26
27
  </button>
28
+ </Tooltip>
27
29
  )
28
30
  }
29
31
 
@@ -86,7 +88,7 @@ export function DebugOverlay() {
86
88
  )}
87
89
  </>
88
90
  ) : (
89
- <span className="text-theme-text-tertiary">Loading...</span>
91
+ <span className="text-theme-text-tertiary">Loading…</span>
90
92
  )}
91
93
  </div>
92
94
  </div>
@@ -0,0 +1,41 @@
1
+ import { forwardRef } from 'react'
2
+ import { NamespacePicker, type NamespacePickerHandle } from '@skyhook-io/k8s-ui'
3
+ import { useNamespaceScope, useSetActiveNamespace } from '../api/client'
4
+
5
+ export type NamespaceSwitcherHandle = NamespacePickerHandle
6
+
7
+ interface NamespaceSwitcherProps {
8
+ className?: string
9
+ disabled?: boolean
10
+ disabledTooltip?: string
11
+ variant?: 'chip' | 'segment'
12
+ label?: string
13
+ }
14
+
15
+ /**
16
+ * OSS Radar's namespace scope control — a thin data container over the shared
17
+ * presentational NamespacePicker (@skyhook-io/k8s-ui). Wires Radar's own API
18
+ * hooks; Radar Hub supplies its own container over the per-cluster apiBase.
19
+ */
20
+ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSwitcherProps>(function NamespaceSwitcher(
21
+ { className, disabled, disabledTooltip, variant, label },
22
+ ref,
23
+ ) {
24
+ const { data: scope, isLoading } = useNamespaceScope()
25
+ const setActive = useSetActiveNamespace()
26
+
27
+ return (
28
+ <NamespacePicker
29
+ ref={ref}
30
+ scope={scope}
31
+ loading={isLoading}
32
+ pending={setActive.isPending}
33
+ onApply={namespaces => setActive.mutate({ namespaces })}
34
+ disabled={disabled}
35
+ disabledTooltip={disabledTooltip}
36
+ className={className}
37
+ variant={variant}
38
+ label={label}
39
+ />
40
+ )
41
+ })