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

This commit is contained in:
LukeGus
2025-12-18 02:18:08 -06:00
parent 7c9762562b
commit 4b4bff4b29
25 changed files with 843 additions and 80 deletions

View File

@@ -86,8 +86,12 @@ export const sshData = sqliteTable("ssh_data", {
enableFileManager: integer("enable_file_manager", { mode: "boolean" }) enableFileManager: integer("enable_file_manager", { mode: "boolean" })
.notNull() .notNull()
.default(true), .default(true),
enableDocker: integer("enable_docker", { mode: "boolean" })
.notNull()
.default(false),
defaultPath: text("default_path"), defaultPath: text("default_path"),
statsConfig: text("stats_config"), statsConfig: text("stats_config"),
dockerConfig: text("docker_config"),
terminalConfig: text("terminal_config"), terminalConfig: text("terminal_config"),
quickActions: text("quick_actions"), quickActions: text("quick_actions"),
createdAt: text("created_at") createdAt: text("created_at")

View File

@@ -235,11 +235,13 @@ router.post(
enableTerminal, enableTerminal,
enableTunnel, enableTunnel,
enableFileManager, enableFileManager,
enableDocker,
defaultPath, defaultPath,
tunnelConnections, tunnelConnections,
jumpHosts, jumpHosts,
quickActions, quickActions,
statsConfig, statsConfig,
dockerConfig,
terminalConfig, terminalConfig,
forceKeyboardInteractive, forceKeyboardInteractive,
} = hostData; } = hostData;
@@ -280,8 +282,10 @@ router.post(
? JSON.stringify(quickActions) ? JSON.stringify(quickActions)
: null, : null,
enableFileManager: enableFileManager ? 1 : 0, enableFileManager: enableFileManager ? 1 : 0,
enableDocker: enableDocker ? 1 : 0,
defaultPath: defaultPath || null, defaultPath: defaultPath || null,
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null, statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
dockerConfig: dockerConfig ? JSON.stringify(dockerConfig) : null,
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
}; };
@@ -341,9 +345,13 @@ router.post(
? JSON.parse(createdHost.jumpHosts as string) ? JSON.parse(createdHost.jumpHosts as string)
: [], : [],
enableFileManager: !!createdHost.enableFileManager, enableFileManager: !!createdHost.enableFileManager,
enableDocker: !!createdHost.enableDocker,
statsConfig: createdHost.statsConfig statsConfig: createdHost.statsConfig
? JSON.parse(createdHost.statsConfig as string) ? JSON.parse(createdHost.statsConfig as string)
: undefined, : undefined,
dockerConfig: createdHost.dockerConfig
? JSON.parse(createdHost.dockerConfig as string)
: undefined,
}; };
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost; const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost;
@@ -457,11 +465,13 @@ router.put(
enableTerminal, enableTerminal,
enableTunnel, enableTunnel,
enableFileManager, enableFileManager,
enableDocker,
defaultPath, defaultPath,
tunnelConnections, tunnelConnections,
jumpHosts, jumpHosts,
quickActions, quickActions,
statsConfig, statsConfig,
dockerConfig,
terminalConfig, terminalConfig,
forceKeyboardInteractive, forceKeyboardInteractive,
} = hostData; } = hostData;
@@ -503,8 +513,10 @@ router.put(
? JSON.stringify(quickActions) ? JSON.stringify(quickActions)
: null, : null,
enableFileManager: enableFileManager ? 1 : 0, enableFileManager: enableFileManager ? 1 : 0,
enableDocker: enableDocker ? 1 : 0,
defaultPath: defaultPath || null, defaultPath: defaultPath || null,
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null, statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
dockerConfig: dockerConfig ? JSON.stringify(dockerConfig) : null,
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
}; };
@@ -582,9 +594,13 @@ router.put(
? JSON.parse(updatedHost.jumpHosts as string) ? JSON.parse(updatedHost.jumpHosts as string)
: [], : [],
enableFileManager: !!updatedHost.enableFileManager, enableFileManager: !!updatedHost.enableFileManager,
enableDocker: !!updatedHost.enableDocker,
statsConfig: updatedHost.statsConfig statsConfig: updatedHost.statsConfig
? JSON.parse(updatedHost.statsConfig as string) ? JSON.parse(updatedHost.statsConfig as string)
: undefined, : undefined,
dockerConfig: updatedHost.dockerConfig
? JSON.parse(updatedHost.dockerConfig as string)
: undefined,
}; };
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost; const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost;
@@ -683,9 +699,13 @@ router.get(
? JSON.parse(row.quickActions as string) ? JSON.parse(row.quickActions as string)
: [], : [],
enableFileManager: !!row.enableFileManager, enableFileManager: !!row.enableFileManager,
enableDocker: !!row.enableDocker,
statsConfig: row.statsConfig statsConfig: row.statsConfig
? JSON.parse(row.statsConfig as string) ? JSON.parse(row.statsConfig as string)
: undefined, : undefined,
dockerConfig: row.dockerConfig
? JSON.parse(row.dockerConfig as string)
: undefined,
terminalConfig: row.terminalConfig terminalConfig: row.terminalConfig
? JSON.parse(row.terminalConfig as string) ? JSON.parse(row.terminalConfig as string)
: undefined, : undefined,

View File

@@ -14,6 +14,18 @@ export interface QuickAction {
snippetId: number; snippetId: number;
} }
export interface DockerConfig {
connectionType: "socket" | "tcp" | "tls";
socketPath?: string;
host?: string;
port?: number;
tlsVerify?: boolean;
tlsCaCert?: string;
tlsCert?: string;
tlsKey?: string;
apiVersion?: string;
}
export interface SSHHost { export interface SSHHost {
id: number; id: number;
name: string; name: string;
@@ -40,11 +52,13 @@ export interface SSHHost {
enableTerminal: boolean; enableTerminal: boolean;
enableTunnel: boolean; enableTunnel: boolean;
enableFileManager: boolean; enableFileManager: boolean;
enableDocker: boolean;
defaultPath: string; defaultPath: string;
tunnelConnections: TunnelConnection[]; tunnelConnections: TunnelConnection[];
jumpHosts?: JumpHost[]; jumpHosts?: JumpHost[];
quickActions?: QuickAction[]; quickActions?: QuickAction[];
statsConfig?: string; statsConfig?: string;
dockerConfig?: string;
terminalConfig?: TerminalConfig; terminalConfig?: TerminalConfig;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -77,12 +91,14 @@ export interface SSHHostData {
enableTerminal?: boolean; enableTerminal?: boolean;
enableTunnel?: boolean; enableTunnel?: boolean;
enableFileManager?: boolean; enableFileManager?: boolean;
enableDocker?: boolean;
defaultPath?: string; defaultPath?: string;
forceKeyboardInteractive?: boolean; forceKeyboardInteractive?: boolean;
tunnelConnections?: TunnelConnection[]; tunnelConnections?: TunnelConnection[];
jumpHosts?: JumpHostData[]; jumpHosts?: JumpHostData[];
quickActions?: QuickActionData[]; quickActions?: QuickActionData[];
statsConfig?: string | Record<string, unknown>; statsConfig?: string | Record<string, unknown>;
dockerConfig?: DockerConfig | string;
terminalConfig?: TerminalConfig; terminalConfig?: TerminalConfig;
} }
@@ -339,13 +355,14 @@ export interface TerminalConfig {
export interface TabContextTab { export interface TabContextTab {
id: number; id: number;
type: type:
| "home" | "home"
| "terminal" | "terminal"
| "ssh_manager" | "ssh_manager"
| "server" | "server"
| "admin" | "admin"
| "file_manager" | "file_manager"
| "user_profile"; | "user_profile"
| "docker";
title: string; title: string;
hostConfig?: SSHHost; hostConfig?: SSHHost;
terminalRef?: any; terminalRef?: any;

View File

@@ -155,7 +155,9 @@ function AppContent() {
const showTerminalView = const showTerminalView =
currentTabData?.type === "terminal" || currentTabData?.type === "terminal" ||
currentTabData?.type === "server" || currentTabData?.type === "server" ||
currentTabData?.type === "file_manager"; currentTabData?.type === "file_manager" ||
currentTabData?.type === "tunnel" ||
currentTabData?.type === "docker";
const showHome = currentTabData?.type === "home"; const showHome = currentTabData?.type === "home";
const showSshManager = currentTabData?.type === "ssh_manager"; const showSshManager = currentTabData?.type === "ssh_manager";
const showAdmin = currentTabData?.type === "admin"; const showAdmin = currentTabData?.type === "admin";

View File

@@ -0,0 +1,126 @@
import React from "react";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Separator } from "@/components/ui/separator.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 DockerManagerProps {
hostConfig?: HostConfig;
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);
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 (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
</div>
</div>
</div>
<Separator className="p-0.25 w-full" />
<div className="flex-1 overflow-hidden min-h-0 p-1">
{/* Empty body as requested */}
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-gray-400 text-lg">
Docker management UI will be here.
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -566,6 +566,20 @@ export function HostManagerEditor({
}), }),
) )
.default([]), .default([]),
enableDocker: z.boolean().default(false),
dockerConfig: z
.object({
connectionType: z.enum(["socket", "tcp", "tls"]).default("socket"),
socketPath: z.string().optional(),
host: z.string().optional(),
port: z.coerce.number().min(1).max(65535).optional(),
tlsVerify: z.boolean().default(true),
tlsCaCert: z.string().optional(),
tlsCert: z.string().optional(),
tlsKey: z.string().optional(),
apiVersion: z.string().optional(),
})
.optional(),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.authType === "none") { if (data.authType === "none") {
@@ -658,6 +672,18 @@ export function HostManagerEditor({
statsConfig: DEFAULT_STATS_CONFIG, statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG, terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false, forceKeyboardInteractive: false,
enableDocker: false,
dockerConfig: {
connectionType: "socket" as const,
socketPath: "/var/run/docker.sock",
host: "",
port: 2375,
tlsVerify: true,
tlsCaCert: "",
tlsCert: "",
tlsKey: "",
apiVersion: "",
},
}, },
}); });
@@ -753,6 +779,18 @@ export function HostManagerEditor({
: [], : [],
}, },
forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive), forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive),
enableDocker: Boolean(cleanedHost.enableDocker),
dockerConfig: cleanedHost.dockerConfig || {
connectionType: "socket" as const,
socketPath: "/var/run/docker.sock",
host: "",
port: 2375,
tlsVerify: true,
tlsCaCert: "",
tlsCert: "",
tlsKey: "",
apiVersion: "",
},
}; };
if (defaultAuthType === "password") { if (defaultAuthType === "password") {
@@ -804,6 +842,18 @@ export function HostManagerEditor({
statsConfig: DEFAULT_STATS_CONFIG, statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG, terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false, forceKeyboardInteractive: false,
enableDocker: false,
dockerConfig: {
connectionType: "socket" as const,
socketPath: "/var/run/docker.sock",
host: "",
port: 2375,
tlsVerify: true,
tlsCaCert: "",
tlsCert: "",
tlsKey: "",
apiVersion: "",
},
}; };
form.reset(defaultFormData); form.reset(defaultFormData);
@@ -861,6 +911,8 @@ export function HostManagerEditor({
authType: data.authType, authType: data.authType,
overrideCredentialUsername: Boolean(data.overrideCredentialUsername), overrideCredentialUsername: Boolean(data.overrideCredentialUsername),
enableTerminal: Boolean(data.enableTerminal), enableTerminal: Boolean(data.enableTerminal),
enableDocker: Boolean(data.enableDocker),
dockerConfig: data.dockerConfig || null,
enableTunnel: Boolean(data.enableTunnel), enableTunnel: Boolean(data.enableTunnel),
enableFileManager: Boolean(data.enableFileManager), enableFileManager: Boolean(data.enableFileManager),
defaultPath: data.defaultPath || "/", defaultPath: data.defaultPath || "/",
@@ -948,9 +1000,8 @@ export function HostManagerEditor({
window.dispatchEvent(new CustomEvent("ssh-hosts:changed")); window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
if (savedHost?.id) { if (savedHost?.id) {
const { notifyHostCreatedOrUpdated } = await import( const { notifyHostCreatedOrUpdated } =
"@/ui/main-axios.ts" await import("@/ui/main-axios.ts");
);
notifyHostCreatedOrUpdated(savedHost.id); notifyHostCreatedOrUpdated(savedHost.id);
} }
} catch (error) { } catch (error) {
@@ -983,6 +1034,8 @@ export function HostManagerEditor({
setActiveTab("general"); setActiveTab("general");
} else if (errors.enableTerminal || errors.terminalConfig) { } else if (errors.enableTerminal || errors.terminalConfig) {
setActiveTab("terminal"); setActiveTab("terminal");
} else if (errors.enableDocker || errors.dockerConfig) {
setActiveTab("docker");
} else if (errors.enableTunnel || errors.tunnelConnections) { } else if (errors.enableTunnel || errors.tunnelConnections) {
setActiveTab("tunnel"); setActiveTab("tunnel");
} else if (errors.enableFileManager || errors.defaultPath) { } else if (errors.enableFileManager || errors.defaultPath) {
@@ -1175,6 +1228,7 @@ export function HostManagerEditor({
<TabsTrigger value="terminal"> <TabsTrigger value="terminal">
{t("hosts.terminal")} {t("hosts.terminal")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="docker">Docker</TabsTrigger>
<TabsTrigger value="tunnel">{t("hosts.tunnel")}</TabsTrigger> <TabsTrigger value="tunnel">{t("hosts.tunnel")}</TabsTrigger>
<TabsTrigger value="file_manager"> <TabsTrigger value="file_manager">
{t("hosts.fileManager")} {t("hosts.fileManager")}
@@ -2545,6 +2599,307 @@ export function HostManagerEditor({
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>
</TabsContent> </TabsContent>
<TabsContent value="docker" className="space-y-4">
<FormField
control={form.control}
name="enableDocker"
render={({ field }) => (
<FormItem>
<FormLabel>Enable Docker</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
Enable Docker integration for this host
</FormDescription>
</FormItem>
)}
/>
{form.watch("enableDocker") && (
<>
<Alert className="mt-4">
<AlertDescription>
<strong>Docker Configuration</strong>
<div className="mt-2">
Configure connection to Docker daemon on this host.
You can connect via Unix socket, TCP, or secure TLS
connection.
</div>
</AlertDescription>
</Alert>
<FormField
control={form.control}
name="dockerConfig.connectionType"
render={({ field }) => (
<FormItem>
<FormLabel>Connection Type</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select connection type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="socket">
Unix Socket
</SelectItem>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="tls">
TCP with TLS
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose how to connect to the Docker daemon
</FormDescription>
</FormItem>
)}
/>
{form.watch("dockerConfig.connectionType") ===
"socket" && (
<FormField
control={form.control}
name="dockerConfig.socketPath"
render={({ field }) => (
<FormItem>
<FormLabel>Socket Path</FormLabel>
<FormControl>
<Input
placeholder="/var/run/docker.sock"
{...field}
/>
</FormControl>
<FormDescription>
Path to the Docker Unix socket (default:
/var/run/docker.sock)
</FormDescription>
</FormItem>
)}
/>
)}
{(form.watch("dockerConfig.connectionType") === "tcp" ||
form.watch("dockerConfig.connectionType") ===
"tls") && (
<>
<div className="grid grid-cols-12 gap-4">
<FormField
control={form.control}
name="dockerConfig.host"
render={({ field }) => (
<FormItem className="col-span-8">
<FormLabel>Docker Host</FormLabel>
<FormControl>
<Input
placeholder="localhost or IP address"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="dockerConfig.port"
render={({ field }) => (
<FormItem className="col-span-4">
<FormLabel>Port</FormLabel>
<FormControl>
<Input
type="number"
placeholder="2375 or 2376"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</>
)}
{form.watch("dockerConfig.connectionType") === "tls" && (
<Accordion type="multiple" className="w-full mt-4">
<AccordionItem value="tls-config">
<AccordionTrigger>
TLS Configuration
</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<FormField
control={form.control}
name="dockerConfig.tlsVerify"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>Verify TLS</FormLabel>
<FormDescription>
Verify the Docker daemon's certificate
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="dockerConfig.tlsCaCert"
render={({ field }) => (
<FormItem>
<FormLabel>CA Certificate</FormLabel>
<FormControl>
<CodeMirror
value={field.value || ""}
onChange={field.onChange}
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
theme={oneDark}
className="border border-input rounded-md"
minHeight="120px"
basicSetup={{
lineNumbers: true,
foldGutter: false,
dropCursor: false,
allowMultipleSelections: false,
highlightSelectionMatches: false,
searchKeymap: false,
scrollPastEnd: false,
}}
extensions={[
EditorView.theme({
".cm-scroller": {
overflow: "auto",
},
}),
]}
/>
</FormControl>
<FormDescription>
Certificate Authority certificate (PEM
format)
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="dockerConfig.tlsCert"
render={({ field }) => (
<FormItem>
<FormLabel>Client Certificate</FormLabel>
<FormControl>
<CodeMirror
value={field.value || ""}
onChange={field.onChange}
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
theme={oneDark}
className="border border-input rounded-md"
minHeight="120px"
basicSetup={{
lineNumbers: true,
foldGutter: false,
dropCursor: false,
allowMultipleSelections: false,
highlightSelectionMatches: false,
searchKeymap: false,
scrollPastEnd: false,
}}
extensions={[
EditorView.theme({
".cm-scroller": {
overflow: "auto",
},
}),
]}
/>
</FormControl>
<FormDescription>
Client certificate (PEM format)
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="dockerConfig.tlsKey"
render={({ field }) => (
<FormItem>
<FormLabel>Client Key</FormLabel>
<FormControl>
<CodeMirror
value={field.value || ""}
onChange={field.onChange}
placeholder="-----BEGIN RSA PRIVATE KEY-----&#10;...&#10;-----END RSA PRIVATE KEY-----"
theme={oneDark}
className="border border-input rounded-md"
minHeight="120px"
basicSetup={{
lineNumbers: true,
foldGutter: false,
dropCursor: false,
allowMultipleSelections: false,
highlightSelectionMatches: false,
searchKeymap: false,
scrollPastEnd: false,
}}
extensions={[
EditorView.theme({
".cm-scroller": {
overflow: "auto",
},
}),
]}
/>
</FormControl>
<FormDescription>
Client private key (PEM format)
</FormDescription>
</FormItem>
)}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
<FormField
control={form.control}
name="dockerConfig.apiVersion"
render={({ field }) => (
<FormItem>
<FormLabel>API Version (Optional)</FormLabel>
<FormControl>
<Input
placeholder="1.41 (leave empty for auto)"
{...field}
/>
</FormControl>
<FormDescription>
Specify Docker API version, or leave empty to use
the default
</FormDescription>
</FormItem>
)}
/>
</>
)}
</TabsContent>
<TabsContent value="tunnel"> <TabsContent value="tunnel">
<FormField <FormField
control={form.control} control={form.control}

View File

@@ -3,7 +3,6 @@ import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status"; import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
import { Separator } from "@/components/ui/separator.tsx"; import { Separator } from "@/components/ui/separator.tsx";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { Tunnel } from "@/ui/desktop/apps/tunnel/Tunnel.tsx";
import { import {
getServerStatusById, getServerStatusById,
getServerMetricsById, getServerMetricsById,
@@ -64,7 +63,7 @@ interface ServerProps {
embedded?: boolean; embedded?: boolean;
} }
export function Server({ export function ServerStats({
hostConfig, hostConfig,
title, title,
isVisible = true, isVisible = true,
@@ -462,7 +461,7 @@ export function Server({
{(metricsEnabled && showStatsUI) || {(metricsEnabled && showStatsUI) ||
(currentHostConfig?.quickActions && (currentHostConfig?.quickActions &&
currentHostConfig.quickActions.length > 0) ? ( currentHostConfig.quickActions.length > 0) ? (
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 overflow-y-auto relative flex-1 flex flex-col"> <div className="rounded-lg border-2 border-dark-border m-3 p-4 overflow-y-auto relative flex-1 flex flex-col">
{currentHostConfig?.quickActions && {currentHostConfig?.quickActions &&
currentHostConfig.quickActions.length > 0 && ( currentHostConfig.quickActions.length > 0 && (
<div className={metricsEnabled && showStatsUI ? "mb-4" : ""}> <div className={metricsEnabled && showStatsUI ? "mb-4" : ""}>
@@ -600,20 +599,6 @@ export function Server({
)} )}
</div> </div>
) : null} ) : null}
{currentHostConfig?.tunnelConnections &&
currentHostConfig.tunnelConnections.length > 0 && (
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker h-[360px] overflow-hidden flex flex-col min-h-0">
<Tunnel
filterHostKey={
currentHostConfig?.name &&
currentHostConfig.name.trim() !== ""
? currentHostConfig.name
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
}
/>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -30,7 +30,7 @@ export function CpuWidget({ metrics, metricsHistory }: CpuWidgetProps) {
}, [metricsHistory]); }, [metricsHistory]);
return ( return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden"> <div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3"> <div className="flex items-center gap-2 flex-shrink-0 mb-3">
<Cpu className="h-5 w-5 text-blue-400" /> <Cpu className="h-5 w-5 text-blue-400" />
<h3 className="font-semibold text-lg text-white"> <h3 className="font-semibold text-lg text-white">

View File

@@ -27,7 +27,7 @@ export function DiskWidget({ metrics }: DiskWidgetProps) {
}, [metrics]); }, [metrics]);
return ( return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden"> <div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3"> <div className="flex items-center gap-2 flex-shrink-0 mb-3">
<HardDrive className="h-5 w-5 text-orange-400" /> <HardDrive className="h-5 w-5 text-orange-400" />
<h3 className="font-semibold text-lg text-white"> <h3 className="font-semibold text-lg text-white">

View File

@@ -35,7 +35,7 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
const uniqueIPs = loginStats?.uniqueIPs || 0; const uniqueIPs = loginStats?.uniqueIPs || 0;
return ( return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden"> <div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3"> <div className="flex items-center gap-2 flex-shrink-0 mb-3">
<UserCheck className="h-5 w-5 text-green-400" /> <UserCheck className="h-5 w-5 text-green-400" />
<h3 className="font-semibold text-lg text-white"> <h3 className="font-semibold text-lg text-white">

View File

@@ -30,7 +30,7 @@ export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
}, [metricsHistory]); }, [metricsHistory]);
return ( return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden"> <div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3"> <div className="flex items-center gap-2 flex-shrink-0 mb-3">
<MemoryStick className="h-5 w-5 text-green-400" /> <MemoryStick className="h-5 w-5 text-green-400" />
<h3 className="font-semibold text-lg text-white"> <h3 className="font-semibold text-lg text-white">

View File

@@ -24,7 +24,7 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) {
const interfaces = network?.interfaces || []; const interfaces = network?.interfaces || [];
return ( return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden"> <div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3"> <div className="flex items-center gap-2 flex-shrink-0 mb-3">
<Network className="h-5 w-5 text-indigo-400" /> <Network className="h-5 w-5 text-indigo-400" />
<h3 className="font-semibold text-lg text-white"> <h3 className="font-semibold text-lg text-white">

View File

@@ -28,7 +28,7 @@ export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
const topProcesses = processes?.top || []; const topProcesses = processes?.top || [];
return ( return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden"> <div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3"> <div className="flex items-center gap-2 flex-shrink-0 mb-3">
<List className="h-5 w-5 text-yellow-400" /> <List className="h-5 w-5 text-yellow-400" />
<h3 className="font-semibold text-lg text-white"> <h3 className="font-semibold text-lg text-white">

View File

@@ -21,7 +21,7 @@ export function SystemWidget({ metrics }: SystemWidgetProps) {
const system = metricsWithSystem?.system; const system = metricsWithSystem?.system;
return ( return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden"> <div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3"> <div className="flex items-center gap-2 flex-shrink-0 mb-3">
<Server className="h-5 w-5 text-purple-400" /> <Server className="h-5 w-5 text-purple-400" />
<h3 className="font-semibold text-lg text-white"> <h3 className="font-semibold text-lg text-white">

View File

@@ -20,7 +20,7 @@ export function UptimeWidget({ metrics }: UptimeWidgetProps) {
const uptime = metricsWithUptime?.uptime; const uptime = metricsWithUptime?.uptime;
return ( return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden"> <div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3"> <div className="flex items-center gap-2 flex-shrink-0 mb-3">
<Clock className="h-5 w-5 text-cyan-400" /> <Clock className="h-5 w-5 text-cyan-400" />
<h3 className="font-semibold text-lg text-white"> <h3 className="font-semibold text-lg text-white">

View File

@@ -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 (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
</div>
</div>
</div>
<Separator className="p-0.25 w-full" />
<div className="flex-1 overflow-hidden min-h-0 p-1">
{currentHostConfig?.tunnelConnections &&
currentHostConfig.tunnelConnections.length > 0 ? (
<div className="rounded-lg h-full overflow-hidden flex flex-col min-h-0">
<Tunnel
filterHostKey={
currentHostConfig?.name &&
currentHostConfig.name.trim() !== ""
? currentHostConfig.name
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
}
/>
</div>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-gray-400 text-lg">
{t("tunnel.noTunnelsConfigured")}
</p>
<p className="text-gray-500 text-sm mt-2">
{t("tunnel.configureTunnelsInHostSettings")}
</p>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -43,11 +43,6 @@ export function TunnelViewer({
return ( return (
<div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0"> <div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0">
<div className="w-full flex-shrink-0 mb-2">
<h1 className="text-xl font-semibold text-foreground">
{t("tunnels.title")}
</h1>
</div>
<div className="min-h-0 flex-1 overflow-auto pr-1"> <div className="min-h-0 flex-1 overflow-auto pr-1">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full"> <div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{activeHost.tunnelConnections.map((t, idx) => ( {activeHost.tunnelConnections.map((t, idx) => (

View File

@@ -1,7 +1,9 @@
import React, { useEffect, useRef, useState, useMemo } from "react"; import React, { useEffect, useRef, useState, useMemo } from "react";
import { Terminal } from "@/ui/desktop/apps/terminal/Terminal.tsx"; 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 { 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 { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { import {
ResizablePanelGroup, ResizablePanelGroup,
@@ -58,7 +60,9 @@ export function AppView({
(tab: TabData) => (tab: TabData) =>
tab.type === "terminal" || tab.type === "terminal" ||
tab.type === "server" || tab.type === "server" ||
tab.type === "file_manager", tab.type === "file_manager" ||
tab.type === "tunnel" ||
tab.type === "docker",
), ),
[tabs], [tabs],
); );
@@ -210,7 +214,10 @@ export function AppView({
const mainTab = terminalTabs.find((tab: TabData) => tab.id === currentTab); const mainTab = terminalTabs.find((tab: TabData) => tab.id === currentTab);
if (allSplitScreenTab.length === 0 && mainTab) { 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 = { const newStyle = {
position: "absolute" as const, position: "absolute" as const,
top: isFileManagerTab ? 0 : 4, top: isFileManagerTab ? 0 : 4,
@@ -257,9 +264,14 @@ export function AppView({
const isVisible = const isVisible =
hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab); hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab);
const effectiveVisible = isVisible;
const previousStyle = previousStylesRef.current[t.id]; 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 = { const standardStyle = {
position: "absolute" as const, position: "absolute" as const,
top: isFileManagerTab ? 0 : 4, top: isFileManagerTab ? 0 : 4,
@@ -270,16 +282,24 @@ export function AppView({
const finalStyle: React.CSSProperties = hasStyle const finalStyle: React.CSSProperties = hasStyle
? { ...styles[t.id], overflow: "hidden" } ? { ...styles[t.id], overflow: "hidden" }
: ({ : effectiveVisible
...(previousStyle || standardStyle), ? {
opacity: 0, ...(previousStyle || standardStyle),
pointerEvents: "none", opacity: 1,
zIndex: 0, pointerEvents: "auto",
transition: "opacity 150ms ease-in-out", zIndex: 20,
overflow: "hidden", display: "block",
} as React.CSSProperties); transition: "opacity 150ms ease-in-out",
overflow: "hidden",
const effectiveVisible = isVisible; }
: ({
...(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 isTerminal = t.type === "terminal";
const terminalConfig = { const terminalConfig = {
@@ -317,6 +337,22 @@ export function AppView({
isTopbarOpen={isTopbarOpen} isTopbarOpen={isTopbarOpen}
embedded embedded
/> />
) : t.type === "tunnel" ? (
<TunnelManager
hostConfig={t.hostConfig}
title={t.title}
isVisible={effectiveVisible}
isTopbarOpen={isTopbarOpen}
embedded
/>
) : t.type === "docker" ? (
<DockerManager
hostConfig={t.hostConfig}
title={t.title}
isVisible={effectiveVisible}
isTopbarOpen={isTopbarOpen}
embedded
/>
) : ( ) : (
<FileManager <FileManager
embedded embedded
@@ -636,6 +672,8 @@ export function AppView({
const currentTabData = tabs.find((tab: TabData) => tab.id === currentTab); const currentTabData = tabs.find((tab: TabData) => tab.id === currentTab);
const isFileManager = currentTabData?.type === "file_manager"; const isFileManager = currentTabData?.type === "file_manager";
const isTunnel = currentTabData?.type === "tunnel";
const isDocker = currentTabData?.type === "docker";
const isTerminal = currentTabData?.type === "terminal"; const isTerminal = currentTabData?.type === "terminal";
const isSplitScreen = allSplitScreenTab.length > 0; const isSplitScreen = allSplitScreenTab.length > 0;
@@ -653,7 +691,7 @@ export function AppView({
const bottomMarginPx = 8; const bottomMarginPx = 8;
let containerBackground = "var(--color-dark-bg)"; let containerBackground = "var(--color-dark-bg)";
if (isFileManager && !isSplitScreen) { if ((isFileManager || isTunnel || isDocker) && !isSplitScreen) {
containerBackground = "var(--color-dark-bg-darkest)"; containerBackground = "var(--color-dark-bg-darkest)";
} else if (isTerminal) { } else if (isTerminal) {
containerBackground = terminalBackgroundColor; containerBackground = terminalBackgroundColor;

View File

@@ -369,10 +369,13 @@ export function TopNavbar({
const isTerminal = tab.type === "terminal"; const isTerminal = tab.type === "terminal";
const isServer = tab.type === "server"; const isServer = tab.type === "server";
const isFileManager = tab.type === "file_manager"; const isFileManager = tab.type === "file_manager";
const isTunnel = tab.type === "tunnel";
const isDocker = tab.type === "docker";
const isSshManager = tab.type === "ssh_manager"; const isSshManager = tab.type === "ssh_manager";
const isAdmin = tab.type === "admin"; const isAdmin = tab.type === "admin";
const isUserProfile = tab.type === "user_profile"; const isUserProfile = tab.type === "user_profile";
const isSplittable = isTerminal || isServer || isFileManager; const isSplittable =
isTerminal || isServer || isFileManager || isTunnel || isDocker;
const disableSplit = !isSplittable; const disableSplit = !isSplittable;
const disableActivate = const disableActivate =
isSplit || isSplit ||
@@ -484,6 +487,8 @@ export function TopNavbar({
isTerminal || isTerminal ||
isServer || isServer ||
isFileManager || isFileManager ||
isTunnel ||
isDocker ||
isSshManager || isSshManager ||
isAdmin || isAdmin ||
isUserProfile isUserProfile
@@ -498,6 +503,8 @@ export function TopNavbar({
isTerminal || isTerminal ||
isServer || isServer ||
isFileManager || isFileManager ||
isTunnel ||
isDocker ||
isSshManager || isSshManager ||
isAdmin || isAdmin ||
isUserProfile isUserProfile

View File

@@ -8,6 +8,8 @@ import {
Server, Server,
FolderOpen, FolderOpen,
Pencil, Pencil,
ArrowDownUp,
Container,
} from "lucide-react"; } from "lucide-react";
import { import {
DropdownMenu, DropdownMenu,
@@ -63,6 +65,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
}, [host.statsConfig]); }, [host.statsConfig]);
const shouldShowStatus = statsConfig.statusCheckEnabled !== false; const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
const shouldShowMetrics = statsConfig.metricsEnabled !== false;
useEffect(() => { useEffect(() => {
if (!shouldShowStatus) { if (!shouldShowStatus) {
@@ -151,24 +154,50 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
side="right" side="right"
className="w-56 bg-dark-bg border-dark-border text-white" className="w-56 bg-dark-bg border-dark-border text-white"
> >
<DropdownMenuItem {shouldShowMetrics && (
onClick={() => <DropdownMenuItem
addTab({ type: "server", title, hostConfig: host }) onClick={() =>
} 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" }
> className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
<Server className="h-4 w-4" /> >
<span className="flex-1">Open Server Details</span> <Server className="h-4 w-4" />
</DropdownMenuItem> <span className="flex-1">Open Server Stats</span>
<DropdownMenuItem </DropdownMenuItem>
onClick={() => )}
addTab({ type: "file_manager", title, hostConfig: host }) {host.enableFileManager && (
} <DropdownMenuItem
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" onClick={() =>
> addTab({ type: "file_manager", title, hostConfig: host })
<FolderOpen className="h-4 w-4" /> }
<span className="flex-1">Open File Manager</span> className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
</DropdownMenuItem> >
<FolderOpen className="h-4 w-4" />
<span className="flex-1">Open File Manager</span>
</DropdownMenuItem>
)}
{host.enableTunnel && (
<DropdownMenuItem
onClick={() =>
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"
>
<ArrowDownUp className="h-4 w-4" />
<span className="flex-1">Open Tunnels</span>
</DropdownMenuItem>
)}
{host.enableDocker && (
<DropdownMenuItem
onClick={() =>
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"
>
<Container className="h-4 w-4" />
<span className="flex-1">Open Docker</span>
</DropdownMenuItem>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
addTab({ addTab({

View File

@@ -10,6 +10,8 @@ import {
Server as ServerIcon, Server as ServerIcon,
Folder as FolderIcon, Folder as FolderIcon,
User as UserIcon, User as UserIcon,
ArrowDownUp as TunnelIcon,
Container as DockerIcon,
} from "lucide-react"; } from "lucide-react";
interface TabProps { interface TabProps {
@@ -119,10 +121,14 @@ export function Tab({
tabType === "terminal" || tabType === "terminal" ||
tabType === "server" || tabType === "server" ||
tabType === "file_manager" || tabType === "file_manager" ||
tabType === "tunnel" ||
tabType === "docker" ||
tabType === "user_profile" tabType === "user_profile"
) { ) {
const isServer = tabType === "server"; const isServer = tabType === "server";
const isFileManager = tabType === "file_manager"; const isFileManager = tabType === "file_manager";
const isTunnel = tabType === "tunnel";
const isDocker = tabType === "docker";
const isUserProfile = tabType === "user_profile"; const isUserProfile = tabType === "user_profile";
const displayTitle = const displayTitle =
@@ -131,9 +137,13 @@ export function Tab({
? t("nav.serverStats") ? t("nav.serverStats")
: isFileManager : isFileManager
? t("nav.fileManager") ? t("nav.fileManager")
: isUserProfile : isTunnel
? t("nav.userProfile") ? t("nav.tunnels")
: t("nav.terminal")); : isDocker
? t("nav.docker")
: isUserProfile
? t("nav.userProfile")
: t("nav.terminal"));
const { base, suffix } = splitTitle(displayTitle); const { base, suffix } = splitTitle(displayTitle);
@@ -151,6 +161,10 @@ export function Tab({
<ServerIcon className="h-4 w-4 flex-shrink-0" /> <ServerIcon className="h-4 w-4 flex-shrink-0" />
) : isFileManager ? ( ) : isFileManager ? (
<FolderIcon className="h-4 w-4 flex-shrink-0" /> <FolderIcon className="h-4 w-4 flex-shrink-0" />
) : isTunnel ? (
<TunnelIcon className="h-4 w-4 flex-shrink-0" />
) : isDocker ? (
<DockerIcon className="h-4 w-4 flex-shrink-0" />
) : isUserProfile ? ( ) : isUserProfile ? (
<UserIcon className="h-4 w-4 flex-shrink-0" /> <UserIcon className="h-4 w-4 flex-shrink-0" />
) : ( ) : (

View File

@@ -76,7 +76,11 @@ export function TabProvider({ children }: TabProviderProps) {
? t("nav.serverStats") ? t("nav.serverStats")
: tabType === "file_manager" : tabType === "file_manager"
? t("nav.fileManager") ? 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 baseTitle = (desiredTitle || defaultTitle).trim();
const match = baseTitle.match(/^(.*) \((\d+)\)$/); const match = baseTitle.match(/^(.*) \((\d+)\)$/);
const root = match ? match[1] : baseTitle; const root = match ? match[1] : baseTitle;
@@ -137,7 +141,9 @@ export function TabProvider({ children }: TabProviderProps) {
const needsUniqueTitle = const needsUniqueTitle =
tabData.type === "terminal" || tabData.type === "terminal" ||
tabData.type === "server" || tabData.type === "server" ||
tabData.type === "file_manager"; tabData.type === "file_manager" ||
tabData.type === "tunnel" ||
tabData.type === "docker";
const effectiveTitle = needsUniqueTitle const effectiveTitle = needsUniqueTitle
? computeUniqueTitle(tabData.type, tabData.title) ? computeUniqueTitle(tabData.type, tabData.title)
: tabData.title || ""; : tabData.title || "";

View File

@@ -12,6 +12,8 @@ import {
Terminal as TerminalIcon, Terminal as TerminalIcon,
Server as ServerIcon, Server as ServerIcon,
Folder as FolderIcon, Folder as FolderIcon,
ArrowDownUp as TunnelIcon,
Container as DockerIcon,
Shield as AdminIcon, Shield as AdminIcon,
Network as SshManagerIcon, Network as SshManagerIcon,
User as UserIcon, User as UserIcon,
@@ -33,6 +35,10 @@ export function TabDropdown(): React.ReactElement {
return <ServerIcon className="h-4 w-4" />; return <ServerIcon className="h-4 w-4" />;
case "file_manager": case "file_manager":
return <FolderIcon className="h-4 w-4" />; return <FolderIcon className="h-4 w-4" />;
case "tunnel":
return <TunnelIcon className="h-4 w-4" />;
case "docker":
return <DockerIcon className="h-4 w-4" />;
case "user_profile": case "user_profile":
return <UserIcon className="h-4 w-4" />; return <UserIcon className="h-4 w-4" />;
case "ssh_manager": case "ssh_manager":
@@ -52,6 +58,10 @@ export function TabDropdown(): React.ReactElement {
return tab.title || t("nav.serverStats"); return tab.title || t("nav.serverStats");
case "file_manager": case "file_manager":
return tab.title || t("nav.fileManager"); 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": case "user_profile":
return tab.title || t("nav.userProfile"); return tab.title || t("nav.userProfile");
case "ssh_manager": case "ssh_manager":

View File

@@ -856,6 +856,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
enableTerminal: Boolean(hostData.enableTerminal), enableTerminal: Boolean(hostData.enableTerminal),
enableTunnel: Boolean(hostData.enableTunnel), enableTunnel: Boolean(hostData.enableTunnel),
enableFileManager: Boolean(hostData.enableFileManager), enableFileManager: Boolean(hostData.enableFileManager),
enableDocker: Boolean(hostData.enableDocker),
defaultPath: hostData.defaultPath || "/", defaultPath: hostData.defaultPath || "/",
tunnelConnections: hostData.tunnelConnections || [], tunnelConnections: hostData.tunnelConnections || [],
jumpHosts: hostData.jumpHosts || [], jumpHosts: hostData.jumpHosts || [],
@@ -865,6 +866,11 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
? hostData.statsConfig ? hostData.statsConfig
: JSON.stringify(hostData.statsConfig) : JSON.stringify(hostData.statsConfig)
: null, : null,
dockerConfig: hostData.dockerConfig
? typeof hostData.dockerConfig === "string"
? hostData.dockerConfig
: JSON.stringify(hostData.dockerConfig)
: null,
terminalConfig: hostData.terminalConfig || null, terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
}; };
@@ -922,6 +928,7 @@ export async function updateSSHHost(
enableTerminal: Boolean(hostData.enableTerminal), enableTerminal: Boolean(hostData.enableTerminal),
enableTunnel: Boolean(hostData.enableTunnel), enableTunnel: Boolean(hostData.enableTunnel),
enableFileManager: Boolean(hostData.enableFileManager), enableFileManager: Boolean(hostData.enableFileManager),
enableDocker: Boolean(hostData.enableDocker),
defaultPath: hostData.defaultPath || "/", defaultPath: hostData.defaultPath || "/",
tunnelConnections: hostData.tunnelConnections || [], tunnelConnections: hostData.tunnelConnections || [],
jumpHosts: hostData.jumpHosts || [], jumpHosts: hostData.jumpHosts || [],
@@ -931,6 +938,11 @@ export async function updateSSHHost(
? hostData.statsConfig ? hostData.statsConfig
: JSON.stringify(hostData.statsConfig) : JSON.stringify(hostData.statsConfig)
: null, : null,
dockerConfig: hostData.dockerConfig
? typeof hostData.dockerConfig === "string"
? hostData.dockerConfig
: JSON.stringify(hostData.dockerConfig)
: null,
terminalConfig: hostData.terminalConfig || null, terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
}; };