server stats chart

This commit is contained in:
mbecker20
2024-04-21 19:22:22 -07:00
parent 43514acc92
commit 176b12f18c
6 changed files with 296 additions and 19 deletions

View File

@@ -16,6 +16,7 @@ import {
} from "@ui/card";
import { Hammer } from "lucide-react";
import { Link } from "react-router-dom";
import { convertTsMsToLocalUnixTsInSecs } from "@lib/utils";
export const BuildChart = () => {
const container_ref = useRef<HTMLDivElement>(null);
@@ -34,6 +35,8 @@ export const BuildChart = () => {
if (line_ref.current) line_ref.current.remove();
const init = () => {
if (!container_ref.current) return;
// INIT LINE
line_ref.current = createChart(container_ref.current, {
width: container_ref.current.clientWidth,
height: container_ref.current.clientHeight,
@@ -50,13 +53,15 @@ export const BuildChart = () => {
handleScroll: false,
});
line_ref.current.timeScale().fitContent();
// INIT SERIES
series_ref.current = line_ref.current.addHistogramSeries({
priceLineVisible: false,
});
const max = build_stats.days.reduce((m, c) => Math.max(m, c.time), 0);
series_ref.current.setData(
build_stats.days.map((d) => ({
time: (d.ts / 1000) as Time,
time: convertTsMsToLocalUnixTsInSecs(d.ts) as Time,
value: d.count,
color:
d.time > max * 0.7
@@ -67,6 +72,8 @@ export const BuildChart = () => {
})) ?? []
);
};
// Run the effect
init();
window.addEventListener("resize", handleResize);
return () => {

View File

@@ -0,0 +1,10 @@
import { Types } from "@monitor/client";
import { useAtom } from "jotai";
import { atomWithStorage } from "jotai/utils";
const statsIntervalAtom = atomWithStorage(
"stats-interval-v0",
Types.Timelength.FiveMinutes
);
export const useStatsInterval = () => useAtom(statsIntervalAtom);

View File

@@ -10,6 +10,7 @@ import {
Database,
Scissors,
XOctagon,
AreaChart,
} from "lucide-react";
import { Section } from "@components/layouts";
import { RenameServer } from "./actions";
@@ -26,6 +27,7 @@ import { Link } from "react-router-dom";
import { DeleteResource, NewResource } from "../common";
import { ActionWithDialog, ConfirmButton } from "@components/util";
import { Card, CardHeader } from "@ui/card";
import { Button } from "@ui/button";
export const useServer = (id?: string) =>
useRead("ListServers", {}).data?.find((d) => d.id === id);
@@ -66,6 +68,12 @@ export const ServerComponents: RequiredResourceComponents = {
</Card>
);
},
Stats: ({id}) => <Link to={`/servers/${id}/stats`}>
<Button variant="link" className="flex gap-2 items-center p-0">
<AreaChart className="w-4 h-4" />
Stats
</Button>
</Link>
},
Info: {

View File

@@ -0,0 +1,138 @@
import { hex_color_by_intention } from "@lib/color";
import { useRead } from "@lib/hooks";
import { convertTsMsToLocalUnixTsInSecs } from "@lib/utils";
import { Types } from "@monitor/client";
import {
ColorType,
IChartApi,
ISeriesApi,
Time,
createChart,
} from "lightweight-charts";
import { useEffect, useRef } from "react";
import { useStatsInterval } from "./hooks";
import { Loader2 } from "lucide-react";
type StatType = "cpu" | "mem" | "disk";
export const StatChart = ({
server_id,
type,
className,
}: {
server_id: string;
type: StatType;
className?: string;
}) => {
const [interval] = useStatsInterval();
const { data, isPending } = useRead("GetHistoricalServerStats", {
server: server_id,
interval,
});
const stats = data?.stats
.map((stat) => {
return {
time: convertTsMsToLocalUnixTsInSecs(stat.ts) as Time,
value: getStat(stat, type),
};
})
.reverse();
return (
<div className={className}>
{isPending ? (
<div className="w-full max-w-full h-full flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
) : (
<InnerStatChart type={type} stats={stats} />
)}
</div>
);
};
export const InnerStatChart = ({
type,
stats,
}: {
type: StatType;
stats:
| {
time: Time;
value: number;
}[]
| undefined;
}) => {
const container_ref = useRef<HTMLDivElement>(null);
const line_ref = useRef<IChartApi>();
const series_ref = useRef<ISeriesApi<"Area">>();
const lineColor = getColor(type);
const handleResize = () =>
line_ref.current?.applyOptions({
width: container_ref.current?.clientWidth,
});
useEffect(() => {
if (!stats) return;
if (line_ref.current) line_ref.current.remove();
const init = () => {
if (!container_ref.current) return;
// INIT LINE
line_ref.current = createChart(container_ref.current, {
width: container_ref.current.clientWidth,
height: container_ref.current.clientHeight,
layout: {
background: { type: ColorType.Solid, color: "transparent" },
textColor: "grey",
fontSize: 12,
},
grid: {
horzLines: { color: "#3f454d" },
vertLines: { color: "#3f454d" },
},
timeScale: { timeVisible: true },
handleScale: false,
handleScroll: false,
});
line_ref.current.timeScale().fitContent();
// INIT SERIES
series_ref.current = line_ref.current.addAreaSeries({
priceLineVisible: false,
title: `${type} %`,
lineColor,
topColor: `${lineColor}B3`,
bottomColor: `${lineColor}0D`,
});
series_ref.current.setData(stats);
};
// Run the effect
init();
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, [stats]);
return <div className="w-full max-w-full h-full" ref={container_ref} />;
};
const getStat = (stat: Types.SystemStatsRecord, type: StatType) => {
if (type === "cpu") return stat.cpu_perc || 0;
if (type === "mem") return (100 * stat.mem_used_gb) / stat.mem_total_gb;
if (type === "disk") return (100 * stat.disk_used_gb) / stat.disk_total_gb;
return 0;
};
const getColor = (type: StatType) => {
if (type === "cpu") return hex_color_by_intention("Good");
if (type === "mem") return hex_color_by_intention("Warning");
if (type === "disk") return hex_color_by_intention("Neutral");
return hex_color_by_intention("Unknown");
};

View File

@@ -10,12 +10,25 @@ import { Progress } from "@ui/progress";
import { Cpu, Database, MemoryStick } from "lucide-react";
import { useRead } from "@lib/hooks";
import { Types } from "@monitor/client";
import { useServer } from ".";
import { ServerComponents, useServer } from ".";
import { DataTable } from "@ui/data-table";
import { useState } from "react";
import { Fragment, useState } from "react";
import { Input } from "@ui/input";
import { ResourceDescription } from "../common";
import { AddTags, ResourceTags } from "@components/tags";
import { StatChart } from "./stat-chart";
import { useStatsInterval } from "./hooks";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/select";
export const ServerStats = ({ id }: { id: string }) => {
const [interval, setInterval] = useStatsInterval();
const server = useServer(id);
const stats = useRead(
"GetSystemStats",
@@ -24,17 +37,58 @@ export const ServerStats = ({ id }: { id: string }) => {
).data;
const info = useRead("GetSystemInformation", { server: id }).data;
const disk_used = stats?.disks.reduce(
(acc, curr) => (acc += curr.used_gb),
0
);
const disk_total = stats?.disks.reduce(
(acc, curr) => (acc += curr.total_gb),
0
);
return (
<Page
title={server?.name}
titleRight={<div className="text-muted-foreground">Stats</div>}
titleRight={
<div className="flex gap-4 items-center">
{Object.entries(ServerComponents.Status).map(([key, Status]) => (
<Status key={key} id={id} />
))}
</div>
}
subtitle={
<div className="flex flex-col gap-4">
<div className="flex gap-4 items-center text-muted-foreground">
<ServerComponents.Icon id={id} />
{Object.entries(ServerComponents.Info).map(([key, Info], i) => (
<Fragment key={key}>
| <Info key={i} id={id} />
</Fragment>
))}
</div>
<ResourceDescription type="Server" id={id} />
</div>
}
actions={
<div className="flex gap-2 items-center">
<div className="text-muted-foreground">tags:</div>
<ResourceTags
target={{ id, type: "Server" }}
className="text-sm"
click_to_delete
/>
<AddTags target={{ id, type: "Server" }} />
</div>
}
>
<div className="flex gap-4">{/* <ServerInfo id={id} /> */}</div>
<Section title="System Info">
<DataTable
tableKey="system-info"
data={info ? [info] : []}
data={
info
? [{ ...info, mem_total: stats?.mem_total_gb, disk_total }]
: []
}
columns={[
{
header: "Hostname",
@@ -48,11 +102,24 @@ export const ServerStats = ({ id }: { id: string }) => {
header: "Kernel",
accessorKey: "kernel",
},
{
header: "Core Count",
accessorFn: ({ core_count }) =>
`${core_count} Core${(core_count || 0) > 1 ? "s" : ""}`,
},
{
header: "Total Memory",
accessorFn: ({ mem_total }) => `${mem_total?.toFixed(2)} GB`,
},
{
header: "Total Disk Size",
accessorFn: ({ disk_total }) => `${disk_total?.toFixed(2)} GB`,
},
]}
/>
</Section>
<Section title="Basic">
<Section title="Current">
<div className="flex flex-col lg:flex-row gap-4">
<CPU stats={stats} />
<RAM stats={stats} />
@@ -60,19 +127,61 @@ export const ServerStats = ({ id }: { id: string }) => {
</div>
</Section>
{/* <Section title="Load">
<div className="flex flex-col lg:flex-row gap-4">
{["one", "five", "fifteen"].map((minutes) => (
<LOAD
load={stats?.basic.load_average}
minutes={minutes as keyof Types.LoadAverage}
core_count={info?.core_count || 0}
/>
<Section
title="Historical"
actions={
<div className="flex gap-2 items-center">
<div className="text-muted-foreground">Interval:</div>
<Select
value={interval}
onValueChange={(interval) =>
setInterval(interval as Types.Timelength)
}
>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[
Types.Timelength.FifteenSeconds,
Types.Timelength.ThirtySeconds,
Types.Timelength.OneMinute,
Types.Timelength.FiveMinutes,
Types.Timelength.FifteenMinutes,
Types.Timelength.ThirtyMinutes,
Types.Timelength.OneHour,
Types.Timelength.SixHours,
Types.Timelength.OneDay,
].map((timelength) => (
<SelectItem value={timelength}>{timelength}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</Section> */}
}
>
<div className="flex flex-col gap-8">
<StatChart server_id={id} type="cpu" className="w-full h-[250px]" />
<StatChart server_id={id} type="mem" className="w-full h-[250px]" />
<StatChart server_id={id} type="disk" className="w-full h-[250px]" />
</div>
</Section>
<Section title="Disks">
<Section
title="Disks"
actions={
<div className="flex gap-4 items-center">
<div className="flex gap-2 items-center">
<div className="text-muted-foreground">Used:</div>
{disk_used?.toFixed(2)} GB
</div>
<div className="flex gap-2 items-center">
<div className="text-muted-foreground">Total:</div>
{disk_total?.toFixed(2)} GB
</div>
</div>
}
>
<DataTable
tableKey="server-disks"
data={stats?.disks ?? []}

View File

@@ -85,3 +85,8 @@ export const has_minimum_permissions = (
if (!level) return false;
return level_to_number(level) >= level_to_number(greater_than);
};
const tzOffset = new Date().getTimezoneOffset() * 60;
export const convertTsMsToLocalUnixTsInSecs = (ts: number) =>
ts / 1000 - tzOffset;