mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-24 23:58:23 +00:00
Moves docker backend and frontend into plugins/docker/, alongside ssh-terminal. Adds a host editor tab seam, a dashboard card registry, and a build step to compile plugin backends into dist.
258 lines
8.4 KiB
TypeScript
258 lines
8.4 KiB
TypeScript
import { getErrorMessage } from "@/lib/error-message.js";
|
|
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";
|
|
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
|
|
|
|
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 statsRef = React.useRef(stats);
|
|
statsRef.current = stats;
|
|
|
|
const fetchStats = React.useCallback(async () => {
|
|
if (containerState !== "running") return false;
|
|
setIsLoading(true);
|
|
setError(null);
|
|
try {
|
|
const data = await getContainerStats(sessionId, containerId);
|
|
const previous = statsRef.current;
|
|
const changed =
|
|
!previous ||
|
|
Math.abs(parseFloat(data.cpu) - parseFloat(previous.cpu)) >= 1.5 ||
|
|
Math.abs(
|
|
parseFloat(data.memoryPercent) - parseFloat(previous.memoryPercent),
|
|
) >= 1 ||
|
|
data.pids !== previous.pids;
|
|
statsRef.current = data;
|
|
setStats(data);
|
|
return changed;
|
|
} catch (err) {
|
|
setError(getErrorMessage(err, t("docker.failedToFetchStats")));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [sessionId, containerId, containerState, t]);
|
|
|
|
useAdaptivePolling(
|
|
fetchStats,
|
|
{
|
|
minIntervalMs: 2_000,
|
|
maxIntervalMs: 12_000,
|
|
stablePollsPerStep: 2,
|
|
maxRequestDutyCycle: 0.2,
|
|
},
|
|
containerState === "running",
|
|
);
|
|
|
|
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>
|
|
);
|
|
}
|