Compare commits

...
Author SHA1 Message Date
陈大猫 f26fd41414 fix(host-details): keep empty override when clearing inherited chain
Clear Chain on an inherited-only jump-host list now stores { hostIds: [] }
instead of deleting the field so group hops cannot snap back.
2026-11-08 20:56:05 +08:00
陈大猫 417bcc86b0 fix(host-details): keep custom proxy and empty jump-host overrides
Selecting Custom while a group saved proxy is inherited now keeps a host-owned draft instead of snapping back to that profile. Removing the last hop of a multi-hop inherited chain keeps an empty override so applyGroupDefaults cannot restore the group hops.
2026-11-08 20:25:57 +08:00
陈大猫 e7e46e3766 fix(host-details): show inherited group proxy and jump hosts
Host editor summaries for HTTP/SOCKS5/command proxy and ProxyJump
chains now display the same values applyGroupDefaults uses at
connect time, without writing inherited settings onto the host
until the user edits them.

Fixes #4187
2026-11-08 20:13:48 +08:00
5 changed files with 341 additions and 36 deletions

No files matched your search

+19 -4
View File
@@ -49,11 +49,13 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
showAlgorithmOverrides,
setShowAlgorithmOverrides,
chainedHosts,
hostChainInherited = false,
setActiveSubPanel,
clearHostChain,
proxySummaryType,
proxySummaryLabel,
proxySummaryTooltip,
proxyInherited = false,
clearProxyConfig,
}) => {
const inheritedDeviceType = effectiveGroupDefaults?.deviceType;
@@ -641,9 +643,16 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
title={t("hostDetails.jumpHosts")}
action={
chainedHosts.length > 0 ? (
<Badge variant="secondary" className="text-xs">
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
</Badge>
<div className="flex items-center gap-1">
{hostChainInherited && (
<Badge variant="outline" className="text-xs text-muted-foreground">
{t("vault.groups.details.inherited")}
</Badge>
)}
<Badge variant="secondary" className="text-xs">
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
</Badge>
</div>
) : (
<Badge variant="outline" className="text-xs text-muted-foreground">
{t("hostDetails.jumpHosts.direct")}
@@ -664,8 +673,10 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
/>
<span className="text-xs text-muted-foreground">
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
{hostChainInherited ? ` · ${t("vault.groups.details.inherited")}` : ""}
</span>
</div>
{!hostChainInherited && (
<X
size={14}
className="text-muted-foreground hover:text-destructive flex-shrink-0"
@@ -674,6 +685,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
clearHostChain();
}}
/>
)}
</div>
<div className="w-full space-y-1 pl-5">
{chainedHosts.slice(0, 5).map((h, idx) => (
@@ -735,7 +747,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
<X size={14} />
</Button>
</div>
) : form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId ? (
) : form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId || proxyInherited ? (
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
<button
type="button"
@@ -750,6 +762,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
<TooltipTrigger asChild>
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{proxySummaryLabel}
{proxyInherited ? ` · ${t("vault.groups.details.inherited")}` : ""}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-xs break-all">
@@ -758,6 +771,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
</Tooltip>
</TooltipProvider>
</button>
{!proxyInherited && (
<Button
type="button"
variant="ghost"
@@ -768,6 +782,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
>
<X size={14} />
</Button>
)}
</div>
) : (
<Button
+67
View File
@@ -1,4 +1,5 @@
import type { GroupConfig } from "../domain/models";
import { applyGroupDefaults } from "../domain/groupConfig";
import {
findProxyProfile,
hasIncompleteProxyIdentity,
@@ -157,3 +158,69 @@ export const prepareProxyConfigForSave = (params: {
normalizedProxyConfig: normalizeManualProxyConfig(params.proxyConfig),
};
};
export const EMPTY_CUSTOM_PROXY_DRAFT: ProxyConfig = { type: "http", host: "", port: 8080 };
export const resolveDisplayProxyProfileId = (
form: Pick<Host, "directProxy" | "proxyProfileId" | "proxyConfig">,
inheritedProfileId?: string,
): string | undefined => {
if (form.directProxy || form.proxyConfig !== undefined) return form.proxyProfileId;
return form.proxyProfileId ?? inheritedProfileId;
};
export const nextFormAfterSelectProxyProfile = (
prev: Host,
profileId: string | undefined,
groupDefaults?: Partial<GroupConfig>,
): Host => {
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
if (!profileId) {
const inheritedConfig = !prev.directProxy
&& prev.proxyProfileId === undefined
&& prev.proxyConfig === undefined
&& groupDefaults
? applyGroupDefaults(prev, groupDefaults).proxyConfig
: undefined;
return {
...rest,
proxyConfig: prev.proxyConfig ?? inheritedConfig ?? EMPTY_CUSTOM_PROXY_DRAFT,
} as Host;
}
return { ...rest, proxyProfileId: profileId } as Host;
};
export const resolveFormHostChainIds = (
host: Host,
groupDefaults?: Partial<GroupConfig>,
): string[] => {
if (host.hostChain !== undefined) return host.hostChain.hostIds || [];
if (!groupDefaults) return [];
return applyGroupDefaults(host, groupDefaults).hostChain?.hostIds || [];
};
export const nextFormAfterClearHostChain = (
prev: Host,
groupDefaults?: Partial<GroupConfig>,
): Host => {
const groupWouldRestore = Boolean(
groupDefaults
&& applyGroupDefaults({ ...prev, hostChain: undefined }, groupDefaults).hostChain?.hostIds?.length,
);
if (prev.hostChain === undefined || groupWouldRestore) {
return { ...prev, hostChain: { hostIds: [] } };
}
const { hostChain: _hostChain, ...rest } = prev;
return rest as Host;
};
export const nextFormAfterRemoveHostFromChain = (
prev: Host,
index: number,
groupDefaults?: Partial<GroupConfig>,
): Host => {
const ids = resolveFormHostChainIds(prev, groupDefaults).filter((_, i) => i !== index);
if (ids.length > 0) return { ...prev, hostChain: { hostIds: ids } };
return nextFormAfterClearHostChain(prev, groupDefaults);
};
@@ -6,12 +6,17 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import type { Host, Identity } from "../types.ts";
import HostDetailsPanel, { parseOptionalPortInput } from "./HostDetailsPanel.tsx";
import { applyGroupDefaults } from "../domain/groupConfig.ts";
import {
prepareProxyConfigForSave,
prepareTelnetCredentialsForSave,
resolvePrimaryProtocolSavePort,
resolvePrimaryProtocolSwitchPort,
validateProxyConfigForSave,
nextFormAfterSelectProxyProfile,
nextFormAfterClearHostChain,
nextFormAfterRemoveHostFromChain,
resolveDisplayProxyProfileId,
} from "./HostDetailsPanel.helpers.ts";
import { TooltipProvider } from "./ui/tooltip.tsx";
@@ -549,3 +554,198 @@ test("HostDetailsPanel expands notes when the host already has notes", () => {
"expected notes to render after connection settings",
);
});
const networkSshHost: Host = {
...hostWithMissingProxyProfile,
proxyProfileId: undefined,
group: "network",
};
const jumpHost: Host = {
id: "jump-1",
label: "Bastion",
hostname: "bastion.example.com",
username: "root",
tags: [],
os: "linux",
port: 22,
protocol: "ssh",
authMethod: "password",
createdAt: 1,
};
const hostOwnedJump: Host = {
...jumpHost,
id: "jump-host",
label: "Edge",
hostname: "edge.example.com",
};
const renderHostDetailsWithGroup = (
initialData: Host,
groupConfig: Record<string, unknown>,
extraHosts: Host[] = [],
) =>
renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(HostDetailsPanel, {
initialData,
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: ["network"],
managedSources: [],
allTags: [],
allHosts: extraHosts,
terminalThemeId: "default",
terminalFontSize: 14,
groupConfigs: [{ path: "network", ...groupConfig }],
onSave: () => {},
onCancel: () => {},
}),
),
),
);
test("HostDetailsPanel displays inherited group HTTP proxy", () => {
const markup = renderHostDetailsWithGroup(networkSshHost, {
proxyConfig: { type: "http", host: "group-proxy.example.com", port: 8080 },
});
assert.match(markup, /group-proxy\.example\.com/);
assert.match(markup, /Inherited from group/);
});
test("HostDetailsPanel host proxy override wins over the group proxy", () => {
const markup = renderHostDetailsWithGroup({
...networkSshHost,
proxyConfig: { type: "http", host: "host-proxy.example.com", port: 9090 },
}, {
proxyConfig: { type: "http", host: "group-proxy.example.com", port: 8080 },
});
assert.match(markup, /host-proxy\.example\.com/);
assert.doesNotMatch(markup, /group-proxy\.example\.com/);
assert.doesNotMatch(markup, /Inherited from group/);
});
test("HostDetailsPanel direct proxy hides inherited group proxy", () => {
const markup = renderHostDetailsWithGroup({
...networkSshHost,
directProxy: true,
}, {
proxyConfig: { type: "http", host: "group-proxy.example.com", port: 8080 },
});
assert.match(markup, /Direct \(no proxy\)/);
assert.doesNotMatch(markup, /group-proxy\.example\.com/);
});
test("HostDetailsPanel displays inherited group jump hosts", () => {
const markup = renderHostDetailsWithGroup(networkSshHost, {
hostChain: { hostIds: ["jump-1"] },
}, [jumpHost]);
assert.match(markup, /bastion\.example\.com/);
assert.match(markup, /Inherited from group/);
});
test("HostDetailsPanel host jump-host override wins over the group chain", () => {
const markup = renderHostDetailsWithGroup({
...networkSshHost,
hostChain: { hostIds: ["jump-host"] },
}, {
hostChain: { hostIds: ["jump-1"] },
}, [jumpHost, hostOwnedJump]);
assert.match(markup, /edge\.example\.com/);
assert.doesNotMatch(markup, /bastion\.example\.com/);
assert.doesNotMatch(markup, /Inherited from group/);
});
test("selecting Custom while inheriting a saved proxy keeps a custom draft instead of the profile", () => {
const group = { proxyProfileId: "office-proxy" };
const next = nextFormAfterSelectProxyProfile(networkSshHost, undefined, group);
assert.equal(next.proxyProfileId, undefined);
assert.equal(next.directProxy, undefined);
assert.deepEqual(next.proxyConfig, { type: "http", host: "", port: 8080 });
assert.equal(resolveDisplayProxyProfileId(next, "office-proxy"), undefined);
assert.equal(applyGroupDefaults(next, group).proxyProfileId, undefined);
});
test("removing the last hop of a multi-hop inherited chain keeps an empty override", () => {
const group = { hostChain: { hostIds: ["jump-1", "jump-host"] } };
const afterFirst = nextFormAfterRemoveHostFromChain(networkSshHost, 0, group);
assert.deepEqual(afterFirst.hostChain, { hostIds: ["jump-host"] });
const afterLast = nextFormAfterRemoveHostFromChain(afterFirst, 0, group);
assert.deepEqual(afterLast.hostChain, { hostIds: [] });
assert.deepEqual(applyGroupDefaults(afterLast, group).hostChain, { hostIds: [] });
});
test("clearing an inherited-only chain keeps an empty override instead of restoring group hops", () => {
const group = { hostChain: { hostIds: ["jump-1", "jump-host"] } };
const cleared = nextFormAfterClearHostChain(networkSshHost, group);
assert.deepEqual(cleared.hostChain, { hostIds: [] });
assert.deepEqual(applyGroupDefaults(cleared, group).hostChain, { hostIds: [] });
});
test("HostDetailsPanel custom proxy draft hides the inherited saved proxy", () => {
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(HostDetailsPanel, {
initialData: {
...networkSshHost,
proxyConfig: { type: "http", host: "", port: 8080 },
},
availableKeys: [],
identities: [],
proxyProfiles: [{
id: "office-proxy",
label: "Office Proxy",
config: { type: "http", host: "office-proxy.example.com", port: 8080 },
createdAt: 1,
}],
groups: ["network"],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
groupConfigs: [{ path: "network", proxyProfileId: "office-proxy" }],
onSave: () => {},
onCancel: () => {},
}),
),
),
);
assert.doesNotMatch(markup, /Office Proxy/);
assert.doesNotMatch(markup, /office-proxy\.example\.com/);
assert.doesNotMatch(markup, /Inherited from group/);
});
test("HostDetailsPanel empty jump-host override hides the inherited group chain", () => {
const markup = renderHostDetailsWithGroup({
...networkSshHost,
hostChain: { hostIds: [] },
}, {
hostChain: { hostIds: ["jump-1", "jump-host"] },
}, [jumpHost, hostOwnedJump]);
assert.doesNotMatch(markup, /bastion\.example\.com/);
assert.doesNotMatch(markup, /edge\.example\.com/);
assert.doesNotMatch(markup, /Inherited from group/);
});
+52 -31
View File
@@ -55,6 +55,11 @@ import {
resolvePrimaryProtocolSwitchPort,
prepareTelnetCredentialsForSave,
prepareProxyConfigForSave,
nextFormAfterSelectProxyProfile,
nextFormAfterClearHostChain,
nextFormAfterRemoveHostFromChain,
resolveFormHostChainIds,
resolveDisplayProxyProfileId,
} from "./HostDetailsPanel.helpers";
export { parseOptionalPortInput } from "./HostDetailsPanel.helpers";
import { TerminalEncodingSelect } from "./TerminalEncodingSelect";
@@ -424,24 +429,34 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
);
const effectiveFormDistro = getEffectiveHostDistro(form);
const hasHostProxyOverride = form.directProxy === true
|| form.proxyProfileId !== undefined
|| form.proxyConfig !== undefined;
const displayProxyProfileId = resolveDisplayProxyProfileId(form, effectiveAuthHost.proxyProfileId);
const displayProxyConfig = form.directProxy
? undefined
: (form.proxyConfig ?? effectiveAuthHost.proxyConfig);
const selectedProxyProfile = useMemo(
() => proxyProfiles.find((profile) => profile.id === form.proxyProfileId),
[form.proxyProfileId, proxyProfiles],
() => proxyProfiles.find((profile) => profile.id === displayProxyProfileId),
[displayProxyProfileId, proxyProfiles],
);
const hasMissingProxyProfile = Boolean(form.proxyProfileId && !selectedProxyProfile);
const hasMissingProxyProfile = Boolean(displayProxyProfileId && !selectedProxyProfile);
const proxyInherited = !hasHostProxyOverride && Boolean(
displayProxyProfileId || displayProxyConfig?.host || displayProxyConfig?.command,
) && Boolean(effectiveGroupDefaults?.proxyProfileId || effectiveGroupDefaults?.proxyConfig);
const proxySummaryType = hasMissingProxyProfile
? t("hostDetails.proxyPanel.missing")
: formatProxyConfigType(selectedProxyProfile?.config || form.proxyConfig) || "HTTP";
: formatProxyConfigType(selectedProxyProfile?.config || displayProxyConfig) || "HTTP";
const proxySummaryLabel = hasMissingProxyProfile
? t("hostDetails.proxyPanel.missingSaved")
: selectedProxyProfile
? selectedProxyProfile.label
: formatProxyConfigEndpoint(form.proxyConfig);
: formatProxyConfigEndpoint(displayProxyConfig);
const proxySummaryTooltip = hasMissingProxyProfile
? t("hostDetails.proxyPanel.missingSaved")
: selectedProxyProfile
? `${selectedProxyProfile.label} - ${formatProxyConfigEndpoint(selectedProxyProfile.config)}`
: `${formatProxyConfigType(form.proxyConfig)} ${formatProxyConfigEndpoint(form.proxyConfig)}`;
: `${formatProxyConfigType(displayProxyConfig)} ${formatProxyConfigEndpoint(displayProxyConfig)}`;
const handleDistroModeChange = useCallback((mode: "auto" | "manual") => {
setForm((prev) => ({
@@ -458,13 +473,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
(field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => {
setForm((prev) => {
const { proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
const inheritedConfig = !prev.directProxy
&& prev.proxyProfileId === undefined
&& prev.proxyConfig === undefined
&& effectiveGroupDefaults
? applyGroupDefaults(prev, effectiveGroupDefaults).proxyConfig
: undefined;
return {
...rest,
proxyConfig: updateProxyConfigField(prev.proxyConfig, field, value),
proxyConfig: updateProxyConfigField(prev.proxyConfig ?? inheritedConfig, field, value),
} as Host;
});
},
[],
[effectiveGroupDefaults],
);
const clearProxyConfig = useCallback(() => {
@@ -475,12 +496,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
}, []);
const selectProxyProfile = useCallback((profileId: string | undefined) => {
setForm((prev) => {
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
if (!profileId) return rest as Host;
return { ...rest, proxyProfileId: profileId } as Host;
});
}, []);
setForm((prev) => nextFormAfterSelectProxyProfile(prev, profileId, effectiveGroupDefaults));
}, [effectiveGroupDefaults]);
const selectDirectProxy = useCallback(() => {
setForm((prev) => {
@@ -493,24 +510,18 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
setForm((prev) => ({
...prev,
hostChain: {
hostIds: [...(prev.hostChain?.hostIds || []), hostId],
hostIds: [...resolveFormHostChainIds(prev, effectiveGroupDefaults), hostId],
},
}));
};
const removeHostFromChain = (index: number) => {
setForm((prev) => {
const ids = (prev.hostChain?.hostIds || []).filter((_, i) => i !== index);
return { ...prev, hostChain: ids.length > 0 ? { hostIds: ids } : undefined };
});
setForm((prev) => nextFormAfterRemoveHostFromChain(prev, index, effectiveGroupDefaults));
};
const clearHostChain = useCallback(() => {
setForm((prev) => {
const { hostChain: _hostChain, ...rest } = prev;
return rest as Host;
});
}, []);
setForm((prev) => nextFormAfterClearHostChain(prev, effectiveGroupDefaults));
}, [effectiveGroupDefaults]);
const [newEnvName, setNewEnvName] = useState("");
const [newEnvValue, setNewEnvValue] = useState("");
@@ -773,8 +784,16 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
setActiveSubPanel("none");
};
const displayHostChainIds = useMemo(
() => form.hostChain !== undefined
? (form.hostChain.hostIds || [])
: (effectiveAuthHost.hostChain?.hostIds || []),
[effectiveAuthHost.hostChain?.hostIds, form.hostChain],
);
const hostChainInherited = form.hostChain === undefined && displayHostChainIds.length > 0;
const availableHostsForChain = useMemo(() => {
const chainedIds = new Set(form.hostChain?.hostIds || []);
const chainedIds = new Set(displayHostChainIds);
// Chain hops are SSH ProxyJump hops; never offer local/serial/telnet/plugin
// entries (e.g. saved local shells) as jump hosts.
return allHosts.filter((h) =>
@@ -782,14 +801,13 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
&& !chainedIds.has(h.id)
&& canServeAsSshJumpHost(h),
);
}, [allHosts, form.id, form.hostChain?.hostIds]);
}, [allHosts, displayHostChainIds, form.id]);
const chainedHosts = useMemo(() => {
const ids = form.hostChain?.hostIds || [];
return ids
return displayHostChainIds
.map((id) => allHosts.find((h) => h.id === id))
.filter(Boolean) as Host[];
}, [allHosts, form.hostChain?.hostIds]);
}, [allHosts, displayHostChainIds]);
const groupOptions: ComboboxOption[] = useMemo(() => {
return groups.map((g) => ({
@@ -905,11 +923,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
if (activeSubPanel === "proxy") {
return (
<ProxyPanel
proxyConfig={form.proxyConfig}
proxyConfig={displayProxyConfig}
proxyProfiles={proxyProfiles}
identities={identities}
selectedProxyProfileId={form.proxyProfileId}
selectedProxyProfileId={displayProxyProfileId}
directProxy={form.directProxy}
allowClear={hasHostProxyOverride}
onUpdateProxy={updateProxyConfig}
onSelectProxyProfile={selectProxyProfile}
onSelectDirectProxy={selectDirectProxy}
@@ -1237,11 +1256,13 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
showAlgorithmOverrides={showAlgorithmOverrides}
setShowAlgorithmOverrides={setShowAlgorithmOverrides}
chainedHosts={chainedHosts}
hostChainInherited={hostChainInherited}
setActiveSubPanel={setActiveSubPanel}
clearHostChain={clearHostChain}
proxySummaryType={proxySummaryType}
proxySummaryLabel={proxySummaryLabel}
proxySummaryTooltip={proxySummaryTooltip}
proxyInherited={proxyInherited}
clearProxyConfig={clearProxyConfig}
/>
+3 -1
View File
@@ -34,6 +34,7 @@ export interface ProxyPanelProps {
onClearProxy: () => void;
onBack: () => void;
onCancel: () => void;
allowClear?: boolean;
layout?: AsidePanelLayout;
}
@@ -51,6 +52,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
onClearProxy,
onBack,
onCancel,
allowClear = true,
layout = 'overlay',
resizable,
persistWidthStorageKey,
@@ -346,7 +348,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
</>
)}
{(proxyConfig?.host || proxyConfig?.command || selectedProxyProfileId || directProxy) && (
{allowClear && (proxyConfig?.host || proxyConfig?.command || selectedProxyProfileId || directProxy) && (
<Button variant="ghost" className="w-full h-10 text-destructive" onClick={onClearProxy}>
<Trash2 size={14} className="mr-2" /> {t('hostDetails.proxyPanel.remove')}
</Button>