feat: initial ui redesign from demo

This commit is contained in:
LukeGus
2026-05-11 01:23:11 -05:00
parent eaa758effe
commit 33dcde0827
349 changed files with 23984 additions and 31634 deletions
+49
View File
@@ -0,0 +1,49 @@
import React from "react";
import { DockerManager } from "@/features/docker/DockerManager.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
interface DockerAppProps {
hostId?: string;
}
const DockerApp: React.FC<DockerAppProps> = ({ hostId }) => {
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, loading) => {
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
<p className="text-muted-foreground">Loading host...</p>
</div>
</div>
);
}
if (!hostConfig) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-red-500 mb-4">Host not found</p>
</div>
</div>
);
}
return (
<DockerManager
hostConfig={hostConfig}
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
isVisible={true}
isTopbarOpen={false}
embedded={true}
onClose={() => {}}
/>
);
}}
</FullScreenAppWrapper>
);
};
export default DockerApp;
+800
View File
@@ -0,0 +1,800 @@
import React from "react";
import { Separator } from "@/components/separator.tsx";
import { Alert, AlertDescription } from "@/components/alert.tsx";
import { Button } from "@/components/button.tsx";
import { Card } from "@/components/card.tsx";
import { Input } from "@/components/input.tsx";
import { AlertCircle, Box, RefreshCw, Search, Settings } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SSHHost, DockerContainer, DockerValidation } from "@/types";
import {
connectDockerSession,
disconnectDockerSession,
listDockerContainers,
validateDockerAvailability,
keepaliveDockerSession,
verifyDockerTOTP,
verifyDockerWarpgate,
logActivity,
getSSHHosts,
} from "@/main-axios.ts";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { ContainerList } from "./components/ContainerList.tsx";
import { ContainerDetail } from "./components/ContainerDetail.tsx";
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx";
import { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog.tsx";
import { useTabsSafe } from "@/shell/TabContext.tsx";
import {
ConnectionLogProvider,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { ConnectionLog } from "@/ssh/connection-log/ConnectionLog.tsx";
import type { LogEntry } from "@/types/connection-log.ts";
interface DockerManagerProps {
hostConfig?: SSHHost;
title?: string;
isVisible?: boolean;
isTopbarOpen?: boolean;
embedded?: boolean;
onClose?: () => void;
}
type ConnectionLogInput = Omit<LogEntry, "id" | "timestamp">;
interface DockerConnectionError {
message?: string;
connectionLogs?: ConnectionLogInput[];
}
function DockerManagerInner({
hostConfig,
title,
isVisible = true,
isTopbarOpen = true,
embedded = false,
onClose,
}: DockerManagerProps): React.ReactElement {
const { t } = useTranslation();
const {
addLog,
setLogs,
clearLogs,
isExpanded: isConnectionLogExpanded,
} = useConnectionLog();
const { currentTab, removeTab } = useTabsSafe();
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 [dockerValidation, setDockerValidation] =
React.useState<DockerValidation | null>(null);
const [isValidating, setIsValidating] = React.useState(false);
const [viewMode, setViewMode] = React.useState<"list" | "detail">("list");
const [isLoadingContainers, setIsLoadingContainers] = React.useState(false);
const [totpRequired, setTotpRequired] = React.useState(false);
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
const [warpgateRequired, setWarpgateRequired] = React.useState(false);
const [warpgateSessionId, setWarpgateSessionId] = React.useState<
string | null
>(null);
const [warpgateUrl, setWarpgateUrl] = React.useState<string>("");
const [warpgateSecurityKey, setWarpgateSecurityKey] =
React.useState<string>("");
const [showAuthDialog, setShowAuthDialog] = React.useState(false);
const [authReason, setAuthReason] = React.useState<
"no_keyboard" | "auth_failed" | "timeout"
>("no_keyboard");
const [hasConnectionError, setHasConnectionError] = React.useState(false);
const [search, setSearch] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState("all");
const activityLoggedRef = React.useRef(false);
const activityLoggingRef = React.useRef(false);
const logDockerActivity = async () => {
if (
!currentHostConfig?.id ||
activityLoggedRef.current ||
activityLoggingRef.current
) {
return;
}
activityLoggingRef.current = true;
activityLoggedRef.current = true;
try {
const hostName =
currentHostConfig.name ||
`${currentHostConfig.username}@${currentHostConfig.ip}`;
await logActivity("docker", currentHostConfig.id, hostName);
} catch (err) {
console.warn("Failed to log docker activity:", err);
activityLoggedRef.current = false;
} finally {
activityLoggingRef.current = false;
}
};
React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
setCurrentHostConfig(hostConfig);
setContainers([]);
setSelectedContainer(null);
setSessionId(null);
setDockerValidation(null);
setViewMode("list");
}
}, [hostConfig?.id]);
React.useEffect(() => {
const fetchLatestHostConfig = async () => {
if (hostConfig?.id) {
try {
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch {
// Silently handle error
}
}
};
fetchLatestHostConfig();
const handleHostsChanged = async () => {
if (hostConfig?.id) {
try {
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch {
// Silently handle error
}
}
};
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
return () =>
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
}, [hostConfig?.id]);
const initializingRef = React.useRef(false);
React.useEffect(() => {
const initSession = async () => {
if (!currentHostConfig?.id || !currentHostConfig.enableDocker) {
return;
}
if (initializingRef.current) return;
initializingRef.current = true;
if (sessionId) {
initializingRef.current = false;
return;
}
setIsConnecting(true);
setHasConnectionError(false);
clearLogs();
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
try {
const result = await connectDockerSession(sid, currentHostConfig.id, {
useSocks5: currentHostConfig.useSocks5,
socks5Host: currentHostConfig.socks5Host,
socks5Port: currentHostConfig.socks5Port,
socks5Username: currentHostConfig.socks5Username,
socks5Password: currentHostConfig.socks5Password,
socks5ProxyChain: currentHostConfig.socks5ProxyChain,
});
if (result?.requires_warpgate) {
setWarpgateRequired(true);
setWarpgateSessionId(sid);
setWarpgateUrl(result.url || "");
setWarpgateSecurityKey(result.securityKey || "");
setIsConnecting(false);
return;
}
if (result?.requires_totp) {
setTotpRequired(true);
setTotpSessionId(sid);
setTotpPrompt(result.prompt || t("docker.verificationCodePrompt"));
setIsConnecting(false);
return;
}
if (result?.status === "auth_required") {
setShowAuthDialog(true);
setAuthReason(
result.reason === "no_keyboard" ? "no_keyboard" : "auth_failed",
);
setIsConnecting(false);
return;
}
setSessionId(sid);
setIsValidating(true);
const validation = await validateDockerAvailability(sid);
setDockerValidation(validation);
setIsValidating(false);
if (!validation.available) {
setHasConnectionError(true);
addLog({
type: "error",
stage: "validation",
message: validation.error || t("docker.error"),
details: validation.code
? `Error code: ${validation.code}`
: undefined,
});
} else {
logDockerActivity();
setTimeout(() => clearLogs(), 1000);
}
} catch (error) {
const dockerError = error as DockerConnectionError;
setIsConnecting(false);
setIsValidating(false);
setHasConnectionError(true);
if (Array.isArray(dockerError.connectionLogs)) {
setLogs(dockerError.connectionLogs);
} else {
addLog({
type: "error",
stage: "connection",
message: dockerError.message || t("docker.connectionFailed"),
});
}
} finally {
setIsConnecting(false);
}
};
initSession();
return () => {
initializingRef.current = false;
if (sessionId) {
disconnectDockerSession(sessionId).catch(() => {
// Silently handle disconnect errors
});
}
};
}, [currentHostConfig?.id, currentHostConfig?.enableDocker]);
React.useEffect(() => {
if (!sessionId || !isVisible) return;
const keepalive = setInterval(
() => {
keepaliveDockerSession(sessionId).catch(() => {
// Silently handle keepalive errors
});
},
10 * 60 * 1000,
);
return () => clearInterval(keepalive);
}, [sessionId, isVisible]);
const refreshContainers = React.useCallback(async () => {
if (!sessionId) return;
try {
const data = await listDockerContainers(sessionId, true);
setContainers(data);
} catch {
// Silently handle polling errors
}
}, [sessionId]);
React.useEffect(() => {
if (!sessionId || !isVisible || !dockerValidation?.available) return;
let cancelled = false;
const pollContainers = async () => {
try {
setIsLoadingContainers(true);
const data = await listDockerContainers(sessionId, true);
if (!cancelled) {
setContainers(data);
}
} catch {
// Silently handle polling errors
} finally {
if (!cancelled) {
setIsLoadingContainers(false);
}
}
};
pollContainers();
const interval = setInterval(pollContainers, 5000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [sessionId, isVisible, dockerValidation?.available]);
const handleBack = React.useCallback(() => {
setViewMode("list");
setSelectedContainer(null);
}, []);
const handleTotpSubmit = async (code: string) => {
if (!totpSessionId || !code) return;
try {
setIsConnecting(true);
const result = await verifyDockerTOTP(totpSessionId, code);
if (result?.status === "success") {
setTotpRequired(false);
setTotpPrompt("");
setSessionId(totpSessionId);
setTotpSessionId(null);
setIsValidating(true);
const validation = await validateDockerAvailability(totpSessionId);
setDockerValidation(validation);
setIsValidating(false);
if (!validation.available) {
setHasConnectionError(true);
addLog({
type: "error",
stage: "validation",
message: validation.error || t("docker.error"),
details: validation.code
? `Error code: ${validation.code}`
: undefined,
});
} else {
logDockerActivity();
}
}
} catch (error) {
console.error("TOTP verification failed:", error);
setHasConnectionError(true);
addLog({
type: "error",
stage: "auth",
message: t("docker.totpVerificationFailed"),
});
} finally {
setIsConnecting(false);
}
};
const handleTotpCancel = () => {
setTotpRequired(false);
setTotpSessionId(null);
setTotpPrompt("");
setIsConnecting(false);
if (currentTab !== null) {
removeTab(currentTab);
}
};
const handleWarpgateContinue = async () => {
if (!warpgateSessionId) return;
try {
setIsConnecting(true);
const result = await verifyDockerWarpgate(warpgateSessionId);
if (result?.status === "success") {
setWarpgateRequired(false);
setWarpgateUrl("");
setWarpgateSecurityKey("");
setSessionId(warpgateSessionId);
setWarpgateSessionId(null);
setIsValidating(true);
const validation = await validateDockerAvailability(warpgateSessionId);
setDockerValidation(validation);
setIsValidating(false);
if (!validation.available) {
setHasConnectionError(true);
addLog({
type: "error",
stage: "validation",
message: validation.error || t("docker.error"),
details: validation.code
? `Error code: ${validation.code}`
: undefined,
});
} else {
logDockerActivity();
}
}
} catch (error) {
console.error("Warpgate verification failed:", error);
setHasConnectionError(true);
addLog({
type: "error",
stage: "auth",
message: t("docker.warpgateVerificationFailed"),
});
} finally {
setIsConnecting(false);
}
};
const handleWarpgateCancel = () => {
setWarpgateRequired(false);
setWarpgateSessionId(null);
setWarpgateUrl("");
setWarpgateSecurityKey("");
setIsConnecting(false);
if (currentTab !== null) {
removeTab(currentTab);
}
};
const handleWarpgateOpenUrl = () => {
if (warpgateUrl) {
window.open(warpgateUrl, "_blank", "noopener,noreferrer");
}
};
const handleAuthSubmit = async (credentials: {
password?: string;
sshKey?: string;
keyPassword?: string;
}) => {
if (!currentHostConfig?.id) return;
setShowAuthDialog(false);
setIsConnecting(true);
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
try {
const result = await connectDockerSession(sid, currentHostConfig.id, {
userProvidedPassword: credentials.password,
userProvidedSshKey: credentials.sshKey,
userProvidedKeyPassword: credentials.keyPassword,
useSocks5: currentHostConfig.useSocks5,
socks5Host: currentHostConfig.socks5Host,
socks5Port: currentHostConfig.socks5Port,
socks5Username: currentHostConfig.socks5Username,
socks5Password: currentHostConfig.socks5Password,
socks5ProxyChain: currentHostConfig.socks5ProxyChain,
});
if (result?.requires_warpgate) {
setWarpgateRequired(true);
setWarpgateSessionId(sid);
setWarpgateUrl(result.url || "");
setWarpgateSecurityKey(result.securityKey || "N/A");
setIsConnecting(false);
return;
}
if (result?.requires_totp) {
setTotpRequired(true);
setTotpSessionId(sid);
setTotpPrompt(result.prompt || t("docker.verificationCodePrompt"));
setIsConnecting(false);
return;
}
if (result?.status === "auth_required") {
setShowAuthDialog(true);
setAuthReason("auth_failed");
setIsConnecting(false);
return;
}
setSessionId(sid);
setIsValidating(true);
const validation = await validateDockerAvailability(sid);
setDockerValidation(validation);
setIsValidating(false);
if (!validation.available) {
setHasConnectionError(true);
} else {
logDockerActivity();
}
} catch (error) {
setIsConnecting(false);
setIsValidating(false);
setHasConnectionError(true);
addLog({
type: "error",
stage: "connection",
message: error?.message || t("docker.connectionFailed"),
});
} finally {
setIsConnecting(false);
}
};
const handleAuthCancel = () => {
setShowAuthDialog(false);
setIsConnecting(false);
onClose?.();
};
const topMarginPx = isTopbarOpen ? 74 : 16;
const leftMarginPx = 8;
const bottomMarginPx = 8;
const wrapperStyle: React.CSSProperties = embedded
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
: {
opacity: isVisible ? 1 : 0,
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
};
const containerClass = embedded
? "h-full w-full text-foreground overflow-hidden bg-transparent"
: "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden";
if (!currentHostConfig?.enableDocker) {
return (
<div style={wrapperStyle} className={`${containerClass} relative`}>
<div className="h-full w-full flex flex-col flex-1 min-h-0 overflow-hidden">
<div className="flex-1 overflow-y-auto px-3 py-3 flex flex-col gap-3">
<Card className="flex-row items-center justify-between px-3 py-3 shrink-0 gap-0">
<div className="flex items-center gap-3">
<div className="size-10 border border-border bg-muted flex items-center justify-center shrink-0">
<Box className="size-5 text-accent-brand" />
</div>
<div>
<h1 className="text-2xl font-bold">{title}</h1>
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("docker.manager")}
</span>
</div>
</div>
</Card>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{t("docker.notEnabled")}</AlertDescription>
</Alert>
</div>
</div>
</div>
);
}
if (isConnecting || isValidating) {
return (
<div style={wrapperStyle} className={`${containerClass} relative`}>
<div className="h-full w-full flex flex-col">
<div className="flex-1 overflow-hidden min-h-0 relative">
<SimpleLoader
visible={!isConnectionLogExpanded}
message={
isValidating ? t("docker.validating") : t("docker.connecting")
}
/>
</div>
</div>
<ConnectionLog
isConnecting={isConnecting}
isConnected={!!sessionId && !!dockerValidation?.available}
hasConnectionError={hasConnectionError}
position={hasConnectionError ? "top" : "bottom"}
/>
</div>
);
}
if (dockerValidation && !dockerValidation.available) {
return (
<div style={wrapperStyle} className={`${containerClass} relative`}>
<ConnectionLog
isConnecting={isConnecting}
isConnected={!!sessionId && !!dockerValidation?.available}
hasConnectionError={
hasConnectionError ||
(!!dockerValidation && !dockerValidation.available)
}
position={
hasConnectionError ||
(!!dockerValidation && !dockerValidation.available)
? "top"
: "bottom"
}
/>
</div>
);
}
return (
<div style={wrapperStyle} className={`${containerClass} relative`}>
<div
className="h-full w-full flex flex-col flex-1 min-h-0 overflow-hidden"
style={{
visibility:
(hasConnectionError ||
(!!dockerValidation && !dockerValidation.available)) &&
isConnectionLogExpanded
? "hidden"
: "visible",
}}
>
{viewMode === "detail" &&
sessionId &&
selectedContainer &&
currentHostConfig ? (
<ContainerDetail
sessionId={sessionId}
containerId={selectedContainer}
containers={containers}
hostConfig={currentHostConfig}
onBack={handleBack}
/>
) : (
<div className="flex-1 overflow-y-auto px-3 py-3 flex flex-col gap-3">
<Card className="flex-row items-center justify-between px-3 py-3 shrink-0 gap-0">
<div className="flex items-center gap-3">
<div className="size-10 border border-border bg-muted flex items-center justify-center shrink-0">
<Box className="size-5 text-accent-brand" />
</div>
<div>
<h1 className="text-2xl font-bold">{title}</h1>
<div className="flex items-center gap-2">
<span className="size-2 rounded-full bg-accent-brand" />
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{dockerValidation?.version
? t("docker.version", {
version: dockerValidation.version,
})
: t("docker.manager")}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative w-56">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
placeholder={t("docker.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 h-8"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="h-8 px-2 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring"
>
<option value="all">{t("docker.allStatuses")}</option>
<option value="running">{t("docker.stateRunning")}</option>
<option value="paused">{t("docker.statePaused")}</option>
<option value="exited">{t("docker.stateExited")}</option>
<option value="restarting">
{t("docker.stateRestarting")}
</option>
</select>
<Separator orientation="vertical" className="h-8 mx-1" />
<Button
variant="ghost"
size="icon"
onClick={refreshContainers}
disabled={isLoadingContainers}
>
<RefreshCw
className={`size-4 text-accent-brand ${isLoadingContainers ? "animate-spin" : ""}`}
/>
</Button>
<Button variant="ghost" size="icon">
<Settings className="size-4 text-accent-brand" />
</Button>
</div>
</Card>
{sessionId ? (
isLoadingContainers && containers.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full opacity-40 py-20">
<RefreshCw className="size-8 animate-spin mb-4" />
<span className="text-sm font-semibold">
{t("docker.loadingContainers")}
</span>
</div>
) : (
<ContainerList
containers={containers}
sessionId={sessionId}
onSelectContainer={(id) => {
setSelectedContainer(id);
setViewMode("detail");
}}
selectedContainerId={selectedContainer}
onRefresh={refreshContainers}
search={search}
statusFilter={statusFilter}
/>
)
) : null}
</div>
)}
</div>
<TOTPDialog
isOpen={totpRequired}
prompt={totpPrompt}
onSubmit={handleTotpSubmit}
onCancel={handleTotpCancel}
/>
<WarpgateDialog
isOpen={warpgateRequired}
url={warpgateUrl}
securityKey={warpgateSecurityKey}
onContinue={handleWarpgateContinue}
onCancel={handleWarpgateCancel}
onOpenUrl={handleWarpgateOpenUrl}
/>
{currentHostConfig && (
<SSHAuthDialog
isOpen={showAuthDialog}
reason={authReason}
onSubmit={handleAuthSubmit}
onCancel={handleAuthCancel}
hostInfo={{
ip: currentHostConfig.ip,
port: currentHostConfig.port,
username: currentHostConfig.username,
name: currentHostConfig.name,
}}
/>
)}
<SimpleLoader
visible={isConnecting && !isConnectionLogExpanded}
message={t("docker.connecting")}
/>
<ConnectionLog
isConnecting={isConnecting}
isConnected={!!sessionId && !!dockerValidation?.available}
hasConnectionError={
hasConnectionError ||
(!!dockerValidation && !dockerValidation.available)
}
position={
hasConnectionError ||
(!!dockerValidation && !dockerValidation.available)
? "top"
: "bottom"
}
/>
</div>
);
}
export function DockerManager(props: DockerManagerProps): React.ReactElement {
return (
<ConnectionLogProvider>
<DockerManagerInner {...props} />
</ConnectionLogProvider>
);
}
@@ -0,0 +1,537 @@
import React from "react";
import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { RobustClipboardProvider } from "@/lib/clipboard-provider";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { Button } from "@/components/button.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select.tsx";
import { Card, CardContent } from "@/components/card.tsx";
import { getBasePath } from "@/lib/base-path";
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
import { toast } from "sonner";
import type { SSHHost } from "@/types";
import { isElectron } from "@/main-axios.ts";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { useTranslation } from "react-i18next";
interface ConsoleTerminalProps {
containerId: string;
containerName: string;
containerState: string;
hostConfig: SSHHost;
}
export function ConsoleTerminal({
containerId,
containerName,
containerState,
hostConfig,
}: ConsoleTerminalProps): React.ReactElement {
const { t } = useTranslation();
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);
React.useEffect(() => {
if (!terminal) return;
const fitAddon = new FitAddon();
const clipboardProvider = new RobustClipboardProvider();
const clipboardAddon = new ClipboardAddon(undefined, clipboardProvider);
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";
const readTextFromClipboard = async (): Promise<string> => {
if (window.electronClipboard) {
return window.electronClipboard.readText();
}
return navigator.clipboard.readText();
};
const writeTextToClipboard = async (text: string): Promise<void> => {
if (window.electronClipboard) {
await window.electronClipboard.writeText(text);
return;
}
await navigator.clipboard.writeText(text);
};
terminal.attachCustomKeyEventHandler((e: KeyboardEvent): boolean => {
if (e.type !== "keydown") return true;
if (
((e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) ||
(e.metaKey && !e.shiftKey && !e.ctrlKey && !e.altKey)) &&
e.key.toLowerCase() === "v"
) {
e.preventDefault();
e.stopPropagation();
readTextFromClipboard()
.then((text) => {
if (text) terminal.paste(text);
})
.catch(() => {
toast.error(t("terminal.clipboardReadFailed"));
});
return false;
}
if (
e.ctrlKey &&
!e.shiftKey &&
!e.altKey &&
!e.metaKey &&
e.key.toLowerCase() === "c" &&
terminal.hasSelection()
) {
e.preventDefault();
e.stopPropagation();
const selection = terminal.getSelection();
if (selection) {
writeTextToClipboard(selection).catch(() => {
toast.error(t("terminal.clipboardWriteFailed"));
});
terminal.clearSelection();
}
return false;
}
if (
e.ctrlKey &&
!e.shiftKey &&
!e.altKey &&
!e.metaKey &&
e.key === "Insert" &&
terminal.hasSelection()
) {
e.preventDefault();
e.stopPropagation();
const selection = terminal.getSelection();
if (selection) {
writeTextToClipboard(selection).catch(() => {
toast.error(t("terminal.clipboardWriteFailed"));
});
}
return false;
}
if (
e.shiftKey &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
e.key === "Insert"
) {
e.preventDefault();
e.stopPropagation();
readTextFromClipboard()
.then((text) => {
if (text) terminal.paste(text);
})
.catch(() => {
toast.error(t("terminal.clipboardReadFailed"));
});
return false;
}
return true;
});
const backgroundColor = getComputedStyle(document.documentElement)
.getPropertyValue("--bg-elevated")
.trim();
const foregroundColor = getComputedStyle(document.documentElement)
.getPropertyValue("--foreground")
.trim();
terminal.options.theme = {
background: backgroundColor || "var(--bg-elevated)",
foreground: foregroundColor || "var(--foreground)",
};
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);
clipboardProvider.dispose();
if (wsRef.current) {
try {
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
} catch {
// Best-effort disconnect during cleanup.
}
wsRef.current.close();
wsRef.current = null;
}
terminal.dispose();
};
}, [terminal, t]);
const disconnect = React.useCallback(() => {
if (wsRef.current) {
try {
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
} catch {
// Best-effort disconnect.
}
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
if (terminal) {
try {
terminal.clear();
} catch {
// Terminal clear can fail after disposal.
}
}
}, [terminal]);
const connect = React.useCallback(() => {
if (!terminal || containerState !== "running") {
toast.error(t("docker.containerMustBeRunning"));
return;
}
setIsConnecting(true);
try {
if (fitAddonRef.current) {
fitAddonRef.current.fit();
}
const isElectronApp = isElectron();
const isDev =
!isElectronApp &&
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "");
const baseWsUrl = isDev
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`
: isElectronApp
? (() => {
const baseUrl =
(window as { configuredServerUrl?: string })
.configuredServerUrl || "http://127.0.0.1:30001";
const wsProtocol = baseUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = baseUrl.replace(/^https?:\/\//, "");
return `${wsProtocol}${wsHost}/docker/console/`;
})()
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`;
const ws = new WebSocket(baseWsUrl);
ws.onopen = () => {
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);
if (msg.data?.shellChanged) {
toast.warning(
`Shell "${msg.data.requestedShell}" not available. Using "${msg.data.shell}" instead.`,
);
} else {
toast.success(t("docker.connectedTo", { containerName }));
}
setTimeout(() => {
if (fitAddonRef.current) {
fitAddonRef.current.fit();
}
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 || t("docker.disconnected")}\x1b[0m\r\n`,
);
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
break;
case "error":
setIsConnecting(false);
toast.error(msg.message || t("docker.consoleError"));
terminal.write(
`\r\n\x1b[1;31m${t("docker.errorMessage", { message: 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(t("docker.failedToConnect"));
};
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
}
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping" }));
}
}, 30000);
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;
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,
hostConfig,
containerId,
selectedShell,
containerName,
t,
]);
React.useEffect(() => {
return () => {
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
if (wsRef.current) {
try {
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
} catch {
// Best-effort disconnect 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-muted-foreground/50 mx-auto" />
<p className="text-muted-foreground text-lg">
{t("docker.containerNotRunning")}
</p>
<p className="text-muted-foreground text-sm">
{t("docker.startContainerToAccess")}
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full gap-3">
<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">
{t("docker.console")}
</span>
</div>
<Select
value={selectedShell}
onValueChange={setSelectedShell}
disabled={isConnected}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder={t("docker.selectShell")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="bash">{t("docker.bash")}</SelectItem>
<SelectItem value="sh">{t("docker.sh")}</SelectItem>
<SelectItem value="ash">{t("docker.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" />
{t("docker.connecting")}
</>
) : (
<>
<Power className="h-4 w-4 mr-2" />
{t("docker.connect")}
</>
)}
</Button>
) : (
<Button
onClick={disconnect}
variant="destructive"
className="min-w-[120px]"
>
<PowerOff className="h-4 w-4 mr-2" />
{t("docker.disconnect")}
</Button>
)}
</div>
</div>
</CardContent>
</Card>
<Card className="flex-1 overflow-hidden pt-1 pb-0">
<CardContent className="p-0 h-full relative">
<div
ref={xtermRef}
className="h-full w-full"
style={{ display: isConnected ? "block" : "none" }}
/>
{!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-muted-foreground/50 mx-auto" />
<p className="text-muted-foreground">
{t("docker.notConnected")}
</p>
<p className="text-muted-foreground text-sm">
{t("docker.clickToConnect")}
</p>
</div>
</div>
)}
{isConnecting && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<SimpleLoader size="lg" />
<p className="text-muted-foreground mt-4">
{t("docker.connectingTo", { containerName })}
</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,311 @@
import React from "react";
import { Card } from "@/components/card.tsx";
import { Button } from "@/components/button.tsx";
import {
Box,
Play,
Square,
RotateCw,
Pause,
PlayCircle,
Trash2,
RefreshCw,
} from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import type { DockerContainer } from "@/types";
import {
startDockerContainer,
stopDockerContainer,
restartDockerContainer,
pauseDockerContainer,
unpauseDockerContainer,
removeDockerContainer,
} from "@/main-axios.ts";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
interface ContainerCardProps {
container: DockerContainer;
sessionId: string;
onSelect?: () => void;
isSelected?: boolean;
onRefresh?: () => void;
}
export function DockerBadge({ state }: { state: DockerContainer["state"] }) {
let colorClass = "border-border text-muted-foreground";
if (state === "running")
colorClass = "border-accent-brand/40 text-accent-brand bg-accent-brand/10";
if (state === "paused")
colorClass = "border-yellow-500/40 text-yellow-500 bg-yellow-500/10";
if (state === "exited")
colorClass = "border-destructive/40 text-destructive bg-destructive/5";
if (state === "restarting")
colorClass = "border-blue-400/40 text-blue-400 bg-blue-400/10";
return (
<span
className={`text-[10px] font-bold px-1.5 py-0.5 border uppercase tracking-wider ${colorClass}`}
>
{state}
</span>
);
}
export function ContainerCard({
container,
sessionId,
onSelect,
isSelected = false,
onRefresh,
}: ContainerCardProps): React.ReactElement {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
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 isLoading =
isStarting || isStopping || isRestarting || isPausing || isRemoving;
const containerName = container.name.startsWith("/")
? container.name.slice(1)
: container.name;
const portsList = (container.ports ?? "")
.split(",")
.map((p) => p.trim())
.filter(Boolean);
const handleStart = async (e: React.MouseEvent) => {
e.stopPropagation();
setIsStarting(true);
try {
await startDockerContainer(sessionId, container.id);
toast.success(t("docker.containerStarted", { name: containerName }));
onRefresh?.();
} catch (err) {
toast.error(
t("docker.failedToStartContainer", {
error: err instanceof Error ? err.message : "Unknown error",
}),
);
} finally {
setIsStarting(false);
}
};
const handleStop = async (e: React.MouseEvent) => {
e.stopPropagation();
setIsStopping(true);
try {
await stopDockerContainer(sessionId, container.id);
toast.success(t("docker.containerStopped", { name: containerName }));
onRefresh?.();
} catch (err) {
toast.error(
t("docker.failedToStopContainer", {
error: err instanceof Error ? err.message : "Unknown error",
}),
);
} finally {
setIsStopping(false);
}
};
const handleRestart = async (e: React.MouseEvent) => {
e.stopPropagation();
setIsRestarting(true);
try {
await restartDockerContainer(sessionId, container.id);
toast.success(t("docker.containerRestarted", { name: containerName }));
onRefresh?.();
} catch (err) {
toast.error(
t("docker.failedToRestartContainer", {
error: err instanceof Error ? err.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(t("docker.containerUnpaused", { name: containerName }));
} else {
await pauseDockerContainer(sessionId, container.id);
toast.success(t("docker.containerPaused", { name: containerName }));
}
onRefresh?.();
} catch (err) {
toast.error(
t("docker.failedToTogglePauseContainer", {
action: container.state === "paused" ? "unpause" : "pause",
error: err instanceof Error ? err.message : "Unknown error",
}),
);
} finally {
setIsPausing(false);
}
};
const handleRemove = async (e: React.MouseEvent) => {
e.stopPropagation();
confirmWithToast(
t("docker.confirmRemoveContainer", { name: containerName }) +
(container.state === "running"
? " " + t("docker.runningContainerWarning")
: ""),
async () => {
setIsRemoving(true);
try {
await removeDockerContainer(
sessionId,
container.id,
container.state === "running",
);
toast.success(t("docker.containerRemoved", { name: containerName }));
onRefresh?.();
} catch (err) {
toast.error(
t("docker.failedToRemoveContainer", {
error: err instanceof Error ? err.message : "Unknown error",
}),
);
} finally {
setIsRemoving(false);
}
},
t("common.remove"),
t("common.cancel"),
);
};
return (
<Card
className={`flex flex-col overflow-hidden p-0 gap-0 group hover:border-accent-brand/40 transition-colors cursor-pointer ${isSelected ? "border-accent-brand/60 bg-accent-brand/5" : ""}`}
onClick={onSelect}
>
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-muted/10">
<div className="flex items-center gap-2 min-w-0">
<Box
className={`size-3.5 ${container.state === "running" ? "text-accent-brand" : "text-muted-foreground"}`}
/>
<span className="text-sm font-bold truncate">{containerName}</span>
</div>
<DockerBadge state={container.state} />
</div>
<div className="px-4 py-3 flex flex-col gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-[10px] text-muted-foreground uppercase font-bold tracking-widest">
{t("docker.image")}
</span>
<span className="text-xs font-mono truncate text-foreground/80">
{container.image}
</span>
</div>
<div className="flex flex-col gap-0.5 mt-1">
<span className="text-[10px] text-muted-foreground uppercase font-bold tracking-widest">
{t("docker.ports")}
</span>
<div className="flex flex-wrap gap-1 mt-0.5">
{portsList.length > 0 ? (
portsList.slice(0, 3).map((p) => (
<span
key={p}
className="text-[10px] font-mono px-1 border border-border bg-muted/30"
>
{p}
</span>
))
) : (
<span className="text-[10px] text-muted-foreground italic">
{t("docker.noPorts")}
</span>
)}
</div>
</div>
</div>
<div className="px-4 py-2 border-t border-border bg-muted/5 flex items-center justify-between opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[10px] text-muted-foreground italic">
{container.id.substring(0, 12)}
</span>
<div className="flex items-center gap-1">
{container.state !== "running" && (
<Button
variant="ghost"
size="icon-xs"
className="text-accent-brand"
disabled={isLoading}
onClick={handleStart}
>
{isStarting ? (
<RefreshCw className="size-3 animate-spin" />
) : (
<Play className="size-3" />
)}
</Button>
)}
{container.state === "running" && (
<Button
variant="ghost"
size="icon-xs"
className="text-destructive"
disabled={isLoading}
onClick={handleStop}
>
{isStopping ? (
<RefreshCw className="size-3 animate-spin" />
) : (
<Square className="size-3" />
)}
</Button>
)}
{(container.state === "running" || container.state === "paused") && (
<Button
variant="ghost"
size="icon-xs"
disabled={isLoading}
onClick={handlePause}
>
{isPausing ? (
<RefreshCw className="size-3 animate-spin" />
) : container.state === "paused" ? (
<PlayCircle className="size-3" />
) : (
<Pause className="size-3" />
)}
</Button>
)}
<Button
variant="ghost"
size="icon-xs"
disabled={isLoading || container.state === "exited"}
onClick={handleRestart}
>
{isRestarting ? (
<RefreshCw className="size-3 animate-spin" />
) : (
<RotateCw className="size-3" />
)}
</Button>
<Button
variant="ghost"
size="icon-xs"
className="text-destructive"
disabled={isLoading}
onClick={handleRemove}
>
<Trash2 className="size-3" />
</Button>
</div>
</div>
</Card>
);
}
@@ -0,0 +1,161 @@
import React from "react";
import { Button } from "@/components/button.tsx";
import { Card } from "@/components/card.tsx";
import { Separator } from "@/components/separator.tsx";
import {
Activity,
ArrowLeft,
Box,
Info,
List,
Settings,
Terminal,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { DockerContainer, SSHHost } from "@/types";
import { LogViewer } from "./LogViewer.tsx";
import { ContainerStats } from "./ContainerStats.tsx";
import { ConsoleTerminal } from "./ConsoleTerminal.tsx";
import { DockerBadge } from "./ContainerCard.tsx";
interface ContainerDetailProps {
sessionId: string;
containerId: string;
containers: DockerContainer[];
hostConfig: SSHHost;
onBack: () => void;
}
type DetailTab = "logs" | "stats" | "console";
export function ContainerDetail({
sessionId,
containerId,
containers,
hostConfig,
onBack,
}: ContainerDetailProps): React.ReactElement {
const { t } = useTranslation();
const [activeTab, setActiveTab] = React.useState<DetailTab>("logs");
const container = containers.find((c) => c.id === containerId);
const containerName = container
? container.name.startsWith("/")
? container.name.slice(1)
: container.name
: "";
if (!container) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center flex flex-col gap-3">
<p className="text-muted-foreground">
{t("docker.containerNotFound")}
</p>
<Button onClick={onBack} variant="outline" size="sm">
<ArrowLeft className="size-4 mr-2" />
{t("docker.backToList")}
</Button>
</div>
</div>
);
}
const tabs: { id: DetailTab; label: string; icon: React.ReactNode }[] = [
{
id: "logs",
label: t("docker.logs"),
icon: <List className="size-3.5" />,
},
{
id: "stats",
label: t("docker.stats"),
icon: <Activity className="size-3.5" />,
},
{
id: "console",
label: t("docker.consoleTab"),
icon: <Terminal className="size-3.5" />,
},
];
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto px-3 py-3 gap-3">
<Card className="flex-row items-center justify-between px-3 py-3 shrink-0 gap-0">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="size-8 text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-4" />
</Button>
<div className="size-10 border border-border bg-muted flex items-center justify-center shrink-0">
<Box className="size-5 text-accent-brand" />
</div>
<div>
<h1 className="text-2xl font-bold">{containerName}</h1>
<span className="text-xs text-muted-foreground font-mono">
{container.image}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<DockerBadge state={container.state} />
<Separator orientation="vertical" className="h-8 mx-2" />
<Button variant="ghost" size="icon">
<Settings className="size-4 text-accent-brand" />
</Button>
</div>
</Card>
<div className="flex flex-col flex-1 min-h-0 gap-3">
<div className="flex gap-1 border-b border-border shrink-0">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2 text-sm font-semibold border-b-2 transition-colors ${
activeTab === tab.id
? "border-b-accent-brand text-foreground bg-accent-brand/5"
: "border-b-transparent text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
<div className="flex-1 min-h-0 flex flex-col">
{activeTab === "logs" && (
<LogViewer
sessionId={sessionId}
containerId={containerId}
containerName={containerName}
/>
)}
{activeTab === "stats" && (
<ContainerStats
sessionId={sessionId}
containerId={containerId}
containerName={containerName}
containerState={container.state}
/>
)}
{activeTab === "console" && (
<ConsoleTerminal
containerId={containerId}
containerName={containerName}
containerState={container.state}
hostConfig={hostConfig}
/>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,83 @@
import React from "react";
import { Box } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { DockerContainer } from "@/types";
import { ContainerCard } from "./ContainerCard.tsx";
interface ContainerListProps {
containers: DockerContainer[];
sessionId: string;
onSelectContainer: (containerId: string) => void;
selectedContainerId?: string | null;
onRefresh?: () => void;
search?: string;
statusFilter?: string;
}
export function ContainerList({
containers,
sessionId,
onSelectContainer,
selectedContainerId = null,
onRefresh,
search = "",
statusFilter = "all",
}: ContainerListProps): React.ReactElement {
const { t } = useTranslation();
const filtered = React.useMemo(() => {
return containers.filter((c) => {
const name = c.name.startsWith("/") ? c.name.slice(1) : c.name;
const matchesSearch =
name.toLowerCase().includes(search.toLowerCase()) ||
c.image.toLowerCase().includes(search.toLowerCase()) ||
c.id.toLowerCase().includes(search.toLowerCase());
return (
matchesSearch && (statusFilter === "all" || c.state === statusFilter)
);
});
}, [containers, search, statusFilter]);
if (containers.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full opacity-20 py-20">
<Box className="size-16 mb-4" />
<span className="text-xl font-bold uppercase tracking-widest">
{t("docker.noContainersFound")}
</span>
<span className="text-xs font-semibold">
{t("docker.noContainersFoundHint")}
</span>
</div>
);
}
if (filtered.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full opacity-20 py-20">
<Box className="size-16 mb-4" />
<span className="text-xl font-bold uppercase tracking-widest">
{t("docker.noContainersMatchFilters")}
</span>
<span className="text-xs font-semibold">
{t("docker.noContainersMatchFiltersHint")}
</span>
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{filtered.map((container) => (
<ContainerCard
key={container.id}
container={container}
sessionId={sessionId}
onSelect={() => onSelectContainer(container.id)}
isSelected={selectedContainerId === container.id}
onRefresh={onRefresh}
/>
))}
</div>
);
}
@@ -0,0 +1,240 @@
import React from "react";
import {
Activity,
Cpu,
HardDrive,
Info,
MemoryStick,
Network,
RefreshCw,
} from "lucide-react";
import type { DockerStats } from "@/types";
import { getContainerStats } from "@/main-axios.ts";
import { useTranslation } from "react-i18next";
import { SectionCard } from "@/components/section-card";
import { DockerBadge } from "./ContainerCard.tsx";
interface ContainerStatsProps {
sessionId: string;
containerId: string;
containerName: string;
containerState: string;
}
export function ContainerStats({
sessionId,
containerId,
containerName,
containerState,
}: ContainerStatsProps): React.ReactElement {
const { t } = useTranslation();
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") return;
setIsLoading(true);
setError(null);
try {
const data = await getContainerStats(sessionId, containerId);
setStats(data);
} catch (err) {
setError(
err instanceof Error ? err.message : t("docker.failedToFetchStats"),
);
} finally {
setIsLoading(false);
}
}, [sessionId, containerId, containerState]);
React.useEffect(() => {
fetchStats();
const interval = setInterval(fetchStats, 2000);
return () => clearInterval(interval);
}, [fetchStats]);
if (containerState !== "running") {
return (
<div className="flex flex-col items-center justify-center h-full opacity-20 py-20">
<Activity className="size-16 mb-4" />
<span className="text-xl font-bold uppercase tracking-widest">
{t("docker.containerNotRunning")}
</span>
<span className="text-xs font-semibold">
{t("docker.startContainerToViewStats")}
</span>
</div>
);
}
if (isLoading && !stats) {
return (
<div className="flex flex-col items-center justify-center h-full opacity-40 py-20">
<RefreshCw className="size-8 animate-spin mb-4" />
<span className="text-sm font-semibold">
{t("docker.loadingStats")}
</span>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full opacity-40 py-20">
<Activity className="size-8 mb-4" />
<span className="text-sm font-semibold text-destructive">
{t("docker.errorLoadingStats")}
</span>
<span className="text-xs text-muted-foreground mt-1">{error}</span>
</div>
);
}
if (!stats) {
return (
<div className="flex flex-col items-center justify-center h-full opacity-20 py-20">
<span className="text-sm font-semibold">
{t("docker.noStatsAvailable")}
</span>
</div>
);
}
const cpuPercent = parseFloat(stats.cpu) || 0;
const memPercent = parseFloat(stats.memoryPercent) || 0;
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<SectionCard
title={t("docker.cpuUsage")}
icon={<Cpu className="size-3.5" />}
>
<div className="flex flex-col gap-3 py-2">
<div className="flex items-end justify-between">
<span className="text-3xl font-bold text-accent-brand">
{stats.cpu}
</span>
<span className="text-[10px] text-muted-foreground uppercase font-bold">
{t("docker.current")}
</span>
</div>
<div className="h-1.5 bg-muted w-full overflow-hidden">
<div
className="h-full bg-accent-brand transition-all duration-500"
style={{ width: `${Math.min(cpuPercent, 100)}%` }}
/>
</div>
</div>
</SectionCard>
<SectionCard
title={t("docker.memoryUsage")}
icon={<MemoryStick className="size-3.5" />}
>
<div className="flex flex-col gap-3 py-2">
<div className="flex items-end justify-between">
<span className="text-3xl font-bold text-accent-brand">
{stats.memoryPercent}
</span>
<span className="text-[10px] text-muted-foreground uppercase font-bold">
{stats.memoryUsed} / {stats.memoryLimit}
</span>
</div>
<div className="h-1.5 bg-muted w-full overflow-hidden">
<div
className="h-full bg-accent-brand transition-all duration-500"
style={{ width: `${Math.min(memPercent, 100)}%` }}
/>
</div>
</div>
</SectionCard>
<SectionCard
title={t("docker.networkIo")}
icon={<Network className="size-3.5" />}
>
<div className="flex flex-col gap-1 py-1">
<div className="flex justify-between items-center py-1">
<span className="text-xs text-muted-foreground font-semibold">
{t("docker.input")}
</span>
<span className="text-sm font-mono font-bold">
{stats.netInput}
</span>
</div>
<div className="flex justify-between items-center py-1">
<span className="text-xs text-muted-foreground font-semibold">
{t("docker.output")}
</span>
<span className="text-sm font-mono font-bold text-accent-brand">
{stats.netOutput}
</span>
</div>
</div>
</SectionCard>
<SectionCard
title={t("docker.blockIo")}
icon={<HardDrive className="size-3.5" />}
>
<div className="flex flex-col gap-1 py-1">
<div className="flex justify-between items-center py-1">
<span className="text-xs text-muted-foreground font-semibold">
{t("docker.read")}
</span>
<span className="text-sm font-mono font-bold">
{stats.blockRead}
</span>
</div>
<div className="flex justify-between items-center py-1">
<span className="text-xs text-muted-foreground font-semibold">
{t("docker.write")}
</span>
<span className="text-sm font-mono font-bold text-accent-brand">
{stats.blockWrite}
</span>
</div>
{stats.pids && (
<div className="flex justify-between items-center py-1">
<span className="text-xs text-muted-foreground font-semibold">
{t("docker.pids")}
</span>
<span className="text-sm font-mono font-bold">{stats.pids}</span>
</div>
)}
</div>
</SectionCard>
<SectionCard
title={t("docker.containerInformation")}
icon={<Info className="size-3.5" />}
>
<div className="flex flex-col gap-1.5 py-1">
<div className="flex justify-between items-center text-xs py-1 border-b border-border/50">
<span className="text-muted-foreground font-semibold">
{t("docker.name")}
</span>
<span className="font-mono">{containerName}</span>
</div>
<div className="flex justify-between items-center text-xs py-1 border-b border-border/50">
<span className="text-muted-foreground font-semibold">
{t("docker.id")}
</span>
<span className="font-mono">{containerId.substring(0, 12)}</span>
</div>
<div className="flex justify-between items-center text-xs py-1">
<span className="text-muted-foreground font-semibold">
{t("docker.state")}
</span>
<DockerBadge
state={
containerState as import("@/types").DockerContainer["state"]
}
/>
</div>
</div>
</SectionCard>
</div>
);
}
@@ -0,0 +1,233 @@
import React from "react";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
import { Separator } from "@/components/separator.tsx";
import { Download, RefreshCw, Search, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import type { DockerLogOptions } from "@/types";
import { getContainerLogs, downloadContainerLogs } from "@/main-axios.ts";
interface LogViewerProps {
sessionId: string;
containerId: string;
containerName: string;
}
function AdminToggle({
on,
onToggle,
label,
}: {
on: boolean;
onToggle: () => void;
label: string;
}) {
return (
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground uppercase font-bold">
{label}
</span>
<button
type="button"
onClick={onToggle}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${on ? "bg-accent-brand border-accent-brand" : "bg-muted border-border"}`}
>
<span
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${on ? "translate-x-4" : "translate-x-0.5"}`}
/>
</button>
</div>
);
}
function getLogColor(line: string): string {
if (line.includes(" WARN") || line.includes(" warn"))
return "text-yellow-400/90";
if (line.includes(" ERROR") || line.includes(" error"))
return "text-destructive";
if (line.includes(" DEBUG") || line.includes(" debug"))
return "text-muted-foreground/60";
return "text-foreground/90";
}
export function LogViewer({
sessionId,
containerId,
containerName,
}: LogViewerProps): React.ReactElement {
const { t } = useTranslation();
const [rawLogs, setRawLogs] = React.useState<string[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const [isDownloading, setIsDownloading] = React.useState(false);
const [tailLines, setTailLines] = React.useState("100");
const [showTimestamps, setShowTimestamps] = React.useState(false);
const [autoRefresh, setAutoRefresh] = React.useState(false);
const [logSearch, setLogSearch] = 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);
setRawLogs(data.logs.split("\n").filter(Boolean));
} 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]);
React.useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(fetchLogs, 3000);
return () => clearInterval(interval);
}, [autoRefresh, fetchLogs]);
React.useEffect(() => {
if (autoRefresh && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [rawLogs, autoRefresh]);
const handleDownload = async () => {
setIsDownloading(true);
try {
const blob = await downloadContainerLogs(sessionId, containerId, {
timestamps: showTimestamps,
});
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(t("docker.logsDownloaded"));
} catch (error) {
toast.error(
`Failed to download logs: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
setIsDownloading(false);
}
};
const filteredLogs = logSearch
? rawLogs.filter((l) => l.toLowerCase().includes(logSearch.toLowerCase()))
: rawLogs;
return (
<div className="flex flex-col flex-1 min-h-0 gap-3">
<div className="flex items-center justify-between bg-card border border-border px-3 py-2 gap-3 flex-wrap">
<div className="flex items-center gap-3 shrink-0 flex-wrap">
<AdminToggle
on={autoRefresh}
onToggle={() => setAutoRefresh(!autoRefresh)}
label={t("docker.autoRefresh")}
/>
<Separator orientation="vertical" className="h-4" />
<AdminToggle
on={showTimestamps}
onToggle={() => setShowTimestamps(!showTimestamps)}
label={t("docker.timestamps")}
/>
<Separator orientation="vertical" className="h-4" />
<select
value={tailLines}
onChange={(e) => setTailLines(e.target.value)}
className="h-7 px-2 text-[10px] bg-background border border-border text-foreground outline-none uppercase font-bold"
>
<option value="50">{t("docker.last50")}</option>
<option value="100">{t("docker.last100")}</option>
<option value="500">{t("docker.last500")}</option>
<option value="1000">{t("docker.last1000")}</option>
<option value="all">{t("docker.allLogs")}</option>
</select>
<Separator orientation="vertical" className="h-4" />
<span className="text-[10px] text-muted-foreground uppercase font-bold tracking-widest">
{filteredLogs.length}/{rawLogs.length} {t("docker.lines")}
</span>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-64">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3 text-muted-foreground" />
<Input
placeholder={t("docker.filterLogs")}
value={logSearch}
onChange={(e) => setLogSearch(e.target.value)}
className="pl-7 h-7 text-xs bg-background border-border rounded-none"
/>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs gap-1.5"
onClick={fetchLogs}
disabled={isLoading}
>
<RefreshCw
className={`size-3 ${isLoading ? "animate-spin" : ""}`}
/>
{t("docker.refresh")}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs gap-1.5"
onClick={handleDownload}
disabled={isDownloading}
>
<Download className="size-3" />
{t("docker.download")}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs gap-1.5"
onClick={() => setRawLogs([])}
>
<Trash2 className="size-3" />
{t("docker.clear")}
</Button>
</div>
</div>
<div className="flex-1 bg-[#111210] border border-border p-3 overflow-auto font-mono text-xs leading-relaxed scrollbar-thin min-h-0">
{filteredLogs.length > 0 ? (
filteredLogs.map((line, i) => {
const tsEnd = line.indexOf(" ", 1);
const maybeTs = tsEnd > 10 ? line.substring(0, tsEnd) : null;
const rest = maybeTs ? line.substring(tsEnd) : line;
return (
<div key={i} className="whitespace-pre-wrap break-all">
{maybeTs && (
<span className="text-accent-brand/50">{maybeTs}</span>
)}
<span className={getLogColor(rest)}>{rest}</span>
</div>
);
})
) : (
<span className="text-muted-foreground italic">
{logSearch
? t("docker.noLogsMatching", { query: logSearch })
: t("docker.noLogsAvailable")}
</span>
)}
<div ref={logsEndRef} />
</div>
</div>
);
}