forked from github-starred/komodo
server stats chart
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
10
frontend/src/components/resources/server/hooks.ts
Normal file
10
frontend/src/components/resources/server/hooks.ts
Normal 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);
|
||||
@@ -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: {
|
||||
|
||||
138
frontend/src/components/resources/server/stat-chart.tsx
Normal file
138
frontend/src/components/resources/server/stat-chart.tsx
Normal 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");
|
||||
};
|
||||
@@ -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 ?? []}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user