feat(ui): add compact free and paid container monitoring cards

Introduce denser monitoring cards so multiple services can be compared
side by side on the project monitoring dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
MangelRyujin 2026-09-11 13:14:11 -05:00
parent 82a5a81b68
commit 70bbc5c944
2 changed files with 315 additions and 0 deletions

View File

@ -0,0 +1,210 @@
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { api } from "@/utils/api";
import { DockerCpuChart } from "./docker-cpu-chart";
import { DockerMemoryChart } from "./docker-memory-chart";
import {
type DockerStats,
type DockerStatsJSON,
convertMemoryToBytes,
} from "./show-free-container-monitoring";
const defaultData: DockerStats = {
cpu: {
value: "0%",
time: "",
},
memory: {
value: {
used: 0,
total: 0,
},
time: "",
},
block: {
value: {
readMb: 0,
writeMb: 0,
},
time: "",
},
network: {
value: {
inputMb: 0,
outputMb: 0,
},
time: "",
},
disk: {
value: { diskTotal: 0, diskUsage: 0, diskUsedPercentage: 0, diskFree: 0 },
time: "",
},
};
interface Props {
appName: string;
appType?: "application" | "stack" | "docker-compose";
label?: string;
}
export const CompactContainerMonitoring = ({
appName,
appType = "application",
label,
}: Props) => {
const { data } = api.application.readAppMonitoring.useQuery(
{ appName },
{
refetchOnWindowFocus: false,
enabled: !!appName,
},
);
const [accumulativeData, setAccumulativeData] = useState<DockerStatsJSON>({
cpu: [],
memory: [],
block: [],
network: [],
disk: [],
});
const [currentData, setCurrentData] = useState<DockerStats>(defaultData);
useEffect(() => {
setCurrentData(defaultData);
setAccumulativeData({
cpu: [],
memory: [],
block: [],
network: [],
disk: [],
});
}, [appName]);
useEffect(() => {
if (!data) return;
setCurrentData({
cpu: data.cpu[data.cpu.length - 1] ?? currentData.cpu,
memory: data.memory[data.memory.length - 1] ?? currentData.memory,
block: data.block[data.block.length - 1] ?? currentData.block,
network: data.network[data.network.length - 1] ?? currentData.network,
disk: data.disk[data.disk.length - 1] ?? currentData.disk,
});
setAccumulativeData({
block: data?.block || [],
cpu: data?.cpu || [],
disk: data?.disk || [],
memory: data?.memory || [],
network: data?.network || [],
});
}, [data]);
useEffect(() => {
if (!appName) return;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}&appType=${appType}`;
const ws = new WebSocket(wsUrl);
ws.onmessage = (e) => {
const value = JSON.parse(e.data);
if (!value) return;
const nextData = {
cpu: value.data.cpu ?? currentData.cpu,
memory: value.data.memory ?? currentData.memory,
block: value.data.block ?? currentData.block,
disk: value.data.disk ?? currentData.disk,
network: value.data.network ?? currentData.network,
};
setCurrentData(nextData);
const MAX_DATA_POINTS = 300;
setAccumulativeData((prevData) => ({
cpu: [...prevData.cpu, nextData.cpu].slice(-MAX_DATA_POINTS),
memory: [...prevData.memory, nextData.memory].slice(-MAX_DATA_POINTS),
block: [...prevData.block, nextData.block].slice(-MAX_DATA_POINTS),
network: [...prevData.network, nextData.network].slice(
-MAX_DATA_POINTS,
),
disk: [...prevData.disk, nextData.disk].slice(-MAX_DATA_POINTS),
}));
};
ws.onclose = (e) => {
if (e.reason) {
toast.error(e.reason);
}
};
return () => ws.close();
}, [appName, appType]);
return (
<div className="rounded-xl bg-background flex flex-col gap-4">
{label && (
<div className="space-y-1">
<h2 className="text-base font-semibold tracking-tight">{label}</h2>
<p className="text-sm text-muted-foreground">
Watch the usage of your server in the current app
</p>
</div>
)}
<div className="grid gap-6 lg:grid-cols-2">
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">CPU Usage</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
Used: {String(currentData.cpu.value ?? "0%")}
</span>
<Progress
value={Number.parseInt(
String(currentData.cpu.value ?? "0%").replace("%", ""),
10,
)}
className="w-full"
/>
<DockerCpuChart accumulativeData={accumulativeData.cpu} />
</div>
</CardContent>
</Card>
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Memory Usage</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
{`Used: ${currentData.memory.value.used} / Limit: ${currentData.memory.value.total} `}
</span>
<Progress
value={
// @ts-ignore
(convertMemoryToBytes(currentData.memory.value.used) /
// @ts-ignore
convertMemoryToBytes(currentData.memory.value.total)) *
100
}
className="w-full"
/>
<DockerMemoryChart
accumulativeData={accumulativeData.memory}
memoryLimitGB={
// @ts-ignore
convertMemoryToBytes(currentData.memory.value.total) /
1024 ** 3
}
/>
</div>
</CardContent>
</Card>
</div>
</div>
);
};

View File

@ -0,0 +1,105 @@
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { api } from "@/utils/api";
import { ContainerCPUChart } from "./container-cpu-chart";
import { ContainerMemoryChart } from "./container-memory-chart";
interface ContainerMetric {
timestamp: string;
CPU: number;
Memory: {
percentage: number;
used: number;
total: number;
unit: string;
usedUnit: string;
totalUnit: string;
};
Network: {
input: number;
output: number;
inputUnit: string;
outputUnit: string;
};
BlockIO: {
read: number;
write: number;
readUnit: string;
writeUnit: string;
};
Container: string;
ID: string;
Name: string;
}
interface Props {
appName: string;
baseUrl: string;
token: string;
label?: string;
}
export const CompactPaidContainerMonitoring = ({
appName,
baseUrl,
token,
label,
}: Props) => {
const [historicalData, setHistoricalData] = useState<ContainerMetric[]>([]);
const { data, isLoading, error } = api.user.getContainerMetrics.useQuery(
{
url: baseUrl,
token,
dataPoints: "200",
appName,
},
{
refetchInterval: 10000,
enabled: !!appName && !!baseUrl && !!token,
},
);
useEffect(() => {
if (!data) return;
// @ts-ignore
setHistoricalData(data);
}, [data]);
if (isLoading && historicalData.length === 0) {
return (
<div className="rounded-xl bg-background p-6 flex items-center justify-center gap-2 text-muted-foreground min-h-[200px]">
<Loader2 className="size-4 animate-spin" />
<span className="text-sm">Loading metrics...</span>
</div>
);
}
if (error) {
return (
<div className="rounded-xl bg-background p-6 text-sm text-muted-foreground">
Unable to fetch metrics for {label || appName}.
</div>
);
}
return (
<div className="rounded-xl bg-background flex flex-col gap-4">
{label && (
<div className="space-y-1">
<h2 className="text-base font-semibold tracking-tight">{label}</h2>
<p className="text-sm text-muted-foreground">
Watch the usage of your server in the current app
</p>
</div>
)}
{historicalData.length > 0 && (
<div className="grid gap-4 grid-cols-1 xl:grid-cols-2">
<ContainerCPUChart data={historicalData} />
<ContainerMemoryChart data={historicalData} />
</div>
)}
</div>
);
};