feat: initial ui redesign from demo

This commit is contained in:
LukeGus
2026-05-13 01:29:43 -05:00
parent eaa758effe
commit 33dcde0827
349 changed files with 23984 additions and 31634 deletions
@@ -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>
);
}