feat: redesign user host defaults system

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

No files matched your search

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