diff --git a/bun.lockb b/bun.lockb index 3f166eb..60009f1 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index bfe7eb4..43213d1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sysmon-web", "private": true, - "version": "0.0.0", + "version": "0.1.0", "type": "module", "scripts": { "dev": "vite --port $CLIENT_PORT --host & cargo run", @@ -11,8 +11,7 @@ "dependencies": { "@tanstack/react-query": "^5.32.0", "react": "^18.2.0", - "react-dom": "^18.2.0", - "recharts": "^2.12.6" + "react-dom": "^18.2.0" }, "devDependencies": { "@biomejs/biome": "1.7.1", diff --git a/src/client/components/chart-cards/common/card.tsx b/src/client/components/chart-cards/common/card.tsx new file mode 100644 index 0000000..bb649b0 --- /dev/null +++ b/src/client/components/chart-cards/common/card.tsx @@ -0,0 +1,29 @@ +import { Legend, type LegendProps } from '@/components/chart-cards/common/legend'; +import type { FormatOptions } from '@/utils/format'; +import type { ReactNode } from 'react'; +import { CanvasChart } from './chart'; + +type Props = { + title: ReactNode; + subtitle?: ReactNode; + legend?: Omit & Partial>; + formatOptions?: FormatOptions; + domain?: [number, number]; + hueOffset?: number; + data: number[]; + total: number; +}; + +export const ChartCard = ({ data, domain, legend, hueOffset = 0, title, subtitle, formatOptions, total }: Props) => { + return ( +
+

{title}

+ + {subtitle} + + {legend && } + + +
+ ); +}; diff --git a/src/client/components/chart-cards/common/chart.css b/src/client/components/chart-cards/common/chart.css new file mode 100644 index 0000000..0d8fa30 --- /dev/null +++ b/src/client/components/chart-cards/common/chart.css @@ -0,0 +1,40 @@ +.chart { + color: var(--color-neutral2); + display: grid; + font-size: 16px; + gap: 4px; + grid-template-columns: max-content 1fr; + height: 100%; + line-height: 1; + overflow: hidden; +} + +.chart > .y-axis { + display: flex; + flex-direction: column; + height: 100%; + justify-content: space-between; + + > div { + text-align: right; + } +} + +.chart .cartesian-grid { + height: calc(100% - 16px); + position: absolute; + top: 8px; + width: 100%; +} + +.chart .canvas-wrapper { + position: relative; + + > canvas { + height: calc(100% - 16px); + left: 8px; + position: absolute; + top: 8px; + width: calc(100% - 8px); + } +} \ No newline at end of file diff --git a/src/client/components/chart-cards/common/chart.tsx b/src/client/components/chart-cards/common/chart.tsx new file mode 100644 index 0000000..633d680 --- /dev/null +++ b/src/client/components/chart-cards/common/chart.tsx @@ -0,0 +1,143 @@ +import { useAnimationFrame } from '@/hooks/use-animation-frame'; +import { getFillColor, getStrokeColor } from '@/utils/colors'; +import { type FormatOptions, formatValue } from '@/utils/format'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import './chart.css'; + +const stepWindow = Number(import.meta.env.CLIENT_GRAPH_STEPS); +const stepPeriod = Number(import.meta.env.CLIENT_REFETCH_INTERVAL); +const width = 640; +const height = 480; +const xMargin = 4; +const fps = 30; +const framePeriod = 1000 / fps; + +type Props = { + total: number; + data: number[]; + domain?: [number, number]; + formatOptions?: FormatOptions; + hueOffset?: number; +}; + +const xFromTimestamp = (timestamp: number) => + ((timestamp - Date.now()) / stepPeriod) * (width / stepWindow) + width + (width / stepWindow) * 2; + +const YAxis = ({ max, formatOptions }: Pick & { max: number }) => ( +
+ {Array.from({ length: 5 }).map((_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: supress react console error +
{formatValue((max * (4 - index)) / 4, formatOptions)}
+ ))} +
+); + +const CartesianGrid = () => ( + + {'Cartesian grid'} + + {Array.from({ length: 5 }).map((_, index) => ( + + ))} + +); + +export const CanvasChart = ({ total, hueOffset = 0, domain, data, formatOptions }: Props) => { + const canvasRef = useRef(null!); + const ignored = useRef(0); + const now = useMemo(() => Date.now(), []); + const [history, setHistory] = useState<[number, number[]][]>([[now, []]]); + const max = useMemo(() => { + if (domain) { + return domain[1]; + } + + return 1.25 * history.reduce((max, [_, values]) => Math.max(max, ...values), 0); + }, [history, domain]); + + useEffect(() => { + if (data) { + setHistory(history => { + const firstValidIndex = history.findIndex(([timestamp]) => xFromTimestamp(timestamp) >= -xMargin); + const newHistory = history.slice(firstValidIndex); + newHistory.push([Date.now(), data]); + + return newHistory; + }); + } + }, [data]); + + useEffect(() => { + const ctx = canvasRef.current.getContext('2d'); + if (!ctx) { + return; + } + + ctx.lineWidth = 2; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + }, []); + + useAnimationFrame(dt => { + ignored.current += dt; + if (ignored.current < framePeriod) { + return; + } + ignored.current = 0; + const ctx = canvasRef.current.getContext('2d'); + if (!ctx) { + return; + } + + ctx.clearRect(0, 0, width, height); + + for (let i = 0; i < total; i++) { + ctx.fillStyle = getFillColor(hueOffset + (360 * Number(i)) / total); + ctx.strokeStyle = getStrokeColor(hueOffset + (360 * Number(i)) / total); + ctx.beginPath(); + ctx.moveTo(-xMargin, height); + ctx.lineTo(-xMargin, height - (height * (history[0][1][i] ?? 0)) / max); + for (const [timestamp, values] of history) { + const x = xFromTimestamp(timestamp); + const y = height - (height * (values[i] ?? 0)) / max; + + ctx.lineTo(x, y); + } + ctx.lineTo(width + xMargin, height - (height * (history[0][1][i] ?? 0)) / max); + ctx.lineTo(width + xMargin, height); + ctx.stroke(); + ctx.fill(); + ctx.closePath(); + } + }); + + return ( +
+ + +
+ + + +
+
+ ); +}; diff --git a/src/client/components/legend/index.css b/src/client/components/chart-cards/common/legend/index.css similarity index 100% rename from src/client/components/legend/index.css rename to src/client/components/chart-cards/common/legend/index.css diff --git a/src/client/components/legend/index.tsx b/src/client/components/chart-cards/common/legend/index.tsx similarity index 100% rename from src/client/components/legend/index.tsx rename to src/client/components/chart-cards/common/legend/index.tsx diff --git a/src/client/components/chart-cards/cpu.tsx b/src/client/components/chart-cards/cpu.tsx index 1bd44ec..5566338 100644 --- a/src/client/components/chart-cards/cpu.tsx +++ b/src/client/components/chart-cards/cpu.tsx @@ -1,22 +1,9 @@ import { useQuery } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; -import { ChartCard } from './index'; +import { ChartCard } from './common/card'; export const Cpu = () => { const { data: staticData } = useQuery({ queryKey: ['static'] }); const { data: dynamicData } = useQuery({ queryKey: ['dynamic'] }); - const [history, setHistory] = useState(new Array(Number(import.meta.env.CLIENT_GRAPH_STEPS)).fill([])); - - useEffect(() => { - if (dynamicData) { - setHistory(history => { - const newHistory = history.slice(1); - newHistory.push(dynamicData.cpu_usage); - - return newHistory; - }); - } - }, [dynamicData]); if (!staticData || !dynamicData) { return
; @@ -36,7 +23,7 @@ export const Cpu = () => { } domain={[0, 100]} formatOptions={{ prefix: false, units: '%' }} - data={history} + data={dynamicData.cpu_usage} total={total_cpus} /> ) diff --git a/src/client/components/chart-cards/disks.tsx b/src/client/components/chart-cards/disks.tsx index 035b373..1469ed0 100644 --- a/src/client/components/chart-cards/disks.tsx +++ b/src/client/components/chart-cards/disks.tsx @@ -1,21 +1,8 @@ import { useQuery } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; -import { ChartCard } from './index'; +import { ChartCard } from './common/card'; export const Disks = () => { const { data: dynamicData } = useQuery({ queryKey: ['dynamic'] }); - const [history, setHistory] = useState(new Array(Number(import.meta.env.CLIENT_GRAPH_STEPS)).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
; @@ -25,12 +12,11 @@ export const Disks = () => { ); diff --git a/src/client/components/chart-cards/index.tsx b/src/client/components/chart-cards/index.tsx deleted file mode 100644 index 7d8b92d..0000000 --- a/src/client/components/chart-cards/index.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Legend, type LegendProps } from '@/components/legend'; -import { getFillColor, getStrokeColor } from '@/utils/colors'; -import { type FormatOptions, formatValue } from '@/utils/format'; -import { type ReactNode, useMemo } 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) => { - const estimateTickWidth = useMemo( - () => ((formatOptions?.units?.length ?? 0) + (formatOptions?.prefix === false ? 0 : 2)) * 8, - [formatOptions], - ); - - return ( -
-

{title}

- - {subtitle} - - {legend && } - - - - - - formatValue(value, formatOptions)} - /> - - {Array.from({ length: total }).map((_, index) => ( - // biome-ignore lint/correctness/useJsxKeyInIterable: order irrelevant - - ))} - - - - -
- ); -}; diff --git a/src/client/components/chart-cards/memory.tsx b/src/client/components/chart-cards/memory.tsx index 297c5fc..78aea40 100644 --- a/src/client/components/chart-cards/memory.tsx +++ b/src/client/components/chart-cards/memory.tsx @@ -1,25 +1,13 @@ import { formatValue } from '@/utils/format'; import { useQuery } from '@tanstack/react-query'; -import { useEffect, useMemo, useState } from 'react'; -import { ChartCard } from './index'; +import { useMemo } from 'react'; +import { ChartCard } from './common/card'; const formatOptions = { units: 'B' }; export const Memory = () => { const { data: staticData } = useQuery({ queryKey: ['static'] }); const { data: dynamicData } = useQuery({ queryKey: ['dynamic'] }); - const [history, setHistory] = useState(new Array(Number(import.meta.env.CLIENT_GRAPH_STEPS)).fill([])); - - useEffect(() => { - if (dynamicData) { - setHistory(history => { - const newHistory = history.slice(1); - newHistory.push([dynamicData.mem_usage, dynamicData.swap_usage]); - - return newHistory; - }); - } - }, [dynamicData]); const formatedTotals = useMemo(() => { if (!staticData) { @@ -44,7 +32,7 @@ export const Memory = () => { }} domain={[0, Math.max(staticData.total_memory, staticData.total_swap)]} formatOptions={formatOptions} - data={history} + data={[dynamicData.mem_usage, dynamicData.swap_usage]} total={2} /> ); diff --git a/src/client/components/chart-cards/network.tsx b/src/client/components/chart-cards/network.tsx index 2c18246..349802a 100644 --- a/src/client/components/chart-cards/network.tsx +++ b/src/client/components/chart-cards/network.tsx @@ -1,21 +1,8 @@ import { useQuery } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; -import { ChartCard } from './index'; +import { ChartCard } from './common/card'; export const Network = () => { const { data: dynamicData } = useQuery({ queryKey: ['dynamic'] }); - const [history, setHistory] = useState(new Array(Number(import.meta.env.CLIENT_GRAPH_STEPS)).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
; @@ -25,12 +12,11 @@ export const Network = () => { ); diff --git a/src/client/components/chart-cards/temps.tsx b/src/client/components/chart-cards/temps.tsx index 4285267..b963398 100644 --- a/src/client/components/chart-cards/temps.tsx +++ b/src/client/components/chart-cards/temps.tsx @@ -1,22 +1,9 @@ import { useQuery } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; -import { ChartCard } from './index'; +import { ChartCard } from './common/card'; export const Temps = () => { const { data: staticData } = useQuery({ queryKey: ['static'] }); const { data: dynamicData } = useQuery({ queryKey: ['dynamic'] }); - const [history, setHistory] = useState(new Array(Number(import.meta.env.CLIENT_GRAPH_STEPS)).fill([])); - - useEffect(() => { - if (dynamicData) { - setHistory(history => { - const newHistory = history.slice(1); - newHistory.push(dynamicData.temps); - - return newHistory; - }); - } - }, [dynamicData]); if (!staticData || !dynamicData) { return
; @@ -26,12 +13,11 @@ export const Temps = () => { Math.max(100, Math.ceil(1.25*max))]} + domain={[0, 100]} //(max: number) => Math.max(100, Math.ceil(1.25 * max))]} formatOptions={{ si: true, prefix: false, units: 'ÂșC' }} - data={history} + data={dynamicData.temps} total={staticData.components.length} /> );