@@ -1613,6 +1686,114 @@ export function AdminSettings({
)}
+
+ {/* Role Management Dialog */}
+
);
}
diff --git a/src/ui/desktop/admin/RoleManagement.tsx b/src/ui/desktop/admin/RoleManagement.tsx
new file mode 100644
index 00000000..9bce9002
--- /dev/null
+++ b/src/ui/desktop/admin/RoleManagement.tsx
@@ -0,0 +1,650 @@
+import React from "react";
+import { Button } from "@/components/ui/button.tsx";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog.tsx";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table.tsx";
+import { Input } from "@/components/ui/input.tsx";
+import { Label } from "@/components/ui/label.tsx";
+import { Textarea } from "@/components/ui/textarea.tsx";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select.tsx";
+import { Badge } from "@/components/ui/badge.tsx";
+import {
+ Shield,
+ Plus,
+ Edit,
+ Trash2,
+ Users,
+ Check,
+ ChevronsUpDown,
+} from "lucide-react";
+import { toast } from "sonner";
+import { useTranslation } from "react-i18next";
+import { useConfirmation } from "@/hooks/use-confirmation.ts";
+import {
+ getRoles,
+ createRole,
+ updateRole,
+ deleteRole,
+ getUserList,
+ getUserRoles,
+ assignRoleToUser,
+ removeRoleFromUser,
+ type Role,
+ type UserRole,
+} from "@/ui/main-axios.ts";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+} from "@/components/ui/command.tsx";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover.tsx";
+import { cn } from "@/lib/utils";
+
+interface User {
+ id: string;
+ username: string;
+ is_admin: boolean;
+}
+
+export function RoleManagement(): React.ReactElement {
+ const { t } = useTranslation();
+ const { confirmWithToast } = useConfirmation();
+
+ const [roles, setRoles] = React.useState
([]);
+ const [users, setUsers] = React.useState([]);
+ const [loading, setLoading] = React.useState(false);
+
+ // Create/Edit Role Dialog
+ const [roleDialogOpen, setRoleDialogOpen] = React.useState(false);
+ const [editingRole, setEditingRole] = React.useState(null);
+ const [roleName, setRoleName] = React.useState("");
+ const [roleDisplayName, setRoleDisplayName] = React.useState("");
+ const [roleDescription, setRoleDescription] = React.useState("");
+
+ // Assign Role Dialog
+ const [assignDialogOpen, setAssignDialogOpen] = React.useState(false);
+ const [selectedUserId, setSelectedUserId] = React.useState("");
+ const [selectedRoleId, setSelectedRoleId] = React.useState(
+ null,
+ );
+ const [userRoles, setUserRoles] = React.useState([]);
+
+ // Combobox states
+ const [userComboOpen, setUserComboOpen] = React.useState(false);
+ const [roleComboOpen, setRoleComboOpen] = React.useState(false);
+
+ // Load roles
+ const loadRoles = React.useCallback(async () => {
+ setLoading(true);
+ try {
+ const response = await getRoles();
+ setRoles(response.roles || []);
+ } catch (error) {
+ toast.error(t("rbac.failedToLoadRoles"));
+ console.error("Failed to load roles:", error);
+ setRoles([]);
+ } finally {
+ setLoading(false);
+ }
+ }, [t]);
+
+ // Load users
+ const loadUsers = React.useCallback(async () => {
+ try {
+ const response = await getUserList();
+ // Map UserInfo to User format
+ const mappedUsers = (response.users || []).map((user) => ({
+ id: user.id,
+ username: user.username,
+ is_admin: user.is_admin,
+ }));
+ setUsers(mappedUsers);
+ } catch (error) {
+ console.error("Failed to load users:", error);
+ setUsers([]);
+ }
+ }, []);
+
+ React.useEffect(() => {
+ loadRoles();
+ loadUsers();
+ }, [loadRoles, loadUsers]);
+
+ // Create role
+ const handleCreateRole = () => {
+ setEditingRole(null);
+ setRoleName("");
+ setRoleDisplayName("");
+ setRoleDescription("");
+ setRoleDialogOpen(true);
+ };
+
+ // Edit role
+ const handleEditRole = (role: Role) => {
+ setEditingRole(role);
+ setRoleName(role.name);
+ setRoleDisplayName(role.displayName);
+ setRoleDescription(role.description || "");
+ setRoleDialogOpen(true);
+ };
+
+ // Save role
+ const handleSaveRole = async () => {
+ if (!roleDisplayName.trim()) {
+ toast.error(t("rbac.roleDisplayNameRequired"));
+ return;
+ }
+
+ if (!editingRole && !roleName.trim()) {
+ toast.error(t("rbac.roleNameRequired"));
+ return;
+ }
+
+ try {
+ if (editingRole) {
+ // Update existing role
+ await updateRole(editingRole.id, {
+ displayName: roleDisplayName,
+ description: roleDescription || null,
+ });
+ toast.success(t("rbac.roleUpdatedSuccessfully"));
+ } else {
+ // Create new role
+ await createRole({
+ name: roleName,
+ displayName: roleDisplayName,
+ description: roleDescription || null,
+ });
+ toast.success(t("rbac.roleCreatedSuccessfully"));
+ }
+
+ setRoleDialogOpen(false);
+ loadRoles();
+ } catch (error) {
+ toast.error(t("rbac.failedToSaveRole"));
+ }
+ };
+
+ // Delete role
+ const handleDeleteRole = async (role: Role) => {
+ const confirmed = await confirmWithToast({
+ title: t("rbac.confirmDeleteRole"),
+ description: t("rbac.confirmDeleteRoleDescription", {
+ name: role.displayName,
+ }),
+ confirmText: t("common.delete"),
+ cancelText: t("common.cancel"),
+ });
+
+ if (!confirmed) return;
+
+ try {
+ await deleteRole(role.id);
+ toast.success(t("rbac.roleDeletedSuccessfully"));
+ loadRoles();
+ } catch (error) {
+ toast.error(t("rbac.failedToDeleteRole"));
+ }
+ };
+
+ // Open assign dialog
+ const handleOpenAssignDialog = async () => {
+ setSelectedUserId("");
+ setSelectedRoleId(null);
+ setUserRoles([]);
+ setAssignDialogOpen(true);
+ };
+
+ // Load user roles when user is selected
+ const handleUserSelect = async (userId: string) => {
+ setSelectedUserId(userId);
+ setUserRoles([]);
+
+ if (!userId) return;
+
+ try {
+ const response = await getUserRoles(userId);
+ setUserRoles(response.roles || []);
+ } catch (error) {
+ console.error("Failed to load user roles:", error);
+ setUserRoles([]);
+ }
+ };
+
+ // Assign role to user
+ const handleAssignRole = async () => {
+ if (!selectedUserId || !selectedRoleId) {
+ toast.error(t("rbac.selectUserAndRole"));
+ return;
+ }
+
+ try {
+ await assignRoleToUser(selectedUserId, selectedRoleId);
+ const selectedUser = users.find((u) => u.id === selectedUserId);
+ toast.success(
+ t("rbac.roleAssignedSuccessfully", {
+ username: selectedUser?.username || selectedUserId,
+ }),
+ );
+ setSelectedRoleId(null);
+ handleUserSelect(selectedUserId);
+ } catch (error) {
+ toast.error(t("rbac.failedToAssignRole"));
+ }
+ };
+
+ // Remove role from user
+ const handleRemoveUserRole = async (roleId: number) => {
+ if (!selectedUserId) return;
+
+ try {
+ await removeRoleFromUser(selectedUserId, roleId);
+ const selectedUser = users.find((u) => u.id === selectedUserId);
+ toast.success(
+ t("rbac.roleRemovedSuccessfully", {
+ username: selectedUser?.username || selectedUserId,
+ }),
+ );
+ handleUserSelect(selectedUserId);
+ } catch (error) {
+ toast.error(t("rbac.failedToRemoveRole"));
+ }
+ };
+
+ return (
+
+ {/* Roles Section */}
+
+
+
+
+ {t("rbac.roleManagement")}
+
+
+
+
+
+
+
+ {t("rbac.roleName")}
+ {t("rbac.displayName")}
+ {t("rbac.description")}
+ {t("rbac.type")}
+
+ {t("common.actions")}
+
+
+
+
+ {loading ? (
+
+
+ {t("common.loading")}
+
+
+ ) : roles.length === 0 ? (
+
+
+ {t("rbac.noRoles")}
+
+
+ ) : (
+ roles.map((role) => (
+
+ {role.name}
+ {t(role.displayName)}
+
+ {role.description || "-"}
+
+
+ {role.isSystem ? (
+ {t("rbac.systemRole")}
+ ) : (
+ {t("rbac.customRole")}
+ )}
+
+
+
+ {!role.isSystem && (
+ <>
+
+
+ >
+ )}
+
+
+
+ ))
+ )}
+
+
+
+
+ {/* User-Role Assignment Section */}
+
+
+
+
+ {t("rbac.userRoleAssignment")}
+
+
+
+
+
+ {/* Create/Edit Role Dialog */}
+
+
+ {/* Assign Role Dialog */}
+
+
+ );
+}
diff --git a/src/ui/desktop/apps/docker/DockerManager.tsx b/src/ui/desktop/apps/docker/DockerManager.tsx
new file mode 100644
index 00000000..fbb56266
--- /dev/null
+++ b/src/ui/desktop/apps/docker/DockerManager.tsx
@@ -0,0 +1,390 @@
+import React from "react";
+import { useSidebar } from "@/components/ui/sidebar.tsx";
+import { Separator } from "@/components/ui/separator.tsx";
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@/components/ui/tabs.tsx";
+import { useTranslation } from "react-i18next";
+import { toast } from "sonner";
+import type {
+ SSHHost,
+ DockerContainer,
+ DockerValidation,
+} from "@/types/index.js";
+import {
+ connectDockerSession,
+ disconnectDockerSession,
+ listDockerContainers,
+ validateDockerAvailability,
+ keepaliveDockerSession,
+} from "@/ui/main-axios.ts";
+import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
+import { AlertCircle } from "lucide-react";
+import { Alert, AlertDescription } from "@/components/ui/alert.tsx";
+import { ContainerList } from "./components/ContainerList.tsx";
+import { LogViewer } from "./components/LogViewer.tsx";
+import { ContainerStats } from "./components/ContainerStats.tsx";
+import { ConsoleTerminal } from "./components/ConsoleTerminal.tsx";
+import { ContainerDetail } from "./components/ContainerDetail.tsx";
+
+interface DockerManagerProps {
+ hostConfig?: SSHHost;
+ title?: string;
+ isVisible?: boolean;
+ isTopbarOpen?: boolean;
+ embedded?: boolean;
+}
+
+export function DockerManager({
+ hostConfig,
+ title,
+ isVisible = true,
+ isTopbarOpen = true,
+ embedded = false,
+}: DockerManagerProps): React.ReactElement {
+ const { t } = useTranslation();
+ const { state: sidebarState } = useSidebar();
+ const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
+ const [sessionId, setSessionId] = React.useState(null);
+ const [containers, setContainers] = React.useState([]);
+ const [selectedContainer, setSelectedContainer] = React.useState<
+ string | null
+ >(null);
+ const [isConnecting, setIsConnecting] = React.useState(false);
+ const [activeTab, setActiveTab] = React.useState("containers");
+ const [dockerValidation, setDockerValidation] =
+ React.useState(null);
+ const [isValidating, setIsValidating] = React.useState(false);
+ const [viewMode, setViewMode] = React.useState<"list" | "detail">("list");
+
+ React.useEffect(() => {
+ if (hostConfig?.id !== currentHostConfig?.id) {
+ setCurrentHostConfig(hostConfig);
+ setContainers([]);
+ setSelectedContainer(null);
+ setSessionId(null);
+ setDockerValidation(null);
+ setViewMode("list");
+ }
+ }, [hostConfig?.id]);
+
+ React.useEffect(() => {
+ const fetchLatestHostConfig = async () => {
+ if (hostConfig?.id) {
+ try {
+ const { getSSHHosts } = await import("@/ui/main-axios.ts");
+ const hosts = await getSSHHosts();
+ const updatedHost = hosts.find((h) => h.id === hostConfig.id);
+ if (updatedHost) {
+ setCurrentHostConfig(updatedHost);
+ }
+ } catch {
+ // Silently handle error
+ }
+ }
+ };
+
+ fetchLatestHostConfig();
+
+ const handleHostsChanged = async () => {
+ if (hostConfig?.id) {
+ try {
+ const { getSSHHosts } = await import("@/ui/main-axios.ts");
+ const hosts = await getSSHHosts();
+ const updatedHost = hosts.find((h) => h.id === hostConfig.id);
+ if (updatedHost) {
+ setCurrentHostConfig(updatedHost);
+ }
+ } catch {
+ // Silently handle error
+ }
+ }
+ };
+
+ window.addEventListener("ssh-hosts:changed", handleHostsChanged);
+ return () =>
+ window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
+ }, [hostConfig?.id]);
+
+ // SSH session lifecycle
+ React.useEffect(() => {
+ const initSession = async () => {
+ if (!currentHostConfig?.id || !currentHostConfig.enableDocker) {
+ return;
+ }
+
+ setIsConnecting(true);
+ const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+
+ try {
+ await connectDockerSession(sid, currentHostConfig.id);
+ setSessionId(sid);
+
+ // Validate Docker availability
+ setIsValidating(true);
+ const validation = await validateDockerAvailability(sid);
+ setDockerValidation(validation);
+ setIsValidating(false);
+
+ if (!validation.available) {
+ toast.error(
+ validation.error || "Docker is not available on this host",
+ );
+ }
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : "Failed to connect to host",
+ );
+ setIsConnecting(false);
+ setIsValidating(false);
+ } finally {
+ setIsConnecting(false);
+ }
+ };
+
+ initSession();
+
+ return () => {
+ if (sessionId) {
+ disconnectDockerSession(sessionId).catch(() => {
+ // Silently handle disconnect errors
+ });
+ }
+ };
+ }, [currentHostConfig?.id, currentHostConfig?.enableDocker]);
+
+ // Keepalive interval
+ React.useEffect(() => {
+ if (!sessionId || !isVisible) return;
+
+ const keepalive = setInterval(
+ () => {
+ keepaliveDockerSession(sessionId).catch(() => {
+ // Silently handle keepalive errors
+ });
+ },
+ 10 * 60 * 1000,
+ ); // Every 10 minutes
+
+ return () => clearInterval(keepalive);
+ }, [sessionId, isVisible]);
+
+ // Refresh containers function
+ const refreshContainers = React.useCallback(async () => {
+ if (!sessionId) return;
+ try {
+ const data = await listDockerContainers(sessionId, true);
+ setContainers(data);
+ } catch (error) {
+ // Silently handle polling errors
+ }
+ }, [sessionId]);
+
+ // Poll containers
+ React.useEffect(() => {
+ if (!sessionId || !isVisible || !dockerValidation?.available) return;
+
+ let cancelled = false;
+
+ const pollContainers = async () => {
+ try {
+ const data = await listDockerContainers(sessionId, true);
+ if (!cancelled) {
+ setContainers(data);
+ }
+ } catch (error) {
+ // Silently handle polling errors
+ }
+ };
+
+ pollContainers(); // Initial fetch
+ const interval = setInterval(pollContainers, 5000); // Poll every 5 seconds
+
+ return () => {
+ cancelled = true;
+ clearInterval(interval);
+ };
+ }, [sessionId, isVisible, dockerValidation?.available]);
+
+ const handleBack = React.useCallback(() => {
+ setViewMode("list");
+ setSelectedContainer(null);
+ }, []);
+
+ const topMarginPx = isTopbarOpen ? 74 : 16;
+ const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
+ const bottomMarginPx = 8;
+
+ const wrapperStyle: React.CSSProperties = embedded
+ ? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
+ : {
+ opacity: isVisible ? 1 : 0,
+ marginLeft: leftMarginPx,
+ marginRight: 17,
+ marginTop: topMarginPx,
+ marginBottom: bottomMarginPx,
+ height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
+ };
+
+ const containerClass = embedded
+ ? "h-full w-full text-white overflow-hidden bg-transparent"
+ : "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
+
+ // Check if Docker is enabled
+ if (!currentHostConfig?.enableDocker) {
+ return (
+
+
+
+
+
+
+ {currentHostConfig?.folder} / {title}
+
+
+
+
+
+
+
+
+
+
+ Docker is not enabled for this host. Enable it in Host Settings
+ to use Docker features.
+
+
+
+
+
+ );
+ }
+
+ // Loading state
+ if (isConnecting || isValidating) {
+ return (
+
+
+
+
+
+
+ {currentHostConfig?.folder} / {title}
+
+
+
+
+
+
+
+
+
+
+ {isValidating
+ ? "Validating Docker..."
+ : "Connecting to host..."}
+
+
+
+
+
+ );
+ }
+
+ // Docker not available
+ if (dockerValidation && !dockerValidation.available) {
+ return (
+
+
+
+
+
+
+ {currentHostConfig?.folder} / {title}
+
+
+
+
+
+
+
+
+
+
+ Docker Error
+ {dockerValidation.error}
+ {dockerValidation.code && (
+
+ Error code: {dockerValidation.code}
+
+ )}
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {currentHostConfig?.folder} / {title}
+
+ {dockerValidation?.version && (
+
+ Docker v{dockerValidation.version}
+
+ )}
+
+
+
+
+
+
+ {viewMode === "list" ? (
+
+ {sessionId ? (
+
{
+ setSelectedContainer(id);
+ setViewMode("detail");
+ }}
+ selectedContainerId={selectedContainer}
+ onRefresh={refreshContainers}
+ />
+ ) : (
+
+ )}
+
+ ) : sessionId && selectedContainer && currentHostConfig ? (
+
+ ) : (
+
+
+ Select a container to view details
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/ui/desktop/apps/docker/components/ConsoleTerminal.tsx b/src/ui/desktop/apps/docker/components/ConsoleTerminal.tsx
new file mode 100644
index 00000000..1f9ca3c0
--- /dev/null
+++ b/src/ui/desktop/apps/docker/components/ConsoleTerminal.tsx
@@ -0,0 +1,448 @@
+import React from "react";
+import { useXTerm } from "react-xtermjs";
+import { FitAddon } from "@xterm/addon-fit";
+import { ClipboardAddon } from "@xterm/addon-clipboard";
+import { WebLinksAddon } from "@xterm/addon-web-links";
+import { Button } from "@/components/ui/button.tsx";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select.tsx";
+import { Card, CardContent } from "@/components/ui/card.tsx";
+import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
+import { toast } from "sonner";
+import type { SSHHost } from "@/types/index.js";
+import { getCookie, isElectron } from "@/ui/main-axios.ts";
+import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
+
+interface ConsoleTerminalProps {
+ sessionId: string;
+ containerId: string;
+ containerName: string;
+ containerState: string;
+ hostConfig: SSHHost;
+}
+
+export function ConsoleTerminal({
+ sessionId,
+ containerId,
+ containerName,
+ containerState,
+ hostConfig,
+}: ConsoleTerminalProps): React.ReactElement {
+ const { instance: terminal, ref: xtermRef } = useXTerm();
+ const [isConnected, setIsConnected] = React.useState(false);
+ const [isConnecting, setIsConnecting] = React.useState(false);
+ const [selectedShell, setSelectedShell] = React.useState("bash");
+ const wsRef = React.useRef(null);
+ const fitAddonRef = React.useRef(null);
+ const pingIntervalRef = React.useRef(null);
+
+ const getWebSocketBaseUrl = React.useCallback(() => {
+ const isElectronApp = isElectron();
+
+ // Development mode check (similar to Terminal.tsx)
+ const isDev =
+ !isElectronApp &&
+ process.env.NODE_ENV === "development" &&
+ (window.location.port === "3000" ||
+ window.location.port === "5173" ||
+ window.location.port === "");
+
+ if (isDev) {
+ // Development: connect directly to port 30008
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ return `${protocol}//localhost:30008`;
+ }
+
+ if (isElectronApp) {
+ // Electron: construct URL from configured server
+ 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?:\/\//, "");
+ // Use nginx path routing, not direct port
+ return `${wsProtocol}${wsHost}/docker/console/`;
+ }
+
+ // Production web: use nginx proxy path (same as Terminal uses /ssh/websocket/)
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ return `${protocol}//${window.location.host}/docker/console/`;
+ }, []);
+
+ // Initialize terminal
+ React.useEffect(() => {
+ if (!terminal) return;
+
+ const fitAddon = new FitAddon();
+ const clipboardAddon = new ClipboardAddon();
+ const webLinksAddon = new WebLinksAddon();
+
+ fitAddonRef.current = fitAddon;
+
+ terminal.loadAddon(fitAddon);
+ terminal.loadAddon(clipboardAddon);
+ terminal.loadAddon(webLinksAddon);
+
+ terminal.options.cursorBlink = true;
+ terminal.options.fontSize = 14;
+ terminal.options.fontFamily = "monospace";
+ terminal.options.theme = {
+ background: "#18181b",
+ foreground: "#c9d1d9",
+ };
+
+ setTimeout(() => {
+ fitAddon.fit();
+ }, 100);
+
+ const resizeHandler = () => {
+ if (fitAddonRef.current) {
+ fitAddonRef.current.fit();
+
+ if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
+ const { rows, cols } = terminal;
+ wsRef.current.send(
+ JSON.stringify({
+ type: "resize",
+ data: { rows, cols },
+ }),
+ );
+ }
+ }
+ };
+
+ window.addEventListener("resize", resizeHandler);
+
+ return () => {
+ window.removeEventListener("resize", resizeHandler);
+
+ // Clean up WebSocket before disposing terminal
+ if (wsRef.current) {
+ try {
+ wsRef.current.send(JSON.stringify({ type: "disconnect" }));
+ } catch (error) {
+ // Ignore errors during cleanup
+ }
+ wsRef.current.close();
+ wsRef.current = null;
+ }
+
+ terminal.dispose();
+ };
+ }, [terminal]);
+
+ const disconnect = React.useCallback(() => {
+ if (wsRef.current) {
+ try {
+ wsRef.current.send(JSON.stringify({ type: "disconnect" }));
+ } catch (error) {
+ // WebSocket might already be closed
+ }
+ wsRef.current.close();
+ wsRef.current = null;
+ }
+ setIsConnected(false);
+ if (terminal) {
+ try {
+ terminal.clear();
+ terminal.write("Disconnected from container console.\r\n");
+ } catch (error) {
+ // Terminal might be disposed
+ }
+ }
+ }, [terminal]);
+
+ const connect = React.useCallback(() => {
+ if (!terminal || containerState !== "running") {
+ toast.error("Container must be running to connect to console");
+ return;
+ }
+
+ setIsConnecting(true);
+
+ try {
+ const token = isElectron()
+ ? localStorage.getItem("jwt")
+ : getCookie("jwt");
+ if (!token) {
+ toast.error("Authentication required");
+ setIsConnecting(false);
+ return;
+ }
+
+ // Ensure terminal is fitted before connecting
+ if (fitAddonRef.current) {
+ fitAddonRef.current.fit();
+ }
+
+ const wsUrl = `${getWebSocketBaseUrl()}?token=${encodeURIComponent(token)}`;
+ const ws = new WebSocket(wsUrl);
+
+ ws.onopen = () => {
+ // Double-check terminal dimensions
+ const cols = terminal.cols || 80;
+ const rows = terminal.rows || 24;
+
+ ws.send(
+ JSON.stringify({
+ type: "connect",
+ data: {
+ hostConfig,
+ containerId,
+ shell: selectedShell,
+ cols,
+ rows,
+ },
+ }),
+ );
+ };
+
+ ws.onmessage = (event) => {
+ try {
+ const msg = JSON.parse(event.data);
+
+ switch (msg.type) {
+ case "output":
+ terminal.write(msg.data);
+ break;
+
+ case "connected":
+ setIsConnected(true);
+ setIsConnecting(false);
+ toast.success(`Connected to ${containerName}`);
+
+ // Fit terminal and send resize to ensure correct dimensions
+ setTimeout(() => {
+ if (fitAddonRef.current) {
+ fitAddonRef.current.fit();
+ }
+
+ // Send resize message with correct dimensions
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(
+ JSON.stringify({
+ type: "resize",
+ data: { rows: terminal.rows, cols: terminal.cols },
+ }),
+ );
+ }
+ }, 100);
+ break;
+
+ case "disconnected":
+ setIsConnected(false);
+ setIsConnecting(false);
+ terminal.write(
+ `\r\n\x1b[1;33m${msg.message || "Disconnected"}\x1b[0m\r\n`,
+ );
+ if (wsRef.current) {
+ wsRef.current.close();
+ wsRef.current = null;
+ }
+ break;
+
+ case "error":
+ setIsConnecting(false);
+ toast.error(msg.message || "Console error");
+ terminal.write(`\r\n\x1b[1;31mError: ${msg.message}\x1b[0m\r\n`);
+ break;
+ }
+ } catch (error) {
+ console.error("Failed to parse WebSocket message:", error);
+ }
+ };
+
+ ws.onerror = (error) => {
+ console.error("WebSocket error:", error);
+ setIsConnecting(false);
+ setIsConnected(false);
+ toast.error("Failed to connect to console");
+ };
+
+ // Set up periodic ping to keep connection alive
+ if (pingIntervalRef.current) {
+ clearInterval(pingIntervalRef.current);
+ }
+ pingIntervalRef.current = setInterval(() => {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: "ping" }));
+ }
+ }, 30000); // Ping every 30 seconds
+
+ ws.onclose = () => {
+ if (pingIntervalRef.current) {
+ clearInterval(pingIntervalRef.current);
+ pingIntervalRef.current = null;
+ }
+ setIsConnected(false);
+ setIsConnecting(false);
+ if (wsRef.current === ws) {
+ wsRef.current = null;
+ }
+ };
+
+ wsRef.current = ws;
+
+ // Handle terminal input
+ terminal.onData((data) => {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(
+ JSON.stringify({
+ type: "input",
+ data,
+ }),
+ );
+ }
+ });
+ } catch (error) {
+ setIsConnecting(false);
+ toast.error(
+ `Failed to connect: ${error instanceof Error ? error.message : "Unknown error"}`,
+ );
+ }
+ }, [
+ terminal,
+ containerState,
+ getWebSocketBaseUrl,
+ hostConfig,
+ containerId,
+ selectedShell,
+ containerName,
+ ]);
+
+ // Cleanup WebSocket on unmount (terminal cleanup is handled in the terminal effect)
+ React.useEffect(() => {
+ return () => {
+ if (pingIntervalRef.current) {
+ clearInterval(pingIntervalRef.current);
+ pingIntervalRef.current = null;
+ }
+ if (wsRef.current) {
+ try {
+ wsRef.current.send(JSON.stringify({ type: "disconnect" }));
+ } catch (error) {
+ // Ignore errors during cleanup
+ }
+ wsRef.current.close();
+ wsRef.current = null;
+ }
+ setIsConnected(false);
+ };
+ }, []);
+
+ if (containerState !== "running") {
+ return (
+
+
+
+
Container is not running
+
+ Start the container to access the console
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Controls */}
+
+
+
+
+
+ Console
+
+
+
+ {!isConnected ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {/* Terminal */}
+
+
+ {/* Terminal container - always rendered */}
+
+
+ {/* Not connected message */}
+ {!isConnected && !isConnecting && (
+
+
+
+
Not connected
+
+ Click Connect to start an interactive shell
+
+
+
+ )}
+
+ {/* Connecting message */}
+ {isConnecting && (
+
+
+
+
+ Connecting to {containerName}...
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/ui/desktop/apps/docker/components/ContainerCard.tsx b/src/ui/desktop/apps/docker/components/ContainerCard.tsx
new file mode 100644
index 00000000..48a6a8c3
--- /dev/null
+++ b/src/ui/desktop/apps/docker/components/ContainerCard.tsx
@@ -0,0 +1,446 @@
+import React from "react";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card.tsx";
+import { Button } from "@/components/ui/button.tsx";
+import { Badge } from "@/components/ui/badge.tsx";
+import {
+ Play,
+ Square,
+ RotateCw,
+ Pause,
+ Trash2,
+ PlayCircle,
+} from "lucide-react";
+import { toast } from "sonner";
+import type { DockerContainer } from "@/types/index.js";
+import {
+ startDockerContainer,
+ stopDockerContainer,
+ restartDockerContainer,
+ pauseDockerContainer,
+ unpauseDockerContainer,
+ removeDockerContainer,
+} from "@/ui/main-axios.ts";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip.tsx";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog.tsx";
+
+interface ContainerCardProps {
+ container: DockerContainer;
+ sessionId: string;
+ onSelect?: () => void;
+ isSelected?: boolean;
+ onRefresh?: () => void;
+}
+
+export function ContainerCard({
+ container,
+ sessionId,
+ onSelect,
+ isSelected = false,
+ onRefresh,
+}: ContainerCardProps): React.ReactElement {
+ const [isStarting, setIsStarting] = React.useState(false);
+ const [isStopping, setIsStopping] = React.useState(false);
+ const [isRestarting, setIsRestarting] = React.useState(false);
+ const [isPausing, setIsPausing] = React.useState(false);
+ const [isRemoving, setIsRemoving] = React.useState(false);
+ const [showRemoveDialog, setShowRemoveDialog] = React.useState(false);
+
+ const statusColors = {
+ running: {
+ bg: "bg-green-500/10",
+ border: "border-green-500/20",
+ text: "text-green-400",
+ badge: "bg-green-500/20 text-green-300 border-green-500/30",
+ },
+ exited: {
+ bg: "bg-red-500/10",
+ border: "border-red-500/20",
+ text: "text-red-400",
+ badge: "bg-red-500/20 text-red-300 border-red-500/30",
+ },
+ paused: {
+ bg: "bg-yellow-500/10",
+ border: "border-yellow-500/20",
+ text: "text-yellow-400",
+ badge: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
+ },
+ created: {
+ bg: "bg-blue-500/10",
+ border: "border-blue-500/20",
+ text: "text-blue-400",
+ badge: "bg-blue-500/20 text-blue-300 border-blue-500/30",
+ },
+ restarting: {
+ bg: "bg-orange-500/10",
+ border: "border-orange-500/20",
+ text: "text-orange-400",
+ badge: "bg-orange-500/20 text-orange-300 border-orange-500/30",
+ },
+ removing: {
+ bg: "bg-purple-500/10",
+ border: "border-purple-500/20",
+ text: "text-purple-400",
+ badge: "bg-purple-500/20 text-purple-300 border-purple-500/30",
+ },
+ dead: {
+ bg: "bg-gray-500/10",
+ border: "border-gray-500/20",
+ text: "text-gray-400",
+ badge: "bg-gray-500/20 text-gray-300 border-gray-500/30",
+ },
+ };
+
+ const colors = statusColors[container.state] || statusColors.created;
+
+ const handleStart = async (e: React.MouseEvent) => {
+ e.stopPropagation();
+ setIsStarting(true);
+ try {
+ await startDockerContainer(sessionId, container.id);
+ toast.success(`Container ${container.name} started`);
+ onRefresh?.();
+ } catch (error) {
+ toast.error(
+ `Failed to start container: ${error instanceof Error ? error.message : "Unknown error"}`,
+ );
+ } finally {
+ setIsStarting(false);
+ }
+ };
+
+ const handleStop = async (e: React.MouseEvent) => {
+ e.stopPropagation();
+ setIsStopping(true);
+ try {
+ await stopDockerContainer(sessionId, container.id);
+ toast.success(`Container ${container.name} stopped`);
+ onRefresh?.();
+ } catch (error) {
+ toast.error(
+ `Failed to stop container: ${error instanceof Error ? error.message : "Unknown error"}`,
+ );
+ } finally {
+ setIsStopping(false);
+ }
+ };
+
+ const handleRestart = async (e: React.MouseEvent) => {
+ e.stopPropagation();
+ setIsRestarting(true);
+ try {
+ await restartDockerContainer(sessionId, container.id);
+ toast.success(`Container ${container.name} restarted`);
+ onRefresh?.();
+ } catch (error) {
+ toast.error(
+ `Failed to restart container: ${error instanceof Error ? error.message : "Unknown error"}`,
+ );
+ } finally {
+ setIsRestarting(false);
+ }
+ };
+
+ const handlePause = async (e: React.MouseEvent) => {
+ e.stopPropagation();
+ setIsPausing(true);
+ try {
+ if (container.state === "paused") {
+ await unpauseDockerContainer(sessionId, container.id);
+ toast.success(`Container ${container.name} unpaused`);
+ } else {
+ await pauseDockerContainer(sessionId, container.id);
+ toast.success(`Container ${container.name} paused`);
+ }
+ onRefresh?.();
+ } catch (error) {
+ toast.error(
+ `Failed to ${container.state === "paused" ? "unpause" : "pause"} container: ${error instanceof Error ? error.message : "Unknown error"}`,
+ );
+ } finally {
+ setIsPausing(false);
+ }
+ };
+
+ const handleRemove = async () => {
+ setIsRemoving(true);
+ try {
+ const force = container.state === "running";
+ await removeDockerContainer(sessionId, container.id, force);
+ toast.success(`Container ${container.name} removed`);
+ setShowRemoveDialog(false);
+ onRefresh?.();
+ } catch (error) {
+ toast.error(
+ `Failed to remove container: ${error instanceof Error ? error.message : "Unknown error"}`,
+ );
+ } finally {
+ setIsRemoving(false);
+ }
+ };
+
+ const isLoading =
+ isStarting || isStopping || isRestarting || isPausing || isRemoving;
+
+ // Format the created date to be more readable
+ const formatCreatedDate = (dateStr: string): string => {
+ try {
+ // Remove the timezone suffix like "+0000 UTC"
+ const cleanDate = dateStr.replace(/\s*\+\d{4}\s*UTC\s*$/, "").trim();
+ return cleanDate;
+ } catch {
+ return dateStr;
+ }
+ };
+
+ // Parse ports into array of port mappings
+ const parsePorts = (portsStr: string | undefined): string[] => {
+ if (!portsStr || portsStr.trim() === "") return [];
+
+ // Split by comma and clean up
+ return portsStr
+ .split(",")
+ .map((p) => p.trim())
+ .filter((p) => p.length > 0);
+ };
+
+ const portsList = parsePorts(container.ports);
+
+ return (
+ <>
+
+
+
+
+ {container.name.startsWith("/")
+ ? container.name.slice(1)
+ : container.name}
+
+
+ {container.state}
+
+
+
+
+
+
+ Image:
+
+ {container.image}
+
+
+
+ ID:
+
+ {container.id.substring(0, 12)}
+
+
+
+
+ Ports:
+
+
+ {portsList.length > 0 ? (
+ portsList.map((port, idx) => (
+
+ {port}
+
+ ))
+ ) : (
+
+ None
+
+ )}
+
+
+
+
+ Created:
+
+
+ {formatCreatedDate(container.created)}
+
+
+
+
+
+
+ {container.state !== "running" && (
+
+
+
+
+ Start
+
+ )}
+
+ {container.state === "running" && (
+
+
+
+
+ Stop
+
+ )}
+
+ {(container.state === "running" ||
+ container.state === "paused") && (
+
+
+
+
+
+ {container.state === "paused" ? "Unpause" : "Pause"}
+
+
+ )}
+
+
+
+
+
+ Restart
+
+
+
+
+
+
+ Remove
+
+
+
+
+
+
+
+
+
+ Remove Container
+
+ Are you sure you want to remove container{" "}
+
+ {container.name.startsWith("/")
+ ? container.name.slice(1)
+ : container.name}
+
+ ?
+ {container.state === "running" && (
+
+ Warning: This container is currently running and will be
+ force-removed.
+
+ )}
+
+
+
+ Cancel
+ {
+ e.preventDefault();
+ handleRemove();
+ }}
+ disabled={isRemoving}
+ className="bg-red-600 hover:bg-red-700"
+ >
+ {isRemoving ? "Removing..." : "Remove"}
+
+
+
+
+ >
+ );
+}
diff --git a/src/ui/desktop/apps/docker/components/ContainerDetail.tsx b/src/ui/desktop/apps/docker/components/ContainerDetail.tsx
new file mode 100644
index 00000000..ded1c3d8
--- /dev/null
+++ b/src/ui/desktop/apps/docker/components/ContainerDetail.tsx
@@ -0,0 +1,118 @@
+import React from "react";
+import { Button } from "@/components/ui/button.tsx";
+import { Separator } from "@/components/ui/separator.tsx";
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@/components/ui/tabs.tsx";
+import { ArrowLeft } from "lucide-react";
+import type { DockerContainer, SSHHost } from "@/types/index.js";
+import { LogViewer } from "./LogViewer.tsx";
+import { ContainerStats } from "./ContainerStats.tsx";
+import { ConsoleTerminal } from "./ConsoleTerminal.tsx";
+
+interface ContainerDetailProps {
+ sessionId: string;
+ containerId: string;
+ containers: DockerContainer[];
+ hostConfig: SSHHost;
+ onBack: () => void;
+}
+
+export function ContainerDetail({
+ sessionId,
+ containerId,
+ containers,
+ hostConfig,
+ onBack,
+}: ContainerDetailProps): React.ReactElement {
+ const [activeTab, setActiveTab] = React.useState("logs");
+
+ const container = containers.find((c) => c.id === containerId);
+
+ if (!container) {
+ return (
+
+
+
Container not found
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header with back button */}
+
+
+
+
{container.name}
+
{container.image}
+
+
+
+
+ {/* Tabs for Logs, Stats, Console */}
+
+
+
+
+ Logs
+ Stats
+ Console
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/ui/desktop/apps/docker/components/ContainerList.tsx b/src/ui/desktop/apps/docker/components/ContainerList.tsx
new file mode 100644
index 00000000..8c383383
--- /dev/null
+++ b/src/ui/desktop/apps/docker/components/ContainerList.tsx
@@ -0,0 +1,124 @@
+import React from "react";
+import { Input } from "@/components/ui/input.tsx";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select.tsx";
+import { Search, Filter } from "lucide-react";
+import type { DockerContainer } from "@/types/index.js";
+import { ContainerCard } from "./ContainerCard.tsx";
+
+interface ContainerListProps {
+ containers: DockerContainer[];
+ sessionId: string;
+ onSelectContainer: (containerId: string) => void;
+ selectedContainerId?: string | null;
+ onRefresh?: () => void;
+}
+
+export function ContainerList({
+ containers,
+ sessionId,
+ onSelectContainer,
+ selectedContainerId = null,
+ onRefresh,
+}: ContainerListProps): React.ReactElement {
+ const [searchQuery, setSearchQuery] = React.useState("");
+ const [statusFilter, setStatusFilter] = React.useState("all");
+
+ const filteredContainers = React.useMemo(() => {
+ return containers.filter((container) => {
+ const matchesSearch =
+ container.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ container.image.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ container.id.toLowerCase().includes(searchQuery.toLowerCase());
+
+ const matchesStatus =
+ statusFilter === "all" || container.state === statusFilter;
+
+ return matchesSearch && matchesStatus;
+ });
+ }, [containers, searchQuery, statusFilter]);
+
+ const statusCounts = React.useMemo(() => {
+ const counts: Record = {};
+ containers.forEach((c) => {
+ counts[c.state] = (counts[c.state] || 0) + 1;
+ });
+ return counts;
+ }, [containers]);
+
+ if (containers.length === 0) {
+ return (
+
+
+
No containers found
+
+ Start by creating containers on your server
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Search and Filter Bar */}
+
+
+
+ setSearchQuery(e.target.value)}
+ className="pl-10"
+ />
+
+
+
+
+
+
+
+ {/* Container Grid */}
+ {filteredContainers.length === 0 ? (
+
+
+
No containers match your filters
+
+ Try adjusting your search or filter
+
+
+
+ ) : (
+
+ {filteredContainers.map((container) => (
+ onSelectContainer(container.id)}
+ isSelected={selectedContainerId === container.id}
+ onRefresh={onRefresh}
+ />
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/ui/desktop/apps/docker/components/ContainerStats.tsx b/src/ui/desktop/apps/docker/components/ContainerStats.tsx
new file mode 100644
index 00000000..a78a83c1
--- /dev/null
+++ b/src/ui/desktop/apps/docker/components/ContainerStats.tsx
@@ -0,0 +1,242 @@
+import React from "react";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card.tsx";
+import { Progress } from "@/components/ui/progress.tsx";
+import { Cpu, MemoryStick, Network, HardDrive, Activity } from "lucide-react";
+import type { DockerStats } from "@/types/index.js";
+import { getContainerStats } from "@/ui/main-axios.ts";
+import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
+
+interface ContainerStatsProps {
+ sessionId: string;
+ containerId: string;
+ containerName: string;
+ containerState: string;
+}
+
+export function ContainerStats({
+ sessionId,
+ containerId,
+ containerName,
+ containerState,
+}: ContainerStatsProps): React.ReactElement {
+ const [stats, setStats] = React.useState(null);
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [error, setError] = React.useState(null);
+
+ const fetchStats = React.useCallback(async () => {
+ if (containerState !== "running") {
+ setError("Container must be running to view stats");
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+ try {
+ const data = await getContainerStats(sessionId, containerId);
+ setStats(data);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to fetch stats");
+ } finally {
+ setIsLoading(false);
+ }
+ }, [sessionId, containerId, containerState]);
+
+ React.useEffect(() => {
+ fetchStats();
+
+ // Poll stats every 2 seconds
+ const interval = setInterval(fetchStats, 2000);
+
+ return () => clearInterval(interval);
+ }, [fetchStats]);
+
+ if (containerState !== "running") {
+ return (
+
+
+
+
Container is not running
+
+ Start the container to view statistics
+
+
+
+ );
+ }
+
+ if (isLoading && !stats) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
Error loading stats
+
{error}
+
+
+ );
+ }
+
+ if (!stats) {
+ return (
+
+ );
+ }
+
+ const cpuPercent = parseFloat(stats.cpu) || 0;
+ const memPercent = parseFloat(stats.memoryPercent) || 0;
+
+ return (
+
+ {/* CPU Usage */}
+
+
+
+
+ CPU Usage
+
+
+
+
+
+ Current
+
+ {stats.cpu}
+
+
+
+
+
+
+
+ {/* Memory Usage */}
+
+
+
+
+ Memory Usage
+
+
+
+
+
+ Used / Limit
+
+ {stats.memoryUsed} / {stats.memoryLimit}
+
+
+
+ Percentage
+
+ {stats.memoryPercent}
+
+
+
+
+
+
+
+ {/* Network I/O */}
+
+
+
+
+ Network I/O
+
+
+
+
+
+ Input
+ {stats.netInput}
+
+
+ Output
+
+ {stats.netOutput}
+
+
+
+
+
+
+ {/* Block I/O */}
+
+
+
+
+ Block I/O
+
+
+
+
+
+ Read
+
+ {stats.blockRead}
+
+
+
+ Write
+
+ {stats.blockWrite}
+
+
+ {stats.pids && (
+
+ PIDs
+ {stats.pids}
+
+ )}
+
+
+
+
+ {/* Container Info */}
+
+
+
+
+ Container Information
+
+
+
+
+
+ Name:
+ {containerName}
+
+
+ ID:
+
+ {containerId.substring(0, 12)}
+
+
+
+ State:
+
+ {containerState}
+
+
+
+
+
+
+ );
+}
diff --git a/src/ui/desktop/apps/docker/components/LogViewer.tsx b/src/ui/desktop/apps/docker/components/LogViewer.tsx
new file mode 100644
index 00000000..d8ecc4bd
--- /dev/null
+++ b/src/ui/desktop/apps/docker/components/LogViewer.tsx
@@ -0,0 +1,246 @@
+import React from "react";
+import { Button } from "@/components/ui/button.tsx";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select.tsx";
+import { Card, CardContent } from "@/components/ui/card.tsx";
+import { Switch } from "@/components/ui/switch.tsx";
+import { Label } from "@/components/ui/label.tsx";
+import { Download, RefreshCw, Filter } from "lucide-react";
+import { toast } from "sonner";
+import type { DockerLogOptions } from "@/types/index.js";
+import { getContainerLogs, downloadContainerLogs } from "@/ui/main-axios.ts";
+import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
+
+interface LogViewerProps {
+ sessionId: string;
+ containerId: string;
+ containerName: string;
+}
+
+export function LogViewer({
+ sessionId,
+ containerId,
+ containerName,
+}: LogViewerProps): React.ReactElement {
+ const [logs, setLogs] = React.useState("");
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [isDownloading, setIsDownloading] = React.useState(false);
+ const [tailLines, setTailLines] = React.useState("100");
+ const [showTimestamps, setShowTimestamps] = React.useState(false);
+ const [autoRefresh, setAutoRefresh] = React.useState(false);
+ const [searchFilter, setSearchFilter] = React.useState("");
+ const logsEndRef = React.useRef(null);
+
+ const fetchLogs = React.useCallback(async () => {
+ setIsLoading(true);
+ try {
+ const options: DockerLogOptions = {
+ tail: tailLines === "all" ? undefined : parseInt(tailLines, 10),
+ timestamps: showTimestamps,
+ };
+
+ const data = await getContainerLogs(sessionId, containerId, options);
+ setLogs(data.logs);
+ } catch (error) {
+ toast.error(
+ `Failed to fetch logs: ${error instanceof Error ? error.message : "Unknown error"}`,
+ );
+ } finally {
+ setIsLoading(false);
+ }
+ }, [sessionId, containerId, tailLines, showTimestamps]);
+
+ React.useEffect(() => {
+ fetchLogs();
+ }, [fetchLogs]);
+
+ // Auto-refresh
+ React.useEffect(() => {
+ if (!autoRefresh) return;
+
+ const interval = setInterval(() => {
+ fetchLogs();
+ }, 3000); // Refresh every 3 seconds
+
+ return () => clearInterval(interval);
+ }, [autoRefresh, fetchLogs]);
+
+ // Auto-scroll to bottom when new logs arrive
+ React.useEffect(() => {
+ if (autoRefresh && logsEndRef.current) {
+ logsEndRef.current.scrollIntoView({ behavior: "smooth" });
+ }
+ }, [logs, autoRefresh]);
+
+ const handleDownload = async () => {
+ setIsDownloading(true);
+ try {
+ const options: DockerLogOptions = {
+ timestamps: showTimestamps,
+ };
+
+ const blob = await downloadContainerLogs(sessionId, containerId, options);
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `${containerName.replace(/[^a-z0-9]/gi, "_")}_logs.txt`;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ toast.success("Logs downloaded successfully");
+ } catch (error) {
+ toast.error(
+ `Failed to download logs: ${error instanceof Error ? error.message : "Unknown error"}`,
+ );
+ } finally {
+ setIsDownloading(false);
+ }
+ };
+
+ const filteredLogs = React.useMemo(() => {
+ if (!searchFilter.trim()) return logs;
+
+ return logs
+ .split("\n")
+ .filter((line) => line.toLowerCase().includes(searchFilter.toLowerCase()))
+ .join("\n");
+ }, [logs, searchFilter]);
+
+ return (
+
+ {/* Controls */}
+
+
+
+ {/* Tail Lines */}
+
+
+
+
+
+ {/* Timestamps */}
+
+
+
+
+
+ {showTimestamps ? "Enabled" : "Disabled"}
+
+
+
+
+ {/* Auto Refresh */}
+
+
+
+
+
+ {autoRefresh ? "On" : "Off"}
+
+
+
+
+ {/* Actions */}
+
+
+
+
+
+
+
+
+
+ {/* Search Filter */}
+
+
+
+ setSearchFilter(e.target.value)}
+ className="w-full pl-10 pr-4 py-2 bg-dark-bg border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary"
+ />
+
+
+
+
+
+ {/* Logs Display */}
+
+
+ {isLoading && !logs ? (
+
+
+
+ ) : (
+
+
+ {filteredLogs || (
+ No logs available
+ )}
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/ui/desktop/apps/host-manager/HostManager.tsx b/src/ui/desktop/apps/host-manager/HostManager.tsx
index 284ad37f..dc37723b 100644
--- a/src/ui/desktop/apps/host-manager/HostManager.tsx
+++ b/src/ui/desktop/apps/host-manager/HostManager.tsx
@@ -42,6 +42,15 @@ export function HostManager({
}
}, [initialTab]);
+ // Update editingHost when hostConfig changes
+ useEffect(() => {
+ if (hostConfig) {
+ setEditingHost(hostConfig);
+ setActiveTab("add_host");
+ lastProcessedHostIdRef.current = hostConfig.id;
+ }
+ }, [hostConfig?.id]);
+
const handleEditHost = (host: SSHHost) => {
setEditingHost(host);
setActiveTab("add_host");
diff --git a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx
index 90551f62..9b27f47a 100644
--- a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx
+++ b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx
@@ -37,6 +37,7 @@ import {
} from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
import { CredentialSelector } from "@/ui/desktop/apps/credentials/CredentialSelector.tsx";
+import { HostSharingTab } from "./HostSharingTab.tsx";
import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorView } from "@codemirror/view";
@@ -514,6 +515,7 @@ export function HostManagerEditor({
startupSnippetId: z.number().nullable(),
autoMosh: z.boolean(),
moshCommand: z.string(),
+ sudoPasswordAutoFill: z.boolean(),
})
.optional(),
forceKeyboardInteractive: z.boolean().optional(),
@@ -548,6 +550,7 @@ export function HostManagerEditor({
}),
)
.optional(),
+ enableDocker: z.boolean().default(false),
})
.superRefine((data, ctx) => {
if (data.authType === "none") {
@@ -610,7 +613,7 @@ export function HostManagerEditor({
type FormData = z.infer;
const form = useForm({
- resolver: zodResolver(formSchema),
+ resolver: zodResolver(formSchema) as any,
defaultValues: {
name: "",
ip: "",
@@ -642,6 +645,7 @@ export function HostManagerEditor({
socks5Username: "",
socks5Password: "",
socks5ProxyChain: [],
+ enableDocker: false,
},
});
@@ -745,6 +749,7 @@ export function HostManagerEditor({
socks5ProxyChain: Array.isArray(cleanedHost.socks5ProxyChain)
? cleanedHost.socks5ProxyChain
: [],
+ enableDocker: Boolean(cleanedHost.enableDocker),
};
// Determine proxy mode based on existing data
@@ -805,6 +810,7 @@ export function HostManagerEditor({
statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false,
+ enableDocker: false,
};
form.reset(defaultFormData as FormData);
@@ -856,14 +862,7 @@ export function HostManagerEditor({
const submitData: Partial = {
...data,
};
-
- console.log("Submitting host data:", {
- useSocks5: submitData.useSocks5,
- socks5Host: submitData.socks5Host,
- socks5ProxyChain: submitData.socks5ProxyChain,
- proxyMode,
- });
-
+
if (proxyMode === "single") {
submitData.socks5ProxyChain = [];
} else if (proxyMode === "chain") {
@@ -974,6 +973,8 @@ export function HostManagerEditor({
setActiveTab("general");
} else if (errors.enableTerminal || errors.terminalConfig) {
setActiveTab("terminal");
+ } else if (errors.enableDocker) {
+ setActiveTab("docker");
} else if (errors.enableTunnel || errors.tunnelConnections) {
setActiveTab("tunnel");
} else if (errors.enableFileManager || errors.defaultPath) {
@@ -1166,6 +1167,7 @@ export function HostManagerEditor({
{t("hosts.terminal")}
+ Docker
{t("hosts.tunnel")}
{t("hosts.fileManager")}
@@ -1173,6 +1175,11 @@ export function HostManagerEditor({
{t("hosts.statistics")}
+ {!editingHost?.isShared && (
+
+ {t("rbac.sharing")}
+
+ )}
@@ -2680,6 +2687,29 @@ export function HostManagerEditor({
/>
)}
+ (
+
+
+
+ {t("hosts.sudoPasswordAutoFill")}
+
+
+ {t("hosts.sudoPasswordAutoFillDesc")}
+
+
+
+
+
+
+ )}
+ />
+
+
+
+
+
diff --git a/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx b/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx
index 4c8cf5fb..014f93e5 100644
--- a/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx
+++ b/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx
@@ -61,6 +61,8 @@ import {
HardDrive,
Globe,
FolderOpen,
+ Share2,
+ Users,
} from "lucide-react";
import type {
SSHHost,
@@ -1230,6 +1232,14 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
{host.name ||
`${host.username}@${host.ip}`}
+ {(host as any).isShared && (
+
+ {t("rbac.shared")}
+
+ )}
{host.ip}:{host.port}
diff --git a/src/ui/desktop/apps/host-manager/HostSharingTab.tsx b/src/ui/desktop/apps/host-manager/HostSharingTab.tsx
new file mode 100644
index 00000000..834e3f32
--- /dev/null
+++ b/src/ui/desktop/apps/host-manager/HostSharingTab.tsx
@@ -0,0 +1,585 @@
+import React from "react";
+import { Button } from "@/components/ui/button.tsx";
+import { Label } from "@/components/ui/label.tsx";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select.tsx";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table.tsx";
+import { Input } from "@/components/ui/input.tsx";
+import { Badge } from "@/components/ui/badge.tsx";
+import {
+ AlertCircle,
+ Plus,
+ Trash2,
+ Users,
+ Shield,
+ Clock,
+ UserCircle,
+ Check,
+ ChevronsUpDown,
+} from "lucide-react";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@/components/ui/tabs.tsx";
+import { toast } from "sonner";
+import { useTranslation } from "react-i18next";
+import { useConfirmation } from "@/hooks/use-confirmation.ts";
+import {
+ getRoles,
+ getUserList,
+ getUserInfo,
+ shareHost,
+ getHostAccess,
+ revokeHostAccess,
+ getSSHHostById,
+ type Role,
+ type AccessRecord,
+ type SSHHost,
+} from "@/ui/main-axios.ts";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+} from "@/components/ui/command.tsx";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover.tsx";
+import { cn } from "@/lib/utils";
+
+interface HostSharingTabProps {
+ hostId: number | undefined;
+ isNewHost: boolean;
+}
+
+interface User {
+ id: string;
+ username: string;
+ is_admin: boolean;
+}
+
+const PERMISSION_LEVELS = [
+ { value: "view", labelKey: "rbac.view" },
+ { value: "manage", labelKey: "rbac.manage" },
+];
+
+export function HostSharingTab({
+ hostId,
+ isNewHost,
+}: HostSharingTabProps): React.ReactElement {
+ const { t } = useTranslation();
+ const { confirmWithToast } = useConfirmation();
+
+ const [shareType, setShareType] = React.useState<"user" | "role">("user");
+ const [selectedUserId, setSelectedUserId] = React.useState("");
+ const [selectedRoleId, setSelectedRoleId] = React.useState(
+ null,
+ );
+ const [permissionLevel, setPermissionLevel] = React.useState("view");
+ const [expiresInHours, setExpiresInHours] = React.useState("");
+
+ const [roles, setRoles] = React.useState([]);
+ const [users, setUsers] = React.useState([]);
+ const [accessList, setAccessList] = React.useState([]);
+ const [loading, setLoading] = React.useState(false);
+ const [currentUserId, setCurrentUserId] = React.useState("");
+ const [hostData, setHostData] = React.useState(null);
+
+ const [userComboOpen, setUserComboOpen] = React.useState(false);
+ const [roleComboOpen, setRoleComboOpen] = React.useState(false);
+
+ // Load roles
+ const loadRoles = React.useCallback(async () => {
+ try {
+ const response = await getRoles();
+ setRoles(response.roles || []);
+ } catch (error) {
+ console.error("Failed to load roles:", error);
+ setRoles([]);
+ }
+ }, []);
+
+ // Load users
+ const loadUsers = React.useCallback(async () => {
+ try {
+ const response = await getUserList();
+ // Map UserInfo to User format
+ const mappedUsers = (response.users || []).map((user) => ({
+ id: user.id,
+ username: user.username,
+ is_admin: user.is_admin,
+ }));
+ setUsers(mappedUsers);
+ } catch (error) {
+ console.error("Failed to load users:", error);
+ setUsers([]);
+ }
+ }, []);
+
+ // Load access list
+ const loadAccessList = React.useCallback(async () => {
+ if (!hostId) return;
+
+ setLoading(true);
+ try {
+ const response = await getHostAccess(hostId);
+ setAccessList(response.accessList || []);
+ } catch (error) {
+ console.error("Failed to load access list:", error);
+ setAccessList([]);
+ } finally {
+ setLoading(false);
+ }
+ }, [hostId]);
+
+ // Load host data
+ const loadHostData = React.useCallback(async () => {
+ if (!hostId) return;
+
+ try {
+ const host = await getSSHHostById(hostId);
+ setHostData(host);
+ } catch (error) {
+ console.error("Failed to load host data:", error);
+ setHostData(null);
+ }
+ }, [hostId]);
+
+ React.useEffect(() => {
+ loadRoles();
+ loadUsers();
+ if (!isNewHost) {
+ loadAccessList();
+ loadHostData();
+ }
+ }, [loadRoles, loadUsers, loadAccessList, loadHostData, isNewHost]);
+
+ // Load current user ID
+ React.useEffect(() => {
+ const fetchCurrentUser = async () => {
+ try {
+ const userInfo = await getUserInfo();
+ setCurrentUserId(userInfo.userId);
+ } catch (error) {
+ console.error("Failed to load current user:", error);
+ }
+ };
+ fetchCurrentUser();
+ }, []);
+
+ // Share host
+ const handleShare = async () => {
+ if (!hostId) {
+ toast.error(t("rbac.saveHostFirst"));
+ return;
+ }
+
+ if (shareType === "user" && !selectedUserId) {
+ toast.error(t("rbac.selectUser"));
+ return;
+ }
+
+ if (shareType === "role" && !selectedRoleId) {
+ toast.error(t("rbac.selectRole"));
+ return;
+ }
+
+ // Prevent sharing with self
+ if (shareType === "user" && selectedUserId === currentUserId) {
+ toast.error(t("rbac.cannotShareWithSelf"));
+ return;
+ }
+
+ try {
+ await shareHost(hostId, {
+ targetType: shareType,
+ targetUserId: shareType === "user" ? selectedUserId : undefined,
+ targetRoleId: shareType === "role" ? selectedRoleId : undefined,
+ permissionLevel,
+ durationHours: expiresInHours
+ ? parseInt(expiresInHours, 10)
+ : undefined,
+ });
+
+ toast.success(t("rbac.sharedSuccessfully"));
+ setSelectedUserId("");
+ setSelectedRoleId(null);
+ setExpiresInHours("");
+ loadAccessList();
+ } catch (error) {
+ toast.error(t("rbac.failedToShare"));
+ }
+ };
+
+ // Revoke access
+ const handleRevoke = async (accessId: number) => {
+ if (!hostId) return;
+
+ const confirmed = await confirmWithToast({
+ title: t("rbac.confirmRevokeAccess"),
+ description: t("rbac.confirmRevokeAccessDescription"),
+ confirmText: t("common.revoke"),
+ cancelText: t("common.cancel"),
+ });
+
+ if (!confirmed) return;
+
+ try {
+ await revokeHostAccess(hostId, accessId);
+ toast.success(t("rbac.accessRevokedSuccessfully"));
+ loadAccessList();
+ } catch (error) {
+ toast.error(t("rbac.failedToRevokeAccess"));
+ }
+ };
+
+ // Format date
+ const formatDate = (dateString: string | null) => {
+ if (!dateString) return "-";
+ return new Date(dateString).toLocaleString();
+ };
+
+ // Check if expired
+ const isExpired = (expiresAt: string | null) => {
+ if (!expiresAt) return false;
+ return new Date(expiresAt) < new Date();
+ };
+
+ // Filter out current user from the users list
+ const availableUsers = React.useMemo(() => {
+ return users.filter((user) => user.id !== currentUserId);
+ }, [users, currentUserId]);
+
+ const selectedUser = availableUsers.find((u) => u.id === selectedUserId);
+ const selectedRole = roles.find((r) => r.id === selectedRoleId);
+
+ if (isNewHost) {
+ return (
+
+
+ {t("rbac.saveHostFirst")}
+
+ {t("rbac.saveHostFirstDescription")}
+
+
+ );
+ }
+
+ return (
+
+ {/* Credential Authentication Warning */}
+ {hostData?.authType === "Credential" && (
+
+
+ {t("rbac.credentialSharingWarning")}
+
+ {t("rbac.credentialSharingWarningDescription")}
+
+
+ )}
+
+ {/* Share Form */}
+
+
+
+ {t("rbac.shareHost")}
+
+
+ {/* Share Type Selection */}
+
setShareType(v as "user" | "role")}
+ >
+
+
+
+ {t("rbac.shareWithUser")}
+
+
+
+ {t("rbac.shareWithRole")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("rbac.noUserFound")}
+
+ {availableUsers.map((user) => (
+ {
+ setSelectedUserId(user.id);
+ setUserComboOpen(false);
+ }}
+ >
+
+ {user.username}
+ {user.is_admin ? " (Admin)" : ""}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("rbac.noRoleFound")}
+
+ {roles.map((role) => (
+ {
+ setSelectedRoleId(role.id);
+ setRoleComboOpen(false);
+ }}
+ >
+
+ {t(role.displayName)}
+ {role.isSystem ? ` (${t("rbac.systemRole")})` : ""}
+
+ ))}
+
+
+
+
+
+
+
+
+ {/* Permission Level */}
+
+
+
+
+
+ {/* Expiration */}
+
+
+ {
+ const value = e.target.value;
+ if (value === "" || /^\d+$/.test(value)) {
+ setExpiresInHours(value);
+ }
+ }}
+ placeholder={t("rbac.neverExpires")}
+ min="1"
+ />
+
+
+
+
+
+ {/* Access List */}
+
+
+
+ {t("rbac.accessList")}
+
+
+
+
+
+ {t("rbac.type")}
+ {t("rbac.target")}
+ {t("rbac.permissionLevel")}
+ {t("rbac.grantedBy")}
+ {t("rbac.expires")}
+ {t("rbac.accessCount")}
+
+ {t("common.actions")}
+
+
+
+
+ {loading ? (
+
+
+ {t("common.loading")}
+
+
+ ) : accessList.length === 0 ? (
+
+
+ {t("rbac.noAccessRecords")}
+
+
+ ) : (
+ accessList.map((access) => (
+
+
+ {access.targetType === "user" ? (
+
+
+ {t("rbac.user")}
+
+ ) : (
+
+
+ {t("rbac.role")}
+
+ )}
+
+
+ {access.targetType === "user"
+ ? access.username
+ : t(access.roleDisplayName || access.roleName || "")}
+
+
+ {access.permissionLevel}
+
+ {access.grantedByUsername}
+
+ {access.expiresAt ? (
+
+
+
+ {formatDate(access.expiresAt)}
+ {isExpired(access.expiresAt) && (
+ ({t("rbac.expired")})
+ )}
+
+
+ ) : (
+ t("rbac.never")
+ )}
+
+ {access.accessCount}
+
+
+
+
+ ))
+ )}
+
+
+
+
+ );
+}
diff --git a/src/ui/desktop/apps/server/Server.tsx b/src/ui/desktop/apps/server-stats/ServerStats.tsx
similarity index 96%
rename from src/ui/desktop/apps/server/Server.tsx
rename to src/ui/desktop/apps/server-stats/ServerStats.tsx
index 8001e4d9..20125083 100644
--- a/src/ui/desktop/apps/server/Server.tsx
+++ b/src/ui/desktop/apps/server-stats/ServerStats.tsx
@@ -3,7 +3,6 @@ import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
import { Separator } from "@/components/ui/separator.tsx";
import { Button } from "@/components/ui/button.tsx";
-import { Tunnel } from "@/ui/desktop/apps/tunnel/Tunnel.tsx";
import {
getServerStatusById,
getServerMetricsById,
@@ -64,7 +63,7 @@ interface ServerProps {
embedded?: boolean;
}
-export function Server({
+export function ServerStats({
hostConfig,
title,
isVisible = true,
@@ -462,7 +461,7 @@ export function Server({
{(metricsEnabled && showStatsUI) ||
(currentHostConfig?.quickActions &&
currentHostConfig.quickActions.length > 0) ? (
-
+
{currentHostConfig?.quickActions &&
currentHostConfig.quickActions.length > 0 && (
@@ -600,20 +599,6 @@ export function Server({
)}
) : null}
-
- {currentHostConfig?.tunnelConnections &&
- currentHostConfig.tunnelConnections.length > 0 && (
-
-
-
- )}
diff --git a/src/ui/desktop/apps/server/widgets/CpuWidget.tsx b/src/ui/desktop/apps/server-stats/widgets/CpuWidget.tsx
similarity index 94%
rename from src/ui/desktop/apps/server/widgets/CpuWidget.tsx
rename to src/ui/desktop/apps/server-stats/widgets/CpuWidget.tsx
index e05470f2..d241926c 100644
--- a/src/ui/desktop/apps/server/widgets/CpuWidget.tsx
+++ b/src/ui/desktop/apps/server-stats/widgets/CpuWidget.tsx
@@ -30,7 +30,7 @@ export function CpuWidget({ metrics, metricsHistory }: CpuWidgetProps) {
}, [metricsHistory]);
return (
-
+
diff --git a/src/ui/desktop/apps/server/widgets/DiskWidget.tsx b/src/ui/desktop/apps/server-stats/widgets/DiskWidget.tsx
similarity index 94%
rename from src/ui/desktop/apps/server/widgets/DiskWidget.tsx
rename to src/ui/desktop/apps/server-stats/widgets/DiskWidget.tsx
index 083b391d..25b469d9 100644
--- a/src/ui/desktop/apps/server/widgets/DiskWidget.tsx
+++ b/src/ui/desktop/apps/server-stats/widgets/DiskWidget.tsx
@@ -27,7 +27,7 @@ export function DiskWidget({ metrics }: DiskWidgetProps) {
}, [metrics]);
return (
-
+
diff --git a/src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx b/src/ui/desktop/apps/server-stats/widgets/LoginStatsWidget.tsx
similarity index 96%
rename from src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx
rename to src/ui/desktop/apps/server-stats/widgets/LoginStatsWidget.tsx
index f70e8727..432e724f 100644
--- a/src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx
+++ b/src/ui/desktop/apps/server-stats/widgets/LoginStatsWidget.tsx
@@ -35,7 +35,7 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
const uniqueIPs = loginStats?.uniqueIPs || 0;
return (
-
+
diff --git a/src/ui/desktop/apps/server/widgets/MemoryWidget.tsx b/src/ui/desktop/apps/server-stats/widgets/MemoryWidget.tsx
similarity index 95%
rename from src/ui/desktop/apps/server/widgets/MemoryWidget.tsx
rename to src/ui/desktop/apps/server-stats/widgets/MemoryWidget.tsx
index ba505a9d..3487fe88 100644
--- a/src/ui/desktop/apps/server/widgets/MemoryWidget.tsx
+++ b/src/ui/desktop/apps/server-stats/widgets/MemoryWidget.tsx
@@ -30,7 +30,7 @@ export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
}, [metricsHistory]);
return (
-
+
diff --git a/src/ui/desktop/apps/server/widgets/NetworkWidget.tsx b/src/ui/desktop/apps/server-stats/widgets/NetworkWidget.tsx
similarity index 93%
rename from src/ui/desktop/apps/server/widgets/NetworkWidget.tsx
rename to src/ui/desktop/apps/server-stats/widgets/NetworkWidget.tsx
index 4a3e7379..597acd08 100644
--- a/src/ui/desktop/apps/server/widgets/NetworkWidget.tsx
+++ b/src/ui/desktop/apps/server-stats/widgets/NetworkWidget.tsx
@@ -24,7 +24,7 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) {
const interfaces = network?.interfaces || [];
return (
-
+
diff --git a/src/ui/desktop/apps/server/widgets/ProcessesWidget.tsx b/src/ui/desktop/apps/server-stats/widgets/ProcessesWidget.tsx
similarity index 94%
rename from src/ui/desktop/apps/server/widgets/ProcessesWidget.tsx
rename to src/ui/desktop/apps/server-stats/widgets/ProcessesWidget.tsx
index 2e51cec3..28c34448 100644
--- a/src/ui/desktop/apps/server/widgets/ProcessesWidget.tsx
+++ b/src/ui/desktop/apps/server-stats/widgets/ProcessesWidget.tsx
@@ -28,7 +28,7 @@ export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
const topProcesses = processes?.top || [];
return (
-
+
diff --git a/src/ui/desktop/apps/server/widgets/SystemWidget.tsx b/src/ui/desktop/apps/server-stats/widgets/SystemWidget.tsx
similarity index 92%
rename from src/ui/desktop/apps/server/widgets/SystemWidget.tsx
rename to src/ui/desktop/apps/server-stats/widgets/SystemWidget.tsx
index e9229d5b..2b38008d 100644
--- a/src/ui/desktop/apps/server/widgets/SystemWidget.tsx
+++ b/src/ui/desktop/apps/server-stats/widgets/SystemWidget.tsx
@@ -21,7 +21,7 @@ export function SystemWidget({ metrics }: SystemWidgetProps) {
const system = metricsWithSystem?.system;
return (
-
+
diff --git a/src/ui/desktop/apps/server/widgets/UptimeWidget.tsx b/src/ui/desktop/apps/server-stats/widgets/UptimeWidget.tsx
similarity index 90%
rename from src/ui/desktop/apps/server/widgets/UptimeWidget.tsx
rename to src/ui/desktop/apps/server-stats/widgets/UptimeWidget.tsx
index c8f02db7..04a72347 100644
--- a/src/ui/desktop/apps/server/widgets/UptimeWidget.tsx
+++ b/src/ui/desktop/apps/server-stats/widgets/UptimeWidget.tsx
@@ -20,7 +20,7 @@ export function UptimeWidget({ metrics }: UptimeWidgetProps) {
const uptime = metricsWithUptime?.uptime;
return (
-
+
diff --git a/src/ui/desktop/apps/server/widgets/index.ts b/src/ui/desktop/apps/server-stats/widgets/index.ts
similarity index 100%
rename from src/ui/desktop/apps/server/widgets/index.ts
rename to src/ui/desktop/apps/server-stats/widgets/index.ts
diff --git a/src/ui/desktop/apps/terminal/SudoPasswordPopup.tsx b/src/ui/desktop/apps/terminal/SudoPasswordPopup.tsx
new file mode 100644
index 00000000..8e1feca7
--- /dev/null
+++ b/src/ui/desktop/apps/terminal/SudoPasswordPopup.tsx
@@ -0,0 +1,82 @@
+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 (
+
+
+
+
+
+
+
+ {t("terminal.sudoPasswordPopupTitle", "Insert password?")}
+
+
+ {t("terminal.sudoPasswordPopupHint", "Press Enter to insert, Esc to dismiss")}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/ui/desktop/apps/terminal/Terminal.tsx b/src/ui/desktop/apps/terminal/Terminal.tsx
index 6e0e7bc4..91d20672 100644
--- a/src/ui/desktop/apps/terminal/Terminal.tsx
+++ b/src/ui/desktop/apps/terminal/Terminal.tsx
@@ -1,10 +1,10 @@
import {
- useEffect,
- useRef,
- useState,
- useImperativeHandle,
- forwardRef,
- useCallback,
+ useEffect,
+ useRef,
+ useState,
+ useImperativeHandle,
+ forwardRef,
+ useCallback,
} from "react";
import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
@@ -14,17 +14,17 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
- getCookie,
- isElectron,
- logActivity,
- getSnippets,
+ getCookie,
+ isElectron,
+ logActivity,
+ getSnippets,
} from "@/ui/main-axios.ts";
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
import {
- TERMINAL_THEMES,
- DEFAULT_TERMINAL_CONFIG,
- TERMINAL_FONTS,
+ TERMINAL_THEMES,
+ DEFAULT_TERMINAL_CONFIG,
+ TERMINAL_FONTS,
} from "@/constants/terminal-themes";
import type { TerminalConfig } from "@/types";
import { useCommandTracker } from "@/ui/hooks/useCommandTracker";
@@ -32,1482 +32,1502 @@ import { useCommandHistory as useCommandHistoryHook } from "@/ui/hooks/useComman
import { useCommandHistory } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx";
import { CommandAutocomplete } from "./command-history/CommandAutocomplete.tsx";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
+import { SudoPasswordPopup } from "./SudoPasswordPopup.tsx";
interface HostConfig {
- id?: number;
- ip: string;
- port: number;
- username: string;
- password?: string;
- key?: string;
- keyPassword?: string;
- keyType?: string;
- authType?: string;
- credentialId?: number;
- terminalConfig?: TerminalConfig;
- [key: string]: unknown;
+ id?: number;
+ ip: string;
+ port: number;
+ username: string;
+ password?: string;
+ key?: string;
+ keyPassword?: string;
+ keyType?: string;
+ authType?: string;
+ credentialId?: number;
+ terminalConfig?: TerminalConfig;
+ [key: string]: unknown;
}
interface TerminalHandle {
- disconnect: () => void;
- fit: () => void;
- sendInput: (data: string) => void;
- notifyResize: () => void;
- refresh: () => void;
+ disconnect: () => void;
+ fit: () => void;
+ sendInput: (data: string) => void;
+ notifyResize: () => void;
+ refresh: () => void;
}
interface SSHTerminalProps {
- hostConfig: HostConfig;
- isVisible: boolean;
- title?: string;
- showTitle?: boolean;
- splitScreen?: boolean;
- onClose?: () => void;
- initialPath?: string;
- executeCommand?: string;
+ hostConfig: HostConfig;
+ isVisible: boolean;
+ title?: string;
+ showTitle?: boolean;
+ splitScreen?: boolean;
+ onClose?: () => void;
+ initialPath?: string;
+ executeCommand?: string;
}
export const Terminal = forwardRef(
- function SSHTerminal(
- {
- hostConfig,
- isVisible,
- splitScreen = false,
- onClose,
- initialPath,
- executeCommand,
- },
- ref,
- ) {
- if (
- typeof window !== "undefined" &&
- !(window as { testJWT?: () => string | null }).testJWT
+ function SSHTerminal(
+ {
+ hostConfig,
+ isVisible,
+ splitScreen = false,
+ onClose,
+ initialPath,
+ executeCommand,
+ },
+ ref,
) {
- (window as { testJWT?: () => string | null }).testJWT = () => {
- const jwt = getCookie("jwt");
- return jwt;
- };
- }
-
- const { t } = useTranslation();
- const { instance: terminal, ref: xtermRef } = useXTerm();
- const commandHistoryContext = useCommandHistory();
-
- const config = { ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig };
- const themeColors =
- TERMINAL_THEMES[config.theme]?.colors || TERMINAL_THEMES.termix.colors;
- const backgroundColor = themeColors.background;
- 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 [isReady, setIsReady] = useState(false);
- const [isConnected, setIsConnected] = useState(false);
- const [isConnecting, setIsConnecting] = useState(false);
- const [isFitted, setIsFitted] = useState(true);
- const [, setConnectionError] = useState(null);
- const [, setIsAuthenticated] = useState(false);
- const [totpRequired, setTotpRequired] = useState(false);
- const [totpPrompt, setTotpPrompt] = useState("");
- const [isPasswordPrompt, setIsPasswordPrompt] = useState(false);
- const [showAuthDialog, setShowAuthDialog] = useState(false);
- const [authDialogReason, setAuthDialogReason] = useState<
- "no_keyboard" | "auth_failed" | "timeout"
- >("no_keyboard");
- const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] =
- useState(false);
- const isVisibleRef = useRef(false);
- const isFittingRef = 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 activityLoggedRef = useRef(false);
- const keyHandlerAttachedRef = useRef(false);
-
- const { trackInput, getCurrentCommand, updateCurrentCommand } =
- useCommandTracker({
- hostId: hostConfig.id,
- enabled: true,
- onCommandExecuted: (command) => {
- if (!autocompleteHistory.current.includes(command)) {
- autocompleteHistory.current = [
- command,
- ...autocompleteHistory.current,
- ];
- }
- },
- });
-
- const getCurrentCommandRef = useRef(getCurrentCommand);
- const updateCurrentCommandRef = useRef(updateCurrentCommand);
-
- useEffect(() => {
- getCurrentCommandRef.current = getCurrentCommand;
- updateCurrentCommandRef.current = updateCurrentCommand;
- }, [getCurrentCommand, updateCurrentCommand]);
-
- const [showAutocomplete, setShowAutocomplete] = useState(false);
- const [autocompleteSuggestions, setAutocompleteSuggestions] = useState<
- string[]
- >([]);
- const [autocompleteSelectedIndex, setAutocompleteSelectedIndex] =
- useState(0);
- const [autocompletePosition, setAutocompletePosition] = useState({
- top: 0,
- left: 0,
- });
- const autocompleteHistory = useRef([]);
- const currentAutocompleteCommand = useRef("");
-
- const showAutocompleteRef = useRef(false);
- const autocompleteSuggestionsRef = useRef([]);
- const autocompleteSelectedIndexRef = useRef(0);
-
- const [showHistoryDialog, setShowHistoryDialog] = useState(false);
- const [commandHistory, setCommandHistory] = useState([]);
- const [isLoadingHistory, setIsLoadingHistory] = useState(false);
-
- const setIsLoadingRef = useRef(commandHistoryContext.setIsLoading);
- const setCommandHistoryContextRef = useRef(
- commandHistoryContext.setCommandHistory,
- );
-
- useEffect(() => {
- setIsLoadingRef.current = commandHistoryContext.setIsLoading;
- setCommandHistoryContextRef.current =
- commandHistoryContext.setCommandHistory;
- }, [
- commandHistoryContext.setIsLoading,
- commandHistoryContext.setCommandHistory,
- ]);
-
- useEffect(() => {
- if (showHistoryDialog && hostConfig.id) {
- setIsLoadingHistory(true);
- setIsLoadingRef.current(true);
- import("@/ui/main-axios.ts")
- .then((module) => module.getCommandHistory(hostConfig.id!))
- .then((history) => {
- setCommandHistory(history);
- setCommandHistoryContextRef.current(history);
- })
- .catch((error) => {
- console.error("Failed to load command history:", error);
- setCommandHistory([]);
- setCommandHistoryContextRef.current([]);
- })
- .finally(() => {
- setIsLoadingHistory(false);
- setIsLoadingRef.current(false);
- });
- }
- }, [showHistoryDialog, hostConfig.id]);
-
- useEffect(() => {
- const autocompleteEnabled =
- localStorage.getItem("commandAutocomplete") !== "false";
-
- if (hostConfig.id && autocompleteEnabled) {
- import("@/ui/main-axios.ts")
- .then((module) => module.getCommandHistory(hostConfig.id!))
- .then((history) => {
- autocompleteHistory.current = history;
- })
- .catch((error) => {
- console.error("Failed to load autocomplete history:", error);
- autocompleteHistory.current = [];
- });
- } else {
- autocompleteHistory.current = [];
- }
- }, [hostConfig.id]);
-
- useEffect(() => {
- showAutocompleteRef.current = showAutocomplete;
- }, [showAutocomplete]);
-
- useEffect(() => {
- autocompleteSuggestionsRef.current = autocompleteSuggestions;
- }, [autocompleteSuggestions]);
-
- useEffect(() => {
- autocompleteSelectedIndexRef.current = autocompleteSelectedIndex;
- }, [autocompleteSelectedIndex]);
-
- const activityLoggingRef = useRef(false);
-
- const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
- const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
- const notifyTimerRef = useRef(null);
- const lastFittedSizeRef = useRef<{ cols: number; rows: number } | null>(
- null,
- );
- const DEBOUNCE_MS = 140;
-
- const logTerminalActivity = async () => {
- if (
- !hostConfig.id ||
- activityLoggedRef.current ||
- activityLoggingRef.current
- ) {
- return;
- }
-
- activityLoggingRef.current = true;
- activityLoggedRef.current = true;
-
- try {
- const hostName =
- hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`;
- await logActivity("terminal", hostConfig.id, hostName);
- } catch (err) {
- console.warn("Failed to log terminal activity:", err);
- activityLoggedRef.current = false;
- } finally {
- activityLoggingRef.current = false;
- }
- };
-
- 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 { refresh?: (start: number, end: number) => void }
- ).refresh === "function"
+ typeof window !== "undefined" &&
+ !(window as { testJWT?: () => string | null }).testJWT
) {
- (
- terminal as { refresh?: (start: number, end: number) => void }
- ).refresh(0, terminal.rows - 1);
- }
- } catch (error) {
- console.error("Terminal operation failed:", error);
- }
- }
-
- function performFit() {
- if (
- !fitAddonRef.current ||
- !terminal ||
- !isVisibleRef.current ||
- isFittingRef.current
- ) {
- return;
- }
-
- const lastSize = lastFittedSizeRef.current;
- if (
- lastSize &&
- lastSize.cols === terminal.cols &&
- lastSize.rows === terminal.rows
- ) {
- return;
- }
-
- isFittingRef.current = true;
-
- try {
- fitAddonRef.current?.fit();
- if (terminal && terminal.cols > 0 && terminal.rows > 0) {
- scheduleNotify(terminal.cols, terminal.rows);
- lastFittedSizeRef.current = {
- cols: terminal.cols,
- rows: terminal.rows,
- };
- }
- setIsFitted(true);
- } finally {
- isFittingRef.current = false;
- }
- }
-
- function handleTotpSubmit(code: string) {
- if (webSocketRef.current && code) {
- webSocketRef.current.send(
- JSON.stringify({
- type: isPasswordPrompt ? "password_response" : "totp_response",
- data: { code },
- }),
- );
- setTotpRequired(false);
- setTotpPrompt("");
- setIsPasswordPrompt(false);
- }
- }
-
- function handleTotpCancel() {
- setTotpRequired(false);
- setTotpPrompt("");
- if (onClose) onClose();
- }
-
- function handleAuthDialogSubmit(credentials: {
- password?: string;
- sshKey?: string;
- keyPassword?: string;
- }) {
- if (webSocketRef.current && terminal) {
- webSocketRef.current.send(
- JSON.stringify({
- type: "reconnect_with_credentials",
- data: {
- cols: terminal.cols,
- rows: terminal.rows,
- password: credentials.password,
- sshKey: credentials.sshKey,
- keyPassword: credentials.keyPassword,
- hostConfig: {
- ...hostConfig,
- password: credentials.password,
- key: credentials.sshKey,
- keyPassword: credentials.keyPassword,
- },
- },
- }),
- );
- setShowAuthDialog(false);
- setIsConnecting(true);
- }
- }
-
- function handleAuthDialogCancel() {
- setShowAuthDialog(false);
- if (onClose) onClose();
- }
-
- 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;
- }
- 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 (error) {
- console.error("Terminal operation failed:", error);
- }
- },
- refresh: () => hardRefresh(),
- }),
- [terminal],
- );
-
- function getUseRightClickCopyPaste() {
- return getCookie("rightClickCopyPaste") === "true";
- }
-
- function attemptReconnection() {
- if (
- isUnmountingRef.current ||
- shouldNotReconnectRef.current ||
- isReconnectingRef.current ||
- isConnectingRef.current ||
- wasDisconnectedBySSH.current
- ) {
- return;
- }
-
- if (reconnectAttempts.current >= maxReconnectAttempts) {
- toast.error(t("terminal.maxReconnectAttemptsReached"));
- if (onClose) {
- onClose();
- }
- return;
- }
-
- isReconnectingRef.current = true;
-
- if (terminal) {
- terminal.clear();
- }
-
- 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;
+ (window as { testJWT?: () => string | null }).testJWT = () => {
+ const jwt = getCookie("jwt");
+ return jwt;
+ };
}
- if (reconnectAttempts.current > maxReconnectAttempts) {
- isReconnectingRef.current = false;
- return;
- }
+ const { t } = useTranslation();
+ const { instance: terminal, ref: xtermRef } = useXTerm();
+ const commandHistoryContext = useCommandHistory();
- const jwtToken = getCookie("jwt");
- if (!jwtToken || jwtToken.trim() === "") {
- console.warn("Reconnection cancelled - no authentication token");
- isReconnectingRef.current = false;
- setConnectionError("Authentication required for reconnection");
- return;
- }
+ const config = { ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig };
+ const themeColors =
+ TERMINAL_THEMES[config.theme]?.colors || TERMINAL_THEMES.termix.colors;
+ const backgroundColor = themeColors.background;
+ 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 [isReady, setIsReady] = useState(false);
+ const [isConnected, setIsConnected] = useState(false);
+ const [isConnecting, setIsConnecting] = useState(false);
+ const [isFitted, setIsFitted] = useState(true);
+ const [, setConnectionError] = useState(null);
+ const [, setIsAuthenticated] = useState(false);
+ const [totpRequired, setTotpRequired] = useState(false);
+ const [totpPrompt, setTotpPrompt] = useState("");
+ const [isPasswordPrompt, setIsPasswordPrompt] = useState(false);
+ const [showAuthDialog, setShowAuthDialog] = useState(false);
+ const [authDialogReason, setAuthDialogReason] = useState<
+ "no_keyboard" | "auth_failed" | "timeout"
+ >("no_keyboard");
+ const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] =
+ useState(false);
+ const isVisibleRef = useRef(false);
+ const isFittingRef = 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 activityLoggedRef = useRef(false);
+ const keyHandlerAttachedRef = useRef(false);
- if (terminal && hostConfig) {
- terminal.clear();
- const cols = terminal.cols;
- const rows = terminal.rows;
- connectToHost(cols, rows);
- }
+ const { trackInput, getCurrentCommand, updateCurrentCommand } =
+ useCommandTracker({
+ hostId: hostConfig.id,
+ enabled: true,
+ onCommandExecuted: (command) => {
+ if (!autocompleteHistory.current.includes(command)) {
+ autocompleteHistory.current = [
+ command,
+ ...autocompleteHistory.current,
+ ];
+ }
+ },
+ });
- isReconnectingRef.current = false;
- }, 2000 * reconnectAttempts.current);
- }
+ const getCurrentCommandRef = useRef(getCurrentCommand);
+ const updateCurrentCommandRef = useRef(updateCurrentCommand);
- function connectToHost(cols: number, rows: number) {
- if (isConnectingRef.current) {
- return;
- }
+ useEffect(() => {
+ getCurrentCommandRef.current = getCurrentCommand;
+ updateCurrentCommandRef.current = updateCurrentCommand;
+ }, [getCurrentCommand, updateCurrentCommand]);
- isConnectingRef.current = true;
-
- const isDev =
- !isElectron() &&
- 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();
- }
-
- 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 && !totpRequired && !isPasswordPrompt) {
- if (terminal) {
- terminal.clear();
- }
- toast.error(t("terminal.connectionTimeout"));
- if (webSocketRef.current) {
- webSocketRef.current.close();
- }
- if (reconnectAttempts.current > 0) {
- attemptReconnection();
- }
- }
- }, 10000);
-
- ws.send(
- JSON.stringify({
- type: "connectToHost",
- data: { cols, rows, hostConfig, initialPath, executeCommand },
- }),
- );
- terminal.onData((data) => {
- trackInput(data);
- ws.send(JSON.stringify({ type: "input", data }));
+ const [showAutocomplete, setShowAutocomplete] = useState(false);
+ const [autocompleteSuggestions, setAutocompleteSuggestions] = useState<
+ string[]
+ >([]);
+ const [autocompleteSelectedIndex, setAutocompleteSelectedIndex] =
+ useState(0);
+ const [autocompletePosition, setAutocompletePosition] = useState({
+ top: 0,
+ left: 0,
});
+ const autocompleteHistory = useRef([]);
+ const currentAutocompleteCommand = useRef("");
- pingIntervalRef.current = setInterval(() => {
- if (ws.readyState === WebSocket.OPEN) {
- ws.send(JSON.stringify({ type: "ping" }));
- }
- }, 30000);
- });
+ const showAutocompleteRef = useRef(false);
+ const autocompleteSuggestionsRef = useRef([]);
+ const autocompleteSelectedIndexRef = useRef(0);
- ws.addEventListener("message", (event) => {
- try {
- const msg = JSON.parse(event.data);
- if (msg.type === "data") {
- if (typeof msg.data === "string") {
- terminal.write(msg.data);
+ const [showHistoryDialog, setShowHistoryDialog] = useState(false);
+ const [commandHistory, setCommandHistory] = useState([]);
+ const [isLoadingHistory, setIsLoadingHistory] = useState(false);
+ const [showSudoPasswordPopup, setShowSudoPasswordPopup] = useState(false);
+
+ const setIsLoadingRef = useRef(commandHistoryContext.setIsLoading);
+ const setCommandHistoryContextRef = useRef(
+ commandHistoryContext.setCommandHistory,
+ );
+
+ useEffect(() => {
+ setIsLoadingRef.current = commandHistoryContext.setIsLoading;
+ setCommandHistoryContextRef.current =
+ commandHistoryContext.setCommandHistory;
+ }, [
+ commandHistoryContext.setIsLoading,
+ commandHistoryContext.setCommandHistory,
+ ]);
+
+ useEffect(() => {
+ if (showHistoryDialog && hostConfig.id) {
+ setIsLoadingHistory(true);
+ setIsLoadingRef.current(true);
+ import("@/ui/main-axios.ts")
+ .then((module) => module.getCommandHistory(hostConfig.id!))
+ .then((history) => {
+ setCommandHistory(history);
+ setCommandHistoryContextRef.current(history);
+ })
+ .catch((error) => {
+ console.error("Failed to load command history:", error);
+ setCommandHistory([]);
+ setCommandHistoryContextRef.current([]);
+ })
+ .finally(() => {
+ setIsLoadingHistory(false);
+ setIsLoadingRef.current(false);
+ });
+ }
+ }, [showHistoryDialog, hostConfig.id]);
+
+ useEffect(() => {
+ const autocompleteEnabled =
+ localStorage.getItem("commandAutocomplete") !== "false";
+
+ if (hostConfig.id && autocompleteEnabled) {
+ import("@/ui/main-axios.ts")
+ .then((module) => module.getCommandHistory(hostConfig.id!))
+ .then((history) => {
+ autocompleteHistory.current = history;
+ })
+ .catch((error) => {
+ console.error("Failed to load autocomplete history:", error);
+ autocompleteHistory.current = [];
+ });
} else {
- terminal.write(String(msg.data));
+ autocompleteHistory.current = [];
}
- } else if (msg.type === "error") {
- const errorMessage = msg.message || t("terminal.unknownError");
+ }, [hostConfig.id]);
+ useEffect(() => {
+ showAutocompleteRef.current = showAutocomplete;
+ }, [showAutocomplete]);
+
+ useEffect(() => {
+ autocompleteSuggestionsRef.current = autocompleteSuggestions;
+ }, [autocompleteSuggestions]);
+
+ useEffect(() => {
+ autocompleteSelectedIndexRef.current = autocompleteSelectedIndex;
+ }, [autocompleteSelectedIndex]);
+
+ const activityLoggingRef = useRef(false);
+
+ const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
+ const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
+ const notifyTimerRef = useRef(null);
+ const lastFittedSizeRef = useRef<{ cols: number; rows: number } | null>(
+ null,
+ );
+ const DEBOUNCE_MS = 140;
+
+ const logTerminalActivity = async () => {
if (
- errorMessage.toLowerCase().includes("connection") ||
- errorMessage.toLowerCase().includes("timeout") ||
- errorMessage.toLowerCase().includes("network")
+ !hostConfig.id ||
+ activityLoggedRef.current ||
+ activityLoggingRef.current
) {
- toast.error(
- t("terminal.connectionError", { message: errorMessage }),
- );
- setIsConnected(false);
- if (terminal) {
+ return;
+ }
+
+ activityLoggingRef.current = true;
+ activityLoggedRef.current = true;
+
+ try {
+ const hostName =
+ hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`;
+ await logActivity("terminal", hostConfig.id, hostName);
+ } catch (err) {
+ console.warn("Failed to log terminal activity:", err);
+ activityLoggedRef.current = false;
+ } finally {
+ activityLoggingRef.current = false;
+ }
+ };
+
+ 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 { refresh?: (start: number, end: number) => void }
+ ).refresh === "function"
+ ) {
+ (
+ terminal as { refresh?: (start: number, end: number) => void }
+ ).refresh(0, terminal.rows - 1);
+ }
+ } catch (error) {
+ console.error("Terminal operation failed:", error);
+ }
+ }
+
+ function performFit() {
+ if (
+ !fitAddonRef.current ||
+ !terminal ||
+ !isVisibleRef.current ||
+ isFittingRef.current
+ ) {
+ return;
+ }
+
+ const lastSize = lastFittedSizeRef.current;
+ if (
+ lastSize &&
+ lastSize.cols === terminal.cols &&
+ lastSize.rows === terminal.rows
+ ) {
+ return;
+ }
+
+ isFittingRef.current = true;
+
+ try {
+ fitAddonRef.current?.fit();
+ if (terminal && terminal.cols > 0 && terminal.rows > 0) {
+ scheduleNotify(terminal.cols, terminal.rows);
+ lastFittedSizeRef.current = {
+ cols: terminal.cols,
+ rows: terminal.rows,
+ };
+ }
+ setIsFitted(true);
+ } finally {
+ isFittingRef.current = false;
+ }
+ }
+
+ function handleTotpSubmit(code: string) {
+ if (webSocketRef.current && code) {
+ webSocketRef.current.send(
+ JSON.stringify({
+ type: isPasswordPrompt ? "password_response" : "totp_response",
+ data: { code },
+ }),
+ );
+ setTotpRequired(false);
+ setTotpPrompt("");
+ setIsPasswordPrompt(false);
+ }
+ }
+
+ function handleTotpCancel() {
+ setTotpRequired(false);
+ setTotpPrompt("");
+ if (onClose) onClose();
+ }
+
+ function handleAuthDialogSubmit(credentials: {
+ password?: string;
+ sshKey?: string;
+ keyPassword?: string;
+ }) {
+ if (webSocketRef.current && terminal) {
+ webSocketRef.current.send(
+ JSON.stringify({
+ type: "reconnect_with_credentials",
+ data: {
+ cols: terminal.cols,
+ rows: terminal.rows,
+ password: credentials.password,
+ sshKey: credentials.sshKey,
+ keyPassword: credentials.keyPassword,
+ hostConfig: {
+ ...hostConfig,
+ password: credentials.password,
+ key: credentials.sshKey,
+ keyPassword: credentials.keyPassword,
+ },
+ },
+ }),
+ );
+ setShowAuthDialog(false);
+ setIsConnecting(true);
+ }
+ }
+
+ function handleAuthDialogCancel() {
+ setShowAuthDialog(false);
+ if (onClose) onClose();
+ }
+
+ 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;
+ }
+ 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 (error) {
+ console.error("Terminal operation failed:", error);
+ }
+ },
+ refresh: () => hardRefresh(),
+ }),
+ [terminal],
+ );
+
+ function getUseRightClickCopyPaste() {
+ return getCookie("rightClickCopyPaste") === "true";
+ }
+
+ function attemptReconnection() {
+ if (
+ isUnmountingRef.current ||
+ shouldNotReconnectRef.current ||
+ isReconnectingRef.current ||
+ isConnectingRef.current ||
+ wasDisconnectedBySSH.current
+ ) {
+ return;
+ }
+
+ if (reconnectAttempts.current >= maxReconnectAttempts) {
+ toast.error(t("terminal.maxReconnectAttemptsReached"));
+ if (onClose) {
+ onClose();
+ }
+ return;
+ }
+
+ isReconnectingRef.current = true;
+
+ if (terminal) {
terminal.clear();
- }
- setIsConnecting(true);
- wasDisconnectedBySSH.current = false;
- attemptReconnection();
- return;
}
+ 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 =
+ !isElectron() &&
+ 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 (
- (errorMessage.toLowerCase().includes("auth") &&
- errorMessage.toLowerCase().includes("failed")) ||
- errorMessage.toLowerCase().includes("permission denied") ||
- (errorMessage.toLowerCase().includes("invalid") &&
- (errorMessage.toLowerCase().includes("password") ||
- errorMessage.toLowerCase().includes("key"))) ||
- errorMessage.toLowerCase().includes("incorrect password")
+ webSocketRef.current &&
+ webSocketRef.current.readyState !== WebSocket.CLOSED
) {
- toast.error(t("terminal.authError", { message: errorMessage }));
- shouldNotReconnectRef.current = true;
- if (webSocketRef.current) {
webSocketRef.current.close();
- }
- if (onClose) {
- onClose();
- }
- return;
}
- toast.error(t("terminal.error", { message: errorMessage }));
- } else if (msg.type === "connected") {
- setIsConnected(true);
- setIsConnecting(false);
- isConnectingRef.current = false;
+ if (pingIntervalRef.current) {
+ clearInterval(pingIntervalRef.current);
+ pingIntervalRef.current = null;
+ }
if (connectionTimeoutRef.current) {
- clearTimeout(connectionTimeoutRef.current);
- connectionTimeoutRef.current = null;
+ clearTimeout(connectionTimeoutRef.current);
+ connectionTimeoutRef.current = null;
}
- if (reconnectAttempts.current > 0) {
- toast.success(t("terminal.reconnected"));
- }
- reconnectAttempts.current = 0;
+
+ 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);
- logTerminalActivity();
+ setupWebSocketListeners(ws, cols, rows);
+ }
- setTimeout(async () => {
- const terminalConfig = {
+ function setupWebSocketListeners(
+ ws: WebSocket,
+ cols: number,
+ rows: number,
+ ) {
+ ws.addEventListener("open", () => {
+ connectionTimeoutRef.current = setTimeout(() => {
+ if (!isConnected && !totpRequired && !isPasswordPrompt) {
+ if (terminal) {
+ terminal.clear();
+ }
+ toast.error(t("terminal.connectionTimeout"));
+ if (webSocketRef.current) {
+ webSocketRef.current.close();
+ }
+ if (reconnectAttempts.current > 0) {
+ attemptReconnection();
+ }
+ }
+ }, 10000);
+
+ ws.send(
+ JSON.stringify({
+ type: "connectToHost",
+ data: { cols, rows, hostConfig, initialPath, executeCommand },
+ }),
+ );
+ terminal.onData((data) => {
+ trackInput(data);
+ ws.send(JSON.stringify({ type: "input", data }));
+ });
+
+ 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);
+ // Sudo password prompt detection
+ const sudoPasswordPattern = /(?:\[sudo\] password for \S+:|sudo: a password is required)/;
+ if (config.sudoPasswordAutoFill && sudoPasswordPattern.test(msg.data)) {
+ setShowSudoPasswordPopup(true);
+ }
+ } else {
+ terminal.write(String(msg.data));
+ }
+ } else if (msg.type === "error") {
+ const errorMessage = msg.message || t("terminal.unknownError");
+
+ 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;
+ }
+
+ if (
+ (errorMessage.toLowerCase().includes("auth") &&
+ errorMessage.toLowerCase().includes("failed")) ||
+ errorMessage.toLowerCase().includes("permission denied") ||
+ (errorMessage.toLowerCase().includes("invalid") &&
+ (errorMessage.toLowerCase().includes("password") ||
+ errorMessage.toLowerCase().includes("key"))) ||
+ errorMessage.toLowerCase().includes("incorrect password")
+ ) {
+ toast.error(t("terminal.authError", { message: errorMessage }));
+ shouldNotReconnectRef.current = true;
+ if (webSocketRef.current) {
+ webSocketRef.current.close();
+ }
+ if (onClose) {
+ onClose();
+ }
+ 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;
+
+ logTerminalActivity();
+
+ setTimeout(async () => {
+ const terminalConfig = {
+ ...DEFAULT_TERMINAL_CONFIG,
+ ...hostConfig.terminalConfig,
+ };
+
+ if (
+ terminalConfig.environmentVariables &&
+ terminalConfig.environmentVariables.length > 0
+ ) {
+ for (const envVar of terminalConfig.environmentVariables) {
+ if (envVar.key && envVar.value && ws.readyState === 1) {
+ ws.send(
+ JSON.stringify({
+ type: "input",
+ data: `export ${envVar.key}="${envVar.value}"\n`,
+ }),
+ );
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ }
+ }
+
+ if (terminalConfig.startupSnippetId) {
+ try {
+ const snippets = await getSnippets();
+ const snippet = snippets.find(
+ (s: { id: number }) =>
+ s.id === terminalConfig.startupSnippetId,
+ );
+ if (snippet && ws.readyState === 1) {
+ ws.send(
+ JSON.stringify({
+ type: "input",
+ data: snippet.content + "\n",
+ }),
+ );
+ await new Promise((resolve) => setTimeout(resolve, 200));
+ }
+ } catch (err) {
+ console.warn("Failed to execute startup snippet:", err);
+ }
+ }
+
+ if (terminalConfig.autoMosh && ws.readyState === 1) {
+ ws.send(
+ JSON.stringify({
+ type: "input",
+ data: terminalConfig.moshCommand + "\n",
+ }),
+ );
+ }
+ }, 500);
+ } 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:");
+ setIsPasswordPrompt(false);
+ if (connectionTimeoutRef.current) {
+ clearTimeout(connectionTimeoutRef.current);
+ connectionTimeoutRef.current = null;
+ }
+ } else if (msg.type === "password_required") {
+ setTotpRequired(true);
+ setTotpPrompt(msg.prompt || "Password:");
+ setIsPasswordPrompt(true);
+ if (connectionTimeoutRef.current) {
+ clearTimeout(connectionTimeoutRef.current);
+ connectionTimeoutRef.current = null;
+ }
+ } else if (msg.type === "keyboard_interactive_available") {
+ setKeyboardInteractiveDetected(true);
+ setIsConnecting(false);
+ if (connectionTimeoutRef.current) {
+ clearTimeout(connectionTimeoutRef.current);
+ connectionTimeoutRef.current = null;
+ }
+ } else if (msg.type === "auth_method_not_available") {
+ setAuthDialogReason("no_keyboard");
+ setShowAuthDialog(true);
+ setIsConnecting(false);
+ if (connectionTimeoutRef.current) {
+ clearTimeout(connectionTimeoutRef.current);
+ connectionTimeoutRef.current = null;
+ }
+ }
+ } catch {
+ 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");
+
+ setTimeout(() => {
+ window.location.reload();
+ }, 1000);
+
+ return;
+ }
+
+ setIsConnecting(false);
+ if (
+ !wasDisconnectedBySSH.current &&
+ !isUnmountingRef.current &&
+ !shouldNotReconnectRef.current
+ ) {
+ wasDisconnectedBySSH.current = false;
+ attemptReconnection();
+ }
+ });
+
+ 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();
+ }
+ });
+ }
+
+ async function writeTextToClipboard(text: string): Promise {
+ try {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ await navigator.clipboard.writeText(text);
+ return;
+ }
+ } catch (error) {
+ console.error("Terminal operation failed:", error);
+ }
+ 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 (error) {
+ console.error("Terminal operation failed:", error);
+ }
+ return "";
+ }
+
+ const handleSelectCommand = useCallback(
+ (command: string) => {
+ if (!terminal || !webSocketRef.current) return;
+
+ for (const char of command) {
+ webSocketRef.current.send(
+ JSON.stringify({ type: "input", data: char }),
+ );
+ }
+
+ setTimeout(() => {
+ terminal.focus();
+ }, 100);
+ },
+ [terminal],
+ );
+
+ useEffect(() => {
+ commandHistoryContext.setOnSelectCommand(handleSelectCommand);
+ }, [handleSelectCommand]);
+
+ const handleAutocompleteSelect = useCallback(
+ (selectedCommand: string) => {
+ if (!webSocketRef.current) return;
+
+ const currentCmd = currentAutocompleteCommand.current;
+ const completion = selectedCommand.substring(currentCmd.length);
+
+ for (const char of completion) {
+ webSocketRef.current.send(
+ JSON.stringify({ type: "input", data: char }),
+ );
+ }
+
+ updateCurrentCommand(selectedCommand);
+
+ setShowAutocomplete(false);
+ setAutocompleteSuggestions([]);
+ currentAutocompleteCommand.current = "";
+
+ setTimeout(() => {
+ terminal?.focus();
+ }, 50);
+
+ console.log(`[Autocomplete] ${currentCmd} → ${selectedCommand}`);
+ },
+ [terminal, updateCurrentCommand],
+ );
+
+ const handleDeleteCommand = useCallback(
+ async (command: string) => {
+ if (!hostConfig.id) return;
+
+ try {
+ const { deleteCommandFromHistory } = await import(
+ "@/ui/main-axios.ts"
+ );
+ await deleteCommandFromHistory(hostConfig.id, command);
+
+ setCommandHistory((prev) => {
+ const newHistory = prev.filter((cmd) => cmd !== command);
+ setCommandHistoryContextRef.current(newHistory);
+ return newHistory;
+ });
+
+ autocompleteHistory.current = autocompleteHistory.current.filter(
+ (cmd) => cmd !== command,
+ );
+
+ console.log(`[Terminal] Command deleted from history: ${command}`);
+ } catch (error) {
+ console.error("Failed to delete command from history:", error);
+ }
+ },
+ [hostConfig.id],
+ );
+
+ useEffect(() => {
+ commandHistoryContext.setOnDeleteCommand(handleDeleteCommand);
+ }, [handleDeleteCommand]);
+
+ useEffect(() => {
+ if (!terminal || !xtermRef.current) return;
+
+ const config = {
...DEFAULT_TERMINAL_CONFIG,
...hostConfig.terminalConfig,
- };
+ };
- if (
- terminalConfig.environmentVariables &&
- terminalConfig.environmentVariables.length > 0
- ) {
- for (const envVar of terminalConfig.environmentVariables) {
- if (envVar.key && envVar.value && ws.readyState === 1) {
- ws.send(
- JSON.stringify({
- type: "input",
- data: `export ${envVar.key}="${envVar.value}"\n`,
- }),
- );
- await new Promise((resolve) => setTimeout(resolve, 100));
- }
- }
- }
+ const themeColors =
+ TERMINAL_THEMES[config.theme]?.colors || TERMINAL_THEMES.termix.colors;
- if (terminalConfig.startupSnippetId) {
- try {
- const snippets = await getSnippets();
- const snippet = snippets.find(
- (s: { id: number }) =>
- s.id === terminalConfig.startupSnippetId,
- );
- if (snippet && ws.readyState === 1) {
- ws.send(
- JSON.stringify({
- type: "input",
- data: snippet.content + "\n",
- }),
- );
- await new Promise((resolve) => setTimeout(resolve, 200));
- }
- } catch (err) {
- console.warn("Failed to execute startup snippet:", err);
- }
- }
-
- if (terminalConfig.autoMosh && ws.readyState === 1) {
- ws.send(
- JSON.stringify({
- type: "input",
- data: terminalConfig.moshCommand + "\n",
- }),
- );
- }
- }, 500);
- } 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:");
- setIsPasswordPrompt(false);
- if (connectionTimeoutRef.current) {
- clearTimeout(connectionTimeoutRef.current);
- connectionTimeoutRef.current = null;
- }
- } else if (msg.type === "password_required") {
- setTotpRequired(true);
- setTotpPrompt(msg.prompt || "Password:");
- setIsPasswordPrompt(true);
- if (connectionTimeoutRef.current) {
- clearTimeout(connectionTimeoutRef.current);
- connectionTimeoutRef.current = null;
- }
- } else if (msg.type === "keyboard_interactive_available") {
- setKeyboardInteractiveDetected(true);
- setIsConnecting(false);
- if (connectionTimeoutRef.current) {
- clearTimeout(connectionTimeoutRef.current);
- connectionTimeoutRef.current = null;
- }
- } else if (msg.type === "auth_method_not_available") {
- setAuthDialogReason("no_keyboard");
- setShowAuthDialog(true);
- setIsConnecting(false);
- if (connectionTimeoutRef.current) {
- clearTimeout(connectionTimeoutRef.current);
- connectionTimeoutRef.current = null;
- }
- }
- } catch {
- 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");
-
- setTimeout(() => {
- window.location.reload();
- }, 1000);
-
- return;
- }
-
- setIsConnecting(false);
- if (
- !wasDisconnectedBySSH.current &&
- !isUnmountingRef.current &&
- !shouldNotReconnectRef.current
- ) {
- wasDisconnectedBySSH.current = false;
- attemptReconnection();
- }
- });
-
- 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();
- }
- });
- }
-
- async function writeTextToClipboard(text: string): Promise {
- try {
- if (navigator.clipboard && navigator.clipboard.writeText) {
- await navigator.clipboard.writeText(text);
- return;
- }
- } catch (error) {
- console.error("Terminal operation failed:", error);
- }
- 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 (error) {
- console.error("Terminal operation failed:", error);
- }
- return "";
- }
-
- const handleSelectCommand = useCallback(
- (command: string) => {
- if (!terminal || !webSocketRef.current) return;
-
- for (const char of command) {
- webSocketRef.current.send(
- JSON.stringify({ type: "input", data: char }),
- );
- }
-
- setTimeout(() => {
- terminal.focus();
- }, 100);
- },
- [terminal],
- );
-
- useEffect(() => {
- commandHistoryContext.setOnSelectCommand(handleSelectCommand);
- }, [handleSelectCommand]);
-
- const handleAutocompleteSelect = useCallback(
- (selectedCommand: string) => {
- if (!webSocketRef.current) return;
-
- const currentCmd = currentAutocompleteCommand.current;
- const completion = selectedCommand.substring(currentCmd.length);
-
- for (const char of completion) {
- webSocketRef.current.send(
- JSON.stringify({ type: "input", data: char }),
- );
- }
-
- updateCurrentCommand(selectedCommand);
-
- setShowAutocomplete(false);
- setAutocompleteSuggestions([]);
- currentAutocompleteCommand.current = "";
-
- setTimeout(() => {
- terminal?.focus();
- }, 50);
-
- console.log(`[Autocomplete] ${currentCmd} → ${selectedCommand}`);
- },
- [terminal, updateCurrentCommand],
- );
-
- const handleDeleteCommand = useCallback(
- async (command: string) => {
- if (!hostConfig.id) return;
-
- try {
- const { deleteCommandFromHistory } = await import(
- "@/ui/main-axios.ts"
- );
- await deleteCommandFromHistory(hostConfig.id, command);
-
- setCommandHistory((prev) => {
- const newHistory = prev.filter((cmd) => cmd !== command);
- setCommandHistoryContextRef.current(newHistory);
- return newHistory;
- });
-
- autocompleteHistory.current = autocompleteHistory.current.filter(
- (cmd) => cmd !== command,
- );
-
- console.log(`[Terminal] Command deleted from history: ${command}`);
- } catch (error) {
- console.error("Failed to delete command from history:", error);
- }
- },
- [hostConfig.id],
- );
-
- useEffect(() => {
- commandHistoryContext.setOnDeleteCommand(handleDeleteCommand);
- }, [handleDeleteCommand]);
-
- useEffect(() => {
- if (!terminal || !xtermRef.current) return;
-
- const config = {
- ...DEFAULT_TERMINAL_CONFIG,
- ...hostConfig.terminalConfig,
- };
-
- const themeColors =
- TERMINAL_THEMES[config.theme]?.colors || TERMINAL_THEMES.termix.colors;
-
- const fontConfig = TERMINAL_FONTS.find(
- (f) => f.value === config.fontFamily,
- );
- const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback;
-
- terminal.options = {
- cursorBlink: config.cursorBlink,
- cursorStyle: config.cursorStyle,
- scrollback: config.scrollback,
- fontSize: config.fontSize,
- fontFamily,
- allowTransparency: true,
- convertEol: true,
- windowsMode: false,
- macOptionIsMeta: false,
- macOptionClickForcesSelection: false,
- rightClickSelectsWord: config.rightClickSelectsWord,
- fastScrollModifier: config.fastScrollModifier,
- fastScrollSensitivity: config.fastScrollSensitivity,
- allowProposedApi: true,
- minimumContrastRatio: config.minimumContrastRatio,
- letterSpacing: config.letterSpacing,
- lineHeight: config.lineHeight,
- bellStyle: config.bellStyle as "none" | "sound" | "visual" | "both",
-
- theme: {
- background: themeColors.background,
- foreground: themeColors.foreground,
- cursor: themeColors.cursor,
- cursorAccent: themeColors.cursorAccent,
- selectionBackground: themeColors.selectionBackground,
- selectionForeground: themeColors.selectionForeground,
- black: themeColors.black,
- red: themeColors.red,
- green: themeColors.green,
- yellow: themeColors.yellow,
- blue: themeColors.blue,
- magenta: themeColors.magenta,
- cyan: themeColors.cyan,
- white: themeColors.white,
- brightBlack: themeColors.brightBlack,
- brightRed: themeColors.brightRed,
- brightGreen: themeColors.brightGreen,
- brightYellow: themeColors.brightYellow,
- brightBlue: themeColors.brightBlue,
- brightMagenta: themeColors.brightMagenta,
- brightCyan: themeColors.brightCyan,
- brightWhite: themeColors.brightWhite,
- },
- };
-
- 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 (error) {
- console.error("Terminal operation failed:", error);
- }
- };
- element?.addEventListener("contextmenu", handleContextMenu);
-
- const handleMacKeyboard = (e: KeyboardEvent) => {
- const isMacOS =
- navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
- navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
-
- if (
- e.ctrlKey &&
- e.key === "r" &&
- !e.shiftKey &&
- !e.altKey &&
- !e.metaKey
- ) {
- e.preventDefault();
- e.stopPropagation();
- setShowHistoryDialog(true);
- if (commandHistoryContext.openCommandHistory) {
- commandHistoryContext.openCommandHistory();
- }
- return false;
- }
-
- if (
- config.backspaceMode === "control-h" &&
- e.key === "Backspace" &&
- !e.ctrlKey &&
- !e.metaKey &&
- !e.altKey
- ) {
- e.preventDefault();
- e.stopPropagation();
- if (webSocketRef.current?.readyState === 1) {
- webSocketRef.current.send(
- JSON.stringify({ type: "input", data: "\x08" }),
+ const fontConfig = TERMINAL_FONTS.find(
+ (f) => f.value === config.fontFamily,
);
- }
- return false;
- }
+ const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback;
- if (!isMacOS) return;
+ terminal.options = {
+ cursorBlink: config.cursorBlink,
+ cursorStyle: config.cursorStyle,
+ scrollback: config.scrollback,
+ fontSize: config.fontSize,
+ fontFamily,
+ allowTransparency: true,
+ convertEol: true,
+ windowsMode: false,
+ macOptionIsMeta: false,
+ macOptionClickForcesSelection: false,
+ rightClickSelectsWord: config.rightClickSelectsWord,
+ fastScrollModifier: config.fastScrollModifier,
+ fastScrollSensitivity: config.fastScrollSensitivity,
+ allowProposedApi: true,
+ minimumContrastRatio: config.minimumContrastRatio,
+ letterSpacing: config.letterSpacing,
+ lineHeight: config.lineHeight,
+ bellStyle: config.bellStyle as "none" | "sound" | "visual" | "both",
- if (e.altKey && !e.metaKey && !e.ctrlKey) {
- const keyMappings: { [key: string]: string } = {
- "7": "|",
- "2": "€",
- "8": "[",
- "9": "]",
- l: "@",
- L: "@",
- Digit7: "|",
- Digit2: "€",
- Digit8: "[",
- Digit9: "]",
- KeyL: "@",
- };
+ theme: {
+ background: themeColors.background,
+ foreground: themeColors.foreground,
+ cursor: themeColors.cursor,
+ cursorAccent: themeColors.cursorAccent,
+ selectionBackground: themeColors.selectionBackground,
+ selectionForeground: themeColors.selectionForeground,
+ black: themeColors.black,
+ red: themeColors.red,
+ green: themeColors.green,
+ yellow: themeColors.yellow,
+ blue: themeColors.blue,
+ magenta: themeColors.magenta,
+ cyan: themeColors.cyan,
+ white: themeColors.white,
+ brightBlack: themeColors.brightBlack,
+ brightRed: themeColors.brightRed,
+ brightGreen: themeColors.brightGreen,
+ brightYellow: themeColors.brightYellow,
+ brightBlue: themeColors.brightBlue,
+ brightMagenta: themeColors.brightMagenta,
+ brightCyan: themeColors.brightCyan,
+ brightWhite: themeColors.brightWhite,
+ },
+ };
- const char = keyMappings[e.key] || keyMappings[e.code];
- if (char) {
- e.preventDefault();
- e.stopPropagation();
+ const fitAddon = new FitAddon();
+ const clipboardAddon = new ClipboardAddon();
+ const unicode11Addon = new Unicode11Addon();
+ const webLinksAddon = new WebLinksAddon();
- if (webSocketRef.current?.readyState === 1) {
- webSocketRef.current.send(
- JSON.stringify({ type: "input", data: char }),
- );
- }
- return false;
- }
- }
- };
+ fitAddonRef.current = fitAddon;
+ terminal.loadAddon(fitAddon);
+ terminal.loadAddon(clipboardAddon);
+ terminal.loadAddon(unicode11Addon);
+ terminal.loadAddon(webLinksAddon);
- element?.addEventListener("keydown", handleMacKeyboard, true);
+ terminal.unicode.activeVersion = "11";
- const resizeObserver = new ResizeObserver(() => {
- if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
- resizeTimeout.current = setTimeout(() => {
- if (!isVisibleRef.current || !isReady) return;
- performFit();
- }, 50);
- });
+ terminal.open(xtermRef.current);
- resizeObserver.observe(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 (error) {
+ console.error("Terminal operation failed:", error);
+ }
+ };
+ element?.addEventListener("contextmenu", handleContextMenu);
- setVisible(true);
+ const handleMacKeyboard = (e: KeyboardEvent) => {
+ const isMacOS =
+ navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
+ navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
- return () => {
- isUnmountingRef.current = true;
- shouldNotReconnectRef.current = true;
- isReconnectingRef.current = false;
- setIsConnecting(false);
- setVisible(false);
- setIsReady(false);
- isFittingRef.current = 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, hostConfig]);
+ if (
+ e.ctrlKey &&
+ e.key === "r" &&
+ !e.shiftKey &&
+ !e.altKey &&
+ !e.metaKey
+ ) {
+ e.preventDefault();
+ e.stopPropagation();
+ setShowHistoryDialog(true);
+ if (commandHistoryContext.openCommandHistory) {
+ commandHistoryContext.openCommandHistory();
+ }
+ return false;
+ }
- useEffect(() => {
- if (!terminal) return;
+ if (
+ config.backspaceMode === "control-h" &&
+ e.key === "Backspace" &&
+ !e.ctrlKey &&
+ !e.metaKey &&
+ !e.altKey
+ ) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (webSocketRef.current?.readyState === 1) {
+ webSocketRef.current.send(
+ JSON.stringify({ type: "input", data: "\x08" }),
+ );
+ }
+ return false;
+ }
- const handleCustomKey = (e: KeyboardEvent): boolean => {
- if (e.type !== "keydown") {
- return true;
- }
+ if (!isMacOS) return;
- if (showAutocompleteRef.current) {
- if (e.key === "Escape") {
- e.preventDefault();
- e.stopPropagation();
- setShowAutocomplete(false);
- setAutocompleteSuggestions([]);
- currentAutocompleteCommand.current = "";
- return false;
- }
+ if (e.altKey && !e.metaKey && !e.ctrlKey) {
+ const keyMappings: { [key: string]: string } = {
+ "7": "|",
+ "2": "€",
+ "8": "[",
+ "9": "]",
+ l: "@",
+ L: "@",
+ Digit7: "|",
+ Digit2: "€",
+ Digit8: "[",
+ Digit9: "]",
+ KeyL: "@",
+ };
- if (e.key === "ArrowDown" || e.key === "ArrowUp") {
- e.preventDefault();
- e.stopPropagation();
+ const char = keyMappings[e.key] || keyMappings[e.code];
+ if (char) {
+ e.preventDefault();
+ e.stopPropagation();
- const currentIndex = autocompleteSelectedIndexRef.current;
- const suggestionsLength = autocompleteSuggestionsRef.current.length;
+ if (webSocketRef.current?.readyState === 1) {
+ webSocketRef.current.send(
+ JSON.stringify({ type: "input", data: char }),
+ );
+ }
+ return false;
+ }
+ }
+ };
- if (e.key === "ArrowDown") {
- const newIndex =
- currentIndex < suggestionsLength - 1 ? currentIndex + 1 : 0;
- setAutocompleteSelectedIndex(newIndex);
- } else if (e.key === "ArrowUp") {
- const newIndex =
- currentIndex > 0 ? currentIndex - 1 : suggestionsLength - 1;
- setAutocompleteSelectedIndex(newIndex);
- }
- return false;
- }
+ element?.addEventListener("keydown", handleMacKeyboard, true);
- if (
- e.key === "Enter" &&
- autocompleteSuggestionsRef.current.length > 0
- ) {
- e.preventDefault();
- e.stopPropagation();
+ const resizeObserver = new ResizeObserver(() => {
+ if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
+ resizeTimeout.current = setTimeout(() => {
+ if (!isVisibleRef.current || !isReady) return;
+ performFit();
+ }, 50);
+ });
- const selectedCommand =
- autocompleteSuggestionsRef.current[
- autocompleteSelectedIndexRef.current
- ];
- const currentCmd = currentAutocompleteCommand.current;
- const completion = selectedCommand.substring(currentCmd.length);
+ resizeObserver.observe(xtermRef.current);
- if (webSocketRef.current?.readyState === 1) {
- for (const char of completion) {
- webSocketRef.current.send(
- JSON.stringify({ type: "input", data: char }),
- );
- }
- }
+ setVisible(true);
- updateCurrentCommandRef.current(selectedCommand);
+ return () => {
+ isUnmountingRef.current = true;
+ shouldNotReconnectRef.current = true;
+ isReconnectingRef.current = false;
+ setIsConnecting(false);
+ setVisible(false);
+ setIsReady(false);
+ isFittingRef.current = 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, hostConfig]);
- setShowAutocomplete(false);
- setAutocompleteSuggestions([]);
- currentAutocompleteCommand.current = "";
+ useEffect(() => {
+ if (!terminal) return;
- return false;
- }
+ const handleCustomKey = (e: KeyboardEvent): boolean => {
+ if (e.type !== "keydown") {
+ return true;
+ }
- if (
- e.key === "Tab" &&
- !e.ctrlKey &&
- !e.altKey &&
- !e.metaKey &&
- !e.shiftKey
- ) {
- e.preventDefault();
- e.stopPropagation();
- const currentIndex = autocompleteSelectedIndexRef.current;
- const suggestionsLength = autocompleteSuggestionsRef.current.length;
- const newIndex =
- currentIndex < suggestionsLength - 1 ? currentIndex + 1 : 0;
- setAutocompleteSelectedIndex(newIndex);
- return false;
- }
+ if (showAutocompleteRef.current) {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ e.stopPropagation();
+ setShowAutocomplete(false);
+ setAutocompleteSuggestions([]);
+ currentAutocompleteCommand.current = "";
+ return false;
+ }
- setShowAutocomplete(false);
- setAutocompleteSuggestions([]);
- currentAutocompleteCommand.current = "";
- return true;
- }
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
+ e.preventDefault();
+ e.stopPropagation();
- if (
- e.key === "Tab" &&
- !e.ctrlKey &&
- !e.altKey &&
- !e.metaKey &&
- !e.shiftKey
- ) {
- e.preventDefault();
- e.stopPropagation();
+ const currentIndex = autocompleteSelectedIndexRef.current;
+ const suggestionsLength = autocompleteSuggestionsRef.current.length;
- const autocompleteEnabled =
- localStorage.getItem("commandAutocomplete") !== "false";
+ if (e.key === "ArrowDown") {
+ const newIndex =
+ currentIndex < suggestionsLength - 1 ? currentIndex + 1 : 0;
+ setAutocompleteSelectedIndex(newIndex);
+ } else if (e.key === "ArrowUp") {
+ const newIndex =
+ currentIndex > 0 ? currentIndex - 1 : suggestionsLength - 1;
+ setAutocompleteSelectedIndex(newIndex);
+ }
+ return false;
+ }
- if (!autocompleteEnabled) {
- if (webSocketRef.current?.readyState === 1) {
- webSocketRef.current.send(
- JSON.stringify({ type: "input", data: "\t" }),
- );
- }
- return false;
- }
+ if (
+ e.key === "Enter" &&
+ autocompleteSuggestionsRef.current.length > 0
+ ) {
+ e.preventDefault();
+ e.stopPropagation();
- const currentCmd = getCurrentCommandRef.current().trim();
- if (currentCmd.length > 0 && webSocketRef.current?.readyState === 1) {
- const matches = autocompleteHistory.current
- .filter(
- (cmd) =>
- cmd.startsWith(currentCmd) &&
- cmd !== currentCmd &&
- cmd.length > currentCmd.length,
- )
- .slice(0, 5);
+ const selectedCommand =
+ autocompleteSuggestionsRef.current[
+ autocompleteSelectedIndexRef.current
+ ];
+ const currentCmd = currentAutocompleteCommand.current;
+ const completion = selectedCommand.substring(currentCmd.length);
- if (matches.length === 1) {
- const completedCommand = matches[0];
- const completion = completedCommand.substring(currentCmd.length);
+ if (webSocketRef.current?.readyState === 1) {
+ for (const char of completion) {
+ webSocketRef.current.send(
+ JSON.stringify({ type: "input", data: char }),
+ );
+ }
+ }
- for (const char of completion) {
- webSocketRef.current.send(
- JSON.stringify({ type: "input", data: char }),
- );
- }
+ updateCurrentCommandRef.current(selectedCommand);
- updateCurrentCommandRef.current(completedCommand);
- } else if (matches.length > 1) {
- currentAutocompleteCommand.current = currentCmd;
- setAutocompleteSuggestions(matches);
- setAutocompleteSelectedIndex(0);
+ setShowAutocomplete(false);
+ setAutocompleteSuggestions([]);
+ currentAutocompleteCommand.current = "";
- const cursorY = terminal.buffer.active.cursorY;
- const cursorX = terminal.buffer.active.cursorX;
- const rect = xtermRef.current?.getBoundingClientRect();
+ return false;
+ }
- if (rect) {
- const cellHeight =
- terminal.rows > 0 ? rect.height / terminal.rows : 20;
- const cellWidth =
- terminal.cols > 0 ? rect.width / terminal.cols : 10;
+ if (
+ e.key === "Tab" &&
+ !e.ctrlKey &&
+ !e.altKey &&
+ !e.metaKey &&
+ !e.shiftKey
+ ) {
+ e.preventDefault();
+ e.stopPropagation();
+ const currentIndex = autocompleteSelectedIndexRef.current;
+ const suggestionsLength = autocompleteSuggestionsRef.current.length;
+ const newIndex =
+ currentIndex < suggestionsLength - 1 ? currentIndex + 1 : 0;
+ setAutocompleteSelectedIndex(newIndex);
+ return false;
+ }
- const itemHeight = 32;
- const footerHeight = 32;
- const maxMenuHeight = 240;
- const estimatedMenuHeight = Math.min(
- matches.length * itemHeight + footerHeight,
- maxMenuHeight,
- );
- const cursorBottomY = rect.top + (cursorY + 1) * cellHeight;
- const cursorTopY = rect.top + cursorY * cellHeight;
- const spaceBelow = window.innerHeight - cursorBottomY;
- const spaceAbove = cursorTopY;
+ setShowAutocomplete(false);
+ setAutocompleteSuggestions([]);
+ currentAutocompleteCommand.current = "";
+ return true;
+ }
- const showAbove =
- spaceBelow < estimatedMenuHeight && spaceAbove > spaceBelow;
+ if (
+ e.key === "Tab" &&
+ !e.ctrlKey &&
+ !e.altKey &&
+ !e.metaKey &&
+ !e.shiftKey
+ ) {
+ e.preventDefault();
+ e.stopPropagation();
- setAutocompletePosition({
- top: showAbove
- ? Math.max(0, cursorTopY - estimatedMenuHeight)
- : cursorBottomY,
- left: Math.max(0, rect.left + cursorX * cellWidth),
+ const autocompleteEnabled =
+ localStorage.getItem("commandAutocomplete") !== "false";
+
+ if (!autocompleteEnabled) {
+ if (webSocketRef.current?.readyState === 1) {
+ webSocketRef.current.send(
+ JSON.stringify({ type: "input", data: "\t" }),
+ );
+ }
+ return false;
+ }
+
+ const currentCmd = getCurrentCommandRef.current().trim();
+ if (currentCmd.length > 0 && webSocketRef.current?.readyState === 1) {
+ const matches = autocompleteHistory.current
+ .filter(
+ (cmd) =>
+ cmd.startsWith(currentCmd) &&
+ cmd !== currentCmd &&
+ cmd.length > currentCmd.length,
+ )
+ .slice(0, 5);
+
+ if (matches.length === 1) {
+ const completedCommand = matches[0];
+ const completion = completedCommand.substring(currentCmd.length);
+
+ for (const char of completion) {
+ webSocketRef.current.send(
+ JSON.stringify({ type: "input", data: char }),
+ );
+ }
+
+ updateCurrentCommandRef.current(completedCommand);
+ } else if (matches.length > 1) {
+ currentAutocompleteCommand.current = currentCmd;
+ setAutocompleteSuggestions(matches);
+ setAutocompleteSelectedIndex(0);
+
+ const cursorY = terminal.buffer.active.cursorY;
+ const cursorX = terminal.buffer.active.cursorX;
+ const rect = xtermRef.current?.getBoundingClientRect();
+
+ if (rect) {
+ const cellHeight =
+ terminal.rows > 0 ? rect.height / terminal.rows : 20;
+ const cellWidth =
+ terminal.cols > 0 ? rect.width / terminal.cols : 10;
+
+ const itemHeight = 32;
+ const footerHeight = 32;
+ const maxMenuHeight = 240;
+ const estimatedMenuHeight = Math.min(
+ matches.length * itemHeight + footerHeight,
+ maxMenuHeight,
+ );
+ const cursorBottomY = rect.top + (cursorY + 1) * cellHeight;
+ const cursorTopY = rect.top + cursorY * cellHeight;
+ const spaceBelow = window.innerHeight - cursorBottomY;
+ const spaceAbove = cursorTopY;
+
+ const showAbove =
+ spaceBelow < estimatedMenuHeight && spaceAbove > spaceBelow;
+
+ setAutocompletePosition({
+ top: showAbove
+ ? Math.max(0, cursorTopY - estimatedMenuHeight)
+ : cursorBottomY,
+ left: Math.max(0, rect.left + cursorX * cellWidth),
+ });
+ }
+
+ setShowAutocomplete(true);
+ }
+ }
+ return false;
+ }
+
+ return true;
+ };
+
+ terminal.attachCustomKeyEventHandler(handleCustomKey);
+ }, [terminal]);
+
+ useEffect(() => {
+ if (!terminal || !hostConfig || !visible) return;
+
+ if (isConnected || isConnecting) return;
+
+ setIsConnecting(true);
+
+ const readyFonts =
+ (document as { fonts?: { ready?: Promise } }).fonts
+ ?.ready instanceof Promise
+ ? (document as { fonts?: { ready?: Promise } }).fonts.ready
+ : Promise.resolve();
+
+ readyFonts.then(() => {
+ requestAnimationFrame(() => {
+ fitAddonRef.current?.fit();
+ if (terminal && terminal.cols > 0 && terminal.rows > 0) {
+ scheduleNotify(terminal.cols, terminal.rows);
+ }
+ hardRefresh();
+
+ setVisible(true);
+ setIsReady(true);
+
+ if (terminal && !splitScreen) {
+ terminal.focus();
+ }
+
+ const jwtToken = getCookie("jwt");
+
+ if (!jwtToken || jwtToken.trim() === "") {
+ setIsConnected(false);
+ setIsConnecting(false);
+ setConnectionError("Authentication required");
+ return;
+ }
+
+ const cols = terminal.cols;
+ const rows = terminal.rows;
+
+ connectToHost(cols, rows);
});
- }
+ });
+ }, [terminal, hostConfig, visible, isConnected, isConnecting, splitScreen]);
- setShowAutocomplete(true);
+ useEffect(() => {
+ if (!isVisible || !isReady || !fitAddonRef.current || !terminal) {
+ return;
}
- }
- return false;
- }
- return true;
- };
+ let rafId: number;
- terminal.attachCustomKeyEventHandler(handleCustomKey);
- }, [terminal]);
+ rafId = requestAnimationFrame(() => {
+ performFit();
+ });
- useEffect(() => {
- if (!terminal || !hostConfig || !visible) return;
+ return () => {
+ if (rafId) cancelAnimationFrame(rafId);
+ };
+ }, [isVisible, isReady, splitScreen, terminal]);
- if (isConnected || isConnecting) return;
-
- setIsConnecting(true);
-
- const readyFonts =
- (document as { fonts?: { ready?: Promise } }).fonts
- ?.ready instanceof Promise
- ? (document as { fonts?: { ready?: Promise } }).fonts.ready
- : Promise.resolve();
-
- readyFonts.then(() => {
- requestAnimationFrame(() => {
- fitAddonRef.current?.fit();
- if (terminal && terminal.cols > 0 && terminal.rows > 0) {
- scheduleNotify(terminal.cols, terminal.rows);
- }
- hardRefresh();
-
- setVisible(true);
- setIsReady(true);
-
- if (terminal && !splitScreen) {
- terminal.focus();
- }
-
- const jwtToken = getCookie("jwt");
-
- if (!jwtToken || jwtToken.trim() === "") {
- setIsConnected(false);
- setIsConnecting(false);
- setConnectionError("Authentication required");
- return;
- }
-
- const cols = terminal.cols;
- const rows = terminal.rows;
-
- connectToHost(cols, rows);
- });
- });
- }, [terminal, hostConfig, visible, isConnected, isConnecting, splitScreen]);
-
- useEffect(() => {
- if (!isVisible || !isReady || !fitAddonRef.current || !terminal) {
- return;
- }
-
- let rafId: number;
-
- rafId = requestAnimationFrame(() => {
- performFit();
- });
-
- return () => {
- if (rafId) cancelAnimationFrame(rafId);
- };
- }, [isVisible, isReady, splitScreen, terminal]);
-
- useEffect(() => {
- if (
- isFitted &&
- isVisible &&
- isReady &&
- !isConnecting &&
- terminal &&
- !splitScreen
- ) {
- const rafId = requestAnimationFrame(() => {
- terminal.focus();
- });
- return () => cancelAnimationFrame(rafId);
- }
- }, [isFitted, isVisible, isReady, isConnecting, terminal, splitScreen]);
-
- return (
-
-
{
- if (terminal && !splitScreen) {
- terminal.focus();
+ useEffect(() => {
+ if (
+ isFitted &&
+ isVisible &&
+ isReady &&
+ !isConnecting &&
+ terminal &&
+ !splitScreen
+ ) {
+ const rafId = requestAnimationFrame(() => {
+ terminal.focus();
+ });
+ return () => cancelAnimationFrame(rafId);
}
- }}
- />
+ }, [isFitted, isVisible, isReady, isConnecting, terminal, splitScreen]);
-
+ return (
+
+
{
+ if (terminal && !splitScreen) {
+ terminal.focus();
+ }
+ }}
+ />
-
+
-
+
-
-
- );
- },
+
+
+
{
+ setShowSudoPasswordPopup(false);
+ if (webSocketRef.current && webSocketRef.current.readyState === WebSocket.OPEN) {
+ webSocketRef.current.send(JSON.stringify({ type: "input", data: password + "\n" }));
+ }
+ }}
+ onDismiss={() => setShowSudoPasswordPopup(false)}
+ />
+
+
+
+ );
+ },
);
const style = document.createElement("style");
diff --git a/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx b/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx
index 649bc1a1..0ee5b574 100644
--- a/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx
+++ b/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx
@@ -197,9 +197,11 @@ export function SSHToolsSidebar({
);
const [draggedSnippet, setDraggedSnippet] = useState
(null);
const [dragOverFolder, setDragOverFolder] = useState(null);
- const [collapsedFolders, setCollapsedFolders] = useState>(
- new Set(),
- );
+ const [collapsedFolders, setCollapsedFolders] = useState>(() => {
+ const shouldCollapse =
+ localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false";
+ return shouldCollapse ? new Set() : new Set();
+ });
const [showFolderDialog, setShowFolderDialog] = useState(false);
const [editingFolder, setEditingFolder] = useState(
null,
@@ -351,6 +353,55 @@ export function SSHToolsSidebar({
}
}, [isOpen, activeTab]);
+ useEffect(() => {
+ if (snippetFolders.length > 0) {
+ const shouldCollapse =
+ localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false";
+ if (shouldCollapse) {
+ const allFolderNames = new Set(snippetFolders.map((f) => f.name));
+ const uncategorizedSnippets = snippets.filter(
+ (s) => !s.folder || s.folder === "",
+ );
+ if (uncategorizedSnippets.length > 0) {
+ allFolderNames.add("");
+ }
+ setCollapsedFolders(allFolderNames);
+ } else {
+ setCollapsedFolders(new Set());
+ }
+ }
+ }, [snippetFolders, snippets]);
+
+ useEffect(() => {
+ const handleSettingChange = () => {
+ const shouldCollapse =
+ localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false";
+ if (shouldCollapse) {
+ const allFolderNames = new Set(snippetFolders.map((f) => f.name));
+ const uncategorizedSnippets = snippets.filter(
+ (s) => !s.folder || s.folder === "",
+ );
+ if (uncategorizedSnippets.length > 0) {
+ allFolderNames.add("");
+ }
+ setCollapsedFolders(allFolderNames);
+ } else {
+ setCollapsedFolders(new Set());
+ }
+ };
+
+ window.addEventListener(
+ "defaultSnippetFoldersCollapsedChanged",
+ handleSettingChange,
+ );
+ return () => {
+ window.removeEventListener(
+ "defaultSnippetFoldersCollapsedChanged",
+ handleSettingChange,
+ );
+ };
+ }, [snippetFolders, snippets]);
+
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
diff --git a/src/ui/desktop/apps/tunnel/TunnelManager.tsx b/src/ui/desktop/apps/tunnel/TunnelManager.tsx
new file mode 100644
index 00000000..e280a594
--- /dev/null
+++ b/src/ui/desktop/apps/tunnel/TunnelManager.tsx
@@ -0,0 +1,143 @@
+import React from "react";
+import { useSidebar } from "@/components/ui/sidebar.tsx";
+import { Separator } from "@/components/ui/separator.tsx";
+import { Tunnel } from "@/ui/desktop/apps/tunnel/Tunnel.tsx";
+import { useTranslation } from "react-i18next";
+
+interface HostConfig {
+ id: number;
+ name: string;
+ ip: string;
+ username: string;
+ folder?: string;
+ enableFileManager?: boolean;
+ tunnelConnections?: unknown[];
+ [key: string]: unknown;
+}
+
+interface TunnelManagerProps {
+ hostConfig?: HostConfig;
+ title?: string;
+ isVisible?: boolean;
+ isTopbarOpen?: boolean;
+ embedded?: boolean;
+}
+
+export function TunnelManager({
+ hostConfig,
+ title,
+ isVisible = true,
+ isTopbarOpen = true,
+ embedded = false,
+}: TunnelManagerProps): React.ReactElement {
+ const { t } = useTranslation();
+ const { state: sidebarState } = useSidebar();
+ const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
+
+ React.useEffect(() => {
+ if (hostConfig?.id !== currentHostConfig?.id) {
+ setCurrentHostConfig(hostConfig);
+ }
+ }, [hostConfig?.id]);
+
+ React.useEffect(() => {
+ const fetchLatestHostConfig = async () => {
+ if (hostConfig?.id) {
+ try {
+ const { getSSHHosts } = await import("@/ui/main-axios.ts");
+ const hosts = await getSSHHosts();
+ const updatedHost = hosts.find((h) => h.id === hostConfig.id);
+ if (updatedHost) {
+ setCurrentHostConfig(updatedHost);
+ }
+ } catch {
+ // Silently handle error
+ }
+ }
+ };
+
+ fetchLatestHostConfig();
+
+ const handleHostsChanged = async () => {
+ if (hostConfig?.id) {
+ try {
+ const { getSSHHosts } = await import("@/ui/main-axios.ts");
+ const hosts = await getSSHHosts();
+ const updatedHost = hosts.find((h) => h.id === hostConfig.id);
+ if (updatedHost) {
+ setCurrentHostConfig(updatedHost);
+ }
+ } catch {
+ // Silently handle error
+ }
+ }
+ };
+
+ window.addEventListener("ssh-hosts:changed", handleHostsChanged);
+ return () =>
+ window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
+ }, [hostConfig?.id]);
+
+ const topMarginPx = isTopbarOpen ? 74 : 16;
+ const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
+ const bottomMarginPx = 8;
+
+ const wrapperStyle: React.CSSProperties = embedded
+ ? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
+ : {
+ opacity: isVisible ? 1 : 0,
+ marginLeft: leftMarginPx,
+ marginRight: 17,
+ marginTop: topMarginPx,
+ marginBottom: bottomMarginPx,
+ height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
+ };
+
+ const containerClass = embedded
+ ? "h-full w-full text-white overflow-hidden bg-transparent"
+ : "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
+
+ return (
+
+
+
+
+
+
+ {currentHostConfig?.folder} / {title}
+
+
+
+
+
+
+
+ {currentHostConfig?.tunnelConnections &&
+ currentHostConfig.tunnelConnections.length > 0 ? (
+
+
+
+ ) : (
+
+
+
+ {t("tunnel.noTunnelsConfigured")}
+
+
+ {t("tunnel.configureTunnelsInHostSettings")}
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/ui/desktop/apps/tunnel/TunnelViewer.tsx b/src/ui/desktop/apps/tunnel/TunnelViewer.tsx
index 10df33f7..28d22b73 100644
--- a/src/ui/desktop/apps/tunnel/TunnelViewer.tsx
+++ b/src/ui/desktop/apps/tunnel/TunnelViewer.tsx
@@ -43,11 +43,6 @@ export function TunnelViewer({
return (
-
-
- {t("tunnels.title")}
-
-
{activeHost.tunnelConnections.map((t, idx) => (
diff --git a/src/ui/desktop/navigation/AppView.tsx b/src/ui/desktop/navigation/AppView.tsx
index 51baf6e4..3d2f845c 100644
--- a/src/ui/desktop/navigation/AppView.tsx
+++ b/src/ui/desktop/navigation/AppView.tsx
@@ -1,7 +1,9 @@
import React, { useEffect, useRef, useState, useMemo } from "react";
import { Terminal } from "@/ui/desktop/apps/terminal/Terminal.tsx";
-import { Server as ServerView } from "@/ui/desktop/apps/server/Server.tsx";
+import { ServerStats as ServerView } from "@/ui/desktop/apps/server-stats/ServerStats.tsx";
import { FileManager } from "@/ui/desktop/apps/file-manager/FileManager.tsx";
+import { TunnelManager } from "@/ui/desktop/apps/tunnel/TunnelManager.tsx";
+import { DockerManager } from "@/ui/desktop/apps/docker/DockerManager.tsx";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import {
ResizablePanelGroup,
@@ -58,7 +60,9 @@ export function AppView({
(tab: TabData) =>
tab.type === "terminal" ||
tab.type === "server" ||
- tab.type === "file_manager",
+ tab.type === "file_manager" ||
+ tab.type === "tunnel" ||
+ tab.type === "docker",
),
[tabs],
);
@@ -210,7 +214,10 @@ export function AppView({
const mainTab = terminalTabs.find((tab: TabData) => tab.id === currentTab);
if (allSplitScreenTab.length === 0 && mainTab) {
- const isFileManagerTab = mainTab.type === "file_manager";
+ const isFileManagerTab =
+ mainTab.type === "file_manager" ||
+ mainTab.type === "tunnel" ||
+ mainTab.type === "docker";
const newStyle = {
position: "absolute" as const,
top: isFileManagerTab ? 0 : 4,
@@ -257,9 +264,14 @@ export function AppView({
const isVisible =
hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab);
+ const effectiveVisible = isVisible;
+
const previousStyle = previousStylesRef.current[t.id];
- const isFileManagerTab = t.type === "file_manager";
+ const isFileManagerTab =
+ t.type === "file_manager" ||
+ t.type === "tunnel" ||
+ t.type === "docker";
const standardStyle = {
position: "absolute" as const,
top: isFileManagerTab ? 0 : 4,
@@ -270,16 +282,24 @@ export function AppView({
const finalStyle: React.CSSProperties = hasStyle
? { ...styles[t.id], overflow: "hidden" }
- : ({
- ...(previousStyle || standardStyle),
- opacity: 0,
- pointerEvents: "none",
- zIndex: 0,
- transition: "opacity 150ms ease-in-out",
- overflow: "hidden",
- } as React.CSSProperties);
-
- const effectiveVisible = isVisible;
+ : effectiveVisible
+ ? {
+ ...(previousStyle || standardStyle),
+ opacity: 1,
+ pointerEvents: "auto",
+ zIndex: 20,
+ display: "block",
+ transition: "opacity 150ms ease-in-out",
+ overflow: "hidden",
+ }
+ : ({
+ ...(previousStyle || standardStyle),
+ opacity: 0,
+ pointerEvents: "none",
+ zIndex: 0,
+ transition: "opacity 150ms ease-in-out",
+ overflow: "hidden",
+ } as React.CSSProperties);
const isTerminal = t.type === "terminal";
const terminalConfig = {
@@ -317,6 +337,22 @@ export function AppView({
isTopbarOpen={isTopbarOpen}
embedded
/>
+ ) : t.type === "tunnel" ? (
+
+ ) : t.type === "docker" ? (
+
) : (
tab.id === currentTab);
const isFileManager = currentTabData?.type === "file_manager";
+ const isTunnel = currentTabData?.type === "tunnel";
+ const isDocker = currentTabData?.type === "docker";
const isTerminal = currentTabData?.type === "terminal";
const isSplitScreen = allSplitScreenTab.length > 0;
@@ -653,7 +691,7 @@ export function AppView({
const bottomMarginPx = 8;
let containerBackground = "var(--color-dark-bg)";
- if (isFileManager && !isSplitScreen) {
+ if ((isFileManager || isTunnel || isDocker) && !isSplitScreen) {
containerBackground = "var(--color-dark-bg-darkest)";
} else if (isTerminal) {
containerBackground = terminalBackgroundColor;
diff --git a/src/ui/desktop/navigation/TopNavbar.tsx b/src/ui/desktop/navigation/TopNavbar.tsx
index 2cb11ef4..4838726c 100644
--- a/src/ui/desktop/navigation/TopNavbar.tsx
+++ b/src/ui/desktop/navigation/TopNavbar.tsx
@@ -369,10 +369,13 @@ export function TopNavbar({
const isTerminal = tab.type === "terminal";
const isServer = tab.type === "server";
const isFileManager = tab.type === "file_manager";
+ const isTunnel = tab.type === "tunnel";
+ const isDocker = tab.type === "docker";
const isSshManager = tab.type === "ssh_manager";
const isAdmin = tab.type === "admin";
const isUserProfile = tab.type === "user_profile";
- const isSplittable = isTerminal || isServer || isFileManager;
+ const isSplittable =
+ isTerminal || isServer || isFileManager || isTunnel || isDocker;
const disableSplit = !isSplittable;
const disableActivate =
isSplit ||
@@ -484,6 +487,8 @@ export function TopNavbar({
isTerminal ||
isServer ||
isFileManager ||
+ isTunnel ||
+ isDocker ||
isSshManager ||
isAdmin ||
isUserProfile
@@ -498,6 +503,8 @@ export function TopNavbar({
isTerminal ||
isServer ||
isFileManager ||
+ isTunnel ||
+ isDocker ||
isSshManager ||
isAdmin ||
isUserProfile
diff --git a/src/ui/desktop/navigation/hosts/Host.tsx b/src/ui/desktop/navigation/hosts/Host.tsx
index 18032159..f085f641 100644
--- a/src/ui/desktop/navigation/hosts/Host.tsx
+++ b/src/ui/desktop/navigation/hosts/Host.tsx
@@ -8,6 +8,8 @@ import {
Server,
FolderOpen,
Pencil,
+ ArrowDownUp,
+ Container,
} from "lucide-react";
import {
DropdownMenu,
@@ -63,6 +65,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
}, [host.statsConfig]);
const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
+ const shouldShowMetrics = statsConfig.metricsEnabled !== false;
useEffect(() => {
if (!shouldShowStatus) {
@@ -151,24 +154,50 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
side="right"
className="w-56 bg-dark-bg border-dark-border text-white"
>
-
- addTab({ type: "server", title, hostConfig: host })
- }
- className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
- >
-
- Open Server Details
-
-
- addTab({ type: "file_manager", title, hostConfig: host })
- }
- className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
- >
-
- Open File Manager
-
+ {shouldShowMetrics && (
+
+ addTab({ type: "server", title, hostConfig: host })
+ }
+ className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
+ >
+
+ Open Server Stats
+
+ )}
+ {host.enableFileManager && (
+
+ addTab({ type: "file_manager", title, hostConfig: host })
+ }
+ className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
+ >
+
+ Open File Manager
+
+ )}
+ {host.enableTunnel && (
+
+ addTab({ type: "tunnel", title, hostConfig: host })
+ }
+ className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
+ >
+
+ Open Tunnels
+
+ )}
+ {host.enableDocker && (
+
+ addTab({ type: "docker", title, hostConfig: host })
+ }
+ className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
+ >
+
+ Open Docker
+
+ )}
addTab({
diff --git a/src/ui/desktop/navigation/tabs/Tab.tsx b/src/ui/desktop/navigation/tabs/Tab.tsx
index 18dc75e0..0d9f2c1e 100644
--- a/src/ui/desktop/navigation/tabs/Tab.tsx
+++ b/src/ui/desktop/navigation/tabs/Tab.tsx
@@ -10,6 +10,8 @@ import {
Server as ServerIcon,
Folder as FolderIcon,
User as UserIcon,
+ ArrowDownUp as TunnelIcon,
+ Container as DockerIcon,
} from "lucide-react";
interface TabProps {
@@ -119,10 +121,14 @@ export function Tab({
tabType === "terminal" ||
tabType === "server" ||
tabType === "file_manager" ||
+ tabType === "tunnel" ||
+ tabType === "docker" ||
tabType === "user_profile"
) {
const isServer = tabType === "server";
const isFileManager = tabType === "file_manager";
+ const isTunnel = tabType === "tunnel";
+ const isDocker = tabType === "docker";
const isUserProfile = tabType === "user_profile";
const displayTitle =
@@ -131,9 +137,13 @@ export function Tab({
? t("nav.serverStats")
: isFileManager
? t("nav.fileManager")
- : isUserProfile
- ? t("nav.userProfile")
- : t("nav.terminal"));
+ : isTunnel
+ ? t("nav.tunnels")
+ : isDocker
+ ? t("nav.docker")
+ : isUserProfile
+ ? t("nav.userProfile")
+ : t("nav.terminal"));
const { base, suffix } = splitTitle(displayTitle);
@@ -151,6 +161,10 @@ export function Tab({
) : isFileManager ? (
+ ) : isTunnel ? (
+
+ ) : isDocker ? (
+
) : isUserProfile ? (
) : (
diff --git a/src/ui/desktop/navigation/tabs/TabContext.tsx b/src/ui/desktop/navigation/tabs/TabContext.tsx
index 7bcd6fe9..61347d6b 100644
--- a/src/ui/desktop/navigation/tabs/TabContext.tsx
+++ b/src/ui/desktop/navigation/tabs/TabContext.tsx
@@ -76,7 +76,11 @@ export function TabProvider({ children }: TabProviderProps) {
? t("nav.serverStats")
: tabType === "file_manager"
? t("nav.fileManager")
- : t("nav.terminal");
+ : tabType === "tunnel"
+ ? t("nav.tunnels")
+ : tabType === "docker"
+ ? t("nav.docker")
+ : t("nav.terminal");
const baseTitle = (desiredTitle || defaultTitle).trim();
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
const root = match ? match[1] : baseTitle;
@@ -137,7 +141,9 @@ export function TabProvider({ children }: TabProviderProps) {
const needsUniqueTitle =
tabData.type === "terminal" ||
tabData.type === "server" ||
- tabData.type === "file_manager";
+ tabData.type === "file_manager" ||
+ tabData.type === "tunnel" ||
+ tabData.type === "docker";
const effectiveTitle = needsUniqueTitle
? computeUniqueTitle(tabData.type, tabData.title)
: tabData.title || "";
diff --git a/src/ui/desktop/navigation/tabs/TabDropdown.tsx b/src/ui/desktop/navigation/tabs/TabDropdown.tsx
index 578a550b..fdc2a711 100644
--- a/src/ui/desktop/navigation/tabs/TabDropdown.tsx
+++ b/src/ui/desktop/navigation/tabs/TabDropdown.tsx
@@ -12,6 +12,8 @@ import {
Terminal as TerminalIcon,
Server as ServerIcon,
Folder as FolderIcon,
+ ArrowDownUp as TunnelIcon,
+ Container as DockerIcon,
Shield as AdminIcon,
Network as SshManagerIcon,
User as UserIcon,
@@ -33,6 +35,10 @@ export function TabDropdown(): React.ReactElement {
return ;
case "file_manager":
return ;
+ case "tunnel":
+ return ;
+ case "docker":
+ return ;
case "user_profile":
return ;
case "ssh_manager":
@@ -52,6 +58,10 @@ export function TabDropdown(): React.ReactElement {
return tab.title || t("nav.serverStats");
case "file_manager":
return tab.title || t("nav.fileManager");
+ case "tunnel":
+ return tab.title || t("nav.tunnels");
+ case "docker":
+ return tab.title || t("nav.docker");
case "user_profile":
return tab.title || t("nav.userProfile");
case "ssh_manager":
diff --git a/src/ui/desktop/user/LanguageSwitcher.tsx b/src/ui/desktop/user/LanguageSwitcher.tsx
index 0adc6658..9021ec82 100644
--- a/src/ui/desktop/user/LanguageSwitcher.tsx
+++ b/src/ui/desktop/user/LanguageSwitcher.tsx
@@ -20,6 +20,8 @@ const languages = [
},
{ code: "ru", name: "Russian", nativeName: "Русский" },
{ code: "fr", name: "French", nativeName: "Français" },
+ { code: "it", name: "Italian", nativeName: "Italiano" },
+ { code: "ko", name: "Korean", nativeName: "한국어" },
];
export function LanguageSwitcher() {
diff --git a/src/ui/desktop/user/UserProfile.tsx b/src/ui/desktop/user/UserProfile.tsx
index 69da72a0..557c8121 100644
--- a/src/ui/desktop/user/UserProfile.tsx
+++ b/src/ui/desktop/user/UserProfile.tsx
@@ -19,6 +19,8 @@ import {
deleteAccount,
logoutUser,
isElectron,
+ getUserRoles,
+ type UserRole,
} from "@/ui/main-axios.ts";
import { PasswordReset } from "@/ui/desktop/user/PasswordReset.tsx";
import { useTranslation } from "react-i18next";
@@ -101,6 +103,11 @@ export function UserProfile({
const [commandAutocomplete, setCommandAutocomplete] = useState(
localStorage.getItem("commandAutocomplete") !== "false",
);
+ const [defaultSnippetFoldersCollapsed, setDefaultSnippetFoldersCollapsed] =
+ useState(
+ localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false",
+ );
+ const [userRoles, setUserRoles] = useState([]);
useEffect(() => {
fetchUserInfo();
@@ -129,6 +136,15 @@ export function UserProfile({
is_dual_auth: info.is_dual_auth || false,
totp_enabled: info.totp_enabled || false,
});
+
+ // Fetch user roles
+ try {
+ const rolesResponse = await getUserRoles(info.userId);
+ setUserRoles(rolesResponse.roles || []);
+ } catch (rolesErr) {
+ console.error("Failed to fetch user roles:", rolesErr);
+ setUserRoles([]);
+ }
} catch (err: unknown) {
const error = err as { response?: { data?: { error?: string } } };
setError(error?.response?.data?.error || t("errors.loadFailed"));
@@ -154,6 +170,12 @@ export function UserProfile({
localStorage.setItem("commandAutocomplete", enabled.toString());
};
+ const handleDefaultSnippetFoldersCollapsedToggle = (enabled: boolean) => {
+ setDefaultSnippetFoldersCollapsed(enabled);
+ localStorage.setItem("defaultSnippetFoldersCollapsed", enabled.toString());
+ window.dispatchEvent(new Event("defaultSnippetFoldersCollapsedChanged"));
+ };
+
const handleDeleteAccount = async (e: React.FormEvent) => {
e.preventDefault();
setDeleteLoading(true);
@@ -294,11 +316,26 @@ export function UserProfile({
-
- {userInfo.is_admin
- ? t("interface.administrator")
- : t("interface.user")}
-
+
+ {userRoles.length > 0 ? (
+
+ {userRoles.map((role) => (
+
+ {t(role.roleDisplayName)}
+
+ ))}
+
+ ) : (
+
+ {userInfo.is_admin
+ ? t("interface.administrator")
+ : t("interface.user")}
+
+ )}
+
+
+
+
+
+
+ {t("profile.defaultSnippetFoldersCollapsedDesc")}
+
+
+
+
+
+
diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts
index 902eae9a..8ad36681 100644
--- a/src/ui/main-axios.ts
+++ b/src/ui/main-axios.ts
@@ -7,7 +7,53 @@ import type {
TunnelStatus,
FileManagerFile,
FileManagerShortcut,
+ DockerContainer,
+ DockerStats,
+ DockerLogOptions,
+ DockerValidation,
} from "../types/index.js";
+
+// ============================================================================
+// RBAC TYPE DEFINITIONS
+// ============================================================================
+
+export interface Role {
+ id: number;
+ name: string;
+ displayName: string;
+ description: string | null;
+ isSystem: boolean;
+ permissions: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface UserRole {
+ userId: string;
+ roleId: number;
+ roleName: string;
+ roleDisplayName: string;
+ grantedBy: string;
+ grantedByUsername: string;
+ grantedAt: string;
+}
+
+export interface AccessRecord {
+ id: number;
+ targetType: "user" | "role";
+ userId: string | null;
+ roleId: number | null;
+ username: string | null;
+ roleName: string | null;
+ roleDisplayName: string | null;
+ grantedBy: string;
+ grantedByUsername: string;
+ permissionLevel: string;
+ expiresAt: string | null;
+ createdAt: string;
+ lastAccessedAt: string | null;
+ accessCount: number;
+}
import {
apiLogger,
authLogger,
@@ -594,6 +640,12 @@ function initializeApiInstances() {
// Homepage API (port 30006)
homepageApi = createApiInstance(getApiUrl("", 30006), "HOMEPAGE");
+
+ // RBAC API (port 30001)
+ rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC");
+
+ // Docker Management API (port 30007)
+ dockerApi = createApiInstance(getApiUrl("/docker", 30007), "DOCKER");
}
// SSH Host Management API (port 30001)
@@ -614,6 +666,12 @@ export let authApi: AxiosInstance;
// Homepage API (port 30006)
export let homepageApi: AxiosInstance;
+// RBAC API (port 30001)
+export let rbacApi: AxiosInstance;
+
+// Docker Management API (port 30007)
+export let dockerApi: AxiosInstance;
+
function initializeApp() {
if (isElectron()) {
getServerConfig()
@@ -856,6 +914,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise {
enableTerminal: Boolean(hostData.enableTerminal),
enableTunnel: Boolean(hostData.enableTunnel),
enableFileManager: Boolean(hostData.enableFileManager),
+ enableDocker: Boolean(hostData.enableDocker),
defaultPath: hostData.defaultPath || "/",
tunnelConnections: hostData.tunnelConnections || [],
jumpHosts: hostData.jumpHosts || [],
@@ -865,6 +924,11 @@ export async function createSSHHost(hostData: SSHHostData): Promise {
? hostData.statsConfig
: JSON.stringify(hostData.statsConfig)
: null,
+ dockerConfig: hostData.dockerConfig
+ ? typeof hostData.dockerConfig === "string"
+ ? hostData.dockerConfig
+ : JSON.stringify(hostData.dockerConfig)
+ : null,
terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
useSocks5: Boolean(hostData.useSocks5),
@@ -928,6 +992,7 @@ export async function updateSSHHost(
enableTerminal: Boolean(hostData.enableTerminal),
enableTunnel: Boolean(hostData.enableTunnel),
enableFileManager: Boolean(hostData.enableFileManager),
+ enableDocker: Boolean(hostData.enableDocker),
defaultPath: hostData.defaultPath || "/",
tunnelConnections: hostData.tunnelConnections || [],
jumpHosts: hostData.jumpHosts || [],
@@ -937,6 +1002,11 @@ export async function updateSSHHost(
? hostData.statsConfig
: JSON.stringify(hostData.statsConfig)
: null,
+ dockerConfig: hostData.dockerConfig
+ ? typeof hostData.dockerConfig === "string"
+ ? hostData.dockerConfig
+ : JSON.stringify(hostData.dockerConfig)
+ : null,
terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
useSocks5: Boolean(hostData.useSocks5),
@@ -3128,3 +3198,361 @@ export async function unlinkOIDCFromPasswordAccount(
}
}
+// ============================================================================
+// RBAC MANAGEMENT
+// ============================================================================
+
+// Role Management
+export async function getRoles(): Promise<{ roles: Role[] }> {
+ try {
+ const response = await rbacApi.get("/rbac/roles");
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "fetch roles");
+ }
+}
+
+export async function createRole(roleData: {
+ name: string;
+ displayName: string;
+ description?: string | null;
+}): Promise<{ role: Role }> {
+ try {
+ const response = await rbacApi.post("/rbac/roles", roleData);
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "create role");
+ }
+}
+
+export async function updateRole(
+ roleId: number,
+ roleData: {
+ displayName?: string;
+ description?: string | null;
+ },
+): Promise<{ role: Role }> {
+ try {
+ const response = await rbacApi.put(`/rbac/roles/${roleId}`, roleData);
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "update role");
+ }
+}
+
+export async function deleteRole(roleId: number): Promise<{ success: boolean }> {
+ try {
+ const response = await rbacApi.delete(`/rbac/roles/${roleId}`);
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "delete role");
+ }
+}
+
+// User-Role Management
+export async function getUserRoles(userId: string): Promise<{ roles: UserRole[] }> {
+ try {
+ const response = await rbacApi.get(`/rbac/users/${userId}/roles`);
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "fetch user roles");
+ }
+}
+
+export async function assignRoleToUser(
+ userId: string,
+ roleId: number,
+): Promise<{ success: boolean }> {
+ try {
+ const response = await rbacApi.post(`/rbac/users/${userId}/roles`, { roleId });
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "assign role to user");
+ }
+}
+
+export async function removeRoleFromUser(
+ userId: string,
+ roleId: number,
+): Promise<{ success: boolean }> {
+ try {
+ const response = await rbacApi.delete(`/rbac/users/${userId}/roles/${roleId}`);
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "remove role from user");
+ }
+}
+
+// Host Sharing Management
+export async function shareHost(
+ hostId: number,
+ shareData: {
+ targetType: "user" | "role";
+ targetUserId?: string;
+ targetRoleId?: number;
+ permissionLevel: string;
+ durationHours?: number;
+ },
+): Promise<{ success: boolean }> {
+ try {
+ const response = await rbacApi.post(`/rbac/host/${hostId}/share`, shareData);
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "share host");
+ }
+}
+
+export async function getHostAccess(hostId: number): Promise<{ accessList: AccessRecord[] }> {
+ try {
+ const response = await rbacApi.get(`/rbac/host/${hostId}/access`);
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "fetch host access");
+ }
+}
+
+export async function revokeHostAccess(
+ hostId: number,
+ accessId: number,
+): Promise<{ success: boolean }> {
+ try {
+ const response = await rbacApi.delete(`/rbac/host/${hostId}/access/${accessId}`);
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "revoke host access");
+
+// ============================================================================
+// DOCKER MANAGEMENT API
+// ============================================================================
+
+export async function connectDockerSession(
+ sessionId: string,
+ hostId: number,
+): Promise<{ success: boolean; message: string }> {
+ try {
+ const response = await dockerApi.post("/ssh/connect", {
+ sessionId,
+ hostId,
+ });
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "connect to Docker SSH session");
+ }
+}
+
+export async function disconnectDockerSession(
+ sessionId: string,
+): Promise<{ success: boolean; message: string }> {
+ try {
+ const response = await dockerApi.post("/ssh/disconnect", {
+ sessionId,
+ });
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "disconnect from Docker SSH session");
+ }
+}
+
+export async function keepaliveDockerSession(
+ sessionId: string,
+): Promise<{ success: boolean }> {
+ try {
+ const response = await dockerApi.post("/ssh/keepalive", {
+ sessionId,
+ });
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "keepalive Docker SSH session");
+ }
+}
+
+export async function getDockerSessionStatus(
+ sessionId: string,
+): Promise<{ success: boolean; connected: boolean }> {
+ try {
+ const response = await dockerApi.get("/ssh/status", {
+ params: { sessionId },
+ });
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "get Docker session status");
+ }
+}
+
+export async function validateDockerAvailability(
+ sessionId: string,
+): Promise {
+ try {
+ const response = await dockerApi.get(`/validate/${sessionId}`);
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "validate Docker availability");
+ }
+}
+
+export async function listDockerContainers(
+ sessionId: string,
+ all: boolean = true,
+): Promise {
+ try {
+ const response = await dockerApi.get(`/containers/${sessionId}`, {
+ params: { all },
+ });
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "list Docker containers");
+ }
+}
+
+export async function getDockerContainerDetails(
+ sessionId: string,
+ containerId: string,
+): Promise {
+ try {
+ const response = await dockerApi.get(
+ `/containers/${sessionId}/${containerId}`,
+ );
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "get Docker container details");
+ }
+}
+
+export async function startDockerContainer(
+ sessionId: string,
+ containerId: string,
+): Promise<{ success: boolean; message: string }> {
+ try {
+ const response = await dockerApi.post(
+ `/containers/${sessionId}/${containerId}/start`,
+ );
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "start Docker container");
+ }
+}
+
+export async function stopDockerContainer(
+ sessionId: string,
+ containerId: string,
+): Promise<{ success: boolean; message: string }> {
+ try {
+ const response = await dockerApi.post(
+ `/containers/${sessionId}/${containerId}/stop`,
+ );
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "stop Docker container");
+ }
+}
+
+export async function restartDockerContainer(
+ sessionId: string,
+ containerId: string,
+): Promise<{ success: boolean; message: string }> {
+ try {
+ const response = await dockerApi.post(
+ `/containers/${sessionId}/${containerId}/restart`,
+ );
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "restart Docker container");
+ }
+}
+
+export async function pauseDockerContainer(
+ sessionId: string,
+ containerId: string,
+): Promise<{ success: boolean; message: string }> {
+ try {
+ const response = await dockerApi.post(
+ `/containers/${sessionId}/${containerId}/pause`,
+ );
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "pause Docker container");
+ }
+}
+
+export async function unpauseDockerContainer(
+ sessionId: string,
+ containerId: string,
+): Promise<{ success: boolean; message: string }> {
+ try {
+ const response = await dockerApi.post(
+ `/containers/${sessionId}/${containerId}/unpause`,
+ );
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "unpause Docker container");
+ }
+}
+
+export async function removeDockerContainer(
+ sessionId: string,
+ containerId: string,
+ force: boolean = false,
+): Promise<{ success: boolean; message: string }> {
+ try {
+ const response = await dockerApi.delete(
+ `/containers/${sessionId}/${containerId}/remove`,
+ {
+ params: { force },
+ },
+ );
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "remove Docker container");
+ }
+}
+
+export async function getContainerLogs(
+ sessionId: string,
+ containerId: string,
+ options?: DockerLogOptions,
+): Promise<{ logs: string }> {
+ try {
+ const response = await dockerApi.get(
+ `/containers/${sessionId}/${containerId}/logs`,
+ {
+ params: options,
+ },
+ );
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "get container logs");
+ }
+}
+
+export async function downloadContainerLogs(
+ sessionId: string,
+ containerId: string,
+ options?: DockerLogOptions,
+): Promise {
+ try {
+ const response = await dockerApi.get(
+ `/containers/${sessionId}/${containerId}/logs`,
+ {
+ params: { ...options, download: true },
+ responseType: "blob",
+ },
+ );
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "download container logs");
+ }
+}
+
+export async function getContainerStats(
+ sessionId: string,
+ containerId: string,
+): Promise {
+ try {
+ const response = await dockerApi.get(
+ `/containers/${sessionId}/${containerId}/stats`,
+ );
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "get container stats");
+ }
+}