Move history from query data to ref and avoid rerenders

This commit is contained in:
2024-05-08 19:45:36 +01:00
parent 9f14c53762
commit 66bef70cb8
12 changed files with 122 additions and 105 deletions

View File

@@ -1,6 +1,9 @@
import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from '@tanstack/react-query';
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
import { useSetAtom } from 'jotai';
import { useEffect, useRef } from 'react';
import './App.css';
import { fetchDynamicData, fetchHistoryData, fetchStaticData } from './api';
import { historyAtom } from './atoms';
import { Cpu } from './components/chart-cards/cpu';
import { Disks } from './components/chart-cards/disks';
import { Memory } from './components/chart-cards/memory';
@@ -16,7 +19,8 @@ const refetchInterval = Number(import.meta.env.CLIENT_REFETCH_INTERVAL);
const queryClient = new QueryClient();
const Main = () => {
const queryClient = useQueryClient();
const history = useRef<HistoryNormalized>(null!);
const setHistoryRef = useSetAtom(historyAtom);
const staticQuery = useQuery({
queryKey: ['static'],
queryFn: fetchStaticData,
@@ -26,28 +30,50 @@ const Main = () => {
const historyQuery = useQuery({
queryKey: ['history'],
queryFn: fetchHistoryData,
queryFn: async () => {
const data = await fetchHistoryData();
const maxes = {} as HistoryNormalized['maxes'];
for (const key of ['net', 'disks', 'temps'] as const) {
maxes[key] = data.reduce((max, slice) => Math.max(max, ...slice[key]), 0);
}
history.current = {
data,
maxes,
};
return data;
},
});
const dynamicQuery = useQuery({
queryKey: ['dynamic'],
queryFn: async () => {
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();
if (history.current) {
while (
history.current.data.length &&
history.current.data[0].timestamp < Date.now() - (serverSteps + 3) * serverRefreshInterval
) {
history.current.data.shift();
}
history.push(data);
return history;
});
history.current.data.push(data);
for (const key of ['net', 'disks', 'temps'] as const) {
history.current.maxes[key] = history.current.data.reduce((max, slice) => Math.max(max, ...slice[key]), 0);
}
}
return data;
},
refetchInterval: refetchInterval,
retry: true,
});
useSetTheme(window.matchMedia('(prefers-color-scheme: dark)').matches);
useEffect(() => setHistoryRef(history), [setHistoryRef]);
const isLoading = staticQuery.isLoading || dynamicQuery.isLoading || historyQuery.isLoading;

View File

@@ -5,6 +5,7 @@ type StaticData = {
brand: string;
name: string;
vendor_id: string;
threads: number;
};
host_name: string;
kernel_version: string;
@@ -23,3 +24,8 @@ type HistorySlice = {
temps: number[];
timestamp: number;
};
type HistoryNormalized = {
data: HistorySlice[];
maxes: Record<'net' | 'disks' | 'temps', number>;
};

View File

@@ -1,4 +1,6 @@
import { atom } from 'jotai';
import type { MutableRefObject } from 'react';
export const highFpsAtom = atom(true);
export const siAtom = atom(false);
export const historyAtom = atom<MutableRefObject<HistoryNormalized>>({ current: null! });

View File

@@ -1,4 +1,4 @@
import { Legend, type LegendProps } from '@/components/chart-cards/common/legend';
import { Legend, type LegendByDataProps, type LegendByKeyProps } from '@/components/chart-cards/common/legend';
import type { FormatOptions } from '@/utils/format';
import type { ReactNode } from 'react';
import { CanvasChart } from './chart';
@@ -6,7 +6,7 @@ import { CanvasChart } from './chart';
type Props = {
title: ReactNode;
subtitle?: ReactNode;
legend?: LegendProps;
legend?: Omit<LegendByKeyProps, 'dataKey'> | LegendByDataProps;
formatOptions?: FormatOptions;
domain?: [number, number];
hardDomain?: boolean;
@@ -22,7 +22,8 @@ export const ChartCard = ({ dataKey, legend, hueOffset = 0, title, subtitle, for
{subtitle}
{legend && (
{legend && (dataKey || 'values' in legend) && (
// @ts-expect-error: not inferable, but safe
<Legend
hueOffset={hueOffset}
formatOptions={formatOptions}

View File

@@ -1,8 +1,9 @@
import { historyAtom } from '@/atoms';
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 { useAtomValue } from 'jotai';
import { useEffect, useRef, useState } from 'react';
import './index.css';
import { YAxis } from './y-axis';
@@ -24,21 +25,9 @@ const xFromTimestamp = (timestamp: number, width: number) =>
export const CanvasChart = ({ total, hueOffset = 0, domain, hardDomain, dataKey, formatOptions }: Props) => {
const canvasRef = useRef<HTMLCanvasElement>(null!);
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, historyData, dataKey]);
const max = useRef(targetMax);
const history = useAtomValue(historyAtom);
const [lockedTargetMax, setLockedTargetMax] = useState(domain?.[1] ?? 0);
const max = useRef(lockedTargetMax);
const [width, setWidth] = useState(640);
const [height, setHeight] = useState(480);
@@ -61,10 +50,28 @@ export const CanvasChart = ({ total, hueOffset = 0, domain, hardDomain, dataKey,
// Redraw chart
useAnimationFrame(() => {
const ctx = canvasRef.current.getContext('2d');
if (!ctx || !historyData) {
if (!ctx || !history.current) {
return;
}
const targetMax = (() => {
if (domain && hardDomain) {
return domain[1];
}
const historyMax = history.current.maxes[dataKey as keyof typeof history.current.maxes] ?? 0;
if (!domain || historyMax > domain[1]) {
return historyMax;
}
return domain[1];
})();
if (lockedTargetMax !== targetMax) {
setLockedTargetMax(targetMax);
}
if (!hardDomain) {
max.current = (4 * max.current + targetMax) / 5;
}
@@ -96,16 +103,16 @@ export const CanvasChart = ({ total, hueOffset = 0, domain, hardDomain, dataKey,
ctx.beginPath();
ctx.moveTo(-xMargin, height);
ctx.lineTo(-xMargin, height - (height * (historyData[0][dataKey][i] ?? 0)) / max.current);
ctx.lineTo(-xMargin, height - (height * (history.current.data[0][dataKey][i] ?? 0)) / max.current);
for (const { timestamp, [dataKey]: values } of historyData) {
for (const { timestamp, [dataKey]: values } of history.current.data) {
const x = xFromTimestamp(timestamp, width);
const y = height - (height * (values[i] ?? 0)) / max.current;
ctx.lineTo(x, y);
}
ctx.lineTo(width + xMargin, height - (height * (historyData[0][dataKey][i] ?? 0)) / max.current);
ctx.lineTo(width + xMargin, height - (height * (history.current.data[0][dataKey][i] ?? 0)) / max.current);
ctx.lineTo(width + xMargin, height);
if (type === 'fill') ctx.fill();
@@ -121,7 +128,7 @@ export const CanvasChart = ({ total, hueOffset = 0, domain, hardDomain, dataKey,
return (
<div className='chart'>
<YAxis max={targetMax} formatOptions={formatOptions} />
<YAxis max={lockedTargetMax} formatOptions={formatOptions} />
<div className='canvas-wrapper'>
<canvas ref={canvasRef} width={width} height={height} />

View File

@@ -1,25 +1,24 @@
import { getTextColor } from '@/utils/colors';
import { type FormatOptions, formatValue } from '@/utils/format';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { memo, useMemo } from 'react';
import './index.css';
type LegendCommonProps = {
export type LegendCommonProps = {
labels: string[];
formatOptions?: FormatOptions;
hueOffset?: number;
};
type LegendByKeyProps = LegendCommonProps & {
export type LegendByKeyProps = LegendCommonProps & {
dataKey: Exclude<keyof HistorySlice, 'timestamp'>;
totals?: number[];
};
type LegendByDataProps = LegendCommonProps & {
export 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) => (
@@ -36,15 +35,26 @@ export const LegendByData = ({ labels, formatOptions, hueOffset = 0, values }: L
</div>
);
export const LegendByKey = ({ dataKey, ...rest }: LegendByKeyProps) => {
const { data: historyData } = useQuery<HistorySlice[]>({ queryKey: ['history'] });
const values = useMemo(() => historyData?.at(-1)?.[dataKey], [historyData, dataKey]);
export const LegendByKey = memo(({ dataKey, totals, formatOptions, ...rest }: LegendByKeyProps) => {
const { data: dynamicData } = useQuery<HistorySlice>({ queryKey: ['dynamic'] });
const values = useMemo(
() =>
dynamicData?.[dataKey]?.map((value, index) => {
if (totals) {
return `${formatValue(value, formatOptions)} / ${formatValue(totals[index], formatOptions)}`;
}
return formatValue(value, formatOptions);
}),
[dynamicData, dataKey, formatOptions, totals],
);
if (values) {
return <LegendByData {...rest} values={values} />;
}
};
});
export const Legend = (props: LegendProps) => {
export const Legend = (props: LegendByDataProps | LegendByKeyProps) => {
if ('values' in props) {
return <LegendByData {...props} />;
}

View File

@@ -3,30 +3,25 @@ import { ChartCard } from './common/card';
export const Cpu = () => {
const { data: staticData } = useQuery<StaticData>({ queryKey: ['static'] });
const { data: historyData } = useQuery<HistorySlice[]>({ queryKey: ['history'] });
if (!staticData || !historyData) {
if (!staticData) {
return <div />;
}
const total_cpus = historyData[0].cpu.length;
return (
!!total_cpus && (
<ChartCard
title='CPU'
subtitle={
<h3>
{staticData.cpu.brand}
<small>{` (${total_cpus} threads)`}</small>
</h3>
}
domain={[0, 100]}
hardDomain
formatOptions={{ prefix: false, units: '%' }}
dataKey={'cpu'}
total={total_cpus}
/>
)
<ChartCard
title='CPU'
subtitle={
<h3>
{staticData.cpu.brand}
<small>{` (${staticData.cpu.threads} threads)`}</small>
</h3>
}
domain={[0, 100]}
hardDomain
formatOptions={{ prefix: false, units: '%' }}
dataKey={'cpu'}
total={staticData.cpu.threads}
/>
);
};

View File

@@ -1,20 +1,13 @@
import { siAtom } from '@/atoms';
import { useQuery } from '@tanstack/react-query';
import { useAtomValue } from 'jotai';
import { ChartCard } from './common/card';
export const Disks = () => {
const { data: historyData } = useQuery<HistorySlice[]>({ queryKey: ['history'] });
const isSi = useAtomValue(siAtom);
if (!historyData) {
return <div />;
}
return (
<ChartCard
title='Disk activity'
// @ts-expect-error: write a better union later
legend={{
labels: ['Read', 'Write'],
}}

View File

@@ -1,26 +1,13 @@
import { siAtom } from '@/atoms';
import { formatValue } from '@/utils/format';
import { useQuery } from '@tanstack/react-query';
import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
import { ChartCard } from './common/card';
export const Memory = () => {
const { data: staticData } = useQuery<StaticData>({ queryKey: ['static'] });
const { data: historyData } = useQuery<HistorySlice[]>({ queryKey: ['history'] });
const isSi = useAtomValue(siAtom);
const formatOptions = { units: 'B', ...(isSi && { si: true }) };
const formatedTotals = useMemo(() => {
if (!staticData) {
return [];
}
return [formatValue(staticData.total_memory, formatOptions), formatValue(staticData.total_swap, formatOptions)];
}, [staticData, formatOptions]);
const last = useMemo(() => historyData?.at(-1), [historyData]);
if (!staticData || !historyData || !last) {
if (!staticData) {
return <div />;
}
@@ -28,15 +15,12 @@ export const Memory = () => {
<ChartCard
title='Memory'
legend={{
values: [
`${formatValue(last.mem[0], formatOptions)} / ${formatedTotals[0]}`,
`${formatValue(last.mem[1], formatOptions)} / ${formatedTotals[1]}`,
],
totals: [staticData.total_memory, staticData.total_swap],
labels: ['Memory', 'Swap'],
}}
domain={[0, Math.max(staticData.total_memory, staticData.total_swap)]}
hardDomain
formatOptions={formatOptions}
formatOptions={{ units: 'B', ...(isSi && { si: true }) }}
dataKey={'mem'}
total={2}
/>

View File

@@ -1,20 +1,13 @@
import { siAtom } from '@/atoms';
import { useQuery } from '@tanstack/react-query';
import { useAtomValue } from 'jotai';
import { ChartCard } from './common/card';
export const Network = () => {
const { data: historyData } = useQuery<HistorySlice[]>({ queryKey: ['history'] });
const isSi = useAtomValue(siAtom);
if (!historyData) {
return <div />;
}
return (
<ChartCard
title='Network'
// @ts-expect-error: write a better union later
legend={{
labels: ['Down', 'Up'],
}}

View File

@@ -3,16 +3,14 @@ import { ChartCard } from './common/card';
export const Temps = () => {
const { data: staticData } = useQuery<StaticData>({ queryKey: ['static'] });
const { data: historyData } = useQuery<HistorySlice[]>({ queryKey: ['history'] });
if (!staticData || !historyData) {
if (!staticData) {
return <div />;
}
return (
<ChartCard
title='Temperatures'
// @ts-expect-error: write a better union later
legend={{
labels: staticData.components,
}}

View File

@@ -151,6 +151,7 @@ async fn static_sysinfo_get() -> impl IntoResponse {
);
let components = Components::new_with_refreshed_list();
let cpus = sys.cpus();
Response::builder()
.header(axum::http::header::ORIGIN, "*")
@@ -167,9 +168,10 @@ async fn static_sysinfo_get() -> impl IntoResponse {
"total_memory": sys.total_memory(),
"total_swap": sys.total_swap(),
"cpu": {
"name": sys.cpus()[0].name(),
"vendor_id": sys.cpus()[0].vendor_id(),
"brand": sys.cpus()[0].brand(),
"name": cpus[0].name(),
"vendor_id": cpus[0].vendor_id(),
"brand": cpus[0].brand(),
"threads": cpus.len(),
},
"components": components
.iter()