Add Dashboard stats

This commit is contained in:
2025-12-31 00:16:47 +01:00
parent c44a900b20
commit 0fba6c1445
8 changed files with 344 additions and 3 deletions

12
Cargo.lock generated
View File

@@ -2528,9 +2528,21 @@ dependencies = [
"pin-project-lite",
"signal-hook-registry",
"socket2 0.6.1",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"

View File

@@ -17,4 +17,4 @@ serde = { version = "1.0.228", features = ["serde_derive", "derive"] }
serde_derive = "1.0.228"
serde_json = "1.0.147"
sqlx = { version = "0.8", features = [ "runtime-tokio", "tls-native-tls", "sqlite" ] }
tokio = "1.48.0"
tokio = { version = "1.48.0", features = ["full"] }

View File

@@ -0,0 +1,174 @@
import type { Metrics } from "@/models/metrics";
import type { MinimalServer } from "@/models/minimal_servers";
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "./ui/chart";
import { Line, LineChart, YAxis } from "recharts";
import { colors64 } from "@/lib/colors";
import { Card } from "./ui/card";
export function ServerStats({
server,
metrics,
}: {
server: MinimalServer;
metrics: Metrics;
}) {
function getServerName() {
if (server.nickname) {
return server.nickname;
}
if (server.hostname) {
return server.hostname;
}
if (server.name) {
return server.name;
}
return server.id.toString();
}
function getCputColor(cpu: string) {
const cpuIndex = parseInt(cpu.replace("CPU", ""));
if (cpuIndex >= 0 && cpuIndex < colors64.length) {
return colors64[cpuIndex];
}
return "#8884d8";
}
function getInterfaceColor(interfaceName: string) {
if (interfaceName.includes("RX")) {
return "#008000";
}
if (interfaceName.includes("TX")) {
return "#FF0000";
}
return "#8884d8";
}
function getDiskColor(diskName: string) {
if (diskName.includes("Read")) {
return "#0000FF";
}
if (diskName.includes("Write")) {
return "#FF00FF";
}
return "#8884d8";
}
return (
<div className="text-white">
<div>
<p>{getServerName()}</p>
</div>
<div className="max-h-80 flex flex-col gap-2 p-2 lg:flex-row">
<Card className="flex-1 p-2">
<h2>CPU Stats</h2>
<ChartContainer
title="CPU Stats"
config={{}}
className="max-h-80 p-2"
>
<LineChart
accessibilityLayer
data={Object.values((metrics?.cpu as unknown as any) ?? {})}
title="CPU Usage (48h)"
>
<ChartTooltip content={<ChartTooltipContent />} />
<ChartLegend content={<ChartLegendContent />} />
<YAxis />
{Object.keys(metrics.cpu[Object.keys(metrics.cpu)[0]]).map(
(cpu) => {
return (
<Line
type="monotone"
dataKey={cpu}
stroke={getCputColor(cpu)}
activeDot
dot={false}
label={cpu}
/>
);
},
)}
</LineChart>
</ChartContainer>
</Card>
<Card className="flex-1 p-2">
<h2>Network Stats</h2>
<ChartContainer
title="Network Stats"
config={{}}
className="max-h-80 p-2"
>
<LineChart
accessibilityLayer
data={Object.values((metrics?.network as unknown as any) ?? {})}
title="Network Usage (48h)"
>
<ChartTooltip content={<ChartTooltipContent />} />
<ChartLegend content={<ChartLegendContent />} />
<YAxis />
{Object.keys(
metrics.network[Object.keys(metrics.network)[0]],
).map((interf) => {
return (
<Line
type="monotone"
dataKey={interf}
stroke={getInterfaceColor(interf)}
activeDot
dot={false}
label={interf}
/>
);
})}
</LineChart>
</ChartContainer>
</Card>
<Card className="flex-1 p-2">
<h2>Disk Stats</h2>
<ChartContainer
title="Disk Stats"
config={{}}
className="max-h-80 p-2"
>
<LineChart
accessibilityLayer
data={Object.values((metrics?.disk as unknown as any) ?? {})}
title="Disk Usage (48h)"
>
<ChartTooltip content={<ChartTooltipContent />} />
<ChartLegend content={<ChartLegendContent />} />
<YAxis />
{Object.keys(metrics.disk[Object.keys(metrics.disk)[0]]).map(
(disk) => {
return (
<Line
type="monotone"
dataKey={disk}
stroke={getDiskColor(disk)}
activeDot
dot={false}
label={disk}
/>
);
},
)}
</LineChart>
</ChartContainer>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
export const colors64: string[] = [
"#0000F0",
"#FFFFFF",
"#FF0000",
"#00FF00",
"#0000FF",
"#FFFF00",
"#00FFFF",
"#FF00FF",
"#800000",
"#008000",
"#000080",
"#808000",
"#008080",
"#800080",
"#C0C0C0",
"#808080",
"#FFA500",
"#A52A2A",
"#8B4513",
"#DEB887",
"#D2691E",
"#CD853F",
"#F4A460",
"#DAA520",
"#B8860B",
"#FFD700",
"#F0E68C",
"#EEE8AA",
"#98FB98",
"#90EE90",
"#00FA9A",
"#2E8B57",
"#3CB371",
"#66CDAA",
"#20B2AA",
"#5F9EA0",
"#4682B4",
"#1E90FF",
"#6495ED",
"#87CEEB",
"#87CEFA",
"#00BFFF",
"#4169E1",
"#00008B",
"#191970",
"#6A5ACD",
"#7B68EE",
"#8A2BE2",
"#4B0082",
"#9400D3",
"#9932CC",
"#BA55D3",
"#DDA0DD",
"#FFC0CB",
"#FF69B4",
"#FF1493",
"#DB7093",
"#DC143C",
"#B22222",
"#FA8072",
"#E9967A",
"#F08080",
"#CD5C5C",
"#FF6347",
"#FF7F50",
];

View File

@@ -0,0 +1,6 @@
export interface Metrics {
server_id: number;
cpu: any;
disk: any;
network: any;
}

View File

@@ -1,9 +1,51 @@
import { ServerStats } from "@/components/server_stats";
import { Card } from "@/components/ui/card";
import type { Metrics } from "@/models/metrics";
import type { MinimalServers } from "@/models/minimal_servers";
import { useQueries, useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useState } from "react";
export const Route = createFileRoute("/")({
component: App,
});
function App() {
return <div className="text-center">Dashboard coming soon!</div>;
const { data: servers = [] } = useQuery<MinimalServers>({
queryKey: ["servers"],
queryFn: async () => {
const res = await fetch("/api/servers");
if (!res.ok) throw new Error("Failed to fetch servers");
return (await res.json()) as MinimalServers;
},
refetchInterval: 40000,
});
const { data: metrics = [] } = useQuery<Metrics[]>({
queryKey: ["serversMetrics", servers.map((s) => s.id).join("-")],
enabled: servers.length > 0,
queryFn: async () => {
const results = await Promise.all(
servers.map(async (server) => {
const res = await fetch(`/api/servers/${server.id}/metrics`);
if (!res.ok) throw new Error(`Failed metrics for ${server.id}`);
return (await res.json()) as Metrics;
}),
);
return results;
},
refetchInterval: 30000,
});
return (
<div className="p-4 flex flex-col gap-2">
{metrics.map((metric, index) => (
<div key={index}>
<Card className="p-2">
{metric && <ServerStats server={servers[index]} metrics={metric} />}
</Card>
</div>
))}
</div>
);
}

View File

@@ -57,6 +57,7 @@ async fn main() -> std::io::Result<()> {
.service(servers::list_servers)
.service(servers::get_server)
.service(servers::update_server_hostname)
.service(servers::get_server_metrics)
.service(
Files::new("/", "./frontend/dist")
.index_file("index.html")

View File

@@ -1,5 +1,6 @@
use actix_web::{Responder, get, patch, web};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::helper;
@@ -55,3 +56,41 @@ pub async fn update_server_hostname(
serde_json::to_string(&response).unwrap()
}
#[derive(Deserialize, Serialize)]
struct Metrics {
server_id: i32,
cpu: Value,
disk: Value,
network: Value,
}
#[get("/api/servers/{id}/metrics")]
pub async fn get_server_metrics(id: web::Path<i32>) -> impl Responder {
let config = helper::get_authed_api_config().await.unwrap();
let cpu_future = scp_core::apis::default_api::api_v1_servers_server_id_metrics_cpu_get(
&config,
id.clone(),
Some(48),
);
let disk_future = scp_core::apis::default_api::api_v1_servers_server_id_metrics_disk_get(
&config,
id.clone(),
Some(48),
);
let network_future = scp_core::apis::default_api::api_v1_servers_server_id_metrics_network_get(
&config,
id.clone(),
Some(48),
);
let result = Metrics {
server_id: id.into_inner(),
cpu: cpu_future.await.unwrap(),
disk: disk_future.await.unwrap(),
network: network_future.await.unwrap(),
};
serde_json::to_string(&result).unwrap()
}