mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
feat: initial ui redesign from demo
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { TunnelViewer } from "@/features/tunnel/TunnelViewer.tsx";
|
||||
import {
|
||||
getSSHHosts,
|
||||
subscribeTunnelStatuses,
|
||||
connectTunnel,
|
||||
disconnectTunnel,
|
||||
cancelTunnel,
|
||||
logActivity,
|
||||
} from "@/main-axios.ts";
|
||||
import type {
|
||||
SSHHost,
|
||||
TunnelConnection,
|
||||
TunnelStatus,
|
||||
SSHTunnelProps,
|
||||
} from "@/types/index";
|
||||
|
||||
export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
||||
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
|
||||
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
|
||||
const [tunnelStatuses, setTunnelStatuses] = useState<
|
||||
Record<string, TunnelStatus>
|
||||
>({});
|
||||
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const prevVisibleHostRef = React.useRef<SSHHost | null>(null);
|
||||
const activityLoggedRef = React.useRef(false);
|
||||
const activityLoggingRef = React.useRef(false);
|
||||
|
||||
const haveTunnelConnectionsChanged = (
|
||||
a: TunnelConnection[] = [],
|
||||
b: TunnelConnection[] = [],
|
||||
): boolean => {
|
||||
if (a.length !== b.length) return true;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const x = a[i];
|
||||
const y = b[i];
|
||||
if (
|
||||
x.sourcePort !== y.sourcePort ||
|
||||
x.endpointPort !== y.endpointPort ||
|
||||
x.endpointHost !== y.endpointHost ||
|
||||
x.maxRetries !== y.maxRetries ||
|
||||
x.retryInterval !== y.retryInterval ||
|
||||
x.autoStart !== y.autoStart
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const fetchHosts = useCallback(async () => {
|
||||
const hostsData = await getSSHHosts();
|
||||
setAllHosts(hostsData);
|
||||
const nextVisible = filterHostKey
|
||||
? hostsData.filter((h) => {
|
||||
const key =
|
||||
h.name && h.name.trim() !== "" ? h.name : `${h.username}@${h.ip}`;
|
||||
return key === filterHostKey;
|
||||
})
|
||||
: hostsData;
|
||||
|
||||
const prev = prevVisibleHostRef.current;
|
||||
const curr = nextVisible[0] ?? null;
|
||||
let changed = false;
|
||||
if (!prev && curr) changed = true;
|
||||
else if (prev && !curr) changed = true;
|
||||
else if (prev && curr) {
|
||||
if (
|
||||
prev.id !== curr.id ||
|
||||
prev.name !== curr.name ||
|
||||
prev.ip !== curr.ip ||
|
||||
prev.port !== curr.port ||
|
||||
prev.username !== curr.username ||
|
||||
haveTunnelConnectionsChanged(
|
||||
prev.tunnelConnections,
|
||||
curr.tunnelConnections,
|
||||
)
|
||||
) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
setVisibleHosts(nextVisible);
|
||||
prevVisibleHostRef.current = curr;
|
||||
}
|
||||
}, [filterHostKey]);
|
||||
|
||||
const logTunnelActivity = async (host: SSHHost) => {
|
||||
if (!host?.id || activityLoggedRef.current || activityLoggingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
activityLoggingRef.current = true;
|
||||
activityLoggedRef.current = true;
|
||||
|
||||
try {
|
||||
const hostName = host.name || `${host.username}@${host.ip}`;
|
||||
await logActivity("tunnel", host.id, hostName);
|
||||
} catch (err) {
|
||||
console.warn("Failed to log tunnel activity:", err);
|
||||
activityLoggedRef.current = false;
|
||||
} finally {
|
||||
activityLoggingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchHosts();
|
||||
const interval = setInterval(fetchHosts, 5000);
|
||||
|
||||
const handleHostsChanged = () => {
|
||||
fetchHosts();
|
||||
};
|
||||
window.addEventListener(
|
||||
"ssh-hosts:changed",
|
||||
handleHostsChanged as EventListener,
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener(
|
||||
"ssh-hosts:changed",
|
||||
handleHostsChanged as EventListener,
|
||||
);
|
||||
};
|
||||
}, [fetchHosts]);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeTunnelStatuses(setTunnelStatuses, () => {
|
||||
// The view remains usable if the stream reconnects or is unavailable.
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleHosts.length > 0 && visibleHosts[0]) {
|
||||
logTunnelActivity(visibleHosts[0]);
|
||||
}
|
||||
}, [visibleHosts.length > 0 ? visibleHosts[0]?.id : null]);
|
||||
|
||||
const handleTunnelAction = async (
|
||||
action: "connect" | "disconnect" | "cancel",
|
||||
host: SSHHost,
|
||||
tunnelIndex: number,
|
||||
) => {
|
||||
const tunnel = host.tunnelConnections[tunnelIndex];
|
||||
const tunnelName = `${host.id}::${tunnelIndex}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
|
||||
|
||||
setTunnelActions((prev) => ({ ...prev, [tunnelName]: true }));
|
||||
|
||||
try {
|
||||
if (action === "connect") {
|
||||
const endpointHost = allHosts.find(
|
||||
(h) =>
|
||||
h.name === tunnel.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnel.endpointHost,
|
||||
);
|
||||
|
||||
const tunnelConfig = {
|
||||
name: tunnelName,
|
||||
scope: tunnel.scope || "s2s",
|
||||
mode: tunnel.mode || tunnel.tunnelType || "remote",
|
||||
bindHost: tunnel.bindHost,
|
||||
targetHost: tunnel.targetHost,
|
||||
tunnelType:
|
||||
tunnel.tunnelType ||
|
||||
(tunnel.mode === "local" || tunnel.mode === "remote"
|
||||
? tunnel.mode
|
||||
: "remote"),
|
||||
sourceHostId: host.id,
|
||||
tunnelIndex: tunnelIndex,
|
||||
hostName: host.name || `${host.username}@${host.ip}`,
|
||||
sourceIP: host.ip,
|
||||
sourceSSHPort: host.port,
|
||||
sourceUsername: host.username,
|
||||
sourcePassword:
|
||||
host.authType === "password" ? host.password : undefined,
|
||||
sourceAuthMethod: host.authType,
|
||||
sourceSSHKey: host.authType === "key" ? host.key : undefined,
|
||||
sourceKeyPassword:
|
||||
host.authType === "key" ? host.keyPassword : undefined,
|
||||
sourceKeyType: host.authType === "key" ? host.keyType : undefined,
|
||||
sourceCredentialId: host.credentialId,
|
||||
sourceUserId: host.userId,
|
||||
endpointHost: tunnel.endpointHost,
|
||||
endpointIP: endpointHost?.ip,
|
||||
endpointSSHPort: endpointHost?.port,
|
||||
endpointUsername: endpointHost?.username,
|
||||
endpointPassword:
|
||||
endpointHost?.authType === "password"
|
||||
? endpointHost.password
|
||||
: undefined,
|
||||
endpointAuthMethod: endpointHost?.authType,
|
||||
endpointSSHKey:
|
||||
endpointHost?.authType === "key" ? endpointHost.key : undefined,
|
||||
endpointKeyPassword:
|
||||
endpointHost?.authType === "key"
|
||||
? endpointHost.keyPassword
|
||||
: undefined,
|
||||
endpointKeyType:
|
||||
endpointHost?.authType === "key" ? endpointHost.keyType : undefined,
|
||||
endpointCredentialId: endpointHost?.credentialId,
|
||||
endpointUserId: endpointHost?.userId,
|
||||
sourcePort: tunnel.sourcePort,
|
||||
endpointPort: tunnel.endpointPort,
|
||||
maxRetries: tunnel.maxRetries,
|
||||
retryInterval: tunnel.retryInterval * 1000,
|
||||
autoStart: tunnel.autoStart,
|
||||
isPinned: host.pin,
|
||||
useSocks5: host.useSocks5,
|
||||
socks5Host: host.socks5Host,
|
||||
socks5Port: host.socks5Port,
|
||||
socks5Username: host.socks5Username,
|
||||
socks5Password: host.socks5Password,
|
||||
socks5ProxyChain: host.socks5ProxyChain,
|
||||
};
|
||||
await connectTunnel(tunnelConfig);
|
||||
} else if (action === "disconnect") {
|
||||
await disconnectTunnel(tunnelName);
|
||||
} else if (action === "cancel") {
|
||||
await cancelTunnel(tunnelName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Tunnel action failed:", {
|
||||
action,
|
||||
tunnelName,
|
||||
hostId: host.id,
|
||||
tunnelIndex,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
fullError: error,
|
||||
});
|
||||
} finally {
|
||||
setTunnelActions((prev) => ({ ...prev, [tunnelName]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TunnelViewer
|
||||
hosts={visibleHosts}
|
||||
tunnelStatuses={tunnelStatuses}
|
||||
tunnelActions={tunnelActions}
|
||||
onTunnelAction={handleTunnelAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
import { TunnelManager } from "@/features/tunnel/TunnelManager.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
|
||||
interface TunnelAppProps {
|
||||
hostId?: string;
|
||||
}
|
||||
|
||||
const TunnelApp: React.FC<TunnelAppProps> = ({ hostId }) => {
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, loading) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading host...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">Host not found</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TunnelManager
|
||||
hostConfig={hostConfig}
|
||||
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FullScreenAppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default TunnelApp;
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import type { TunnelStatus } from "@/types/index.js";
|
||||
import {
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Play,
|
||||
Square,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type TunnelInlineControlsProps = {
|
||||
status?: TunnelStatus;
|
||||
loading?: boolean;
|
||||
onStart?: () => void;
|
||||
onStop?: () => void;
|
||||
startDisabled?: boolean;
|
||||
startDisabledReason?: string;
|
||||
};
|
||||
|
||||
function getStatusKind(status?: TunnelStatus) {
|
||||
const value = status?.status?.toUpperCase() || "DISCONNECTED";
|
||||
|
||||
if (value === "CONNECTED") return "connected";
|
||||
if (value === "ERROR" || value === "FAILED") return "error";
|
||||
if (
|
||||
value === "CONNECTING" ||
|
||||
value === "DISCONNECTING" ||
|
||||
value === "RETRYING" ||
|
||||
value === "WAITING"
|
||||
) {
|
||||
return "connecting";
|
||||
}
|
||||
|
||||
return "disconnected";
|
||||
}
|
||||
|
||||
function getStatusTitle(
|
||||
status: TunnelStatus | undefined,
|
||||
statusText: string,
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
) {
|
||||
if (!status) return statusText;
|
||||
|
||||
const details = [];
|
||||
if (status.reason) details.push(status.reason);
|
||||
if (status.retryCount && status.maxRetries) {
|
||||
details.push(
|
||||
t("tunnels.attempt", {
|
||||
current: status.retryCount,
|
||||
max: status.maxRetries,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (status.nextRetryIn) {
|
||||
details.push(
|
||||
t("tunnels.nextRetryIn", {
|
||||
seconds: status.nextRetryIn,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (status.errorType && !status.reason) details.push(status.errorType);
|
||||
|
||||
return details.length > 0 ? details.join("\n") : statusText;
|
||||
}
|
||||
|
||||
export function TunnelInlineControls({
|
||||
status,
|
||||
loading = false,
|
||||
onStart,
|
||||
onStop,
|
||||
startDisabled,
|
||||
startDisabledReason,
|
||||
}: TunnelInlineControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const kind = getStatusKind(status);
|
||||
const isDisconnected = kind === "disconnected";
|
||||
const statusText =
|
||||
kind === "connected"
|
||||
? t("tunnels.connected")
|
||||
: kind === "connecting"
|
||||
? t("tunnels.connecting")
|
||||
: kind === "error"
|
||||
? t("tunnels.error")
|
||||
: t("tunnels.disconnected");
|
||||
const title = getStatusTitle(status, statusText, t);
|
||||
|
||||
const statusClass =
|
||||
kind === "connected"
|
||||
? "text-green-600 dark:text-green-400 bg-green-500/10 border-green-500/20"
|
||||
: kind === "connecting"
|
||||
? "text-blue-600 dark:text-blue-400 bg-blue-500/10 border-blue-500/20"
|
||||
: kind === "error"
|
||||
? "text-red-600 dark:text-red-400 bg-red-500/10 border-red-500/20"
|
||||
: "text-muted-foreground bg-muted/30 border-border";
|
||||
|
||||
const statusIcon =
|
||||
kind === "connected" ? (
|
||||
<Wifi className="h-3 w-3" />
|
||||
) : kind === "connecting" ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : kind === "error" ? (
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
) : (
|
||||
<WifiOff className="h-3 w-3" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<span
|
||||
className={`inline-flex h-8 items-center gap-1.5 rounded-md border px-2 text-xs font-medium ${statusClass}`}
|
||||
title={title}
|
||||
>
|
||||
{statusIcon}
|
||||
{statusText}
|
||||
</span>
|
||||
{loading ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="h-8 px-3 text-xs text-muted-foreground border-border"
|
||||
>
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
{isDisconnected ? t("tunnels.start") : t("tunnels.stop")}
|
||||
</Button>
|
||||
) : isDisconnected ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onStart}
|
||||
disabled={startDisabled}
|
||||
title={startDisabled ? startDisabledReason : undefined}
|
||||
className="h-8 px-3 text-xs text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50"
|
||||
>
|
||||
<Play className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.start")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onStop}
|
||||
className="h-8 px-3 text-xs text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50"
|
||||
>
|
||||
<Square className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.stop")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import React from "react";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Tunnel } from "@/features/tunnel/Tunnel.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getSSHHosts } from "@/main-axios.ts";
|
||||
import { useTabsSafe } from "@/shell/TabContext.tsx";
|
||||
|
||||
interface HostConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
username: string;
|
||||
folder?: string;
|
||||
enableFileManager?: boolean;
|
||||
tunnelConnections?: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TunnelManagerProps {
|
||||
hostConfig?: HostConfig;
|
||||
title?: string;
|
||||
isVisible?: boolean;
|
||||
isTopbarOpen?: boolean;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function TunnelManager({
|
||||
hostConfig,
|
||||
title,
|
||||
isVisible = true,
|
||||
isTopbarOpen = true,
|
||||
embedded = false,
|
||||
}: TunnelManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { tabs, addTab, setCurrentTab, updateTab } = useTabsSafe();
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
const isElectron =
|
||||
typeof window !== "undefined" && window.electronAPI?.isElectron === true;
|
||||
|
||||
const openC2SPresets = React.useCallback(() => {
|
||||
const profileTab = tabs.find((tab) => tab.type === "user_profile");
|
||||
if (profileTab) {
|
||||
updateTab(profileTab.id, {
|
||||
initialTab: "c2s-tunnels",
|
||||
_updateTimestamp: Date.now(),
|
||||
});
|
||||
setCurrentTab(profileTab.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = addTab({
|
||||
type: "user_profile",
|
||||
title: t("profile.title"),
|
||||
initialTab: "c2s-tunnels",
|
||||
});
|
||||
setCurrentTab(id);
|
||||
}, [addTab, setCurrentTab, t, tabs, updateTab]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||
setCurrentHostConfig(hostConfig);
|
||||
}
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchLatestHostConfig = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch {
|
||||
// Silently handle error
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchLatestHostConfig();
|
||||
|
||||
const handleHostsChanged = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch {
|
||||
// Silently handle error
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
return () =>
|
||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
const leftMarginPx = 8;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
const wrapperStyle: React.CSSProperties = embedded
|
||||
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
|
||||
: {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
};
|
||||
|
||||
const containerClass = embedded
|
||||
? "h-full w-full text-foreground overflow-hidden bg-transparent"
|
||||
: "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden";
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-bold text-lg truncate">
|
||||
{currentHostConfig?.folder} / {title}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
{isElectron && (
|
||||
<Button size="sm" variant="outline" onClick={openC2SPresets}>
|
||||
{t("tunnels.manageClientTunnels")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0 p-1">
|
||||
{currentHostConfig?.tunnelConnections &&
|
||||
currentHostConfig.tunnelConnections.length > 0 ? (
|
||||
<div className="rounded-lg h-full overflow-hidden flex flex-col min-h-0">
|
||||
<Tunnel
|
||||
filterHostKey={
|
||||
currentHostConfig?.name &&
|
||||
currentHostConfig.name.trim() !== ""
|
||||
? currentHostConfig.name
|
||||
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-foreground-subtle text-lg">
|
||||
{t("tunnels.noTunnelsConfigured")}
|
||||
</p>
|
||||
<p className="text-foreground-subtle text-sm mt-2">
|
||||
{t("tunnels.configureTunnelsInHostSettings")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TunnelMode } from "@/types/index.js";
|
||||
|
||||
type TunnelModeSelectorProps = {
|
||||
mode: TunnelMode;
|
||||
scope: "client" | "server";
|
||||
onChange: (mode: TunnelMode) => void;
|
||||
};
|
||||
|
||||
export function TunnelModeSelector({
|
||||
mode,
|
||||
scope,
|
||||
onChange,
|
||||
}: TunnelModeSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const options: Array<{
|
||||
value: TunnelMode;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
value: "local",
|
||||
label: t("tunnels.typeLocal"),
|
||||
description:
|
||||
scope === "client"
|
||||
? t("tunnels.typeClientLocalDesc")
|
||||
: t("tunnels.typeServerLocalDesc"),
|
||||
},
|
||||
{
|
||||
value: "remote",
|
||||
label: t("tunnels.typeRemote"),
|
||||
description:
|
||||
scope === "client"
|
||||
? t("tunnels.typeClientRemoteDesc")
|
||||
: t("tunnels.typeServerRemoteDesc"),
|
||||
},
|
||||
{
|
||||
value: "dynamic",
|
||||
label: t("tunnels.typeDynamic"),
|
||||
description:
|
||||
scope === "client"
|
||||
? t("tunnels.typeClientDynamicDesc")
|
||||
: t("tunnels.typeDynamicDesc"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex items-start gap-3 rounded-md border bg-card p-3 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
value={option.value}
|
||||
checked={mode === option.value}
|
||||
onChange={() => onChange(option.value)}
|
||||
className="mt-0.5 w-4 h-4 text-primary border-input focus:ring-ring"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{option.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{option.description}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Card } from "@/components/card.tsx";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Loader2,
|
||||
Pin,
|
||||
Network,
|
||||
Tag,
|
||||
Play,
|
||||
Square,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/badge.tsx";
|
||||
import type { TunnelStatus, SSHTunnelObjectProps } from "@/types/index";
|
||||
|
||||
export function TunnelObject({
|
||||
host,
|
||||
tunnelIndex,
|
||||
tunnelStatuses,
|
||||
tunnelActions,
|
||||
onTunnelAction,
|
||||
compact = false,
|
||||
bare = false,
|
||||
}: SSHTunnelObjectProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getTunnelStatus = (idx: number): TunnelStatus | undefined => {
|
||||
const tunnel = host.tunnelConnections[idx];
|
||||
const tunnelName = `${host.id}::${idx}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
|
||||
return tunnelStatuses[tunnelName];
|
||||
};
|
||||
|
||||
const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => {
|
||||
if (!status)
|
||||
return {
|
||||
icon: <WifiOff className="h-4 w-4" />,
|
||||
text: t("tunnels.unknown"),
|
||||
color: "text-muted-foreground",
|
||||
bgColor: "bg-muted/50",
|
||||
borderColor: "border-border",
|
||||
};
|
||||
|
||||
const statusValue = status.status || "DISCONNECTED";
|
||||
|
||||
switch (statusValue.toUpperCase()) {
|
||||
case "CONNECTED":
|
||||
return {
|
||||
icon: <Wifi className="h-4 w-4" />,
|
||||
text: t("tunnels.connected"),
|
||||
color: "text-green-600 dark:text-green-400",
|
||||
bgColor: "bg-green-500/10 dark:bg-green-400/10",
|
||||
borderColor: "border-green-500/20 dark:border-green-400/20",
|
||||
};
|
||||
case "CONNECTING":
|
||||
return {
|
||||
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
||||
text: t("tunnels.connecting"),
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
bgColor: "bg-blue-500/10 dark:bg-blue-400/10",
|
||||
borderColor: "border-blue-500/20 dark:border-blue-400/20",
|
||||
};
|
||||
case "DISCONNECTING":
|
||||
return {
|
||||
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
||||
text: t("tunnels.disconnecting"),
|
||||
color: "text-orange-600 dark:text-orange-400",
|
||||
bgColor: "bg-orange-500/10 dark:bg-orange-400/10",
|
||||
borderColor: "border-orange-500/20 dark:border-orange-400/20",
|
||||
};
|
||||
case "DISCONNECTED":
|
||||
return {
|
||||
icon: <WifiOff className="h-4 w-4" />,
|
||||
text: t("tunnels.disconnected"),
|
||||
color: "text-muted-foreground",
|
||||
bgColor: "bg-muted/30",
|
||||
borderColor: "border-border",
|
||||
};
|
||||
case "WAITING":
|
||||
return {
|
||||
icon: <Clock className="h-4 w-4" />,
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
bgColor: "bg-blue-500/10 dark:bg-blue-400/10",
|
||||
borderColor: "border-blue-500/20 dark:border-blue-400/20",
|
||||
};
|
||||
case "ERROR":
|
||||
case "FAILED":
|
||||
return {
|
||||
icon: <AlertCircle className="h-4 w-4" />,
|
||||
text: status.reason || t("tunnels.error"),
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
bgColor: "bg-red-500/10 dark:bg-red-400/10",
|
||||
borderColor: "border-red-500/20 dark:border-red-400/20",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <WifiOff className="h-4 w-4" />,
|
||||
text: t("tunnels.unknown"),
|
||||
color: "text-muted-foreground",
|
||||
bgColor: "bg-muted/30",
|
||||
borderColor: "border-border",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (bare) {
|
||||
return (
|
||||
<div className="w-full min-w-0">
|
||||
<div className="space-y-3">
|
||||
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{(tunnelIndex !== undefined
|
||||
? [tunnelIndex]
|
||||
: host.tunnelConnections.map((_, idx) => idx)
|
||||
).map((idx) => {
|
||||
const tunnel = host.tunnelConnections[idx];
|
||||
const status = getTunnelStatus(idx);
|
||||
const statusDisplay = getTunnelStatusDisplay(status);
|
||||
const tunnelName = `${host.id}::${idx}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
|
||||
const isActionLoading = tunnelActions[tunnelName];
|
||||
const statusValue =
|
||||
status?.status?.toUpperCase() || "DISCONNECTED";
|
||||
const isConnected = statusValue === "CONNECTED";
|
||||
const isConnecting = statusValue === "CONNECTING";
|
||||
const isDisconnecting = statusValue === "DISCONNECTING";
|
||||
const isRetrying = statusValue === "RETRYING";
|
||||
const isWaiting = statusValue === "WAITING";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||
<span
|
||||
className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}
|
||||
>
|
||||
{statusDisplay.icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium break-words">
|
||||
{t("tunnels.port")} {tunnel.sourcePort} →{" "}
|
||||
{tunnel.endpointHost}:{tunnel.endpointPort}
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs ${statusDisplay.color} font-medium`}
|
||||
>
|
||||
{statusDisplay.text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[120px]">
|
||||
{!isActionLoading ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onTunnelAction("disconnect", host, idx)
|
||||
}
|
||||
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
||||
>
|
||||
<Square className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.stop")}
|
||||
</Button>
|
||||
</>
|
||||
) : isRetrying || isWaiting ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onTunnelAction("cancel", host, idx)
|
||||
}
|
||||
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.cancel")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onTunnelAction("connect", host, idx)
|
||||
}
|
||||
disabled={isConnecting || isDisconnecting}
|
||||
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
||||
>
|
||||
<Play className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.start")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="h-7 px-2 text-muted-foreground border-border text-xs"
|
||||
>
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
{isConnected
|
||||
? t("tunnels.disconnecting")
|
||||
: isRetrying || isWaiting
|
||||
? t("tunnels.canceling")
|
||||
: t("tunnels.connecting")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(statusValue === "ERROR" || statusValue === "FAILED") &&
|
||||
status?.reason && (
|
||||
<div className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
||||
<div className="font-medium mb-1">
|
||||
{t("tunnels.error")}:
|
||||
</div>
|
||||
{status.reason}
|
||||
{status.reason &&
|
||||
status.reason.includes("Max retries exhausted") && (
|
||||
<>
|
||||
<div className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
|
||||
{t("tunnels.checkDockerLogs")}{" "}
|
||||
<a
|
||||
href="https://discord.com/invite/jVQGdvHDrf"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
{t("tunnels.discord")}
|
||||
</a>{" "}
|
||||
{t("tunnels.orCreate")}{" "}
|
||||
<a
|
||||
href="https://github.com/Termix-SSH/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
{t("tunnels.githubIssue")}
|
||||
</a>{" "}
|
||||
{t("tunnels.forHelp")}.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(statusValue === "RETRYING" ||
|
||||
statusValue === "WAITING") &&
|
||||
status?.retryCount &&
|
||||
status?.maxRetries && (
|
||||
<div className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
||||
<div className="font-medium mb-1">
|
||||
{statusValue === "WAITING"
|
||||
? t("tunnels.waitingForRetry")
|
||||
: t("tunnels.retryingConnection")}
|
||||
</div>
|
||||
<div>
|
||||
{t("tunnels.attempt", {
|
||||
current: status.retryCount,
|
||||
max: status.maxRetries,
|
||||
})}
|
||||
{status.nextRetryIn && (
|
||||
<span>
|
||||
{" "}
|
||||
•{" "}
|
||||
{t("tunnels.nextRetryIn", {
|
||||
seconds: status.nextRetryIn,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
<Network className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">{t("tunnels.noTunnelConnections")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
|
||||
<div className="p-4">
|
||||
{!compact && (
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{host.pin && (
|
||||
<Pin className="h-4 w-4 text-yellow-500 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-card-foreground truncate">
|
||||
{host.name || `${host.username}@${host.ip}`}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.ip}:{host.port} • {host.username}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!compact && host.tags && host.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{host.tags.slice(0, 3).map((tag, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="secondary"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
<Tag className="h-2 w-2 mr-0.5" />
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{host.tags.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
+{host.tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!compact && <Separator className="mb-3" />}
|
||||
|
||||
<div className="space-y-3">
|
||||
{!compact && (
|
||||
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
|
||||
<Network className="h-4 w-4" />
|
||||
{t("tunnels.tunnelConnections")} (
|
||||
{tunnelIndex !== undefined ? 1 : host.tunnelConnections.length})
|
||||
</h4>
|
||||
)}
|
||||
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{(tunnelIndex !== undefined
|
||||
? [tunnelIndex]
|
||||
: host.tunnelConnections.map((_, idx) => idx)
|
||||
).map((idx) => {
|
||||
const tunnel = host.tunnelConnections[idx];
|
||||
const status = getTunnelStatus(idx);
|
||||
const statusDisplay = getTunnelStatusDisplay(status);
|
||||
const tunnelName = `${host.id}::${idx}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
|
||||
const isActionLoading = tunnelActions[tunnelName];
|
||||
const statusValue =
|
||||
status?.status?.toUpperCase() || "DISCONNECTED";
|
||||
const isConnected = statusValue === "CONNECTED";
|
||||
const isConnecting = statusValue === "CONNECTING";
|
||||
const isDisconnecting = statusValue === "DISCONNECTING";
|
||||
const isRetrying = statusValue === "RETRYING";
|
||||
const isWaiting = statusValue === "WAITING";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||
<span
|
||||
className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}
|
||||
>
|
||||
{statusDisplay.icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium break-words">
|
||||
{t("tunnels.port")} {tunnel.sourcePort} →{" "}
|
||||
{tunnel.endpointHost}:{tunnel.endpointPort}
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs ${statusDisplay.color} font-medium`}
|
||||
>
|
||||
{statusDisplay.text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{!isActionLoading && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onTunnelAction("disconnect", host, idx)
|
||||
}
|
||||
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
||||
>
|
||||
<Square className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.stop")}
|
||||
</Button>
|
||||
</>
|
||||
) : isRetrying || isWaiting ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onTunnelAction("cancel", host, idx)
|
||||
}
|
||||
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.cancel")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onTunnelAction("connect", host, idx)
|
||||
}
|
||||
disabled={isConnecting || isDisconnecting}
|
||||
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
||||
>
|
||||
<Play className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.start")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isActionLoading && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="h-7 px-2 text-muted-foreground border-border text-xs"
|
||||
>
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
{isConnected
|
||||
? t("tunnels.disconnecting")
|
||||
: isRetrying || isWaiting
|
||||
? t("tunnels.canceling")
|
||||
: t("tunnels.connecting")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(statusValue === "ERROR" || statusValue === "FAILED") &&
|
||||
status?.reason && (
|
||||
<div className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
||||
<div className="font-medium mb-1">
|
||||
{t("tunnels.error")}:
|
||||
</div>
|
||||
{status.reason}
|
||||
{status.reason &&
|
||||
status.reason.includes("Max retries exhausted") && (
|
||||
<>
|
||||
<div className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
|
||||
{t("tunnels.checkDockerLogs")}{" "}
|
||||
<a
|
||||
href="https://discord.com/invite/jVQGdvHDrf"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
{t("tunnels.discord")}
|
||||
</a>{" "}
|
||||
{t("tunnels.orCreate")}{" "}
|
||||
<a
|
||||
href="https://github.com/Termix-SSH/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
{t("tunnels.githubIssue")}
|
||||
</a>{" "}
|
||||
{t("tunnels.forHelp")}.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(statusValue === "RETRYING" ||
|
||||
statusValue === "WAITING") &&
|
||||
status?.retryCount &&
|
||||
status?.maxRetries && (
|
||||
<div className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
||||
<div className="font-medium mb-1">
|
||||
{statusValue === "WAITING"
|
||||
? t("tunnels.waitingForRetry")
|
||||
: t("tunnels.retryingConnection")}
|
||||
</div>
|
||||
<div>
|
||||
{t("tunnels.attempt", {
|
||||
current: status.retryCount,
|
||||
max: status.maxRetries,
|
||||
})}
|
||||
{status.nextRetryIn && (
|
||||
<span>
|
||||
{" "}
|
||||
•{" "}
|
||||
{t("tunnels.nextRetryIn", {
|
||||
seconds: status.nextRetryIn,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
<Network className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">{t("tunnels.noTunnelConnections")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Card } from "@/components/card";
|
||||
import { Input } from "@/components/input";
|
||||
import { Separator } from "@/components/separator";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import {
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Network,
|
||||
Play,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Square,
|
||||
Trash2,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getSSHHosts,
|
||||
subscribeTunnelStatuses,
|
||||
connectTunnel,
|
||||
disconnectTunnel,
|
||||
cancelTunnel,
|
||||
logActivity,
|
||||
} from "@/main-axios";
|
||||
import type { Host as DemoHost } from "@/types/ui-types";
|
||||
import type { SSHHost, TunnelConnection, TunnelStatus } from "@/types";
|
||||
|
||||
function tunnelName(
|
||||
host: SSHHost,
|
||||
index: number,
|
||||
tunnel: TunnelConnection,
|
||||
): string {
|
||||
const hostKey = host.name || `${host.username}@${host.ip}`;
|
||||
return `${host.id}::${index}::${hostKey}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
|
||||
}
|
||||
|
||||
function statusLabel(status: TunnelStatus | undefined): string {
|
||||
if (!status) return "DISCONNECTED";
|
||||
const s = status.status?.toUpperCase();
|
||||
if (s === "CONNECTED") return "CONNECTED";
|
||||
if (s === "CONNECTING" || s === "VERIFYING") return "CONNECTING";
|
||||
if (s === "FAILED") return "ERROR";
|
||||
if (s === "RETRYING" || s === "WAITING") return "WAITING";
|
||||
if (s === "DISCONNECTING") return "DISCONNECTED";
|
||||
return "DISCONNECTED";
|
||||
}
|
||||
|
||||
function TunnelCard({
|
||||
host,
|
||||
index,
|
||||
tunnel,
|
||||
status,
|
||||
isActing,
|
||||
onAction,
|
||||
}: {
|
||||
host: SSHHost;
|
||||
index: number;
|
||||
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 = tunnel.mode ?? (tunnel.tunnelType as string) ?? "local";
|
||||
const destination =
|
||||
mode === "dynamic"
|
||||
? "SOCKS5 Proxy"
|
||||
: `${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 ${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") : 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">
|
||||
→ localhost:{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">
|
||||
{host.name || `${host.username}@${host.ip}`}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export function TunnelTab({ host }: { label: string; host?: DemoHost }) {
|
||||
const { t } = useTranslation();
|
||||
const [sshHost, setSshHost] = useState<SSHHost | null>(null);
|
||||
const [tunnelStatuses, setTunnelStatuses] = useState<
|
||||
Record<string, TunnelStatus>
|
||||
>({});
|
||||
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const activityLoggedRef = React.useRef(false);
|
||||
|
||||
const fetchHost = useCallback(async () => {
|
||||
if (!host) return;
|
||||
try {
|
||||
const hosts = await getSSHHosts();
|
||||
const found = hosts.find(
|
||||
(h) => String(h.id) === host.id || h.name === host.name,
|
||||
);
|
||||
if (found) setSshHost(found);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [host?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHost();
|
||||
const interval = setInterval(fetchHost, 5000);
|
||||
window.addEventListener("ssh-hosts:changed", fetchHost);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener("ssh-hosts:changed", fetchHost);
|
||||
};
|
||||
}, [fetchHost]);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeTunnelStatuses(setTunnelStatuses, () => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sshHost || activityLoggedRef.current) return;
|
||||
activityLoggedRef.current = true;
|
||||
const name = sshHost.name || `${sshHost.username}@${sshHost.ip}`;
|
||||
logActivity("tunnel", sshHost.id, name).catch(() => {
|
||||
activityLoggedRef.current = false;
|
||||
});
|
||||
}, [sshHost?.id]);
|
||||
|
||||
const handleAction = async (
|
||||
action: "connect" | "disconnect" | "cancel",
|
||||
index: number,
|
||||
) => {
|
||||
if (!sshHost) return;
|
||||
const tunnel = sshHost.tunnelConnections[index];
|
||||
if (!tunnel) return;
|
||||
const name = tunnelName(sshHost, index, tunnel);
|
||||
|
||||
setTunnelActions((prev) => ({ ...prev, [name]: true }));
|
||||
try {
|
||||
if (action === "connect") {
|
||||
const allHosts = await getSSHHosts();
|
||||
const endpointSsh = allHosts.find(
|
||||
(h) =>
|
||||
h.name === tunnel.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnel.endpointHost,
|
||||
);
|
||||
await connectTunnel({
|
||||
name,
|
||||
scope: tunnel.scope ?? "s2s",
|
||||
mode:
|
||||
tunnel.mode ??
|
||||
(tunnel.tunnelType as "local" | "remote" | "dynamic") ??
|
||||
"remote",
|
||||
tunnelType:
|
||||
tunnel.tunnelType ??
|
||||
(tunnel.mode === "local" || tunnel.mode === "remote"
|
||||
? tunnel.mode
|
||||
: "remote"),
|
||||
bindHost: tunnel.bindHost,
|
||||
targetHost: tunnel.targetHost,
|
||||
sourceHostId: sshHost.id,
|
||||
tunnelIndex: index,
|
||||
hostName: sshHost.name || `${sshHost.username}@${sshHost.ip}`,
|
||||
sourceIP: sshHost.ip,
|
||||
sourceSSHPort: sshHost.port,
|
||||
sourceUsername: sshHost.username,
|
||||
sourcePassword:
|
||||
sshHost.authType === "password" ? sshHost.password : undefined,
|
||||
sourceAuthMethod: sshHost.authType,
|
||||
sourceSSHKey: sshHost.authType === "key" ? sshHost.key : undefined,
|
||||
sourceKeyPassword:
|
||||
sshHost.authType === "key" ? sshHost.keyPassword : undefined,
|
||||
sourceKeyType:
|
||||
sshHost.authType === "key" ? sshHost.keyType : undefined,
|
||||
sourceCredentialId: sshHost.credentialId,
|
||||
endpointHost: tunnel.endpointHost ?? "",
|
||||
endpointIP: endpointSsh?.ip ?? tunnel.endpointHost ?? "",
|
||||
endpointSSHPort: endpointSsh?.port ?? 22,
|
||||
endpointUsername: endpointSsh?.username ?? "",
|
||||
endpointPassword:
|
||||
endpointSsh?.authType === "password"
|
||||
? endpointSsh.password
|
||||
: undefined,
|
||||
endpointAuthMethod: endpointSsh?.authType ?? "password",
|
||||
endpointSSHKey:
|
||||
endpointSsh?.authType === "key" ? endpointSsh.key : undefined,
|
||||
endpointKeyPassword:
|
||||
endpointSsh?.authType === "key"
|
||||
? endpointSsh.keyPassword
|
||||
: undefined,
|
||||
endpointKeyType:
|
||||
endpointSsh?.authType === "key" ? endpointSsh.keyType : undefined,
|
||||
endpointCredentialId: endpointSsh?.credentialId,
|
||||
sourcePort: tunnel.sourcePort,
|
||||
endpointPort: tunnel.endpointPort,
|
||||
maxRetries: tunnel.maxRetries,
|
||||
retryInterval: tunnel.retryInterval * 1000,
|
||||
autoStart: tunnel.autoStart,
|
||||
isPinned: sshHost.pin,
|
||||
useSocks5: sshHost.useSocks5,
|
||||
socks5Host: sshHost.socks5Host,
|
||||
socks5Port: sshHost.socks5Port,
|
||||
socks5Username: sshHost.socks5Username,
|
||||
socks5Password: sshHost.socks5Password,
|
||||
});
|
||||
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 (err) {
|
||||
toast.error(t("tunnels.manualControlError"));
|
||||
console.error("Tunnel action failed:", err);
|
||||
} finally {
|
||||
setTunnelActions((prev) => ({ ...prev, [name]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
if (!host) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
|
||||
<div className="size-10 rounded-full 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 = sshHost?.tunnelConnections ?? [];
|
||||
const connectedCount = tunnels.filter((t, i) => {
|
||||
if (!sshHost) return false;
|
||||
const name = tunnelName(sshHost, i, t);
|
||||
return tunnelStatuses[name]?.connected;
|
||||
}).length;
|
||||
|
||||
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">
|
||||
{connectedCount}/{tunnels.length} {t("tunnels.active")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0">
|
||||
<Separator orientation="vertical" className="h-8 mx-3" />
|
||||
<Button variant="ghost" size="icon">
|
||||
<Settings className="size-4 text-accent-brand" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{tunnels.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{tunnels.map((tunnel, index) => {
|
||||
if (!sshHost) return null;
|
||||
const name = tunnelName(sshHost, index, tunnel);
|
||||
return (
|
||||
<TunnelCard
|
||||
key={name}
|
||||
host={sshHost}
|
||||
index={index}
|
||||
tunnel={tunnel}
|
||||
status={tunnelStatuses[name]}
|
||||
isActing={tunnelActions[name] ?? 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,64 @@
|
||||
import React from "react";
|
||||
import { TunnelObject } from "./TunnelObject.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SSHHost, TunnelStatus } from "@/types/index";
|
||||
|
||||
interface SSHTunnelViewerProps {
|
||||
hosts: SSHHost[];
|
||||
tunnelStatuses: Record<string, TunnelStatus>;
|
||||
tunnelActions: Record<string, boolean>;
|
||||
onTunnelAction: (
|
||||
action: "connect" | "disconnect" | "cancel",
|
||||
host: SSHHost,
|
||||
tunnelIndex: number,
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export function TunnelViewer({
|
||||
hosts = [],
|
||||
tunnelStatuses = {},
|
||||
tunnelActions = {},
|
||||
onTunnelAction,
|
||||
}: SSHTunnelViewerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const activeHost: SSHHost | undefined =
|
||||
Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
|
||||
|
||||
if (
|
||||
!activeHost ||
|
||||
!activeHost.tunnelConnections ||
|
||||
activeHost.tunnelConnections.length === 0
|
||||
) {
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-center p-3">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
{t("tunnels.noSshTunnels")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
{t("tunnels.createFirstTunnelMessage")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0">
|
||||
<div className="min-h-0 flex-1 overflow-auto thin-scrollbar pr-1">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{activeHost.tunnelConnections.map((t, idx) => (
|
||||
<TunnelObject
|
||||
key={`tunnel-${activeHost.id}-${idx}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}
|
||||
host={activeHost}
|
||||
tunnelIndex={idx}
|
||||
tunnelStatuses={tunnelStatuses}
|
||||
tunnelActions={tunnelActions}
|
||||
onTunnelAction={onTunnelAction}
|
||||
compact
|
||||
bare
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { TunnelMode } from "@/types/index.js";
|
||||
|
||||
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.remotePort"),
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user