feat: initial ui redesign from demo

This commit is contained in:
LukeGus
2026-05-11 01:23:11 -05:00
parent eaa758effe
commit 33dcde0827
349 changed files with 23984 additions and 31634 deletions
+248
View File
@@ -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}
/>
);
}
+48
View File
@@ -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>
);
}
+169
View File
@@ -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>
);
}
+532
View File
@@ -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>
);
}
+473
View File
@@ -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>
);
}
+64
View File
@@ -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);
}