import { $systems, pb, $chartTime, $containerFilter, $userSettings } from '@/lib/stores' import { ChartData, ChartTimes, ContainerStatsRecord, 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, 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 { useTranslation } from 'react-i18next' 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 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', }) } export default function SystemDetail({ name }: { name: string }) { const { t } = useTranslation() 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 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, ...getTimeData(chartTime, lastCreated), } }, [systemStats, containerData]) // get stats useEffect(() => { if (!system.id || !chartTime) { return } Promise.allSettled([ getStats('system_stats', system, chartTime), getStats('container_stats', system, chartTime), ]).then(([systemStats, containerStats]) => { 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: number | string = system.info.u if (system.info.u < 172800) { const hours = Math.trunc(uptime / 3600) uptime = `${hours} hour${hours == 1 ? '' : 's'}` } else { uptime = `${Math.trunc(system.info?.u / 86400)} days` } 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: 'Uptime' }, { value: system.info.k, Icon: TuxIcon, label: '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 } 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('monitor.toggle_grid')}
{/* main charts */}
: null} > {containerFilterBar && ( )} {containerFilterBar && ( )} : null} > : null} description={t('monitor.bandwidth_des')} > {containerFilterBar && containerData.length > 0 && (
{/* @ts-ignore */}
)} {(systemStats.at(-1)?.stats.su ?? 0) > 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 (
: null} >
) })}
)}
{/* add space for tooltip if more than 12 containers */} {bottomSpacing > 0 && } ) } function ContainerFilterBar() { const { t } = useTranslation() const containerFilter = useStore($containerFilter) const handleChange = useCallback((e: React.ChangeEvent) => { $containerFilter.set(e.target.value) }, []) return ( <> {containerFilter && ( )} ) } function SelectAvgMax({ store, }: { store: [boolean, React.Dispatch>] }) { const { t } = useTranslation() const [max, setMax] = store const Icon = max ? ChartMax : ChartAverage return ( ) } function ChartCard({ title, description, children, grid, cornerEl, }: { title: string description: string children: React.ReactNode grid?: boolean cornerEl?: JSX.Element | null }) { const { isIntersecting, ref } = useIntersectionObserver() return ( {title} {description} {cornerEl && (
{cornerEl}
)}
{} {isIntersecting && children}
) }