diff --git a/src/client/App.tsx b/src/client/App.tsx index 3ba1aaa..da0c404 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -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(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; diff --git a/src/client/api/types.ts b/src/client/api/types.ts index 697d731..66cabad 100644 --- a/src/client/api/types.ts +++ b/src/client/api/types.ts @@ -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>; +}; diff --git a/src/client/atoms/index.ts b/src/client/atoms/index.ts index f2686c7..5bd482c 100644 --- a/src/client/atoms/index.ts +++ b/src/client/atoms/index.ts @@ -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>({ current: null! }); diff --git a/src/client/components/chart-cards/common/card.tsx b/src/client/components/chart-cards/common/card.tsx index 8ae4410..8372f78 100644 --- a/src/client/components/chart-cards/common/card.tsx +++ b/src/client/components/chart-cards/common/card.tsx @@ -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 | 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 export const CanvasChart = ({ total, hueOffset = 0, domain, hardDomain, dataKey, formatOptions }: Props) => { const canvasRef = useRef(null!); - const { data: historyData } = useQuery({ 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 (
- +
diff --git a/src/client/components/chart-cards/common/legend/index.tsx b/src/client/components/chart-cards/common/legend/index.tsx index 3b6766e..0114718 100644 --- a/src/client/components/chart-cards/common/legend/index.tsx +++ b/src/client/components/chart-cards/common/legend/index.tsx @@ -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; + 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) => (
{labels.map((label, index) => ( @@ -36,15 +35,26 @@ export const LegendByData = ({ labels, formatOptions, hueOffset = 0, values }: L
); -export const LegendByKey = ({ dataKey, ...rest }: LegendByKeyProps) => { - const { data: historyData } = useQuery({ queryKey: ['history'] }); - const values = useMemo(() => historyData?.at(-1)?.[dataKey], [historyData, dataKey]); +export const LegendByKey = memo(({ dataKey, totals, formatOptions, ...rest }: LegendByKeyProps) => { + const { data: dynamicData } = useQuery({ 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 ; } -}; +}); -export const Legend = (props: LegendProps) => { +export const Legend = (props: LegendByDataProps | LegendByKeyProps) => { if ('values' in props) { return ; } diff --git a/src/client/components/chart-cards/cpu.tsx b/src/client/components/chart-cards/cpu.tsx index 582ec29..23741f7 100644 --- a/src/client/components/chart-cards/cpu.tsx +++ b/src/client/components/chart-cards/cpu.tsx @@ -3,30 +3,25 @@ import { ChartCard } from './common/card'; export const Cpu = () => { const { data: staticData } = useQuery({ queryKey: ['static'] }); - const { data: historyData } = useQuery({ queryKey: ['history'] }); - if (!staticData || !historyData) { + if (!staticData) { return
; } - const total_cpus = historyData[0].cpu.length; - return ( - !!total_cpus && ( - - {staticData.cpu.brand} - {` (${total_cpus} threads)`} - - } - domain={[0, 100]} - hardDomain - formatOptions={{ prefix: false, units: '%' }} - dataKey={'cpu'} - total={total_cpus} - /> - ) + + {staticData.cpu.brand} + {` (${staticData.cpu.threads} threads)`} + + } + domain={[0, 100]} + hardDomain + formatOptions={{ prefix: false, units: '%' }} + dataKey={'cpu'} + total={staticData.cpu.threads} + /> ); }; diff --git a/src/client/components/chart-cards/disks.tsx b/src/client/components/chart-cards/disks.tsx index 1235a45..8902c05 100644 --- a/src/client/components/chart-cards/disks.tsx +++ b/src/client/components/chart-cards/disks.tsx @@ -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({ queryKey: ['history'] }); const isSi = useAtomValue(siAtom); - if (!historyData) { - return
; - } - return ( { const { data: staticData } = useQuery({ queryKey: ['static'] }); - const { data: historyData } = useQuery({ 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
; } @@ -28,15 +15,12 @@ export const Memory = () => { diff --git a/src/client/components/chart-cards/network.tsx b/src/client/components/chart-cards/network.tsx index 7c86102..3715059 100644 --- a/src/client/components/chart-cards/network.tsx +++ b/src/client/components/chart-cards/network.tsx @@ -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({ queryKey: ['history'] }); const isSi = useAtomValue(siAtom); - if (!historyData) { - return
; - } - return ( { const { data: staticData } = useQuery({ queryKey: ['static'] }); - const { data: historyData } = useQuery({ queryKey: ['history'] }); - if (!staticData || !historyData) { + if (!staticData) { return
; } return ( 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()