mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: redesign user host defaults system
This commit is contained in:
1 parent
3028ab1176
commit
a4a4d4f27b
144 files changed
+24609
-2555
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1104
-782
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user