diff --git a/apps/desktop/src/lib/trpc/routers/ports/ports.ts b/apps/desktop/src/lib/trpc/routers/ports/ports.ts index 43684314c96..d14c822c991 100644 --- a/apps/desktop/src/lib/trpc/routers/ports/ports.ts +++ b/apps/desktop/src/lib/trpc/routers/ports/ports.ts @@ -43,6 +43,17 @@ export const createPortsRouter = () => { }); }), + kill: publicProcedure + .input( + z.object({ + paneId: z.string(), + port: z.number().int().positive(), + }), + ) + .mutation(({ input }): { success: boolean; error?: string } => { + return portManager.killPort(input); + }), + hasStaticConfig: publicProcedure .input(z.object({ workspaceId: z.string() })) .query(({ input }): { hasStatic: boolean } => { diff --git a/apps/desktop/src/main/lib/terminal/port-manager.ts b/apps/desktop/src/main/lib/terminal/port-manager.ts index bdd0fd5cac1..5267a7a98f2 100644 --- a/apps/desktop/src/main/lib/terminal/port-manager.ts +++ b/apps/desktop/src/main/lib/terminal/port-manager.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "node:events"; +import process from "node:process"; import type { DetectedPort } from "shared/types"; import { getListeningPortsForPids, getProcessTree } from "./port-scanner"; import type { TerminalSession } from "./types"; @@ -399,6 +400,48 @@ class PortManager extends EventEmitter { async forceScan(): Promise { await this.scanAllSessions(); } + + /** + * Safely kill a process listening on a tracked port. + * Only kills if: + * - The port is tracked by us + * - The PID is not the terminal's shell PID (only child processes) + */ + killPort({ paneId, port }: { paneId: string; port: number }): { + success: boolean; + error?: string; + } { + const key = this.makeKey(paneId, port); + const detectedPort = this.ports.get(key); + + if (!detectedPort) { + return { success: false, error: "Port not found in tracked ports" }; + } + + // Get the terminal's shell PID to ensure we don't kill it + const session = this.sessions.get(paneId); + const daemonSession = this.daemonSessions.get(paneId); + const shellPid = session?.session.pty.pid ?? daemonSession?.pid; + + if (shellPid != null && detectedPort.pid === shellPid) { + return { + success: false, + error: "Cannot kill the terminal shell process", + }; + } + + try { + process.kill(detectedPort.pid, "SIGTERM"); + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + console.error( + `[PortManager] Failed to kill process ${detectedPort.pid}:`, + message, + ); + return { success: false, error: message }; + } + } } export const portManager = new PortManager(); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx index 9283261dfb7..ca14cda322b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx @@ -1,10 +1,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useNavigate } from "@tanstack/react-router"; -import { LuExternalLink } from "react-icons/lu"; +import { LuExternalLink, LuX } from "react-icons/lu"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useTabsStore } from "renderer/stores/tabs/store"; import type { MergedPort } from "shared/types"; import { STROKE_WIDTH } from "../../../constants"; +import { useKillPort } from "../../hooks/useKillPort"; interface MergedPortBadgeProps { port: MergedPort; @@ -14,6 +15,7 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) { const navigate = useNavigate(); const setActiveTab = useTabsStore((s) => s.setActiveTab); const setFocusedPane = useTabsStore((s) => s.setFocusedPane); + const { killPort } = useKillPort(); const portNumberColor = port.isActive ? "text-muted-foreground" @@ -48,6 +50,12 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) { window.open(`http://localhost:${port.port}`, "_blank"); }; + const handleClose = () => { + killPort(port); + }; + + const canClose = port.isActive && port.paneId != null; + return ( @@ -64,13 +72,23 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) { type="button" onClick={handleOpenInBrowser} aria-label={`Open ${port.label || `port ${port.port}`} in browser`} - className="opacity-0 group-hover:opacity-100 pr-1 transition-opacity hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none" + className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-primary focus-visible:opacity-100 focus-visible:outline-none" > - + + {canClose && ( + + )} - +
{port.label &&
{port.label}
}
{ navigateToWorkspace(group.workspaceId, navigate); }; + const activePorts = group.ports.filter((p) => p.isActive && p.paneId != null); + + const handleCloseAll = () => { + killPorts(group.ports); + }; + return (
- +
+ + {activePorts.length > 0 && ( + + + + + +

Close all ports

+
+
+ )} +
{group.ports.map((port) => ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/useKillPort.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/useKillPort.ts new file mode 100644 index 00000000000..58705b8a653 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/useKillPort.ts @@ -0,0 +1,42 @@ +import { toast } from "@superset/ui/sonner"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import type { MergedPort } from "shared/types"; + +export function useKillPort() { + const killMutation = electronTrpc.ports.kill.useMutation(); + + const killPort = async (port: MergedPort) => { + if (!port.isActive || port.paneId == null) return; + + const result = await killMutation.mutateAsync({ + paneId: port.paneId, + port: port.port, + }); + if (!result.success) { + toast.error(`Failed to close port ${port.port}`, { + description: result.error, + }); + } + }; + + const killPorts = async (ports: MergedPort[]) => { + const portsToKill = ports.filter((p) => p.isActive && p.paneId != null); + if (portsToKill.length === 0) return; + + const results = await Promise.all( + portsToKill.map((port) => + killMutation.mutateAsync({ + paneId: port.paneId as string, + port: port.port, + }), + ), + ); + + const failed = results.filter((r) => !r.success); + if (failed.length > 0) { + toast.error(`Failed to close ${failed.length} port(s)`); + } + }; + + return { killPort, killPorts, isPending: killMutation.isPending }; +}