- {/* CPU Stats */}
-
-
-
-
- {t("serverStats.cpuUsage")}
-
+
+ {enabledWidgets.map((widgetType) => (
+
+ {renderWidget(widgetType)}
-
-
-
-
- {(() => {
- const pct = metrics?.cpu?.percent;
- const cores = metrics?.cpu?.cores;
- const pctText =
- typeof pct === "number" ? `${pct}%` : "N/A";
- const coresText =
- typeof cores === "number"
- ? t("serverStats.cpuCores", { count: cores })
- : t("serverStats.naCpus");
- return `${pctText} ${t("serverStats.of")} ${coresText}`;
- })()}
-
-
-
-
-
-
- {metrics?.cpu?.load
- ? `Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}`
- : "Load: N/A"}
-
-
-
-
- {/* Memory Stats */}
-
-
-
-
- {t("serverStats.memoryUsage")}
-
-
-
-
-
-
- {(() => {
- const pct = metrics?.memory?.percent;
- const used = metrics?.memory?.usedGiB;
- const total = metrics?.memory?.totalGiB;
- const pctText =
- typeof pct === "number" ? `${pct}%` : "N/A";
- const usedText =
- typeof used === "number"
- ? `${used.toFixed(1)} GiB`
- : "N/A";
- const totalText =
- typeof total === "number"
- ? `${total.toFixed(1)} GiB`
- : "N/A";
- return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
- })()}
-
-
-
-
-
-
- {(() => {
- const used = metrics?.memory?.usedGiB;
- const total = metrics?.memory?.totalGiB;
- const free =
- typeof used === "number" && typeof total === "number"
- ? (total - used).toFixed(1)
- : "N/A";
- return `Free: ${free} GiB`;
- })()}
-
-
-
-
- {/* Disk Stats */}
-
-
-
-
- {t("serverStats.rootStorageSpace")}
-
-
-
-
-
-
- {(() => {
- const pct = metrics?.disk?.percent;
- const used = metrics?.disk?.usedHuman;
- const total = metrics?.disk?.totalHuman;
- const pctText =
- typeof pct === "number" ? `${pct}%` : "N/A";
- const usedText = used ?? "N/A";
- const totalText = total ?? "N/A";
- return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
- })()}
-
-
-
-
-
-
- {(() => {
- const available = metrics?.disk?.availableHuman;
- return available
- ? `Available: ${available}`
- : "Available: N/A";
- })()}
-
-
-
+ ))}
)}
diff --git a/src/ui/Desktop/Apps/Server/widgets/CpuWidget.tsx b/src/ui/Desktop/Apps/Server/widgets/CpuWidget.tsx
new file mode 100644
index 00000000..11f26e28
--- /dev/null
+++ b/src/ui/Desktop/Apps/Server/widgets/CpuWidget.tsx
@@ -0,0 +1,102 @@
+import React from "react";
+import { Cpu } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import type { ServerMetrics } from "@/ui/main-axios.ts";
+import { RechartsPrimitive } from "@/components/ui/chart.tsx";
+
+const {
+ LineChart,
+ Line,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+} = RechartsPrimitive;
+
+interface CpuWidgetProps {
+ metrics: ServerMetrics | null;
+ metricsHistory: ServerMetrics[];
+}
+
+export function CpuWidget({ metrics, metricsHistory }: CpuWidgetProps) {
+ const { t } = useTranslation();
+
+ // Prepare chart data
+ const chartData = React.useMemo(() => {
+ return metricsHistory.map((m, index) => ({
+ index,
+ cpu: m.cpu?.percent || 0,
+ }));
+ }, [metricsHistory]);
+
+ return (
+
+
+
+
+ {t("serverStats.cpuUsage")}
+
+
+
+
+
+
+ {typeof metrics?.cpu?.percent === "number"
+ ? `${metrics.cpu.percent}%`
+ : "N/A"}
+
+
+ {typeof metrics?.cpu?.cores === "number"
+ ? t("serverStats.cpuCores", { count: metrics.cpu.cores })
+ : t("serverStats.naCpus")}
+
+
+
+ {metrics?.cpu?.load
+ ? t("serverStats.loadAverage", {
+ avg1: metrics.cpu.load[0].toFixed(2),
+ avg5: metrics.cpu.load[1].toFixed(2),
+ avg15: metrics.cpu.load[2].toFixed(2),
+ })
+ : t("serverStats.loadAverageNA")}
+
+
+
+
+
+
+
+ [`${value.toFixed(1)}%`, "CPU"]}
+ />
+
+
+
+
+
+
+ );
+}
diff --git a/src/ui/Desktop/Apps/Server/widgets/DiskWidget.tsx b/src/ui/Desktop/Apps/Server/widgets/DiskWidget.tsx
new file mode 100644
index 00000000..64e2b9db
--- /dev/null
+++ b/src/ui/Desktop/Apps/Server/widgets/DiskWidget.tsx
@@ -0,0 +1,100 @@
+import React from "react";
+import { HardDrive } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import type { ServerMetrics } from "@/ui/main-axios.ts";
+import { RechartsPrimitive } from "@/components/ui/chart.tsx";
+
+const { RadialBarChart, RadialBar, PolarAngleAxis, ResponsiveContainer } =
+ RechartsPrimitive;
+
+interface DiskWidgetProps {
+ metrics: ServerMetrics | null;
+ metricsHistory: ServerMetrics[];
+}
+
+export function DiskWidget({ metrics }: DiskWidgetProps) {
+ const { t } = useTranslation();
+
+ // Prepare radial chart data
+ const radialData = React.useMemo(() => {
+ const percent = metrics?.disk?.percent || 0;
+ return [
+ {
+ name: "Disk",
+ value: percent,
+ fill: "#fb923c",
+ },
+ ];
+ }, [metrics]);
+
+ return (
+
+
+
+
+ {t("serverStats.diskUsage")}
+
+
+
+
+
+
+
+
+
+
+ {typeof metrics?.disk?.percent === "number"
+ ? `${metrics.disk.percent}%`
+ : "N/A"}
+
+
+
+
+
+
+ {(() => {
+ const used = metrics?.disk?.usedHuman;
+ const total = metrics?.disk?.totalHuman;
+ if (used && total) {
+ return `${used} / ${total}`;
+ }
+ return "N/A";
+ })()}
+
+
+ {(() => {
+ const available = metrics?.disk?.availableHuman;
+ return available
+ ? `${t("serverStats.available")}: ${available}`
+ : `${t("serverStats.available")}: N/A`;
+ })()}
+
+
+
+
+ );
+}
diff --git a/src/ui/Desktop/Apps/Server/widgets/MemoryWidget.tsx b/src/ui/Desktop/Apps/Server/widgets/MemoryWidget.tsx
new file mode 100644
index 00000000..679dd4e6
--- /dev/null
+++ b/src/ui/Desktop/Apps/Server/widgets/MemoryWidget.tsx
@@ -0,0 +1,118 @@
+import React from "react";
+import { MemoryStick } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import type { ServerMetrics } from "@/ui/main-axios.ts";
+import { RechartsPrimitive } from "@/components/ui/chart.tsx";
+
+const {
+ AreaChart,
+ Area,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+} = RechartsPrimitive;
+
+interface MemoryWidgetProps {
+ metrics: ServerMetrics | null;
+ metricsHistory: ServerMetrics[];
+}
+
+export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
+ const { t } = useTranslation();
+
+ // Prepare chart data
+ const chartData = React.useMemo(() => {
+ return metricsHistory.map((m, index) => ({
+ index,
+ memory: m.memory?.percent || 0,
+ }));
+ }, [metricsHistory]);
+
+ return (
+
+
+
+
+ {t("serverStats.memoryUsage")}
+
+
+
+
+
+
+ {typeof metrics?.memory?.percent === "number"
+ ? `${metrics.memory.percent}%`
+ : "N/A"}
+
+
+ {(() => {
+ const used = metrics?.memory?.usedGiB;
+ const total = metrics?.memory?.totalGiB;
+ if (typeof used === "number" && typeof total === "number") {
+ return `${used.toFixed(1)} / ${total.toFixed(1)} GiB`;
+ }
+ return "N/A";
+ })()}
+
+
+
+ {(() => {
+ const used = metrics?.memory?.usedGiB;
+ const total = metrics?.memory?.totalGiB;
+ const free =
+ typeof used === "number" && typeof total === "number"
+ ? (total - used).toFixed(1)
+ : "N/A";
+ return `${t("serverStats.free")}: ${free} GiB`;
+ })()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ [
+ `${value.toFixed(1)}%`,
+ "Memory",
+ ]}
+ />
+
+
+
+
+
+
+ );
+}
diff --git a/src/ui/Desktop/Apps/Server/widgets/NetworkWidget.tsx b/src/ui/Desktop/Apps/Server/widgets/NetworkWidget.tsx
new file mode 100644
index 00000000..4a3e7379
--- /dev/null
+++ b/src/ui/Desktop/Apps/Server/widgets/NetworkWidget.tsx
@@ -0,0 +1,75 @@
+import React from "react";
+import { Network, Wifi, WifiOff } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import type { ServerMetrics } from "@/ui/main-axios.ts";
+
+interface NetworkWidgetProps {
+ metrics: ServerMetrics | null;
+ metricsHistory: ServerMetrics[];
+}
+
+export function NetworkWidget({ metrics }: NetworkWidgetProps) {
+ const { t } = useTranslation();
+
+ const metricsWithNetwork = metrics as ServerMetrics & {
+ network?: {
+ interfaces?: Array<{
+ name: string;
+ state: string;
+ ip: string;
+ }>;
+ };
+ };
+ const network = metricsWithNetwork?.network;
+ const interfaces = network?.interfaces || [];
+
+ return (
+
+
+
+
+ {t("serverStats.networkInterfaces")}
+
+
+
+
+ {interfaces.length === 0 ? (
+
+
+
{t("serverStats.noInterfacesFound")}
+
+ ) : (
+ interfaces.map((iface, index: number) => (
+
+
+
+
+
+ {iface.name}
+
+
+
+ {iface.state}
+
+
+
+ {iface.ip}
+
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/src/ui/Desktop/Apps/Server/widgets/ProcessesWidget.tsx b/src/ui/Desktop/Apps/Server/widgets/ProcessesWidget.tsx
new file mode 100644
index 00000000..2e51cec3
--- /dev/null
+++ b/src/ui/Desktop/Apps/Server/widgets/ProcessesWidget.tsx
@@ -0,0 +1,87 @@
+import React from "react";
+import { List, Activity } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import type { ServerMetrics } from "@/ui/main-axios.ts";
+
+interface ProcessesWidgetProps {
+ metrics: ServerMetrics | null;
+ metricsHistory: ServerMetrics[];
+}
+
+export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
+ const { t } = useTranslation();
+
+ const metricsWithProcesses = metrics as ServerMetrics & {
+ processes?: {
+ total?: number;
+ running?: number;
+ top?: Array<{
+ pid: number;
+ cpu: number;
+ mem: number;
+ command: string;
+ user: string;
+ }>;
+ };
+ };
+ const processes = metricsWithProcesses?.processes;
+ const topProcesses = processes?.top || [];
+
+ return (
+
+
+
+
+ {t("serverStats.processes")}
+
+
+
+
+
+ {t("serverStats.totalProcesses")}:{" "}
+
+ {processes?.total ?? "N/A"}
+
+
+
+ {t("serverStats.running")}:{" "}
+
+ {processes?.running ?? "N/A"}
+
+
+
+
+
+ {topProcesses.length === 0 ? (
+
+
+
{t("serverStats.noProcessesFound")}
+
+ ) : (
+
+ {topProcesses.map((proc, index: number) => (
+
+
+
+ PID: {proc.pid}
+
+
+ CPU: {proc.cpu}%
+ MEM: {proc.mem}%
+
+
+
+ {proc.command}
+
+
User: {proc.user}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/ui/Desktop/Apps/Server/widgets/SystemWidget.tsx b/src/ui/Desktop/Apps/Server/widgets/SystemWidget.tsx
new file mode 100644
index 00000000..e9229d5b
--- /dev/null
+++ b/src/ui/Desktop/Apps/Server/widgets/SystemWidget.tsx
@@ -0,0 +1,71 @@
+import React from "react";
+import { Server, Info } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import type { ServerMetrics } from "@/ui/main-axios.ts";
+
+interface SystemWidgetProps {
+ metrics: ServerMetrics | null;
+ metricsHistory: ServerMetrics[];
+}
+
+export function SystemWidget({ metrics }: SystemWidgetProps) {
+ const { t } = useTranslation();
+
+ const metricsWithSystem = metrics as ServerMetrics & {
+ system?: {
+ hostname?: string;
+ os?: string;
+ kernel?: string;
+ };
+ };
+ const system = metricsWithSystem?.system;
+
+ return (
+
+
+
+
+ {t("serverStats.systemInfo")}
+
+
+
+
+
+
+
+
+ {t("serverStats.hostname")}
+
+
+ {system?.hostname || "N/A"}
+
+
+
+
+
+
+
+
+ {t("serverStats.operatingSystem")}
+
+
+ {system?.os || "N/A"}
+
+
+
+
+
+
+
+
+ {t("serverStats.kernel")}
+
+
+ {system?.kernel || "N/A"}
+
+
+
+
+
+ );
+}
diff --git a/src/ui/Desktop/Apps/Server/widgets/UptimeWidget.tsx b/src/ui/Desktop/Apps/Server/widgets/UptimeWidget.tsx
new file mode 100644
index 00000000..c8f02db7
--- /dev/null
+++ b/src/ui/Desktop/Apps/Server/widgets/UptimeWidget.tsx
@@ -0,0 +1,55 @@
+import React from "react";
+import { Clock, Activity } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import type { ServerMetrics } from "@/ui/main-axios.ts";
+
+interface UptimeWidgetProps {
+ metrics: ServerMetrics | null;
+ metricsHistory: ServerMetrics[];
+}
+
+export function UptimeWidget({ metrics }: UptimeWidgetProps) {
+ const { t } = useTranslation();
+
+ const metricsWithUptime = metrics as ServerMetrics & {
+ uptime?: {
+ formatted?: string;
+ seconds?: number;
+ };
+ };
+ const uptime = metricsWithUptime?.uptime;
+
+ return (
+
+
+
+
+ {t("serverStats.uptime")}
+
+
+
+
+
+
+
+
+ {uptime?.formatted || "N/A"}
+
+
+ {t("serverStats.totalUptime")}
+
+ {uptime?.seconds && (
+
+ {Math.floor(uptime.seconds).toLocaleString()}{" "}
+ {t("serverStats.seconds")}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/ui/Desktop/Apps/Server/widgets/index.ts b/src/ui/Desktop/Apps/Server/widgets/index.ts
new file mode 100644
index 00000000..2d227299
--- /dev/null
+++ b/src/ui/Desktop/Apps/Server/widgets/index.ts
@@ -0,0 +1,7 @@
+export { CpuWidget } from "./CpuWidget";
+export { MemoryWidget } from "./MemoryWidget";
+export { DiskWidget } from "./DiskWidget";
+export { NetworkWidget } from "./NetworkWidget";
+export { UptimeWidget } from "./UptimeWidget";
+export { ProcessesWidget } from "./ProcessesWidget";
+export { SystemWidget } from "./SystemWidget";
diff --git a/src/ui/Desktop/Apps/Terminal/SnippetsSidebar.tsx b/src/ui/Desktop/Apps/Terminal/SnippetsSidebar.tsx
new file mode 100644
index 00000000..8879f12a
--- /dev/null
+++ b/src/ui/Desktop/Apps/Terminal/SnippetsSidebar.tsx
@@ -0,0 +1,404 @@
+import React, { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Separator } from "@/components/ui/separator";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { Plus, Play, Edit, Trash2, Copy } from "lucide-react";
+import { toast } from "sonner";
+import { useTranslation } from "react-i18next";
+import { useConfirmation } from "@/hooks/use-confirmation.ts";
+import {
+ getSnippets,
+ createSnippet,
+ updateSnippet,
+ deleteSnippet,
+} from "@/ui/main-axios";
+import type { Snippet, SnippetData } from "../../../../types/index.js";
+
+interface SnippetsSidebarProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onExecute: (content: string) => void;
+}
+
+export function SnippetsSidebar({
+ isOpen,
+ onClose,
+ onExecute,
+}: SnippetsSidebarProps) {
+ const { t } = useTranslation();
+ const { confirmWithToast } = useConfirmation();
+ const [snippets, setSnippets] = useState
([]);
+ const [loading, setLoading] = useState(true);
+ const [showDialog, setShowDialog] = useState(false);
+ const [editingSnippet, setEditingSnippet] = useState(null);
+ const [formData, setFormData] = useState({
+ name: "",
+ content: "",
+ description: "",
+ });
+ const [formErrors, setFormErrors] = useState({
+ name: false,
+ content: false,
+ });
+
+ useEffect(() => {
+ if (isOpen) {
+ fetchSnippets();
+ }
+ }, [isOpen]);
+
+ const fetchSnippets = async () => {
+ try {
+ setLoading(true);
+ const data = await getSnippets();
+ // Defensive: ensure data is an array
+ setSnippets(Array.isArray(data) ? data : []);
+ } catch {
+ toast.error(t("snippets.failedToFetch"));
+ setSnippets([]);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleCreate = () => {
+ setEditingSnippet(null);
+ setFormData({ name: "", content: "", description: "" });
+ setFormErrors({ name: false, content: false });
+ setShowDialog(true);
+ };
+
+ const handleEdit = (snippet: Snippet) => {
+ setEditingSnippet(snippet);
+ setFormData({
+ name: snippet.name,
+ content: snippet.content,
+ description: snippet.description || "",
+ });
+ setFormErrors({ name: false, content: false });
+ setShowDialog(true);
+ };
+
+ const handleDelete = (snippet: Snippet) => {
+ confirmWithToast(
+ t("snippets.deleteConfirmDescription", { name: snippet.name }),
+ async () => {
+ try {
+ await deleteSnippet(snippet.id);
+ toast.success(t("snippets.deleteSuccess"));
+ fetchSnippets();
+ } catch {
+ toast.error(t("snippets.deleteFailed"));
+ }
+ },
+ "destructive",
+ );
+ };
+
+ const handleSubmit = async () => {
+ // Validate required fields
+ const errors = {
+ name: !formData.name.trim(),
+ content: !formData.content.trim(),
+ };
+
+ setFormErrors(errors);
+
+ if (errors.name || errors.content) {
+ return;
+ }
+
+ try {
+ if (editingSnippet) {
+ await updateSnippet(editingSnippet.id, formData);
+ toast.success(t("snippets.updateSuccess"));
+ } else {
+ await createSnippet(formData);
+ toast.success(t("snippets.createSuccess"));
+ }
+ setShowDialog(false);
+ fetchSnippets();
+ } catch {
+ toast.error(
+ editingSnippet
+ ? t("snippets.updateFailed")
+ : t("snippets.createFailed"),
+ );
+ }
+ };
+
+ const handleExecute = (snippet: Snippet) => {
+ onExecute(snippet.content);
+ toast.success(t("snippets.executeSuccess", { name: snippet.name }));
+ };
+
+ const handleCopy = (snippet: Snippet) => {
+ navigator.clipboard.writeText(snippet.content);
+ toast.success(t("snippets.copySuccess", { name: snippet.name }));
+ };
+
+ if (!isOpen) return null;
+
+ return (
+ <>
+ {/* Overlay and Sidebar */}
+
+
+
+
e.stopPropagation()}
+ >
+ {/* Header */}
+
+
+ {t("snippets.title")}
+
+
+
+
+ {/* Content */}
+
+
+
+
+ {loading ? (
+
+
{t("common.loading")}
+
+ ) : snippets.length === 0 ? (
+
+
{t("snippets.empty")}
+
{t("snippets.emptyHint")}
+
+ ) : (
+
+
+ {snippets.map((snippet) => (
+
+
+
+ {snippet.name}
+
+ {snippet.description && (
+
+ {snippet.description}
+
+ )}
+
+
+
+
+ {snippet.content}
+
+
+
+
+
+
+
+
+
+ {t("snippets.runTooltip")}
+
+
+
+
+
+
+
+
+ {t("snippets.copyTooltip")}
+
+
+
+
+
+
+
+
+ {t("snippets.editTooltip")}
+
+
+
+
+
+
+
+
+ {t("snippets.deleteTooltip")}
+
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ {/* Create/Edit Dialog - centered modal */}
+ {showDialog && (
+ setShowDialog(false)}
+ >
+
e.stopPropagation()}
+ >
+
+
+ {editingSnippet ? t("snippets.edit") : t("snippets.create")}
+
+
+ {editingSnippet
+ ? t("snippets.editDescription")
+ : t("snippets.createDescription")}
+
+
+
+
+
+
+
+ setFormData({ ...formData, name: e.target.value })
+ }
+ placeholder={t("snippets.namePlaceholder")}
+ className={`${formErrors.name ? "border-destructive focus-visible:ring-destructive" : ""}`}
+ autoFocus
+ />
+ {formErrors.name && (
+
+ {t("snippets.nameRequired")}
+
+ )}
+
+
+
+
+
+ setFormData({ ...formData, description: e.target.value })
+ }
+ placeholder={t("snippets.descriptionPlaceholder")}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/src/ui/Desktop/Apps/Terminal/Terminal.tsx b/src/ui/Desktop/Apps/Terminal/Terminal.tsx
index 2de03a7d..77b4c1f2 100644
--- a/src/ui/Desktop/Apps/Terminal/Terminal.tsx
+++ b/src/ui/Desktop/Apps/Terminal/Terminal.tsx
@@ -13,9 +13,32 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getCookie, isElectron } from "@/ui/main-axios.ts";
+import { TOTPDialog } from "@/ui/components/TOTPDialog";
+
+interface HostConfig {
+ id?: number;
+ ip: string;
+ port: number;
+ username: string;
+ password?: string;
+ key?: string;
+ keyPassword?: string;
+ keyType?: string;
+ authType?: string;
+ credentialId?: number;
+ [key: string]: unknown;
+}
+
+interface TerminalHandle {
+ disconnect: () => void;
+ fit: () => void;
+ sendInput: (data: string) => void;
+ notifyResize: () => void;
+ refresh: () => void;
+}
interface SSHTerminalProps {
- hostConfig: any;
+ hostConfig: HostConfig;
isVisible: boolean;
title?: string;
showTitle?: boolean;
@@ -25,713 +48,765 @@ interface SSHTerminalProps {
executeCommand?: string;
}
-export const Terminal = forwardRef(function SSHTerminal(
- {
- hostConfig,
- isVisible,
- splitScreen = false,
- onClose,
- initialPath,
- executeCommand,
- },
- ref,
-) {
- if (typeof window !== "undefined" && !(window as any).testJWT) {
- (window as any).testJWT = () => {
- const jwt = getCookie("jwt");
- return jwt;
- };
- }
-
- const { t } = useTranslation();
- const { instance: terminal, ref: xtermRef } = useXTerm();
- const fitAddonRef = useRef(null);
- const webSocketRef = useRef(null);
- const resizeTimeout = useRef(null);
- const wasDisconnectedBySSH = useRef(false);
- const pingIntervalRef = useRef(null);
- const [visible, setVisible] = useState(false);
- const [isConnected, setIsConnected] = useState(false);
- const [isConnecting, setIsConnecting] = useState(false);
- const [connectionError, setConnectionError] = useState(null);
- const [isAuthenticated, setIsAuthenticated] = useState(false);
- const isVisibleRef = useRef(false);
- const reconnectTimeoutRef = useRef(null);
- const reconnectAttempts = useRef(0);
- const maxReconnectAttempts = 3;
- const isUnmountingRef = useRef(false);
- const shouldNotReconnectRef = useRef(false);
- const isReconnectingRef = useRef(false);
- const isConnectingRef = useRef(false);
- const connectionTimeoutRef = useRef(null);
-
- const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
- const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
- const notifyTimerRef = useRef(null);
- const DEBOUNCE_MS = 140;
-
- useEffect(() => {
- isVisibleRef.current = isVisible;
- }, [isVisible]);
-
- useEffect(() => {
- const checkAuth = () => {
- const jwtToken = getCookie("jwt");
- const isAuth = !!(jwtToken && jwtToken.trim() !== "");
-
- setIsAuthenticated((prev) => {
- if (prev !== isAuth) {
- return isAuth;
- }
- return prev;
- });
- };
-
- checkAuth();
-
- const authCheckInterval = setInterval(checkAuth, 5000);
-
- return () => clearInterval(authCheckInterval);
- }, []);
-
- function hardRefresh() {
- try {
- if (terminal && typeof (terminal as any).refresh === "function") {
- (terminal as any).refresh(0, terminal.rows - 1);
- }
- } catch (_) {}
- }
-
- function scheduleNotify(cols: number, rows: number) {
- if (!(cols > 0 && rows > 0)) return;
- pendingSizeRef.current = { cols, rows };
- if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
- notifyTimerRef.current = setTimeout(() => {
- const next = pendingSizeRef.current;
- const last = lastSentSizeRef.current;
- if (!next) return;
- if (last && last.cols === next.cols && last.rows === next.rows) return;
- if (webSocketRef.current?.readyState === WebSocket.OPEN) {
- webSocketRef.current.send(
- JSON.stringify({ type: "resize", data: next }),
- );
- lastSentSizeRef.current = next;
- }
- }, DEBOUNCE_MS);
- }
-
- useImperativeHandle(
+export const Terminal = forwardRef(
+ function SSHTerminal(
+ {
+ hostConfig,
+ isVisible,
+ splitScreen = false,
+ onClose,
+ initialPath,
+ executeCommand,
+ },
ref,
- () => ({
- disconnect: () => {
- isUnmountingRef.current = true;
- shouldNotReconnectRef.current = true;
- isReconnectingRef.current = false;
- if (pingIntervalRef.current) {
- clearInterval(pingIntervalRef.current);
- pingIntervalRef.current = null;
- }
- if (reconnectTimeoutRef.current) {
- clearTimeout(reconnectTimeoutRef.current);
- reconnectTimeoutRef.current = null;
- }
- if (connectionTimeoutRef.current) {
- clearTimeout(connectionTimeoutRef.current);
- connectionTimeoutRef.current = null;
- }
- webSocketRef.current?.close();
- setIsConnected(false);
- setIsConnecting(false);
- },
- fit: () => {
- fitAddonRef.current?.fit();
- if (terminal) scheduleNotify(terminal.cols, terminal.rows);
- hardRefresh();
- },
- sendInput: (data: string) => {
- if (webSocketRef.current?.readyState === 1) {
- webSocketRef.current.send(JSON.stringify({ type: "input", data }));
- }
- },
- notifyResize: () => {
- try {
- const cols = terminal?.cols ?? undefined;
- const rows = terminal?.rows ?? undefined;
- if (typeof cols === "number" && typeof rows === "number") {
- scheduleNotify(cols, rows);
- hardRefresh();
- }
- } catch (_) {}
- },
- refresh: () => hardRefresh(),
- }),
- [terminal],
- );
-
- function handleWindowResize() {
- if (!isVisibleRef.current) return;
- fitAddonRef.current?.fit();
- if (terminal) scheduleNotify(terminal.cols, terminal.rows);
- hardRefresh();
- }
-
- function getUseRightClickCopyPaste() {
- return getCookie("rightClickCopyPaste") === "true";
- }
-
- function attemptReconnection() {
+ ) {
if (
- isUnmountingRef.current ||
- shouldNotReconnectRef.current ||
- isReconnectingRef.current ||
- isConnectingRef.current ||
- wasDisconnectedBySSH.current
+ typeof window !== "undefined" &&
+ !(window as { testJWT?: () => string | null }).testJWT
) {
- return;
+ (window as { testJWT?: () => string | null }).testJWT = () => {
+ const jwt = getCookie("jwt");
+ return jwt;
+ };
}
- if (reconnectAttempts.current >= maxReconnectAttempts) {
- toast.error(t("terminal.maxReconnectAttemptsReached"));
- if (onClose) {
- onClose();
- }
- return;
- }
+ const { t } = useTranslation();
+ const { instance: terminal, ref: xtermRef } = useXTerm();
+ const fitAddonRef = useRef(null);
+ const webSocketRef = useRef(null);
+ const resizeTimeout = useRef(null);
+ const wasDisconnectedBySSH = useRef(false);
+ const pingIntervalRef = useRef(null);
+ const [visible, setVisible] = useState(false);
+ const [isConnected, setIsConnected] = useState(false);
+ const [isConnecting, setIsConnecting] = useState(false);
+ const [, setConnectionError] = useState(null);
+ const [, setIsAuthenticated] = useState(false);
+ const [totpRequired, setTotpRequired] = useState(false);
+ const [totpPrompt, setTotpPrompt] = useState("");
+ const isVisibleRef = useRef(false);
+ const reconnectTimeoutRef = useRef(null);
+ const reconnectAttempts = useRef(0);
+ const maxReconnectAttempts = 3;
+ const isUnmountingRef = useRef(false);
+ const shouldNotReconnectRef = useRef(false);
+ const isReconnectingRef = useRef(false);
+ const isConnectingRef = useRef(false);
+ const connectionTimeoutRef = useRef(null);
- isReconnectingRef.current = true;
+ const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
+ const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
+ const notifyTimerRef = useRef(null);
+ const DEBOUNCE_MS = 140;
- if (terminal) {
- terminal.clear();
- }
+ useEffect(() => {
+ isVisibleRef.current = isVisible;
+ }, [isVisible]);
- reconnectAttempts.current++;
+ useEffect(() => {
+ const checkAuth = () => {
+ const jwtToken = getCookie("jwt");
+ const isAuth = !!(jwtToken && jwtToken.trim() !== "");
- toast.info(
- t("terminal.reconnecting", {
- attempt: reconnectAttempts.current,
- max: maxReconnectAttempts,
- }),
- );
-
- reconnectTimeoutRef.current = setTimeout(() => {
- if (
- isUnmountingRef.current ||
- shouldNotReconnectRef.current ||
- wasDisconnectedBySSH.current
- ) {
- isReconnectingRef.current = false;
- return;
- }
-
- if (reconnectAttempts.current > maxReconnectAttempts) {
- isReconnectingRef.current = false;
- return;
- }
-
- const jwtToken = getCookie("jwt");
- if (!jwtToken || jwtToken.trim() === "") {
- console.warn("Reconnection cancelled - no authentication token");
- isReconnectingRef.current = false;
- setConnectionError("Authentication required for reconnection");
- return;
- }
-
- if (terminal && hostConfig) {
- terminal.clear();
- const cols = terminal.cols;
- const rows = terminal.rows;
- connectToHost(cols, rows);
- }
-
- isReconnectingRef.current = false;
- }, 2000 * reconnectAttempts.current);
- }
-
- function connectToHost(cols: number, rows: number) {
- if (isConnectingRef.current) {
- return;
- }
-
- isConnectingRef.current = true;
-
- const isDev =
- process.env.NODE_ENV === "development" &&
- (window.location.port === "3000" ||
- window.location.port === "5173" ||
- window.location.port === "");
-
- const jwtToken = getCookie("jwt");
-
- if (!jwtToken || jwtToken.trim() === "") {
- console.error("No JWT token available for WebSocket connection");
- setIsConnected(false);
- setIsConnecting(false);
- setConnectionError("Authentication required");
- isConnectingRef.current = false;
- return;
- }
-
- const baseWsUrl = isDev
- ? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`
- : isElectron()
- ? (() => {
- const baseUrl =
- (window as any).configuredServerUrl || "http://127.0.0.1:30001";
- const wsProtocol = baseUrl.startsWith("https://")
- ? "wss://"
- : "ws://";
- const wsHost = baseUrl.replace(/^https?:\/\//, "");
- return `${wsProtocol}${wsHost}/ssh/websocket/`;
- })()
- : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
-
- if (
- webSocketRef.current &&
- webSocketRef.current.readyState !== WebSocket.CLOSED
- ) {
- webSocketRef.current.close();
- }
-
- if (pingIntervalRef.current) {
- clearInterval(pingIntervalRef.current);
- pingIntervalRef.current = null;
- }
- if (connectionTimeoutRef.current) {
- clearTimeout(connectionTimeoutRef.current);
- connectionTimeoutRef.current = null;
- }
-
- const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(jwtToken)}`;
-
- const ws = new WebSocket(wsUrl);
- webSocketRef.current = ws;
- wasDisconnectedBySSH.current = false;
- setConnectionError(null);
- shouldNotReconnectRef.current = false;
- isReconnectingRef.current = false;
- setIsConnecting(true);
-
- setupWebSocketListeners(ws, cols, rows);
- }
-
- function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) {
- ws.addEventListener("open", () => {
- connectionTimeoutRef.current = setTimeout(() => {
- if (!isConnected) {
- if (terminal) {
- terminal.clear();
+ setIsAuthenticated((prev) => {
+ if (prev !== isAuth) {
+ return isAuth;
}
- toast.error(t("terminal.connectionTimeout"));
- if (webSocketRef.current) {
- webSocketRef.current.close();
- }
- if (reconnectAttempts.current > 0) {
- attemptReconnection();
- }
- }
- }, 10000);
+ return prev;
+ });
+ };
- ws.send(
- JSON.stringify({
- type: "connectToHost",
- data: { cols, rows, hostConfig, initialPath, executeCommand },
- }),
- );
- terminal.onData((data) => {
- ws.send(JSON.stringify({ type: "input", data }));
- });
+ checkAuth();
- pingIntervalRef.current = setInterval(() => {
- if (ws.readyState === WebSocket.OPEN) {
- ws.send(JSON.stringify({ type: "ping" }));
- }
- }, 30000);
- });
+ const authCheckInterval = setInterval(checkAuth, 5000);
- ws.addEventListener("message", (event) => {
+ return () => clearInterval(authCheckInterval);
+ }, []);
+
+ function hardRefresh() {
try {
- const msg = JSON.parse(event.data);
- if (msg.type === "data") {
- if (typeof msg.data === "string") {
- terminal.write(msg.data);
- } else {
- terminal.write(String(msg.data));
- }
- } else if (msg.type === "error") {
- const errorMessage = msg.message || t("terminal.unknownError");
+ if (
+ terminal &&
+ typeof (
+ terminal as { refresh?: (start: number, end: number) => void }
+ ).refresh === "function"
+ ) {
+ (
+ terminal as { refresh?: (start: number, end: number) => void }
+ ).refresh(0, terminal.rows - 1);
+ }
+ } catch {
+ // Ignore terminal refresh errors
+ }
+ }
- if (
- errorMessage.toLowerCase().includes("auth") ||
- errorMessage.toLowerCase().includes("password") ||
- errorMessage.toLowerCase().includes("permission") ||
- errorMessage.toLowerCase().includes("denied") ||
- errorMessage.toLowerCase().includes("invalid") ||
- errorMessage.toLowerCase().includes("failed") ||
- errorMessage.toLowerCase().includes("incorrect")
- ) {
- toast.error(t("terminal.authError", { message: errorMessage }));
- shouldNotReconnectRef.current = true;
- if (webSocketRef.current) {
- webSocketRef.current.close();
- }
- if (onClose) {
- onClose();
- }
- return;
- }
+ function handleTotpSubmit(code: string) {
+ if (webSocketRef.current && code) {
+ webSocketRef.current.send(
+ JSON.stringify({
+ type: "totp_response",
+ data: { code },
+ }),
+ );
+ setTotpRequired(false);
+ setTotpPrompt("");
+ }
+ }
- if (
- errorMessage.toLowerCase().includes("connection") ||
- errorMessage.toLowerCase().includes("timeout") ||
- errorMessage.toLowerCase().includes("network")
- ) {
- toast.error(
- t("terminal.connectionError", { message: errorMessage }),
- );
- setIsConnected(false);
- if (terminal) {
- terminal.clear();
- }
- setIsConnecting(true);
- wasDisconnectedBySSH.current = false;
- attemptReconnection();
- return;
- }
+ function handleTotpCancel() {
+ setTotpRequired(false);
+ setTotpPrompt("");
+ if (onClose) onClose();
+ }
- toast.error(t("terminal.error", { message: errorMessage }));
- } else if (msg.type === "connected") {
- setIsConnected(true);
- setIsConnecting(false);
- isConnectingRef.current = false;
+ function scheduleNotify(cols: number, rows: number) {
+ if (!(cols > 0 && rows > 0)) return;
+ pendingSizeRef.current = { cols, rows };
+ if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
+ notifyTimerRef.current = setTimeout(() => {
+ const next = pendingSizeRef.current;
+ const last = lastSentSizeRef.current;
+ if (!next) return;
+ if (last && last.cols === next.cols && last.rows === next.rows) return;
+ if (webSocketRef.current?.readyState === WebSocket.OPEN) {
+ webSocketRef.current.send(
+ JSON.stringify({ type: "resize", data: next }),
+ );
+ lastSentSizeRef.current = next;
+ }
+ }, DEBOUNCE_MS);
+ }
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ disconnect: () => {
+ isUnmountingRef.current = true;
+ shouldNotReconnectRef.current = true;
+ isReconnectingRef.current = false;
+ if (pingIntervalRef.current) {
+ clearInterval(pingIntervalRef.current);
+ pingIntervalRef.current = null;
+ }
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current);
+ reconnectTimeoutRef.current = null;
+ }
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
- if (reconnectAttempts.current > 0) {
- toast.success(t("terminal.reconnected"));
- }
- reconnectAttempts.current = 0;
- isReconnectingRef.current = false;
- } else if (msg.type === "disconnected") {
- wasDisconnectedBySSH.current = true;
+ webSocketRef.current?.close();
setIsConnected(false);
- if (terminal) {
- terminal.clear();
- }
setIsConnecting(false);
- if (onClose) {
- onClose();
- }
- }
- } catch (error) {
- toast.error(t("terminal.messageParseError"));
- }
- });
-
- ws.addEventListener("close", (event) => {
- setIsConnected(false);
- isConnectingRef.current = false;
- if (terminal) {
- terminal.clear();
- }
-
- if (event.code === 1008) {
- console.error("WebSocket authentication failed:", event.reason);
- setConnectionError("Authentication failed - please re-login");
- setIsConnecting(false);
- shouldNotReconnectRef.current = true;
-
- localStorage.removeItem("jwt");
-
- toast.error("Authentication failed. Please log in again.");
-
- return;
- }
-
- setIsConnecting(false);
- if (
- !wasDisconnectedBySSH.current &&
- !isUnmountingRef.current &&
- !shouldNotReconnectRef.current
- ) {
- wasDisconnectedBySSH.current = false;
- attemptReconnection();
- }
- });
-
- ws.addEventListener("error", (event) => {
- setIsConnected(false);
- isConnectingRef.current = false;
- setConnectionError(t("terminal.websocketError"));
- if (terminal) {
- terminal.clear();
- }
- setIsConnecting(false);
- if (!isUnmountingRef.current && !shouldNotReconnectRef.current) {
- wasDisconnectedBySSH.current = false;
- attemptReconnection();
- }
- });
- }
-
- async function writeTextToClipboard(text: string): Promise {
- try {
- if (navigator.clipboard && navigator.clipboard.writeText) {
- await navigator.clipboard.writeText(text);
- return;
- }
- } catch (_) {}
- const textarea = document.createElement("textarea");
- textarea.value = text;
- textarea.style.position = "fixed";
- textarea.style.left = "-9999px";
- document.body.appendChild(textarea);
- textarea.focus();
- textarea.select();
- try {
- document.execCommand("copy");
- } finally {
- document.body.removeChild(textarea);
- }
- }
-
- async function readTextFromClipboard(): Promise {
- try {
- if (navigator.clipboard && navigator.clipboard.readText) {
- return await navigator.clipboard.readText();
- }
- } catch (_) {}
- return "";
- }
-
- useEffect(() => {
- if (!terminal || !xtermRef.current) return;
-
- terminal.options = {
- cursorBlink: true,
- cursorStyle: "bar",
- scrollback: 10000,
- fontSize: 14,
- fontFamily:
- '"Caskaydia Cove Nerd Font Mono", "SF Mono", Consolas, "Liberation Mono", monospace',
- theme: { background: "#18181b", foreground: "#f7f7f7" },
- allowTransparency: true,
- convertEol: true,
- windowsMode: false,
- macOptionIsMeta: false,
- macOptionClickForcesSelection: false,
- rightClickSelectsWord: false,
- fastScrollModifier: "alt",
- fastScrollSensitivity: 5,
- allowProposedApi: true,
- minimumContrastRatio: 1,
- letterSpacing: 0,
- lineHeight: 1.2,
- };
-
- const fitAddon = new FitAddon();
- const clipboardAddon = new ClipboardAddon();
- const unicode11Addon = new Unicode11Addon();
- const webLinksAddon = new WebLinksAddon();
-
- fitAddonRef.current = fitAddon;
- terminal.loadAddon(fitAddon);
- terminal.loadAddon(clipboardAddon);
- terminal.loadAddon(unicode11Addon);
- terminal.loadAddon(webLinksAddon);
-
- terminal.unicode.activeVersion = "11";
-
- terminal.open(xtermRef.current);
-
- const element = xtermRef.current;
- const handleContextMenu = async (e: MouseEvent) => {
- 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);
- }
- } catch (_) {}
- };
- element?.addEventListener("contextmenu", handleContextMenu);
-
- const handleMacKeyboard = (e: KeyboardEvent) => {
- const isMacOS =
- navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
- navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
-
- if (!isMacOS) return;
-
- if (e.altKey && !e.metaKey && !e.ctrlKey) {
- const keyMappings: { [key: string]: string } = {
- "7": "|",
- "2": "€",
- "8": "[",
- "9": "]",
- l: "@",
- L: "@",
- Digit7: "|",
- Digit2: "€",
- Digit8: "[",
- Digit9: "]",
- KeyL: "@",
- };
-
- const char = keyMappings[e.key] || keyMappings[e.code];
- if (char) {
- e.preventDefault();
- e.stopPropagation();
-
+ },
+ fit: () => {
+ fitAddonRef.current?.fit();
+ if (terminal) scheduleNotify(terminal.cols, terminal.rows);
+ hardRefresh();
+ },
+ sendInput: (data: string) => {
if (webSocketRef.current?.readyState === 1) {
- webSocketRef.current.send(
- JSON.stringify({ type: "input", data: char }),
- );
+ webSocketRef.current.send(JSON.stringify({ type: "input", data }));
}
- return false;
- }
+ },
+ notifyResize: () => {
+ try {
+ const cols = terminal?.cols ?? undefined;
+ const rows = terminal?.rows ?? undefined;
+ if (typeof cols === "number" && typeof rows === "number") {
+ scheduleNotify(cols, rows);
+ hardRefresh();
+ }
+ } catch {
+ // Ignore resize notification errors
+ }
+ },
+ refresh: () => hardRefresh(),
+ }),
+ [terminal],
+ );
+
+ function getUseRightClickCopyPaste() {
+ return getCookie("rightClickCopyPaste") === "true";
+ }
+
+ function attemptReconnection() {
+ if (
+ isUnmountingRef.current ||
+ shouldNotReconnectRef.current ||
+ isReconnectingRef.current ||
+ isConnectingRef.current ||
+ wasDisconnectedBySSH.current
+ ) {
+ return;
}
- };
- element?.addEventListener("keydown", handleMacKeyboard, true);
+ if (reconnectAttempts.current >= maxReconnectAttempts) {
+ toast.error(t("terminal.maxReconnectAttemptsReached"));
+ if (onClose) {
+ onClose();
+ }
+ return;
+ }
- const resizeObserver = new ResizeObserver(() => {
- if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
- resizeTimeout.current = setTimeout(() => {
- if (!isVisibleRef.current) return;
- fitAddonRef.current?.fit();
- if (terminal) scheduleNotify(terminal.cols, terminal.rows);
- hardRefresh();
- }, 150);
- });
+ isReconnectingRef.current = true;
- resizeObserver.observe(xtermRef.current);
+ if (terminal) {
+ terminal.clear();
+ }
- setVisible(true);
+ reconnectAttempts.current++;
+
+ toast.info(
+ t("terminal.reconnecting", {
+ attempt: reconnectAttempts.current,
+ max: maxReconnectAttempts,
+ }),
+ );
+
+ reconnectTimeoutRef.current = setTimeout(() => {
+ if (
+ isUnmountingRef.current ||
+ shouldNotReconnectRef.current ||
+ wasDisconnectedBySSH.current
+ ) {
+ isReconnectingRef.current = false;
+ return;
+ }
+
+ if (reconnectAttempts.current > maxReconnectAttempts) {
+ isReconnectingRef.current = false;
+ return;
+ }
+
+ const jwtToken = getCookie("jwt");
+ if (!jwtToken || jwtToken.trim() === "") {
+ console.warn("Reconnection cancelled - no authentication token");
+ isReconnectingRef.current = false;
+ setConnectionError("Authentication required for reconnection");
+ return;
+ }
+
+ if (terminal && hostConfig) {
+ terminal.clear();
+ const cols = terminal.cols;
+ const rows = terminal.rows;
+ connectToHost(cols, rows);
+ }
+
+ isReconnectingRef.current = false;
+ }, 2000 * reconnectAttempts.current);
+ }
+
+ function connectToHost(cols: number, rows: number) {
+ if (isConnectingRef.current) {
+ return;
+ }
+
+ isConnectingRef.current = true;
+
+ const isDev =
+ process.env.NODE_ENV === "development" &&
+ (window.location.port === "3000" ||
+ window.location.port === "5173" ||
+ window.location.port === "");
+
+ const jwtToken = getCookie("jwt");
+
+ if (!jwtToken || jwtToken.trim() === "") {
+ console.error("No JWT token available for WebSocket connection");
+ setIsConnected(false);
+ setIsConnecting(false);
+ setConnectionError("Authentication required");
+ isConnectingRef.current = false;
+ return;
+ }
+
+ const baseWsUrl = isDev
+ ? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`
+ : isElectron()
+ ? (() => {
+ const baseUrl =
+ (window as { configuredServerUrl?: string })
+ .configuredServerUrl || "http://127.0.0.1:30001";
+ const wsProtocol = baseUrl.startsWith("https://")
+ ? "wss://"
+ : "ws://";
+ const wsHost = baseUrl.replace(/^https?:\/\//, "");
+ return `${wsProtocol}${wsHost}/ssh/websocket/`;
+ })()
+ : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
+
+ if (
+ webSocketRef.current &&
+ webSocketRef.current.readyState !== WebSocket.CLOSED
+ ) {
+ webSocketRef.current.close();
+ }
- return () => {
- isUnmountingRef.current = true;
- shouldNotReconnectRef.current = true;
- isReconnectingRef.current = false;
- setIsConnecting(false);
- resizeObserver.disconnect();
- element?.removeEventListener("contextmenu", handleContextMenu);
- element?.removeEventListener("keydown", handleMacKeyboard, true);
- if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
- if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
- if (reconnectTimeoutRef.current)
- clearTimeout(reconnectTimeoutRef.current);
- if (connectionTimeoutRef.current)
- clearTimeout(connectionTimeoutRef.current);
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
- webSocketRef.current?.close();
- };
- }, [xtermRef, terminal]);
+ if (connectionTimeoutRef.current) {
+ clearTimeout(connectionTimeoutRef.current);
+ connectionTimeoutRef.current = null;
+ }
- useEffect(() => {
- if (!terminal || !hostConfig || !visible) return;
+ const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(jwtToken)}`;
- if (isConnected || isConnecting) return;
+ const ws = new WebSocket(wsUrl);
+ webSocketRef.current = ws;
+ wasDisconnectedBySSH.current = false;
+ setConnectionError(null);
+ shouldNotReconnectRef.current = false;
+ isReconnectingRef.current = false;
+ setIsConnecting(true);
- setIsConnecting(true);
+ setupWebSocketListeners(ws, cols, rows);
+ }
- const readyFonts =
- (document as any).fonts?.ready instanceof Promise
- ? (document as any).fonts.ready
- : Promise.resolve();
+ function setupWebSocketListeners(
+ ws: WebSocket,
+ cols: number,
+ rows: number,
+ ) {
+ ws.addEventListener("open", () => {
+ connectionTimeoutRef.current = setTimeout(() => {
+ if (!isConnected) {
+ if (terminal) {
+ terminal.clear();
+ }
+ toast.error(t("terminal.connectionTimeout"));
+ if (webSocketRef.current) {
+ webSocketRef.current.close();
+ }
+ if (reconnectAttempts.current > 0) {
+ attemptReconnection();
+ }
+ }
+ }, 10000);
- readyFonts.then(() => {
- setTimeout(() => {
- fitAddonRef.current?.fit();
- if (terminal) scheduleNotify(terminal.cols, terminal.rows);
- hardRefresh();
+ ws.send(
+ JSON.stringify({
+ type: "connectToHost",
+ data: { cols, rows, hostConfig, initialPath, executeCommand },
+ }),
+ );
+ terminal.onData((data) => {
+ ws.send(JSON.stringify({ type: "input", data }));
+ });
- if (terminal && !splitScreen) {
- terminal.focus();
+ pingIntervalRef.current = setInterval(() => {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: "ping" }));
+ }
+ }, 30000);
+ });
+
+ ws.addEventListener("message", (event) => {
+ try {
+ const msg = JSON.parse(event.data);
+ if (msg.type === "data") {
+ if (typeof msg.data === "string") {
+ terminal.write(msg.data);
+ } else {
+ terminal.write(String(msg.data));
+ }
+ } else if (msg.type === "error") {
+ const errorMessage = msg.message || t("terminal.unknownError");
+
+ if (
+ errorMessage.toLowerCase().includes("auth") ||
+ errorMessage.toLowerCase().includes("password") ||
+ errorMessage.toLowerCase().includes("permission") ||
+ errorMessage.toLowerCase().includes("denied") ||
+ errorMessage.toLowerCase().includes("invalid") ||
+ errorMessage.toLowerCase().includes("failed") ||
+ errorMessage.toLowerCase().includes("incorrect")
+ ) {
+ toast.error(t("terminal.authError", { message: errorMessage }));
+ shouldNotReconnectRef.current = true;
+ if (webSocketRef.current) {
+ webSocketRef.current.close();
+ }
+ if (onClose) {
+ onClose();
+ }
+ return;
+ }
+
+ if (
+ errorMessage.toLowerCase().includes("connection") ||
+ errorMessage.toLowerCase().includes("timeout") ||
+ errorMessage.toLowerCase().includes("network")
+ ) {
+ toast.error(
+ t("terminal.connectionError", { message: errorMessage }),
+ );
+ setIsConnected(false);
+ if (terminal) {
+ terminal.clear();
+ }
+ setIsConnecting(true);
+ wasDisconnectedBySSH.current = false;
+ attemptReconnection();
+ return;
+ }
+
+ toast.error(t("terminal.error", { message: errorMessage }));
+ } else if (msg.type === "connected") {
+ setIsConnected(true);
+ setIsConnecting(false);
+ isConnectingRef.current = false;
+ if (connectionTimeoutRef.current) {
+ clearTimeout(connectionTimeoutRef.current);
+ connectionTimeoutRef.current = null;
+ }
+ if (reconnectAttempts.current > 0) {
+ toast.success(t("terminal.reconnected"));
+ }
+ reconnectAttempts.current = 0;
+ isReconnectingRef.current = false;
+ } else if (msg.type === "disconnected") {
+ wasDisconnectedBySSH.current = true;
+ setIsConnected(false);
+ if (terminal) {
+ terminal.clear();
+ }
+ setIsConnecting(false);
+ if (onClose) {
+ onClose();
+ }
+ } else if (msg.type === "totp_required") {
+ setTotpRequired(true);
+ setTotpPrompt(msg.prompt || "Verification code:");
+ }
+ } catch {
+ toast.error(t("terminal.messageParseError"));
+ }
+ });
+
+ ws.addEventListener("close", (event) => {
+ setIsConnected(false);
+ isConnectingRef.current = false;
+ if (terminal) {
+ terminal.clear();
}
- const jwtToken = getCookie("jwt");
-
- if (!jwtToken || jwtToken.trim() === "") {
- setIsConnected(false);
+ if (event.code === 1008) {
+ console.error("WebSocket authentication failed:", event.reason);
+ setConnectionError("Authentication failed - please re-login");
setIsConnecting(false);
- setConnectionError("Authentication required");
+ shouldNotReconnectRef.current = true;
+
+ localStorage.removeItem("jwt");
+
+ toast.error("Authentication failed. Please log in again.");
+
return;
}
- const cols = terminal.cols;
- const rows = terminal.rows;
+ setIsConnecting(false);
+ if (
+ !wasDisconnectedBySSH.current &&
+ !isUnmountingRef.current &&
+ !shouldNotReconnectRef.current
+ ) {
+ wasDisconnectedBySSH.current = false;
+ attemptReconnection();
+ }
+ });
- connectToHost(cols, rows);
- }, 200);
- });
- }, [terminal, hostConfig, visible, isConnected, isConnecting, splitScreen]);
+ ws.addEventListener("error", () => {
+ setIsConnected(false);
+ isConnectingRef.current = false;
+ setConnectionError(t("terminal.websocketError"));
+ if (terminal) {
+ terminal.clear();
+ }
+ setIsConnecting(false);
+ if (!isUnmountingRef.current && !shouldNotReconnectRef.current) {
+ wasDisconnectedBySSH.current = false;
+ attemptReconnection();
+ }
+ });
+ }
- useEffect(() => {
- if (isVisible && fitAddonRef.current) {
+ async function writeTextToClipboard(text: string): Promise