Move history from query data to ref and avoid rerenders
This commit is contained in:
@@ -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.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);
|
||||
}
|
||||
}
|
||||
|
||||
history.push(data);
|
||||
return history;
|
||||
});
|
||||
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;
|
||||
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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! });
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<small>{` (${staticData.cpu.threads} threads)`}</small>
|
||||
</h3>
|
||||
}
|
||||
domain={[0, 100]}
|
||||
hardDomain
|
||||
formatOptions={{ prefix: false, units: '%' }}
|
||||
dataKey={'cpu'}
|
||||
total={total_cpus}
|
||||
total={staticData.cpu.threads}
|
||||
/>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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'],
|
||||
}}
|
||||
|
||||
@@ -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) {
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -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'],
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user