mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35: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
@@ -333,6 +333,24 @@ export function AppShell({
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [railView, setRailView] = useState<RailView>("hosts");
|
||||
|
||||
// Host defaults open in the host manager, from anywhere (the admin panel,
|
||||
// a folder's menu).
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
setSidebarOpen(true);
|
||||
setRailView("hosts");
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("host-manager:edit-defaults", { detail }),
|
||||
);
|
||||
}, 0);
|
||||
};
|
||||
window.addEventListener("termix:open-host-defaults", handler);
|
||||
return () =>
|
||||
window.removeEventListener("termix:open-host-defaults", handler);
|
||||
}, []);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
const saved = localStorage.getItem("termix_sidebarWidth");
|
||||
return saved ? parseInt(saved, 10) : 291;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { handleApiError, sshHostApi } from "@/main-axios";
|
||||
import { invalidateHostsAndStatusCaches } from "@/lib/hosts-request-cache";
|
||||
import type {
|
||||
HostDefaultsLevel,
|
||||
ResolvedHostDefault,
|
||||
} from "@/types/host-defaults";
|
||||
|
||||
// HOST DEFAULTS
|
||||
// ============================================================================
|
||||
|
||||
export interface HostDefaultsLevelData {
|
||||
/** Values set at this level, by "namespace.key". */
|
||||
values: Record<string, unknown>;
|
||||
/** What each key reads when this level leaves it unset. */
|
||||
inherited: Record<string, ResolvedHostDefault>;
|
||||
}
|
||||
|
||||
export interface HostDefaultsTarget {
|
||||
level: HostDefaultsLevel;
|
||||
folderId?: number;
|
||||
}
|
||||
|
||||
export interface HostDefaultsChange {
|
||||
set: Record<string, unknown>;
|
||||
unset: string[];
|
||||
}
|
||||
|
||||
function levelPath(target: HostDefaultsTarget): string {
|
||||
if (target.level === "admin") return "/defaults/admin";
|
||||
if (target.level === "user") return "/defaults/user";
|
||||
return `/defaults/folders/${target.folderId}`;
|
||||
}
|
||||
|
||||
function adminHeaders(targetUserId?: string): Record<string, string> {
|
||||
return targetUserId ? { "X-Admin-Target-User": targetUserId } : {};
|
||||
}
|
||||
|
||||
export async function getHostDefaultsLevel(
|
||||
target: HostDefaultsTarget,
|
||||
): Promise<HostDefaultsLevelData> {
|
||||
try {
|
||||
const response = await sshHostApi.get(levelPath(target));
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch host defaults");
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveHostDefaultsLevel(
|
||||
target: HostDefaultsTarget,
|
||||
change: HostDefaultsChange,
|
||||
): Promise<{ changedHosts?: number; jobId?: string }> {
|
||||
try {
|
||||
const response = await sshHostApi.put(levelPath(target), change);
|
||||
invalidateHostsAndStatusCaches();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "save host defaults");
|
||||
}
|
||||
}
|
||||
|
||||
export async function previewHostDefaultsLevel(
|
||||
target: HostDefaultsTarget,
|
||||
change: HostDefaultsChange,
|
||||
): Promise<{ changedHosts: number }> {
|
||||
try {
|
||||
const response = await sshHostApi.post("/defaults/preview", {
|
||||
...target,
|
||||
...change,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "preview host defaults");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHostDefaultsJob(
|
||||
id: string,
|
||||
): Promise<{ status: "running" | "done" | "failed"; changedHosts: number }> {
|
||||
try {
|
||||
const response = await sshHostApi.get(`/defaults/jobs/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch host defaults progress");
|
||||
}
|
||||
}
|
||||
|
||||
/** A folder's id for its defaults, created when it only exists on hosts. */
|
||||
export async function getFolderDefaultsId(name: string): Promise<number> {
|
||||
try {
|
||||
const response = await sshHostApi.post("/defaults/folders", { name });
|
||||
return response.data.id;
|
||||
} catch (error) {
|
||||
handleApiError(error, "open folder defaults");
|
||||
}
|
||||
}
|
||||
|
||||
/** What a host, or a new one in a folder or under a parent, resolves to. */
|
||||
export async function resolveHostDefaults(
|
||||
where: {
|
||||
hostId?: number;
|
||||
folder?: string | null;
|
||||
parentHostId?: number | null;
|
||||
},
|
||||
adminTargetUserId?: string,
|
||||
): Promise<Record<string, ResolvedHostDefault>> {
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (where.hostId !== undefined) params.hostId = String(where.hostId);
|
||||
if (where.folder !== undefined) params.folder = where.folder ?? "";
|
||||
if (where.parentHostId !== undefined && where.parentHostId !== null) {
|
||||
params.parentHostId = String(where.parentHostId);
|
||||
}
|
||||
const response = await sshHostApi.get("/defaults/resolve", {
|
||||
params,
|
||||
headers: adminHeaders(adminTargetUserId),
|
||||
});
|
||||
return response.data.values ?? {};
|
||||
} catch (error) {
|
||||
handleApiError(error, "resolve host defaults");
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetHostToDefaults(
|
||||
hostIds: number[],
|
||||
reset: { all?: boolean; namespaces?: string[]; keys?: string[] },
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (hostIds.length === 1) {
|
||||
await sshHostApi.post(`/db/host/${hostIds[0]}/reset-defaults`, reset);
|
||||
} else {
|
||||
await sshHostApi.patch("/bulk-update", {
|
||||
hostIds,
|
||||
updates: { resetDefaults: reset },
|
||||
});
|
||||
}
|
||||
invalidateHostsAndStatusCaches();
|
||||
} catch (error) {
|
||||
handleApiError(error, "reset hosts to defaults");
|
||||
}
|
||||
}
|
||||
|
||||
/** Waits for a server-wide defaults job. Resolves to the hosts it changed, or null. */
|
||||
export async function waitForDefaultsJob(
|
||||
id: string,
|
||||
intervalMs = 1500,
|
||||
maxWaitMs = 10 * 60 * 1000,
|
||||
): Promise<number | null> {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < maxWaitMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
try {
|
||||
const job = await getHostDefaultsJob(id);
|
||||
if (job.status === "done") return job.changedHosts;
|
||||
if (job.status === "failed") return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -33,7 +33,10 @@ export interface PluginSettingsField {
|
||||
group?: string;
|
||||
component?: string;
|
||||
hidden?: boolean;
|
||||
defaultFrom?: string;
|
||||
defaultable?: boolean;
|
||||
defaultLevels?: Array<"admin" | "user" | "folder">;
|
||||
personal?: boolean;
|
||||
secretKeys?: string[];
|
||||
shareRead?: "connect" | "view" | "edit" | "manage";
|
||||
ownerOnly?: boolean;
|
||||
}
|
||||
|
||||
@@ -112,37 +112,3 @@ export async function updateBranding(
|
||||
handleApiError(error, "update branding settings");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HOST DEFAULTS SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
export type HostDefaults = {
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
socks5Port?: number;
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
credentialId?: number | null;
|
||||
statusCheckEnabled?: boolean;
|
||||
};
|
||||
|
||||
export async function getHostDefaults(): Promise<HostDefaults> {
|
||||
try {
|
||||
const response = await authApi.get("/users/host-defaults");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch host defaults");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateHostDefaults(
|
||||
defaults: HostDefaults,
|
||||
): Promise<HostDefaults> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/host-defaults", defaults);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update host defaults");
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DefaultsOnly, HostDefaultBadge } from "@/lib/host-defaults-context";
|
||||
import { useState } from "react";
|
||||
import type React from "react";
|
||||
|
||||
@@ -34,34 +35,43 @@ export function SettingRow({
|
||||
label,
|
||||
badge,
|
||||
description,
|
||||
defaultKey,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
badge?: string;
|
||||
description?: React.ReactNode;
|
||||
/**
|
||||
* In the host editor, the host default this row edits: a plugin's own
|
||||
* field key, or "core.<key>". Shows where the value comes from.
|
||||
*/
|
||||
defaultKey?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
// In a gap-4 form stack the gap already spaces the row, so its own padding
|
||||
// and divider would double up.
|
||||
<div className="flex items-center justify-between gap-3 py-3 border-b border-border last:border-0 [.gap-4>&]:py-0 [.gap-4>&]:border-0">
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-sm font-medium leading-snug">{label}</span>
|
||||
{badge && (
|
||||
<span className="text-[10px] font-bold text-yellow-500 border border-yellow-500/40 px-1 shrink-0">
|
||||
{badge}
|
||||
<DefaultsOnly settingKey={defaultKey ?? ""}>
|
||||
{/* In a gap-4 form stack the gap already spaces the row, so its own
|
||||
padding and divider would double up. */}
|
||||
<div className="flex items-center justify-between gap-3 py-3 border-b border-border last:border-0 [.gap-4>&]:py-0 [.gap-4>&]:border-0">
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-sm font-medium leading-snug">{label}</span>
|
||||
{badge && (
|
||||
<span className="text-[10px] font-bold text-yellow-500 border border-yellow-500/40 px-1 shrink-0">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
{defaultKey && <HostDefaultBadge settingKey={defaultKey} />}
|
||||
</div>
|
||||
{description && (
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
</DefaultsOnly>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
/**
|
||||
* The host editor's view of host defaults, for the fields it draws.
|
||||
*
|
||||
* In a host, a field either follows its default (and says where from) or is
|
||||
* the host's own (and can be reset). In the defaults editor, a field is either
|
||||
* set at the level being edited (and can be cleared) or shows what it
|
||||
* inherits. Outside either editor there is no provider and nothing renders.
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RotateCcw, X } from "lucide-react";
|
||||
import type {
|
||||
HostDefaultSource,
|
||||
HostDefaultsLevel,
|
||||
} from "@/types/host-defaults";
|
||||
import { usePluginScope } from "@/plugin-host/scope";
|
||||
|
||||
export interface HostDefaultsContextValue {
|
||||
mode: "host" | "defaults";
|
||||
/** The level being edited, in the defaults editor. */
|
||||
level?: HostDefaultsLevel;
|
||||
/** A host's own value, or a value set at the level being edited. */
|
||||
isOwn: (fullKey: string) => boolean;
|
||||
/** Where the value it would otherwise have comes from. */
|
||||
source: (fullKey: string) => HostDefaultSource | undefined;
|
||||
/** Follows the default again, or clears the value at this level. */
|
||||
reset: (fullKey: string) => void;
|
||||
/** Whether the key can have a default here at all. */
|
||||
isAvailable: (fullKey: string) => boolean;
|
||||
}
|
||||
|
||||
export const HostDefaultsContext =
|
||||
createContext<HostDefaultsContextValue | null>(null);
|
||||
|
||||
export function useHostDefaults(): HostDefaultsContextValue | null {
|
||||
return useContext(HostDefaultsContext);
|
||||
}
|
||||
|
||||
/** Whether the editor is editing a level of defaults rather than a host. */
|
||||
export function useIsDefaultsEditor(): boolean {
|
||||
return useContext(HostDefaultsContext)?.mode === "defaults";
|
||||
}
|
||||
|
||||
/**
|
||||
* A short key ("fontSize") is the calling plugin's own; core passes a full
|
||||
* one ("core.sshPort").
|
||||
*/
|
||||
export function useDefaultKey(settingKey: string | undefined): string | null {
|
||||
const scope = usePluginScope();
|
||||
if (!settingKey) return null;
|
||||
if (settingKey.includes(".")) return settingKey;
|
||||
return scope ? `${scope}.${settingKey}` : null;
|
||||
}
|
||||
|
||||
export function useSourceLabel(): (
|
||||
source: HostDefaultSource | undefined,
|
||||
) => string {
|
||||
const { t } = useTranslation();
|
||||
return (source) => {
|
||||
switch (source?.level) {
|
||||
case "folder":
|
||||
return t("hostDefaults.source.folder", {
|
||||
name: source.folderName ?? "",
|
||||
});
|
||||
case "user":
|
||||
return t("hostDefaults.source.user");
|
||||
case "admin":
|
||||
return t("hostDefaults.source.admin");
|
||||
default:
|
||||
return t("hostDefaults.source.builtin");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The marker next to a field's label: where an inherited value comes from,
|
||||
* or a control to hand an own value back.
|
||||
*/
|
||||
export function HostDefaultBadge({ settingKey }: { settingKey: string }) {
|
||||
const { t } = useTranslation();
|
||||
const context = useHostDefaults();
|
||||
const fullKey = useDefaultKey(settingKey);
|
||||
const sourceLabel = useSourceLabel();
|
||||
if (!context || !fullKey || !context.isAvailable(fullKey)) return null;
|
||||
|
||||
const own = context.isOwn(fullKey);
|
||||
const source = context.source(fullKey);
|
||||
const from = sourceLabel(source);
|
||||
|
||||
if (own && (context.mode === "defaults" || source)) {
|
||||
const defaults = context.mode === "defaults";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => context.reset(fullKey)}
|
||||
title={
|
||||
defaults
|
||||
? t("hostDefaults.clearTitle", { source: from })
|
||||
: t("hostDefaults.resetTitle", { source: from })
|
||||
}
|
||||
className="inline-flex items-center gap-0.5 border border-accent-brand/40 px-1 text-[10px] font-medium normal-case tracking-normal text-accent-brand hover:bg-accent-brand/10"
|
||||
>
|
||||
{defaults ? (
|
||||
<X className="size-2.5" />
|
||||
) : (
|
||||
<RotateCcw className="size-2.5" />
|
||||
)}
|
||||
{defaults ? t("hostDefaults.setHere") : t("hostDefaults.custom")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (own) return null;
|
||||
return (
|
||||
<span
|
||||
title={
|
||||
source
|
||||
? t("hostDefaults.inheritedTitle", { source: from })
|
||||
: t("hostDefaults.notSetTitle")
|
||||
}
|
||||
className="border border-border px-1 text-[10px] font-normal normal-case tracking-normal text-muted-foreground"
|
||||
>
|
||||
{source ? from : t("hostDefaults.notSet")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders its children only when the key can have a default in this editor. */
|
||||
export function DefaultsOnly({
|
||||
settingKey,
|
||||
children,
|
||||
}: {
|
||||
settingKey: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const context = useHostDefaults();
|
||||
const fullKey = useDefaultKey(settingKey);
|
||||
if (
|
||||
context?.mode === "defaults" &&
|
||||
fullKey &&
|
||||
!context.isAvailable(fullKey)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/** Renders its children only when editing a host, never a level of defaults. */
|
||||
export function HostOnly({ children }: { children: React.ReactNode }) {
|
||||
return useIsDefaultsEditor() ? null : <>{children}</>;
|
||||
}
|
||||
+42
-12
@@ -1445,19 +1445,7 @@
|
||||
"importFailed": "Import failed: {{error}}",
|
||||
"importError": "Database import failed",
|
||||
"sectionHostDefaults": "Host Defaults",
|
||||
"hostDefaultsDesc": "Settings applied automatically when creating a new host. Individual hosts can still override these.",
|
||||
"hostDefaultsSocks5": "SOCKS5 Proxy",
|
||||
"hostDefaultsUseSocks5": "Enable SOCKS5 Proxy",
|
||||
"hostDefaultsUseSocks5Desc": "Pre-fill SOCKS5 proxy on all new hosts",
|
||||
"hostDefaultsSocks5Host": "Proxy Host / Port",
|
||||
"hostDefaultsSocks5Username": "Proxy Username",
|
||||
"hostDefaultsSocks5Password": "Proxy Password",
|
||||
"hostDefaultsStatusCheckEnabled": "Enable Status Check",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Poll online/offline status on new hosts by default",
|
||||
"hostDefaultsStatus": "Status Checks",
|
||||
"minutes": "minutes",
|
||||
"hostDefaultsSaved": "Host defaults saved",
|
||||
"hostDefaultsSaveFailed": "Failed to save host defaults",
|
||||
"rolePermissions": {
|
||||
"count": "{{count}} permissions",
|
||||
"editAction": "Edit permissions",
|
||||
@@ -2005,5 +1993,47 @@
|
||||
"linked": "Linked. Your data is syncing now.",
|
||||
"signedInAgain": "Signed in. Sync is running again."
|
||||
}
|
||||
},
|
||||
"hostDefaults": {
|
||||
"source": {
|
||||
"folder": "Folder {{name}}",
|
||||
"user": "My defaults",
|
||||
"admin": "Server",
|
||||
"builtin": "Built-in"
|
||||
},
|
||||
"custom": "Custom",
|
||||
"setHere": "Set here",
|
||||
"notSet": "Not set",
|
||||
"notSetTitle": "No default is set for this",
|
||||
"resetTitle": "This host sets its own value. Click to use the default from {{source}} again",
|
||||
"clearTitle": "Set at this level. Click to clear it and use {{source}} instead",
|
||||
"inheritedTitle": "Follows the default from {{source}}",
|
||||
"titleAdmin": "Server host defaults",
|
||||
"titleUser": "My host defaults",
|
||||
"titleFolder": "Folder defaults: {{name}}",
|
||||
"descriptionAdmin": "Defaults for every host on this server. A user's own defaults, a folder's defaults or the host itself can still set a different value. Change a field to set it here, or clear it to leave it unset.",
|
||||
"descriptionUser": "Defaults for all of your hosts, over the server's. A folder or the host itself can still set a different value. Change a field to set it here, or clear it to leave it unset.",
|
||||
"descriptionFolder": "Defaults for the hosts in this folder and its subfolders, over your own. A host can still set a different value. Change a field to set it here, or clear it to leave it unset.",
|
||||
"save": "Save defaults",
|
||||
"saved_one": "Defaults saved. {{count}} host was updated.",
|
||||
"saved_other": "Defaults saved. {{count}} hosts were updated.",
|
||||
"savedInBackground": "Defaults saved. Hosts are being updated in the background.",
|
||||
"appliedToHosts_one": "{{count}} host was updated with the new defaults.",
|
||||
"appliedToHosts_other": "{{count}} hosts were updated with the new defaults.",
|
||||
"saveFailed": "Could not save the defaults",
|
||||
"loadFailed": "Could not load the defaults",
|
||||
"confirmTitle": "Save defaults?",
|
||||
"confirmChanges_one": "{{count}} host follows these defaults and will change.",
|
||||
"confirmChanges_other": "{{count}} hosts follow these defaults and will change.",
|
||||
"confirmNoChanges": "No existing host changes. New hosts will start with these defaults.",
|
||||
"menuMyDefaults": "My host defaults",
|
||||
"folderDefaultsAction": "Folder defaults",
|
||||
"adminSectionDescription": "Set the defaults every host on this server follows, for every setting a host has, including each feature's. Users and folders can set their own on top, and any host can still use its own value.",
|
||||
"editServerDefaults": "Edit server defaults",
|
||||
"resetMenu": "Reset",
|
||||
"resetAll": "Everything to defaults",
|
||||
"resetCore": "Connection and SSH to defaults",
|
||||
"resetDone_one": "{{count}} host now follows its defaults.",
|
||||
"resetDone_other": "{{count}} hosts now follow their defaults."
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,7 @@ export function createPluginApp(
|
||||
icon: withIconBoundary(pluginId, section.icon as never) as never,
|
||||
order: section.order,
|
||||
visible: section.visible,
|
||||
defaults: section.defaults,
|
||||
component: scoped(
|
||||
section.component as unknown as ComponentType<HostEditorSectionRenderProps>,
|
||||
),
|
||||
|
||||
@@ -159,6 +159,14 @@ export {
|
||||
type HostFeatureFieldsProps,
|
||||
} from "@/settings/HostPluginSections";
|
||||
|
||||
// Host defaults in a host editor section: where a field's value comes from,
|
||||
// and whether the section is editing a level of defaults rather than a host.
|
||||
export {
|
||||
HostDefaultBadge,
|
||||
DefaultsOnly as HostDefaultField,
|
||||
useIsDefaultsEditor,
|
||||
} from "@/lib/host-defaults-context";
|
||||
|
||||
// Components other plugins offer by id (app.registerComponent), rendered with
|
||||
// a fallback while their plugin is off.
|
||||
export { PluginComponent } from "@/plugin-host/component-registry";
|
||||
|
||||
@@ -63,29 +63,6 @@ function hostFieldDefaults(plugin: PluginSummary): Record<string, unknown> {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* A new host saves the fields whose default comes from an admin setting,
|
||||
* since the server only knows the manifest default for a host with no value.
|
||||
*/
|
||||
export function withNewHostDefaults(
|
||||
values: HostPluginSettings,
|
||||
plugins: PluginSummary[],
|
||||
): HostPluginSettings {
|
||||
const next: HostPluginSettings = { ...values };
|
||||
for (const plugin of plugins) {
|
||||
const fields = plugin.contributes?.settings?.host?.fields ?? [];
|
||||
for (const field of fields) {
|
||||
if (!field.defaultFrom || field.default === undefined) continue;
|
||||
if (next[plugin.id] && field.key in next[plugin.id]) continue;
|
||||
next[plugin.id] = {
|
||||
...(next[plugin.id] ?? {}),
|
||||
[field.key]: field.default,
|
||||
};
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export interface HostPluginSectionsProps {
|
||||
plugins: PluginSummary[];
|
||||
values: HostPluginSettings;
|
||||
@@ -197,6 +174,7 @@ function HostPluginSection({
|
||||
values={shown}
|
||||
setValue={setValue}
|
||||
running={running}
|
||||
defaultKey={`${plugin.id}.${enableKey}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -211,6 +189,7 @@ function HostPluginSection({
|
||||
values={shown}
|
||||
setValue={setValue}
|
||||
running={running}
|
||||
defaultKey={`${plugin.id}.${field.key}`}
|
||||
/>
|
||||
))}
|
||||
</SectionCard>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import { pluginKey } from "@/lib/plugin-i18n";
|
||||
import { DefaultsOnly, HostDefaultBadge } from "@/lib/host-defaults-context";
|
||||
import { isRedactedSecret, type PluginSettingsField } from "@/api/plugins-api";
|
||||
import {
|
||||
getSettingsComponent,
|
||||
@@ -37,6 +38,8 @@ export interface SettingsFieldProps {
|
||||
/** Message from a rejected save, shown under the control. */
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
/** In the host editor, the host default this field is ("pluginId.key"). */
|
||||
defaultKey?: string;
|
||||
}
|
||||
|
||||
export function SettingsFieldRow({
|
||||
@@ -47,6 +50,7 @@ export function SettingsFieldRow({
|
||||
running,
|
||||
error,
|
||||
disabled = false,
|
||||
defaultKey,
|
||||
}: SettingsFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
// A custom component appears once its plugin's frontend has registered it.
|
||||
@@ -98,6 +102,7 @@ export function SettingsFieldRow({
|
||||
label={label(field.labelKey)}
|
||||
description={description}
|
||||
badge={error ? t("common.error") : undefined}
|
||||
defaultKey={defaultKey}
|
||||
>
|
||||
{control}
|
||||
</SettingRow>
|
||||
@@ -105,24 +110,27 @@ export function SettingsFieldRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-sm font-medium leading-snug">
|
||||
{label(field.labelKey)}
|
||||
</span>
|
||||
{error && (
|
||||
<span className="text-[10px] font-bold text-yellow-500 border border-yellow-500/40 px-1 shrink-0">
|
||||
{t("common.error")}
|
||||
<DefaultsOnly settingKey={defaultKey ?? ""}>
|
||||
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-sm font-medium leading-snug">
|
||||
{label(field.labelKey)}
|
||||
</span>
|
||||
{error && (
|
||||
<span className="text-[10px] font-bold text-yellow-500 border border-yellow-500/40 px-1 shrink-0">
|
||||
{t("common.error")}
|
||||
</span>
|
||||
)}
|
||||
{defaultKey && <HostDefaultBadge settingKey={defaultKey} />}
|
||||
</div>
|
||||
{description && (
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">{control}</div>
|
||||
</div>
|
||||
{description && (
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">{control}</div>
|
||||
</div>
|
||||
</DefaultsOnly>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,16 +32,20 @@ interface HostFormLike {
|
||||
/** Adds, updates and removes generated tabs to match the plugin list. */
|
||||
export function syncHostFeatureTabs(plugins: PluginSummary[]): string[] {
|
||||
const existing = hostEditorSectionList();
|
||||
const ownSections = new Set(
|
||||
existing
|
||||
.filter(
|
||||
(section) => section.pluginId && !section.id.startsWith(TAB_PREFIX),
|
||||
)
|
||||
const own = existing.filter(
|
||||
(section) => section.pluginId && !section.id.startsWith(TAB_PREFIX),
|
||||
);
|
||||
const ownSections = new Set(own.map((section) => section.pluginId));
|
||||
// A plugin whose own sections are all about one host still gets its
|
||||
// switches in the defaults editor, through a generated tab shown only there.
|
||||
const ownDefaultsSections = new Set(
|
||||
own
|
||||
.filter((section) => section.defaults)
|
||||
.map((section) => section.pluginId),
|
||||
);
|
||||
|
||||
const contributors = plugins.filter((plugin) => {
|
||||
if (!plugin.enabled || ownSections.has(plugin.id)) return false;
|
||||
if (!plugin.enabled || ownDefaultsSections.has(plugin.id)) return false;
|
||||
const host = plugin.contributes?.settings?.host;
|
||||
return !!host && (hasVisibleFields(host.fields) || !!host.enableKey);
|
||||
});
|
||||
@@ -65,6 +69,8 @@ export function syncHostFeatureTabs(plugins: PluginSummary[]): string[] {
|
||||
order: host.editorOrder ?? 100,
|
||||
labelKey: plugin.name,
|
||||
label: plugin.name,
|
||||
// Nothing but the plugin's host settings, so it works for defaults too.
|
||||
defaults: ownSections.has(plugin.id) ? "only" : true,
|
||||
icon: icon
|
||||
? ({ className }: { className?: string }) => (
|
||||
<PluginIcon name={icon} className={className} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import {
|
||||
HostDefaultBadge,
|
||||
HostDefaultsContext,
|
||||
HostOnly,
|
||||
type HostDefaultsContextValue,
|
||||
} from "@/lib/host-defaults-context";
|
||||
import { SettingRow } from "@/components/section-card";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) =>
|
||||
options?.name ? `${key}:${options.name}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
function context(
|
||||
partial: Partial<HostDefaultsContextValue> = {},
|
||||
): HostDefaultsContextValue {
|
||||
return {
|
||||
mode: "host",
|
||||
isOwn: () => false,
|
||||
source: () => ({ level: "user" }),
|
||||
reset: vi.fn(),
|
||||
isAvailable: () => true,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function withContext(value: HostDefaultsContextValue, ui: React.ReactNode) {
|
||||
return render(
|
||||
<HostDefaultsContext.Provider value={value}>
|
||||
{ui}
|
||||
</HostDefaultsContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("HostDefaultBadge", () => {
|
||||
it("renders nothing outside the host editor", () => {
|
||||
const { container } = render(
|
||||
<HostDefaultBadge settingKey="core.sshPort" />,
|
||||
);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("says where an inherited value comes from", () => {
|
||||
withContext(
|
||||
context({
|
||||
source: () => ({ level: "folder", folderName: "Prod" }),
|
||||
}),
|
||||
<HostDefaultBadge settingKey="core.sshPort" />,
|
||||
);
|
||||
expect(screen.getByText("hostDefaults.source.folder:Prod")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers a reset for a host's own value", () => {
|
||||
const value = context({ isOwn: () => true });
|
||||
withContext(value, <HostDefaultBadge settingKey="core.sshPort" />);
|
||||
fireEvent.click(screen.getByText("hostDefaults.custom"));
|
||||
expect(value.reset).toHaveBeenCalledWith("core.sshPort");
|
||||
});
|
||||
|
||||
it("offers a clear for a value set at the level being edited", () => {
|
||||
withContext(
|
||||
context({ mode: "defaults", isOwn: () => true, source: () => undefined }),
|
||||
<HostDefaultBadge settingKey="core.sshPort" />,
|
||||
);
|
||||
expect(screen.getByText("hostDefaults.setHere")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says when nothing is set", () => {
|
||||
withContext(
|
||||
context({ source: () => undefined }),
|
||||
<HostDefaultBadge settingKey="core.username" />,
|
||||
);
|
||||
expect(screen.getByText("hostDefaults.notSet")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("in the defaults editor", () => {
|
||||
it("hides a row whose key cannot be a default at this level", () => {
|
||||
withContext(
|
||||
context({ mode: "defaults", isAvailable: () => false }),
|
||||
<SettingRow label="Jump hosts" defaultKey="core.jumpHosts">
|
||||
<span />
|
||||
</SettingRow>,
|
||||
);
|
||||
expect(screen.queryByText("Jump hosts")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides what only one host can have", () => {
|
||||
withContext(
|
||||
context({ mode: "defaults" }),
|
||||
<HostOnly>
|
||||
<span>Address</span>
|
||||
</HostOnly>,
|
||||
);
|
||||
expect(screen.queryByText("Address")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -105,7 +105,7 @@ describe("syncHostFeatureTabs", () => {
|
||||
expect(getHostEditorSection(hostFeatureTabId("docker"))?.group).toBe("top");
|
||||
});
|
||||
|
||||
it("skips a plugin that registered its own host editor section", () => {
|
||||
it("gives a plugin with its own section a tab only in the defaults editor", () => {
|
||||
registerHostEditorSection({
|
||||
id: "docker-own",
|
||||
pluginId: "docker",
|
||||
@@ -113,10 +113,34 @@ describe("syncHostFeatureTabs", () => {
|
||||
labelKey: "k",
|
||||
component: () => null,
|
||||
});
|
||||
expect(syncHostFeatureTabs([plugin()])).toEqual([
|
||||
hostFeatureTabId("docker"),
|
||||
]);
|
||||
expect(getHostEditorSection(hostFeatureTabId("docker"))?.defaults).toBe(
|
||||
"only",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips a plugin whose own section already covers the defaults editor", () => {
|
||||
registerHostEditorSection({
|
||||
id: "docker-own",
|
||||
pluginId: "docker",
|
||||
group: "top",
|
||||
labelKey: "k",
|
||||
defaults: true,
|
||||
component: () => null,
|
||||
});
|
||||
expect(syncHostFeatureTabs([plugin()])).toEqual([]);
|
||||
expect(getHostEditorSection(hostFeatureTabId("docker"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("offers a generated tab to both the host and the defaults editor", () => {
|
||||
syncHostFeatureTabs([plugin()]);
|
||||
expect(getHostEditorSection(hostFeatureTabId("docker"))?.defaults).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("registers nothing for a plugin whose host fields are all hidden", () => {
|
||||
const own = plugin({
|
||||
contributes: {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createHostEditorForm } from "@/sidebar/HostEditorData";
|
||||
import {
|
||||
applyDefaultToForm,
|
||||
changedKeys,
|
||||
classifyForm,
|
||||
draftOwnKeys,
|
||||
formValueForKey,
|
||||
ownPluginValues,
|
||||
withAllNamespaces,
|
||||
withOwnKeys,
|
||||
withoutOwnKey,
|
||||
} from "@/sidebar/host-defaults/form-mapping";
|
||||
|
||||
const form = () => createHostEditorForm(null);
|
||||
|
||||
describe("host defaults on the editor form", () => {
|
||||
it("reads core keys in the API's shape", () => {
|
||||
const base = {
|
||||
...form(),
|
||||
authType: "credential" as const,
|
||||
credentialId: "7",
|
||||
};
|
||||
expect(formValueForKey(base, "core.auth")).toEqual({
|
||||
authType: "credential",
|
||||
credentialId: 7,
|
||||
overrideCredentialUsername: false,
|
||||
agentSocketPath: null,
|
||||
agentIdentity: null,
|
||||
});
|
||||
expect(formValueForKey(base, "core.sshPort")).toBe(22);
|
||||
});
|
||||
|
||||
it("puts a default's value back on the form", () => {
|
||||
let next = applyDefaultToForm(form(), "core.socks5", {
|
||||
useSocks5: true,
|
||||
socks5ProxyChain: [{ host: "a", port: 1080, type: "socks5" }],
|
||||
});
|
||||
expect(next.useSocks5).toBe(true);
|
||||
expect(next.socks5ProxyMode).toBe("chain");
|
||||
next = applyDefaultToForm(next, "core.jumpHosts", [{ hostId: 4 }]);
|
||||
expect(next.jumpHosts).toEqual([{ hostId: "4" }]);
|
||||
next = applyDefaultToForm(next, "term.fontSize", 18);
|
||||
expect(next.pluginSettings.term.fontSize).toBe(18);
|
||||
});
|
||||
|
||||
it("finds the keys an edit changed", () => {
|
||||
const before = form();
|
||||
const after = { ...before, sshPort: 2222, keySubTab: "upload" as const };
|
||||
expect(
|
||||
changedKeys(before, after, ["core.sshPort", "core.auth", "term.x"]),
|
||||
).toEqual(["core.sshPort"]);
|
||||
});
|
||||
|
||||
it("classifies what differs from the defaults as the host's own", () => {
|
||||
const current = { ...form(), sshPort: 2222 };
|
||||
expect(
|
||||
classifyForm(
|
||||
current,
|
||||
{
|
||||
"core.sshPort": { value: 22 },
|
||||
"core.statusCheckEnabled": { value: true },
|
||||
},
|
||||
["core.sshPort", "core.statusCheckEnabled", "core.username"],
|
||||
),
|
||||
).toEqual({ core: ["sshPort", "username"] });
|
||||
});
|
||||
|
||||
it("adds and removes own keys", () => {
|
||||
const own = withOwnKeys({}, ["core.sshPort", "term.a"]);
|
||||
expect(own).toEqual({ core: ["sshPort"], term: ["a"] });
|
||||
expect(withoutOwnKey(own, "term.a")).toEqual({
|
||||
core: ["sshPort"],
|
||||
term: [],
|
||||
});
|
||||
expect(
|
||||
withAllNamespaces(own, new Map([["x.y", { namespace: "x" }]])),
|
||||
).toEqual({ core: ["sshPort"], term: ["a"], x: [] });
|
||||
});
|
||||
|
||||
it("sends only the plugin values a host sets itself", () => {
|
||||
const keys = new Map<string, unknown>([
|
||||
["term.a", {}],
|
||||
["term.b", {}],
|
||||
]);
|
||||
expect(
|
||||
ownPluginValues(
|
||||
{ term: { a: 1, b: 2, enableRdp: true } },
|
||||
{ term: ["a"] },
|
||||
keys,
|
||||
),
|
||||
).toEqual({ term: { a: 1, enableRdp: true } });
|
||||
expect(ownPluginValues({ term: { a: 1 } }, null, keys)).toEqual({
|
||||
term: { a: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("makes what a plugin's draft filled in the new host's own", () => {
|
||||
expect(draftOwnKeys({ port: 2222, username: "u" })).toEqual({
|
||||
core: ["sshPort", "username"],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user