Initial implementation

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

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

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