import { t } from "@lingui/core/macro" import { Trans, useLingui } from "@lingui/react/macro" import { useStore } from "@nanostores/react" import { getPagePath } from "@nanostores/router" import { timeTicks } from "d3-time" import { XIcon } from "lucide-react" import { subscribeKeys } from "nanostores" import React, { type JSX, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import AreaChartDefault, { type DataPoint } 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, SystemStatus, Unit } from "@/lib/enums" import { batteryStateTranslations } from "@/lib/i18n" import { $allSystemsById, $allSystemsByName, $chartTime, $containerFilter, $direction, $maxValues, $systems, $temperatureFilter, $userSettings, } from "@/lib/stores" import { useIntersectionObserver } from "@/lib/use-intersection-observer" import { chartTimeData, cn, compareSemVer, decimalString, formatBytes, listen, parseSemVer, toFixedFloat, useBrowserStorage, } from "@/lib/utils" import type { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemDetailsRecord, SystemInfo, SystemRecord, SystemStats, SystemStatsRecord, } from "@/types" import { $router, navigate } from "../router" import Spinner from "../spinner" import { Button } from "../ui/button" import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card" import { ChartAverage, ChartMax } from "../ui/icons" import { Input } from "../ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select" import NetworkSheet from "./system/network-sheet" import CpuCoresSheet from "./system/cpu-sheet" import LineChartDefault from "../charts/line-chart" import { pinnedAxisDomain } from "../ui/chart" import InfoBar from "./system/info-bar" 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 buffer = chartTime === "1m" ? 400 : 20_000 const now = new Date(Date.now()) 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 ): T[] { const modifiedRecords: T[] = [] let prevTime = (prevRecords.at(-1)?.created ?? 0) as number for (let i = 0; i < newRecords.length; i++) { const record = newRecords[i] if (record.created !== null) { record.created = new Date(record.created).getTime() } if (prevTime && record.created !== null) { const interval = record.created - prevTime // if interval is too large, add a null record if (interval > expectedInterval / 2 + expectedInterval) { modifiedRecords.push({ created: null, ...("stats" in record ? { stats: null } : {}) } as T) } } if (record.created !== 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, isPodman: boolean): string { if (isPodman) { return str.replace("docker", "podman").replace("Docker", "Podman") } return str } export default memo(function SystemDetail({ id }: { id: 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 temperatureChartRef = useRef(null) const persistChartTime = useRef(false) const [bottomSpacing, setBottomSpacing] = useState(0) const [chartLoading, setChartLoading] = useState(true) const isLongerChart = !["1m", "1h"].includes(chartTime) // true if chart time is not 1m or 1h const userSettings = $userSettings.get() const chartWrapRef = useRef(null) const [details, setDetails] = useState({} as SystemDetailsRecord) useEffect(() => { return () => { if (!persistChartTime.current) { $chartTime.set($userSettings.get().chartTime) } persistChartTime.current = false setSystemStats([]) setContainerData([]) setDetails({} as SystemDetailsRecord) $containerFilter.set("") } }, [id]) // find matching system and update when it changes useEffect(() => { if (!systems.length) { return } // allow old system-name slug to work const store = $allSystemsById.get()[id] ? $allSystemsById : $allSystemsByName return subscribeKeys(store, [id], (newSystems) => { const sys = newSystems[id] if (sys) { setSystem(sys) document.title = `${sys?.name} / Beszel` } }) }, [id, systems.length]) // hide 1m chart time if system agent version is less than 0.13.0 useEffect(() => { if (parseSemVer(system?.info?.v) < parseSemVer("0.13.0")) { $chartTime.set("1h") } }, [system?.info?.v]) // fetch system details useEffect(() => { // if system.info.m exists, agent is old version without system details if (!system.id || system.info?.m) { return } pb.collection("system_details") .getOne(system.id, { fields: "hostname,kernel,cores,threads,cpu,os,os_name,arch,memory,podman", headers: { "Cache-Control": "public, max-age=60", }, }) .then(setDetails) }, [system.id]) // subscribe to realtime metrics if chart time is 1m useEffect(() => { let unsub = () => {} if (!system.id || chartTime !== "1m") { return } if (system.status !== SystemStatus.Up || parseSemVer(system?.info?.v).minor < 13) { $chartTime.set("1h") return } pb.realtime .subscribe( `rt_metrics`, (data: { container: ContainerStatsRecord[]; info: SystemInfo; stats: SystemStats }) => { if (data.container?.length > 0) { const newContainerData = makeContainerData([ { created: Date.now(), stats: data.container } as unknown as ContainerStatsRecord, ]) setContainerData((prevData) => addEmptyValues(prevData, prevData.slice(-59).concat(newContainerData), 1000)) } setSystemStats((prevStats) => addEmptyValues( prevStats, prevStats.slice(-59).concat({ created: Date.now(), stats: data.stats } as SystemStatsRecord), 1000 ) ) }, { query: { system: system.id } } ) .then((us) => { unsub = us }) return () => { unsub?.() } }, [chartTime, 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), 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) } return containerData }, []) // get stats useEffect(() => { if (!system.id || !chartTime || chartTime === "1m") { 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) } setContainerData(makeContainerData(containerData)) }) }, [system, chartTime]) /** Space for tooltip if more than 10 sensors and no containers table */ useEffect(() => { const sensors = Object.keys(systemStats.at(-1)?.stats.t ?? {}) if (!temperatureChartRef.current || sensors.length < 10 || containerData.length > 0) { setBottomSpacing(0) return } const tooltipHeight = (sensors.length - 10) * 17.8 - 40 const wrapperEl = chartWrapRef.current as HTMLDivElement const wrapperRect = wrapperEl.getBoundingClientRect() const chartRect = temperatureChartRef.current.getBoundingClientRect() const distanceToBottom = wrapperRect.bottom - chartRect.bottom setBottomSpacing(tooltipHeight - distanceToBottom) }, []) // 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 || e.altKey ) { return } const currentIndex = systems.findIndex((s) => s.id === id) 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", { id: systems[prevIndex].id })) } case "ArrowRight": case "l": { const nextIndex = (currentIndex + 1) % systems.length persistChartTime.current = true return navigate(getPagePath($router, "system", { id: systems[nextIndex].id })) } } } return listen(document, "keyup", handleKeyUp) }, [id, systems]) if (!system.id) { return null } // select field for switching between avg and max values const maxValSelect = isLongerChart ? : null const showMax = maxValues && isLongerChart const containerFilterBar = containerData.length ? : null const dataEmpty = !chartLoading && chartData.systemStats.length === 0 const lastGpus = systemStats.at(-1)?.stats?.g let hasGpuData = false let hasGpuEnginesData = false let hasGpuPowerData = false if (lastGpus) { // check if there are any GPUs at all hasGpuData = Object.keys(lastGpus).length > 0 // check if there are any GPUs with engines or power data for (let i = 0; i < systemStats.length && (!hasGpuEnginesData || !hasGpuPowerData); i++) { const gpus = systemStats[i].stats?.g if (!gpus) continue for (const id in gpus) { if (!hasGpuEnginesData && gpus[id].e !== undefined) { hasGpuEnginesData = true } if (!hasGpuPowerData && (gpus[id].p !== undefined || gpus[id].pp !== undefined)) { hasGpuPowerData = true } if (hasGpuEnginesData && hasGpuPowerData) break } } } const isLinux = !(details?.os ?? system.info?.os) const isPodman = details?.podman ?? system.info?.p ?? false return ( <>
{/* system info */} {/* Overview Containers S.M.A.R.T. */} {/* main charts */}
{maxValSelect}
} > (showMax ? stats?.cpum : stats?.cpu), color: 1, opacity: 0.4, }, ]} tickFormatter={(val) => `${toFixedFloat(val, 2)}%`} contentFormatter={({ value }) => `${decimalString(value)}%`} domain={pinnedAxisDomain()} /> {containerFilterBar && ( )} {containerFilterBar && ( )} { if (showMax) { return stats?.dio?.[1] ?? (stats?.dwm ?? 0) * 1024 * 1024 } return stats?.dio?.[1] ?? (stats?.dw ?? 0) * 1024 * 1024 }, color: 3, opacity: 0.3, }, { label: t({ message: "Read", comment: "Disk read" }), dataKey: ({ stats }: SystemStatsRecord) => { if (showMax) { return stats?.diom?.[0] ?? (stats?.drm ?? 0) * 1024 * 1024 } return stats?.dio?.[0] ?? (stats?.dr ?? 0) * 1024 * 1024 }, color: 1, opacity: 0.3, }, ]} tickFormatter={(val) => { const { value, unit } = formatBytes(val, true, userSettings.unitDisk, false) return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}` }} contentFormatter={({ value }) => { const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false) return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}` }} showTotal={true} /> {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}` }} showTotal={true} /> {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 && ( )} {lastGpus && Object.keys(lastGpus).map((id) => { const gpu = lastGpus[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]?.du} diskSize={systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN} /> { if (showMax) { return ( stats?.efs?.[extraFsName]?.wbm || (stats?.efs?.[extraFsName]?.wm ?? 0) * 1024 * 1024 ) } return stats?.efs?.[extraFsName]?.wb || (stats?.efs?.[extraFsName]?.w ?? 0) * 1024 * 1024 }, color: 3, opacity: 0.3, }, { label: t`Read`, dataKey: ({ stats }) => { if (showMax) { return ( stats?.efs?.[extraFsName]?.rbm ?? (stats?.efs?.[extraFsName]?.rm ?? 0) * 1024 * 1024 ) } return stats?.efs?.[extraFsName]?.rb ?? (stats?.efs?.[extraFsName]?.r ?? 0) * 1024 * 1024 }, color: 1, opacity: 0.3, }, ]} maxToggled={maxValues} tickFormatter={(val) => { const { value, unit } = formatBytes(val, true, userSettings.unitDisk, false) return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}` }} contentFormatter={({ value }) => { const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false) return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}` }} />
) })}
)} {compareSemVer(chartData.agentVersion, parseSemVer("0.15.0")) >= 0 && } {containerData.length > 0 && compareSemVer(chartData.agentVersion, parseSemVer("0.14.0")) >= 0 && ( )} {isLinux && compareSemVer(chartData.agentVersion, parseSemVer("0.16.0")) >= 0 && ( )} {/* add space for tooltip if lots of sensors */} {bottomSpacing > 0 && } ) }) function GpuEnginesChart({ chartData }: { chartData: ChartData }) { const { gpuId, engines } = useMemo(() => { for (let i = chartData.systemStats.length - 1; i >= 0; i--) { const gpus = chartData.systemStats[i].stats?.g if (!gpus) continue for (const id in gpus) { if (gpus[id].e) { return { gpuId: id, engines: Object.keys(gpus[id].e).sort() } } } } return { gpuId: null, engines: [] } }, [chartData.systemStats]) if (!gpuId) { return null } const dataPoints: DataPoint[] = engines.map((engine, i) => ({ label: engine, dataKey: ({ stats }: SystemStatsRecord) => stats?.g?.[gpuId]?.e?.[engine] ?? 0, color: `hsl(${140 + (((i * 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 storeValue = useStore(store) const [inputValue, setInputValue] = useState(storeValue) const { t } = useLingui() useEffect(() => { setInputValue(storeValue) }, [storeValue]) useEffect(() => { if (inputValue === storeValue) { return } const handle = window.setTimeout(() => store.set(inputValue), 80) return () => clearTimeout(handle) }, [inputValue, storeValue, store]) const handleChange = useCallback((e: React.ChangeEvent) => { const value = e.target.value setInputValue(value) }, []) const handleClear = useCallback(() => { setInputValue("") store.set("") }, [store]) return ( <> {inputValue && ( )} ) } 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}
) } const ContainersTable = lazy(() => import("../containers-table/containers-table")) function LazyContainersTable({ systemId }: { systemId: string }) { const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" }) return (
{isIntersecting && }
) } const SmartTable = lazy(() => import("./system/smart-table")) function LazySmartTable({ systemId }: { systemId: string }) { const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" }) return (
{isIntersecting && }
) } const SystemdTable = lazy(() => import("../systemd-table/systemd-table")) function LazySystemdTable({ systemId }: { systemId: string }) { const { isIntersecting, ref } = useIntersectionObserver() return (
{isIntersecting && }
) }