mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
feat: finish the remote-desktop plugin (B14)
This commit is contained in:
1 parent
0af2c94983
commit
14eb558b85
215 files changed
+30029
-3921
No files matched your search
@@ -33,8 +33,6 @@ import {
|
||||
updateGlobalMonitoringSettings,
|
||||
getLogLevel,
|
||||
updateLogLevel,
|
||||
getGuacamoleSettings,
|
||||
updateGuacamoleSettings,
|
||||
getOidcAutoProvision,
|
||||
updateOidcAutoProvision,
|
||||
getSecondFactorAfterExternalLogin,
|
||||
@@ -137,8 +135,6 @@ export function AdminSettingsPanel({
|
||||
const [sessionTimeout, setSessionTimeout] = useState("24");
|
||||
const [statusInterval, setStatusInterval] = useState("60");
|
||||
const [metricsInterval, setMetricsInterval] = useState("30");
|
||||
const [guacEnabled, setGuacEnabled] = useState(false);
|
||||
const [guacUrl, setGuacUrl] = useState("guacd:4822");
|
||||
const [logLevel, setLogLevel] = useState("info");
|
||||
const [analyticsEnabled, setAnalyticsEnabled] = useState(true);
|
||||
const [analyticsLocked, setAnalyticsLocked] = useState(false);
|
||||
@@ -350,7 +346,6 @@ export function AdminSettingsPanel({
|
||||
timeout,
|
||||
monitoring,
|
||||
level,
|
||||
guac,
|
||||
oidcProv,
|
||||
secondFactorExternal,
|
||||
oidcSilent,
|
||||
@@ -366,7 +361,6 @@ export function AdminSettingsPanel({
|
||||
getSessionTimeout(),
|
||||
getGlobalMonitoringSettings(),
|
||||
getLogLevel(),
|
||||
getGuacamoleSettings(),
|
||||
getOidcAutoProvision(),
|
||||
getSecondFactorAfterExternalLogin(),
|
||||
getOidcSilentLoginDefault(),
|
||||
@@ -402,10 +396,6 @@ export function AdminSettingsPanel({
|
||||
}
|
||||
|
||||
if (level.status === "fulfilled") setLogLevel(level.value.level);
|
||||
if (guac.status === "fulfilled") {
|
||||
setGuacEnabled(guac.value.enabled);
|
||||
setGuacUrl(guac.value.url || "guacd:4822");
|
||||
}
|
||||
if (analytics.status === "fulfilled") {
|
||||
setAnalyticsEnabled(analytics.value.enabled);
|
||||
setAnalyticsLocked(analytics.value.locked ?? false);
|
||||
@@ -656,26 +646,6 @@ export function AdminSettingsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveGuacamole() {
|
||||
try {
|
||||
await updateGuacamoleSettings({ enabled: guacEnabled, url: guacUrl });
|
||||
toast.success(t("admin.remoteDesktopSaved"));
|
||||
} catch {
|
||||
toast.error(t("admin.remoteDesktopSaveFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleGuacamole() {
|
||||
const newVal = !guacEnabled;
|
||||
setGuacEnabled(newVal);
|
||||
try {
|
||||
await updateGuacamoleSettings({ enabled: newVal, url: guacUrl });
|
||||
} catch {
|
||||
setGuacEnabled(!newVal);
|
||||
toast.error(t("admin.remoteDesktopUpdateFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveLogLevel(level: string) {
|
||||
setLogLevel(level);
|
||||
try {
|
||||
@@ -1103,11 +1073,6 @@ export function AdminSettingsPanel({
|
||||
metricsInterval={metricsInterval}
|
||||
setMetricsInterval={setMetricsInterval}
|
||||
handleSaveMonitoring={handleSaveMonitoring}
|
||||
guacEnabled={guacEnabled}
|
||||
handleToggleGuacamole={handleToggleGuacamole}
|
||||
guacUrl={guacUrl}
|
||||
setGuacUrl={setGuacUrl}
|
||||
handleSaveGuacamole={handleSaveGuacamole}
|
||||
logLevel={logLevel}
|
||||
handleSaveLogLevel={handleSaveLogLevel}
|
||||
/>
|
||||
|
||||
@@ -60,11 +60,6 @@ type GeneralSettingsSectionProps = {
|
||||
metricsInterval: string;
|
||||
setMetricsInterval: Dispatch<SetStateAction<string>>;
|
||||
handleSaveMonitoring: () => void;
|
||||
guacEnabled: boolean;
|
||||
handleToggleGuacamole: () => void;
|
||||
guacUrl: string;
|
||||
setGuacUrl: Dispatch<SetStateAction<string>>;
|
||||
handleSaveGuacamole: () => void;
|
||||
logLevel: string;
|
||||
handleSaveLogLevel: (level: string) => void;
|
||||
};
|
||||
@@ -106,11 +101,6 @@ export function AdminGeneralSettingsSection({
|
||||
metricsInterval,
|
||||
setMetricsInterval,
|
||||
handleSaveMonitoring,
|
||||
guacEnabled,
|
||||
handleToggleGuacamole,
|
||||
guacUrl,
|
||||
setGuacUrl,
|
||||
handleSaveGuacamole,
|
||||
logLevel,
|
||||
handleSaveLogLevel,
|
||||
}: GeneralSettingsSectionProps) {
|
||||
@@ -390,50 +380,6 @@ export function AdminGeneralSettingsSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
|
||||
<SettingRow
|
||||
label={t("admin.enableRemoteDesktop")}
|
||||
description={
|
||||
<>
|
||||
{t("admin.enableRemoteDesktopDesc")}{" "}
|
||||
<a
|
||||
href="https://docs.termix.site/setup/remote-desktop"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("admin.enableRemoteDesktopDocsLink")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AdminToggle on={guacEnabled} onToggle={handleToggleGuacamole} />
|
||||
</SettingRow>
|
||||
{guacEnabled && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.guacdUrl")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={guacUrl}
|
||||
onChange={(e) => setGuacUrl(e.target.value)}
|
||||
placeholder="guacd:4822"
|
||||
className="text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7 shrink-0"
|
||||
onClick={handleSaveGuacamole}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("admin.logLevel")}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { hostProtocolFlags, type HostProtocols } from "./host-protocols";
|
||||
import { getUserList } from "@/main-axios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@@ -141,12 +142,9 @@ export function AdminUserManagePanel({
|
||||
}, [user.id]);
|
||||
const [editor, setEditor] = useState<EditorState>(null);
|
||||
const [editorTab, setEditorTab] = useState("general");
|
||||
const [editorProtocols, setEditorProtocols] = useState({
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
});
|
||||
const [editorProtocols, setEditorProtocols] = useState<HostProtocols>(() =>
|
||||
hostProtocolFlags(null),
|
||||
);
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
@@ -791,12 +789,7 @@ export function AdminUserManagePanel({
|
||||
size="sm"
|
||||
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={() => {
|
||||
setEditorProtocols({
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
});
|
||||
setEditorProtocols(hostProtocolFlags(null));
|
||||
setEditorTab("general");
|
||||
setEditor({ kind: "host", host: null });
|
||||
}}
|
||||
@@ -842,12 +835,7 @@ export function AdminUserManagePanel({
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setEditorProtocols({
|
||||
enableSsh: host.enableSsh,
|
||||
enableRdp: host.enableRdp,
|
||||
enableVnc: host.enableVnc,
|
||||
enableTelnet: host.enableTelnet,
|
||||
});
|
||||
setEditorProtocols(hostProtocolFlags(host));
|
||||
setEditorTab("general");
|
||||
setEditor({ kind: "host", host });
|
||||
}}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import {
|
||||
fromTriState,
|
||||
toTriState,
|
||||
type RemoteDesktopDefaults,
|
||||
type TerminalDefaults,
|
||||
type TriState,
|
||||
} from "@/lib/connection-defaults";
|
||||
@@ -35,34 +34,6 @@ const inputClass =
|
||||
const labelClass =
|
||||
"text-[10px] font-bold uppercase tracking-widest text-muted-foreground";
|
||||
|
||||
const RDP_TOGGLES = [
|
||||
["forceLossless", "hosts.guac.forceLossless", "hosts.guac.forceLosslessDesc"],
|
||||
["enableWallpaper", "hosts.guac.wallpaper", "hosts.guac.wallpaperDesc"],
|
||||
[
|
||||
"enableFontSmoothing",
|
||||
"hosts.guac.fontSmoothing",
|
||||
"hosts.guac.fontSmoothingDesc",
|
||||
],
|
||||
[
|
||||
"enableDesktopComposition",
|
||||
"hosts.guac.desktopComposition",
|
||||
"hosts.guac.desktopCompositionDesc",
|
||||
],
|
||||
["disableAudio", "hosts.guac.disableAudio", "hosts.guac.disableAudioDesc"],
|
||||
[
|
||||
"enablePrinting",
|
||||
"hosts.guac.enablePrinting",
|
||||
"hosts.guac.enablePrintingDesc",
|
||||
],
|
||||
[
|
||||
"enableDrive",
|
||||
"hosts.guac.enableDriveRedirection",
|
||||
"hosts.guac.enableDriveRedirectionDesc",
|
||||
],
|
||||
["disableCopy", "hosts.guac.disableCopy", "hosts.guac.disableCopyDesc"],
|
||||
["disablePaste", "hosts.guac.disablePaste", "hosts.guac.disablePasteDesc"],
|
||||
] as const;
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
@@ -105,17 +76,14 @@ export function ConnectionDefaultsSettings() {
|
||||
const { t } = useTranslation();
|
||||
const defaults = useConnectionDefaults();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tab, setTab] = useState<"shell" | "desktop">("shell");
|
||||
const [terminal, setTerminal] = useState<TerminalDefaults>({});
|
||||
const [rdp, setRdp] = useState<RemoteDesktopDefaults>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savedThemes, setSavedThemes] = useState<SavedCustomTheme[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!defaults.ready) return;
|
||||
setTerminal(defaults.terminal);
|
||||
setRdp(defaults.rdp);
|
||||
}, [defaults.ready, defaults.terminal, defaults.rdp]);
|
||||
}, [defaults.ready, defaults.terminal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -127,17 +95,13 @@ export function ConnectionDefaultsSettings() {
|
||||
const configuredCount = useMemo(() => {
|
||||
const count = (source: Record<string, unknown>) =>
|
||||
Object.values(source).filter((value) => value !== undefined).length;
|
||||
return count(defaults.terminal) + count(defaults.rdp);
|
||||
}, [defaults.terminal, defaults.rdp]);
|
||||
return count(defaults.terminal);
|
||||
}, [defaults.terminal]);
|
||||
|
||||
const updateTerminal = <K extends keyof TerminalDefaults>(
|
||||
key: K,
|
||||
value: TerminalDefaults[K],
|
||||
) => setTerminal((current) => ({ ...current, [key]: value }));
|
||||
const updateRdp = <K extends keyof RemoteDesktopDefaults>(
|
||||
key: K,
|
||||
value: RemoteDesktopDefaults[K],
|
||||
) => setRdp((current) => ({ ...current, [key]: value }));
|
||||
|
||||
const applySavedTheme = (id: string) => {
|
||||
if (!id) return;
|
||||
@@ -153,10 +117,7 @@ export function ConnectionDefaultsSettings() {
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
defaults.saveTerminalDefaults(terminal),
|
||||
defaults.saveRdpDefaults(rdp),
|
||||
]);
|
||||
await defaults.saveTerminalDefaults(terminal);
|
||||
toast.success(t("newUi.sidebar.connectionDefaults.saved"));
|
||||
setOpen(false);
|
||||
} catch {
|
||||
@@ -204,241 +165,155 @@ export function ConnectionDefaultsSettings() {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex border-b border-border">
|
||||
{(
|
||||
[
|
||||
["shell", "newUi.sidebar.connectionDefaults.tabTerminal"],
|
||||
[
|
||||
"desktop",
|
||||
"newUi.sidebar.connectionDefaults.tabRemoteDesktop",
|
||||
],
|
||||
] as const
|
||||
).map(([id, labelKey]) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setTab(id)}
|
||||
className={`px-3 py-2 text-xs font-medium border-b-2 -mb-px transition-colors ${
|
||||
tab === id
|
||||
? "border-accent-brand text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[55vh] overflow-y-auto pr-1">
|
||||
{tab === "shell" ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field label={t("hosts.fontFamilyLabel")}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={terminal.fontFamily ?? ""}
|
||||
onChange={(e) =>
|
||||
updateTerminal("fontFamily", e.target.value || undefined)
|
||||
}
|
||||
>
|
||||
<option value="">{inheritLabel}</option>
|
||||
{TERMINAL_FONTS.map((font) => (
|
||||
<option key={font.value} value={font.value}>
|
||||
{font.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label={t("hosts.colorTheme")}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={terminal.theme ?? ""}
|
||||
onChange={(e) =>
|
||||
updateTerminal("theme", e.target.value || undefined)
|
||||
}
|
||||
>
|
||||
<option value="">{inheritLabel}</option>
|
||||
{Object.entries(TERMINAL_THEMES)
|
||||
.filter(
|
||||
([key]) =>
|
||||
key !== "termixDark" && key !== "termixLight",
|
||||
)
|
||||
.map(([key, theme]) => (
|
||||
<option key={key} value={key}>
|
||||
{theme.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{savedThemes.length > 0 && (
|
||||
<Field
|
||||
label={t(
|
||||
"newUi.sidebar.connectionDefaults.savedThemeLabel",
|
||||
)}
|
||||
>
|
||||
<select
|
||||
className={inputClass}
|
||||
value=""
|
||||
onChange={(e) => applySavedTheme(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{t(
|
||||
"newUi.sidebar.connectionDefaults.savedThemePlaceholder",
|
||||
)}
|
||||
</option>
|
||||
{savedThemes.map((theme) => (
|
||||
<option key={theme.id} value={theme.id}>
|
||||
{theme.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label={t("hosts.fontSizeLabel")}>
|
||||
<input
|
||||
className={inputClass}
|
||||
type="number"
|
||||
min={8}
|
||||
max={32}
|
||||
value={terminal.fontSize ?? ""}
|
||||
placeholder={inheritLabel}
|
||||
onChange={(e) =>
|
||||
updateTerminal(
|
||||
"fontSize",
|
||||
e.target.value ? Number(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t("hosts.lineHeightLabel")}>
|
||||
<input
|
||||
className={inputClass}
|
||||
type="number"
|
||||
min={0.8}
|
||||
max={2}
|
||||
step={0.05}
|
||||
value={terminal.lineHeight ?? ""}
|
||||
placeholder={inheritLabel}
|
||||
onChange={(e) =>
|
||||
updateTerminal(
|
||||
"lineHeight",
|
||||
e.target.value ? Number(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t("hosts.cursorStyleLabel")}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={terminal.cursorStyle ?? ""}
|
||||
onChange={(e) =>
|
||||
updateTerminal(
|
||||
"cursorStyle",
|
||||
(e.target.value || undefined) as
|
||||
TerminalDefaults["cursorStyle"] | undefined,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">{inheritLabel}</option>
|
||||
{CURSOR_STYLES.map((style) => (
|
||||
<option key={style.value} value={style.value}>
|
||||
{style.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label={t("hosts.scrollbackBufferLabel")}>
|
||||
<input
|
||||
className={inputClass}
|
||||
type="number"
|
||||
min={1000}
|
||||
max={100000}
|
||||
step={1000}
|
||||
value={terminal.scrollback ?? ""}
|
||||
placeholder={inheritLabel}
|
||||
onChange={(e) =>
|
||||
updateTerminal(
|
||||
"scrollback",
|
||||
e.target.value ? Number(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<SettingRow
|
||||
label={t("hosts.cursorBlinking")}
|
||||
description={t("hosts.cursorBlinkingDesc")}
|
||||
>
|
||||
<TriStateSelect
|
||||
value={terminal.cursorBlink}
|
||||
onChange={(value) => updateTerminal("cursorBlink", value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field label={t("hosts.guac.colorDepth")}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={rdp.colorDepth ?? ""}
|
||||
onChange={(e) =>
|
||||
updateRdp(
|
||||
"colorDepth",
|
||||
e.target.value ? Number(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">{inheritLabel}</option>
|
||||
<option value="16">16-bit</option>
|
||||
<option value="24">24-bit</option>
|
||||
<option value="32">32-bit</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label={t("hosts.guac.resizeMethod")}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={rdp.resizeMethod ?? ""}
|
||||
onChange={(e) =>
|
||||
updateRdp("resizeMethod", e.target.value || undefined)
|
||||
}
|
||||
>
|
||||
<option value="">{inheritLabel}</option>
|
||||
<option value="display-update">
|
||||
{t(
|
||||
"newUi.sidebar.connectionDefaults.resizeDisplayUpdate",
|
||||
)}
|
||||
</option>
|
||||
<option value="reconnect">
|
||||
{t("newUi.sidebar.connectionDefaults.resizeReconnect")}
|
||||
</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{RDP_TOGGLES.map(([key, labelKey, descriptionKey]) => (
|
||||
<SettingRow
|
||||
key={key}
|
||||
label={t(labelKey)}
|
||||
description={t(descriptionKey)}
|
||||
>
|
||||
<TriStateSelect
|
||||
value={rdp[key] as boolean | undefined}
|
||||
onChange={(value) => updateRdp(key, value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field label={t("hosts.fontFamilyLabel")}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={terminal.fontFamily ?? ""}
|
||||
onChange={(e) =>
|
||||
updateTerminal("fontFamily", e.target.value || undefined)
|
||||
}
|
||||
>
|
||||
<option value="">{inheritLabel}</option>
|
||||
{TERMINAL_FONTS.map((font) => (
|
||||
<option key={font.value} value={font.value}>
|
||||
{font.label}
|
||||
</option>
|
||||
))}
|
||||
</div>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label={t("hosts.colorTheme")}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={terminal.theme ?? ""}
|
||||
onChange={(e) =>
|
||||
updateTerminal("theme", e.target.value || undefined)
|
||||
}
|
||||
>
|
||||
<option value="">{inheritLabel}</option>
|
||||
{Object.entries(TERMINAL_THEMES)
|
||||
.filter(
|
||||
([key]) => key !== "termixDark" && key !== "termixLight",
|
||||
)
|
||||
.map(([key, theme]) => (
|
||||
<option key={key} value={key}>
|
||||
{theme.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{savedThemes.length > 0 && (
|
||||
<Field
|
||||
label={t("newUi.sidebar.connectionDefaults.savedThemeLabel")}
|
||||
>
|
||||
<select
|
||||
className={inputClass}
|
||||
value=""
|
||||
onChange={(e) => applySavedTheme(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{t(
|
||||
"newUi.sidebar.connectionDefaults.savedThemePlaceholder",
|
||||
)}
|
||||
</option>
|
||||
{savedThemes.map((theme) => (
|
||||
<option key={theme.id} value={theme.id}>
|
||||
{theme.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label={t("hosts.fontSizeLabel")}>
|
||||
<input
|
||||
className={inputClass}
|
||||
type="number"
|
||||
min={8}
|
||||
max={32}
|
||||
value={terminal.fontSize ?? ""}
|
||||
placeholder={inheritLabel}
|
||||
onChange={(e) =>
|
||||
updateTerminal(
|
||||
"fontSize",
|
||||
e.target.value ? Number(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t("hosts.lineHeightLabel")}>
|
||||
<input
|
||||
className={inputClass}
|
||||
type="number"
|
||||
min={0.8}
|
||||
max={2}
|
||||
step={0.05}
|
||||
value={terminal.lineHeight ?? ""}
|
||||
placeholder={inheritLabel}
|
||||
onChange={(e) =>
|
||||
updateTerminal(
|
||||
"lineHeight",
|
||||
e.target.value ? Number(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t("hosts.cursorStyleLabel")}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={terminal.cursorStyle ?? ""}
|
||||
onChange={(e) =>
|
||||
updateTerminal(
|
||||
"cursorStyle",
|
||||
(e.target.value || undefined) as
|
||||
TerminalDefaults["cursorStyle"] | undefined,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">{inheritLabel}</option>
|
||||
{CURSOR_STYLES.map((style) => (
|
||||
<option key={style.value} value={style.value}>
|
||||
{style.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label={t("hosts.scrollbackBufferLabel")}>
|
||||
<input
|
||||
className={inputClass}
|
||||
type="number"
|
||||
min={1000}
|
||||
max={100000}
|
||||
step={1000}
|
||||
value={terminal.scrollback ?? ""}
|
||||
placeholder={inheritLabel}
|
||||
onChange={(e) =>
|
||||
updateTerminal(
|
||||
"scrollback",
|
||||
e.target.value ? Number(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<SettingRow
|
||||
label={t("hosts.cursorBlinking")}
|
||||
description={t("hosts.cursorBlinkingDesc")}
|
||||
>
|
||||
<TriStateSelect
|
||||
value={terminal.cursorBlink}
|
||||
onChange={(value) => updateTerminal("cursorBlink", value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 pt-3 border-t border-border">
|
||||
@@ -447,7 +322,6 @@ export function ConnectionDefaultsSettings() {
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTerminal({});
|
||||
setRdp({});
|
||||
}}
|
||||
>
|
||||
{t("newUi.sidebar.connectionDefaults.clearAll")}
|
||||
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
} from "./HostEditorData";
|
||||
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
|
||||
import { HostEditorGeneralTab } from "./HostEditorGeneralTab";
|
||||
import { withProtocolSettings } from "./host-protocols";
|
||||
import { canEditHost } from "./host-permissions";
|
||||
import { VaultProfileManager } from "./VaultProfileManager";
|
||||
import {
|
||||
@@ -331,7 +332,10 @@ export function HostEditor({
|
||||
}
|
||||
// After the host: a new one has no id to scope settings to until it
|
||||
// exists. A failure here must not claim the host itself failed to save.
|
||||
await savePluginHostSettings(Number(saved.id), form.pluginSettings);
|
||||
await savePluginHostSettings(
|
||||
Number(saved.id),
|
||||
withProtocolSettings(form.pluginSettings, protocols),
|
||||
);
|
||||
|
||||
toast.success(host ? t("hosts.hostUpdated") : t("hosts.hostCreated"));
|
||||
setPreviewTerminalTheme(null);
|
||||
|
||||
@@ -2,19 +2,16 @@ import { TERMINAL_THEMES } from "@/lib/terminal-themes";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { SSHHostData } from "@/types";
|
||||
import type { HostDefaults } from "@/api/settings-api";
|
||||
import type {
|
||||
RemoteDesktopDefaults,
|
||||
TerminalDefaults,
|
||||
} from "@/lib/connection-defaults";
|
||||
import type { TerminalDefaults } from "@/lib/connection-defaults";
|
||||
import {
|
||||
listHostProtocols,
|
||||
protocolPort,
|
||||
type HostProtocols,
|
||||
} from "./host-protocols";
|
||||
|
||||
type HostSocks5ProxyNode = NonNullable<Host["socks5ProxyChain"]>[number];
|
||||
|
||||
export type HostProtocols = {
|
||||
enableSsh: boolean;
|
||||
enableRdp: boolean;
|
||||
enableVnc: boolean;
|
||||
enableTelnet: boolean;
|
||||
};
|
||||
export type { HostProtocols };
|
||||
|
||||
export type HostAuthType = Host["authType"];
|
||||
export type HostCursorStyle = NonNullable<
|
||||
@@ -44,23 +41,8 @@ export const terminalAppearanceKeys = [
|
||||
"customThemeColors",
|
||||
] as const satisfies readonly (keyof TerminalDefaults)[];
|
||||
|
||||
export const remoteDesktopDefaultKeys = [
|
||||
"colorDepth",
|
||||
"resizeMethod",
|
||||
"forceLossless",
|
||||
"disableAudio",
|
||||
"enableWallpaper",
|
||||
"enableFontSmoothing",
|
||||
"enableDesktopComposition",
|
||||
"enablePrinting",
|
||||
"enableDrive",
|
||||
"disableCopy",
|
||||
"disablePaste",
|
||||
] as const satisfies readonly (keyof RemoteDesktopDefaults)[];
|
||||
|
||||
export interface UserConnectionDefaults {
|
||||
terminal: TerminalDefaults;
|
||||
rdp: RemoteDesktopDefaults;
|
||||
}
|
||||
|
||||
function hasOwn(value: object | undefined, key: PropertyKey): boolean {
|
||||
@@ -86,18 +68,13 @@ type SnippetResponse = SnippetListItem[] | { snippets?: SnippetListItem[] };
|
||||
/**
|
||||
* Whether the Connection Origin control is meaningful for a host.
|
||||
*
|
||||
* Every protocol Termix can dial from either backend belongs here. RDP, VNC
|
||||
* and Telnet were added once they could originate from the desktop
|
||||
* (Termix-SSH/Support#1240); before that the control was gated on SSH alone,
|
||||
* so a host enabling only those protocols could never reach the setting.
|
||||
* Every protocol Termix can dial from either backend belongs here, plugin
|
||||
* protocols included (Termix-SSH/Support#1240); before that the control was
|
||||
* gated on SSH alone, so a host enabling only remote desktop could never
|
||||
* reach the setting.
|
||||
*/
|
||||
export function connectionOriginAppliesTo(protocols: HostProtocols): boolean {
|
||||
return (
|
||||
protocols.enableSsh ||
|
||||
protocols.enableRdp ||
|
||||
protocols.enableVnc ||
|
||||
protocols.enableTelnet
|
||||
);
|
||||
return Object.values(protocols).some(Boolean);
|
||||
}
|
||||
|
||||
export function mapSnippetResponse(
|
||||
@@ -122,11 +99,6 @@ export function createHostEditorForm(
|
||||
...(connectionDefaults?.terminal ?? {}),
|
||||
...(host?.terminalConfig ?? {}),
|
||||
};
|
||||
const remoteDefaults = host?.enableRdp ? connectionDefaults?.rdp : undefined;
|
||||
const guacamoleConfig = {
|
||||
...(remoteDefaults ?? {}),
|
||||
...(host?.guacamoleConfig ?? {}),
|
||||
};
|
||||
const proxmoxSettings = (host?.pluginSettings?.proxmox ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
@@ -147,9 +119,6 @@ export function createHostEditorForm(
|
||||
ip: host?.ip ?? "",
|
||||
username: host?.username ?? (host ? "" : "root"),
|
||||
sshPort: host?.sshPort ?? host?.port ?? 22,
|
||||
rdpPort: host?.rdpPort ?? 3389,
|
||||
vncPort: host?.vncPort ?? 5900,
|
||||
telnetPort: host?.telnetPort ?? 23,
|
||||
authType: host?.authType ?? "password",
|
||||
useWarpgate: host?.useWarpgate ?? false,
|
||||
shareSshAuth: host?.shareSshAuth ?? false,
|
||||
@@ -223,11 +192,6 @@ export function createHostEditorForm(
|
||||
inheritTerminalAppearance:
|
||||
!host ||
|
||||
terminalAppearanceKeys.every((key) => !hasOwn(host.terminalConfig, key)),
|
||||
inheritRemoteDesktopDefaults:
|
||||
!host ||
|
||||
remoteDesktopDefaultKeys.every(
|
||||
(key) => !hasOwn(host.guacamoleConfig, key),
|
||||
),
|
||||
localEcho: host?.terminalConfig?.localEcho ?? "default",
|
||||
fontSize: terminalConfig.fontSize ?? d?.fontSize ?? 14,
|
||||
fontFamily:
|
||||
@@ -298,8 +262,6 @@ export function createHostEditorForm(
|
||||
? "existing_rdp_password"
|
||||
: (host?.rdpPassword ?? ""),
|
||||
domain: host?.domain ?? "",
|
||||
security: host?.security ?? "",
|
||||
ignoreCert: host?.ignoreCert ?? false,
|
||||
vncCredentialId: host?.vncCredentialId ?? "",
|
||||
vncPassword: host?.hasVncPassword
|
||||
? "existing_vnc_password"
|
||||
@@ -320,7 +282,6 @@ export function createHostEditorForm(
|
||||
telnetAuthType: (host?.telnetAuthType ??
|
||||
(host?.telnetCredentialId ? "credential" : "direct")) as
|
||||
"direct" | "credential",
|
||||
guacamoleConfig,
|
||||
statsConfig: host?.statsConfig ?? {
|
||||
statusCheckEnabled: d?.statusCheckEnabled ?? true,
|
||||
statusCheckInterval: 60,
|
||||
@@ -399,9 +360,11 @@ export function buildHostEditorPayload(
|
||||
const usesPassword = form.authType === "password";
|
||||
const usesAgent = form.authType === "agent";
|
||||
const usesVault = form.authType === "vault";
|
||||
const guacamoleConfig = form.inheritRemoteDesktopDefaults
|
||||
? stripKeys(form.guacamoleConfig, remoteDesktopDefaultKeys)
|
||||
: form.guacamoleConfig;
|
||||
// With SSH off, the host's primary protocol is the first plugin protocol
|
||||
// switched on, and its port stands in for the host port.
|
||||
const primaryProtocol = protocols.enableSsh
|
||||
? undefined
|
||||
: listHostProtocols().find((protocol) => protocols[protocol.settingKey]);
|
||||
const terminalConfig = {
|
||||
theme: form.theme,
|
||||
cursorBlink: form.cursorBlink,
|
||||
@@ -449,22 +412,12 @@ export function buildHostEditorPayload(
|
||||
: terminalConfig;
|
||||
|
||||
return {
|
||||
connectionType: protocols.enableSsh
|
||||
? "ssh"
|
||||
: protocols.enableRdp
|
||||
? "rdp"
|
||||
: protocols.enableVnc
|
||||
? "vnc"
|
||||
: "telnet",
|
||||
connectionType: primaryProtocol?.id ?? "ssh",
|
||||
name: form.name,
|
||||
ip: form.ip,
|
||||
port: protocols.enableSsh
|
||||
? Number(form.sshPort)
|
||||
: protocols.enableRdp
|
||||
? Number(form.rdpPort)
|
||||
: protocols.enableVnc
|
||||
? Number(form.vncPort)
|
||||
: Number(form.telnetPort),
|
||||
port: primaryProtocol
|
||||
? protocolPort(form.pluginSettings, primaryProtocol)
|
||||
: Number(form.sshPort),
|
||||
username: form.username,
|
||||
folder: form.folder,
|
||||
parentHostId: form.parentHostId ? Number(form.parentHostId) : null,
|
||||
@@ -531,13 +484,7 @@ export function buildHostEditorPayload(
|
||||
form.socks5ProxyMode === "chain" ? form.socks5ProxyChain : null,
|
||||
connectionOrigin: form.connectionOrigin,
|
||||
enableSsh: protocols.enableSsh,
|
||||
enableRdp: protocols.enableRdp,
|
||||
enableVnc: protocols.enableVnc,
|
||||
enableTelnet: protocols.enableTelnet,
|
||||
sshPort: Number(form.sshPort),
|
||||
rdpPort: Number(form.rdpPort),
|
||||
vncPort: Number(form.vncPort),
|
||||
telnetPort: Number(form.telnetPort),
|
||||
forceKeyboardInteractive: form.forceKeyboardInteractive,
|
||||
rdpAuthType: protocols.enableRdp ? form.rdpAuthType : null,
|
||||
rdpCredentialId:
|
||||
@@ -557,8 +504,6 @@ export function buildHostEditorPayload(
|
||||
? form.rdpPassword || null
|
||||
: null,
|
||||
rdpDomain: form.domain || null,
|
||||
rdpSecurity: form.security || null,
|
||||
rdpIgnoreCert: form.ignoreCert,
|
||||
vncAuthType: protocols.enableVnc ? form.vncAuthType : null,
|
||||
vncCredentialId:
|
||||
protocols.enableVnc &&
|
||||
@@ -602,11 +547,6 @@ export function buildHostEditorPayload(
|
||||
snippetId: Number(a.snippetId),
|
||||
})),
|
||||
statsConfig: form.statsConfig,
|
||||
guacamoleConfig:
|
||||
(protocols.enableRdp || protocols.enableVnc || protocols.enableTelnet) &&
|
||||
Object.keys(guacamoleConfig).length > 0
|
||||
? guacamoleConfig
|
||||
: null,
|
||||
terminalConfig: protocols.enableSsh ? terminalOverrides : null,
|
||||
};
|
||||
}
|
||||
@@ -9,8 +9,6 @@ import type { Host } from "@/types/ui-types";
|
||||
import {
|
||||
Globe,
|
||||
LayoutGrid,
|
||||
Monitor,
|
||||
MousePointerClick,
|
||||
Plus,
|
||||
Tag,
|
||||
Terminal,
|
||||
@@ -23,6 +21,7 @@ import { getSSHFolders, isElectron } from "@/main-axios";
|
||||
import { connectionOriginAppliesTo } from "./HostEditorData";
|
||||
import type { HostEditorForm, HostProtocols } from "./HostEditorData";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { useHostProtocols } from "./host-protocols";
|
||||
|
||||
type HostEditorSetField = <K extends keyof HostEditorForm>(
|
||||
key: K,
|
||||
@@ -48,6 +47,7 @@ export function HostEditorGeneralTab({
|
||||
simpleMode?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pluginProtocols = useHostProtocols();
|
||||
|
||||
// Tracks which picker is shown, independent of whether a value is set yet
|
||||
// -- switching to "parent host" mode with nothing picked shouldn't bounce
|
||||
@@ -116,35 +116,22 @@ export function HostEditorGeneralTab({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 py-3">
|
||||
{[
|
||||
{
|
||||
proto: "enableSsh" as const,
|
||||
proto: "enableSsh",
|
||||
label: t("hosts.tabSsh"),
|
||||
desc: t("hosts.secureShell"),
|
||||
icon: <Terminal className="size-4" />,
|
||||
portField: "sshPort" as const,
|
||||
},
|
||||
{
|
||||
proto: "enableRdp" as const,
|
||||
label: t("hosts.tabRdp"),
|
||||
desc: t("hosts.remoteDesktop"),
|
||||
icon: <Monitor className="size-4" />,
|
||||
portField: "rdpPort" as const,
|
||||
},
|
||||
{
|
||||
proto: "enableVnc" as const,
|
||||
label: t("hosts.tabVnc"),
|
||||
desc: t("hosts.virtualNetwork"),
|
||||
icon: <MousePointerClick className="size-4" />,
|
||||
portField: "vncPort" as const,
|
||||
},
|
||||
{
|
||||
proto: "enableTelnet" as const,
|
||||
label: t("hosts.tabTelnet"),
|
||||
desc: t("hosts.unencryptedShell"),
|
||||
icon: <Terminal className="size-4" />,
|
||||
portField: "telnetPort" as const,
|
||||
},
|
||||
...pluginProtocols.map((protocol) => {
|
||||
const Icon = protocol.icon;
|
||||
return {
|
||||
proto: protocol.settingKey,
|
||||
label: t(protocol.titleKey),
|
||||
desc: protocol.descriptionKey ? t(protocol.descriptionKey) : "",
|
||||
icon: <Icon className="size-4" />,
|
||||
};
|
||||
}),
|
||||
].map(({ proto, label, desc, icon }) => {
|
||||
const enabled = protocols[proto];
|
||||
const enabled = !!protocols[proto];
|
||||
return (
|
||||
<div
|
||||
key={proto}
|
||||
@@ -245,15 +232,12 @@ export function HostEditorGeneralTab({
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{!protocols.enableSsh &&
|
||||
!protocols.enableRdp &&
|
||||
!protocols.enableVnc &&
|
||||
!protocols.enableTelnet && (
|
||||
<div className="flex items-center gap-3 p-3 border border-border bg-muted/20 text-xs text-muted-foreground">
|
||||
<Globe className="size-4 shrink-0 text-muted-foreground/40" />
|
||||
<span>{t("hosts.enableAtLeastOneProtocol")}</span>
|
||||
</div>
|
||||
)}
|
||||
{!Object.values(protocols).some(Boolean) && (
|
||||
<div className="flex items-center gap-3 p-3 border border-border bg-muted/20 text-xs text-muted-foreground">
|
||||
<Globe className="size-4 shrink-0 text-muted-foreground/40" />
|
||||
<span>{t("hosts.enableAtLeastOneProtocol")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SectionCard
|
||||
title={t("hosts.folderAndAdvanced")}
|
||||
@@ -769,13 +753,7 @@ export function HostEditorGeneralTab({
|
||||
{isElectron() && connectionOriginAppliesTo(protocols) && (
|
||||
<SettingRow
|
||||
label={t("hosts.connectionOrigin")}
|
||||
description={
|
||||
protocols.enableRdp ||
|
||||
protocols.enableVnc ||
|
||||
protocols.enableTelnet
|
||||
? `${t("hosts.connectionOriginDesc")} ${t("hosts.connectionOriginRemoteDesktopNote")}`
|
||||
: t("hosts.connectionOriginDesc")
|
||||
}
|
||||
description={t("hosts.connectionOriginDesc")}
|
||||
>
|
||||
<select
|
||||
className="flex h-7 border border-border bg-background px-2 py-0 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import {
|
||||
enabledHostProtocols,
|
||||
hostProtocolFlags,
|
||||
type HostProtocols,
|
||||
} from "./host-protocols";
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
@@ -102,12 +107,9 @@ export function HostManager({
|
||||
} | null>(null);
|
||||
const [hostEditorDirty, setHostEditorDirty] = useState(false);
|
||||
const [showUnsavedHostDialog, setShowUnsavedHostDialog] = useState(false);
|
||||
const [editingProtocols, setEditingProtocols] = useState({
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
});
|
||||
const [editingProtocols, setEditingProtocols] = useState<HostProtocols>(() =>
|
||||
hostProtocolFlags(null),
|
||||
);
|
||||
const simpleEditor = useUiPreference("hostEditor", "mode") === "simple";
|
||||
// Expanding advanced is a per-session choice; fixing one host's SSH options
|
||||
// shouldn't quietly move the whole app off the Simple preset.
|
||||
@@ -142,12 +144,7 @@ export function HostManager({
|
||||
setEditingHost(host);
|
||||
setEditingCredential(null);
|
||||
setActiveHostTab("general");
|
||||
setEditingProtocols({
|
||||
enableSsh: host.enableSsh,
|
||||
enableRdp: host.enableRdp,
|
||||
enableVnc: host.enableVnc,
|
||||
enableTelnet: host.enableTelnet,
|
||||
});
|
||||
setEditingProtocols(hostProtocolFlags(host));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -204,12 +201,7 @@ export function HostManager({
|
||||
if (action === "add-host") {
|
||||
setEditingHost("new");
|
||||
setEditingCredential(null);
|
||||
setEditingProtocols({
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
});
|
||||
setEditingProtocols(hostProtocolFlags(null));
|
||||
setActiveHostTab("general");
|
||||
} else if (action === "add-credential") {
|
||||
setEditingCredential("new");
|
||||
@@ -224,12 +216,7 @@ export function HostManager({
|
||||
const handleAddHost = () => {
|
||||
setEditingHost("new");
|
||||
setEditingCredential(null);
|
||||
setEditingProtocols({
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
});
|
||||
setEditingProtocols(hostProtocolFlags(null));
|
||||
setActiveHostTab("general");
|
||||
};
|
||||
const handleAddCredential = () => {
|
||||
@@ -244,12 +231,7 @@ export function HostManager({
|
||||
setEditingHost(host);
|
||||
setEditingCredential(null);
|
||||
setActiveHostTab("general");
|
||||
setEditingProtocols({
|
||||
enableSsh: host.enableSsh,
|
||||
enableRdp: host.enableRdp,
|
||||
enableVnc: host.enableVnc,
|
||||
enableTelnet: host.enableTelnet,
|
||||
});
|
||||
setEditingProtocols(hostProtocolFlags(host));
|
||||
}
|
||||
};
|
||||
window.addEventListener("host-manager:add-host", handleAddHost);
|
||||
@@ -848,9 +830,7 @@ export function HostManager({
|
||||
<option value="">{t("credentials.selectHostOption")}</option>
|
||||
{allHosts
|
||||
.filter(
|
||||
(h) =>
|
||||
h.enableSsh ||
|
||||
(!h.enableRdp && !h.enableVnc && !h.enableTelnet),
|
||||
(h) => h.enableSsh || enabledHostProtocols(h).length === 0,
|
||||
)
|
||||
.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
|
||||
@@ -109,16 +109,9 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
proxmoxStatsConfig:
|
||||
(proxmoxSettings.proxmoxStatsConfig as Host["proxmoxStatsConfig"]) ??
|
||||
null,
|
||||
enableRdp: h.enableRdp != null ? h.enableRdp : h.connectionType === "rdp",
|
||||
enableVnc: h.enableVnc != null ? h.enableVnc : h.connectionType === "vnc",
|
||||
enableTelnet:
|
||||
h.enableTelnet != null ? h.enableTelnet : h.connectionType === "telnet",
|
||||
sshPort:
|
||||
h.sshPort ??
|
||||
(h.connectionType === "ssh" || !h.connectionType ? h.port : 22),
|
||||
rdpPort: h.rdpPort ?? (h.connectionType === "rdp" ? h.port : 3389),
|
||||
vncPort: h.vncPort ?? (h.connectionType === "vnc" ? h.port : 5900),
|
||||
telnetPort: h.telnetPort ?? (h.connectionType === "telnet" ? h.port : 23),
|
||||
rdpAuthType:
|
||||
(h.rdpAuthType as "direct" | "credential") ??
|
||||
(h.rdpCredentialId ? "credential" : "direct"),
|
||||
@@ -128,8 +121,6 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
rdpPassword: h.rdpPassword ?? "",
|
||||
hasRdpPassword: !!host.hasRdpPassword || !!h.rdpPassword,
|
||||
domain: h.rdpDomain,
|
||||
security: h.rdpSecurity,
|
||||
ignoreCert: h.rdpIgnoreCert ?? false,
|
||||
vncAuthType:
|
||||
(h.vncAuthType as "direct" | "credential") ??
|
||||
(h.vncCredentialId ? "credential" : "direct"),
|
||||
@@ -160,7 +151,6 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
hasSudoPassword:
|
||||
!!host.hasSudoPassword || !!parsedTerminalConfig?.sudoPassword,
|
||||
statsConfig: parseJson(h.statsConfig) as Host["statsConfig"],
|
||||
guacamoleConfig: parseJson(h.guacamoleConfig),
|
||||
forceKeyboardInteractive: h.forceKeyboardInteractive ?? false,
|
||||
useSocks5: h.useSocks5,
|
||||
socks5Host: h.socks5Host,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getErrorMessage } from "../lib/error-message.js";
|
||||
import { enabledHostProtocols, useHostProtocols } from "./host-protocols";
|
||||
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { HostData } from "@/types/index";
|
||||
@@ -87,9 +88,9 @@ function hostGroupNames(host: Host, key: GroupKey): string[] {
|
||||
case "protocol": {
|
||||
const protos: string[] = [];
|
||||
if (host.enableSsh) protos.push("ssh");
|
||||
if (host.enableRdp) protos.push("rdp");
|
||||
if (host.enableVnc) protos.push("vnc");
|
||||
if (host.enableTelnet) protos.push("telnet");
|
||||
for (const protocol of enabledHostProtocols(host)) {
|
||||
protos.push(protocol.id);
|
||||
}
|
||||
return protos.length > 0 ? protos : ["__none__"];
|
||||
}
|
||||
case "auth":
|
||||
@@ -144,9 +145,9 @@ function hostPassesFilters(host: Host, filters: FilterState): boolean {
|
||||
if (filters.protocol.length > 0) {
|
||||
const ok =
|
||||
(filters.protocol.includes("ssh") && host.enableSsh) ||
|
||||
(filters.protocol.includes("rdp") && host.enableRdp) ||
|
||||
(filters.protocol.includes("vnc") && host.enableVnc) ||
|
||||
(filters.protocol.includes("telnet") && host.enableTelnet);
|
||||
enabledHostProtocols(host).some((protocol) =>
|
||||
filters.protocol.includes(protocol.id),
|
||||
);
|
||||
if (!ok) return false;
|
||||
}
|
||||
if (filters.features.length > 0) {
|
||||
@@ -203,6 +204,7 @@ export function HostsPanel({
|
||||
active?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const hostProtocols = useHostProtocols();
|
||||
const sshAuthProviders = useSshAuthProviders();
|
||||
const [hostSearch, setHostSearch] = useState("");
|
||||
const [managerEditing, setManagerEditing] = useState(false);
|
||||
@@ -349,9 +351,6 @@ export function HostsPanel({
|
||||
pin: true,
|
||||
notes: "Main production web server running Nginx",
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
sshPort: 22,
|
||||
enableTerminal: true,
|
||||
enableTunnel: false,
|
||||
@@ -370,9 +369,6 @@ export function HostsPanel({
|
||||
folder: "Production",
|
||||
tags: ["database", "production", "postgresql"],
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
sshPort: 22,
|
||||
enableTerminal: true,
|
||||
enableTunnel: true,
|
||||
@@ -449,10 +445,6 @@ export function HostsPanel({
|
||||
...h,
|
||||
port: h.port ?? h.sshPort ?? 22,
|
||||
enableSsh: h.enableSsh ?? h.connectionType === "ssh",
|
||||
enableRdp: h.enableRdp ?? h.connectionType === "rdp",
|
||||
enableVnc: h.enableVnc ?? h.connectionType === "vnc",
|
||||
enableTelnet:
|
||||
h.enableTelnet ?? h.connectionType === "telnet",
|
||||
}),
|
||||
);
|
||||
const result = await bulkImportSSHHosts(
|
||||
@@ -768,23 +760,20 @@ export function HostsPanel({
|
||||
<DropdownMenuLabel>
|
||||
{t("hosts.filterProtocolGroup")}
|
||||
</DropdownMenuLabel>
|
||||
{(
|
||||
[
|
||||
["ssh", "Ssh"],
|
||||
["rdp", "Rdp"],
|
||||
["vnc", "Vnc"],
|
||||
["telnet", "Telnet"],
|
||||
] as const
|
||||
).map(([val, key]) => (
|
||||
{[
|
||||
{ id: "ssh", label: t("hosts.filterProtocolSsh") },
|
||||
...hostProtocols.map((protocol) => ({
|
||||
id: protocol.id,
|
||||
label: t(protocol.titleKey),
|
||||
})),
|
||||
].map(({ id, label }) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={val}
|
||||
checked={filterState.protocol.includes(val)}
|
||||
onCheckedChange={() =>
|
||||
handleFilterToggle("protocol", val)
|
||||
}
|
||||
key={id}
|
||||
checked={filterState.protocol.includes(id)}
|
||||
onCheckedChange={() => handleFilterToggle("protocol", id)}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{t(`hosts.filterProtocol${key}`)}
|
||||
{label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -1,27 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
FolderSearch,
|
||||
Monitor,
|
||||
MousePointerClick,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { Eye, EyeOff, FolderSearch, Terminal } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import { getCredentials } from "@/api/credentials-api";
|
||||
import { mapCredentials } from "./HostManagerData";
|
||||
import {
|
||||
createQuickConnectHost,
|
||||
type QuickConnectProtocol,
|
||||
} from "./quick-connect-host";
|
||||
|
||||
const DEFAULT_PORTS: Record<QuickConnectProtocol, string> = {
|
||||
ssh: "22",
|
||||
rdp: "3389",
|
||||
vnc: "5900",
|
||||
};
|
||||
import { createQuickConnectHost } from "./quick-connect-host";
|
||||
import { useHostProtocols } from "./host-protocols";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { resolveHostTabType } from "@/lib/host-connection-tabs";
|
||||
|
||||
@@ -32,7 +17,8 @@ interface QuickConnectPanelProps {
|
||||
export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [host, setHost] = useState("");
|
||||
const [protocol, setProtocol] = useState<QuickConnectProtocol>("ssh");
|
||||
// "ssh", or the id of a plugin protocol offered in Quick Connect.
|
||||
const [protocol, setProtocol] = useState("ssh");
|
||||
const [port, setPort] = useState("22");
|
||||
const [domain, setDomain] = useState("");
|
||||
const [username, setUsername] = useState("root");
|
||||
@@ -53,11 +39,17 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const isDesktop = protocol !== "ssh";
|
||||
const pluginProtocols = useHostProtocols().filter(
|
||||
(entry) => entry.quickConnect,
|
||||
);
|
||||
const selected = pluginProtocols.find((entry) => entry.id === protocol);
|
||||
const isDesktop = !!selected;
|
||||
const defaultPort = (id: string) =>
|
||||
String(pluginProtocols.find((entry) => entry.id === id)?.defaultPort ?? 22);
|
||||
|
||||
const switchProtocol = (next: QuickConnectProtocol) => {
|
||||
const switchProtocol = (next: string) => {
|
||||
// Keep a port the user typed; only swap the protocol default.
|
||||
if (port === DEFAULT_PORTS[protocol]) setPort(DEFAULT_PORTS[next]);
|
||||
if (port === defaultPort(protocol)) setPort(defaultPort(next));
|
||||
setProtocol(next);
|
||||
};
|
||||
|
||||
@@ -67,13 +59,13 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
if (!isDesktop && !username) return;
|
||||
const hostConfig = createQuickConnectHost({
|
||||
ip: host,
|
||||
port: parseInt(port) || parseInt(DEFAULT_PORTS[protocol]),
|
||||
port: parseInt(port) || parseInt(defaultPort(protocol)),
|
||||
username,
|
||||
authType: isDesktop ? "password" : authType,
|
||||
password,
|
||||
key: privateKey,
|
||||
credentialId,
|
||||
protocol,
|
||||
protocol: selected,
|
||||
domain: domain || undefined,
|
||||
});
|
||||
const target = type ?? resolveHostTabType(hostConfig);
|
||||
@@ -90,19 +82,21 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
{t("newUi.sidebar.quickConnect.protocolLabel")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{(["ssh", "rdp", "vnc"] as const).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => switchProtocol(type)}
|
||||
className={`flex-1 py-1 text-[10px] font-semibold border transition-colors uppercase ${
|
||||
protocol === type
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{type}
|
||||
</button>
|
||||
))}
|
||||
{["ssh", ...pluginProtocols.map((entry) => entry.id)].map(
|
||||
(type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => switchProtocol(type)}
|
||||
className={`flex-1 py-1 text-[10px] font-semibold border transition-colors uppercase ${
|
||||
protocol === type
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{type}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -206,7 +200,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isDesktop && protocol === "rdp" && (
|
||||
{selected?.quickConnect?.showDomain && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.quickConnect.domainLabel")}
|
||||
@@ -264,21 +258,15 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5 pt-1">
|
||||
{isDesktop ? (
|
||||
{selected ? (
|
||||
<button
|
||||
onClick={() => connect(protocol)}
|
||||
onClick={() => connect()}
|
||||
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
|
||||
>
|
||||
{protocol === "rdp" ? (
|
||||
<Monitor className="size-3.5" />
|
||||
) : (
|
||||
<MousePointerClick className="size-3.5" />
|
||||
)}
|
||||
{t(
|
||||
protocol === "rdp"
|
||||
? "newUi.sidebar.quickConnect.connectToRdp"
|
||||
: "newUi.sidebar.quickConnect.connectToVnc",
|
||||
)}
|
||||
<selected.icon className="size-3.5" />
|
||||
{t("newUi.sidebar.quickConnect.connectWith", {
|
||||
protocol: t(selected.titleKey),
|
||||
})}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Host, SharePermissionLevel } from "@/types/ui-types";
|
||||
import {
|
||||
AUTH_PROTOCOL_METADATA,
|
||||
isSupportedAuthOverrideProtocol,
|
||||
type AuthOverrideProtocol,
|
||||
} from "@/types/auth-protocols";
|
||||
import { listHostProtocols, protocolEnabled } from "./host-protocols";
|
||||
|
||||
const LEVEL_RANK: Record<SharePermissionLevel, number> = {
|
||||
connect: 1,
|
||||
@@ -36,10 +36,11 @@ export function canOverrideHostAuth(
|
||||
host: Host,
|
||||
protocol: AuthOverrideProtocol,
|
||||
): boolean {
|
||||
const enableField = AUTH_PROTOCOL_METADATA[protocol].enableField;
|
||||
return (
|
||||
!!host.isShared &&
|
||||
isSupportedAuthOverrideProtocol(protocol) &&
|
||||
!!host[enableField]
|
||||
);
|
||||
if (!host.isShared || !isSupportedAuthOverrideProtocol(protocol)) {
|
||||
return false;
|
||||
}
|
||||
if (protocol === "ssh") return !!host.enableSsh;
|
||||
// Other protocols are switched on in their plugin's host settings.
|
||||
const plugin = listHostProtocols().find((entry) => entry.id === protocol);
|
||||
return !!plugin && protocolEnabled(host.pluginSettings, plugin);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { byOrderThenId, createRegistry } from "@/lib/registry";
|
||||
|
||||
type Icon = ComponentType<{ className?: string; size?: number | string }>;
|
||||
|
||||
/**
|
||||
* A connection protocol a plugin adds next to SSH (RDP, VNC). Its on/off
|
||||
* switch and port are the plugin's own host settings, so core reads them
|
||||
* from host.pluginSettings[pluginId] and never knows the protocol by name.
|
||||
*/
|
||||
export interface HostProtocolDef {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
/** The plugin's boolean host setting that switches the protocol on. */
|
||||
settingKey: string;
|
||||
/** The plugin's number host setting holding the port, if it has one. */
|
||||
portKey?: string;
|
||||
defaultPort: number;
|
||||
titleKey: string;
|
||||
descriptionKey?: string;
|
||||
icon: Icon;
|
||||
order?: number;
|
||||
/** Offer it in Quick Connect, optionally with a domain field. */
|
||||
quickConnect?: { showDomain?: boolean };
|
||||
}
|
||||
|
||||
const protocols = createRegistry<HostProtocolDef>(byOrderThenId);
|
||||
|
||||
export const registerHostProtocol = protocols.register;
|
||||
export const useHostProtocols = protocols.useList;
|
||||
export const listHostProtocols = protocols.list;
|
||||
|
||||
/** Which protocols are on, keyed by setting key, with SSH as enableSsh. */
|
||||
export type HostProtocols = { enableSsh: boolean } & Record<string, boolean>;
|
||||
|
||||
type PluginSettingsBag = Record<string, Record<string, unknown>> | undefined;
|
||||
|
||||
function settingsOf(
|
||||
pluginSettings: PluginSettingsBag,
|
||||
protocol: HostProtocolDef,
|
||||
): Record<string, unknown> {
|
||||
return pluginSettings?.[protocol.pluginId] ?? {};
|
||||
}
|
||||
|
||||
export function protocolEnabled(
|
||||
pluginSettings: PluginSettingsBag,
|
||||
protocol: HostProtocolDef,
|
||||
): boolean {
|
||||
return settingsOf(pluginSettings, protocol)[protocol.settingKey] === true;
|
||||
}
|
||||
|
||||
export function protocolPort(
|
||||
pluginSettings: PluginSettingsBag,
|
||||
protocol: HostProtocolDef,
|
||||
): number {
|
||||
const raw = protocol.portKey
|
||||
? Number(settingsOf(pluginSettings, protocol)[protocol.portKey])
|
||||
: NaN;
|
||||
return Number.isInteger(raw) && raw > 0 ? raw : protocol.defaultPort;
|
||||
}
|
||||
|
||||
export function hostProtocolFlags(
|
||||
host: { enableSsh?: boolean; pluginSettings?: PluginSettingsBag } | null,
|
||||
list: HostProtocolDef[] = listHostProtocols(),
|
||||
): HostProtocols {
|
||||
const flags: HostProtocols = {
|
||||
enableSsh: host ? host.enableSsh !== false : true,
|
||||
};
|
||||
for (const protocol of list) {
|
||||
flags[protocol.settingKey] = host
|
||||
? protocolEnabled(host.pluginSettings, protocol)
|
||||
: false;
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
/** The protocols a host has switched on, in registration order. */
|
||||
export function enabledHostProtocols(
|
||||
host: { pluginSettings?: PluginSettingsBag },
|
||||
list: HostProtocolDef[] = listHostProtocols(),
|
||||
): HostProtocolDef[] {
|
||||
return list.filter((protocol) =>
|
||||
protocolEnabled(host.pluginSettings, protocol),
|
||||
);
|
||||
}
|
||||
|
||||
/** Writes the editor's protocol switches into each owner's host settings. */
|
||||
export function withProtocolSettings(
|
||||
pluginSettings: Record<string, Record<string, unknown>>,
|
||||
flags: HostProtocols,
|
||||
list: HostProtocolDef[] = listHostProtocols(),
|
||||
): Record<string, Record<string, unknown>> {
|
||||
const next = { ...pluginSettings };
|
||||
for (const protocol of list) {
|
||||
if (!(protocol.settingKey in flags)) continue;
|
||||
next[protocol.pluginId] = {
|
||||
...(next[protocol.pluginId] ?? {}),
|
||||
[protocol.settingKey]: flags[protocol.settingKey] === true,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { SSHHostData } from "@/types";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
export type QuickConnectProtocol = "ssh" | "rdp" | "vnc";
|
||||
import type { HostProtocolDef } from "./host-protocols";
|
||||
|
||||
type QuickConnectInput = Pick<
|
||||
Host,
|
||||
"ip" | "port" | "username" | "authType" | "password" | "key" | "credentialId"
|
||||
> & { protocol?: QuickConnectProtocol; domain?: string };
|
||||
> & {
|
||||
/** A plugin protocol; SSH when omitted. */
|
||||
protocol?: HostProtocolDef;
|
||||
domain?: string;
|
||||
};
|
||||
|
||||
export const QUICK_CONNECT_ID_PREFIX = "quick-connect-";
|
||||
|
||||
@@ -15,10 +18,17 @@ export function isQuickConnectHost(host: Pick<Host, "id">): boolean {
|
||||
}
|
||||
|
||||
export function createQuickConnectHost(input: QuickConnectInput): Host {
|
||||
const protocol = input.protocol ?? "ssh";
|
||||
if (protocol !== "ssh") {
|
||||
const protocol = input.protocol;
|
||||
if (protocol) {
|
||||
// Protocol logins are core host fields named after the protocol.
|
||||
const login = {
|
||||
[`${protocol.id}AuthType`]: "direct",
|
||||
[`${protocol.id}User`]: input.username,
|
||||
[`${protocol.id}Password`]: input.password,
|
||||
};
|
||||
return {
|
||||
...createQuickConnectHost({ ...input, protocol: "ssh", port: 22 }),
|
||||
...createQuickConnectHost({ ...input, protocol: undefined, port: 22 }),
|
||||
...login,
|
||||
port: input.port,
|
||||
enableTerminal: false,
|
||||
enableCommandHistory: false,
|
||||
@@ -28,16 +38,13 @@ export function createQuickConnectHost(input: QuickConnectInput): Host {
|
||||
enableTerminalToolbar: false,
|
||||
enableAiAssistant: false,
|
||||
enableSsh: false,
|
||||
enableRdp: protocol === "rdp",
|
||||
enableVnc: protocol === "vnc",
|
||||
rdpPort: protocol === "rdp" ? input.port : 3389,
|
||||
vncPort: protocol === "vnc" ? input.port : 5900,
|
||||
rdpAuthType: "direct",
|
||||
rdpUser: protocol === "rdp" ? input.username : undefined,
|
||||
rdpPassword: protocol === "rdp" ? input.password : undefined,
|
||||
domain: protocol === "rdp" ? input.domain : undefined,
|
||||
vncUser: protocol === "vnc" ? input.username : undefined,
|
||||
vncPassword: protocol === "vnc" ? input.password : undefined,
|
||||
domain: input.domain,
|
||||
pluginSettings: {
|
||||
[protocol.pluginId]: {
|
||||
[protocol.settingKey]: true,
|
||||
...(protocol.portKey ? { [protocol.portKey]: input.port } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -71,13 +78,7 @@ export function createQuickConnectHost(input: QuickConnectInput): Host {
|
||||
enableTerminalToolbar: true,
|
||||
enableAiAssistant: false,
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
sshPort: input.port,
|
||||
rdpPort: 3389,
|
||||
vncPort: 5900,
|
||||
telnetPort: 23,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,12 +114,6 @@ export function quickConnectHostToPayload(host: Host): SSHHostData {
|
||||
showServerStatsInSidebar: true,
|
||||
connectionType: "ssh",
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
sshPort: host.sshPort,
|
||||
rdpPort: host.rdpPort,
|
||||
vncPort: host.vncPort,
|
||||
telnetPort: host.telnetPort,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { registerHostProtocol } from "@/sidebar/host-protocols";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import {
|
||||
buildStatusTooltip,
|
||||
@@ -69,25 +70,27 @@ describe("buildStatusTooltip", () => {
|
||||
});
|
||||
|
||||
it("includes protocol names in the tooltip when protocols are enabled", () => {
|
||||
const dispose = registerHostProtocol({
|
||||
id: "demo-desktop",
|
||||
pluginId: "demo",
|
||||
settingKey: "enableDemo",
|
||||
defaultPort: 3389,
|
||||
titleKey: "Demo Desktop",
|
||||
icon: () => null,
|
||||
});
|
||||
const host = makeHost({
|
||||
enableSsh: true,
|
||||
enableRdp: true,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
pluginSettings: { demo: { enableDemo: true } },
|
||||
});
|
||||
const tooltip = buildStatusTooltip(host, "online", t);
|
||||
dispose();
|
||||
expect(tooltip).toContain("SSH");
|
||||
expect(tooltip).toContain("RDP");
|
||||
expect(tooltip).toContain("Demo Desktop");
|
||||
expect(tooltip).toContain("Available");
|
||||
});
|
||||
|
||||
it("returns just the status label when no protocols are enabled", () => {
|
||||
const host = makeHost({
|
||||
enableSsh: false,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
});
|
||||
const host = makeHost({ enableSsh: false });
|
||||
const tooltip = buildStatusTooltip(host, "online", t);
|
||||
expect(tooltip).toBe("Available");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { enabledHostProtocols } from "@/sidebar/host-protocols";
|
||||
import { useEffect, useRef, useState, type MouseEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -105,9 +106,9 @@ export function buildStatusTooltip(
|
||||
if (!statusCheckEnabled(host)) return t("hosts.status.monitoringDisabled");
|
||||
const protocols: string[] = [];
|
||||
if (host.enableSsh) protocols.push("SSH");
|
||||
if (host.enableRdp) protocols.push("RDP");
|
||||
if (host.enableVnc) protocols.push("VNC");
|
||||
if (host.enableTelnet) protocols.push("Telnet");
|
||||
for (const protocol of enabledHostProtocols(host)) {
|
||||
protocols.push(t(protocol.titleKey));
|
||||
}
|
||||
if (protocols.length === 0) return statusLabel;
|
||||
return `${protocols.join(", ")}: ${statusLabel}`;
|
||||
}
|
||||
@@ -958,11 +959,7 @@ export function HostItem({
|
||||
// reachable. If the host only exposes a single action, just launch it.
|
||||
if (isTouchOnly) {
|
||||
e.stopPropagation();
|
||||
const otherProtocols = [
|
||||
host.enableRdp,
|
||||
host.enableVnc,
|
||||
host.enableTelnet,
|
||||
].filter(Boolean).length;
|
||||
const otherProtocols = enabledHostProtocols(host).length;
|
||||
if (otherProtocols <= 1) {
|
||||
openHostTab(defaultAction);
|
||||
} else {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { updatePluginHostSettings } from "@/api/plugins-api";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
|
||||
@@ -608,22 +609,14 @@ export function SidebarTree({
|
||||
credentialId: host.credentialId ? Number(host.credentialId) : null,
|
||||
overrideCredentialUsername: host.overrideCredentialUsername ?? false,
|
||||
enableSsh: host.enableSsh,
|
||||
enableRdp: host.enableRdp,
|
||||
enableVnc: host.enableVnc,
|
||||
enableTelnet: host.enableTelnet,
|
||||
enableTerminal: host.enableTerminal,
|
||||
enableTunnel: host.enableTunnel,
|
||||
enableFileManager: host.enableFileManager,
|
||||
enableDocker: host.enableDocker,
|
||||
sshPort: host.sshPort,
|
||||
rdpPort: host.rdpPort,
|
||||
vncPort: host.vncPort,
|
||||
telnetPort: host.telnetPort,
|
||||
rdpUser: host.rdpUser ?? null,
|
||||
rdpPassword: host.rdpPassword ?? null,
|
||||
rdpDomain: host.domain ?? null,
|
||||
rdpSecurity: host.security ?? null,
|
||||
rdpIgnoreCert: host.ignoreCert ?? false,
|
||||
vncAuthType: host.vncAuthType ?? null,
|
||||
vncCredentialId: host.vncCredentialId
|
||||
? Number(host.vncCredentialId)
|
||||
@@ -650,10 +643,21 @@ export function SidebarTree({
|
||||
snippetId: Number(a.snippetId),
|
||||
})),
|
||||
statsConfig: host.statsConfig,
|
||||
guacamoleConfig: host.guacamoleConfig ?? null,
|
||||
terminalConfig: host.terminalConfig ?? null,
|
||||
};
|
||||
await createSSHHost(duplicateHost);
|
||||
const created = await createSSHHost(duplicateHost);
|
||||
// Plugin host settings live outside the host row. Secrets come back
|
||||
// redacted, and sending the marker back is a no-op, so they stay unset.
|
||||
for (const [pluginId, fields] of Object.entries(
|
||||
host.pluginSettings ?? {},
|
||||
)) {
|
||||
if (!fields || Object.keys(fields).length === 0) continue;
|
||||
await updatePluginHostSettings(
|
||||
pluginId,
|
||||
Number(created.id),
|
||||
fields,
|
||||
).catch(() => {});
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
toast.success(t("hosts.duplicatedHost", { name: host.name }));
|
||||
} catch {
|
||||
|
||||
Reference in new issue
Block a user