Add Dashboard stats
This commit is contained in:
12
Cargo.lock
generated
12
Cargo.lock
generated
@@ -2528,9 +2528,21 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
"socket2 0.6.1",
|
"socket2 0.6.1",
|
||||||
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-native-tls"
|
name = "tokio-native-tls"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
|
|||||||
@@ -17,4 +17,4 @@ serde = { version = "1.0.228", features = ["serde_derive", "derive"] }
|
|||||||
serde_derive = "1.0.228"
|
serde_derive = "1.0.228"
|
||||||
serde_json = "1.0.147"
|
serde_json = "1.0.147"
|
||||||
sqlx = { version = "0.8", features = [ "runtime-tokio", "tls-native-tls", "sqlite" ] }
|
sqlx = { version = "0.8", features = [ "runtime-tokio", "tls-native-tls", "sqlite" ] }
|
||||||
tokio = "1.48.0"
|
tokio = { version = "1.48.0", features = ["full"] }
|
||||||
|
|||||||
174
frontend/src/components/server_stats.tsx
Normal file
174
frontend/src/components/server_stats.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
frontend/src/lib/colors.ts
Normal file
67
frontend/src/lib/colors.ts
Normal 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",
|
||||||
|
];
|
||||||
6
frontend/src/models/metrics.ts
Normal file
6
frontend/src/models/metrics.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface Metrics {
|
||||||
|
server_id: number;
|
||||||
|
cpu: any;
|
||||||
|
disk: any;
|
||||||
|
network: any;
|
||||||
|
}
|
||||||
@@ -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 { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
component: App,
|
component: App,
|
||||||
});
|
});
|
||||||
|
|
||||||
function 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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ async fn main() -> std::io::Result<()> {
|
|||||||
.service(servers::list_servers)
|
.service(servers::list_servers)
|
||||||
.service(servers::get_server)
|
.service(servers::get_server)
|
||||||
.service(servers::update_server_hostname)
|
.service(servers::update_server_hostname)
|
||||||
|
.service(servers::get_server_metrics)
|
||||||
.service(
|
.service(
|
||||||
Files::new("/", "./frontend/dist")
|
Files::new("/", "./frontend/dist")
|
||||||
.index_file("index.html")
|
.index_file("index.html")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use actix_web::{Responder, get, patch, web};
|
use actix_web::{Responder, get, patch, web};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::helper;
|
use crate::helper;
|
||||||
|
|
||||||
@@ -55,3 +56,41 @@ pub async fn update_server_hostname(
|
|||||||
|
|
||||||
serde_json::to_string(&response).unwrap()
|
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()
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user