@skyhook-io/radar-app 0.2.2 → 0.3.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 +7 -1
- package/package.json +33 -25
- package/src/App.tsx +1449 -382
- package/src/RadarApp.tsx +132 -19
- package/src/api/apiResources.ts +1 -1
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.delta.test.ts +89 -0
- package/src/api/client.deltaSync.test.ts +216 -0
- package/src/api/client.metrics.test.ts +106 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +2730 -271
- package/src/api/client.yaml.test.ts +45 -0
- package/src/api/diagnose.ts +289 -0
- package/src/api/quotas.ts +16 -0
- package/src/api/rbac.ts +57 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/components/ConnectionErrorView.tsx +186 -70
- package/src/components/ContextSwitcher.tsx +63 -18
- package/src/components/DebugOverlay.tsx +5 -3
- package/src/components/NamespaceSwitcher.tsx +41 -0
- package/src/components/UserMenu.tsx +69 -21
- package/src/components/applications/ApplicationsView.tsx +936 -0
- package/src/components/audit/AuditSettingsDialog.tsx +79 -17
- package/src/components/audit/AuditView.tsx +65 -62
- package/src/components/compare/CompareViewRoute.tsx +124 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +79 -0
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +106 -75
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +507 -223
- package/src/components/cost/CostViewTabs.test.tsx +21 -0
- package/src/components/cost/CostViewTabs.tsx +40 -0
- package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
- package/src/components/cost/CurrentAllocationUse.tsx +126 -0
- package/src/components/cost/WorkloadCostTab.test.ts +153 -0
- package/src/components/cost/WorkloadCostTab.tsx +372 -0
- package/src/components/cost/cloud-console.test.ts +39 -0
- package/src/components/cost/cloud-console.ts +81 -0
- package/src/components/cost/errors.ts +8 -0
- package/src/components/cost/format.test.ts +27 -0
- package/src/components/cost/format.ts +46 -0
- package/src/components/cost/kinds.ts +5 -0
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/diagnose/AISettings.tsx +147 -0
- package/src/components/diagnose/DiagnoseContext.tsx +495 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +622 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1756 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/dock/index.ts +1 -1
- package/src/components/execution/BatchExecutionView.test.ts +170 -0
- package/src/components/execution/BatchExecutionView.tsx +1329 -0
- package/src/components/execution/batch-run-actions.test.ts +48 -0
- package/src/components/execution/batch-run-actions.ts +24 -0
- package/src/components/execution/batch-timeline.test.ts +57 -0
- package/src/components/execution/batch-timeline.ts +46 -0
- package/src/components/execution/execution-definition.test.ts +208 -0
- package/src/components/execution/execution-definition.ts +245 -0
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +1042 -0
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +87 -31
- package/src/components/helm/HelmCompareRoute.tsx +1341 -0
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
- package/src/components/helm/HelmView.tsx +237 -96
- package/src/components/helm/InstallWizard.tsx +94 -38
- package/src/components/helm/ManifestDiffViewer.tsx +8 -27
- package/src/components/helm/OwnedResources.tsx +34 -59
- package/src/components/helm/RevisionHistory.tsx +52 -3
- package/src/components/helm/RoleGatedPanel.tsx +3 -3
- package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/helm/ValuesViewer.tsx +49 -53
- package/src/components/helm/helm-utils.ts +4 -0
- package/src/components/home/ActivitySummary.tsx +4 -1
- package/src/components/home/ClusterHealthCard.tsx +56 -42
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/GitOpsControllersCard.tsx +110 -0
- package/src/components/home/HelmSummary.tsx +3 -1
- package/src/components/home/HomeView.tsx +339 -105
- package/src/components/home/MCPSetupDialog.tsx +29 -87
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +333 -0
- package/src/components/issues/IssuesPane.tsx +151 -0
- package/src/components/logs/LogsViewer.tsx +4 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
- package/src/components/nav/PrimaryNavRail.tsx +285 -0
- package/src/components/portforward/PortForwardButton.tsx +118 -47
- package/src/components/portforward/PortForwardManager.tsx +253 -131
- package/src/components/resource/HPACharts.tsx +237 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +160 -584
- package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
- package/src/components/resource/RestartChart.tsx +133 -0
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +363 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/ImageFilesystemModal.tsx +19 -12
- package/src/components/resources/PodFilesystemModal.tsx +6 -5
- package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
- package/src/components/resources/ResourcesView.tsx +194 -17
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +20 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +30 -6
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils.ts +2 -1
- package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
- package/src/components/rightsizing/copy.test.ts +56 -0
- package/src/components/rightsizing/model.test.ts +227 -0
- package/src/components/rightsizing/model.ts +158 -0
- package/src/components/rightsizing/presentation.test.ts +104 -0
- package/src/components/rightsizing/presentation.ts +94 -0
- package/src/components/settings/MyPermissionsDialog.tsx +241 -0
- package/src/components/settings/SettingsDialog.tsx +1505 -165
- package/src/components/shared/CreateResourceDialog.tsx +9 -2
- package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +86 -13
- package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
- package/src/components/timeline/TimelineView.tsx +873 -24
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
- package/src/components/traffic/TrafficFlowList.tsx +29 -15
- package/src/components/traffic/TrafficGraph.tsx +42 -24
- package/src/components/traffic/TrafficView.tsx +32 -19
- package/src/components/ui/CommandPalette.tsx +8 -215
- package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
- package/src/components/ui/Markdown.tsx +3 -3
- package/src/components/ui/Omnibar.tsx +602 -0
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
- package/src/components/ui/UpdateNotification.tsx +48 -36
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +1342 -158
- package/src/context/ConnectionContext.tsx +146 -21
- package/src/context/DiagnoseCustomization.tsx +93 -0
- package/src/context/NavCustomization.tsx +75 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/contexts/CapabilitiesContext.tsx +32 -8
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/index.css +162 -1
- package/src/index.ts +73 -1
- package/src/main.tsx +7 -5
- package/src/types/clusterLoadState.ts +33 -0
- package/src/types.ts +2 -0
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +64 -1
- package/src/components/ui/NamespaceSelector.tsx +0 -436
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
Globe,
|
|
21
21
|
Monitor,
|
|
22
22
|
PenLine,
|
|
23
|
+
RotateCw,
|
|
23
24
|
} from 'lucide-react'
|
|
24
25
|
import { clsx } from 'clsx'
|
|
25
26
|
// CSS_EASE (the shared spring curve) is intentionally NOT used for this panel —
|
|
@@ -30,6 +31,7 @@ import { Tooltip } from '../ui/Tooltip'
|
|
|
30
31
|
import { useToast } from '../ui/Toast'
|
|
31
32
|
import { openExternal } from '../../utils/navigation'
|
|
32
33
|
import { apiUrl } from '../../api/config'
|
|
34
|
+
import { apiFetch, useCapabilities } from '../../api/client'
|
|
33
35
|
import { pluralize } from '@skyhook-io/k8s-ui'
|
|
34
36
|
|
|
35
37
|
// --- Types -------------------------------------------------------------------
|
|
@@ -42,21 +44,58 @@ interface PortForwardSession {
|
|
|
42
44
|
localPort: number
|
|
43
45
|
listenAddress: string
|
|
44
46
|
serviceName?: string
|
|
47
|
+
servicePort?: number
|
|
48
|
+
scheme?: 'http' | 'https'
|
|
45
49
|
startedAt: string
|
|
46
50
|
status: 'running' | 'stopped' | 'error'
|
|
47
51
|
error?: string
|
|
48
52
|
}
|
|
49
53
|
|
|
54
|
+
function sessionUrl(session: PortForwardSession): string {
|
|
55
|
+
return `${session.scheme || 'http'}://localhost:${session.localPort}`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function formatPortLabel(session: PortForwardSession): string {
|
|
59
|
+
if (session.servicePort && session.servicePort !== session.podPort) {
|
|
60
|
+
return `${session.servicePort} → ${session.podPort}`
|
|
61
|
+
}
|
|
62
|
+
return String(session.podPort)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Build the recreate request body for toggle-listen / change-port flows.
|
|
66
|
+
// When the original session was service-resolved, the recreate must also go
|
|
67
|
+
// through the service path (servicePort + serviceName). Sending podName+podPort
|
|
68
|
+
// would skip resolution and validate against the pod's declared containerPorts,
|
|
69
|
+
// which can fail even though the original session worked: services can route to
|
|
70
|
+
// any port the container actually listens on, regardless of containerPort
|
|
71
|
+
// declarations. Going through service also re-resolves to a currently-running
|
|
72
|
+
// pod if the original has since been replaced.
|
|
73
|
+
function buildRecreateBody(session: PortForwardSession, overrides: { localPort: number; listenAddress: string }) {
|
|
74
|
+
const base = {
|
|
75
|
+
namespace: session.namespace,
|
|
76
|
+
localPort: overrides.localPort,
|
|
77
|
+
listenAddress: overrides.listenAddress,
|
|
78
|
+
}
|
|
79
|
+
if (session.serviceName && session.servicePort) {
|
|
80
|
+
return { ...base, serviceName: session.serviceName, podPort: session.servicePort }
|
|
81
|
+
}
|
|
82
|
+
return { ...base, podName: session.podName, podPort: session.podPort }
|
|
83
|
+
}
|
|
84
|
+
|
|
50
85
|
// --- Shared query ------------------------------------------------------------
|
|
51
86
|
|
|
52
|
-
function usePortForwardQuery() {
|
|
87
|
+
function usePortForwardQuery(enabled: boolean) {
|
|
53
88
|
return useQuery<PortForwardSession[]>({
|
|
54
89
|
queryKey: ['portforwards'],
|
|
55
90
|
queryFn: async () => {
|
|
56
|
-
const res = await
|
|
91
|
+
const res = await apiFetch(apiUrl('/portforwards'))
|
|
57
92
|
if (!res.ok) throw new Error('Failed to fetch port forwards')
|
|
58
93
|
return res.json()
|
|
59
94
|
},
|
|
95
|
+
// Port-forward is a local-binary feature; in-cluster (Radar Cloud) the
|
|
96
|
+
// capability is false, so don't poll an endpoint that can never return a
|
|
97
|
+
// usable session. Also covers RBAC-denied users.
|
|
98
|
+
enabled,
|
|
60
99
|
// 30s fallback poll — user mutations invalidate immediately, but out-of-band
|
|
61
100
|
// session death (pod restart, OOM kill, server-side cleanup) only surfaces on
|
|
62
101
|
// the next tick.
|
|
@@ -109,12 +148,21 @@ interface PortForwardContextValue {
|
|
|
109
148
|
const PortForwardContext = createContext<PortForwardContextValue | null>(null)
|
|
110
149
|
|
|
111
150
|
export function PortForwardProvider({ children }: { children: ReactNode }) {
|
|
151
|
+
// Gate the session-list poll on runtime mode, not the RBAC capability: port-forward
|
|
152
|
+
// only works when radar runs as a local binary, so in-cluster (Radar Cloud) we never
|
|
153
|
+
// poll /portforwards. We deliberately do NOT gate on `portForward` (RBAC) — a local
|
|
154
|
+
// user with portforward rights in only some namespaces must still see/stop the
|
|
155
|
+
// sessions they start (the start buttons gate per-namespace separately). Using the
|
|
156
|
+
// resolved value (undefined while capabilities load → no poll) keeps Cloud silent on
|
|
157
|
+
// first paint.
|
|
158
|
+
const { data: caps } = useCapabilities()
|
|
159
|
+
const canPortForward = caps?.deployment?.mode === 'local'
|
|
112
160
|
const {
|
|
113
161
|
data: sessions = [],
|
|
114
162
|
isLoading,
|
|
115
163
|
isError: isQueryError,
|
|
116
164
|
error: queryError,
|
|
117
|
-
} = usePortForwardQuery()
|
|
165
|
+
} = usePortForwardQuery(canPortForward)
|
|
118
166
|
const activeSessions = sessions.filter((s) => s.status !== 'stopped')
|
|
119
167
|
const errorSessions = sessions.filter((s) => s.status === 'error')
|
|
120
168
|
const count = activeSessions.length
|
|
@@ -139,10 +187,25 @@ export function PortForwardProvider({ children }: { children: ReactNode }) {
|
|
|
139
187
|
const measureAnchor = useCallback(() => {
|
|
140
188
|
if (!indicatorRef.current) return
|
|
141
189
|
const rect = indicatorRef.current.getBoundingClientRect()
|
|
190
|
+
// Align panel right edge with indicator right edge, but clamp so the
|
|
191
|
+
// panel's left edge never runs off the viewport's left edge — happens on
|
|
192
|
+
// narrow windows / split-screens where the indicator is closer to the
|
|
193
|
+
// left edge than the panel is wide. PANEL_WIDTH must match the panel's
|
|
194
|
+
// Tailwind w-80 (20rem = 320px).
|
|
195
|
+
const PANEL_WIDTH = 320
|
|
196
|
+
const MARGIN = 8
|
|
197
|
+
const desiredRight = Math.max(MARGIN, window.innerWidth - rect.right)
|
|
198
|
+
const maxRight = Math.max(MARGIN, window.innerWidth - PANEL_WIDTH - MARGIN)
|
|
199
|
+
const right = Math.min(desiredRight, maxRight)
|
|
200
|
+
// Keep the caret pointing at the indicator's horizontal center even after
|
|
201
|
+
// the panel has been clamped away from the indicator.
|
|
202
|
+
const panelRightX = window.innerWidth - right
|
|
203
|
+
const indicatorCenterX = rect.right - rect.width / 2
|
|
204
|
+
const caretRight = panelRightX - indicatorCenterX - 6
|
|
142
205
|
setAnchor({
|
|
143
206
|
top: rect.bottom + 10,
|
|
144
|
-
right
|
|
145
|
-
caretRight
|
|
207
|
+
right,
|
|
208
|
+
caretRight,
|
|
146
209
|
})
|
|
147
210
|
}, [])
|
|
148
211
|
|
|
@@ -342,6 +405,9 @@ export function PortForwardPanel() {
|
|
|
342
405
|
// without disabling all stop buttons (the old shared-mutation approach blocked
|
|
343
406
|
// every row when any single stop was in-flight).
|
|
344
407
|
const [stoppingIds, setStoppingIds] = useState<Set<string>>(() => new Set())
|
|
408
|
+
// Per-session retry tracking — same rationale as stoppingIds: multiple failed
|
|
409
|
+
// forwards can be retried independently without disabling every retry button.
|
|
410
|
+
const [retryingIds, setRetryingIds] = useState<Set<string>>(() => new Set())
|
|
345
411
|
const queryClient = useQueryClient()
|
|
346
412
|
const { showSuccess, showError } = useToast()
|
|
347
413
|
|
|
@@ -376,7 +442,7 @@ export function PortForwardPanel() {
|
|
|
376
442
|
const stopPortForward = useCallback(async (id: string) => {
|
|
377
443
|
setStoppingIds(prev => new Set(prev).add(id))
|
|
378
444
|
try {
|
|
379
|
-
const res = await
|
|
445
|
+
const res = await apiFetch(apiUrl(`/portforwards/${id}`), { method: 'DELETE' })
|
|
380
446
|
if (!res.ok) {
|
|
381
447
|
const body = await res.json().catch(() => ({}))
|
|
382
448
|
throw new Error(body.error || `Failed to stop port forward (HTTP ${res.status})`)
|
|
@@ -396,6 +462,48 @@ export function PortForwardPanel() {
|
|
|
396
462
|
}
|
|
397
463
|
}, [queryClient, showError])
|
|
398
464
|
|
|
465
|
+
// Recreate a failed forward. The errored session is already dead — there's no live
|
|
466
|
+
// forward to lose — so we drop the stale row FIRST, then recreate. Delete-first keeps
|
|
467
|
+
// the panel at exactly one row in every outcome (success → one running row; failure →
|
|
468
|
+
// one errored row), avoiding the orphaned-duplicate the reverse order would leave when
|
|
469
|
+
// the backend keeps a failed-start session in its map. A 404 means it was already
|
|
470
|
+
// cleared (e.g. context switch) — benign, proceed. Service-resolved sessions re-route
|
|
471
|
+
// through the service path via buildRecreateBody, so a retry after the backing pod was
|
|
472
|
+
// replaced re-resolves to a currently-running pod.
|
|
473
|
+
const retryPortForward = useCallback(async (session: PortForwardSession) => {
|
|
474
|
+
commitInteraction()
|
|
475
|
+
setRetryingIds(prev => new Set(prev).add(session.id))
|
|
476
|
+
try {
|
|
477
|
+
const delRes = await apiFetch(apiUrl(`/portforwards/${session.id}`), { method: 'DELETE' })
|
|
478
|
+
if (!delRes.ok && delRes.status !== 404) {
|
|
479
|
+
const body = await delRes.json().catch(() => ({}))
|
|
480
|
+
throw new Error(body.error || `Failed to clear failed port forward (HTTP ${delRes.status})`)
|
|
481
|
+
}
|
|
482
|
+
const res = await apiFetch(apiUrl('/portforwards'), {
|
|
483
|
+
method: 'POST',
|
|
484
|
+
headers: { 'Content-Type': 'application/json' },
|
|
485
|
+
body: JSON.stringify(buildRecreateBody(session, { localPort: session.localPort, listenAddress: session.listenAddress })),
|
|
486
|
+
})
|
|
487
|
+
if (!res.ok) {
|
|
488
|
+
const body = await res.json().catch(() => ({}))
|
|
489
|
+
throw new Error(body.error || `Failed to retry port forward (HTTP ${res.status})`)
|
|
490
|
+
}
|
|
491
|
+
queryClient.invalidateQueries({ queryKey: ['portforwards'] })
|
|
492
|
+
showSuccess('Port forward restarted', `Now listening on localhost:${session.localPort}`)
|
|
493
|
+
} catch (err) {
|
|
494
|
+
queryClient.invalidateQueries({ queryKey: ['portforwards'] })
|
|
495
|
+
const msg = err instanceof Error ? err.message : 'Failed to retry port forward'
|
|
496
|
+
showError('Failed to retry port forward', msg)
|
|
497
|
+
console.error('Failed to retry port forward:', err)
|
|
498
|
+
} finally {
|
|
499
|
+
setRetryingIds(prev => {
|
|
500
|
+
const next = new Set(prev)
|
|
501
|
+
next.delete(session.id)
|
|
502
|
+
return next
|
|
503
|
+
})
|
|
504
|
+
}
|
|
505
|
+
}, [commitInteraction, queryClient, showSuccess, showError])
|
|
506
|
+
|
|
399
507
|
const toggleListenAddress = async (session: PortForwardSession) => {
|
|
400
508
|
commitInteraction()
|
|
401
509
|
const newAddress = session.listenAddress === '0.0.0.0' ? '127.0.0.1' : '0.0.0.0'
|
|
@@ -406,23 +514,16 @@ export function PortForwardPanel() {
|
|
|
406
514
|
// apart from "original gone and recreate failed = data loss."
|
|
407
515
|
let deleted = false
|
|
408
516
|
try {
|
|
409
|
-
const delRes = await
|
|
517
|
+
const delRes = await apiFetch(apiUrl(`/portforwards/${session.id}`), { method: 'DELETE' })
|
|
410
518
|
if (!delRes.ok) {
|
|
411
519
|
const body = await delRes.json().catch(() => ({}))
|
|
412
520
|
throw new Error(body.error || `Failed to stop existing port forward (HTTP ${delRes.status})`)
|
|
413
521
|
}
|
|
414
522
|
deleted = true
|
|
415
|
-
const res = await
|
|
523
|
+
const res = await apiFetch(apiUrl('/portforwards'), {
|
|
416
524
|
method: 'POST',
|
|
417
525
|
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
|
-
}),
|
|
526
|
+
body: JSON.stringify(buildRecreateBody(session, { localPort: session.localPort, listenAddress: newAddress })),
|
|
426
527
|
})
|
|
427
528
|
if (!res.ok) {
|
|
428
529
|
const error = await res.json().catch(() => ({}))
|
|
@@ -460,23 +561,16 @@ export function PortForwardPanel() {
|
|
|
460
561
|
// apart from "original gone and recreate failed = data loss."
|
|
461
562
|
let deleted = false
|
|
462
563
|
try {
|
|
463
|
-
const delRes = await
|
|
564
|
+
const delRes = await apiFetch(apiUrl(`/portforwards/${session.id}`), { method: 'DELETE' })
|
|
464
565
|
if (!delRes.ok) {
|
|
465
566
|
const body = await delRes.json().catch(() => ({}))
|
|
466
567
|
throw new Error(body.error || `Failed to stop existing port forward (HTTP ${delRes.status})`)
|
|
467
568
|
}
|
|
468
569
|
deleted = true
|
|
469
|
-
const res = await
|
|
570
|
+
const res = await apiFetch(apiUrl('/portforwards'), {
|
|
470
571
|
method: 'POST',
|
|
471
572
|
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
|
-
}),
|
|
573
|
+
body: JSON.stringify(buildRecreateBody(session, { localPort: newPort, listenAddress: session.listenAddress })),
|
|
480
574
|
})
|
|
481
575
|
if (!res.ok) {
|
|
482
576
|
const error = await res.json().catch(() => ({}))
|
|
@@ -506,7 +600,7 @@ export function PortForwardPanel() {
|
|
|
506
600
|
async (session: PortForwardSession) => {
|
|
507
601
|
commitInteraction()
|
|
508
602
|
try {
|
|
509
|
-
await navigator.clipboard.writeText(
|
|
603
|
+
await navigator.clipboard.writeText(sessionUrl(session))
|
|
510
604
|
} catch (err) {
|
|
511
605
|
// Clipboard API can reject in non-secure contexts, denied permissions, or
|
|
512
606
|
// when the document isn't focused. Surface the failure — the checkmark
|
|
@@ -529,7 +623,7 @@ export function PortForwardPanel() {
|
|
|
529
623
|
const handleOpenUrl = useCallback(
|
|
530
624
|
(session: PortForwardSession) => {
|
|
531
625
|
commitInteraction()
|
|
532
|
-
openExternal(
|
|
626
|
+
openExternal(sessionUrl(session))
|
|
533
627
|
},
|
|
534
628
|
[commitInteraction]
|
|
535
629
|
)
|
|
@@ -550,7 +644,7 @@ export function PortForwardPanel() {
|
|
|
550
644
|
onMouseEnter={onPanelHoverEnter}
|
|
551
645
|
onMouseLeave={onPanelHoverLeave}
|
|
552
646
|
className={clsx(
|
|
553
|
-
'fixed z-[51] w-
|
|
647
|
+
'fixed z-[51] w-[26rem] max-w-[calc(100vw-2rem)]',
|
|
554
648
|
'transition-opacity duration-150 ease-out',
|
|
555
649
|
isPanelOpen
|
|
556
650
|
? 'opacity-100 pointer-events-auto'
|
|
@@ -611,7 +705,7 @@ export function PortForwardPanel() {
|
|
|
611
705
|
</div>
|
|
612
706
|
|
|
613
707
|
{/* Sessions list */}
|
|
614
|
-
<div className="max-h-
|
|
708
|
+
<div className="max-h-[28rem] overflow-y-auto">
|
|
615
709
|
{isQueryError ? (
|
|
616
710
|
<div className="p-3 text-xs bg-red-500/10 border-b border-theme-border">
|
|
617
711
|
<div className={clsx('badge-sm mb-1 inline-block', SEVERITY_BADGE.error)}>
|
|
@@ -632,41 +726,107 @@ export function PortForwardPanel() {
|
|
|
632
726
|
</div>
|
|
633
727
|
) : (
|
|
634
728
|
<div className="divide-y divide-theme-border">
|
|
635
|
-
{activeSessions.map((session) =>
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
729
|
+
{activeSessions.map((session) => {
|
|
730
|
+
const resourceName = session.serviceName || session.podName
|
|
731
|
+
return (
|
|
732
|
+
<div
|
|
733
|
+
key={session.id}
|
|
734
|
+
className={clsx(
|
|
735
|
+
'p-3 space-y-1',
|
|
736
|
+
session.status === 'error' ? 'bg-red-500/10' : 'hover:bg-theme-elevated'
|
|
737
|
+
)}
|
|
738
|
+
>
|
|
739
|
+
{/* Row 1: status dot + name | stop button */}
|
|
643
740
|
<div className="flex items-start justify-between gap-2">
|
|
644
|
-
<div className="flex-
|
|
645
|
-
<
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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>
|
|
741
|
+
<div className="flex items-start gap-2 min-w-0 flex-1">
|
|
742
|
+
<span
|
|
743
|
+
className={clsx(
|
|
744
|
+
'w-2 h-2 rounded-full shrink-0 mt-[7px]',
|
|
745
|
+
session.status === 'running' ? 'bg-green-500' : 'bg-red-500'
|
|
657
746
|
)}
|
|
658
|
-
|
|
659
|
-
<
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
</
|
|
747
|
+
/>
|
|
748
|
+
<Tooltip content={resourceName} delay={300} position="bottom" disabled={!isPanelOpen} wrapperClassName="min-w-0 flex-1">
|
|
749
|
+
<span className="block min-w-0 truncate text-sm font-medium text-theme-text-primary">
|
|
750
|
+
{resourceName}
|
|
751
|
+
</span>
|
|
752
|
+
</Tooltip>
|
|
753
|
+
{session.status === 'error' && (
|
|
754
|
+
<span className={clsx('badge-sm shrink-0', SEVERITY_BADGE.error)}>Failed</span>
|
|
666
755
|
)}
|
|
756
|
+
</div>
|
|
757
|
+
<div className="flex items-center gap-0.5 shrink-0">
|
|
667
758
|
{session.status === 'running' && (
|
|
668
|
-
<
|
|
669
|
-
{
|
|
759
|
+
<Tooltip
|
|
760
|
+
content={session.listenAddress === '0.0.0.0' ? 'Switch to localhost only' : 'Allow access from other machines'}
|
|
761
|
+
delay={300} position="bottom" disabled={!isPanelOpen}
|
|
762
|
+
>
|
|
763
|
+
<button
|
|
764
|
+
onClick={() => toggleListenAddress(session)}
|
|
765
|
+
disabled={togglingId === session.id || changingPortId === session.id}
|
|
766
|
+
className={clsx(
|
|
767
|
+
'flex items-center justify-center p-1.5 rounded transition-colors',
|
|
768
|
+
session.listenAddress === '0.0.0.0'
|
|
769
|
+
? `${SEVERITY_BADGE.warning} hover:bg-amber-500/30`
|
|
770
|
+
: 'text-theme-text-disabled hover:text-theme-text-primary hover:bg-theme-hover'
|
|
771
|
+
)}
|
|
772
|
+
>
|
|
773
|
+
{togglingId === session.id ? (
|
|
774
|
+
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
775
|
+
) : session.listenAddress === '0.0.0.0' ? (
|
|
776
|
+
<Globe className="w-3.5 h-3.5" />
|
|
777
|
+
) : (
|
|
778
|
+
<Monitor className="w-3.5 h-3.5" />
|
|
779
|
+
)}
|
|
780
|
+
</button>
|
|
781
|
+
</Tooltip>
|
|
782
|
+
)}
|
|
783
|
+
{session.status === 'error' && (
|
|
784
|
+
<Tooltip content="Retry" delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
785
|
+
<button
|
|
786
|
+
onClick={() => retryPortForward(session)}
|
|
787
|
+
disabled={retryingIds.has(session.id) || stoppingIds.has(session.id)}
|
|
788
|
+
className="p-1.5 text-theme-text-tertiary hover:text-green-400 hover:bg-theme-hover rounded disabled:opacity-50"
|
|
789
|
+
>
|
|
790
|
+
{retryingIds.has(session.id) ? (
|
|
791
|
+
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
792
|
+
) : (
|
|
793
|
+
<RotateCw className="w-3.5 h-3.5" />
|
|
794
|
+
)}
|
|
795
|
+
</button>
|
|
796
|
+
</Tooltip>
|
|
797
|
+
)}
|
|
798
|
+
<Tooltip content={session.status === 'error' ? 'Dismiss' : 'Stop'} delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
799
|
+
<button
|
|
800
|
+
onClick={() => {
|
|
801
|
+
commitInteraction()
|
|
802
|
+
stopPortForward(session.id)
|
|
803
|
+
}}
|
|
804
|
+
disabled={stoppingIds.has(session.id) || retryingIds.has(session.id)}
|
|
805
|
+
className="p-1.5 text-theme-text-tertiary hover:text-red-400 hover:bg-theme-hover rounded disabled:opacity-50"
|
|
806
|
+
>
|
|
807
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
808
|
+
</button>
|
|
809
|
+
</Tooltip>
|
|
810
|
+
</div>
|
|
811
|
+
</div>
|
|
812
|
+
|
|
813
|
+
{/* Row 2: namespace · port translation */}
|
|
814
|
+
<div className="text-xs text-theme-text-disabled">
|
|
815
|
+
{session.namespace} · {formatPortLabel(session)}
|
|
816
|
+
</div>
|
|
817
|
+
|
|
818
|
+
{/* Row 2.5: error message */}
|
|
819
|
+
{session.status === 'error' && session.error && (
|
|
820
|
+
<div className="text-xs text-red-400 bg-red-500/10 px-2 py-1 rounded">
|
|
821
|
+
{session.error}
|
|
822
|
+
</div>
|
|
823
|
+
)}
|
|
824
|
+
|
|
825
|
+
{/* Row 3: URL (+ optional toggle) | copy + open */}
|
|
826
|
+
{session.status === 'running' && (
|
|
827
|
+
<div className="pt-0.5 flex items-center justify-between gap-2">
|
|
828
|
+
<div className="flex items-center gap-1.5 min-w-0">
|
|
829
|
+
{editingPortId === session.id ? (
|
|
670
830
|
<div className="flex items-center text-xs bg-theme-base rounded text-accent-text font-mono">
|
|
671
831
|
<span className="pl-2 py-1 text-theme-text-disabled select-none">
|
|
672
832
|
{session.listenAddress === '0.0.0.0' ? '0.0.0.0' : 'localhost'}:
|
|
@@ -709,10 +869,11 @@ export function PortForwardPanel() {
|
|
|
709
869
|
/>
|
|
710
870
|
</div>
|
|
711
871
|
) : (
|
|
872
|
+
<>
|
|
712
873
|
<Tooltip content="Click to change local port" delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
713
874
|
<code
|
|
714
875
|
className={clsx(
|
|
715
|
-
'group/port text-xs
|
|
876
|
+
'inline-code group/port text-xs transition-all inline-flex items-center gap-1',
|
|
716
877
|
changingPortId === session.id
|
|
717
878
|
? 'opacity-50'
|
|
718
879
|
: 'cursor-pointer hover:ring-1 hover:ring-blue-500/50'
|
|
@@ -732,76 +893,36 @@ export function PortForwardPanel() {
|
|
|
732
893
|
<PenLine className="w-3 h-3 text-theme-text-disabled opacity-0 group-hover/port:opacity-100 transition-opacity" />
|
|
733
894
|
</code>
|
|
734
895
|
</Tooltip>
|
|
896
|
+
</>
|
|
897
|
+
)}
|
|
898
|
+
</div>
|
|
899
|
+
<div className="flex items-center gap-0.5 shrink-0">
|
|
900
|
+
<Tooltip content={copiedId === session.id ? 'Copied!' : 'Copy URL'} delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
901
|
+
<button
|
|
902
|
+
onClick={() => handleCopyUrl(session)}
|
|
903
|
+
className="p-1 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
|
|
904
|
+
>
|
|
905
|
+
{copiedId === session.id ? (
|
|
906
|
+
<Check className="w-3.5 h-3.5 text-green-400" />
|
|
907
|
+
) : (
|
|
908
|
+
<Copy className="w-3.5 h-3.5" />
|
|
735
909
|
)}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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>
|
|
910
|
+
</button>
|
|
911
|
+
</Tooltip>
|
|
912
|
+
<Tooltip content="Open in browser" delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
913
|
+
<button
|
|
914
|
+
onClick={() => handleOpenUrl(session)}
|
|
915
|
+
className="p-1 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
|
|
916
|
+
>
|
|
917
|
+
<ExternalLink className="w-3.5 h-3.5" />
|
|
918
|
+
</button>
|
|
919
|
+
</Tooltip>
|
|
920
|
+
</div>
|
|
801
921
|
</div>
|
|
922
|
+
)}
|
|
802
923
|
</div>
|
|
803
|
-
|
|
804
|
-
)
|
|
924
|
+
)
|
|
925
|
+
})}
|
|
805
926
|
</div>
|
|
806
927
|
)}
|
|
807
928
|
</div>
|
|
@@ -841,7 +962,7 @@ export function useStartPortForward() {
|
|
|
841
962
|
localPort?: number
|
|
842
963
|
listenAddress?: string // "127.0.0.1" (default) or "0.0.0.0"
|
|
843
964
|
}) => {
|
|
844
|
-
const res = await
|
|
965
|
+
const res = await apiFetch(apiUrl('/portforwards'), {
|
|
845
966
|
method: 'POST',
|
|
846
967
|
headers: { 'Content-Type': 'application/json' },
|
|
847
968
|
body: JSON.stringify(req),
|
|
@@ -867,6 +988,7 @@ export function useStartPortForward() {
|
|
|
867
988
|
|
|
868
989
|
// Backwards-compat: existing consumers that just want a count number.
|
|
869
990
|
export function usePortForwardCount() {
|
|
870
|
-
const { data:
|
|
991
|
+
const { data: caps } = useCapabilities()
|
|
992
|
+
const { data: sessions = [] } = usePortForwardQuery(caps?.deployment?.mode === 'local')
|
|
871
993
|
return sessions.filter((s) => s.status !== 'stopped').length
|
|
872
994
|
}
|