feat: redesign user host defaults system

This commit is contained in:
LukeGus committed 2026-09-30 06:19:56 -05:00
1 parent 3028ab1176
commit a4a4d4f27b
144 files changed
+24609 -2555

No files matched your search

-20
View File
@@ -37,13 +37,10 @@ import {
getUserRoles,
} from "@/main-axios";
import {
getHostDefaults,
updateHostDefaults,
getAnalyticsEnabled,
updateAnalyticsEnabled,
getBranding,
updateBranding,
type HostDefaults,
type BrandingSettings,
} from "@/api/settings-api";
import {
@@ -128,7 +125,6 @@ export function AdminSettingsPanel({
const [analyticsLocked, setAnalyticsLocked] = useState(false);
const [notificationPrivateEndpoints, setNotificationPrivateEndpoints] =
useState<string[]>([]);
const [hostDefaults, setHostDefaults] = useState<HostDefaults>({});
const [brandingSettings, setBrandingSettings] =
useState<BrandingSettings | null>(null);
@@ -341,10 +337,6 @@ export function AdminSettingsPanel({
// non-fatal
}
getHostDefaults()
.then((d) => setHostDefaults(d))
.catch(() => {});
getTlsStatus()
.then((s) => setTlsStatus(s))
.catch(() => {});
@@ -359,15 +351,6 @@ export function AdminSettingsPanel({
});
}
async function handleSaveHostDefaults() {
try {
await updateHostDefaults(hostDefaults);
toast.success(t("admin.hostDefaultsSaved"));
} catch {
toast.error(t("admin.hostDefaultsSaveFailed"));
}
}
async function handleToggleRegistration() {
const newVal = !allowRegistration;
setAllowRegistration(newVal);
@@ -881,9 +864,6 @@ export function AdminSettingsPanel({
<AdminHostDefaultsSection
open={openSections.has("host-defaults")}
onToggle={() => toggle("host-defaults")}
defaults={hostDefaults}
setDefaults={setHostDefaults}
handleSaveDefaults={handleSaveHostDefaults}
/>
<AdminBrandingSection
+10 -111
View File
@@ -2,11 +2,9 @@ import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { SettingRow } from "@/components/section-card";
import { Database, Lock, RefreshCw, Server, Settings } from "lucide-react";
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
import type { HostDefaults } from "@/api/settings-api";
import type { TlsStatus } from "@/api/tls-api";
type GeneralSettingsSectionProps = {
@@ -338,17 +336,11 @@ export function AdminDatabaseSection({
type AdminHostDefaultsSectionProps = {
open: boolean;
onToggle: () => void;
defaults: HostDefaults;
setDefaults: Dispatch<SetStateAction<HostDefaults>>;
handleSaveDefaults: () => void;
};
export function AdminHostDefaultsSection({
open,
onToggle,
defaults,
setDefaults,
handleSaveDefaults,
}: AdminHostDefaultsSectionProps) {
const { t } = useTranslation();
@@ -359,116 +351,23 @@ export function AdminHostDefaultsSection({
open={open}
onToggle={onToggle}
>
<div className="flex flex-col gap-4 pt-3">
<div className="flex flex-col gap-3 pt-3">
<span className="text-[10px] text-muted-foreground">
{t("admin.hostDefaultsDesc")}
{t("hostDefaults.adminSectionDescription")}
</span>
<div className="flex flex-col gap-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.hostDefaultsSocks5")}
</span>
<SettingRow
label={t("admin.hostDefaultsUseSocks5")}
description={t("admin.hostDefaultsUseSocks5Desc")}
>
<AdminToggle
on={defaults.useSocks5 ?? false}
onToggle={() =>
setDefaults((p) => ({ ...p, useSocks5: !p.useSocks5 }))
}
/>
</SettingRow>
{defaults.useSocks5 && (
<div className="flex flex-col gap-3 ml-4">
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.hostDefaultsSocks5Host")}
</label>
<div className="flex gap-2">
<Input
value={defaults.socks5Host ?? ""}
onChange={(e) =>
setDefaults((p) => ({ ...p, socks5Host: e.target.value }))
}
placeholder="127.0.0.1"
className="text-xs"
/>
<Input
type="number"
value={defaults.socks5Port ?? 1080}
onChange={(e) =>
setDefaults((p) => ({
...p,
socks5Port: Number(e.target.value),
}))
}
placeholder="1080"
className="text-xs w-24 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.hostDefaultsSocks5Username")}
</label>
<Input
value={defaults.socks5Username ?? ""}
onChange={(e) =>
setDefaults((p) => ({
...p,
socks5Username: e.target.value,
}))
}
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.hostDefaultsSocks5Password")}
</label>
<PasswordInput
value={defaults.socks5Password ?? ""}
onChange={(e) =>
setDefaults((p) => ({
...p,
socks5Password: e.target.value,
}))
}
className="h-8 text-xs pr-8"
/>
</div>
</div>
)}
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.hostDefaultsStatus")}
</span>
<SettingRow
label={t("admin.hostDefaultsStatusCheckEnabled")}
description={t("admin.hostDefaultsStatusCheckEnabledDesc")}
>
<AdminToggle
on={defaults.statusCheckEnabled ?? true}
onToggle={() =>
setDefaults((p) => ({
...p,
statusCheckEnabled: !(p.statusCheckEnabled ?? true),
}))
}
/>
</SettingRow>
</div>
<Button
variant="outline"
size="sm"
className="self-start text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={handleSaveDefaults}
onClick={() =>
window.dispatchEvent(
new CustomEvent("termix:open-host-defaults", {
detail: { level: "admin" },
}),
)
}
>
{t("common.save")}
{t("hostDefaults.editServerDefaults")}
</Button>
</div>
</AccordionSection>
File diff suppressed because it is too large. Load diff
+18 -19
View File
@@ -6,7 +6,7 @@ import {
type HostProtocolAuthForm,
type HostProtocolAuthSummary,
} from "@termix/plugin-sdk/frontend";
import type { HostDefaults } from "@/api/settings-api";
import type { DefaultOverrides } from "@/types/host-defaults";
import {
listHostProtocols,
protocolPort,
@@ -109,11 +109,7 @@ export function protocolAuthPayload(
return payload;
}
export function createHostEditorForm(
host: Host | null,
defaults?: HostDefaults,
) {
const d = host ? undefined : defaults;
export function createHostEditorForm(host: Host | null) {
const sshOptions = host?.sshOptions ?? {};
return {
@@ -130,12 +126,7 @@ export function createHostEditorForm(
: (host?.keyPassword ?? ""),
keyType: host?.keyType ?? "auto",
keySubTab: "paste" as "paste" | "upload",
credentialId:
host?.credentialId != null
? String(host.credentialId)
: d?.credentialId != null
? String(d.credentialId)
: "",
credentialId: host?.credentialId != null ? String(host.credentialId) : "",
overrideCredentialUsername: host?.overrideCredentialUsername ?? false,
folder: host?.folder ?? "",
parentHostId: host?.parentHostId ?? "",
@@ -143,11 +134,11 @@ export function createHostEditorForm(
tagInput: "",
notes: host?.notes ?? "",
pin: host?.pin ?? false,
useSocks5: host?.useSocks5 ?? d?.useSocks5 ?? false,
socks5Host: host?.socks5Host ?? d?.socks5Host ?? "",
socks5Port: host?.socks5Port ?? d?.socks5Port ?? 1080,
socks5Username: host?.socks5Username ?? d?.socks5Username ?? "",
socks5Password: host?.socks5Password ?? d?.socks5Password ?? "",
useSocks5: host?.useSocks5 ?? false,
socks5Host: host?.socks5Host ?? "",
socks5Port: host?.socks5Port ?? 1080,
socks5Username: host?.socks5Username ?? "",
socks5Password: host?.socks5Password ?? "",
socks5ProxyMode: ((host?.socks5ProxyChain ?? []).length > 0
? "chain"
: "single") as "single" | "chain",
@@ -174,8 +165,7 @@ export function createHostEditorForm(
([] as { port: number; protocol: "tcp" | "udp"; delay: number }[]),
// Each plugin protocol's login, edited by that plugin's host editor tab.
protocolAuth: protocolAuthForm(host?.protocolAuth),
statusCheckEnabled:
host?.statusCheckEnabled ?? d?.statusCheckEnabled ?? true,
statusCheckEnabled: host?.statusCheckEnabled ?? true,
statusCheckInterval: (host?.statusCheckInterval ?? null) as number | null,
// Host-scope plugin settings, keyed by plugin id. Loaded with the host and
@@ -184,6 +174,12 @@ export function createHostEditorForm(
string,
Record<string, unknown>
>,
// Host default keys the host sets itself; the rest follow its defaults.
// Null until known, for a host saved before host defaults.
defaultOverrides: (host
? (host.defaultOverrides ?? null)
: {}) as DefaultOverrides | null,
};
}
@@ -290,6 +286,9 @@ export function buildHostEditorPayload(
portKnockSequence: form.portKnockSequence,
statusCheckEnabled: form.statusCheckEnabled,
statusCheckInterval: form.statusCheckInterval,
...(form.defaultOverrides
? { defaultOverrides: form.defaultOverrides }
: {}),
// Left out with SSH off, so the stored values stay as they are.
...(protocols.enableSsh
? {
+335 -311
View File
@@ -15,6 +15,11 @@ import { useSyncStatus } from "@/hooks/use-sync-status";
import type { HostEditorForm, HostProtocols } from "./HostEditorData";
import { Select2 } from "@/components/select2";
import { useHostProtocols } from "./host-protocols";
import {
DefaultsOnly,
HostDefaultBadge,
HostOnly,
} from "@/lib/host-defaults-context";
type HostEditorSetField = <K extends keyof HostEditorForm>(
key: K,
@@ -102,235 +107,242 @@ export function HostEditorGeneralTab({
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"}`}
>
<HostOnly>
{/* 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
className={`size-8 flex items-center justify-center shrink-0 ${enabled ? "text-accent-brand" : "text-muted-foreground/30"}`}
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"}`}
>
{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"}`}
<div
className={`size-8 flex items-center justify-center shrink-0 ${enabled ? "text-accent-brand" : "text-muted-foreground/30"}`}
>
{label}
</span>
<span className="text-[10px] text-muted-foreground/50">
{desc}
</span>
{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>
<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>
</SectionCard>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<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.friendlyName")}
{t("hosts.addressIp")}
</label>
<Input
placeholder="e.g. Web Server Production"
value={form.name}
onChange={(e) => setField("name", e.target.value)}
placeholder="10.0.0.1 or example.com"
value={form.ip}
onChange={(e) => setField("ip", e.target.value)}
/>
</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>
)}
<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>
</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>
)}
</HostOnly>
<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),
)
<HostOnly>
<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-muted-foreground hover:text-destructive ml-0.5"
}}
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"
>
<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));
}
}}
{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>
</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>
{syncLinked && (
<SettingRow
label={t("hosts.localOnly")}
description={t("hosts.localOnlyDesc")}
label={t("hosts.pinToTop")}
description={t("hosts.pinToTopDesc")}
>
<FakeSwitch
checked={form.localOnly}
onChange={(v) => setField("localOnly", v)}
checked={form.pin}
onChange={(v) => setField("pin", v)}
/>
</SettingRow>
)}
</div>
{syncLinked && (
<SettingRow
label={t("hosts.localOnly")}
description={t("hosts.localOnlyDesc")}
>
<FakeSwitch
checked={form.localOnly}
onChange={(v) => setField("localOnly", v)}
/>
</SettingRow>
)}
</div>
</HostOnly>
<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>
<HostDefaultBadge settingKey="core.portKnockSequence" />
<a
href="https://docs.termix.site/features/networking/port-knocking"
target="_blank"
@@ -445,6 +457,7 @@ export function HostEditorGeneralTab({
<SettingRow
label={t("hosts.useSocks5Proxy")}
description={t("hosts.useSocks5ProxyDesc")}
defaultKey="core.socks5"
>
<FakeSwitch
checked={form.useSocks5}
@@ -509,19 +522,21 @@ export function HostEditorGeneralTab({
}
/>
</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>
<HostOnly>
<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>
</HostOnly>
</div>
)}
@@ -625,24 +640,26 @@ export function HostEditorGeneralTab({
}}
/>
</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>
<HostOnly>
<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>
</HostOnly>
</div>
</div>
))}
@@ -717,95 +734,100 @@ export function HostEditorGeneralTab({
</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,
)
}
<HostOnly>
<SettingRow
label={t("hosts.connectionOrigin")}
description={t("hosts.connectionOriginDesc")}
>
<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"
<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,
)
}
>
<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.connectionOriginDefault")}</option>
<option value="local">
{t("hosts.connectionOriginLocal")}
</option>
<option value="remote">
{t("hosts.connectionOriginRemote")}
</option>
</select>
</SettingRow>
</HostOnly>
)}
<DefaultsOnly settingKey="core.jumpHosts">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.jumpHostChainLabel")}
<HostDefaultBadge settingKey="core.jumpHosts" />
</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"
>
<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>
))}
<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>
</DefaultsOnly>
</div>
</SectionCard>
@@ -817,6 +839,7 @@ export function HostEditorGeneralTab({
<SettingRow
label={t("hosts.enableStatusChecks")}
description={t("hosts.enableStatusChecksDesc")}
defaultKey="core.statusCheckEnabled"
>
<FakeSwitch
checked={form.statusCheckEnabled}
@@ -827,6 +850,7 @@ export function HostEditorGeneralTab({
<SettingRow
label={t("hosts.useGlobalInterval")}
description={t("hosts.useGlobalIntervalDesc")}
defaultKey="core.statusCheckInterval"
>
<FakeSwitch
checked={form.statusCheckInterval === null}
+46 -1
View File
@@ -11,6 +11,12 @@ import React, {
type MutableRefObject,
} from "react";
import { useTranslation } from "react-i18next";
import type { HostDefaultsLevel } from "@/types/host-defaults";
import { getFolderDefaultsId } from "@/api/host-defaults-api";
import {
HostDefaultsEditorView,
type HostDefaultsEditorTarget,
} from "./host-defaults/HostDefaultsEditorView";
import type { HostDraft } from "@termix/plugin-sdk/frontend";
import { CredentialShareModal } from "./CredentialShareModal";
@@ -83,6 +89,9 @@ export function HostManager({
useHostEditorSections();
const { t } = useTranslation();
const [editingHost, setEditingHost] = useState<Host | "new" | null>(null);
// A level of host defaults open in the editor instead of a host.
const [editingDefaults, setEditingDefaults] =
useState<HostDefaultsEditorTarget | null>(null);
// Fields a plugin filled in for a new host, keyed so a second draft remounts
// the editor.
const [hostDraft, setHostDraft] = useState<{
@@ -223,6 +232,25 @@ export function HostManager({
setEditingHost(null);
setActiveCredentialTab("general");
};
const handleEditDefaults = (e: Event) => {
const detail = (
e as CustomEvent<{ level: HostDefaultsLevel; folderName?: string }>
).detail;
if (!detail) return;
const open = (target: HostDefaultsEditorTarget) => {
setEditingHost(null);
setEditingCredential(null);
setEditingDefaults(target);
};
if (detail.level === "folder" && detail.folderName) {
const folderName = detail.folderName;
getFolderDefaultsId(folderName)
.then((folderId) => open({ level: "folder", folderId, folderName }))
.catch(() => toast.error(t("hostDefaults.loadFailed")));
} else if (detail.level !== "folder") {
open({ level: detail.level });
}
};
const handleEditHost = (e: Event) => {
const id = (e as CustomEvent<string>).detail;
const host = hostsRef.current.find((h) => h.id === id);
@@ -236,7 +264,12 @@ export function HostManager({
window.addEventListener("host-manager:add-host", handleAddHost);
window.addEventListener("host-manager:add-credential", handleAddCredential);
window.addEventListener("host-manager:edit-host", handleEditHost);
window.addEventListener("host-manager:edit-defaults", handleEditDefaults);
return () => {
window.removeEventListener(
"host-manager:edit-defaults",
handleEditDefaults,
);
window.removeEventListener("host-manager:add-host", handleAddHost);
window.removeEventListener(
"host-manager:add-credential",
@@ -386,6 +419,7 @@ export function HostManager({
setHostEditorDirty(false);
setShowUnsavedHostDialog(false);
setEditingHost(null);
setEditingDefaults(null);
setActiveHostTab("general");
};
@@ -398,6 +432,17 @@ export function HostManager({
};
const renderEditorView = () => {
if (editingDefaults) {
return (
<HostDefaultsEditorView
target={editingDefaults}
onClose={requestCloseHostEditor}
onDirtyChange={setHostEditorDirty}
hosts={hosts}
credentials={credentials}
/>
);
}
const isHost = !!editingHost;
// Simple mode keeps General and SSH -- between them they hold everything
// needed to reach a host (name, address, username, auth) -- and hides the
@@ -657,7 +702,7 @@ export function HostManager({
);
};
const isEditing = !!editingHost || !!editingCredential;
const isEditing = !!editingHost || !!editingCredential || !!editingDefaults;
useEffect(() => {
if (active) onEditingChange?.(isEditing);
+1
View File
@@ -80,6 +80,7 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
(h.connectionType === "ssh" || !h.connectionType ? h.port : 22),
protocolAuth: h.protocolAuth ?? {},
pluginSettings: h.pluginSettings ?? {},
defaultOverrides: h.defaultOverrides ?? null,
jumpHosts: (parseJson<HostJumpHost[]>(h.jumpHosts) ?? []).map((j) => ({
hostId: String(j.hostId ?? j.hostid ?? j),
})),
+11 -1
View File
@@ -32,6 +32,8 @@ export interface HostEditorSectionRenderProps {
credentials?: unknown[];
adminTargetUserId?: string;
protocols: Record<string, boolean>;
/** "defaults" in the host defaults editor. */
mode?: "host" | "defaults";
}
/**
@@ -50,6 +52,8 @@ export interface HostEditorSectionDef {
order?: number;
/** Whether to offer the tab for these protocols. Defaults to always. */
visible?: (protocols: Record<string, boolean>) => boolean;
/** Also offered in the host defaults editor; "only" for nowhere else. */
defaults?: boolean | "only";
component: ComponentType<HostEditorSectionRenderProps>;
}
@@ -86,13 +90,15 @@ function mergeByOrder(
group: "top" | "ssh",
t: (key: string) => string,
protocols: Record<string, boolean> | undefined,
defaultsMode = false,
): HostTab[] {
const registered = sections
.list()
.filter(
(section) =>
section.group === group &&
(!protocols || sectionVisible(section, protocols)),
(defaultsMode ? !!section.defaults : section.defaults !== "only") &&
(defaultsMode || !protocols || sectionVisible(section, protocols)),
)
.map((section) => {
const Icon = section.icon;
@@ -115,6 +121,7 @@ function mergeByOrder(
export function makeHostTabs(
t: (key: string) => string,
protocols?: Record<string, boolean>,
defaultsMode = false,
): HostTab[] {
return mergeByOrder(
[
@@ -134,12 +141,14 @@ export function makeHostTabs(
"top",
t,
protocols,
defaultsMode,
);
}
export function makeHostSshSubTabs(
t: (key: string) => string,
protocols?: Record<string, boolean>,
defaultsMode = false,
): HostTab[] {
return mergeByOrder(
[
@@ -153,6 +162,7 @@ export function makeHostSshSubTabs(
"ssh",
t,
protocols,
defaultsMode,
);
}
+13
View File
@@ -19,6 +19,7 @@ import {
Plus,
RefreshCw,
Search,
Settings2,
SlidersHorizontal,
Upload,
X,
@@ -927,6 +928,18 @@ export function HostsPanel({
? t("hosts.exitSelectionTitle")
: t("hosts.selectHosts")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
window.dispatchEvent(
new CustomEvent("host-manager:edit-defaults", {
detail: { level: "user" },
}),
)
}
>
<Settings2 className="size-3.5 mr-2" />
{t("hostDefaults.menuMyDefaults")}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="flex items-center gap-2">
<Upload className="size-3.5 shrink-0" />
+30 -12
View File
@@ -6,6 +6,11 @@ import { Input } from "@/components/input";
import type { Host } from "@/types/ui-types";
import { getCredentials } from "@/api/credentials-api";
import { mapCredentials } from "./HostManagerData";
import { resolveHostDefaults } from "@/api/host-defaults-api";
import {
pluginSettingsFrom,
withDefaultPluginSettings,
} from "./host-defaults/quick-connect-defaults";
import {
createQuickConnectHost,
quickConnectTargets,
@@ -88,19 +93,32 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
setProtocol(next);
};
// A host that is never saved still follows the user's host defaults.
const [defaultPluginSettings, setDefaultPluginSettings] = useState<
Record<string, Record<string, unknown>>
>({});
useEffect(() => {
resolveHostDefaults({ folder: null })
.then((values) => setDefaultPluginSettings(pluginSettingsFrom(values)))
.catch(() => {});
}, []);
const buildHost = () =>
createQuickConnectHost({
ip: host.trim(),
port: parseInt(port) || parseInt(defaultPort(protocol)),
username,
authType: isDesktop ? "password" : authType,
password,
key: privateKey,
credentialId,
protocol: selected,
domain: domain || undefined,
authFields: INLINE_AUTH_TYPES.has(authType) ? undefined : authFields,
});
withDefaultPluginSettings(
createQuickConnectHost({
ip: host.trim(),
port: parseInt(port) || parseInt(defaultPort(protocol)),
username,
authType: isDesktop ? "password" : authType,
password,
key: privateKey,
credentialId,
protocol: selected,
domain: domain || undefined,
authFields: INLINE_AUTH_TYPES.has(authType) ? undefined : authFields,
}),
defaultPluginSettings,
);
const targets = quickConnectTargets(allActions, buildHost());
@@ -0,0 +1,125 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowLeft } from "lucide-react";
import type { Host } from "@/types/ui-types";
import type { HostDefaultsTarget } from "@/api/host-defaults-api";
import { HostEditor } from "../HostEditor";
import {
isSshGroupTab,
makeHostSshSubTabs,
makeHostTabs,
TabStrip,
useHostEditorSections,
} from "../HostManagerTabs";
import { listHostProtocols, type HostProtocols } from "../host-protocols";
export interface HostDefaultsEditorTarget extends HostDefaultsTarget {
/** The folder's path, shown as the title. */
folderName?: string;
}
/**
* The host editor as a form for one level of host defaults: the same tabs
* and fields as a new host, minus what only one host can have (its name,
* address, secrets, folder).
*/
export function HostDefaultsEditorView({
target,
onClose,
onDirtyChange,
hosts,
credentials,
}: {
target: HostDefaultsEditorTarget;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
hosts: Host[];
credentials: { id: string; name: string; username: string }[];
}) {
const { t } = useTranslation();
useHostEditorSections();
const [activeTab, setActiveTab] = useState("general");
// Every protocol counts as on, so every tab that has defaults shows.
const protocols = useMemo<HostProtocols>(() => {
const all: HostProtocols = { enableSsh: true };
for (const protocol of listHostProtocols()) all[protocol.settingKey] = true;
return all;
}, []);
const flags = protocols as unknown as Record<string, boolean>;
const tabs = makeHostTabs(t, flags, true);
const sshSubTabs = makeHostSshSubTabs(t, flags, true);
const inSshGroup = isSshGroupTab(activeTab);
const title =
target.level === "admin"
? t("hostDefaults.titleAdmin")
: target.level === "user"
? t("hostDefaults.titleUser")
: t("hostDefaults.titleFolder", { name: target.folderName ?? "" });
const description =
target.level === "admin"
? t("hostDefaults.descriptionAdmin")
: target.level === "user"
? t("hostDefaults.descriptionUser")
: t("hostDefaults.descriptionFolder");
return (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex flex-col shrink-0 border-b border-border">
<button
onClick={onClose}
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>{t("hosts.backToHosts")}</span>
<span
className="ml-auto font-semibold text-foreground truncate max-w-[220px]"
title={title}
>
{title}
</span>
</button>
<TabStrip
tabs={tabs}
activeTab={activeTab}
onTabChange={(id) => {
if (id === "ssh") {
if (!inSshGroup) setActiveTab("ssh");
} else {
setActiveTab(id);
}
}}
isActive={(id) => (id === "ssh" ? inSshGroup : activeTab === id)}
/>
{inSshGroup && (
<TabStrip
tabs={sshSubTabs}
activeTab={activeTab}
onTabChange={setActiveTab}
variant="secondary"
/>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-3">
<p className="text-xs text-muted-foreground border border-border bg-muted/20 px-3 py-2">
{description}
</p>
<HostEditor
key={`${target.level}:${target.folderId ?? ""}`}
host={null}
activeTab={activeTab}
onBack={onClose}
onSave={onClose}
protocols={protocols}
onProtocolChange={() => {}}
onDirtyChange={onDirtyChange}
onTabChange={setActiveTab}
hosts={hosts}
credentials={credentials}
defaultsTarget={target}
/>
</div>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
/**
* The keys the host editor can show a default for: core's own, and every
* running plugin's host fields that can have one.
*/
import { useEffect, useMemo, useState } from "react";
import {
CORE_HOST_DEFAULTS,
CORE_NAMESPACE,
HOST_DEFAULTS_LEVELS,
type HostDefaultsLevel,
} from "@/types/host-defaults";
import {
getPlugins,
type PluginSettingsField,
type PluginSummary,
} from "@/api/plugins-api";
export interface EditorDefaultKey {
fullKey: string;
namespace: string;
key: string;
levels: readonly HostDefaultsLevel[];
}
export function isDefaultableField(field: PluginSettingsField): boolean {
if (field.type === "secret") return false;
if (field.type === "json" && field.secretKeys?.length) return false;
return field.defaultable !== false;
}
export function pluginDefaultKeys(
plugins: PluginSummary[],
): EditorDefaultKey[] {
const keys: EditorDefaultKey[] = [];
for (const plugin of plugins) {
if (!plugin.enabled) continue;
const host = plugin.contributes?.settings?.host;
if (!host) continue;
if (host.enableKey) {
keys.push({
fullKey: `${plugin.id}.${host.enableKey}`,
namespace: plugin.id,
key: host.enableKey,
levels: HOST_DEFAULTS_LEVELS,
});
}
for (const field of host.fields) {
if (!isDefaultableField(field)) continue;
keys.push({
fullKey: `${plugin.id}.${field.key}`,
namespace: plugin.id,
key: field.key,
levels: field.defaultLevels?.length
? field.defaultLevels
: HOST_DEFAULTS_LEVELS,
});
}
}
return keys;
}
export const CORE_EDITOR_KEYS: EditorDefaultKey[] = CORE_HOST_DEFAULTS.map(
(entry) => ({
fullKey: `${CORE_NAMESPACE}.${entry.key}`,
namespace: CORE_NAMESPACE,
key: entry.key,
levels: entry.levels,
}),
);
/** Every key, by full key. Refreshed when the plugin list is. */
export function useEditorDefaultKeys(): Map<string, EditorDefaultKey> {
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
useEffect(() => {
let cancelled = false;
void getPlugins()
.then((loaded) => {
if (!cancelled) setPlugins(loaded);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
return useMemo(
() =>
new Map(
[...CORE_EDITOR_KEYS, ...pluginDefaultKeys(plugins)].map((entry) => [
entry.fullKey,
entry,
]),
),
[plugins],
);
}
@@ -0,0 +1,294 @@
/**
* Between the host editor's form and host default keys: what value a key has
* on the form, and how to put a default's value back on it.
*
* Core keys use the API's shape ("core.auth" is an object, "core.socks5"
* too); plugin keys are the plugin's own field values.
*/
import {
CORE_NAMESPACE,
CORE_HOST_DEFAULTS,
defaultValuesEqual,
findCoreHostDefault,
splitDefaultKey,
type DefaultOverrides,
} from "@/types/host-defaults";
import type { HostEditorForm } from "../HostEditorData";
type Form = HostEditorForm;
type PluginValues = Record<string, Record<string, unknown>>;
export const CORE_DEFAULT_KEYS = CORE_HOST_DEFAULTS.map(
(entry) => `${CORE_NAMESPACE}.${entry.key}`,
);
function coreValue(form: Form, key: string): unknown {
switch (key) {
case "username":
return form.username;
case "sshPort":
return form.sshPort;
case "auth":
return {
authType: form.authType,
credentialId: form.credentialId ? Number(form.credentialId) : null,
overrideCredentialUsername: form.overrideCredentialUsername,
agentSocketPath: form.agentSocketPath || null,
agentIdentity: form.agentIdentity || null,
};
case "forceKeyboardInteractive":
case "keepaliveInterval":
case "keepaliveCountMax":
case "allowLegacyAlgorithms":
case "agentForwarding":
case "environmentVariables":
case "portKnockSequence":
case "statusCheckEnabled":
case "statusCheckInterval":
return form[key as keyof Form];
case "socks5":
return {
useSocks5: form.useSocks5,
socks5Host:
form.socks5ProxyMode === "single" ? form.socks5Host || null : null,
socks5Port:
form.socks5ProxyMode === "single" ? form.socks5Port || null : null,
socks5Username:
form.socks5ProxyMode === "single"
? form.socks5Username || null
: null,
socks5ProxyChain:
form.socks5ProxyMode === "chain" ? form.socks5ProxyChain : null,
};
case "jumpHosts":
return form.jumpHosts.map((jump) => ({ hostId: Number(jump.hostId) }));
default:
return undefined;
}
}
/** A key's value as the form holds it, normalized for core keys. */
export function formValueForKey(form: Form, fullKey: string): unknown {
const [namespace, key] = splitDefaultKey(fullKey);
if (namespace === CORE_NAMESPACE) {
const entry = findCoreHostDefault(key);
return entry ? entry.normalize(coreValue(form, key)) : undefined;
}
return (form.pluginSettings as PluginValues)?.[namespace]?.[key];
}
/** The form with one key set to a default's value. */
export function applyDefaultToForm(
form: Form,
fullKey: string,
value: unknown,
): Form {
const [namespace, key] = splitDefaultKey(fullKey);
if (namespace !== CORE_NAMESPACE) {
const all = (form.pluginSettings ?? {}) as PluginValues;
return {
...form,
pluginSettings: {
...all,
[namespace]: { ...(all[namespace] ?? {}), [key]: value },
},
};
}
const entry = findCoreHostDefault(key);
const normalized = entry ? entry.normalize(value) : value;
switch (key) {
case "username":
return { ...form, username: (normalized as string | null) ?? "" };
case "sshPort":
return { ...form, sshPort: normalized as number };
case "auth": {
const auth = normalized as {
authType: string;
credentialId: number | null;
overrideCredentialUsername: boolean;
agentSocketPath: string | null;
agentIdentity: string | null;
} | null;
if (!auth) return form;
return {
...form,
authType: auth.authType as Form["authType"],
credentialId:
auth.credentialId !== null ? String(auth.credentialId) : "",
overrideCredentialUsername: auth.overrideCredentialUsername,
agentSocketPath: auth.agentSocketPath ?? "",
agentIdentity: auth.agentIdentity ?? "",
};
}
case "socks5": {
const socks = normalized as {
useSocks5: boolean;
socks5Host: string | null;
socks5Port: number | null;
socks5Username: string | null;
socks5ProxyChain: Form["socks5ProxyChain"] | null;
};
const chain = socks.socks5ProxyChain ?? [];
return {
...form,
useSocks5: socks.useSocks5,
socks5Host: socks.socks5Host ?? "",
socks5Port: socks.socks5Port ?? 1080,
socks5Username: socks.socks5Username ?? "",
socks5ProxyMode: chain.length > 0 ? "chain" : "single",
socks5ProxyChain: chain.map((node, index) => ({
...node,
// The host's own proxy passwords stay with it.
...(form.socks5ProxyChain[index]?.password
? { password: form.socks5ProxyChain[index].password }
: {}),
})),
};
}
case "jumpHosts":
return {
...form,
jumpHosts: (normalized as Array<{ hostId: number }>).map((jump) => ({
hostId: String(jump.hostId),
})),
};
default:
return { ...form, [key]: normalized } as Form;
}
}
/** Keys whose value differs between two forms. */
export function changedKeys(
before: Form,
after: Form,
keys: Iterable<string>,
): string[] {
const changed: string[] = [];
for (const fullKey of keys) {
if (
!defaultValuesEqual(
formValueForKey(before, fullKey),
formValueForKey(after, fullKey),
)
) {
changed.push(fullKey);
}
}
return changed;
}
export function withOwnKeys(
overrides: DefaultOverrides,
keys: string[],
): DefaultOverrides {
if (keys.length === 0) return overrides;
const next: DefaultOverrides = { ...overrides };
for (const fullKey of keys) {
const [namespace, key] = splitDefaultKey(fullKey);
const own = new Set(next[namespace] ?? []);
own.add(key);
next[namespace] = [...own].sort();
}
return next;
}
export function withoutOwnKey(
overrides: DefaultOverrides,
fullKey: string,
): DefaultOverrides {
const [namespace, key] = splitDefaultKey(fullKey);
return {
...overrides,
[namespace]: (overrides[namespace] ?? []).filter((own) => own !== key),
};
}
export function isOwnDefault(
overrides: DefaultOverrides | null | undefined,
fullKey: string,
): boolean {
const [namespace, key] = splitDefaultKey(fullKey);
return !!overrides?.[namespace]?.includes(key);
}
/** Every key the overrides mark, as "namespace.key". */
export function ownDefaultKeys(
overrides: DefaultOverrides | null | undefined,
): string[] {
return Object.entries(overrides ?? {}).flatMap(([namespace, keys]) =>
keys.map((key) => `${namespace}.${key}`),
);
}
function isEmpty(value: unknown): boolean {
if (value === undefined || value === null || value === "") return true;
return Array.isArray(value) && value.length === 0;
}
/**
* Which keys a form's values make its own: those that differ from what
* resolves, and those with a value where nothing resolves.
*/
export function classifyForm(
form: Form,
resolved: Record<string, { value: unknown }>,
keys: Iterable<string>,
): DefaultOverrides {
const overrides: DefaultOverrides = { [CORE_NAMESPACE]: [] };
for (const fullKey of keys) {
const [namespace, key] = splitDefaultKey(fullKey);
overrides[namespace] ??= [];
const value = formValueForKey(form, fullKey);
const entry = resolved[fullKey];
const own = entry
? !defaultValuesEqual(value, entry.value)
: !isEmpty(value);
if (own) overrides[namespace].push(key);
}
return overrides;
}
/** Every namespace the editor knows is listed, so none is left to guess. */
export function withAllNamespaces(
overrides: DefaultOverrides,
keys: Map<string, { namespace: string }>,
): DefaultOverrides {
const next: DefaultOverrides = { [CORE_NAMESPACE]: [], ...overrides };
for (const entry of keys.values()) next[entry.namespace] ??= [];
return next;
}
/**
* The plugin values a save sends: a host's own values, and every value no
* default covers (a protocol switch). The server writes the rest.
*/
export function ownPluginValues(
values: PluginValues | undefined,
overrides: DefaultOverrides | null,
keys: Map<string, unknown>,
): PluginValues | undefined {
if (!values || overrides === null) return values;
const result: PluginValues = {};
for (const [pluginId, fields] of Object.entries(values)) {
const kept = Object.fromEntries(
Object.entries(fields ?? {}).filter(([key]) => {
const fullKey = `${pluginId}.${key}`;
return !keys.has(fullKey) || isOwnDefault(overrides, fullKey);
}),
);
if (Object.keys(kept).length > 0) result[pluginId] = kept;
}
return result;
}
/** A plugin's draft for a new host makes what it filled in the host's own. */
export function draftOwnKeys(
draft: { port?: unknown; username?: unknown; authType?: unknown } | undefined,
): DefaultOverrides {
const own: string[] = [];
if (draft?.port !== undefined) own.push("sshPort");
if (draft?.username !== undefined) own.push("username");
if (draft?.authType !== undefined) own.push("auth");
return { [CORE_NAMESPACE]: own };
}
@@ -0,0 +1,34 @@
import {
CORE_NAMESPACE,
splitDefaultKey,
type ResolvedHostDefault,
} from "@/types/host-defaults";
import type { Host } from "@/types/ui-types";
type PluginValues = Record<string, Record<string, unknown>>;
/** The plugin keys of a resolved set, in the host payload's shape. */
export function pluginSettingsFrom(
resolved: Record<string, ResolvedHostDefault>,
): PluginValues {
const result: PluginValues = {};
for (const [fullKey, entry] of Object.entries(resolved)) {
const [namespace, key] = splitDefaultKey(fullKey);
if (namespace === CORE_NAMESPACE) continue;
result[namespace] = { ...(result[namespace] ?? {}), [key]: entry.value };
}
return result;
}
/** A quick connect host with the defaults under whatever it set itself. */
export function withDefaultPluginSettings(
host: Host,
defaults: PluginValues,
): Host {
const own = host.pluginSettings ?? {};
const merged: PluginValues = { ...defaults };
for (const [pluginId, values] of Object.entries(own)) {
merged[pluginId] = { ...(defaults[pluginId] ?? {}), ...values };
}
return { ...host, pluginSettings: merged };
}
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { FolderOpen, Pencil, Share2, Trash2 } from "lucide-react";
import { FolderOpen, Pencil, Settings2, Share2, Trash2 } from "lucide-react";
import type { HostFolder } from "@/types/ui-types";
export function FolderActions({
@@ -44,6 +44,24 @@ export function FolderActions({
<Share2 className="size-3" />
</button>
)}
<button
type="button"
title={t("hostDefaults.folderDefaultsAction")}
className={actionButtonClass}
onClick={(e) => {
e.stopPropagation();
window.dispatchEvent(
new CustomEvent("host-manager:edit-defaults", {
detail: {
level: "folder",
folderName: folder.path ?? folder.name,
},
}),
);
}}
>
<Settings2 className="size-3" />
</button>
<button
type="button"
title={t("hosts.editFolder")}
+51 -1
View File
@@ -7,6 +7,7 @@ import {
useState,
} from "react";
import { updatePluginHostSettings } from "@/api/plugins-api";
import { resetHostToDefaults } from "@/api/host-defaults-api";
import { PluginIcon } from "@/lib/plugin-icon";
import { usePluginHostSections } from "@/settings/HostPluginSections";
import { useTranslation } from "react-i18next";
@@ -18,6 +19,7 @@ import {
FolderOpen,
Loader2,
Plus,
RotateCcw,
Server,
Terminal,
} from "lucide-react";
@@ -117,7 +119,8 @@ export function SidebarTree({
hostClickBehavior?: HostClickBehavior;
}) {
const { t } = useTranslation();
const hostSwitchPlugins = usePluginHostSections().filter(
const hostSettingPlugins = usePluginHostSections();
const hostSwitchPlugins = hostSettingPlugins.filter(
(plugin) => !!plugin.contributes?.settings?.host?.enableKey,
);
// Knobs with no other owner come straight from the interface preset; the
@@ -1185,6 +1188,53 @@ export function SidebarTree({
)}
</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("hostDefaults.resetMenu")}{" "}
<ChevronDown className="size-2.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="text-xs">
{[
{ id: "all", label: t("hostDefaults.resetAll") },
{ id: "core", label: t("hostDefaults.resetCore") },
...hostSettingPlugins.map((plugin) => ({
id: plugin.id,
label: plugin.name,
})),
].map((option) => (
<DropdownMenuItem
key={option.id}
onClick={async () => {
const ids = Array.from(selectedHostIds).map(Number);
try {
await resetHostToDefaults(
ids,
option.id === "all"
? { all: true }
: { namespaces: [option.id] },
);
window.dispatchEvent(
new CustomEvent("termix:hosts-changed"),
);
toast.success(
t("hostDefaults.resetDone", { count: ids.length }),
);
} catch {
toast.error(t("hosts.bulkUpdateFailed"));
}
}}
>
<RotateCcw className="size-3.5 mr-2" />
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button