@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.
- package/README.md +67 -0
- package/package.json +80 -0
- package/src/App.tsx +1538 -0
- package/src/RadarApp.tsx +145 -0
- package/src/api/apiResources.ts +28 -0
- package/src/api/client.ts +2583 -0
- package/src/api/config.ts +116 -0
- package/src/api/traffic.ts +139 -0
- package/src/components/ConnectionErrorView.tsx +272 -0
- package/src/components/ContextSwitcher.tsx +481 -0
- package/src/components/DebugOverlay.tsx +94 -0
- package/src/components/UserMenu.tsx +87 -0
- package/src/components/audit/AuditSettingsDialog.tsx +162 -0
- package/src/components/audit/AuditView.tsx +123 -0
- package/src/components/cost/CostTrendChart.tsx +388 -0
- package/src/components/cost/CostView.tsx +545 -0
- package/src/components/dock/BottomDock.tsx +96 -0
- package/src/components/dock/DockContext.tsx +11 -0
- package/src/components/dock/LocalTerminalTab.tsx +22 -0
- package/src/components/dock/LogsTab.tsx +26 -0
- package/src/components/dock/NodeTerminalTab.tsx +50 -0
- package/src/components/dock/TerminalTab.tsx +42 -0
- package/src/components/dock/TrafficFlowListTab.tsx +18 -0
- package/src/components/dock/WorkloadLogsTab.tsx +23 -0
- package/src/components/dock/index.ts +2 -0
- package/src/components/gitops/GitOpsActions.tsx +1 -0
- package/src/components/gitops/GitOpsStatusBadge.tsx +1 -0
- package/src/components/gitops/ManagedResourcesList.tsx +1 -0
- package/src/components/gitops/SyncCountdown.tsx +1 -0
- package/src/components/gitops/index.ts +4 -0
- package/src/components/helm/ChartBrowser.tsx +580 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +774 -0
- package/src/components/helm/HelmView.tsx +475 -0
- package/src/components/helm/InstallWizard.tsx +1060 -0
- package/src/components/helm/ManifestDiffViewer.tsx +91 -0
- package/src/components/helm/ManifestViewer.tsx +61 -0
- package/src/components/helm/OwnedResources.tsx +465 -0
- package/src/components/helm/RevisionHistory.tsx +167 -0
- package/src/components/helm/ValuesDiffPreview.tsx +190 -0
- package/src/components/helm/ValuesViewer.tsx +365 -0
- package/src/components/helm/helm-utils.ts +37 -0
- package/src/components/home/ActivitySummary.tsx +262 -0
- package/src/components/home/CertificateHealthCard.tsx +105 -0
- package/src/components/home/ClusterHealthCard.tsx +483 -0
- package/src/components/home/CostCard.tsx +112 -0
- package/src/components/home/HealthRing.tsx +1 -0
- package/src/components/home/HelmSummary.tsx +129 -0
- package/src/components/home/HomeView.tsx +224 -0
- package/src/components/home/MCPSetupDialog.tsx +417 -0
- package/src/components/home/NetworkPolicyCoverageCard.tsx +109 -0
- package/src/components/home/TopologyPreview.tsx +219 -0
- package/src/components/home/TrafficSummary.tsx +154 -0
- package/src/components/logs/JsonLogLine.tsx +1 -0
- package/src/components/logs/LogCore.tsx +2 -0
- package/src/components/logs/LogsViewer.tsx +44 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +40 -0
- package/src/components/logs/useLogBuffer.ts +2 -0
- package/src/components/logs/useLogSearch.ts +1 -0
- package/src/components/portforward/PortForwardButton.tsx +375 -0
- package/src/components/portforward/PortForwardManager.tsx +871 -0
- package/src/components/resource/PrometheusCharts.tsx +687 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +214 -0
- package/src/components/resources/ImageFilesystemModal.tsx +745 -0
- package/src/components/resources/PodFilesystemModal.tsx +407 -0
- package/src/components/resources/ResourceDetailDrawer.tsx +43 -0
- package/src/components/resources/ResourcesView.tsx +190 -0
- package/src/components/resources/drawer-components.tsx +1 -0
- package/src/components/resources/file-browser-utils.ts +35 -0
- package/src/components/resources/renderers/AlertRenderer.tsx +1 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +17 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +1 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +1 -0
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +1 -0
- package/src/components/resources/renderers/CertificateRenderer.tsx +1 -0
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +1 -0
- package/src/components/resources/renderers/ChallengeRenderer.tsx +1 -0
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +1 -0
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +1 -0
- package/src/components/resources/renderers/CronJobRenderer.tsx +1 -0
- package/src/components/resources/renderers/EventRenderer.tsx +1 -0
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +1 -0
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/GatewayRenderer.tsx +1 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +1 -0
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +1 -0
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +1 -0
- package/src/components/resources/renderers/IngressClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioGatewayRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +1 -0
- package/src/components/resources/renderers/JobRenderer.tsx +1 -0
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +1 -0
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +1 -0
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +1 -0
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +1 -0
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeServiceRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -0
- package/src/components/resources/renderers/KustomizationRenderer.tsx +1 -0
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/LeaseRenderer.tsx +1 -0
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +1 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +44 -0
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +1 -0
- package/src/components/resources/renderers/OrderRenderer.tsx +1 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +1 -0
- package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +1 -0
- package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +1 -0
- package/src/components/resources/renderers/PodMonitorRenderer.tsx +1 -0
- package/src/components/resources/renderers/PodRenderer.tsx +94 -0
- package/src/components/resources/renderers/PriorityClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +1 -0
- package/src/components/resources/renderers/ReplicaSetRenderer.tsx +1 -0
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +1 -0
- package/src/components/resources/renderers/RoleRenderer.tsx +1 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +1 -0
- package/src/components/resources/renderers/RuntimeClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/SbomReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/SecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +1 -0
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +1 -0
- package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +1 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +26 -0
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/StorageClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/VPARenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroVSLRenderer.tsx +1 -0
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +1 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +1 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +52 -0
- package/src/components/resources/renderers/argo-cells.tsx +1 -0
- package/src/components/resources/renderers/certmanager-cells.tsx +1 -0
- package/src/components/resources/renderers/cnpg-cells.tsx +1 -0
- package/src/components/resources/renderers/eso-cells.tsx +1 -0
- package/src/components/resources/renderers/flux-cells.tsx +1 -0
- package/src/components/resources/renderers/index.ts +91 -0
- package/src/components/resources/renderers/istio-cells.tsx +1 -0
- package/src/components/resources/renderers/karpenter-cells.tsx +1 -0
- package/src/components/resources/renderers/keda-cells.tsx +1 -0
- package/src/components/resources/renderers/knative-cells.tsx +1 -0
- package/src/components/resources/renderers/kyverno-cells.tsx +1 -0
- package/src/components/resources/renderers/prometheus-cells.tsx +1 -0
- package/src/components/resources/renderers/traefik-cells.tsx +1 -0
- package/src/components/resources/renderers/trivy-cells.tsx +1 -0
- package/src/components/resources/renderers/trivy-shared.tsx +1 -0
- package/src/components/resources/renderers/velero-cells.tsx +1 -0
- package/src/components/resources/resource-utils-argo.ts +2 -0
- package/src/components/resources/resource-utils-certmanager.ts +2 -0
- package/src/components/resources/resource-utils-cnpg.ts +2 -0
- package/src/components/resources/resource-utils-eso.ts +2 -0
- package/src/components/resources/resource-utils-flux.ts +2 -0
- package/src/components/resources/resource-utils-istio.ts +2 -0
- package/src/components/resources/resource-utils-karpenter.ts +2 -0
- package/src/components/resources/resource-utils-keda.ts +2 -0
- package/src/components/resources/resource-utils-knative.ts +2 -0
- package/src/components/resources/resource-utils-kyverno.ts +2 -0
- package/src/components/resources/resource-utils-prometheus.ts +2 -0
- package/src/components/resources/resource-utils-traefik.ts +1 -0
- package/src/components/resources/resource-utils-trivy.ts +2 -0
- package/src/components/resources/resource-utils-velero.ts +2 -0
- package/src/components/resources/resource-utils.ts +5 -0
- package/src/components/settings/SettingsDialog.tsx +537 -0
- package/src/components/shared/CreateResourceDialog.tsx +17 -0
- package/src/components/shared/EditableYamlView.tsx +24 -0
- package/src/components/shared/LargeClusterNamespacePicker.tsx +70 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +31 -0
- package/src/components/timeline/DiffViewer.tsx +1 -0
- package/src/components/timeline/TimelineList.tsx +69 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +1308 -0
- package/src/components/timeline/TimelineView.tsx +157 -0
- package/src/components/timeline/shared.tsx +1 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +571 -0
- package/src/components/traffic/TrafficFlowList.tsx +415 -0
- package/src/components/traffic/TrafficFlowListContext.tsx +68 -0
- package/src/components/traffic/TrafficGraph.tsx +1546 -0
- package/src/components/traffic/TrafficView.tsx +1213 -0
- package/src/components/traffic/TrafficWizard.tsx +386 -0
- package/src/components/traffic/index.ts +3 -0
- package/src/components/ui/CodeViewer.tsx +8 -0
- package/src/components/ui/CommandPalette.tsx +460 -0
- package/src/components/ui/ConfirmDialog.tsx +1 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +619 -0
- package/src/components/ui/ErrorBoundary.tsx +46 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +1 -0
- package/src/components/ui/Markdown.tsx +108 -0
- package/src/components/ui/MetricsChart.tsx +1 -0
- package/src/components/ui/NamespaceSelector.tsx +436 -0
- package/src/components/ui/ResourceBar.tsx +1 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +301 -0
- package/src/components/ui/Toast.tsx +1 -0
- package/src/components/ui/Tooltip.tsx +1 -0
- package/src/components/ui/UpdateNotification.tsx +299 -0
- package/src/components/ui/YamlEditor.tsx +1 -0
- package/src/components/workload/WorkloadView.tsx +532 -0
- package/src/context/ConnectionContext.tsx +173 -0
- package/src/context/ContextSwitchContext.tsx +56 -0
- package/src/context/NavCustomization.tsx +62 -0
- package/src/context/ThemeContext.tsx +97 -0
- package/src/contexts/CapabilitiesContext.tsx +130 -0
- package/src/hooks/useAnimatedUnmount.ts +1 -0
- package/src/hooks/useDesktopDownload.ts +41 -0
- package/src/hooks/useEventSource.ts +262 -0
- package/src/hooks/useFavorites.ts +69 -0
- package/src/hooks/useKeyboardShortcuts.tsx +7 -0
- package/src/hooks/useRefreshAnimation.ts +1 -0
- package/src/index.css +243 -0
- package/src/index.ts +17 -0
- package/src/main.tsx +158 -0
- package/src/types/gitops.ts +2 -0
- package/src/types.ts +3 -0
- package/src/utils/animation.ts +2 -0
- package/src/utils/badge-colors.ts +2 -0
- package/src/utils/context-name.ts +2 -0
- package/src/utils/desktop-download.ts +66 -0
- package/src/utils/desktop-open-folder.ts +21 -0
- package/src/utils/format.ts +2 -0
- package/src/utils/log-format.ts +12 -0
- package/src/utils/navigation.ts +23 -0
- package/src/utils/resource-hierarchy.ts +2 -0
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/skeleton-yaml.ts +2 -0
- package/src/utils/traffic-colors.ts +54 -0
- 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
|
+
}
|