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

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>
);