import { $systems, pb, $chartTime, $containerFilter, $userSettings, $direction } from "@/lib/stores" import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types" import React, { lazy, useCallback, useEffect, useMemo, useRef, useState } from "react" import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card" import { useStore } from "@nanostores/react" import Spinner from "../spinner" import { ClockArrowUp, CpuIcon, GlobeIcon, LayoutGridIcon, MonitorIcon, XIcon } from "lucide-react" import ChartTimeSelect from "../charts/chart-time-select" import { chartTimeData, cn, getPbTimestamp, getSizeAndUnit, toFixedFloat, useLocalStorage } from "@/lib/utils" import { Separator } from "../ui/separator" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip" import { Button } from "../ui/button" import { Input } from "../ui/input" import { ChartAverage, ChartMax, Rows, TuxIcon } from "../ui/icons" import { useIntersectionObserver } from "@/lib/use-intersection-observer" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select" import { timeTicks } from "d3-time" import { Plural, Trans, t } from "@lingui/macro" import { useLingui } from "@lingui/react" const AreaChartDefault = lazy(() => import("../charts/area-chart")) const ContainerChart = lazy(() => import("../charts/container-chart")) const MemChart = lazy(() => import("../charts/mem-chart")) const DiskChart = lazy(() => import("../charts/disk-chart")) const SwapChart = lazy(() => import("../charts/swap-chart")) const TemperatureChart = lazy(() => import("../charts/temperature-chart")) const GpuPowerChart = lazy(() => import("../charts/gpu-power-chart")) const cache = new Map() // create ticks and domain for charts function getTimeData(chartTime: ChartTimes, lastCreated: number) { const cached = cache.get("td") if (cached && cached.chartTime === chartTime) { if (!lastCreated || cached.time >= lastCreated) { return cached.data } } const now = new Date() const startTime = chartTimeData[chartTime].getOffset(now) const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime()) const data = { ticks, domain: [chartTimeData[chartTime].getOffset(now).getTime(), now.getTime()], } cache.set("td", { time: now.getTime(), data, chartTime }) return data } // add empty values between records to make gaps if interval is too large function addEmptyValues( prevRecords: T[], newRecords: T[], expectedInterval: number ) { const modifiedRecords: T[] = [] let prevTime = (prevRecords.at(-1)?.created ?? 0) as number for (let i = 0; i < newRecords.length; i++) { const record = newRecords[i] record.created = new Date(record.created).getTime() if (prevTime) { const interval = record.created - prevTime // if interval is too large, add a null record if (interval > expectedInterval / 2 + expectedInterval) { // @ts-ignore modifiedRecords.push({ created: null, stats: null }) } } prevTime = record.created modifiedRecords.push(record) } return modifiedRecords } async function getStats(collection: string, system: SystemRecord, chartTime: ChartTimes): Promise { const lastCached = cache.get(`${system.id}_${chartTime}_${collection}`)?.at(-1)?.created as number return await pb.collection(collection).getFullList({ filter: pb.filter("system={:id} && created > {:created} && type={:type}", { id: system.id, created: getPbTimestamp(chartTime, lastCached ? new Date(lastCached + 1000) : undefined), type: chartTimeData[chartTime].type, }), fields: "created,stats", sort: "created", }) } function dockerOrPodman(str: string, system: SystemRecord) { if (system.info.p) { str = str.replace("docker", "podman").replace("Docker", "Podman") } return str } export default function SystemDetail({ name }: { name: string }) { const direction = useStore($direction) const { _ } = useLingui() const systems = useStore($systems) const chartTime = useStore($chartTime) /** Max CPU toggle value */ const cpuMaxStore = useState(false) const bandwidthMaxStore = useState(false) const diskIoMaxStore = useState(false) const [grid, setGrid] = useLocalStorage("grid", true) const [system, setSystem] = useState({} as SystemRecord) const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[]) const [containerData, setContainerData] = useState([] as ChartData["containerData"]) const netCardRef = useRef(null) const [containerFilterBar, setContainerFilterBar] = useState(null as null | JSX.Element) const [bottomSpacing, setBottomSpacing] = useState(0) const [chartLoading, setChartLoading] = useState(true) const isLongerChart = chartTime !== "1h" useEffect(() => { document.title = `${name} / Beszel` return () => { $chartTime.set($userSettings.get().chartTime) // resetCharts() setSystemStats([]) setContainerData([]) setContainerFilterBar(null) $containerFilter.set("") cpuMaxStore[1](false) bandwidthMaxStore[1](false) diskIoMaxStore[1](false) } }, [name]) // function resetCharts() { // setSystemStats([]) // setContainerData([]) // } // useEffect(resetCharts, [chartTime]) // find matching system useEffect(() => { if (system.id && system.name === name) { return } const matchingSystem = systems.find((s) => s.name === name) as SystemRecord if (matchingSystem) { setSystem(matchingSystem) } }, [name, system, systems]) // update system when new data is available useEffect(() => { if (!system.id) { return } pb.collection("systems").subscribe(system.id, (e) => { setSystem(e.record) }) return () => { pb.collection("systems").unsubscribe(system.id) } }, [system.id]) const chartData: ChartData = useMemo(() => { const lastCreated = Math.max( (systemStats.at(-1)?.created as number) ?? 0, (containerData.at(-1)?.created as number) ?? 0 ) return { systemStats, containerData, chartTime, orientation: direction === "rtl" ? "right" : "left", ...getTimeData(chartTime, lastCreated), } }, [systemStats, containerData, direction]) // get stats useEffect(() => { if (!system.id || !chartTime) { return } // loading: true setChartLoading(true) Promise.allSettled([ getStats("system_stats", system, chartTime), getStats("container_stats", system, chartTime), ]).then(([systemStats, containerStats]) => { // loading: false setChartLoading(false) const { expectedInterval } = chartTimeData[chartTime] // make new system stats const ss_cache_key = `${system.id}_${chartTime}_system_stats` let systemData = (cache.get(ss_cache_key) || []) as SystemStatsRecord[] if (systemStats.status === "fulfilled" && systemStats.value.length) { systemData = systemData.concat(addEmptyValues(systemData, systemStats.value, expectedInterval)) if (systemData.length > 120) { systemData = systemData.slice(-100) } cache.set(ss_cache_key, systemData) } setSystemStats(systemData) // make new container stats const cs_cache_key = `${system.id}_${chartTime}_container_stats` let containerData = (cache.get(cs_cache_key) || []) as ContainerStatsRecord[] if (containerStats.status === "fulfilled" && containerStats.value.length) { containerData = containerData.concat(addEmptyValues(containerData, containerStats.value, expectedInterval)) if (containerData.length > 120) { containerData = containerData.slice(-100) } cache.set(cs_cache_key, containerData) } if (containerData.length) { !containerFilterBar && setContainerFilterBar() } else if (containerFilterBar) { setContainerFilterBar(null) } makeContainerData(containerData) }) }, [system, chartTime]) // make container stats for charts const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => { const containerData = [] as ChartData["containerData"] for (let { created, stats } of containers) { if (!created) { // @ts-ignore add null value for gaps containerData.push({ created: null }) continue } created = new Date(created).getTime() // @ts-ignore not dealing with this rn let containerStats: ChartData["containerData"][0] = { created } for (let container of stats) { containerStats[container.n] = container } containerData.push(containerStats) } setContainerData(containerData) }, []) // values for system info bar const systemInfo = useMemo(() => { if (!system.info) { return [] } let uptime: React.ReactNode if (system.info.u < 172800) { const hours = Math.trunc(system.info.u / 3600) uptime = } else { uptime = } return [ { value: system.host, Icon: GlobeIcon }, { value: system.info.h, Icon: MonitorIcon, label: "Hostname", // hide if hostname is same as host or name hide: system.info.h === system.host || system.info.h === system.name, }, { value: uptime, Icon: ClockArrowUp, label: t`Uptime` }, { value: system.info.k, Icon: TuxIcon, label: t({ comment: "Linux kernel", message: "Kernel" }) }, { value: `${system.info.m} (${system.info.c}c${system.info.t ? `/${system.info.t}t` : ""})`, Icon: CpuIcon, hide: !system.info.m, }, ] as { value: string | number | undefined label?: string Icon: any hide?: boolean }[] }, [system.info]) /** Space for tooltip if more than 12 containers */ useEffect(() => { if (!netCardRef.current || !containerData.length) { setBottomSpacing(0) return } const tooltipHeight = (Object.keys(containerData[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 setBottomSpacing(tooltipHeight - distanceToBottom) }, [netCardRef, containerData]) if (!system.id) { return null } // if no data, show empty message const dataEmpty = !chartLoading && chartData.systemStats.length === 0 const hasGpuData = Object.keys(systemStats.at(-1)?.stats.g ?? {}).length > 0 return ( <>
{/* system info */}

{system.name}

{system.status === "up" && ( )} {system.status}
{systemInfo.map(({ value, label, Icon, hide }, i) => { if (hide || !value) { return null } const content = (
{value}
) return (
{label ? ( {content} {label} ) : ( content )}
) })}
{t`Toggle grid`}
{/* main charts */}
: null} > {containerFilterBar && ( )} {containerFilterBar && ( )} : null} > : null} description={t`Network traffic of public interfaces`} > {containerFilterBar && containerData.length > 0 && (
{/* @ts-ignore */}
)} {/* Swap chart */} {(systemStats.at(-1)?.stats.su ?? 0) > 0 && ( )} {/* Temperature chart */} {systemStats.at(-1)?.stats.t && ( )} {/* GPU power draw chart */} {hasGpuData && ( )}
{/* GPU charts */} {hasGpuData && (
{Object.keys(systemStats.at(-1)?.stats.g ?? {}).map((id) => { const gpu = systemStats.at(-1)?.stats.g?.[id] as GPUData return (
{ const { v, u } = getSizeAndUnit(value, false) return toFixedFloat(v, 1) + u }} />
) })}
)} {/* extra filesystem charts */} {Object.keys(systemStats.at(-1)?.stats.efs ?? {}).length > 0 && (
{Object.keys(systemStats.at(-1)?.stats.efs ?? {}).map((extraFsName) => { return (
: null} >
) })}
)}
{/* add space for tooltip if more than 12 containers */} {bottomSpacing > 0 && } ) } function ContainerFilterBar() { const containerFilter = useStore($containerFilter) const { _ } = useLingui() const handleChange = useCallback((e: React.ChangeEvent) => { $containerFilter.set(e.target.value) }, []) return ( <> {containerFilter && ( )} ) } function SelectAvgMax({ store }: { store: [boolean, React.Dispatch>] }) { const [max, setMax] = store const Icon = max ? ChartMax : ChartAverage return ( ) } function ChartCard({ title, description, children, grid, empty, cornerEl, }: { title: string description: string children: React.ReactNode grid?: boolean empty?: boolean cornerEl?: JSX.Element | null }) { const { isIntersecting, ref } = useIntersectionObserver() return ( {title} {description} {cornerEl &&
{cornerEl}
}
{} {isIntersecting && children}
) }