feat: Add docker management support (local squash)

This commit is contained in:
LukeGus
2025-12-19 20:12:19 -06:00
parent 48933e9b11
commit 1f168c6f97
21 changed files with 4809 additions and 388 deletions

View File

@@ -1,21 +1,37 @@
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";
interface HostConfig {
id: number;
name: string;
ip: string;
username: string;
folder?: string;
enableFileManager?: boolean;
tunnelConnections?: unknown[];
[key: string]: unknown;
}
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?: HostConfig;
hostConfig?: SSHHost;
title?: string;
isVisible?: boolean;
isTopbarOpen?: boolean;
@@ -32,10 +48,26 @@ export function DockerManager({
const { t } = useTranslation();
const { state: sidebarState } = useSidebar();
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const [sessionId, setSessionId] = React.useState<string | null>(null);
const [containers, setContainers] = React.useState<DockerContainer[]>([]);
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<DockerValidation | null>(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]);
@@ -77,6 +109,111 @@ export function DockerManager({
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;
@@ -96,6 +233,102 @@ export function DockerManager({
? "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 (
<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-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Docker is not enabled for this host. Enable it in Host Settings
to use Docker features.
</AlertDescription>
</Alert>
</div>
</div>
</div>
);
}
// Loading state
if (isConnecting || isValidating) {
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-4 flex items-center justify-center">
<div className="text-center">
<SimpleLoader size="lg" />
<p className="text-gray-400 mt-4">
{isValidating
? "Validating Docker..."
: "Connecting to host..."}
</p>
</div>
</div>
</div>
</div>
);
}
// Docker not available
if (dockerValidation && !dockerValidation.available) {
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-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<div className="font-semibold mb-2">Docker Error</div>
<div>{dockerValidation.error}</div>
{dockerValidation.code && (
<div className="mt-2 text-xs opacity-70">
Error code: {dockerValidation.code}
</div>
)}
</AlertDescription>
</Alert>
</div>
</div>
</div>
);
}
return (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
@@ -105,20 +338,51 @@ export function DockerManager({
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
{dockerValidation?.version && (
<p className="text-xs text-gray-400">
Docker v{dockerValidation.version}
</p>
)}
</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.
<div className="flex-1 overflow-hidden min-h-0">
{viewMode === "list" ? (
<div className="h-full px-4 py-4">
{sessionId ? (
<ContainerList
containers={containers}
sessionId={sessionId}
onSelectContainer={(id) => {
setSelectedContainer(id);
setViewMode("detail");
}}
selectedContainerId={selectedContainer}
onRefresh={refreshContainers}
/>
) : (
<div className="text-center py-8">
<p className="text-gray-400">No session available</p>
</div>
)}
</div>
) : sessionId && selectedContainer && currentHostConfig ? (
<ContainerDetail
sessionId={sessionId}
containerId={selectedContainer}
containers={containers}
hostConfig={currentHostConfig}
onBack={handleBack}
/>
) : (
<div className="text-center py-8">
<p className="text-gray-400">
Select a container to view details
</p>
</div>
</div>
)}
</div>
</div>
</div>

View File

@@ -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<string>("bash");
const wsRef = React.useRef<WebSocket | null>(null);
const fitAddonRef = React.useRef<FitAddon | null>(null);
const pingIntervalRef = React.useRef<NodeJS.Timeout | null>(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 (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<TerminalIcon className="h-12 w-12 text-gray-600 mx-auto" />
<p className="text-gray-400 text-lg">Container is not running</p>
<p className="text-gray-500 text-sm">
Start the container to access the console
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full gap-3">
{/* Controls */}
<Card className="py-3">
<CardContent className="px-3">
<div className="flex flex-col sm:flex-row gap-2 items-center sm:items-center">
<div className="flex items-center gap-2 flex-1">
<TerminalIcon className="h-5 w-5" />
<span className="text-base font-medium">Console</span>
</div>
<Select
value={selectedShell}
onValueChange={setSelectedShell}
disabled={isConnected}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="Select shell" />
</SelectTrigger>
<SelectContent>
<SelectItem value="bash">Bash</SelectItem>
<SelectItem value="sh">Sh</SelectItem>
<SelectItem value="ash">Ash</SelectItem>
</SelectContent>
</Select>
<div className="flex gap-2 sm:gap-2">
{!isConnected ? (
<Button
onClick={connect}
disabled={isConnecting}
className="min-w-[120px]"
>
{isConnecting ? (
<>
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
Connecting...
</>
) : (
<>
<Power className="h-4 w-4 mr-2" />
Connect
</>
)}
</Button>
) : (
<Button
onClick={disconnect}
variant="destructive"
className="min-w-[120px]"
>
<PowerOff className="h-4 w-4 mr-2" />
Disconnect
</Button>
)}
</div>
</div>
</CardContent>
</Card>
{/* Terminal */}
<Card className="flex-1 overflow-hidden pt-1 pb-0">
<CardContent className="p-0 h-full relative">
{/* Terminal container - always rendered */}
<div
ref={xtermRef}
className="h-full w-full"
style={{ display: isConnected ? "block" : "none" }}
/>
{/* Not connected message */}
{!isConnected && !isConnecting && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center space-y-2">
<TerminalIcon className="h-12 w-12 text-gray-600 mx-auto" />
<p className="text-gray-400">Not connected</p>
<p className="text-gray-500 text-sm">
Click Connect to start an interactive shell
</p>
</div>
</div>
)}
{/* Connecting message */}
{isConnecting && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<SimpleLoader size="lg" />
<p className="text-gray-400 mt-4">
Connecting to {containerName}...
</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -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 (
<>
<Card
className={`cursor-pointer transition-all hover:shadow-lg ${
isSelected
? "ring-2 ring-primary border-primary"
: `border-2 ${colors.border}`
} ${colors.bg} pt-3 pb-0`}
onClick={onSelect}
>
<CardHeader className="pb-2 px-4">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base font-semibold truncate flex-1">
{container.name.startsWith("/")
? container.name.slice(1)
: container.name}
</CardTitle>
<Badge className={`${colors.badge} border shrink-0`}>
{container.state}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-3">
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-gray-400 min-w-[50px] text-xs">Image:</span>
<span className="truncate text-gray-200 text-xs">
{container.image}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-gray-400 min-w-[50px] text-xs">ID:</span>
<span className="font-mono text-xs text-gray-200">
{container.id.substring(0, 12)}
</span>
</div>
<div className="flex items-start gap-2">
<span className="text-gray-400 min-w-[50px] text-xs shrink-0">
Ports:
</span>
<div className="flex flex-wrap gap-1">
{portsList.length > 0 ? (
portsList.map((port, idx) => (
<Badge
key={idx}
variant="outline"
className="text-xs font-mono bg-gray-500/10 text-gray-400 border-gray-500/30"
>
{port}
</Badge>
))
) : (
<Badge
variant="outline"
className="text-xs bg-gray-500/10 text-gray-400 border-gray-500/30"
>
None
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-gray-400 min-w-[50px] text-xs">
Created:
</span>
<span className="text-gray-200 text-xs">
{formatCreatedDate(container.created)}
</span>
</div>
</div>
<div className="flex flex-wrap gap-2 pt-2 border-t border-gray-700/50">
<TooltipProvider>
{container.state !== "running" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8"
onClick={handleStart}
disabled={isLoading}
>
{isStarting ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Start</TooltipContent>
</Tooltip>
)}
{container.state === "running" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8"
onClick={handleStop}
disabled={isLoading}
>
{isStopping ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
) : (
<Square className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Stop</TooltipContent>
</Tooltip>
)}
{(container.state === "running" ||
container.state === "paused") && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8"
onClick={handlePause}
disabled={isLoading}
>
{isPausing ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
) : container.state === "paused" ? (
<PlayCircle className="h-4 w-4" />
) : (
<Pause className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{container.state === "paused" ? "Unpause" : "Pause"}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8"
onClick={handleRestart}
disabled={isLoading || container.state === "exited"}
>
{isRestarting ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
) : (
<RotateCw className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Restart</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 text-red-400 hover:text-red-300 hover:bg-red-500/20"
onClick={(e) => {
e.stopPropagation();
setShowRemoveDialog(true);
}}
disabled={isLoading}
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Remove</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</CardContent>
</Card>
<AlertDialog open={showRemoveDialog} onOpenChange={setShowRemoveDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Container</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove container{" "}
<span className="font-semibold">
{container.name.startsWith("/")
? container.name.slice(1)
: container.name}
</span>
?
{container.state === "running" && (
<div className="mt-2 text-yellow-400">
Warning: This container is currently running and will be
force-removed.
</div>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isRemoving}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleRemove();
}}
disabled={isRemoving}
className="bg-red-600 hover:bg-red-700"
>
{isRemoving ? "Removing..." : "Remove"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -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 (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<p className="text-gray-400 text-lg">Container not found</p>
<Button onClick={onBack} variant="outline">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to list
</Button>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header with back button */}
<div className="flex items-center gap-4 px-4 pt-3 pb-3">
<Button variant="ghost" onClick={onBack} size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<div className="min-w-0 flex-1">
<h2 className="font-bold text-lg truncate">{container.name}</h2>
<p className="text-sm text-gray-400 truncate">{container.image}</p>
</div>
</div>
<Separator className="p-0.25 w-full" />
{/* Tabs for Logs, Stats, Console */}
<div className="flex-1 overflow-hidden min-h-0">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="h-full flex flex-col"
>
<div className="px-4 pt-2">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="stats">Stats</TabsTrigger>
<TabsTrigger value="console">Console</TabsTrigger>
</TabsList>
</div>
<TabsContent
value="logs"
className="flex-1 overflow-auto px-3 pb-3 mt-3"
>
<LogViewer
sessionId={sessionId}
containerId={containerId}
containerName={container.name}
/>
</TabsContent>
<TabsContent
value="stats"
className="flex-1 overflow-auto px-3 pb-3 mt-3"
>
<ContainerStats
sessionId={sessionId}
containerId={containerId}
containerName={container.name}
containerState={container.state}
/>
</TabsContent>
<TabsContent
value="console"
className="flex-1 overflow-hidden px-3 pb-3 mt-3"
>
<ConsoleTerminal
sessionId={sessionId}
containerId={containerId}
containerName={container.name}
containerState={container.state}
hostConfig={hostConfig}
/>
</TabsContent>
</Tabs>
</div>
</div>
);
}

View File

@@ -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<string>("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<string, number> = {};
containers.forEach((c) => {
counts[c.state] = (counts[c.state] || 0) + 1;
});
return counts;
}, [containers]);
if (containers.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<p className="text-gray-400 text-lg">No containers found</p>
<p className="text-gray-500 text-sm">
Start by creating containers on your server
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full gap-3">
{/* Search and Filter Bar */}
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Search by name, image, or ID..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex items-center gap-2 sm:min-w-[200px]">
<Filter className="h-4 w-4 text-gray-400" />
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All ({containers.length})</SelectItem>
{Object.entries(statusCounts).map(([status, count]) => (
<SelectItem key={status} value={status}>
{status.charAt(0).toUpperCase() + status.slice(1)} ({count})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Container Grid */}
{filteredContainers.length === 0 ? (
<div className="flex items-center justify-center flex-1">
<div className="text-center space-y-2">
<p className="text-gray-400">No containers match your filters</p>
<p className="text-gray-500 text-sm">
Try adjusting your search or filter
</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-3 overflow-auto pb-2">
{filteredContainers.map((container) => (
<ContainerCard
key={container.id}
container={container}
sessionId={sessionId}
onSelect={() => onSelectContainer(container.id)}
isSelected={selectedContainerId === container.id}
onRefresh={onRefresh}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -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<DockerStats | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(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 (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<Activity className="h-12 w-12 text-gray-600 mx-auto" />
<p className="text-gray-400 text-lg">Container is not running</p>
<p className="text-gray-500 text-sm">
Start the container to view statistics
</p>
</div>
</div>
);
}
if (isLoading && !stats) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<SimpleLoader size="lg" />
<p className="text-gray-400 mt-4">Loading stats...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<p className="text-red-400 text-lg">Error loading stats</p>
<p className="text-gray-500 text-sm">{error}</p>
</div>
</div>
);
}
if (!stats) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-gray-400">No stats available</p>
</div>
);
}
const cpuPercent = parseFloat(stats.cpu) || 0;
const memPercent = parseFloat(stats.memoryPercent) || 0;
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 h-full overflow-auto">
{/* CPU Usage */}
<Card className="py-3">
<CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2">
<Cpu className="h-5 w-5 text-blue-400" />
CPU Usage
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-3">
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Current</span>
<span className="font-mono font-semibold text-blue-300">
{stats.cpu}
</span>
</div>
<Progress value={Math.min(cpuPercent, 100)} className="h-2" />
</div>
</CardContent>
</Card>
{/* Memory Usage */}
<Card className="py-3">
<CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2">
<MemoryStick className="h-5 w-5 text-purple-400" />
Memory Usage
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-3">
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Used / Limit</span>
<span className="font-mono font-semibold text-purple-300">
{stats.memoryUsed} / {stats.memoryLimit}
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Percentage</span>
<span className="font-mono text-purple-300">
{stats.memoryPercent}
</span>
</div>
<Progress value={Math.min(memPercent, 100)} className="h-2" />
</div>
</CardContent>
</Card>
{/* Network I/O */}
<Card className="py-3">
<CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2">
<Network className="h-5 w-5 text-green-400" />
Network I/O
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-3">
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Input</span>
<span className="font-mono text-green-300">{stats.netInput}</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Output</span>
<span className="font-mono text-green-300">
{stats.netOutput}
</span>
</div>
</div>
</CardContent>
</Card>
{/* Block I/O */}
<Card className="py-3">
<CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2">
<HardDrive className="h-5 w-5 text-orange-400" />
Block I/O
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-3">
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Read</span>
<span className="font-mono text-orange-300">
{stats.blockRead}
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Write</span>
<span className="font-mono text-orange-300">
{stats.blockWrite}
</span>
</div>
{stats.pids && (
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">PIDs</span>
<span className="font-mono text-orange-300">{stats.pids}</span>
</div>
)}
</div>
</CardContent>
</Card>
{/* Container Info */}
<Card className="md:col-span-2 py-3">
<CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2">
<Activity className="h-5 w-5 text-cyan-400" />
Container Information
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
<div className="flex justify-between items-center">
<span className="text-gray-400">Name:</span>
<span className="font-mono text-gray-200">{containerName}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-400">ID:</span>
<span className="font-mono text-sm text-gray-300">
{containerId.substring(0, 12)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-400">State:</span>
<span className="font-semibold text-green-400 capitalize">
{containerState}
</span>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -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<string>("");
const [isLoading, setIsLoading] = React.useState(false);
const [isDownloading, setIsDownloading] = React.useState(false);
const [tailLines, setTailLines] = React.useState<string>("100");
const [showTimestamps, setShowTimestamps] = React.useState(false);
const [autoRefresh, setAutoRefresh] = React.useState(false);
const [searchFilter, setSearchFilter] = React.useState("");
const logsEndRef = React.useRef<HTMLDivElement>(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 (
<div className="flex flex-col h-full gap-3">
{/* Controls */}
<Card className="py-3">
<CardContent className="px-3">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
{/* Tail Lines */}
<div className="flex flex-col">
<Label htmlFor="tail-lines" className="mb-1">
Lines to show
</Label>
<Select value={tailLines} onValueChange={setTailLines}>
<SelectTrigger id="tail-lines">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="50">Last 50 lines</SelectItem>
<SelectItem value="100">Last 100 lines</SelectItem>
<SelectItem value="500">Last 500 lines</SelectItem>
<SelectItem value="1000">Last 1000 lines</SelectItem>
<SelectItem value="all">All logs</SelectItem>
</SelectContent>
</Select>
</div>
{/* Timestamps */}
<div className="flex flex-col">
<Label htmlFor="timestamps" className="mb-1">
Show Timestamps
</Label>
<div className="flex items-center h-10 px-3 border rounded-md">
<Switch
id="timestamps"
checked={showTimestamps}
onCheckedChange={setShowTimestamps}
/>
<span className="ml-2 text-sm">
{showTimestamps ? "Enabled" : "Disabled"}
</span>
</div>
</div>
{/* Auto Refresh */}
<div className="flex flex-col">
<Label htmlFor="auto-refresh" className="mb-1">
Auto Refresh
</Label>
<div className="flex items-center h-10 px-3 border rounded-md">
<Switch
id="auto-refresh"
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
<span className="ml-2 text-sm">
{autoRefresh ? "On" : "Off"}
</span>
</div>
</div>
{/* Actions */}
<div className="flex flex-col">
<Label className="mb-1">Actions</Label>
<div className="flex gap-2 h-10">
<Button
size="sm"
variant="outline"
onClick={fetchLogs}
disabled={isLoading}
className="flex-1 h-full"
>
{isLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleDownload}
disabled={isDownloading}
className="flex-1 h-full"
>
{isDownloading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
{/* Search Filter */}
<div className="mt-2">
<div className="relative">
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Filter logs..."
value={searchFilter}
onChange={(e) => 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"
/>
</div>
</div>
</CardContent>
</Card>
{/* Logs Display */}
<Card className="flex-1 overflow-hidden py-0">
<CardContent className="p-0 h-full">
{isLoading && !logs ? (
<div className="flex items-center justify-center h-full">
<SimpleLoader size="lg" />
</div>
) : (
<div className="h-full overflow-auto">
<pre className="p-4 text-xs font-mono whitespace-pre-wrap break-words text-gray-200 leading-relaxed">
{filteredLogs || (
<span className="text-gray-500">No logs available</span>
)}
<div ref={logsEndRef} />
</pre>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -567,18 +567,6 @@ export function HostManagerEditor({
)
.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(),
})
.optional(),
})
.superRefine((data, ctx) => {
if (data.authType === "none") {
@@ -672,16 +660,6 @@ export function HostManagerEditor({
terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false,
enableDocker: false,
dockerConfig: {
connectionType: "socket" as const,
socketPath: "/var/run/docker.sock",
host: "",
port: 2375,
tlsVerify: true,
tlsCaCert: "",
tlsCert: "",
tlsKey: "",
},
},
});
@@ -736,28 +714,6 @@ export function HostManagerEditor({
parsedStatsConfig = { ...DEFAULT_STATS_CONFIG, ...parsedStatsConfig };
let parsedDockerConfig = {
connectionType: "socket" as const,
socketPath: "/var/run/docker.sock",
host: "",
port: 2375,
tlsVerify: true,
tlsCaCert: "",
tlsCert: "",
tlsKey: "",
};
try {
if (cleanedHost.dockerConfig) {
const parsed =
typeof cleanedHost.dockerConfig === "string"
? JSON.parse(cleanedHost.dockerConfig)
: cleanedHost.dockerConfig;
parsedDockerConfig = { ...parsedDockerConfig, ...parsed };
}
} catch (error) {
console.error("Failed to parse dockerConfig:", error);
}
const formData = {
name: cleanedHost.name || "",
ip: cleanedHost.ip || "",
@@ -800,7 +756,6 @@ export function HostManagerEditor({
},
forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive),
enableDocker: Boolean(cleanedHost.enableDocker),
dockerConfig: parsedDockerConfig,
};
if (defaultAuthType === "password") {
@@ -853,16 +808,6 @@ export function HostManagerEditor({
terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false,
enableDocker: false,
dockerConfig: {
connectionType: "socket" as const,
socketPath: "/var/run/docker.sock",
host: "",
port: 2375,
tlsVerify: true,
tlsCaCert: "",
tlsCert: "",
tlsKey: "",
},
};
form.reset(defaultFormData);
@@ -921,7 +866,6 @@ export function HostManagerEditor({
overrideCredentialUsername: Boolean(data.overrideCredentialUsername),
enableTerminal: Boolean(data.enableTerminal),
enableDocker: Boolean(data.enableDocker),
dockerConfig: data.dockerConfig || null,
enableTunnel: Boolean(data.enableTunnel),
enableFileManager: Boolean(data.enableFileManager),
defaultPath: data.defaultPath || "/",
@@ -1043,7 +987,7 @@ export function HostManagerEditor({
setActiveTab("general");
} else if (errors.enableTerminal || errors.terminalConfig) {
setActiveTab("terminal");
} else if (errors.enableDocker || errors.dockerConfig) {
} else if (errors.enableDocker) {
setActiveTab("docker");
} else if (errors.enableTunnel || errors.tunnelConnections) {
setActiveTab("tunnel");
@@ -2627,267 +2571,6 @@ export function HostManagerEditor({
</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>
)}
</>
)}
</TabsContent>
<TabsContent value="tunnel">
<FormField