* Guacd, Docker-Compose, RDP (#475)

* fix select edit host but not update view (#438)

* fix: Checksum issue with chocolatey

* fix: Remove homebrew old stuff

* Add Korean translation (#439)

Co-authored-by: 송준우 <2484@coreit.co.kr>

* feat: Automate flatpak

* fix: Add imagemagik to electron builder to resolve build error

* fix: Build error with runtime repo flag

* fix: Flatpak runtime error and install freedesktop ver warning

* fix: Flatpak runtime error and install freedesktop ver warning

* feat: Re-add homebrew cask and move scripts to backend

* fix: No sandbox flag issue

* fix: Change name for electron macos cask output

* fix: Sandbox error with Linux

* fix: Remove comming soon for app stores in readme

* Adding Comment at the end of the public_key on the host on deploy (#440)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* -Add New Interface for Credential DB
-Add Credential Name as a comment into the server authorized_key file

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* Sudo auto fill password (#441)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* Feature Sudo password auto-fill;

* Fix locale json shema;

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* Added Italian Language; (#445)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* Added Italian Language;

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* Auto collapse snippet folders (#448)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* feat: Add collapsable snippets (customizable in user profile)

* Translations (#447)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* Added Italian Language;

* Fix translations;

Removed duplicate keys, synchronised other languages using English as the source, translated added keys, fixed inaccurate translations.

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* Remove PTY-level keepalive (#449)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* Remove PTY-level keepalive to prevent unwanted terminal output; use SSH-level keepalive instead

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* feat: add Guacamole support for RDP, VNC, and Telnet connections

- Implemented WebSocket support for Guacamole in Nginx configuration.
- Added REST API endpoints for generating connection tokens and checking guacd status.
- Created Guacamole server using guacamole-lite for handling connections.
- Developed frontend components for testing RDP/VNC connections and displaying the remote session.
- Updated package dependencies to include guacamole-common-js and guacamole-lite.
- Enhanced logging for Guacamole operations.

* feat: enhance Guacamole support with RDP and VNC connection settings and UI updates

* feat: Seperate server stats and tunnel management (improved both UI's) then started initial docker implementation

* fix: finalize adding docker to db

* fix: merge syntax errors

* feat: implement mouse coordinate adjustment based on scale factor in GuacamoleDisplay

* feat: add TypeScript definitions for guacamole-common-js module

* feat: enhance Mouse.State constructor to accept optional parameters and object destructuring

* feat: Add support for RDP and VNC connections in SSH host management

- Introduced connectionType field to differentiate between SSH, RDP, VNC, and Telnet in host data structures.
- Updated backend routes to handle RDP/VNC specific fields: domain, security, and ignoreCert.
- Enhanced the HostManagerEditor to include RDP/VNC specific settings and authentication options.
- Implemented token retrieval for RDP/VNC connections using Guacamole API.
- Updated UI components to reflect connection type changes and provide appropriate connection buttons.
- Removed the GuacamoleTestDialog component as its functionality is integrated into the HostManagerEditor.
- Adjusted the TopNavbar and Host components to accommodate new connection types and their respective actions.

* feat: Enhance Guacamole integration with extended configuration options

- Added detailed Guacamole configuration interface for RDP/VNC/Telnet connections, including display, audio, performance, and session settings.
- Implemented logging for token requests and received options for better debugging.
- Updated HostManagerEditor to support new Guacamole configuration fields with validation and default values.
- Integrated Guacamole configuration parsing in HostManagerViewer and Host components.
- Enhanced API requests to include extended Guacamole configuration parameters in the token request.
- Refactored code to convert camelCase configuration keys to kebab-case for compatibility with Guacamole API.

* feat: merge guacd into 2.0.0 and improve UI for host manager and made general bug fixes

---------

Co-authored-by: Tran Trung Kien <kientt13.7@gmail.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: junu <bigdwarf_@naver.com>
Co-authored-by: 송준우 <2484@coreit.co.kr>
Co-authored-by: SlimGary <trash.slim@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: rename api routes and files

* feat: improve guacd ui/backend

* feat: improve guacd ui/backend

* fix: state persistance issues causing refresh

* feat: improge guacd connections, fixed telnet not opening, and improved general guacd integration

* feat: continue improving integration also with bug fixes

* Merge 2.0.0 with 2.0.0 that includes bug fixes (#620)

* Guacd, Docker-Compose, RDP (#475)

* fix select edit host but not update view (#438)

* fix: Checksum issue with chocolatey

* fix: Remove homebrew old stuff

* Add Korean translation (#439)

Co-authored-by: 송준우 <2484@coreit.co.kr>

* feat: Automate flatpak

* fix: Add imagemagik to electron builder to resolve build error

* fix: Build error with runtime repo flag

* fix: Flatpak runtime error and install freedesktop ver warning

* fix: Flatpak runtime error and install freedesktop ver warning

* feat: Re-add homebrew cask and move scripts to backend

* fix: No sandbox flag issue

* fix: Change name for electron macos cask output

* fix: Sandbox error with Linux

* fix: Remove comming soon for app stores in readme

* Adding Comment at the end of the public_key on the host on deploy (#440)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* -Add New Interface for Credential DB
-Add Credential Name as a comment into the server authorized_key file

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* Sudo auto fill password (#441)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* Feature Sudo password auto-fill;

* Fix locale json shema;

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* Added Italian Language; (#445)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* Added Italian Language;

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* Auto collapse snippet folders (#448)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* feat: Add collapsable snippets (customizable in user profile)

* Translations (#447)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* Added Italian Language;

* Fix translations;

Removed duplicate keys, synchronised other languages using English as the source, translated added keys, fixed inaccurate translations.

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* Remove PTY-level keepalive (#449)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* Remove PTY-level keepalive to prevent unwanted terminal output; use SSH-level keepalive instead

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* feat: add Guacamole support for RDP, VNC, and Telnet connections

- Implemented WebSocket support for Guacamole in Nginx configuration.
- Added REST API endpoints for generating connection tokens and checking guacd status.
- Created Guacamole server using guacamole-lite for handling connections.
- Developed frontend components for testing RDP/VNC connections and displaying the remote session.
- Updated package dependencies to include guacamole-common-js and guacamole-lite.
- Enhanced logging for Guacamole operations.

* feat: enhance Guacamole support with RDP and VNC connection settings and UI updates

* feat: Seperate server stats and tunnel management (improved both UI's) then started initial docker implementation

* fix: finalize adding docker to db

* fix: merge syntax errors

* feat: implement mouse coordinate adjustment based on scale factor in GuacamoleDisplay

* feat: add TypeScript definitions for guacamole-common-js module

* feat: enhance Mouse.State constructor to accept optional parameters and object destructuring

* feat: Add support for RDP and VNC connections in SSH host management

- Introduced connectionType field to differentiate between SSH, RDP, VNC, and Telnet in host data structures.
- Updated backend routes to handle RDP/VNC specific fields: domain, security, and ignoreCert.
- Enhanced the HostManagerEditor to include RDP/VNC specific settings and authentication options.
- Implemented token retrieval for RDP/VNC connections using Guacamole API.
- Updated UI components to reflect connection type changes and provide appropriate connection buttons.
- Removed the GuacamoleTestDialog component as its functionality is integrated into the HostManagerEditor.
- Adjusted the TopNavbar and Host components to accommodate new connection types and their respective actions.

* feat: Enhance Guacamole integration with extended configuration options

- Added detailed Guacamole configuration interface for RDP/VNC/Telnet connections, including display, audio, performance, and session settings.
- Implemented logging for token requests and received options for better debugging.
- Updated HostManagerEditor to support new Guacamole configuration fields with validation and default values.
- Integrated Guacamole configuration parsing in HostManagerViewer and Host components.
- Enhanced API requests to include extended Guacamole configuration parameters in the token request.
- Refactored code to convert camelCase configuration keys to kebab-case for compatibility with Guacamole API.

* feat: merge guacd into 2.0.0 and improve UI for host manager and made general bug fixes

---------

Co-authored-by: Tran Trung Kien <kientt13.7@gmail.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: junu <bigdwarf_@naver.com>
Co-authored-by: 송준우 <2484@coreit.co.kr>
Co-authored-by: SlimGary <trash.slim@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: rename api routes and files

* feat: improve guacd ui/backend

* feat: improve guacd ui/backend

* fix: state persistance issues causing refresh

* feat: improge guacd connections, fixed telnet not opening, and improved general guacd integration

* feat: continue improving integration also with bug fixes

---------

Co-authored-by: Wesley Reid <starhound@lostsouls.org>
Co-authored-by: Tran Trung Kien <kientt13.7@gmail.com>
Co-authored-by: junu <bigdwarf_@naver.com>
Co-authored-by: 송준우 <2484@coreit.co.kr>
Co-authored-by: SlimGary <trash.slim@gmail.com>
Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: allow customizing guacd backened url

* fix: ssh route mistmatching and guacamole url not changing

* chore: increment ver

* feat: change default to work with default compose, added splits creen support, updated readmes

* fix: linux app not starting due to better sqlite isuses, improved copy/paste system so no context menu, added oidc remember me toggle, improved OS detection for sessions, flatpak invalid key, and sharing hosts with other users errors

* fix: global settings not setting

* chore: update compose

* feat: improve the global status input

* chore: cleanup files

* chore: update export/improt with new host fields

* fix: file manager and docker not loading properly

---------

Co-authored-by: Wesley Reid <starhound@lostsouls.org>
Co-authored-by: Tran Trung Kien <kientt13.7@gmail.com>
Co-authored-by: junu <bigdwarf_@naver.com>
Co-authored-by: 송준우 <2484@coreit.co.kr>
Co-authored-by: SlimGary <trash.slim@gmail.com>
Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Luke Gustafson
2026-03-14 20:05:05 -05:00
committed by GitHub
parent a255a08903
commit e9e30cd318
85 changed files with 7053 additions and 2286 deletions
+15 -3
View File
@@ -5,6 +5,7 @@ import React, {
useEffect,
useCallback,
useRef,
useMemo,
} from "react";
import { getAllServerStatuses, getSSHHosts } from "@/ui/main-axios";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
@@ -71,7 +72,13 @@ export function ServerStatusProvider({
}
});
setEnabledHostIds(enabled);
setEnabledHostIds((prev) => {
if (prev.size !== enabled.size) return enabled;
for (const id of enabled) {
if (!prev.has(id)) return enabled;
}
return prev;
});
return enabled;
} catch (error) {
return new Set<number>();
@@ -125,14 +132,19 @@ export function ServerStatusProvider({
}
}, [isAuthenticated]);
const stableEnabledHostIds = useMemo(
() => enabledHostIds,
[[...enabledHostIds].sort().join(",")],
);
const getStatus = useCallback(
(hostId: number): StatusValue => {
if (!enabledHostIds.has(hostId)) {
if (!stableEnabledHostIds.has(hostId)) {
return "offline";
}
return statuses.get(hostId)?.status || "degraded";
},
[statuses, enabledHostIds],
[statuses, stableEnabledHostIds],
);
useEffect(() => {
+5 -3
View File
@@ -156,9 +156,8 @@ function AppContent({
if (hostIdentifier) {
const openTerminal = async () => {
try {
const { getSSHHostById, getSSHHosts } = await import(
"@/ui/main-axios.ts"
);
const { getSSHHostById, getSSHHosts } =
await import("@/ui/main-axios.ts");
let host = null;
if (/^\d+$/.test(hostIdentifier)) {
@@ -282,6 +281,9 @@ function AppContent({
currentTabData?.type === "terminal" ||
currentTabData?.type === "server_stats" ||
currentTabData?.type === "file_manager" ||
currentTabData?.type === "rdp" ||
currentTabData?.type === "vnc" ||
currentTabData?.type === "telnet" ||
currentTabData?.type === "tunnel" ||
currentTabData?.type === "docker" ||
currentTabData?.type === "network_graph";
@@ -17,7 +17,10 @@ import {
updatePasswordResetAllowed,
getGlobalMonitoringSettings,
updateGlobalMonitoringSettings,
getGuacamoleSettings,
updateGuacamoleSettings,
} from "@/ui/main-axios.ts";
import { Button } from "@/components/ui/button.tsx";
interface GeneralSettingsTabProps {
allowRegistration: boolean;
@@ -51,9 +54,10 @@ export function GeneralSettingsTab({
const [passwordLoginLoading, setPasswordLoginLoading] = React.useState(false);
const [passwordResetLoading, setPasswordResetLoading] = React.useState(false);
// Global monitoring defaults
const [statusInterval, setStatusInterval] = React.useState(60);
const [metricsInterval, setMetricsInterval] = React.useState(30);
const [statusInputValue, setStatusInputValue] = React.useState("60");
const [metricsInputValue, setMetricsInputValue] = React.useState("30");
const [statusUnit, setStatusUnit] = React.useState<"seconds" | "minutes">(
"seconds",
);
@@ -62,59 +66,101 @@ export function GeneralSettingsTab({
);
const [monitoringLoading, setMonitoringLoading] = React.useState(false);
const [guacEnabled, setGuacEnabled] = React.useState(true);
const [guacUrl, setGuacUrl] = React.useState("guacd:4822");
const [guacLoading, setGuacLoading] = React.useState(false);
React.useEffect(() => {
getGlobalMonitoringSettings()
getGuacamoleSettings()
.then((data) => {
setStatusInterval(data.statusCheckInterval);
setMetricsInterval(data.metricsInterval);
setGuacEnabled(data.enabled);
setGuacUrl(data.url);
})
.catch(() => {
// Use defaults silently
toast.error(t("admin.failedToLoadGuacamoleSettings"));
});
}, []);
}, [t]);
const saveMonitoringDebounce = React.useRef<NodeJS.Timeout | null>(null);
const saveGuacDebounce = React.useRef<NodeJS.Timeout | null>(null);
const saveMonitoringSettings = React.useCallback(
(newStatus: number, newMetrics: number) => {
if (saveMonitoringDebounce.current) {
clearTimeout(saveMonitoringDebounce.current);
const saveGuacSettings = React.useCallback(
(newEnabled: boolean, newUrl: string) => {
if (saveGuacDebounce.current) {
clearTimeout(saveGuacDebounce.current);
}
saveMonitoringDebounce.current = setTimeout(async () => {
setMonitoringLoading(true);
saveGuacDebounce.current = setTimeout(async () => {
setGuacLoading(true);
try {
await updateGlobalMonitoringSettings({
statusCheckInterval: newStatus,
metricsInterval: newMetrics,
});
toast.success(t("admin.globalSettingsSaved"));
} catch (error) {
const errorMessage =
error instanceof Error
? error.message
: t("admin.failedToSaveGlobalSettings");
toast.error(errorMessage);
await updateGuacamoleSettings({ enabled: newEnabled, url: newUrl });
toast.success(t("admin.guacamoleSettingsSaved"));
} catch {
toast.error(t("admin.failedToSaveGuacamoleSettings"));
} finally {
setMonitoringLoading(false);
setGuacLoading(false);
}
}, 800);
},
[t],
);
const handleStatusIntervalChange = (value: string) => {
const num = parseInt(value) || 0;
React.useEffect(() => {
getGlobalMonitoringSettings()
.then((data) => {
setStatusInterval(data.statusCheckInterval);
setMetricsInterval(data.metricsInterval);
setStatusInputValue(String(data.statusCheckInterval));
setMetricsInputValue(String(data.metricsInterval));
})
.catch(() => {
// Use defaults silently
});
}, []);
const saveMonitoringSettings = React.useCallback(
async (newStatus: number, newMetrics: number) => {
setMonitoringLoading(true);
try {
await updateGlobalMonitoringSettings({
statusCheckInterval: newStatus,
metricsInterval: newMetrics,
});
toast.success(t("admin.globalSettingsSaved"));
} catch (error) {
const errorMessage =
error instanceof Error
? error.message
: t("admin.failedToSaveGlobalSettings");
toast.error(errorMessage);
} finally {
setMonitoringLoading(false);
}
},
[t],
);
const handleStatusBlur = () => {
const num = parseInt(statusInputValue) || 0;
const seconds = statusUnit === "minutes" ? num * 60 : num;
const clamped = Math.max(5, Math.min(3600, seconds));
setStatusInterval(clamped);
setStatusInputValue(
statusUnit === "minutes"
? String(Math.round(clamped / 60))
: String(clamped),
);
saveMonitoringSettings(clamped, metricsInterval);
};
const handleMetricsIntervalChange = (value: string) => {
const num = parseInt(value) || 0;
const handleMetricsBlur = () => {
const num = parseInt(metricsInputValue) || 0;
const seconds = metricsUnit === "minutes" ? num * 60 : num;
const clamped = Math.max(5, Math.min(3600, seconds));
setMetricsInterval(clamped);
setMetricsInputValue(
metricsUnit === "minutes"
? String(Math.round(clamped / 60))
: String(clamped),
);
saveMonitoringSettings(statusInterval, clamped);
};
@@ -244,12 +290,9 @@ export function GeneralSettingsTab({
<div className="flex gap-2 mt-1">
<Input
type="number"
value={
statusUnit === "minutes"
? Math.round(statusInterval / 60)
: statusInterval
}
onChange={(e) => handleStatusIntervalChange(e.target.value)}
value={statusInputValue}
onChange={(e) => setStatusInputValue(e.target.value)}
onBlur={handleStatusBlur}
disabled={monitoringLoading}
className="flex-1"
/>
@@ -257,6 +300,11 @@ export function GeneralSettingsTab({
value={statusUnit}
onValueChange={(value: "seconds" | "minutes") => {
setStatusUnit(value);
setStatusInputValue(
value === "minutes"
? String(Math.round(statusInterval / 60))
: String(statusInterval),
);
}}
>
<SelectTrigger className="w-[120px]">
@@ -280,12 +328,9 @@ export function GeneralSettingsTab({
<div className="flex gap-2 mt-1">
<Input
type="number"
value={
metricsUnit === "minutes"
? Math.round(metricsInterval / 60)
: metricsInterval
}
onChange={(e) => handleMetricsIntervalChange(e.target.value)}
value={metricsInputValue}
onChange={(e) => setMetricsInputValue(e.target.value)}
onBlur={handleMetricsBlur}
disabled={monitoringLoading}
className="flex-1"
/>
@@ -293,6 +338,11 @@ export function GeneralSettingsTab({
value={metricsUnit}
onValueChange={(value: "seconds" | "minutes") => {
setMetricsUnit(value);
setMetricsInputValue(
value === "minutes"
? String(Math.round(metricsInterval / 60))
: String(metricsInterval),
);
}}
>
<SelectTrigger className="w-[120px]">
@@ -311,6 +361,55 @@ export function GeneralSettingsTab({
</div>
</div>
</div>
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
<h3 className="text-lg font-semibold">
{t("admin.guacamoleIntegration")}
</h3>
<p className="text-sm text-muted-foreground">
{t("admin.guacamoleIntegrationDesc")}
</p>
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
onClick={() =>
window.open("https://docs.termix.site/remote-desktop", "_blank")
}
>
{t("common.documentation")}
</Button>
<label className="flex items-center gap-2">
<Checkbox
checked={guacEnabled}
onCheckedChange={(checked) => {
const val = checked === true;
setGuacEnabled(val);
saveGuacSettings(val, guacUrl);
}}
disabled={guacLoading}
/>
{t("admin.enableGuacamole")}
</label>
{guacEnabled && (
<div>
<label className="text-sm font-medium">{t("admin.guacdUrl")}</label>
<Input
className="mt-1"
value={guacUrl}
placeholder={t("admin.guacdUrlPlaceholder")}
disabled={guacLoading}
onChange={(e) => {
setGuacUrl(e.target.value);
saveGuacSettings(guacEnabled, e.target.value);
}}
/>
<p className="text-xs text-muted-foreground mt-1">
{t("admin.guacdUrlNote")}
</p>
</div>
)}
</div>
</div>
);
}
@@ -16,6 +16,9 @@ import {
User,
Github,
Terminal,
Monitor,
Eye,
MessagesSquare,
FolderOpen,
Pencil,
EllipsisVertical,
@@ -27,8 +30,14 @@ import { BiMoney, BiSupport } from "react-icons/bi";
import { BsDiscord } from "react-icons/bs";
import { GrUpdate } from "react-icons/gr";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { getRecentActivity, getSSHHosts } from "@/ui/main-axios.ts";
import {
getRecentActivity,
getSSHHosts,
getGuacamoleToken,
logActivity,
} from "@/ui/main-axios.ts";
import type { RecentActivityItem } from "@/ui/main-axios.ts";
import { toast } from "sonner";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
import {
DropdownMenu,
@@ -62,6 +71,16 @@ interface SSHHost {
statsConfig?: string;
createdAt: string;
updatedAt: string;
connectionType?: "ssh" | "rdp" | "vnc" | "telnet";
domain?: string;
security?: string;
ignoreCert?: boolean;
guacamoleConfig?: any;
showTerminalInSidebar?: boolean;
showFileManagerInSidebar?: boolean;
showTunnelInSidebar?: boolean;
showDockerInSidebar?: boolean;
showServerStatsInSidebar?: boolean;
}
export function CommandPalette({
@@ -204,10 +223,60 @@ export function CommandPalette({
setIsOpen(false);
};
const handleHostTerminalClick = (host: SSHHost) => {
const handleHostTerminalClick = async (host: SSHHost) => {
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
if (
host.connectionType === "rdp" ||
host.connectionType === "vnc" ||
host.connectionType === "telnet"
) {
try {
const protocol = host.connectionType as "rdp" | "vnc" | "telnet";
const result = await getGuacamoleToken({
protocol,
hostname: host.ip,
port: host.port,
username: host.username,
password: host.password,
domain: host.domain,
security: host.security,
ignoreCert: host.ignoreCert,
guacamoleConfig: host.guacamoleConfig as any,
});
addTab({
type: protocol,
title,
hostConfig: host,
connectionConfig: {
token: result.token,
protocol,
type: protocol,
hostname: host.ip,
port: host.port,
username: host.username,
password: host.password,
domain: host.domain,
security: host.security,
"ignore-cert": host.ignoreCert,
},
});
try {
await logActivity(protocol, host.id, title);
} catch (err) {
console.warn(`Failed to log ${protocol} activity:`, err);
}
} catch (err) {
console.error("Failed to get Guacamole token:", err);
}
setIsOpen(false);
return;
}
addTab({ type: "terminal", title, hostConfig: host });
setIsOpen(false);
};
@@ -340,6 +409,9 @@ export function CommandPalette({
shouldShowMetrics = true;
}
const isSSH =
!host.connectionType || host.connectionType === "ssh";
let hasTunnelConnections = false;
try {
const tunnelConnections = Array.isArray(
@@ -356,13 +428,18 @@ export function CommandPalette({
const visibleButtons = [
host.enableTerminal && (host.showTerminalInSidebar ?? true),
host.enableFileManager &&
isSSH &&
host.enableFileManager &&
(host.showFileManagerInSidebar ?? false),
host.enableTunnel &&
isSSH &&
host.enableTunnel &&
hasTunnelConnections &&
(host.showTunnelInSidebar ?? false),
host.enableDocker && (host.showDockerInSidebar ?? false),
shouldShowMetrics &&
isSSH &&
host.enableDocker &&
(host.showDockerInSidebar ?? false),
isSSH &&
shouldShowMetrics &&
(host.showServerStatsInSidebar ?? false),
].filter(Boolean).length;
@@ -395,11 +472,20 @@ export function CommandPalette({
handleHostTerminalClick(host);
}}
>
<Terminal className="h-3 w-3" />
{host.connectionType === "rdp" ? (
<Monitor className="h-3 w-3" />
) : host.connectionType === "vnc" ? (
<Eye className="h-3 w-3" />
) : host.connectionType === "telnet" ? (
<MessagesSquare className="h-3 w-3" />
) : (
<Terminal className="h-3 w-3" />
)}
</Button>
)}
{host.enableFileManager &&
{isSSH &&
host.enableFileManager &&
(host.showFileManagerInSidebar ?? false) && (
<Button
variant="outline"
@@ -413,7 +499,8 @@ export function CommandPalette({
</Button>
)}
{host.enableTunnel &&
{isSSH &&
host.enableTunnel &&
hasTunnelConnections &&
(host.showTunnelInSidebar ?? false) && (
<Button
@@ -428,7 +515,8 @@ export function CommandPalette({
</Button>
)}
{host.enableDocker &&
{isSSH &&
host.enableDocker &&
(host.showDockerInSidebar ?? false) && (
<Button
variant="outline"
@@ -442,7 +530,8 @@ export function CommandPalette({
</Button>
)}
{shouldShowMetrics &&
{isSSH &&
shouldShowMetrics &&
(host.showServerStatsInSidebar ?? false) && (
<Button
variant="outline"
@@ -484,13 +573,22 @@ export function CommandPalette({
}}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
>
<Terminal className="h-4 w-4" />
{host.connectionType === "rdp" ? (
<Monitor className="h-4 w-4" />
) : host.connectionType === "vnc" ? (
<Eye className="h-4 w-4" />
) : host.connectionType === "telnet" ? (
<MessagesSquare className="h-4 w-4" />
) : (
<Terminal className="h-4 w-4" />
)}
<span className="flex-1">
{t("hosts.openTerminal")}
</span>
</DropdownMenuItem>
)}
{shouldShowMetrics &&
{isSSH &&
shouldShowMetrics &&
!(host.showServerStatsInSidebar ?? false) && (
<DropdownMenuItem
onClick={(e) => {
@@ -505,7 +603,8 @@ export function CommandPalette({
</span>
</DropdownMenuItem>
)}
{host.enableFileManager &&
{isSSH &&
host.enableFileManager &&
!(host.showFileManagerInSidebar ?? false) && (
<DropdownMenuItem
onClick={(e) => {
@@ -520,7 +619,8 @@ export function CommandPalette({
</span>
</DropdownMenuItem>
)}
{host.enableTunnel &&
{isSSH &&
host.enableTunnel &&
hasTunnelConnections &&
!(host.showTunnelInSidebar ?? false) && (
<DropdownMenuItem
@@ -536,7 +636,8 @@ export function CommandPalette({
</span>
</DropdownMenuItem>
)}
{host.enableDocker &&
{isSSH &&
host.enableDocker &&
!(host.showDockerInSidebar ?? false) && (
<DropdownMenuItem
onClick={(e) => {
+103
View File
@@ -15,6 +15,7 @@ import {
getServerMetricsById,
registerMetricsViewer,
sendMetricsHeartbeat,
getGuacamoleToken,
type RecentActivityItem,
} from "@/ui/main-axios.ts";
import { useSidebar } from "@/components/ui/sidebar.tsx";
@@ -386,6 +387,108 @@ export function Dashboard({
title: item.hostName,
hostConfig: host,
});
} else if (item.type === "telnet") {
getGuacamoleToken({
protocol: "telnet",
hostname: host.ip,
port: host.port,
username: host.username,
password: host.password,
domain: host.domain,
security: host.security,
ignoreCert: host.ignoreCert,
guacamoleConfig: host.guacamoleConfig as any,
})
.then((result) => {
addTab({
type: "telnet",
title: item.hostName,
hostConfig: host,
connectionConfig: {
token: result.token,
protocol: "telnet",
type: "telnet",
hostname: host.ip,
port: host.port,
username: host.username,
password: host.password,
domain: host.domain,
security: host.security,
"ignore-cert": host.ignoreCert,
},
});
})
.catch((error) => {
console.error("Failed to get telnet token:", error);
});
} else if (item.type === "vnc") {
getGuacamoleToken({
protocol: "vnc",
hostname: host.ip,
port: host.port,
username: host.username,
password: host.password,
domain: host.domain,
security: host.security,
ignoreCert: host.ignoreCert,
guacamoleConfig: host.guacamoleConfig as any,
})
.then((result) => {
addTab({
type: "vnc",
title: item.hostName,
hostConfig: host,
connectionConfig: {
token: result.token,
protocol: "vnc",
type: "vnc",
hostname: host.ip,
port: host.port,
username: host.username,
password: host.password,
domain: host.domain,
security: host.security,
"ignore-cert": host.ignoreCert,
},
});
})
.catch((error) => {
console.error("Failed to get vnc token:", error);
});
} else if (item.type === "rdp") {
getGuacamoleToken({
protocol: "rdp",
hostname: host.ip,
port: host.port,
username: host.username,
password: host.password,
domain: host.domain,
security: host.security,
ignoreCert: host.ignoreCert,
guacamoleConfig: host.guacamoleConfig as any,
})
.then((result) => {
addTab({
type: "rdp",
title: item.hostName,
hostConfig: host,
connectionConfig: {
token: result.token,
protocol: "rdp",
type: "rdp",
hostname: host.ip,
port: host.port,
username: host.username,
password: host.password,
domain: host.domain,
security: host.security,
"ignore-cert": host.ignoreCert,
},
});
})
.catch((error) => {
console.error("Failed to get rdp token:", error);
});
}
});
};
@@ -8,6 +8,10 @@ import {
Server,
ArrowDownUp,
Container,
Monitor,
Eye,
MessagesSquare,
Network,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { type RecentActivityItem } from "@/ui/main-axios";
@@ -83,6 +87,12 @@ export function RecentActivityCard({
<ArrowDownUp size={20} className="shrink-0" />
) : item.type === "docker" ? (
<Container size={20} className="shrink-0" />
) : item.type === "telnet" ? (
<MessagesSquare size={20} className="shrink-0" />
) : item.type === "vnc" ? (
<Eye size={20} className="shrink-0" />
) : item.type === "rdp" ? (
<Monitor size={20} className="shrink-0" />
) : (
<Terminal size={20} className="shrink-0" />
)}
@@ -249,7 +249,7 @@ export function ConsoleTerminal({
window.location.port === "");
const baseWsUrl = isDev
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30008`
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`
: isElectronApp
? (() => {
const baseUrl =
@@ -0,0 +1,103 @@
import React, { useState, useEffect } from "react";
import { GuacamoleDisplay } from "@/ui/desktop/apps/features/guacamole/GuacamoleDisplay.tsx";
import { FullScreenAppWrapper } from "@/ui/desktop/apps/FullScreenAppWrapper.tsx";
import { getGuacamoleTokenFromHost } from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
interface GuacamoleAppProps {
hostId?: string;
}
const GuacamoleApp: React.FC<GuacamoleAppProps> = ({ hostId }) => {
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, loading) => {
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
<p className="text-muted-foreground">Loading host...</p>
</div>
</div>
);
}
if (!hostConfig) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-red-500 mb-4">Host not found</p>
</div>
</div>
);
}
return (
<GuacamoleAppInner
hostId={parseInt(hostId!, 10)}
hostConfig={hostConfig}
/>
);
}}
</FullScreenAppWrapper>
);
};
interface GuacamoleAppInnerProps {
hostId: number;
hostConfig: any;
}
const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
hostId,
hostConfig,
}) => {
const { t } = useTranslation();
const [token, setToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getGuacamoleTokenFromHost(hostId)
.then((result) => setToken(result.token))
.catch((err) => setError(err?.message || t("guacamole.failedToConnect")));
}, [hostId]);
if (error) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-red-500 mb-4">{error}</p>
</div>
</div>
);
}
if (!token) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
<p className="text-muted-foreground">
{t("guacamole.connecting", {
type: (hostConfig.connectionType || "remote").toUpperCase(),
})}
</p>
</div>
</div>
);
}
const protocol = hostConfig.connectionType as "rdp" | "vnc" | "telnet";
return (
<div className="relative w-full h-full">
<GuacamoleDisplay
connectionConfig={{ token, protocol, type: protocol }}
isVisible={true}
/>
</div>
);
};
export default GuacamoleApp;
@@ -0,0 +1,385 @@
import {
useEffect,
useRef,
useState,
useImperativeHandle,
forwardRef,
useCallback,
} from "react";
import Guacamole from "guacamole-common-js";
import { useTranslation } from "react-i18next";
import { getCookie, isElectron } from "@/ui/main-axios.ts";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
export interface GuacamoleConnectionConfig {
token?: string;
protocol?: GuacamoleConnectionType;
type?: GuacamoleConnectionType;
hostname?: string;
port?: number;
username?: string;
password?: string;
domain?: string;
width?: number;
height?: number;
dpi?: number;
[key: string]: unknown;
}
export interface GuacamoleDisplayHandle {
disconnect: () => void;
sendKey: (keysym: number, pressed: boolean) => void;
sendMouse: (x: number, y: number, buttonMask: number) => void;
setClipboard: (data: string) => void;
}
interface GuacamoleDisplayProps {
connectionConfig: GuacamoleConnectionConfig;
isVisible: boolean;
onConnect?: () => void;
onDisconnect?: () => void;
onError?: (error: string) => void;
}
const isDev = import.meta.env.DEV;
export const GuacamoleDisplay = forwardRef<
GuacamoleDisplayHandle,
GuacamoleDisplayProps
>(function GuacamoleDisplay(
{ connectionConfig, isVisible, onConnect, onDisconnect, onError },
ref,
) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const displayRef = useRef<HTMLDivElement>(null);
const clientRef = useRef<Guacamole.Client | null>(null);
const scaleRef = useRef<number>(1);
const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
const [isReady, setIsReady] = useState(false);
useImperativeHandle(ref, () => ({
disconnect: () => {
if (clientRef.current) {
clientRef.current.disconnect();
}
},
sendKey: (keysym: number, pressed: boolean) => {
if (clientRef.current) {
clientRef.current.sendKeyEvent(pressed ? 1 : 0, keysym);
}
},
sendMouse: (x: number, y: number, buttonMask: number) => {
if (clientRef.current) {
clientRef.current.sendMouseState(
new Guacamole.Mouse.State({
x,
y,
left: !!(buttonMask & 1),
middle: !!(buttonMask & 2),
right: !!(buttonMask & 4),
}),
);
}
},
setClipboard: (data: string) => {
if (clientRef.current) {
const stream = clientRef.current.createClipboardStream("text/plain");
const writer = new Guacamole.StringWriter(stream);
writer.sendText(data);
writer.sendEnd();
}
},
}));
const getWebSocketUrl = useCallback(
async (
containerWidth: number,
containerHeight: number,
): Promise<string | null> => {
try {
let token: string;
if (connectionConfig.token) {
token = connectionConfig.token;
} else {
const jwtToken = getCookie("jwt");
if (!jwtToken) {
onError?.("Authentication required");
return null;
}
const baseUrl = isDev
? "http://localhost:30001"
: isElectron()
? (window as { configuredServerUrl?: string })
.configuredServerUrl || "http://127.0.0.1:30001"
: `${window.location.origin}`;
const response = await fetch(`${baseUrl}/guacamole/token`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${jwtToken}`,
},
body: JSON.stringify(connectionConfig),
credentials: "include",
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error || "Failed to get connection token");
}
const data = await response.json();
token = data.token;
}
const width = connectionConfig.width || containerWidth || 1280;
const height = connectionConfig.height || containerHeight || 720;
const dpi = connectionConfig.dpi || 96;
const wsBase = isDev
? `ws://localhost:30008`
: isElectron()
? (() => {
const base =
(window as { configuredServerUrl?: string })
.configuredServerUrl || "http://127.0.0.1:30001";
return `${base.startsWith("https://") ? "wss://" : "ws://"}${base.replace(/^https?:\/\//, "")}/guacamole/websocket/`;
})()
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/guacamole/websocket/`;
return `${wsBase}?token=${encodeURIComponent(token)}&width=${width}&height=${height}&dpi=${dpi}`;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
onError?.(errorMessage);
return null;
}
},
[connectionConfig, onError],
);
const rescaleDisplay = useCallback((immediate: boolean = false) => {
if (!clientRef.current || !containerRef.current) return;
const performRescale = () => {
if (!clientRef.current || !containerRef.current) return;
const display = clientRef.current.getDisplay();
const cWidth = containerRef.current.clientWidth;
const cHeight = containerRef.current.clientHeight;
const displayWidth = display.getWidth();
const displayHeight = display.getHeight();
if (displayWidth > 0 && displayHeight > 0 && cWidth > 0 && cHeight > 0) {
const scale = Math.min(cWidth / displayWidth, cHeight / displayHeight);
scaleRef.current = scale;
display.scale(scale);
}
};
if (immediate) {
performRescale();
} else {
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
resizeTimeoutRef.current = setTimeout(performRescale, 200);
}
}, []);
const connect = useCallback(async () => {
if (isConnectingRef.current) return;
isConnectingRef.current = true;
setIsConnecting(true);
setIsReady(false);
let containerWidth = containerRef.current?.clientWidth || 0;
let containerHeight = containerRef.current?.clientHeight || 0;
if (containerWidth < 100 || containerHeight < 100) {
containerWidth = 1280;
containerHeight = 720;
}
const wsUrl = await getWebSocketUrl(containerWidth, containerHeight);
if (!wsUrl) {
isConnectingRef.current = false;
setIsConnecting(false);
return;
}
const tunnel = new Guacamole.WebSocketTunnel(wsUrl);
const client = new Guacamole.Client(tunnel);
clientRef.current = client;
const display = client.getDisplay();
const displayElement = display.getElement();
if (displayRef.current) {
displayRef.current.innerHTML = "";
displayRef.current.appendChild(displayElement);
}
display.onresize = () => {
rescaleDisplay(true);
setIsReady(true);
};
const mouse = new Guacamole.Mouse(displayElement);
const sendMouseState = (state: Guacamole.Mouse.State) => {
const scale = scaleRef.current;
const adjustedX = Math.round(state.x / scale);
const adjustedY = Math.round(state.y / scale);
const adjustedState = new Guacamole.Mouse.State(
adjustedX,
adjustedY,
state.left,
state.middle,
state.right,
state.up,
state.down,
) as Guacamole.Mouse.State;
client.sendMouseState(adjustedState);
};
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = sendMouseState;
const keyboard = new Guacamole.Keyboard(document);
keyboard.onkeydown = (keysym: number) => {
client.sendKeyEvent(1, keysym);
};
keyboard.onkeyup = (keysym: number) => {
client.sendKeyEvent(0, keysym);
};
client.onstatechange = (state: number) => {
switch (state) {
case 0:
break;
case 1:
setIsConnecting(true);
break;
case 2:
break;
case 3:
setIsConnecting(false);
onConnect?.();
break;
case 4:
break;
case 5:
setIsConnecting(false);
setIsReady(false);
keyboard.onkeydown = null;
keyboard.onkeyup = null;
onDisconnect?.();
break;
}
};
client.onerror = (error: Guacamole.Status) => {
const errorMessage = error.message || "Connection error";
setIsConnecting(false);
setIsReady(false);
onError?.(errorMessage);
};
client.onclipboard = (stream: Guacamole.InputStream, mimetype: string) => {
if (mimetype === "text/plain") {
const reader = new Guacamole.StringReader(stream);
let data = "";
reader.ontext = (text: string) => {
data += text;
};
reader.onend = () => {
navigator.clipboard.writeText(data).catch(() => {});
};
}
};
client.connect();
}, [getWebSocketUrl, onConnect, onDisconnect, onError, rescaleDisplay]);
const hasInitiatedRef = useRef(false);
const isMountedRef = useRef(false);
const isConnectingRef = useRef(false);
useEffect(() => {
isMountedRef.current = true;
if (isVisible && !hasInitiatedRef.current) {
hasInitiatedRef.current = true;
requestAnimationFrame(() => {
if (isMountedRef.current) {
connect();
}
});
}
}, [isVisible, connect]);
useEffect(() => {
return () => {
isMountedRef.current = false;
hasInitiatedRef.current = false;
isConnectingRef.current = false;
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
if (clientRef.current) {
clientRef.current.disconnect();
clientRef.current = null;
}
};
}, []);
useEffect(() => {
if (!containerRef.current) return;
const resizeObserver = new ResizeObserver(() => {
rescaleDisplay(false);
});
resizeObserver.observe(containerRef.current);
const initialTimeout = setTimeout(() => rescaleDisplay(true), 100);
return () => {
resizeObserver.disconnect();
clearTimeout(initialTimeout);
};
}, [rescaleDisplay]);
const connectingMessage = t("guacamole.connecting", {
type: (
connectionConfig.protocol ||
connectionConfig.type ||
"remote"
).toUpperCase(),
});
return (
<div
ref={containerRef}
className="absolute inset-0 overflow-hidden"
style={{ backgroundColor: "var(--bg-base)" }}
>
<div
ref={displayRef}
className="relative w-full h-full flex items-center justify-center"
style={{
cursor: isReady ? "none" : "default",
visibility: isReady ? "visible" : "hidden",
}}
/>
<SimpleLoader visible={!isReady} message={connectingMessage} />
</div>
);
});
@@ -0,0 +1,81 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { KeyRound } from "lucide-react";
import { Button } from "@/components/ui/button.tsx";
interface SudoPasswordPopupProps {
isOpen: boolean;
hostPassword: string;
backgroundColor: string;
onConfirm: (password: string) => void;
onDismiss: () => void;
}
export function SudoPasswordPopup({
isOpen,
hostPassword,
backgroundColor,
onConfirm,
onDismiss,
}: SudoPasswordPopupProps) {
const { t } = useTranslation();
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
onConfirm(hostPassword);
} else if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
onDismiss();
}
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [isOpen, onConfirm, onDismiss, hostPassword]);
if (!isOpen) return null;
return (
<div
className="absolute bottom-4 right-4 z-50 backdrop-blur-sm border border-border rounded-lg shadow-lg p-4 min-w-[280px]"
style={{ backgroundColor: backgroundColor }}
>
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-primary/10 rounded-full">
<KeyRound className="h-5 w-5 text-primary" />
</div>
<div>
<h4 className="font-medium text-sm">
{t("terminal.sudoPasswordPopupTitle", "Insert password?")}
</h4>
<p className="text-xs text-muted-foreground">
{t(
"terminal.sudoPasswordPopupHint",
"Press Enter to insert, Esc to dismiss",
)}
</p>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button variant="ghost" size="sm" onClick={onDismiss}>
{t("terminal.sudoPasswordPopupDismiss", "Dismiss")}
</Button>
<Button
variant="default"
size="sm"
onClick={() => onConfirm(hostPassword)}
>
{t("terminal.sudoPasswordPopupConfirm", "Insert")}
</Button>
</div>
</div>
);
}
@@ -1499,7 +1499,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
return await navigator.clipboard.readText();
}
} catch {
toast.error(t("terminal.clipboardReadFailed"));
// fall through
}
if (window.location.protocol !== "https:" && !isElectron()) {
toast.error(t("terminal.clipboardHttpWarning"));
}
return "";
}
@@ -1679,19 +1682,15 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
if (!getUseRightClickCopyPaste()) return;
e.preventDefault();
e.stopPropagation();
try {
if (terminal.hasSelection()) {
const selection = terminal.getSelection();
if (selection) {
await writeTextToClipboard(selection);
terminal.clearSelection();
}
} else {
const pasteText = await readTextFromClipboard();
if (pasteText) terminal.paste(pasteText);
if (terminal.hasSelection()) {
const selection = terminal.getSelection();
if (selection) {
await writeTextToClipboard(selection);
terminal.clearSelection();
}
} catch (error) {
console.error("Terminal operation failed:", error);
} else {
const text = await readTextFromClipboard();
if (text) terminal.paste(text);
}
};
element?.addEventListener("contextmenu", handleContextMenu);
@@ -1832,6 +1831,21 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
}
if (
e.ctrlKey &&
!e.shiftKey &&
!e.altKey &&
!e.metaKey &&
e.key.toLowerCase() === "v"
) {
e.preventDefault();
e.stopPropagation();
readTextFromClipboard().then((text) => {
if (text) terminal.paste(text);
});
return false;
}
if (e.ctrlKey && e.altKey && !e.metaKey && !e.shiftKey) {
const key = e.key.toLowerCase();
const blockedKeys = ["w", "t", "n", "q"];
@@ -1,4 +1,4 @@
import { HostManager } from "@/ui/desktop/apps/host-manager/hosts/HostManager";
import { HostManager } from "@/ui/desktop/apps/host-manager/hosts/HostManager.tsx";
import React from "react";
const HostManagerApp: React.FC = () => {
@@ -53,6 +53,7 @@ import {
revokeHostAccess,
getSSHHostById,
notifyHostCreatedOrUpdated,
getGuacamoleSettings,
type Role,
type AccessRecord,
} from "@/ui/main-axios.ts";
@@ -130,7 +131,9 @@ import { HostDockerTab } from "./tabs/HostDockerTab";
import { HostTunnelTab } from "./tabs/HostTunnelTab";
import { HostFileManagerTab } from "./tabs/HostFileManagerTab";
import { HostStatisticsTab } from "./tabs/HostStatisticsTab";
import { HostStatusTab } from "./tabs/HostStatusTab";
import { HostSharingTab } from "./tabs/HostSharingTab";
import { HostRemoteDesktopTab } from "./tabs/HostRemoteDesktopTab";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
interface User {
@@ -176,11 +179,18 @@ export function HostManagerEditor({
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState("general");
const [formError, setFormError] = useState<string | null>(null);
const [guacEnabled, setGuacEnabled] = useState(true);
useEffect(() => {
setFormError(null);
}, [activeTab]);
useEffect(() => {
getGuacamoleSettings()
.then((data) => setGuacEnabled(data.enabled))
.catch(() => {});
}, []);
const [statusIntervalUnit, setStatusIntervalUnit] = useState<
"seconds" | "minutes"
>("seconds");
@@ -265,10 +275,11 @@ export function HostManagerEditor({
const formSchema = z
.object({
connectionType: z.enum(["ssh", "rdp", "vnc", "telnet"]).default("ssh"),
name: z.string().optional(),
ip: z.string().min(1),
ip: z.string().min(1, t("hosts.ipRequired", "IP address is required")),
port: z.coerce.number().min(1).max(65535),
username: z.string().min(1),
username: z.string().optional(),
folder: z.string().optional(),
tags: z.array(z.string().min(1)).default([]),
pin: z.boolean().default(false),
@@ -350,6 +361,7 @@ export function HostManagerEditor({
metricsEnabled: z.boolean().default(true),
metricsInterval: z.number().min(5).max(3600).default(30),
useGlobalMetricsInterval: z.boolean().default(true),
disableTcpPing: z.boolean().default(false),
})
.default({
enabledWidgets: [
@@ -369,6 +381,7 @@ export function HostManagerEditor({
metricsEnabled: true,
metricsInterval: 30,
useGlobalMetricsInterval: true,
disableTcpPing: false,
}),
terminalConfig: z
.object({
@@ -436,6 +449,10 @@ export function HostManagerEditor({
)
.optional(),
enableDocker: z.boolean().default(false),
domain: z.string().optional(),
security: z.string().optional(),
ignoreCert: z.boolean().default(true),
guacamoleConfig: z.record(z.string(), z.unknown()).optional(),
showTerminalInSidebar: z.boolean().default(true),
showFileManagerInSidebar: z.boolean().default(false),
showTunnelInSidebar: z.boolean().default(false),
@@ -443,6 +460,20 @@ export function HostManagerEditor({
showServerStatsInSidebar: z.boolean().default(false),
})
.superRefine((data, ctx) => {
if (data.connectionType !== "ssh") {
return;
}
if (!data.username || data.username.trim() === "") {
if (data.authType !== "credential") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("hosts.usernameRequired", "Username is required"),
path: ["username"],
});
}
}
if (data.authType === "none") {
return;
}
@@ -458,25 +489,27 @@ export function HostManagerEditor({
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("hosts.passwordRequired"),
message: t("hosts.passwordRequired", "Password is required"),
path: ["password"],
});
}
} else if (data.authType === "key") {
if (
!data.key ||
(typeof data.key === "string" && data.key.trim() === "")
(typeof data.key === "string" &&
data.key.trim() === "" &&
data.key !== "existing_key")
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("hosts.sshKeyRequired"),
message: t("hosts.sshKeyRequired", "SSH key is required"),
path: ["key"],
});
}
if (!data.keyType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("hosts.keyTypeRequired"),
message: t("hosts.keyTypeRequired", "Key type is required"),
path: ["keyType"],
});
}
@@ -484,7 +517,7 @@ export function HostManagerEditor({
if (!data.credentialId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("hosts.credentialRequired"),
message: t("hosts.credentialRequired", "Credential is required"),
path: ["credentialId"],
});
}
@@ -510,6 +543,7 @@ export function HostManagerEditor({
resolver: zodResolver(formSchema) as any,
mode: "all",
defaultValues: {
connectionType: "ssh" as const,
name: "",
ip: "",
port: 22,
@@ -547,32 +581,80 @@ export function HostManagerEditor({
socks5Password: "",
socks5ProxyChain: [],
enableDocker: false,
domain: "",
security: "any",
ignoreCert: true,
guacamoleConfig: {},
},
});
const watchedFields = form.watch();
const formState = form.formState;
const watchedConnectionType = form.watch("connectionType") || "ssh";
const prevConnectionTypeRef = useRef<string>("ssh");
useEffect(() => {
const prev = prevConnectionTypeRef.current;
const current = watchedConnectionType;
if (prev === current) return;
prevConnectionTypeRef.current = current;
const portDefaults: Record<string, number> = {
ssh: 22,
rdp: 3389,
vnc: 5900,
telnet: 23,
};
const currentPort = form.getValues("port");
const oldDefault = portDefaults[prev] || 22;
if (currentPort === oldDefault) {
form.setValue("port", portDefaults[current] || 22);
}
if (current !== "ssh") {
const currentStatsConfig = form.getValues("statsConfig");
form.setValue("statsConfig", {
...currentStatsConfig,
metricsEnabled: false,
disableTcpPing: false,
});
}
if (activeTab !== "general" && current !== "ssh") {
setActiveTab("general");
}
}, [watchedConnectionType]);
const isFormValid = React.useMemo(() => {
const values = form.getValues();
const errors = formState.errors;
if (!values.ip || !values.username || values.username.trim() === "")
if (!watchedFields.ip) return false;
if (watchedFields.connectionType !== "ssh") {
const port = Number(watchedFields.port);
return !errors.ip && port >= 1 && port <= 65535;
}
if (!watchedFields.username || watchedFields.username.trim() === "")
return false;
if (authTab === "password") {
return !!(values.password && values.password.trim() !== "");
if (!watchedFields.password || watchedFields.password.trim() === "")
return false;
} else if (authTab === "key") {
return !!(values.key && values.keyType);
if (!watchedFields.key || !watchedFields.keyType) return false;
} else if (authTab === "credential") {
return !!values.credentialId;
if (!watchedFields.credentialId) return false;
} else if (authTab === "none") {
return true;
// No auth required
} else if (authTab === "opkssh") {
return true;
// No auth required
} else {
return false;
}
return false;
}, [watchedFields, authTab]);
return Object.keys(errors).length === 0;
}, [watchedFields, authTab, formState.errors]);
useEffect(() => {
const updateAuthFields = async () => {
@@ -605,7 +687,10 @@ export function HostManagerEditor({
}
}
} else if (authTab === "none") {
form.setValue("password", "", { shouldValidate: true });
const connectionType = form.getValues("connectionType");
if (connectionType === "ssh") {
form.setValue("password", "", { shouldValidate: true });
}
form.setValue("key", null, { shouldValidate: true });
form.setValue("keyPassword", "", { shouldValidate: true });
form.setValue("keyType", "auto", { shouldValidate: true });
@@ -628,14 +713,16 @@ export function HostManagerEditor({
useEffect(() => {
if (editingHost) {
const cleanedHost = { ...editingHost };
if (cleanedHost.credentialId && cleanedHost.key) {
cleanedHost.key = undefined;
cleanedHost.keyPassword = undefined;
cleanedHost.keyType = undefined;
} else if (cleanedHost.credentialId && cleanedHost.password) {
cleanedHost.password = undefined;
} else if (cleanedHost.key && cleanedHost.password) {
cleanedHost.password = undefined;
if ((cleanedHost as any).connectionType === "ssh") {
if (cleanedHost.credentialId && cleanedHost.key) {
cleanedHost.key = undefined;
cleanedHost.keyPassword = undefined;
cleanedHost.keyType = undefined;
} else if (cleanedHost.credentialId && cleanedHost.password) {
cleanedHost.password = undefined;
} else if (cleanedHost.key && cleanedHost.password) {
cleanedHost.password = undefined;
}
}
const defaultAuthType = (cleanedHost.authType ||
@@ -669,6 +756,7 @@ export function HostManagerEditor({
parsedStatsConfig = { ...DEFAULT_STATS_CONFIG, ...parsedStatsConfig };
const formData: Partial<FormData> = {
connectionType: (cleanedHost as any).connectionType || "ssh",
name: cleanedHost.name || "",
ip: cleanedHost.ip || "",
port: cleanedHost.port || 22,
@@ -734,6 +822,21 @@ export function HostManagerEditor({
? cleanedHost.socks5ProxyChain
: [],
enableDocker: Boolean(cleanedHost.enableDocker),
domain: (cleanedHost as any).domain || "",
security: (cleanedHost as any).security || "any",
ignoreCert: (cleanedHost as any).ignoreCert ?? true,
guacamoleConfig: (() => {
const cfg = (cleanedHost as any).guacamoleConfig;
if (!cfg) return {};
if (typeof cfg === "string") {
try {
return JSON.parse(cfg);
} catch {
return {};
}
}
return cfg;
})(),
showTerminalInSidebar: cleanedHost.showTerminalInSidebar ?? true,
showFileManagerInSidebar: cleanedHost.showFileManagerInSidebar ?? false,
showTunnelInSidebar: cleanedHost.showTunnelInSidebar ?? false,
@@ -750,7 +853,11 @@ export function HostManagerEditor({
setProxyMode("single");
}
if (defaultAuthType === "password") {
if (cleanedHost.connectionType !== "ssh") {
if (cleanedHost.password) {
formData.password = cleanedHost.password;
}
} else if (defaultAuthType === "password") {
formData.password = cleanedHost.password || "";
} else if (defaultAuthType === "key") {
formData.key = editingHost.id ? "existing_key" : editingHost.key;
@@ -774,6 +881,7 @@ export function HostManagerEditor({
} else {
setAuthTab("password");
const defaultFormData: Partial<FormData> = {
connectionType: "ssh" as const,
name: "",
ip: "",
port: 22,
@@ -799,6 +907,10 @@ export function HostManagerEditor({
terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false,
enableDocker: false,
domain: "",
security: "any",
ignoreCert: true,
guacamoleConfig: {},
showTerminalInSidebar: true,
showFileManagerInSidebar: false,
showTunnelInSidebar: false,
@@ -834,23 +946,46 @@ export function HostManagerEditor({
...data,
};
if (
data.terminalConfig?.sudoPasswordAutoFill &&
data.terminalConfig?.sudoPassword
) {
submitData.sudoPassword = data.terminalConfig.sudoPassword;
}
(submitData as any).connectionType = data.connectionType;
(submitData as any).domain = data.domain;
(submitData as any).security = data.security;
(submitData as any).ignoreCert = data.ignoreCert;
(submitData as any).guacamoleConfig = data.guacamoleConfig;
if (data.authType !== "credential") {
submitData.credentialId = undefined;
}
if (data.authType !== "password") {
submitData.password = undefined;
}
if (data.authType !== "key") {
if (data.connectionType !== "ssh") {
submitData.authType = "none";
submitData.key = undefined;
submitData.keyPassword = undefined;
submitData.keyType = undefined;
submitData.credentialId = undefined;
submitData.tunnelConnections = [];
submitData.jumpHosts = [];
(submitData as any).useSocks5 = false;
(submitData as any).socks5ProxyChain = [];
submitData.forceKeyboardInteractive = false;
submitData.enableTunnel = false;
submitData.enableFileManager = false;
submitData.enableDocker = false;
submitData.enableTerminal = true;
} else {
if (
data.terminalConfig?.sudoPasswordAutoFill &&
data.terminalConfig?.sudoPassword
) {
submitData.sudoPassword = data.terminalConfig.sudoPassword;
}
if (data.authType !== "credential") {
submitData.credentialId = undefined;
}
if (data.authType !== "password") {
submitData.password = undefined;
}
if (data.authType !== "key") {
submitData.key = undefined;
submitData.keyPassword = undefined;
submitData.keyType = undefined;
}
}
if (data.authType === "key") {
@@ -955,6 +1090,11 @@ export function HostManagerEditor({
enableTerminal: "terminal",
terminalConfig: "terminal",
enableDocker: "docker",
domain: "general",
security: "general",
ignoreCert: "general",
guacamoleConfig: "remote_desktop",
connectionType: "general",
enableTunnel: "tunnel",
tunnelConnections: "tunnel",
enableFileManager: "file_manager",
@@ -1194,6 +1334,50 @@ export function HostManagerEditor({
: t("hosts.addHost")}
</h3>
</div>
{guacEnabled && (
<FormField
control={form.control}
name="connectionType"
render={({ field }) => (
<FormItem className="mb-4">
<FormControl>
<Tabs
value={field.value || "ssh"}
onValueChange={field.onChange}
className="w-full"
>
<TabsList className="bg-button border border-edge-medium">
<TabsTrigger
value="ssh"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.ssh")}
</TabsTrigger>
<TabsTrigger
value="rdp"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.rdp")}
</TabsTrigger>
<TabsTrigger
value="vnc"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.vnc")}
</TabsTrigger>
<TabsTrigger
value="telnet"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.telnet")}
</TabsTrigger>
</TabsList>
</Tabs>
</FormControl>
</FormItem>
)}
/>
)}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
@@ -1206,45 +1390,63 @@ export function HostManagerEditor({
>
{t("hosts.general")}
</TabsTrigger>
<TabsTrigger
value="terminal"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.terminal")}
</TabsTrigger>
<TabsTrigger
value="docker"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
Docker
</TabsTrigger>
<TabsTrigger
value="tunnel"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.tunnel")}
</TabsTrigger>
<TabsTrigger
value="file_manager"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.fileManager")}
</TabsTrigger>
{watchedConnectionType === "ssh" && (
<>
<TabsTrigger
value="terminal"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.terminal")}
</TabsTrigger>
<TabsTrigger
value="docker"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
Docker
</TabsTrigger>
<TabsTrigger
value="tunnel"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.tunnel")}
</TabsTrigger>
<TabsTrigger
value="file_manager"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.fileManager")}
</TabsTrigger>
</>
)}
<TabsTrigger
value="statistics"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.statistics")}
{watchedConnectionType === "ssh"
? t("hosts.statistics")
: t("hosts.status")}
</TabsTrigger>
{!editingHost?.isShared && (
<TabsTrigger value="sharing">
{t("rbac.sharing")}
{watchedConnectionType !== "ssh" && (
<TabsTrigger
value="remote_desktop"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.remoteDesktop")}
</TabsTrigger>
)}
{watchedConnectionType === "ssh" &&
!editingHost?.isShared && (
<TabsTrigger value="sharing">
{t("rbac.sharing")}
</TabsTrigger>
)}
</TabsList>
<TabsContent value="general" className="pt-2">
<HostGeneralTab
form={form}
connectionType={
watchedConnectionType as "ssh" | "rdp" | "vnc" | "telnet"
}
authTab={authTab}
setAuthTab={setAuthTab}
keyInputMethod={keyInputMethod}
@@ -1273,44 +1475,70 @@ export function HostManagerEditor({
t={t}
/>
</TabsContent>
<TabsContent value="terminal" className="space-y-1">
<HostTerminalTab form={form} snippets={snippets} t={t} />
</TabsContent>
<TabsContent value="docker" className="space-y-4">
<HostDockerTab form={form} t={t} />
</TabsContent>
<TabsContent value="tunnel">
<HostTunnelTab
form={form}
sshConfigDropdownOpen={sshConfigDropdownOpen}
setSshConfigDropdownOpen={setSshConfigDropdownOpen}
sshConfigInputRefs={sshConfigInputRefs}
sshConfigDropdownRefs={sshConfigDropdownRefs}
getFilteredSshConfigs={getFilteredSshConfigs}
handleSshConfigClick={handleSshConfigClick}
t={t}
/>
</TabsContent>
<TabsContent value="file_manager">
<HostFileManagerTab form={form} t={t} />
</TabsContent>
{watchedConnectionType === "ssh" && (
<>
<TabsContent value="terminal" className="space-y-1">
<HostTerminalTab form={form} snippets={snippets} t={t} />
</TabsContent>
<TabsContent value="docker" className="space-y-4">
<HostDockerTab form={form} t={t} />
</TabsContent>
<TabsContent value="tunnel">
<HostTunnelTab
form={form}
sshConfigDropdownOpen={sshConfigDropdownOpen}
setSshConfigDropdownOpen={setSshConfigDropdownOpen}
sshConfigInputRefs={sshConfigInputRefs}
sshConfigDropdownRefs={sshConfigDropdownRefs}
getFilteredSshConfigs={getFilteredSshConfigs}
handleSshConfigClick={handleSshConfigClick}
t={t}
/>
</TabsContent>
<TabsContent value="file_manager">
<HostFileManagerTab form={form} t={t} />
</TabsContent>
</>
)}
<TabsContent value="statistics" className="space-y-6">
<HostStatisticsTab
form={form}
statusIntervalUnit={statusIntervalUnit}
setStatusIntervalUnit={setStatusIntervalUnit}
metricsIntervalUnit={metricsIntervalUnit}
setMetricsIntervalUnit={setMetricsIntervalUnit}
snippets={snippets}
t={t}
/>
</TabsContent>
<TabsContent value="sharing" className="space-y-6">
<HostSharingTab
hostId={editingHost?.id}
isNewHost={!editingHost}
/>
{watchedConnectionType === "ssh" ? (
<HostStatisticsTab
form={form}
statusIntervalUnit={statusIntervalUnit}
setStatusIntervalUnit={setStatusIntervalUnit}
metricsIntervalUnit={metricsIntervalUnit}
setMetricsIntervalUnit={setMetricsIntervalUnit}
snippets={snippets}
t={t}
/>
) : (
<HostStatusTab
form={form}
statusIntervalUnit={statusIntervalUnit}
setStatusIntervalUnit={setStatusIntervalUnit}
t={t}
/>
)}
</TabsContent>
{watchedConnectionType !== "ssh" && (
<TabsContent value="remote_desktop" className="space-y-4">
<HostRemoteDesktopTab
form={form}
connectionType={
watchedConnectionType as "rdp" | "vnc" | "telnet"
}
t={t}
/>
</TabsContent>
)}
{watchedConnectionType === "ssh" && (
<TabsContent value="sharing" className="space-y-6">
<HostSharingTab
hostId={editingHost?.id}
isNewHost={!editingHost}
/>
</TabsContent>
)}
</Tabs>
</div>
</ScrollArea>
@@ -41,6 +41,9 @@ import {
refreshServerPolling,
isElectron,
getConfiguredServerUrl,
getGuacamoleTokenFromHost,
getGuacamoleToken,
logActivity,
} from "@/ui/main-axios.ts";
import { useServerStatus } from "@/ui/contexts/ServerStatusContext";
import { toast } from "sonner";
@@ -83,6 +86,9 @@ import {
Plus,
ListChecks,
ChevronDown,
Monitor,
MessagesSquare,
Eye,
} from "lucide-react";
import type {
SSHHost,
@@ -587,6 +593,7 @@ export function HostManagerViewer({
const getSampleData = () => ({
hosts: [
{
connectionType: "ssh",
name: t("interface.webServerProduction"),
ip: "192.168.1.100",
port: 22,
@@ -607,6 +614,7 @@ export function HostManagerViewer({
defaultPath: "/var/www",
},
{
connectionType: "ssh",
name: t("interface.databaseServer"),
ip: "192.168.1.101",
port: 22,
@@ -645,6 +653,7 @@ export function HostManagerViewer({
},
},
{
connectionType: "ssh",
name: t("interface.developmentServer"),
ip: "192.168.1.102",
port: 2222,
@@ -667,6 +676,66 @@ export function HostManagerViewer({
sudoPassword: "dev_sudo_password",
},
{
connectionType: "rdp",
name: "Windows Server 2022",
ip: "192.168.1.200",
port: 3389,
username: "Administrator",
password: "windows_password",
domain: "COMPANY",
security: "nla",
ignoreCert: false,
folder: "Remote Desktop",
tags: ["rdp", "windows", "production"],
pin: false,
notes: "Production Windows Server with RDP access",
guacamoleConfig: {
"enable-drive": true,
"drive-path": "/shared",
"create-drive-path": true,
"server-layout": "en-us-qwerty",
"resize-method": "display-update",
},
},
{
connectionType: "vnc",
name: "Ubuntu Desktop",
ip: "192.168.1.201",
port: 5900,
username: "vncuser",
password: "vnc_password",
folder: "Remote Desktop",
tags: ["vnc", "linux", "desktop"],
pin: false,
notes: "Ubuntu desktop with VNC server",
guacamoleConfig: {
"color-depth": 24,
cursor: "remote",
"read-only": false,
"clipboard-encoding": "UTF-8",
},
},
{
connectionType: "telnet",
name: "Network Switch",
ip: "192.168.1.254",
port: 23,
username: "admin",
password: "switch_password",
folder: "Infrastructure",
tags: ["telnet", "network", "switch"],
pin: false,
notes: "Legacy network switch with Telnet access",
guacamoleConfig: {
"color-scheme": "green-black",
"font-name": "monospace",
"font-size": 12,
scrollback: 1024,
backspace: 127,
},
},
{
connectionType: "ssh",
name: "Jump Host Server",
ip: "10.0.0.50",
port: 22,
@@ -693,6 +762,7 @@ export function HostManagerViewer({
],
},
{
connectionType: "ssh",
name: "Server with SOCKS5 Proxy",
ip: "10.10.10.100",
port: 22,
@@ -713,6 +783,7 @@ export function HostManagerViewer({
socks5Password: "proxypass",
},
{
connectionType: "ssh",
name: "Customized Terminal Server",
ip: "192.168.1.150",
port: 22,
@@ -1388,34 +1459,47 @@ export function HostManagerViewer({
)}
{(() => {
const statsConfig = (() => {
if (!host.statsConfig) {
return DEFAULT_STATS_CONFIG;
}
if (
typeof host.statsConfig === "object"
) {
return host.statsConfig;
}
try {
return host.statsConfig
? JSON.parse(host.statsConfig)
: DEFAULT_STATS_CONFIG;
} catch {
return JSON.parse(host.statsConfig);
} catch (e) {
return DEFAULT_STATS_CONFIG;
}
})();
const shouldShowStatus =
statsConfig.statusCheckEnabled !==
false;
const serverStatus = getStatus(host.id);
const shouldShowStatus = ![
false,
"false",
].includes(
statsConfig.statusCheckEnabled,
);
return shouldShowStatus ? (
if (!shouldShowStatus) return null;
const serverStatus = getStatus(host.id);
return (
<Status
status={serverStatus}
className="!bg-transparent !p-0.75 flex-shrink-0"
>
<StatusIndicator />
</Status>
) : null;
);
})()}
{host.pin && (
<Pin className="h-3 w-3 text-yellow-500 flex-shrink-0" />
)}
<h3 className="font-medium truncate text-sm">
{host.name ||
`${host.username}@${host.ip}`}
(host.username
? `${host.username}@${host.ip}`
: host.ip)}
</h3>
{(host as any).isShared && (
<Badge
@@ -1429,9 +1513,11 @@ export function HostManagerViewer({
<p className="text-xs text-muted-foreground truncate">
{host.ip}:{host.port}
</p>
<p className="text-xs text-muted-foreground truncate">
{host.username}
</p>
{host.username && (
<p className="text-xs text-muted-foreground truncate">
{host.username}
</p>
)}
<p className="text-xs text-muted-foreground truncate">
ID: {host.id}
</p>
@@ -1569,74 +1655,118 @@ export function HostManagerViewer({
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
{host.enableTerminal && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
copyFullScreenUrl(
host,
"terminal",
);
}}
>
<Terminal className="h-4 w-4 mr-2" />
{t("hosts.copyTerminalUrl")}
</DropdownMenuItem>
)}
{host.enableFileManager && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
copyFullScreenUrl(
host,
"file-manager",
);
}}
>
<FolderOpen className="h-4 w-4 mr-2" />
{t("hosts.copyFileManagerUrl")}
</DropdownMenuItem>
)}
{host.enableTunnel && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
copyFullScreenUrl(
host,
"tunnel",
);
}}
>
<ArrowDownUp className="h-4 w-4 mr-2" />
{t("hosts.copyTunnelUrl")}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
copyFullScreenUrl(
host,
"server-stats",
{(() => {
const connType = (host as any)
.connectionType;
const isRemoteDesktop =
connType === "rdp" ||
connType === "vnc" ||
connType === "telnet";
if (isRemoteDesktop) {
return (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
copyFullScreenUrl(
host,
connType,
);
}}
>
{connType === "rdp" ? (
<Monitor className="h-4 w-4 mr-2" />
) : connType === "vnc" ? (
<Eye className="h-4 w-4 mr-2" />
) : (
<MessagesSquare className="h-4 w-4 mr-2" />
)}
{t(
"hosts.copyRemoteDesktopUrl",
)}
</DropdownMenuItem>
);
}}
>
<Server className="h-4 w-4 mr-2" />
{t("hosts.copyServerStatsUrl")}
</DropdownMenuItem>
{host.enableDocker && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
copyFullScreenUrl(
host,
"docker",
);
}}
>
<Container className="h-4 w-4 mr-2" />
{t("hosts.copyDockerUrl")}
</DropdownMenuItem>
)}
}
return (
<>
{host.enableTerminal && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
copyFullScreenUrl(
host,
"terminal",
);
}}
>
<Terminal className="h-4 w-4 mr-2" />
{t(
"hosts.copyTerminalUrl",
)}
</DropdownMenuItem>
)}
{host.enableFileManager && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
copyFullScreenUrl(
host,
"file-manager",
);
}}
>
<FolderOpen className="h-4 w-4 mr-2" />
{t(
"hosts.copyFileManagerUrl",
)}
</DropdownMenuItem>
)}
{host.enableTunnel && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
copyFullScreenUrl(
host,
"tunnel",
);
}}
>
<ArrowDownUp className="h-4 w-4 mr-2" />
{t("hosts.copyTunnelUrl")}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
copyFullScreenUrl(
host,
"server-stats",
);
}}
>
<Server className="h-4 w-4 mr-2" />
{t(
"hosts.copyServerStatsUrl",
)}
</DropdownMenuItem>
{host.enableDocker && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
copyFullScreenUrl(
host,
"docker",
);
}}
>
<Container className="h-4 w-4 mr-2" />
{t("hosts.copyDockerUrl")}
</DropdownMenuItem>
)}
</>
);
})()}
</DropdownMenuContent>
</DropdownMenu>
</>
@@ -1671,185 +1801,339 @@ export function HostManagerViewer({
)}
<div className="flex flex-wrap gap-1">
{host.enableTerminal && (
<Badge
variant="outline"
className="text-xs px-1 py-0"
>
<Terminal className="h-2 w-2 mr-0.5" />
{t("hosts.terminalBadge")}
</Badge>
)}
{host.enableTunnel && (
<Badge
variant="outline"
className="text-xs px-1 py-0"
>
<Network className="h-2 w-2 mr-0.5" />
{t("hosts.tunnelBadge")}
{host.tunnelConnections &&
host.tunnelConnections.length > 0 && (
<span className="ml-0.5">
({host.tunnelConnections.length})
</span>
{(() => {
const connType = (host as any)
.connectionType;
if (connType === "rdp") {
return (
<Badge
variant="outline"
className="text-xs px-1 py-0"
>
<Monitor className="h-2 w-2 mr-0.5" />
{t("hosts.rdp")}
</Badge>
);
}
if (connType === "vnc") {
return (
<Badge
variant="outline"
className="text-xs px-1 py-0"
>
<Eye className="h-2 w-2 mr-0.5" />
{t("hosts.vnc")}
</Badge>
);
}
if (connType === "telnet") {
return (
<Badge
variant="outline"
className="text-xs px-1 py-0"
>
<MessagesSquare className="h-2 w-2 mr-0.5" />
{t("hosts.telnet")}
</Badge>
);
}
return (
<>
{host.enableTerminal && (
<Badge
variant="outline"
className="text-xs px-1 py-0"
>
<Terminal className="h-2 w-2 mr-0.5" />
{t("hosts.terminalBadge")}
</Badge>
)}
</Badge>
)}
{host.enableFileManager && (
<Badge
variant="outline"
className="text-xs px-1 py-0"
>
<FileEdit className="h-2 w-2 mr-0.5" />
{t("hosts.fileManagerBadge")}
</Badge>
)}
{host.enableDocker && (
<Badge
variant="outline"
className="text-xs px-1 py-0"
>
<Container className="h-2 w-2 mr-0.5" />
{t("hosts.docker")}
</Badge>
)}
{host.enableTunnel && (
<Badge
variant="outline"
className="text-xs px-1 py-0"
>
<Network className="h-2 w-2 mr-0.5" />
{t("hosts.tunnelBadge")}
{host.tunnelConnections &&
host.tunnelConnections.length >
0 && (
<span className="ml-0.5">
(
{
host.tunnelConnections
.length
}
)
</span>
)}
</Badge>
)}
{host.enableFileManager && (
<Badge
variant="outline"
className="text-xs px-1 py-0"
>
<FileEdit className="h-2 w-2 mr-0.5" />
{t("hosts.fileManagerBadge")}
</Badge>
)}
{host.enableDocker && (
<Badge
variant="outline"
className="text-xs px-1 py-0"
>
<Container className="h-2 w-2 mr-0.5" />
{t("hosts.docker")}
</Badge>
)}
</>
);
})()}
</div>
</div>
<div className="mt-3 pt-3 border-t border-border/50 flex items-center justify-center gap-1">
{host.enableTerminal && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({
type: "terminal",
title,
hostConfig: host,
});
}}
className="h-7 px-2 hover:bg-blue-500/10 hover:border-blue-500/50 flex-1"
>
<Terminal className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("hosts.openTerminal")}</p>
</TooltipContent>
</Tooltip>
)}
{host.enableFileManager && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({
type: "file_manager",
title,
hostConfig: host,
});
}}
className="h-7 px-2 hover:bg-emerald-500/10 hover:border-emerald-500/50 flex-1"
>
<FolderOpen className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Open File Manager</p>
</TooltipContent>
</Tooltip>
)}
{host.enableTunnel && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({
type: "tunnel",
title,
hostConfig: host,
});
}}
className="h-7 px-2 hover:bg-orange-500/10 hover:border-orange-500/50 flex-1"
>
<ArrowDownUp className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Open Tunnels</p>
</TooltipContent>
</Tooltip>
)}
{host.enableDocker && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({
type: "docker",
title,
hostConfig: host,
});
}}
className="h-7 px-2 hover:bg-cyan-500/10 hover:border-cyan-500/50 flex-1"
>
<Container className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("hosts.openDocker")}</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({
type: "server_stats",
title,
hostConfig: host,
});
}}
className="h-7 px-2 hover:bg-purple-500/10 hover:border-purple-500/50 flex-1"
>
<Server className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Open Server Details</p>
</TooltipContent>
</Tooltip>
{(() => {
const connType = (host as any)
.connectionType;
const isRemoteDesktop =
connType === "rdp" ||
connType === "vnc" ||
connType === "telnet";
if (isRemoteDesktop) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={async (e) => {
e.stopPropagation();
const title = host.name?.trim()
? host.name
: `${host.ip}:${host.port}`;
try {
const protocol = connType as
| "rdp"
| "vnc"
| "telnet";
const result =
await getGuacamoleToken({
protocol,
hostname: host.ip,
port: host.port,
username: host.username,
password: host.password,
domain: host.domain,
security: host.security,
ignoreCert:
host.ignoreCert,
guacamoleConfig:
host.guacamoleConfig as any,
});
addTab({
type: protocol,
title,
hostConfig: host,
connectionConfig: {
token: result.token,
protocol,
type: protocol,
hostname: host.ip,
port: host.port,
username: host.username,
password: host.password,
domain: host.domain,
security: host.security,
"ignore-cert":
host.ignoreCert,
},
});
try {
await logActivity(
protocol,
host.id,
title,
);
} catch (err) {
console.warn(
`Failed to log ${protocol} activity:`,
err,
);
}
} catch (error) {
toast.error(
`Failed to connect: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}}
className="h-7 px-2 hover:bg-indigo-500/10 hover:border-indigo-500/50 flex-1"
>
{connType === "rdp" ? (
<Monitor className="h-3.5 w-3.5" />
) : connType === "vnc" ? (
<Eye className="h-3.5 w-3.5" />
) : (
<MessagesSquare className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("hosts.remoteDesktop")}</p>
</TooltipContent>
</Tooltip>
);
}
return (
<>
{host.enableTerminal && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
const title =
host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({
type: "terminal",
title,
hostConfig: host,
});
}}
className="h-7 px-2 hover:bg-blue-500/10 hover:border-blue-500/50 flex-1"
>
<Terminal className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("hosts.openTerminal")}</p>
</TooltipContent>
</Tooltip>
)}
{host.enableFileManager && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
const title =
host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({
type: "file_manager",
title,
hostConfig: host,
});
}}
className="h-7 px-2 hover:bg-emerald-500/10 hover:border-emerald-500/50 flex-1"
>
<FolderOpen className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{t("hosts.openFileManager")}
</p>
</TooltipContent>
</Tooltip>
)}
{host.enableTunnel && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
const title =
host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({
type: "tunnel",
title,
hostConfig: host,
});
}}
className="h-7 px-2 hover:bg-orange-500/10 hover:border-orange-500/50 flex-1"
>
<ArrowDownUp className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("hosts.openTunnels")}</p>
</TooltipContent>
</Tooltip>
)}
{host.enableDocker && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
const title =
host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({
type: "docker",
title,
hostConfig: host,
});
}}
className="h-7 px-2 hover:bg-cyan-500/10 hover:border-cyan-500/50 flex-1"
>
<Container className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("hosts.openDocker")}</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({
type: "server_stats",
title,
hostConfig: host,
});
}}
className="h-7 px-2 hover:bg-purple-500/10 hover:border-purple-500/50 flex-1"
>
<Server className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("hosts.openServerStats")}</p>
</TooltipContent>
</Tooltip>
</>
);
})()}
</div>
</div>
</TooltipTrigger>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,797 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Switch } from "@/components/ui/switch.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion.tsx";
import { PasswordInput } from "@/components/ui/password-input.tsx";
import type { HostRemoteDesktopTabProps } from "./shared/tab-types";
function GuacField({
form,
path,
label,
description,
type = "text",
t,
}: {
form: any;
path: string;
label: string;
description?: string;
type?: "text" | "number" | "password" | "switch";
t: (key: string) => string;
}) {
const fieldName = `guacamoleConfig.${path}` as any;
if (type === "switch") {
return (
<FormField
control={form.control}
name={fieldName}
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{label}</FormLabel>
{description && <FormDescription>{description}</FormDescription>}
</div>
<FormControl>
<Switch
checked={!!field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
);
}
if (type === "password") {
return (
<FormField
control={form.control}
name={fieldName}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<PasswordInput
value={field.value || ""}
onChange={field.onChange}
/>
</FormControl>
{description && <FormDescription>{description}</FormDescription>}
</FormItem>
)}
/>
);
}
return (
<FormField
control={form.control}
name={fieldName}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<Input
type={type}
value={field.value ?? ""}
onChange={(e) =>
field.onChange(
type === "number"
? e.target.value === ""
? undefined
: parseInt(e.target.value)
: e.target.value,
)
}
/>
</FormControl>
{description && <FormDescription>{description}</FormDescription>}
</FormItem>
)}
/>
);
}
function GuacSelect({
form,
path,
label,
options,
placeholder,
}: {
form: any;
path: string;
label: string;
options: { value: string; label: string }[];
placeholder?: string;
}) {
const fieldName = `guacamoleConfig.${path}` as any;
return (
<FormField
control={form.control}
name={fieldName}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<Select
value={field.value || "auto"}
onValueChange={(v) => field.onChange(v === "auto" ? "" : v)}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={placeholder || label} />
</SelectTrigger>
</FormControl>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
/>
);
}
export function HostRemoteDesktopTab({
form,
connectionType,
t,
}: HostRemoteDesktopTabProps) {
const isRDP = connectionType === "rdp";
const isVNC = connectionType === "vnc";
const isTelnet = connectionType === "telnet";
return (
<div className="pt-2 space-y-4">
<Accordion
type="multiple"
defaultValue={["connection", "display"]}
className="w-full"
>
{/* Connection Settings */}
{isRDP && (
<AccordionItem value="connection">
<AccordionTrigger>{t("hosts.connectionSettings")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<FormField
control={form.control}
name="security"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.securityMode")}</FormLabel>
<Select
value={field.value || "any"}
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="any">Any</SelectItem>
<SelectItem value="nla">NLA</SelectItem>
<SelectItem value="nla-ext">NLA Extended</SelectItem>
<SelectItem value="tls">TLS</SelectItem>
<SelectItem value="vmconnect">VMConnect</SelectItem>
<SelectItem value="rdp">RDP</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
/>
<FormField
control={form.control}
name="ignoreCert"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{t("hosts.ignoreCert")}</FormLabel>
<FormDescription>
{t("hosts.ignoreCertDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={!!field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</AccordionContent>
</AccordionItem>
)}
{/* Display Settings */}
<AccordionItem value="display">
<AccordionTrigger>{t("hosts.displaySettings")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
{!isTelnet && (
<GuacSelect
form={form}
path="color-depth"
label={t("hosts.colorDepth")}
options={[
{ value: "auto", label: "Auto" },
{ value: "8", label: "8-bit" },
{ value: "16", label: "16-bit" },
{ value: "24", label: "24-bit" },
{ value: "32", label: "32-bit" },
]}
/>
)}
<div className="grid grid-cols-2 gap-4">
<GuacField
form={form}
path="width"
label={t("hosts.width")}
type="number"
t={t}
/>
<GuacField
form={form}
path="height"
label={t("hosts.height")}
type="number"
t={t}
/>
</div>
{!isTelnet && (
<>
<GuacField
form={form}
path="dpi"
label={t("hosts.dpi")}
type="number"
t={t}
/>
<GuacSelect
form={form}
path="resize-method"
label={t("hosts.resizeMethod")}
options={[
{ value: "auto", label: "Auto" },
{ value: "display-update", label: "Display Update" },
{ value: "reconnect", label: "Reconnect" },
]}
/>
<GuacField
form={form}
path="force-lossless"
label={t("hosts.forceLossless")}
type="switch"
t={t}
/>
</>
)}
</AccordionContent>
</AccordionItem>
{/* Telnet Terminal Settings */}
{isTelnet && (
<AccordionItem value="telnet-terminal">
<AccordionTrigger>
{t("hosts.telnetTerminalSettings")}
</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<GuacSelect
form={form}
path="terminal-type"
label={t("hosts.terminalType")}
options={[
{ value: "auto", label: "Auto" },
{ value: "xterm", label: "xterm" },
{ value: "xterm-256color", label: "xterm-256color" },
{ value: "vt100", label: "VT100" },
{ value: "vt220", label: "VT220" },
]}
/>
<GuacField
form={form}
path="font-name"
label={t("hosts.guacFontName")}
t={t}
/>
<GuacField
form={form}
path="font-size"
label={t("hosts.guacFontSize")}
type="number"
t={t}
/>
<GuacSelect
form={form}
path="color-scheme"
label={t("hosts.guacColorScheme")}
options={[
{ value: "auto", label: "Auto" },
{ value: "black-white", label: "Black on White" },
{ value: "white-black", label: "White on Black" },
{ value: "gray-black", label: "Gray on Black" },
{ value: "green-black", label: "Green on Black" },
]}
/>
<GuacSelect
form={form}
path="backspace"
label={t("hosts.guacBackspaceKey")}
options={[
{ value: "auto", label: "Auto" },
{ value: "127", label: "DEL (127)" },
{ value: "8", label: "BS (8)" },
]}
/>
</AccordionContent>
</AccordionItem>
)}
{/* Audio Settings */}
{(isRDP || isVNC) && (
<AccordionItem value="audio">
<AccordionTrigger>{t("hosts.audioSettings")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<GuacField
form={form}
path="disable-audio"
label={t("hosts.disableAudio")}
type="switch"
t={t}
/>
{isRDP && (
<GuacField
form={form}
path="enable-audio-input"
label={t("hosts.enableAudioInput")}
type="switch"
t={t}
/>
)}
</AccordionContent>
</AccordionItem>
)}
{/* RDP Performance */}
{isRDP && (
<AccordionItem value="performance">
<AccordionTrigger>{t("hosts.rdpPerformance")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<GuacField
form={form}
path="enable-wallpaper"
label={t("hosts.enableWallpaper")}
type="switch"
t={t}
/>
<GuacField
form={form}
path="enable-theming"
label={t("hosts.enableTheming")}
type="switch"
t={t}
/>
<GuacField
form={form}
path="enable-font-smoothing"
label={t("hosts.enableFontSmoothing")}
type="switch"
t={t}
/>
<GuacField
form={form}
path="enable-full-window-drag"
label={t("hosts.enableFullWindowDrag")}
type="switch"
t={t}
/>
<GuacField
form={form}
path="enable-desktop-composition"
label={t("hosts.enableDesktopComposition")}
type="switch"
t={t}
/>
<GuacField
form={form}
path="enable-menu-animations"
label={t("hosts.enableMenuAnimations")}
type="switch"
t={t}
/>
<GuacField
form={form}
path="disable-bitmap-caching"
label={t("hosts.disableBitmapCaching")}
type="switch"
t={t}
/>
<GuacField
form={form}
path="disable-offscreen-caching"
label={t("hosts.disableOffscreenCaching")}
type="switch"
t={t}
/>
<GuacField
form={form}
path="disable-glyph-caching"
label={t("hosts.disableGlyphCaching")}
type="switch"
t={t}
/>
<GuacField
form={form}
path="enable-gfx"
label={t("hosts.enableGfx")}
type="switch"
t={t}
/>
</AccordionContent>
</AccordionItem>
)}
{/* RDP Device Redirection */}
{isRDP && (
<AccordionItem value="device-redirection">
<AccordionTrigger>{t("hosts.deviceRedirection")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<GuacField
form={form}
path="enable-printing"
label={t("hosts.enablePrinting")}
type="switch"
t={t}
/>
<GuacField
form={form}
path="enable-drive"
label={t("hosts.enableDrive")}
type="switch"
t={t}
/>
<GuacField
form={form}
path="drive-name"
label={t("hosts.driveName")}
t={t}
/>
<GuacField
form={form}
path="drive-path"
label={t("hosts.drivePath")}
t={t}
/>
<GuacField
form={form}
path="create-drive-path"
label={t("hosts.createDrivePath")}
type="switch"
t={t}
/>
<GuacField
form={form}
path="disable-download"
label={t("hosts.disableDownload")}
type="switch"
t={t}
/>
<GuacField
form={form}
path="disable-upload"
label={t("hosts.disableUpload")}
type="switch"
t={t}
/>
<GuacField
form={form}
path="enable-touch"
label={t("hosts.enableTouch")}
type="switch"
t={t}
/>
</AccordionContent>
</AccordionItem>
)}
{/* RDP Session */}
{isRDP && (
<AccordionItem value="session">
<AccordionTrigger>{t("hosts.rdpSession")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<GuacField
form={form}
path="client-name"
label={t("hosts.clientName")}
t={t}
/>
<GuacField
form={form}
path="console"
label={t("hosts.consoleSession")}
type="switch"
t={t}
/>
<GuacField
form={form}
path="initial-program"
label={t("hosts.initialProgram")}
t={t}
/>
<GuacSelect
form={form}
path="server-layout"
label={t("hosts.serverLayout")}
options={[
{ value: "auto", label: "Auto" },
{ value: "en-us-qwerty", label: "English (US) QWERTY" },
{ value: "en-gb-qwerty", label: "English (UK) QWERTY" },
{ value: "de-de-qwertz", label: "German QWERTZ" },
{ value: "fr-fr-azerty", label: "French AZERTY" },
{ value: "it-it-qwerty", label: "Italian QWERTY" },
{ value: "sv-se-qwerty", label: "Swedish QWERTY" },
{ value: "ja-jp-qwerty", label: "Japanese QWERTY" },
{ value: "pt-br-qwerty", label: "Portuguese (BR) QWERTY" },
{ value: "es-es-qwerty", label: "Spanish QWERTY" },
{ value: "failsafe", label: "Failsafe (Unicode)" },
]}
/>
<GuacField
form={form}
path="timezone"
label={t("hosts.timezone")}
t={t}
/>
</AccordionContent>
</AccordionItem>
)}
{/* RDP Gateway */}
{isRDP && (
<AccordionItem value="gateway">
<AccordionTrigger>{t("hosts.gatewaySettings")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<GuacField
form={form}
path="gateway-hostname"
label={t("hosts.gatewayHostname")}
t={t}
/>
<GuacField
form={form}
path="gateway-port"
label={t("hosts.gatewayPort")}
type="number"
t={t}
/>
<GuacField
form={form}
path="gateway-username"
label={t("hosts.gatewayUsername")}
t={t}
/>
<GuacField
form={form}
path="gateway-password"
label={t("hosts.gatewayPassword")}
type="password"
t={t}
/>
<GuacField
form={form}
path="gateway-domain"
label={t("hosts.gatewayDomain")}
t={t}
/>
</AccordionContent>
</AccordionItem>
)}
{/* RDP RemoteApp */}
{isRDP && (
<AccordionItem value="remoteapp">
<AccordionTrigger>{t("hosts.remoteApp")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<GuacField
form={form}
path="remote-app"
label={t("hosts.remoteAppProgram")}
t={t}
/>
<GuacField
form={form}
path="remote-app-dir"
label={t("hosts.remoteAppDir")}
t={t}
/>
<GuacField
form={form}
path="remote-app-args"
label={t("hosts.remoteAppArgs")}
t={t}
/>
</AccordionContent>
</AccordionItem>
)}
{/* Clipboard */}
<AccordionItem value="clipboard">
<AccordionTrigger>{t("hosts.clipboardSettings")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<GuacSelect
form={form}
path="normalize-clipboard"
label={t("hosts.normalizeClipboard")}
options={[
{ value: "auto", label: "Auto" },
{ value: "preserve", label: "Preserve" },
{ value: "unix", label: "Unix (LF)" },
{ value: "windows", label: "Windows (CRLF)" },
]}
/>
<GuacField
form={form}
path="disable-copy"
label={t("hosts.disableCopy")}
type="switch"
t={t}
/>
<GuacField
form={form}
path="disable-paste"
label={t("hosts.disablePaste")}
type="switch"
t={t}
/>
</AccordionContent>
</AccordionItem>
{/* VNC Specific */}
{isVNC && (
<AccordionItem value="vnc-specific">
<AccordionTrigger>{t("hosts.vncSettings")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<GuacSelect
form={form}
path="cursor"
label={t("hosts.cursorMode")}
options={[
{ value: "auto", label: "Auto" },
{ value: "local", label: "Local" },
{ value: "remote", label: "Remote" },
]}
/>
<GuacField
form={form}
path="swap-red-blue"
label={t("hosts.swapRedBlue")}
type="switch"
t={t}
/>
<GuacField
form={form}
path="read-only"
label={t("hosts.readOnly")}
type="switch"
t={t}
/>
</AccordionContent>
</AccordionItem>
)}
{/* Recording */}
<AccordionItem value="recording">
<AccordionTrigger>{t("hosts.recordingSettings")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<GuacField
form={form}
path="recording-path"
label={t("hosts.recordingPath")}
t={t}
/>
<GuacField
form={form}
path="recording-name"
label={t("hosts.recordingName")}
t={t}
/>
<GuacField
form={form}
path="create-recording-path"
label={t("hosts.createRecordingPath")}
type="switch"
t={t}
/>
<GuacField
form={form}
path="recording-exclude-output"
label={t("hosts.excludeOutput")}
type="switch"
t={t}
/>
<GuacField
form={form}
path="recording-exclude-mouse"
label={t("hosts.excludeMouse")}
type="switch"
t={t}
/>
<GuacField
form={form}
path="recording-include-keys"
label={t("hosts.includeKeys")}
type="switch"
t={t}
/>
</AccordionContent>
</AccordionItem>
{/* Wake-on-LAN */}
<AccordionItem value="wol">
<AccordionTrigger>{t("hosts.wakeOnLan")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<GuacField
form={form}
path="wol-send-packet"
label={t("hosts.sendWolPacket")}
type="switch"
t={t}
/>
<GuacField
form={form}
path="wol-mac-addr"
label={t("hosts.wolMacAddr")}
t={t}
/>
<GuacField
form={form}
path="wol-broadcast-addr"
label={t("hosts.wolBroadcastAddr")}
t={t}
/>
<GuacField
form={form}
path="wol-udp-port"
label={t("hosts.wolUdpPort")}
type="number"
t={t}
/>
<GuacField
form={form}
path="wol-wait-time"
label={t("hosts.wolWaitTime")}
type="number"
t={t}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}
@@ -0,0 +1,143 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Switch } from "@/components/ui/switch.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import type { HostStatisticsTabProps } from "./shared/tab-types";
export function HostStatusTab({
form,
statusIntervalUnit,
setStatusIntervalUnit,
t,
}: Pick<
HostStatisticsTabProps,
"form" | "statusIntervalUnit" | "setStatusIntervalUnit" | "t"
>) {
return (
<div className="space-y-6">
<div className="space-y-4">
<div className="space-y-3">
<FormField
control={form.control}
name="statsConfig.statusCheckEnabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{t("hosts.statusCheckEnabled")}</FormLabel>
<FormDescription>
{t("hosts.statusCheckEnabledDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{form.watch("statsConfig.statusCheckEnabled") && (
<>
<FormField
control={form.control}
name="statsConfig.useGlobalStatusInterval"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-2 space-y-0">
<FormControl>
<Switch
checked={field.value ?? true}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="font-normal">
{t("hosts.useGlobalStatusInterval")}
</FormLabel>
</FormItem>
)}
/>
{form.watch("statsConfig.useGlobalStatusInterval") === false && (
<FormField
control={form.control}
name="statsConfig.statusCheckInterval"
render={({ field }) => {
const displayValue =
statusIntervalUnit === "minutes"
? Math.round((field.value || 30) / 60)
: field.value || 30;
const handleIntervalChange = (value: string) => {
const numValue = parseInt(value) || 0;
const seconds =
statusIntervalUnit === "minutes"
? numValue * 60
: numValue;
field.onChange(seconds);
};
return (
<FormItem>
<FormLabel>{t("hosts.statusCheckInterval")}</FormLabel>
<div className="flex gap-2">
<FormControl>
<Input
type="number"
value={displayValue}
onChange={(e) =>
handleIntervalChange(e.target.value)
}
className="flex-1"
/>
</FormControl>
<Select
value={statusIntervalUnit}
onValueChange={(value: "seconds" | "minutes") => {
setStatusIntervalUnit(value);
const currentSeconds = field.value || 30;
if (value === "minutes") {
const minutes = Math.round(currentSeconds / 60);
field.onChange(minutes * 60);
}
}}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="seconds">
{t("hosts.intervalSeconds")}
</SelectItem>
<SelectItem value="minutes">
{t("hosts.intervalMinutes")}
</SelectItem>
</SelectContent>
</Select>
</div>
<FormDescription>
{t("hosts.statusCheckIntervalDesc")}
</FormDescription>
</FormItem>
);
}}
/>
)}
</>
)}
</div>
</div>
</div>
);
}
@@ -4,6 +4,7 @@ import type { SSHHost, Credential } from "@/types";
export interface HostGeneralTabProps {
form: UseFormReturn<FormData>;
connectionType: "ssh" | "rdp" | "vnc" | "telnet";
authTab: "password" | "key" | "credential" | "none";
setAuthTab: (value: "password" | "key" | "credential" | "none") => void;
keyInputMethod: "upload" | "paste";
@@ -75,6 +76,12 @@ export interface HostStatisticsTabProps {
t: (key: string) => string;
}
export interface HostRemoteDesktopTabProps {
form: UseFormReturn<FormData>;
connectionType: "rdp" | "vnc" | "telnet";
t: (key: string) => string;
}
export interface HostSharingTabProps {
hostId: number | undefined;
isNewHost: boolean;
@@ -251,7 +251,10 @@ export function SSHToolsSidebar({
tab.type === "server_stats" ||
tab.type === "file_manager" ||
tab.type === "tunnel" ||
tab.type === "docker",
tab.type === "docker" ||
tab.type === "rdp" ||
tab.type === "vnc" ||
tab.type === "telnet",
);
useEffect(() => {
+25 -11
View File
@@ -615,7 +615,7 @@ export function Auth({
async function handleOIDCLogin() {
setOidcLoading(true);
try {
const authResponse = await getOIDCAuthorizeUrl();
const authResponse = await getOIDCAuthorizeUrl(rememberMe);
const { auth_url: authUrl } = authResponse;
if (!authUrl || authUrl === "undefined") {
@@ -1236,16 +1236,30 @@ export function Auth({
);
} else {
return (
<Button
type="button"
className="w-full h-11 mt-2 text-base font-semibold"
disabled={oidcLoading}
onClick={handleOIDCLogin}
>
{oidcLoading
? Spinner
: t("auth.loginWithExternal")}
</Button>
<>
<div className="flex items-center gap-2">
<Checkbox
id="rememberMeOIDC"
checked={rememberMe}
onCheckedChange={(checked) =>
setRememberMe(checked === true)
}
/>
<Label htmlFor="rememberMeOIDC">
{t("auth.rememberMe")}
</Label>
</div>
<Button
type="button"
className="w-full h-11 mt-2 text-base font-semibold"
disabled={oidcLoading}
onClick={handleOIDCLogin}
>
{oidcLoading
? Spinner
: t("auth.loginWithExternal")}
</Button>
</>
);
}
})()}
+34
View File
@@ -2,6 +2,10 @@ import React, { useEffect, useRef, useState, useMemo } from "react";
import { Terminal } from "@/ui/desktop/apps/features/terminal/Terminal.tsx";
import { ServerStats as ServerView } from "@/ui/desktop/apps/features/server-stats/ServerStats.tsx";
import { FileManager } from "@/ui/desktop/apps/features/file-manager/FileManager.tsx";
import {
GuacamoleDisplay,
type GuacamoleConnectionConfig,
} from "@/ui/desktop/apps/features/guacamole/GuacamoleDisplay.tsx";
import { TunnelManager } from "@/ui/desktop/apps/features/tunnel/TunnelManager.tsx";
import { DockerManager } from "@/ui/desktop/apps/features/docker/DockerManager.tsx";
import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard";
@@ -14,6 +18,7 @@ import {
import * as ResizablePrimitive from "react-resizable-panels";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { RefreshCcw } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button.tsx";
import {
TERMINAL_THEMES,
@@ -34,6 +39,7 @@ interface TabData {
};
};
hostConfig?: any;
connectionConfig?: GuacamoleConnectionConfig;
[key: string]: unknown;
}
@@ -105,6 +111,9 @@ export function AppView({
tab.type === "terminal" ||
tab.type === "server_stats" ||
tab.type === "file_manager" ||
tab.type === "rdp" ||
tab.type === "vnc" ||
tab.type === "telnet" ||
tab.type === "tunnel" ||
tab.type === "docker" ||
tab.type === "network_graph",
@@ -376,6 +385,7 @@ export function AppView({
>
{t.type === "terminal" ? (
<Terminal
key={`term-${t.id}-${t.instanceId || ""}`}
ref={t.terminalRef}
hostConfig={t.hostConfig}
isVisible={effectiveVisible}
@@ -387,14 +397,35 @@ export function AppView({
/>
) : t.type === "server_stats" ? (
<ServerView
key={`stats-${t.id}-${t.instanceId || ""}`}
hostConfig={t.hostConfig}
title={t.title}
isVisible={effectiveVisible}
isTopbarOpen={isTopbarOpen}
embedded
/>
) : t.type === "rdp" ||
t.type === "vnc" ||
t.type === "telnet" ? (
t.connectionConfig ? (
<GuacamoleDisplay
key={`guac-${t.id}-${t.instanceId || ""}`}
connectionConfig={t.connectionConfig}
isVisible={effectiveVisible}
onDisconnect={() => removeTab(t.id)}
onError={(err) => {
toast.error(err);
removeTab(t.id);
}}
/>
) : (
<div className="flex items-center justify-center h-full text-red-500">
Missing connection configuration
</div>
)
) : t.type === "network_graph" ? (
<NetworkGraphCard
key={`netgraph-${t.id}-${t.instanceId || ""}`}
isTopbarOpen={isTopbarOpen}
rightSidebarOpen={rightSidebarOpen}
rightSidebarWidth={rightSidebarWidth}
@@ -402,6 +433,7 @@ export function AppView({
/>
) : t.type === "tunnel" ? (
<TunnelManager
key={`tunnel-${t.id}-${t.instanceId || ""}`}
hostConfig={t.hostConfig}
title={t.title}
isVisible={effectiveVisible}
@@ -410,6 +442,7 @@ export function AppView({
/>
) : t.type === "docker" ? (
<DockerManager
key={`docker-${t.id}-${t.instanceId || ""}`}
hostConfig={t.hostConfig}
title={t.title}
isVisible={effectiveVisible}
@@ -419,6 +452,7 @@ export function AppView({
/>
) : (
<FileManager
key={`filemgr-${t.id}-${t.instanceId || ""}`}
embedded
initialHost={t.hostConfig}
onClose={() => removeTab(t.id)}
+21 -40
View File
@@ -36,30 +36,7 @@ import { Button } from "@/components/ui/button.tsx";
import { FolderCard } from "@/ui/desktop/navigation/hosts/FolderCard.tsx";
import { getSSHHosts, getSSHFolders } from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import type { SSHFolder } from "@/types/index.ts";
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: unknown[];
createdAt: string;
updatedAt: string;
}
import type { SSHFolder, SSHHost } from "@/types/index.ts";
interface SidebarProps {
disabled?: boolean;
@@ -218,37 +195,41 @@ export function LeftSidebar({
setTimeout(() => {
setHosts(newHosts);
prevHostsRef.current = newHosts;
newHosts.forEach((newHost) => {
updateHostConfig(newHost.id, newHost);
});
}, 50);
}
} catch {
setHostsError(t("leftSidebar.failedToLoadHosts"));
}
}, [updateHostConfig]);
}, [t]);
const fetchHostsRef = React.useRef(fetchHosts);
const fetchFolderMetadataRef = React.useRef(fetchFolderMetadata);
React.useEffect(() => {
fetchHosts();
fetchFolderMetadata();
fetchHostsRef.current = fetchHosts;
fetchFolderMetadataRef.current = fetchFolderMetadata;
});
React.useEffect(() => {
fetchHostsRef.current();
fetchFolderMetadataRef.current();
const interval = setInterval(() => {
fetchHosts();
fetchFolderMetadata();
fetchHostsRef.current();
fetchFolderMetadataRef.current();
}, 300000);
return () => clearInterval(interval);
}, [fetchHosts, fetchFolderMetadata]);
}, []);
React.useEffect(() => {
const handleHostsChanged = () => {
fetchHosts();
fetchFolderMetadata();
fetchHostsRef.current();
fetchFolderMetadataRef.current();
};
const handleCredentialsChanged = () => {
fetchHosts();
fetchHostsRef.current();
};
const handleFoldersChanged = () => {
fetchFolderMetadata();
fetchFolderMetadataRef.current();
};
window.addEventListener(
"ssh-hosts:changed",
@@ -276,7 +257,7 @@ export function LeftSidebar({
handleFoldersChanged as EventListener,
);
};
}, [fetchHosts, fetchFolderMetadata]);
}, []);
React.useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
@@ -549,7 +530,7 @@ export function LeftSidebar({
const metadata = folderMetadata.get(folder);
return (
<FolderCard
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
key={`folder-${folder}`}
folderName={folder}
hosts={getSortedHosts(hostsByFolder[folder])}
isFirst={idx === 0}
+6
View File
@@ -376,6 +376,8 @@ export function TopNavbar({
const isSshManager = tab.type === "ssh_manager";
const isAdmin = tab.type === "admin";
const isUserProfile = tab.type === "user_profile";
const isRdp = tab.type === "rdp";
const isVnc = tab.type === "vnc";
const isSplittable =
isTerminal || isServer || isFileManager || isTunnel || isDocker;
const disableSplit = !isSplittable;
@@ -495,6 +497,8 @@ export function TopNavbar({
isSshManager ||
isAdmin ||
isUserProfile ||
isRdp ||
isVnc ||
tab.type === "network_graph"
? () => handleTabClose(tab.id)
: undefined
@@ -512,6 +516,8 @@ export function TopNavbar({
isSshManager ||
isAdmin ||
isUserProfile ||
isRdp ||
isVnc ||
tab.type === "network_graph"
}
disableActivate={disableActivate}
+145 -55
View File
@@ -5,6 +5,9 @@ import { ButtonGroup } from "@/components/ui/button-group";
import {
EllipsisVertical,
Terminal,
Monitor,
Eye,
MessagesSquare,
Server,
FolderOpen,
Pencil,
@@ -18,7 +21,7 @@ import {
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext";
import { getSSHHosts } from "@/ui/main-axios";
import { getSSHHosts, getGuacamoleToken, logActivity } from "@/ui/main-axios";
import type { HostProps } from "../../../../types";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
import { useTranslation } from "react-i18next";
@@ -44,10 +47,16 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
setHost(initialHost);
}, [initialHost]);
useEffect(() => {
const hostIdRef = React.useRef(host.id);
React.useEffect(() => {
hostIdRef.current = host.id;
});
React.useEffect(() => {
const handleHostsChanged = async () => {
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === host.id);
const updatedHost = hosts.find((h) => h.id === hostIdRef.current);
if (updatedHost) {
setHost(updatedHost);
}
@@ -56,7 +65,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
return () =>
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
}, [host.id]);
}, []);
useEffect(() => {
const handleShowTagsChanged = () => {
@@ -70,16 +79,21 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
}, []);
const statsConfig = useMemo(() => {
if (!host.statsConfig) {
return DEFAULT_STATS_CONFIG;
}
if (typeof host.statsConfig === "object") {
return host.statsConfig;
}
try {
return host.statsConfig
? JSON.parse(host.statsConfig)
: DEFAULT_STATS_CONFIG;
} catch {
return JSON.parse(host.statsConfig);
} catch (e) {
return DEFAULT_STATS_CONFIG;
}
}, [host.statsConfig]);
const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
const shouldShowStatus = ![false, "false"].includes(
statsConfig.statusCheckEnabled,
);
const shouldShowMetrics = statsConfig.metricsEnabled !== false;
const serverStatus = useHostStatus(host.id, shouldShowStatus);
@@ -96,18 +110,67 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
}
}, [host.tunnelConnections]);
const handleTerminalClick = () => {
const handleTerminalClick = async () => {
if (
host.connectionType === "rdp" ||
host.connectionType === "vnc" ||
host.connectionType === "telnet"
) {
try {
const protocol = host.connectionType as "rdp" | "vnc" | "telnet";
const result = await getGuacamoleToken({
protocol,
hostname: host.ip,
port: host.port,
username: host.username,
password: host.password,
domain: host.domain,
security: host.security,
ignoreCert: host.ignoreCert,
guacamoleConfig: host.guacamoleConfig as any,
});
addTab({
type: protocol,
title,
hostConfig: host,
connectionConfig: {
token: result.token,
protocol,
type: protocol,
hostname: host.ip,
port: host.port,
username: host.username,
password: host.password,
domain: host.domain,
security: host.security,
"ignore-cert": host.ignoreCert,
},
});
try {
await logActivity(protocol, host.id, title);
} catch (err) {
console.warn(`Failed to log ${protocol} activity:`, err);
}
} catch (err) {
console.error("Failed to get Guacamole token:", err);
}
return;
}
addTab({ type: "terminal", title, hostConfig: host });
};
const isSSH = !host.connectionType || host.connectionType === "ssh";
const visibleButtons = [
host.enableTerminal && (host.showTerminalInSidebar ?? true),
host.enableFileManager && (host.showFileManagerInSidebar ?? false),
host.enableTunnel &&
isSSH && host.enableFileManager && (host.showFileManagerInSidebar ?? false),
isSSH &&
host.enableTunnel &&
hasTunnelConnections &&
(host.showTunnelInSidebar ?? false),
host.enableDocker && (host.showDockerInSidebar ?? false),
shouldShowMetrics && (host.showServerStatsInSidebar ?? false),
isSSH && host.enableDocker && (host.showDockerInSidebar ?? false),
isSSH && shouldShowMetrics && (host.showServerStatsInSidebar ?? false),
].filter(Boolean).length;
return (
@@ -133,11 +196,20 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
className="!px-2 border-1 border-edge"
onClick={handleTerminalClick}
>
<Terminal />
{host.connectionType === "rdp" ? (
<Monitor />
) : host.connectionType === "vnc" ? (
<Eye />
) : host.connectionType === "telnet" ? (
<MessagesSquare />
) : (
<Terminal />
)}
</Button>
)}
{host.enableFileManager &&
{isSSH &&
host.enableFileManager &&
(host.showFileManagerInSidebar ?? false) && (
<Button
variant="outline"
@@ -150,7 +222,8 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
</Button>
)}
{host.enableTunnel &&
{isSSH &&
host.enableTunnel &&
hasTunnelConnections &&
(host.showTunnelInSidebar ?? false) && (
<Button
@@ -164,29 +237,33 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
</Button>
)}
{host.enableDocker && (host.showDockerInSidebar ?? false) && (
<Button
variant="outline"
className="!px-2 border-1 border-edge"
onClick={() =>
addTab({ type: "docker", title, hostConfig: host })
}
>
<Container />
</Button>
)}
{isSSH &&
host.enableDocker &&
(host.showDockerInSidebar ?? false) && (
<Button
variant="outline"
className="!px-2 border-1 border-edge"
onClick={() =>
addTab({ type: "docker", title, hostConfig: host })
}
>
<Container />
</Button>
)}
{shouldShowMetrics && (host.showServerStatsInSidebar ?? false) && (
<Button
variant="outline"
className="!px-2 border-1 border-edge"
onClick={() =>
addTab({ type: "server_stats", title, hostConfig: host })
}
>
<Server />
</Button>
)}
{isSSH &&
shouldShowMetrics &&
(host.showServerStatsInSidebar ?? false) && (
<Button
variant="outline"
className="!px-2 border-1 border-edge"
onClick={() =>
addTab({ type: "server_stats", title, hostConfig: host })
}
>
<Server />
</Button>
)}
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
@@ -211,11 +288,20 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
onClick={handleTerminalClick}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
>
<Terminal className="h-4 w-4" />
{host.connectionType === "rdp" ? (
<Monitor className="h-4 w-4" />
) : host.connectionType === "vnc" ? (
<Eye className="h-4 w-4" />
) : host.connectionType === "telnet" ? (
<MessagesSquare className="h-4 w-4" />
) : (
<Terminal className="h-4 w-4" />
)}
<span className="flex-1">{t("hosts.openTerminal")}</span>
</DropdownMenuItem>
)}
{shouldShowMetrics &&
{isSSH &&
shouldShowMetrics &&
!(host.showServerStatsInSidebar ?? false) && (
<DropdownMenuItem
onClick={() =>
@@ -227,7 +313,8 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
<span className="flex-1">{t("hosts.openServerStats")}</span>
</DropdownMenuItem>
)}
{host.enableFileManager &&
{isSSH &&
host.enableFileManager &&
!(host.showFileManagerInSidebar ?? false) && (
<DropdownMenuItem
onClick={() =>
@@ -239,7 +326,8 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
<span className="flex-1">{t("hosts.openFileManager")}</span>
</DropdownMenuItem>
)}
{host.enableTunnel &&
{isSSH &&
host.enableTunnel &&
hasTunnelConnections &&
!(host.showTunnelInSidebar ?? false) && (
<DropdownMenuItem
@@ -252,17 +340,19 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
<span className="flex-1">{t("hosts.openTunnels")}</span>
</DropdownMenuItem>
)}
{host.enableDocker && !(host.showDockerInSidebar ?? false) && (
<DropdownMenuItem
onClick={() =>
addTab({ type: "docker", title, hostConfig: host })
}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
>
<Container className="h-4 w-4" />
<span className="flex-1">{t("hosts.openDocker")}</span>
</DropdownMenuItem>
)}
{isSSH &&
host.enableDocker &&
!(host.showDockerInSidebar ?? false) && (
<DropdownMenuItem
onClick={() =>
addTab({ type: "docker", title, hostConfig: host })
}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
>
<Container className="h-4 w-4" />
<span className="flex-1">{t("hosts.openDocker")}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() =>
addTab({
+15 -2
View File
@@ -10,6 +10,9 @@ import {
Server as ServerIcon,
Folder as FolderIcon,
User as UserIcon,
Monitor as MonitorIcon,
Eye as EyeIcon,
MessagesSquare as MessageSquareIcon,
Network,
ArrowDownUp as TunnelIcon,
Container as DockerIcon,
@@ -172,6 +175,9 @@ export function Tab({
tabType === "terminal" ||
tabType === "server_stats" ||
tabType === "file_manager" ||
tabType === "rdp" ||
tabType === "vnc" ||
tabType === "telnet" ||
tabType === "tunnel" ||
tabType === "docker" ||
tabType === "user_profile"
@@ -181,7 +187,6 @@ export function Tab({
const isTunnel = tabType === "tunnel";
const isDocker = tabType === "docker";
const isUserProfile = tabType === "user_profile";
const displayTitle =
title ||
(isServer
@@ -194,7 +199,9 @@ export function Tab({
? t("nav.docker")
: isUserProfile
? t("nav.userProfile")
: t("nav.terminal"));
: tabType === "rdp" || tabType === "vnc" || tabType === "telnet"
? tabType.toUpperCase()
: t("nav.terminal"));
const { base, suffix } = splitTitle(displayTitle);
@@ -219,6 +226,12 @@ export function Tab({
<DockerIcon className="h-4 w-4 flex-shrink-0" />
) : isUserProfile ? (
<UserIcon className="h-4 w-4 flex-shrink-0" />
) : tabType === "rdp" ? (
<MonitorIcon className="h-4 w-4 flex-shrink-0" />
) : tabType === "vnc" ? (
<EyeIcon className="h-4 w-4 flex-shrink-0" />
) : tabType === "telnet" ? (
<MessageSquareIcon className="h-4 w-4 flex-shrink-0" />
) : (
<TerminalIcon className="h-4 w-4 flex-shrink-0" />
)}
+80 -66
View File
@@ -4,6 +4,8 @@ import React, {
useState,
useEffect,
useRef,
useCallback,
useMemo,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
@@ -51,7 +53,6 @@ interface TabProviderProps {
export function clearTermixSessionStorage() {
localStorage.removeItem("termix_tabs");
localStorage.removeItem("termix_currentTab");
// Clear all session keys
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
@@ -65,7 +66,6 @@ export function clearTermixSessionStorage() {
export function TabProvider({ children }: TabProviderProps) {
const { t } = useTranslation();
const [tabs, setTabs] = useState<Tab[]>(() => {
// Check if persistence is enabled (always enabled for mobile/Electron)
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
const isElectron =
typeof window !== "undefined" && !!(window as any).electronAPI;
@@ -85,10 +85,9 @@ export function TabProvider({ children }: TabProviderProps) {
let maxId = 1;
for (const tab of parsed) {
if (tab.type === "home") continue;
// Preserve instanceId from saved tab - critical for session lookup
const restoredTab: Tab = {
...tab,
instanceId: tab.instanceId, // Preserve instanceId for session persistence
instanceId: tab.instanceId,
terminalRef:
tab.type === "terminal"
? React.createRef<{ disconnect?: () => void }>()
@@ -115,7 +114,6 @@ export function TabProvider({ children }: TabProviderProps) {
const saved = localStorage.getItem("termix_currentTab");
if (saved) {
const parsed = parseInt(saved, 10);
// Validate against restored tabs
if (parsed && tabs.some((t) => t.id === parsed)) return parsed;
}
} catch {
@@ -133,9 +131,7 @@ export function TabProvider({ children }: TabProviderProps) {
});
const nextTabId = useRef(initialMaxId);
// Save tabs to localStorage on change
useEffect(() => {
// Check if persistence is enabled (always enabled for mobile/Electron)
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
const isElectron =
typeof window !== "undefined" && !!(window as any).electronAPI;
@@ -144,14 +140,12 @@ export function TabProvider({ children }: TabProviderProps) {
const shouldSave = isMobile || isElectron || persistenceEnabled;
if (shouldSave) {
// Serialize tabs, preserving instanceId for session persistence
const serializable = tabs
.filter((t) => t.type !== "home")
.map(({ terminalRef, ...rest }) => rest);
localStorage.setItem("termix_tabs", JSON.stringify(serializable));
localStorage.setItem("termix_currentTab", String(currentTab));
} else {
// Clear saved tabs when persistence is disabled
localStorage.removeItem("termix_tabs");
localStorage.removeItem("termix_currentTab");
}
@@ -346,72 +340,92 @@ export function TabProvider({ children }: TabProviderProps) {
});
};
const updateHostConfig = (
hostId: number,
newHostConfig: {
id: number;
name?: string;
username: string;
ip: string;
port: number;
},
) => {
setTabs((prev) =>
prev.map((tab) => {
if (tab.hostConfig && tab.hostConfig.id === hostId) {
if (tab.type === "ssh_manager") {
const updateHostConfig = useCallback(
(
hostId: number,
newHostConfig: {
id: number;
name?: string;
username: string;
ip: string;
port: number;
},
) => {
setTabs((prev) =>
prev.map((tab) => {
if (tab.hostConfig && tab.hostConfig.id === hostId) {
if (tab.type === "ssh_manager") {
return {
...tab,
hostConfig: {
...newHostConfig,
instanceId: tab.hostConfig.instanceId,
},
};
}
return {
...tab,
hostConfig: {
...newHostConfig,
instanceId: tab.hostConfig.instanceId, // Preserve instanceId for session persistence
instanceId: tab.hostConfig.instanceId,
},
title: newHostConfig.name?.trim()
? newHostConfig.name
: t("nav.hostTabTitle", {
username: newHostConfig.username,
ip: newHostConfig.ip,
port: newHostConfig.port,
}),
};
}
return tab;
}),
);
},
[t],
);
return {
...tab,
hostConfig: {
...newHostConfig,
instanceId: tab.hostConfig.instanceId, // Preserve instanceId for session persistence
},
title: newHostConfig.name?.trim()
? newHostConfig.name
: t("nav.hostTabTitle", {
username: newHostConfig.username,
ip: newHostConfig.ip,
port: newHostConfig.port,
}),
};
}
return tab;
}),
);
};
const updateTab = useCallback(
(tabId: number, updates: Partial<Omit<Tab, "id">>) => {
setTabs((prev) =>
prev.map((tab) =>
tab.id === tabId
? { ...tab, ...updates, _updateTimestamp: Date.now() }
: tab,
),
);
},
[],
);
const updateTab = (tabId: number, updates: Partial<Omit<Tab, "id">>) => {
setTabs((prev) =>
prev.map((tab) =>
tab.id === tabId
? { ...tab, ...updates, _updateTimestamp: Date.now() }
: tab,
),
);
};
const value: TabContextType = {
tabs,
currentTab,
allSplitScreenTab,
addTab,
removeTab,
setCurrentTab,
setSplitScreenTab,
getTab,
reorderTabs,
updateHostConfig,
updateTab,
};
const value: TabContextType = useMemo(
() => ({
tabs,
currentTab,
allSplitScreenTab,
addTab,
removeTab,
setCurrentTab,
setSplitScreenTab,
getTab,
reorderTabs,
updateHostConfig,
updateTab,
}),
[
tabs,
currentTab,
allSplitScreenTab,
addTab,
removeTab,
setSplitScreenTab,
getTab,
reorderTabs,
updateHostConfig,
updateTab,
],
);
return <TabContext.Provider value={value}>{children}</TabContext.Provider>;
}
+280 -32
View File
@@ -731,8 +731,9 @@ function getApiUrl(path: string, defaultPort: number): string {
}
function initializeApiInstances() {
// SSH Host Management API (port 30001)
sshHostApi = createApiInstance(getApiUrl("/ssh", 30001), "SSH_HOST");
// Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet
hostApi = createApiInstance(getApiUrl("/host", 30001), "HOST");
sshHostApi = hostApi;
// Tunnel Management API (port 30003)
tunnelApi = createApiInstance(getApiUrl("/ssh", 30003), "TUNNEL");
@@ -759,7 +760,9 @@ function initializeApiInstances() {
dockerApi = createApiInstance(getApiUrl("/docker", 30007), "DOCKER");
}
// SSH Host Management API (port 30001)
// Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet
export let hostApi: AxiosInstance;
// Backward compatibility
export let sshHostApi: AxiosInstance;
// Tunnel Management API (port 30003)
@@ -1020,6 +1023,7 @@ export async function getSSHHosts(): Promise<SSHHostWithStatus[]> {
export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
try {
const submitData = {
connectionType: hostData.connectionType || "ssh",
name: hostData.name || "",
ip: hostData.ip,
port: parseInt(hostData.port.toString()) || 22,
@@ -1028,7 +1032,12 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
tags: hostData.tags || [],
pin: Boolean(hostData.pin),
authType: hostData.authType,
password: hostData.authType === "password" ? hostData.password : null,
password:
hostData.connectionType !== "ssh"
? hostData.password || null
: hostData.authType === "password"
? hostData.password
: null,
key: hostData.authType === "key" ? hostData.key : null,
keyPassword: hostData.authType === "key" ? hostData.keyPassword : null,
keyType: hostData.authType === "key" ? hostData.keyType : null,
@@ -1050,8 +1059,13 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
quickActions: hostData.quickActions || [],
sudoPassword: hostData.sudoPassword || null,
statsConfig: hostData.statsConfig || null,
dockerConfig: hostData.dockerConfig || null,
terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
domain: hostData.domain || null,
security: hostData.security || null,
ignoreCert: Boolean(hostData.ignoreCert),
guacamoleConfig: hostData.guacamoleConfig || null,
notes: hostData.notes || "",
useSocks5: Boolean(hostData.useSocks5),
socks5Host: hostData.socks5Host || null,
@@ -1096,6 +1110,7 @@ export async function updateSSHHost(
): Promise<SSHHost> {
try {
const submitData = {
connectionType: hostData.connectionType || "ssh",
name: hostData.name || "",
ip: hostData.ip,
port: parseInt(hostData.port.toString()) || 22,
@@ -1104,7 +1119,12 @@ export async function updateSSHHost(
tags: hostData.tags || [],
pin: Boolean(hostData.pin),
authType: hostData.authType,
password: hostData.authType === "password" ? hostData.password : null,
password:
hostData.connectionType !== "ssh"
? hostData.password || null
: hostData.authType === "password"
? hostData.password
: null,
key: hostData.authType === "key" ? hostData.key : null,
keyPassword: hostData.authType === "key" ? hostData.keyPassword : null,
keyType: hostData.authType === "key" ? hostData.keyType : null,
@@ -1126,8 +1146,13 @@ export async function updateSSHHost(
quickActions: hostData.quickActions || [],
sudoPassword: hostData.sudoPassword || null,
statsConfig: hostData.statsConfig || null,
dockerConfig: hostData.dockerConfig || null,
terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
domain: hostData.domain || null,
security: hostData.security || null,
ignoreCert: Boolean(hostData.ignoreCert),
guacamoleConfig: hostData.guacamoleConfig || null,
notes: hostData.notes || "",
useSocks5: Boolean(hostData.useSocks5),
socks5Host: hostData.socks5Host || null,
@@ -1511,7 +1536,6 @@ export async function connectSSH(
});
return response.data;
} catch (error: any) {
// Preserve connection logs from error response
if (error?.response?.data?.connectionLogs) {
const errorWithLogs = new Error(
error?.response?.data?.error ||
@@ -1520,7 +1544,6 @@ export async function connectSSH(
);
(errorWithLogs as any).connectionLogs =
error.response.data.connectionLogs;
// Also preserve other fields like requires_totp
if (error.response.data.requires_totp) {
(errorWithLogs as any).requires_totp = true;
(errorWithLogs as any).sessionId = error.response.data.sessionId;
@@ -1653,7 +1676,7 @@ export async function quickConnect(
data: Record<string, unknown>,
): Promise<SSHHost> {
try {
const response = await authApi.post("/ssh/quick-connect", data);
const response = await authApi.post("/host/quick-connect", data);
return response.data;
} catch (error) {
throw handleApiError(error, "quick connect");
@@ -2135,7 +2158,7 @@ export async function getRecentFiles(
hostId: number,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.get("/ssh/file_manager/recent", {
const response = await authApi.get("/host/file_manager/recent", {
params: { hostId },
});
return response.data;
@@ -2151,7 +2174,7 @@ export async function addRecentFile(
name?: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/ssh/file_manager/recent", {
const response = await authApi.post("/host/file_manager/recent", {
hostId,
path,
name,
@@ -2168,7 +2191,7 @@ export async function removeRecentFile(
path: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete("/ssh/file_manager/recent", {
const response = await authApi.delete("/host/file_manager/recent", {
data: { hostId, path },
});
return response.data;
@@ -2182,7 +2205,7 @@ export async function getPinnedFiles(
hostId: number,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.get("/ssh/file_manager/pinned", {
const response = await authApi.get("/host/file_manager/pinned", {
params: { hostId },
});
return response.data;
@@ -2198,7 +2221,7 @@ export async function addPinnedFile(
name?: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/ssh/file_manager/pinned", {
const response = await authApi.post("/host/file_manager/pinned", {
hostId,
path,
name,
@@ -2215,7 +2238,7 @@ export async function removePinnedFile(
path: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete("/ssh/file_manager/pinned", {
const response = await authApi.delete("/host/file_manager/pinned", {
data: { hostId, path },
});
return response.data;
@@ -2229,7 +2252,7 @@ export async function getFolderShortcuts(
hostId: number,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.get("/ssh/file_manager/shortcuts", {
const response = await authApi.get("/host/file_manager/shortcuts", {
params: { hostId },
});
return response.data;
@@ -2245,7 +2268,7 @@ export async function addFolderShortcut(
name?: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/ssh/file_manager/shortcuts", {
const response = await authApi.post("/host/file_manager/shortcuts", {
hostId,
path,
name,
@@ -2262,7 +2285,7 @@ export async function removeFolderShortcut(
path: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete("/ssh/file_manager/shortcuts", {
const response = await authApi.delete("/host/file_manager/shortcuts", {
data: { hostId, path },
});
return response.data;
@@ -2319,7 +2342,6 @@ export async function startMetricsPolling(hostId: number): Promise<{
const response = await statsApi.post(`/metrics/start/${hostId}`);
return response.data;
} catch (error: any) {
// Preserve connection logs from error response
if (error?.response?.data?.connectionLogs) {
const errorWithLogs = new Error(
error?.response?.data?.error || error.message,
@@ -2449,6 +2471,33 @@ export async function updateGlobalMonitoringSettings(settings: {
}
}
// ============================================================================
// GUACAMOLE SETTINGS
// ============================================================================
export async function getGuacamoleSettings(): Promise<{
enabled: boolean;
url: string;
}> {
try {
const response = await authApi.get("/users/guacamole-settings");
return response.data;
} catch (error) {
handleApiError(error, "fetch guacamole settings");
}
}
export async function updateGuacamoleSettings(settings: {
enabled?: boolean;
url?: string;
}): Promise<void> {
try {
await authApi.patch("/users/guacamole-settings", settings);
} catch (error) {
handleApiError(error, "update guacamole settings");
}
}
// ============================================================================
// AUTHENTICATION
// ============================================================================
@@ -2708,9 +2757,13 @@ export async function changePassword(oldPassword: string, newPassword: string) {
}
}
export async function getOIDCAuthorizeUrl(): Promise<OIDCAuthorize> {
export async function getOIDCAuthorizeUrl(
rememberMe = false,
): Promise<OIDCAuthorize> {
try {
const response = await authApi.get("/users/oidc/authorize");
const response = await authApi.get("/users/oidc/authorize", {
params: { rememberMe },
});
return response.data;
} catch (error) {
handleApiError(error, "get OIDC authorize URL");
@@ -3201,7 +3254,7 @@ export async function migrateHostToCredential(
export async function getFoldersWithStats(): Promise<Record<string, unknown>> {
try {
const response = await authApi.get("/ssh/db/folders/with-stats");
const response = await authApi.get("/host/db/folders/with-stats");
return response.data;
} catch (error) {
handleApiError(error, "fetch folders with statistics");
@@ -3213,7 +3266,7 @@ export async function renameFolder(
newName: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.put("/ssh/folders/rename", {
const response = await authApi.put("/host/folders/rename", {
oldName,
newName,
});
@@ -3229,7 +3282,7 @@ export async function getSSHFolders(): Promise<SSHFolder[]> {
operation: "fetch_ssh_folders",
});
const response = await authApi.get("/ssh/folders");
const response = await authApi.get("/host/folders");
sshLogger.success("SSH folders fetched successfully", {
operation: "fetch_ssh_folders",
@@ -3259,7 +3312,7 @@ export async function updateFolderMetadata(
icon,
});
await authApi.put("/ssh/folders/metadata", {
await authApi.put("/host/folders/metadata", {
name,
color,
icon,
@@ -3289,7 +3342,7 @@ export async function deleteAllHostsInFolder(
});
const response = await authApi.delete(
`/ssh/folders/${encodeURIComponent(folderName)}/hosts`,
`/host/folders/${encodeURIComponent(folderName)}/hosts`,
);
sshLogger.success("All hosts in folder deleted successfully", {
@@ -3596,7 +3649,15 @@ export interface UptimeInfo {
export interface RecentActivityItem {
id: number;
userId: string;
type: "terminal" | "file_manager" | "server_stats" | "tunnel" | "docker";
type:
| "terminal"
| "file_manager"
| "server_stats"
| "tunnel"
| "docker"
| "telnet"
| "vnc"
| "rdp";
hostId: number;
hostName: string;
timestamp: string;
@@ -3625,7 +3686,15 @@ export async function getRecentActivity(
}
export async function logActivity(
type: "terminal" | "file_manager" | "server_stats" | "tunnel" | "docker",
type:
| "terminal"
| "file_manager"
| "server_stats"
| "tunnel"
| "docker"
| "rdp"
| "vnc"
| "telnet",
hostId: number,
hostName: string,
): Promise<{ message: string; id: number | string }> {
@@ -3743,11 +3812,193 @@ export async function unlinkOIDCFromPasswordAccount(
}
}
export interface GuacamoleTokenRequest {
protocol: "rdp" | "vnc" | "telnet";
hostname: string;
port?: number;
username?: string;
password?: string;
domain?: string;
security?: string;
ignoreCert?: boolean;
guacamoleConfig?: {
colorDepth?: number;
width?: number;
height?: number;
dpi?: number;
resizeMethod?: string;
forceLossless?: boolean;
disableAudio?: boolean;
enableAudioInput?: boolean;
enableWallpaper?: boolean;
enableTheming?: boolean;
enableFontSmoothing?: boolean;
enableFullWindowDrag?: boolean;
enableDesktopComposition?: boolean;
enableMenuAnimations?: boolean;
disableBitmapCaching?: boolean;
disableOffscreenCaching?: boolean;
disableGlyphCaching?: boolean;
disableGfx?: boolean;
enablePrinting?: boolean;
printerName?: string;
enableDrive?: boolean;
driveName?: string;
drivePath?: string;
createDrivePath?: boolean;
disableDownload?: boolean;
disableUpload?: boolean;
enableTouch?: boolean;
clientName?: string;
console?: boolean;
initialProgram?: string;
serverLayout?: string;
timezone?: string;
gatewayHostname?: string;
gatewayPort?: number;
gatewayUsername?: string;
gatewayPassword?: string;
gatewayDomain?: string;
remoteApp?: string;
remoteAppDir?: string;
remoteAppArgs?: string;
normalizeClipboard?: string;
disableCopy?: boolean;
disablePaste?: boolean;
cursor?: string;
swapRedBlue?: boolean;
readOnly?: boolean;
recordingPath?: string;
recordingName?: string;
createRecordingPath?: boolean;
recordingExcludeOutput?: boolean;
recordingExcludeMouse?: boolean;
recordingIncludeKeys?: boolean;
wolSendPacket?: boolean;
wolMacAddr?: string;
wolBroadcastAddr?: string;
wolUdpPort?: number;
wolWaitTime?: number;
};
}
export interface GuacamoleTokenResponse {
token: string;
}
function toGuacamoleParams(
config: GuacamoleTokenRequest["guacamoleConfig"],
): Record<string, unknown> {
if (!config) return {};
const params: Record<string, unknown> = {};
const mappings: Record<string, string> = {
colorDepth: "color-depth",
resizeMethod: "resize-method",
forceLossless: "force-lossless",
disableAudio: "disable-audio",
enableAudioInput: "enable-audio-input",
enableWallpaper: "enable-wallpaper",
enableTheming: "enable-theming",
enableFontSmoothing: "enable-font-smoothing",
enableFullWindowDrag: "enable-full-window-drag",
enableDesktopComposition: "enable-desktop-composition",
enableMenuAnimations: "enable-menu-animations",
disableBitmapCaching: "disable-bitmap-caching",
disableOffscreenCaching: "disable-offscreen-caching",
disableGlyphCaching: "disable-glyph-caching",
disableGfx: "disable-gfx",
enablePrinting: "enable-printing",
printerName: "printer-name",
enableDrive: "enable-drive",
driveName: "drive-name",
drivePath: "drive-path",
createDrivePath: "create-drive-path",
disableDownload: "disable-download",
disableUpload: "disable-upload",
enableTouch: "enable-touch",
clientName: "client-name",
initialProgram: "initial-program",
serverLayout: "server-layout",
gatewayHostname: "gateway-hostname",
gatewayPort: "gateway-port",
gatewayUsername: "gateway-username",
gatewayPassword: "gateway-password",
gatewayDomain: "gateway-domain",
remoteApp: "remote-app",
remoteAppDir: "remote-app-dir",
remoteAppArgs: "remote-app-args",
normalizeClipboard: "normalize-clipboard",
disableCopy: "disable-copy",
disablePaste: "disable-paste",
swapRedBlue: "swap-red-blue",
readOnly: "read-only",
recordingPath: "recording-path",
recordingName: "recording-name",
createRecordingPath: "create-recording-path",
recordingExcludeOutput: "recording-exclude-output",
recordingExcludeMouse: "recording-exclude-mouse",
recordingIncludeKeys: "recording-include-keys",
wolSendPacket: "wol-send-packet",
wolMacAddr: "wol-mac-addr",
wolBroadcastAddr: "wol-broadcast-addr",
wolUdpPort: "wol-udp-port",
wolWaitTime: "wol-wait-time",
};
for (const [key, value] of Object.entries(config)) {
if (value !== undefined && value !== null && value !== "") {
const paramName = mappings[key] || key;
if (typeof value === "boolean") {
params[paramName] = value ? "true" : "false";
} else {
params[paramName] = value;
}
}
}
return params;
}
export async function getGuacamoleToken(
request: GuacamoleTokenRequest,
): Promise<GuacamoleTokenResponse> {
try {
const guacParams = toGuacamoleParams(request.guacamoleConfig);
const response = await authApi.post("/guacamole/token", {
type: request.protocol,
hostname: request.hostname,
port: request.port,
username: request.username,
password: request.password,
domain: request.domain,
security: request.security,
"ignore-cert": request.ignoreCert,
...guacParams,
});
return response.data;
} catch (error) {
throw handleApiError(error, "get guacamole token");
}
}
export async function getGuacamoleTokenFromHost(
hostId: number,
): Promise<GuacamoleTokenResponse> {
try {
const response = await authApi.post(`/guacamole/connect-host/${hostId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "get guacamole token from host");
}
}
// ============================================================================
// RBAC MANAGEMENT
// ============================================================================
// Role Management
export async function getRoles(): Promise<{ roles: Role[] }> {
try {
const response = await rbacApi.get("/rbac/roles");
@@ -3796,7 +4047,6 @@ export async function deleteRole(
}
}
// User-Role Management
export async function getUserRoles(
userId: string,
): Promise<{ roles: UserRole[] }> {
@@ -3836,14 +4086,13 @@ export async function removeRoleFromUser(
}
}
// Host Sharing Management
export async function shareHost(
hostId: number,
shareData: {
targetType: "user" | "role";
targetUserId?: string;
targetRoleId?: number;
permissionLevel: "view"; // Only view permission is supported
permissionLevel: "view";
durationHours?: number;
},
): Promise<{ success: boolean }> {
@@ -3932,7 +4181,6 @@ export async function connectDockerSession(
if (error.response?.data?.requires_warpgate) {
return error.response.data;
}
// Preserve connection logs from error response
if (error?.response?.data?.connectionLogs) {
const errorWithLogs = new Error(
error?.response?.data?.error ||
+13 -1
View File
@@ -549,7 +549,7 @@ export function Auth({
setError(null);
setOidcLoading(true);
try {
const authResponse = await getOIDCAuthorizeUrl();
const authResponse = await getOIDCAuthorizeUrl(rememberMe);
const { auth_url: authUrl } = authResponse;
if (!authUrl || authUrl === "undefined") {
@@ -908,6 +908,18 @@ export function Auth({
<div className="text-center text-muted-foreground mb-4">
<p>{t("auth.loginWithExternalDesc")}</p>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="rememberMeOIDC"
checked={rememberMe}
onCheckedChange={(checked) =>
setRememberMe(checked === true)
}
/>
<Label htmlFor="rememberMeOIDC">
{t("auth.rememberMe")}
</Label>
</div>
<Button
type="button"
className="w-full h-11 mt-2 text-base font-semibold"
+4 -2
View File
@@ -36,6 +36,7 @@ interface SSHHost {
tags: string[];
pin: boolean;
authType: string;
connectionType: string;
password?: string;
key?: string;
keyPassword?: string;
@@ -131,9 +132,10 @@ export function LeftSidebar({
}, [search]);
const filteredHosts = useMemo(() => {
if (!debouncedSearch.trim()) return hosts;
const sshOnlyHosts = hosts.filter((h) => h.connectionType === "ssh");
if (!debouncedSearch.trim()) return sshOnlyHosts;
const q = debouncedSearch.trim().toLowerCase();
return hosts.filter((h) => {
return sshOnlyHosts.filter((h) => {
const searchableText = [
h.name || "",
h.username,