feat: Add docker management support (local squash)
This commit is contained in:
@@ -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>
|
||||
|
||||
448
src/ui/desktop/apps/docker/components/ConsoleTerminal.tsx
Normal file
448
src/ui/desktop/apps/docker/components/ConsoleTerminal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
446
src/ui/desktop/apps/docker/components/ContainerCard.tsx
Normal file
446
src/ui/desktop/apps/docker/components/ContainerCard.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
118
src/ui/desktop/apps/docker/components/ContainerDetail.tsx
Normal file
118
src/ui/desktop/apps/docker/components/ContainerDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/ui/desktop/apps/docker/components/ContainerList.tsx
Normal file
124
src/ui/desktop/apps/docker/components/ContainerList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
242
src/ui/desktop/apps/docker/components/ContainerStats.tsx
Normal file
242
src/ui/desktop/apps/docker/components/ContainerStats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
246
src/ui/desktop/apps/docker/components/LogViewer.tsx
Normal file
246
src/ui/desktop/apps/docker/components/LogViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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----- ... -----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----- ... -----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----- ... -----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
|
||||
|
||||
Reference in New Issue
Block a user