Initial implementation

This commit is contained in:
2024-05-02 19:41:50 +01:00
parent 7bda6dba00
commit cb5fb93c97
33 changed files with 2285 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
dist-ssr
*.local
target

1311
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

17
Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "stats_server"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
axum = { version = "0.6.9", features = ["macros", "ws"] }
serde_json = "1.0.93"
sysinfo = "0.30.11"
tokio = { version = "1.25.0", features = ["full"] }
tracing-subscriber = { version = "0.3.16", features = ["env-filter"] }
[[bin]]
name = "stats_server"
path = "src/stats-server/main.rs"

30
README.md Normal file
View File

@@ -0,0 +1,30 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
}
```
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list

30
biome.json Normal file
View File

@@ -0,0 +1,30 @@
{
"$schema": "https://biomejs.dev/schemas/1.7.1/schema.json",
"organizeImports": {
"enabled": true
},
"files": {
"ignore": ["src/stats-server", "dist"],
"ignoreUnknown": true
},
"formatter": {
"indentStyle": "space",
"lineWidth": 120
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noNonNullAssertion": "off"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"jsxQuoteStyle": "single",
"arrowParentheses": "asNeeded"
}
}
}

BIN
bun.lockb Executable file

Binary file not shown.

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SysMon Web</title>
</head>
<body>
<main id="root"></main>
<script type="module" src="/src/client/main.tsx"></script>
</body>
</html>

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "sysmon-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host & cargo run",
"build": "tsc && vite build && cargo build --release",
"preview": "vite preview --host & cargo run --release"
},
"dependencies": {
"@tanstack/react-query": "^5.32.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"recharts": "^2.12.6"
},
"devDependencies": {
"@biomejs/biome": "1.7.1",
"@types/node": "^20.12.8",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"typescript": "^5.2.2",
"vite": "^5.2.0"
}
}

21
public/favicon.svg Normal file
View File

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 40 40">
<defs>
<linearGradient id="c" x2="0" y1="543.8" y2="503.8" gradientTransform="translate(-389 -504)" gradientUnits="userSpaceOnUse">
<stop stop-color="#002b36"/>
<stop offset="1" stop-color="#073642"/>
</linearGradient>
<linearGradient id="b" x1="0" x2="0" y1="44" y2="12" gradientUnits="userSpaceOnUse">
<stop stop-color="#2aa198"/>
<stop offset="1" stop-color="#268bd2"/>
</linearGradient>
<linearGradient id="a" x1="9" x2="13" y1="15" y2="21.9" gradientUnits="userSpaceOnUse">
<stop stop-color="#fff"/>
<stop offset="1" stop-color="#fff" stop-opacity="0"/>
</linearGradient>
<linearGradient xlink:href="#a" id="e" x1="9" x2="13" y1="15" y2="21.9" gradientTransform="translate(-4 -4)" gradientUnits="userSpaceOnUse"/>
<linearGradient xlink:href="#b" id="d" x1="-42" x2="-42" y1="44" y2="18" gradientTransform="translate(56 -4)" gradientUnits="userSpaceOnUse"/>
</defs>
<path fill="url(#c)" d="M5.9 0A5.9 5.9 0 0 0 0 5.9v28.2C0 37.4 2.6 40 5.9 40h28.2c3.3 0 5.9-2.6 5.9-5.9V5.9C40 2.6 37.4 0 34.1 0H5.9z"/>
<path fill="url(#d)" d="M9 12c-5 0-5 5-9 10v12.1A5.86 5.86 0 0 0 2.58 39h34.84A5.86 5.86 0 0 0 40 34.1V31s-6-9-9-10c-4-1-6 4-11 3-4-1-6-12-11-12z"/>
<path fill="url(#e)" fill-rule="evenodd" d="M9 12H8c-5 1-5 8-10 11v1c5-3 5-10 10-11 7-1 6 9 11 12 6 3 10-7 18 3 2 2 3 4 5 4v-1c-2 0-3-2-5-4-8-10-12 0-18-3-5-3-4-12-10-12"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

26
src/client/App.css Normal file
View File

@@ -0,0 +1,26 @@
#root {
background-color: var(--color-neutral1);
display: grid;
grid-auto-rows: calc(50svh - 16px);
padding: 8px;
gap: 8px;
@media (min-width: 500px) {
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(2, 1fr);
height: 100vh;
}
> div {
border-radius: 16px;
background-color: var(--color-neutral0);
padding: 16px;
width: 100%;
overflow: hidden;
}
}
.chart-card {
display: grid;
grid-template-rows: repeat(2, max-content) 1fr;
}

45
src/client/App.tsx Normal file
View File

@@ -0,0 +1,45 @@
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
import './App.css';
import { fetchDynamicData, fetchStaticData } from './api';
import { Cpu } from './components/chart-cards/cpu';
import { Disks } from './components/chart-cards/disks';
import { Memory } from './components/chart-cards/memory';
import { Network } from './components/chart-cards/network';
import { Static } from './components/chart-cards/static';
import { Temps } from './components/chart-cards/temps';
const queryClient = new QueryClient();
const Main = () => {
const staticQuery = useQuery({
queryKey: ['static'],
queryFn: fetchStaticData,
});
const dynamicQuery = useQuery({
queryKey: ['dynamic'],
queryFn: fetchDynamicData,
refetchInterval: 500,
});
const isLoading = staticQuery.isLoading || dynamicQuery.isLoading;
return (
!isLoading && (
<>
<Static />
<Cpu />
<Memory />
<Network />
<Disks />
<Temps />
</>
)
);
};
export const App = () => (
<QueryClientProvider client={queryClient}>
<Main />
</QueryClientProvider>
);

17
src/client/api.ts Normal file
View File

@@ -0,0 +1,17 @@
const getApiUrl = (path: string) => {
const url = new URL(window.location.href);
url.port = '3001';
url.pathname = path;
return url;
};
export const fetchStaticData = async (): Promise<StaticData> => {
const response = await fetch(getApiUrl('/api/static'));
return await response.json();
};
export const fetchDynamicData = async (): Promise<DynamicData> => {
const response = await fetch(getApiUrl('/api/dynamic'));
return await response.json();
};

View File

@@ -0,0 +1,44 @@
import { useQuery } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { ChartCard } from './index';
export const Cpu = () => {
const { data: staticData } = useQuery<StaticData>({ queryKey: ['static'] });
const { data: dynamicData } = useQuery<DynamicData>({ queryKey: ['dynamic'] });
const [history, setHistory] = useState<number[][]>(new Array(150).fill([]));
useEffect(() => {
if (dynamicData) {
setHistory(history => {
const newHistory = history.slice(1);
newHistory.push(dynamicData.cpu_usage);
return newHistory;
});
}
}, [dynamicData]);
const total_cpus = useMemo(() => history.at(-1)?.length ?? 0, [history]);
if (!staticData || !dynamicData) {
return <div />;
}
return (
!!total_cpus && (
<ChartCard
title='CPU'
subtitle={
<h3>
{staticData.cpu.brand}
<small>{` (${total_cpus} threads)`}</small>
</h3>
}
domain={[0, 100]}
formatOptions={{ prefix: false, units: '%' }}
data={history}
total={total_cpus}
/>
)
);
};

View File

@@ -0,0 +1,37 @@
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { ChartCard } from './index';
export const Disks = () => {
const { data: dynamicData } = useQuery<DynamicData>({ queryKey: ['dynamic'] });
const [history, setHistory] = useState<number[][]>(new Array(150).fill([]));
useEffect(() => {
if (dynamicData) {
setHistory(history => {
const newHistory = history.slice(1);
newHistory.push([dynamicData.disks.read, dynamicData.disks.write]);
return newHistory;
});
}
}, [dynamicData]);
if (!dynamicData) {
return <div />;
}
return (
<ChartCard
title='Disk activity'
legend={{
values: [dynamicData.disks.read, dynamicData.disks.write],
labels: ['Read', 'Write'],
}}
hueOffset={120}
formatOptions={{ units: 'B/s' }}
data={history}
total={2}
/>
);
};

View File

@@ -0,0 +1,66 @@
import { Legend, type LegendProps } from '@/components/legend';
import { getFillColor, getStrokeColor } from '@/utils/colors';
import { type FormatOptions, formatValue } from '@/utils/format';
import type { ReactNode } from 'react';
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis } from 'recharts';
import type { AxisDomain } from 'recharts/types/util/types';
type Props = {
title: ReactNode;
subtitle?: ReactNode;
legend?: LegendProps;
formatOptions?: FormatOptions;
domain?: AxisDomain;
hueOffset?: number;
data: number[][];
total: number;
};
export const ChartCard = ({ data, domain, legend, hueOffset = 0, title, subtitle, formatOptions, total }: Props) => {
return (
<div className='chart-card'>
<h2>{title}</h2>
{subtitle}
{legend && <Legend hueOffset={hueOffset} formatOptions={formatOptions} {...legend} />}
<ResponsiveContainer>
<AreaChart
width={500}
height={300}
data={data}
margin={{
bottom: -16,
left: 40,
}}
>
<CartesianGrid vertical={false} stroke='var(--color-neutral1)' />
<YAxis
axisLine={false}
domain={domain}
stroke='var(--color-neutral1)'
tick={{ fill: 'var(--color-neutral2)' }}
tickFormatter={value => formatValue(value, formatOptions)}
/>
{Array.from({ length: total }).map((_, index) => (
// biome-ignore lint/correctness/useJsxKeyInIterable: order irrelevant
<Area
isAnimationActive={false}
type='monotone'
dataKey={index}
fill={getFillColor(((index * 360) / total + hueOffset) % 360)}
stroke={getStrokeColor(((index * 360) / total + hueOffset) % 360)}
strokeWidth={2}
dot={false}
/>
))}
<XAxis tick={false} stroke='var(--color-neutral0)' strokeWidth={2} transform='translate(0 1)' />
</AreaChart>
</ResponsiveContainer>
</div>
);
};

View File

@@ -0,0 +1,37 @@
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { ChartCard } from './index';
export const Memory = () => {
const { data: staticData } = useQuery<StaticData>({ queryKey: ['static'] });
const { data: dynamicData } = useQuery<DynamicData>({ queryKey: ['dynamic'] });
const [history, setHistory] = useState<number[][]>(new Array(150).fill([]));
useEffect(() => {
if (dynamicData) {
setHistory(history => {
const newHistory = history.slice(1);
newHistory.push([dynamicData.mem_usage, dynamicData.swap_usage]);
return newHistory;
});
}
}, [dynamicData]);
if (!staticData || !dynamicData) {
return <div />;
}
return (
<ChartCard
title='Memory'
legend={{
values: [staticData.total_memory, staticData.total_swap],
labels: ['Total memory', 'Total swap'],
}}
formatOptions={{ units: 'B' }}
data={history}
total={2}
/>
);
};

View File

@@ -0,0 +1,37 @@
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { ChartCard } from './index';
export const Network = () => {
const { data: dynamicData } = useQuery<DynamicData>({ queryKey: ['dynamic'] });
const [history, setHistory] = useState<number[][]>(new Array(150).fill([]));
useEffect(() => {
if (dynamicData) {
setHistory(history => {
const newHistory = history.slice(1);
newHistory.push([dynamicData.network.down, dynamicData.network.up]);
return newHistory;
});
}
}, [dynamicData]);
if (!dynamicData) {
return <div />;
}
return (
<ChartCard
title='Network'
legend={{
values: [dynamicData.network.down, dynamicData.network.up],
labels: ['Down', 'Up'],
}}
hueOffset={60}
formatOptions={{ units: 'B/s' }}
data={history}
total={2}
/>
);
};

View File

@@ -0,0 +1,63 @@
import { useAnimationFrame } from '@/hooks/use-animation-frame';
import { useQuery } from '@tanstack/react-query';
import { useRef, useState } from 'react';
const formatUptime = (value: number) => {
const seconds = String(Math.floor(value % 60)).padStart(2, '0');
const minutes = String(Math.floor((value / 60) % 60)).padStart(2, '0');
const hours = String(Math.floor((value / (60 * 60)) % 24)).padStart(2, '0');
const days = Math.floor((value / (60 * 60 * 24)) % 365);
const years = Math.floor(value / (60 * 60 * 24 * 365));
let formatted = '';
if (years >= 1) {
formatted += `${years}y `;
}
if (days >= 1 || years >= 1) {
formatted += `${days}d `;
}
return `${formatted}${hours}:${minutes}:${seconds}`;
};
const Uptime = ({ boot_time }: Pick<StaticData, 'boot_time'>) => {
const [uptime, setUptime] = useState(formatUptime(Date.now() / 1000 - boot_time));
const lastUpdate = useRef(0);
useAnimationFrame(dt => {
lastUpdate.current += dt;
if (lastUpdate.current > 1000) {
setUptime(formatUptime(Date.now() / 1000 - boot_time));
}
});
return (
<>
<small>Uptime</small>
<h3>{uptime}</h3>
</>
);
};
export const Static = () => {
const { data: staticData } = useQuery<StaticData>({ queryKey: ['static'] });
return (
staticData && (
<div>
<h2>{staticData.host_name}</h2>
<small>OS</small>
<h3>
{staticData.name} {staticData.os_version}
</h3>
<small>Kernel</small>
<h3>{staticData.kernel_version}</h3>
{staticData.boot_time && <Uptime boot_time={staticData.boot_time} />}
</div>
)
);
};

View File

@@ -0,0 +1,39 @@
import { useQuery } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { ChartCard } from './index';
export const Temps = () => {
const { data: staticData } = useQuery<StaticData>({ queryKey: ['static'] });
const { data: dynamicData } = useQuery<DynamicData>({ queryKey: ['dynamic'] });
const [history, setHistory] = useState<number[][]>(new Array(150).fill([]));
useEffect(() => {
if (dynamicData) {
setHistory(history => {
const newHistory = history.slice(1);
newHistory.push(dynamicData.temps);
return newHistory;
});
}
}, [dynamicData]);
const total_sensors = useMemo(() => history.at(-1)?.length ?? 0, [history]);
if (!staticData || !dynamicData) {
return <div />;
}
return (
<ChartCard
title='Temperatures'
legend={{
values: dynamicData.temps,
labels: staticData.components,
}}
formatOptions={{ units: 'ºC' }}
data={history}
total={total_sensors}
/>
);
};

View File

@@ -0,0 +1,19 @@
.legend-wrapper {
display: flex;
flex-wrap: wrap;
gap: 12;
width: 100%;
overflow: hidden;
> div {
flex: 1;
}
small {
display: inline-block;
max-width: 128px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

View File

@@ -0,0 +1,21 @@
import { getTextColor } from '@/utils/colors';
import { type FormatOptions, formatValue } from '@/utils/format';
import './index.css';
export type LegendProps = {
labels: string[];
values: number[];
formatOptions?: FormatOptions;
hueOffset?: number;
};
export const Legend = ({ labels, values, formatOptions, hueOffset = 0 }: LegendProps) => (
<div className='legend-wrapper'>
{labels.map((label, index) => (
<div key={labels[index]}>
<small style={{ color: getTextColor((hueOffset + (360 * index) / values.length) % 360) }}>{label}</small>
<h4>{formatValue(values[index], formatOptions)}</h4>
</div>
))}
</div>
);

View File

@@ -0,0 +1,24 @@
import { useEffect, useRef } from 'react';
export const useAnimationFrame = (callback: (dt: number) => void) => {
const requestRef = useRef<number>();
const previousTimeRef = useRef<number>();
useEffect(() => {
const animate: FrameRequestCallback = time => {
if (previousTimeRef.current !== undefined) {
const deltaTime = time - previousTimeRef.current;
callback(deltaTime);
}
previousTimeRef.current = time;
requestRef.current = requestAnimationFrame(animate);
};
requestRef.current = requestAnimationFrame(animate);
return () => {
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
}
};
}, [callback]);
};

33
src/client/index.css Normal file
View File

@@ -0,0 +1,33 @@
:root {
--color-neutral0: #002b36;
--color-neutral1: #073642;
--color-neutral2: #586e75;
--color-neutral3: #657b83;
--color-neutral4: #839496;
--color-neutral5: #93a1a1;
--color-neutral6: #eee8d5;
--color-neutral7: #fdf6e3;
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color: var(--color-neutral6);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
}
* {
box-sizing: border-box;
}
* {
margin-top: 0;
}

10
src/client/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App.tsx';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

31
src/client/types.ts Normal file
View File

@@ -0,0 +1,31 @@
type StaticData = {
boot_time: number;
components: string[];
cpu: {
brand: string;
name: string;
vendor_id: string;
};
host_name: string;
kernel_version: string;
name: string;
os_version: string;
total_memory: number;
total_swap: number;
uptime: number;
};
type DynamicData = {
cpu_usage: number[];
disks: {
read: number;
write: number;
};
mem_usage: number;
network: {
down: number;
up: number;
};
swap_usage: number;
temps: number[];
};

View File

@@ -0,0 +1,3 @@
export const getTextColor = (hue: number) => `hsl(${hue} 50 50)`;
export const getFillColor = (hue: number) => `hsl(${hue} 50 50 / 10%)`;
export const getStrokeColor = (hue: number) => `hsl(${hue} 50 50 / 60%)`;

View File

@@ -0,0 +1,20 @@
const prefixes = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'];
export type FormatOptions = { decimals?: number; prefix?: boolean; si?: boolean; units?: string };
export const formatValue = (value: number, { decimals = 1, prefix = true, si, units = 'B' }: FormatOptions = {}) => {
if (!value) {
return `0\u2009${units}`;
}
if (!prefix) {
return `${value}\u2009${units}`;
}
const k = si ? 1000 : 1024;
const i = Math.floor(Math.log(value) / Math.log(k));
return `${Number((value / k ** i).toFixed(Math.max(0, decimals)))}\u2009${prefixes[i]}${
i > 0 && !si ? 'i' : ''
}${units}`;
};

1
src/client/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

56
src/stats-server/disks.rs Normal file
View File

@@ -0,0 +1,56 @@
pub mod disks {
use serde_json::json;
use std::{
fs::File,
io::{Read, Seek, SeekFrom},
};
pub struct DiskUsage {
read: u64,
write: u64,
}
impl DiskUsage {
fn new() -> Self {
DiskUsage { read: 0, write: 0 }
}
}
pub struct DiskStats {
fd: File,
prev: DiskUsage,
}
impl DiskStats {
pub fn new() -> Self {
let fd = File::open("/proc/diskstats").unwrap();
DiskStats {
fd,
prev: DiskUsage::new(),
}
}
pub fn refresh(&mut self) -> DiskUsage {
let mut curr = DiskUsage::new();
let mut io_data = String::new();
self.fd.read_to_string(&mut io_data).unwrap();
for line in io_data.lines() {
let fields: Vec<_> = line.split_whitespace().collect();
curr.read += fields[5].parse::<u64>().unwrap() * 512 * 8;
curr.write += fields[9].parse::<u64>().unwrap() * 512 * 8;
}
self.fd.seek(SeekFrom::Start(0)).unwrap();
curr
}
pub fn diff(&mut self) -> serde_json::Value {
let curr = self.refresh();
let diff = json!({
"read": curr.read - self.prev.read,
"write": curr.write - self.prev.write,
});
self.prev = curr;
diff
}
}
}

149
src/stats-server/main.rs Normal file
View File

@@ -0,0 +1,149 @@
use axum::{extract::State, http::Response, response::IntoResponse, routing::get, Router, Server};
use serde_json::{json, Value};
use std::{
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use sysinfo::{Components, CpuRefreshKind, MemoryRefreshKind, Networks, RefreshKind, System};
use crate::disks::disks::DiskStats;
mod disks;
#[derive(Clone)]
struct AppState {
latest: Arc<Mutex<Instant>>,
data: Arc<Mutex<Value>>,
}
impl Default for AppState {
fn default() -> Self {
AppState {
latest: Arc::new(Mutex::new(Instant::now())),
data: Arc::new(Mutex::new(json!({}))),
}
}
}
#[tokio::main]
async fn main() {
let app_state = AppState::default();
let router = Router::new()
.route("/api/dynamic", get(dynamic_sysinfo_get))
.route("/api/static", get(static_sysinfo_get))
.with_state(app_state.clone());
// Update system usage in the background
tokio::task::spawn_blocking(move || {
let mut sys = System::new();
let mut components = Components::new_with_refreshed_list();
let mut networks = Networks::new_with_refreshed_list();
let mut disk_stats = DiskStats::new();
loop {
let now = Instant::now();
let latest = app_state.latest.lock().unwrap().clone();
if (now - latest).as_millis() < 10000 {
sys.refresh_cpu();
sys.refresh_memory();
components.refresh();
networks.refresh();
disk_stats.refresh();
let cpu_usage: Vec<_> = sys.cpus().iter().map(|cpu| cpu.cpu_usage()).collect();
let mem_usage = sys.used_memory();
let swap_usage = sys.used_swap();
let temps: Vec<_> = components
.iter()
.map(|component| component.temperature())
.collect();
let (net_down, net_up) =
networks
.iter()
.fold((0, 0), |(down, up), (_name, network)| {
(down + network.received(), up + network.transmitted())
});
{
let mut data = app_state.data.lock().unwrap();
*data = json!({
"cpu_usage": cpu_usage,
"mem_usage": mem_usage,
"swap_usage": swap_usage,
"temps": temps,
"network": {
"down": net_down,
"up": net_up
},
"disks": disk_stats.diff()
});
}
}
std::thread::sleep(Duration::from_millis(200));
}
});
let server = Server::bind(&"0.0.0.0:3001".parse().unwrap()).serve(router.into_make_service());
let addr = server.local_addr();
println!("Listening on {addr}");
server.await.unwrap();
}
#[axum::debug_handler]
async fn dynamic_sysinfo_get(State(state): State<AppState>) -> impl IntoResponse {
let data = state.data.lock().unwrap().clone();
{
let mut latest = state.latest.lock().unwrap();
*latest = Instant::now()
}
Response::builder()
.header(axum::http::header::ORIGIN, "*")
.header(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
.header("content-type", "application/json")
.body(data.to_string())
.unwrap()
}
async fn static_sysinfo_get() -> impl IntoResponse {
let sys = System::new_with_specifics(
RefreshKind::new()
.with_cpu(CpuRefreshKind::everything())
.with_memory(MemoryRefreshKind::everything()),
);
let components = Components::new_with_refreshed_list();
Response::builder()
.header(axum::http::header::ORIGIN, "*")
.header(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
.header("content-type", "application/json")
.body(
json!({
"boot_time": System::boot_time(),
"uptime": System::uptime(),
"name": System::name(),
"kernel_version": System::kernel_version(),
"os_version": System::os_version(),
"host_name": System::host_name(),
"total_memory": sys.total_memory(),
"total_swap": sys.total_swap(),
"cpu": {
"name": sys.cpus()[0].name(),
"vendor_id": sys.cpus()[0].vendor_id(),
"brand": sys.cpus()[0].brand(),
},
"components": components
.iter()
.map(|component| component.label())
.collect::<Vec<_>>()
})
.to_string(),
)
.unwrap()
}

28
tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/client/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
tsconfig.node.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

13
vite.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import path from 'node:path';
import react from '@vitejs/plugin-react-swc';
import { defineConfig } from 'vite';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve('./src/client'),
},
},
});