Initial implementation
This commit is contained in:
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>
|
||||
);
|
||||
Reference in New Issue
Block a user