631 lines
26 KiB
TypeScript
631 lines
26 KiB
TypeScript
import React from "react";
|
|
import { AlertTriangle, ChevronDown, ChevronUp, Forward, Globe, HeartPulse, Link2, Palette, Plus, Router, ShieldAlert, TerminalSquare, Wifi, X, Variable } from "lucide-react";
|
|
import { customThemeStore } from "../application/state/customThemeStore";
|
|
import { clearHostFontSizeOverride, clearHostThemeOverride } from "../domain/terminalAppearance";
|
|
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 } 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";
|
|
|
|
// 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; onToggle: () => void }> = ({ label, hint, enabled, onToggle }) => {
|
|
return (
|
|
<HostDetailsSettingRow label={label} hint={hint}>
|
|
<Switch checked={enabled} onCheckedChange={() => onToggle()} />
|
|
</HostDetailsSettingRow>
|
|
);
|
|
};
|
|
|
|
export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsProps> = ({
|
|
t,
|
|
form,
|
|
setForm,
|
|
update,
|
|
effectiveThemeId,
|
|
hasEffectiveThemeOverride,
|
|
effectiveFontSize,
|
|
hasEffectiveFontSizeOverride,
|
|
sshAgentStatus,
|
|
effectiveGroupDefaults,
|
|
showAlgorithmOverrides,
|
|
setShowAlgorithmOverrides,
|
|
chainedHosts,
|
|
setActiveSubPanel,
|
|
clearHostChain,
|
|
proxySummaryType,
|
|
proxySummaryLabel,
|
|
proxySummaryTooltip,
|
|
clearProxyConfig,
|
|
groupDefaults,
|
|
}) => (
|
|
<>
|
|
<HostDetailsSection
|
|
icon={<Palette size={14} className="text-muted-foreground" />}
|
|
title={t("hostDetails.section.appearance")}
|
|
>
|
|
|
|
{/* SSH Theme Selection */}
|
|
<button
|
|
type="button"
|
|
className="w-full flex items-center gap-3 p-2 rounded-lg bg-secondary/50 hover:bg-secondary transition-colors 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"
|
|
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">
|
|
{customThemeStore.getThemeById(effectiveThemeId)?.name || "Flexoki Dark"}
|
|
</span>
|
|
</button>
|
|
{hasEffectiveThemeOverride && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="w-full justify-start text-primary"
|
|
onClick={() => setForm((prev) => clearHostThemeOverride(prev))}
|
|
>
|
|
{t("common.useGlobal")}
|
|
</Button>
|
|
)}
|
|
|
|
{/* Font Size */}
|
|
<HostDetailsSettingRow label="Font Size">
|
|
<div className="flex items-center gap-2">
|
|
<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>
|
|
{hasEffectiveFontSizeOverride && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 text-primary"
|
|
onClick={() => setForm((prev) => clearHostFontSizeOverride(prev))}
|
|
>
|
|
{t("common.useGlobal")}
|
|
</Button>
|
|
)}
|
|
<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>
|
|
</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,
|
|
deviceType: prev.deviceType === 'network' ? undefined : prev.deviceType,
|
|
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,
|
|
deviceType: prev.deviceType === 'network' ? undefined : prev.deviceType,
|
|
x11Forwarding: undefined,
|
|
}));
|
|
} else {
|
|
update("etEnabled", false);
|
|
}
|
|
}}
|
|
/>
|
|
{form.etEnabled && (
|
|
<>
|
|
<HostDetailsSettingRow label={t("hostDetails.et.port")} hint={t("hostDetails.et.port.desc")}>
|
|
<Input
|
|
type="number"
|
|
className="w-28"
|
|
placeholder="2022"
|
|
value={form.etPort ?? ""}
|
|
onChange={(e) => {
|
|
const v = e.target.value.trim();
|
|
update("etPort", v === "" ? undefined : Number(v));
|
|
}}
|
|
/>
|
|
</HostDetailsSettingRow>
|
|
</>
|
|
)}
|
|
</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 && 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.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>
|
|
)}
|
|
|
|
{/* Network Device Mode — only for SSH hosts without Mosh / ET (serial already uses raw mode) */}
|
|
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && !form.etEnabled && (
|
|
<HostDetailsSection
|
|
icon={<Router size={14} className="text-muted-foreground" />}
|
|
title={t("hostDetails.section.deviceType")}
|
|
>
|
|
<ToggleRow
|
|
label={t("hostDetails.deviceType")}
|
|
hint={t("hostDetails.deviceType.desc")}
|
|
enabled={form.deviceType === 'network'}
|
|
onToggle={() => update("deviceType", form.deviceType === 'network' ? undefined : 'network')}
|
|
/>
|
|
{form.deviceType === '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>
|
|
)}
|
|
</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-10 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>
|
|
|
|
{/* 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={groupDefaults?.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}
|
|
/>
|
|
</HostDetailsSection>
|
|
</>
|
|
);
|