Finalized ssh tunnels, updatetd database schemas, started on config editor.

This commit is contained in:
LukeGus
2025-07-21 01:25:38 -05:00
parent cfaa04e42c
commit 547701378f
18 changed files with 4791 additions and 25 deletions

View File

@@ -0,0 +1,128 @@
import React, { useState, useEffect } from "react";
import CodeMirror from "@uiw/react-codemirror";
import type { LanguageName } from '@uiw/codemirror-extensions-langs';
import { loadLanguage } from '@uiw/codemirror-extensions-langs';
import { hyperLink } from '@uiw/codemirror-extensions-hyper-link';
import { EditorView } from '@codemirror/view';
import { createTheme } from '@uiw/codemirror-themes';
import { tags as t } from '@lezer/highlight';
interface ConfigCodeEditorProps {
content: string;
fileName: string;
onContentChange: (value: string) => void;
}
export function ConfigCodeEditor({content, fileName, onContentChange,}: ConfigCodeEditorProps) {
const langName = getLanguageName(fileName);
const langExt = langName ? loadLanguage(langName) : null;
const extensions = [hyperLink];
if (langExt) extensions.unshift(langExt);
// Custom theme based on built-in 'dark', with overrides for background, gutter, and font size
const customDarkTheme = [
createTheme({
theme: 'dark',
settings: {
background: '#09090b',
gutterBackground: '#18181b',
gutterForeground: 'oklch(0.985 0 0)',
foreground: '#e0e0e0',
caret: '#ffcc00',
selection: '#22223b99',
selectionMatch: '#22223b66',
lineHighlight: '#18181b',
gutterBorder: '1px solid #22223b',
},
styles: [
{ tag: t.keyword, color: '#ff5370' }, // red
{ tag: t.string, color: '#c3e88d' }, // green
{ tag: t.number, color: '#82aaff' }, // blue
{ tag: t.comment, color: '#5c6370' }, // gray
{ tag: t.variableName, color: '#f78c6c' }, // orange
{ tag: t.function(t.variableName), color: '#82aaff' }, // blue
{ tag: t.typeName, color: '#ffcb6b' }, // yellow
{ tag: t.className, color: '#ffcb6b' }, // yellow
{ tag: t.definition(t.typeName), color: '#ffcb6b' }, // yellow
{ tag: t.operator, color: '#89ddff' }, // cyan
{ tag: t.bool, color: '#f78c6c' }, // orange
{ tag: t.null, color: '#f78c6c' }, // orange
{ tag: t.tagName, color: '#ff5370' }, // red
{ tag: t.attributeName, color: '#c792ea' }, // purple
{ tag: t.angleBracket, color: '#89ddff' }, // cyan
],
}),
EditorView.theme({
'&': {
fontSize: '13px',
},
}),
];
function getLanguageName(filename: string): LanguageName | undefined {
const ext = filename.slice(filename.lastIndexOf('.') + 1).toLowerCase();
switch (ext) {
case 'js':
case 'mjs':
case 'cjs': return 'javascript';
case 'ts': return 'typescript';
case 'tsx': return 'tsx';
case 'json': return 'json';
case 'css': return 'css';
case 'html':
case 'htm': return 'html';
case 'md': return 'markdown';
case 'py': return 'python';
case 'sh': return 'shell';
case 'yaml':
case 'yml': return 'yaml';
case 'go': return 'go';
case 'java': return 'java';
case 'c': return 'c';
case 'cpp':
case 'cc':
case 'cxx': return 'cpp';
case 'rs': return 'rust';
case 'php': return 'php';
case 'rb': return 'ruby';
case 'swift': return 'swift';
case 'lua': return 'lua';
case 'xml': return 'xml';
case 'sql': return 'sql';
default: return undefined;
}
}
useEffect(() => {
document.body.style.overflowX = 'hidden';
return () => {
document.body.style.overflowX = '';
};
}, []);
return (
<div style={{ width: '100%', height: '100%', position: 'relative', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div
style={{
width: '100%',
height: '100%',
overflow: 'auto',
flex: 1,
display: 'flex',
flexDirection: 'column',
}}
className="config-codemirror-scroll-wrapper"
>
<CodeMirror
value={content}
extensions={extensions.concat(customDarkTheme)}
onChange={(value: any) => onContentChange(value)}
theme={customDarkTheme}
height="100%"
basicSetup={{ lineNumbers: true }}
style={{ minHeight: '100%', minWidth: '100%', flex: 1 }}
/>
</div>
</div>
);
}

View File

@@ -1,18 +1,33 @@
import React, { useState } from "react";
import {ConfigEditorSidebar} from "@/apps/Config Editor/ConfigEditorSidebar.tsx";
import React from "react";
import {ConfigCodeEditor} from "@/apps/Config Editor/ConfigCodeEditor.tsx";
import {ConfigTopbar} from "@/apps/Config Editor/ConfigTopbar.tsx";
interface ConfigEditorProps {
onSelectView: (view: string) => void;
}
export function ConfigEditor({ onSelectView }: ConfigEditorProps): React.ReactElement {
export function ConfigEditor({onSelectView}: ConfigEditorProps): React.ReactElement {
const [content, setContent] = useState<string>("");
const [fileName, setFileName] = useState<string>("config.yaml");
return (
<div>
<ConfigEditorSidebar
onSelectView={onSelectView}
/>
Config Editor
<div style={{ position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden' }}>
{/* Sidebar - fixed width, full height */}
<div style={{ position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20 }}>
<ConfigEditorSidebar onSelectView={onSelectView} />
</div>
{/* Topbar - fixed height, full width minus sidebar */}
<div style={{ position: 'absolute', top: 0, left: 256, right: 0, height: 46, zIndex: 30 }}>
<ConfigTopbar />
</div>
{/* Editor area - fills remaining space, with padding for sidebar and topbar */}
<div style={{ position: 'absolute', top: 46, left: 256, right: 0, bottom: 0, overflow: 'hidden', zIndex: 10 }}>
<ConfigCodeEditor
content={content}
fileName={fileName}
onContentChange={setContent}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,16 @@
import React from "react";
export function ConfigTopbar(): React.ReactElement {
return (
<div className="flex h-11.5 z-100" style={{
position: 'relative',
width: '100%',
height: 46,
backgroundColor: '#18181b',
borderBottom: '1px solid #222224',
zIndex: 100,
}}>
test
</div>
)
}

View File

@@ -211,6 +211,14 @@ export function HomepageAuth({ className, setLoggedIn, setIsAdmin, setUsername,
>
Discord
</Button>
<div className="w-px h-4 bg-border"></div>
<Button
variant="link"
className="text-sm"
onClick={() => window.open('https://www.paypal.com/paypalme/LukeGustafson803', '_blank')}
>
Fund
</Button>
</div>
</div>
</div>

View File

@@ -1,18 +1,225 @@
import React from "react";
import {SSHTunnelSidebar} from "@/apps/SSH Tunnel/SSHTunnelSidebar.tsx";
import React, { useState, useEffect, useCallback } from "react";
import { SSHTunnelSidebar } from "@/apps/SSH Tunnel/SSHTunnelSidebar.tsx";
import { SSHTunnelViewer } from "@/apps/SSH Tunnel/SSHTunnelViewer.tsx";
import axios from "axios";
interface ConfigEditorProps {
onSelectView: (view: string) => void;
}
export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactElement {
return (
<div>
<SSHTunnelSidebar
onSelectView={onSelectView}
/>
interface SSHTunnel {
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;
}
SSH Tunnel
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 fetchTunnels = useCallback(async () => {
setTunnelsLoading(true);
setTunnelsError(null);
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);
}
}, []);
// Poll backend for tunnel statuses
const fetchTunnelStatuses = useCallback(async () => {
try {
const res = await axios.get('http://localhost:8083/status');
setTunnelStatusMap(res.data || {});
} catch (err) {
// Optionally handle error
}
}, []);
useEffect(() => {
fetchTunnels();
const interval = setInterval(fetchTunnels, 10000);
return () => clearInterval(interval);
}, [fetchTunnels]);
useEffect(() => {
fetchTunnelStatuses();
const interval = setInterval(fetchTunnelStatuses, 500);
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 (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;
try {
await axios.post('http://localhost:8083/connect', {
...tunnel,
name: tunnel.name
});
// No need to update state here; polling will update real status
} catch (err) {
// Optionally handle error
}
};
const handleDisconnect = async (tunnelId: string) => {
// 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;
try {
await axios.post('http://localhost:8083/disconnect', {
tunnelName: tunnel.name
});
// No need to update state here; polling will update real status
} 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);
}
};
return (
<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}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
onDeleteTunnel={handleDeleteTunnel}
onEditTunnel={handleEditTunnelClick}
/>
</div>
</div>
)
);
}

View File

@@ -0,0 +1,186 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Loader2, Edit, Trash2 } from "lucide-react";
const CONNECTION_STATES = {
DISCONNECTED: "disconnected",
CONNECTING: "connecting",
CONNECTED: "connected",
VERIFYING: "verifying",
FAILED: "failed",
UNSTABLE: "unstable",
RETRYING: "retrying",
DISCONNECTING: "disconnecting"
};
interface SSHTunnelObjectProps {
hostConfig: any;
onConnect?: () => void;
onDisconnect?: () => void;
onDelete?: () => void;
onEdit?: () => void;
connectionState?: keyof typeof CONNECTION_STATES;
isPinned?: boolean;
}
export function SSHTunnelObject({
hostConfig = {},
onConnect,
onDisconnect,
onDelete,
onEdit,
connectionState = "DISCONNECTED",
isPinned = false
}: SSHTunnelObjectProps): React.ReactElement {
const getStatusColor = (state: keyof typeof CONNECTION_STATES) => {
switch (state) {
case "CONNECTED":
return "bg-green-500";
case "CONNECTING":
case "VERIFYING":
case "RETRYING":
return "bg-yellow-500";
case "FAILED":
return "bg-red-500";
case "UNSTABLE":
return "bg-orange-500";
default:
return "bg-gray-500";
}
};
const getStatusText = (state: keyof typeof CONNECTION_STATES) => {
switch (state) {
case "CONNECTED":
return "Connected";
case "CONNECTING":
return "Connecting";
case "VERIFYING":
return "Verifying";
case "FAILED":
return "Failed";
case "UNSTABLE":
return "Unstable";
case "RETRYING":
return "Retrying";
default:
return "Disconnected";
}
};
const isConnected = connectionState === "CONNECTED";
const isConnecting = ["CONNECTING", "VERIFYING", "RETRYING"].includes(connectionState);
const isDisconnecting = 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>
<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">
{getStatusText(connectionState)}
</span>
</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>
</div>
</div>
<Separator className="my-1" />
{/* Error/Status Reason */}
{((connectionState === "FAILED" || connectionState === "UNSTABLE") && hostConfig.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') && (
<>
<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>
)}
<div className="flex gap-2 mt-2">
<Button
onClick={onConnect}
disabled={isConnected || isConnecting || isDisconnecting}
className="flex-1"
variant={isConnected ? "secondary" : "default"}
>
{isConnecting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Connecting...
</>
) : isConnected ? (
"Connected"
) : (
"Connect"
)}
</Button>
<Button
onClick={onDisconnect}
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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,137 @@
import React from "react";
import { SSHTunnelObject } from "./SSHTunnelObject";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator";
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;
}
export function SSHTunnelViewer({
tunnels = [],
onConnect,
onDisconnect,
onDeleteTunnel,
onEditTunnel
}: SSHTunnelViewerProps): React.ReactElement {
const handleConnect = (tunnelId: string) => {
onConnect?.(tunnelId);
};
const handleDisconnect = (tunnelId: string) => {
onDisconnect?.(tunnelId);
};
// 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';
if (!map[folder]) map[folder] = [];
map[folder].push(tunnel);
});
return map;
}, [tunnels]);
const sortedFolders = React.useMemo(() => {
const folders = Object.keys(tunnelsByFolder);
folders.sort((a, b) => {
if (a === 'No Folder') return -1;
if (b === 'No Folder') return 1;
return a.localeCompare(b);
});
return folders;
}, [tunnelsByFolder]);
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 || ''));
return [...pinned, ...rest];
};
return (
<div className="w-full p-6" style={{ width: 'calc(100vw - 256px)', maxWidth: 'none' }}>
<div className="w-full min-w-0" style={{ width: '100%', maxWidth: 'none' }}>
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-foreground mb-2">
SSH Tunnels
</h1>
<p className="text-muted-foreground">
Manage your SSH tunnel connections
</p>
</div>
{/* Accordion Layout */}
{tunnels.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.
</p>
</div>
) : (
<Accordion type="multiple" className="w-full" defaultValue={sortedFolders}>
{sortedFolders.map((folder, idx) => (
<AccordionItem value={folder} key={`folder-${folder}`} className={idx === 0 ? "mt-0" : "mt-2"}>
<AccordionTrigger className="text-base font-semibold rounded-t-none px-3 py-2" style={{marginTop: idx === 0 ? 0 : undefined}}>
{folder}
</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">
<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())}
/>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)}
</div>
</div>
);
}