999 lines
34 KiB
TypeScript
999 lines
34 KiB
TypeScript
import {
|
|
Check,
|
|
Eye,
|
|
EyeOff,
|
|
FileText,
|
|
FolderPlus,
|
|
Plus,
|
|
Settings2,
|
|
Tag,
|
|
X,
|
|
} from "lucide-react";
|
|
import React, { useEffect, useMemo, useState, useCallback } from "react";
|
|
import { useI18n } from "../application/i18n/I18nProvider";
|
|
import { useApplicationBackend } from "../application/state/useApplicationBackend";
|
|
import { resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/groupConfig";
|
|
import {
|
|
getEffectiveHostDistro,
|
|
normalizePrimaryTelnetState,
|
|
} from "../domain/host";
|
|
import {
|
|
formatProxyConfigEndpoint,
|
|
formatProxyConfigType,
|
|
isCompleteProxyConfig,
|
|
normalizeManualProxyConfig,
|
|
} from "../domain/proxyProfiles";
|
|
import { customThemeStore } from "../application/state/customThemeStore";
|
|
import {
|
|
hasHostFontSizeOverride,
|
|
hasHostThemeOverride,
|
|
resolveHostTerminalFontSize,
|
|
resolveHostTerminalThemeId,
|
|
} from "../domain/terminalAppearance";
|
|
import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, ProxyProfile, SSHKey } from "../types";
|
|
import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar";
|
|
import ThemeSelectPanel from "./ThemeSelectPanel";
|
|
import {
|
|
AsidePanel,
|
|
AsidePanelContent,
|
|
AsidePanelFooter,
|
|
type AsidePanelLayout,
|
|
} from "./ui/aside-panel";
|
|
import { HostDetailsAdvancedSections } from "./HostDetailsAdvancedSections";
|
|
import { HostDetailsConnectionSections } from "./HostDetailsConnectionSections";
|
|
import {
|
|
LINUX_DISTRO_OPTION_IDS,
|
|
parseOptionalPortInput,
|
|
resolveDetailsTelnetPassword,
|
|
resolveDetailsTelnetPort,
|
|
resolveDetailsTelnetUsername,
|
|
resolvePrimaryProtocolSavePort,
|
|
resolvePrimaryProtocolSwitchPort,
|
|
} from "./HostDetailsPanel.helpers";
|
|
export { parseOptionalPortInput } from "./HostDetailsPanel.helpers";
|
|
import { Button } from "./ui/button";
|
|
import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
|
|
import { Input } from "./ui/input";
|
|
import { Switch } from "./ui/switch";
|
|
import { toast } from "./ui/toast";
|
|
|
|
import {
|
|
ChainPanel,
|
|
CreateGroupPanel,
|
|
HostDetailsSection,
|
|
EnvVarsPanel,
|
|
ProxyPanel,
|
|
} from "./host-details";
|
|
import { HostNotesEditor } from "./host/HostNotesEditor";
|
|
|
|
type CredentialType = "sshid" | "key" | "certificate" | "localKeyFile" | null;
|
|
type SubPanel =
|
|
| "none"
|
|
| "create-group"
|
|
| "proxy"
|
|
| "chain"
|
|
| "env-vars"
|
|
| "theme-select"
|
|
| "telnet-theme-select";
|
|
|
|
interface HostDetailsPanelProps {
|
|
initialData?: Host | null;
|
|
availableKeys: SSHKey[];
|
|
identities: Identity[];
|
|
proxyProfiles?: ProxyProfile[];
|
|
groups: string[];
|
|
managedSources?: ManagedSource[];
|
|
allTags?: string[]; // All available tags for autocomplete
|
|
allHosts?: Host[]; // All hosts for chain selection
|
|
defaultGroup?: string | null; // Default group for new hosts (from current navigation)
|
|
terminalThemeId: string;
|
|
terminalFontSize: number;
|
|
onSave: (host: Host) => void;
|
|
onCancel: () => void;
|
|
onCreateGroup?: (groupPath: string) => void; // Callback to create a new group
|
|
onCreateTag?: (tag: string) => void; // Callback to create a new tag
|
|
groupDefaults?: Partial<import('../domain/models').GroupConfig>;
|
|
groupConfigs?: GroupConfig[];
|
|
layout?: AsidePanelLayout;
|
|
onImportKey?: (draft: Partial<SSHKey>) => SSHKey;
|
|
}
|
|
|
|
const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
|
initialData,
|
|
availableKeys,
|
|
identities,
|
|
proxyProfiles = [],
|
|
groups,
|
|
managedSources = [],
|
|
allTags = [],
|
|
allHosts = [],
|
|
defaultGroup,
|
|
terminalThemeId,
|
|
terminalFontSize,
|
|
onSave,
|
|
onCancel,
|
|
onCreateGroup,
|
|
onCreateTag,
|
|
groupDefaults,
|
|
groupConfigs = [],
|
|
layout = "overlay",
|
|
onImportKey,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const { checkSshAgent } = useApplicationBackend();
|
|
const [form, setForm] = useState<Host>(
|
|
() =>
|
|
(initialData ? normalizePrimaryTelnetState(initialData) : null) ||
|
|
({
|
|
id: crypto.randomUUID(),
|
|
label: "",
|
|
hostname: "",
|
|
port: groupDefaults?.port ? undefined : 22,
|
|
username: groupDefaults?.username ? "" : "root",
|
|
protocol: "ssh",
|
|
tags: [],
|
|
os: "linux",
|
|
authMethod: "password",
|
|
charset: groupDefaults?.charset ? undefined : "UTF-8",
|
|
distroMode: "auto",
|
|
createdAt: Date.now(),
|
|
group: defaultGroup || undefined, // Pre-fill with current navigation group
|
|
} as Host),
|
|
);
|
|
|
|
const [activeSubPanel, setActiveSubPanel] = useState<SubPanel>("none");
|
|
|
|
const [credentialPopoverOpen, setCredentialPopoverOpen] = useState(false);
|
|
const [selectedCredentialType, setSelectedCredentialType] =
|
|
useState<CredentialType>(null);
|
|
|
|
const [identitySuggestionsOpen, setIdentitySuggestionsOpen] = useState(false);
|
|
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [showTelnetPassword, setShowTelnetPassword] = useState(false);
|
|
const [showAlgorithmOverrides, setShowAlgorithmOverrides] = useState(false);
|
|
|
|
const [newKeyFilePath, setNewKeyFilePath] = useState("");
|
|
const [pendingReferenceKeyPath, setPendingReferenceKeyPath] = useState<string | null>(null);
|
|
|
|
const [newGroupName, setNewGroupName] = useState("");
|
|
const [newGroupParent, setNewGroupParent] = useState("");
|
|
|
|
const [sshAgentStatus, setSshAgentStatus] = useState<{
|
|
running: boolean;
|
|
startupType: string | null;
|
|
error: string | null;
|
|
} | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (form.agentForwarding) {
|
|
checkSshAgent().then(setSshAgentStatus);
|
|
} else {
|
|
setSshAgentStatus(null);
|
|
}
|
|
}, [form.agentForwarding, checkSshAgent]);
|
|
|
|
const [groupInputValue, setGroupInputValue] = useState(form.group || "");
|
|
|
|
useEffect(() => {
|
|
if (initialData) {
|
|
setForm(normalizePrimaryTelnetState(initialData));
|
|
setGroupInputValue(initialData.group || "");
|
|
setPendingReferenceKeyPath(null);
|
|
setShowPassword(false);
|
|
setShowTelnetPassword(false);
|
|
}
|
|
}, [initialData]);
|
|
|
|
const update = <K extends keyof Host>(key: K, value: Host[K]) => {
|
|
setForm((prev) => ({ ...prev, [key]: value }));
|
|
};
|
|
|
|
const addLocalKeyFilePath = useCallback((path: string) => {
|
|
const trimmed = path.trim();
|
|
if (!trimmed) return;
|
|
setForm((prev) => ({
|
|
...prev,
|
|
identityFilePaths: onImportKey ? [trimmed] : [...(prev.identityFilePaths || []), trimmed],
|
|
identityFileId: undefined,
|
|
authMethod: "key",
|
|
}));
|
|
setPendingReferenceKeyPath(onImportKey ? trimmed : null);
|
|
setNewKeyFilePath("");
|
|
setSelectedCredentialType(null);
|
|
}, [onImportKey]);
|
|
|
|
const effectiveGroupDefaults = useMemo(() => {
|
|
const currentGroupPath = form.group || defaultGroup;
|
|
if (currentGroupPath && groupConfigs.length > 0) {
|
|
return resolveGroupDefaults(currentGroupPath, groupConfigs);
|
|
}
|
|
return groupDefaults;
|
|
}, [defaultGroup, form.group, groupConfigs, groupDefaults]);
|
|
|
|
const effectiveThemeId = useMemo(
|
|
() => resolveHostTerminalThemeId(form, resolveGroupTerminalThemeId(effectiveGroupDefaults, terminalThemeId)),
|
|
[effectiveGroupDefaults, form, terminalThemeId],
|
|
);
|
|
const effectiveFontSize = useMemo(
|
|
() => resolveHostTerminalFontSize(form, terminalFontSize),
|
|
[form, terminalFontSize],
|
|
);
|
|
const hasEffectiveThemeOverride = useMemo(
|
|
() => hasHostThemeOverride(form),
|
|
[form],
|
|
);
|
|
const hasEffectiveFontSizeOverride = useMemo(
|
|
() => hasHostFontSizeOverride(form),
|
|
[form],
|
|
);
|
|
const effectiveTelnetThemeId =
|
|
form.protocols?.find((p) => p.protocol === "telnet")?.theme || effectiveThemeId;
|
|
const effectiveTelnetPort = resolveDetailsTelnetPort(form, effectiveGroupDefaults);
|
|
const effectiveTelnetUsername = resolveDetailsTelnetUsername(form, effectiveGroupDefaults);
|
|
const effectiveTelnetPassword = resolveDetailsTelnetPassword(form, effectiveGroupDefaults);
|
|
const distroOptions = useMemo(
|
|
() =>
|
|
LINUX_DISTRO_OPTION_IDS.map((value) => ({
|
|
value,
|
|
label: t(`hostDetails.distro.option.${value}`),
|
|
icon: DISTRO_LOGOS[value],
|
|
bgClass: DISTRO_COLORS[value] || DISTRO_COLORS.default,
|
|
})),
|
|
[t],
|
|
);
|
|
|
|
const getDistroOptionLabel = useCallback(
|
|
(value?: string) =>
|
|
distroOptions.find((option) => option.value === value)?.label ||
|
|
value ||
|
|
t("hostDetails.distro.pending"),
|
|
[distroOptions, t],
|
|
);
|
|
|
|
const effectiveFormDistro = getEffectiveHostDistro(form);
|
|
const selectedProxyProfile = useMemo(
|
|
() => proxyProfiles.find((profile) => profile.id === form.proxyProfileId),
|
|
[form.proxyProfileId, proxyProfiles],
|
|
);
|
|
const hasMissingProxyProfile = Boolean(form.proxyProfileId && !selectedProxyProfile);
|
|
const proxySummaryType = hasMissingProxyProfile
|
|
? t("hostDetails.proxyPanel.missing")
|
|
: formatProxyConfigType(selectedProxyProfile?.config || form.proxyConfig) || "HTTP";
|
|
const proxySummaryLabel = hasMissingProxyProfile
|
|
? t("hostDetails.proxyPanel.missingSaved")
|
|
: selectedProxyProfile
|
|
? selectedProxyProfile.label
|
|
: formatProxyConfigEndpoint(form.proxyConfig);
|
|
const proxySummaryTooltip = hasMissingProxyProfile
|
|
? t("hostDetails.proxyPanel.missingSaved")
|
|
: selectedProxyProfile
|
|
? `${selectedProxyProfile.label} - ${formatProxyConfigEndpoint(selectedProxyProfile.config)}`
|
|
: `${formatProxyConfigType(form.proxyConfig)} ${formatProxyConfigEndpoint(form.proxyConfig)}`;
|
|
|
|
const handleDistroModeChange = useCallback((mode: "auto" | "manual") => {
|
|
setForm((prev) => ({
|
|
...prev,
|
|
distroMode: mode,
|
|
manualDistro:
|
|
mode === "manual"
|
|
? prev.manualDistro || getEffectiveHostDistro(prev) || "linux"
|
|
: prev.manualDistro,
|
|
}));
|
|
}, []);
|
|
|
|
const updateProxyConfig = useCallback(
|
|
(field: keyof ProxyConfig, value: string | number) => {
|
|
setForm((prev) => {
|
|
const { proxyProfileId: _proxyProfileId, ...rest } = prev;
|
|
return {
|
|
...rest,
|
|
proxyConfig: {
|
|
type: prev.proxyConfig?.type || "http",
|
|
host: prev.proxyConfig?.host || "",
|
|
port: prev.proxyConfig?.port || 8080,
|
|
...prev.proxyConfig,
|
|
[field]: value,
|
|
},
|
|
} as Host;
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const clearProxyConfig = useCallback(() => {
|
|
setForm((prev) => {
|
|
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
|
|
return rest as Host;
|
|
});
|
|
}, []);
|
|
|
|
const selectProxyProfile = useCallback((profileId: string | undefined) => {
|
|
setForm((prev) => {
|
|
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
|
|
if (!profileId) return rest as Host;
|
|
return { ...rest, proxyProfileId: profileId } as Host;
|
|
});
|
|
}, []);
|
|
|
|
const addHostToChain = (hostId: string) => {
|
|
setForm((prev) => ({
|
|
...prev,
|
|
hostChain: {
|
|
hostIds: [...(prev.hostChain?.hostIds || []), 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 };
|
|
});
|
|
};
|
|
|
|
const clearHostChain = useCallback(() => {
|
|
setForm((prev) => {
|
|
const { hostChain: _hostChain, ...rest } = prev;
|
|
return rest as Host;
|
|
});
|
|
}, []);
|
|
|
|
const [newEnvName, setNewEnvName] = useState("");
|
|
const [newEnvValue, setNewEnvValue] = useState("");
|
|
|
|
const addEnvVar = () => {
|
|
if (!newEnvName.trim()) return;
|
|
const newVar: EnvVar = { name: newEnvName.trim(), value: newEnvValue };
|
|
setForm((prev) => ({
|
|
...prev,
|
|
environmentVariables: [...(prev.environmentVariables || []), newVar],
|
|
}));
|
|
setNewEnvName("");
|
|
setNewEnvValue("");
|
|
};
|
|
|
|
const removeEnvVar = (index: number) => {
|
|
setForm((prev) => {
|
|
const filtered = (prev.environmentVariables || []).filter((_, i) => i !== index);
|
|
return { ...prev, environmentVariables: filtered.length > 0 ? filtered : undefined };
|
|
});
|
|
};
|
|
|
|
const handleSubmit = () => {
|
|
if (!form.hostname) return;
|
|
const normalizedProxyConfig = normalizeManualProxyConfig(form.proxyConfig);
|
|
if (normalizedProxyConfig && !isCompleteProxyConfig(normalizedProxyConfig)) {
|
|
toast.error(
|
|
normalizedProxyConfig.host ? t("proxyProfiles.error.port") : t("hostDetails.proxyPanel.error.required"),
|
|
);
|
|
setActiveSubPanel("proxy");
|
|
return;
|
|
}
|
|
if (hasMissingProxyProfile) {
|
|
toast.error(t("hostDetails.proxyPanel.missingSaved"));
|
|
setActiveSubPanel("proxy");
|
|
return;
|
|
}
|
|
let finalLabel = form.label?.trim() || form.hostname;
|
|
const finalGroup = groupInputValue.trim() || form.group || "";
|
|
|
|
const targetManagedSource = managedSources
|
|
.filter(s => finalGroup === s.groupName || finalGroup.startsWith(s.groupName + "/"))
|
|
.sort((a, b) => b.groupName.length - a.groupName.length)[0];
|
|
|
|
const canBeManaged = !form.protocol || form.protocol === "ssh";
|
|
|
|
if (targetManagedSource && canBeManaged) {
|
|
finalLabel = finalLabel.replace(/\s/g, '');
|
|
}
|
|
|
|
let finalManagedSourceId: string | undefined;
|
|
if (targetManagedSource && canBeManaged) {
|
|
finalManagedSourceId = targetManagedSource.id;
|
|
} else if (managedSources.length === 0 && form.managedSourceId && canBeManaged) {
|
|
finalManagedSourceId = form.managedSourceId;
|
|
} else {
|
|
finalManagedSourceId = undefined;
|
|
}
|
|
|
|
const { proxyConfig: _draftProxyConfig, ...formWithoutProxyDraft } = form;
|
|
const finalPort = resolvePrimaryProtocolSavePort(
|
|
form.protocol,
|
|
form.port,
|
|
Boolean(groupDefaults?.port),
|
|
Boolean(groupDefaults?.telnetPort),
|
|
);
|
|
let cleaned: Host = {
|
|
...formWithoutProxyDraft,
|
|
...(normalizedProxyConfig && { proxyConfig: normalizedProxyConfig }),
|
|
label: finalLabel,
|
|
group: finalGroup,
|
|
tags: form.tags || [],
|
|
notes: form.notes?.trim() || undefined,
|
|
port: finalPort,
|
|
password: form.savePassword === false ? undefined : form.password,
|
|
managedSourceId: finalManagedSourceId,
|
|
};
|
|
cleaned = normalizePrimaryTelnetState(cleaned);
|
|
if (
|
|
onImportKey &&
|
|
pendingReferenceKeyPath &&
|
|
cleaned.identityFilePaths?.includes(pendingReferenceKeyPath)
|
|
) {
|
|
const fileName = pendingReferenceKeyPath.split('/').pop() || pendingReferenceKeyPath;
|
|
const key = onImportKey({
|
|
source: 'reference',
|
|
filePath: pendingReferenceKeyPath,
|
|
label: fileName,
|
|
privateKey: '',
|
|
category: 'key',
|
|
});
|
|
cleaned = {
|
|
...cleaned,
|
|
identityFileId: key.id,
|
|
identityFilePaths: [pendingReferenceKeyPath],
|
|
authMethod: "key",
|
|
};
|
|
}
|
|
const preserveLegacyTheme = initialData?.theme != null && cleaned.themeOverride !== false;
|
|
const preserveLegacyFontFamily = initialData?.fontFamily != null && cleaned.fontFamilyOverride !== false;
|
|
const preserveLegacyFontSize = initialData?.fontSize != null && cleaned.fontSizeOverride !== false;
|
|
|
|
if (cleaned.themeOverride === false) {
|
|
delete cleaned.theme;
|
|
} else if (preserveLegacyTheme && cleaned.theme == null) {
|
|
cleaned.theme = initialData?.theme;
|
|
}
|
|
|
|
if (cleaned.fontFamilyOverride === false) {
|
|
delete cleaned.fontFamily;
|
|
} else if (preserveLegacyFontFamily && cleaned.fontFamily == null) {
|
|
cleaned.fontFamily = initialData?.fontFamily;
|
|
}
|
|
|
|
if (cleaned.fontSizeOverride === false) {
|
|
delete cleaned.fontSize;
|
|
} else if (preserveLegacyFontSize && cleaned.fontSize == null) {
|
|
cleaned.fontSize = initialData?.fontSize;
|
|
}
|
|
|
|
if ((cleaned.protocol && cleaned.protocol !== "ssh") || cleaned.moshEnabled || cleaned.etEnabled) {
|
|
delete cleaned.x11Forwarding;
|
|
}
|
|
onSave(cleaned);
|
|
};
|
|
|
|
const handleCreateGroup = () => {
|
|
if (!newGroupName.trim()) return;
|
|
const fullPath = newGroupParent
|
|
? `${newGroupParent}/${newGroupName.trim()}`
|
|
: newGroupName.trim();
|
|
onCreateGroup?.(fullPath);
|
|
setGroupInputValue(fullPath);
|
|
update("group", fullPath);
|
|
setNewGroupName("");
|
|
setNewGroupParent("");
|
|
setActiveSubPanel("none");
|
|
};
|
|
|
|
const availableHostsForChain = useMemo(() => {
|
|
const chainedIds = new Set(form.hostChain?.hostIds || []);
|
|
return allHosts.filter((h) => h.id !== form.id && !chainedIds.has(h.id));
|
|
}, [allHosts, form.id, form.hostChain?.hostIds]);
|
|
|
|
const chainedHosts = useMemo(() => {
|
|
const ids = form.hostChain?.hostIds || [];
|
|
return ids
|
|
.map((id) => allHosts.find((h) => h.id === id))
|
|
.filter(Boolean) as Host[];
|
|
}, [allHosts, form.hostChain?.hostIds]);
|
|
|
|
const groupOptions: ComboboxOption[] = useMemo(() => {
|
|
return groups.map((g) => ({
|
|
value: g,
|
|
label: g.includes("/") ? g.split("/").pop()! : g,
|
|
sublabel: g.includes("/") ? g : undefined,
|
|
}));
|
|
}, [groups]);
|
|
|
|
const tagOptions: ComboboxOption[] = useMemo(() => {
|
|
const allTagSet = new Set([...allTags, ...(form.tags || [])]);
|
|
return Array.from(allTagSet).map((t) => ({ value: t, label: t }));
|
|
}, [allTags, form.tags]);
|
|
|
|
const keysByCategory = useMemo(() => {
|
|
return {
|
|
key: availableKeys.filter((k) => k.category === "key"),
|
|
certificate: availableKeys.filter((k) => k.category === "certificate"),
|
|
identity: availableKeys.filter((k) => k.category === "identity"),
|
|
};
|
|
}, [availableKeys]);
|
|
|
|
const selectedIdentity = useMemo(() => {
|
|
if (!form.identityId) return undefined;
|
|
return identities.find((i) => i.id === form.identityId);
|
|
}, [form.identityId, identities]);
|
|
|
|
const filteredIdentitySuggestions = useMemo(() => {
|
|
if (selectedIdentity) return [];
|
|
const q = (form.username || "").toLowerCase().trim();
|
|
const base = identities;
|
|
const filtered = q
|
|
? base.filter(
|
|
(i) =>
|
|
i.label.toLowerCase().includes(q) ||
|
|
i.username.toLowerCase().includes(q),
|
|
)
|
|
: base;
|
|
return filtered.slice(0, 6);
|
|
}, [form.username, identities, selectedIdentity]);
|
|
|
|
useEffect(() => {
|
|
if (!identitySuggestionsOpen) return;
|
|
if (filteredIdentitySuggestions.length === 0) {
|
|
setIdentitySuggestionsOpen(false);
|
|
}
|
|
}, [filteredIdentitySuggestions.length, identitySuggestionsOpen]);
|
|
|
|
const applyIdentity = useCallback(
|
|
(identity: Identity) => {
|
|
setForm((prev) => ({
|
|
...prev,
|
|
identityId: identity.id,
|
|
username: identity.username,
|
|
authMethod: identity.authMethod,
|
|
password: undefined,
|
|
identityFileId: undefined,
|
|
identityFilePaths: undefined,
|
|
}));
|
|
setPendingReferenceKeyPath(null);
|
|
setSelectedCredentialType(null);
|
|
setCredentialPopoverOpen(false);
|
|
setIdentitySuggestionsOpen(false);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const clearIdentity = useCallback(() => {
|
|
setForm((prev) => ({ ...prev, identityId: undefined }));
|
|
setIdentitySuggestionsOpen(false);
|
|
}, []);
|
|
|
|
if (activeSubPanel === "create-group") {
|
|
return (
|
|
<CreateGroupPanel
|
|
newGroupName={newGroupName}
|
|
setNewGroupName={setNewGroupName}
|
|
newGroupParent={newGroupParent}
|
|
setNewGroupParent={setNewGroupParent}
|
|
groups={groups}
|
|
onSave={handleCreateGroup}
|
|
onBack={() => setActiveSubPanel("none")}
|
|
onCancel={onCancel}
|
|
layout={layout}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (activeSubPanel === "proxy") {
|
|
return (
|
|
<ProxyPanel
|
|
proxyConfig={form.proxyConfig}
|
|
proxyProfiles={proxyProfiles}
|
|
selectedProxyProfileId={form.proxyProfileId}
|
|
onUpdateProxy={updateProxyConfig}
|
|
onSelectProxyProfile={selectProxyProfile}
|
|
onClearProxy={clearProxyConfig}
|
|
onBack={() => setActiveSubPanel("none")}
|
|
onCancel={onCancel}
|
|
layout={layout}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (activeSubPanel === "chain") {
|
|
return (
|
|
<ChainPanel
|
|
formLabel={form.label}
|
|
formHostname={form.hostname}
|
|
form={form}
|
|
chainedHosts={chainedHosts}
|
|
availableHostsForChain={availableHostsForChain}
|
|
onAddHost={addHostToChain}
|
|
onRemoveHost={removeHostFromChain}
|
|
onClearChain={clearHostChain}
|
|
onBack={() => setActiveSubPanel("none")}
|
|
onCancel={onCancel}
|
|
layout={layout}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (activeSubPanel === "env-vars") {
|
|
return (
|
|
<EnvVarsPanel
|
|
hostLabel={form.label}
|
|
hostHostname={form.hostname}
|
|
environmentVariables={form.environmentVariables || []}
|
|
newEnvName={newEnvName}
|
|
newEnvValue={newEnvValue}
|
|
setNewEnvName={setNewEnvName}
|
|
setNewEnvValue={setNewEnvValue}
|
|
onAddEnvVar={addEnvVar}
|
|
onRemoveEnvVar={removeEnvVar}
|
|
onUpdateEnvVar={(index, field, value) => {
|
|
const newVars = [...(form.environmentVariables || [])];
|
|
newVars[index] = { ...newVars[index], [field]: value };
|
|
setForm((prev) => ({ ...prev, environmentVariables: newVars }));
|
|
}}
|
|
onSave={() => {
|
|
if (newEnvName.trim()) addEnvVar();
|
|
setActiveSubPanel("none");
|
|
}}
|
|
onBack={() => setActiveSubPanel("none")}
|
|
onCancel={onCancel}
|
|
layout={layout}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (activeSubPanel === "theme-select") {
|
|
return (
|
|
<ThemeSelectPanel
|
|
open={true}
|
|
selectedThemeId={effectiveThemeId}
|
|
onSelect={(themeId) => {
|
|
if (themeId === effectiveThemeId && !hasEffectiveThemeOverride) {
|
|
setActiveSubPanel("none");
|
|
return;
|
|
}
|
|
setForm((prev) => ({ ...prev, theme: themeId, themeOverride: true }));
|
|
setActiveSubPanel("none");
|
|
}}
|
|
onClose={onCancel}
|
|
onBack={() => setActiveSubPanel("none")}
|
|
showBackButton={true}
|
|
layout={layout}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (activeSubPanel === "telnet-theme-select") {
|
|
return (
|
|
<ThemeSelectPanel
|
|
open={true}
|
|
selectedThemeId={effectiveTelnetThemeId}
|
|
onSelect={(themeId) => {
|
|
const telnetConfig = form.protocols?.find(
|
|
(p) => p.protocol === "telnet",
|
|
);
|
|
if (telnetConfig) {
|
|
const newProtocols = form.protocols?.map((p) =>
|
|
p.protocol === "telnet" ? { ...p, theme: themeId } : p,
|
|
);
|
|
setForm((prev) => ({ ...prev, protocols: newProtocols }));
|
|
} else {
|
|
const newProtocols = [
|
|
...(form.protocols || []),
|
|
{
|
|
protocol: "telnet" as const,
|
|
port: effectiveTelnetPort,
|
|
enabled: true,
|
|
theme: themeId,
|
|
},
|
|
];
|
|
setForm((prev) => ({ ...prev, protocols: newProtocols }));
|
|
}
|
|
setActiveSubPanel("none");
|
|
}}
|
|
onClose={onCancel}
|
|
onBack={() => setActiveSubPanel("none")}
|
|
showBackButton={true}
|
|
layout={layout}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AsidePanel
|
|
open={true}
|
|
onClose={onCancel}
|
|
width="w-[420px]"
|
|
layout={layout}
|
|
dataSection="host-details-panel"
|
|
title={
|
|
initialData ? t("hostDetails.title.details") : t("hostDetails.title.new")
|
|
}
|
|
actions={
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={handleSubmit}
|
|
disabled={!form.hostname}
|
|
aria-label={t("hostDetails.saveAria")}
|
|
>
|
|
<Check size={16} />
|
|
</Button>
|
|
}
|
|
>
|
|
<AsidePanelContent>
|
|
<HostDetailsSection
|
|
icon={<Settings2 size={14} className="text-muted-foreground" />}
|
|
title={t("hostDetails.section.general")}
|
|
>
|
|
<Input
|
|
placeholder={t("hostDetails.label.placeholder")}
|
|
value={form.label}
|
|
onChange={(e) => {
|
|
let value = e.target.value;
|
|
const targetGroup = groupInputValue.trim() || form.group || "";
|
|
const willBeManaged = managedSources.some(s =>
|
|
targetGroup === s.groupName || targetGroup.startsWith(s.groupName + "/")
|
|
);
|
|
const canBeManaged = !form.protocol || form.protocol === "ssh";
|
|
if (willBeManaged && canBeManaged) {
|
|
value = value.replace(/\s/g, '');
|
|
}
|
|
update("label", value);
|
|
}}
|
|
className="h-10"
|
|
/>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-10 w-10 rounded-lg bg-secondary/80 flex items-center justify-center shrink-0">
|
|
<FolderPlus size={16} className="text-muted-foreground" />
|
|
</div>
|
|
<Combobox
|
|
options={groupOptions}
|
|
value={form.group || ""}
|
|
onValueChange={(val) => {
|
|
update("group", val);
|
|
setGroupInputValue(val);
|
|
}}
|
|
placeholder={t("hostDetails.group.placeholder")}
|
|
allowCreate={true}
|
|
onCreateNew={(val) => {
|
|
onCreateGroup?.(val);
|
|
update("group", val);
|
|
setGroupInputValue(val);
|
|
}}
|
|
createText="Create Group"
|
|
triggerClassName="flex-1 h-10"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-10 w-10 rounded-lg bg-secondary/80 flex items-center justify-center shrink-0">
|
|
<Tag size={16} className="text-muted-foreground" />
|
|
</div>
|
|
<MultiCombobox
|
|
options={tagOptions}
|
|
values={form.tags || []}
|
|
onValuesChange={(vals) => update("tags", vals)}
|
|
placeholder="Add tags..."
|
|
allowCreate={true}
|
|
onCreateNew={(val) => onCreateTag?.(val)}
|
|
createText="Create Tag"
|
|
triggerClassName="flex-1 min-h-10"
|
|
/>
|
|
</div>
|
|
</HostDetailsSection>
|
|
|
|
<HostDetailsSection
|
|
icon={<FileText size={14} className="text-muted-foreground shrink-0" />}
|
|
title={t("hostDetails.notes.label")}
|
|
hint={t("hostDetails.notes.help")}
|
|
>
|
|
<HostNotesEditor
|
|
panelKey={form.id}
|
|
value={form.notes ?? ""}
|
|
onChange={(notes) => update("notes", notes)}
|
|
showHeader={false}
|
|
/>
|
|
</HostDetailsSection>
|
|
|
|
<HostDetailsConnectionSections
|
|
t={t}
|
|
form={form}
|
|
update={update}
|
|
groupDefaults={groupDefaults}
|
|
selectedIdentity={selectedIdentity}
|
|
clearIdentity={clearIdentity}
|
|
identities={identities}
|
|
identitySuggestionsOpen={identitySuggestionsOpen}
|
|
filteredIdentitySuggestions={filteredIdentitySuggestions}
|
|
setIdentitySuggestionsOpen={setIdentitySuggestionsOpen}
|
|
availableKeys={availableKeys}
|
|
applyIdentity={applyIdentity}
|
|
showPassword={showPassword}
|
|
setShowPassword={setShowPassword}
|
|
pendingReferenceKeyPath={pendingReferenceKeyPath}
|
|
setPendingReferenceKeyPath={setPendingReferenceKeyPath}
|
|
selectedCredentialType={selectedCredentialType}
|
|
setSelectedCredentialType={setSelectedCredentialType}
|
|
credentialPopoverOpen={credentialPopoverOpen}
|
|
setCredentialPopoverOpen={setCredentialPopoverOpen}
|
|
keysByCategory={keysByCategory}
|
|
newKeyFilePath={newKeyFilePath}
|
|
setNewKeyFilePath={setNewKeyFilePath}
|
|
addLocalKeyFilePath={addLocalKeyFilePath}
|
|
handleDistroModeChange={handleDistroModeChange}
|
|
distroOptions={distroOptions}
|
|
effectiveFormDistro={effectiveFormDistro}
|
|
getDistroOptionLabel={getDistroOptionLabel}
|
|
/>
|
|
|
|
<HostDetailsAdvancedSections
|
|
t={t}
|
|
form={form}
|
|
setForm={setForm}
|
|
update={update}
|
|
effectiveThemeId={effectiveThemeId}
|
|
hasEffectiveThemeOverride={hasEffectiveThemeOverride}
|
|
effectiveFontSize={effectiveFontSize}
|
|
hasEffectiveFontSizeOverride={hasEffectiveFontSizeOverride}
|
|
sshAgentStatus={sshAgentStatus}
|
|
effectiveGroupDefaults={effectiveGroupDefaults}
|
|
showAlgorithmOverrides={showAlgorithmOverrides}
|
|
setShowAlgorithmOverrides={setShowAlgorithmOverrides}
|
|
chainedHosts={chainedHosts}
|
|
setActiveSubPanel={setActiveSubPanel}
|
|
clearHostChain={clearHostChain}
|
|
proxySummaryType={proxySummaryType}
|
|
proxySummaryLabel={proxySummaryLabel}
|
|
proxySummaryTooltip={proxySummaryTooltip}
|
|
clearProxyConfig={clearProxyConfig}
|
|
groupDefaults={groupDefaults}
|
|
/>
|
|
|
|
<div className="flex items-center gap-3 py-2">
|
|
<div className="flex-1 h-px bg-border/60" />
|
|
<span className="text-xs text-muted-foreground">{t("hostDetails.otherProtocols")}</span>
|
|
<div className="flex-1 h-px bg-border/60" />
|
|
</div>
|
|
|
|
{form.telnetEnabled || form.protocol === "telnet" ? (
|
|
<HostDetailsSection
|
|
icon={<Plus size={14} className="text-muted-foreground" />}
|
|
title="Telnet"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex-1 min-w-0 h-10 flex items-center gap-2 bg-secondary/70 border border-border/70 rounded-md px-3">
|
|
<span className="text-xs text-muted-foreground">{t("hostDetails.telnetOn")}</span>
|
|
<div className="ml-auto w-1/2 min-w-0 flex items-center gap-2 justify-end">
|
|
<Input
|
|
type="number"
|
|
value={effectiveTelnetPort}
|
|
onChange={(e) => update("telnetPort", parseOptionalPortInput(e.target.value))}
|
|
className="h-8 flex-1 min-w-0 text-center"
|
|
/>
|
|
<span className="text-xs text-muted-foreground">{t("hostDetails.port")}</span>
|
|
</div>
|
|
</div>
|
|
{form.protocol !== "telnet" && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
|
onClick={() => update("telnetEnabled", false)}
|
|
>
|
|
<X size={14} />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="text-xs text-muted-foreground">{t("hostDetails.telnet.setDefault")}</span>
|
|
<Switch
|
|
checked={form.protocol === "telnet"}
|
|
onCheckedChange={(checked) => {
|
|
const nextProtocol = checked ? "telnet" : "ssh";
|
|
setForm((prev) => ({
|
|
...prev,
|
|
protocol: nextProtocol,
|
|
port: resolvePrimaryProtocolSwitchPort(
|
|
prev.port,
|
|
nextProtocol,
|
|
Boolean(groupDefaults?.telnetPort),
|
|
Boolean(groupDefaults?.port),
|
|
),
|
|
}));
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<p className="text-xs font-semibold">{t("hostDetails.telnet.credentials")}</p>
|
|
<Input
|
|
placeholder={t("hostDetails.telnet.username")}
|
|
value={effectiveTelnetUsername}
|
|
onChange={(e) =>
|
|
update("telnetUsername" as keyof Host, e.target.value)
|
|
}
|
|
className="h-10"
|
|
/>
|
|
<div className="relative">
|
|
<Input
|
|
placeholder={t("hostDetails.telnet.password")}
|
|
type={showTelnetPassword ? "text" : "password"}
|
|
value={effectiveTelnetPassword}
|
|
onChange={(e) =>
|
|
update("telnetPassword" as keyof Host, e.target.value)
|
|
}
|
|
className="h-10 pr-10"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowTelnetPassword(!showTelnetPassword)}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
{showTelnetPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
</button>
|
|
</div>
|
|
|
|
<Input
|
|
placeholder={groupDefaults?.charset || t("hostDetails.charset.placeholder")}
|
|
value={form.charset || "UTF-8"}
|
|
onChange={(e) => update("charset", e.target.value)}
|
|
className="h-10"
|
|
/>
|
|
|
|
<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("telnet-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(effectiveTelnetThemeId)?.colors.background || "#100F0F",
|
|
color:
|
|
customThemeStore.getThemeById(effectiveTelnetThemeId)?.colors.foreground || "#CECDC3",
|
|
}}
|
|
>
|
|
<div className="p-0.5">
|
|
<div
|
|
style={{
|
|
color: customThemeStore.getThemeById(effectiveTelnetThemeId)?.colors.green,
|
|
}}
|
|
>
|
|
$
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<span className="text-sm flex-1">
|
|
{customThemeStore.getThemeById(effectiveTelnetThemeId)?.name || "Flexoki Dark"}
|
|
</span>
|
|
</button>
|
|
</HostDetailsSection>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
className="w-full h-10 justify-start gap-2 border border-dashed border-border/60"
|
|
onClick={() => {
|
|
update("telnetEnabled", true);
|
|
}}
|
|
>
|
|
<Plus size={14} />
|
|
{t("hostDetails.telnet.add")}
|
|
</Button>
|
|
)}
|
|
</AsidePanelContent>
|
|
<AsidePanelFooter>
|
|
<Button
|
|
className="w-full h-10"
|
|
onClick={handleSubmit}
|
|
disabled={!form.hostname}
|
|
>
|
|
{t("common.save")}
|
|
</Button>
|
|
</AsidePanelFooter>
|
|
</AsidePanel>
|
|
);
|
|
};
|
|
|
|
|
|
export default HostDetailsPanel;
|