@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
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, useNamespaceScope, useSetActiveNamespace, useSwitchContext, useAuthMe } from './api/client'
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 namespaceSwitcherRef = useRef<NamespaceSwitcherHandle>(null)
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: () => namespaceSwitcherRef.current?.open(),
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). Push the URL choice
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="relative flex flex-col h-screen bg-theme-base min-w-[800px]">
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
- <NamespaceSwitcher
984
- ref={namespaceSwitcherRef}
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) => setActiveNamespace.mutate({ namespaces: [ns] })}
1203
+ onMaximizeNamespace={(ns) => setNamespaces([ns])}
1252
1204
  namespaceBreadcrumb={namespaces.length === 1 ? namespaces[0] : undefined}
1253
- onClearNamespace={namespaces.length >= 1 ? () => setActiveNamespace.mutate({ namespaces: [] }) : undefined}
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={(ns) => {
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(buildRecreateBody(session, { localPort: session.localPort, listenAddress: newAddress })),
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(buildRecreateBody(session, { localPort: newPort, listenAddress: session.listenAddress })),
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(sessionUrl(session))
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(sessionUrl(session))
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-[28rem] overflow-y-auto">
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 space-y-1',
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 items-start gap-2 min-w-0 flex-1">
680
- <span
681
- className={clsx(
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
- 'flex items-center justify-center p-1.5 rounded transition-colors',
704
- session.listenAddress === '0.0.0.0'
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
- </button>
717
- </Tooltip>
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
- <Tooltip content={session.status === 'error' ? 'Dismiss' : 'Stop'} delay={300} position="bottom" disabled={!isPanelOpen}>
720
- <button
721
- onClick={() => {
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
- </button>
832
- </Tooltip>
833
- <Tooltip content="Open in browser" delay={300} position="bottom" disabled={!isPanelOpen}>
834
- <button
835
- onClick={() => handleOpenUrl(session)}
836
- className="p-1 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
837
- >
838
- <ExternalLink className="w-3.5 h-3.5" />
839
- </button>
840
- </Tooltip>
841
- </div>
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>