From ec5b1a833d2b47b78f6ea5b90707598f2661d587 Mon Sep 17 00:00:00 2001 From: Henry Dollman Date: Mon, 2 Sep 2024 16:13:07 -0400 Subject: [PATCH] extra fs charts and filter bar for container charts --- .../components/charts/container-cpu-chart.tsx | 39 +- .../components/charts/container-mem-chart.tsx | 37 +- .../components/charts/container-net-chart.tsx | 39 +- .../site/src/components/charts/disk-chart.tsx | 16 +- .../components/charts/extra-fs-disk-chart.tsx | 92 ++++ .../charts/extra-fs-disk-io-chart.tsx | 111 +++++ beszel/site/src/components/routes/home.tsx | 2 +- beszel/site/src/components/routes/system.tsx | 397 +++++++++++------- .../systems-table/systems-table.tsx | 2 +- beszel/site/src/components/ui/chart.tsx | 5 + beszel/site/src/lib/stores.ts | 3 + beszel/site/src/types.d.ts | 13 + 12 files changed, 543 insertions(+), 213 deletions(-) create mode 100644 beszel/site/src/components/charts/extra-fs-disk-chart.tsx create mode 100644 beszel/site/src/components/charts/extra-fs-disk-io-chart.tsx diff --git a/beszel/site/src/components/charts/container-cpu-chart.tsx b/beszel/site/src/components/charts/container-cpu-chart.tsx index 3634dc0..5569584 100644 --- a/beszel/site/src/components/charts/container-cpu-chart.tsx +++ b/beszel/site/src/components/charts/container-cpu-chart.tsx @@ -9,7 +9,7 @@ import { useMemo, useRef } from 'react' import { chartTimeData, cn, formatShortDate, useYaxisWidth } from '@/lib/utils' // import Spinner from '../spinner' import { useStore } from '@nanostores/react' -import { $chartTime } from '@/lib/stores' +import { $chartTime, $containerFilter } from '@/lib/stores' export default function ContainerCpuChart({ chartData, @@ -21,6 +21,7 @@ export default function ContainerCpuChart({ const chartRef = useRef(null) const yAxisWidth = useYaxisWidth(chartRef) const chartTime = useStore($chartTime) + const filter = useStore($containerFilter) const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth]) @@ -109,23 +110,27 @@ export default function ContainerCpuChart({ labelFormatter={(_, data) => formatShortDate(data[0].payload.time)} // @ts-ignore itemSorter={(a, b) => b.value - a.value} - content={} + content={} /> - {Object.keys(chartConfig).map((key) => ( - - ))} + {Object.keys(chartConfig).map((key) => { + const filtered = filter && !key.includes(filter) + let fillOpacity = filtered ? 0.05 : 0.4 + let strokeOpacity = filtered ? 0.1 : 1 + return ( + + ) + })} diff --git a/beszel/site/src/components/charts/container-mem-chart.tsx b/beszel/site/src/components/charts/container-mem-chart.tsx index 3d277aa..ca482d4 100644 --- a/beszel/site/src/components/charts/container-mem-chart.tsx +++ b/beszel/site/src/components/charts/container-mem-chart.tsx @@ -15,7 +15,7 @@ import { } from '@/lib/utils' // import Spinner from '../spinner' import { useStore } from '@nanostores/react' -import { $chartTime } from '@/lib/stores' +import { $chartTime, $containerFilter } from '@/lib/stores' export default function ContainerMemChart({ chartData, @@ -27,6 +27,7 @@ export default function ContainerMemChart({ const chartTime = useStore($chartTime) const chartRef = useRef(null) const yAxisWidth = useYaxisWidth(chartRef) + const filter = useStore($containerFilter) const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth]) @@ -114,21 +115,27 @@ export default function ContainerMemChart({ labelFormatter={(_, data) => formatShortDate(data[0].payload.time)} // @ts-ignore itemSorter={(a, b) => b.value - a.value} - content={} + content={} /> - {Object.keys(chartConfig).map((key) => ( - - ))} + {Object.keys(chartConfig).map((key) => { + const filtered = filter && !key.includes(filter) + let fillOpacity = filtered ? 0.05 : 0.4 + let strokeOpacity = filtered ? 0.1 : 1 + return ( + + ) + })} diff --git a/beszel/site/src/components/charts/container-net-chart.tsx b/beszel/site/src/components/charts/container-net-chart.tsx index a42ed6e..0aebfce 100644 --- a/beszel/site/src/components/charts/container-net-chart.tsx +++ b/beszel/site/src/components/charts/container-net-chart.tsx @@ -15,7 +15,7 @@ import { } from '@/lib/utils' // import Spinner from '../spinner' import { useStore } from '@nanostores/react' -import { $chartTime } from '@/lib/stores' +import { $chartTime, $containerFilter } from '@/lib/stores' import { Separator } from '@/components/ui/separator' export default function ContainerCpuChart({ @@ -28,6 +28,7 @@ export default function ContainerCpuChart({ const chartTime = useStore($chartTime) const chartRef = useRef(null) const yAxisWidth = useYaxisWidth(chartRef) + const filter = useStore($containerFilter) const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth]) @@ -116,6 +117,7 @@ export default function ContainerCpuChart({ itemSorter={(a, b) => b.value - a.value} content={ { try { @@ -136,20 +138,27 @@ export default function ContainerCpuChart({ /> } /> - {Object.keys(chartConfig).map((key) => ( - data?.[key]?.[2] ?? 0} - type="monotoneX" - fill={chartConfig[key].color} - fillOpacity={0.4} - stroke={chartConfig[key].color} - stackId="a" - /> - ))} + {Object.keys(chartConfig).map((key) => { + const filtered = filter && !key.includes(filter) + let fillOpacity = filtered ? 0.05 : 0.4 + let strokeOpacity = filtered ? 0.1 : 1 + return ( + data?.[key]?.[2] ?? 0} + type="monotoneX" + fill={chartConfig[key].color} + fillOpacity={fillOpacity} + stroke={chartConfig[key].color} + strokeOpacity={strokeOpacity} + activeDot={{ opacity: filtered ? 0 : 1 }} + stackId="a" + /> + ) + })} diff --git a/beszel/site/src/components/charts/disk-chart.tsx b/beszel/site/src/components/charts/disk-chart.tsx index 430cff1..705a5e4 100644 --- a/beszel/site/src/components/charts/disk-chart.tsx +++ b/beszel/site/src/components/charts/disk-chart.tsx @@ -22,22 +22,9 @@ export default function DiskChart({ const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth]) const diskSize = useMemo(() => { - return Math.round(systemData[0]?.stats.d) + return Math.round(systemData.at(-1)?.stats.d ?? NaN) }, [systemData]) - // const ticks = useMemo(() => { - // let ticks = [0] - // for (let i = 1; i < diskSize; i += diskSize / 5) { - // ticks.push(Math.trunc(i)) - // } - // ticks.push(diskSize) - // return ticks - // }, [diskSize]) - - // if (!systemData.length || !ticks.length) { - // return - // } - return (
{/* {!yAxisSet && } */} @@ -63,6 +50,7 @@ export default function DiskChart({ width={yAxisWidth} domain={[0, diskSize]} tickCount={9} + minTickGap={6} tickLine={false} axisLine={false} unit={' GB'} diff --git a/beszel/site/src/components/charts/extra-fs-disk-chart.tsx b/beszel/site/src/components/charts/extra-fs-disk-chart.tsx new file mode 100644 index 0000000..e1df44d --- /dev/null +++ b/beszel/site/src/components/charts/extra-fs-disk-chart.tsx @@ -0,0 +1,92 @@ +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' + +import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart' +import { chartTimeData, cn, formatShortDate, useYaxisWidth } from '@/lib/utils' +import { useMemo, useRef } from 'react' +// import Spinner from '../spinner' +import { useStore } from '@nanostores/react' +import { $chartTime } from '@/lib/stores' +import { SystemStatsRecord } from '@/types' + +export default function ExFsDiskChart({ + ticks, + systemData, + fs, +}: { + ticks: number[] + systemData: SystemStatsRecord[] + fs: string +}) { + const chartTime = useStore($chartTime) + const chartRef = useRef(null) + const yAxisWidth = useYaxisWidth(chartRef) + + const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth]) + + const diskSize = useMemo(() => { + const size = systemData.at(-1)?.stats.efs?.[fs].d ?? 0 + return size > 10 ? Math.round(size) : size + }, [systemData]) + + return ( +
+ + + + + + formatShortDate(data[0].payload.created)} + indicator="line" + /> + } + /> + + + +
+ ) +} diff --git a/beszel/site/src/components/charts/extra-fs-disk-io-chart.tsx b/beszel/site/src/components/charts/extra-fs-disk-io-chart.tsx new file mode 100644 index 0000000..8dc99ff --- /dev/null +++ b/beszel/site/src/components/charts/extra-fs-disk-io-chart.tsx @@ -0,0 +1,111 @@ +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' + +import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart' +import { + chartTimeData, + cn, + formatShortDate, + toFixedWithoutTrailingZeros, + useYaxisWidth, +} from '@/lib/utils' +// import Spinner from '../spinner' +import { useStore } from '@nanostores/react' +import { $chartTime } from '@/lib/stores' +import { SystemStatsRecord } from '@/types' +import { useMemo, useRef } from 'react' + +export default function ExFsDiskIoChart({ + ticks, + systemData, + fs, +}: { + ticks: number[] + systemData: SystemStatsRecord[] + fs: string +}) { + const chartTime = useStore($chartTime) + const chartRef = useRef(null) + const yAxisWidth = useYaxisWidth(chartRef) + + const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth]) + + // if (!systemData.length || !ticks.length) { + // return + // } + + return ( +
+ {/* {!yAxisSet && } */} + + + + (max <= 0.4 ? 0.4 : Math.ceil(max))]} + tickFormatter={(value) => toFixedWithoutTrailingZeros(value, 2)} + tickLine={false} + axisLine={false} + unit={' MB/s'} + /> + + formatShortDate(data[0].payload.created)} + indicator="line" + /> + } + /> + + + + +
+ ) +} diff --git a/beszel/site/src/components/routes/home.tsx b/beszel/site/src/components/routes/home.tsx index 8f1617f..b6a7483 100644 --- a/beszel/site/src/components/routes/home.tsx +++ b/beszel/site/src/components/routes/home.tsx @@ -51,7 +51,7 @@ export default function () { setFilter(e.target.value)} - className="w-full md:w-56 lg:w-80 ml-auto pl-4" + className="w-full md:w-56 lg:w-80 ml-auto px-4" />
diff --git a/beszel/site/src/components/routes/system.tsx b/beszel/site/src/components/routes/system.tsx index 9bfbd96..c63eb14 100644 --- a/beszel/site/src/components/routes/system.tsx +++ b/beszel/site/src/components/routes/system.tsx @@ -1,10 +1,17 @@ -import { $systems, pb, $chartTime } from '@/lib/stores' +import { $systems, pb, $chartTime, $containerFilter } from '@/lib/stores' import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types' import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../ui/card' import { useStore } from '@nanostores/react' import Spinner from '../spinner' -import { ClockArrowUp, CpuIcon, GlobeIcon, LayoutGridIcon } from 'lucide-react' +import { + ClockArrowUp, + CpuIcon, + GlobeIcon, + LayoutGridIcon, + StretchHorizontalIcon, + XIcon, +} from 'lucide-react' import ChartTimeSelect from '../charts/chart-time-select' import { chartTimeData, @@ -16,8 +23,8 @@ import { import { Separator } from '../ui/separator' import { scaleTime } from 'd3-scale' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' -import { Toggle } from '../ui/toggle' -import { buttonVariants } from '../ui/button' +import { Button, buttonVariants } from '../ui/button' +import { Input } from '../ui/input' const CpuChart = lazy(() => import('../charts/cpu-chart')) const ContainerCpuChart = lazy(() => import('../charts/container-cpu-chart')) @@ -29,6 +36,8 @@ const BandwidthChart = lazy(() => import('../charts/bandwidth-chart')) const ContainerNetChart = lazy(() => import('../charts/container-net-chart')) const SwapChart = lazy(() => import('../charts/swap-chart')) const TemperatureChart = lazy(() => import('../charts/temperature-chart')) +const ExFsDiskChart = lazy(() => import('../charts/extra-fs-disk-chart')) +const ExFsDiskIoChart = lazy(() => import('../charts/extra-fs-disk-io-chart')) export default function SystemDetail({ name }: { name: string }) { const systems = useStore($systems) @@ -38,6 +47,7 @@ export default function SystemDetail({ name }: { name: string }) { const [system, setSystem] = useState({} as SystemRecord) const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[]) const [hasDockerStats, setHasDocker] = useState(false) + const netCardRef = useRef(null) const [dockerCpuChartData, setDockerCpuChartData] = useState[]>( [] ) @@ -62,6 +72,7 @@ export default function SystemDetail({ name }: { name: string }) { setDockerCpuChartData([]) setDockerMemChartData([]) setDockerNetChartData([]) + $containerFilter.set('') } useEffect(resetCharts, [chartTime]) @@ -114,7 +125,7 @@ export default function SystemDetail({ name }: { name: string }) { if (prevTime) { const interval = record.created - prevTime // if interval is too large, add a null record - if (interval - interval * 0.5 > expectedInterval) { + if (interval > expectedInterval / 2 + expectedInterval) { // @ts-ignore modifiedRecords.push({ created: null, stats: null }) } @@ -195,169 +206,251 @@ export default function SystemDetail({ name }: { name: string }) { return `${Math.trunc(system.info?.u / 86400)} days` }, [system.info?.u]) + /** Space for tooltip if more than 12 containers */ + const bottomSpacing = useMemo(() => { + if (!netCardRef.current || !dockerNetChartData.length) { + return 0 + } + const tooltipHeight = (Object.keys(dockerNetChartData[0]).length - 11) * 17.8 - 40 + const wrapperEl = document.getElementById('chartwrap') as HTMLDivElement + const wrapperRect = wrapperEl.getBoundingClientRect() + const chartRect = netCardRef.current.getBoundingClientRect() + const distanceToBottom = wrapperRect.bottom - chartRect.bottom + return tooltipHeight - distanceToBottom + }, [netCardRef.current, dockerNetChartData]) + if (!system.id) { return null } return ( -
- -
-
-

{system.name}

-
-
- - {system.status === 'up' && ( + <> +
+ {/* system info */} + +
+
+

{system.name}

+
+
+ + {system.status === 'up' && ( + + )} - )} - - - {system.status} -
- -
- {system.host} -
- {system.info?.u && ( - - + + {system.status} +
+ +
+ {system.host} +
+ {system.info?.u && ( + + + + +
+ {uptime} +
+
+ Uptime +
+
+ )} + {system.info?.m && ( + <> - -
- {uptime} -
-
- Uptime - - - )} - {system.info?.m && ( - <> - -
- - {system.info.m} ({system.info.c}c / {system.info.t}t) -
- - )} +
+ + {system.info.m} ({system.info.c}c / {system.info.t}t) +
+ + )} +
-
-
- - - - - - + + + + + + + Toggle grid + + +
-
- + - - - - - {hasDockerStats && ( - - - - )} - - - - - - {hasDockerStats && ( - - - - )} - - {(systemStats.at(-1)?.stats.s ?? 0) > 0 && ( - - - - )} - - - - - - {systemStats.at(-1)?.stats.t && ( - - - - )} - - - - - - - - - - {hasDockerStats && dockerNetChartData.length > 0 && ( - <> + {/* main charts */} +
- + - {/* add space for tooltip if more than 12 containers */} - {Object.keys(dockerNetChartData[0]).length > 12 && ( - + + {hasDockerStats && ( + + + )} - + + + + + + {hasDockerStats && ( + + + + )} + + {(systemStats.at(-1)?.stats.s ?? 0) > 0 && ( + + + + )} + + + + + + + + + + + + + + {hasDockerStats && dockerNetChartData.length > 0 && ( +
+ + + +
+ )} + + {systemStats.at(-1)?.stats.t && ( + + + + )} +
+ + {/* extra filesystem charts */} + {Object.keys(systemStats.at(-1)?.stats.efs ?? {}).length > 0 && ( +
+ {Object.keys(systemStats.at(-1)?.stats.efs ?? {}).map((extraFsName) => { + return ( +
+ + + + + + +
+ ) + })} +
+ )} +
+ + {/* add space for tooltip if more than 12 containers */} + {bottomSpacing > 0 && } + + ) +} + +function ContainerFilterBar() { + const containerFilter = useStore($containerFilter) + + const handleChange = useCallback((e: React.ChangeEvent) => { + $containerFilter.set(e.target.value) + }, []) // Use an empty dependency array to prevent re-creation + + return ( +
+ + {containerFilter && ( + )}
) @@ -368,22 +461,26 @@ function ChartCard({ description, children, grid, + isContainerChart, }: { title: string description: string children: React.ReactNode - grid: boolean + grid?: boolean + isContainerChart?: boolean }) { const target = useRef(null) const [isInViewport, wrappedTargetRef] = useClampedIsInViewport({ target: target }) + return ( {title} {description} + {isContainerChart && } {} diff --git a/beszel/site/src/components/systems-table/systems-table.tsx b/beszel/site/src/components/systems-table/systems-table.tsx index 6953473..71b2490 100644 --- a/beszel/site/src/components/systems-table/systems-table.tsx +++ b/beszel/site/src/components/systems-table/systems-table.tsx @@ -72,7 +72,7 @@ function CellFormatter(info: CellContext) { diff --git a/beszel/site/src/components/ui/chart.tsx b/beszel/site/src/components/ui/chart.tsx index 29dfc84..fdcb8ee 100644 --- a/beszel/site/src/components/ui/chart.tsx +++ b/beszel/site/src/components/ui/chart.tsx @@ -100,6 +100,7 @@ const ChartTooltipContent = React.forwardRef< nameKey?: string labelKey?: string unit?: string + filter?: string contentFormatter?: (item: any, key: string) => React.ReactNode | string } >( @@ -119,6 +120,7 @@ const ChartTooltipContent = React.forwardRef< nameKey, labelKey, unit, + filter, itemSorter, contentFormatter: content = undefined, }, @@ -127,6 +129,9 @@ const ChartTooltipContent = React.forwardRef< const { config } = useChart() React.useMemo(() => { + if (filter) { + payload = payload?.filter((item) => (item.name as string)?.includes(filter)) + } if (itemSorter) { // @ts-ignore payload?.sort(itemSorter) diff --git a/beszel/site/src/lib/stores.ts b/beszel/site/src/lib/stores.ts index a72b469..1e03ba4 100644 --- a/beszel/site/src/lib/stores.ts +++ b/beszel/site/src/lib/stores.ts @@ -22,3 +22,6 @@ export const $hubVersion = atom('') /** Chart time period */ export const $chartTime = atom('1h') as WritableAtom + +/** Container chart filter */ +export const $containerFilter = atom('') diff --git a/beszel/site/src/types.d.ts b/beszel/site/src/types.d.ts index 07200f5..6b7a963 100644 --- a/beszel/site/src/types.d.ts +++ b/beszel/site/src/types.d.ts @@ -59,6 +59,19 @@ export interface SystemStats { nr: number /** temperatures */ t?: Record + /** extra filesystems */ + efs?: Record +} + +export interface ExtraFsStats { + /** disk size (gb) */ + d: number + /** disk used (gb) */ + du: number + /** total read (mb) */ + r: number + /** total write (mb) */ + w: number } export interface ContainerStatsRecord extends RecordModel {