mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-17 20:05:24 +02:00
Feat/monitoring (#1267) Cloud Version
* feat: add start monitoring remote servers * reafctor: update * refactor: update * refactor: update * refactor: update * refactor: update * refactor: update * refactor: update * refactor: * refactor: add metrics * feat: add disk monitoring * refactor: translate to english * refacotor: add stats * refactor: remove color * feat: add log server metrics * refactor: remove unused deps * refactor: add origin * refactor: add logs * refactor: update * feat: add series monitoring * refactor: add system monitoring * feat: add benchmark to optimize data * refactor: update fn * refactor: remove comments * refactor: update * refactor: exclude items * feat: add refresh rate * feat: add monitoring remote servers * refactor: update * refactor: remove unsued volumes * refactor: update monitoring * refactor: add more presets * feat: add container metrics * feat: add docker monitoring * refactor: update conversion * refactor: remove unused code * refactor: update * refactor: add docker compose logs * refactor: add docker cli * refactor: add install curl * refactor: add get update * refactor: add monitoring remote servers * refactor: add containers config * feat: add container specification * refactor: update path * refactor: add server filter * refactor: simplify logic * fix: verify if file exist before get stats * refactor: update * refactor: remove unused deps * test: add test for containers * refactor: update * refactor add memory collector * refactor: update * refactor: update * refactor: update * refactor: remove * refactor: add memory * refactor: add server memory usage * refactor: change memory * refactor: update * refactor: update * refactor: add container metrics * refactor: comment code * refactor: mount proc bind * refactor: change interval with node cron * refactor: remove opening file * refactor: use streams * refactor: remove unused ws * refactor: disable live when is all * refactor: add sqlite * refactor: update * feat: add golang benchmark * refactor: update go * refactor: update dockerfile * refactor: update db * refactor: add env * refactor: separate logic * refactor: split logic * refactor: update logs * refactor: update dockerfile * refactor: hide .env * refactor: update * chore: hide ,.ebnv * refactor: add end angle * refactor: update * refactor: update * refactor: update * refactor: update * refactor: update * refactor: update monitoring * refactor: add mount db * refactor: add metrics and url callback * refactor: add middleware * refactor: add threshold property * feat: add memory and cpu threshold notification * feat: send notifications to the server * feat: add metrics for dokploy server * refactor: add dokploy server to monitoring * refactor: update methods * refactor: add admin to useeffect * refactor: stop monitoring containers if elements are 0 * refactor: cancel request if appName is empty * refactor: reuse methods * chore; add feat monitoring * refactor: set base url * refactor: adjust monitoring * refactor: delete migrations * feat: add columns * fix: add missing flag * refactor: add free metrics * refactor: add paid monitoring * refactor: update methods * feat: improve ui * feat: add container stats * refactor: add all container metrics * refactor: add color primary * refactor: change default rate limiting refresher * refactor: update retention days * refactor: use json instead of individual properties * refactor: lint * refactor: pass json env * refactor: update * refactor: delete * refactor: update * refactor: fix types * refactor: add retention days * chore: add license * refactor: create db * refactor: update path * refactor: update setup * refactor: update * refactor: create files * refactor: update * refactor: delete * refactor: update * refactor: update token metrics * fix: typechecks * refactor: setup web server * refactor: update error handling and add monitoring * refactor: add local storage save * refactor: add spacing * refactor: update * refactor: upgrade drizzle * refactor: delete * refactor: uppgrade drizzle kit * refactor: update search with jsonB * chore: upgrade drizzle * chore: update packages * refactor: add missing type * refactor: add serverType * refactor: update url * refactor: update * refactor: update * refactor: hide monitoring on self hosted * refactor: update server * refactor: update * refactor: update * refactor: pin node version
This commit is contained in:
@@ -8,7 +8,7 @@ import {
|
||||
Tooltip,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { DockerStatsJSON } from "./show";
|
||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||
|
||||
interface Props {
|
||||
acummulativeData: DockerStatsJSON["block"];
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Tooltip,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { DockerStatsJSON } from "./show";
|
||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||
|
||||
interface Props {
|
||||
acummulativeData: DockerStatsJSON["cpu"];
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Tooltip,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { DockerStatsJSON } from "./show";
|
||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||
|
||||
interface Props {
|
||||
acummulativeData: DockerStatsJSON["disk"];
|
||||
@@ -8,9 +8,8 @@ import {
|
||||
Tooltip,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { DockerStatsJSON } from "./show";
|
||||
import { convertMemoryToBytes } from "./show";
|
||||
|
||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||
import { convertMemoryToBytes } from "./show-free-container-monitoring";
|
||||
interface Props {
|
||||
acummulativeData: DockerStatsJSON["memory"];
|
||||
memoryLimitGB: number;
|
||||
@@ -8,8 +8,7 @@ import {
|
||||
Tooltip,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { DockerStatsJSON } from "./show";
|
||||
1;
|
||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||
interface Props {
|
||||
acummulativeData: DockerStatsJSON["network"];
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ContainerFreeMonitoring } from "./show-free-container-monitoring";
|
||||
|
||||
interface Props {
|
||||
appName: string;
|
||||
serverId?: string;
|
||||
appType: "stack" | "docker-compose";
|
||||
}
|
||||
|
||||
export const ComposeFreeMonitoring = ({
|
||||
appName,
|
||||
appType = "stack",
|
||||
serverId,
|
||||
}: Props) => {
|
||||
const { data, isLoading } = api.docker.getContainersByAppNameMatch.useQuery(
|
||||
{
|
||||
appName: appName,
|
||||
appType,
|
||||
serverId,
|
||||
},
|
||||
{
|
||||
enabled: !!appName,
|
||||
},
|
||||
);
|
||||
|
||||
const [containerAppName, setContainerAppName] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
|
||||
const [containerId, setContainerId] = useState<string | undefined>();
|
||||
|
||||
const { mutateAsync: restart, isLoading: isRestarting } =
|
||||
api.docker.restartContainer.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (data && data?.length > 0) {
|
||||
setContainerAppName(data[0]?.name);
|
||||
setContainerId(data[0]?.containerId);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Monitoring</CardTitle>
|
||||
<CardDescription>Watch the usage of your compose</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<Label>Select a container to watch the monitoring</Label>
|
||||
<div className="flex flex-row gap-4">
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
setContainerAppName(value);
|
||||
setContainerId(
|
||||
data?.find((container) => container.name === value)
|
||||
?.containerId,
|
||||
);
|
||||
}}
|
||||
value={containerAppName}
|
||||
>
|
||||
<SelectTrigger>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder="Select a container" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{data?.map((container) => (
|
||||
<SelectItem
|
||||
key={container.containerId}
|
||||
value={container.name}
|
||||
>
|
||||
{container.name} ({container.containerId}) {container.state}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>Containers ({data?.length})</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
isLoading={isRestarting}
|
||||
onClick={async () => {
|
||||
if (!containerId) return;
|
||||
toast.success(`Restarting container ${containerAppName}`);
|
||||
await restart({ containerId }).then(() => {
|
||||
toast.success("Container restarted");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
</div>
|
||||
<ContainerFreeMonitoring
|
||||
appName={containerAppName || ""}
|
||||
appType={appType}
|
||||
/>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -120,7 +120,7 @@ export const convertMemoryToBytes = (
|
||||
}
|
||||
};
|
||||
|
||||
export const DockerMonitoring = ({
|
||||
export const ContainerFreeMonitoring = ({
|
||||
appName,
|
||||
appType = "application",
|
||||
}: Props) => {
|
||||
@@ -206,128 +206,111 @@ export const DockerMonitoring = ({
|
||||
}, [appName]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-4">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Monitoring
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Watch the usage of your server in the current app
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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: {currentData.cpu.value}%
|
||||
</span>
|
||||
<Progress
|
||||
value={currentData.cpu.value}
|
||||
className="w-[100%]"
|
||||
/>
|
||||
<DockerCpuChart acummulativeData={acummulativeData.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-[100%]"
|
||||
/>
|
||||
<DockerMemoryChart
|
||||
acummulativeData={acummulativeData.memory}
|
||||
memoryLimitGB={
|
||||
// @ts-ignore
|
||||
convertMemoryToBytes(currentData.memory.value.total) /
|
||||
1024 ** 3
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{appName === "dokploy" && (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Disk Space
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{`Used: ${currentData.disk.value.diskUsage} GB / Limit: ${currentData.disk.value.diskTotal} GB`}
|
||||
</span>
|
||||
<Progress
|
||||
value={currentData.disk.value.diskUsedPercentage}
|
||||
className="w-[100%]"
|
||||
/>
|
||||
<DockerDiskChart
|
||||
acummulativeData={acummulativeData.disk}
|
||||
diskTotal={currentData.disk.value.diskTotal}
|
||||
/>
|
||||
</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">Block I/O</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{`Read: ${currentData.block.value.readMb} / Write: ${currentData.block.value.writeMb} `}
|
||||
</span>
|
||||
<DockerBlockChart acummulativeData={acummulativeData.block} />
|
||||
</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">
|
||||
Network I/O
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{`In MB: ${currentData.network.value.inputMb} / Out MB: ${currentData.network.value.outputMb} `}
|
||||
</span>
|
||||
<DockerNetworkChart
|
||||
acummulativeData={acummulativeData.network}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="rounded-xl bg-background shadow-md flex flex-col gap-4">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Monitoring</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Watch the usage of your server in the current app
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</header>
|
||||
|
||||
<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: {currentData.cpu.value}%
|
||||
</span>
|
||||
<Progress value={currentData.cpu.value} className="w-[100%]" />
|
||||
<DockerCpuChart acummulativeData={acummulativeData.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-[100%]"
|
||||
/>
|
||||
<DockerMemoryChart
|
||||
acummulativeData={acummulativeData.memory}
|
||||
memoryLimitGB={
|
||||
// @ts-ignore
|
||||
convertMemoryToBytes(currentData.memory.value.total) /
|
||||
1024 ** 3
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{appName === "dokploy" && (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Disk Space</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{`Used: ${currentData.disk.value.diskUsage} GB / Limit: ${currentData.disk.value.diskTotal} GB`}
|
||||
</span>
|
||||
<Progress
|
||||
value={currentData.disk.value.diskUsedPercentage}
|
||||
className="w-[100%]"
|
||||
/>
|
||||
<DockerDiskChart
|
||||
acummulativeData={acummulativeData.disk}
|
||||
diskTotal={currentData.disk.value.diskTotal}
|
||||
/>
|
||||
</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">Block I/O</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{`Read: ${currentData.block.value.readMb} / Write: ${currentData.block.value.writeMb} `}
|
||||
</span>
|
||||
<DockerBlockChart acummulativeData={acummulativeData.block} />
|
||||
</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">Network I/O</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{`In MB: ${currentData.network.value.inputMb} / Out MB: ${currentData.network.value.outputMb} `}
|
||||
</span>
|
||||
<DockerNetworkChart acummulativeData={acummulativeData.network} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
} from "@/components/ui/chart";
|
||||
import { formatTimestamp } from "@/lib/utils";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
|
||||
interface ContainerMetric {
|
||||
timestamp: string;
|
||||
BlockIO: {
|
||||
read: number;
|
||||
write: number;
|
||||
readUnit: string;
|
||||
writeUnit: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: ContainerMetric[];
|
||||
}
|
||||
|
||||
interface FormattedMetric {
|
||||
timestamp: string;
|
||||
read: number;
|
||||
write: number;
|
||||
readUnit: string;
|
||||
writeUnit: string;
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
read: {
|
||||
label: "Read",
|
||||
color: "hsl(217, 91%, 60%)", // Azul brillante
|
||||
},
|
||||
write: {
|
||||
label: "Write",
|
||||
color: "hsl(142, 71%, 45%)", // Verde brillante
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export const ContainerBlockChart = ({ data }: Props) => {
|
||||
const formattedData = data.map((metric) => ({
|
||||
timestamp: metric.timestamp,
|
||||
read: metric.BlockIO.read,
|
||||
write: metric.BlockIO.write,
|
||||
readUnit: metric.BlockIO.readUnit,
|
||||
writeUnit: metric.BlockIO.writeUnit,
|
||||
}));
|
||||
|
||||
const latestData = formattedData[formattedData.length - 1] || {
|
||||
timestamp: "",
|
||||
read: 0,
|
||||
write: 0,
|
||||
readUnit: "B",
|
||||
writeUnit: "B",
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader className="border-b py-5">
|
||||
<CardTitle>Block I/O</CardTitle>
|
||||
<CardDescription>
|
||||
Read: {latestData.read}
|
||||
{latestData.readUnit} / Write: {latestData.write}
|
||||
{latestData.writeUnit}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[250px] w-full"
|
||||
>
|
||||
<AreaChart data={formattedData}>
|
||||
<defs>
|
||||
<linearGradient id="fillRead" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="hsl(217, 91%, 60%)"
|
||||
stopOpacity={0.3}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="hsl(217, 91%, 60%)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="fillWrite" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="hsl(142, 71%, 45%)"
|
||||
stopOpacity={0.3}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="hsl(142, 71%, 45%)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => formatTimestamp(value)}
|
||||
/>
|
||||
<YAxis />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload?.[0]?.payload;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
Time
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{formatTimestamp(label)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
Read
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{data.read}
|
||||
{data.readUnit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
Write
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{data.write}
|
||||
{data.writeUnit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
name="Write"
|
||||
dataKey="write"
|
||||
type="monotone"
|
||||
fill="url(#fillWrite)"
|
||||
stroke="hsl(142, 71%, 45%)"
|
||||
strokeWidth={2}
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
<Area
|
||||
name="Read"
|
||||
dataKey="read"
|
||||
type="monotone"
|
||||
fill="url(#fillRead)"
|
||||
stroke="hsl(217, 91%, 60%)"
|
||||
strokeWidth={2}
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
<ChartLegend
|
||||
content={<ChartLegendContent />}
|
||||
verticalAlign="bottom"
|
||||
align="center"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
} from "@/components/ui/chart";
|
||||
import { formatTimestamp } from "@/lib/utils";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
|
||||
interface ContainerMetric {
|
||||
timestamp: string;
|
||||
CPU: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: ContainerMetric[];
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
cpu: {
|
||||
label: "CPU",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export const ContainerCPUChart = ({ data }: Props) => {
|
||||
const formattedData = data.map((metric) => ({
|
||||
timestamp: metric.timestamp,
|
||||
cpu: metric.CPU,
|
||||
}));
|
||||
|
||||
const latestData = formattedData[formattedData.length - 1] || {
|
||||
timestamp: "",
|
||||
cpu: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader className="border-b py-5">
|
||||
<CardTitle>CPU</CardTitle>
|
||||
<CardDescription>CPU Usage: {latestData.cpu}%</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[250px] w-full"
|
||||
>
|
||||
<AreaChart data={formattedData}>
|
||||
<defs>
|
||||
<linearGradient id="fillCPU" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="hsl(var(--chart-1))"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="hsl(var(--chart-1))"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => formatTimestamp(value)}
|
||||
/>
|
||||
<YAxis tickFormatter={(value) => `${value}%`} domain={[0, 100]} />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload?.[0]?.payload;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
Time
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{formatTimestamp(label)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
CPU
|
||||
</span>
|
||||
<span className="font-bold">{data.cpu}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
name="CPU"
|
||||
dataKey="cpu"
|
||||
type="monotone"
|
||||
fill="url(#fillCPU)"
|
||||
stroke="hsl(var(--chart-1))"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<ChartLegend
|
||||
content={<ChartLegendContent />}
|
||||
verticalAlign="bottom"
|
||||
align="center"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
} from "@/components/ui/chart";
|
||||
import { formatTimestamp } from "@/lib/utils";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
|
||||
interface ContainerMetric {
|
||||
timestamp: string;
|
||||
Memory: {
|
||||
percentage: number;
|
||||
used: number;
|
||||
total: number;
|
||||
usedUnit: string;
|
||||
totalUnit: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: ContainerMetric[];
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
memory: {
|
||||
label: "Memory",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const formatMemoryValue = (value: number) => {
|
||||
return value.toLocaleString("en-US", {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
};
|
||||
|
||||
export const ContainerMemoryChart = ({ data }: Props) => {
|
||||
const formattedData = data.map((metric) => ({
|
||||
timestamp: metric.timestamp,
|
||||
memory: metric.Memory.percentage,
|
||||
usage: `${formatMemoryValue(metric.Memory.used)}${metric.Memory.usedUnit} / ${formatMemoryValue(metric.Memory.total)}${metric.Memory.totalUnit}`,
|
||||
}));
|
||||
|
||||
const latestData = formattedData[formattedData.length - 1] || {
|
||||
timestamp: "",
|
||||
memory: 0,
|
||||
usage: "0 / 0 B",
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader className="border-b py-5">
|
||||
<CardTitle>Memory</CardTitle>
|
||||
<CardDescription>Memory Usage: {latestData.usage}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[250px] w-full"
|
||||
>
|
||||
<AreaChart data={formattedData}>
|
||||
<defs>
|
||||
<linearGradient id="fillMemory" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="hsl(var(--chart-2))"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="hsl(var(--chart-2))"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => formatTimestamp(value)}
|
||||
/>
|
||||
<YAxis tickFormatter={(value) => `${value}%`} domain={[0, 100]} />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload?.[0]?.payload;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
Time
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{formatTimestamp(label)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
Memory
|
||||
</span>
|
||||
<span className="font-bold">{data.memory}%</span>
|
||||
</div>
|
||||
<div className="flex flex-col col-span-2">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
Usage
|
||||
</span>
|
||||
<span className="font-bold">{data.usage}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
name="Memory"
|
||||
dataKey="memory"
|
||||
type="monotone"
|
||||
fill="url(#fillMemory)"
|
||||
stroke="hsl(var(--chart-2))"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<ChartLegend
|
||||
content={<ChartLegendContent />}
|
||||
verticalAlign="bottom"
|
||||
align="center"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
} from "@/components/ui/chart";
|
||||
import { formatTimestamp } from "@/lib/utils";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
|
||||
interface ContainerMetric {
|
||||
timestamp: string;
|
||||
Network: {
|
||||
input: number;
|
||||
output: number;
|
||||
inputUnit: string;
|
||||
outputUnit: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: ContainerMetric[];
|
||||
}
|
||||
|
||||
interface FormattedMetric {
|
||||
timestamp: string;
|
||||
input: number;
|
||||
output: number;
|
||||
inputUnit: string;
|
||||
outputUnit: string;
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
input: {
|
||||
label: "Input",
|
||||
color: "hsl(var(--chart-3))",
|
||||
},
|
||||
output: {
|
||||
label: "Output",
|
||||
color: "hsl(var(--chart-4))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export const ContainerNetworkChart = ({ data }: Props) => {
|
||||
const formattedData: FormattedMetric[] = data.map((metric) => ({
|
||||
timestamp: metric.timestamp,
|
||||
input: metric.Network.input,
|
||||
output: metric.Network.output,
|
||||
inputUnit: metric.Network.inputUnit,
|
||||
outputUnit: metric.Network.outputUnit,
|
||||
}));
|
||||
|
||||
const latestData = formattedData[formattedData.length - 1] || {
|
||||
input: 0,
|
||||
output: 0,
|
||||
inputUnit: "B",
|
||||
outputUnit: "B",
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader className="border-b py-5">
|
||||
<CardTitle>Network I/O</CardTitle>
|
||||
<CardDescription>
|
||||
Input: {latestData.input}
|
||||
{latestData.inputUnit} / Output: {latestData.output}
|
||||
{latestData.outputUnit}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[250px] w-full"
|
||||
>
|
||||
<AreaChart data={formattedData}>
|
||||
<defs>
|
||||
<linearGradient id="fillInput" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="hsl(var(--chart-3))"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="hsl(var(--chart-3))"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="fillOutput" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="hsl(var(--chart-4))"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="hsl(var(--chart-4))"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => formatTimestamp(value)}
|
||||
/>
|
||||
<YAxis />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload?.[0]?.payload;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
Time
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{formatTimestamp(label)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
Input
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{data.input}
|
||||
{data.inputUnit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
Output
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{data.output}
|
||||
{data.outputUnit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
name="Input"
|
||||
dataKey="input"
|
||||
type="monotone"
|
||||
fill="url(#fillInput)"
|
||||
stroke="hsl(var(--chart-3))"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
name="Output"
|
||||
dataKey="output"
|
||||
type="monotone"
|
||||
fill="url(#fillOutput)"
|
||||
stroke="hsl(var(--chart-4))"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<ChartLegend
|
||||
content={<ChartLegendContent />}
|
||||
verticalAlign="bottom"
|
||||
align="center"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -20,18 +20,22 @@ import { api } from "@/utils/api";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DockerMonitoring } from "../../monitoring/docker/show";
|
||||
import { ContainerPaidMonitoring } from "./show-paid-container-monitoring";
|
||||
|
||||
interface Props {
|
||||
appName: string;
|
||||
serverId?: string;
|
||||
appType: "stack" | "docker-compose";
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const ShowMonitoringCompose = ({
|
||||
export const ComposePaidMonitoring = ({
|
||||
appName,
|
||||
appType = "stack",
|
||||
serverId,
|
||||
baseUrl,
|
||||
token,
|
||||
}: Props) => {
|
||||
const { data, isLoading } = api.docker.getContainersByAppNameMatch.useQuery(
|
||||
{
|
||||
@@ -44,9 +48,9 @@ export const ShowMonitoringCompose = ({
|
||||
},
|
||||
);
|
||||
|
||||
const [containerAppName, setContainerAppName] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [containerAppName, setContainerAppName] = useState<string | undefined>(
|
||||
"",
|
||||
);
|
||||
|
||||
const [containerId, setContainerId] = useState<string | undefined>();
|
||||
|
||||
@@ -62,7 +66,7 @@ export const ShowMonitoringCompose = ({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card className="bg-background">
|
||||
<Card className="bg-background border-0">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Monitoring</CardTitle>
|
||||
<CardDescription>Watch the usage of your compose</CardDescription>
|
||||
@@ -118,10 +122,13 @@ export const ShowMonitoringCompose = ({
|
||||
Restart
|
||||
</Button>
|
||||
</div>
|
||||
<DockerMonitoring
|
||||
appName={containerAppName || ""}
|
||||
appType={appType}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<ContainerPaidMonitoring
|
||||
appName={containerAppName || ""}
|
||||
baseUrl={baseUrl}
|
||||
token={token}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -0,0 +1,298 @@
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Cpu, HardDrive, Loader2, MemoryStick, Network } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ContainerBlockChart } from "./container-block-chart";
|
||||
import { ContainerCPUChart } from "./container-cpu-chart";
|
||||
import { ContainerMemoryChart } from "./container-memory-chart";
|
||||
import { ContainerNetworkChart } from "./container-network-chart";
|
||||
|
||||
const REFRESH_INTERVALS = {
|
||||
"5000": "5 Seconds",
|
||||
"10000": "10 Seconds",
|
||||
"20000": "20 Seconds",
|
||||
"30000": "30 Seconds",
|
||||
} as const;
|
||||
|
||||
const DATA_POINTS_OPTIONS = {
|
||||
"50": "50 points",
|
||||
"200": "200 points",
|
||||
"500": "500 points",
|
||||
"800": "800 points",
|
||||
"1200": "1200 points",
|
||||
"1600": "1600 points",
|
||||
"2000": "2000 points",
|
||||
all: "All points",
|
||||
} as const;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export const ContainerPaidMonitoring = ({ appName, baseUrl, token }: Props) => {
|
||||
const [historicalData, setHistoricalData] = useState<ContainerMetric[]>([]);
|
||||
const [metrics, setMetrics] = useState<ContainerMetric>(
|
||||
{} as ContainerMetric,
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dataPoints, setDataPoints] =
|
||||
useState<keyof typeof DATA_POINTS_OPTIONS>("50");
|
||||
const [refreshInterval, setRefreshInterval] = useState<string>("5000");
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
try {
|
||||
const url = new URL(`${baseUrl}/metrics/containers`);
|
||||
|
||||
// if (dataPoints !== "all") {
|
||||
url.searchParams.append("limit", dataPoints);
|
||||
// }
|
||||
|
||||
if (!appName) {
|
||||
throw new Error(
|
||||
[
|
||||
"No Application Selected:",
|
||||
"",
|
||||
"Make Sure to select an application to monitor.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
url.searchParams.append("appName", appName);
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Error ${response.status}: ${response.statusText}. Please verify that the application "${appName}" is running and this service is included in the monitoring configuration.`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
throw new Error(
|
||||
[
|
||||
`No monitoring data available for "${appName}". This could be because:`,
|
||||
"",
|
||||
"1. The container was recently started - wait a few minutes for data to be collected",
|
||||
"2. The container is not running - verify its status",
|
||||
"3. The service is not included in your monitoring configuration",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
setHistoricalData(data);
|
||||
setMetrics(data[data.length - 1]);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to fetch metrics, Please check your monitoring Instance is Configured correctly.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchMetrics();
|
||||
|
||||
if (dataPoints === "all") {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetchMetrics();
|
||||
}, Number(refreshInterval));
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [dataPoints, appName, token, refreshInterval]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-[400px] w-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-5 flex min-h-[55vh] w-full items-center justify-center p-4">
|
||||
<div className="max-w-xl text-center">
|
||||
<p className="mb-2 text-base font-medium leading-none text-muted-foreground">
|
||||
Error fetching metrics for{" "}
|
||||
<strong className="text-primary">{appName}</strong>
|
||||
</p>
|
||||
<p className="whitespace-pre-line text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
<p className=" text-sm text-muted-foreground">URL: {baseUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
Container Monitoring
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Data points:</span>
|
||||
<Select
|
||||
value={dataPoints}
|
||||
onValueChange={(value: keyof typeof DATA_POINTS_OPTIONS) =>
|
||||
setDataPoints(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Select points" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(DATA_POINTS_OPTIONS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Refresh interval:
|
||||
</span>
|
||||
<Select
|
||||
value={refreshInterval}
|
||||
onValueChange={(value: keyof typeof REFRESH_INTERVALS) =>
|
||||
setRefreshInterval(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Select interval" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(REFRESH_INTERVALS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card className="p-6 bg-transparent">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">CPU Usage</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold">{metrics.CPU}%</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 bg-transparent">
|
||||
<div className="flex items-center gap-2">
|
||||
<MemoryStick className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">Memory Usage</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold">
|
||||
{metrics.Memory.percentage}%
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{metrics.Memory.used} {metrics.Memory.unit} / {metrics.Memory.total}{" "}
|
||||
{metrics.Memory.unit}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 bg-transparent">
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">Network I/O</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold">
|
||||
{metrics.Network.input} {metrics.Network.inputUnit} /{" "}
|
||||
{metrics.Network.output} {metrics.Network.outputUnit}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 bg-transparent">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">Block I/O</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold">
|
||||
{metrics.BlockIO.read} {metrics.BlockIO.readUnit} /{" "}
|
||||
{metrics.BlockIO.write} {metrics.BlockIO.writeUnit}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Container Information */}
|
||||
<Card className="p-6 bg-transparent">
|
||||
<h3 className="text-lg font-medium mb-4">Container Information</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
Container ID
|
||||
</h4>
|
||||
<p className="mt-1">{metrics.ID}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">Name</h4>
|
||||
<p className="mt-1">{metrics.Name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Charts Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-2">
|
||||
<ContainerCPUChart data={historicalData} />
|
||||
<ContainerMemoryChart data={historicalData} />
|
||||
<ContainerBlockChart data={historicalData} />
|
||||
<ContainerNetworkChart data={historicalData} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
} from "@/components/ui/chart";
|
||||
import { formatTimestamp } from "@/lib/utils";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
|
||||
interface CPUChartProps {
|
||||
data: any[];
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
cpu: {
|
||||
label: "CPU",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function CPUChart({ data }: CPUChartProps) {
|
||||
const latestData = data[data.length - 1] || {};
|
||||
|
||||
return (
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader className="border-b py-5">
|
||||
<CardTitle>CPU</CardTitle>
|
||||
<CardDescription>CPU Usage: {latestData.cpu}%</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[250px] w-full"
|
||||
>
|
||||
<AreaChart data={data}>
|
||||
<defs>
|
||||
<linearGradient id="fillCPU" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="hsl(var(--chart-1))"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="hsl(var(--chart-1))"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => formatTimestamp(value)}
|
||||
/>
|
||||
<YAxis tickFormatter={(value) => `${value}%`} domain={[0, 100]} />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload?.[0]?.payload;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
Time
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{formatTimestamp(label)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
CPU
|
||||
</span>
|
||||
<span className="font-bold">{data.cpu}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
name="CPU"
|
||||
dataKey="cpu"
|
||||
type="monotone"
|
||||
fill="url(#fillCPU)"
|
||||
stroke="hsl(var(--chart-1))"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<ChartLegend
|
||||
content={<ChartLegendContent />}
|
||||
verticalAlign="bottom"
|
||||
align="center"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { HardDrive } from "lucide-react";
|
||||
import {
|
||||
Label,
|
||||
PolarGrid,
|
||||
PolarRadiusAxis,
|
||||
RadialBar,
|
||||
RadialBarChart,
|
||||
} from "recharts";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
|
||||
|
||||
interface RadialChartProps {
|
||||
data: any;
|
||||
}
|
||||
|
||||
export function DiskChart({ data }: RadialChartProps) {
|
||||
const diskUsed = Number.parseFloat(data.diskUsed || 0);
|
||||
const totalDiskGB = Number.parseFloat(data.totalDisk || 0);
|
||||
const usedDiskGB = (totalDiskGB * diskUsed) / 100;
|
||||
|
||||
const chartData = [
|
||||
{
|
||||
disk: 25,
|
||||
fill: "hsl(var(--chart-2))",
|
||||
},
|
||||
];
|
||||
|
||||
const chartConfig = {
|
||||
disk: {
|
||||
label: "Disk",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const endAngle = (diskUsed * 360) / 100;
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col bg-transparent">
|
||||
<CardHeader className="items-center border-b pb-5">
|
||||
<CardTitle>Disk</CardTitle>
|
||||
<CardDescription>Storage Space</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 pb-0">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="mx-auto aspect-square max-h-[250px]"
|
||||
>
|
||||
<RadialBarChart
|
||||
data={chartData}
|
||||
startAngle={0}
|
||||
endAngle={endAngle}
|
||||
innerRadius={80}
|
||||
outerRadius={110}
|
||||
>
|
||||
<PolarGrid
|
||||
gridType="circle"
|
||||
radialLines={false}
|
||||
stroke="none"
|
||||
className="first:fill-muted last:fill-background"
|
||||
polarRadius={[86, 74]}
|
||||
/>
|
||||
<RadialBar
|
||||
dataKey="disk"
|
||||
background
|
||||
cornerRadius={10}
|
||||
fill="hsl(var(--chart-2))"
|
||||
/>
|
||||
<PolarRadiusAxis tick={false} tickLine={false} axisLine={false}>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||
return (
|
||||
<text
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
className="fill-foreground text-4xl font-bold"
|
||||
>
|
||||
{diskUsed.toFixed(1)}%
|
||||
</tspan>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={(viewBox.cy || 0) + 24}
|
||||
className="fill-muted-foreground text-sm"
|
||||
>
|
||||
Used
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</PolarRadiusAxis>
|
||||
</RadialBarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col gap-2 text-sm">
|
||||
<div className="flex items-center gap-2 font-medium leading-none">
|
||||
<HardDrive className="h-4 w-4" /> {usedDiskGB.toFixed(1)} GB used
|
||||
</div>
|
||||
<div className="leading-none text-muted-foreground">
|
||||
Of {totalDiskGB.toFixed(1)} GB total
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
} from "@/components/ui/chart";
|
||||
import { formatTimestamp } from "@/lib/utils";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
|
||||
interface MemoryChartProps {
|
||||
data: any[];
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
Memory: {
|
||||
label: "Memory",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function MemoryChart({ data }: MemoryChartProps) {
|
||||
const latestData = data[data.length - 1] || {};
|
||||
|
||||
return (
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader className="border-b py-5">
|
||||
<CardTitle>Memory</CardTitle>
|
||||
<CardDescription>
|
||||
Memory Usage: {latestData.memUsedGB} GB of {latestData.memTotal} GB (
|
||||
{latestData.memUsed}%)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[250px] w-full"
|
||||
>
|
||||
<AreaChart data={data}>
|
||||
<defs>
|
||||
<linearGradient id="fillMemory" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="hsl(var(--chart-2))"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="hsl(var(--chart-2))"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => formatTimestamp(value)}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
orientation="left"
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
domain={[0, 100]}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tickFormatter={(value) => `${value.toFixed(1)} GB`}
|
||||
domain={[
|
||||
0,
|
||||
Math.ceil(Number.parseFloat(latestData.memTotal || "0")),
|
||||
]}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload?.[0]?.payload;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
Time
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{formatTimestamp(label)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
Memory
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{data.memUsed}% ({data.memUsedGB} GB)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
yAxisId="left"
|
||||
dataKey="memUsed"
|
||||
type="monotone"
|
||||
fill="url(#fillMemory)"
|
||||
stroke="hsl(var(--chart-2))"
|
||||
strokeWidth={2}
|
||||
name="Memory"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
} from "@/components/ui/chart";
|
||||
import { formatTimestamp } from "@/lib/utils";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
|
||||
interface NetworkChartProps {
|
||||
data: any[];
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
networkIn: {
|
||||
label: "Network In",
|
||||
color: "hsl(var(--chart-3))",
|
||||
},
|
||||
networkOut: {
|
||||
label: "Network Out",
|
||||
color: "hsl(var(--chart-4))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function NetworkChart({ data }: NetworkChartProps) {
|
||||
const latestData = data[data.length - 1] || {};
|
||||
|
||||
return (
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader className="border-b py-5">
|
||||
<CardTitle>Network</CardTitle>
|
||||
<CardDescription>
|
||||
Network Traffic: ↑ {latestData.networkOut} KB/s ↓{" "}
|
||||
{latestData.networkIn} KB/s
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[250px] w-full"
|
||||
>
|
||||
<AreaChart data={data}>
|
||||
<defs>
|
||||
<linearGradient id="fillNetworkIn" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="hsl(var(--chart-3))"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="hsl(var(--chart-3))"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="fillNetworkOut" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="hsl(var(--chart-4))"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="hsl(var(--chart-4))"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => formatTimestamp(value)}
|
||||
/>
|
||||
<YAxis tickFormatter={(value) => `${value} KB/s`} />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload?.[0]?.payload;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
Time
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{formatTimestamp(label)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
Network
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
↑ {data.networkOut} KB/s
|
||||
<br />↓ {data.networkIn} KB/s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
name="Network In"
|
||||
dataKey="networkIn"
|
||||
type="monotone"
|
||||
fill="url(#fillNetworkIn)"
|
||||
stroke="hsl(var(--chart-3))"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
name="Network Out"
|
||||
dataKey="networkOut"
|
||||
type="monotone"
|
||||
fill="url(#fillNetworkOut)"
|
||||
stroke="hsl(var(--chart-4))"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<ChartLegend
|
||||
content={<ChartLegendContent />}
|
||||
verticalAlign="bottom"
|
||||
align="center"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Clock, Cpu, HardDrive, Loader2, MemoryStick } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CPUChart } from "./cpu-chart";
|
||||
import { DiskChart } from "./disk-chart";
|
||||
import { MemoryChart } from "./memory-chart";
|
||||
import { NetworkChart } from "./network-chart";
|
||||
|
||||
const REFRESH_INTERVALS = {
|
||||
"5000": "5 Seconds",
|
||||
"10000": "10 Seconds",
|
||||
"20000": "20 Seconds",
|
||||
"30000": "30 Seconds",
|
||||
} as const;
|
||||
|
||||
const DATA_POINTS_OPTIONS = {
|
||||
"50": "50 points",
|
||||
"200": "200 points",
|
||||
"500": "500 points",
|
||||
"800": "800 points",
|
||||
"1200": "1200 points",
|
||||
"1600": "1600 points",
|
||||
"2000": "2000 points",
|
||||
all: "All points",
|
||||
} as const;
|
||||
|
||||
interface SystemMetrics {
|
||||
cpu: string;
|
||||
cpuModel: string;
|
||||
cpuCores: number;
|
||||
cpuPhysicalCores: number;
|
||||
cpuSpeed: number;
|
||||
os: string;
|
||||
distro: string;
|
||||
kernel: string;
|
||||
arch: string;
|
||||
memUsed: string;
|
||||
memUsedGB: string;
|
||||
memTotal: string;
|
||||
uptime: number;
|
||||
diskUsed: string;
|
||||
totalDisk: string;
|
||||
networkIn: string;
|
||||
networkOut: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
BASE_URL?: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export const ShowPaidMonitoring = ({
|
||||
BASE_URL = process.env.NEXT_PUBLIC_METRICS_URL ||
|
||||
"http://localhost:3001/metrics",
|
||||
token = process.env.NEXT_PUBLIC_METRICS_TOKEN || "my-token",
|
||||
}: Props) => {
|
||||
const [historicalData, setHistoricalData] = useState<SystemMetrics[]>([]);
|
||||
const [metrics, setMetrics] = useState<SystemMetrics>({} as SystemMetrics);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dataPoints, setDataPoints] =
|
||||
useState<keyof typeof DATA_POINTS_OPTIONS>("50");
|
||||
const [refreshInterval, setRefreshInterval] = useState<string>("5000");
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
try {
|
||||
const url = new URL(BASE_URL);
|
||||
url.searchParams.append("limit", dataPoints);
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Error ${response.status}: ${response.statusText}. Ensure the container is running and this service is included in the monitoring configuration.`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
throw new Error(
|
||||
[
|
||||
"No monitoring data available. This could be because:",
|
||||
"",
|
||||
"1. You don't have setup the monitoring service, you can do in web server section.",
|
||||
"2. If you already have setup the monitoring service, wait a few minutes and refresh the page.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
const formattedData = data.map((metric: SystemMetrics) => ({
|
||||
timestamp: metric.timestamp,
|
||||
cpu: Number.parseFloat(metric.cpu),
|
||||
cpuModel: metric.cpuModel,
|
||||
cpuCores: metric.cpuCores,
|
||||
cpuPhysicalCores: metric.cpuPhysicalCores,
|
||||
cpuSpeed: metric.cpuSpeed,
|
||||
os: metric.os,
|
||||
distro: metric.distro,
|
||||
kernel: metric.kernel,
|
||||
arch: metric.arch,
|
||||
memUsed: Number.parseFloat(metric.memUsed),
|
||||
memUsedGB: Number.parseFloat(metric.memUsedGB),
|
||||
memTotal: Number.parseFloat(metric.memTotal),
|
||||
networkIn: Number.parseFloat(metric.networkIn),
|
||||
networkOut: Number.parseFloat(metric.networkOut),
|
||||
diskUsed: Number.parseFloat(metric.diskUsed),
|
||||
totalDisk: Number.parseFloat(metric.totalDisk),
|
||||
uptime: metric.uptime,
|
||||
}));
|
||||
|
||||
// @ts-ignore
|
||||
setHistoricalData(formattedData);
|
||||
// @ts-ignore
|
||||
setMetrics(formattedData[formattedData.length - 1] || {});
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to fetch metrics, Please check your monitoring Instance is Configured correctly.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatUptime = (seconds: number): string => {
|
||||
const days = Math.floor(seconds / (24 * 60 * 60));
|
||||
const hours = Math.floor((seconds % (24 * 60 * 60)) / (60 * 60));
|
||||
const minutes = Math.floor((seconds % (60 * 60)) / 60);
|
||||
|
||||
return `${days}d ${hours}h ${minutes}m`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchMetrics();
|
||||
|
||||
if (dataPoints === "all") {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetchMetrics();
|
||||
}, Number(refreshInterval));
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [dataPoints, token, refreshInterval]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-[400px] w-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-[55vh] w-full items-center justify-center p-4">
|
||||
<div className="max-w-xl text-center">
|
||||
<p className="mb-2 text-base font-medium leading-none text-muted-foreground">
|
||||
Error fetching metrics{" "}
|
||||
</p>
|
||||
<p className="whitespace-pre-line text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
<p className=" text-sm text-muted-foreground">URL: {BASE_URL}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pt-5 pb-10 w-full px-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold tracking-tight">System Monitoring</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Data points:</span>
|
||||
<Select
|
||||
value={dataPoints}
|
||||
onValueChange={(value: keyof typeof DATA_POINTS_OPTIONS) =>
|
||||
setDataPoints(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Select points" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(DATA_POINTS_OPTIONS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Refresh interval:
|
||||
</span>
|
||||
<Select
|
||||
value={refreshInterval}
|
||||
onValueChange={(value: keyof typeof REFRESH_INTERVALS) =>
|
||||
setRefreshInterval(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Select interval" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(REFRESH_INTERVALS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<div className="rounded-lg border text-card-foreground shadow-sm p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">Uptime</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold">
|
||||
{formatUptime(metrics.uptime || 0)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border text-card-foreground shadow-sm p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">CPU Usage</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold">{metrics.cpu}%</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border text-card-foreground bg-transparent shadow-sm p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<MemoryStick className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">Memory Usage</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold">
|
||||
{metrics.memUsedGB} GB / {metrics.memTotal} GB
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border text-card-foreground shadow-sm p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">Disk Usage</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold">{metrics.diskUsed}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Information */}
|
||||
<div className="rounded-lg border text-card-foreground shadow-sm p-6">
|
||||
<h3 className="text-lg font-medium mb-4">System Information</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">CPU</h4>
|
||||
<p className="mt-1">{metrics.cpuModel}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{metrics.cpuPhysicalCores} Physical Cores ({metrics.cpuCores}{" "}
|
||||
Threads) @ {metrics.cpuSpeed}GHz
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
Operating System
|
||||
</h4>
|
||||
<p className="mt-1">{metrics.distro}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Kernel: {metrics.kernel} ({metrics.arch})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-2">
|
||||
<CPUChart data={historicalData} />
|
||||
<MemoryChart data={historicalData} />
|
||||
<DiskChart data={metrics} />
|
||||
<NetworkChart data={historicalData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
import React from "react";
|
||||
import { DockerMonitoring } from "../docker/show";
|
||||
|
||||
export const ShowMonitoring = () => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<DockerMonitoring appName="dokploy" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -168,6 +168,7 @@ export const ShowGeneralRedis = ({ redisId }: Props) => {
|
||||
setIsDrawerOpen(false);
|
||||
setFilteredLogs([]);
|
||||
setIsDeploying(false);
|
||||
refetch();
|
||||
}}
|
||||
filteredLogs={filteredLogs}
|
||||
/>
|
||||
|
||||
@@ -49,6 +49,7 @@ const notificationBaseSchema = z.object({
|
||||
databaseBackup: z.boolean().default(false),
|
||||
dokployRestart: z.boolean().default(false),
|
||||
dockerCleanup: z.boolean().default(false),
|
||||
serverThreshold: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const notificationSchema = z.discriminatedUnion("type", [
|
||||
@@ -204,6 +205,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
channel: notification.slack?.channel || "",
|
||||
name: notification.name,
|
||||
type: notification.notificationType,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
} else if (notification.notificationType === "telegram") {
|
||||
form.reset({
|
||||
@@ -216,6 +218,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
type: notification.notificationType,
|
||||
name: notification.name,
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
} else if (notification.notificationType === "discord") {
|
||||
form.reset({
|
||||
@@ -228,6 +231,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
decoration: notification.discord?.decoration || undefined,
|
||||
name: notification.name,
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
} else if (notification.notificationType === "email") {
|
||||
form.reset({
|
||||
@@ -244,6 +248,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
fromAddress: notification.email?.fromAddress,
|
||||
name: notification.name,
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
} else if (notification.notificationType === "gotify") {
|
||||
form.reset({
|
||||
@@ -280,6 +285,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
dokployRestart,
|
||||
databaseBackup,
|
||||
dockerCleanup,
|
||||
serverThreshold,
|
||||
} = data;
|
||||
let promise: Promise<unknown> | null = null;
|
||||
if (data.type === "slack") {
|
||||
@@ -294,6 +300,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
dockerCleanup: dockerCleanup,
|
||||
slackId: notification?.slackId || "",
|
||||
notificationId: notificationId || "",
|
||||
serverThreshold: serverThreshold,
|
||||
});
|
||||
} else if (data.type === "telegram") {
|
||||
promise = telegramMutation.mutateAsync({
|
||||
@@ -307,6 +314,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
dockerCleanup: dockerCleanup,
|
||||
notificationId: notificationId || "",
|
||||
telegramId: notification?.telegramId || "",
|
||||
serverThreshold: serverThreshold,
|
||||
});
|
||||
} else if (data.type === "discord") {
|
||||
promise = discordMutation.mutateAsync({
|
||||
@@ -320,6 +328,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
dockerCleanup: dockerCleanup,
|
||||
notificationId: notificationId || "",
|
||||
discordId: notification?.discordId || "",
|
||||
serverThreshold: serverThreshold,
|
||||
});
|
||||
} else if (data.type === "email") {
|
||||
promise = emailMutation.mutateAsync({
|
||||
@@ -337,6 +346,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
dockerCleanup: dockerCleanup,
|
||||
notificationId: notificationId || "",
|
||||
emailId: notification?.emailId || "",
|
||||
serverThreshold: serverThreshold,
|
||||
});
|
||||
} else if (data.type === "gotify") {
|
||||
promise = gotifyMutation.mutateAsync({
|
||||
@@ -955,6 +965,30 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isCloud && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverThreshold"
|
||||
render={({ field }) => (
|
||||
<FormItem className=" flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Server Threshold</FormLabel>
|
||||
<FormDescription>
|
||||
Trigger the action when the server threshold is
|
||||
reached.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input, NumberInput } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { extractServices } from "@/pages/dashboard/project/[projectId]";
|
||||
import { api } from "@/utils/api";
|
||||
import { useUrl } from "@/utils/hooks/use-url";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Eye, EyeOff, LayoutDashboardIcon, RefreshCw } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
interface Props {
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
const Schema = z.object({
|
||||
metricsConfig: z.object({
|
||||
server: z.object({
|
||||
refreshRate: z.number().min(2, {
|
||||
message: "Server Refresh Rate is required",
|
||||
}),
|
||||
port: z.number().min(1, {
|
||||
message: "Port is required",
|
||||
}),
|
||||
token: z.string(),
|
||||
urlCallback: z.string(),
|
||||
retentionDays: z.number().min(1, {
|
||||
message: "Retention days must be at least 1",
|
||||
}),
|
||||
thresholds: z.object({
|
||||
cpu: z.number().min(0),
|
||||
memory: z.number().min(0),
|
||||
}),
|
||||
cronJob: z.string().min(1, {
|
||||
message: "Cron Job is required",
|
||||
}),
|
||||
}),
|
||||
containers: z.object({
|
||||
refreshRate: z.number().min(2, {
|
||||
message: "Container Refresh Rate is required",
|
||||
}),
|
||||
services: z.object({
|
||||
include: z.array(z.string()).optional(),
|
||||
exclude: z.array(z.string()).optional(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
type Schema = z.infer<typeof Schema>;
|
||||
|
||||
export const SetupMonitoring = ({ serverId }: Props) => {
|
||||
const { data, isLoading } = serverId
|
||||
? api.server.one.useQuery(
|
||||
{
|
||||
serverId: serverId || "",
|
||||
},
|
||||
{
|
||||
enabled: !!serverId,
|
||||
},
|
||||
)
|
||||
: api.admin.one.useQuery();
|
||||
|
||||
const url = useUrl();
|
||||
|
||||
const { data: projects } = api.project.all.useQuery();
|
||||
|
||||
const extractServicesFromProjects = (projects: any[] | undefined) => {
|
||||
if (!projects) return [];
|
||||
|
||||
const allServices = projects.flatMap((project) => {
|
||||
const services = extractServices(project);
|
||||
return serverId
|
||||
? services
|
||||
.filter((service) => service.serverId === serverId)
|
||||
.map((service) => service.appName)
|
||||
: services.map((service) => service.appName);
|
||||
});
|
||||
|
||||
return [...new Set(allServices)];
|
||||
};
|
||||
|
||||
const services = extractServicesFromProjects(projects);
|
||||
|
||||
const form = useForm<Schema>({
|
||||
resolver: zodResolver(Schema),
|
||||
defaultValues: {
|
||||
metricsConfig: {
|
||||
server: {
|
||||
refreshRate: 20,
|
||||
port: 4500,
|
||||
token: "",
|
||||
urlCallback: `${url}/api/trpc/notification.receiveNotification`,
|
||||
retentionDays: 7,
|
||||
thresholds: {
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
},
|
||||
cronJob: "",
|
||||
},
|
||||
containers: {
|
||||
refreshRate: 20,
|
||||
services: {
|
||||
include: [],
|
||||
exclude: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
metricsConfig: {
|
||||
server: {
|
||||
refreshRate: data?.metricsConfig?.server?.refreshRate,
|
||||
port: data?.metricsConfig?.server?.port,
|
||||
token: data?.metricsConfig?.server?.token || generateToken(),
|
||||
urlCallback:
|
||||
data?.metricsConfig?.server?.urlCallback ||
|
||||
`${url}/api/trpc/notification.receiveNotification`,
|
||||
retentionDays: data?.metricsConfig?.server?.retentionDays || 5,
|
||||
thresholds: {
|
||||
cpu: data?.metricsConfig?.server?.thresholds?.cpu,
|
||||
memory: data?.metricsConfig?.server?.thresholds?.memory,
|
||||
},
|
||||
cronJob: data?.metricsConfig?.server?.cronJob || "0 0 * * *",
|
||||
},
|
||||
containers: {
|
||||
refreshRate: data?.metricsConfig?.containers?.refreshRate,
|
||||
services: {
|
||||
include: data?.metricsConfig?.containers?.services?.include,
|
||||
exclude: data?.metricsConfig?.containers?.services?.exclude,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [data, url]);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [searchExclude, setSearchExclude] = useState("");
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
|
||||
const availableServices = services?.filter(
|
||||
(service) =>
|
||||
!form
|
||||
.watch("metricsConfig.containers.services.include")
|
||||
?.some((s) => s === service) &&
|
||||
!form
|
||||
.watch("metricsConfig.containers.services.exclude")
|
||||
?.includes(service) &&
|
||||
service.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
const availableServicesToExclude = [
|
||||
...(services?.filter(
|
||||
(service) =>
|
||||
!form
|
||||
.watch("metricsConfig.containers.services.exclude")
|
||||
?.includes(service) &&
|
||||
!form
|
||||
.watch("metricsConfig.containers.services.include")
|
||||
?.some((s) => s === service) &&
|
||||
service.toLowerCase().includes(searchExclude.toLowerCase()),
|
||||
) ?? []),
|
||||
...(!form.watch("metricsConfig.containers.services.exclude")?.includes("*")
|
||||
? ["*"]
|
||||
: []),
|
||||
];
|
||||
|
||||
const { mutateAsync } = serverId
|
||||
? api.server.setupMonitoring.useMutation()
|
||||
: api.admin.setupMonitoring.useMutation();
|
||||
|
||||
const generateToken = () => {
|
||||
const array = new Uint8Array(64);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(
|
||||
"",
|
||||
);
|
||||
};
|
||||
|
||||
const onSubmit = async (values: Schema) => {
|
||||
await mutateAsync({
|
||||
serverId: serverId || "",
|
||||
metricsConfig: values.metricsConfig,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Server updated successfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating the server");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<LayoutDashboardIcon className="size-6 text-muted-foreground self-center" />
|
||||
Monitoring
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Monitor your servers and containers in realtime with notifications
|
||||
when they reach their thresholds.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 py-6 border-t">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex w-full flex-col gap-4"
|
||||
>
|
||||
<AlertBlock>
|
||||
Using a lower refresh rate will make your CPU and memory usage
|
||||
higher, we recommend 30-60 seconds
|
||||
</AlertBlock>
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metricsConfig.server.refreshRate"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col justify-center max-sm:items-center">
|
||||
<FormLabel>Server Refresh Rate</FormLabel>
|
||||
<FormControl>
|
||||
<NumberInput placeholder="10" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Please set the refresh rate for the server in seconds
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metricsConfig.containers.refreshRate"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col justify-center max-sm:items-center">
|
||||
<FormLabel>Container Refresh Rate</FormLabel>
|
||||
<FormControl>
|
||||
<NumberInput placeholder="10" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Please set the refresh rate for the containers in seconds
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metricsConfig.server.cronJob"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Cron Job</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="0 0 * * *" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Cron job for cleaning up metrics
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metricsConfig.server.retentionDays"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server Retention Days</FormLabel>
|
||||
<FormControl>
|
||||
<NumberInput {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Number of days to retain server metrics data
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metricsConfig.server.port"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col justify-center max-sm:items-center">
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<NumberInput placeholder="4500" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Please set the port for the metrics server
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metricsConfig.containers.services.include"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Include Services</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline">Add Service</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[300px] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search service..."
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
{availableServices?.length === 0 ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
No services available.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CommandEmpty>
|
||||
No service found.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableServices?.map((service) => (
|
||||
<CommandItem
|
||||
key={service}
|
||||
value={service}
|
||||
onSelect={() => {
|
||||
field.onChange([
|
||||
...(field.value ?? []),
|
||||
service,
|
||||
]);
|
||||
setSearch("");
|
||||
}}
|
||||
>
|
||||
{service}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{field.value?.map((service) => (
|
||||
<Badge
|
||||
key={service}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{service}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 p-0"
|
||||
onClick={() => {
|
||||
field.onChange(
|
||||
field.value?.filter((s) => s !== service),
|
||||
);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
<FormDescription>
|
||||
Services to monitor.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metricsConfig.containers.services.exclude"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Exclude Services</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline">Add Service</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[300px] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search service..."
|
||||
value={searchExclude}
|
||||
onValueChange={setSearchExclude}
|
||||
/>
|
||||
{availableServicesToExclude?.length === 0 ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
No services available.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CommandEmpty>
|
||||
No service found.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableServicesToExclude.map(
|
||||
(service) => (
|
||||
<CommandItem
|
||||
key={service}
|
||||
value={service}
|
||||
onSelect={() => {
|
||||
field.onChange([
|
||||
...(field.value ?? []),
|
||||
service,
|
||||
]);
|
||||
setSearchExclude("");
|
||||
}}
|
||||
>
|
||||
{service}
|
||||
</CommandItem>
|
||||
),
|
||||
)}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{field.value?.map((service, index) => (
|
||||
<Badge
|
||||
key={service}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{service}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 p-0"
|
||||
onClick={() => {
|
||||
field.onChange(
|
||||
field.value?.filter((_, i) => i !== index),
|
||||
);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
<FormDescription>
|
||||
Services to exclude from monitoring
|
||||
</FormDescription>
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metricsConfig.server.thresholds.cpu"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>CPU Threshold (%)</FormLabel>
|
||||
<FormControl>
|
||||
<NumberInput {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Alert when CPU usage exceeds this percentage
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metricsConfig.server.thresholds.memory"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Memory Threshold (%)</FormLabel>
|
||||
<FormControl>
|
||||
<NumberInput {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Alert when memory usage exceeds this percentage
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metricsConfig.server.token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Metrics Token</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showToken ? "text" : "password"}
|
||||
placeholder="Enter your metrics token"
|
||||
{...field}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
title={showToken ? "Hide token" : "Show token"}
|
||||
>
|
||||
{showToken ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const newToken = generateToken();
|
||||
form.setValue(
|
||||
"metricsConfig.server.token",
|
||||
newToken,
|
||||
);
|
||||
toast.success("Token generated successfully");
|
||||
}}
|
||||
title="Generate new token"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Token for authenticating metrics requests
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metricsConfig.server.urlCallback"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Metrics Callback URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://your-callback-url.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
URL where metrics will be sent
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button type="submit" isLoading={form.formState.isSubmitting}>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CopyIcon, ExternalLinkIcon, ServerIcon } from "lucide-react";
|
||||
@@ -30,6 +31,7 @@ import { type LogLine, parseLogs } from "../../docker/logs/utils";
|
||||
import { EditScript } from "./edit-script";
|
||||
import { GPUSupport } from "./gpu-support";
|
||||
import { SecurityAudit } from "./security-audit";
|
||||
import { SetupMonitoring } from "./setup-monitoring";
|
||||
import { ValidateServer } from "./validate-server";
|
||||
|
||||
interface Props {
|
||||
@@ -48,7 +50,7 @@ export const SetupServer = ({ serverId }: Props) => {
|
||||
);
|
||||
|
||||
const [activeLog, setActiveLog] = useState<string | null>(null);
|
||||
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
@@ -112,11 +114,19 @@ export const SetupServer = ({ serverId }: Props) => {
|
||||
</AlertBlock>
|
||||
|
||||
<Tabs defaultValue="ssh-keys">
|
||||
<TabsList className="grid grid-cols-5 w-[700px]">
|
||||
<TabsList
|
||||
className={cn(
|
||||
"grid w-[700px]",
|
||||
isCloud ? "grid-cols-6" : "grid-cols-5",
|
||||
)}
|
||||
>
|
||||
<TabsTrigger value="ssh-keys">SSH Keys</TabsTrigger>
|
||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||
<TabsTrigger value="validate">Validate</TabsTrigger>
|
||||
<TabsTrigger value="audit">Security</TabsTrigger>
|
||||
{isCloud && (
|
||||
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
@@ -309,6 +319,16 @@ export const SetupServer = ({ serverId }: Props) => {
|
||||
<SecurityAudit serverId={serverId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="monitoring"
|
||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
>
|
||||
<div className="flex flex-col gap-2 text-sm pt-3">
|
||||
<div className="rounded-xl bg-background shadow-md border">
|
||||
<SetupMonitoring serverId={serverId} />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="gpu-setup"
|
||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { useState } from "react";
|
||||
import { ShowPaidMonitoring } from "../../monitoring/paid/servers/show-paid-monitoring";
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const ShowMonitoringModal = ({ url, token }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer "
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
Show Monitoring
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-7xl overflow-y-auto max-h-screen ">
|
||||
<div className="flex gap-4 py-4 w-full">
|
||||
<ShowPaidMonitoring BASE_URL={url} token={token} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -38,6 +38,7 @@ import { ShowServerActions } from "./actions/show-server-actions";
|
||||
import { HandleServers } from "./handle-servers";
|
||||
import { SetupServer } from "./setup-server";
|
||||
import { ShowDockerContainersModal } from "./show-docker-containers-modal";
|
||||
import { ShowMonitoringModal } from "./show-monitoring-modal";
|
||||
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
|
||||
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
|
||||
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
|
||||
@@ -314,6 +315,16 @@ export const ShowServers = () => {
|
||||
<ShowDockerContainersModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
{isCloud && (
|
||||
<ShowMonitoringModal
|
||||
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
|
||||
token={
|
||||
server?.metricsConfig?.server
|
||||
?.token
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ShowSwarmOverviewModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
|
||||
@@ -15,7 +15,7 @@ export const OnboardingLayout = ({ children }: Props) => {
|
||||
<div className="absolute inset-0 bg-muted" />
|
||||
<Link
|
||||
href="https://dokploy.com"
|
||||
className="relative z-20 flex items-center text-lg font-medium gap-4"
|
||||
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
|
||||
>
|
||||
<Logo className="size-10" />
|
||||
Dokploy
|
||||
|
||||
@@ -67,7 +67,7 @@ ChartContainer.displayName = "Chart";
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([_, config]) => config.theme || config.color,
|
||||
([, config]) => config.theme || config.color,
|
||||
);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
|
||||
Reference in New Issue
Block a user