Persist history on server
This commit is contained in:
3
.env
3
.env
@@ -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
9
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
7
src/client/hooks/use-set-theme.ts
Normal file
7
src/client/hooks/use-set-theme.ts
Normal 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]);
|
||||||
|
};
|
||||||
3
src/client/vite-env.d.ts
vendored
3
src/client/vite-env.d.ts
vendored
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,47 +56,59 @@ async fn main() {
|
|||||||
let mut disk_stats = DiskStats::new();
|
let mut disk_stats = DiskStats::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let now = Instant::now();
|
sys.refresh_cpu();
|
||||||
let latest = app_state.latest.lock().unwrap().clone();
|
sys.refresh_memory();
|
||||||
|
components.refresh();
|
||||||
|
networks.refresh();
|
||||||
|
disk_stats.refresh();
|
||||||
|
|
||||||
if (now - latest).as_millis() < active_window {
|
let cpu_usage: Vec<_> = sys.cpus().iter().map(|cpu| cpu.cpu_usage()).collect();
|
||||||
sys.refresh_cpu();
|
let mem_usage = sys.used_memory();
|
||||||
sys.refresh_memory();
|
let swap_usage = sys.used_swap();
|
||||||
components.refresh();
|
let temps: Vec<_> = components
|
||||||
networks.refresh();
|
.iter()
|
||||||
disk_stats.refresh();
|
.map(|component| component.temperature())
|
||||||
|
.collect();
|
||||||
let cpu_usage: Vec<_> = sys.cpus().iter().map(|cpu| cpu.cpu_usage()).collect();
|
let (net_down, net_up) =
|
||||||
let mem_usage = sys.used_memory();
|
networks
|
||||||
let swap_usage = sys.used_swap();
|
|
||||||
let temps: Vec<_> = components
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|component| component.temperature())
|
.fold((0, 0), |(down, up), (_name, network)| {
|
||||||
.collect();
|
(down + network.received(), up + network.transmitted())
|
||||||
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()
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
{
|
||||||
|
let disks_diff = disk_stats.diff();
|
||||||
|
let mut data = app_state.data.lock().unwrap();
|
||||||
|
|
||||||
|
// Get the current time
|
||||||
|
let current_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis();
|
||||||
|
|
||||||
|
// Find the index of the first element within the last 60 seconds
|
||||||
|
let first_index_to_keep = match data.iter().position(|item| {
|
||||||
|
item.timestamp >= current_time - (steps * refresh_interval) as u128
|
||||||
|
}) {
|
||||||
|
Some(index) => index,
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user