forked from github-starred/komodo
server stats chart
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "@ui/card";
|
} from "@ui/card";
|
||||||
import { Hammer } from "lucide-react";
|
import { Hammer } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { convertTsMsToLocalUnixTsInSecs } from "@lib/utils";
|
||||||
|
|
||||||
export const BuildChart = () => {
|
export const BuildChart = () => {
|
||||||
const container_ref = useRef<HTMLDivElement>(null);
|
const container_ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -34,6 +35,8 @@ export const BuildChart = () => {
|
|||||||
if (line_ref.current) line_ref.current.remove();
|
if (line_ref.current) line_ref.current.remove();
|
||||||
const init = () => {
|
const init = () => {
|
||||||
if (!container_ref.current) return;
|
if (!container_ref.current) return;
|
||||||
|
|
||||||
|
// INIT LINE
|
||||||
line_ref.current = createChart(container_ref.current, {
|
line_ref.current = createChart(container_ref.current, {
|
||||||
width: container_ref.current.clientWidth,
|
width: container_ref.current.clientWidth,
|
||||||
height: container_ref.current.clientHeight,
|
height: container_ref.current.clientHeight,
|
||||||
@@ -50,13 +53,15 @@ export const BuildChart = () => {
|
|||||||
handleScroll: false,
|
handleScroll: false,
|
||||||
});
|
});
|
||||||
line_ref.current.timeScale().fitContent();
|
line_ref.current.timeScale().fitContent();
|
||||||
|
|
||||||
|
// INIT SERIES
|
||||||
series_ref.current = line_ref.current.addHistogramSeries({
|
series_ref.current = line_ref.current.addHistogramSeries({
|
||||||
priceLineVisible: false,
|
priceLineVisible: false,
|
||||||
});
|
});
|
||||||
const max = build_stats.days.reduce((m, c) => Math.max(m, c.time), 0);
|
const max = build_stats.days.reduce((m, c) => Math.max(m, c.time), 0);
|
||||||
series_ref.current.setData(
|
series_ref.current.setData(
|
||||||
build_stats.days.map((d) => ({
|
build_stats.days.map((d) => ({
|
||||||
time: (d.ts / 1000) as Time,
|
time: convertTsMsToLocalUnixTsInSecs(d.ts) as Time,
|
||||||
value: d.count,
|
value: d.count,
|
||||||
color:
|
color:
|
||||||
d.time > max * 0.7
|
d.time > max * 0.7
|
||||||
@@ -67,6 +72,8 @@ export const BuildChart = () => {
|
|||||||
})) ?? []
|
})) ?? []
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Run the effect
|
||||||
init();
|
init();
|
||||||
window.addEventListener("resize", handleResize);
|
window.addEventListener("resize", handleResize);
|
||||||
return () => {
|
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,
|
Database,
|
||||||
Scissors,
|
Scissors,
|
||||||
XOctagon,
|
XOctagon,
|
||||||
|
AreaChart,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Section } from "@components/layouts";
|
import { Section } from "@components/layouts";
|
||||||
import { RenameServer } from "./actions";
|
import { RenameServer } from "./actions";
|
||||||
@@ -26,6 +27,7 @@ import { Link } from "react-router-dom";
|
|||||||
import { DeleteResource, NewResource } from "../common";
|
import { DeleteResource, NewResource } from "../common";
|
||||||
import { ActionWithDialog, ConfirmButton } from "@components/util";
|
import { ActionWithDialog, ConfirmButton } from "@components/util";
|
||||||
import { Card, CardHeader } from "@ui/card";
|
import { Card, CardHeader } from "@ui/card";
|
||||||
|
import { Button } from "@ui/button";
|
||||||
|
|
||||||
export const useServer = (id?: string) =>
|
export const useServer = (id?: string) =>
|
||||||
useRead("ListServers", {}).data?.find((d) => d.id === id);
|
useRead("ListServers", {}).data?.find((d) => d.id === id);
|
||||||
@@ -66,6 +68,12 @@ export const ServerComponents: RequiredResourceComponents = {
|
|||||||
</Card>
|
</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: {
|
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 { Cpu, Database, MemoryStick } from "lucide-react";
|
||||||
import { useRead } from "@lib/hooks";
|
import { useRead } from "@lib/hooks";
|
||||||
import { Types } from "@monitor/client";
|
import { Types } from "@monitor/client";
|
||||||
import { useServer } from ".";
|
import { ServerComponents, useServer } from ".";
|
||||||
import { DataTable } from "@ui/data-table";
|
import { DataTable } from "@ui/data-table";
|
||||||
import { useState } from "react";
|
import { Fragment, useState } from "react";
|
||||||
import { Input } from "@ui/input";
|
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 }) => {
|
export const ServerStats = ({ id }: { id: string }) => {
|
||||||
|
const [interval, setInterval] = useStatsInterval();
|
||||||
|
|
||||||
const server = useServer(id);
|
const server = useServer(id);
|
||||||
const stats = useRead(
|
const stats = useRead(
|
||||||
"GetSystemStats",
|
"GetSystemStats",
|
||||||
@@ -24,17 +37,58 @@ export const ServerStats = ({ id }: { id: string }) => {
|
|||||||
).data;
|
).data;
|
||||||
const info = useRead("GetSystemInformation", { server: id }).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 (
|
return (
|
||||||
<Page
|
<Page
|
||||||
title={server?.name}
|
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">
|
<Section title="System Info">
|
||||||
<DataTable
|
<DataTable
|
||||||
tableKey="system-info"
|
tableKey="system-info"
|
||||||
data={info ? [info] : []}
|
data={
|
||||||
|
info
|
||||||
|
? [{ ...info, mem_total: stats?.mem_total_gb, disk_total }]
|
||||||
|
: []
|
||||||
|
}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
header: "Hostname",
|
header: "Hostname",
|
||||||
@@ -48,11 +102,24 @@ export const ServerStats = ({ id }: { id: string }) => {
|
|||||||
header: "Kernel",
|
header: "Kernel",
|
||||||
accessorKey: "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>
|
||||||
|
|
||||||
<Section title="Basic">
|
<Section title="Current">
|
||||||
<div className="flex flex-col lg:flex-row gap-4">
|
<div className="flex flex-col lg:flex-row gap-4">
|
||||||
<CPU stats={stats} />
|
<CPU stats={stats} />
|
||||||
<RAM stats={stats} />
|
<RAM stats={stats} />
|
||||||
@@ -60,19 +127,61 @@ export const ServerStats = ({ id }: { id: string }) => {
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* <Section title="Load">
|
<Section
|
||||||
<div className="flex flex-col lg:flex-row gap-4">
|
title="Historical"
|
||||||
{["one", "five", "fifteen"].map((minutes) => (
|
actions={
|
||||||
<LOAD
|
<div className="flex gap-2 items-center">
|
||||||
load={stats?.basic.load_average}
|
<div className="text-muted-foreground">Interval:</div>
|
||||||
minutes={minutes as keyof Types.LoadAverage}
|
<Select
|
||||||
core_count={info?.core_count || 0}
|
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>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
</div>
|
||||||
</Section> */}
|
</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
|
<DataTable
|
||||||
tableKey="server-disks"
|
tableKey="server-disks"
|
||||||
data={stats?.disks ?? []}
|
data={stats?.disks ?? []}
|
||||||
|
|||||||
@@ -85,3 +85,8 @@ export const has_minimum_permissions = (
|
|||||||
if (!level) return false;
|
if (!level) return false;
|
||||||
return level_to_number(level) >= level_to_number(greater_than);
|
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