Added tools (run multi cmds), fixed UI scrolling, added SSH algo's and key types.
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 221 KiB |
80
public/icon.svg
Normal file
80
public/icon.svg
Normal file
@@ -0,0 +1,80 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="201.47884mm"
|
||||
height="168.06284mm"
|
||||
viewBox="0 0 201.47884 168.06284"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(-99.862555,-88.982953)">
|
||||
<g
|
||||
id="g2"
|
||||
transform="matrix(1.238566,0,0,1.238566,-497.97829,-269.39983)">
|
||||
<path
|
||||
id="path68"
|
||||
style="fill:#3f3f45;fill-opacity:1;stroke:none;stroke-width:3.175;stroke-linecap:square;stroke-miterlimit:8;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 493.79462,290.70621 h 140.28116 c 5.3225,0 9.6074,4.28488 9.6074,9.60736 v 113.30061 c 0,5.32248 -4.2849,9.60737 -9.6074,9.60737 H 493.79462 c -5.3224,0 -9.6073,-4.28489 -9.6073,-9.60737 V 300.31357 c 0,-5.32248 4.2849,-9.60736 9.6073,-9.60736 z" />
|
||||
<path
|
||||
id="path69"
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#090909;fill-opacity:1;stroke-linecap:square;stroke-miterlimit:8;enable-background:accumulate;stop-color:#000000"
|
||||
d="m 493.88312,289.353 c -6.1745,0 -11.1952,5.02069 -11.1952,11.19518 v 113.30058 c 0,6.1745 5.0207,11.1957 11.1952,11.1957 h 140.28066 c 6.1745,0 11.1952,-5.0212 11.1952,-11.1957 V 300.54818 c 0,-6.1745 -5.0207,-11.19518 -11.1952,-11.19518 z m 0,3.17552 h 140.28066 c 4.4705,0 8.0197,3.5492 8.0197,8.01966 V 385.0717 H 485.86132 v -84.52352 c 0,-4.47046 3.5513,-8.01966 8.0218,-8.01966 z m -8.0218,95.71818 h 156.32216 v 25.60206 c 0,4.47047 -3.5492,8.01966 -8.0197,8.01966 H 493.88312 c -4.4705,0 -8.0218,-3.54919 -8.0218,-8.01966 z" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-weight:800;font-size:25.1593px;line-height:1.25;font-family:'Argentum Sans';-inkscape-font-specification:'Argentum Sans, Ultra-Bold';letter-spacing:0px;word-spacing:0px;fill:#e4e4e4;fill-opacity:1;stroke-width:0.336953"
|
||||
x="497.09778"
|
||||
y="410.97794"
|
||||
id="text69"><tspan
|
||||
id="tspan69"
|
||||
style="fill:#e4e4e4;fill-opacity:1;stroke-width:0.336953"
|
||||
x="497.09778"
|
||||
y="410.97794">>_</tspan></text>
|
||||
<path
|
||||
style="fill:#00ff00;fill-opacity:1;stroke:none;stroke-width:6.11001;stroke-linecap:square;stroke-miterlimit:8;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 503.38876,299.01072 c -1.3765,0 -2.4846,1.10862 -2.4846,2.48512 v 14.07976 c 0,1.37651 1.1081,2.48461 2.4846,2.48461 h 115.66187 c 1.3765,0 2.4851,-1.1081 2.4851,-2.48461 v -14.07976 c 0,-1.3765 -1.1086,-2.48512 -2.4851,-2.48512 z"
|
||||
id="rect31" />
|
||||
<rect
|
||||
style="fill:#00ff00;fill-opacity:1;stroke:none;stroke-width:0.170087;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:4.4"
|
||||
id="rect2"
|
||||
width="87.026749"
|
||||
height="16.21431"
|
||||
x="500.90417"
|
||||
y="321.50845"
|
||||
ry="2.1688685" />
|
||||
<rect
|
||||
style="fill:#00ff00;fill-opacity:1;stroke:none;stroke-width:0.170086;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:4.4"
|
||||
id="rect3"
|
||||
width="113.76796"
|
||||
height="10.175971"
|
||||
x="500.90417"
|
||||
y="341.17099"
|
||||
ry="1.3611646" />
|
||||
<rect
|
||||
style="fill:#00ff00;fill-opacity:1;stroke:none;stroke-width:0.170086;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:4.4"
|
||||
id="rect4"
|
||||
width="101.98421"
|
||||
height="10.175971"
|
||||
x="500.90417"
|
||||
y="354.79523"
|
||||
ry="1.3611646" />
|
||||
<rect
|
||||
style="fill:#00ff00;fill-opacity:1;stroke:none;stroke-width:0.170086;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:4.4"
|
||||
id="rect5"
|
||||
width="52.736389"
|
||||
height="10.175971"
|
||||
x="500.90417"
|
||||
y="368.41943"
|
||||
ry="1.3611646" />
|
||||
<path
|
||||
d="m 519.16076,303.77439 h -5.40281 l -3.07739,12.02691 h -3.35346 l 3.07738,-12.02691 h -5.32 l 0.63032,-2.46336 h 14.07626 z m 11.15437,11.98551 h -7.05883 q -1.63534,0 -2.95578,-0.7452 -1.42877,-0.80732 -1.84795,-2.56685 -0.3985,-1.75954 0.14176,-3.87096 0.53497,-2.09077 1.79731,-3.78819 1.38223,-1.84233 3.21402,-2.69105 1.00665,-0.45541 1.86162,-0.6417 0.8757,-0.18631 1.93142,-0.18631 h 6.54132 l -0.65679,2.56684 h -6.04451 q -0.80731,0 -1.42449,0.22771 -0.59119,0.207 -1.2724,0.76592 -0.68123,0.5589 -1.02594,1.09712 -0.33942,0.51751 -0.5407,1.30411 h 9.52219 l -0.65679,2.56688 h -9.48078 q -0.1801,0.70379 -0.13152,1.24201 0.0539,0.51751 0.38701,1.07642 0.35379,0.55892 0.79715,0.84872 0.46405,0.28979 1.18856,0.28979 h 6.35503 z m 16.95364,0 h -4.07798 l -2.30077,-5.65121 h -3.89167 l -1.446,5.65121 h -3.47767 l 3.7077,-14.49027 h 10.5986 q 1.80093,0 2.62068,1.40764 0.82503,1.38692 0.33776,3.29136 -0.38668,1.51112 -1.67114,2.56684 -1.28444,1.05574 -2.90582,1.32482 z m -2.07376,-9.93617 q 0.25953,-1.01434 -0.36867,-1.55254 -0.6022,-0.55891 -1.88565,-0.55891 h -4.34706 l -1.03815,4.05729 h 4.45057 q 0.97291,0 1.85007,-0.43472 1.08995,-0.53821 1.33889,-1.51112 z m 20.97321,9.93617 h -3.12576 l 2.83373,-11.0747 -6.93241,11.0747 h -3.72607 l -1.32558,-11.32311 -2.89729,11.32311 h -3.02227 l 3.70769,-14.49027 h 4.84389 l 1.30922,11.46801 7.01234,-11.46801 h 5.0302 z m 5.98242,0 h -3.87097 l 3.70769,-14.49027 h 3.87098 z m 17.84375,0 h -4.22289 l -2.46594,-4.84389 -4.68629,4.8853 h -4.32637 l 7.49976,-7.22442 -3.65727,-7.30727 h 4.34708 l 2.07794,4.82318 4.77388,-4.82318 h 4.22289 l -7.42224,7.24515 z"
|
||||
id="path45"
|
||||
style="font-size:19.7556px;line-height:1.25;font-family:Denmark;-inkscape-font-specification:'Denmark, Normal';letter-spacing:0px;word-spacing:0px;fill:#26262b;fill-opacity:1;stroke-width:0.264583" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
@@ -21,6 +21,7 @@ import {
|
||||
import {
|
||||
Separator,
|
||||
} from "@/components/ui/separator.tsx"
|
||||
import Icon from "../../../public/icon.svg";
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectView: (view: string) => void;
|
||||
@@ -32,8 +33,9 @@ export function ConfigEditorSidebar({ onSelectView }: SidebarProps): React.React
|
||||
<Sidebar>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="text-lg text-center font-bold text-white">
|
||||
Termix / Config Editor
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
<img src={Icon} alt="Icon" className="w-6 h-6" />
|
||||
- Termix / Config
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<SidebarGroupContent className="flex flex-col flex-grow">
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem, SidebarProvider,
|
||||
} from "@/components/ui/sidebar.tsx"
|
||||
import Icon from "/public/icon.svg";
|
||||
|
||||
import {
|
||||
Separator,
|
||||
@@ -85,8 +86,9 @@ export function HomepageSidebar({ onSelectView, disabled, isAdmin, username }: S
|
||||
<Sidebar>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="text-lg text-center font-bold text-white">
|
||||
Termix
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
<img src={Icon} alt="Icon" className="w-6 h-6" />
|
||||
- Termix
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<SidebarGroupContent>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import {
|
||||
Separator,
|
||||
} from "@/components/ui/separator.tsx"
|
||||
import Icon from "../../../public/icon.svg";
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectView: (view: string) => void;
|
||||
@@ -32,8 +33,9 @@ export function SSHTunnelSidebar({ onSelectView }: SidebarProps): React.ReactEle
|
||||
<Sidebar>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="text-lg text-center font-bold text-white">
|
||||
Termix / SSH Tunnel
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
<img src={Icon} alt="Icon" className="w-6 h-6" />
|
||||
- Termix / SSH Tunnel
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<SidebarGroupContent className="flex flex-col flex-grow">
|
||||
|
||||
@@ -431,6 +431,14 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
onSelectView={onSelectView}
|
||||
onAddHostSubmit={onAddHostSubmit}
|
||||
onHostConnect={onHostConnect}
|
||||
allTabs={allTabs}
|
||||
runCommandOnTabs={(tabIds: number[], command: string) => {
|
||||
allTabs.forEach(tab => {
|
||||
if (tabIds.includes(tab.id) && tab.terminalRef?.current?.sendInput) {
|
||||
tab.terminalRef.current.sendInput(command);
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Main area: fills the rest */}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useForm, Controller } from "react-hook-form";
|
||||
import {
|
||||
CornerDownLeft,
|
||||
Plus,
|
||||
MoreVertical
|
||||
MoreVertical,
|
||||
Hammer
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
@@ -58,11 +59,15 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import Icon from "../../../public/icon.svg";
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select";
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectView: (view: string) => void;
|
||||
onAddHostSubmit: (data: any) => void;
|
||||
onHostConnect: (hostConfig: any) => void;
|
||||
allTabs: { id: number; title: string; terminalRef: React.RefObject<any> }[];
|
||||
runCommandOnTabs: (tabIds: number[], command: string) => void;
|
||||
}
|
||||
|
||||
interface AuthPromptFormData {
|
||||
@@ -70,6 +75,8 @@ interface AuthPromptFormData {
|
||||
authMethod: string;
|
||||
sshKeyFile: File | null;
|
||||
sshKeyContent?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
}
|
||||
|
||||
interface AddHostFormData {
|
||||
@@ -84,11 +91,13 @@ interface AddHostFormData {
|
||||
authMethod: string;
|
||||
sshKeyFile: File | null;
|
||||
sshKeyContent?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
saveAuthMethod: boolean;
|
||||
isPinned: boolean;
|
||||
}
|
||||
|
||||
export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect }: SidebarProps): React.ReactElement {
|
||||
export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect, allTabs, runCommandOnTabs }: SidebarProps): React.ReactElement {
|
||||
const addHostForm = useForm<AddHostFormData>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
@@ -228,6 +237,8 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect }: Sid
|
||||
password: data.password,
|
||||
authMethod: data.authMethod,
|
||||
key: sshKeyContent,
|
||||
keyPassword: data.keyPassword,
|
||||
keyType: data.keyType === 'auto' ? '' : data.keyType,
|
||||
saveAuthMethod: data.saveAuthMethod,
|
||||
isPinned: data.isPinned
|
||||
},
|
||||
@@ -381,6 +392,8 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect }: Sid
|
||||
tagsInput: '',
|
||||
sshKeyFile: null,
|
||||
sshKeyContent: editHostData.key || '',
|
||||
keyPassword: editHostData.keyPassword || '',
|
||||
keyType: editHostData.keyType || '',
|
||||
});
|
||||
}
|
||||
}, [editHostData]);
|
||||
@@ -423,9 +436,11 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect }: Sid
|
||||
ip: data.ip,
|
||||
port: data.port,
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
password: data.password, // always send
|
||||
authMethod: data.authMethod,
|
||||
key: sshKeyContent,
|
||||
key: sshKeyContent, // always send
|
||||
keyPassword: data.keyPassword, // always send
|
||||
keyType: data.keyType, // always send
|
||||
saveAuthMethod: data.saveAuthMethod,
|
||||
isPinned: data.isPinned
|
||||
},
|
||||
@@ -486,6 +501,8 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect }: Sid
|
||||
...authPromptHost,
|
||||
password: data.authMethod === 'password' ? data.password : undefined,
|
||||
key: data.authMethod === 'key' ? sshKeyContent : undefined,
|
||||
keyPassword: data.authMethod === 'key' ? data.keyPassword : undefined,
|
||||
keyType: data.authMethod === 'key' ? (data.keyType === 'auto' ? undefined : data.keyType) : undefined,
|
||||
authMethod: data.authMethod,
|
||||
};
|
||||
|
||||
@@ -507,17 +524,125 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect }: Sid
|
||||
}
|
||||
}, [authPromptOpen, authPromptForm]);
|
||||
|
||||
// Key type options
|
||||
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 [editKeyTypeDropdownOpen, setEditKeyTypeDropdownOpen] = useState(false);
|
||||
const keyTypeDropdownRef = React.useRef<HTMLDivElement>(null);
|
||||
const editKeyTypeDropdownRef = React.useRef<HTMLDivElement>(null);
|
||||
const keyTypeButtonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const editKeyTypeButtonRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Close dropdown on outside click (add form)
|
||||
React.useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
keyTypeDropdownRef.current &&
|
||||
!keyTypeDropdownRef.current.contains(event.target as Node) &&
|
||||
keyTypeButtonRef.current &&
|
||||
!keyTypeButtonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setKeyTypeDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
if (keyTypeDropdownOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
} else {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [keyTypeDropdownOpen]);
|
||||
// Close dropdown on outside click (edit form)
|
||||
React.useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
editKeyTypeDropdownRef.current &&
|
||||
!editKeyTypeDropdownRef.current.contains(event.target as Node) &&
|
||||
editKeyTypeButtonRef.current &&
|
||||
!editKeyTypeButtonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setEditKeyTypeDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
if (editKeyTypeDropdownOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
} else {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [editKeyTypeDropdownOpen]);
|
||||
|
||||
const [keyTypeDropdownOpenAuth, setKeyTypeDropdownOpenAuth] = useState(false);
|
||||
const keyTypeDropdownAuthRef = React.useRef<HTMLDivElement>(null);
|
||||
const keyTypeButtonAuthRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Close dropdown on outside click (auth prompt)
|
||||
React.useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
keyTypeDropdownAuthRef.current &&
|
||||
!keyTypeDropdownAuthRef.current.contains(event.target as Node) &&
|
||||
keyTypeButtonAuthRef.current &&
|
||||
!keyTypeButtonAuthRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setKeyTypeDropdownOpenAuth(false);
|
||||
}
|
||||
}
|
||||
if (keyTypeDropdownOpenAuth) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
} else {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [keyTypeDropdownOpenAuth]);
|
||||
|
||||
// Tools Sheet State
|
||||
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
|
||||
const [toolsCommand, setToolsCommand] = useState("");
|
||||
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
|
||||
const handleTabToggle = (tabId: number) => {
|
||||
setSelectedTabIds(prev => prev.includes(tabId) ? prev.filter(id => id !== tabId) : [...prev, tabId]);
|
||||
};
|
||||
// --- Fix: Run Command logic ---
|
||||
const handleRunCommand = () => {
|
||||
if (selectedTabIds.length && toolsCommand.trim()) {
|
||||
// Ensure command ends with newline
|
||||
let cmd = toolsCommand;
|
||||
if (!cmd.endsWith("\n")) cmd += "\n";
|
||||
runCommandOnTabs(selectedTabIds, cmd);
|
||||
setToolsCommand(""); // Clear after run
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar className="h-full flex flex-col">
|
||||
<SidebarContent className="flex flex-col flex-grow h-full">
|
||||
<SidebarGroup className="flex flex-col flex-grow h-full">
|
||||
<SidebarGroupLabel className="text-lg text-center font-bold text-white">
|
||||
Termix / SSH
|
||||
<Sidebar className="h-full flex flex-col overflow-hidden">
|
||||
<SidebarContent className="flex flex-col flex-grow h-full overflow-hidden">
|
||||
<SidebarGroup className="flex flex-col flex-grow h-full overflow-hidden">
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
<img src={Icon} alt="Icon" className="w-6 h-6" />
|
||||
- Termix / SSH
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<SidebarGroupContent className="flex flex-col flex-grow h-full">
|
||||
<SidebarMenu className="flex flex-col flex-grow h-full">
|
||||
<SidebarGroupContent className="flex flex-col flex-grow h-full overflow-hidden">
|
||||
<SidebarMenu className="flex flex-col flex-grow h-full overflow-hidden">
|
||||
|
||||
<SidebarMenuItem key="Homepage">
|
||||
<Button
|
||||
@@ -754,34 +879,98 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect }: Sid
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="key" className="mt-1">
|
||||
<Controller
|
||||
control={addHostForm.control}
|
||||
name="sshKeyFile"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SSH Private Key</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept=".pem,.key,.txt"
|
||||
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">
|
||||
{field.value ? field.value.name : "Upload"}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="key" className="mt-1">
|
||||
<Controller
|
||||
control={addHostForm.control}
|
||||
name="sshKeyFile"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SSH Private Key</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="file-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">
|
||||
{field.value ? field.value.name : "Upload"}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={addHostForm.control}
|
||||
name="keyPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-3">
|
||||
<FormLabel>Key Password (if protected)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter key password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={addHostForm.control}
|
||||
name="keyType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-3 relative">
|
||||
<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>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
/>
|
||||
@@ -865,10 +1054,10 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect }: Sid
|
||||
</Sheet>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<SidebarMenuItem key="Main" className="flex flex-col flex-grow">
|
||||
<div className="w-full flex-grow rounded-md bg-[#09090b] border border-[#434345] overflow-hidden p-0 m-0 relative flex flex-col">
|
||||
<SidebarMenuItem key="Main" className="flex flex-col flex-grow overflow-hidden">
|
||||
<div className="w-full flex-grow rounded-md bg-[#09090b] border border-[#434345] overflow-hidden p-0 m-0 relative flex flex-col min-h-0">
|
||||
{/* Search bar */}
|
||||
<div className="w-full px-2 pt-2 pb-1 bg-[#09090b] sticky top-0 z-10">
|
||||
<div className="w-full px-2 pt-2 pb-1 bg-[#09090b] z-10">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
@@ -890,7 +1079,7 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect }: Sid
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
<ScrollArea className="w-full h-full flex-1">
|
||||
<ScrollArea className="w-full h-full">
|
||||
<Accordion key={`host-accordion-${sortedFolders.length}`} type="multiple" className="w-full" defaultValue={sortedFolders.length > 0 ? sortedFolders : undefined}>
|
||||
{sortedFolders.map((folder, idx) => (
|
||||
<AccordionItem value={folder} key={`folder-${folder}`} className={idx === 0 ? "mt-0" : "mt-2"}>
|
||||
@@ -921,6 +1110,65 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect }: Sid
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
{/* Tools Button at the very bottom */}
|
||||
<div className="bg-sidebar">
|
||||
<Sheet open={toolsSheetOpen} onOpenChange={setToolsSheetOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
className="w-full h-8 mt-2"
|
||||
variant="outline"
|
||||
onClick={() => setToolsSheetOpen(true)}
|
||||
>
|
||||
<Hammer className="mr-2 h-4 w-4" />
|
||||
Tools
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-[256px] fixed top-0 left-0 h-full z-[100] flex flex-col">
|
||||
<SheetHeader className="pb-0.5">
|
||||
<SheetTitle>Tools</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto px-2 pt-2">
|
||||
<Accordion type="single" collapsible defaultValue="multiwindow">
|
||||
<AccordionItem value="multiwindow">
|
||||
<AccordionTrigger className="text-base font-semibold">Run multiwindow commands</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<textarea
|
||||
className="w-full min-h-[120px] max-h-48 rounded-md border border-input text-foreground p-2 text-sm font-mono resize-vertical focus:outline-none focus:ring-0"
|
||||
placeholder="Enter command(s) to run on selected tabs..."
|
||||
value={toolsCommand}
|
||||
onChange={e => setToolsCommand(e.target.value)}
|
||||
style={{ fontFamily: 'monospace', marginBottom: 8, background: '#141416' }}
|
||||
/>
|
||||
{/* Tab selection as tag-like buttons */}
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{allTabs.map(tab => (
|
||||
<Button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
variant={selectedTabIds.includes(tab.id) ? "secondary" : "outline"}
|
||||
size="sm"
|
||||
className="rounded-full px-3 py-1 text-xs flex items-center gap-1"
|
||||
onClick={() => handleTabToggle(tab.id)}
|
||||
>
|
||||
{tab.title}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
onClick={handleRunCommand}
|
||||
disabled={!toolsCommand.trim() || !selectedTabIds.length}
|
||||
>
|
||||
Run Command
|
||||
</Button>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
@@ -1137,7 +1385,7 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect }: Sid
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept=".pem,.key,.txt"
|
||||
accept=".pem,.key,.txt,.ppk"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
field.onChange(file || null);
|
||||
@@ -1152,6 +1400,70 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect }: Sid
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={editHostForm.control}
|
||||
name="keyPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-3">
|
||||
<FormLabel>Key Password (if protected)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter key password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={editHostForm.control}
|
||||
name="keyType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-3 relative">
|
||||
<FormLabel>Key Type</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Button
|
||||
ref={editKeyTypeButtonRef}
|
||||
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={() => setEditKeyTypeDropdownOpen((open) => !open)}
|
||||
>
|
||||
{keyTypeOptions.find(opt => opt.value === field.value)?.label || 'Auto-detect'}
|
||||
</Button>
|
||||
{editKeyTypeDropdownOpen && (
|
||||
<div
|
||||
ref={editKeyTypeDropdownRef}
|
||||
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);
|
||||
setEditKeyTypeDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
@@ -1270,7 +1582,7 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect }: Sid
|
||||
<input
|
||||
id="auth-file-upload"
|
||||
type="file"
|
||||
accept=".pem,.key,.txt"
|
||||
accept=".pem,.key,.txt,.ppk"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
field.onChange(file || null);
|
||||
@@ -1285,6 +1597,70 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect }: Sid
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={authPromptForm.control}
|
||||
name="keyPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-3">
|
||||
<FormLabel>Key Password (if protected)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter key password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={authPromptForm.control}
|
||||
name="keyType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-3 relative">
|
||||
<FormLabel>Key Type</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Button
|
||||
ref={keyTypeButtonAuthRef}
|
||||
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={() => setKeyTypeDropdownOpenAuth((open) => !open)}
|
||||
>
|
||||
{keyTypeOptions.find(opt => opt.value === field.value)?.label || 'Auto-detect'}
|
||||
</Button>
|
||||
{keyTypeDropdownOpenAuth && (
|
||||
<div
|
||||
ref={keyTypeDropdownAuthRef}
|
||||
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);
|
||||
setKeyTypeDropdownOpenAuth(false);
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
@@ -31,6 +31,11 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
||||
if (fitAddonRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
}
|
||||
},
|
||||
sendInput: (data: string) => {
|
||||
if (webSocketRef.current && webSocketRef.current.readyState === 1) {
|
||||
webSocketRef.current.send(JSON.stringify({ type: 'input', data }));
|
||||
}
|
||||
}
|
||||
}), []);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import {
|
||||
Separator,
|
||||
} from "@/components/ui/separator.tsx"
|
||||
import Icon from "../../../public/icon.svg";
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectView: (view: string) => void;
|
||||
@@ -32,8 +33,9 @@ export function TemplateSidebar({ onSelectView }: SidebarProps): React.ReactElem
|
||||
<Sidebar>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="text-lg text-center font-bold text-white">
|
||||
Termix / Template
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
<img src={Icon} alt="Icon" className="w-6 h-6" />
|
||||
- Termix / Template
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<SidebarGroupContent className="flex flex-col flex-grow">
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import {
|
||||
Separator,
|
||||
} from "@/components/ui/separator.tsx"
|
||||
import Icon from "../../../public/icon.svg";
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectView: (view: string) => void;
|
||||
@@ -32,8 +33,9 @@ export function ToolsSidebar({ onSelectView }: SidebarProps): React.ReactElement
|
||||
<Sidebar>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="text-lg text-center font-bold text-white">
|
||||
Termix / Tools
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
<img src={Icon} alt="Icon" className="w-6 h-6" />
|
||||
- Termix / Tools
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<SidebarGroupContent className="flex flex-col flex-grow">
|
||||
|
||||
@@ -58,6 +58,8 @@ CREATE TABLE IF NOT EXISTS ssh_data (
|
||||
password TEXT,
|
||||
auth_method TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
save_auth_method INTEGER,
|
||||
is_pinned INTEGER,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
|
||||
@@ -18,7 +18,9 @@ export const sshData = sqliteTable('ssh_data', {
|
||||
username: text('username'),
|
||||
password: text('password'),
|
||||
authMethod: text('auth_method'),
|
||||
key: text('key', { length: 2048 }),
|
||||
key: text('key', { length: 8192 }), // Increased for larger keys
|
||||
keyPassword: text('key_password'), // Password for protected keys
|
||||
keyType: text('key_type'), // Type of SSH key (RSA, ED25519, etc.)
|
||||
saveAuthMethod: integer('save_auth_method', { mode: 'boolean' }),
|
||||
isPinned: integer('is_pinned', { mode: 'boolean' }),
|
||||
});
|
||||
|
||||
@@ -69,7 +69,7 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||
// Route: Create SSH data (requires JWT)
|
||||
// POST /ssh/host
|
||||
router.post('/host', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const { name, folder, tags, ip, port, username, password, authMethod, key, saveAuthMethod, isPinned } = req.body;
|
||||
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, saveAuthMethod, isPinned } = req.body;
|
||||
const userId = (req as any).userId;
|
||||
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port)) {
|
||||
logger.warn('Invalid SSH data input');
|
||||
@@ -93,13 +93,19 @@ router.post('/host', authenticateJWT, async (req: Request, res: Response) => {
|
||||
if (authMethod === 'password') {
|
||||
sshDataObj.password = password;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
} else if (authMethod === 'key') {
|
||||
sshDataObj.key = key;
|
||||
sshDataObj.keyPassword = keyPassword;
|
||||
sshDataObj.keyType = keyType;
|
||||
sshDataObj.password = null;
|
||||
}
|
||||
} else {
|
||||
sshDataObj.password = null;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -114,7 +120,7 @@ router.post('/host', authenticateJWT, async (req: Request, res: Response) => {
|
||||
// Route: Update SSH data (requires JWT)
|
||||
// PUT /ssh/host/:id
|
||||
router.put('/host/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const { name, folder, tags, ip, port, username, password, authMethod, key, saveAuthMethod, isPinned } = req.body;
|
||||
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, saveAuthMethod, isPinned } = req.body;
|
||||
const { id } = req.params;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
@@ -139,13 +145,19 @@ router.put('/host/:id', authenticateJWT, async (req: Request, res: Response) =>
|
||||
if (authMethod === 'password') {
|
||||
sshDataObj.password = password;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
} else if (authMethod === 'key') {
|
||||
sshDataObj.key = key;
|
||||
sshDataObj.keyPassword = keyPassword;
|
||||
sshDataObj.keyType = keyType;
|
||||
sshDataObj.password = null;
|
||||
}
|
||||
} else {
|
||||
sshDataObj.password = null;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -81,11 +81,13 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authMethod?: string;
|
||||
};
|
||||
}) {
|
||||
const { cols, rows, hostConfig } = data;
|
||||
const { ip, port, username, password, key, authMethod } = hostConfig;
|
||||
const { ip, port, username, password, key, keyPassword, keyType, authMethod } = hostConfig;
|
||||
|
||||
if (!username || typeof username !== 'string' || username.trim() === '') {
|
||||
logger.error('Invalid username provided');
|
||||
@@ -147,7 +149,23 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
|
||||
sshConn.on('error', (err: Error) => {
|
||||
logger.error('SSH connection error: ' + err.message);
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'SSH error: ' + err.message }));
|
||||
|
||||
let errorMessage = 'SSH error: ' + err.message;
|
||||
if (err.message.includes('No matching key exchange algorithm')) {
|
||||
errorMessage = 'SSH error: No compatible key exchange algorithm found. This may be due to an older SSH server or network device.';
|
||||
} else if (err.message.includes('No matching cipher')) {
|
||||
errorMessage = 'SSH error: No compatible cipher found. This may be due to an older SSH server or network device.';
|
||||
} else if (err.message.includes('No matching MAC')) {
|
||||
errorMessage = 'SSH error: No compatible MAC algorithm found. This may be due to an older SSH server or network device.';
|
||||
} else if (err.message.includes('ENOTFOUND') || err.message.includes('ENOENT')) {
|
||||
errorMessage = 'SSH error: Could not resolve hostname or connect to server.';
|
||||
} else if (err.message.includes('ECONNREFUSED')) {
|
||||
errorMessage = 'SSH error: Connection refused. The server may not be running or the port may be incorrect.';
|
||||
} else if (err.message.includes('ETIMEDOUT')) {
|
||||
errorMessage = 'SSH error: Connection timed out. Check your network connection and server availability.';
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify({ type: 'error', message: errorMessage }));
|
||||
cleanupSSH();
|
||||
});
|
||||
|
||||
@@ -162,12 +180,51 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
keepaliveInterval: 5000,
|
||||
keepaliveCountMax: 10,
|
||||
readyTimeout: 10000,
|
||||
|
||||
algorithms: {
|
||||
kex: [
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group14-sha1',
|
||||
'diffie-hellman-group1-sha1',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
'diffie-hellman-group-exchange-sha1',
|
||||
'ecdh-sha2-nistp256',
|
||||
'ecdh-sha2-nistp384',
|
||||
'ecdh-sha2-nistp521'
|
||||
],
|
||||
cipher: [
|
||||
'aes128-ctr',
|
||||
'aes192-ctr',
|
||||
'aes256-ctr',
|
||||
'aes128-gcm@openssh.com',
|
||||
'aes256-gcm@openssh.com',
|
||||
'aes128-cbc',
|
||||
'aes192-cbc',
|
||||
'aes256-cbc',
|
||||
'3des-cbc'
|
||||
],
|
||||
hmac: [
|
||||
'hmac-sha2-256',
|
||||
'hmac-sha2-512',
|
||||
'hmac-sha1',
|
||||
'hmac-md5'
|
||||
],
|
||||
compress: [
|
||||
'none',
|
||||
'zlib@openssh.com',
|
||||
'zlib'
|
||||
]
|
||||
}
|
||||
};
|
||||
if (authMethod === 'key' && key) {
|
||||
connectConfig.privateKey = key;
|
||||
if (keyPassword) {
|
||||
connectConfig.passphrase = keyPassword;
|
||||
}
|
||||
} else {
|
||||
connectConfig.password = password;
|
||||
}
|
||||
|
||||
sshConn.connect(connectConfig);
|
||||
}
|
||||
|
||||
|
||||
183
src/components/ui/select.tsx
Normal file
183
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
Reference in New Issue
Block a user