@skyhook-io/radar-app 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -21,7 +21,6 @@ import { PortForwardProvider, PortForwardIndicator, PortForwardPanel } from './c
|
|
|
21
21
|
import { DockProvider, BottomDock, useDock, useOpenLocalTerminal } from './components/dock'
|
|
22
22
|
import { DURATION_DOCK } from '@skyhook-io/k8s-ui/utils/animation'
|
|
23
23
|
import { ContextSwitcher } from './components/ContextSwitcher'
|
|
24
|
-
import { NamespaceSwitcher, type NamespaceSwitcherHandle } from './components/NamespaceSwitcher'
|
|
25
24
|
import { useNavCustomization } from './context/NavCustomization'
|
|
26
25
|
import { ContextSwitchProvider, useContextSwitch } from './context/ContextSwitchContext'
|
|
27
26
|
import { ConnectionProvider, useConnection } from './context/ConnectionContext'
|
|
@@ -29,12 +28,13 @@ import { ConnectionErrorView } from './components/ConnectionErrorView'
|
|
|
29
28
|
import { CapabilitiesProvider, useCapabilitiesContext } from './contexts/CapabilitiesContext'
|
|
30
29
|
import { UserMenu } from './components/UserMenu'
|
|
31
30
|
import { ErrorBoundary } from './components/ui/ErrorBoundary'
|
|
31
|
+
import { NamespaceSelector, type NamespaceSelectorHandle } from './components/ui/NamespaceSelector'
|
|
32
32
|
import { UpdateNotification } from './components/ui/UpdateNotification'
|
|
33
33
|
import { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay'
|
|
34
34
|
import { CommandPalette } from './components/ui/CommandPalette'
|
|
35
35
|
import { DiagnosticsOverlay } from './components/ui/DiagnosticsOverlay'
|
|
36
36
|
import { useEventSource } from './hooks/useEventSource'
|
|
37
|
-
import { useNamespaces,
|
|
37
|
+
import { useNamespaces, useSwitchContext, useAuthMe } from './api/client'
|
|
38
38
|
import { routePath, apiUrl, getAuthHeaders, getCredentialsMode } from './api/config'
|
|
39
39
|
import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts } from './hooks/useKeyboardShortcuts'
|
|
40
40
|
import { useAnimatedUnmount } from './hooks/useAnimatedUnmount'
|
|
@@ -390,7 +390,7 @@ function AppInner() {
|
|
|
390
390
|
const switchContext = useSwitchContext()
|
|
391
391
|
|
|
392
392
|
// Refs for dropdown components to trigger them via shortcuts
|
|
393
|
-
const
|
|
393
|
+
const namespaceSelectorRef = useRef<NamespaceSelectorHandle>(null)
|
|
394
394
|
const contextSwitcherRef = useRef<ContextSwitcherHandle>(null)
|
|
395
395
|
|
|
396
396
|
// View switching keyboard shortcuts
|
|
@@ -410,7 +410,7 @@ function AppInner() {
|
|
|
410
410
|
description: 'Switch namespace',
|
|
411
411
|
category: 'Navigation' as const,
|
|
412
412
|
scope: 'global' as const,
|
|
413
|
-
handler: () =>
|
|
413
|
+
handler: () => namespaceSelectorRef.current?.open(),
|
|
414
414
|
},
|
|
415
415
|
{
|
|
416
416
|
id: 'switch-context',
|
|
@@ -490,12 +490,7 @@ function AppInner() {
|
|
|
490
490
|
const hideGroupHeader = namespaces.length === 1 && effectiveGroupingMode === 'namespace'
|
|
491
491
|
|
|
492
492
|
// Fetch available namespaces
|
|
493
|
-
const { data: availableNamespaces } = useNamespaces()
|
|
494
|
-
|
|
495
|
-
// Per-user view filter served by the backend. Loaded eagerly so the
|
|
496
|
-
// picker can render its current state without showing the multi-select
|
|
497
|
-
// fallback during the initial scope fetch.
|
|
498
|
-
const { data: namespaceScope } = useNamespaceScope()
|
|
493
|
+
const { data: availableNamespaces, error: namespacesError } = useNamespaces()
|
|
499
494
|
|
|
500
495
|
// Context switch state
|
|
501
496
|
const { isSwitching, targetContext, progressMessage, updateProgress, endSwitch } = useContextSwitch()
|
|
@@ -666,39 +661,6 @@ function AppInner() {
|
|
|
666
661
|
// Serialize namespaces for stable dependency tracking
|
|
667
662
|
const namespacesKey = namespaces.join(',')
|
|
668
663
|
|
|
669
|
-
// The server is canonical for the per-user namespace pick. Mirror its
|
|
670
|
-
// `actives` into App.tsx state so consumer hooks (SSE, dashboard, resource
|
|
671
|
-
// lists) stay in lockstep with the picker. The dedicated URL-write effect
|
|
672
|
-
// below propagates the mirrored state to `?namespaces=`.
|
|
673
|
-
const setActiveNamespace = useSetActiveNamespace()
|
|
674
|
-
const initialBookmarkReconciledRef = useRef(false)
|
|
675
|
-
const scopeActives = useMemo(() => namespaceScope?.actives ?? [], [namespaceScope?.actives])
|
|
676
|
-
const namespaceScopeKey = useMemo(() => namespaceScope ? [...scopeActives].sort().join(',') : null, [namespaceScope, scopeActives])
|
|
677
|
-
useEffect(() => {
|
|
678
|
-
if (!namespaceScope) return
|
|
679
|
-
const sortedScope = [...scopeActives].sort()
|
|
680
|
-
const sortedState = [...namespaces].sort()
|
|
681
|
-
const sameAsState = sortedScope.length === sortedState.length && sortedScope.every((ns, i) => ns === sortedState[i])
|
|
682
|
-
|
|
683
|
-
// First-load bookmark reconciliation: if the URL had namespaces that
|
|
684
|
-
// differ from the server pick when the scope first arrives, push the
|
|
685
|
-
// URL choice to the server so shared/bookmarked deep links keep
|
|
686
|
-
// working. The ref flips on the first scope load regardless of whether
|
|
687
|
-
// the URL had namespaces — subsequent runs mirror server → state.
|
|
688
|
-
if (!initialBookmarkReconciledRef.current) {
|
|
689
|
-
initialBookmarkReconciledRef.current = true
|
|
690
|
-
if (!sameAsState && sortedState.length > 0) {
|
|
691
|
-
setActiveNamespace.mutate({ namespaces: sortedState })
|
|
692
|
-
return
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
if (!sameAsState) {
|
|
697
|
-
setNamespaces(scopeActives)
|
|
698
|
-
}
|
|
699
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- namespaces and setActiveNamespace are intentionally excluded; we only react to server-side changes.
|
|
700
|
-
}, [namespaceScope, namespaceScopeKey])
|
|
701
|
-
|
|
702
664
|
// Update URL query params when state changes (path is handled by setMainView)
|
|
703
665
|
// Read from window.location.search (not React Router's searchParams) to preserve
|
|
704
666
|
// params set by child components via window.history.replaceState (e.g., kind from ResourcesView).
|
|
@@ -739,24 +701,11 @@ function AppInner() {
|
|
|
739
701
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- reads window.location.search, not searchParams
|
|
740
702
|
}, [namespacesKey, topologyMode, groupingMode, mainView, setSearchParams])
|
|
741
703
|
|
|
742
|
-
// Sync state from URL when navigating (back/forward)
|
|
743
|
-
// to the server too so the canonical pick stays in lockstep — otherwise a
|
|
744
|
-
// back-nav would visually update but leave the next reload pulling the
|
|
745
|
-
// server's previous (forward-nav) pick.
|
|
704
|
+
// Sync state from URL when navigating (back/forward)
|
|
746
705
|
useEffect(() => {
|
|
747
706
|
const urlNamespaces = parseNamespacesFromURL(searchParams)
|
|
748
707
|
|
|
749
|
-
if (urlNamespaces.join(',') !== namespacesKey)
|
|
750
|
-
setNamespaces(urlNamespaces)
|
|
751
|
-
if (namespaceScope) {
|
|
752
|
-
const sortedURL = [...urlNamespaces].sort()
|
|
753
|
-
const sortedScope = [...(namespaceScope.actives ?? [])].sort()
|
|
754
|
-
const same = sortedURL.length === sortedScope.length && sortedURL.every((ns, i) => ns === sortedScope[i])
|
|
755
|
-
if (!same) {
|
|
756
|
-
setActiveNamespace.mutate({ namespaces: urlNamespaces })
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
}
|
|
708
|
+
if (urlNamespaces.join(',') !== namespacesKey) setNamespaces(urlNamespaces)
|
|
760
709
|
|
|
761
710
|
// Restore helm release from URL (back navigation)
|
|
762
711
|
const releaseParam = searchParams.get('release')
|
|
@@ -879,7 +828,7 @@ function AppInner() {
|
|
|
879
828
|
|
|
880
829
|
return (
|
|
881
830
|
<PortForwardProvider>
|
|
882
|
-
<div className="
|
|
831
|
+
<div className="flex flex-col h-screen bg-theme-base min-w-[800px]">
|
|
883
832
|
{/* Header */}
|
|
884
833
|
<header className="relative z-50 flex items-center justify-between px-4 py-2 bg-theme-base/90 backdrop-blur-sm border-b border-theme-border/50">
|
|
885
834
|
{/* Left: Logo + Cluster info */}
|
|
@@ -980,13 +929,17 @@ function AppInner() {
|
|
|
980
929
|
|
|
981
930
|
{/* Right: Controls */}
|
|
982
931
|
<div className="flex items-center gap-3 shrink-0">
|
|
983
|
-
|
|
984
|
-
|
|
932
|
+
{/* Namespace selector with search */}
|
|
933
|
+
<NamespaceSelector
|
|
934
|
+
ref={namespaceSelectorRef}
|
|
935
|
+
value={namespaces}
|
|
936
|
+
onChange={setNamespaces}
|
|
937
|
+
namespaces={availableNamespaces}
|
|
938
|
+
namespacesError={namespacesError}
|
|
985
939
|
disabled={mainView === 'helm'}
|
|
986
940
|
disabledTooltip="Helm view always shows all namespaces"
|
|
987
941
|
/>
|
|
988
942
|
|
|
989
|
-
|
|
990
943
|
{/* Command palette trigger */}
|
|
991
944
|
<button
|
|
992
945
|
onClick={() => setShowCommandPalette(true)}
|
|
@@ -1211,7 +1164,6 @@ function AppInner() {
|
|
|
1211
1164
|
namespaces={availableNamespaces}
|
|
1212
1165
|
onSelect={(ns) => {
|
|
1213
1166
|
setNamespaces([ns])
|
|
1214
|
-
setActiveNamespace.mutate({ namespaces: [ns] })
|
|
1215
1167
|
// Large clusters need server-side filtering — reconnect SSE with namespace
|
|
1216
1168
|
setForceNamespaceFilter([ns])
|
|
1217
1169
|
}}
|
|
@@ -1248,9 +1200,9 @@ function AppInner() {
|
|
|
1248
1200
|
selectedNodeId={selectedResource ? `${apiResourceToNodeIdPrefix(selectedResource.kind)}-${selectedResource.namespace}-${selectedResource.name}` : undefined}
|
|
1249
1201
|
paused={topologyPaused}
|
|
1250
1202
|
onTogglePause={handleTogglePause}
|
|
1251
|
-
onMaximizeNamespace={(ns) =>
|
|
1203
|
+
onMaximizeNamespace={(ns) => setNamespaces([ns])}
|
|
1252
1204
|
namespaceBreadcrumb={namespaces.length === 1 ? namespaces[0] : undefined}
|
|
1253
|
-
onClearNamespace={namespaces.length
|
|
1205
|
+
onClearNamespace={namespaces.length === 1 ? () => setNamespaces([]) : undefined}
|
|
1254
1206
|
namespacesKey={namespaces.join(',')}
|
|
1255
1207
|
/>
|
|
1256
1208
|
|
|
@@ -1298,10 +1250,7 @@ function AppInner() {
|
|
|
1298
1250
|
initialTimeRange={(searchParams.get('time') as '5m' | '30m' | '1h' | '6h' | '24h' | 'all') || undefined}
|
|
1299
1251
|
requiresNamespaceFilter={topology?.requiresNamespaceFilter && namespaces.length === 0}
|
|
1300
1252
|
availableNamespaces={availableNamespaces}
|
|
1301
|
-
onNamespaceSelect={(ns) =>
|
|
1302
|
-
setNamespaces([ns])
|
|
1303
|
-
setActiveNamespace.mutate({ namespaces: [ns] })
|
|
1304
|
-
}}
|
|
1253
|
+
onNamespaceSelect={(ns) => setNamespaces([ns])}
|
|
1305
1254
|
/>
|
|
1306
1255
|
)}
|
|
1307
1256
|
|
|
@@ -1456,14 +1405,9 @@ function AppInner() {
|
|
|
1456
1405
|
{ name },
|
|
1457
1406
|
// Namespace filter from the previous context may not exist in the
|
|
1458
1407
|
// new one — clear it so resource lists don't silently go empty.
|
|
1459
|
-
// The server clears all per-user picks on context switch already;
|
|
1460
|
-
// local state mirrors that via the namespace-scope effect.
|
|
1461
1408
|
{ onSettled: () => setNamespaces([]) },
|
|
1462
1409
|
)}
|
|
1463
|
-
onSetNamespaces={
|
|
1464
|
-
setNamespaces(ns)
|
|
1465
|
-
setActiveNamespace.mutate({ namespaces: ns })
|
|
1466
|
-
}}
|
|
1410
|
+
onSetNamespaces={setNamespaces}
|
|
1467
1411
|
onToggleTheme={toggleTheme}
|
|
1468
1412
|
onShowDiagnostics={() => setShowDiagnostics(true)}
|
|
1469
1413
|
/>
|
package/src/api/client.ts
CHANGED
|
@@ -1348,7 +1348,6 @@ export interface AvailablePort {
|
|
|
1348
1348
|
protocol: string
|
|
1349
1349
|
containerName?: string
|
|
1350
1350
|
name?: string
|
|
1351
|
-
scheme?: 'http' | 'https'
|
|
1352
1351
|
}
|
|
1353
1352
|
|
|
1354
1353
|
export function useAvailablePorts(type: 'pod' | 'service', namespace: string, name: string) {
|
|
@@ -2451,87 +2450,6 @@ export function useSwitchContext() {
|
|
|
2451
2450
|
})
|
|
2452
2451
|
}
|
|
2453
2452
|
|
|
2454
|
-
// ============================================================================
|
|
2455
|
-
// Active namespace switcher
|
|
2456
|
-
// ============================================================================
|
|
2457
|
-
|
|
2458
|
-
export interface NamespaceScope {
|
|
2459
|
-
actives: string[]
|
|
2460
|
-
kubeconfigNamespace: string
|
|
2461
|
-
/**
|
|
2462
|
-
* 'cluster-wide' — no per-user pick; user can list across namespaces.
|
|
2463
|
-
* 'namespace' — per-user view filter pinned to one or more namespaces.
|
|
2464
|
-
* 'restricted' — user can't list namespaces and isn't pinned to any.
|
|
2465
|
-
*/
|
|
2466
|
-
mode: 'cluster-wide' | 'namespace' | 'restricted'
|
|
2467
|
-
accessibleNamespaces: string[]
|
|
2468
|
-
/** false when accessibleNamespaces is a best-effort short list (no list perm). */
|
|
2469
|
-
authoritative: boolean
|
|
2470
|
-
/** false when clearing would leave no usable namespace fallback. */
|
|
2471
|
-
canClearNamespace: boolean
|
|
2472
|
-
}
|
|
2473
|
-
|
|
2474
|
-
export function useNamespaceScope() {
|
|
2475
|
-
return useQuery<NamespaceScope>({
|
|
2476
|
-
queryKey: ['namespace-scope'],
|
|
2477
|
-
queryFn: () => fetchJSON('/cluster/namespace-scope'),
|
|
2478
|
-
staleTime: 30000,
|
|
2479
|
-
})
|
|
2480
|
-
}
|
|
2481
|
-
|
|
2482
|
-
const NAMESPACE_SWITCH_TIMEOUT = 5000
|
|
2483
|
-
|
|
2484
|
-
export function useSetActiveNamespace() {
|
|
2485
|
-
const queryClient = useQueryClient()
|
|
2486
|
-
return useMutation<NamespaceScope, Error, { namespaces: string[] }>({
|
|
2487
|
-
meta: {
|
|
2488
|
-
// Surface 403s (RBAC drift, denied bookmark) and network errors via the
|
|
2489
|
-
// global toast. Without this, App.tsx call sites that mutate without
|
|
2490
|
-
// their own onError (bookmark reconciliation, back-nav, topology
|
|
2491
|
-
// maximize/clear, command palette) silently revert when the scope
|
|
2492
|
-
// refetches and the mirror effect overwrites local state.
|
|
2493
|
-
errorMessage: 'Failed to update namespace selection',
|
|
2494
|
-
},
|
|
2495
|
-
mutationFn: async ({ namespaces }) => {
|
|
2496
|
-
const controller = new AbortController()
|
|
2497
|
-
const timeoutId = setTimeout(() => controller.abort(), NAMESPACE_SWITCH_TIMEOUT)
|
|
2498
|
-
try {
|
|
2499
|
-
const response = await apiFetch(`${getApiBase()}/cluster/namespace`, {
|
|
2500
|
-
method: 'POST',
|
|
2501
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2502
|
-
body: JSON.stringify({ namespaces }),
|
|
2503
|
-
signal: controller.signal,
|
|
2504
|
-
})
|
|
2505
|
-
clearTimeout(timeoutId)
|
|
2506
|
-
if (!response.ok) {
|
|
2507
|
-
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
2508
|
-
throw new Error(error.error || `HTTP ${response.status}`)
|
|
2509
|
-
}
|
|
2510
|
-
return response.json()
|
|
2511
|
-
} catch (error) {
|
|
2512
|
-
clearTimeout(timeoutId)
|
|
2513
|
-
if (error instanceof Error && error.name === 'AbortError') {
|
|
2514
|
-
throw new Error('Namespace switch timed out. The cluster may be unreachable.')
|
|
2515
|
-
}
|
|
2516
|
-
throw error
|
|
2517
|
-
}
|
|
2518
|
-
},
|
|
2519
|
-
onSuccess: () => {
|
|
2520
|
-
// The user's view filter changed; every cached query result was
|
|
2521
|
-
// shaped by the previous filter, so drop and refetch.
|
|
2522
|
-
queryClient.removeQueries()
|
|
2523
|
-
queryClient.invalidateQueries()
|
|
2524
|
-
},
|
|
2525
|
-
onError: () => {
|
|
2526
|
-
// A failed switch can leave the server's stored pick out of sync
|
|
2527
|
-
// with the cached scope (network timeout after the server wrote;
|
|
2528
|
-
// partial mutation). Refetch so the displayed picker matches what
|
|
2529
|
-
// the server actually persisted instead of what we assumed.
|
|
2530
|
-
queryClient.invalidateQueries({ queryKey: ['namespace-scope'] })
|
|
2531
|
-
},
|
|
2532
|
-
})
|
|
2533
|
-
}
|
|
2534
|
-
|
|
2535
2453
|
// ============================================================================
|
|
2536
2454
|
// Image Filesystem Inspection
|
|
2537
2455
|
// ============================================================================
|
|
@@ -42,44 +42,11 @@ interface PortForwardSession {
|
|
|
42
42
|
localPort: number
|
|
43
43
|
listenAddress: string
|
|
44
44
|
serviceName?: string
|
|
45
|
-
servicePort?: number
|
|
46
|
-
scheme?: 'http' | 'https'
|
|
47
45
|
startedAt: string
|
|
48
46
|
status: 'running' | 'stopped' | 'error'
|
|
49
47
|
error?: string
|
|
50
48
|
}
|
|
51
49
|
|
|
52
|
-
function sessionUrl(session: PortForwardSession): string {
|
|
53
|
-
return `${session.scheme || 'http'}://localhost:${session.localPort}`
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function formatPortLabel(session: PortForwardSession): string {
|
|
57
|
-
if (session.servicePort && session.servicePort !== session.podPort) {
|
|
58
|
-
return `${session.servicePort} → ${session.podPort}`
|
|
59
|
-
}
|
|
60
|
-
return String(session.podPort)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Build the recreate request body for toggle-listen / change-port flows.
|
|
64
|
-
// When the original session was service-resolved, the recreate must also go
|
|
65
|
-
// through the service path (servicePort + serviceName). Sending podName+podPort
|
|
66
|
-
// would skip resolution and validate against the pod's declared containerPorts,
|
|
67
|
-
// which can fail even though the original session worked: services can route to
|
|
68
|
-
// any port the container actually listens on, regardless of containerPort
|
|
69
|
-
// declarations. Going through service also re-resolves to a currently-running
|
|
70
|
-
// pod if the original has since been replaced.
|
|
71
|
-
function buildRecreateBody(session: PortForwardSession, overrides: { localPort: number; listenAddress: string }) {
|
|
72
|
-
const base = {
|
|
73
|
-
namespace: session.namespace,
|
|
74
|
-
localPort: overrides.localPort,
|
|
75
|
-
listenAddress: overrides.listenAddress,
|
|
76
|
-
}
|
|
77
|
-
if (session.serviceName && session.servicePort) {
|
|
78
|
-
return { ...base, serviceName: session.serviceName, podPort: session.servicePort }
|
|
79
|
-
}
|
|
80
|
-
return { ...base, podName: session.podName, podPort: session.podPort }
|
|
81
|
-
}
|
|
82
|
-
|
|
83
50
|
// --- Shared query ------------------------------------------------------------
|
|
84
51
|
|
|
85
52
|
function usePortForwardQuery() {
|
|
@@ -463,7 +430,14 @@ export function PortForwardPanel() {
|
|
|
463
430
|
const res = await fetch(apiUrl('/portforwards'), {
|
|
464
431
|
method: 'POST',
|
|
465
432
|
headers: { 'Content-Type': 'application/json' },
|
|
466
|
-
body: JSON.stringify(
|
|
433
|
+
body: JSON.stringify({
|
|
434
|
+
namespace: session.namespace,
|
|
435
|
+
podName: session.podName || undefined,
|
|
436
|
+
serviceName: session.serviceName || undefined,
|
|
437
|
+
podPort: session.podPort,
|
|
438
|
+
localPort: session.localPort,
|
|
439
|
+
listenAddress: newAddress,
|
|
440
|
+
}),
|
|
467
441
|
})
|
|
468
442
|
if (!res.ok) {
|
|
469
443
|
const error = await res.json().catch(() => ({}))
|
|
@@ -510,7 +484,14 @@ export function PortForwardPanel() {
|
|
|
510
484
|
const res = await fetch(apiUrl('/portforwards'), {
|
|
511
485
|
method: 'POST',
|
|
512
486
|
headers: { 'Content-Type': 'application/json' },
|
|
513
|
-
body: JSON.stringify(
|
|
487
|
+
body: JSON.stringify({
|
|
488
|
+
namespace: session.namespace,
|
|
489
|
+
podName: session.podName || undefined,
|
|
490
|
+
serviceName: session.serviceName || undefined,
|
|
491
|
+
podPort: session.podPort,
|
|
492
|
+
localPort: newPort,
|
|
493
|
+
listenAddress: session.listenAddress,
|
|
494
|
+
}),
|
|
514
495
|
})
|
|
515
496
|
if (!res.ok) {
|
|
516
497
|
const error = await res.json().catch(() => ({}))
|
|
@@ -540,7 +521,7 @@ export function PortForwardPanel() {
|
|
|
540
521
|
async (session: PortForwardSession) => {
|
|
541
522
|
commitInteraction()
|
|
542
523
|
try {
|
|
543
|
-
await navigator.clipboard.writeText(
|
|
524
|
+
await navigator.clipboard.writeText(`http://localhost:${session.localPort}`)
|
|
544
525
|
} catch (err) {
|
|
545
526
|
// Clipboard API can reject in non-secure contexts, denied permissions, or
|
|
546
527
|
// when the document isn't focused. Surface the failure — the checkmark
|
|
@@ -563,7 +544,7 @@ export function PortForwardPanel() {
|
|
|
563
544
|
const handleOpenUrl = useCallback(
|
|
564
545
|
(session: PortForwardSession) => {
|
|
565
546
|
commitInteraction()
|
|
566
|
-
openExternal(
|
|
547
|
+
openExternal(`http://localhost:${session.localPort}`)
|
|
567
548
|
},
|
|
568
549
|
[commitInteraction]
|
|
569
550
|
)
|
|
@@ -645,7 +626,7 @@ export function PortForwardPanel() {
|
|
|
645
626
|
</div>
|
|
646
627
|
|
|
647
628
|
{/* Sessions list */}
|
|
648
|
-
<div className="max-h-
|
|
629
|
+
<div className="max-h-64 overflow-y-auto">
|
|
649
630
|
{isQueryError ? (
|
|
650
631
|
<div className="p-3 text-xs bg-red-500/10 border-b border-theme-border">
|
|
651
632
|
<div className={clsx('badge-sm mb-1 inline-block', SEVERITY_BADGE.error)}>
|
|
@@ -670,84 +651,37 @@ export function PortForwardPanel() {
|
|
|
670
651
|
<div
|
|
671
652
|
key={session.id}
|
|
672
653
|
className={clsx(
|
|
673
|
-
'p-3
|
|
654
|
+
'p-3',
|
|
674
655
|
session.status === 'error' ? 'bg-red-500/10' : 'hover:bg-theme-elevated'
|
|
675
656
|
)}
|
|
676
657
|
>
|
|
677
|
-
{/* Row 1: status dot + name | stop button */}
|
|
678
658
|
<div className="flex items-start justify-between gap-2">
|
|
679
|
-
<div className="flex
|
|
680
|
-
<
|
|
681
|
-
|
|
682
|
-
'w-2 h-2 rounded-full shrink-0 mt-[7px]',
|
|
683
|
-
session.status === 'running' ? 'bg-green-500' : 'bg-red-500'
|
|
684
|
-
)}
|
|
685
|
-
/>
|
|
686
|
-
<span className="text-sm text-theme-text-primary font-medium break-all line-clamp-2">
|
|
687
|
-
{session.serviceName || session.podName}
|
|
688
|
-
</span>
|
|
689
|
-
{session.status === 'error' && (
|
|
690
|
-
<span className={clsx('badge-sm shrink-0', SEVERITY_BADGE.error)}>Failed</span>
|
|
691
|
-
)}
|
|
692
|
-
</div>
|
|
693
|
-
<div className="flex items-center gap-0.5 shrink-0">
|
|
694
|
-
{session.status === 'running' && (
|
|
695
|
-
<Tooltip
|
|
696
|
-
content={session.listenAddress === '0.0.0.0' ? 'Switch to localhost only' : 'Allow access from other machines'}
|
|
697
|
-
delay={300} position="bottom" disabled={!isPanelOpen}
|
|
698
|
-
>
|
|
699
|
-
<button
|
|
700
|
-
onClick={() => toggleListenAddress(session)}
|
|
701
|
-
disabled={togglingId === session.id || changingPortId === session.id}
|
|
659
|
+
<div className="flex-1 min-w-0">
|
|
660
|
+
<div className="flex items-center gap-2">
|
|
661
|
+
<span
|
|
702
662
|
className={clsx(
|
|
703
|
-
'
|
|
704
|
-
session.
|
|
705
|
-
? `${SEVERITY_BADGE.warning} hover:bg-amber-500/30`
|
|
706
|
-
: 'text-theme-text-disabled hover:text-theme-text-primary hover:bg-theme-hover'
|
|
707
|
-
)}
|
|
708
|
-
>
|
|
709
|
-
{togglingId === session.id ? (
|
|
710
|
-
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
711
|
-
) : session.listenAddress === '0.0.0.0' ? (
|
|
712
|
-
<Globe className="w-3.5 h-3.5" />
|
|
713
|
-
) : (
|
|
714
|
-
<Monitor className="w-3.5 h-3.5" />
|
|
663
|
+
'w-2 h-2 rounded-full shrink-0',
|
|
664
|
+
session.status === 'running' ? 'bg-green-500' : 'bg-red-500'
|
|
715
665
|
)}
|
|
716
|
-
|
|
717
|
-
|
|
666
|
+
/>
|
|
667
|
+
<span className="text-sm text-theme-text-primary font-medium truncate">
|
|
668
|
+
{session.serviceName || session.podName}
|
|
669
|
+
</span>
|
|
670
|
+
{session.status === 'error' && (
|
|
671
|
+
<span className={clsx('badge-sm', SEVERITY_BADGE.error)}>Failed</span>
|
|
672
|
+
)}
|
|
673
|
+
</div>
|
|
674
|
+
<div className="mt-1 text-xs text-theme-text-disabled">
|
|
675
|
+
{session.namespace} · Port {session.podPort}
|
|
676
|
+
</div>
|
|
677
|
+
{session.status === 'error' && session.error && (
|
|
678
|
+
<div className="mt-1.5 text-xs text-red-400 bg-red-500/10 px-2 py-1 rounded">
|
|
679
|
+
{session.error}
|
|
680
|
+
</div>
|
|
718
681
|
)}
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
commitInteraction()
|
|
723
|
-
stopPortForward(session.id)
|
|
724
|
-
}}
|
|
725
|
-
disabled={stoppingIds.has(session.id)}
|
|
726
|
-
className="p-1.5 text-theme-text-tertiary hover:text-red-400 hover:bg-theme-hover rounded disabled:opacity-50"
|
|
727
|
-
>
|
|
728
|
-
<Trash2 className="w-3.5 h-3.5" />
|
|
729
|
-
</button>
|
|
730
|
-
</Tooltip>
|
|
731
|
-
</div>
|
|
732
|
-
</div>
|
|
733
|
-
|
|
734
|
-
{/* Row 2: namespace · port translation */}
|
|
735
|
-
<div className="text-xs text-theme-text-disabled">
|
|
736
|
-
{session.namespace} · {formatPortLabel(session)}
|
|
737
|
-
</div>
|
|
738
|
-
|
|
739
|
-
{/* Row 2.5: error message */}
|
|
740
|
-
{session.status === 'error' && session.error && (
|
|
741
|
-
<div className="text-xs text-red-400 bg-red-500/10 px-2 py-1 rounded">
|
|
742
|
-
{session.error}
|
|
743
|
-
</div>
|
|
744
|
-
)}
|
|
745
|
-
|
|
746
|
-
{/* Row 3: URL (+ optional toggle) | copy + open */}
|
|
747
|
-
{session.status === 'running' && (
|
|
748
|
-
<div className="pt-0.5 flex items-center justify-between gap-2">
|
|
749
|
-
<div className="flex items-center gap-1.5 min-w-0">
|
|
750
|
-
{editingPortId === session.id ? (
|
|
682
|
+
{session.status === 'running' && (
|
|
683
|
+
<div className="mt-1.5 flex items-center gap-2">
|
|
684
|
+
{editingPortId === session.id ? (
|
|
751
685
|
<div className="flex items-center text-xs bg-theme-base rounded text-accent-text font-mono">
|
|
752
686
|
<span className="pl-2 py-1 text-theme-text-disabled select-none">
|
|
753
687
|
{session.listenAddress === '0.0.0.0' ? '0.0.0.0' : 'localhost'}:
|
|
@@ -790,7 +724,6 @@ export function PortForwardPanel() {
|
|
|
790
724
|
/>
|
|
791
725
|
</div>
|
|
792
726
|
) : (
|
|
793
|
-
<>
|
|
794
727
|
<Tooltip content="Click to change local port" delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
795
728
|
<code
|
|
796
729
|
className={clsx(
|
|
@@ -814,33 +747,74 @@ export function PortForwardPanel() {
|
|
|
814
747
|
<PenLine className="w-3 h-3 text-theme-text-disabled opacity-0 group-hover/port:opacity-100 transition-opacity" />
|
|
815
748
|
</code>
|
|
816
749
|
</Tooltip>
|
|
817
|
-
</>
|
|
818
|
-
)}
|
|
819
|
-
</div>
|
|
820
|
-
<div className="flex items-center gap-0.5 shrink-0">
|
|
821
|
-
<Tooltip content={copiedId === session.id ? 'Copied!' : 'Copy URL'} delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
822
|
-
<button
|
|
823
|
-
onClick={() => handleCopyUrl(session)}
|
|
824
|
-
className="p-1 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
|
|
825
|
-
>
|
|
826
|
-
{copiedId === session.id ? (
|
|
827
|
-
<Check className="w-3.5 h-3.5 text-green-400" />
|
|
828
|
-
) : (
|
|
829
|
-
<Copy className="w-3.5 h-3.5" />
|
|
830
750
|
)}
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
751
|
+
<Tooltip
|
|
752
|
+
content={session.listenAddress === '0.0.0.0' ? 'Switch to localhost only' : 'Allow access from other machines'}
|
|
753
|
+
delay={300} position="bottom" disabled={!isPanelOpen}
|
|
754
|
+
>
|
|
755
|
+
<button
|
|
756
|
+
onClick={() => toggleListenAddress(session)}
|
|
757
|
+
disabled={togglingId === session.id || changingPortId === session.id}
|
|
758
|
+
className={clsx(
|
|
759
|
+
'flex items-center gap-1 px-1.5 py-0.5 text-xs rounded transition-colors',
|
|
760
|
+
session.listenAddress === '0.0.0.0'
|
|
761
|
+
? `${SEVERITY_BADGE.warning} hover:bg-amber-500/30`
|
|
762
|
+
: 'bg-theme-elevated text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary'
|
|
763
|
+
)}
|
|
764
|
+
>
|
|
765
|
+
{togglingId === session.id ? (
|
|
766
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
767
|
+
) : session.listenAddress === '0.0.0.0' ? (
|
|
768
|
+
<Globe className="w-3 h-3" />
|
|
769
|
+
) : (
|
|
770
|
+
<Monitor className="w-3 h-3" />
|
|
771
|
+
)}
|
|
772
|
+
{session.listenAddress === '0.0.0.0' ? 'network' : 'local'}
|
|
773
|
+
</button>
|
|
774
|
+
</Tooltip>
|
|
775
|
+
</div>
|
|
776
|
+
)}
|
|
842
777
|
</div>
|
|
843
|
-
|
|
778
|
+
|
|
779
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
780
|
+
{session.status === 'running' && (
|
|
781
|
+
<>
|
|
782
|
+
<Tooltip content={copiedId === session.id ? 'Copied!' : 'Copy URL'} delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
783
|
+
<button
|
|
784
|
+
onClick={() => handleCopyUrl(session)}
|
|
785
|
+
className="p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
|
|
786
|
+
>
|
|
787
|
+
{copiedId === session.id ? (
|
|
788
|
+
<Check className="w-3.5 h-3.5 text-green-400" />
|
|
789
|
+
) : (
|
|
790
|
+
<Copy className="w-3.5 h-3.5" />
|
|
791
|
+
)}
|
|
792
|
+
</button>
|
|
793
|
+
</Tooltip>
|
|
794
|
+
<Tooltip content="Open in browser" delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
795
|
+
<button
|
|
796
|
+
onClick={() => handleOpenUrl(session)}
|
|
797
|
+
className="p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
|
|
798
|
+
>
|
|
799
|
+
<ExternalLink className="w-3.5 h-3.5" />
|
|
800
|
+
</button>
|
|
801
|
+
</Tooltip>
|
|
802
|
+
</>
|
|
803
|
+
)}
|
|
804
|
+
<Tooltip content={session.status === 'error' ? 'Dismiss' : 'Stop'} delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
805
|
+
<button
|
|
806
|
+
onClick={() => {
|
|
807
|
+
commitInteraction()
|
|
808
|
+
stopPortForward(session.id)
|
|
809
|
+
}}
|
|
810
|
+
disabled={stoppingIds.has(session.id)}
|
|
811
|
+
className="p-1.5 text-theme-text-tertiary hover:text-red-400 hover:bg-theme-hover rounded disabled:opacity-50"
|
|
812
|
+
>
|
|
813
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
814
|
+
</button>
|
|
815
|
+
</Tooltip>
|
|
816
|
+
</div>
|
|
817
|
+
</div>
|
|
844
818
|
</div>
|
|
845
819
|
))}
|
|
846
820
|
</div>
|