import { t } from "@lingui/core/macro" import { Plural, Trans, useLingui } from "@lingui/react/macro" import { useStore } from "@nanostores/react" import { getPagePath } from "@nanostores/router" import { timeTicks } from "d3-time" import { ChevronRightSquareIcon, ClockArrowUp, CpuIcon, GlobeIcon, LayoutGridIcon, MonitorIcon, XIcon, } from "lucide-react" import { subscribeKeys } from "nanostores" import React, { type JSX, memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import AreaChartDefault from "@/components/charts/area-chart" import ContainerChart from "@/components/charts/container-chart" import DiskChart from "@/components/charts/disk-chart" import GpuPowerChart from "@/components/charts/gpu-power-chart" import { useContainerChartConfigs } from "@/components/charts/hooks" import LoadAverageChart from "@/components/charts/load-average-chart" import MemChart from "@/components/charts/mem-chart" import SwapChart from "@/components/charts/swap-chart" import TemperatureChart from "@/components/charts/temperature-chart" import { getPbTimestamp, pb } from "@/lib/api" import { ChartType, ConnectionType, connectionTypeLabels, Os, SystemStatus, Unit } from "@/lib/enums" import { batteryStateTranslations } from "@/lib/i18n" import { $allSystemsByName, $chartTime, $containerFilter, $direction, $maxValues, $systems, $temperatureFilter, $userSettings, } from "@/lib/stores" import { useIntersectionObserver } from "@/lib/use-intersection-observer" import { chartTimeData, cn, debounce, decimalString, formatBytes, getHostDisplayValue, listen, parseSemVer, toFixedFloat, useBrowserStorage, } from "@/lib/utils" import type { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types" import ChartTimeSelect from "../charts/chart-time-select" import { $router, navigate } from "../router" import Spinner from "../spinner" import { Button } from "../ui/button" import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card" import { AppleIcon, ChartAverage, ChartMax, FreeBsdIcon, Rows, TuxIcon, WebSocketIcon, WindowsIcon } from "../ui/icons" import { Input } from "../ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select" import { Separator } from "../ui/separator" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip" import NetworkSheet from "./system/network-sheet" import LineChartDefault from "../charts/line-chart" type ChartTimeData = { time: number data: { ticks: number[] domain: number[] } chartTime: ChartTimes } const cache = new Map() // create ticks and domain for charts function getTimeData(chartTime: ChartTimes, lastCreated: number) { const cached = cache.get("td") as ChartTimeData | undefined 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-expect-error 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 cachedStats = cache.get(`${system.id}_${chartTime}_${collection}`) as T[] | undefined const lastCached = cachedStats?.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) { return str.replace("docker", "podman").replace("Docker", "Podman") } return str } export default memo(function SystemDetail({ name }: { name: string }) { const direction = useStore($direction) const { t } = useLingui() const systems = useStore($systems) const chartTime = useStore($chartTime) const maxValues = useStore($maxValues) const [grid, setGrid] = useBrowserStorage("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 persistChartTime = useRef(false) const [containerFilterBar, setContainerFilterBar] = useState(null as null | JSX.Element) const [bottomSpacing, setBottomSpacing] = useState(0) const [chartLoading, setChartLoading] = useState(true) const isLongerChart = chartTime !== "1h" const userSettings = $userSettings.get() const chartWrapRef = useRef(null) useEffect(() => { document.title = `${name} / Beszel` return () => { if (!persistChartTime.current) { $chartTime.set($userSettings.get().chartTime) } persistChartTime.current = false setSystemStats([]) setContainerData([]) setContainerFilterBar(null) $containerFilter.set("") } }, [name]) // find matching system and update when it changes useEffect(() => { return subscribeKeys($allSystemsByName, [name], (newSystems) => { const sys = newSystems[name] sys?.id && setSystem(sys) }) }, [name]) // biome-ignore lint/correctness/useExhaustiveDependencies: not necessary 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), agentVersion: parseSemVer(system?.info?.v), } }, [systemStats, containerData, direction]) // Share chart config computation for all container charts const containerChartConfigs = useContainerChartConfigs(containerData) // make container stats for charts const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => { const containerData = [] as ChartData["containerData"] for (let { created, stats } of containers) { if (!created) { // @ts-expect-error add null value for gaps containerData.push({ created: null }) continue } created = new Date(created).getTime() // @ts-expect-error not dealing with this rn const containerStats: ChartData["containerData"][0] = { created } for (const container of stats) { containerStats[container.n] = container } containerData.push(containerStats) } setContainerData(containerData) }, []) // get stats // biome-ignore lint/correctness/useExhaustiveDependencies: not necessary 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]) // values for system info bar const systemInfo = useMemo(() => { if (!system.info) { return [] } const osInfo = { [Os.Linux]: { Icon: TuxIcon, value: system.info.k, label: t({ comment: "Linux kernel", message: "Kernel" }), }, [Os.Darwin]: { Icon: AppleIcon, value: `macOS ${system.info.k}`, }, [Os.Windows]: { Icon: WindowsIcon, value: system.info.k, }, [Os.FreeBSD]: { Icon: FreeBsdIcon, value: system.info.k, }, } let uptime: React.ReactNode if (system.info.u < 3600) { uptime = ( ) } else if (system.info.u < 172800) { uptime = } else { uptime = } return [ { value: getHostDisplayValue(system), 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`, hide: !system.info.u }, osInfo[system.info.os ?? Os.Linux], { 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: React.ElementType hide?: boolean }[] }, [system, t]) /** 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 = chartWrapRef.current as HTMLDivElement const wrapperRect = wrapperEl.getBoundingClientRect() const chartRect = netCardRef.current.getBoundingClientRect() const distanceToBottom = wrapperRect.bottom - chartRect.bottom setBottomSpacing(tooltipHeight - distanceToBottom) }, [containerData]) // keyboard navigation between systems useEffect(() => { if (!systems.length) { return } const handleKeyUp = (e: KeyboardEvent) => { if ( e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.shiftKey || e.ctrlKey || e.metaKey ) { return } const currentIndex = systems.findIndex((s) => s.name === name) if (currentIndex === -1 || systems.length <= 1) { return } switch (e.key) { case "ArrowLeft": case "h": { const prevIndex = (currentIndex - 1 + systems.length) % systems.length persistChartTime.current = true return navigate(getPagePath($router, "system", { name: systems[prevIndex].name })) } case "ArrowRight": case "l": { const nextIndex = (currentIndex + 1) % systems.length persistChartTime.current = true return navigate(getPagePath($router, "system", { name: systems[nextIndex].name })) } } } return listen(document, "keyup", handleKeyUp) }, [name, systems]) if (!system.id) { return null } // select field for switching between avg and max values const maxValSelect = isLongerChart ? : null const showMax = chartTime !== "1h" && maxValues // if no data, show empty message const dataEmpty = !chartLoading && chartData.systemStats.length === 0 const lastGpuVals = Object.values(systemStats.at(-1)?.stats.g ?? {}) const hasGpuData = lastGpuVals.length > 0 const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined || gpu.pp !== undefined) const hasGpuEnginesData = lastGpuVals.some((gpu) => gpu.e !== undefined) let translatedStatus: string = system.status if (system.status === SystemStatus.Up) { translatedStatus = t({ message: "Up", comment: "Context: System is up" }) } else if (system.status === SystemStatus.Down) { translatedStatus = t({ message: "Down", comment: "Context: System is down" }) } return ( <>
{/* system info */}

{system.name}

{system.status === SystemStatus.Up && ( )} {translatedStatus}
{system.info.ct && (
{system.info.ct === ConnectionType.WebSocket ? ( ) : ( )} {connectionTypeLabels[system.info.ct as ConnectionType]}
)}
{systemInfo.map(({ value, label, Icon, hide }) => { if (hide || !value) { return null } const content = (
{value}
) return (
{label ? ( {content} {label} ) : ( content )}
) })}
{t`Toggle grid`}
{/* main charts */}
(showMax ? stats?.cpum : stats?.cpu), color: 1, opacity: 0.4, }, ]} tickFormatter={(val) => `${toFixedFloat(val, 2)}%`} contentFormatter={({ value }) => `${decimalString(value)}%`} /> {containerFilterBar && ( )} {containerFilterBar && ( )} (showMax ? stats?.dwm : stats?.dw), color: 3, opacity: 0.3, }, { label: t({ message: "Read", comment: "Disk read" }), dataKey: ({ stats }: SystemStatsRecord) => (showMax ? stats?.drm : stats?.dr), color: 1, opacity: 0.3, }, ]} tickFormatter={(val) => { const { value, unit } = formatBytes(val, true, userSettings.unitDisk, true) return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}` }} contentFormatter={({ value }) => { const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, true) return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}` }} /> {maxValSelect}
} description={t`Network traffic of public interfaces`} > (systemStats.at(-1)?.stats.b?.[1] ?? 0) - (systemStats.at(-1)?.stats.b?.[0] ?? 0))} tickFormatter={(val) => { const { value, unit } = formatBytes(val, true, userSettings.unitNet, false) return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}` }} contentFormatter={(data) => { const { value, unit } = formatBytes(data.value, true, userSettings.unitNet, false) return `${decimalString(value, value >= 100 ? 1 : 2)} ${unit}` }} /> {containerFilterBar && containerData.length > 0 && (
)} {/* Swap chart */} {(systemStats.at(-1)?.stats.su ?? 0) > 0 && ( )} {/* Load Average chart */} {chartData.agentVersion?.minor >= 12 && ( )} {/* Temperature chart */} {systemStats.at(-1)?.stats.t && ( } legend={Object.keys(systemStats.at(-1)?.stats.t ?? {}).length < 12} > )} {/* Battery chart */} {systemStats.at(-1)?.stats.bat && ( stats?.bat?.[0], color: 1, opacity: 0.35, }, ]} domain={[0, 100]} tickFormatter={(val) => `${val}%`} contentFormatter={({ value }) => `${value}%`} /> )} {/* GPU power draw chart */} {hasGpuPowerData && ( )}
{/* Non-power GPU charts */} {hasGpuData && (
{hasGpuEnginesData && ( )} {Object.keys(systemStats.at(-1)?.stats.g ?? {}).map((id) => { const gpu = systemStats.at(-1)?.stats.g?.[id] as GPUData return (
stats?.g?.[id]?.u ?? 0, color: 1, opacity: 0.35, }, ]} tickFormatter={(val) => `${toFixedFloat(val, 2)}%`} contentFormatter={({ value }) => `${decimalString(value)}%`} /> {(gpu.mt ?? 0) > 0 && ( stats?.g?.[id]?.mu ?? 0, color: 2, opacity: 0.25, }, ]} max={gpu.mt} tickFormatter={(val) => { const { value, unit } = formatBytes(val, false, Unit.Bytes, true) return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}` }} contentFormatter={({ value }) => { const { value: convertedValue, unit } = formatBytes(value, false, Unit.Bytes, true) return `${decimalString(convertedValue)} ${unit}` }} /> )}
) })}
)} {/* extra filesystem charts */} {Object.keys(systemStats.at(-1)?.stats.efs ?? {}).length > 0 && (
{Object.keys(systemStats.at(-1)?.stats.efs ?? {}).map((extraFsName) => { return (
stats?.efs?.[extraFsName]?.[showMax ? "wm" : "w"] ?? 0, color: 3, opacity: 0.3, }, { label: t`Read`, dataKey: ({ stats }) => stats?.efs?.[extraFsName]?.[showMax ? "rm" : "r"] ?? 0, color: 1, opacity: 0.3, }, ]} maxToggled={maxValues} tickFormatter={(val) => { const { value, unit } = formatBytes(val, true, userSettings.unitDisk, true) return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}` }} contentFormatter={({ value }) => { const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, true) return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}` }} />
) })}
)} {/* add space for tooltip if more than 12 containers */} {bottomSpacing > 0 && } ) }) function GpuEnginesChart({ chartData }: { chartData: ChartData }) { const dataPoints = [] const engines = Object.keys(chartData.systemStats?.at(-1)?.stats.g?.[0]?.e ?? {}).sort() for (const engine of engines) { dataPoints.push({ label: engine, dataKey: ({ stats }: SystemStatsRecord) => stats?.g?.[0]?.e?.[engine] ?? 0, color: `hsl(${140 + (((engines.indexOf(engine) * 360) / engines.length) % 360)}, 65%, 52%)`, opacity: 0.35, }) } return ( `${toFixedFloat(val, 2)}%`} contentFormatter={({ value }) => `${decimalString(value)}%`} /> ) } function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) { const containerFilter = useStore(store) const { t } = useLingui() const debouncedStoreSet = useMemo(() => debounce((value: string) => store.set(value), 80), [store]) const handleChange = useCallback( (e: React.ChangeEvent) => debouncedStoreSet(e.target.value), [debouncedStoreSet] ) return ( <> {containerFilter && ( )} ) } const SelectAvgMax = memo(({ max }: { max: boolean }) => { const Icon = max ? ChartMax : ChartAverage return ( ) }) export function ChartCard({ title, description, children, grid, empty, cornerEl, legend, className, }: { title: string description: string children: React.ReactNode grid?: boolean empty?: boolean cornerEl?: JSX.Element | null legend?: boolean className?: string }) { const { isIntersecting, ref } = useIntersectionObserver() return ( {title} {description} {cornerEl &&
{cornerEl}
}
{ } {isIntersecting && children}
) }