Individual SSH Tunnel control

This commit is contained in:
LukeGus
2025-07-27 16:04:32 -05:00
parent 32945adcd9
commit 634e625eef
6 changed files with 491 additions and 354 deletions

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback } from "react";
import { SSHTunnelSidebar } from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx";
import { SSHTunnelViewer } from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx";
import { getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel } from "@/apps/SSH/ssh-axios";
import { getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel } from "@/apps/SSH/ssh-axios";
interface ConfigEditorProps {
onSelectView: (view: string) => void;
@@ -39,16 +39,6 @@ interface SSHHost {
updatedAt: string;
}
interface HostStatus {
connectionState?: string;
statusReason?: string;
statusErrorType?: string;
statusRetryCount?: number;
statusMaxRetries?: number;
statusNextRetryIn?: number;
statusRetryExhausted?: boolean;
}
interface TunnelStatus {
status: string;
reason?: string;
@@ -61,7 +51,8 @@ interface TunnelStatus {
export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactElement {
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [hostStatuses, setHostStatuses] = useState<Record<number, HostStatus>>({});
const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({});
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>({}); // Track loading states
const fetchHosts = useCallback(async () => {
try {
@@ -76,48 +67,11 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
const fetchTunnelStatuses = useCallback(async () => {
try {
const statusData = await getTunnelStatuses();
// Convert tunnel statuses to host statuses
const newHostStatuses: Record<number, HostStatus> = {};
hosts.forEach(host => {
// Find all tunnel statuses for this host
const hostName = host.name || `${host.username}@${host.ip}`;
const hostTunnelStatuses: TunnelStatus[] = [];
// Look for tunnel statuses that start with this host name
Object.entries(statusData).forEach(([tunnelName, status]) => {
if (tunnelName.startsWith(hostName + '_')) {
hostTunnelStatuses.push(status as any);
}
});
if (hostTunnelStatuses.length > 0) {
// Just use the first tunnel's status for now - simplify
const firstTunnelStatus = hostTunnelStatuses[0];
newHostStatuses[host.id] = {
connectionState: firstTunnelStatus.status,
statusReason: firstTunnelStatus.reason,
statusErrorType: firstTunnelStatus.errorType,
statusRetryCount: firstTunnelStatus.retryCount,
statusMaxRetries: firstTunnelStatus.maxRetries,
statusNextRetryIn: firstTunnelStatus.nextRetryIn,
statusRetryExhausted: firstTunnelStatus.retryExhausted,
};
} else {
// Set default disconnected status
newHostStatuses[host.id] = {
connectionState: 'disconnected'
};
}
});
setHostStatuses(newHostStatuses);
setTunnelStatuses(statusData);
} catch (err) {
// Silent error handling
}
}, [hosts]);
}, []);
useEffect(() => {
fetchHosts();
@@ -131,76 +85,66 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
return () => clearInterval(interval);
}, [fetchTunnelStatuses]);
const handleConnect = async (hostId: number) => {
const host = hosts.find(h => h.id === hostId);
if (!host || !host.tunnelConnections || host.tunnelConnections.length === 0) {
return;
}
// Let the backend handle the status updates
const handleTunnelAction = async (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => {
const tunnel = host.tunnelConnections[tunnelIndex];
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
setTunnelActions(prev => ({ ...prev, [tunnelName]: true }));
try {
// For each tunnel connection, create a tunnel configuration
for (const tunnelConnection of host.tunnelConnections) {
if (action === 'connect') {
// Find the endpoint host configuration
const endpointHost = hosts.find(h =>
h.name === tunnelConnection.endpointHost ||
`${h.username}@${h.ip}` === tunnelConnection.endpointHost
h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost
);
if (!endpointHost) {
continue;
throw new Error('Endpoint host not found');
}
// Create tunnel configuration
const tunnelConfig = {
name: `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`,
name: tunnelName,
hostName: host.name || `${host.username}@${host.ip}`,
sourceIP: host.ip,
sourceSSHPort: host.port,
sourceUsername: host.username,
sourcePassword: host.password,
sourcePassword: host.authType === 'password' ? host.password : undefined,
sourceAuthMethod: host.authType,
sourceSSHKey: host.key,
sourceKeyPassword: host.keyPassword,
sourceKeyType: host.keyType,
sourceSSHKey: host.authType === 'key' ? host.key : undefined,
sourceKeyPassword: host.authType === 'key' ? host.keyPassword : undefined,
sourceKeyType: host.authType === 'key' ? host.keyType : undefined,
endpointIP: endpointHost.ip,
endpointSSHPort: endpointHost.port,
endpointUsername: endpointHost.username,
endpointPassword: endpointHost.password,
endpointPassword: endpointHost.authType === 'password' ? endpointHost.password : undefined,
endpointAuthMethod: endpointHost.authType,
endpointSSHKey: endpointHost.key,
endpointKeyPassword: endpointHost.keyPassword,
endpointKeyType: endpointHost.keyType,
sourcePort: tunnelConnection.sourcePort,
endpointPort: tunnelConnection.endpointPort,
maxRetries: tunnelConnection.maxRetries,
retryInterval: tunnelConnection.retryInterval * 1000, // Convert to milliseconds
autoStart: tunnelConnection.autoStart,
endpointSSHKey: endpointHost.authType === 'key' ? endpointHost.key : undefined,
endpointKeyPassword: endpointHost.authType === 'key' ? endpointHost.keyPassword : undefined,
endpointKeyType: endpointHost.authType === 'key' ? endpointHost.keyType : undefined,
sourcePort: tunnel.sourcePort,
endpointPort: tunnel.endpointPort,
maxRetries: tunnel.maxRetries,
retryInterval: tunnel.retryInterval * 1000, // Convert to milliseconds
autoStart: tunnel.autoStart,
isPinned: host.pin
};
await connectTunnel(tunnelConfig);
}
} catch (err) {
// Let the backend handle error status updates
}
};
const handleDisconnect = async (hostId: number) => {
const host = hosts.find(h => h.id === hostId);
if (!host) return;
// Let the backend handle the status updates
try {
// Disconnect all tunnels for this host
for (const tunnelConnection of host.tunnelConnections) {
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`;
} else if (action === 'disconnect') {
await disconnectTunnel(tunnelName);
} else if (action === 'cancel') {
await cancelTunnel(tunnelName);
}
// Refresh statuses after action
await fetchTunnelStatuses();
} catch (err) {
// Silent error handling
console.error(`Failed to ${action} tunnel:`, err);
// Let the backend handle error status updates
} finally {
setTunnelActions(prev => ({ ...prev, [tunnelName]: false }));
}
};
@@ -214,9 +158,9 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
<div className="flex-1 overflow-auto">
<SSHTunnelViewer
hosts={hosts}
hostStatuses={hostStatuses}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={handleTunnelAction}
/>
</div>
</div>

View File

@@ -2,7 +2,7 @@ import React from "react";
import { Button } from "@/components/ui/button.tsx";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { Loader2, Pin, Terminal, Network, FileEdit, Tag } from "lucide-react";
import { Loader2, Pin, Terminal, Network, FileEdit, Tag, Play, Square, AlertCircle, Clock, Wifi, WifiOff, Zap, X } from "lucide-react";
import { Badge } from "@/components/ui/badge.tsx";
const CONNECTION_STATES = {
@@ -45,64 +45,112 @@ interface SSHHost {
updatedAt: string;
}
interface TunnelStatus {
status: string;
reason?: string;
errorType?: string;
retryCount?: number;
maxRetries?: number;
nextRetryIn?: number;
retryExhausted?: boolean;
}
interface SSHTunnelObjectProps {
host: SSHHost;
onConnect?: (hostId: number) => void;
onDisconnect?: (hostId: number) => void;
connectionState?: keyof typeof CONNECTION_STATES | string;
statusReason?: string;
statusErrorType?: string;
statusRetryCount?: number;
statusMaxRetries?: number;
statusNextRetryIn?: number;
statusRetryExhausted?: boolean;
tunnelStatuses: Record<string, TunnelStatus>;
tunnelActions: Record<string, boolean>;
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<void>;
}
export function SSHTunnelObject({
host,
onConnect,
onDisconnect,
connectionState = "DISCONNECTED",
statusReason,
statusErrorType,
statusRetryCount,
statusMaxRetries,
statusNextRetryIn,
statusRetryExhausted
tunnelStatuses,
tunnelActions,
onTunnelAction
}: SSHTunnelObjectProps): React.ReactElement {
const getStatusColor = (state: string) => {
const upperState = state.toUpperCase();
switch (upperState) {
case "CONNECTED":
return "bg-green-500";
case "CONNECTING":
case "VERIFYING":
case "RETRYING":
case "WAITING":
return "bg-yellow-500";
case "FAILED":
return "bg-red-500";
case "UNSTABLE":
return "bg-orange-500";
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
const tunnel = host.tunnelConnections[tunnelIndex];
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
return tunnelStatuses[tunnelName];
};
const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => {
if (!status) return {
icon: <WifiOff className="h-4 w-4" />,
text: 'Unknown',
color: 'text-muted-foreground',
bgColor: 'bg-muted/50',
borderColor: 'border-border'
};
// Handle both the old format (status.status) and new format (status.status)
const statusValue = status.status || 'DISCONNECTED';
switch (statusValue.toUpperCase()) {
case 'CONNECTED':
return {
icon: <Wifi className="h-4 w-4" />,
text: '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: '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: '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: '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 || '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 "bg-gray-500";
return {
icon: <WifiOff className="h-4 w-4" />,
text: statusValue,
color: 'text-muted-foreground',
bgColor: 'bg-muted/30',
borderColor: 'border-border'
};
}
};
const getStatusText = (state: string) => {
// Just capitalize the first letter of the status from backend
return state.charAt(0).toUpperCase() + state.slice(1);
};
const isConnected = connectionState === "CONNECTED" || connectionState === "connected";
const isConnecting = ["CONNECTING", "VERIFYING", "RETRYING", "WAITING", "connecting", "verifying", "retrying", "waiting"].includes(connectionState);
const isDisconnecting = connectionState === "DISCONNECTING" || connectionState === "disconnecting";
return (
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
<div className="p-3">
<div className="p-4">
{/* Host Header */}
<div className="flex items-center justify-between gap-2 mb-2">
<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">
@@ -114,17 +162,11 @@ export function SSHTunnelObject({
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className={`w-2 h-2 rounded-full ${getStatusColor(connectionState)}`} />
<span className="text-sm text-muted-foreground whitespace-nowrap">
{getStatusText(connectionState)}
</span>
</div>
</div>
{/* Tags */}
{host.tags && host.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
<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" />
@@ -139,91 +181,143 @@ export function SSHTunnelObject({
</div>
)}
<Separator className="mb-2" />
<Separator className="mb-3" />
{/* Tunnel Connections */}
<div className="space-y-2 mb-3">
<h4 className="text-sm font-medium text-card-foreground">Tunnel Connections</h4>
<div className="space-y-3">
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
<Network className="h-4 w-4" />
Tunnel Connections ({host.tunnelConnections.length})
</h4>
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
<div className="space-y-1">
{host.tunnelConnections.map((tunnel, index) => (
<div key={index} className="text-xs bg-muted/50 rounded px-2 py-1">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Port {tunnel.sourcePort} {tunnel.endpointHost}:{tunnel.endpointPort}</span>
{tunnel.autoStart && (
<Badge variant="outline" className="text-xs px-1 py-0">
Auto
</Badge>
<div className="space-y-3">
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
const status = getTunnelStatus(tunnelIndex);
const statusDisplay = getTunnelStatusDisplay(status);
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${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={tunnelIndex} className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
{/* Tunnel Header */}
<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">
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-2 flex-shrink-0">
{tunnel.autoStart && (
<Badge variant="outline" className="text-xs px-2 py-1">
<Zap className="h-3 w-3 mr-1" />
Auto
</Badge>
)}
{/* Action Button */}
{!isActionLoading && (
<>
{isConnected ? (
<Button
size="sm"
variant="outline"
onClick={() => onTunnelAction('disconnect', host, tunnelIndex)}
className="h-8 px-3 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" />
Disconnect
</Button>
) : isRetrying || isWaiting ? (
<Button
size="sm"
variant="outline"
onClick={() => onTunnelAction('cancel', host, tunnelIndex)}
className="h-8 px-3 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"
>
<X className="h-3 w-3 mr-1" />
Cancel
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => onTunnelAction('connect', host, tunnelIndex)}
disabled={isConnecting || isDisconnecting}
className="h-8 px-3 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" />
Connect
</Button>
)}
</>
)}
{isActionLoading && (
<Button
size="sm"
variant="outline"
disabled
className="h-8 px-3 text-muted-foreground border-border"
>
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
{isConnected ? 'Disconnecting...' : isRetrying || isWaiting ? 'Canceling...' : 'Connecting...'}
</Button>
)}
</div>
</div>
{/* Error/Status Reason */}
{(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">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">
Check your Docker logs for the error reason, join the <a href="https://discord.com/invite/jVQGdvHDrf" target="_blank" rel="noopener noreferrer" className="underline text-blue-600 dark:text-blue-400">Discord</a> or create a <a href="https://github.com/LukeGus/Termix/issues/new" target="_blank" rel="noopener noreferrer" className="underline text-blue-600 dark:text-blue-400">GitHub issue</a> for help.
</div>
</>
)}
</div>
)}
{/* Retry Info */}
{(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' ? 'Waiting for retry' : 'Retrying connection'}
</div>
<div>
Attempt {status.retryCount} of {status.maxRetries}
{status.nextRetryIn && (
<span> Next retry in {status.nextRetryIn} seconds</span>
)}
</div>
</div>
)}
</div>
</div>
))}
);
})}
</div>
) : (
<p className="text-xs text-muted-foreground">No tunnel connections configured</p>
<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">No tunnel connections configured</p>
</div>
)}
</div>
{/* Error/Status Reason */}
{((connectionState === "FAILED" || connectionState === "UNSTABLE") && statusReason) && (
<div className="mb-2 text-xs text-red-500 bg-red-500/10 rounded px-2 py-1 border border-red-500/20">
{statusReason}
{statusReason && statusReason.includes('Max retries exhausted') && (
<>
<br />
<span>
Check your Docker logs for the error reason, join the <a href="https://discord.com/invite/jVQGdvHDrf" target="_blank" rel="noopener noreferrer" className="underline text-blue-400">Discord</a> or create a <a href="https://github.com/LukeGus/Termix/issues/new" target="_blank" rel="noopener noreferrer" className="underline text-blue-400">GitHub issue</a> for help.
</span>
</>
)}
</div>
)}
{/* Retry Info */}
{(connectionState === "retrying" || connectionState === "waiting") && statusRetryCount && statusMaxRetries && (
<div className="mb-2 text-xs text-yellow-600 bg-yellow-500/10 rounded px-2 py-1 border border-yellow-500/20">
{connectionState === "waiting" ? "Waiting" : "Retry"} {statusRetryCount}/{statusMaxRetries}
{statusNextRetryIn && (
<span> Next retry in {statusNextRetryIn}s</span>
)}
</div>
)}
{/* Action Buttons */}
<div className="flex gap-2">
<Button
onClick={() => onConnect?.(host.id)}
disabled={isConnected || isConnecting || isDisconnecting}
className="flex-1"
variant={isConnected ? "secondary" : "default"}
>
{isConnecting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{getStatusText(connectionState)}...
</>
) : isConnected ? (
"Connected"
) : (
"Connect"
)}
</Button>
<Button
onClick={() => onDisconnect?.(host.id)}
disabled={!isConnected || isDisconnecting || isConnecting}
variant="outline"
className="flex-1"
>
{isDisconnecting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Disconnecting...
</>
) : (
"Disconnect"
)}
</Button>
</div>
</div>
</Card>
);

View File

@@ -5,6 +5,15 @@ import { Separator } from "@/components/ui/separator.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Search } from "lucide-react";
interface TunnelConnection {
sourcePort: number;
endpointPort: number;
endpointHost: string;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
}
interface SSHHost {
id: number;
name: string;
@@ -19,33 +28,33 @@ interface SSHHost {
enableTunnel: boolean;
enableConfigEditor: boolean;
defaultPath: string;
tunnelConnections: any[];
tunnelConnections: TunnelConnection[];
createdAt: string;
updatedAt: string;
}
interface HostStatus {
connectionState?: string;
statusReason?: string;
statusErrorType?: string;
statusRetryCount?: number;
statusMaxRetries?: number;
statusNextRetryIn?: number;
statusRetryExhausted?: boolean;
interface TunnelStatus {
status: string;
reason?: string;
errorType?: string;
retryCount?: number;
maxRetries?: number;
nextRetryIn?: number;
retryExhausted?: boolean;
}
interface SSHTunnelViewerProps {
hosts: SSHHost[];
hostStatuses?: Record<number, HostStatus>;
onConnect?: (hostId: number) => void;
onDisconnect?: (hostId: number) => void;
tunnelStatuses: Record<string, TunnelStatus>;
tunnelActions: Record<string, boolean>;
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<void>;
}
export function SSHTunnelViewer({
hosts = [],
hostStatuses = {},
onConnect,
onDisconnect
tunnelStatuses = {},
tunnelActions = {},
onTunnelAction
}: SSHTunnelViewerProps): React.ReactElement {
const [searchQuery, setSearchQuery] = React.useState("");
const [debouncedSearch, setDebouncedSearch] = React.useState("");
@@ -56,14 +65,6 @@ export function SSHTunnelViewer({
return () => clearTimeout(handler);
}, [searchQuery]);
const handleConnect = (hostId: number) => {
onConnect?.(hostId);
};
const handleDisconnect = (hostId: number) => {
onDisconnect?.(hostId);
};
// Filter hosts by search query
const filteredHosts = React.useMemo(() => {
if (!debouncedSearch.trim()) return hosts;
@@ -169,15 +170,9 @@ export function SSHTunnelViewer({
<div key={host.id} className="w-full">
<SSHTunnelObject
host={host}
connectionState={hostStatuses[host.id]?.connectionState as any}
statusReason={hostStatuses[host.id]?.statusReason}
statusErrorType={hostStatuses[host.id]?.statusErrorType}
statusRetryCount={hostStatuses[host.id]?.statusRetryCount}
statusMaxRetries={hostStatuses[host.id]?.statusMaxRetries}
statusNextRetryIn={hostStatuses[host.id]?.statusNextRetryIn}
statusRetryExhausted={hostStatuses[host.id]?.statusRetryExhausted}
onConnect={() => handleConnect(host.id)}
onDisconnect={() => handleDisconnect(host.id)}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={onTunnelAction}
/>
</div>
))}

View File

@@ -280,7 +280,7 @@ export async function getSSHHostById(hostId: number): Promise<SSHHost> {
// Tunnel-related functions
// Get tunnel statuses
// Get all tunnel statuses (per-tunnel)
export async function getTunnelStatuses(): Promise<Record<string, TunnelStatus>> {
try {
// Determine the tunnel API URL based on environment
@@ -293,7 +293,13 @@ export async function getTunnelStatuses(): Promise<Record<string, TunnelStatus>>
}
}
// Connect tunnel
// Get status for a specific tunnel by tunnel name
export async function getTunnelStatusByName(tunnelName: string): Promise<TunnelStatus | undefined> {
const statuses = await getTunnelStatuses();
return statuses[tunnelName];
}
// Connect tunnel (per-tunnel)
export async function connectTunnel(tunnelConfig: TunnelConfig): Promise<any> {
try {
// Determine the tunnel API URL based on environment
@@ -306,7 +312,7 @@ export async function connectTunnel(tunnelConfig: TunnelConfig): Promise<any> {
}
}
// Disconnect tunnel
// Disconnect tunnel (per-tunnel)
export async function disconnectTunnel(tunnelName: string): Promise<any> {
try {
// Determine the tunnel API URL based on environment
@@ -319,4 +325,16 @@ export async function disconnectTunnel(tunnelName: string): Promise<any> {
}
}
export async function cancelTunnel(tunnelName: string): Promise<any> {
try {
// Determine the tunnel API URL based on environment
const tunnelUrl = isLocalhost ? 'http://localhost:8083/cancel' : `${baseURL}/ssh_tunnel/cancel`;
const response = await tunnelApi.post(tunnelUrl, { tunnelName });
return response.data;
} catch (error) {
console.error('Error canceling tunnel:', error);
throw error;
}
}
export { api };