mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
v2.0.0 (#621)
* 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:
@@ -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(() => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
-1
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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 ||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user