Files
Termix/src/ui/sidebar/HostManager.tsx
T
2026-05-14 00:55:12 -05:00

6552 lines
272 KiB
TypeScript

import React, {
useState,
useEffect,
useRef,
type MutableRefObject,
} from "react";
import { useTranslation } from "react-i18next";
import {
TERMINAL_THEMES,
TERMINAL_FONTS,
BELL_STYLES,
FAST_SCROLL_MODIFIERS,
CURSOR_STYLES,
} from "@/lib/terminal-themes";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
Activity,
ArrowLeft,
Box,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Copy,
Download,
Folder,
FolderOpen,
FolderSearch,
Globe,
Info,
KeyRound,
LayoutDashboard,
ListChecks,
Lock,
MoreHorizontal,
Monitor,
Network,
Palette,
Pencil,
Pin,
Plus,
RefreshCw,
Search,
Server,
Settings,
Share2,
Shield,
Tag,
Terminal,
Trash2,
Upload,
User,
Users,
X,
Zap,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import { toast } from "sonner";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import {
getSSHHosts,
getCredentials,
createSSHHost,
updateSSHHost,
deleteSSHHost,
createCredential,
updateCredential,
deleteCredential,
getAllServerStatuses,
getServerMetricsById,
bulkImportSSHHosts,
bulkUpdateSSHHosts,
generateKeyPair,
generatePublicKeyFromPrivate,
deployCredentialToHost,
getSnippets,
getUserList,
getRoles,
shareHost,
getHostAccess,
revokeHostAccess,
renameFolder,
renameCredentialFolder,
refreshServerPolling,
deleteAllHostsInFolder,
subscribeTunnelStatuses,
connectTunnel,
disconnectTunnel,
} from "@/main-axios";
import type { SSHHostWithStatus } from "@/main-axios";
import type { Host, Credential } from "@/types/ui-types";
function sshHostToHost(h: SSHHostWithStatus): Host {
const parseJson = (v: any) => {
if (!v) return undefined;
if (typeof v === "string") {
try {
return JSON.parse(v);
} catch {
return undefined;
}
}
return v;
};
return {
id: String(h.id),
name: h.name,
username: h.username,
ip: h.ip,
port: h.port,
folder: h.folder ?? "",
online: h.status === "online",
cpu: null,
ram: null,
lastAccess: "",
tags: h.tags ?? [],
authType: h.authType,
password: h.password,
key: typeof h.key === "string" ? h.key : undefined,
keyPassword: h.keyPassword,
keyType: h.keyType,
credentialId: h.credentialId != null ? String(h.credentialId) : undefined,
notes: h.notes,
pin: h.pin ?? false,
macAddress: h.macAddress,
enableSsh:
h.enableSsh != null
? h.enableSsh
: h.connectionType === "ssh" || !h.connectionType,
enableTerminal:
h.enableTerminal ??
(h.enableSsh != null
? h.enableSsh
: h.connectionType === "ssh" || !h.connectionType),
enableTunnel: h.enableTunnel ?? false,
enableFileManager: h.enableFileManager ?? false,
enableDocker: h.enableDocker ?? false,
enableRdp: h.enableRdp != null ? h.enableRdp : h.connectionType === "rdp",
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,
rdpUser: h.rdpUser,
rdpPassword: h.rdpPassword,
domain: h.rdpDomain,
security: h.rdpSecurity,
ignoreCert: h.rdpIgnoreCert ?? false,
vncPassword: h.vncPassword,
vncUser: h.vncUser,
telnetUser: h.telnetUser,
telnetPassword: h.telnetPassword,
quickActions: (h.quickActions ?? []).map((a: any) => ({
name: a.name,
snippetId: String(a.snippetId),
})),
serverTunnels: parseJson(h.tunnelConnections) ?? [],
jumpHosts: (parseJson(h.jumpHosts) ?? []).map((j: any) => ({
hostId: String(j.hostId ?? j.hostid ?? j),
})),
portKnockSequence: parseJson(h.portKnockSequence) ?? [],
defaultPath: h.defaultPath,
terminalConfig: parseJson(h.terminalConfig) as Host["terminalConfig"],
statsConfig: parseJson(h.statsConfig) as Host["statsConfig"],
guacamoleConfig: parseJson(h.guacamoleConfig),
forceKeyboardInteractive: h.forceKeyboardInteractive ?? false,
useSocks5: h.useSocks5,
socks5Host: h.socks5Host,
socks5Port: h.socks5Port,
socks5Username: h.socks5Username,
socks5Password: h.socks5Password,
};
}
const HOST_TAB_IDS = [
"general",
"ssh",
"tunnels",
"docker",
"files",
"stats",
"rdp",
"vnc",
"telnet",
"sharing",
] as const;
const CREDENTIAL_TAB_IDS = ["general", "auth"] as const;
type HostTab = {
id: (typeof HOST_TAB_IDS)[number];
label: string;
icon: React.ReactNode;
};
type CredentialTab = {
id: (typeof CREDENTIAL_TAB_IDS)[number];
label: string;
icon: React.ReactNode;
};
function makeHostTabs(t: (key: string) => string): HostTab[] {
return [
{
id: "general",
label: t("hosts.tabGeneral"),
icon: <Settings className="size-3" />,
},
{ id: "ssh", label: "SSH", icon: <Terminal className="size-3" /> },
{
id: "tunnels",
label: t("hosts.tabTunnels"),
icon: <Network className="size-3" />,
},
{
id: "docker",
label: t("hosts.tabDocker"),
icon: <Box className="size-3" />,
},
{
id: "files",
label: t("hosts.tabFiles"),
icon: <Folder className="size-3" />,
},
{
id: "stats",
label: t("hosts.tabStats"),
icon: <Activity className="size-3" />,
},
{ id: "rdp", label: "RDP", icon: <Monitor className="size-3" /> },
{ id: "vnc", label: "VNC", icon: <Monitor className="size-3" /> },
{
id: "telnet",
label: t("hosts.tabTelnet"),
icon: <Terminal className="size-3" />,
},
{
id: "sharing",
label: t("hosts.tabSharing"),
icon: <Share2 className="size-3" />,
},
];
}
function makeCredentialTabs(t: (key: string) => string): CredentialTab[] {
return [
{
id: "general",
label: t("hosts.tabGeneral"),
icon: <Settings className="size-3" />,
},
{
id: "auth",
label: t("hosts.tabAuthentication"),
icon: <KeyRound className="size-3" />,
},
];
}
const SSH_DEP_TABS = new Set(["tunnels", "docker", "files", "stats"]);
function TabStrip({
tabs,
activeTab,
onTabChange,
}: {
tabs: { id: string; label: string; icon: React.ReactNode }[];
activeTab: string;
onTabChange: (id: string) => void;
}) {
const hasSshGroup = tabs.some((t) => SSH_DEP_TABS.has(t.id));
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (e.deltaY === 0) return;
e.preventDefault();
el.scrollLeft += e.deltaY;
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, []);
// Split tabs into groups for rendering a labeled SSH section
const nonSshTabs = tabs.filter((t) => !SSH_DEP_TABS.has(t.id));
const sshDepTabs = tabs.filter((t) => SSH_DEP_TABS.has(t.id));
const renderTab = (tab: (typeof tabs)[0]) => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`flex items-center gap-1.5 px-3 py-2 text-xs font-medium whitespace-nowrap border-b-2 transition-colors shrink-0 ${
activeTab === tab.id
? "border-accent-brand text-accent-brand"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{tab.icon}
{tab.label}
</button>
);
return (
<div ref={ref} className="overflow-x-auto">
<div className="flex min-w-max">
{nonSshTabs.map(renderTab)}
{hasSshGroup && sshDepTabs.length > 0 && (
<div className="flex flex-col border-l border-border/40 ml-0.5">
<div className="flex items-center gap-1 px-2 pt-0.5">
<Terminal className="size-2.5 text-muted-foreground/30" />
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground/30">
SSH
</span>
</div>
<div className="flex">{sshDepTabs.map(renderTab)}</div>
</div>
)}
</div>
</div>
);
}
function HostRow({
host,
selectionMode,
selected,
onToggleSelect,
onEdit,
onDelete,
onDragStart,
onDragEnd,
depth = 0,
stripeIndex = 0,
statusesLoading = false,
initialLoadComplete = true,
}: {
host: Host;
selectionMode: boolean;
selected: boolean;
onToggleSelect: () => void;
onEdit: () => void;
onDelete: () => void;
onDragStart?: () => void;
onDragEnd?: () => void;
depth?: number;
stripeIndex?: number;
statusesLoading?: boolean;
initialLoadComplete?: boolean;
}) {
const { t } = useTranslation();
const [hovered, setHovered] = useState(false);
const connTypeColor = "border-border/60 text-muted-foreground/60";
const metricsEnabled = host.statsConfig?.metricsEnabled !== false;
const fireOpen = (type: string) => {
window.dispatchEvent(
new CustomEvent("termix:open-tab", { detail: { hostId: host.id, type } }),
);
};
return (
<div
draggable={!!onDragStart && !selectionMode}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={selectionMode ? onToggleSelect : undefined}
style={{ paddingLeft: depth > 0 ? `${depth * 12 + 8}px` : undefined }}
className={`relative flex flex-col border-b border-border/50 last:border-0 transition-colors select-none
${selectionMode ? "cursor-pointer" : ""}
${selected ? "bg-accent-brand/5" : hovered ? "bg-muted/40" : stripeIndex % 2 === 1 ? "bg-muted/20" : ""}
${onDragStart && !selectionMode ? "cursor-grab active:cursor-grabbing" : ""}`}
>
{/* Main row */}
<div className="flex items-center gap-2 px-3 py-2">
{selectionMode && (
<div
className={`size-3.5 border-2 flex items-center justify-center shrink-0 transition-colors ${selected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
>
{selected && <Check className="size-2 text-background" />}
</div>
)}
{/* Status dot */}
<div
className={`size-1.5 rounded-full shrink-0 ${
!initialLoadComplete || (statusesLoading && !host.online)
? "bg-muted-foreground/30 animate-[blink_1s_step-start_infinite]"
: host.online
? "bg-accent-brand"
: "bg-muted-foreground/25"
}`}
/>
{/* Name + badges */}
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<span className="text-[13px] font-semibold truncate leading-none">
{host.name}
</span>
{host.pin && (
<Pin className="size-2.5 text-accent-brand/50 shrink-0" />
)}
<div className="flex items-center gap-0.5 shrink-0">
{host.enableSsh && (
<span
className={`text-[9px] px-1 py-0.5 font-bold border leading-none ${connTypeColor}`}
>
SSH
</span>
)}
{host.enableRdp && (
<span
className={`text-[9px] px-1 py-0.5 font-bold border leading-none ${connTypeColor}`}
>
RDP
</span>
)}
{host.enableVnc && (
<span
className={`text-[9px] px-1 py-0.5 font-bold border leading-none ${connTypeColor}`}
>
VNC
</span>
)}
{host.enableTelnet && (
<span
className={`text-[9px] px-1 py-0.5 font-bold border leading-none ${connTypeColor}`}
>
TELNET
</span>
)}
</div>
</div>
{/* Right: last access always visible, CPU/RAM on hover */}
<div className="flex items-center gap-2 shrink-0">
{host.online &&
hovered &&
metricsEnabled &&
host.cpu != null &&
host.ram != null && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<span className="text-[9px] text-muted-foreground/50">
CPU
</span>
<div className="w-10 h-[3px] bg-muted-foreground/15 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${host.cpu > 80 ? "bg-red-400" : host.cpu > 50 ? "bg-yellow-400" : "bg-accent-brand"}`}
style={{ width: `${host.cpu}%` }}
/>
</div>
<span className="text-[9px] tabular-nums text-accent-brand font-bold">
{host.cpu}%
</span>
</div>
<div className="flex items-center gap-1">
<span className="text-[9px] text-muted-foreground/50">
RAM
</span>
<div className="w-10 h-[3px] bg-muted-foreground/15 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${host.ram > 80 ? "bg-red-400" : host.ram > 60 ? "bg-yellow-400" : "bg-accent-brand/60"}`}
style={{ width: `${host.ram}%` }}
/>
</div>
<span className="text-[9px] tabular-nums text-accent-brand font-bold">
{host.ram}%
</span>
</div>
</div>
)}
<span className="text-[10px] text-muted-foreground/40 tabular-nums shrink-0">
{host.lastAccess}
</span>
</div>
</div>
{/* Sub-row: address + tags */}
<div
className="flex items-center gap-2 px-3 pb-1.5 -mt-0.5"
style={{
paddingLeft:
depth > 0 ? `${depth * 12 + 8 + 12 + 8 + 6}px` : undefined,
}}
>
<span className="text-[11px] text-muted-foreground/50 font-mono break-all">
{host.username}@{host.ip}:{host.sshPort || host.port}
</span>
{host.tags && host.tags.length > 0 && (
<div className="flex items-center gap-1 min-w-0 overflow-hidden">
{host.tags.slice(0, 4).map((tag) => (
<span
key={tag}
className="text-[9px] px-1 py-px border border-border/50 bg-muted/30 text-muted-foreground/60 lowercase shrink-0 leading-none"
>
{tag}
</span>
))}
{host.tags.length > 4 && (
<span className="text-[9px] text-muted-foreground/40 shrink-0">
+{host.tags.length - 4}
</span>
)}
</div>
)}
</div>
{/* Hover action tray */}
{hovered && !selectionMode && (
<div
className="border-t border-border/40"
style={{ marginLeft: depth > 0 ? `-${depth * 12 + 8}px` : undefined }}
>
<div
className="flex items-center pt-0.5 pb-1"
style={{
paddingLeft: depth > 0 ? `${depth * 12 + 8}px` : "8px",
paddingRight: "8px",
}}
>
{host.enableTerminal && (
<button
onClick={(e) => {
e.stopPropagation();
fireOpen("terminal");
}}
className="flex items-center gap-1 px-2 py-1 text-[10px] font-medium text-muted-foreground/60 hover:text-foreground hover:bg-muted rounded transition-colors"
>
<Terminal className="size-3 shrink-0" />
<span>{t("hosts.terminal")}</span>
</button>
)}
{host.enableFileManager && (
<button
onClick={(e) => {
e.stopPropagation();
fireOpen("files");
}}
className="flex items-center gap-1 px-2 py-1 text-[10px] font-medium text-muted-foreground/60 hover:text-foreground hover:bg-muted rounded transition-colors"
>
<FolderSearch className="size-3 shrink-0" />
<span>{t("hosts.fileManager")}</span>
</button>
)}
{host.enableDocker && (
<button
onClick={(e) => {
e.stopPropagation();
fireOpen("docker");
}}
className="flex items-center gap-1 px-2 py-1 text-[10px] font-medium text-muted-foreground/60 hover:text-foreground hover:bg-muted rounded transition-colors"
>
<Box className="size-3 shrink-0" />
<span>{t("hosts.docker")}</span>
</button>
)}
{host.enableTunnel && (
<button
onClick={(e) => {
e.stopPropagation();
fireOpen("tunnel");
}}
className="flex items-center gap-1 px-2 py-1 text-[10px] font-medium text-muted-foreground/60 hover:text-foreground hover:bg-muted rounded transition-colors"
>
<Network className="size-3 shrink-0" />
<span>{t("hosts.tunnel")}</span>
</button>
)}
{metricsEnabled && (
<button
onClick={(e) => {
e.stopPropagation();
fireOpen("stats");
}}
className="flex items-center gap-1 px-2 py-1 text-[10px] font-medium text-muted-foreground/60 hover:text-foreground hover:bg-muted rounded transition-colors"
>
<Server className="size-3 shrink-0" />
<span>{t("hosts.serverStats")}</span>
</button>
)}
{host.enableRdp && (
<button
onClick={(e) => {
e.stopPropagation();
fireOpen("rdp");
}}
className="flex items-center gap-1 px-2 py-1 text-[10px] font-medium text-muted-foreground/60 hover:text-foreground hover:bg-muted rounded transition-colors"
>
<Monitor className="size-3 shrink-0" />
<span>RDP</span>
</button>
)}
{host.enableVnc && (
<button
onClick={(e) => {
e.stopPropagation();
fireOpen("vnc");
}}
className="flex items-center gap-1 px-2 py-1 text-[10px] font-medium text-muted-foreground/60 hover:text-foreground hover:bg-muted rounded transition-colors"
>
<Monitor className="size-3 shrink-0" />
<span>VNC</span>
</button>
)}
{host.enableTelnet && (
<button
onClick={(e) => {
e.stopPropagation();
fireOpen("telnet");
}}
className="flex items-center gap-1 px-2 py-1 text-[10px] font-medium text-muted-foreground/60 hover:text-foreground hover:bg-muted rounded transition-colors"
>
<Terminal className="size-3 shrink-0" />
<span>{t("hosts.telnet")}</span>
</button>
)}
<div className="flex-1" />
<button
title={t("hosts.editHostTooltip")}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className="flex items-center gap-1 px-2 py-1 text-[10px] font-medium text-muted-foreground/60 hover:text-foreground hover:bg-muted rounded transition-colors"
>
<Pencil className="size-3 shrink-0" />
<span>{t("common.edit")}</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="flex items-center justify-center size-6 text-muted-foreground/50 hover:text-foreground hover:bg-muted rounded transition-colors"
>
<MoreHorizontal className="size-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs">
<DropdownMenuItem
onClick={() => toast.success(t("hosts.hostCloned"))}
>
<Copy className="size-3.5 mr-2" />
{t("hosts.cloneHostAction")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(
`${host.username}@${host.ip}`,
);
toast.success(t("hosts.copiedToClipboard"));
}}
>
<Copy className="size-3.5 mr-2" />
{t("hosts.copyAddress")}
</DropdownMenuItem>
{host.enableTerminal && (
<DropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(
`${window.location.origin}?view=terminal&hostId=${host.id}`,
);
toast.success(t("hosts.terminalUrlCopied"));
}}
>
<Copy className="size-3.5 mr-2" />
{t("hosts.copyTerminalUrlAction")}
</DropdownMenuItem>
)}
{host.enableFileManager && (
<DropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(
`${window.location.origin}?view=file_manager&hostId=${host.id}`,
);
toast.success(t("hosts.fileManagerUrlCopied"));
}}
>
<Copy className="size-3.5 mr-2" />
{t("hosts.copyFileManagerUrlAction")}
</DropdownMenuItem>
)}
{(host.enableRdp || host.enableVnc || host.enableTelnet) && (
<DropdownMenuItem
onClick={() => {
const proto = host.enableRdp
? "rdp"
: host.enableVnc
? "vnc"
: "telnet";
navigator.clipboard.writeText(
`${window.location.origin}?view=${proto}&hostId=${host.id}`,
);
toast.success(t("hosts.remoteDesktopUrlCopied"));
}}
>
<Copy className="size-3.5 mr-2" />
{t("hosts.copyRemoteDesktopUrlAction")}
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={onDelete}
>
<Trash2 className="size-3.5 mr-2" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
</div>
);
}
function HostEditor({
host,
activeTab,
onBack,
onSave,
protocols,
onProtocolChange,
onTabChange,
hosts,
credentials,
}: {
host: Host | null;
activeTab: string;
onBack: () => void;
onSave: (saved: any) => void;
protocols: {
enableSsh: boolean;
enableRdp: boolean;
enableVnc: boolean;
enableTelnet: boolean;
};
onProtocolChange: (p: Partial<typeof protocols>) => void;
onTabChange: (tab: string) => void;
hosts: Host[];
credentials: { id: string; name: string; username: string }[];
}) {
const { t } = useTranslation();
const [form, setForm] = useState(() => {
const rawTheme = host?.terminalConfig?.theme;
const normalizedTheme =
!rawTheme ||
["Termix Dark", "Termix Light", "termixDark", "termixLight"].includes(
rawTheme,
)
? "termix"
: TERMINAL_THEMES[rawTheme]
? rawTheme
: "termix";
return {
name: host?.name ?? "",
ip: host?.ip ?? "",
username: host?.username ?? "",
sshPort: host?.sshPort ?? 22,
rdpPort: host?.rdpPort ?? 3389,
vncPort: host?.vncPort ?? 5900,
telnetPort: host?.telnetPort ?? 23,
authType: host?.authType ?? "password",
password: host?.password ?? "",
key: host?.key ?? "",
keyPassword: host?.keyPassword ?? "",
credentialId: host?.credentialId ?? "",
folder: host?.folder ?? "",
tags: host?.tags ?? ([] as string[]),
tagInput: "",
notes: host?.notes ?? "",
pin: host?.pin ?? false,
macAddress: host?.macAddress ?? "",
useSocks5: host?.useSocks5 ?? false,
socks5Host: host?.socks5Host ?? "",
socks5Port: host?.socks5Port ?? 1080,
socks5Username: host?.socks5Username ?? "",
socks5Password: host?.socks5Password ?? "",
enableTerminal: host?.enableTerminal ?? true,
enableFileManager: host?.enableFileManager ?? false,
enableDocker: host?.enableDocker ?? false,
enableTunnel: host?.enableTunnel ?? false,
defaultPath: host?.defaultPath ?? "/",
forceKeyboardInteractive: host?.forceKeyboardInteractive ?? false,
fontSize: host?.terminalConfig?.fontSize ?? 14,
fontFamily:
host?.terminalConfig?.fontFamily ?? "Caskaydia Cove Nerd Font Mono",
theme: normalizedTheme,
cursorStyle: (host?.terminalConfig?.cursorStyle ?? "bar") as
| "block"
| "underline"
| "bar",
cursorBlink: host?.terminalConfig?.cursorBlink ?? true,
scrollback: host?.terminalConfig?.scrollback ?? 10000,
letterSpacing: host?.terminalConfig?.letterSpacing ?? 0,
lineHeight: host?.terminalConfig?.lineHeight ?? 1.0,
bellStyle: (host?.terminalConfig?.bellStyle ?? "none") as
| "none"
| "sound"
| "visual"
| "both",
rightClickSelectsWord:
host?.terminalConfig?.rightClickSelectsWord ?? false,
fastScrollModifier: (host?.terminalConfig?.fastScrollModifier ??
"alt") as "alt" | "ctrl" | "shift",
fastScrollSensitivity: host?.terminalConfig?.fastScrollSensitivity ?? 5,
minimumContrastRatio: host?.terminalConfig?.minimumContrastRatio ?? 1,
backspaceMode: (host?.terminalConfig?.backspaceMode ?? "normal") as
| "normal"
| "control-h",
startupSnippetId: host?.terminalConfig?.startupSnippetId ?? null,
moshCommand: host?.terminalConfig?.moshCommand ?? "",
agentForwarding: host?.terminalConfig?.agentForwarding ?? false,
autoMosh: host?.terminalConfig?.autoMosh ?? false,
autoTmux: host?.terminalConfig?.autoTmux ?? false,
sudoPasswordAutoFill: host?.terminalConfig?.sudoPasswordAutoFill ?? false,
sudoPassword: host?.terminalConfig?.sudoPassword ?? "",
keepaliveInterval: host?.terminalConfig?.keepaliveInterval ?? 30,
keepaliveCountMax: host?.terminalConfig?.keepaliveCountMax ?? 3,
environmentVariables:
host?.terminalConfig?.environmentVariables ??
([] as { key: string; value: string }[]),
serverTunnels: host?.serverTunnels ?? ([] as Host["serverTunnels"]),
jumpHosts: host?.jumpHosts ?? ([] as { hostId: string }[]),
portKnockSequence:
host?.portKnockSequence ??
([] as { port: number; protocol: "tcp" | "udp"; delay: number }[]),
quickActions:
host?.quickActions ?? ([] as { name: string; snippetId: string }[]),
rdpUser: host?.rdpUser ?? "",
rdpPassword: host?.rdpPassword ?? "",
domain: host?.domain ?? "",
security: host?.security ?? "",
ignoreCert: host?.ignoreCert ?? false,
vncPassword: host?.vncPassword ?? "",
vncUser: host?.vncUser ?? "",
telnetUser: host?.telnetUser ?? "",
telnetPassword: host?.telnetPassword ?? "",
guacamoleConfig: host?.guacamoleConfig ?? ({} as Record<string, any>),
statsConfig: host?.statsConfig ?? {
statusCheckEnabled: true,
statusCheckInterval: 60,
useGlobalStatusInterval: true,
metricsEnabled: true,
metricsInterval: 30,
useGlobalMetricsInterval: true,
enabledWidgets: [
"cpu",
"memory",
"disk",
"network",
"uptime",
"system",
"login_stats",
"processes",
"ports",
"firewall",
],
},
};
});
const setField = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
setForm((p) => ({ ...p, [k]: v }));
const setGuacField = (key: string, value: any) =>
setField("guacamoleConfig", { ...form.guacamoleConfig, [key]: value });
const [saving, setSaving] = useState(false);
const [snippets, setSnippets] = useState<{ id: number; name: string }[]>([]);
const [shareType, setShareType] = useState<"user" | "role">("user");
const [shareGranteeId, setShareGranteeId] = useState("");
const [sharePermission, setSharePermission] = useState("view");
const [shareExpiryHours, setShareExpiryHours] = useState("");
const [accessList, setAccessList] = useState<any[]>([]);
const [shareUsers, setShareUsers] = useState<
{ id: string; username: string }[]
>([]);
const [shareRoles, setShareRoles] = useState<{ id: string; name: string }[]>(
[],
);
const [sharingLoaded, setSharingLoaded] = useState(false);
const [sharingLoadError, setSharingLoadError] = useState(false);
const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, any>>({});
const [connectingTunnel, setConnectingTunnel] = useState<number | null>(null);
useEffect(() => {
getSnippets()
.then((res: any) => {
const arr = Array.isArray(res) ? res : (res?.snippets ?? []);
setSnippets(
arr.map((s: any) => ({
id: s.id,
name: s.name ?? s.title ?? `Snippet ${s.id}`,
})),
);
})
.catch(() => {});
}, []);
useEffect(() => {
if (activeTab !== "sharing" || !host) return;
if (sharingLoaded) return;
setSharingLoaded(true);
Promise.all([
getHostAccess(Number(host.id)).catch(() => ({ access: [] })),
getUserList().catch(() => ({ users: [] })),
getRoles().catch(() => ({ roles: [] })),
])
.then(([accessRes, usersRes, rolesRes]) => {
setAccessList((accessRes as any)?.access ?? []);
setShareUsers(
((usersRes as any)?.users ?? []).map((u: any) => ({
id: String(u.id ?? u.userId),
username: u.username,
})),
);
setShareRoles(
((rolesRes as any)?.roles ?? []).map((r: any) => ({
id: String(r.id),
name: r.name,
})),
);
})
.catch(() => setSharingLoadError(true));
}, [activeTab, host, sharingLoaded]);
useEffect(() => {
setSharingLoaded(false);
setSharingLoadError(false);
setAccessList([]);
}, [host?.id]);
useEffect(() => {
if (activeTab !== "tunnels") return;
const unsub = subscribeTunnelStatuses((s) => setTunnelStatuses(s));
return unsub;
}, [activeTab]);
const handleSave = async () => {
setSaving(true);
try {
const tags = form.tags;
const data = {
connectionType: protocols.enableSsh
? "ssh"
: protocols.enableRdp
? "rdp"
: protocols.enableVnc
? "vnc"
: "telnet",
name: form.name,
ip: form.ip,
port: protocols.enableSsh
? Number(form.sshPort)
: protocols.enableRdp
? Number(form.rdpPort)
: protocols.enableVnc
? Number(form.vncPort)
: Number(form.telnetPort),
username: form.username,
folder: form.folder,
tags,
pin: form.pin,
authType: form.authType,
password: form.password || null,
key: form.key || null,
keyPassword: form.keyPassword || null,
credentialId: form.credentialId ? Number(form.credentialId) : null,
notes: form.notes,
macAddress: form.macAddress || null,
enableTerminal: form.enableTerminal,
enableTunnel: form.enableTunnel,
enableFileManager: form.enableFileManager,
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,
enableSsh: protocols.enableSsh,
enableRdp: protocols.enableRdp,
enableVnc: protocols.enableVnc,
enableTelnet: protocols.enableTelnet,
sshPort: Number(form.sshPort),
rdpPort: Number(form.rdpPort),
vncPort: Number(form.vncPort),
telnetPort: Number(form.telnetPort),
forceKeyboardInteractive: form.forceKeyboardInteractive,
rdpUser: form.rdpUser || null,
rdpPassword: form.rdpPassword || null,
rdpDomain: form.domain || null,
rdpSecurity: form.security || null,
rdpIgnoreCert: form.ignoreCert,
vncPassword: form.vncPassword || null,
vncUser: form.vncUser || null,
telnetUser: form.telnetUser || null,
telnetPassword: form.telnetPassword || null,
jumpHosts: form.jumpHosts,
portKnockSequence: form.portKnockSequence,
tunnelConnections: form.serverTunnels,
quickActions: form.quickActions.map((a) => ({
name: a.name,
snippetId: Number(a.snippetId),
})),
statsConfig: form.statsConfig,
guacamoleConfig:
(protocols.enableRdp ||
protocols.enableVnc ||
protocols.enableTelnet) &&
Object.keys(form.guacamoleConfig).length > 0
? form.guacamoleConfig
: null,
terminalConfig: protocols.enableSsh
? {
theme: form.theme,
cursorBlink: form.cursorBlink,
cursorStyle: form.cursorStyle,
fontSize: Number(form.fontSize),
fontFamily: form.fontFamily,
scrollback: Number(form.scrollback),
letterSpacing: Number(form.letterSpacing),
lineHeight: Number(form.lineHeight),
bellStyle: form.bellStyle,
rightClickSelectsWord: form.rightClickSelectsWord,
fastScrollModifier: form.fastScrollModifier,
fastScrollSensitivity: Number(form.fastScrollSensitivity),
minimumContrastRatio: Number(form.minimumContrastRatio),
backspaceMode: form.backspaceMode,
startupSnippetId: form.startupSnippetId ?? null,
moshCommand: form.moshCommand || null,
agentForwarding: form.agentForwarding,
autoMosh: form.autoMosh,
autoTmux: form.autoTmux,
sudoPasswordAutoFill: form.sudoPasswordAutoFill,
sudoPassword: form.sudoPassword || null,
keepaliveInterval: Number(form.keepaliveInterval),
keepaliveCountMax: Number(form.keepaliveCountMax),
environmentVariables: form.environmentVariables,
}
: null,
};
const saved = host
? await updateSSHHost(Number(host.id), data as any)
: await createSSHHost(data as any);
toast.success(host ? t("hosts.hostUpdated") : t("hosts.hostCreated"));
onSave(saved);
} catch {
toast.error(t("hosts.failedToSave"));
} finally {
setSaving(false);
}
};
const authMethod = form.authType;
const handleProtocolToggle = (
proto: keyof typeof protocols,
value: boolean,
) => {
onProtocolChange({ [proto]: value });
const tabForProto: Record<string, string> = {
enableSsh: "ssh",
enableRdp: "rdp",
enableVnc: "vnc",
enableTelnet: "telnet",
};
if (!value && activeTab === tabForProto[proto]) onTabChange("general");
if (value && tabForProto[proto]) onTabChange(tabForProto[proto]);
};
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3">
{activeTab === "general" && (
<>
{/* Protocols — enable/disable each connection type */}
<SectionCard
title={t("hosts.protocols")}
icon={<Globe className="size-3.5" />}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 py-3">
{[
{
proto: "enableSsh" as const,
label: "SSH",
desc: t("hosts.secureShell"),
icon: <Terminal className="size-4" />,
portField: "sshPort" as const,
},
{
proto: "enableRdp" as const,
label: "RDP",
desc: "Remote Desktop",
icon: <Monitor className="size-4" />,
portField: "rdpPort" as const,
},
{
proto: "enableVnc" as const,
label: "VNC",
desc: t("hosts.virtualNetwork"),
icon: <Monitor className="size-4" />,
portField: "vncPort" as const,
},
{
proto: "enableTelnet" as const,
label: "Telnet",
desc: t("hosts.unencryptedShell"),
icon: <Terminal className="size-4" />,
portField: "telnetPort" as const,
},
].map(({ proto, label, desc, icon, portField }) => {
const enabled = protocols[proto];
return (
<div
key={proto}
className={`flex items-center gap-3 p-3 border transition-colors ${enabled ? "border-accent-brand/20 bg-accent-brand/5" : "border-border bg-muted/10"}`}
>
<div
className={`size-8 flex items-center justify-center shrink-0 ${enabled ? "text-accent-brand" : "text-muted-foreground/30"}`}
>
{icon}
</div>
<div className="flex flex-col gap-1 flex-1 min-w-0">
<span
className={`text-xs font-bold ${enabled ? "text-foreground" : "text-muted-foreground/50"}`}
>
{label}
</span>
<span className="text-[10px] text-muted-foreground/50">
{desc}
</span>
</div>
<FakeSwitch
checked={enabled}
onChange={(v: boolean) =>
handleProtocolToggle(proto, v)
}
/>
</div>
);
})}
</div>
</SectionCard>
<SectionCard
title={t("hosts.connectionDetails")}
icon={<Globe className="size-3.5" />}
>
<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.addressIp")}
</label>
<Input
placeholder="10.0.0.1 or example.com"
value={form.ip}
onChange={(e) => setField("ip", e.target.value)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.friendlyName")}
</label>
<Input
placeholder="e.g. Web Server Production"
value={form.name}
onChange={(e) => setField("name", e.target.value)}
/>
</div>
{protocols.enableSsh && (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
MAC Address
</label>
<Input
placeholder="AA:BB:CC:DD:EE:FF"
value={form.macAddress}
onChange={(e) => setField("macAddress", e.target.value)}
/>
</div>
)}
</div>
</div>
</SectionCard>
{!protocols.enableSsh &&
!protocols.enableRdp &&
!protocols.enableVnc &&
!protocols.enableTelnet && (
<div className="flex items-center gap-3 p-3 border border-border bg-muted/20 text-xs text-muted-foreground">
<Globe className="size-4 shrink-0 text-muted-foreground/40" />
<span>{t("hosts.enableAtLeastOneProtocol")}</span>
</div>
)}
<SectionCard
title={t("hosts.folderAndAdvanced")}
icon={<Tag className="size-3.5" />}
>
<div className="grid grid-cols-1 md:grid-cols-2 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.folder")}
</label>
<Input
placeholder="e.g. Production"
value={form.folder}
onChange={(e) => setField("folder", 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.tags")}
</label>
<div className="flex flex-wrap items-center gap-1 min-h-9 px-2 py-1 border border-border bg-background focus-within:ring-1 focus-within:ring-ring">
{form.tags.map((tag) => (
<span
key={tag}
className="flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] bg-muted border border-border/60 text-foreground"
>
{tag}
<button
type="button"
onClick={() =>
setField(
"tags",
form.tags.filter((tg) => tg !== tag),
)
}
className="text-muted-foreground hover:text-destructive ml-0.5"
>
<X className="size-2.5" />
</button>
</span>
))}
<input
className="flex-1 min-w-16 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50"
placeholder={
form.tags.length === 0 ? t("hosts.addTag") : ""
}
value={form.tagInput}
onChange={(e) => setField("tagInput", e.target.value)}
onKeyDown={(e) => {
if (
(e.key === " " || e.key === "Enter") &&
form.tagInput.trim()
) {
e.preventDefault();
const tag = form.tagInput.trim();
if (!form.tags.includes(tag))
setField("tags", [...form.tags, tag]);
setField("tagInput", "");
} else if (
e.key === "Backspace" &&
!form.tagInput &&
form.tags.length > 0
) {
setField("tags", form.tags.slice(0, -1));
}
}}
/>
</div>
</div>
<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.privateNotes")}
</label>
<textarea
rows={3}
placeholder={t("hosts.privateNotesPlaceholder")}
className="w-full px-3 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
value={form.notes}
onChange={(e) => setField("notes", e.target.value)}
/>
</div>
<SettingRow
label={t("hosts.pinToTop")}
description={t("hosts.pinToTopDesc")}
>
<FakeSwitch
checked={form.pin}
onChange={(v) => setField("pin", v)}
/>
</SettingRow>
</div>
<div className="flex flex-col gap-3 border-t border-border pt-4 pb-0">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.portKnockingSequence")}
</span>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() =>
setField("portKnockSequence", [
...form.portKnockSequence,
{ port: 0, protocol: "tcp" as const, delay: 0 },
])
}
>
<Plus className="size-3 mr-1" /> {t("hosts.addKnockBtn")}
</Button>
</div>
{form.portKnockSequence.length === 0 && (
<p className="text-[10px] text-muted-foreground/50">
{t("hosts.noPortKnocking")}
</p>
)}
<div className="flex flex-col gap-2">
{form.portKnockSequence.map((knock, i) => (
<div
key={i}
className="flex items-end gap-1.5 p-1.5 bg-muted/30 border border-border"
>
<span className="text-[9px] font-bold text-muted-foreground/50 mb-1.5 shrink-0">
{i + 1}.
</span>
<div className="flex flex-col gap-0.5">
<span className="text-[9px] text-muted-foreground/60 uppercase font-bold tracking-wide px-0.5">
{t("hosts.knockPort")}
</span>
<Input
className="h-7 text-xs w-20 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
placeholder="8080"
type="number"
value={knock.port}
onChange={(e) => {
const updated = [...form.portKnockSequence];
updated[i] = {
...updated[i],
port: Number(e.target.value),
};
setField("portKnockSequence", updated);
}}
/>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[9px] text-muted-foreground/60 uppercase font-bold tracking-wide px-0.5">
{t("hosts.protocol")}
</span>
<select
className="h-7 text-[10px] bg-background border border-border px-1"
value={knock.protocol}
onChange={(e) => {
const updated = [...form.portKnockSequence];
updated[i] = {
...updated[i],
protocol: e.target.value as "tcp" | "udp",
};
setField("portKnockSequence", updated);
}}
>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[9px] text-muted-foreground/60 uppercase font-bold tracking-wide px-0.5">
{t("hosts.delayAfterMs")}
</span>
<Input
className="h-7 text-xs w-20 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
placeholder="100"
type="number"
value={knock.delay}
onChange={(e) => {
const updated = [...form.portKnockSequence];
updated[i] = {
...updated[i],
delay: Number(e.target.value),
};
setField("portKnockSequence", updated);
}}
/>
</div>
<button
className="text-destructive p-1 mb-0.5"
onClick={() =>
setField(
"portKnockSequence",
form.portKnockSequence.filter(
(_, idx) => idx !== i,
),
)
}
>
<X className="size-3" />
</button>
</div>
))}
</div>
</div>
<div className="flex flex-col gap-4 border-t border-border pt-4 pb-2">
<SettingRow
label={t("hosts.useSocks5Proxy")}
description={t("hosts.useSocks5ProxyDesc")}
>
<FakeSwitch
checked={form.useSocks5}
onChange={(v) => setField("useSocks5", v)}
/>
</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>
</div>
)}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.jumpHostChainLabel")}
</span>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() =>
setField("jumpHosts", [
...form.jumpHosts,
{ hostId: "" },
])
}
>
<Plus className="size-3 mr-1" /> {t("hosts.addJumpBtn")}
</Button>
</div>
{form.jumpHosts.length === 0 && (
<p className="text-[10px] text-muted-foreground/50">
{t("hosts.noJumpHosts")}
</p>
)}
<div className="flex flex-col gap-2">
{form.jumpHosts.map((jh, i) => (
<div
key={i}
className="flex items-center gap-2 p-2 bg-background border border-border"
>
<span className="text-[10px] font-bold text-muted-foreground shrink-0">
{i + 1}.
</span>
<select
className="flex h-7 flex-1 border border-border bg-background px-2 py-0 text-xs outline-none focus:ring-1 focus:ring-ring"
value={jh.hostId}
onChange={(e) => {
const updated = [...form.jumpHosts];
updated[i] = { hostId: e.target.value };
setField("jumpHosts", updated);
}}
>
<option value="">{t("hosts.selectAServer")}</option>
{hosts
.filter((h) => (host ? h.id !== host.id : true))
.map((h) => (
<option key={h.id} value={h.id}>
{h.name || h.ip}
</option>
))}
</select>
<Button
variant="ghost"
size="icon"
className="size-7 text-destructive"
onClick={() =>
setField(
"jumpHosts",
form.jumpHosts.filter((_, idx) => idx !== i),
)
}
>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
</div>
</div>
</div>
</SectionCard>
</>
)}
{activeTab === "ssh" && (
<>
<SectionCard
title={t("hosts.connectionLabel")}
icon={<Globe className="size-3.5" />}
>
<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.sshPort")}
</label>
<Input
type="number"
placeholder="22"
value={form.sshPort}
onChange={(e) =>
setField("sshPort", Number(e.target.value) as any)
}
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</div>
</SectionCard>
<SectionCard
title={t("hosts.authenticationLabel")}
icon={<Shield className="size-3.5" />}
>
<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.authMethod")}
</label>
<div className="flex flex-wrap gap-2">
{["password", "key", "credential", "none", "opkssh"].map(
(m) => (
<button
key={m}
onClick={() => setField("authType", m as any)}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${authMethod === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{m}
</button>
),
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-border pt-4 mt-1">
<div className="flex flex-col gap-1.5">
<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>
{authMethod === "password" && (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.password")}
</label>
<Input
type="password"
placeholder="••••••••"
value={form.password}
onChange={(e) => setField("password", e.target.value)}
/>
</div>
)}
{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>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.keyPassphrase")}
</label>
<Input
type="password"
placeholder="Optional"
value={form.keyPassword}
onChange={(e) =>
setField("keyPassword", e.target.value)
}
/>
</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})
</option>
))}
</select>
</div>
)}
</div>
<SettingRow
label={t("hosts.forceKeyboardInteractiveLabel")}
description={t("hosts.forceKeyboardInteractiveShortDesc")}
>
<FakeSwitch
checked={form.forceKeyboardInteractive}
onChange={(v) => setField("forceKeyboardInteractive", v)}
/>
</SettingRow>
</div>
</SectionCard>
<SectionCard
title={t("hosts.terminalAppearance")}
icon={<Palette className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<div className="space-y-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.themePreview")}
</label>
<div className="w-full bg-[#111210] border border-border font-mono text-xs leading-relaxed overflow-hidden">
<div className="px-3 py-2.5 flex flex-col gap-0.5">
<div>
<span className="text-[#5af78e]">deploy@web-01</span>
<span className="text-[#555]">:</span>
<span className="text-[#57c7ff]">~</span>
<span className="text-[#555]">$</span>
<span className="text-[#f1f1f0]"> ls -la</span>
</div>
<div className="text-[#555]">total 48</div>
<div>
<span className="text-[#9aedfe]">drwxr-xr-x</span>
<span className="text-[#555]">
{" "}
5 deploy deploy 4096 May 1 09:12{" "}
</span>
<span className="text-[#57c7ff]">.</span>
</div>
<div>
<span className="text-[#9aedfe]">drwxr-xr-x</span>
<span className="text-[#555]">
{" "}
3 root root 4096 Apr 15 18:44{" "}
</span>
<span className="text-[#57c7ff]">..</span>
</div>
<div>
<span className="text-[#9aedfe]">-rw-r--r--</span>
<span className="text-[#555]">
{" "}
1 deploy deploy 220 Apr 15 18:44{" "}
</span>
<span className="text-[#f1f1f0]">.bash_logout</span>
</div>
<div>
<span className="text-[#9aedfe]">-rwxr-xr-x</span>
<span className="text-[#555]">
{" "}
1 deploy deploy 8192 May 1 08:55{" "}
</span>
<span className="text-[#5af78e]">deploy.sh</span>
</div>
<div className="flex items-center gap-0.5 mt-0.5">
<span className="text-[#5af78e]">deploy@web-01</span>
<span className="text-[#555]">:</span>
<span className="text-[#57c7ff]">~</span>
<span className="text-[#555]">$</span>
<span className="text-[#f1f1f0]"> </span>
<span className="inline-block w-1.5 h-3.5 bg-[#f1f1f0] animate-pulse" />
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.colorTheme")}
</label>
<select
value={form.theme}
onChange={(e) => setField("theme", 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"
>
{Object.entries(TERMINAL_THEMES)
.filter(
([key]) =>
key !== "termixDark" && key !== "termixLight",
)
.map(([key, theme]) => (
<option key={key} value={key}>
{theme.name}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.fontFamilyLabel")}
</label>
<select
value={form.fontFamily}
onChange={(e) => setField("fontFamily", 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 font-mono"
>
{TERMINAL_FONTS.map((f) => (
<option key={f.value} value={f.value}>
{f.label}
</option>
))}
</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>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.cursorStyleLabel")}
</label>
<select
value={form.cursorStyle}
onChange={(e) =>
setField("cursorStyle", 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"
>
{CURSOR_STYLES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</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)
}
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>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.bellStyleLabel")}
</label>
<select
value={form.bellStyle}
onChange={(e) =>
setField("bellStyle", 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"
>
{BELL_STYLES.map((b) => (
<option key={b.value} value={b.value}>
{b.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.backspaceModeLabel")}
</label>
<select
value={form.backspaceMode}
onChange={(e) =>
setField("backspaceMode", 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="normal">Normal (DEL)</option>
<option value="control-h">Control-H (BS)</option>
</select>
</div>
</div>
<SettingRow
label={t("hosts.cursorBlinking")}
description={t("hosts.cursorBlinkingDesc")}
>
<FakeSwitch
checked={form.cursorBlink}
onChange={(v) => setField("cursorBlink", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.rightClickSelectsWordLabel")}
description={t("hosts.rightClickSelectsWordShortDesc")}
>
<FakeSwitch
checked={form.rightClickSelectsWord}
onChange={(v) => setField("rightClickSelectsWord", v)}
/>
</SettingRow>
</div>
</SectionCard>
<SectionCard
title={t("hosts.behaviorAndAdvanced")}
icon={<Zap className="size-3.5" />}
>
<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"
/>
<span className="text-[10px] text-muted-foreground">
{t("hosts.scrollbackMaxLines")}
</span>
</div>
<SettingRow
label={t("hosts.sshAgentForwardingLabel")}
description={t("hosts.sshAgentForwardingShortDesc")}
>
<FakeSwitch
checked={form.agentForwarding}
onChange={(v) => setField("agentForwarding", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.enableAutoMosh")}
description={t("hosts.enableAutoMoshDesc")}
>
<FakeSwitch
checked={form.autoMosh}
onChange={(v) => setField("autoMosh", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.enableAutoTmux")}
description={t("hosts.enableAutoTmuxDesc")}
>
<FakeSwitch
checked={form.autoTmux}
onChange={(v) => setField("autoTmux", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.sudoPasswordAutoFillLabel")}
description={t("hosts.sudoPasswordAutoFillShortDesc")}
>
<FakeSwitch
checked={form.sudoPasswordAutoFill}
onChange={(v) => setField("sudoPasswordAutoFill", v)}
/>
</SettingRow>
{form.sudoPasswordAutoFill && (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.sudoPasswordLabel")}
</label>
<Input
type="password"
placeholder="••••••••"
value={form.sudoPassword}
onChange={(e) => setField("sudoPassword", e.target.value)}
/>
</div>
)}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.environmentVariablesLabel")}
</span>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() =>
setField("environmentVariables", [
...form.environmentVariables,
{ key: "", value: "" },
])
}
>
<Plus className="size-3 mr-1" />{" "}
{t("hosts.addVariableBtn")}
</Button>
</div>
{form.environmentVariables.length === 0 && (
<p className="text-[10px] text-muted-foreground/50">
{t("hosts.noEnvVars")}
</p>
)}
<div className="flex flex-col gap-2">
{form.environmentVariables.map((ev, i) => (
<div key={i} className="flex items-center gap-2">
<Input
className="h-7 text-xs flex-1"
placeholder="KEY"
value={ev.key}
onChange={(e) => {
const updated = [...form.environmentVariables];
updated[i] = { ...updated[i], key: e.target.value };
setField("environmentVariables", updated);
}}
/>
<Input
className="h-7 text-xs flex-1"
placeholder="VALUE"
value={ev.value}
onChange={(e) => {
const updated = [...form.environmentVariables];
updated[i] = {
...updated[i],
value: e.target.value,
};
setField("environmentVariables", updated);
}}
/>
<button
className="text-destructive"
onClick={() =>
setField(
"environmentVariables",
form.environmentVariables.filter(
(_, idx) => idx !== i,
),
)
}
>
<X className="size-4" />
</button>
</div>
))}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-border pt-4">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.fastScrollModifierLabel")}
</label>
<select
value={form.fastScrollModifier}
onChange={(e) =>
setField("fastScrollModifier", 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"
>
{FAST_SCROLL_MODIFIERS.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</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,
)
}
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</div>
{form.autoMosh && (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.moshCommandLabel")}
</label>
<Input
placeholder="mosh"
value={form.moshCommand}
onChange={(e) => setField("moshCommand", e.target.value)}
/>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-border pt-4">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.startupSnippetLabel")}
</label>
<select
value={form.startupSnippetId ?? ""}
onChange={(e) =>
setField(
"startupSnippetId",
e.target.value ? Number(e.target.value) : null,
)
}
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.none")}</option>
{snippets.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-border pt-4">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.keepaliveIntervalLabel")}
</label>
<Input
type="number"
value={form.keepaliveInterval}
onChange={(e) =>
setField(
"keepaliveInterval",
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.maxKeepaliveMisses")}
</label>
<Input
type="number"
value={form.keepaliveCountMax}
onChange={(e) =>
setField(
"keepaliveCountMax",
Number(e.target.value) as any,
)
}
/>
</div>
</div>
</div>
</SectionCard>
</>
)}
{activeTab === "tunnels" && (
<>
<SectionCard
title={t("hosts.tunnelSettings")}
icon={<Network className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.enableTunneling")}
description={t("hosts.enableTunnelingDesc")}
>
<FakeSwitch
checked={form.enableTunnel}
onChange={(v) => setField("enableTunnel", v)}
/>
</SettingRow>
<div className="text-xs text-muted-foreground p-3 bg-muted/30 border border-border space-y-1">
<p>{t("hosts.tunnelRequirementsText")}</p>
</div>
</div>
</SectionCard>
<SectionCard
title={t("hosts.serverTunnelsSection")}
icon={<Network className="size-3.5" />}
action={
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() =>
setField("serverTunnels", [
...form.serverTunnels,
{
mode: "local" as const,
sourcePort: 8080,
endpointHost: "",
endpointPort: 80,
bindHost: "127.0.0.1",
maxRetries: 3,
retryInterval: 10,
autoStart: false,
},
])
}
>
<Plus className="size-3 mr-1" /> {t("hosts.addTunnelBtn")}
</Button>
}
>
<div className="flex flex-col gap-3 py-3">
{form.serverTunnels.length === 0 && (
<p className="text-[10px] text-muted-foreground/50 px-1">
{t("hosts.noTunnelsConfigured")}
</p>
)}
{form.serverTunnels.map((tun, i) => {
const tunnelName = `${host?.id ?? "new"}-${i}-${tun.sourcePort}`;
const tunnelStatus = tunnelStatuses[tunnelName]?.status as
| string
| undefined;
const isConnected = tunnelStatus === "connected";
return (
<div
key={i}
className="flex flex-col gap-3 p-3 border border-border bg-muted/20 relative group"
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-muted-foreground">
{t("hosts.tunnelLabel", { number: i + 1 })}
</span>
<div
className={`size-1.5 rounded-full shrink-0 ${
isConnected
? "bg-accent-brand shadow-[0_0_4px_rgba(251,146,60,0.4)]"
: tunnelStatus === "error"
? "bg-red-400"
: "bg-muted-foreground/25"
}`}
title={tunnelStatus ?? "not connected"}
/>
{host && (
<Button
variant="outline"
size="sm"
disabled={connectingTunnel === i}
className={`h-6 text-[10px] px-2 ${isConnected ? "border-destructive/40 text-destructive hover:bg-destructive/10" : "border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"}`}
onClick={async () => {
setConnectingTunnel(i);
try {
if (isConnected) {
await disconnectTunnel(tunnelName);
toast.success(
t("hosts.tunnelDisconnected"),
);
} else {
await connectTunnel({
name: tunnelName,
mode: tun.mode as any,
sourceHostId: Number(host.id),
tunnelIndex: i,
hostName: host.name,
sourceIP: host.ip,
sourceSSHPort: host.sshPort ?? host.port,
sourceUsername: form.username,
sourcePassword:
form.password || undefined,
sourceAuthMethod: form.authType,
sourceSSHKey: form.key || undefined,
sourceKeyPassword:
form.keyPassword || undefined,
sourceCredentialId: form.credentialId
? Number(form.credentialId)
: undefined,
endpointIP: host.ip,
endpointSSHPort:
host.sshPort ?? host.port,
endpointHost: tun.endpointHost ?? "",
endpointUsername: form.username,
endpointAuthMethod: form.authType,
sourcePort: tun.sourcePort,
endpointPort: tun.endpointPort ?? 0,
bindHost: tun.bindHost ?? "127.0.0.1",
maxRetries: tun.maxRetries ?? 3,
retryInterval: tun.retryInterval ?? 10,
autoStart: tun.autoStart ?? false,
isPinned: false,
});
toast.success(t("hosts.tunnelConnecting"));
}
} catch {
toast.error(
isConnected
? t("hosts.failedToDisconnectTunnel")
: t("hosts.failedToConnectTunnel"),
);
} finally {
setConnectingTunnel(null);
}
}}
>
{connectingTunnel === i
? "..."
: isConnected
? t("hosts.disconnectBtn")
: t("hosts.connectBtn")}
</Button>
)}
</div>
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] px-2 text-destructive"
onClick={() =>
setField(
"serverTunnels",
form.serverTunnels.filter((_, idx) => idx !== i),
)
}
>
{t("common.delete")}
</Button>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.tunnelType")}
</label>
<div className="flex gap-2">
{(["remote", "local", "dynamic"] as const).map(
(m) => (
<button
key={m}
onClick={() => {
const updated = [...form.serverTunnels];
updated[i] = { ...updated[i], mode: m };
setField("serverTunnels", updated);
}}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${tun.mode === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{m}
</button>
),
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{tun.mode !== "dynamic" && (
<div className="flex flex-col gap-1">
<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}
onChange={(e) => {
const updated = [...form.serverTunnels];
updated[i] = {
...updated[i],
endpointHost: e.target.value,
};
setField("serverTunnels", updated);
}}
/>
</div>
)}
{tun.mode !== "dynamic" && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.endpointPort")}
</label>
<Input
className="h-7 text-xs"
type="number"
value={tun.endpointPort}
onChange={(e) => {
const updated = [...form.serverTunnels];
updated[i] = {
...updated[i],
endpointPort: Number(e.target.value),
};
setField("serverTunnels", updated);
}}
/>
</div>
)}
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.bindHost")}
</label>
<Input
className="h-7 text-xs"
placeholder="127.0.0.1"
value={tun.bindHost ?? ""}
onChange={(e) => {
const updated = [...form.serverTunnels];
updated[i] = {
...updated[i],
bindHost: e.target.value,
};
setField("serverTunnels", updated);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.sourcePort")}
</label>
<Input
className="h-7 text-xs"
type="number"
value={tun.sourcePort}
onChange={(e) => {
const updated = [...form.serverTunnels];
updated[i] = {
...updated[i],
sourcePort: Number(e.target.value),
};
setField("serverTunnels", updated);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.maxRetries")}
</label>
<Input
className="h-7 text-xs"
type="number"
value={tun.maxRetries}
onChange={(e) => {
const updated = [...form.serverTunnels];
updated[i] = {
...updated[i],
maxRetries: Number(e.target.value),
};
setField("serverTunnels", updated);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.retryIntervalS")}
</label>
<Input
className="h-7 text-xs"
type="number"
value={tun.retryInterval}
onChange={(e) => {
const updated = [...form.serverTunnels];
updated[i] = {
...updated[i],
retryInterval: Number(e.target.value),
};
setField("serverTunnels", updated);
}}
/>
</div>
</div>
<SettingRow
label={t("hosts.autoStartLabel")}
description={t("hosts.autoStartDesc")}
>
<FakeSwitch
checked={tun.autoStart}
onChange={(v) => {
const updated = [...form.serverTunnels];
updated[i] = { ...updated[i], autoStart: v };
setField("serverTunnels", updated);
}}
/>
</SettingRow>
</div>
);
})}
</div>
</SectionCard>
</>
)}
{activeTab === "docker" && (
<SectionCard
title={t("hosts.dockerIntegration")}
icon={<Box className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.enableDockerMonitor")}
description={t("hosts.enableDockerMonitorDesc")}
>
<FakeSwitch
checked={form.enableDocker}
onChange={(v) => setField("enableDocker", v)}
/>
</SettingRow>
</div>
</SectionCard>
)}
{activeTab === "files" && (
<SectionCard
title={t("hosts.fileManager")}
icon={<FolderSearch className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.enableFileManagerMonitor")}
description={t("hosts.enableFileManagerMonitorDesc")}
>
<FakeSwitch
checked={form.enableFileManager}
onChange={(v) => setField("enableFileManager", v)}
/>
</SettingRow>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.defaultPathLabel")}
</label>
<Input
placeholder="/"
value={form.defaultPath}
onChange={(e) => setField("defaultPath", e.target.value)}
/>
<span className="text-[10px] text-muted-foreground">
{t("hosts.fileManagerPathHint")}
</span>
</div>
</div>
</SectionCard>
)}
{activeTab === "stats" && (
<>
<SectionCard
title={t("hosts.statusChecksLabel")}
icon={<Activity className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableStatusChecks")}
description={t("hosts.enableStatusChecksDesc")}
>
<FakeSwitch
checked={form.statsConfig.statusCheckEnabled}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
statusCheckEnabled: v,
})
}
/>
</SettingRow>
<SettingRow
label={t("hosts.useGlobalInterval")}
description={t("hosts.useGlobalIntervalDesc")}
>
<FakeSwitch
checked={form.statsConfig.useGlobalStatusInterval}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
useGlobalStatusInterval: v,
})
}
/>
</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>
)}
</div>
</SectionCard>
<SectionCard
title={t("hosts.metricsCollectionLabel")}
icon={<Server className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableMetricsLabel")}
description={t("hosts.enableMetricsDesc")}
>
<FakeSwitch
checked={form.statsConfig.metricsEnabled}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
metricsEnabled: v,
})
}
/>
</SettingRow>
<SettingRow
label={t("hosts.useGlobalMetrics")}
description={t("hosts.useGlobalMetricsDesc")}
>
<FakeSwitch
checked={form.statsConfig.useGlobalMetricsInterval}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
useGlobalMetricsInterval: v,
})
}
/>
</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>
)}
</div>
</SectionCard>
<SectionCard
title={t("hosts.visibleWidgets")}
icon={<LayoutDashboard className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
{[
{
id: "cpu",
label: t("hosts.cpuUsageLabel"),
desc: t("hosts.cpuUsageDesc"),
},
{
id: "memory",
label: t("hosts.memoryLabel"),
desc: t("hosts.memoryDesc"),
},
{
id: "disk",
label: t("hosts.storageLabel"),
desc: t("hosts.storageDesc"),
},
{
id: "network",
label: t("hosts.networkLabel"),
desc: t("hosts.networkDesc"),
},
{
id: "uptime",
label: t("hosts.uptimeLabel"),
desc: t("hosts.uptimeDesc"),
},
{
id: "system",
label: t("hosts.systemInfoLabel"),
desc: t("hosts.systemInfoDesc"),
},
{
id: "login_stats",
label: t("hosts.recentLoginsLabel"),
desc: t("hosts.recentLoginsDesc"),
},
{
id: "processes",
label: t("hosts.topProcessesLabel"),
desc: t("hosts.topProcessesDesc"),
},
{
id: "ports",
label: t("hosts.listeningPortsLabel"),
desc: t("hosts.listeningPortsDesc"),
},
{
id: "firewall",
label: t("hosts.firewallLabel"),
desc: t("hosts.firewallDesc"),
},
].map((w) => (
<SettingRow key={w.id} label={w.label} description={w.desc}>
<FakeSwitch
checked={form.statsConfig.enabledWidgets.includes(w.id)}
onChange={(v) => {
const widgets = v
? [...form.statsConfig.enabledWidgets, w.id]
: form.statsConfig.enabledWidgets.filter(
(x) => x !== w.id,
);
setField("statsConfig", {
...form.statsConfig,
enabledWidgets: widgets,
});
}}
/>
</SettingRow>
))}
</div>
</SectionCard>
<SectionCard
title={t("hosts.quickActionsToolbar").split(".")[0]}
icon={<Zap className="size-3.5" />}
action={
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() =>
setField("quickActions", [
...form.quickActions,
{ name: "", snippetId: "" },
])
}
>
<Plus className="size-3 mr-1" /> {t("hosts.addActionBtn")}
</Button>
}
>
<div className="flex flex-col gap-3 py-3">
<p className="text-xs text-muted-foreground">
{t("hosts.quickActionsToolbar")}
</p>
{form.quickActions.length === 0 && (
<div className="flex flex-col items-center justify-center py-4 text-muted-foreground/40 gap-1.5">
<Zap className="size-6" />
<span className="text-xs">{t("hosts.noQuickActions")}</span>
</div>
)}
{form.quickActions.map((a, i) => (
<div
key={i}
className="flex items-center gap-2 p-2 bg-muted/20 border border-border group"
>
<Input
className="h-7 text-xs flex-1"
placeholder={t("hosts.buttonLabel")}
value={a.name}
onChange={(e) => {
const updated = [...form.quickActions];
updated[i] = { ...updated[i], name: e.target.value };
setField("quickActions", updated);
}}
/>
<select
className="h-7 text-xs flex-1 border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
value={a.snippetId}
onChange={(e) => {
const updated = [...form.quickActions];
updated[i] = {
...updated[i],
snippetId: e.target.value,
};
setField("quickActions", updated);
}}
>
<option value="">
{t("hosts.selectSnippetPlaceholder")}
</option>
{snippets.map((s) => (
<option key={s.id} value={String(s.id)}>
{s.name}
</option>
))}
</select>
<button
className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() =>
setField(
"quickActions",
form.quickActions.filter((_, idx) => idx !== i),
)
}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
</SectionCard>
</>
)}
{activeTab === "rdp" && (
<>
<SectionCard
title={t("hosts.guac.connection")}
icon={<Globe className="size-3.5" />}
>
<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.guac.rdpPort")}
</label>
<Input
type="number"
placeholder="3389"
value={form.rdpPort}
onChange={(e) =>
setField("rdpPort", Number(e.target.value) as any)
}
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.authentication")}
icon={<Shield className="size-3.5" />}
>
<div className="grid grid-cols-1 md:grid-cols-2 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.guac.username")}
</label>
<Input
placeholder="Administrator"
value={form.rdpUser}
onChange={(e) => setField("rdpUser", 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.guac.password")}
</label>
<Input
type="password"
placeholder="••••••••"
value={form.rdpPassword}
onChange={(e) => setField("rdpPassword", 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.guac.domain")}
</label>
<Input
placeholder="WORKGROUP"
value={form.domain}
onChange={(e) => setField("domain", e.target.value)}
/>
</div>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.connectionSettings")}
icon={<Shield className="size-3.5" />}
>
<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.guac.securityMode")}
</label>
<select
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"
value={form.guacamoleConfig["security"] ?? "any"}
onChange={(e) => setGuacField("security", e.target.value)}
>
<option value="any">Any</option>
<option value="nla">NLA</option>
<option value="nla-ext">NLA Extended</option>
<option value="tls">TLS</option>
<option value="vmconnect">VMConnect</option>
<option value="rdp">RDP</option>
</select>
</div>
<SettingRow
label={t("hosts.guac.ignoreCertificate")}
description={t("hosts.guac.ignoreCertificateDesc")}
>
<FakeSwitch
checked={form.ignoreCert}
onChange={(v) => setField("ignoreCert", v)}
/>
</SettingRow>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.displaySettings")}
icon={<Monitor className="size-3.5" />}
>
<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.guac.colorDepth")}
</label>
<select
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"
value={form.guacamoleConfig["color-depth"] ?? "auto"}
onChange={(e) =>
setGuacField("color-depth", e.target.value)
}
>
<option value="auto">Auto</option>
<option value="8">8-bit</option>
<option value="16">16-bit</option>
<option value="24">24-bit</option>
<option value="32">32-bit</option>
</select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.width")}
</label>
<Input
type="number"
placeholder="Auto"
value={form.guacamoleConfig["width"] ?? ""}
onChange={(e) => setGuacField("width", 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.guac.height")}
</label>
<Input
type="number"
placeholder="Auto"
value={form.guacamoleConfig["height"] ?? ""}
onChange={(e) => setGuacField("height", e.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.dpi")}
</label>
<Input
type="number"
placeholder="96"
value={form.guacamoleConfig["dpi"] ?? ""}
onChange={(e) => setGuacField("dpi", 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.guac.resizeMethod")}
</label>
<select
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"
value={form.guacamoleConfig["resize-method"] ?? "auto"}
onChange={(e) =>
setGuacField("resize-method", e.target.value)
}
>
<option value="auto">Auto</option>
<option value="display-update">Display Update</option>
<option value="reconnect">Reconnect</option>
</select>
</div>
<SettingRow
label={t("hosts.guac.forceLossless")}
description={t("hosts.guac.forceLosslessDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["force-lossless"]}
onChange={(v) => setGuacField("force-lossless", v)}
/>
</SettingRow>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.audioSettings")}
icon={<Activity className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.guac.disableAudio")}
description={t("hosts.guac.disableAudioDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["disable-audio"]}
onChange={(v) => setGuacField("disable-audio", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.enableAudioInput")}
description={t("hosts.guac.enableAudioInputDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["enable-audio-input"]}
onChange={(v) => setGuacField("enable-audio-input", v)}
/>
</SettingRow>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.rdpPerformance")}
icon={<Zap className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.guac.wallpaper")}
description={t("hosts.guac.wallpaperDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["enable-wallpaper"]}
onChange={(v) => setGuacField("enable-wallpaper", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.theming")}
description={t("hosts.guac.themingDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["enable-theming"]}
onChange={(v) => setGuacField("enable-theming", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.fontSmoothing")}
description={t("hosts.guac.fontSmoothingDesc")}
>
<FakeSwitch
checked={
form.guacamoleConfig["enable-font-smoothing"] !== false
}
onChange={(v) => setGuacField("enable-font-smoothing", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.fullWindowDrag")}
description={t("hosts.guac.fullWindowDragDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["enable-full-window-drag"]}
onChange={(v) => setGuacField("enable-full-window-drag", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.desktopComposition")}
description={t("hosts.guac.desktopCompositionDesc")}
>
<FakeSwitch
checked={
!!form.guacamoleConfig["enable-desktop-composition"]
}
onChange={(v) =>
setGuacField("enable-desktop-composition", v)
}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.menuAnimations")}
description={t("hosts.guac.menuAnimationsDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["enable-menu-animations"]}
onChange={(v) => setGuacField("enable-menu-animations", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.disableBitmapCaching")}
description={t("hosts.guac.disableBitmapCachingDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["disable-bitmap-caching"]}
onChange={(v) => setGuacField("disable-bitmap-caching", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.disableOffscreenCaching")}
description={t("hosts.guac.disableOffscreenCachingDesc")}
>
<FakeSwitch
checked={
!!form.guacamoleConfig["disable-offscreen-caching"]
}
onChange={(v) =>
setGuacField("disable-offscreen-caching", v)
}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.disableGlyphCaching")}
description={t("hosts.guac.disableGlyphCachingDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["disable-glyph-caching"]}
onChange={(v) => setGuacField("disable-glyph-caching", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.enableGfx")}
description={t("hosts.guac.enableGfxDesc")}
>
<FakeSwitch
checked={form.guacamoleConfig["enable-gfx"] !== false}
onChange={(v) => setGuacField("enable-gfx", v)}
/>
</SettingRow>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.deviceRedirection")}
icon={<Settings className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.guac.enablePrinting")}
description={t("hosts.guac.enablePrintingDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["enable-printing"]}
onChange={(v) => setGuacField("enable-printing", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.enableDriveRedirection")}
description={t("hosts.guac.enableDriveRedirectionDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["enable-drive"]}
onChange={(v) => setGuacField("enable-drive", v)}
/>
</SettingRow>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 border-t border-border pt-3">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.driveName")}
</label>
<Input
placeholder="Termix Drive"
value={form.guacamoleConfig["drive-name"] ?? ""}
onChange={(e) =>
setGuacField("drive-name", 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.guac.drivePath")}
</label>
<Input
placeholder="/home/user/shared"
value={form.guacamoleConfig["drive-path"] ?? ""}
onChange={(e) =>
setGuacField("drive-path", e.target.value)
}
/>
</div>
</div>
<SettingRow
label={t("hosts.guac.createDrivePath")}
description={t("hosts.guac.createDrivePathDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["create-drive-path"]}
onChange={(v) => setGuacField("create-drive-path", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.disableDownload")}
description={t("hosts.guac.disableDownloadDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["disable-download"]}
onChange={(v) => setGuacField("disable-download", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.disableUpload")}
description={t("hosts.guac.disableUploadDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["disable-upload"]}
onChange={(v) => setGuacField("disable-upload", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.enableTouch")}
description={t("hosts.guac.enableTouchDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["enable-touch"]}
onChange={(v) => setGuacField("enable-touch", v)}
/>
</SettingRow>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.session")}
icon={<Server className="size-3.5" />}
>
<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.guac.clientName")}
</label>
<Input
placeholder="Termix"
value={form.guacamoleConfig["client-name"] ?? ""}
onChange={(e) =>
setGuacField("client-name", e.target.value)
}
/>
</div>
<SettingRow
label={t("hosts.guac.consoleSession")}
description={t("hosts.guac.consoleSessionDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["console"]}
onChange={(v) => setGuacField("console", v)}
/>
</SettingRow>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.initialProgram")}
</label>
<Input
placeholder="e.g. cmd.exe"
value={form.guacamoleConfig["initial-program"] ?? ""}
onChange={(e) =>
setGuacField("initial-program", 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.guac.serverLayout")}
</label>
<select
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"
value={form.guacamoleConfig["server-layout"] ?? "auto"}
onChange={(e) =>
setGuacField("server-layout", e.target.value)
}
>
<option value="auto">Auto</option>
<option>en-us-qwerty</option>
<option>en-gb-qwerty</option>
<option>de-de-qwertz</option>
<option>fr-fr-azerty</option>
<option>it-it-qwerty</option>
<option>sv-se-qwerty</option>
<option>ja-jp-qwerty</option>
<option>pt-br-qwerty</option>
<option>es-es-qwerty</option>
<option>failsafe</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.timezone")}
</label>
<Input
placeholder="e.g. America/New_York"
value={form.guacamoleConfig["timezone"] ?? ""}
onChange={(e) => setGuacField("timezone", e.target.value)}
/>
</div>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.gateway")}
icon={<Network className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.gatewayHostname")}
</label>
<Input
placeholder="gateway.example.com"
value={form.guacamoleConfig["gateway-hostname"] ?? ""}
onChange={(e) =>
setGuacField("gateway-hostname", 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.guac.gatewayPort")}
</label>
<Input
type="number"
placeholder="443"
value={form.guacamoleConfig["gateway-port"] ?? ""}
onChange={(e) =>
setGuacField("gateway-port", 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.guac.gatewayUsername")}
</label>
<Input
placeholder="user"
value={form.guacamoleConfig["gateway-username"] ?? ""}
onChange={(e) =>
setGuacField("gateway-username", 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.guac.gatewayPassword")}
</label>
<Input
type="password"
placeholder="••••••••"
value={form.guacamoleConfig["gateway-password"] ?? ""}
onChange={(e) =>
setGuacField("gateway-password", e.target.value)
}
/>
</div>
<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.guac.gatewayDomain")}
</label>
<Input
placeholder="DOMAIN"
value={form.guacamoleConfig["gateway-domain"] ?? ""}
onChange={(e) =>
setGuacField("gateway-domain", e.target.value)
}
/>
</div>
</div>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.remoteApp")}
icon={<Monitor className="size-3.5" />}
>
<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.guac.remoteAppProgram")}
</label>
<Input
placeholder="||MyApp"
value={form.guacamoleConfig["remote-app"] ?? ""}
onChange={(e) => setGuacField("remote-app", 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.guac.workingDirectory")}
</label>
<Input
placeholder="C:\Apps\MyApp"
value={form.guacamoleConfig["remote-app-dir"] ?? ""}
onChange={(e) =>
setGuacField("remote-app-dir", 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.guac.arguments")}
</label>
<Input
placeholder="--flag value"
value={form.guacamoleConfig["remote-app-args"] ?? ""}
onChange={(e) =>
setGuacField("remote-app-args", e.target.value)
}
/>
</div>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.clipboard")}
icon={<Copy className="size-3.5" />}
>
<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.guac.normalizeLineEndings")}
</label>
<select
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"
value={
form.guacamoleConfig["normalize-clipboard"] ?? "auto"
}
onChange={(e) =>
setGuacField("normalize-clipboard", e.target.value)
}
>
<option value="auto">Auto</option>
<option value="preserve">Preserve</option>
<option value="unix">Unix (LF)</option>
<option value="windows">Windows (CRLF)</option>
</select>
</div>
<SettingRow
label={t("hosts.guac.disableCopy")}
description={t("hosts.guac.disableCopyDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["disable-copy"]}
onChange={(v) => setGuacField("disable-copy", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.disablePaste")}
description={t("hosts.guac.disablePasteDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["disable-paste"]}
onChange={(v) => setGuacField("disable-paste", v)}
/>
</SettingRow>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.sessionRecording")}
icon={<Activity className="size-3.5" />}
>
<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.guac.recordingPath")}
</label>
<Input
placeholder="/var/lib/termix/recordings"
value={form.guacamoleConfig["recording-path"] ?? ""}
onChange={(e) =>
setGuacField("recording-path", 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.guac.recordingName")}
</label>
<Input
placeholder="${GUAC_USERNAME}-${GUAC_DATE}-${GUAC_TIME}"
value={form.guacamoleConfig["recording-name"] ?? ""}
onChange={(e) =>
setGuacField("recording-name", e.target.value)
}
/>
</div>
<SettingRow
label={t("hosts.guac.createPathIfMissing")}
description={t("hosts.guac.createPathIfMissingDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["create-recording-path"]}
onChange={(v) => setGuacField("create-recording-path", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.excludeOutput")}
description={t("hosts.guac.excludeOutputDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["recording-exclude-output"]}
onChange={(v) =>
setGuacField("recording-exclude-output", v)
}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.excludeMouse")}
description={t("hosts.guac.excludeMouseDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["recording-exclude-mouse"]}
onChange={(v) => setGuacField("recording-exclude-mouse", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.includeKeystrokes")}
description={t("hosts.guac.includeKeystrokesDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["recording-include-keys"]}
onChange={(v) => setGuacField("recording-include-keys", v)}
/>
</SettingRow>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.wakeOnLan")}
icon={<Zap className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.guac.sendWolPacket")}
description={t("hosts.guac.sendWolPacketDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["wol-send-packet"]}
onChange={(v) => setGuacField("wol-send-packet", v)}
/>
</SettingRow>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.macAddress")}
</label>
<Input
placeholder="AA:BB:CC:DD:EE:FF"
value={
form.guacamoleConfig["wol-mac-addr"] ??
host?.macAddress ??
""
}
onChange={(e) =>
setGuacField("wol-mac-addr", 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.guac.broadcastAddress")}
</label>
<Input
placeholder="255.255.255.255"
value={form.guacamoleConfig["wol-broadcast-addr"] ?? ""}
onChange={(e) =>
setGuacField("wol-broadcast-addr", 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.guac.udpPort")}
</label>
<Input
type="number"
placeholder="9"
value={form.guacamoleConfig["wol-udp-port"] ?? ""}
onChange={(e) =>
setGuacField("wol-udp-port", 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.guac.waitTimeS")}
</label>
<Input
type="number"
placeholder="0"
value={form.guacamoleConfig["wol-wait-time"] ?? ""}
onChange={(e) =>
setGuacField("wol-wait-time", e.target.value)
}
/>
</div>
</div>
</div>
</SectionCard>
</>
)}
{activeTab === "vnc" && (
<>
<SectionCard
title={t("hosts.guac.connection")}
icon={<Globe className="size-3.5" />}
>
<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.guac.vncPort")}
</label>
<Input
type="number"
placeholder="5900"
value={form.vncPort}
onChange={(e) =>
setField("vncPort", Number(e.target.value) as any)
}
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.authentication")}
icon={<Shield className="size-3.5" />}
>
<div className="grid grid-cols-1 md:grid-cols-2 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.guac.vncPassword")}
</label>
<Input
type="password"
placeholder="••••••••"
value={form.vncPassword}
onChange={(e) => setField("vncPassword", 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.guac.vncUsernameOptional")}
</label>
<Input
placeholder={t("hosts.guac.vncLeaveBlank")}
value={form.vncUser}
onChange={(e) => setField("vncUser", e.target.value)}
/>
</div>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.displaySettings")}
icon={<Monitor className="size-3.5" />}
>
<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.guac.colorDepth")}
</label>
<select
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"
value={form.guacamoleConfig["color-depth"] ?? "auto"}
onChange={(e) =>
setGuacField("color-depth", e.target.value)
}
>
<option value="auto">Auto</option>
<option value="8">8-bit</option>
<option value="16">16-bit</option>
<option value="24">24-bit</option>
<option value="32">32-bit</option>
</select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.width")}
</label>
<Input
type="number"
placeholder="Auto"
value={form.guacamoleConfig["width"] ?? ""}
onChange={(e) => setGuacField("width", 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.guac.height")}
</label>
<Input
type="number"
placeholder="Auto"
value={form.guacamoleConfig["height"] ?? ""}
onChange={(e) => setGuacField("height", e.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.resizeMethod")}
</label>
<select
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"
value={form.guacamoleConfig["resize-method"] ?? "auto"}
onChange={(e) =>
setGuacField("resize-method", e.target.value)
}
>
<option value="auto">Auto</option>
<option value="display-update">Display Update</option>
<option value="reconnect">Reconnect</option>
</select>
</div>
<SettingRow
label={t("hosts.guac.forceLossless")}
description={t("hosts.guac.forceLosslessDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["force-lossless"]}
onChange={(v) => setGuacField("force-lossless", v)}
/>
</SettingRow>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.audioSettings")}
icon={<Activity className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.guac.disableAudio")}
description={t("hosts.guac.disableAudioDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["disable-audio"]}
onChange={(v) => setGuacField("disable-audio", v)}
/>
</SettingRow>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.vncSettings")}
icon={<Settings className="size-3.5" />}
>
<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.guac.cursorMode")}
</label>
<select
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"
value={form.guacamoleConfig["cursor"] ?? "auto"}
onChange={(e) => setGuacField("cursor", e.target.value)}
>
<option value="auto">Auto</option>
<option value="local">Local</option>
<option value="remote">Remote</option>
</select>
</div>
<SettingRow
label={t("hosts.guac.swapRedBlue")}
description={t("hosts.guac.swapRedBlueDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["swap-red-blue"]}
onChange={(v) => setGuacField("swap-red-blue", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.readOnly")}
description={t("hosts.guac.readOnlyDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["read-only"]}
onChange={(v) => setGuacField("read-only", v)}
/>
</SettingRow>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.clipboard")}
icon={<Copy className="size-3.5" />}
>
<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.guac.normalizeLineEndings")}
</label>
<select
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"
value={
form.guacamoleConfig["normalize-clipboard"] ?? "auto"
}
onChange={(e) =>
setGuacField("normalize-clipboard", e.target.value)
}
>
<option value="auto">Auto</option>
<option value="preserve">Preserve</option>
<option value="unix">Unix (LF)</option>
<option value="windows">Windows (CRLF)</option>
</select>
</div>
<SettingRow
label={t("hosts.guac.disableCopy")}
description={t("hosts.guac.disableCopyDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["disable-copy"]}
onChange={(v) => setGuacField("disable-copy", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.disablePaste")}
description={t("hosts.guac.disablePasteDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["disable-paste"]}
onChange={(v) => setGuacField("disable-paste", v)}
/>
</SettingRow>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.sessionRecording")}
icon={<Activity className="size-3.5" />}
>
<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.guac.recordingPath")}
</label>
<Input
placeholder="/var/lib/termix/recordings"
value={form.guacamoleConfig["recording-path"] ?? ""}
onChange={(e) =>
setGuacField("recording-path", 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.guac.recordingName")}
</label>
<Input
placeholder="${GUAC_USERNAME}-${GUAC_DATE}-${GUAC_TIME}"
value={form.guacamoleConfig["recording-name"] ?? ""}
onChange={(e) =>
setGuacField("recording-name", e.target.value)
}
/>
</div>
<SettingRow
label={t("hosts.guac.createPathIfMissing")}
description={t("hosts.guac.createPathIfMissingDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["create-recording-path"]}
onChange={(v) => setGuacField("create-recording-path", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.excludeOutput")}
description={t("hosts.guac.excludeOutputDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["recording-exclude-output"]}
onChange={(v) =>
setGuacField("recording-exclude-output", v)
}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.excludeMouse")}
description={t("hosts.guac.excludeMouseDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["recording-exclude-mouse"]}
onChange={(v) => setGuacField("recording-exclude-mouse", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.includeKeystrokes")}
description={t("hosts.guac.includeKeystrokesDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["recording-include-keys"]}
onChange={(v) => setGuacField("recording-include-keys", v)}
/>
</SettingRow>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.wakeOnLan")}
icon={<Zap className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.guac.sendWolPacket")}
description={t("hosts.guac.sendWolPacketDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["wol-send-packet"]}
onChange={(v) => setGuacField("wol-send-packet", v)}
/>
</SettingRow>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.macAddress")}
</label>
<Input
placeholder="AA:BB:CC:DD:EE:FF"
value={
form.guacamoleConfig["wol-mac-addr"] ??
host?.macAddress ??
""
}
onChange={(e) =>
setGuacField("wol-mac-addr", 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.guac.broadcastAddress")}
</label>
<Input
placeholder="255.255.255.255"
value={form.guacamoleConfig["wol-broadcast-addr"] ?? ""}
onChange={(e) =>
setGuacField("wol-broadcast-addr", 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.guac.udpPort")}
</label>
<Input
type="number"
placeholder="9"
value={form.guacamoleConfig["wol-udp-port"] ?? ""}
onChange={(e) =>
setGuacField("wol-udp-port", 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.guac.waitTimeS")}
</label>
<Input
type="number"
placeholder="0"
value={form.guacamoleConfig["wol-wait-time"] ?? ""}
onChange={(e) =>
setGuacField("wol-wait-time", e.target.value)
}
/>
</div>
</div>
</div>
</SectionCard>
</>
)}
{activeTab === "telnet" && (
<>
<SectionCard
title={t("hosts.guac.connection")}
icon={<Globe className="size-3.5" />}
>
<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.guac.telnetPort")}
</label>
<Input
type="number"
placeholder="23"
value={form.telnetPort}
onChange={(e) =>
setField("telnetPort", Number(e.target.value) as any)
}
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.authentication")}
icon={<Shield className="size-3.5" />}
>
<div className="grid grid-cols-1 md:grid-cols-2 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.guac.username")}
</label>
<Input
placeholder="admin"
value={form.telnetUser}
onChange={(e) => setField("telnetUser", 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.guac.password")}
</label>
<Input
type="password"
placeholder="••••••••"
value={form.telnetPassword}
onChange={(e) => setField("telnetPassword", e.target.value)}
/>
</div>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.displaySettings")}
icon={<Monitor className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.width")}
</label>
<Input
type="number"
placeholder="Auto"
value={form.guacamoleConfig["width"] ?? ""}
onChange={(e) => setGuacField("width", 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.guac.height")}
</label>
<Input
type="number"
placeholder="Auto"
value={form.guacamoleConfig["height"] ?? ""}
onChange={(e) => setGuacField("height", e.target.value)}
/>
</div>
</div>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.terminalSettings")}
icon={<Terminal className="size-3.5" />}
>
<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.guac.terminalType")}
</label>
<select
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"
value={form.guacamoleConfig["terminal-type"] ?? "auto"}
onChange={(e) =>
setGuacField("terminal-type", e.target.value)
}
>
<option value="auto">Auto</option>
<option value="xterm">xterm</option>
<option value="xterm-256color">xterm-256color</option>
<option value="vt100">VT100</option>
<option value="vt220">VT220</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.fontName")}
</label>
<Input
placeholder="monospace"
value={form.guacamoleConfig["font-name"] ?? ""}
onChange={(e) => setGuacField("font-name", 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.guac.fontSize")}
</label>
<Input
type="number"
value={form.guacamoleConfig["font-size"] ?? 12}
onChange={(e) =>
setGuacField("font-size", Number(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.guac.colorScheme")}
</label>
<select
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"
value={form.guacamoleConfig["color-scheme"] ?? "auto"}
onChange={(e) =>
setGuacField("color-scheme", e.target.value)
}
>
<option value="auto">Auto</option>
<option value="black-white">Black on White</option>
<option value="white-black">White on Black</option>
<option value="gray-black">Gray on Black</option>
<option value="green-black">Green on Black</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.backspaceKey")}
</label>
<select
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"
value={form.guacamoleConfig["backspace"] ?? "auto"}
onChange={(e) => setGuacField("backspace", e.target.value)}
>
<option value="auto">Auto</option>
<option value="127">DEL (127)</option>
<option value="8">BS (8)</option>
</select>
</div>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.sessionRecording")}
icon={<Activity className="size-3.5" />}
>
<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.guac.recordingPath")}
</label>
<Input
placeholder="/var/lib/termix/recordings"
value={form.guacamoleConfig["recording-path"] ?? ""}
onChange={(e) =>
setGuacField("recording-path", 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.guac.recordingName")}
</label>
<Input
placeholder="${GUAC_USERNAME}-${GUAC_DATE}-${GUAC_TIME}"
value={form.guacamoleConfig["recording-name"] ?? ""}
onChange={(e) =>
setGuacField("recording-name", e.target.value)
}
/>
</div>
<SettingRow
label={t("hosts.guac.createPathIfMissing")}
description={t("hosts.guac.createPathIfMissingDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["create-recording-path"]}
onChange={(v) => setGuacField("create-recording-path", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.excludeOutput")}
description={t("hosts.guac.excludeOutputDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["recording-exclude-output"]}
onChange={(v) =>
setGuacField("recording-exclude-output", v)
}
/>
</SettingRow>
<SettingRow
label={t("hosts.guac.includeKeystrokes")}
description={t("hosts.guac.includeKeystrokesDesc")}
>
<FakeSwitch
checked={!!form.guacamoleConfig["recording-include-keys"]}
onChange={(v) => setGuacField("recording-include-keys", v)}
/>
</SettingRow>
</div>
</SectionCard>
</>
)}
{activeTab === "sharing" && (
<>
{host === null && (
<div className="flex items-start gap-3 p-3 border border-yellow-500/30 bg-yellow-500/5 text-xs text-yellow-500">
<Shield className="size-3.5 shrink-0 mt-0.5" />
<div>
<strong>{t("hosts.guac.saveHostFirst")}</strong>{" "}
{t("hosts.guac.sharingOptionsAfterSave")}
</div>
</div>
)}
{sharingLoadError && (
<div className="flex items-start gap-3 p-3 border border-destructive/30 bg-destructive/5 text-xs text-destructive">
<Shield className="size-3.5 shrink-0 mt-0.5" />
<div>{t("hosts.guac.sharingLoadError")}</div>
</div>
)}
{host !== null && (
<SectionCard
title={t("hosts.guac.shareHostSection")}
icon={<Users className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<div className="flex gap-2">
{(["user", "role"] as const).map((shareTypeOpt) => (
<button
key={shareTypeOpt}
onClick={() => {
setShareType(shareTypeOpt);
setShareGranteeId("");
}}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${shareType === shareTypeOpt ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{shareTypeOpt === "user" ? (
<>
<User className="size-3 inline mr-1" />
{t("hosts.guac.shareWithUser")}
</>
) : (
<>
<Shield className="size-3 inline mr-1" />
{t("hosts.guac.shareWithRole")}
</>
)}
</button>
))}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{shareType === "user"
? t("hosts.guac.selectUser")
: t("hosts.guac.selectRole")}
</label>
<select
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"
value={shareGranteeId}
onChange={(e) => setShareGranteeId(e.target.value)}
>
<option value="">
{shareType === "user"
? t("hosts.guac.selectUserOption")
: t("hosts.guac.selectRoleOption")}
</option>
{shareType === "user"
? shareUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.username}
</option>
))
: shareRoles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.permissionLevel")}
</label>
<select
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"
value={sharePermission}
onChange={(e) => setSharePermission(e.target.value)}
>
<option value="view">View</option>
<option value="connect">Connect</option>
<option value="manage">Manage</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.expiresInHours")}
</label>
<Input
type="number"
placeholder={t("hosts.guac.noExpiryPlaceholder")}
value={shareExpiryHours}
onChange={(e) => setShareExpiryHours(e.target.value)}
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
<div className="flex justify-end">
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={!shareGranteeId}
onClick={async () => {
try {
await shareHost(
Number(host.id),
shareType,
shareGranteeId,
sharePermission,
shareExpiryHours
? Number(shareExpiryHours)
: undefined,
);
const res = await getHostAccess(Number(host.id));
setAccessList((res as any)?.access ?? []);
setShareGranteeId("");
setShareExpiryHours("");
toast.success(t("hosts.hostSharedSuccessfully"));
} catch {
toast.error(t("hosts.failedToShareHost"));
}
}}
>
<Plus className="size-3.5 mr-1.5" />
{t("hosts.guac.shareBtn")}
</Button>
</div>
</div>
</SectionCard>
)}
{host !== null && (
<SectionCard
title={t("hosts.guac.currentAccess")}
icon={<ListChecks className="size-3.5" />}
>
<div className="py-2">
<div className="grid grid-cols-6 gap-2 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground border-b border-border">
<span>{t("hosts.guac.typeHeader")}</span>
<span>{t("hosts.guac.targetHeader")}</span>
<span>{t("hosts.guac.permissionHeader")}</span>
<span>{t("hosts.guac.grantedByHeader")}</span>
<span>{t("hosts.guac.expiresHeader")}</span>
<span></span>
</div>
{accessList.length === 0 && (
<div className="px-2 py-4 text-xs text-muted-foreground/50 text-center">
{t("hosts.guac.noAccessEntries")}
</div>
)}
{accessList.map((r: any, i: number) => {
const expired =
r.expiresAt && new Date(r.expiresAt) < new Date();
return (
<div
key={i}
className="grid grid-cols-6 gap-2 px-2 py-2.5 border-b border-border last:border-0 items-center text-xs"
>
<div className="flex items-center gap-1">
{r.granteeType === "user" ? (
<User className="size-3 text-muted-foreground" />
) : (
<Shield className="size-3 text-muted-foreground" />
)}
<span className="text-muted-foreground capitalize">
{r.granteeType}
</span>
</div>
<span className="font-semibold truncate">
{r.granteeName ?? r.granteeId}
</span>
<span className="capitalize">{r.permission}</span>
<span className="text-muted-foreground truncate">
{r.grantedBy ?? "—"}
</span>
<span
className={
expired
? "text-destructive"
: "text-muted-foreground"
}
>
{expired ? (
<span className="flex items-center gap-1">
<X className="size-3" />
{t("hosts.guac.expiredLabel")}
</span>
) : r.expiresAt ? (
new Date(r.expiresAt).toLocaleDateString()
) : (
t("hosts.guac.neverLabel")
)}
</span>
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10"
onClick={async () => {
try {
await revokeHostAccess(Number(host!.id), r.id);
setAccessList((prev) =>
prev.filter((_, idx) => idx !== i),
);
toast.success(t("hosts.accessRevoked"));
} catch {
toast.error(t("hosts.failedToRevokeAccess"));
}
}}
>
{t("hosts.guac.revokeBtn")}
</Button>
</div>
</div>
);
})}
</div>
</SectionCard>
)}
</>
)}
</div>
<div className="flex justify-end gap-3 mt-3 mb-6">
<Button variant="ghost" onClick={onBack} disabled={saving}>
{t("hosts.guac.cancelBtn")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand px-8"
onClick={handleSave}
disabled={saving}
>
{saving
? t("hosts.guac.savingBtn")
: host
? t("hosts.guac.updateHostBtn")
: t("hosts.guac.addHostBtn")}
</Button>
</div>
</div>
);
}
function CredentialEditorView({
credential,
activeTab,
onBack,
onSave,
}: {
credential: Credential | null;
activeTab: string;
onBack: () => void;
onSave: (saved: any) => void;
}) {
const [credForm, setCredForm] = useState(() => ({
name: credential?.name ?? "",
username: credential?.username ?? "",
folder: credential?.folder ?? "",
description: credential?.description ?? "",
tags: credential?.tags ?? ([] as string[]),
tagInput: "",
type: credential?.type ?? "password",
value: credential?.value ?? "",
publicKey: credential?.publicKey ?? "",
passphrase: credential?.passphrase ?? "",
}));
const { t } = useTranslation();
const [generatingKey, setGeneratingKey] = useState(false);
const [generatingPublicKey, setGeneratingPublicKey] = useState(false);
const credFileInputRef = useRef<HTMLInputElement>(null);
const setCredField = <K extends keyof typeof credForm>(
k: K,
v: (typeof credForm)[K],
) => setCredForm((p) => ({ ...p, [k]: v }));
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
const data = {
name: credForm.name,
username: credForm.username,
folder: credForm.folder || null,
description: credForm.description || null,
tags: credForm.tags,
authType: credForm.type,
password: credForm.type === "password" ? credForm.value : null,
key: credForm.type === "key" ? credForm.value : null,
publicKey: credForm.type === "key" ? credForm.publicKey : null,
keyPassword: credForm.type === "key" ? credForm.passphrase : null,
};
const saved = credential
? await updateCredential(Number(credential.id), data)
: await createCredential(data);
toast.success(
credential
? t("hosts.credentialUpdated")
: t("hosts.credentialCreated"),
);
onSave(saved);
} catch {
toast.error(t("hosts.failedToSaveCredential"));
} finally {
setSaving(false);
}
};
const type = credForm.type;
return (
<div className="flex flex-col gap-3">
{activeTab === "general" && (
<SectionCard
title={t("hosts.basicInformation")}
icon={<Info className="size-3.5" />}
>
<div className="grid grid-cols-1 md:grid-cols-2 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.friendlyNameLabel")}
</label>
<Input
placeholder="e.g. Production SSH Key"
value={credForm.name}
onChange={(e) => setCredField("name", 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.folder")}
</label>
<Input
placeholder="e.g. Server Keys"
value={credForm.folder}
onChange={(e) => setCredField("folder", e.target.value)}
/>
</div>
<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.descriptionLabel")}
</label>
<Input
placeholder="Optional details..."
value={credForm.description}
onChange={(e) => setCredField("description", e.target.value)}
/>
</div>
<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.tags")}
</label>
<div className="flex flex-wrap items-center gap-1 min-h-9 px-2 py-1 border border-border bg-background focus-within:ring-1 focus-within:ring-ring">
{credForm.tags.map((tag) => (
<span
key={tag}
className="flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] bg-muted border border-border/60 text-foreground"
>
{tag}
<button
type="button"
onClick={() =>
setCredField(
"tags",
credForm.tags.filter((tg) => tg !== tag),
)
}
className="text-muted-foreground hover:text-destructive ml-0.5"
>
<X className="size-2.5" />
</button>
</span>
))}
<input
className="flex-1 min-w-16 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50"
placeholder={
credForm.tags.length === 0
? t("hosts.addTagsPlaceholder")
: ""
}
value={credForm.tagInput}
onChange={(e) => setCredField("tagInput", e.target.value)}
onKeyDown={(e) => {
if (
(e.key === " " || e.key === "Enter") &&
credForm.tagInput.trim()
) {
e.preventDefault();
const tag = credForm.tagInput.trim();
if (!credForm.tags.includes(tag))
setCredField("tags", [...credForm.tags, tag]);
setCredField("tagInput", "");
} else if (
e.key === "Backspace" &&
!credForm.tagInput &&
credForm.tags.length > 0
) {
setCredField("tags", credForm.tags.slice(0, -1));
}
}}
/>
</div>
</div>
</div>
</SectionCard>
)}
{activeTab === "auth" && (
<SectionCard
title={t("hosts.authDetailsSection")}
icon={<Lock className="size-3.5" />}
>
<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.credTypeLabel")}
</label>
<div className="flex gap-2">
{["password", "key"].map((m) => (
<button
key={m}
onClick={() => setCredField("type", m as any)}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${type === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{m === "key"
? t("hosts.sshPrivateKey")
: t("hosts.password")}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.username")}
</label>
<Input
placeholder="e.g. root or deploy"
value={credForm.username}
onChange={(e) => setCredField("username", e.target.value)}
/>
</div>
{type === "password" && (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.password")}
</label>
<Input
type="password"
placeholder="••••••••"
value={credForm.value}
onChange={(e) => setCredField("value", e.target.value)}
/>
</div>
)}
{type === "key" && (
<div className="flex flex-col gap-4">
<div className="p-3 border border-border bg-muted/20">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2">
{t("hosts.generateKeyPairTitle")}
</p>
<p className="text-[10px] text-muted-foreground mb-2">
{t("hosts.generateKeyPairDescription")}
</p>
<div className="flex flex-wrap gap-2">
{[
{ label: "Ed25519", type: "ssh-ed25519" },
{
label: "ECDSA (nistp256)",
type: "ecdsa-sha2-nistp256",
},
{ label: "RSA (2048)", type: "ssh-rsa", bits: 2048 },
].map(({ label, type: keyType, bits }) => (
<Button
key={label}
type="button"
variant="outline"
size="sm"
className="h-7 text-[10px] px-2"
disabled={generatingKey}
onClick={async () => {
setGeneratingKey(true);
try {
const result = await generateKeyPair(
keyType,
bits,
credForm.passphrase || undefined,
);
if (result.success) {
setCredField("value", result.privateKey);
setCredField("publicKey", result.publicKey);
toast.success(
t("hosts.keyPairGenerated", { label }),
);
} else {
toast.error(
result.error ??
t("hosts.failedToGenerateKeyPair"),
);
}
} catch {
toast.error(t("hosts.failedToGenerateKeyPair"));
} finally {
setGeneratingKey(false);
}
}}
>
{generatingKey
? t("hosts.generatingKey")
: t("hosts.generateLabel", { label })}
</Button>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.sshPrivateKey")}
</label>
<button
type="button"
className="text-[10px] text-accent-brand hover:text-accent-brand/80 flex items-center gap-1"
onClick={() => credFileInputRef.current?.click()}
>
<Upload className="size-3" /> {t("hosts.uploadFileBtn")}
</button>
</div>
<input
ref={credFileInputRef}
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();
setCredField("value", text.trim());
e.target.value = "";
}}
/>
<textarea
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
rows={8}
value={credForm.value}
onChange={(e) => setCredField("value", 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>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.keyPassphraseOptional")}
</label>
<Input
type="password"
placeholder="••••••••"
value={credForm.passphrase}
onChange={(e) => setCredField("passphrase", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.sshPublicKeyOptional")}
</label>
<Button
type="button"
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
disabled={!credForm.value || generatingPublicKey}
onClick={async () => {
setGeneratingPublicKey(true);
try {
const result = await generatePublicKeyFromPrivate(
credForm.value,
credForm.passphrase || undefined,
);
if (result?.publicKey) {
setCredField("publicKey", result.publicKey);
toast.success(t("hosts.publicKeyGenerated"));
} else {
toast.error(t("hosts.failedToGeneratePublicKey"));
}
} catch {
toast.error(t("hosts.failedToGeneratePublicKey"));
} finally {
setGeneratingPublicKey(false);
}
}}
>
{generatingPublicKey
? t("hosts.generatingKey")
: t("hosts.generateFromPrivateKey")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-6 text-[10px] px-2"
disabled={!credForm.publicKey}
onClick={() => {
navigator.clipboard.writeText(credForm.publicKey ?? "");
toast.success(t("hosts.publicKeyCopied"));
}}
>
<Copy className="size-3 mr-1" /> {t("common.copy")}
</Button>
</div>
<textarea
placeholder="ssh-rsa AAAAB3Nza..."
rows={3}
value={credForm.publicKey}
onChange={(e) => setCredField("publicKey", 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>
</div>
)}
</div>
</SectionCard>
)}
<div className="flex justify-end gap-3 mt-3">
<Button variant="ghost" onClick={onBack} disabled={saving}>
{t("hosts.cancelBtn")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand px-8"
onClick={handleSave}
disabled={saving}
>
{saving
? t("hosts.savingBtn")
: credential
? t("hosts.updateCredentialBtn")
: t("hosts.addCredentialBtn")}
</Button>
</div>
</div>
);
}
export function HostManager({
onCollapse,
pendingEditId,
pendingAction,
}: {
onCollapse?: () => void;
pendingEditId?: MutableRefObject<string | null>;
pendingAction?: MutableRefObject<"add-host" | "add-credential" | null>;
} = {}) {
const { t } = useTranslation();
const [section, setSection] = useState<"hosts" | "credentials">("hosts");
const [editingHost, setEditingHost] = useState<Host | "new" | null>(null);
const [editingCredential, setEditingCredential] = useState<
Credential | "new" | null
>(null);
const [activeHostTab, setActiveHostTab] = useState("general");
const [activeCredentialTab, setActiveCredentialTab] = useState("general");
const [searchQuery, setSearchQuery] = useState("");
const [hosts, setHosts] = useState<Host[]>([]);
const [credentials, setCredentials] = useState<Credential[]>([]);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set(),
);
const [selectionMode, setSelectionMode] = useState(false);
const [selectedHostIds, setSelectedHostIds] = useState<Set<string>>(
new Set(),
);
const [editingFolderName, setEditingFolderName] = useState<string | null>(
null,
);
const [editingFolderValue, setEditingFolderValue] = useState("");
const [deployDialog, setDeployDialog] = useState<{
cred: Credential;
hostId: string;
} | null>(null);
const [deploying, setDeploying] = useState(false);
const [confirmDialog, setConfirmDialog] = useState<{
message: string;
onConfirm: () => void;
} | null>(null);
const [draggedHost, setDraggedHost] = useState<Host | null>(null);
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
const [editingProtocols, setEditingProtocols] = useState({
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
});
const [statusesLoading, setStatusesLoading] = useState(true);
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
const hostsRef = useRef<Host[]>([]);
useEffect(() => {
hostsRef.current = hosts;
}, [hosts]);
const fileInputRef = useRef<HTMLInputElement>(null);
const importOverwriteRef = useRef(false);
const [refreshing, setRefreshing] = useState(false);
const [editingCredFolderName, setEditingCredFolderName] = useState<
string | null
>(null);
const [editingCredFolderValue, setEditingCredFolderValue] = useState("");
const applyPendingEdit = (hostList: Host[]) => {
if (pendingEditId?.current) {
const id = pendingEditId.current;
pendingEditId.current = null;
const host = hostList.find((h) => h.id === id);
if (host) {
setSection("hosts");
setEditingHost(host);
setEditingCredential(null);
setActiveHostTab("general");
setEditingProtocols({
enableSsh: host.enableSsh,
enableRdp: host.enableRdp,
enableVnc: host.enableVnc,
enableTelnet: host.enableTelnet,
});
return true;
}
}
return false;
};
useEffect(() => {
getSSHHosts()
.then(async (raw) => {
const converted = raw.map(sshHostToHost);
setHosts(converted);
setExpandedFolders(
new Set(converted.map((h) => h.folder.split(" / ")[0])),
);
applyPendingEdit(converted);
setStatusesLoading(true);
let statuses: Record<number, { status?: string }> = {};
try {
statuses = (await getAllServerStatuses()) as Record<
number,
{ status?: string }
>;
} catch {
// best-effort
} finally {
setStatusesLoading(false);
setInitialLoadComplete(true);
}
const onlineHosts = converted.filter(
(h) => statuses[Number(h.id)]?.status === "online",
);
const metricsResults = await Promise.allSettled(
onlineHosts.map((h) => getServerMetricsById(Number(h.id))),
);
const metricsMap = new Map<string, { cpu: number; ram: number }>();
onlineHosts.forEach((h, i) => {
const result = metricsResults[i];
if (result.status === "fulfilled" && result.value) {
const cpu = result.value.cpu?.percent;
const ram = result.value.memory?.percent;
if (cpu != null && ram != null) {
metricsMap.set(h.id, { cpu, ram });
}
}
});
if (metricsMap.size > 0) {
setHosts((prev) =>
prev.map((h) => {
const m = metricsMap.get(h.id);
return m ? { ...h, cpu: m.cpu, ram: m.ram } : h;
}),
);
}
})
.catch(() => {});
getCredentials()
.then((res: any) => {
const arr = Array.isArray(res) ? res : [];
setCredentials(
arr.map((c: any) => ({
id: String(c.id),
name: c.name,
username: c.username,
type: c.authType === "key" ? "key" : "password",
description: c.description ?? "",
folder: c.folder ?? "",
tags: c.tags ?? [],
publicKey: c.publicKey ?? undefined,
})),
);
})
.catch(() => {});
}, []);
useEffect(() => {
if (pendingAction?.current) {
const action = pendingAction.current;
pendingAction.current = null;
if (action === "add-host") {
setSection("hosts");
setEditingHost("new");
setEditingCredential(null);
setEditingProtocols({
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
});
setActiveHostTab("general");
} else if (action === "add-credential") {
setSection("credentials");
setEditingCredential("new");
setEditingHost(null);
}
}
}, [pendingEditId, pendingAction]);
useEffect(() => {
const handleAddHost = () => {
setSection("hosts");
setEditingHost("new");
setEditingCredential(null);
setEditingProtocols({
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
});
setActiveHostTab("general");
};
const handleAddCredential = () => {
setSection("credentials");
setEditingCredential("new");
setEditingHost(null);
};
const handleEditHost = (e: Event) => {
const id = (e as CustomEvent<string>).detail;
const host = hostsRef.current.find((h) => h.id === id);
if (host) {
setSection("hosts");
setEditingHost(host);
setEditingCredential(null);
setActiveHostTab("general");
setEditingProtocols({
enableSsh: host.enableSsh,
enableRdp: host.enableRdp,
enableVnc: host.enableVnc,
enableTelnet: host.enableTelnet,
});
}
};
window.addEventListener("host-manager:add-host", handleAddHost);
window.addEventListener("host-manager:add-credential", handleAddCredential);
window.addEventListener("host-manager:edit-host", handleEditHost);
return () => {
window.removeEventListener("host-manager:add-host", handleAddHost);
window.removeEventListener(
"host-manager:add-credential",
handleAddCredential,
);
window.removeEventListener("host-manager:edit-host", handleEditHost);
};
}, []);
const allHosts = hosts;
const filteredHosts = allHosts.filter(
(h) =>
h.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
h.ip.toLowerCase().includes(searchQuery.toLowerCase()) ||
h.tags?.some((tg) =>
tg.toLowerCase().includes(searchQuery.toLowerCase()),
),
);
const filteredCredentials = credentials.filter(
(c) =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.username.toLowerCase().includes(searchQuery.toLowerCase()),
);
const folders = Array.from(new Set(allHosts.map((h) => h.folder))).sort();
const pinnedHosts = filteredHosts.filter((h) => h.pin);
const hostsByFolder = folders.reduce<Record<string, Host[]>>(
(acc, folder) => {
acc[folder] = filteredHosts.filter((h) => h.folder === folder && !h.pin);
return acc;
},
{},
);
const credentialFolders = Array.from(
new Set(credentials.map((c) => c.folder || "Uncategorized")),
).sort();
const toggleFolder = (folder: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(folder)) next.delete(folder);
else next.add(folder);
return next;
});
};
const toggleHostSelection = (id: string) => {
setSelectedHostIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleExportHosts = () => {
const data = JSON.stringify({ hosts: allHosts }, null, 2);
const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "termix-hosts.json";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(t("hosts.hostsExported"));
};
const handleDownloadSample = () => {
const sample = JSON.stringify(
{
hosts: [
{
name: "My Server",
ip: "192.168.1.1",
username: "root",
port: 22,
folder: "Production",
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
sshPort: 22,
rdpPort: 3389,
vncPort: 5900,
telnetPort: 23,
},
],
},
null,
2,
);
const blob = new Blob([sample], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "termix-hosts-sample.json";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(t("hosts.sampleDownloaded"));
};
const handleRefresh = async () => {
setRefreshing(true);
try {
const [raw] = await Promise.all([
getSSHHosts(),
refreshServerPolling().catch(() => {}),
]);
const converted = raw.map(sshHostToHost);
setHosts(converted);
setStatusesLoading(true);
let statuses: Record<number, { status?: string }> = {};
try {
statuses = (await getAllServerStatuses()) as Record<
number,
{ status?: string }
>;
} catch {
// best-effort
} finally {
setStatusesLoading(false);
}
const onlineHosts = converted.filter(
(h) => statuses[Number(h.id)]?.status === "online",
);
const metricsResults = await Promise.allSettled(
onlineHosts.map((h) => getServerMetricsById(Number(h.id))),
);
const metricsMap = new Map<string, { cpu: number; ram: number }>();
onlineHosts.forEach((h, i) => {
const result = metricsResults[i];
if (result.status === "fulfilled" && result.value) {
const cpu = result.value.cpu?.percent;
const ram = result.value.memory?.percent;
if (cpu != null && ram != null) metricsMap.set(h.id, { cpu, ram });
}
});
if (metricsMap.size > 0) {
setHosts((prev) =>
prev.map((h) => {
const m = metricsMap.get(h.id);
return m ? { ...h, cpu: m.cpu, ram: m.ram } : h;
}),
);
}
toast.success(t("hosts.hostStatusesRefreshed"));
} catch {
toast.error(t("hosts.failedToRefreshHosts"));
} finally {
setRefreshing(false);
}
};
// Build a nested folder tree from flat hosts using "/" as path separator
type FolderNode = {
name: string;
fullPath: string;
children: FolderNode[];
hosts: Host[];
};
const buildFolderTree = (hostList: Host[]): FolderNode => {
const root: FolderNode = {
name: "",
fullPath: "",
children: [],
hosts: [],
};
const nodeMap = new Map<string, FolderNode>();
nodeMap.set("", root);
const ensureNode = (path: string): FolderNode => {
if (nodeMap.has(path)) return nodeMap.get(path)!;
const parts = path.split(" / ");
const parentPath = parts.slice(0, -1).join(" / ");
const parent = ensureNode(parentPath);
const node: FolderNode = {
name: parts[parts.length - 1],
fullPath: path,
children: [],
hosts: [],
};
parent.children.push(node);
nodeMap.set(path, node);
return node;
};
for (const host of hostList) {
const node = ensureNode(host.folder || "");
if (!host.pin) node.hosts.push(host);
}
return root;
};
const folderTree = buildFolderTree(filteredHosts);
// Global stripe counter — mutable object so renderFolderNode can increment across recursion
const stripeCounter = { value: 0 };
const renderFolderNode = (
node: FolderNode,
depth: number = 0,
): React.ReactNode => {
const isExpanded = expandedFolders.has(node.fullPath);
const isOver = dragOverFolder === node.fullPath;
const totalHosts = (() => {
const count = (n: FolderNode): number =>
n.hosts.length + n.children.reduce((s, c) => s + count(c), 0);
return count(node);
})();
const onlineHosts = (() => {
const count = (n: FolderNode): number =>
n.hosts.filter((h) => h.online).length +
n.children.reduce((s, c) => s + count(c), 0);
return count(node);
})();
if (totalHosts === 0 && node.children.length === 0) return null;
const folderStripe = stripeCounter.value++ % 2 === 1;
return (
<div key={node.fullPath}>
<div
className={`flex items-center gap-1.5 py-1.5 border-b border-border/40 group/folder transition-colors ${isOver ? "bg-accent-brand/5" : folderStripe ? "bg-muted/20 hover:bg-muted/40" : "hover:bg-muted/30"}`}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
onDragOver={(e) => {
e.preventDefault();
setDragOverFolder(node.fullPath);
}}
onDragLeave={() => setDragOverFolder(null)}
onDrop={async (e) => {
e.preventDefault();
setDragOverFolder(null);
if (draggedHost) {
const h = draggedHost;
setDraggedHost(null);
if (h.folder === node.fullPath) return;
try {
await updateSSHHost(Number(h.id), {
...h,
folder: node.fullPath,
} as any);
setHosts((prev) =>
prev.map((x) =>
x.id === h.id ? { ...x, folder: node.fullPath } : x,
),
);
toast.success(
t("hosts.movedHostTo", {
host: h.name,
folder: node.fullPath || "root",
}),
);
} catch {
toast.error(t("hosts.failedToMoveHost"));
}
}
}}
>
<button
className="flex items-center gap-1.5 flex-1 text-left min-w-0"
onClick={() => toggleFolder(node.fullPath)}
>
<ChevronRight
className={`size-3 text-muted-foreground/40 shrink-0 transition-transform ${isExpanded ? "rotate-90" : ""}`}
/>
{isExpanded ? (
<FolderOpen className="size-3 text-accent-brand/60 shrink-0" />
) : (
<Folder className="size-3 text-muted-foreground/50 shrink-0" />
)}
{editingFolderName === node.fullPath ? (
<input
autoFocus
value={editingFolderValue}
onChange={(e) => setEditingFolderValue(e.target.value)}
onBlur={async () => {
const newName = editingFolderValue.trim();
setEditingFolderName(null);
if (newName && newName !== node.name) {
try {
await renameFolder(node.fullPath, newName);
const raw = await getSSHHosts();
setHosts(raw.map(sshHostToHost));
toast.success(
t("hosts.folderRenamedTo", { name: newName }),
);
} catch {
toast.error(t("hosts.failedToRenameFolder"));
}
}
}}
onKeyDown={async (e) => {
if (e.key === "Enter") {
const newName = editingFolderValue.trim();
setEditingFolderName(null);
if (newName && newName !== node.name) {
try {
await renameFolder(node.fullPath, newName);
const raw = await getSSHHosts();
setHosts(raw.map(sshHostToHost));
toast.success(
t("hosts.folderRenamedTo", { name: newName }),
);
} catch {
toast.error(t("hosts.failedToRenameFolder"));
}
}
}
if (e.key === "Escape") setEditingFolderName(null);
}}
onClick={(e) => e.stopPropagation()}
className="text-[11px] font-semibold bg-background border border-accent-brand/60 px-1 outline-none text-foreground min-w-0 flex-1"
/>
) : (
<span className="text-[11px] font-semibold text-foreground/70 truncate">
{node.name}
</span>
)}
<span className="text-[10px] text-muted-foreground/40 shrink-0 ml-0.5 tabular-nums">
<span className={onlineHosts > 0 ? "text-accent-brand" : ""}>
{onlineHosts}
</span>
<span>/{totalHosts}</span>
</span>
</button>
<div className="flex items-center gap-0.5 opacity-0 group-hover/folder:opacity-100 transition-opacity pr-2">
<button
className="size-5 flex items-center justify-center text-muted-foreground/40 hover:text-foreground rounded transition-colors"
onClick={(e) => {
e.stopPropagation();
setEditingFolderName(node.fullPath);
setEditingFolderValue(node.name);
}}
>
<Pencil className="size-2.5" />
</button>
<button
className="size-5 flex items-center justify-center text-muted-foreground/40 hover:text-destructive rounded transition-colors"
onClick={(e) => {
e.stopPropagation();
setConfirmDialog({
message: t("hosts.deleteAllInFolder", { name: node.name }),
onConfirm: async () => {
try {
await deleteAllHostsInFolder(node.fullPath);
const raw = await getSSHHosts();
setHosts(raw.map(sshHostToHost));
toast.success(
t("hosts.deletedFolder", { name: node.name }),
);
} catch {
toast.error(t("hosts.failedToDeleteFolder"));
}
},
});
}}
>
<Trash2 className="size-2.5" />
</button>
</div>
</div>
{isExpanded && (
<>
{node.children.map((child) => renderFolderNode(child, depth + 1))}
{node.hosts.map((host) => {
const stripe = stripeCounter.value++ % 2 === 1;
return (
<HostRow
key={host.id}
host={host}
depth={depth + 1}
stripeIndex={stripe ? 1 : 0}
selectionMode={selectionMode}
selected={selectedHostIds.has(host.id)}
onToggleSelect={() => toggleHostSelection(host.id)}
statusesLoading={statusesLoading}
initialLoadComplete={initialLoadComplete}
onEdit={() => {
setEditingHost(host);
setActiveHostTab("general");
setEditingProtocols({
enableSsh: host.enableSsh,
enableRdp: host.enableRdp,
enableVnc: host.enableVnc,
enableTelnet: host.enableTelnet,
});
}}
onDelete={() => {
setConfirmDialog({
message: t("hosts.deleteHostConfirm", {
name: host.name,
}),
onConfirm: async () => {
try {
await deleteSSHHost(Number(host.id));
setHosts((prev) =>
prev.filter((h) => h.id !== host.id),
);
toast.success(
t("hosts.deletedHost", { name: host.name }),
);
} catch {
toast.error(
t("hosts.failedToDeleteHost", { name: host.name }),
);
}
},
});
}}
onDragStart={() => setDraggedHost(host)}
onDragEnd={() => setDraggedHost(null)}
/>
);
})}
</>
)}
</div>
);
};
// Editor view: full-width with top tab bar instead of side nav
const renderEditorView = () => {
const isHost = !!editingHost;
const tabs = isHost
? makeHostTabs(t).filter((tab) => {
if (tab.id === "general" || tab.id === "sharing") return true;
if (["ssh", "tunnels", "docker", "files", "stats"].includes(tab.id))
return editingProtocols.enableSsh;
if (tab.id === "rdp") return editingProtocols.enableRdp;
if (tab.id === "vnc") return editingProtocols.enableVnc;
if (tab.id === "telnet") return editingProtocols.enableTelnet;
return false;
})
: makeCredentialTabs(t);
const activeTab = isHost ? activeHostTab : activeCredentialTab;
const setActiveTab = isHost ? setActiveHostTab : setActiveCredentialTab;
return (
<div className="flex flex-col flex-1 min-h-0">
{/* Back bar + tab strip */}
<div className="flex flex-col shrink-0 border-b border-border">
<button
onClick={() => {
if (isHost) {
setEditingHost(null);
setActiveHostTab("general");
} else {
setEditingCredential(null);
setActiveCredentialTab("general");
}
}}
className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border/50"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span>
{isHost ? t("hosts.backToHosts") : t("hosts.backToCredentials")}
</span>
{isHost && editingHost !== "new" && (
<span
className="ml-auto font-semibold text-foreground truncate max-w-[200px]"
title={(editingHost as Host).name}
>
{(editingHost as Host).name}
</span>
)}
</button>
<TabStrip
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-3">
{isHost ? (
<HostEditor
key={
editingHost === "new" ? "new-host" : (editingHost as Host).id
}
host={editingHost === "new" ? null : (editingHost as Host)}
activeTab={activeHostTab}
onBack={() => {
setEditingHost(null);
setActiveHostTab("general");
}}
onSave={(saved) => {
const updated = sshHostToHost(saved);
setHosts((prev) => {
const idx = prev.findIndex((h) => h.id === updated.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = updated;
return next;
}
return [...prev, updated];
});
setEditingHost(null);
setActiveHostTab("general");
}}
protocols={editingProtocols}
onProtocolChange={(p) =>
setEditingProtocols((prev) => ({ ...prev, ...p }))
}
onTabChange={setActiveHostTab}
hosts={hosts}
credentials={credentials}
/>
) : (
<CredentialEditorView
key={
editingCredential === "new"
? "new-cred"
: (editingCredential as Credential).id
}
credential={
editingCredential === "new"
? null
: (editingCredential as Credential)
}
activeTab={activeCredentialTab}
onBack={() => {
setEditingCredential(null);
setActiveCredentialTab("general");
}}
onSave={(saved) => {
setCredentials((prev) => {
const idx = prev.findIndex((c) => c.id === String(saved.id));
const updated: Credential = {
id: String(saved.id),
name: saved.name,
username: saved.username ?? "",
type: saved.authType === "key" ? "key" : "password",
value: saved.value,
publicKey: saved.publicKey,
passphrase: saved.passphrase,
description: saved.description,
folder: saved.folder ?? "",
tags: saved.tags ?? [],
};
if (idx >= 0) {
const next = [...prev];
next[idx] = updated;
return next;
}
return [...prev, updated];
});
setEditingCredential(null);
setActiveCredentialTab("general");
}}
/>
)}
</div>
</div>
);
};
const isEditing = !!editingHost || !!editingCredential;
return (
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
{/* Top bar: section switcher + actions */}
{!isEditing && (
<div className="flex items-center gap-0 shrink-0 border-b border-border/60">
{/* Section tabs */}
<button
onClick={() => {
setSection("hosts");
setEditingCredential(null);
setSearchQuery("");
}}
className={`flex items-center gap-1.5 px-3 py-2.5 text-xs font-semibold border-b-2 transition-colors ${section === "hosts" ? "border-accent-brand text-accent-brand" : "border-transparent text-muted-foreground hover:text-foreground"}`}
>
<Server className="size-3.5" />
{t("hosts.hostsTab")}
<span className="text-[10px] font-bold text-muted-foreground/50 ml-0.5">
{allHosts.length}
</span>
</button>
<button
onClick={() => {
setSection("credentials");
setEditingHost(null);
setSearchQuery("");
}}
className={`flex items-center gap-1.5 px-3 py-2.5 text-xs font-semibold border-b-2 transition-colors ${section === "credentials" ? "border-accent-brand text-accent-brand" : "border-transparent text-muted-foreground hover:text-foreground"}`}
>
<KeyRound className="size-3.5" />
{t("hosts.credentialsTab")}
<span className="text-[10px] font-bold text-muted-foreground/50 ml-0.5">
{credentials.length}
</span>
</button>
<div className="flex-1" />
{onCollapse && (
<Button
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-foreground shrink-0"
title={t("hosts.collapseBtn")}
onClick={onCollapse}
>
<ChevronLeft className="size-3.5" />
</Button>
)}
{/* Action buttons — icon-only to save space */}
{section === "hosts" && (
<div className="flex items-center gap-0.5 pr-2">
<input
ref={fileInputRef}
type="file"
accept=".json"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
try {
const text = await file.text();
const parsed = JSON.parse(text);
const hostsArray = Array.isArray(parsed)
? parsed
: (parsed.hosts ?? []);
if (!Array.isArray(hostsArray) || hostsArray.length === 0) {
toast.error("No hosts found in file");
return;
}
if (hostsArray.length > 100) {
toast.error("Cannot import more than 100 hosts at once");
return;
}
const normalized = hostsArray.map((h: any) => ({
...h,
port: h.port ?? h.sshPort ?? 22,
}));
const result = await bulkImportSSHHosts(
normalized,
importOverwriteRef.current,
);
const raw = await getSSHHosts();
setHosts(raw.map(sshHostToHost));
const msg = [
result.success ? `${result.success} imported` : null,
result.updated ? `${result.updated} updated` : null,
result.failed ? `${result.failed} failed` : null,
]
.filter(Boolean)
.join(", ");
toast.success(`Import complete: ${msg}`);
} catch (err: any) {
toast.error(err?.message ?? "Failed to import hosts");
}
}}
/>
<Button
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-foreground"
title={t("hosts.refreshBtn2")}
onClick={handleRefresh}
disabled={refreshing}
>
<RefreshCw
className={`size-3.5 ${refreshing ? "animate-spin" : ""}`}
/>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-foreground"
title={t("hosts.importExportBtn")}
>
<Upload className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs">
<DropdownMenuItem
onClick={() => {
importOverwriteRef.current = false;
fileInputRef.current?.click();
}}
>
<Upload className="size-3.5 mr-2" />
{t("hosts.importSkipExisting")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
importOverwriteRef.current = true;
fileInputRef.current?.click();
}}
>
<Upload className="size-3.5 mr-2" />
{t("hosts.importOverwrite")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleExportHosts}
disabled={allHosts.length === 0}
>
<Download className="size-3.5 mr-2" />
{t("hosts.exportAll")}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleDownloadSample}>
<Download className="size-3.5 mr-2" />
{t("hosts.downloadSample")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
className={`size-8 transition-colors ${selectionMode ? "text-accent-brand bg-accent-brand/10" : "text-muted-foreground hover:text-foreground"}`}
title={
selectionMode
? t("hosts.exitSelectionTitle")
: t("hosts.selectMultiple")
}
onClick={() => {
setSelectionMode((s) => !s);
setSelectedHostIds(new Set());
}}
>
<ListChecks className="size-3.5" />
</Button>
<div className="w-px h-4 bg-border mx-1" />
<Button
variant="outline"
size="sm"
className="h-7 text-xs px-2.5"
onClick={() => {
setEditingHost("new");
setActiveHostTab("general");
setEditingProtocols({
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
});
}}
>
<Plus className="size-3.5 mr-1" />
{t("hosts.addHostBtn2")}
</Button>
</div>
)}
{section === "credentials" && (
<div className="flex items-center gap-0.5 pr-2">
<Button
variant="outline"
size="sm"
className="h-7 text-xs px-2.5"
onClick={() => {
setEditingCredential("new");
setActiveCredentialTab("general");
}}
>
<Plus className="size-3.5 mr-1" />
{t("hosts.addCredentialBtn2")}
</Button>
</div>
)}
</div>
)}
{isEditing ? (
renderEditorView()
) : (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
{/* Search bar */}
<div className="px-2 py-1.5 shrink-0 border-b border-border/40">
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60">
<Search className="size-3 text-muted-foreground/60 shrink-0" />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={
section === "hosts"
? t("hosts.searchHostsPlaceholder")
: t("hosts.searchCredentialsPlaceholder")
}
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
<X className="size-3" />
</button>
)}
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{section === "hosts" && (
<div className="flex flex-col">
{statusesLoading && (
<div className="flex items-center gap-2 px-3 py-1.5 text-[10px] text-muted-foreground/50 border-b border-border/30">
<RefreshCw className="size-2.5 animate-spin shrink-0" />
{t("hosts.checkingHostStatuses")}
</div>
)}
{/* Pinned hosts */}
{pinnedHosts.length > 0 && (
<div className="flex flex-col">
<div className="flex items-center gap-1.5 px-3 py-1.5 border-b border-border/40 bg-accent-brand/5">
<Pin className="size-2.5 text-accent-brand" />
<span className="text-[10px] font-bold uppercase tracking-widest text-accent-brand">
{t("hosts.pinnedSection")}
</span>
<span className="text-[10px] text-accent-brand/50 ml-0.5">
{pinnedHosts.length}
</span>
</div>
{pinnedHosts.map((host) => (
<HostRow
key={host.id}
host={host}
selectionMode={selectionMode}
selected={selectedHostIds.has(host.id)}
onToggleSelect={() => toggleHostSelection(host.id)}
statusesLoading={statusesLoading}
initialLoadComplete={initialLoadComplete}
onEdit={() => {
setEditingHost(host);
setActiveHostTab("general");
setEditingProtocols({
enableSsh: host.enableSsh,
enableRdp: host.enableRdp,
enableVnc: host.enableVnc,
enableTelnet: host.enableTelnet,
});
}}
onDelete={() => {
setConfirmDialog({
message: t("hosts.deleteHostConfirm", {
name: host.name,
}),
onConfirm: async () => {
try {
await deleteSSHHost(Number(host.id));
setHosts((prev) =>
prev.filter((h) => h.id !== host.id),
);
toast.success(
t("hosts.deletedHost", { name: host.name }),
);
} catch {
toast.error(
t("hosts.failedToDeleteHost", {
name: host.name,
}),
);
}
},
});
}}
/>
))}
</div>
)}
{/* Nested folder tree */}
{folderTree.children.map((node) => renderFolderNode(node, 0))}
{/* Root-level (no folder) hosts */}
{folderTree.hosts.map((host) => (
<HostRow
key={host.id}
host={host}
selectionMode={selectionMode}
selected={selectedHostIds.has(host.id)}
onToggleSelect={() => toggleHostSelection(host.id)}
statusesLoading={statusesLoading}
initialLoadComplete={initialLoadComplete}
onEdit={() => {
setEditingHost(host);
setActiveHostTab("general");
setEditingProtocols({
enableSsh: host.enableSsh,
enableRdp: host.enableRdp,
enableVnc: host.enableVnc,
enableTelnet: host.enableTelnet,
});
}}
onDelete={() => {
setConfirmDialog({
message: t("hosts.deleteHostConfirm", {
name: host.name,
}),
onConfirm: async () => {
try {
await deleteSSHHost(Number(host.id));
setHosts((prev) =>
prev.filter((h) => h.id !== host.id),
);
toast.success(
t("hosts.deletedHost", { name: host.name }),
);
} catch {
toast.error(
t("hosts.failedToDeleteHost", {
name: host.name,
}),
);
}
},
});
}}
onDragStart={() => setDraggedHost(host)}
onDragEnd={() => setDraggedHost(null)}
/>
))}
{filteredHosts.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center px-4">
<Server className="size-8 text-muted-foreground/20 mb-2" />
<span className="text-sm font-semibold text-muted-foreground/60">
{t("hosts.noHostsFound")}
</span>
<span className="text-xs text-muted-foreground/40 mt-1">
{searchQuery
? t("hosts.tryDifferentTerm")
: t("hosts.addFirstHost")}
</span>
{!searchQuery && (
<Button
variant="outline"
size="sm"
className="mt-3 h-7 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
onClick={() => {
setEditingHost("new");
setActiveHostTab("general");
setEditingProtocols({
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
});
}}
>
<Plus className="size-3 mr-1" />
{t("hosts.addHostBtn2")}
</Button>
)}
</div>
)}
</div>
)}
{section === "credentials" && (
<div className="flex flex-col">
{credentialFolders.map((folder) => {
const creds = filteredCredentials.filter(
(c) => (c.folder || "Uncategorized") === folder,
);
if (creds.length === 0) return null;
return (
<div key={folder} className="group/folder">
<div className="flex items-center gap-1.5 px-3 py-1.5 border-b border-border/40 bg-muted/20">
<Folder className="size-3 text-muted-foreground/50 shrink-0" />
{editingCredFolderName === folder ? (
<>
<input
autoFocus
value={editingCredFolderValue}
onChange={(e) =>
setEditingCredFolderValue(e.target.value)
}
onBlur={async () => {
const newName = editingCredFolderValue.trim();
setEditingCredFolderName(null);
if (newName && newName !== folder) {
try {
await renameCredentialFolder(
folder,
newName,
);
const res = (await getCredentials()) as any;
const arr = Array.isArray(res) ? res : [];
setCredentials(
arr.map((c: any) => ({
id: String(c.id),
name: c.name,
username: c.username,
type:
c.authType === "key"
? "key"
: "password",
description: c.description ?? "",
folder: c.folder ?? "",
tags: c.tags ?? [],
publicKey: c.publicKey ?? undefined,
})),
);
toast.success(
t("hosts.folderRenamedTo", {
name: newName,
}),
);
} catch {
toast.error(
t("hosts.failedToRenameFolder"),
);
}
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
if (e.key === "Escape")
setEditingCredFolderName(null);
}}
onClick={(e) => e.stopPropagation()}
className="text-[10px] font-semibold bg-background border border-accent-brand/60 px-1 outline-none text-foreground min-w-0 flex-1"
/>
<button
onClick={() => setEditingCredFolderName(null)}
className="text-muted-foreground hover:text-foreground shrink-0"
>
<X className="size-3" />
</button>
</>
) : (
<>
<span className="text-[10px] font-semibold text-muted-foreground/70 flex-1">
{folder}
</span>
<span className="text-[10px] text-muted-foreground/40">
{creds.length}
</span>
{folder !== "Uncategorized" && (
<button
className="opacity-0 group-hover/folder:opacity-100 transition-opacity ml-1 text-muted-foreground/50 hover:text-foreground"
onClick={() => {
setEditingCredFolderName(folder);
setEditingCredFolderValue(folder);
}}
>
<Pencil className="size-2.5" />
</button>
)}
</>
)}
</div>
{creds.map((cred) => {
const usedByHosts = allHosts.filter(
(h) => h.credentialId === cred.id,
);
return (
<div
key={cred.id}
className="flex items-center justify-between px-3 py-2 border-b border-border/40 last:border-0 hover:bg-muted/30 group"
>
<div className="flex items-center gap-2.5 min-w-0">
<div className="size-7 border border-border/60 bg-muted/30 flex items-center justify-center shrink-0">
{cred.type === "key" ? (
<Shield className="size-3 text-accent-brand" />
) : (
<Lock className="size-3 text-accent-brand" />
)}
</div>
<div className="flex flex-col min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold truncate">
{cred.name}
</span>
<span
className={`text-[9px] px-1 py-px font-bold border leading-none shrink-0 ${cred.type === "key" ? "border-accent-brand/30 text-accent-brand" : "border-border/60 text-muted-foreground/60"}`}
>
{cred.type === "key" ? "KEY" : "PWD"}
</span>
</div>
<span className="text-[11px] text-muted-foreground/50 truncate">
{cred.username}
{usedByHosts.length > 0 && (
<span className="text-muted-foreground/30">
{" "}
· {usedByHosts.length}h
</span>
)}
</span>
{cred.tags && cred.tags.length > 0 && (
<div className="flex items-center gap-0.5 mt-0.5 flex-wrap">
{cred.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="text-[9px] px-1 py-px border border-border/50 bg-muted/30 text-muted-foreground/60 lowercase leading-none"
>
{tag}
</span>
))}
{cred.tags.length > 3 && (
<span className="text-[9px] text-muted-foreground/40">
+{cred.tags.length - 3}
</span>
)}
</div>
)}
</div>
</div>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
{cred.type === "key" && (
<>
<button
title="Deploy key to host"
className="flex items-center gap-1 px-1.5 py-1 text-[10px] text-muted-foreground/60 hover:text-foreground hover:bg-muted rounded transition-colors"
onClick={() =>
setDeployDialog({ cred, hostId: "" })
}
>
<Upload className="size-3" />
</button>
<button
title="Copy deploy command"
className="flex items-center gap-1 px-1.5 py-1 text-[10px] text-muted-foreground/60 hover:text-foreground hover:bg-muted rounded transition-colors"
onClick={() => {
const pubKey = cred.publicKey;
if (!pubKey) {
toast.error(
"No public key available — open the credential editor first",
);
return;
}
const cmd = `mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo "${pubKey}" >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`;
navigator.clipboard.writeText(cmd);
toast.success("Deploy command copied");
}}
>
<Copy className="size-3" />
</button>
</>
)}
<button
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted rounded transition-colors"
onClick={() => {
setEditingCredential(cred);
setActiveCredentialTab("general");
}}
>
<Pencil className="size-3" />
</button>
<button
className="size-6 flex items-center justify-center text-muted-foreground/40 hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
onClick={() => {
setConfirmDialog({
message: t(
"hosts.deleteCredentialConfirm",
{ name: cred.name },
),
onConfirm: async () => {
try {
await deleteCredential(Number(cred.id));
setCredentials((prev) =>
prev.filter((c) => c.id !== cred.id),
);
toast.success(
t("hosts.deletedCredential", {
name: cred.name,
}),
);
} catch {
toast.error(
t("hosts.failedToDeleteCredential2"),
);
}
},
});
}}
>
<Trash2 className="size-3" />
</button>
</div>
</div>
);
})}
</div>
);
})}
{filteredCredentials.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center px-4">
<KeyRound className="size-8 text-muted-foreground/20 mb-2" />
<span className="text-sm font-semibold text-muted-foreground/60">
{t("hosts.noCredentialsFound")}
</span>
<Button
variant="outline"
size="sm"
className="mt-3 h-7 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
onClick={() => {
setEditingCredential("new");
setActiveCredentialTab("general");
}}
>
<Plus className="size-3 mr-1" />
{t("hosts.addCredentialBtn2")}
</Button>
</div>
)}
</div>
)}
</div>
</div>
)}
{/* Confirm dialog */}
{confirmDialog && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
<p className="text-sm text-foreground">{confirmDialog.message}</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setConfirmDialog(null)}
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors"
>
{t("hosts.cancelBtn")}
</button>
<button
onClick={() => {
confirmDialog.onConfirm();
setConfirmDialog(null);
}}
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded transition-colors"
>
{t("hosts.deleteConfirmBtn")}
</button>
</div>
</div>
</div>
)}
{/* Deploy credential dialog */}
{deployDialog && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-popover border border-border shadow-xl w-full max-w-sm flex flex-col gap-4 p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-bold">
{t("hosts.deploySSHKeyTitle")}
</span>
<button
onClick={() => setDeployDialog(null)}
className="text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
<div className="text-xs text-muted-foreground">
{t("hosts.deployDialogDesc", { name: deployDialog.cred.name })}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.targetHostLabel")}
</label>
<select
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"
value={deployDialog.hostId}
onChange={(e) =>
setDeployDialog({ ...deployDialog, hostId: e.target.value })
}
>
<option value="">{t("hosts.selectHostOption")}</option>
{allHosts
.filter(
(h) =>
h.enableSsh ||
(!h.enableRdp && !h.enableVnc && !h.enableTelnet),
)
.map((h) => (
<option key={h.id} value={h.id}>
{h.name || h.ip}
</option>
))}
</select>
</div>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setDeployDialog(null)}
disabled={deploying}
>
{t("hosts.cancelBtn")}
</Button>
<Button
variant="outline"
size="sm"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
disabled={!deployDialog.hostId || deploying}
onClick={async () => {
setDeploying(true);
try {
await deployCredentialToHost(
Number(deployDialog.cred.id),
Number(deployDialog.hostId),
);
toast.success(t("hosts.keyDeployedSuccess"));
setDeployDialog(null);
} catch {
toast.error(t("hosts.failedToDeployKey2"));
} finally {
setDeploying(false);
}
}}
>
{deploying ? t("hosts.deployingBtn") : t("hosts.deployBtn")}
</Button>
</div>
</div>
</div>
)}
{/* Floating selection bar */}
{selectionMode && !isEditing && (
<div className="absolute bottom-4 inset-x-3 z-50">
<div className="bg-popover border border-border shadow-xl px-2.5 py-2 flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-semibold tabular-nums shrink-0">
{t("hosts.nSelected", { count: selectedHostIds.size })}
</span>
<div className="w-px h-4 bg-border mx-0.5" />
<button
className="text-[10px] text-muted-foreground hover:text-foreground px-1.5 py-1 hover:bg-muted rounded transition-colors"
onClick={() => {
if (selectedHostIds.size === allHosts.length)
setSelectedHostIds(new Set());
else setSelectedHostIds(new Set(allHosts.map((h) => h.id)));
}}
>
{selectedHostIds.size === allHosts.length
? t("hosts.deselectAll")
: t("hosts.selectAll")}
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="text-[10px] text-muted-foreground hover:text-foreground px-1.5 py-1 hover:bg-muted rounded transition-colors flex items-center gap-1 disabled:opacity-40"
disabled={selectedHostIds.size === 0}
>
{t("hosts.featuresMenu")} <ChevronDown className="size-2.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="text-xs">
{[
{
labelKey: "hosts.enableTerminalFeature",
field: "enableTerminal",
value: true,
icon: Terminal,
},
{
labelKey: "hosts.disableTerminalFeature",
field: "enableTerminal",
value: false,
icon: Terminal,
},
{
labelKey: "hosts.enableFilesFeature",
field: "enableFileManager",
value: true,
icon: FolderSearch,
},
{
labelKey: "hosts.disableFilesFeature",
field: "enableFileManager",
value: false,
icon: FolderSearch,
},
{
labelKey: "hosts.enableTunnelsFeature",
field: "enableTunnel",
value: true,
icon: Network,
},
{
labelKey: "hosts.disableTunnelsFeature",
field: "enableTunnel",
value: false,
icon: Network,
},
{
labelKey: "hosts.enableDockerFeature",
field: "enableDocker",
value: true,
icon: Box,
},
{
labelKey: "hosts.disableDockerFeature",
field: "enableDocker",
value: false,
icon: Box,
},
].map(({ labelKey, field, value, icon: Icon }) => (
<DropdownMenuItem
key={labelKey}
onClick={async () => {
const ids = Array.from(selectedHostIds).map(Number);
try {
const result = await bulkUpdateSSHHosts(ids, {
[field]: value,
});
const raw = await getSSHHosts();
setHosts(raw.map(sshHostToHost));
toast.success(
t("hosts.updatedCount", { count: result.updated }),
);
} catch {
toast.error(t("hosts.bulkUpdateFailed"));
}
}}
>
<Icon className="size-3.5 mr-2" />
{t(labelKey)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="text-[10px] text-muted-foreground hover:text-foreground px-1.5 py-1 hover:bg-muted rounded transition-colors flex items-center gap-1 disabled:opacity-40"
disabled={selectedHostIds.size === 0}
>
{t("hosts.moveMenu")} <ChevronDown className="size-2.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="text-xs">
<DropdownMenuItem
onClick={async () => {
const ids = Array.from(selectedHostIds).map(Number);
try {
await bulkUpdateSSHHosts(ids, { folder: "" });
const raw = await getSSHHosts();
setHosts(raw.map(sshHostToHost));
toast.success(t("hosts.movedToRoot"));
} catch {
toast.error(t("hosts.failedToMoveHosts"));
}
}}
>
<FolderOpen className="size-3.5 mr-2" />
{t("hosts.noFolderOption")}
</DropdownMenuItem>
{folders.filter(Boolean).map((f) => (
<DropdownMenuItem
key={f}
onClick={async () => {
const ids = Array.from(selectedHostIds).map(Number);
try {
await bulkUpdateSSHHosts(ids, { folder: f });
const raw = await getSSHHosts();
setHosts(raw.map(sshHostToHost));
toast.success(t("hosts.movedToFolder", { folder: f }));
} catch {
toast.error(t("hosts.failedToMoveHosts"));
}
}}
>
<FolderOpen className="size-3.5 mr-2" />
{f}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<button
className="text-[10px] text-destructive hover:text-destructive px-1.5 py-1 hover:bg-destructive/10 rounded transition-colors disabled:opacity-40"
disabled={selectedHostIds.size === 0}
onClick={() => {
setConfirmDialog({
message: t("hosts.deleteHostsConfirm", {
count: selectedHostIds.size,
plural: selectedHostIds.size !== 1 ? "s" : "",
}),
onConfirm: async () => {
const ids = Array.from(selectedHostIds);
const results = await Promise.allSettled(
ids.map((id) => deleteSSHHost(Number(id))),
);
const succeeded = results.filter(
(r) => r.status === "fulfilled",
).length;
const failed = results.filter(
(r) => r.status === "rejected",
).length;
setHosts((prev) =>
prev.filter((h) => !selectedHostIds.has(h.id)),
);
setSelectedHostIds(new Set());
if (succeeded > 0)
toast.success(
t("hosts.deletedCount", { count: succeeded }),
);
if (failed > 0)
toast.error(
t("hosts.failedToDeleteCount", { count: failed }),
);
},
});
}}
>
{t("hosts.deleteSelected")}
</button>
<div className="flex-1" />
<button
className="text-[10px] text-muted-foreground hover:text-foreground px-1.5 py-1 hover:bg-muted rounded transition-colors"
onClick={() => {
setSelectionMode(false);
setSelectedHostIds(new Set());
}}
>
{t("hosts.cancelSelection")}
</button>
</div>
</div>
)}
</div>
);
}