@skyhook-io/radar-app 0.1.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 (251) hide show
  1. package/README.md +67 -0
  2. package/package.json +80 -0
  3. package/src/App.tsx +1538 -0
  4. package/src/RadarApp.tsx +145 -0
  5. package/src/api/apiResources.ts +28 -0
  6. package/src/api/client.ts +2583 -0
  7. package/src/api/config.ts +116 -0
  8. package/src/api/traffic.ts +139 -0
  9. package/src/components/ConnectionErrorView.tsx +272 -0
  10. package/src/components/ContextSwitcher.tsx +481 -0
  11. package/src/components/DebugOverlay.tsx +94 -0
  12. package/src/components/UserMenu.tsx +87 -0
  13. package/src/components/audit/AuditSettingsDialog.tsx +162 -0
  14. package/src/components/audit/AuditView.tsx +123 -0
  15. package/src/components/cost/CostTrendChart.tsx +388 -0
  16. package/src/components/cost/CostView.tsx +545 -0
  17. package/src/components/dock/BottomDock.tsx +96 -0
  18. package/src/components/dock/DockContext.tsx +11 -0
  19. package/src/components/dock/LocalTerminalTab.tsx +22 -0
  20. package/src/components/dock/LogsTab.tsx +26 -0
  21. package/src/components/dock/NodeTerminalTab.tsx +50 -0
  22. package/src/components/dock/TerminalTab.tsx +42 -0
  23. package/src/components/dock/TrafficFlowListTab.tsx +18 -0
  24. package/src/components/dock/WorkloadLogsTab.tsx +23 -0
  25. package/src/components/dock/index.ts +2 -0
  26. package/src/components/gitops/GitOpsActions.tsx +1 -0
  27. package/src/components/gitops/GitOpsStatusBadge.tsx +1 -0
  28. package/src/components/gitops/ManagedResourcesList.tsx +1 -0
  29. package/src/components/gitops/SyncCountdown.tsx +1 -0
  30. package/src/components/gitops/index.ts +4 -0
  31. package/src/components/helm/ChartBrowser.tsx +580 -0
  32. package/src/components/helm/HelmReleaseDrawer.tsx +774 -0
  33. package/src/components/helm/HelmView.tsx +475 -0
  34. package/src/components/helm/InstallWizard.tsx +1060 -0
  35. package/src/components/helm/ManifestDiffViewer.tsx +91 -0
  36. package/src/components/helm/ManifestViewer.tsx +61 -0
  37. package/src/components/helm/OwnedResources.tsx +465 -0
  38. package/src/components/helm/RevisionHistory.tsx +167 -0
  39. package/src/components/helm/ValuesDiffPreview.tsx +190 -0
  40. package/src/components/helm/ValuesViewer.tsx +365 -0
  41. package/src/components/helm/helm-utils.ts +37 -0
  42. package/src/components/home/ActivitySummary.tsx +262 -0
  43. package/src/components/home/CertificateHealthCard.tsx +105 -0
  44. package/src/components/home/ClusterHealthCard.tsx +483 -0
  45. package/src/components/home/CostCard.tsx +112 -0
  46. package/src/components/home/HealthRing.tsx +1 -0
  47. package/src/components/home/HelmSummary.tsx +129 -0
  48. package/src/components/home/HomeView.tsx +224 -0
  49. package/src/components/home/MCPSetupDialog.tsx +417 -0
  50. package/src/components/home/NetworkPolicyCoverageCard.tsx +109 -0
  51. package/src/components/home/TopologyPreview.tsx +219 -0
  52. package/src/components/home/TrafficSummary.tsx +154 -0
  53. package/src/components/logs/JsonLogLine.tsx +1 -0
  54. package/src/components/logs/LogCore.tsx +2 -0
  55. package/src/components/logs/LogsViewer.tsx +44 -0
  56. package/src/components/logs/WorkloadLogsViewer.tsx +40 -0
  57. package/src/components/logs/useLogBuffer.ts +2 -0
  58. package/src/components/logs/useLogSearch.ts +1 -0
  59. package/src/components/portforward/PortForwardButton.tsx +375 -0
  60. package/src/components/portforward/PortForwardManager.tsx +871 -0
  61. package/src/components/resource/PrometheusCharts.tsx +687 -0
  62. package/src/components/resource-drawer/ResourceDrawer.tsx +214 -0
  63. package/src/components/resources/ImageFilesystemModal.tsx +745 -0
  64. package/src/components/resources/PodFilesystemModal.tsx +407 -0
  65. package/src/components/resources/ResourceDetailDrawer.tsx +43 -0
  66. package/src/components/resources/ResourcesView.tsx +190 -0
  67. package/src/components/resources/drawer-components.tsx +1 -0
  68. package/src/components/resources/file-browser-utils.ts +35 -0
  69. package/src/components/resources/renderers/AlertRenderer.tsx +1 -0
  70. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +17 -0
  71. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +1 -0
  72. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +1 -0
  73. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -0
  74. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +1 -0
  75. package/src/components/resources/renderers/CertificateRenderer.tsx +1 -0
  76. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +1 -0
  77. package/src/components/resources/renderers/ChallengeRenderer.tsx +1 -0
  78. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +1 -0
  79. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +1 -0
  80. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +1 -0
  81. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +1 -0
  82. package/src/components/resources/renderers/ConfigMapRenderer.tsx +1 -0
  83. package/src/components/resources/renderers/CronJobRenderer.tsx +1 -0
  84. package/src/components/resources/renderers/EventRenderer.tsx +1 -0
  85. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +1 -0
  86. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +1 -0
  87. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +1 -0
  88. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +1 -0
  89. package/src/components/resources/renderers/GatewayClassRenderer.tsx +1 -0
  90. package/src/components/resources/renderers/GatewayRenderer.tsx +1 -0
  91. package/src/components/resources/renderers/GenericRenderer.tsx +1 -0
  92. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +1 -0
  93. package/src/components/resources/renderers/HPARenderer.tsx +1 -0
  94. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +1 -0
  95. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +1 -0
  96. package/src/components/resources/renderers/IngressClassRenderer.tsx +1 -0
  97. package/src/components/resources/renderers/IngressRenderer.tsx +1 -0
  98. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +1 -0
  99. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +1 -0
  100. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +1 -0
  101. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +1 -0
  102. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +1 -0
  103. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +1 -0
  104. package/src/components/resources/renderers/JobRenderer.tsx +1 -0
  105. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +1 -0
  106. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +1 -0
  107. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +1 -0
  108. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +1 -0
  109. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +1 -0
  110. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +1 -0
  111. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +1 -0
  112. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +1 -0
  113. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +1 -0
  114. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -0
  116. package/src/components/resources/renderers/KnativeRouteRenderer.tsx +1 -0
  117. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +1 -0
  118. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -0
  119. package/src/components/resources/renderers/KustomizationRenderer.tsx +1 -0
  120. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +1 -0
  121. package/src/components/resources/renderers/LeaseRenderer.tsx +1 -0
  122. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +1 -0
  123. package/src/components/resources/renderers/NodeRenderer.tsx +44 -0
  124. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +1 -0
  125. package/src/components/resources/renderers/OrderRenderer.tsx +1 -0
  126. package/src/components/resources/renderers/PVCRenderer.tsx +1 -0
  127. package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +1 -0
  128. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +1 -0
  129. package/src/components/resources/renderers/PodMonitorRenderer.tsx +1 -0
  130. package/src/components/resources/renderers/PodRenderer.tsx +94 -0
  131. package/src/components/resources/renderers/PriorityClassRenderer.tsx +1 -0
  132. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +1 -0
  133. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +1 -0
  134. package/src/components/resources/renderers/RoleBindingRenderer.tsx +1 -0
  135. package/src/components/resources/renderers/RoleRenderer.tsx +1 -0
  136. package/src/components/resources/renderers/RolloutRenderer.tsx +1 -0
  137. package/src/components/resources/renderers/RuntimeClassRenderer.tsx +1 -0
  138. package/src/components/resources/renderers/SbomReportRenderer.tsx +1 -0
  139. package/src/components/resources/renderers/SealedSecretRenderer.tsx +1 -0
  140. package/src/components/resources/renderers/SecretRenderer.tsx +1 -0
  141. package/src/components/resources/renderers/SecretStoreRenderer.tsx +1 -0
  142. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +1 -0
  143. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +1 -0
  144. package/src/components/resources/renderers/ServiceRenderer.tsx +26 -0
  145. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -0
  146. package/src/components/resources/renderers/StorageClassRenderer.tsx +1 -0
  147. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +1 -0
  148. package/src/components/resources/renderers/VPARenderer.tsx +1 -0
  149. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +1 -0
  150. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -0
  151. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +1 -0
  152. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +1 -0
  153. package/src/components/resources/renderers/VeleroVSLRenderer.tsx +1 -0
  154. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +1 -0
  155. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +1 -0
  156. package/src/components/resources/renderers/WorkflowRenderer.tsx +1 -0
  157. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +1 -0
  158. package/src/components/resources/renderers/WorkloadRenderer.tsx +52 -0
  159. package/src/components/resources/renderers/argo-cells.tsx +1 -0
  160. package/src/components/resources/renderers/certmanager-cells.tsx +1 -0
  161. package/src/components/resources/renderers/cnpg-cells.tsx +1 -0
  162. package/src/components/resources/renderers/eso-cells.tsx +1 -0
  163. package/src/components/resources/renderers/flux-cells.tsx +1 -0
  164. package/src/components/resources/renderers/index.ts +91 -0
  165. package/src/components/resources/renderers/istio-cells.tsx +1 -0
  166. package/src/components/resources/renderers/karpenter-cells.tsx +1 -0
  167. package/src/components/resources/renderers/keda-cells.tsx +1 -0
  168. package/src/components/resources/renderers/knative-cells.tsx +1 -0
  169. package/src/components/resources/renderers/kyverno-cells.tsx +1 -0
  170. package/src/components/resources/renderers/prometheus-cells.tsx +1 -0
  171. package/src/components/resources/renderers/traefik-cells.tsx +1 -0
  172. package/src/components/resources/renderers/trivy-cells.tsx +1 -0
  173. package/src/components/resources/renderers/trivy-shared.tsx +1 -0
  174. package/src/components/resources/renderers/velero-cells.tsx +1 -0
  175. package/src/components/resources/resource-utils-argo.ts +2 -0
  176. package/src/components/resources/resource-utils-certmanager.ts +2 -0
  177. package/src/components/resources/resource-utils-cnpg.ts +2 -0
  178. package/src/components/resources/resource-utils-eso.ts +2 -0
  179. package/src/components/resources/resource-utils-flux.ts +2 -0
  180. package/src/components/resources/resource-utils-istio.ts +2 -0
  181. package/src/components/resources/resource-utils-karpenter.ts +2 -0
  182. package/src/components/resources/resource-utils-keda.ts +2 -0
  183. package/src/components/resources/resource-utils-knative.ts +2 -0
  184. package/src/components/resources/resource-utils-kyverno.ts +2 -0
  185. package/src/components/resources/resource-utils-prometheus.ts +2 -0
  186. package/src/components/resources/resource-utils-traefik.ts +1 -0
  187. package/src/components/resources/resource-utils-trivy.ts +2 -0
  188. package/src/components/resources/resource-utils-velero.ts +2 -0
  189. package/src/components/resources/resource-utils.ts +5 -0
  190. package/src/components/settings/SettingsDialog.tsx +537 -0
  191. package/src/components/shared/CreateResourceDialog.tsx +17 -0
  192. package/src/components/shared/EditableYamlView.tsx +24 -0
  193. package/src/components/shared/LargeClusterNamespacePicker.tsx +70 -0
  194. package/src/components/shared/ResourceRendererDispatch.tsx +31 -0
  195. package/src/components/timeline/DiffViewer.tsx +1 -0
  196. package/src/components/timeline/TimelineList.tsx +69 -0
  197. package/src/components/timeline/TimelineSwimlanes.tsx +1308 -0
  198. package/src/components/timeline/TimelineView.tsx +157 -0
  199. package/src/components/timeline/shared.tsx +1 -0
  200. package/src/components/traffic/TrafficFilterSidebar.tsx +571 -0
  201. package/src/components/traffic/TrafficFlowList.tsx +415 -0
  202. package/src/components/traffic/TrafficFlowListContext.tsx +68 -0
  203. package/src/components/traffic/TrafficGraph.tsx +1546 -0
  204. package/src/components/traffic/TrafficView.tsx +1213 -0
  205. package/src/components/traffic/TrafficWizard.tsx +386 -0
  206. package/src/components/traffic/index.ts +3 -0
  207. package/src/components/ui/CodeViewer.tsx +8 -0
  208. package/src/components/ui/CommandPalette.tsx +460 -0
  209. package/src/components/ui/ConfirmDialog.tsx +1 -0
  210. package/src/components/ui/DiagnosticsOverlay.tsx +619 -0
  211. package/src/components/ui/ErrorBoundary.tsx +46 -0
  212. package/src/components/ui/ForceDeleteConfirmDialog.tsx +1 -0
  213. package/src/components/ui/Markdown.tsx +108 -0
  214. package/src/components/ui/MetricsChart.tsx +1 -0
  215. package/src/components/ui/NamespaceSelector.tsx +436 -0
  216. package/src/components/ui/ResourceBar.tsx +1 -0
  217. package/src/components/ui/ShortcutHelpOverlay.tsx +301 -0
  218. package/src/components/ui/Toast.tsx +1 -0
  219. package/src/components/ui/Tooltip.tsx +1 -0
  220. package/src/components/ui/UpdateNotification.tsx +299 -0
  221. package/src/components/ui/YamlEditor.tsx +1 -0
  222. package/src/components/workload/WorkloadView.tsx +532 -0
  223. package/src/context/ConnectionContext.tsx +173 -0
  224. package/src/context/ContextSwitchContext.tsx +56 -0
  225. package/src/context/NavCustomization.tsx +62 -0
  226. package/src/context/ThemeContext.tsx +97 -0
  227. package/src/contexts/CapabilitiesContext.tsx +130 -0
  228. package/src/hooks/useAnimatedUnmount.ts +1 -0
  229. package/src/hooks/useDesktopDownload.ts +41 -0
  230. package/src/hooks/useEventSource.ts +262 -0
  231. package/src/hooks/useFavorites.ts +69 -0
  232. package/src/hooks/useKeyboardShortcuts.tsx +7 -0
  233. package/src/hooks/useRefreshAnimation.ts +1 -0
  234. package/src/index.css +243 -0
  235. package/src/index.ts +17 -0
  236. package/src/main.tsx +158 -0
  237. package/src/types/gitops.ts +2 -0
  238. package/src/types.ts +3 -0
  239. package/src/utils/animation.ts +2 -0
  240. package/src/utils/badge-colors.ts +2 -0
  241. package/src/utils/context-name.ts +2 -0
  242. package/src/utils/desktop-download.ts +66 -0
  243. package/src/utils/desktop-open-folder.ts +21 -0
  244. package/src/utils/format.ts +2 -0
  245. package/src/utils/log-format.ts +12 -0
  246. package/src/utils/navigation.ts +23 -0
  247. package/src/utils/resource-hierarchy.ts +2 -0
  248. package/src/utils/resource-icons.ts +2 -0
  249. package/src/utils/skeleton-yaml.ts +2 -0
  250. package/src/utils/traffic-colors.ts +54 -0
  251. package/src/vite-env.d.ts +1 -0
@@ -0,0 +1,481 @@
1
+ import { useState, useRef, useEffect, useMemo } from 'react'
2
+ import { ChevronDown, Check, Loader2, Server, AlertTriangle, Search, X } from 'lucide-react'
3
+ import { useContexts, useSwitchContext, useClusterInfo, fetchSessionCounts, type SessionCounts } from '../api/client'
4
+ import { useContextSwitch } from '../context/ContextSwitchContext'
5
+ import { useToast } from '../components/ui/Toast'
6
+ import { useDock } from '../components/dock'
7
+ import type { ContextInfo } from '../types'
8
+ import { parseContextName, type ParsedContextName } from '../utils/context-name'
9
+
10
+ interface ContextSwitcherProps {
11
+ className?: string
12
+ }
13
+
14
+ interface ParsedContext extends ParsedContextName {
15
+ context: ContextInfo
16
+ }
17
+
18
+ // Group contexts by provider, then by account
19
+ interface ContextGroup {
20
+ provider: string | null
21
+ account: string | null
22
+ items: ParsedContext[]
23
+ }
24
+
25
+ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
26
+ const [isOpen, setIsOpen] = useState(false)
27
+ const [search, setSearch] = useState('')
28
+ const [highlightedIndex, setHighlightedIndex] = useState(-1)
29
+ const [showConfirm, setShowConfirm] = useState(false)
30
+ const [pendingSwitch, setPendingSwitch] = useState<ParsedContext | null>(null)
31
+ const [sessionCounts, setSessionCounts] = useState<SessionCounts | null>(null)
32
+ const dropdownRef = useRef<HTMLDivElement>(null)
33
+ const searchInputRef = useRef<HTMLInputElement>(null)
34
+
35
+ const { data: contexts, isLoading: contextsLoading } = useContexts()
36
+ const { data: clusterInfo } = useClusterInfo()
37
+ const switchContext = useSwitchContext()
38
+ const { startSwitch, endSwitch } = useContextSwitch()
39
+ const { showError } = useToast()
40
+ const { tabs } = useDock()
41
+
42
+ // Parse, group, and sort contexts
43
+ const { groups, hasMultipleAccounts } = useMemo(() => {
44
+ if (!contexts) return { groups: [], hasMultipleProviders: false, hasMultipleAccounts: false }
45
+
46
+ // Parse all contexts
47
+ const parsed: ParsedContext[] = contexts.map(ctx => ({
48
+ context: ctx,
49
+ ...parseContextName(ctx.name),
50
+ }))
51
+
52
+ // Check if we have multiple accounts (to decide whether to show group headers)
53
+ const accounts = new Set(parsed.map(p => `${p.provider}:${p.account}`))
54
+ const hasMultipleAccounts = accounts.size > 1
55
+
56
+ // Group by provider + account
57
+ const groupMap = new Map<string, ContextGroup>()
58
+ for (const p of parsed) {
59
+ const key = `${p.provider || 'other'}:${p.account || 'default'}`
60
+ if (!groupMap.has(key)) {
61
+ groupMap.set(key, { provider: p.provider, account: p.account, items: [] })
62
+ }
63
+ groupMap.get(key)!.items.push(p)
64
+ }
65
+
66
+ // Sort groups: GKE first, then EKS, then AKS, then Other
67
+ // Within provider, sort by account name
68
+ const providerOrder: Record<string, number> = { 'GKE': 0, 'EKS': 1, 'AKS': 2 }
69
+ const groups = Array.from(groupMap.values()).sort((a, b) => {
70
+ const orderA = providerOrder[a.provider || ''] ?? 3
71
+ const orderB = providerOrder[b.provider || ''] ?? 3
72
+ if (orderA !== orderB) return orderA - orderB
73
+ return (a.account || '').localeCompare(b.account || '')
74
+ })
75
+
76
+ // Sort items within each group by cluster name
77
+ for (const group of groups) {
78
+ group.items.sort((a, b) => a.clusterName.localeCompare(b.clusterName))
79
+ }
80
+
81
+ return { groups, hasMultipleAccounts }
82
+ }, [contexts])
83
+
84
+ // Filter groups by search query
85
+ const { filteredGroups, flatItems, itemIndexMap } = useMemo(() => {
86
+ const filteredGroups = search.trim()
87
+ ? groups
88
+ .map(group => ({
89
+ ...group,
90
+ items: group.items.filter(item => {
91
+ const searchLower = search.toLowerCase()
92
+ return (
93
+ item.clusterName.toLowerCase().includes(searchLower) ||
94
+ item.raw.toLowerCase().includes(searchLower) ||
95
+ (item.region && item.region.toLowerCase().includes(searchLower)) ||
96
+ (item.account && item.account.toLowerCase().includes(searchLower))
97
+ )
98
+ }),
99
+ }))
100
+ .filter(group => group.items.length > 0)
101
+ : groups
102
+
103
+ const flatItems = filteredGroups.flatMap(g => g.items)
104
+ const itemIndexMap = new Map<string, number>()
105
+ flatItems.forEach((item, i) => itemIndexMap.set(item.context.name, i))
106
+
107
+ return { filteredGroups, flatItems, itemIndexMap }
108
+ }, [groups, search])
109
+
110
+ // Reset search and highlight when dropdown opens/closes
111
+ useEffect(() => {
112
+ if (isOpen) {
113
+ setSearch('')
114
+ setHighlightedIndex(-1)
115
+ requestAnimationFrame(() => {
116
+ searchInputRef.current?.focus()
117
+ })
118
+ }
119
+ }, [isOpen])
120
+
121
+ // Reset highlighted index when filtered results change
122
+ useEffect(() => {
123
+ setHighlightedIndex(-1)
124
+ }, [search])
125
+
126
+ // Keyboard navigation for search
127
+ const handleSearchKeyDown = (e: React.KeyboardEvent) => {
128
+ switch (e.key) {
129
+ case 'ArrowDown':
130
+ e.preventDefault()
131
+ setHighlightedIndex(prev => (prev < flatItems.length - 1 ? prev + 1 : prev))
132
+ break
133
+ case 'ArrowUp':
134
+ e.preventDefault()
135
+ setHighlightedIndex(prev => (prev > 0 ? prev - 1 : 0))
136
+ break
137
+ case 'Enter':
138
+ e.preventDefault()
139
+ if (highlightedIndex >= 0 && flatItems[highlightedIndex]) {
140
+ handleContextSwitch(flatItems[highlightedIndex])
141
+ } else if (flatItems.length > 0) {
142
+ setHighlightedIndex(0)
143
+ }
144
+ break
145
+ case 'Escape':
146
+ e.preventDefault()
147
+ setIsOpen(false)
148
+ break
149
+ }
150
+ }
151
+
152
+ // Scroll highlighted item into view
153
+ useEffect(() => {
154
+ if (!isOpen || highlightedIndex < 0 || !dropdownRef.current) return
155
+ const highlighted = dropdownRef.current.querySelector('[data-highlighted="true"]')
156
+ if (highlighted) {
157
+ highlighted.scrollIntoView({ block: 'nearest' })
158
+ }
159
+ }, [highlightedIndex, isOpen])
160
+
161
+ // Close dropdown when clicking outside
162
+ useEffect(() => {
163
+ function handleClickOutside(event: MouseEvent) {
164
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
165
+ setIsOpen(false)
166
+ }
167
+ }
168
+
169
+ document.addEventListener('mousedown', handleClickOutside)
170
+ return () => document.removeEventListener('mousedown', handleClickOutside)
171
+ }, [])
172
+
173
+ // Close dropdown on escape
174
+ useEffect(() => {
175
+ function handleEscape(event: KeyboardEvent) {
176
+ if (event.key === 'Escape') {
177
+ setIsOpen(false)
178
+ }
179
+ }
180
+
181
+ document.addEventListener('keydown', handleEscape)
182
+ return () => document.removeEventListener('keydown', handleEscape)
183
+ }, [])
184
+
185
+ // Check for active sessions and show confirmation if needed
186
+ const handleContextSwitch = async (parsed: ParsedContext) => {
187
+ if (parsed.context.isCurrent || switchContext.isPending) return
188
+
189
+ setIsOpen(false)
190
+
191
+ // Check for active sessions (port forwards from API + terminal tabs from dock)
192
+ try {
193
+ const counts = await fetchSessionCounts()
194
+ const terminalTabs = tabs.filter(t => t.type === 'terminal').length
195
+ const totalSessions = counts.portForwards + terminalTabs
196
+
197
+ if (totalSessions > 0) {
198
+ // Show confirmation dialog
199
+ setSessionCounts({ ...counts, execSessions: terminalTabs, total: totalSessions })
200
+ setPendingSwitch(parsed)
201
+ setShowConfirm(true)
202
+ return
203
+ }
204
+ } catch (error) {
205
+ console.error('Failed to check sessions:', error)
206
+ // Continue with switch even if check fails
207
+ }
208
+
209
+ // No active sessions, proceed with switch
210
+ performSwitch(parsed)
211
+ }
212
+
213
+ // Actually perform the context switch
214
+ const performSwitch = async (parsed: ParsedContext) => {
215
+ startSwitch({
216
+ raw: parsed.raw,
217
+ provider: parsed.provider,
218
+ account: parsed.account,
219
+ region: parsed.region,
220
+ clusterName: parsed.clusterName,
221
+ })
222
+ try {
223
+ await switchContext.mutateAsync({ name: parsed.context.name })
224
+ // Success - endSwitch is called by the overlay when it detects success
225
+ } catch (error) {
226
+ console.error('Failed to switch context:', error)
227
+ endSwitch()
228
+ // Show toast as fallback — if the backend set StateDisconnected,
229
+ // ConnectionErrorView will render with provider-specific hints.
230
+ // But if the request never reached the backend (network error,
231
+ // client timeout), connection.state stays 'connected' and the
232
+ // toast is the only error feedback the user gets.
233
+ const message = error instanceof Error ? error.message : 'Unknown error'
234
+ showError('Failed to switch context', message)
235
+ }
236
+ }
237
+
238
+ // Handle confirmation dialog actions
239
+ const handleConfirmSwitch = () => {
240
+ setShowConfirm(false)
241
+ if (pendingSwitch) {
242
+ performSwitch(pendingSwitch)
243
+ setPendingSwitch(null)
244
+ }
245
+ }
246
+
247
+ const handleCancelSwitch = () => {
248
+ setShowConfirm(false)
249
+ setPendingSwitch(null)
250
+ setSessionCounts(null)
251
+ }
252
+
253
+ // Get current context info - parse it to extract cluster name
254
+ const currentContextRaw = clusterInfo?.context || contexts?.find(c => c.isCurrent)?.name || 'Unknown'
255
+ const currentParsed = useMemo(() => parseContextName(currentContextRaw), [currentContextRaw])
256
+ const currentDisplayName = currentParsed.clusterName
257
+
258
+ // Check if in-cluster mode (only one context named "in-cluster")
259
+ const isInClusterMode = contexts?.length === 1 && contexts[0].name === 'in-cluster'
260
+
261
+ // If in-cluster mode, just show a static badge
262
+ if (isInClusterMode) {
263
+ return (
264
+ <div className={`flex items-center gap-2 ${className}`}>
265
+ <span className="px-2 py-1 bg-theme-elevated rounded text-sm font-medium text-blue-300">
266
+ in-cluster
267
+ </span>
268
+ </div>
269
+ )
270
+ }
271
+
272
+ return (
273
+ <div className={`relative ${className}`} ref={dropdownRef}>
274
+ {/* Trigger button */}
275
+ <button
276
+ onClick={() => setIsOpen(!isOpen)}
277
+ disabled={switchContext.isPending || contextsLoading}
278
+ className={`
279
+ flex items-center gap-1.5 px-2.5 py-1.5
280
+ bg-theme-elevated border border-theme-border rounded text-sm font-medium
281
+ text-theme-text-primary hover:bg-theme-hover hover:border-theme-border-light
282
+ transition-colors cursor-pointer
283
+ disabled:opacity-50 disabled:cursor-not-allowed
284
+ `}
285
+ title={currentContextRaw}
286
+ >
287
+ {switchContext.isPending ? (
288
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
289
+ ) : (
290
+ <Server className="w-3.5 h-3.5 text-theme-text-secondary" />
291
+ )}
292
+ <span className="max-w-[120px] sm:max-w-[220px] truncate">
293
+ {switchContext.isPending ? 'Switching...' : currentDisplayName}
294
+ </span>
295
+ <ChevronDown className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
296
+ </button>
297
+
298
+ {/* Dropdown menu */}
299
+ {isOpen && !contextsLoading && contexts && (
300
+ <div className="absolute top-full left-0 mt-1 z-50 min-w-[280px] max-w-[420px] bg-theme-surface border border-theme-border-light rounded-lg shadow-xl overflow-hidden">
301
+ {/* Search input */}
302
+ {contexts.length > 1 && (
303
+ <div className="p-2 border-b border-theme-border">
304
+ <div className="relative">
305
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-theme-text-tertiary" />
306
+ <input
307
+ ref={searchInputRef}
308
+ type="text"
309
+ value={search}
310
+ onChange={(e) => setSearch(e.target.value)}
311
+ onKeyDown={handleSearchKeyDown}
312
+ placeholder="Search clusters..."
313
+ className="w-full bg-theme-base text-theme-text-primary text-xs rounded px-2 py-1.5 pl-7 pr-7 border border-theme-border-light focus:outline-none focus:ring-1 focus:ring-blue-500 placeholder:text-theme-text-tertiary"
314
+ />
315
+ {search && (
316
+ <button
317
+ type="button"
318
+ onClick={() => setSearch('')}
319
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-theme-text-tertiary hover:text-theme-text-secondary"
320
+ >
321
+ <X className="w-3.5 h-3.5" />
322
+ </button>
323
+ )}
324
+ </div>
325
+ </div>
326
+ )}
327
+
328
+ <div className="max-h-[400px] overflow-y-auto">
329
+ {flatItems.length === 0 ? (
330
+ <div className="px-3 py-6 text-center text-xs text-theme-text-tertiary">
331
+ No clusters match "{search}"
332
+ </div>
333
+ ) : (
334
+ filteredGroups.map((group, groupIndex) => {
335
+ const showHeader = hasMultipleAccounts
336
+ const headerLabel = group.provider
337
+ ? `${group.provider}${group.account ? ` · ${group.account}` : ''}`
338
+ : 'Other'
339
+
340
+ return (
341
+ <div key={`${group.provider}:${group.account}`}>
342
+ {groupIndex > 0 && (
343
+ <div className="border-t border-theme-border-light my-1" />
344
+ )}
345
+ {showHeader && (
346
+ <div className="px-3 py-1.5 bg-theme-elevated/30">
347
+ <span className="text-[10px] text-theme-text-tertiary font-medium">
348
+ {headerLabel}
349
+ </span>
350
+ </div>
351
+ )}
352
+ {group.items.map((item) => {
353
+ const itemIndex = itemIndexMap.get(item.context.name) ?? -1
354
+ return (
355
+ <button
356
+ key={item.context.name}
357
+ data-highlighted={itemIndex === highlightedIndex}
358
+ onClick={() => handleContextSwitch(item)}
359
+ onMouseEnter={() => setHighlightedIndex(itemIndex)}
360
+ disabled={item.context.isCurrent || switchContext.isPending}
361
+ className={`
362
+ w-full flex items-center gap-2 px-3 py-2 text-left
363
+ transition-colors
364
+ ${item.context.isCurrent
365
+ ? 'bg-blue-500/10'
366
+ : itemIndex === highlightedIndex
367
+ ? 'bg-theme-hover cursor-pointer'
368
+ : 'hover:bg-theme-hover cursor-pointer'
369
+ }
370
+ disabled:opacity-50
371
+ `}
372
+ >
373
+ <div className="shrink-0 w-4 h-4 flex items-center justify-center">
374
+ {item.context.isCurrent ? (
375
+ <Check className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
376
+ ) : (
377
+ <div className="w-1.5 h-1.5 rounded-full bg-theme-text-tertiary/30" />
378
+ )}
379
+ </div>
380
+ <div className="flex-1 min-w-0">
381
+ <div className="flex items-center gap-1.5">
382
+ <span className={`text-sm font-medium truncate ${item.context.isCurrent ? 'text-blue-600 dark:text-blue-400' : 'text-theme-text-primary'}`}>
383
+ {item.clusterName}
384
+ </span>
385
+ {item.region && (
386
+ <span className="shrink-0 text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 rounded">
387
+ {item.region}
388
+ </span>
389
+ )}
390
+ {item.context.isCurrent && (
391
+ <span className="shrink-0 text-[9px] text-blue-600 dark:text-blue-400">
392
+
393
+ </span>
394
+ )}
395
+ </div>
396
+ {item.provider && (
397
+ <div className="text-[10px] text-theme-text-tertiary truncate mt-0.5" title={item.raw}>
398
+ {item.raw}
399
+ </div>
400
+ )}
401
+ </div>
402
+ </button>
403
+ )
404
+ })}
405
+ </div>
406
+ )
407
+ })
408
+ )}
409
+ </div>
410
+
411
+ {/* Footer with count */}
412
+ {contexts.length > 1 && search && flatItems.length > 0 && (
413
+ <div className="px-3 py-1.5 text-[10px] text-theme-text-tertiary border-t border-theme-border bg-theme-base">
414
+ {flatItems.length === contexts.length
415
+ ? `${contexts.length} clusters`
416
+ : `${flatItems.length} of ${contexts.length} clusters`}
417
+ </div>
418
+ )}
419
+
420
+ {/* Error message if switch failed */}
421
+ {switchContext.isError && (
422
+ <div className="px-3 py-2 bg-red-500/10 border-t border-red-500/20">
423
+ <span className="text-xs text-red-400">
424
+ {switchContext.error?.message}
425
+ </span>
426
+ </div>
427
+ )}
428
+ </div>
429
+ )}
430
+
431
+ {/* Confirmation modal */}
432
+ {showConfirm && sessionCounts && pendingSwitch && (
433
+ <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50">
434
+ <div className="bg-theme-surface border border-theme-border rounded-lg shadow-xl max-w-md mx-4 overflow-hidden">
435
+ <div className="px-4 py-3 border-b border-theme-border flex items-center gap-2">
436
+ <AlertTriangle className="w-5 h-5 text-amber-400" />
437
+ <span className="font-medium text-theme-text-primary">Active Sessions</span>
438
+ </div>
439
+ <div className="px-4 py-4">
440
+ <p className="text-sm text-theme-text-secondary mb-3">
441
+ Switching contexts will terminate active sessions:
442
+ </p>
443
+ <ul className="text-sm text-theme-text-primary space-y-1 mb-4">
444
+ {sessionCounts.portForwards > 0 && (
445
+ <li className="flex items-center gap-2">
446
+ <span className="w-1.5 h-1.5 rounded-full bg-blue-400" />
447
+ {sessionCounts.portForwards} port forward{sessionCounts.portForwards !== 1 ? 's' : ''}
448
+ </li>
449
+ )}
450
+ {sessionCounts.execSessions > 0 && (
451
+ <li className="flex items-center gap-2">
452
+ <span className="w-1.5 h-1.5 rounded-full bg-green-400" />
453
+ {sessionCounts.execSessions} terminal session{sessionCounts.execSessions !== 1 ? 's' : ''}
454
+ </li>
455
+ )}
456
+ </ul>
457
+ <p className="text-xs text-theme-text-tertiary">
458
+ Switch to: <span className="text-theme-text-secondary">{pendingSwitch.clusterName}</span>
459
+ </p>
460
+ </div>
461
+ <div className="px-4 py-3 border-t border-theme-border flex justify-end gap-2">
462
+ <button
463
+ onClick={handleCancelSwitch}
464
+ className="px-3 py-1.5 text-sm rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary transition-colors"
465
+ >
466
+ Cancel
467
+ </button>
468
+ <button
469
+ onClick={handleConfirmSwitch}
470
+ className="px-3 py-1.5 text-sm rounded-md bg-amber-500 hover:bg-amber-600 text-white transition-colors"
471
+ >
472
+ Switch Anyway
473
+ </button>
474
+ </div>
475
+ </div>
476
+ </div>
477
+ )}
478
+
479
+ </div>
480
+ )
481
+ }
@@ -0,0 +1,94 @@
1
+ import { useState } from 'react'
2
+ import { Bug, X, ChevronDown, ChevronUp } from 'lucide-react'
3
+ import { useRuntimeStats } from '../api/client'
4
+
5
+ function formatUptime(seconds: number): string {
6
+ if (seconds < 60) return `${seconds}s`
7
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
8
+ const hours = Math.floor(seconds / 3600)
9
+ const mins = Math.floor((seconds % 3600) / 60)
10
+ return `${hours}h ${mins}m`
11
+ }
12
+
13
+ export function DebugOverlay() {
14
+ const [visible, setVisible] = useState(true)
15
+ const [expanded, setExpanded] = useState(false)
16
+ const { data } = useRuntimeStats(visible)
17
+
18
+ if (!visible) {
19
+ return (
20
+ <button
21
+ 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
+ >
25
+ <Bug className="w-4 h-4" />
26
+ </button>
27
+ )
28
+ }
29
+
30
+ const runtime = data?.runtime
31
+
32
+ return (
33
+ <div className="fixed bottom-3 right-3 z-50 bg-theme-surface/95 border border-theme-border rounded-lg shadow-lg backdrop-blur-sm text-xs font-mono">
34
+ {/* Header */}
35
+ <div className="flex items-center gap-2 px-2 py-1.5 border-b border-theme-border/50">
36
+ <Bug className="w-3 h-3 text-theme-text-tertiary" />
37
+ <span className="text-theme-text-secondary">Debug</span>
38
+ <div className="flex-1" />
39
+ <button
40
+ onClick={() => setExpanded(!expanded)}
41
+ className="p-0.5 text-theme-text-tertiary hover:text-theme-text-secondary"
42
+ >
43
+ {expanded ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />}
44
+ </button>
45
+ <button
46
+ onClick={() => setVisible(false)}
47
+ className="p-0.5 text-theme-text-tertiary hover:text-theme-text-secondary"
48
+ >
49
+ <X className="w-3 h-3" />
50
+ </button>
51
+ </div>
52
+
53
+ {/* Stats */}
54
+ <div className="px-2 py-1.5 space-y-0.5">
55
+ {runtime ? (
56
+ <>
57
+ <div className="flex justify-between gap-4">
58
+ <span className="text-theme-text-tertiary">Heap</span>
59
+ <span className="text-theme-text-primary">{runtime.heapMB.toFixed(1)} MB</span>
60
+ </div>
61
+ {expanded && (
62
+ <>
63
+ <div className="flex justify-between gap-4">
64
+ <span className="text-theme-text-tertiary">Objects</span>
65
+ <span className="text-theme-text-primary">{runtime.heapObjectsK.toFixed(1)}K</span>
66
+ </div>
67
+ <div className="flex justify-between gap-4">
68
+ <span className="text-theme-text-tertiary">Goroutines</span>
69
+ <span className="text-theme-text-primary">{runtime.goroutines}</span>
70
+ </div>
71
+ <div className="flex justify-between gap-4">
72
+ <span className="text-theme-text-tertiary">Informers</span>
73
+ <span className="text-theme-text-primary">
74
+ {runtime.typedInformers ?? 16}+{runtime.dynamicInformers ?? 0}
75
+ </span>
76
+ </div>
77
+ <div className="flex justify-between gap-4">
78
+ <span className="text-theme-text-tertiary">Uptime</span>
79
+ <span className="text-theme-text-primary">{formatUptime(runtime.uptimeSeconds)}</span>
80
+ </div>
81
+ <div className="flex justify-between gap-4">
82
+ <span className="text-theme-text-tertiary">Resources</span>
83
+ <span className="text-theme-text-primary">{data?.resourceCount ?? '-'}</span>
84
+ </div>
85
+ </>
86
+ )}
87
+ </>
88
+ ) : (
89
+ <span className="text-theme-text-tertiary">Loading...</span>
90
+ )}
91
+ </div>
92
+ </div>
93
+ )
94
+ }
@@ -0,0 +1,87 @@
1
+ import { useState, useRef, useEffect, useCallback } from 'react'
2
+ import { User, LogOut } from 'lucide-react'
3
+ import { useAuthMe } from '../api/client'
4
+ import { useQueryClient } from '@tanstack/react-query'
5
+
6
+ export function UserMenu() {
7
+ const { data: authMe } = useAuthMe()
8
+ const [isOpen, setIsOpen] = useState(false)
9
+ const menuRef = useRef<HTMLDivElement>(null)
10
+ const queryClient = useQueryClient()
11
+
12
+ // Close on click outside
13
+ useEffect(() => {
14
+ if (!isOpen) return
15
+ function handleClick(e: MouseEvent) {
16
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
17
+ setIsOpen(false)
18
+ }
19
+ }
20
+ document.addEventListener('mousedown', handleClick)
21
+ return () => document.removeEventListener('mousedown', handleClick)
22
+ }, [isOpen])
23
+
24
+ const handleLogout = useCallback(async () => {
25
+ let redirectTo = '/'
26
+ try {
27
+ const res = await fetch('/auth/logout', { credentials: 'same-origin' })
28
+ const data = await res.json()
29
+ if (data.redirectTo) {
30
+ redirectTo = data.redirectTo
31
+ }
32
+ } catch (err) {
33
+ console.error('[logout] Failed to complete server-side logout:', err)
34
+ }
35
+ queryClient.clear()
36
+ window.location.href = redirectTo
37
+ }, [queryClient])
38
+
39
+ if (!authMe?.authEnabled || !authMe?.username) {
40
+ return null
41
+ }
42
+
43
+ const initials = authMe.username
44
+ .split('@')[0]
45
+ .split(/[._-]/)
46
+ .slice(0, 2)
47
+ .map(s => s[0]?.toUpperCase() || '')
48
+ .join('')
49
+
50
+ return (
51
+ <div ref={menuRef} className="relative">
52
+ <button
53
+ onClick={() => setIsOpen(!isOpen)}
54
+ className="w-7 h-7 rounded-full bg-blue-500/15 text-blue-500 flex items-center justify-center text-xs font-medium hover:bg-blue-500/25 transition-colors"
55
+ title={authMe.username}
56
+ >
57
+ {initials || <User className="w-3.5 h-3.5" />}
58
+ </button>
59
+
60
+ {isOpen && (
61
+ <div className="absolute right-0 top-full mt-1.5 w-56 bg-theme-surface border border-theme-border rounded-lg shadow-lg z-50 py-1">
62
+ <div className="px-3 py-2 border-b border-theme-border">
63
+ <p className="text-sm font-medium text-theme-text-primary truncate">{authMe.username}</p>
64
+ {authMe.groups && authMe.groups.length > 0 && (
65
+ <p className="text-[11px] text-theme-text-tertiary mt-0.5 truncate">
66
+ {authMe.groups.join(', ')}
67
+ </p>
68
+ )}
69
+ </div>
70
+ {authMe.authMode === 'proxy' ? (
71
+ <p className="px-3 py-1.5 text-[11px] text-theme-text-tertiary">
72
+ Session managed by auth proxy
73
+ </p>
74
+ ) : (
75
+ <button
76
+ onClick={handleLogout}
77
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-theme-text-secondary hover:bg-theme-hover transition-colors"
78
+ >
79
+ <LogOut className="w-3.5 h-3.5" />
80
+ Logout
81
+ </button>
82
+ )}
83
+ </div>
84
+ )}
85
+ </div>
86
+ )
87
+ }