Files
Termix/src/apps/SSH/Manager/SSHManagerHostEditor.tsx
LukeGus abeba66432 Fixes
2025-07-26 21:33:39 -05:00

1023 lines
63 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {zodResolver} from "@hookform/resolvers/zod"
import {Controller, useForm} from "react-hook-form"
import {z} from "zod"
import {Button} from "@/components/ui/button.tsx"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form.tsx";
import {Input} from "@/components/ui/input.tsx";
import {ScrollArea} from "@/components/ui/scroll-area"
import {Separator} from "@/components/ui/separator.tsx";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
import React, {useEffect, useRef, useState} from "react";
import {Switch} from "@/components/ui/switch.tsx";
import {Alert, AlertDescription} from "@/components/ui/alert.tsx";
import { createSSHHost, updateSSHHost, getSSHHosts } from '@/apps/SSH/ssh-axios';
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: any[];
createdAt: string;
updatedAt: string;
}
interface SSHManagerHostEditorProps {
editingHost?: SSHHost | null;
onFormSubmit?: () => void;
}
export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHostEditorProps) {
// State for dynamic data
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
// State for authentication tab selection
const [authTab, setAuthTab] = useState<'password' | 'key'>('password');
// Fetch hosts and extract folders and configurations
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const hostsData = await getSSHHosts();
setHosts(hostsData);
// Extract unique folders (excluding empty ones)
const uniqueFolders = [...new Set(
hostsData
.filter(host => host.folder && host.folder.trim() !== '')
.map(host => host.folder)
)].sort();
// Extract unique host names for SSH configurations
const uniqueConfigurations = [...new Set(
hostsData
.filter(host => host.name && host.name.trim() !== '')
.map(host => host.name)
)].sort();
setFolders(uniqueFolders);
setSshConfigurations(uniqueConfigurations);
} catch (error) {
console.error('Failed to fetch hosts:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
// Create dynamic form schema based on fetched data
const formSchema = z.object({
name: z.string().optional(),
ip: z.string().min(1),
port: z.coerce.number().min(1).max(65535),
username: z.string().min(1),
folder: z.string().optional(),
tags: z.array(z.string().min(1)).default([]),
pin: z.boolean().default(false),
authType: z.enum(['password', 'key']),
password: z.string().optional(),
key: z.instanceof(File).optional().nullable(),
keyPassword: z.string().optional(),
keyType: z.enum([
'auto',
'ssh-rsa',
'ssh-ed25519',
'ecdsa-sha2-nistp256',
'ecdsa-sha2-nistp384',
'ecdsa-sha2-nistp521',
'ssh-dss',
'ssh-rsa-sha2-256',
'ssh-rsa-sha2-512',
]).optional(),
enableTerminal: z.boolean().default(true),
enableTunnel: z.boolean().default(true),
tunnelConnections: z.array(z.object({
sourcePort: z.coerce.number().min(1).max(65535),
endpointPort: z.coerce.number().min(1).max(65535),
endpointHost: z.string().min(1),
maxRetries: z.coerce.number().min(0).max(100).default(3),
retryInterval: z.coerce.number().min(1).max(3600).default(10),
autoStart: z.boolean().default(false),
})).default([]),
enableConfigEditor: z.boolean().default(true),
defaultPath: z.string().optional(),
}).superRefine((data, ctx) => {
// Conditional validation based on authType
if (data.authType === 'password') {
if (!data.password || data.password.trim() === '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Password is required when using password authentication",
path: ['password']
});
}
} else if (data.authType === 'key') {
if (!data.key) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "SSH Private Key is required when using key authentication",
path: ['key']
});
}
if (!data.keyType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Key Type is required when using key authentication",
path: ['keyType']
});
}
}
// Validate endpointHost against available configurations
data.tunnelConnections.forEach((connection, index) => {
if (connection.endpointHost && !sshConfigurations.includes(connection.endpointHost)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Must select a valid SSH configuration from the list",
path: ['tunnelConnections', index, 'endpointHost']
});
}
});
});
type FormData = z.infer<typeof formSchema>;
const form = useForm<FormData>({
resolver: zodResolver(formSchema) as any,
defaultValues: {
name: editingHost?.name || "",
ip: editingHost?.ip || "",
port: editingHost?.port || 22,
username: editingHost?.username || "",
folder: editingHost?.folder || "",
tags: editingHost?.tags || [],
pin: editingHost?.pin || false,
authType: (editingHost?.authType as 'password' | 'key') || "password",
password: "",
key: null,
keyPassword: "",
keyType: "auto",
enableTerminal: editingHost?.enableTerminal !== false,
enableTunnel: editingHost?.enableTunnel !== false,
enableConfigEditor: editingHost?.enableConfigEditor !== false,
defaultPath: editingHost?.defaultPath || "/",
tunnelConnections: editingHost?.tunnelConnections || [],
}
});
// Update form when editingHost changes
useEffect(() => {
if (editingHost) {
// Determine the default auth type based on what's available
const defaultAuthType = editingHost.key ? 'key' : 'password';
// Update the auth tab state
setAuthTab(defaultAuthType);
form.reset({
name: editingHost.name || "",
ip: editingHost.ip || "",
port: editingHost.port || 22,
username: editingHost.username || "",
folder: editingHost.folder || "",
tags: editingHost.tags || [],
pin: editingHost.pin || false,
authType: defaultAuthType,
password: editingHost.password || "",
key: editingHost.key ? new File([editingHost.key], "key.pem") : null,
keyPassword: editingHost.keyPassword || "",
keyType: (editingHost.keyType as any) || "auto",
enableTerminal: editingHost.enableTerminal !== false,
enableTunnel: editingHost.enableTunnel !== false,
enableConfigEditor: editingHost.enableConfigEditor !== false,
defaultPath: editingHost.defaultPath || "/",
tunnelConnections: editingHost.tunnelConnections || [],
});
} else {
// Reset to password tab for new hosts
setAuthTab('password');
form.reset({
name: "",
ip: "",
port: 22,
username: "",
folder: "",
tags: [],
pin: false,
authType: "password",
password: "",
key: null,
keyPassword: "",
keyType: "auto",
enableTerminal: true,
enableTunnel: true,
enableConfigEditor: true,
defaultPath: "/",
tunnelConnections: [],
});
}
}, [editingHost, form]);
const onSubmit = async (data: any) => {
try {
const formData = data as FormData;
// Set default name if empty or undefined
if (!formData.name || formData.name.trim() === '') {
formData.name = `${formData.username}@${formData.ip}`;
}
if (editingHost) {
await updateSSHHost(editingHost.id, formData);
console.log('Host updated successfully');
} else {
await createSSHHost(formData);
console.log('Host created successfully');
}
// Call the callback to redirect to host viewer
if (onFormSubmit) {
onFormSubmit();
}
} catch (error) {
console.error('Failed to save host:', error);
alert('Failed to save host. Please try again.');
}
};
// Tag input state
const [tagInput, setTagInput] = useState("");
// Folder dropdown state
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
const folderInputRef = useRef<HTMLInputElement>(null);
const folderDropdownRef = useRef<HTMLDivElement>(null);
// Folder filtering logic
const folderValue = form.watch('folder');
const filteredFolders = React.useMemo(() => {
if (!folderValue) return folders;
return folders.filter(f => f.toLowerCase().includes(folderValue.toLowerCase()));
}, [folderValue, folders]);
// Handle folder click
const handleFolderClick = (folder: string) => {
form.setValue('folder', folder);
setFolderDropdownOpen(false);
};
// Close dropdown on outside click
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
folderDropdownRef.current &&
!folderDropdownRef.current.contains(event.target as Node) &&
folderInputRef.current &&
!folderInputRef.current.contains(event.target as Node)
) {
setFolderDropdownOpen(false);
}
}
if (folderDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [folderDropdownOpen]);
// keyType Dropdown
const keyTypeOptions = [
{value: 'auto', label: 'Auto-detect'},
{value: 'ssh-rsa', label: 'RSA'},
{value: 'ssh-ed25519', label: 'ED25519'},
{value: 'ecdsa-sha2-nistp256', label: 'ECDSA NIST P-256'},
{value: 'ecdsa-sha2-nistp384', label: 'ECDSA NIST P-384'},
{value: 'ecdsa-sha2-nistp521', label: 'ECDSA NIST P-521'},
{value: 'ssh-dss', label: 'DSA'},
{value: 'ssh-rsa-sha2-256', label: 'RSA SHA2-256'},
{value: 'ssh-rsa-sha2-512', label: 'RSA SHA2-512'},
];
const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false);
const keyTypeButtonRef = useRef<HTMLButtonElement>(null);
const keyTypeDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function onClickOutside(event: MouseEvent) {
if (
keyTypeDropdownOpen &&
keyTypeDropdownRef.current &&
!keyTypeDropdownRef.current.contains(event.target as Node) &&
keyTypeButtonRef.current &&
!keyTypeButtonRef.current.contains(event.target as Node)
) {
setKeyTypeDropdownOpen(false);
}
}
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, [keyTypeDropdownOpen]);
// SSH Configuration dropdown state and logic
const [sshConfigDropdownOpen, setSshConfigDropdownOpen] = useState<{ [key: number]: boolean }>({});
const sshConfigInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>({});
const sshConfigDropdownRefs = useRef<{ [key: number]: HTMLDivElement | null }>({});
// SSH Configuration filtering logic
const getFilteredSshConfigs = (index: number) => {
const value = form.watch(`tunnelConnections.${index}.endpointHost`);
// Get current host name to exclude it from the list
const currentHostName = form.watch('name') || `${form.watch('username')}@${form.watch('ip')}`;
// Filter out the current host and apply search filter
let filtered = sshConfigurations.filter(config => config !== currentHostName);
if (value) {
filtered = filtered.filter(config =>
config.toLowerCase().includes(value.toLowerCase())
);
}
return filtered;
};
// Handle SSH configuration click
const handleSshConfigClick = (config: string, index: number) => {
form.setValue(`tunnelConnections.${index}.endpointHost`, config);
setSshConfigDropdownOpen(prev => ({ ...prev, [index]: false }));
};
// Close SSH configuration dropdown on outside click
useEffect(() => {
function handleSshConfigClickOutside(event: MouseEvent) {
const openDropdowns = Object.keys(sshConfigDropdownOpen).filter(key => sshConfigDropdownOpen[parseInt(key)]);
openDropdowns.forEach((indexStr: string) => {
const index = parseInt(indexStr);
if (
sshConfigDropdownRefs.current[index] &&
!sshConfigDropdownRefs.current[index]?.contains(event.target as Node) &&
sshConfigInputRefs.current[index] &&
!sshConfigInputRefs.current[index]?.contains(event.target as Node)
) {
setSshConfigDropdownOpen(prev => ({ ...prev, [index]: false }));
}
});
}
const hasOpenDropdowns = Object.values(sshConfigDropdownOpen).some(open => open);
if (hasOpenDropdowns) {
document.addEventListener('mousedown', handleSshConfigClickOutside);
} else {
document.removeEventListener('mousedown', handleSshConfigClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleSshConfigClickOutside);
};
}, [sshConfigDropdownOpen]);
return (
<div className="flex-1 flex flex-col h-full min-h-0 w-full">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0 h-full">
<ScrollArea className="flex-1 min-h-0 w-full my-1">
<Tabs defaultValue="general" className="w-full">
<TabsList>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="terminal">Terminal</TabsTrigger>
<TabsTrigger value="tunnel">Tunnel</TabsTrigger>
<TabsTrigger value="config_editor">Config Editor</TabsTrigger>
</TabsList>
<TabsContent value="general">
<FormLabel className="mb-3 font-bold">Connection Details</FormLabel>
<div className="grid grid-cols-12 gap-4">
<FormField
control={form.control}
name="ip"
render={({field}) => (
<FormItem className="col-span-5">
<FormLabel>IP</FormLabel>
<FormControl>
<Input placeholder="127.0.0.1" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
render={({field}) => (
<FormItem className="col-span-1">
<FormLabel>Port</FormLabel>
<FormControl>
<Input placeholder="22" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({field}) => (
<FormItem className="col-span-6">
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="username" {...field} />
</FormControl>
</FormItem>
)}
/>
</div>
<FormLabel className="mb-3 mt-3 font-bold">Organization</FormLabel>
<div className="grid grid-cols-26 gap-4">
<FormField
control={form.control}
name="name"
render={({field}) => (
<FormItem className="col-span-10">
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="host name" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="folder"
render={({field}) => (
<FormItem className="col-span-10 relative">
<FormLabel>Folder</FormLabel>
<FormControl>
<Input
ref={folderInputRef}
placeholder="folder"
className="min-h-[40px]"
autoComplete="off"
value={field.value}
onFocus={() => setFolderDropdownOpen(true)}
onChange={e => {
field.onChange(e);
setFolderDropdownOpen(true);
}}
/>
</FormControl>
{/* Folder dropdown menu */}
{folderDropdownOpen && filteredFolders.length > 0 && (
<div
ref={folderDropdownRef}
className="absolute top-full left-0 z-50 mt-1 w-full bg-[#18181b] border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{filteredFolders.map((folder) => (
<Button
key={folder}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded px-2 py-1.5 hover:bg-white/15 focus:bg-white/20 focus:outline-none"
onClick={() => handleFolderClick(folder)}
>
{folder}
</Button>
))}
</div>
</div>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="tags"
render={({ field }) => (
<FormItem className="col-span-10 overflow-visible">
<FormLabel>Tags</FormLabel>
<FormControl>
<div className="flex flex-wrap items-center gap-1 border border-input rounded-md px-3 py-2 bg-[#222225] focus-within:ring-2 ring-ring min-h-[40px]">
{field.value.map((tag: string, idx: number) => (
<span key={tag + idx} className="flex items-center bg-gray-200 text-gray-800 rounded-full px-2 py-0.5 text-xs">
{tag}
<button
type="button"
className="ml-1 text-gray-500 hover:text-red-500 focus:outline-none"
onClick={() => {
const newTags = field.value.filter((_: string, i: number) => i !== idx);
field.onChange(newTags);
}}
>
×
</button>
</span>
))}
<input
type="text"
className="flex-1 min-w-[60px] border-none outline-none bg-transparent p-0 h-6"
value={tagInput}
onChange={e => setTagInput(e.target.value)}
onKeyDown={e => {
if (e.key === " " && tagInput.trim() !== "") {
e.preventDefault();
if (!field.value.includes(tagInput.trim())) {
field.onChange([...field.value, tagInput.trim()]);
}
setTagInput("");
} else if (e.key === "Backspace" && tagInput === "" && field.value.length > 0) {
field.onChange(field.value.slice(0, -1));
}
}}
placeholder="add tags (space to add)"
/>
</div>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="pin"
render={({field}) => (
<FormItem className="col-span-6">
<FormLabel>Pin Connection</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<FormLabel className="mb-3 mt-3 font-bold">Authentication</FormLabel>
<Tabs
value={authTab}
onValueChange={(value) => {
setAuthTab(value as 'password' | 'key');
form.setValue('authType', value as 'password' | 'key');
}}
className="flex-1 flex flex-col h-full min-h-0"
>
<TabsList>
<TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="key">Key</TabsTrigger>
</TabsList>
<TabsContent value="password">
<FormField
control={form.control}
name="password"
render={({field}) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder="password" {...field} />
</FormControl>
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="key">
<div className="grid grid-cols-15 gap-4">
<Controller
control={form.control}
name="key"
render={({field}) => (
<FormItem className="col-span-4 overflow-hidden min-w-0">
<FormLabel>SSH Private Key</FormLabel>
<FormControl>
<div className="relative min-w-0">
<input
id="key-upload"
type="file"
accept=".pem,.key,.txt,.ppk"
onChange={(e) => {
const file = e.target.files?.[0];
field.onChange(file || null);
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="w-full min-w-0 overflow-hidden px-3 py-2 text-left"
>
<span className="block w-full truncate"
title={field.value?.name || 'Upload'}>
{field.value ? (editingHost ? 'Update Key' : field.value.name) : 'Upload'}
</span>
</Button>
</div>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="keyPassword"
render={({field}) => (
<FormItem className="col-span-8">
<FormLabel>Key Password</FormLabel>
<FormControl>
<Input placeholder="key password" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="keyType"
render={({field}) => (
<FormItem className="relative col-span-3">
<FormLabel>Key Type</FormLabel>
<FormControl>
<div className="relative">
<Button
ref={keyTypeButtonRef}
type="button"
variant="outline"
className="w-full justify-start text-left rounded-md px-2 py-2 bg-[#18181b] border border-input text-foreground"
onClick={() => setKeyTypeDropdownOpen((open) => !open)}
>
{keyTypeOptions.find((opt) => opt.value === field.value)?.label || "Auto-detect"}
</Button>
{keyTypeDropdownOpen && (
<div
ref={keyTypeDropdownRef}
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-[#18181b] border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{keyTypeOptions.map((opt) => (
<Button
key={opt.value}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded-md px-2 py-1.5 bg-[#18181b] text-foreground hover:bg-white/15 focus:bg-white/20 focus:outline-none"
onClick={() => {
field.onChange(opt.value);
setKeyTypeDropdownOpen(false);
}}
>
{opt.label}
</Button>
))}
</div>
</div>
)}
</div>
</FormControl>
</FormItem>
)}
/>
</div>
</TabsContent>
</Tabs>
</TabsContent>
<TabsContent value="terminal">
<FormField
control={form.control}
name="enableTerminal"
render={({field}) => (
<FormItem>
<FormLabel>Enable Terminal</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
Enable/disable host visibility in Terminal tab.
</FormDescription>
</FormItem>
)}
/>
{form.watch('enableTerminal') && (
<div className="mt-4">
{/* Tunnel Config (none yet) */}
</div>
)}
</TabsContent>
<TabsContent value="tunnel">
<FormField
control={form.control}
name="enableTunnel"
render={({field}) => (
<FormItem>
<FormLabel>Enable Tunnel</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
Enable/disable host visibility in Tunnel tab.
</FormDescription>
</FormItem>
)}
/>
{form.watch('enableTunnel') && (
<>
<Alert className="mt-4">
<AlertDescription>
<strong>Sshpass Required For Password Authentication</strong>
<div>
For password-based SSH authentication, sshpass must be installed on both the local and remote servers. Install with: <code className="bg-muted px-1 rounded inline">sudo apt install sshpass</code> (Debian/Ubuntu) or the equivalent for your OS.
</div>
<div className="mt-2">
<strong>Other installation methods:</strong>
<div> CentOS/RHEL/Fedora: <code className="bg-muted px-1 rounded inline">sudo yum install sshpass</code> or <code className="bg-muted px-1 rounded inline">sudo dnf install sshpass</code></div>
<div> macOS: <code className="bg-muted px-1 rounded inline">brew install hudochenkov/sshpass/sshpass</code></div>
<div> Windows: Use WSL or consider SSH key authentication</div>
</div>
</AlertDescription>
</Alert>
<Alert className="mt-4">
<AlertDescription>
<strong>SSH Server Configuration Required</strong>
<div>For reverse SSH tunnels, the endpoint SSH server must allow:</div>
<div> <code className="bg-muted px-1 rounded inline">GatewayPorts yes</code> (bind remote ports)</div>
<div> <code className="bg-muted px-1 rounded inline">AllowTcpForwarding yes</code> (port forwarding)</div>
<div> <code className="bg-muted px-1 rounded inline">PermitRootLogin yes</code> (if using root)</div>
<div className="mt-2">Edit <code className="bg-muted px-1 rounded inline">/etc/ssh/sshd_config</code> and restart SSH: <code className="bg-muted px-1 rounded inline">sudo systemctl restart sshd</code></div>
</AlertDescription>
</Alert>
<FormField
control={form.control}
name="tunnelConnections"
render={({field}) => (
<FormItem className="mt-4">
<FormLabel>Tunnel Connections</FormLabel>
<FormControl>
<div className="space-y-4">
{field.value.map((connection, index) => (
<div key={index} className="p-4 border rounded-lg bg-muted/50">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-bold">Connection {index + 1}</h4>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const newConnections = field.value.filter((_, i) => i !== index);
field.onChange(newConnections);
}}
>
Remove
</Button>
</div>
<div className="grid grid-cols-12 gap-4">
<FormField
control={form.control}
name={`tunnelConnections.${index}.sourcePort`}
render={({field: sourcePortField}) => (
<FormItem className="col-span-4">
<FormLabel>Source Port (Local)</FormLabel>
<FormControl>
<Input placeholder="22" {...sourcePortField} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`tunnelConnections.${index}.endpointPort`}
render={({field: endpointPortField}) => (
<FormItem className="col-span-4">
<FormLabel>Endpoint Port (Remote)</FormLabel>
<FormControl>
<Input placeholder="224" {...endpointPortField} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`tunnelConnections.${index}.endpointHost`}
render={({field: endpointHostField}) => (
<FormItem className="col-span-4 relative">
<FormLabel>Endpoint SSH Configuration</FormLabel>
<FormControl>
<Input
ref={(el) => {
sshConfigInputRefs.current[index] = el;
}}
placeholder="endpoint ssh configuration"
className="min-h-[40px]"
autoComplete="off"
value={endpointHostField.value}
onFocus={() => setSshConfigDropdownOpen(prev => ({ ...prev, [index]: true }))}
onChange={e => {
endpointHostField.onChange(e);
setSshConfigDropdownOpen(prev => ({ ...prev, [index]: true }));
}}
/>
</FormControl>
{/* SSH Configuration dropdown menu */}
{sshConfigDropdownOpen[index] && getFilteredSshConfigs(index).length > 0 && (
<div
ref={(el) => {
sshConfigDropdownRefs.current[index] = el;
}}
className="absolute top-full left-0 z-50 mt-1 w-full bg-[#18181b] border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{getFilteredSshConfigs(index).map((config) => (
<Button
key={config}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded px-2 py-1.5 hover:bg-white/15 focus:bg-white/20 focus:outline-none"
onClick={() => handleSshConfigClick(config, index)}
>
{config}
</Button>
))}
</div>
</div>
)}
</FormItem>
)}
/>
</div>
<p className="text-sm text-muted-foreground mt-2">
This tunnel will forward traffic from port {form.watch(`tunnelConnections.${index}.sourcePort`) || '22'} on the source machine (current connection details in general tab) to port {form.watch(`tunnelConnections.${index}.endpointPort`) || '224'} on the endpoint machine.
</p>
<div className="grid grid-cols-12 gap-4 mt-4">
<FormField
control={form.control}
name={`tunnelConnections.${index}.maxRetries`}
render={({field: maxRetriesField}) => (
<FormItem className="col-span-4">
<FormLabel>Max Retries</FormLabel>
<FormControl>
<Input placeholder="3" {...maxRetriesField} />
</FormControl>
<FormDescription>
Maximum number of retry attempts for tunnel connection.
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`tunnelConnections.${index}.retryInterval`}
render={({field: retryIntervalField}) => (
<FormItem className="col-span-4">
<FormLabel>Retry Interval (seconds)</FormLabel>
<FormControl>
<Input placeholder="10" {...retryIntervalField} />
</FormControl>
<FormDescription>
Time to wait between retry attempts.
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`tunnelConnections.${index}.autoStart`}
render={({field}) => (
<FormItem className="col-span-4">
<FormLabel>Auto Start on Container Launch</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
Automatically start this tunnel when the container launches.
</FormDescription>
</FormItem>
)}
/>
</div>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() => {
field.onChange([...field.value, {
sourcePort: 22,
endpointPort: 224,
endpointHost: "",
maxRetries: 3,
retryInterval: 10,
autoStart: false,
}]);
}}
>
Add Tunnel Connection
</Button>
</div>
</FormControl>
</FormItem>
)}
/>
</>
)}
</TabsContent>
<TabsContent value="config_editor">
<FormField
control={form.control}
name="enableConfigEditor"
render={({field}) => (
<FormItem>
<FormLabel>Enable Config Editor</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
Enable/disable host visibility in Config Editor tab.
</FormDescription>
</FormItem>
)}
/>
{form.watch('enableConfigEditor') && (
<div className="mt-4">
<FormField
control={form.control}
name="defaultPath"
render={({field}) => (
<FormItem>
<FormLabel>Default Path</FormLabel>
<FormControl>
<Input placeholder="/home" {...field} />
</FormControl>
<FormDescription>Set default directory shown when connected via Config Editor</FormDescription>
</FormItem>
)}
/>
</div>
)}
</TabsContent>
</Tabs>
</ScrollArea>
<footer className="shrink-0 w-full">
<Separator className="p-0.25 mt-1 mb-3"/>
<Button type="submit">{editingHost ? "Update Host" : "Add Host"}</Button>
</footer>
</form>
</Form>
</div>
);
}