[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
810
components/HostDetailsAdvancedSections.tsx
Normal file
810
components/HostDetailsAdvancedSections.tsx
Normal file
@@ -0,0 +1,810 @@
|
||||
import { getHostOsSelection, HOST_OS_SELECTIONS, resolveHostOs } from '../domain/host';
|
||||
import React from "react";
|
||||
import { AlertTriangle, ChevronDown, ChevronUp, Forward, Globe, HeartPulse, KeyRound, Link2, Palette, Plus, Router, ShieldAlert, TerminalSquare, Timer, Wifi, X, Variable } from "lucide-react";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import { clearHostFontSizeOverride, clearHostThemeOverride } from "../domain/terminalAppearance";
|
||||
import { resolveSshAgentToggleUpdate } from "../domain/sshAuth";
|
||||
import { MAX_FONT_SIZE, MIN_FONT_SIZE } from "../infrastructure/config/fonts";
|
||||
import { AlgorithmOverridesPanel } from "./host-details/AlgorithmOverridesPanel";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { HostDetailsSection, HostDetailsSettingRow, HostDetailsOverrideReset } from "./host-details";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
|
||||
import { Input } from "./ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
|
||||
import {
|
||||
DEFAULT_SSH_AUTH_READY_TIMEOUT_SECONDS,
|
||||
DEFAULT_SSH_TCP_CONNECT_TIMEOUT_SECONDS,
|
||||
MAX_SSH_CONNECTION_TIMEOUT_SECONDS,
|
||||
} from "../domain/sshConnectionTimeouts";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type HostDetailsAdvancedSectionsProps = Record<string, any>;
|
||||
|
||||
const ToggleRow: React.FC<{ label: string; hint?: React.ReactNode; enabled: boolean; disabled?: boolean; onToggle: () => void }> = ({ label, hint, enabled, disabled, onToggle }) => {
|
||||
return (
|
||||
<HostDetailsSettingRow label={label} hint={hint}>
|
||||
<Switch checked={enabled} disabled={disabled} onCheckedChange={() => onToggle()} />
|
||||
</HostDetailsSettingRow>
|
||||
);
|
||||
};
|
||||
|
||||
export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsProps> = ({
|
||||
t,
|
||||
form,
|
||||
setForm,
|
||||
update,
|
||||
effectiveThemeId,
|
||||
hasEffectiveThemeOverride,
|
||||
effectiveFontSize,
|
||||
hasEffectiveFontSizeOverride,
|
||||
sshAgentStatus,
|
||||
sshForwardingAgentStatus,
|
||||
effectiveGroupDefaults,
|
||||
effectiveAuthMethod,
|
||||
showAlgorithmOverrides,
|
||||
setShowAlgorithmOverrides,
|
||||
chainedHosts,
|
||||
setActiveSubPanel,
|
||||
clearHostChain,
|
||||
proxySummaryType,
|
||||
proxySummaryLabel,
|
||||
proxySummaryTooltip,
|
||||
clearProxyConfig,
|
||||
}) => {
|
||||
const inheritedDeviceType = effectiveGroupDefaults?.deviceType;
|
||||
const effectiveDeviceType = form.deviceType ?? inheritedDeviceType;
|
||||
const inheritedStartupCommandRunMode = effectiveGroupDefaults?.startupCommandRunMode ?? "paste";
|
||||
const effectiveStartupCommandRunMode = form.startupCommandRunMode ?? inheritedStartupCommandRunMode;
|
||||
const systemSshAgentSupported = effectiveAuthMethod === "auto" || effectiveAuthMethod === "key";
|
||||
const systemSshAgentEnabled = effectiveAuthMethod === "auto"
|
||||
? form.useSshAgent !== false
|
||||
: effectiveAuthMethod === "key" && form.useSshAgent === true;
|
||||
|
||||
return (
|
||||
<>
|
||||
<HostDetailsSection
|
||||
icon={<Palette size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.appearance")}
|
||||
>
|
||||
|
||||
{/* SSH Theme Selection */}
|
||||
<div className="flex w-full items-center gap-1 rounded-lg bg-secondary/50 p-2 transition-colors hover:bg-secondary">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
onClick={() => setActiveSubPanel("theme-select")}
|
||||
>
|
||||
<div
|
||||
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden shrink-0"
|
||||
style={{
|
||||
backgroundColor:
|
||||
customThemeStore.getThemeById(effectiveThemeId)?.colors.background || "#100F0F",
|
||||
color:
|
||||
customThemeStore.getThemeById(effectiveThemeId)?.colors.foreground || "#CECDC3",
|
||||
}}
|
||||
>
|
||||
<div className="p-0.5">
|
||||
<div
|
||||
style={{
|
||||
color: customThemeStore.getThemeById(effectiveThemeId)?.colors.green,
|
||||
}}
|
||||
>
|
||||
$
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm flex-1 truncate">
|
||||
{customThemeStore.getThemeById(effectiveThemeId)?.name || "Flexoki Dark"}
|
||||
</span>
|
||||
</button>
|
||||
{hasEffectiveThemeOverride && (
|
||||
<HostDetailsOverrideReset
|
||||
label={t("common.useGlobal")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setForm((prev) => clearHostThemeOverride(prev));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Font Size */}
|
||||
<HostDetailsSettingRow label="Font Size">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (effectiveFontSize > MIN_FONT_SIZE) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontSize: effectiveFontSize - 1,
|
||||
fontSizeOverride: true,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
disabled={effectiveFontSize <= MIN_FONT_SIZE}
|
||||
className="h-8 w-8 px-0"
|
||||
>
|
||||
-
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
value={effectiveFontSize}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
if (val >= MIN_FONT_SIZE && val <= MAX_FONT_SIZE) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontSize: val,
|
||||
fontSizeOverride: true,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
className="h-8 w-16 text-center"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">pt</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (effectiveFontSize < MAX_FONT_SIZE) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontSize: effectiveFontSize + 1,
|
||||
fontSizeOverride: true,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
disabled={effectiveFontSize >= MAX_FONT_SIZE}
|
||||
className="h-8 w-8 px-0"
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
{hasEffectiveFontSizeOverride && (
|
||||
<HostDetailsOverrideReset
|
||||
size="sm"
|
||||
label={t("common.useGlobal")}
|
||||
onClick={() => setForm((prev) => clearHostFontSizeOverride(prev))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</HostDetailsSettingRow>
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsSection
|
||||
icon={<Wifi size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.mosh")}
|
||||
>
|
||||
<ToggleRow
|
||||
label="Mosh"
|
||||
enabled={!!form.moshEnabled}
|
||||
onToggle={() => {
|
||||
const enabling = !form.moshEnabled;
|
||||
if (enabling) {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
moshEnabled: true,
|
||||
etEnabled: false,
|
||||
x11Forwarding: undefined,
|
||||
}));
|
||||
} else {
|
||||
update("moshEnabled", false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsSection
|
||||
icon={<Wifi size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.et")}
|
||||
>
|
||||
<ToggleRow
|
||||
label="EternalTerminal"
|
||||
enabled={!!form.etEnabled}
|
||||
onToggle={() => {
|
||||
const enabling = !form.etEnabled;
|
||||
if (enabling) {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
etEnabled: true,
|
||||
moshEnabled: false,
|
||||
x11Forwarding: undefined,
|
||||
}));
|
||||
} else {
|
||||
update("etEnabled", false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{form.etEnabled && (
|
||||
<>
|
||||
<HostDetailsSettingRow label={t("hostDetails.et.port")} hint={t("hostDetails.et.port.desc")}>
|
||||
<Input
|
||||
type="number"
|
||||
className="h-8 w-28"
|
||||
placeholder="2022"
|
||||
value={form.etPort ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
update("etPort", v === "" ? undefined : Number(v));
|
||||
}}
|
||||
/>
|
||||
</HostDetailsSettingRow>
|
||||
</>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* System SSH Agent login */}
|
||||
<HostDetailsSection
|
||||
icon={<KeyRound size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.systemSshAgent")}
|
||||
>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.systemSshAgent")}
|
||||
hint={t("hostDetails.systemSshAgent.desc")}
|
||||
enabled={systemSshAgentEnabled}
|
||||
disabled={!systemSshAgentSupported}
|
||||
onToggle={() => setForm((previous: typeof form) => {
|
||||
const enabled = effectiveAuthMethod === "auto"
|
||||
? previous.useSshAgent !== false
|
||||
: effectiveAuthMethod === "key" && previous.useSshAgent === true;
|
||||
const enabling = !enabled;
|
||||
return {
|
||||
...previous,
|
||||
// Automatic mode treats the ambient agent as an optional first
|
||||
// choice. Keep the unset state when re-enabling that default so
|
||||
// an unavailable agent still falls back to local keys/password.
|
||||
...resolveSshAgentToggleUpdate(previous, effectiveAuthMethod, enabling),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
{systemSshAgentEnabled && (
|
||||
<>
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.systemSshAgent.socket")}
|
||||
hint={t("hostDetails.systemSshAgent.socket.desc")}
|
||||
>
|
||||
<Input
|
||||
className="h-8 w-44 font-mono text-xs"
|
||||
placeholder="$SSH_AUTH_SOCK"
|
||||
value={form.identityAgent ?? ""}
|
||||
onChange={(event) => setForm((previous: typeof form) => ({
|
||||
...previous,
|
||||
useSshAgent: true,
|
||||
identityAgent: event.target.value || undefined,
|
||||
}))}
|
||||
/>
|
||||
</HostDetailsSettingRow>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.systemSshAgent.identitiesOnly")}
|
||||
hint={t("hostDetails.systemSshAgent.identitiesOnly.desc")}
|
||||
enabled={!!form.identitiesOnly}
|
||||
onToggle={() => setForm((previous: typeof form) => ({
|
||||
...previous,
|
||||
useSshAgent: true,
|
||||
identitiesOnly: !previous.identitiesOnly,
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{systemSshAgentEnabled && sshAgentStatus && !sshAgentStatus.running && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 font-medium">
|
||||
{t("hostDetails.systemSshAgent.notRunning")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.systemSshAgent.notRunningHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Agent Forwarding */}
|
||||
<HostDetailsSection
|
||||
icon={<Forward size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.agentForwarding")}
|
||||
>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.agentForwarding")}
|
||||
hint={t("hostDetails.agentForwarding.desc")}
|
||||
enabled={!!form.agentForwarding}
|
||||
onToggle={() => update("agentForwarding", !form.agentForwarding)}
|
||||
/>
|
||||
{form.agentForwarding && sshForwardingAgentStatus && !sshForwardingAgentStatus.running && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 font-medium">
|
||||
{t("hostDetails.agentForwarding.agentNotRunning")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.agentForwarding.agentNotRunningHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* X11 Forwarding */}
|
||||
{(!form.protocol || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
|
||||
<HostDetailsSection
|
||||
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.x11Forwarding")}
|
||||
>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.x11Forwarding")}
|
||||
hint={t("hostDetails.x11Forwarding.desc")}
|
||||
enabled={!!form.x11Forwarding}
|
||||
onToggle={() => update("x11Forwarding", !form.x11Forwarding)}
|
||||
/>
|
||||
</HostDetailsSection>
|
||||
)}
|
||||
|
||||
{(!form.protocol || ['ssh', 'telnet', 'mosh', 'et'].includes(form.protocol)) && (
|
||||
<HostDetailsSection
|
||||
icon={<Router size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.deviceType")}
|
||||
>
|
||||
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && !form.etEnabled && (<>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.deviceType")}
|
||||
hint={t("hostDetails.deviceType.desc")}
|
||||
enabled={effectiveDeviceType === 'network'}
|
||||
onToggle={() => update("deviceType", effectiveDeviceType === 'network' ? 'general' : 'network')}
|
||||
/>
|
||||
{effectiveDeviceType === 'network' && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
|
||||
{t("hostDetails.deviceType.warning")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="group w-full justify-between h-8 px-2 hover:bg-accent/50"
|
||||
aria-label={t("hostDetails.os.title")}
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">{t("hostDetails.os.title")}</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{t(`hostDetails.os.${getHostOsSelection(form)}`)}
|
||||
<ChevronDown size={14} className="group-data-[state=open]:rotate-180" />
|
||||
</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 space-y-2">
|
||||
<HostDetailsSettingRow label={t("hostDetails.os.selection")} hint={t("hostDetails.os.desc")}>
|
||||
<Select value={getHostOsSelection(form)} onValueChange={(value) => update("osOverride", value)}>
|
||||
<SelectTrigger className="h-8 w-36 text-xs" aria-label={t("hostDetails.os.selection")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HOST_OS_SELECTIONS.map((value) => (
|
||||
<SelectItem key={value} value={value}>{t(`hostDetails.os.${value}`)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</HostDetailsSettingRow>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.os.current")}: {t(`hostDetails.os.${resolveHostOs({ ...form, deviceType: effectiveDeviceType })}`)}
|
||||
</p>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</HostDetailsSection>
|
||||
)}
|
||||
|
||||
{/* SSH Algorithms */}
|
||||
<HostDetailsSection
|
||||
icon={<ShieldAlert size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.sshAlgorithms")}
|
||||
>
|
||||
{/* Display the *effective* value of these toggles (host field
|
||||
falling back to the resolved group default). Without the
|
||||
fallback a host that inherits the flag from its group would
|
||||
show "off" while the runtime applied it anyway, and the
|
||||
toggle's onToggle handler would compute the wrong "next"
|
||||
value from the raw host field. */}
|
||||
<ToggleRow
|
||||
label={t("hostDetails.legacyAlgorithms")}
|
||||
hint={t("hostDetails.legacyAlgorithms.desc")}
|
||||
enabled={!!(form.legacyAlgorithms ?? effectiveGroupDefaults?.legacyAlgorithms)}
|
||||
onToggle={() => update(
|
||||
"legacyAlgorithms",
|
||||
!(form.legacyAlgorithms ?? effectiveGroupDefaults?.legacyAlgorithms),
|
||||
)}
|
||||
/>
|
||||
{(form.legacyAlgorithms ?? effectiveGroupDefaults?.legacyAlgorithms) && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
|
||||
{t("hostDetails.legacyAlgorithms.warning")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<ToggleRow
|
||||
label={t("hostDetails.skipEcdsaHostKey")}
|
||||
hint={t("hostDetails.skipEcdsaHostKey.desc")}
|
||||
enabled={!!(form.skipEcdsaHostKey ?? effectiveGroupDefaults?.skipEcdsaHostKey)}
|
||||
onToggle={() => update(
|
||||
"skipEcdsaHostKey",
|
||||
!(form.skipEcdsaHostKey ?? effectiveGroupDefaults?.skipEcdsaHostKey),
|
||||
)}
|
||||
/>
|
||||
<Collapsible open={showAlgorithmOverrides} onOpenChange={setShowAlgorithmOverrides}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between h-8 px-2 hover:bg-accent/50"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("hostDetails.algorithms.advanced")}
|
||||
{form.algorithms && Object.keys(form.algorithms).length > 0 && (
|
||||
<span className="ml-1.5 text-[10px] text-yellow-600 dark:text-yellow-400">
|
||||
({t("hostDetails.algorithms.customized")})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{showAlgorithmOverrides
|
||||
? <ChevronUp size={14} className="text-muted-foreground" />
|
||||
: <ChevronDown size={14} className="text-muted-foreground" />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2">
|
||||
<AlgorithmOverridesPanel
|
||||
value={form.algorithms}
|
||||
/* Use the effective legacy flag (host value falling back to
|
||||
the currently selected group's default) so the seed
|
||||
reflects what the host would actually advertise. We
|
||||
read from `effectiveGroupDefaults` (re-resolved on
|
||||
every form.group change), not the `groupDefaults` prop
|
||||
— otherwise switching the host into a different group
|
||||
without saving first would seed from the original
|
||||
group's flag and silently mis-populate the override. */
|
||||
legacyEnabled={!!(form.legacyAlgorithms ?? effectiveGroupDefaults?.legacyAlgorithms)}
|
||||
inheritedFromGroup={effectiveGroupDefaults?.algorithms}
|
||||
onChange={(next) => update("algorithms", next)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Terminal Behavior — input/output key mappings (backspace, etc.) */}
|
||||
<HostDetailsSection
|
||||
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.terminalBehavior")}
|
||||
>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.lineTimestamps")}
|
||||
hint={t("hostDetails.lineTimestamps.desc")}
|
||||
enabled={!!form.showLineTimestamps}
|
||||
onToggle={() => update("showLineTimestamps", !form.showLineTimestamps)}
|
||||
/>
|
||||
<HostDetailsSettingRow label={t("hostDetails.backspaceBehavior")}>
|
||||
<Select
|
||||
value={form.backspaceBehavior ?? "default"}
|
||||
onValueChange={(v) => update("backspaceBehavior", v === "default" ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-36 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{t("hostDetails.backspaceBehavior.default")}</SelectItem>
|
||||
<SelectItem value="ctrl-h">^H (0x08)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</HostDetailsSettingRow>
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Per-host keepalive override */}
|
||||
<HostDetailsSection
|
||||
icon={<HeartPulse size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.keepalive")}
|
||||
>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.keepalive.override")}
|
||||
hint={t("hostDetails.keepalive.desc")}
|
||||
enabled={!!form.keepaliveOverride}
|
||||
onToggle={() => {
|
||||
const next = !form.keepaliveOverride;
|
||||
update("keepaliveOverride", next);
|
||||
// Seed sensible per-host defaults the first time the user
|
||||
// turns the override on so the inputs aren't empty.
|
||||
if (next) {
|
||||
if (form.keepaliveInterval == null) update("keepaliveInterval", 0);
|
||||
if (form.keepaliveCountMax == null) update("keepaliveCountMax", 3);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{form.keepaliveOverride && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<HostDetailsSettingRow label={t("hostDetails.keepalive.interval")}>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={3600}
|
||||
className="h-8 w-24 text-xs"
|
||||
value={form.keepaliveInterval ?? 0}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!Number.isFinite(v)) return;
|
||||
if (v < 0 || v > 3600) return;
|
||||
update("keepaliveInterval", v);
|
||||
}}
|
||||
/>
|
||||
</HostDetailsSettingRow>
|
||||
<HostDetailsSettingRow label={t("hostDetails.keepalive.countMax")}>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
className="h-8 w-24 text-xs"
|
||||
value={form.keepaliveCountMax ?? 3}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!Number.isFinite(v)) return;
|
||||
if (v < 1 || v > 100) return;
|
||||
update("keepaliveCountMax", v);
|
||||
}}
|
||||
/>
|
||||
</HostDetailsSettingRow>
|
||||
{(form.keepaliveInterval ?? 0) === 0 && (
|
||||
<p className="text-xs text-muted-foreground break-words pl-1">
|
||||
{t("hostDetails.keepalive.disabledHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsSection
|
||||
icon={<Timer size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.sshTimeouts")}
|
||||
>
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.sshTimeouts.tcpConnect")}
|
||||
hint={t("hostDetails.sshTimeouts.tcpConnect.desc")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_SSH_CONNECTION_TIMEOUT_SECONDS}
|
||||
className="h-8 w-20 text-xs"
|
||||
aria-label={t("hostDetails.sshTimeouts.tcpConnect")}
|
||||
value={form.sshTcpConnectTimeoutSeconds ?? DEFAULT_SSH_TCP_CONNECT_TIMEOUT_SECONDS}
|
||||
onChange={(e) => {
|
||||
const value = Number.parseInt(e.target.value, 10);
|
||||
if (!Number.isFinite(value) || value < 1 || value > MAX_SSH_CONNECTION_TIMEOUT_SECONDS) return;
|
||||
update("sshTcpConnectTimeoutSeconds", value);
|
||||
}}
|
||||
/>
|
||||
</HostDetailsSettingRow>
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.sshTimeouts.authReady")}
|
||||
hint={t("hostDetails.sshTimeouts.authReady.desc")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_SSH_CONNECTION_TIMEOUT_SECONDS}
|
||||
className="h-8 w-20 text-xs"
|
||||
aria-label={t("hostDetails.sshTimeouts.authReady")}
|
||||
value={form.sshAuthReadyTimeoutSeconds ?? DEFAULT_SSH_AUTH_READY_TIMEOUT_SECONDS}
|
||||
onChange={(e) => {
|
||||
const value = Number.parseInt(e.target.value, 10);
|
||||
if (!Number.isFinite(value) || value < 1 || value > MAX_SSH_CONNECTION_TIMEOUT_SECONDS) return;
|
||||
update("sshAuthReadyTimeoutSeconds", value);
|
||||
}}
|
||||
/>
|
||||
</HostDetailsSettingRow>
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
|
||||
<HostDetailsSection
|
||||
icon={<Link2 size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.jumpHosts")}
|
||||
action={
|
||||
chainedHosts.length > 0 ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.jumpHosts.direct")}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
>
|
||||
{chainedHosts.length > 0 && (
|
||||
<button
|
||||
className="w-full flex flex-col items-start gap-1 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer"
|
||||
onClick={() => setActiveSubPanel("chain")}
|
||||
>
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1">
|
||||
<Link2
|
||||
size={14}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
size={14}
|
||||
className="text-muted-foreground hover:text-destructive flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearHostChain();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full space-y-1 pl-5">
|
||||
{chainedHosts.slice(0, 5).map((h, idx) => (
|
||||
<div key={h.id} className="flex items-center gap-1 text-sm">
|
||||
<span className="text-muted-foreground">{idx + 1}.</span>
|
||||
<span className="truncate">
|
||||
{h.label !== h.hostname ? `${h.hostname} (${h.label})` : h.hostname}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{chainedHosts.length > 5 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
+{chainedHosts.length - 5} more...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{chainedHosts.length === 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full h-9 justify-start gap-2 text-sm"
|
||||
onClick={() => setActiveSubPanel("chain")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("hostDetails.jumpHosts.configure")}
|
||||
</Button>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Proxy Configuration */}
|
||||
<HostDetailsSection
|
||||
icon={<Globe size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.proxy")}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId ? (
|
||||
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer overflow-hidden"
|
||||
onClick={() => setActiveSubPanel("proxy")}
|
||||
>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{proxySummaryType}
|
||||
</Badge>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
{proxySummaryLabel}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-xs break-all">
|
||||
{proxySummaryTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive shrink-0"
|
||||
aria-label={t("hostDetails.proxyPanel.remove")}
|
||||
onClick={clearProxyConfig}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full h-9 justify-start gap-2 text-sm"
|
||||
onClick={() => setActiveSubPanel("proxy")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("hostDetails.proxy.configure")}
|
||||
</Button>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<HostDetailsSection
|
||||
icon={<Variable size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.envVars")}
|
||||
>
|
||||
{(form.environmentVariables?.length || 0) > 0 ? (
|
||||
<button
|
||||
className="w-full flex items-center gap-1 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer"
|
||||
onClick={() => setActiveSubPanel("env-vars")}
|
||||
>
|
||||
<span className="text-sm truncate">
|
||||
{form.environmentVariables
|
||||
?.slice(0, 2)
|
||||
.map((v) => `${v.name}=${v.value}`)
|
||||
.join(", ")}
|
||||
{(form.environmentVariables?.length || 0) > 2 && "..."}
|
||||
</span>
|
||||
<X
|
||||
size={14}
|
||||
className="text-muted-foreground hover:text-destructive flex-shrink-0 ml-auto"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setForm((prev) => ({ ...prev, environmentVariables: undefined }));
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full h-9 justify-start gap-2 text-sm"
|
||||
onClick={() => setActiveSubPanel("env-vars")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("hostDetails.envVars.add")}
|
||||
</Button>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Startup Command */}
|
||||
<HostDetailsSection
|
||||
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.startupCommand")}
|
||||
hint={t("hostDetails.startupCommand.help")}
|
||||
>
|
||||
<Textarea
|
||||
placeholder={effectiveGroupDefaults?.startupCommand || t("hostDetails.startupCommand.placeholder")}
|
||||
value={form.startupCommand || ""}
|
||||
onChange={(e) => update("startupCommand", e.target.value)}
|
||||
className="min-h-[80px] font-mono text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.startupCommand.runMode")}
|
||||
hint={t("hostDetails.startupCommand.runMode.help")}
|
||||
>
|
||||
<Select
|
||||
value={effectiveStartupCommandRunMode}
|
||||
onValueChange={(value) => update(
|
||||
"startupCommandRunMode",
|
||||
value === inheritedStartupCommandRunMode ? undefined : value,
|
||||
)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="lineDelay">{t("hostDetails.startupCommand.runMode.lineDelay")}</SelectItem>
|
||||
<SelectItem value="paste">{t("hostDetails.startupCommand.runMode.paste")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</HostDetailsSettingRow>
|
||||
</HostDetailsSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user