mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
feat: convert ssh tunnels into a plugin
This commit is contained in:
1 parent
c85c1fdb86
commit
1682861853
220 files changed
+36928
-11374
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,372 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Network, Plus } from "lucide-react";
|
||||
import {
|
||||
useHosts,
|
||||
useToast,
|
||||
useTranslation,
|
||||
type HostEditorSectionProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
Button,
|
||||
FakeSwitch,
|
||||
Input,
|
||||
SectionCard,
|
||||
SettingRow,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type { TunnelConnection } from "../shared/types";
|
||||
import { serverTunnelName } from "../shared/tunnel-naming";
|
||||
import {
|
||||
connectTunnel,
|
||||
disconnectTunnel,
|
||||
subscribeTunnelStatuses,
|
||||
type TunnelStatusMap,
|
||||
} from "./api";
|
||||
import {
|
||||
connectRequestFor,
|
||||
parseConnections,
|
||||
tunnelMode,
|
||||
} from "./host-tunnels";
|
||||
|
||||
type PluginSettingsForm = Record<string, Record<string, unknown>>;
|
||||
|
||||
function newTunnel(): TunnelConnection {
|
||||
return {
|
||||
scope: "s2s",
|
||||
mode: "local",
|
||||
sourcePort: 8080,
|
||||
endpointHost: "",
|
||||
endpointPort: 80,
|
||||
bindHost: "127.0.0.1",
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: false,
|
||||
};
|
||||
}
|
||||
|
||||
const MODE_DESCRIPTION_KEYS = {
|
||||
local: "hosts.tunnelModeLocalDesc",
|
||||
remote: "hosts.tunnelModeRemoteDesc",
|
||||
dynamic: "hosts.tunnelModeDynamicDesc",
|
||||
} as const;
|
||||
|
||||
const numberInputClass =
|
||||
"h-7 text-xs [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none";
|
||||
|
||||
/**
|
||||
* The host editor's Tunnels tab. Writes into the form's pluginSettings for
|
||||
* this plugin, which the editor saves through the plugin's host settings
|
||||
* route after the host itself.
|
||||
*/
|
||||
export function HostTunnelsSection({
|
||||
form,
|
||||
updateForm,
|
||||
host,
|
||||
}: HostEditorSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { hosts } = useHosts();
|
||||
const [statuses, setStatuses] = useState<TunnelStatusMap>({});
|
||||
const [busyIndex, setBusyIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => subscribeTunnelStatuses(setStatuses), []);
|
||||
|
||||
const settings = ((form?.pluginSettings as PluginSettingsForm | undefined)
|
||||
?.tunnels ?? {}) as Record<string, unknown>;
|
||||
const enabled = settings.enableTunnel === true;
|
||||
const tunnels = parseConnections(settings.tunnelConnections);
|
||||
|
||||
const setSettings = (patch: Record<string, unknown>) =>
|
||||
updateForm((current) => {
|
||||
const all = (current.pluginSettings ?? {}) as PluginSettingsForm;
|
||||
return {
|
||||
...current,
|
||||
pluginSettings: {
|
||||
...all,
|
||||
tunnels: { ...(all.tunnels ?? {}), ...patch },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const setTunnels = (next: TunnelConnection[]) =>
|
||||
setSettings({ tunnelConnections: next });
|
||||
|
||||
const updateTunnel = (index: number, patch: Partial<TunnelConnection>) =>
|
||||
setTunnels(
|
||||
tunnels.map((tunnel, i) =>
|
||||
i === index ? { ...tunnel, ...patch } : tunnel,
|
||||
),
|
||||
);
|
||||
|
||||
const sourceHost = host
|
||||
? {
|
||||
id: host.id,
|
||||
name: (form?.name as string) || host.name,
|
||||
username: (form?.username as string) || host.username,
|
||||
ip: (form?.ip as string) || host.ip,
|
||||
}
|
||||
: null;
|
||||
|
||||
const toggle = async (index: number, connected: boolean) => {
|
||||
if (!sourceHost) return;
|
||||
const tunnel = tunnels[index];
|
||||
setBusyIndex(index);
|
||||
try {
|
||||
if (connected) {
|
||||
await disconnectTunnel(serverTunnelName(sourceHost, index, tunnel));
|
||||
toast.success(t("hosts.tunnelDisconnected"));
|
||||
} else {
|
||||
await connectTunnel(connectRequestFor(sourceHost, index, tunnel));
|
||||
toast.success(t("hosts.tunnelConnecting"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
connected
|
||||
? t("hosts.failedToDisconnectTunnel")
|
||||
: t("hosts.failedToConnectTunnel"),
|
||||
);
|
||||
} finally {
|
||||
setBusyIndex(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionCard
|
||||
title={t("hosts.tunnelSettings")}
|
||||
icon={<Network className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<SettingRow
|
||||
label={t("hosts.enableTunneling")}
|
||||
description={
|
||||
<>
|
||||
{t("hosts.enableTunnelingDesc")}{" "}
|
||||
<a
|
||||
href="https://docs.termix.site/features/networking/tunnels"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={enabled}
|
||||
onChange={(value) => setSettings({ enableTunnel: value })}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="text-xs text-muted-foreground p-3 bg-muted/30 border border-border space-y-1">
|
||||
<p>{t("hosts.tunnelRequirementsText")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
title={t("hosts.serverTunnelsSection")}
|
||||
icon={<Network className="size-3.5" />}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
|
||||
onClick={() => setTunnels([...tunnels, newTunnel()])}
|
||||
>
|
||||
<Plus className="size-3 mr-1" /> {t("hosts.addTunnelBtn")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3 py-3">
|
||||
{tunnels.length === 0 && (
|
||||
<p className="text-[10px] text-muted-foreground/50 px-1">
|
||||
{t("hosts.noTunnelsConfigured")}
|
||||
</p>
|
||||
)}
|
||||
{tunnels.map((tunnel, i) => {
|
||||
const mode = tunnelMode(tunnel);
|
||||
const endpointInputId = `server-tunnel-endpoint-${host?.id ?? "new"}-${i}`;
|
||||
const status = sourceHost
|
||||
? statuses[serverTunnelName(sourceHost, i, tunnel)]?.status
|
||||
: undefined;
|
||||
const isConnected = status === "connected";
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col gap-3 p-3 border border-border bg-muted/20 relative group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold text-muted-foreground">
|
||||
{t("hosts.tunnelLabel", { number: i + 1 })}
|
||||
</span>
|
||||
<div
|
||||
className={`size-1.5 rounded-full shrink-0 ${
|
||||
isConnected
|
||||
? "bg-accent-brand shadow-[0_0_4px_rgba(251,146,60,0.4)]"
|
||||
: status === "failed"
|
||||
? "bg-red-400"
|
||||
: "bg-muted-foreground/25"
|
||||
}`}
|
||||
title={status ?? t("tunnels.disconnected")}
|
||||
/>
|
||||
{sourceHost && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busyIndex === i}
|
||||
className={`h-6 text-[10px] px-2 ${isConnected ? "border-destructive/40 text-destructive hover:bg-destructive/10" : "border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"}`}
|
||||
onClick={() => toggle(i, isConnected)}
|
||||
>
|
||||
{busyIndex === i
|
||||
? "..."
|
||||
: isConnected
|
||||
? t("hosts.disconnectBtn")
|
||||
: t("hosts.connectBtn")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 text-destructive"
|
||||
onClick={() =>
|
||||
setTunnels(tunnels.filter((_, idx) => idx !== i))
|
||||
}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold text-muted-foreground">
|
||||
{t("hosts.tunnelType")}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(["remote", "local", "dynamic"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => updateTunnel(i, { mode: m })}
|
||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${mode === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground/70 mt-0.5">
|
||||
{t(MODE_DESCRIPTION_KEYS[mode])}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{mode !== "dynamic" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-bold text-muted-foreground">
|
||||
{t("hosts.endpointHost")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
|
||||
list={endpointInputId}
|
||||
placeholder={t("hosts.endpointHostPlaceholder")}
|
||||
value={tunnel.endpointHost ?? ""}
|
||||
onChange={(e) =>
|
||||
updateTunnel(i, { endpointHost: e.target.value })
|
||||
}
|
||||
/>
|
||||
<datalist id={endpointInputId}>
|
||||
{hosts
|
||||
.filter((h) => h.enableSsh !== false)
|
||||
.map((h) => (
|
||||
<option key={h.id} value={h.ip}>
|
||||
{h.name || h.ip} ({h.ip})
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
)}
|
||||
{mode !== "dynamic" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-bold text-muted-foreground">
|
||||
{t("hosts.endpointPort")}
|
||||
</label>
|
||||
<Input
|
||||
className={numberInputClass}
|
||||
type="number"
|
||||
value={tunnel.endpointPort}
|
||||
onChange={(e) =>
|
||||
updateTunnel(i, {
|
||||
endpointPort: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-bold text-muted-foreground">
|
||||
{t("hosts.bindHost")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
placeholder="127.0.0.1"
|
||||
value={tunnel.bindHost ?? ""}
|
||||
onChange={(e) =>
|
||||
updateTunnel(i, { bindHost: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-bold text-muted-foreground">
|
||||
{t("hosts.sourcePort")}
|
||||
</label>
|
||||
<Input
|
||||
className={numberInputClass}
|
||||
type="number"
|
||||
value={tunnel.sourcePort}
|
||||
onChange={(e) =>
|
||||
updateTunnel(i, { sourcePort: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-bold text-muted-foreground">
|
||||
{t("hosts.maxRetries")}
|
||||
</label>
|
||||
<Input
|
||||
className={numberInputClass}
|
||||
type="number"
|
||||
value={tunnel.maxRetries}
|
||||
onChange={(e) =>
|
||||
updateTunnel(i, { maxRetries: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-bold text-muted-foreground">
|
||||
{t("hosts.retryIntervalS")}
|
||||
</label>
|
||||
<Input
|
||||
className={numberInputClass}
|
||||
type="number"
|
||||
value={tunnel.retryInterval}
|
||||
onChange={(e) =>
|
||||
updateTunnel(i, {
|
||||
retryInterval: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t("hosts.autoStartLabel")}
|
||||
description={t("hosts.autoStartDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={tunnel.autoStart}
|
||||
onChange={(value) => updateTunnel(i, { autoStart: value })}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Network,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Square,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useHost,
|
||||
useToast,
|
||||
useTranslation,
|
||||
type PluginHostRecord,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { Button, Card } from "@termix/plugin-sdk/ui";
|
||||
import type { TunnelConnection, TunnelStatus } from "../shared/types";
|
||||
import { serverTunnelName, tunnelHostLabel } from "../shared/tunnel-naming";
|
||||
import {
|
||||
cancelTunnel,
|
||||
connectTunnel,
|
||||
disconnectTunnel,
|
||||
subscribeTunnelStatuses,
|
||||
type TunnelStatusMap,
|
||||
} from "./api";
|
||||
import {
|
||||
connectRequestFor,
|
||||
hostTunnelSettings,
|
||||
tunnelMode,
|
||||
} from "./host-tunnels";
|
||||
|
||||
type StatusLabel =
|
||||
"CONNECTED" | "CONNECTING" | "ERROR" | "WAITING" | "DISCONNECTED";
|
||||
|
||||
function statusLabel(status: TunnelStatus | undefined): StatusLabel {
|
||||
const value = status?.status?.toUpperCase();
|
||||
if (value === "CONNECTED") return "CONNECTED";
|
||||
if (value === "CONNECTING" || value === "VERIFYING") return "CONNECTING";
|
||||
if (value === "FAILED") return "ERROR";
|
||||
if (value === "RETRYING" || value === "WAITING") return "WAITING";
|
||||
return "DISCONNECTED";
|
||||
}
|
||||
|
||||
const STATUS_TEXT_KEYS: Record<StatusLabel, string> = {
|
||||
CONNECTED: "tunnels.connected",
|
||||
CONNECTING: "tunnels.connecting",
|
||||
ERROR: "tunnels.error",
|
||||
WAITING: "tunnels.waiting",
|
||||
DISCONNECTED: "tunnels.disconnected",
|
||||
};
|
||||
|
||||
function TunnelCard({
|
||||
host,
|
||||
tunnel,
|
||||
status,
|
||||
isActing,
|
||||
onAction,
|
||||
}: {
|
||||
host: PluginHostRecord;
|
||||
tunnel: TunnelConnection;
|
||||
status: TunnelStatus | undefined;
|
||||
isActing: boolean;
|
||||
onAction: (action: "connect" | "disconnect" | "cancel") => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const label = statusLabel(status);
|
||||
const isConnected = label === "CONNECTED";
|
||||
const isConnecting = label === "CONNECTING";
|
||||
const isError = label === "ERROR";
|
||||
const isWaiting = label === "WAITING";
|
||||
|
||||
let statusColor = "text-muted-foreground border-border bg-muted/30";
|
||||
if (isConnected)
|
||||
statusColor = "text-accent-brand border-accent-brand/40 bg-accent-brand/10";
|
||||
if (isConnecting)
|
||||
statusColor = "text-blue-400 border-blue-400/40 bg-blue-400/10";
|
||||
if (isError)
|
||||
statusColor = "text-destructive border-destructive/40 bg-destructive/10";
|
||||
if (isWaiting)
|
||||
statusColor = "text-yellow-500 border-yellow-500/40 bg-yellow-500/10";
|
||||
|
||||
const mode = tunnelMode(tunnel);
|
||||
const destination =
|
||||
mode === "dynamic"
|
||||
? t("tunnels.socksProxy")
|
||||
: `${tunnel.endpointHost ?? ""}:${tunnel.endpointPort}`;
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col overflow-hidden p-0 gap-0">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-muted/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("tunnels.port")} {tunnel.sourcePort}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-1.5 px-2 py-0.5 border text-[10px] font-bold uppercase ${statusColor}`}
|
||||
>
|
||||
{isConnecting || isActing ? (
|
||||
<RefreshCw className="size-3 animate-spin" />
|
||||
) : isConnected ? (
|
||||
<Wifi className="size-3" />
|
||||
) : isError ? (
|
||||
<AlertCircle className="size-3" />
|
||||
) : isWaiting ? (
|
||||
<Clock className="size-3" />
|
||||
) : (
|
||||
<WifiOff className="size-3" />
|
||||
)}
|
||||
{isActing ? t("tunnels.working") : t(STATUS_TEXT_KEYS[label])}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-4 flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-bold tracking-wider">
|
||||
{t("tunnels.destination")}
|
||||
</span>
|
||||
<span
|
||||
className="text-sm font-mono font-semibold truncate"
|
||||
title={destination}
|
||||
>
|
||||
{destination}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<span className="text-[10px] font-semibold px-1.5 py-px border border-border text-muted-foreground uppercase">
|
||||
{mode}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("tunnels.listensOn", { port: tunnel.sourcePort })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError && status?.reason && (
|
||||
<div className="flex items-start gap-2 p-2 bg-destructive/5 border border-destructive/20 text-destructive text-[10px]">
|
||||
<AlertCircle className="size-3 mt-0.5 shrink-0" />
|
||||
<span>{status.reason}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSettings && (
|
||||
<div className="border border-border bg-muted/20 p-3 flex flex-col gap-2 text-xs">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground font-semibold">
|
||||
{t("tunnels.host")}
|
||||
</span>
|
||||
<span className="font-mono">{tunnelHostLabel(host)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground font-semibold">
|
||||
{t("tunnels.mode")}
|
||||
</span>
|
||||
<span className="uppercase font-bold">{mode}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground font-semibold">
|
||||
{t("tunnels.localPort")}
|
||||
</span>
|
||||
<span className="font-mono">{tunnel.sourcePort}</span>
|
||||
</div>
|
||||
{mode !== "dynamic" && (
|
||||
<>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground font-semibold">
|
||||
{t("tunnels.remoteHost")}
|
||||
</span>
|
||||
<span className="font-mono">{tunnel.endpointHost}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground font-semibold">
|
||||
{t("tunnels.remotePort")}
|
||||
</span>
|
||||
<span className="font-mono">{tunnel.endpointPort}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground font-semibold">
|
||||
{t("tunnels.maxRetries")}
|
||||
</span>
|
||||
<span className="font-mono">{tunnel.maxRetries}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 mt-1">
|
||||
{isConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 h-8 text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive gap-1.5"
|
||||
disabled={isActing}
|
||||
onClick={() => onAction("disconnect")}
|
||||
>
|
||||
<Square className="size-3" />
|
||||
{t("tunnels.stop")}
|
||||
</Button>
|
||||
) : isConnecting || isWaiting ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 h-8 text-yellow-500 border-yellow-500/40 hover:bg-yellow-500/10 hover:text-yellow-500 gap-1.5"
|
||||
disabled={isActing}
|
||||
onClick={() => onAction("cancel")}
|
||||
>
|
||||
<Square className="size-3" />
|
||||
{t("tunnels.cancel")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 h-8 text-accent-brand border-accent-brand/40 hover:bg-accent-brand/10 hover:text-accent-brand gap-1.5"
|
||||
disabled={isActing}
|
||||
onClick={() => onAction("connect")}
|
||||
>
|
||||
{isActing ? (
|
||||
<RefreshCw className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Play className="size-3" />
|
||||
)}
|
||||
{t("tunnels.start")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant={showSettings ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className={`h-8 w-8 ${showSettings ? "bg-accent-brand/10 text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
|
||||
onClick={() => setShowSettings((s) => !s)}
|
||||
>
|
||||
<Settings className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** A host's saved server tunnels, with live status and start/stop controls. */
|
||||
export function TunnelTab({ host: given }: { host?: PluginHostRecord }) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const live = useHost(given?.id);
|
||||
const host = live ?? given ?? null;
|
||||
const [statuses, setStatuses] = useState<TunnelStatusMap>({});
|
||||
const [acting, setActing] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => subscribeTunnelStatuses(setStatuses), []);
|
||||
|
||||
if (!host) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
|
||||
<div className="size-10 bg-muted/40 flex items-center justify-center">
|
||||
<Network className="size-5 text-muted-foreground/30" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-muted-foreground/60">
|
||||
{t("tunnels.noHostSelected")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tunnels = hostTunnelSettings(host).connections;
|
||||
const names = tunnels.map((tunnel, index) =>
|
||||
serverTunnelName(host, index, tunnel),
|
||||
);
|
||||
const connectedCount = names.filter(
|
||||
(name) => statuses[name]?.status === "connected",
|
||||
).length;
|
||||
|
||||
const handleAction = async (
|
||||
action: "connect" | "disconnect" | "cancel",
|
||||
index: number,
|
||||
) => {
|
||||
const tunnel = tunnels[index];
|
||||
if (!tunnel) return;
|
||||
const name = names[index];
|
||||
setActing((current) => ({ ...current, [name]: true }));
|
||||
try {
|
||||
if (action === "connect") {
|
||||
await connectTunnel(connectRequestFor(host, index, tunnel));
|
||||
toast.success(t("tunnels.clientTunnelStarted"));
|
||||
} else if (action === "disconnect") {
|
||||
await disconnectTunnel(name);
|
||||
toast.info(t("tunnels.clientTunnelStopped"));
|
||||
} else {
|
||||
await cancelTunnel(name);
|
||||
toast.info(t("tunnels.canceling"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("tunnels.manualControlError"));
|
||||
} finally {
|
||||
setActing((current) => ({ ...current, [name]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto px-3 py-3 flex flex-col gap-3">
|
||||
<Card className="flex-row items-center justify-between px-3 py-3 shrink-0 gap-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 border border-border bg-muted flex items-center justify-center shrink-0">
|
||||
<Network className="size-5 text-accent-brand" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{host.name}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="size-2 rounded-full bg-accent-brand" />
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{t("tunnels.activeCount", {
|
||||
connected: connectedCount,
|
||||
total: tunnels.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="https://docs.termix.site/features/networking/tunnels"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center justify-center size-9 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t("hosts.docsLink")}
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</a>
|
||||
</Card>
|
||||
|
||||
{tunnels.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{tunnels.map((tunnel, index) => (
|
||||
<TunnelCard
|
||||
key={names[index]}
|
||||
host={host}
|
||||
tunnel={tunnel}
|
||||
status={statuses[names[index]]}
|
||||
isActing={acting[names[index]] ?? false}
|
||||
onAction={(action) => handleAction(action, index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 py-20">
|
||||
<div className="opacity-10 flex flex-col items-center gap-4">
|
||||
<Network className="size-16" />
|
||||
<span className="text-xl font-bold uppercase tracking-widest">
|
||||
{t("tunnels.noSshTunnels")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
||||
{t("tunnels.createFirstTunnelMessage")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import type {
|
||||
C2STunnelPreset,
|
||||
TunnelConnectRequest,
|
||||
TunnelConnection,
|
||||
TunnelStatus,
|
||||
} from "../shared/types";
|
||||
|
||||
export type TunnelStatusMap = Record<string, TunnelStatus>;
|
||||
|
||||
let client: PluginApiClient | null = null;
|
||||
|
||||
/** Set from activate with app.api, cleared on deactivate. */
|
||||
export function setTunnelsApi(api: PluginApiClient | null): void {
|
||||
client = api;
|
||||
}
|
||||
|
||||
function api(): PluginApiClient {
|
||||
if (!client) throw new Error("The tunnels plugin is not active");
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's own reason for a failed call, or the fallback. The routes
|
||||
* always answer { error: string }.
|
||||
*/
|
||||
export function apiErrorMessage(error: unknown, fallback: string): string {
|
||||
const data = (error as { response?: { data?: { error?: unknown } } })
|
||||
?.response?.data;
|
||||
if (typeof data?.error === "string" && data.error) return data.error;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Tunnel status is a process-local, in-memory view, so reading it is always
|
||||
// safe. connectTunnel/disconnectTunnel/cancelTunnel always target the local
|
||||
// embedded backend: they address a host by numeric id, and a synced host has
|
||||
// a different numeric id in each database (only its syncId matches), so
|
||||
// routing them to a remote backend would need a local-to-remote id lookup
|
||||
// that does not exist yet.
|
||||
|
||||
export async function getTunnelStatuses(): Promise<TunnelStatusMap> {
|
||||
const response = await api().get<TunnelStatusMap>("/status");
|
||||
return response.data || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Local statuses win a name collision with remote ones, since every control
|
||||
* targets the local backend.
|
||||
*/
|
||||
export function mergeTunnelStatuses(
|
||||
local: TunnelStatusMap,
|
||||
remote: TunnelStatusMap,
|
||||
): TunnelStatusMap {
|
||||
return { ...remote, ...local };
|
||||
}
|
||||
|
||||
export interface ServerSentEvent {
|
||||
event: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
/** Splits a text/event-stream body into events. Returns what is left over. */
|
||||
export function parseServerSentEvents(
|
||||
buffer: string,
|
||||
onEvent: (event: ServerSentEvent) => void,
|
||||
): string {
|
||||
const blocks = buffer.split(/\r?\n\r?\n/);
|
||||
const rest = blocks.pop() ?? "";
|
||||
for (const block of blocks) {
|
||||
let event = "message";
|
||||
const data: string[] = [];
|
||||
for (const line of block.split(/\r?\n/)) {
|
||||
if (line.startsWith(":")) continue;
|
||||
if (line.startsWith("event:")) event = line.slice(6).trim();
|
||||
else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
|
||||
}
|
||||
if (data.length > 0) onEvent({ event, data: data.join("\n") });
|
||||
}
|
||||
return rest;
|
||||
}
|
||||
|
||||
function streamUrl(): string | null {
|
||||
const base = (client as unknown as { defaults?: { baseURL?: string } })
|
||||
?.defaults?.baseURL;
|
||||
if (base === undefined) return null;
|
||||
return `${base.replace(/\/$/, "")}/plugin-api/tunnels/status/stream`;
|
||||
}
|
||||
|
||||
export interface SubscribeOptions {
|
||||
/** Replaces the default fetch, for tests. */
|
||||
fetchImpl?: typeof fetch;
|
||||
/** Statuses from a connected remote server, polled and merged in. */
|
||||
fetchRemote?: () => Promise<TunnelStatusMap>;
|
||||
pollIntervalMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live tunnel statuses over the plugin's event stream, reconnecting after a
|
||||
* drop. Falls back to polling when the stream URL cannot be worked out.
|
||||
*/
|
||||
export function subscribeTunnelStatuses(
|
||||
onStatuses: (statuses: TunnelStatusMap) => void,
|
||||
onError?: () => void,
|
||||
options: SubscribeOptions = {},
|
||||
): () => void {
|
||||
const controller = new AbortController();
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const pollIntervalMs = options.pollIntervalMs ?? 5000;
|
||||
let latestLocal: TunnelStatusMap = {};
|
||||
let latestRemote: TunnelStatusMap = {};
|
||||
const timers = new Set<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const emit = () => onStatuses(mergeTunnelStatuses(latestLocal, latestRemote));
|
||||
|
||||
const wait = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
timers.delete(timer);
|
||||
resolve();
|
||||
}, ms);
|
||||
timers.add(timer);
|
||||
});
|
||||
|
||||
const url = streamUrl();
|
||||
|
||||
void (async () => {
|
||||
while (!controller.signal.aborted) {
|
||||
if (!url) {
|
||||
try {
|
||||
latestLocal = await getTunnelStatuses();
|
||||
emit();
|
||||
} catch {
|
||||
onError?.();
|
||||
}
|
||||
await wait(pollIntervalMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "text/event-stream",
|
||||
};
|
||||
if (typeof window !== "undefined" && window.electronAPI?.isElectron) {
|
||||
headers["X-Electron-App"] = "true";
|
||||
const jwt = localStorage.getItem("jwt");
|
||||
if (jwt) headers.Authorization = `Bearer ${jwt}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
credentials: "include",
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok || !response.body) throw new Error("stream failed");
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (!controller.signal.aborted) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer = parseServerSentEvents(
|
||||
buffer + decoder.decode(value, { stream: true }),
|
||||
(event) => {
|
||||
if (event.event !== "statuses") return;
|
||||
try {
|
||||
latestLocal = JSON.parse(event.data) as TunnelStatusMap;
|
||||
emit();
|
||||
} catch {
|
||||
onError?.();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!controller.signal.aborted) onError?.();
|
||||
} catch {
|
||||
if (!controller.signal.aborted) onError?.();
|
||||
}
|
||||
if (!controller.signal.aborted) await wait(1000);
|
||||
}
|
||||
})();
|
||||
|
||||
if (options.fetchRemote) {
|
||||
const fetchRemote = options.fetchRemote;
|
||||
void (async () => {
|
||||
while (!controller.signal.aborted) {
|
||||
try {
|
||||
latestRemote = await fetchRemote();
|
||||
emit();
|
||||
} catch {
|
||||
// A remote that is down just contributes nothing.
|
||||
}
|
||||
await wait(pollIntervalMs);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
for (const timer of timers) clearTimeout(timer);
|
||||
timers.clear();
|
||||
};
|
||||
}
|
||||
|
||||
export async function connectTunnel(
|
||||
request: TunnelConnectRequest,
|
||||
): Promise<void> {
|
||||
await api().post("/connect", request);
|
||||
}
|
||||
|
||||
export async function disconnectTunnel(tunnelName: string): Promise<void> {
|
||||
await api().post("/disconnect", { tunnelName });
|
||||
}
|
||||
|
||||
export async function cancelTunnel(tunnelName: string): Promise<void> {
|
||||
await api().post("/cancel", { tunnelName });
|
||||
}
|
||||
|
||||
export async function getC2STunnelPresets(): Promise<C2STunnelPreset[]> {
|
||||
const response = await api().get<C2STunnelPreset[]>("/presets");
|
||||
return response.data || [];
|
||||
}
|
||||
|
||||
export async function createC2STunnelPreset(data: {
|
||||
name: string;
|
||||
config: TunnelConnection[];
|
||||
platform?: string;
|
||||
computerName?: string;
|
||||
}): Promise<C2STunnelPreset> {
|
||||
const response = await api().post<C2STunnelPreset>("/presets", data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function updateC2STunnelPreset(
|
||||
id: number,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
config: TunnelConnection[];
|
||||
platform: string;
|
||||
computerName: string;
|
||||
}>,
|
||||
): Promise<C2STunnelPreset> {
|
||||
const response = await api().put<C2STunnelPreset>(`/presets/${id}`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteC2STunnelPreset(id: number): Promise<void> {
|
||||
await api().delete(`/presets/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { TunnelConnection, TunnelMode } from "../shared/types";
|
||||
|
||||
export type ClientTunnel = TunnelConnection & {
|
||||
localAddress: string;
|
||||
remoteAddress: string;
|
||||
bindHost: string;
|
||||
sourceHostId?: number;
|
||||
sourceHostSyncId?: string;
|
||||
sourceHostName?: string;
|
||||
displayName?: string;
|
||||
lastStartedAt?: string;
|
||||
lastTestedAt?: string;
|
||||
lastError?: string;
|
||||
};
|
||||
|
||||
export function getTunnelMode(tunnel: Partial<TunnelConnection>): TunnelMode {
|
||||
return tunnel.mode || tunnel.tunnelType || "local";
|
||||
}
|
||||
|
||||
export function normalizeClientTunnel(
|
||||
tunnel: Partial<ClientTunnel>,
|
||||
): ClientTunnel {
|
||||
const mode = getTunnelMode(tunnel);
|
||||
const metadata = tunnel as Partial<ClientTunnel>;
|
||||
const localAddress =
|
||||
tunnel.localAddress?.trim() || tunnel.bindHost?.trim() || "";
|
||||
const remoteAddress =
|
||||
tunnel.remoteAddress?.trim() || tunnel.targetHost?.trim() || "";
|
||||
|
||||
return {
|
||||
...tunnel,
|
||||
scope: "c2s",
|
||||
mode,
|
||||
tunnelType: mode === "dynamic" ? "local" : mode,
|
||||
localAddress,
|
||||
remoteAddress,
|
||||
bindHost: localAddress,
|
||||
targetHost: remoteAddress,
|
||||
sourcePort: Number(tunnel.sourcePort) || 8080,
|
||||
endpointPort: Number(tunnel.endpointPort) || 22,
|
||||
sourceHostSyncId: metadata.sourceHostSyncId,
|
||||
endpointHost: tunnel.endpointHost || tunnel.sourceHostName || "",
|
||||
maxRetries: Number(tunnel.maxRetries) || 3,
|
||||
retryInterval: Number(tunnel.retryInterval) || 10,
|
||||
autoStart: Boolean(tunnel.autoStart),
|
||||
displayName: metadata.displayName ?? "",
|
||||
lastStartedAt: metadata.lastStartedAt,
|
||||
lastTestedAt: metadata.lastTestedAt,
|
||||
lastError: metadata.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
export function stripClientTunnelDiagnostics(
|
||||
tunnel: ClientTunnel,
|
||||
): TunnelConnection {
|
||||
const presetTunnel = normalizeClientTunnel(tunnel);
|
||||
const displayName = presetTunnel.displayName?.trim();
|
||||
|
||||
if (displayName) {
|
||||
presetTunnel.displayName = displayName;
|
||||
} else {
|
||||
delete presetTunnel.displayName;
|
||||
}
|
||||
|
||||
delete presetTunnel.lastStartedAt;
|
||||
delete presetTunnel.lastTestedAt;
|
||||
delete presetTunnel.lastError;
|
||||
|
||||
return presetTunnel;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { PluginHostRecord } from "@termix/plugin-sdk/frontend";
|
||||
import type { TunnelConnectRequest, TunnelConnection } from "../shared/types";
|
||||
import { serverTunnelName } from "../shared/tunnel-naming";
|
||||
|
||||
/** A host's tunnel settings, as the host payload's pluginSettings carries them. */
|
||||
export function hostTunnelSettings(host: PluginHostRecord | null | undefined): {
|
||||
enabled: boolean;
|
||||
connections: TunnelConnection[];
|
||||
} {
|
||||
const settings = (
|
||||
host?.pluginSettings as Record<string, Record<string, unknown>> | undefined
|
||||
)?.tunnels;
|
||||
return {
|
||||
enabled: settings?.enableTunnel === true,
|
||||
connections: parseConnections(settings?.tunnelConnections),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseConnections(value: unknown): TunnelConnection[] {
|
||||
let parsed = value;
|
||||
if (typeof parsed === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(parsed);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return Array.isArray(parsed) ? (parsed as TunnelConnection[]) : [];
|
||||
}
|
||||
|
||||
export function tunnelMode(
|
||||
tunnel: Pick<TunnelConnection, "mode" | "tunnelType">,
|
||||
): "local" | "remote" | "dynamic" {
|
||||
return tunnel.mode ?? tunnel.tunnelType ?? "local";
|
||||
}
|
||||
|
||||
/** What POST /connect takes for a host's saved tunnel. */
|
||||
export function connectRequestFor(
|
||||
host: {
|
||||
id: string | number;
|
||||
name?: string | null;
|
||||
username?: string | null;
|
||||
ip: string;
|
||||
},
|
||||
index: number,
|
||||
tunnel: TunnelConnection,
|
||||
): TunnelConnectRequest {
|
||||
const mode = tunnelMode(tunnel);
|
||||
return {
|
||||
name: serverTunnelName(host, index, tunnel),
|
||||
sourceHostId: Number(host.id),
|
||||
tunnelIndex: index,
|
||||
scope: tunnel.scope ?? "s2s",
|
||||
mode,
|
||||
tunnelType: mode === "remote" ? "remote" : "local",
|
||||
bindHost: tunnel.bindHost,
|
||||
targetHost: tunnel.targetHost,
|
||||
endpointHost: (tunnel.endpointHost ?? "").trim(),
|
||||
sourcePort: tunnel.sourcePort,
|
||||
endpointPort: tunnel.endpointPort ?? 0,
|
||||
maxRetries: tunnel.maxRetries,
|
||||
retryInterval: tunnel.retryInterval,
|
||||
autoStart: tunnel.autoStart,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { Network, Plug } from "lucide-react";
|
||||
import type {
|
||||
HomepageWidgetContribution,
|
||||
TabProps,
|
||||
TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { TunnelTab } from "./TunnelTab";
|
||||
import { HostTunnelsSection } from "./HostTunnelsSection";
|
||||
import {
|
||||
PortForwardingPanel,
|
||||
TunnelStandalone,
|
||||
TunnelWidget,
|
||||
TunnelWidgetEditForm,
|
||||
} from "./views";
|
||||
import { getTunnelStatuses, setTunnelsApi } from "./api";
|
||||
import { hostTunnelSettings } from "./host-tunnels";
|
||||
|
||||
// Matches the homepage canvas grid.
|
||||
const GRID_SIZE = 30;
|
||||
|
||||
function TunnelTabView({ host }: TabProps) {
|
||||
return <TunnelTab host={host} />;
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
setTunnelsApi(app.api);
|
||||
app.onDispose(() => setTunnelsApi(null));
|
||||
|
||||
// Keeps the "tunnel" tab type and ?view=tunnel links from before the
|
||||
// conversion, so saved layouts and recent activity still open here.
|
||||
app.registerTab("tunnel", TunnelTabView as ComponentType<TabProps>, {
|
||||
icon: Network,
|
||||
titleKey: "nav.tunnels",
|
||||
persistent: true,
|
||||
hostless: true,
|
||||
standalone: TunnelStandalone,
|
||||
activityTypes: ["tunnel"],
|
||||
});
|
||||
|
||||
app.registerHostAction({
|
||||
id: "tunnel",
|
||||
titleKey: "nav.tunnels",
|
||||
icon: Network,
|
||||
kind: "open",
|
||||
order: 40,
|
||||
tabType: "tunnel",
|
||||
copyUrlView: "tunnel",
|
||||
when: (host) =>
|
||||
host.enableSsh !== false && hostTunnelSettings(host).enabled,
|
||||
});
|
||||
|
||||
app.registerHostEditorSection({
|
||||
id: "tunnels",
|
||||
group: "ssh",
|
||||
titleKey: "hosts.tabTunnels",
|
||||
icon: Network,
|
||||
order: 20,
|
||||
component: HostTunnelsSection,
|
||||
});
|
||||
|
||||
app.registerPanel("port-forwarding", PortForwardingPanel);
|
||||
app.registerRailItem({
|
||||
id: "port-forwarding",
|
||||
icon: Network,
|
||||
titleKey: "nav.portForwarding",
|
||||
kind: "panel",
|
||||
electronOnly: true,
|
||||
separatorAfter: true,
|
||||
after: "credentials",
|
||||
permission: "use",
|
||||
});
|
||||
|
||||
app.registerSlotContribution("onboarding.features", {
|
||||
actionId: "tunnels.feature",
|
||||
titleKey: "onboarding.feature_tunnels",
|
||||
descriptionKey: "onboarding.feature_tunnels_desc",
|
||||
icon: Plug as ComponentType<{ className?: string }>,
|
||||
});
|
||||
|
||||
app.registerHomepageWidget({
|
||||
id: "tunnel_widget",
|
||||
name: "Tunnel Manager",
|
||||
description: "Embedded SSH tunnel manager for a configured host",
|
||||
category: "system",
|
||||
icon: <Network size={14} />,
|
||||
defaultConfig: { hostId: 0 },
|
||||
defaultSize: { w: GRID_SIZE * 16, h: GRID_SIZE * 10 },
|
||||
minSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 6 },
|
||||
component: TunnelWidget,
|
||||
editFormComponent: TunnelWidgetEditForm,
|
||||
} as HomepageWidgetContribution);
|
||||
|
||||
// The dashboard's active tunnel counter reads and opens these, and gets
|
||||
// undefined back while the plugin is off.
|
||||
app.registerAction("tunnels.statuses", (() => getTunnelStatuses()) as never, {
|
||||
permission: "use",
|
||||
});
|
||||
app.registerAction(
|
||||
"tunnels.open",
|
||||
(() => app.tabs.openSingletonTab("tunnel")) as never,
|
||||
{ permission: "use" },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { TunnelMode } from "../shared/types";
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
options?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
export function getTunnelTypeForMode(mode: TunnelMode): "local" | "remote" {
|
||||
return mode === "remote" ? "remote" : "local";
|
||||
}
|
||||
|
||||
export function getTunnelPortLabels(
|
||||
scope: "client" | "server",
|
||||
mode: TunnelMode,
|
||||
t: Translate,
|
||||
) {
|
||||
if (scope === "client") {
|
||||
return {
|
||||
sourcePortLabel:
|
||||
mode === "remote" ? t("tunnels.remotePort") : t("tunnels.localPort"),
|
||||
endpointPortLabel:
|
||||
mode === "remote"
|
||||
? t("tunnels.localPort")
|
||||
: t("tunnels.destinationPort"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sourcePortLabel: t("tunnels.currentHostPort"),
|
||||
endpointPortLabel: t("tunnels.endpointPort"),
|
||||
};
|
||||
}
|
||||
|
||||
export function getTunnelModeDescription(
|
||||
scope: "client" | "server",
|
||||
mode: TunnelMode,
|
||||
ports: {
|
||||
sourcePort: string | number;
|
||||
endpointPort: string | number;
|
||||
},
|
||||
t: Translate,
|
||||
) {
|
||||
if (scope === "client") {
|
||||
if (mode === "dynamic") {
|
||||
return t("tunnels.forwardDescriptionClientDynamic", {
|
||||
sourcePort: ports.sourcePort,
|
||||
});
|
||||
}
|
||||
if (mode === "local") {
|
||||
return t("tunnels.forwardDescriptionClientLocal", ports);
|
||||
}
|
||||
return t("tunnels.forwardDescriptionClientRemote", ports);
|
||||
}
|
||||
|
||||
if (mode === "dynamic") {
|
||||
return t("tunnels.forwardDescriptionServerDynamic", {
|
||||
sourcePort: ports.sourcePort,
|
||||
});
|
||||
}
|
||||
if (mode === "local") {
|
||||
return t("tunnels.forwardDescriptionServerLocal", ports);
|
||||
}
|
||||
return t("tunnels.forwardDescriptionServerRemote", ports);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Network } from "lucide-react";
|
||||
import {
|
||||
useHosts,
|
||||
useTranslation,
|
||||
type PluginHostRecord,
|
||||
type StandaloneViewProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
ConnectionScreen,
|
||||
FullScreenAppWrapper,
|
||||
Select2,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { TunnelTab } from "./TunnelTab";
|
||||
import { C2STunnelPresetManager } from "./C2STunnelPresetManager";
|
||||
import { hostTunnelSettings } from "./host-tunnels";
|
||||
|
||||
/** The `?view=tunnel` full-screen page for one host. */
|
||||
export function TunnelStandalone({ hostId }: StandaloneViewProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, phase) => {
|
||||
if (phase === "loading") {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="connecting"
|
||||
message={t("hosts.loadingHost")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="disconnected"
|
||||
message={t("hosts.hostNotFound")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const host = {
|
||||
...hostConfig,
|
||||
id: String(hostConfig.id),
|
||||
} as unknown as PluginHostRecord;
|
||||
return <TunnelTab host={host} />;
|
||||
}}
|
||||
</FullScreenAppWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
/** The rail panel for the desktop app's client tunnels. */
|
||||
export function PortForwardingPanel() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto px-2">
|
||||
<C2STunnelPresetManager />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TunnelWidgetConfig {
|
||||
hostId: number;
|
||||
}
|
||||
|
||||
/** Homepage widget: the tunnel tab for one host. */
|
||||
export function TunnelWidget({
|
||||
widget,
|
||||
config,
|
||||
}: {
|
||||
widget: { title?: string | null };
|
||||
config: TunnelWidgetConfig;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { hosts, loaded } = useHosts();
|
||||
|
||||
const placeholder = (text: string) => (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-xs text-muted-foreground/60">
|
||||
<Network size={20} />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!config.hostId) return placeholder(t("homepage.widgetNoHostSelected"));
|
||||
if (!loaded) return placeholder(t("homepage.loading"));
|
||||
|
||||
const host = hosts.find(
|
||||
(candidate) =>
|
||||
String(candidate.id) === String(config.hostId) &&
|
||||
hostTunnelSettings(candidate).enabled,
|
||||
);
|
||||
if (!host) return placeholder(t("homepage.widgetNoHostSelected"));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{widget.title && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-border/50 shrink-0">
|
||||
<span className="text-accent-brand shrink-0">
|
||||
<Network size={11} />
|
||||
</span>
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider truncate">
|
||||
{widget.title}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="flex-1 overflow-hidden"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TunnelTab host={host} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The widget's edit form: pick a host with tunnels on. */
|
||||
export function TunnelWidgetEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: {
|
||||
config: TunnelWidgetConfig;
|
||||
onChange: (config: TunnelWidgetConfig) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { hosts } = useHosts();
|
||||
const options = hosts.filter(
|
||||
(host) => host.enableSsh !== false && hostTunnelSettings(host).enabled,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.host")}
|
||||
</label>
|
||||
<Select2
|
||||
value={config.hostId || ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, hostId: Number(e.target.value) })
|
||||
}
|
||||
className="h-8 text-xs border border-border bg-background px-2"
|
||||
>
|
||||
<option value="">{t("homepage.selectHost")}</option>
|
||||
{options.map((host) => (
|
||||
<option key={host.id} value={host.id}>
|
||||
{host.name || host.ip}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user