Persist history on server
This commit is contained in:
@@ -6,25 +6,32 @@ import { CanvasChart } from './chart';
|
||||
type Props = {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
legend?: Omit<LegendProps, 'values'> & Partial<Pick<LegendProps, 'values'>>;
|
||||
legend?: LegendProps;
|
||||
formatOptions?: FormatOptions;
|
||||
domain?: [number, number];
|
||||
hardDomain?: boolean;
|
||||
hueOffset?: number;
|
||||
data: number[];
|
||||
dataKey: Exclude<keyof HistorySlice, 'timestamp'>;
|
||||
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 (
|
||||
<div className='chart-card'>
|
||||
<h2>{title}</h2>
|
||||
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useAnimationFrame } from '@/hooks/use-animation-frame';
|
||||
import { getFillColor, getStrokeColor } from '@/utils/colors';
|
||||
import type { FormatOptions } from '@/utils/format';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import './index.css';
|
||||
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 xMargin = 4;
|
||||
|
||||
type Props = {
|
||||
total: number;
|
||||
data: number[];
|
||||
dataKey: Exclude<keyof HistorySlice, 'timestamp'>;
|
||||
domain?: [number, number];
|
||||
hardDomain?: boolean;
|
||||
formatOptions?: FormatOptions;
|
||||
@@ -21,39 +22,27 @@ type Props = {
|
||||
const xFromTimestamp = (timestamp: number, width: number) =>
|
||||
((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 now = useMemo(() => Date.now(), []);
|
||||
const history = useRef<[number, number[]][]>([[now, []]]);
|
||||
const [historyMax, setHistoryMax] = useState(0);
|
||||
const { data: historyData } = useQuery<HistorySlice[]>({ queryKey: ['history'] });
|
||||
const targetMax = useMemo(() => {
|
||||
if (domain && hardDomain) {
|
||||
return domain[1];
|
||||
}
|
||||
|
||||
const historyMax = (historyData ?? []).reduce((max, slice) => Math.max(max, ...slice[dataKey]), 0);
|
||||
|
||||
if (!domain || historyMax > domain[1]) {
|
||||
return historyMax;
|
||||
}
|
||||
|
||||
return domain[1];
|
||||
}, [domain, hardDomain, historyMax]);
|
||||
}, [domain, hardDomain, historyData, dataKey]);
|
||||
const max = useRef(targetMax);
|
||||
|
||||
const [width, setWidth] = useState(640);
|
||||
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
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
@@ -72,7 +61,7 @@ export const CanvasChart = ({ total, hueOffset = 0, domain, hardDomain, data, fo
|
||||
// Redraw chart
|
||||
useAnimationFrame(() => {
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
if (!ctx) {
|
||||
if (!ctx || !historyData) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -107,16 +96,16 @@ export const CanvasChart = ({ total, hueOffset = 0, domain, hardDomain, data, fo
|
||||
|
||||
ctx.beginPath();
|
||||
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 y = height - (height * (values[i] ?? 0)) / max.current;
|
||||
|
||||
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);
|
||||
|
||||
if (type === 'fill') ctx.fill();
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import { getTextColor } from '@/utils/colors';
|
||||
import { type FormatOptions, formatValue } from '@/utils/format';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import './index.css';
|
||||
|
||||
export type LegendProps = {
|
||||
type LegendCommonProps = {
|
||||
labels: string[];
|
||||
values: string[] | number[];
|
||||
formatOptions?: FormatOptions;
|
||||
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'>
|
||||
{labels.map((label, index) => (
|
||||
<div key={labels[index]}>
|
||||
@@ -24,3 +35,19 @@ export const Legend = ({ labels, values, formatOptions, hueOffset = 0 }: LegendP
|
||||
))}
|
||||
</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 = () => {
|
||||
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 />;
|
||||
}
|
||||
|
||||
const total_cpus = dynamicData.cpu_usage.length;
|
||||
const total_cpus = historyData[0].cpu.length;
|
||||
|
||||
return (
|
||||
!!total_cpus && (
|
||||
@@ -24,7 +24,7 @@ export const Cpu = () => {
|
||||
domain={[0, 100]}
|
||||
hardDomain
|
||||
formatOptions={{ prefix: false, units: '%' }}
|
||||
data={dynamicData.cpu_usage}
|
||||
dataKey={'cpu'}
|
||||
total={total_cpus}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -4,22 +4,23 @@ import { useAtomValue } from 'jotai';
|
||||
import { ChartCard } from './common/card';
|
||||
|
||||
export const Disks = () => {
|
||||
const { data: dynamicData } = useQuery<DynamicData>({ queryKey: ['dynamic'] });
|
||||
const { data: historyData } = useQuery<HistorySlice[]>({ queryKey: ['history'] });
|
||||
const isSi = useAtomValue(siAtom);
|
||||
|
||||
if (!dynamicData) {
|
||||
if (!historyData) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title='Disk activity'
|
||||
// @ts-expect-error: write a better union later
|
||||
legend={{
|
||||
labels: ['Read', 'Write'],
|
||||
}}
|
||||
hueOffset={120}
|
||||
formatOptions={{ units: 'B/s', ...(isSi && { si: true }) }}
|
||||
data={[dynamicData.disks.read, dynamicData.disks.write]}
|
||||
dataKey={'disks'}
|
||||
total={2}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ChartCard } from './common/card';
|
||||
|
||||
export const Memory = () => {
|
||||
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 formatOptions = { units: 'B', ...(isSi && { si: true }) };
|
||||
|
||||
@@ -18,7 +18,9 @@ export const Memory = () => {
|
||||
return [formatValue(staticData.total_memory, formatOptions), formatValue(staticData.total_swap, formatOptions)];
|
||||
}, [staticData, formatOptions]);
|
||||
|
||||
if (!staticData || !dynamicData) {
|
||||
const last = useMemo(() => historyData?.at(-1), [historyData]);
|
||||
|
||||
if (!staticData || !historyData || !last) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
@@ -27,15 +29,15 @@ export const Memory = () => {
|
||||
title='Memory'
|
||||
legend={{
|
||||
values: [
|
||||
`${formatValue(dynamicData.mem_usage, formatOptions)} / ${formatedTotals[0]}`,
|
||||
`${formatValue(dynamicData.swap_usage, formatOptions)} / ${formatedTotals[1]}`,
|
||||
`${formatValue(last.mem[0], formatOptions)} / ${formatedTotals[0]}`,
|
||||
`${formatValue(last.mem[1], formatOptions)} / ${formatedTotals[1]}`,
|
||||
],
|
||||
labels: ['Memory', 'Swap'],
|
||||
}}
|
||||
domain={[0, Math.max(staticData.total_memory, staticData.total_swap)]}
|
||||
hardDomain
|
||||
formatOptions={formatOptions}
|
||||
data={[dynamicData.mem_usage, dynamicData.swap_usage]}
|
||||
dataKey={'mem'}
|
||||
total={2}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -4,22 +4,23 @@ import { useAtomValue } from 'jotai';
|
||||
import { ChartCard } from './common/card';
|
||||
|
||||
export const Network = () => {
|
||||
const { data: dynamicData } = useQuery<DynamicData>({ queryKey: ['dynamic'] });
|
||||
const { data: historyData } = useQuery<HistorySlice[]>({ queryKey: ['history'] });
|
||||
const isSi = useAtomValue(siAtom);
|
||||
|
||||
if (!dynamicData) {
|
||||
if (!historyData) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title='Network'
|
||||
// @ts-expect-error: write a better union later
|
||||
legend={{
|
||||
labels: ['Down', 'Up'],
|
||||
}}
|
||||
hueOffset={60}
|
||||
formatOptions={{ units: 'B/s', ...(isSi && { si: true }) }}
|
||||
data={[dynamicData.network.down, dynamicData.network.up]}
|
||||
dataKey={'net'}
|
||||
total={2}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { highFpsAtom, siAtom } from '@/atoms';
|
||||
import { Switch } from '@/components/switch';
|
||||
import { useAnimationFrame } from '@/hooks/use-animation-frame';
|
||||
import { useSetTheme } from '@/hooks/use-set-theme';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import './static.css';
|
||||
|
||||
const formatUptime = (value: number) => {
|
||||
@@ -41,14 +42,11 @@ const Uptime = ({ boot_time }: Pick<StaticData, 'boot_time'>) => {
|
||||
|
||||
export const 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 [highFps, setHighFps] = useAtom(highFpsAtom);
|
||||
const [isSi, setIsSi] = useAtom(siAtom);
|
||||
|
||||
useEffect(() => {
|
||||
root.current.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
}, [dark]);
|
||||
useSetTheme(dark);
|
||||
|
||||
return (
|
||||
staticData && (
|
||||
|
||||
@@ -3,21 +3,22 @@ import { ChartCard } from './common/card';
|
||||
|
||||
export const Temps = () => {
|
||||
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 (
|
||||
<ChartCard
|
||||
title='Temperatures'
|
||||
// @ts-expect-error: write a better union later
|
||||
legend={{
|
||||
labels: staticData.components,
|
||||
}}
|
||||
domain={[0, 100]}
|
||||
formatOptions={{ si: true, prefix: false, units: 'ºC' }}
|
||||
data={dynamicData.temps}
|
||||
dataKey={'temps'}
|
||||
total={staticData.components.length}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user