@skyhook-io/radar-app 1.0.0 → 1.0.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.
@@ -42,11 +42,44 @@ interface PortForwardSession {
42
42
  localPort: number
43
43
  listenAddress: string
44
44
  serviceName?: string
45
+ servicePort?: number
46
+ scheme?: 'http' | 'https'
45
47
  startedAt: string
46
48
  status: 'running' | 'stopped' | 'error'
47
49
  error?: string
48
50
  }
49
51
 
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
+
50
83
  // --- Shared query ------------------------------------------------------------
51
84
 
52
85
  function usePortForwardQuery() {
@@ -139,10 +172,25 @@ export function PortForwardProvider({ children }: { children: ReactNode }) {
139
172
  const measureAnchor = useCallback(() => {
140
173
  if (!indicatorRef.current) return
141
174
  const rect = indicatorRef.current.getBoundingClientRect()
175
+ // Align panel right edge with indicator right edge, but clamp so the
176
+ // panel's left edge never runs off the viewport's left edge — happens on
177
+ // narrow windows / split-screens where the indicator is closer to the
178
+ // left edge than the panel is wide. PANEL_WIDTH must match the panel's
179
+ // Tailwind w-80 (20rem = 320px).
180
+ const PANEL_WIDTH = 320
181
+ const MARGIN = 8
182
+ const desiredRight = Math.max(MARGIN, window.innerWidth - rect.right)
183
+ const maxRight = Math.max(MARGIN, window.innerWidth - PANEL_WIDTH - MARGIN)
184
+ const right = Math.min(desiredRight, maxRight)
185
+ // Keep the caret pointing at the indicator's horizontal center even after
186
+ // the panel has been clamped away from the indicator.
187
+ const panelRightX = window.innerWidth - right
188
+ const indicatorCenterX = rect.right - rect.width / 2
189
+ const caretRight = panelRightX - indicatorCenterX - 6
142
190
  setAnchor({
143
191
  top: rect.bottom + 10,
144
- right: Math.max(16, window.innerWidth - rect.right),
145
- caretRight: rect.width / 2 - 6,
192
+ right,
193
+ caretRight,
146
194
  })
147
195
  }, [])
148
196
 
@@ -415,14 +463,7 @@ export function PortForwardPanel() {
415
463
  const res = await fetch(apiUrl('/portforwards'), {
416
464
  method: 'POST',
417
465
  headers: { 'Content-Type': 'application/json' },
418
- body: JSON.stringify({
419
- namespace: session.namespace,
420
- podName: session.podName || undefined,
421
- serviceName: session.serviceName || undefined,
422
- podPort: session.podPort,
423
- localPort: session.localPort,
424
- listenAddress: newAddress,
425
- }),
466
+ body: JSON.stringify(buildRecreateBody(session, { localPort: session.localPort, listenAddress: newAddress })),
426
467
  })
427
468
  if (!res.ok) {
428
469
  const error = await res.json().catch(() => ({}))
@@ -469,14 +510,7 @@ export function PortForwardPanel() {
469
510
  const res = await fetch(apiUrl('/portforwards'), {
470
511
  method: 'POST',
471
512
  headers: { 'Content-Type': 'application/json' },
472
- body: JSON.stringify({
473
- namespace: session.namespace,
474
- podName: session.podName || undefined,
475
- serviceName: session.serviceName || undefined,
476
- podPort: session.podPort,
477
- localPort: newPort,
478
- listenAddress: session.listenAddress,
479
- }),
513
+ body: JSON.stringify(buildRecreateBody(session, { localPort: newPort, listenAddress: session.listenAddress })),
480
514
  })
481
515
  if (!res.ok) {
482
516
  const error = await res.json().catch(() => ({}))
@@ -506,7 +540,7 @@ export function PortForwardPanel() {
506
540
  async (session: PortForwardSession) => {
507
541
  commitInteraction()
508
542
  try {
509
- await navigator.clipboard.writeText(`http://localhost:${session.localPort}`)
543
+ await navigator.clipboard.writeText(sessionUrl(session))
510
544
  } catch (err) {
511
545
  // Clipboard API can reject in non-secure contexts, denied permissions, or
512
546
  // when the document isn't focused. Surface the failure — the checkmark
@@ -529,7 +563,7 @@ export function PortForwardPanel() {
529
563
  const handleOpenUrl = useCallback(
530
564
  (session: PortForwardSession) => {
531
565
  commitInteraction()
532
- openExternal(`http://localhost:${session.localPort}`)
566
+ openExternal(sessionUrl(session))
533
567
  },
534
568
  [commitInteraction]
535
569
  )
@@ -611,7 +645,7 @@ export function PortForwardPanel() {
611
645
  </div>
612
646
 
613
647
  {/* Sessions list */}
614
- <div className="max-h-64 overflow-y-auto">
648
+ <div className="max-h-[28rem] overflow-y-auto">
615
649
  {isQueryError ? (
616
650
  <div className="p-3 text-xs bg-red-500/10 border-b border-theme-border">
617
651
  <div className={clsx('badge-sm mb-1 inline-block', SEVERITY_BADGE.error)}>
@@ -636,37 +670,84 @@ export function PortForwardPanel() {
636
670
  <div
637
671
  key={session.id}
638
672
  className={clsx(
639
- 'p-3',
673
+ 'p-3 space-y-1',
640
674
  session.status === 'error' ? 'bg-red-500/10' : 'hover:bg-theme-elevated'
641
675
  )}
642
676
  >
677
+ {/* Row 1: status dot + name | stop button */}
643
678
  <div className="flex items-start justify-between gap-2">
644
- <div className="flex-1 min-w-0">
645
- <div className="flex items-center gap-2">
646
- <span
647
- className={clsx(
648
- 'w-2 h-2 rounded-full shrink-0',
649
- session.status === 'running' ? 'bg-green-500' : 'bg-red-500'
650
- )}
651
- />
652
- <span className="text-sm text-theme-text-primary font-medium truncate">
653
- {session.serviceName || session.podName}
654
- </span>
655
- {session.status === 'error' && (
656
- <span className={clsx('badge-sm', SEVERITY_BADGE.error)}>Failed</span>
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'
657
684
  )}
658
- </div>
659
- <div className="mt-1 text-xs text-theme-text-disabled">
660
- {session.namespace} · Port {session.podPort}
661
- </div>
662
- {session.status === 'error' && session.error && (
663
- <div className="mt-1.5 text-xs text-red-400 bg-red-500/10 px-2 py-1 rounded">
664
- {session.error}
665
- </div>
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>
666
691
  )}
692
+ </div>
693
+ <div className="flex items-center gap-0.5 shrink-0">
667
694
  {session.status === 'running' && (
668
- <div className="mt-1.5 flex items-center gap-2">
669
- {editingPortId === session.id ? (
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}
702
+ 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" />
715
+ )}
716
+ </button>
717
+ </Tooltip>
718
+ )}
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 ? (
670
751
  <div className="flex items-center text-xs bg-theme-base rounded text-accent-text font-mono">
671
752
  <span className="pl-2 py-1 text-theme-text-disabled select-none">
672
753
  {session.listenAddress === '0.0.0.0' ? '0.0.0.0' : 'localhost'}:
@@ -709,6 +790,7 @@ export function PortForwardPanel() {
709
790
  />
710
791
  </div>
711
792
  ) : (
793
+ <>
712
794
  <Tooltip content="Click to change local port" delay={300} position="bottom" disabled={!isPanelOpen}>
713
795
  <code
714
796
  className={clsx(
@@ -732,74 +814,33 @@ export function PortForwardPanel() {
732
814
  <PenLine className="w-3 h-3 text-theme-text-disabled opacity-0 group-hover/port:opacity-100 transition-opacity" />
733
815
  </code>
734
816
  </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" />
735
830
  )}
736
- <Tooltip
737
- content={session.listenAddress === '0.0.0.0' ? 'Switch to localhost only' : 'Allow access from other machines'}
738
- delay={300} position="bottom" disabled={!isPanelOpen}
739
- >
740
- <button
741
- onClick={() => toggleListenAddress(session)}
742
- disabled={togglingId === session.id || changingPortId === session.id}
743
- className={clsx(
744
- 'flex items-center gap-1 px-1.5 py-0.5 text-xs rounded transition-colors',
745
- session.listenAddress === '0.0.0.0'
746
- ? `${SEVERITY_BADGE.warning} hover:bg-amber-500/30`
747
- : 'bg-theme-elevated text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary'
748
- )}
749
- >
750
- {togglingId === session.id ? (
751
- <Loader2 className="w-3 h-3 animate-spin" />
752
- ) : session.listenAddress === '0.0.0.0' ? (
753
- <Globe className="w-3 h-3" />
754
- ) : (
755
- <Monitor className="w-3 h-3" />
756
- )}
757
- {session.listenAddress === '0.0.0.0' ? 'network' : 'local'}
758
- </button>
759
- </Tooltip>
760
- </div>
761
- )}
762
- </div>
763
-
764
- <div className="flex items-center gap-1 shrink-0">
765
- {session.status === 'running' && (
766
- <>
767
- <Tooltip content={copiedId === session.id ? 'Copied!' : 'Copy URL'} delay={300} position="bottom" disabled={!isPanelOpen}>
768
- <button
769
- onClick={() => handleCopyUrl(session)}
770
- className="p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
771
- >
772
- {copiedId === session.id ? (
773
- <Check className="w-3.5 h-3.5 text-green-400" />
774
- ) : (
775
- <Copy className="w-3.5 h-3.5" />
776
- )}
777
- </button>
778
- </Tooltip>
779
- <Tooltip content="Open in browser" delay={300} position="bottom" disabled={!isPanelOpen}>
780
- <button
781
- onClick={() => handleOpenUrl(session)}
782
- className="p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
783
- >
784
- <ExternalLink className="w-3.5 h-3.5" />
785
- </button>
786
- </Tooltip>
787
- </>
788
- )}
789
- <Tooltip content={session.status === 'error' ? 'Dismiss' : 'Stop'} delay={300} position="bottom" disabled={!isPanelOpen}>
790
- <button
791
- onClick={() => {
792
- commitInteraction()
793
- stopPortForward(session.id)
794
- }}
795
- disabled={stoppingIds.has(session.id)}
796
- className="p-1.5 text-theme-text-tertiary hover:text-red-400 hover:bg-theme-hover rounded disabled:opacity-50"
797
- >
798
- <Trash2 className="w-3.5 h-3.5" />
799
- </button>
800
- </Tooltip>
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>
801
842
  </div>
802
- </div>
843
+ )}
803
844
  </div>
804
845
  ))}
805
846
  </div>
@@ -13,7 +13,7 @@ import {
13
13
  } from '@skyhook-io/k8s-ui'
14
14
  import type { ResourceQueryResult } from '@skyhook-io/k8s-ui'
15
15
  import type { SelectedResource } from '../../types'
16
- import type { NavigateToResource } from '../../utils/navigation'
16
+ import { kindToPlural, type NavigateToResource } from '../../utils/navigation'
17
17
  import { CreateResourceDialog } from '../shared/CreateResourceDialog'
18
18
  import { getSkeletonYaml } from '../../utils/skeleton-yaml'
19
19
 
@@ -204,7 +204,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
204
204
  initialYaml={createDialogYaml}
205
205
  title={createDialogTitle}
206
206
  onCreated={(result) => {
207
- onResourceClick?.({ kind: result.kind, namespace: result.namespace, name: result.name, group: '' })
207
+ onResourceClick?.({ kind: kindToPlural(result.kind), namespace: result.namespace, name: result.name, group: '' })
208
208
  }}
209
209
  />
210
210
  </>
@@ -8,7 +8,7 @@ import {
8
8
  type RendererOverrides,
9
9
  } from '@skyhook-io/k8s-ui'
10
10
  import type { SelectedResource, ResourceRef, ResolvedEnvFrom } from '../../types'
11
- import type { NavigateToResource } from '../../utils/navigation'
11
+ import { kindToPlural, type NavigateToResource } from '../../utils/navigation'
12
12
  import {
13
13
  useChanges, useResourceWithRelationships, usePodLogs, useTopology, useUpdateResource,
14
14
  useDeleteResource, useTriggerCronJob, useSuspendCronJob, useResumeCronJob,
@@ -383,7 +383,7 @@ export function WorkloadView({
383
383
  initialYaml={duplicateYaml}
384
384
  title="Duplicate Resource"
385
385
  onCreated={(result) => {
386
- rest.onNavigateToResource?.({ kind: result.kind, namespace: result.namespace, name: result.name, group: '' })
386
+ rest.onNavigateToResource?.({ kind: kindToPlural(result.kind), namespace: result.namespace, name: result.name, group: '' })
387
387
  }}
388
388
  />
389
389
  </>