Persist history on server

This commit is contained in:
2024-05-08 17:35:28 +01:00
parent d656e06193
commit 9f14c53762
20 changed files with 224 additions and 146 deletions

3
.env
View File

@@ -1,7 +1,6 @@
CLIENT_GRAPH_STEPS=150 SERVER_STEPS=150
CLIENT_PORT=3000 CLIENT_PORT=3000
CLIENT_REFETCH_INTERVAL=500 CLIENT_REFETCH_INTERVAL=500
SERVER_ACTIVE_WINDOW=5000
SERVER_DEPLOY_URL= SERVER_DEPLOY_URL=
SERVER_PORT=3001 SERVER_PORT=3001
SERVER_REFRESH_INTERVAL=500 SERVER_REFRESH_INTERVAL=500

9
Cargo.lock generated
View File

@@ -689,18 +689,18 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.198" version = "1.0.200"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9846a40c979031340571da2545a4e5b7c4163bdae79b301d5f86d03979451fcc" checksum = "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f"
dependencies = [ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.198" version = "1.0.200"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e88edab869b01783ba905e7d0153f9fc1a6505a96e4ad3018011eedb838566d9" checksum = "856f046b9400cee3c8c94ed572ecdb752444c24528c035cd35882aad6f492bcb"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -799,6 +799,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"dotenv", "dotenv",
"serde",
"serde_json", "serde_json",
"sysinfo", "sysinfo",
"tokio", "tokio",

View File

@@ -8,6 +8,7 @@ edition = "2021"
[dependencies] [dependencies]
axum = { version = "0.6.9", features = ["macros", "ws"] } axum = { version = "0.6.9", features = ["macros", "ws"] }
dotenv = "0.15.0" dotenv = "0.15.0"
serde = { version = "1.0.200", features = ["derive"] }
serde_json = "1.0.93" serde_json = "1.0.93"
sysinfo = "0.30.11" sysinfo = "0.30.11"
tokio = { version = "1.25.0", features = ["full"] } tokio = { version = "1.25.0", features = ["full"] }

View File

@@ -4,6 +4,7 @@
display: grid; display: grid;
padding: 8px; padding: 8px;
gap: 8px; gap: 8px;
min-height: 100dvh;
> div { > div {
border-radius: 16px; border-radius: 16px;

View File

@@ -1,28 +1,55 @@
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from '@tanstack/react-query';
import './App.css'; import './App.css';
import { fetchDynamicData, fetchStaticData } from './api'; import { fetchDynamicData, fetchHistoryData, fetchStaticData } from './api';
import { Cpu } from './components/chart-cards/cpu'; import { Cpu } from './components/chart-cards/cpu';
import { Disks } from './components/chart-cards/disks'; import { Disks } from './components/chart-cards/disks';
import { Memory } from './components/chart-cards/memory'; import { Memory } from './components/chart-cards/memory';
import { Network } from './components/chart-cards/network'; import { Network } from './components/chart-cards/network';
import { Static } from './components/chart-cards/static'; import { Static } from './components/chart-cards/static';
import { Temps } from './components/chart-cards/temps'; import { Temps } from './components/chart-cards/temps';
import { useSetTheme } from './hooks/use-set-theme';
const serverSteps = Number(import.meta.env.SERVER_STEPS);
const serverRefreshInterval = Number(import.meta.env.SERVER_REFRESH_INTERVAL);
const refetchInterval = Number(import.meta.env.CLIENT_REFETCH_INTERVAL);
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const Main = () => { const Main = () => {
const queryClient = useQueryClient();
const staticQuery = useQuery({ const staticQuery = useQuery({
queryKey: ['static'], queryKey: ['static'],
queryFn: fetchStaticData, queryFn: fetchStaticData,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const historyQuery = useQuery({
queryKey: ['history'],
queryFn: fetchHistoryData,
}); });
const dynamicQuery = useQuery({ const dynamicQuery = useQuery({
queryKey: ['dynamic'], queryKey: ['dynamic'],
queryFn: fetchDynamicData, queryFn: async () => {
refetchInterval: Number(import.meta.env.CLIENT_REFETCH_INTERVAL), const data = await fetchDynamicData();
queryClient.setQueryData(['history'], (historyOld: HistorySlice[]) => {
const history = historyOld.slice();
while (history.length && history[0].timestamp < Date.now() - (serverSteps + 3) * serverRefreshInterval) {
history.shift();
}
history.push(data);
return history;
});
return data;
},
refetchInterval: refetchInterval,
}); });
const isLoading = staticQuery.isLoading || dynamicQuery.isLoading; useSetTheme(window.matchMedia('(prefers-color-scheme: dark)').matches);
const isLoading = staticQuery.isLoading || dynamicQuery.isLoading || historyQuery.isLoading;
return ( return (
!isLoading && ( !isLoading && (

View File

@@ -17,7 +17,12 @@ export const fetchStaticData = async (): Promise<StaticData> => {
return await response.json(); return await response.json();
}; };
export const fetchDynamicData = async (): Promise<DynamicData> => { export const fetchHistoryData = async (): Promise<HistorySlice[]> => {
const response = await fetch(getApiUrl('/api/history'));
return await response.json();
};
export const fetchDynamicData = async (): Promise<HistorySlice> => {
const response = await fetch(getApiUrl('/api/dynamic')); const response = await fetch(getApiUrl('/api/dynamic'));
return await response.json(); return await response.json();
}; };

View File

@@ -15,17 +15,11 @@ type StaticData = {
uptime: number; uptime: number;
}; };
type DynamicData = { type HistorySlice = {
cpu_usage: number[]; cpu: number[];
disks: { mem: [number, number];
read: number; net: [number, number];
write: number; disks: [number, number];
};
mem_usage: number;
network: {
down: number;
up: number;
};
swap_usage: number;
temps: number[]; temps: number[];
timestamp: number;
}; };

View File

@@ -6,25 +6,32 @@ import { CanvasChart } from './chart';
type Props = { type Props = {
title: ReactNode; title: ReactNode;
subtitle?: ReactNode; subtitle?: ReactNode;
legend?: Omit<LegendProps, 'values'> & Partial<Pick<LegendProps, 'values'>>; legend?: LegendProps;
formatOptions?: FormatOptions; formatOptions?: FormatOptions;
domain?: [number, number]; domain?: [number, number];
hardDomain?: boolean; hardDomain?: boolean;
hueOffset?: number; hueOffset?: number;
data: number[]; dataKey: Exclude<keyof HistorySlice, 'timestamp'>;
total: number; total: number;
}; };
export const ChartCard = ({ data, legend, hueOffset = 0, title, subtitle, formatOptions, ...rest }: Props) => { export const ChartCard = ({ dataKey, legend, hueOffset = 0, title, subtitle, formatOptions, ...rest }: Props) => {
return ( return (
<div className='chart-card'> <div className='chart-card'>
<h2>{title}</h2> <h2>{title}</h2>
{subtitle} {subtitle}
{legend && <Legend hueOffset={hueOffset} formatOptions={formatOptions} values={data} {...legend} />} {legend && (
<Legend
hueOffset={hueOffset}
formatOptions={formatOptions}
{...legend}
{...(!('values' in legend) && { dataKey })}
/>
)}
<CanvasChart data={data} hueOffset={hueOffset} formatOptions={formatOptions} {...rest} /> <CanvasChart dataKey={dataKey} hueOffset={hueOffset} formatOptions={formatOptions} {...rest} />
</div> </div>
); );
}; };

View File

@@ -1,17 +1,18 @@
import { useAnimationFrame } from '@/hooks/use-animation-frame'; import { useAnimationFrame } from '@/hooks/use-animation-frame';
import { getFillColor, getStrokeColor } from '@/utils/colors'; import { getFillColor, getStrokeColor } from '@/utils/colors';
import type { FormatOptions } from '@/utils/format'; import type { FormatOptions } from '@/utils/format';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import './index.css'; import './index.css';
import { YAxis } from './y-axis'; import { YAxis } from './y-axis';
const stepWindow = Number(import.meta.env.CLIENT_GRAPH_STEPS); const stepWindow = Number(import.meta.env.SERVER_STEPS);
const stepPeriod = Number(import.meta.env.CLIENT_REFETCH_INTERVAL); const stepPeriod = Number(import.meta.env.CLIENT_REFETCH_INTERVAL);
const xMargin = 4; const xMargin = 4;
type Props = { type Props = {
total: number; total: number;
data: number[]; dataKey: Exclude<keyof HistorySlice, 'timestamp'>;
domain?: [number, number]; domain?: [number, number];
hardDomain?: boolean; hardDomain?: boolean;
formatOptions?: FormatOptions; formatOptions?: FormatOptions;
@@ -21,39 +22,27 @@ type Props = {
const xFromTimestamp = (timestamp: number, width: number) => const xFromTimestamp = (timestamp: number, width: number) =>
((timestamp - Date.now()) / stepPeriod) * (width / stepWindow) + width + (width / stepWindow) * 2; ((timestamp - Date.now()) / stepPeriod) * (width / stepWindow) + width + (width / stepWindow) * 2;
export const CanvasChart = ({ total, hueOffset = 0, domain, hardDomain, data, formatOptions }: Props) => { export const CanvasChart = ({ total, hueOffset = 0, domain, hardDomain, dataKey, formatOptions }: Props) => {
const canvasRef = useRef<HTMLCanvasElement>(null!); const canvasRef = useRef<HTMLCanvasElement>(null!);
const now = useMemo(() => Date.now(), []); const { data: historyData } = useQuery<HistorySlice[]>({ queryKey: ['history'] });
const history = useRef<[number, number[]][]>([[now, []]]);
const [historyMax, setHistoryMax] = useState(0);
const targetMax = useMemo(() => { const targetMax = useMemo(() => {
if (domain && hardDomain) { if (domain && hardDomain) {
return domain[1]; return domain[1];
} }
const historyMax = (historyData ?? []).reduce((max, slice) => Math.max(max, ...slice[dataKey]), 0);
if (!domain || historyMax > domain[1]) { if (!domain || historyMax > domain[1]) {
return historyMax; return historyMax;
} }
return domain[1]; return domain[1];
}, [domain, hardDomain, historyMax]); }, [domain, hardDomain, historyData, dataKey]);
const max = useRef(targetMax); const max = useRef(targetMax);
const [width, setWidth] = useState(640); const [width, setWidth] = useState(640);
const [height, setHeight] = useState(480); const [height, setHeight] = useState(480);
// Record data changes
useEffect(() => {
if (data) {
while (history.current.length && xFromTimestamp(history.current[0][0], width) < -xMargin) {
history.current.shift();
}
history.current.push([Date.now(), data]);
setHistoryMax(history.current.reduce((max, [_, values]) => Math.max(max, ...values), 0));
}
}, [data, width]);
// Sync canvas size to actual element dimensions // Sync canvas size to actual element dimensions
useEffect(() => { useEffect(() => {
const onResize = () => { const onResize = () => {
@@ -72,7 +61,7 @@ export const CanvasChart = ({ total, hueOffset = 0, domain, hardDomain, data, fo
// Redraw chart // Redraw chart
useAnimationFrame(() => { useAnimationFrame(() => {
const ctx = canvasRef.current.getContext('2d'); const ctx = canvasRef.current.getContext('2d');
if (!ctx) { if (!ctx || !historyData) {
return; return;
} }
@@ -107,16 +96,16 @@ export const CanvasChart = ({ total, hueOffset = 0, domain, hardDomain, data, fo
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(-xMargin, height); ctx.moveTo(-xMargin, height);
ctx.lineTo(-xMargin, height - (height * (history.current[0][1][i] ?? 0)) / max.current); ctx.lineTo(-xMargin, height - (height * (historyData[0][dataKey][i] ?? 0)) / max.current);
for (const [timestamp, values] of history.current) { for (const { timestamp, [dataKey]: values } of historyData) {
const x = xFromTimestamp(timestamp, width); const x = xFromTimestamp(timestamp, width);
const y = height - (height * (values[i] ?? 0)) / max.current; const y = height - (height * (values[i] ?? 0)) / max.current;
ctx.lineTo(x, y); ctx.lineTo(x, y);
} }
ctx.lineTo(width + xMargin, height - (height * (history.current[0][1][i] ?? 0)) / max.current); ctx.lineTo(width + xMargin, height - (height * (historyData[0][dataKey][i] ?? 0)) / max.current);
ctx.lineTo(width + xMargin, height); ctx.lineTo(width + xMargin, height);
if (type === 'fill') ctx.fill(); if (type === 'fill') ctx.fill();

View File

@@ -1,15 +1,26 @@
import { getTextColor } from '@/utils/colors'; import { getTextColor } from '@/utils/colors';
import { type FormatOptions, formatValue } from '@/utils/format'; import { type FormatOptions, formatValue } from '@/utils/format';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import './index.css'; import './index.css';
export type LegendProps = { type LegendCommonProps = {
labels: string[]; labels: string[];
values: string[] | number[];
formatOptions?: FormatOptions; formatOptions?: FormatOptions;
hueOffset?: number; hueOffset?: number;
}; };
export const Legend = ({ labels, values, formatOptions, hueOffset = 0 }: LegendProps) => ( type LegendByKeyProps = LegendCommonProps & {
dataKey: Exclude<keyof HistorySlice, 'timestamp'>;
};
type LegendByDataProps = LegendCommonProps & {
values: string[] | number[];
};
export type LegendProps = LegendByDataProps | LegendByKeyProps;
export const LegendByData = ({ labels, formatOptions, hueOffset = 0, values }: LegendByDataProps) => (
<div className='legend-wrapper'> <div className='legend-wrapper'>
{labels.map((label, index) => ( {labels.map((label, index) => (
<div key={labels[index]}> <div key={labels[index]}>
@@ -24,3 +35,19 @@ export const Legend = ({ labels, values, formatOptions, hueOffset = 0 }: LegendP
))} ))}
</div> </div>
); );
export const LegendByKey = ({ dataKey, ...rest }: LegendByKeyProps) => {
const { data: historyData } = useQuery<HistorySlice[]>({ queryKey: ['history'] });
const values = useMemo(() => historyData?.at(-1)?.[dataKey], [historyData, dataKey]);
if (values) {
return <LegendByData {...rest} values={values} />;
}
};
export const Legend = (props: LegendProps) => {
if ('values' in props) {
return <LegendByData {...props} />;
}
return <LegendByKey {...props} />;
};

View File

@@ -3,13 +3,13 @@ import { ChartCard } from './common/card';
export const Cpu = () => { export const Cpu = () => {
const { data: staticData } = useQuery<StaticData>({ queryKey: ['static'] }); const { data: staticData } = useQuery<StaticData>({ queryKey: ['static'] });
const { data: dynamicData } = useQuery<DynamicData>({ queryKey: ['dynamic'] }); const { data: historyData } = useQuery<HistorySlice[]>({ queryKey: ['history'] });
if (!staticData || !dynamicData) { if (!staticData || !historyData) {
return <div />; return <div />;
} }
const total_cpus = dynamicData.cpu_usage.length; const total_cpus = historyData[0].cpu.length;
return ( return (
!!total_cpus && ( !!total_cpus && (
@@ -24,7 +24,7 @@ export const Cpu = () => {
domain={[0, 100]} domain={[0, 100]}
hardDomain hardDomain
formatOptions={{ prefix: false, units: '%' }} formatOptions={{ prefix: false, units: '%' }}
data={dynamicData.cpu_usage} dataKey={'cpu'}
total={total_cpus} total={total_cpus}
/> />
) )

View File

@@ -4,22 +4,23 @@ import { useAtomValue } from 'jotai';
import { ChartCard } from './common/card'; import { ChartCard } from './common/card';
export const Disks = () => { export const Disks = () => {
const { data: dynamicData } = useQuery<DynamicData>({ queryKey: ['dynamic'] }); const { data: historyData } = useQuery<HistorySlice[]>({ queryKey: ['history'] });
const isSi = useAtomValue(siAtom); const isSi = useAtomValue(siAtom);
if (!dynamicData) { if (!historyData) {
return <div />; return <div />;
} }
return ( return (
<ChartCard <ChartCard
title='Disk activity' title='Disk activity'
// @ts-expect-error: write a better union later
legend={{ legend={{
labels: ['Read', 'Write'], labels: ['Read', 'Write'],
}} }}
hueOffset={120} hueOffset={120}
formatOptions={{ units: 'B/s', ...(isSi && { si: true }) }} formatOptions={{ units: 'B/s', ...(isSi && { si: true }) }}
data={[dynamicData.disks.read, dynamicData.disks.write]} dataKey={'disks'}
total={2} total={2}
/> />
); );

View File

@@ -7,7 +7,7 @@ import { ChartCard } from './common/card';
export const Memory = () => { export const Memory = () => {
const { data: staticData } = useQuery<StaticData>({ queryKey: ['static'] }); const { data: staticData } = useQuery<StaticData>({ queryKey: ['static'] });
const { data: dynamicData } = useQuery<DynamicData>({ queryKey: ['dynamic'] }); const { data: historyData } = useQuery<HistorySlice[]>({ queryKey: ['history'] });
const isSi = useAtomValue(siAtom); const isSi = useAtomValue(siAtom);
const formatOptions = { units: 'B', ...(isSi && { si: true }) }; const formatOptions = { units: 'B', ...(isSi && { si: true }) };
@@ -18,7 +18,9 @@ export const Memory = () => {
return [formatValue(staticData.total_memory, formatOptions), formatValue(staticData.total_swap, formatOptions)]; return [formatValue(staticData.total_memory, formatOptions), formatValue(staticData.total_swap, formatOptions)];
}, [staticData, formatOptions]); }, [staticData, formatOptions]);
if (!staticData || !dynamicData) { const last = useMemo(() => historyData?.at(-1), [historyData]);
if (!staticData || !historyData || !last) {
return <div />; return <div />;
} }
@@ -27,15 +29,15 @@ export const Memory = () => {
title='Memory' title='Memory'
legend={{ legend={{
values: [ values: [
`${formatValue(dynamicData.mem_usage, formatOptions)} / ${formatedTotals[0]}`, `${formatValue(last.mem[0], formatOptions)} / ${formatedTotals[0]}`,
`${formatValue(dynamicData.swap_usage, formatOptions)} / ${formatedTotals[1]}`, `${formatValue(last.mem[1], formatOptions)} / ${formatedTotals[1]}`,
], ],
labels: ['Memory', 'Swap'], labels: ['Memory', 'Swap'],
}} }}
domain={[0, Math.max(staticData.total_memory, staticData.total_swap)]} domain={[0, Math.max(staticData.total_memory, staticData.total_swap)]}
hardDomain hardDomain
formatOptions={formatOptions} formatOptions={formatOptions}
data={[dynamicData.mem_usage, dynamicData.swap_usage]} dataKey={'mem'}
total={2} total={2}
/> />
); );

View File

@@ -4,22 +4,23 @@ import { useAtomValue } from 'jotai';
import { ChartCard } from './common/card'; import { ChartCard } from './common/card';
export const Network = () => { export const Network = () => {
const { data: dynamicData } = useQuery<DynamicData>({ queryKey: ['dynamic'] }); const { data: historyData } = useQuery<HistorySlice[]>({ queryKey: ['history'] });
const isSi = useAtomValue(siAtom); const isSi = useAtomValue(siAtom);
if (!dynamicData) { if (!historyData) {
return <div />; return <div />;
} }
return ( return (
<ChartCard <ChartCard
title='Network' title='Network'
// @ts-expect-error: write a better union later
legend={{ legend={{
labels: ['Down', 'Up'], labels: ['Down', 'Up'],
}} }}
hueOffset={60} hueOffset={60}
formatOptions={{ units: 'B/s', ...(isSi && { si: true }) }} formatOptions={{ units: 'B/s', ...(isSi && { si: true }) }}
data={[dynamicData.network.down, dynamicData.network.up]} dataKey={'net'}
total={2} total={2}
/> />
); );

View File

@@ -1,9 +1,10 @@
import { highFpsAtom, siAtom } from '@/atoms'; import { highFpsAtom, siAtom } from '@/atoms';
import { Switch } from '@/components/switch'; import { Switch } from '@/components/switch';
import { useAnimationFrame } from '@/hooks/use-animation-frame'; import { useAnimationFrame } from '@/hooks/use-animation-frame';
import { useSetTheme } from '@/hooks/use-set-theme';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { useEffect, useRef, useState } from 'react'; import { useState } from 'react';
import './static.css'; import './static.css';
const formatUptime = (value: number) => { const formatUptime = (value: number) => {
@@ -41,14 +42,11 @@ const Uptime = ({ boot_time }: Pick<StaticData, 'boot_time'>) => {
export const Static = () => { export const Static = () => {
const { data: staticData } = useQuery<StaticData>({ queryKey: ['static'] }); const { data: staticData } = useQuery<StaticData>({ queryKey: ['static'] });
const root = useRef(document.getElementById('root')!);
const [dark, setDark] = useState(window.matchMedia('(prefers-color-scheme: dark)').matches); const [dark, setDark] = useState(window.matchMedia('(prefers-color-scheme: dark)').matches);
const [highFps, setHighFps] = useAtom(highFpsAtom); const [highFps, setHighFps] = useAtom(highFpsAtom);
const [isSi, setIsSi] = useAtom(siAtom); const [isSi, setIsSi] = useAtom(siAtom);
useEffect(() => { useSetTheme(dark);
root.current.setAttribute('data-theme', dark ? 'dark' : 'light');
}, [dark]);
return ( return (
staticData && ( staticData && (

View File

@@ -3,21 +3,22 @@ import { ChartCard } from './common/card';
export const Temps = () => { export const Temps = () => {
const { data: staticData } = useQuery<StaticData>({ queryKey: ['static'] }); const { data: staticData } = useQuery<StaticData>({ queryKey: ['static'] });
const { data: dynamicData } = useQuery<DynamicData>({ queryKey: ['dynamic'] }); const { data: historyData } = useQuery<HistorySlice[]>({ queryKey: ['history'] });
if (!staticData || !dynamicData) { if (!staticData || !historyData) {
return <div />; return <div />;
} }
return ( return (
<ChartCard <ChartCard
title='Temperatures' title='Temperatures'
// @ts-expect-error: write a better union later
legend={{ legend={{
labels: staticData.components, labels: staticData.components,
}} }}
domain={[0, 100]} domain={[0, 100]}
formatOptions={{ si: true, prefix: false, units: 'ºC' }} formatOptions={{ si: true, prefix: false, units: 'ºC' }}
data={dynamicData.temps} dataKey={'temps'}
total={staticData.components.length} total={staticData.components.length}
/> />
); );

View File

@@ -0,0 +1,7 @@
import { useEffect } from 'react';
export const useSetTheme = (dark?: boolean) => {
useEffect(() => {
document.getElementById('root')!.setAttribute('data-theme', dark ? 'dark' : 'light');
}, [dark]);
};

View File

@@ -1,10 +1,9 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv { interface ImportMetaEnv {
readonly CLIENT_GRAPH_STEPS: string; readonly SERVER_STEPS: string;
readonly CLIENT_PORT: string; readonly CLIENT_PORT: string;
readonly CLIENT_REFETCH_INTERVAL: string; readonly CLIENT_REFETCH_INTERVAL: string;
readonly SERVER_ACTIVE_WINDOW: string;
readonly SERVER_DEPLOY_URL?: string; readonly SERVER_DEPLOY_URL?: string;
readonly SERVER_PORT: string; readonly SERVER_PORT: string;
readonly SERVER_REFRESH_INTERVAL: string; readonly SERVER_REFRESH_INTERVAL: string;

View File

@@ -1,13 +1,12 @@
pub mod disks { pub mod disks {
use serde_json::json;
use std::{ use std::{
fs::File, fs::File,
io::{Read, Seek, SeekFrom}, io::{Read, Seek, SeekFrom},
}; };
pub struct DiskUsage { pub struct DiskUsage {
read: u64, pub read: u64,
write: u64, pub write: u64,
} }
impl DiskUsage { impl DiskUsage {
@@ -43,12 +42,12 @@ pub mod disks {
curr curr
} }
pub fn diff(&mut self) -> serde_json::Value { pub fn diff(&mut self) -> DiskUsage {
let curr = self.refresh(); let curr = self.refresh();
let diff = json!({ let diff = DiskUsage {
"read": curr.read - self.prev.read, read: curr.read - self.prev.read,
"write": curr.write - self.prev.write, write: curr.write - self.prev.write,
}); };
self.prev = curr; self.prev = curr;
diff diff
} }

View File

@@ -1,9 +1,9 @@
use axum::{extract::State, http::Response, response::IntoResponse, routing::get, Router, Server}; use axum::{extract::State, http::Response, response::IntoResponse, routing::get, Router, Server};
use dotenv::dotenv; use dotenv::dotenv;
use serde_json::{json, Value}; use serde_json::json;
use std::{ use std::{
sync::{Arc, Mutex}, sync::{Arc, Mutex},
time::{Duration, Instant}, time::{Duration, SystemTime, UNIX_EPOCH},
}; };
use sysinfo::{Components, CpuRefreshKind, MemoryRefreshKind, Networks, RefreshKind, System}; use sysinfo::{Components, CpuRefreshKind, MemoryRefreshKind, Networks, RefreshKind, System};
@@ -11,17 +11,25 @@ use crate::disks::disks::DiskStats;
mod disks; mod disks;
#[derive(Clone, serde::Serialize)]
struct HistorySlice {
cpu: Vec<f32>,
mem: (u64, u64),
net: (u64, u64),
disks: (u64, u64),
temps: Vec<f32>,
timestamp: u128,
}
#[derive(Clone)] #[derive(Clone)]
struct AppState { struct AppState {
latest: Arc<Mutex<Instant>>, data: Arc<Mutex<Vec<HistorySlice>>>,
data: Arc<Mutex<Value>>,
} }
impl Default for AppState { impl Default for AppState {
fn default() -> Self { fn default() -> Self {
AppState { AppState {
latest: Arc::new(Mutex::new(Instant::now())), data: Arc::new(Mutex::new(Vec::new())),
data: Arc::new(Mutex::new(json!({}))),
} }
} }
} }
@@ -29,22 +37,15 @@ impl Default for AppState {
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
dotenv().ok(); dotenv().ok();
let refresh_interval = Duration::from_millis( let refresh_interval = std::env::var("SERVER_REFRESH_INTERVAL").unwrap().parse::<u64>().unwrap();
std::env::var("SERVER_REFRESH_INTERVAL") let steps = std::env::var("SERVER_STEPS").unwrap().parse::<u64>().unwrap();
.unwrap()
.parse::<u64>()
.unwrap(),
);
let active_window = std::env::var("SERVER_ACTIVE_WINDOW")
.unwrap()
.parse::<u128>()
.unwrap();
let port = std::env::var("SERVER_PORT").unwrap(); let port = std::env::var("SERVER_PORT").unwrap();
let app_state = AppState::default(); let app_state = AppState::default();
let router = Router::new() let router = Router::new()
.route("/api/dynamic", get(dynamic_sysinfo_get))
.route("/api/static", get(static_sysinfo_get)) .route("/api/static", get(static_sysinfo_get))
.route("/api/history", get(history_sysinfo_get))
.route("/api/dynamic", get(dynamic_sysinfo_get))
.with_state(app_state.clone()); .with_state(app_state.clone());
// Update system usage in the background // Update system usage in the background
@@ -55,10 +56,6 @@ async fn main() {
let mut disk_stats = DiskStats::new(); let mut disk_stats = DiskStats::new();
loop { loop {
let now = Instant::now();
let latest = app_state.latest.lock().unwrap().clone();
if (now - latest).as_millis() < active_window {
sys.refresh_cpu(); sys.refresh_cpu();
sys.refresh_memory(); sys.refresh_memory();
components.refresh(); components.refresh();
@@ -80,22 +77,38 @@ async fn main() {
}); });
{ {
let disks_diff = disk_stats.diff();
let mut data = app_state.data.lock().unwrap(); let mut data = app_state.data.lock().unwrap();
*data = json!({
"cpu_usage": cpu_usage, // Get the current time
"mem_usage": mem_usage, let current_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis();
"swap_usage": swap_usage,
"temps": temps, // Find the index of the first element within the last 60 seconds
"network": { let first_index_to_keep = match data.iter().position(|item| {
"down": net_down, item.timestamp >= current_time - (steps * refresh_interval) as u128
"up": net_up }) {
}, Some(index) => index,
"disks": disk_stats.diff() None => 0,
}); };
}
// Remove elements before the first index to keep
data.drain(..first_index_to_keep);
let slice = HistorySlice {
cpu: cpu_usage,
mem: (mem_usage, swap_usage),
net: (net_down, net_up),
disks: (disks_diff.read, disks_diff.write),
temps,
timestamp: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis(),
};
(*data).push(slice);
} }
std::thread::sleep(refresh_interval); std::thread::sleep(Duration::from_millis(refresh_interval));
} }
}); });
@@ -111,16 +124,22 @@ async fn main() {
async fn dynamic_sysinfo_get(State(state): State<AppState>) -> impl IntoResponse { async fn dynamic_sysinfo_get(State(state): State<AppState>) -> impl IntoResponse {
let data = state.data.lock().unwrap().clone(); let data = state.data.lock().unwrap().clone();
{ Response::builder()
let mut latest = state.latest.lock().unwrap(); .header(axum::http::header::ORIGIN, "*")
*latest = Instant::now() .header(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
} .header("content-type", "application/json")
.body(serde_json::to_string(&(data.last())).unwrap())
.unwrap()
}
#[axum::debug_handler]
async fn history_sysinfo_get(State(state): State<AppState>) -> impl IntoResponse {
let data = state.data.lock().unwrap().clone();
Response::builder() Response::builder()
.header(axum::http::header::ORIGIN, "*") .header(axum::http::header::ORIGIN, "*")
.header(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, "*") .header(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
.header("content-type", "application/json") .header("content-type", "application/json")
.body(data.to_string()) .body(serde_json::to_string(&data).unwrap())
.unwrap() .unwrap()
} }