mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
922 lines
38 KiB
TypeScript
922 lines
38 KiB
TypeScript
import React from "react";
|
|
import type { ProxyNode } from "@/types/index";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Button } from "@/components/button";
|
|
import { Input } from "@/components/input";
|
|
import { PasswordInput } from "@/components/password-input";
|
|
import { FakeSwitch, SectionCard, SettingRow } from "@/components/section-card";
|
|
import type { Host } from "@/types/ui-types";
|
|
import {
|
|
Activity,
|
|
Globe,
|
|
LayoutGrid,
|
|
Plus,
|
|
Tag,
|
|
Terminal,
|
|
Trash2,
|
|
X,
|
|
} from "lucide-react";
|
|
import { FolderPathPicker } from "./FolderPathPicker";
|
|
import { HostParentPicker } from "./HostParentPicker";
|
|
import { getSSHFolders, isElectron } from "@/main-axios";
|
|
import { connectionOriginAppliesTo } from "./HostEditorData";
|
|
import type { HostEditorForm, HostProtocols } from "./HostEditorData";
|
|
import { Select2 } from "@/components/select2";
|
|
import { useHostProtocols } from "./host-protocols";
|
|
|
|
type HostEditorSetField = <K extends keyof HostEditorForm>(
|
|
key: K,
|
|
value: HostEditorForm[K],
|
|
) => void;
|
|
|
|
export function HostEditorGeneralTab({
|
|
form,
|
|
setField,
|
|
protocols,
|
|
handleProtocolToggle,
|
|
hosts,
|
|
host,
|
|
simpleMode = false,
|
|
}: {
|
|
form: HostEditorForm;
|
|
setField: HostEditorSetField;
|
|
protocols: HostProtocols;
|
|
handleProtocolToggle: (proto: keyof HostProtocols, value: boolean) => void;
|
|
hosts: Host[];
|
|
host: Host | null;
|
|
/** Hides organizational/advanced fields; their values still save unchanged. */
|
|
simpleMode?: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const pluginProtocols = useHostProtocols();
|
|
|
|
// Tracks which picker is shown, independent of whether a value is set yet
|
|
// -- switching to "parent host" mode with nothing picked shouldn't bounce
|
|
// back to the folder picker just because parentHostId is still empty.
|
|
const [placementMode, setPlacementMode] = React.useState<
|
|
"folder" | "parentHost"
|
|
>(form.parentHostId ? "parentHost" : "folder");
|
|
|
|
const [folderMeta, setFolderMeta] = React.useState<
|
|
Map<string, { color?: string; icon?: string }>
|
|
>(new Map());
|
|
|
|
React.useEffect(() => {
|
|
let cancelled = false;
|
|
const load = () => {
|
|
getSSHFolders()
|
|
.then((folders) => {
|
|
if (cancelled) return;
|
|
const map = new Map<string, { color?: string; icon?: string }>();
|
|
for (const f of folders) {
|
|
map.set(f.name, {
|
|
color: f.color ?? undefined,
|
|
icon: f.icon ?? undefined,
|
|
});
|
|
}
|
|
setFolderMeta(map);
|
|
})
|
|
.catch(() => {});
|
|
};
|
|
load();
|
|
window.addEventListener("termix:hosts-changed", load);
|
|
return () => {
|
|
cancelled = true;
|
|
window.removeEventListener("termix:hosts-changed", load);
|
|
};
|
|
}, []);
|
|
|
|
// Folders come from two sources: paths referenced by existing hosts, and
|
|
// standalone folder records (including empty ones just created).
|
|
// Intermediate ancestor paths are expanded so "A" appears even when only
|
|
// "A / B" is directly stored on a host.
|
|
const folderPaths = React.useMemo(() => {
|
|
const set = new Set<string>();
|
|
const addWithAncestors = (path: string) => {
|
|
const parts = path.split(" / ");
|
|
let accumulated = "";
|
|
for (const part of parts) {
|
|
accumulated = accumulated ? `${accumulated} / ${part}` : part;
|
|
set.add(accumulated);
|
|
}
|
|
};
|
|
for (const h of hosts) {
|
|
if (h.folder) addWithAncestors(h.folder);
|
|
}
|
|
for (const path of folderMeta.keys()) addWithAncestors(path);
|
|
return [...set];
|
|
}, [hosts, folderMeta]);
|
|
|
|
return (
|
|
<>
|
|
{/* 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",
|
|
label: t("hosts.tabSsh"),
|
|
desc: t("hosts.secureShell"),
|
|
icon: <Terminal className="size-4" />,
|
|
},
|
|
...pluginProtocols.map((protocol) => {
|
|
const Icon = protocol.icon;
|
|
return {
|
|
proto: protocol.settingKey,
|
|
label: t(protocol.titleKey),
|
|
desc: protocol.descriptionKey ? t(protocol.descriptionKey) : "",
|
|
icon: <Icon className="size-4" />,
|
|
};
|
|
}),
|
|
].map(({ proto, label, desc, icon }) => {
|
|
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 && !simpleMode && (
|
|
<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.macAddress")}
|
|
</label>
|
|
<a
|
|
href="https://docs.termix.site/features/networking/wake-on-lan"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-[10px] text-accent-brand hover:underline"
|
|
>
|
|
{t("hosts.docsLink")}
|
|
</a>
|
|
</div>
|
|
<Input
|
|
placeholder="AA:BB:CC:DD:EE:FF"
|
|
value={form.macAddress}
|
|
onChange={(e) => setField("macAddress", e.target.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
{protocols.enableSsh && !simpleMode && form.macAddress && (
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.wolBroadcastAddress")}
|
|
</label>
|
|
<Input
|
|
placeholder="192.168.1.255"
|
|
value={form.wolBroadcastAddress}
|
|
onChange={(e) =>
|
|
setField("wolBroadcastAddress", e.target.value)
|
|
}
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground/60">
|
|
{t("hosts.wolBroadcastAddressDesc")}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</SectionCard>
|
|
|
|
{!Object.values(protocols).some(Boolean) && (
|
|
<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" />}
|
|
className={simpleMode ? "hidden" : undefined}
|
|
>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-3">
|
|
<div className="flex flex-col gap-1.5 col-span-2 md:col-span-1">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{placementMode === "parentHost"
|
|
? t("hosts.parentHost")
|
|
: t("hosts.folder")}
|
|
</label>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (placementMode === "parentHost") {
|
|
setPlacementMode("folder");
|
|
setField("parentHostId", "");
|
|
} else {
|
|
setPlacementMode("parentHost");
|
|
setField("folder", "");
|
|
}
|
|
}}
|
|
className="text-[10px] text-accent-brand hover:underline"
|
|
>
|
|
{placementMode === "parentHost"
|
|
? t("hosts.useFolderInstead")
|
|
: t("hosts.useParentHostInstead")}
|
|
</button>
|
|
</div>
|
|
{placementMode === "parentHost" ? (
|
|
<HostParentPicker
|
|
value={String(form.parentHostId)}
|
|
onChange={(hostId) => setField("parentHostId", hostId)}
|
|
hosts={hosts}
|
|
excludeHostId={host?.id}
|
|
/>
|
|
) : (
|
|
<FolderPathPicker
|
|
value={form.folder}
|
|
onChange={(path) => setField("folder", path)}
|
|
folderPaths={folderPaths}
|
|
folderMeta={folderMeta}
|
|
/>
|
|
)}
|
|
</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">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.portKnockingSequence")}
|
|
</span>
|
|
<a
|
|
href="https://docs.termix.site/features/networking/port-knocking"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-[10px] text-accent-brand hover:underline"
|
|
>
|
|
{t("hosts.docsLink")}
|
|
</a>
|
|
</div>
|
|
<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>
|
|
<Select2
|
|
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>
|
|
</Select2>
|
|
</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="flex flex-col gap-3">
|
|
{/* Single / Chain mode toggle */}
|
|
<div className="flex gap-2">
|
|
{(["single", "chain"] as const).map((m) => (
|
|
<button
|
|
key={m}
|
|
type="button"
|
|
onClick={() => setField("socks5ProxyMode", m)}
|
|
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${form.socks5ProxyMode === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
|
>
|
|
{m === "single"
|
|
? t("hosts.proxySingleMode")
|
|
: t("hosts.proxyChainMode")}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{form.socks5ProxyMode === "single" && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 p-3 bg-muted/20 border border-border">
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.proxyHost")}
|
|
</label>
|
|
<Input
|
|
className="h-7 text-xs"
|
|
placeholder="proxy.example.com"
|
|
value={form.socks5Host}
|
|
onChange={(e) => setField("socks5Host", e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.proxyPort")}
|
|
</label>
|
|
<Input
|
|
className="h-7 text-xs"
|
|
type="number"
|
|
placeholder="1080"
|
|
value={form.socks5Port}
|
|
onChange={(e) =>
|
|
setField("socks5Port", Number(e.target.value))
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.proxyUsername")}
|
|
</label>
|
|
<Input
|
|
className="h-7 text-xs"
|
|
placeholder={t("hosts.optional")}
|
|
value={form.socks5Username}
|
|
onChange={(e) =>
|
|
setField("socks5Username", e.target.value)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.proxyPassword")}
|
|
</label>
|
|
<PasswordInput
|
|
className="h-7 text-xs pr-8"
|
|
placeholder={t("hosts.optional")}
|
|
value={form.socks5Password}
|
|
onChange={(e) =>
|
|
setField("socks5Password", e.target.value)
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{form.socks5ProxyMode === "chain" && (
|
|
<div className="flex flex-col gap-2">
|
|
{form.socks5ProxyChain.map((node, ni) => (
|
|
<div
|
|
key={ni}
|
|
className="flex flex-col gap-2 p-3 bg-muted/20 border border-border"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[10px] font-bold text-muted-foreground">
|
|
{t("hosts.proxyNode")} {ni + 1}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="text-destructive"
|
|
onClick={() =>
|
|
setField(
|
|
"socks5ProxyChain",
|
|
form.socks5ProxyChain.filter(
|
|
(_, idx) => idx !== ni,
|
|
),
|
|
)
|
|
}
|
|
>
|
|
<X className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.proxyHost")}
|
|
</label>
|
|
<Input
|
|
className="h-7 text-xs"
|
|
placeholder="proxy.example.com"
|
|
value={node.host}
|
|
onChange={(e) => {
|
|
const u = [...form.socks5ProxyChain];
|
|
u[ni] = { ...u[ni], host: e.target.value };
|
|
setField("socks5ProxyChain", u);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.proxyPort")}
|
|
</label>
|
|
<Input
|
|
className="h-7 text-xs"
|
|
type="number"
|
|
placeholder="1080"
|
|
value={node.port}
|
|
onChange={(e) => {
|
|
const u = [...form.socks5ProxyChain];
|
|
u[ni] = {
|
|
...u[ni],
|
|
port: Number(e.target.value),
|
|
};
|
|
setField("socks5ProxyChain", u);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.proxyType")}
|
|
</label>
|
|
<Select2
|
|
className="h-7 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
|
|
value={node.type}
|
|
onChange={(e) => {
|
|
const u = [...form.socks5ProxyChain];
|
|
u[ni] = {
|
|
...u[ni],
|
|
type: e.target.value as ProxyNode["type"],
|
|
};
|
|
setField("socks5ProxyChain", u);
|
|
}}
|
|
>
|
|
<option value="socks5">SOCKS5</option>
|
|
<option value="socks4">SOCKS4</option>
|
|
<option value="http">HTTP</option>
|
|
</Select2>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.proxyUsername")}
|
|
</label>
|
|
<Input
|
|
className="h-7 text-xs"
|
|
placeholder={t("hosts.optional")}
|
|
value={node.username}
|
|
onChange={(e) => {
|
|
const u = [...form.socks5ProxyChain];
|
|
u[ni] = {
|
|
...u[ni],
|
|
username: e.target.value,
|
|
};
|
|
setField("socks5ProxyChain", u);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1 col-span-2">
|
|
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.proxyPassword")}
|
|
</label>
|
|
<PasswordInput
|
|
className="h-7 text-xs pr-8"
|
|
placeholder={t("hosts.optional")}
|
|
value={node.password}
|
|
onChange={(e) => {
|
|
const u = [...form.socks5ProxyChain];
|
|
u[ni] = {
|
|
...u[ni],
|
|
password: e.target.value,
|
|
};
|
|
setField("socks5ProxyChain", u);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand self-start"
|
|
onClick={() =>
|
|
setField("socks5ProxyChain", [
|
|
...form.socks5ProxyChain,
|
|
{
|
|
host: "",
|
|
port: 1080,
|
|
type: "socks5",
|
|
username: "",
|
|
password: "",
|
|
},
|
|
])
|
|
}
|
|
>
|
|
<Plus className="size-3 mr-1" /> {t("hosts.addProxyNode")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Connection path visualization */}
|
|
{(form.socks5ProxyMode === "single" && form.socks5Host) ||
|
|
(form.socks5ProxyMode === "chain" &&
|
|
form.socks5ProxyChain.length > 0) ? (
|
|
<div className="flex items-center gap-1 flex-wrap p-2 bg-muted/30 border border-border text-[10px]">
|
|
<span className="px-2 py-0.5 bg-background border border-border text-foreground font-mono">
|
|
{t("hosts.you")}
|
|
</span>
|
|
{form.socks5ProxyMode === "single" && form.socks5Host ? (
|
|
<>
|
|
<span className="text-muted-foreground">→</span>
|
|
<span className="px-2 py-0.5 bg-muted border border-border text-muted-foreground font-mono">
|
|
{form.socks5Host}:{form.socks5Port}
|
|
</span>
|
|
</>
|
|
) : (
|
|
form.socks5ProxyChain
|
|
.filter((n) => n.host)
|
|
.map((n, ni) => (
|
|
<React.Fragment key={ni}>
|
|
<span className="text-muted-foreground">→</span>
|
|
<span className="px-2 py-0.5 bg-muted border border-border text-muted-foreground font-mono">
|
|
{n.host}:{n.port}
|
|
</span>
|
|
</React.Fragment>
|
|
))
|
|
)}
|
|
{form.jumpHosts
|
|
.filter((j) => j.hostId)
|
|
.map((j, ji) => {
|
|
const jh = hosts.find((h) => h.id === j.hostId);
|
|
return jh ? (
|
|
<React.Fragment key={`j${ji}`}>
|
|
<span className="text-muted-foreground">→</span>
|
|
<span className="px-2 py-0.5 bg-muted border border-border text-muted-foreground font-mono">
|
|
{jh.name || jh.ip}
|
|
</span>
|
|
</React.Fragment>
|
|
) : null;
|
|
})}
|
|
<span className="text-muted-foreground">→</span>
|
|
<span className="px-2 py-0.5 bg-accent-brand/10 border border-accent-brand/30 text-accent-brand font-mono">
|
|
{form.ip || "target"}:{form.sshPort}
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
{isElectron() && connectionOriginAppliesTo(protocols) && (
|
|
<SettingRow
|
|
label={t("hosts.connectionOrigin")}
|
|
description={t("hosts.connectionOriginDesc")}
|
|
>
|
|
<select
|
|
className="flex h-7 border border-border bg-background px-2 py-0 text-xs outline-none focus:ring-1 focus:ring-ring"
|
|
value={form.connectionOrigin ?? ""}
|
|
onChange={(e) =>
|
|
setField(
|
|
"connectionOrigin",
|
|
(e.target.value || null) as "local" | "remote" | null,
|
|
)
|
|
}
|
|
>
|
|
<option value="">{t("hosts.connectionOriginDefault")}</option>
|
|
<option value="local">
|
|
{t("hosts.connectionOriginLocal")}
|
|
</option>
|
|
<option value="remote">
|
|
{t("hosts.connectionOriginRemote")}
|
|
</option>
|
|
</select>
|
|
</SettingRow>
|
|
)}
|
|
<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>
|
|
<Select2
|
|
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>
|
|
))}
|
|
</Select2>
|
|
<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>
|
|
|
|
<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.statusCheckEnabled}
|
|
onChange={(value) => setField("statusCheckEnabled", value)}
|
|
/>
|
|
</SettingRow>
|
|
{form.statusCheckEnabled && (
|
|
<SettingRow
|
|
label={t("hosts.useGlobalInterval")}
|
|
description={t("hosts.useGlobalIntervalDesc")}
|
|
>
|
|
<FakeSwitch
|
|
checked={form.statusCheckInterval === null}
|
|
onChange={(useGlobal) =>
|
|
setField("statusCheckInterval", useGlobal ? null : 60)
|
|
}
|
|
/>
|
|
</SettingRow>
|
|
)}
|
|
{form.statusCheckEnabled && form.statusCheckInterval !== null && (
|
|
<SettingRow
|
|
label={t("hosts.checkIntervalS")}
|
|
description={t("hosts.checkIntervalDesc")}
|
|
>
|
|
<Input
|
|
type="number"
|
|
min={5}
|
|
max={86400}
|
|
value={form.statusCheckInterval}
|
|
onChange={(e) =>
|
|
setField("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.connectionToolbar")}
|
|
icon={<LayoutGrid className="size-3.5" />}
|
|
>
|
|
<div className="flex flex-col gap-4 py-3">
|
|
<SettingRow
|
|
label={t("hosts.showConnectionToolbar")}
|
|
description={t("hosts.showConnectionToolbarDesc")}
|
|
>
|
|
<FakeSwitch
|
|
checked={form.enableTerminalToolbar}
|
|
onChange={(value) => setField("enableTerminalToolbar", value)}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("hosts.enableAiAssistant")}
|
|
description={t("hosts.enableAiAssistantDesc")}
|
|
>
|
|
<FakeSwitch
|
|
checked={form.enableAiAssistant}
|
|
onChange={(value) => setField("enableAiAssistant", value)}
|
|
/>
|
|
</SettingRow>
|
|
</div>
|
|
</SectionCard>
|
|
</>
|
|
);
|
|
}
|