mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
feat: host manager bug fixes, i18n improvements, general tab fixes
This commit is contained in:
+23
-20
@@ -27,7 +27,7 @@ import type {
|
||||
SplitMode,
|
||||
HostFolder,
|
||||
} from "@/types/ui-types";
|
||||
import { getSSHHosts } from "@/main-axios";
|
||||
import { getSSHHosts, getUserInfo } from "@/main-axios";
|
||||
import { dbHealthMonitor } from "@/lib/db-health-monitor";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
|
||||
@@ -58,9 +58,9 @@ function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
enableTunnel: h.enableTunnel ?? false,
|
||||
enableFileManager: h.enableFileManager ?? false,
|
||||
enableDocker: h.enableDocker ?? false,
|
||||
enableRdp: h.connectionType === "rdp",
|
||||
enableVnc: h.connectionType === "vnc",
|
||||
enableTelnet: h.connectionType === "telnet",
|
||||
enableRdp: h.enableRdp ?? h.connectionType === "rdp",
|
||||
enableVnc: h.enableVnc ?? h.connectionType === "vnc",
|
||||
enableTelnet: h.enableTelnet ?? h.connectionType === "telnet",
|
||||
sshPort: h.port,
|
||||
rdpPort: 3389,
|
||||
vncPort: 5900,
|
||||
@@ -112,19 +112,6 @@ function buildHostTree(hosts: SSHHostWithStatus[]): HostFolder {
|
||||
export { tabIcon, renderTabContent } from "@/shell/tabUtils";
|
||||
import { renderTabContent } from "@/shell/tabUtils";
|
||||
|
||||
const DASHBOARD_TAB: Tab = {
|
||||
id: "dashboard",
|
||||
type: "dashboard",
|
||||
label: "Dashboard",
|
||||
};
|
||||
|
||||
const SINGLETON_TAB_LABELS: Partial<Record<TabType, string>> = {
|
||||
"host-manager": "Host Manager",
|
||||
docker: "Docker",
|
||||
tunnel: "Tunnels",
|
||||
network_graph: "Network Graph",
|
||||
};
|
||||
|
||||
// ─── AppShell ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function AppShell({
|
||||
@@ -135,7 +122,9 @@ export function AppShell({
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [tabs, setTabs] = useState<Tab[]>([DASHBOARD_TAB]);
|
||||
const [tabs, setTabs] = useState<Tab[]>([
|
||||
{ id: "dashboard", type: "dashboard", label: t("nav.dashboard") },
|
||||
]);
|
||||
const [activeTabId, setActiveTabId] = useState("dashboard");
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||
const [splitMode, setSplitMode] = useState<SplitMode>("none");
|
||||
@@ -144,6 +133,7 @@ export function AppShell({
|
||||
);
|
||||
const [realHostTree, setRealHostTree] = useState<HostFolder | null>(null);
|
||||
const [allHosts, setAllHosts] = useState<Host[]>([]);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [railView, setRailView] = useState<RailView>("hosts");
|
||||
@@ -159,6 +149,12 @@ export function AppShell({
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}, [isMobile]);
|
||||
|
||||
useEffect(() => {
|
||||
getUserInfo()
|
||||
.then((info) => setIsAdmin(info.is_admin))
|
||||
.catch(() => setIsAdmin(false));
|
||||
}, []);
|
||||
|
||||
const pendingHostManagerEditId = useRef<string | null>(null);
|
||||
const pendingHostManagerAction = useRef<"add-host" | "add-credential" | null>(
|
||||
null,
|
||||
@@ -378,7 +374,13 @@ export function AppShell({
|
||||
const id = type;
|
||||
setTabs((prev) => {
|
||||
if (prev.find((t) => t.id === id)) return prev;
|
||||
return [...prev, { id, type, label: SINGLETON_TAB_LABELS[type] ?? type }];
|
||||
const singletonLabels: Partial<Record<TabType, string>> = {
|
||||
"host-manager": t("nav.hostManager"),
|
||||
docker: t("nav.docker"),
|
||||
tunnel: t("nav.tunnels"),
|
||||
network_graph: t("nav.networkGraph"),
|
||||
};
|
||||
return [...prev, { id, type, label: singletonLabels[type] ?? type }];
|
||||
});
|
||||
setActiveTabId(id);
|
||||
}
|
||||
@@ -530,7 +532,7 @@ export function AppShell({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{railView === "admin-settings" && (
|
||||
{railView === "admin-settings" && isAdmin && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<AdminSettingsPanel />
|
||||
</div>
|
||||
@@ -582,6 +584,7 @@ export function AppShell({
|
||||
sidebarOpen={sidebarOpen}
|
||||
splitMode={splitMode}
|
||||
username={username}
|
||||
isAdmin={isAdmin}
|
||||
profileDropdownOpen={profileDropdownOpen}
|
||||
onProfileDropdownChange={setProfileDropdownOpen}
|
||||
onRailClick={handleRailClick}
|
||||
|
||||
@@ -9,6 +9,8 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-none border border-input bg-transparent px-2.5 py-1 text-xs transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-xs file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 md:text-xs dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
type === "number" &&
|
||||
"[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -70,7 +70,6 @@ import {
|
||||
listSSHFiles,
|
||||
resolveSSHPath,
|
||||
uploadSSHFile,
|
||||
downloadSSHFile,
|
||||
createSSHFile,
|
||||
createSSHFolder,
|
||||
deleteSSHItem,
|
||||
@@ -410,6 +409,17 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
async function initializeSSHConnection() {
|
||||
if (!currentHost || isConnectingRef.current) return;
|
||||
|
||||
if (currentHost.enableSsh === false) {
|
||||
setHasConnectionError(true);
|
||||
addLog({
|
||||
type: "error",
|
||||
message: t("fileManager.sshRequiredForFileManager"),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
isConnectingRef.current = true;
|
||||
|
||||
try {
|
||||
@@ -813,9 +823,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
);
|
||||
files.push({ file, relativePath: path });
|
||||
} else if (entry.isDirectory) {
|
||||
const reader = (
|
||||
entry as FileSystemDirectoryEntry
|
||||
).createReader();
|
||||
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||
const dirEntries = await new Promise<FileSystemEntry[]>(
|
||||
(resolve, reject) => reader.readEntries(resolve, reject),
|
||||
);
|
||||
|
||||
+52
-1672
File diff suppressed because it is too large
Load Diff
+35
-22
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Clock,
|
||||
Hammer,
|
||||
@@ -35,22 +36,29 @@ type RailItem =
|
||||
}
|
||||
| { kind: "separator" };
|
||||
|
||||
function buildRailButtons(splitMode: SplitMode): RailItem[] {
|
||||
function buildRailButtons(
|
||||
splitMode: SplitMode,
|
||||
t: (key: string) => string,
|
||||
): RailItem[] {
|
||||
return [
|
||||
{ view: "hosts", icon: <Server size={16} />, title: "Hosts" },
|
||||
{ view: "hosts", icon: <Server size={16} />, title: t("nav.hosts") },
|
||||
{ kind: "separator" },
|
||||
{ view: "quick-connect", icon: <Zap size={16} />, title: "Quick Connect" },
|
||||
{
|
||||
view: "quick-connect",
|
||||
icon: <Zap size={16} />,
|
||||
title: t("nav.quickConnect"),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{ view: "ssh-tools", icon: <Hammer size={16} />, title: "SSH Tools" },
|
||||
{ view: "ssh-tools", icon: <Hammer size={16} />, title: t("nav.sshTools") },
|
||||
{ kind: "separator" },
|
||||
{ view: "snippets", icon: <Play size={16} />, title: "Snippets" },
|
||||
{ view: "snippets", icon: <Play size={16} />, title: t("nav.snippets") },
|
||||
{ kind: "separator" },
|
||||
{ view: "history", icon: <Clock size={16} />, title: "History" },
|
||||
{ view: "history", icon: <Clock size={16} />, title: t("nav.history") },
|
||||
{ kind: "separator" },
|
||||
{
|
||||
view: "split-screen",
|
||||
icon: <LayoutPanelLeft size={16} />,
|
||||
title: "Split Screen",
|
||||
title: t("nav.splitScreen"),
|
||||
dot: splitMode !== "none",
|
||||
},
|
||||
{ kind: "separator" },
|
||||
@@ -66,6 +74,7 @@ export function AppRail({
|
||||
sidebarOpen,
|
||||
splitMode,
|
||||
username,
|
||||
isAdmin,
|
||||
profileDropdownOpen,
|
||||
onProfileDropdownChange,
|
||||
onRailClick,
|
||||
@@ -75,14 +84,16 @@ export function AppRail({
|
||||
sidebarOpen: boolean;
|
||||
splitMode: SplitMode;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
profileDropdownOpen: boolean;
|
||||
onProfileDropdownChange: (open: boolean) => void;
|
||||
onRailClick: (view: RailView) => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const railExpanded = hovered || profileDropdownOpen;
|
||||
const railButtons = buildRailButtons(splitMode);
|
||||
const railButtons = buildRailButtons(splitMode, t);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -132,20 +143,22 @@ export function AppRail({
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex flex-col gap-1 border-t border-border pt-1 pb-1">
|
||||
{(
|
||||
[
|
||||
{
|
||||
view: "user-profile" as RailView,
|
||||
icon: <User size={16} />,
|
||||
title: "Profile",
|
||||
},
|
||||
{
|
||||
view: "admin-settings" as RailView,
|
||||
icon: <Settings size={16} />,
|
||||
title: "Admin",
|
||||
},
|
||||
] as const
|
||||
).map((item) => (
|
||||
{[
|
||||
{
|
||||
view: "user-profile" as RailView,
|
||||
icon: <User size={16} />,
|
||||
title: t("nav.userProfile"),
|
||||
},
|
||||
...(isAdmin
|
||||
? [
|
||||
{
|
||||
view: "admin-settings" as RailView,
|
||||
icon: <Settings size={16} />,
|
||||
title: t("nav.admin"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.view}
|
||||
onClick={() => onRailClick(item.view)}
|
||||
|
||||
+604
-190
@@ -14,6 +14,7 @@ import {
|
||||
} from "@/lib/terminal-themes";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Slider } from "@/components/slider";
|
||||
import {
|
||||
Activity,
|
||||
ArrowLeft,
|
||||
@@ -146,10 +147,12 @@ function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
enableVnc: h.enableVnc != null ? h.enableVnc : h.connectionType === "vnc",
|
||||
enableTelnet:
|
||||
h.enableTelnet != null ? h.enableTelnet : h.connectionType === "telnet",
|
||||
sshPort: h.sshPort ?? h.port,
|
||||
rdpPort: h.rdpPort ?? 3389,
|
||||
vncPort: h.vncPort ?? 5900,
|
||||
telnetPort: h.telnetPort ?? 23,
|
||||
sshPort:
|
||||
h.sshPort ??
|
||||
(h.connectionType === "ssh" || !h.connectionType ? h.port : 22),
|
||||
rdpPort: h.rdpPort ?? (h.connectionType === "rdp" ? h.port : 3389),
|
||||
vncPort: h.vncPort ?? (h.connectionType === "vnc" ? h.port : 5900),
|
||||
telnetPort: h.telnetPort ?? (h.connectionType === "telnet" ? h.port : 23),
|
||||
rdpUser: h.rdpUser,
|
||||
rdpPassword: h.rdpPassword,
|
||||
domain: h.rdpDomain,
|
||||
@@ -178,6 +181,8 @@ function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
socks5Port: h.socks5Port,
|
||||
socks5Username: h.socks5Username,
|
||||
socks5Password: h.socks5Password,
|
||||
socks5ProxyChain: parseJson(h.socks5ProxyChain) ?? [],
|
||||
overrideCredentialUsername: h.overrideCredentialUsername ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -776,7 +781,10 @@ function HostEditor({
|
||||
password: host?.password ?? "",
|
||||
key: host?.key ?? "",
|
||||
keyPassword: host?.keyPassword ?? "",
|
||||
keyType: host?.keyType ?? "auto",
|
||||
keySubTab: "paste" as "paste" | "upload",
|
||||
credentialId: host?.credentialId ?? "",
|
||||
overrideCredentialUsername: host?.overrideCredentialUsername ?? false,
|
||||
folder: host?.folder ?? "",
|
||||
tags: host?.tags ?? ([] as string[]),
|
||||
tagInput: "",
|
||||
@@ -788,6 +796,16 @@ function HostEditor({
|
||||
socks5Port: host?.socks5Port ?? 1080,
|
||||
socks5Username: host?.socks5Username ?? "",
|
||||
socks5Password: host?.socks5Password ?? "",
|
||||
socks5ProxyMode: ((host?.socks5ProxyChain as any[])?.length > 0
|
||||
? "chain"
|
||||
: "single") as "single" | "chain",
|
||||
socks5ProxyChain: ((host?.socks5ProxyChain as any[]) ?? []) as {
|
||||
host: string;
|
||||
port: number;
|
||||
type: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}[],
|
||||
enableTerminal: host?.enableTerminal ?? true,
|
||||
enableFileManager: host?.enableFileManager ?? false,
|
||||
enableDocker: host?.enableDocker ?? false,
|
||||
@@ -978,7 +996,9 @@ function HostEditor({
|
||||
password: form.password || null,
|
||||
key: form.key || null,
|
||||
keyPassword: form.keyPassword || null,
|
||||
keyType: form.keyType !== "auto" ? form.keyType : null,
|
||||
credentialId: form.credentialId ? Number(form.credentialId) : null,
|
||||
overrideCredentialUsername: form.overrideCredentialUsername,
|
||||
notes: form.notes,
|
||||
macAddress: form.macAddress || null,
|
||||
enableTerminal: form.enableTerminal,
|
||||
@@ -987,10 +1007,20 @@ function HostEditor({
|
||||
enableDocker: form.enableDocker,
|
||||
defaultPath: form.defaultPath || "/",
|
||||
useSocks5: form.useSocks5,
|
||||
socks5Host: form.socks5Host || null,
|
||||
socks5Port: form.socks5Port || null,
|
||||
socks5Username: form.socks5Username || null,
|
||||
socks5Password: form.socks5Password || null,
|
||||
socks5Host:
|
||||
form.socks5ProxyMode === "single" ? form.socks5Host || null : null,
|
||||
socks5Port:
|
||||
form.socks5ProxyMode === "single" ? form.socks5Port || null : null,
|
||||
socks5Username:
|
||||
form.socks5ProxyMode === "single"
|
||||
? form.socks5Username || null
|
||||
: null,
|
||||
socks5Password:
|
||||
form.socks5ProxyMode === "single"
|
||||
? form.socks5Password || null
|
||||
: null,
|
||||
socks5ProxyChain:
|
||||
form.socks5ProxyMode === "chain" ? form.socks5ProxyChain : null,
|
||||
enableSsh: protocols.enableSsh,
|
||||
enableRdp: protocols.enableRdp,
|
||||
enableVnc: protocols.enableVnc,
|
||||
@@ -1418,59 +1448,274 @@ function HostEditor({
|
||||
/>
|
||||
</SettingRow>
|
||||
{form.useSocks5 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 p-3 bg-muted/20 border border-border">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyHost")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
placeholder="proxy.example.com"
|
||||
value={form.socks5Host}
|
||||
onChange={(e) => setField("socks5Host", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyPort")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
type="number"
|
||||
placeholder="1080"
|
||||
value={form.socks5Port}
|
||||
onChange={(e) =>
|
||||
setField("socks5Port", Number(e.target.value) as any)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyUsername")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
placeholder="Optional"
|
||||
value={form.socks5Username}
|
||||
onChange={(e) =>
|
||||
setField("socks5Username", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyPassword")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
type="password"
|
||||
placeholder="Optional"
|
||||
value={form.socks5Password}
|
||||
onChange={(e) =>
|
||||
setField("socks5Password", e.target.value)
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Single / Chain mode toggle */}
|
||||
<div className="flex gap-2">
|
||||
{(["single", "chain"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setField("socks5ProxyMode", m)}
|
||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${form.socks5ProxyMode === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{m === "single"
|
||||
? t("hosts.proxySingleMode")
|
||||
: t("hosts.proxyChainMode")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{form.socks5ProxyMode === "single" && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 p-3 bg-muted/20 border border-border">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyHost")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
placeholder="proxy.example.com"
|
||||
value={form.socks5Host}
|
||||
onChange={(e) =>
|
||||
setField("socks5Host", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyPort")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
type="number"
|
||||
placeholder="1080"
|
||||
value={form.socks5Port}
|
||||
onChange={(e) =>
|
||||
setField(
|
||||
"socks5Port",
|
||||
Number(e.target.value) as any,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyUsername")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
placeholder={t("hosts.optional")}
|
||||
value={form.socks5Username}
|
||||
onChange={(e) =>
|
||||
setField("socks5Username", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyPassword")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
type="password"
|
||||
placeholder={t("hosts.optional")}
|
||||
value={form.socks5Password}
|
||||
onChange={(e) =>
|
||||
setField("socks5Password", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.socks5ProxyMode === "chain" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{form.socks5ProxyChain.map((node, ni) => (
|
||||
<div
|
||||
key={ni}
|
||||
className="flex flex-col gap-2 p-3 bg-muted/20 border border-border"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold text-muted-foreground">
|
||||
{t("hosts.proxyNode")} {ni + 1}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
onClick={() =>
|
||||
setField(
|
||||
"socks5ProxyChain",
|
||||
form.socks5ProxyChain.filter(
|
||||
(_, idx) => idx !== ni,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyHost")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
placeholder="proxy.example.com"
|
||||
value={node.host}
|
||||
onChange={(e) => {
|
||||
const u = [...form.socks5ProxyChain];
|
||||
u[ni] = { ...u[ni], host: e.target.value };
|
||||
setField("socks5ProxyChain", u);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyPort")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
type="number"
|
||||
placeholder="1080"
|
||||
value={node.port}
|
||||
onChange={(e) => {
|
||||
const u = [...form.socks5ProxyChain];
|
||||
u[ni] = {
|
||||
...u[ni],
|
||||
port: Number(e.target.value),
|
||||
};
|
||||
setField("socks5ProxyChain", u);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyType")}
|
||||
</label>
|
||||
<select
|
||||
className="h-7 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
|
||||
value={node.type}
|
||||
onChange={(e) => {
|
||||
const u = [...form.socks5ProxyChain];
|
||||
u[ni] = { ...u[ni], type: e.target.value };
|
||||
setField("socks5ProxyChain", u);
|
||||
}}
|
||||
>
|
||||
<option value="socks5">SOCKS5</option>
|
||||
<option value="socks4">SOCKS4</option>
|
||||
<option value="http">HTTP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyUsername")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
placeholder={t("hosts.optional")}
|
||||
value={node.username}
|
||||
onChange={(e) => {
|
||||
const u = [...form.socks5ProxyChain];
|
||||
u[ni] = {
|
||||
...u[ni],
|
||||
username: e.target.value,
|
||||
};
|
||||
setField("socks5ProxyChain", u);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 col-span-2">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyPassword")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
type="password"
|
||||
placeholder={t("hosts.optional")}
|
||||
value={node.password}
|
||||
onChange={(e) => {
|
||||
const u = [...form.socks5ProxyChain];
|
||||
u[ni] = {
|
||||
...u[ni],
|
||||
password: e.target.value,
|
||||
};
|
||||
setField("socks5ProxyChain", u);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand self-start"
|
||||
onClick={() =>
|
||||
setField("socks5ProxyChain", [
|
||||
...form.socks5ProxyChain,
|
||||
{
|
||||
host: "",
|
||||
port: 1080,
|
||||
type: "socks5",
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className="size-3 mr-1" />{" "}
|
||||
{t("hosts.addProxyNode")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection path visualization */}
|
||||
{(form.socks5ProxyMode === "single" && form.socks5Host) ||
|
||||
(form.socks5ProxyMode === "chain" &&
|
||||
form.socks5ProxyChain.length > 0) ? (
|
||||
<div className="flex items-center gap-1 flex-wrap p-2 bg-muted/30 border border-border text-[10px]">
|
||||
<span className="px-2 py-0.5 bg-background border border-border text-foreground font-mono">
|
||||
{t("hosts.you")}
|
||||
</span>
|
||||
{form.socks5ProxyMode === "single" &&
|
||||
form.socks5Host ? (
|
||||
<>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="px-2 py-0.5 bg-muted border border-border text-muted-foreground font-mono">
|
||||
{form.socks5Host}:{form.socks5Port}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
form.socks5ProxyChain
|
||||
.filter((n) => n.host)
|
||||
.map((n, ni) => (
|
||||
<React.Fragment key={ni}>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="px-2 py-0.5 bg-muted border border-border text-muted-foreground font-mono">
|
||||
{n.host}:{n.port}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
{form.jumpHosts
|
||||
.filter((j) => j.hostId)
|
||||
.map((j, ji) => {
|
||||
const jh = hosts.find((h) => h.id === j.hostId);
|
||||
return jh ? (
|
||||
<React.Fragment key={`j${ji}`}>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="px-2 py-0.5 bg-muted border border-border text-muted-foreground font-mono">
|
||||
{jh.name || jh.ip}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
) : null;
|
||||
})}
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="px-2 py-0.5 bg-accent-brand/10 border border-accent-brand/30 text-accent-brand font-mono">
|
||||
{form.ip || "target"}:{form.sshPort}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -1619,16 +1864,68 @@ function HostEditor({
|
||||
{authMethod === "key" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.sshPrivateKey")}
|
||||
</label>
|
||||
<textarea
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
rows={5}
|
||||
value={form.key}
|
||||
onChange={(e) => setField("key", e.target.value)}
|
||||
className="w-full px-3 py-2 text-[10px] bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.sshPrivateKey")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{(["paste", "upload"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => setField("keySubTab", tab)}
|
||||
className={`px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest border transition-colors ${form.keySubTab === tab ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{tab === "paste"
|
||||
? t("hosts.keyPasteTab")
|
||||
: t("hosts.keyUploadTab")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{form.keySubTab === "paste" ? (
|
||||
<textarea
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
rows={5}
|
||||
value={form.key}
|
||||
onChange={(e) => setField("key", e.target.value)}
|
||||
className="w-full px-3 py-2 text-[10px] bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
className={`flex items-center justify-center gap-2 h-16 border-2 border-dashed cursor-pointer transition-colors ${form.key ? "border-accent-brand/40 bg-accent-brand/5 text-accent-brand" : "border-border text-muted-foreground hover:border-accent-brand/30 hover:text-foreground"}`}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
<span className="text-xs">
|
||||
{form.key
|
||||
? t("hosts.keyFileLoaded")
|
||||
: t("hosts.keyUploadClick")}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pem,.key,.txt,.ppk"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const text = await file.text();
|
||||
setField("key", text);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{form.key && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setField("key", "")}
|
||||
className="text-[10px] text-destructive self-start"
|
||||
>
|
||||
{t("hosts.clearKey")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
@@ -1643,28 +1940,90 @@ function HostEditor({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.keyTypeLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={form.keyType}
|
||||
onChange={(e) =>
|
||||
setField("keyType", e.target.value as any)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="auto">{t("hosts.keyTypeAuto")}</option>
|
||||
<option value="ssh-rsa">RSA</option>
|
||||
<option value="ssh-ed25519">Ed25519</option>
|
||||
<option value="ecdsa-sha2-nistp256">
|
||||
ECDSA P-256
|
||||
</option>
|
||||
<option value="ecdsa-sha2-nistp384">
|
||||
ECDSA P-384
|
||||
</option>
|
||||
<option value="ecdsa-sha2-nistp521">
|
||||
ECDSA P-521
|
||||
</option>
|
||||
<option value="ssh-dss">DSA</option>
|
||||
<option value="ssh-rsa-sha2-256">RSA SHA2-256</option>
|
||||
<option value="ssh-rsa-sha2-512">RSA SHA2-512</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{authMethod === "credential" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.storedCredential")}
|
||||
</label>
|
||||
<select
|
||||
value={form.credentialId}
|
||||
onChange={(e) =>
|
||||
setField("credentialId", e.target.value)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t("hosts.selectACredential")}</option>
|
||||
{credentials.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name} ({c.username})
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.storedCredential")}
|
||||
</label>
|
||||
<select
|
||||
value={form.credentialId}
|
||||
onChange={(e) =>
|
||||
setField("credentialId", e.target.value)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">
|
||||
{t("hosts.selectACredential")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{credentials.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name} ({c.username})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between col-span-2 pt-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">
|
||||
{t("hosts.overrideCredentialUsername")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.overrideCredentialUsernameDesc")}
|
||||
</span>
|
||||
</div>
|
||||
<FakeSwitch
|
||||
checked={form.overrideCredentialUsername}
|
||||
onChange={(v) =>
|
||||
setField("overrideCredentialUsername", v)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{form.overrideCredentialUsername && (
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.username")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="root"
|
||||
value={form.username}
|
||||
onChange={(e) =>
|
||||
setField("username", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<SettingRow
|
||||
@@ -1780,16 +2139,20 @@ function HostEditor({
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.fontSizeLabel")}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.fontSize}
|
||||
onChange={(e) =>
|
||||
setField("fontSize", Number(e.target.value) as any)
|
||||
}
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.fontSizeLabel")}
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.fontSize}px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={8}
|
||||
max={24}
|
||||
step={1}
|
||||
value={[form.fontSize]}
|
||||
onValueChange={([v]) => setField("fontSize", v as any)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -1811,30 +2174,39 @@ function HostEditor({
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.letterSpacingPx")}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.letterSpacing}
|
||||
onChange={(e) =>
|
||||
setField("letterSpacing", Number(e.target.value) as any)
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.letterSpacingPx")}
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.letterSpacing}px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={-2}
|
||||
max={10}
|
||||
step={0.5}
|
||||
value={[form.letterSpacing]}
|
||||
onValueChange={([v]) =>
|
||||
setField("letterSpacing", v as any)
|
||||
}
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.lineHeightLabel")}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={form.lineHeight}
|
||||
onChange={(e) =>
|
||||
setField("lineHeight", Number(e.target.value) as any)
|
||||
}
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.lineHeightLabel")}
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.lineHeight.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1.0}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={[form.lineHeight]}
|
||||
onValueChange={([v]) => setField("lineHeight", v as any)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -1898,20 +2270,22 @@ function HostEditor({
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.scrollbackBufferLabel")}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.scrollback}
|
||||
onChange={(e) =>
|
||||
setField("scrollback", Number(e.target.value) as any)
|
||||
}
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.scrollbackBufferLabel")}
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.scrollback.toLocaleString()}{" "}
|
||||
{t("hosts.scrollbackMaxLines")}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1000}
|
||||
max={100000}
|
||||
step={1000}
|
||||
value={[form.scrollback]}
|
||||
onValueChange={([v]) => setField("scrollback", v as any)}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.scrollbackMaxLines")}
|
||||
</span>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t("hosts.sshAgentForwardingLabel")}
|
||||
@@ -2050,19 +2424,22 @@ function HostEditor({
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.fastScrollSensitivityLabel")}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.fastScrollSensitivity}
|
||||
onChange={(e) =>
|
||||
setField(
|
||||
"fastScrollSensitivity",
|
||||
Number(e.target.value) as any,
|
||||
)
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.fastScrollSensitivityLabel")}
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.fastScrollSensitivity}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
value={[form.fastScrollSensitivity]}
|
||||
onValueChange={([v]) =>
|
||||
setField("fastScrollSensitivity", v as any)
|
||||
}
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2116,6 +2493,7 @@ function HostEditor({
|
||||
Number(e.target.value) as any,
|
||||
)
|
||||
}
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -2131,6 +2509,7 @@ function HostEditor({
|
||||
Number(e.target.value) as any,
|
||||
)
|
||||
}
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2323,6 +2702,13 @@ function HostEditor({
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground/70 mt-0.5">
|
||||
{tun.mode === "local"
|
||||
? t("hosts.tunnelModeLocalDesc")
|
||||
: tun.mode === "remote"
|
||||
? t("hosts.tunnelModeRemoteDesc")
|
||||
: t("hosts.tunnelModeDynamicDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{tun.mode !== "dynamic" && (
|
||||
@@ -2330,10 +2716,9 @@ function HostEditor({
|
||||
<label className="text-[10px] font-bold text-muted-foreground">
|
||||
{t("hosts.endpointHost")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
placeholder="e.g. 127.0.0.1"
|
||||
value={tun.endpointHost}
|
||||
<select
|
||||
className="h-7 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
|
||||
value={tun.endpointHost ?? ""}
|
||||
onChange={(e) => {
|
||||
const updated = [...form.serverTunnels];
|
||||
updated[i] = {
|
||||
@@ -2342,7 +2727,21 @@ function HostEditor({
|
||||
};
|
||||
setField("serverTunnels", updated);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<option value="">
|
||||
{t("hosts.selectAServer")}
|
||||
</option>
|
||||
<option value="127.0.0.1">
|
||||
127.0.0.1 (localhost)
|
||||
</option>
|
||||
{hosts
|
||||
.filter((h) => h.enableSsh)
|
||||
.map((h) => (
|
||||
<option key={h.id} value={h.ip}>
|
||||
{h.name || h.ip} ({h.ip})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{tun.mode !== "dynamic" && (
|
||||
@@ -2351,7 +2750,7 @@ function HostEditor({
|
||||
{t("hosts.endpointPort")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
className="h-7 text-xs [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
type="number"
|
||||
value={tun.endpointPort}
|
||||
onChange={(e) => {
|
||||
@@ -2388,7 +2787,7 @@ function HostEditor({
|
||||
{t("hosts.sourcePort")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
className="h-7 text-xs [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
type="number"
|
||||
value={tun.sourcePort}
|
||||
onChange={(e) => {
|
||||
@@ -2406,7 +2805,7 @@ function HostEditor({
|
||||
{t("hosts.maxRetries")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
className="h-7 text-xs [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
type="number"
|
||||
value={tun.maxRetries}
|
||||
onChange={(e) => {
|
||||
@@ -2424,7 +2823,7 @@ function HostEditor({
|
||||
{t("hosts.retryIntervalS")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
className="h-7 text-xs [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
type="number"
|
||||
value={tun.retryInterval}
|
||||
onChange={(e) => {
|
||||
@@ -2545,24 +2944,25 @@ function HostEditor({
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
{!form.statsConfig.useGlobalStatusInterval && (
|
||||
<SettingRow
|
||||
label={t("hosts.checkIntervalS")}
|
||||
description={t("hosts.checkIntervalDesc")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.statsConfig.statusCheckInterval}
|
||||
onChange={(e) =>
|
||||
setField("statsConfig", {
|
||||
...form.statsConfig,
|
||||
statusCheckInterval: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
{form.statsConfig.statusCheckEnabled &&
|
||||
!form.statsConfig.useGlobalStatusInterval && (
|
||||
<SettingRow
|
||||
label={t("hosts.checkIntervalS")}
|
||||
description={t("hosts.checkIntervalDesc")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.statsConfig.statusCheckInterval}
|
||||
onChange={(e) =>
|
||||
setField("statsConfig", {
|
||||
...form.statsConfig,
|
||||
statusCheckInterval: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
@@ -2598,24 +2998,25 @@ function HostEditor({
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
{!form.statsConfig.useGlobalMetricsInterval && (
|
||||
<SettingRow
|
||||
label={t("hosts.metricsIntervalS")}
|
||||
description={t("hosts.metricsIntervalDesc2")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.statsConfig.metricsInterval}
|
||||
onChange={(e) =>
|
||||
setField("statsConfig", {
|
||||
...form.statsConfig,
|
||||
metricsInterval: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
{form.statsConfig.metricsEnabled &&
|
||||
!form.statsConfig.useGlobalMetricsInterval && (
|
||||
<SettingRow
|
||||
label={t("hosts.metricsIntervalS")}
|
||||
description={t("hosts.metricsIntervalDesc2")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.statsConfig.metricsInterval}
|
||||
onChange={(e) =>
|
||||
setField("statsConfig", {
|
||||
...form.statsConfig,
|
||||
metricsInterval: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
@@ -2695,7 +3096,7 @@ function HostEditor({
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
title={t("hosts.quickActionsToolbar").split(".")[0]}
|
||||
title={t("hosts.quickActionsLabel")}
|
||||
icon={<Zap className="size-3.5" />}
|
||||
action={
|
||||
<Button
|
||||
@@ -2906,6 +3307,7 @@ function HostEditor({
|
||||
placeholder="Auto"
|
||||
value={form.guacamoleConfig["width"] ?? ""}
|
||||
onChange={(e) => setGuacField("width", e.target.value)}
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -2917,6 +3319,7 @@ function HostEditor({
|
||||
placeholder="Auto"
|
||||
value={form.guacamoleConfig["height"] ?? ""}
|
||||
onChange={(e) => setGuacField("height", e.target.value)}
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2929,6 +3332,7 @@ function HostEditor({
|
||||
placeholder="96"
|
||||
value={form.guacamoleConfig["dpi"] ?? ""}
|
||||
onChange={(e) => setGuacField("dpi", e.target.value)}
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -4650,7 +5054,10 @@ function CredentialEditorView({
|
||||
setGeneratingKey(true);
|
||||
try {
|
||||
const result = await generateKeyPair(
|
||||
keyType,
|
||||
keyType as
|
||||
| "ssh-ed25519"
|
||||
| "ssh-rsa"
|
||||
| "ecdsa-sha2-nistp256",
|
||||
bits,
|
||||
credForm.passphrase || undefined,
|
||||
);
|
||||
@@ -5681,6 +6088,13 @@ export function HostManager({
|
||||
const normalized = hostsArray.map((h: any) => ({
|
||||
...h,
|
||||
port: h.port ?? h.sshPort ?? 22,
|
||||
enableSsh:
|
||||
h.enableSsh ??
|
||||
(h.connectionType === "ssh" || !h.connectionType),
|
||||
enableRdp: h.enableRdp ?? h.connectionType === "rdp",
|
||||
enableVnc: h.enableVnc ?? h.connectionType === "vnc",
|
||||
enableTelnet:
|
||||
h.enableTelnet ?? h.connectionType === "telnet",
|
||||
}));
|
||||
const result = await bulkImportSSHHosts(
|
||||
normalized,
|
||||
|
||||
Reference in New Issue
Block a user