Update ssh tunnel to new system
This commit is contained in:
@@ -471,7 +471,9 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
filteredSshByFolder[folder] = hosts.filter(conn => {
|
||||
const q = debouncedSearch.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return (conn.name || '').toLowerCase().includes(q) || (conn.ip || '').toLowerCase().includes(q);
|
||||
return (conn.name || '').toLowerCase().includes(q) || (conn.ip || '').toLowerCase().includes(q) ||
|
||||
(conn.username || '').toLowerCase().includes(q) || (conn.folder || '').toLowerCase().includes(q) ||
|
||||
(conn.tags || '').toLowerCase().includes(q);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -926,7 +928,7 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search hosts..."
|
||||
placeholder="Search hosts by name, username, IP, folder, tags..."
|
||||
className="w-full h-8 text-sm bg-background border border-border rounded"
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
|
||||
import {
|
||||
CornerDownLeft,
|
||||
Hammer
|
||||
Hammer, Pin
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
@@ -131,10 +131,16 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
|
||||
if (!debouncedSearch.trim()) return hosts;
|
||||
const q = debouncedSearch.trim().toLowerCase();
|
||||
return hosts.filter(h => {
|
||||
const name = (h.name || "").toLowerCase();
|
||||
const ip = (h.ip || "").toLowerCase();
|
||||
const tags = Array.isArray(h.tags) ? h.tags : [];
|
||||
return name.includes(q) || ip.includes(q) || tags.some((tag: string) => tag.toLowerCase().includes(q));
|
||||
const searchableText = [
|
||||
h.name || '',
|
||||
h.username,
|
||||
h.ip,
|
||||
h.folder || '',
|
||||
...(h.tags || []),
|
||||
h.authType,
|
||||
h.defaultPath || ''
|
||||
].join(' ').toLowerCase();
|
||||
return searchableText.includes(q);
|
||||
});
|
||||
}, [hosts, debouncedSearch]);
|
||||
|
||||
@@ -214,7 +220,7 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search hosts..."
|
||||
placeholder="Search hosts by name, username, IP, folder, tags..."
|
||||
className="w-full h-8 text-sm bg-background border border-border rounded"
|
||||
autoComplete="off"
|
||||
/>
|
||||
@@ -343,7 +349,9 @@ const HostMenuItem = React.memo(function HostMenuItem({ host, onHostConnect }: {
|
||||
onClick={() => onHostConnect(host)}
|
||||
>
|
||||
<div className="flex items-center w-full">
|
||||
{host.pin && <span className="text-yellow-400 mr-1 flex-shrink-0">★</span>}
|
||||
{host.pin &&
|
||||
<Pin className="h-3.5 mr-1 w-3.5 mt-0.5 text-yellow-500 flex-shrink-0" />
|
||||
}
|
||||
<span className="font-medium truncate">{host.name || host.ip}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,90 +1,75 @@
|
||||
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 } from "@/apps/SSH/ssh-axios";
|
||||
import axios from "axios";
|
||||
|
||||
interface ConfigEditorProps {
|
||||
onSelectView: (view: string) => void;
|
||||
}
|
||||
|
||||
interface SSHTunnel {
|
||||
id: number;
|
||||
name: string;
|
||||
folder: string;
|
||||
interface TunnelConnection {
|
||||
sourcePort: number;
|
||||
endpointPort: number;
|
||||
sourceIP: string;
|
||||
sourceSSHPort: number;
|
||||
sourceUsername: string;
|
||||
sourcePassword: string;
|
||||
sourceAuthMethod: string;
|
||||
sourceSSHKey: string;
|
||||
sourceKeyPassword: string;
|
||||
sourceKeyType: string;
|
||||
endpointIP: string;
|
||||
endpointSSHPort: number;
|
||||
endpointUsername: string;
|
||||
endpointPassword: string;
|
||||
endpointAuthMethod: string;
|
||||
endpointSSHKey: string;
|
||||
endpointKeyPassword: string;
|
||||
endpointKeyType: string;
|
||||
endpointHost: string;
|
||||
maxRetries: number;
|
||||
retryInterval: number;
|
||||
connectionState: string;
|
||||
autoStart: boolean;
|
||||
isPinned: boolean;
|
||||
}
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
defaultPath: string;
|
||||
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;
|
||||
}
|
||||
|
||||
export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
const [tunnels, setTunnels] = useState<SSHTunnel[]>([]);
|
||||
const [tunnelsLoading, setTunnelsLoading] = useState(false);
|
||||
const [tunnelsError, setTunnelsError] = useState<string | null>(null);
|
||||
const [tunnelStatusMap, setTunnelStatusMap] = useState<Record<string, any>>({});
|
||||
const sidebarRef = React.useRef<any>(null);
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
const [hostStatuses, setHostStatuses] = useState<Record<number, HostStatus>>({});
|
||||
|
||||
const fetchTunnels = useCallback(async () => {
|
||||
setTunnelsLoading(true);
|
||||
setTunnelsError(null);
|
||||
const fetchHosts = useCallback(async () => {
|
||||
try {
|
||||
const jwt = document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1];
|
||||
const res = await axios.get(
|
||||
(window.location.hostname === 'localhost' ? 'http://localhost:8081' : '') + '/ssh_tunnel/tunnel',
|
||||
{ headers: { Authorization: `Bearer ${jwt}` } }
|
||||
);
|
||||
const tunnelData = res.data || [];
|
||||
setTunnels(tunnelData.map((tunnel: any) => ({
|
||||
id: tunnel.id,
|
||||
name: tunnel.name,
|
||||
folder: tunnel.folder || '',
|
||||
sourcePort: tunnel.sourcePort,
|
||||
endpointPort: tunnel.endpointPort,
|
||||
sourceIP: tunnel.sourceIP,
|
||||
sourceSSHPort: tunnel.sourceSSHPort,
|
||||
sourceUsername: tunnel.sourceUsername || '',
|
||||
sourcePassword: tunnel.sourcePassword || '',
|
||||
sourceAuthMethod: tunnel.sourceAuthMethod || 'password',
|
||||
sourceSSHKey: tunnel.sourceSSHKey || '',
|
||||
sourceKeyPassword: tunnel.sourceKeyPassword || '',
|
||||
sourceKeyType: tunnel.sourceKeyType || '',
|
||||
endpointIP: tunnel.endpointIP,
|
||||
endpointSSHPort: tunnel.endpointSSHPort,
|
||||
endpointUsername: tunnel.endpointUsername || '',
|
||||
endpointPassword: tunnel.endpointPassword || '',
|
||||
endpointAuthMethod: tunnel.endpointAuthMethod || 'password',
|
||||
endpointSSHKey: tunnel.endpointSSHKey || '',
|
||||
endpointKeyPassword: tunnel.endpointKeyPassword || '',
|
||||
endpointKeyType: tunnel.endpointKeyType || '',
|
||||
maxRetries: tunnel.maxRetries || 3,
|
||||
retryInterval: tunnel.retryInterval || 5000,
|
||||
connectionState: tunnel.connectionState || 'DISCONNECTED',
|
||||
autoStart: tunnel.autoStart || false,
|
||||
isPinned: tunnel.isPinned || false
|
||||
})));
|
||||
} catch (err: any) {
|
||||
setTunnelsError('Failed to load tunnels');
|
||||
} finally {
|
||||
setTunnelsLoading(false);
|
||||
const hostsData = await getSSHHosts();
|
||||
setHosts(hostsData);
|
||||
} catch (err) {
|
||||
// Silent error handling
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -92,17 +77,75 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
|
||||
const fetchTunnelStatuses = useCallback(async () => {
|
||||
try {
|
||||
const res = await axios.get('http://localhost:8083/status');
|
||||
setTunnelStatusMap(res.data || {});
|
||||
const statusData = res.data || {};
|
||||
|
||||
// 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) {
|
||||
// Determine overall host status based on tunnel statuses
|
||||
const connectedTunnels = hostTunnelStatuses.filter(s => s.status === 'connected');
|
||||
const failedTunnels = hostTunnelStatuses.filter(s => s.status === 'failed');
|
||||
const connectingTunnels = hostTunnelStatuses.filter(s =>
|
||||
['connecting', 'verifying', 'retrying'].includes(s.status)
|
||||
);
|
||||
|
||||
let overallStatus: string;
|
||||
let statusReason: string | undefined;
|
||||
|
||||
if (connectingTunnels.length > 0) {
|
||||
overallStatus = 'connecting';
|
||||
} else if (failedTunnels.length === hostTunnelStatuses.length) {
|
||||
overallStatus = 'failed';
|
||||
statusReason = failedTunnels[0]?.reason;
|
||||
} else if (connectedTunnels.length === hostTunnelStatuses.length) {
|
||||
overallStatus = 'connected';
|
||||
} else if (connectedTunnels.length > 0) {
|
||||
overallStatus = 'connected';
|
||||
} else {
|
||||
overallStatus = 'disconnected';
|
||||
}
|
||||
|
||||
newHostStatuses[host.id] = {
|
||||
connectionState: overallStatus,
|
||||
statusReason,
|
||||
statusErrorType: failedTunnels[0]?.errorType,
|
||||
statusRetryCount: connectingTunnels.find(s => s.status === 'retrying')?.retryCount,
|
||||
statusMaxRetries: connectingTunnels.find(s => s.status === 'retrying')?.maxRetries,
|
||||
statusNextRetryIn: connectingTunnels.find(s => s.status === 'retrying')?.nextRetryIn,
|
||||
statusRetryExhausted: failedTunnels.some(s => s.retryExhausted),
|
||||
};
|
||||
} else {
|
||||
// Set default disconnected status
|
||||
newHostStatuses[host.id] = {
|
||||
connectionState: 'disconnected'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
setHostStatuses(newHostStatuses);
|
||||
} catch (err) {
|
||||
// Optionally handle error
|
||||
// Silent error handling
|
||||
}
|
||||
}, []);
|
||||
}, [hosts]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTunnels();
|
||||
const interval = setInterval(fetchTunnels, 10000);
|
||||
fetchHosts();
|
||||
const interval = setInterval(fetchHosts, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchTunnels]);
|
||||
}, [fetchHosts]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTunnelStatuses();
|
||||
@@ -110,94 +153,88 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchTunnelStatuses]);
|
||||
|
||||
// Merge backend status into tunnels
|
||||
const tunnelsWithStatus = tunnels.map(tunnel => {
|
||||
const status = tunnelStatusMap[tunnel.name] || {};
|
||||
return {
|
||||
...tunnel,
|
||||
connectionState: status.status ? status.status.toUpperCase() : tunnel.connectionState,
|
||||
statusReason: status.reason || '',
|
||||
statusErrorType: status.errorType || '',
|
||||
statusManualDisconnect: status.manualDisconnect || false,
|
||||
statusRetryCount: status.retryCount,
|
||||
statusMaxRetries: status.maxRetries,
|
||||
statusNextRetryIn: status.nextRetryIn,
|
||||
statusRetryExhausted: status.retryExhausted,
|
||||
};
|
||||
});
|
||||
const handleConnect = async (hostId: number) => {
|
||||
const host = hosts.find(h => h.id === hostId);
|
||||
if (!host || !host.tunnelConnections || host.tunnelConnections.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleConnect = async (tunnelId: string) => {
|
||||
// Immediately set to CONNECTING for instant UI feedback
|
||||
setTunnels(prev => prev.map(t =>
|
||||
t.id.toString() === tunnelId
|
||||
? { ...t, connectionState: "CONNECTING" }
|
||||
: t
|
||||
));
|
||||
const tunnel = tunnels.find(t => t.id.toString() === tunnelId);
|
||||
if (!tunnel) return;
|
||||
setHostStatuses(prev => ({
|
||||
...prev,
|
||||
[hostId]: { ...prev[hostId], connectionState: "connecting" }
|
||||
}));
|
||||
|
||||
try {
|
||||
await axios.post('http://localhost:8083/connect', {
|
||||
...tunnel,
|
||||
name: tunnel.name
|
||||
});
|
||||
// No need to update state here; polling will update real status
|
||||
// For each tunnel connection, create a tunnel configuration
|
||||
for (const tunnelConnection of host.tunnelConnections) {
|
||||
// Find the endpoint host configuration
|
||||
const endpointHost = hosts.find(h =>
|
||||
h.name === tunnelConnection.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnelConnection.endpointHost
|
||||
);
|
||||
|
||||
if (!endpointHost) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create tunnel configuration
|
||||
const tunnelConfig = {
|
||||
name: `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`,
|
||||
hostName: host.name || `${host.username}@${host.ip}`,
|
||||
sourceIP: host.ip,
|
||||
sourceSSHPort: host.port,
|
||||
sourceUsername: host.username,
|
||||
sourcePassword: host.password,
|
||||
sourceAuthMethod: host.authType,
|
||||
sourceSSHKey: host.key,
|
||||
sourceKeyPassword: host.keyPassword,
|
||||
sourceKeyType: host.keyType,
|
||||
endpointIP: endpointHost.ip,
|
||||
endpointSSHPort: endpointHost.port,
|
||||
endpointUsername: endpointHost.username,
|
||||
endpointPassword: endpointHost.password,
|
||||
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,
|
||||
isPinned: host.pin
|
||||
};
|
||||
|
||||
await axios.post('http://localhost:8083/connect', tunnelConfig);
|
||||
}
|
||||
} catch (err) {
|
||||
// Optionally handle error
|
||||
// Reset status on error
|
||||
setHostStatuses(prev => ({
|
||||
...prev,
|
||||
[hostId]: { ...prev[hostId], connectionState: "failed", statusReason: "Failed to connect" }
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async (tunnelId: string) => {
|
||||
const handleDisconnect = async (hostId: number) => {
|
||||
const host = hosts.find(h => h.id === hostId);
|
||||
if (!host) return;
|
||||
|
||||
// Immediately set to DISCONNECTING for instant UI feedback
|
||||
setTunnels(prev => prev.map(t =>
|
||||
t.id.toString() === tunnelId
|
||||
? { ...t, connectionState: "DISCONNECTING" }
|
||||
: t
|
||||
));
|
||||
const tunnel = tunnels.find(t => t.id.toString() === tunnelId);
|
||||
if (!tunnel) return;
|
||||
setHostStatuses(prev => ({
|
||||
...prev,
|
||||
[hostId]: { ...prev[hostId], connectionState: "disconnecting" }
|
||||
}));
|
||||
|
||||
try {
|
||||
await axios.post('http://localhost:8083/disconnect', {
|
||||
tunnelName: tunnel.name
|
||||
});
|
||||
// No need to update state here; polling will update real status
|
||||
// Disconnect all tunnels for this host
|
||||
for (const tunnelConnection of host.tunnelConnections) {
|
||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`;
|
||||
await axios.post('http://localhost:8083/disconnect', { tunnelName });
|
||||
}
|
||||
} catch (err) {
|
||||
// Optionally handle error
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTunnel = async (tunnelId: string) => {
|
||||
try {
|
||||
const jwt = document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1];
|
||||
await axios.delete(
|
||||
(window.location.hostname === 'localhost' ? 'http://localhost:8081' : '') + `/ssh_tunnel/tunnel/${tunnelId}`,
|
||||
{ headers: { Authorization: `Bearer ${jwt}` } }
|
||||
);
|
||||
fetchTunnels();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to delete tunnel:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTunnel = async (tunnelId: string, data: any) => {
|
||||
try {
|
||||
const jwt = document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1];
|
||||
await axios.put(
|
||||
(window.location.hostname === 'localhost' ? 'http://localhost:8081' : '') + `/ssh_tunnel/tunnel/${tunnelId}`,
|
||||
data,
|
||||
{ headers: { Authorization: `Bearer ${jwt}` } }
|
||||
);
|
||||
fetchTunnels();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to edit tunnel:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTunnelClick = (tunnelId: string) => {
|
||||
// Find the tunnel data and pass it to the sidebar
|
||||
const tunnel = tunnels.find(t => t.id.toString() === tunnelId);
|
||||
if (tunnel && sidebarRef.current) {
|
||||
// Call the sidebar's openEditSheet function
|
||||
sidebarRef.current.openEditSheet(tunnel);
|
||||
// Silent error handling
|
||||
}
|
||||
};
|
||||
|
||||
@@ -205,19 +242,15 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
|
||||
<div className="flex h-screen w-full">
|
||||
<div className="w-64 flex-shrink-0">
|
||||
<SSHTunnelSidebar
|
||||
ref={sidebarRef}
|
||||
onSelectView={onSelectView}
|
||||
onTunnelAdded={fetchTunnels}
|
||||
onEditTunnel={handleEditTunnelClick}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<SSHTunnelViewer
|
||||
tunnels={tunnelsWithStatus}
|
||||
hosts={hosts}
|
||||
hostStatuses={hostStatuses}
|
||||
onConnect={handleConnect}
|
||||
onDisconnect={handleDisconnect}
|
||||
onDeleteTunnel={handleDeleteTunnel}
|
||||
onEditTunnel={handleEditTunnelClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,8 @@ 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, Edit, Trash2 } from "lucide-react";
|
||||
import { Loader2, Pin, Terminal, Network, FileEdit, Tag } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge.tsx";
|
||||
|
||||
const CONNECTION_STATES = {
|
||||
DISCONNECTED: "disconnected",
|
||||
@@ -15,27 +16,62 @@ const CONNECTION_STATES = {
|
||||
DISCONNECTING: "disconnecting"
|
||||
};
|
||||
|
||||
interface TunnelConnection {
|
||||
sourcePort: number;
|
||||
endpointPort: number;
|
||||
endpointHost: string;
|
||||
maxRetries: number;
|
||||
retryInterval: number;
|
||||
autoStart: boolean;
|
||||
}
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: TunnelConnection[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface SSHTunnelObjectProps {
|
||||
hostConfig: any;
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: () => void;
|
||||
onDelete?: () => void;
|
||||
onEdit?: () => void;
|
||||
connectionState?: keyof typeof CONNECTION_STATES;
|
||||
isPinned?: boolean;
|
||||
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;
|
||||
}
|
||||
|
||||
export function SSHTunnelObject({
|
||||
hostConfig = {},
|
||||
host,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onDelete,
|
||||
onEdit,
|
||||
connectionState = "DISCONNECTED",
|
||||
isPinned = false
|
||||
statusReason,
|
||||
statusErrorType,
|
||||
statusRetryCount,
|
||||
statusMaxRetries,
|
||||
statusNextRetryIn,
|
||||
statusRetryExhausted
|
||||
}: SSHTunnelObjectProps): React.ReactElement {
|
||||
const getStatusColor = (state: keyof typeof CONNECTION_STATES) => {
|
||||
switch (state) {
|
||||
const getStatusColor = (state: string) => {
|
||||
const upperState = state.toUpperCase();
|
||||
switch (upperState) {
|
||||
case "CONNECTED":
|
||||
return "bg-green-500";
|
||||
case "CONNECTING":
|
||||
@@ -51,8 +87,9 @@ export function SSHTunnelObject({
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (state: keyof typeof CONNECTION_STATES) => {
|
||||
switch (state) {
|
||||
const getStatusText = (state: string) => {
|
||||
const upperState = state.toUpperCase();
|
||||
switch (upperState) {
|
||||
case "CONNECTED":
|
||||
return "Connected";
|
||||
case "CONNECTING":
|
||||
@@ -70,43 +107,26 @@ export function SSHTunnelObject({
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const isConnected = connectionState === "CONNECTED";
|
||||
const isConnecting = ["CONNECTING", "VERIFYING", "RETRYING"].includes(connectionState);
|
||||
const isDisconnecting = connectionState === "DISCONNECTING";
|
||||
const isConnected = connectionState === "CONNECTED" || connectionState === "connected";
|
||||
const isConnecting = ["CONNECTING", "VERIFYING", "RETRYING", "connecting", "verifying", "retrying"].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">
|
||||
{/* Hover overlay buttons */}
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-10 flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 w-8 p-0 bg-black/50 hover:bg-black/70 border-0"
|
||||
onClick={onEdit}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-8 w-8 p-0 bg-red-500/50 hover:bg-red-500/70 border-0"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-2">
|
||||
<div className="flex items-center justify-between gap-1 mb-1">
|
||||
<div className="text-lg font-semibold text-card-foreground flex-1 min-w-0">
|
||||
<span className="break-words">
|
||||
{isPinned && <span className="text-yellow-400 mr-1 flex-shrink-0">★</span>}
|
||||
{hostConfig.name || "My SSH Tunnel"}
|
||||
</span>
|
||||
<div className="p-3">
|
||||
{/* Host Header */}
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<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 className="w-px h-4 bg-border mx-1"></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">
|
||||
@@ -115,28 +135,53 @@ export function SSHTunnelObject({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="mb-1" />
|
||||
<div className="space-y-1 mb-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground flex-shrink-0 mr-2">Source:</span>
|
||||
<span className="text-card-foreground font-mono text-right break-all">
|
||||
{hostConfig.source || "localhost:22"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground flex-shrink-0 mr-2">Endpoint:</span>
|
||||
<span className="text-card-foreground font-mono text-right break-all">
|
||||
{hostConfig.endpoint || "test:224"}
|
||||
</span>
|
||||
{/* Tags */}
|
||||
{host.tags && host.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{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>
|
||||
)}
|
||||
|
||||
<Separator className="mb-2" />
|
||||
|
||||
{/* Tunnel Connections */}
|
||||
<div className="space-y-2 mb-3">
|
||||
<h4 className="text-sm font-medium text-card-foreground">Tunnel Connections</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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No tunnel connections configured</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="my-1" />
|
||||
{/* Error/Status Reason */}
|
||||
{((connectionState === "FAILED" || connectionState === "UNSTABLE") && hostConfig.statusReason) && (
|
||||
{((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">
|
||||
{hostConfig.statusReason}
|
||||
{typeof hostConfig.statusReason === 'string' && hostConfig.statusReason.includes('Max retries exhausted') && (
|
||||
{statusReason}
|
||||
{statusReason && statusReason.includes('Max retries exhausted') && (
|
||||
<>
|
||||
<br />
|
||||
<span>
|
||||
@@ -146,9 +191,21 @@ export function SSHTunnelObject({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 mt-2">
|
||||
|
||||
{/* Retry Info */}
|
||||
{connectionState === "RETRYING" && 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">
|
||||
Retry {statusRetryCount}/{statusMaxRetries}
|
||||
{statusNextRetryIn && (
|
||||
<span> • Next retry in {statusNextRetryIn}s</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={onConnect}
|
||||
onClick={() => onConnect?.(host.id)}
|
||||
disabled={isConnected || isConnecting || isDisconnecting}
|
||||
className="flex-1"
|
||||
variant={isConnected ? "secondary" : "default"}
|
||||
@@ -165,7 +222,7 @@ export function SSHTunnelObject({
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onDisconnect}
|
||||
onClick={() => onDisconnect?.(host.id)}
|
||||
disabled={!isConnected || isDisconnecting || isConnecting}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,81 +2,120 @@ import React from "react";
|
||||
import { SSHTunnelObject } from "./SSHTunnelObject.tsx";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface HostStatus {
|
||||
connectionState?: string;
|
||||
statusReason?: string;
|
||||
statusErrorType?: string;
|
||||
statusRetryCount?: number;
|
||||
statusMaxRetries?: number;
|
||||
statusNextRetryIn?: number;
|
||||
statusRetryExhausted?: boolean;
|
||||
}
|
||||
|
||||
interface SSHTunnelViewerProps {
|
||||
tunnels: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
folder: string;
|
||||
sourcePort: number;
|
||||
endpointPort: number;
|
||||
sourceIP: string;
|
||||
sourceSSHPort: number;
|
||||
sourceUsername: string;
|
||||
sourcePassword: string;
|
||||
sourceAuthMethod: string;
|
||||
sourceSSHKey: string;
|
||||
sourceKeyPassword: string;
|
||||
sourceKeyType: string;
|
||||
endpointIP: string;
|
||||
endpointSSHPort: number;
|
||||
endpointUsername: string;
|
||||
endpointPassword: string;
|
||||
endpointAuthMethod: string;
|
||||
endpointSSHKey: string;
|
||||
endpointKeyPassword: string;
|
||||
endpointKeyType: string;
|
||||
maxRetries: number;
|
||||
retryInterval: number;
|
||||
connectionState?: string;
|
||||
autoStart: boolean;
|
||||
isPinned: boolean;
|
||||
}>;
|
||||
onConnect?: (tunnelId: string) => void;
|
||||
onDisconnect?: (tunnelId: string) => void;
|
||||
onDeleteTunnel?: (tunnelId: string) => void;
|
||||
onEditTunnel?: (tunnelId: string) => void;
|
||||
hosts: SSHHost[];
|
||||
hostStatuses?: Record<number, HostStatus>;
|
||||
onConnect?: (hostId: number) => void;
|
||||
onDisconnect?: (hostId: number) => void;
|
||||
}
|
||||
|
||||
export function SSHTunnelViewer({
|
||||
tunnels = [],
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onDeleteTunnel,
|
||||
onEditTunnel
|
||||
hosts = [],
|
||||
hostStatuses = {},
|
||||
onConnect,
|
||||
onDisconnect
|
||||
}: SSHTunnelViewerProps): React.ReactElement {
|
||||
const handleConnect = (tunnelId: string) => {
|
||||
onConnect?.(tunnelId);
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = React.useState("");
|
||||
|
||||
// Debounce search
|
||||
React.useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedSearch(searchQuery), 200);
|
||||
return () => clearTimeout(handler);
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleConnect = (hostId: number) => {
|
||||
onConnect?.(hostId);
|
||||
};
|
||||
|
||||
const handleDisconnect = (tunnelId: string) => {
|
||||
onDisconnect?.(tunnelId);
|
||||
const handleDisconnect = (hostId: number) => {
|
||||
onDisconnect?.(hostId);
|
||||
};
|
||||
|
||||
// Group tunnels by folder and sort
|
||||
const tunnelsByFolder = React.useMemo(() => {
|
||||
const map: Record<string, typeof tunnels> = {};
|
||||
tunnels.forEach(tunnel => {
|
||||
const folder = tunnel.folder && tunnel.folder.trim() ? tunnel.folder : 'No Folder';
|
||||
// Filter hosts by search query
|
||||
const filteredHosts = React.useMemo(() => {
|
||||
if (!debouncedSearch.trim()) return hosts;
|
||||
|
||||
const query = debouncedSearch.trim().toLowerCase();
|
||||
return hosts.filter(host => {
|
||||
const searchableText = [
|
||||
host.name || '',
|
||||
host.username,
|
||||
host.ip,
|
||||
host.folder || '',
|
||||
...(host.tags || []),
|
||||
host.authType,
|
||||
host.defaultPath || ''
|
||||
].join(' ').toLowerCase();
|
||||
return searchableText.includes(query);
|
||||
});
|
||||
}, [hosts, debouncedSearch]);
|
||||
|
||||
// Filter hosts to only show those with enableTunnel: true and tunnelConnections
|
||||
const tunnelHosts = React.useMemo(() => {
|
||||
return filteredHosts.filter(host =>
|
||||
host.enableTunnel &&
|
||||
host.tunnelConnections &&
|
||||
host.tunnelConnections.length > 0
|
||||
);
|
||||
}, [filteredHosts]);
|
||||
|
||||
// Group hosts by folder and sort
|
||||
const hostsByFolder = React.useMemo(() => {
|
||||
const map: Record<string, SSHHost[]> = {};
|
||||
tunnelHosts.forEach(host => {
|
||||
const folder = host.folder && host.folder.trim() ? host.folder : 'Uncategorized';
|
||||
if (!map[folder]) map[folder] = [];
|
||||
map[folder].push(tunnel);
|
||||
map[folder].push(host);
|
||||
});
|
||||
return map;
|
||||
}, [tunnels]);
|
||||
}, [tunnelHosts]);
|
||||
|
||||
const sortedFolders = React.useMemo(() => {
|
||||
const folders = Object.keys(tunnelsByFolder);
|
||||
const folders = Object.keys(hostsByFolder);
|
||||
folders.sort((a, b) => {
|
||||
if (a === 'No Folder') return -1;
|
||||
if (b === 'No Folder') return 1;
|
||||
if (a === 'Uncategorized') return -1;
|
||||
if (b === 'Uncategorized') return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
return folders;
|
||||
}, [tunnelsByFolder]);
|
||||
}, [hostsByFolder]);
|
||||
|
||||
const getSortedTunnels = (arr: typeof tunnels) => {
|
||||
const pinned = arr.filter(t => t.isPinned).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
const rest = arr.filter(t => !t.isPinned).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
const getSortedHosts = (arr: SSHHost[]) => {
|
||||
const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
return [...pinned, ...rest];
|
||||
};
|
||||
|
||||
@@ -93,14 +132,28 @@ export function SSHTunnelViewer({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search hosts by name, username, IP, folder, tags..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Accordion Layout */}
|
||||
{tunnels.length === 0 ? (
|
||||
{tunnelHosts.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
No SSH Tunnels
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
Create your first SSH tunnel to get started. Use the sidebar to add a new tunnel configuration.
|
||||
{searchQuery.trim() ?
|
||||
"No hosts match your search criteria." :
|
||||
"Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections."
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -112,16 +165,19 @@ export function SSHTunnelViewer({
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-1 px-3 pb-2 pt-1">
|
||||
<div className="grid grid-cols-4 gap-6 w-full">
|
||||
{getSortedTunnels(tunnelsByFolder[folder]).map((tunnel, tunnelIndex) => (
|
||||
<div key={tunnel.id} className="w-full">
|
||||
{getSortedHosts(hostsByFolder[folder]).map((host, hostIndex) => (
|
||||
<div key={host.id} className="w-full">
|
||||
<SSHTunnelObject
|
||||
hostConfig={tunnel}
|
||||
connectionState={tunnel.connectionState as any}
|
||||
isPinned={tunnel.isPinned}
|
||||
onConnect={() => handleConnect(tunnel.id.toString())}
|
||||
onDisconnect={() => handleDisconnect(tunnel.id.toString())}
|
||||
onDelete={() => onDeleteTunnel?.(tunnel.id.toString())}
|
||||
onEdit={() => onEditTunnel?.(tunnel.id.toString())}
|
||||
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)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user