Initial implementation
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
target
|
||||
1311
Cargo.lock
generated
Normal file
1311
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
Cargo.toml
Normal file
17
Cargo.toml
Normal 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
30
README.md
Normal 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
30
biome.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
13
index.html
Normal file
13
index.html
Normal 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
28
package.json
Normal 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
21
public/favicon.svg
Normal 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
26
src/client/App.css
Normal 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
45
src/client/App.tsx
Normal 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
17
src/client/api.ts
Normal 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();
|
||||
};
|
||||
44
src/client/components/chart-cards/cpu.tsx
Normal file
44
src/client/components/chart-cards/cpu.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
);
|
||||
};
|
||||
37
src/client/components/chart-cards/disks.tsx
Normal file
37
src/client/components/chart-cards/disks.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
66
src/client/components/chart-cards/index.tsx
Normal file
66
src/client/components/chart-cards/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
37
src/client/components/chart-cards/memory.tsx
Normal file
37
src/client/components/chart-cards/memory.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
37
src/client/components/chart-cards/network.tsx
Normal file
37
src/client/components/chart-cards/network.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
63
src/client/components/chart-cards/static.tsx
Normal file
63
src/client/components/chart-cards/static.tsx
Normal 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>
|
||||
)
|
||||
);
|
||||
};
|
||||
39
src/client/components/chart-cards/temps.tsx
Normal file
39
src/client/components/chart-cards/temps.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
19
src/client/components/legend/index.css
Normal file
19
src/client/components/legend/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
21
src/client/components/legend/index.tsx
Normal file
21
src/client/components/legend/index.tsx
Normal 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>
|
||||
);
|
||||
24
src/client/hooks/use-animation-frame.ts
Normal file
24
src/client/hooks/use-animation-frame.ts
Normal 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
33
src/client/index.css
Normal 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
10
src/client/main.tsx
Normal 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
31
src/client/types.ts
Normal 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[];
|
||||
};
|
||||
3
src/client/utils/colors.ts
Normal file
3
src/client/utils/colors.ts
Normal 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%)`;
|
||||
20
src/client/utils/format.ts
Normal file
20
src/client/utils/format.ts
Normal 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
1
src/client/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
56
src/stats-server/disks.rs
Normal file
56
src/stats-server/disks.rs
Normal 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
149
src/stats-server/main.rs
Normal 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
28
tsconfig.json
Normal 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
11
tsconfig.node.json
Normal 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
13
vite.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user