mirror of
https://github.com/henrygd/beszel.git
synced 2025-12-17 02:36:17 +01:00
492 lines
16 KiB
Go
492 lines
16 KiB
Go
import { $systems, pb, $chartTime, $containerFilter, $userSettings } 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,
|
|
StretchHorizontalIcon,
|
|
XIcon,
|
|
} from 'lucide-react'
|
|
import ChartTimeSelect from '../charts/chart-time-select'
|
|
import {
|
|
chartTimeData,
|
|
cn,
|
|
getPbTimestamp,
|
|
useClampedIsInViewport,
|
|
useLocalStorage,
|
|
} from '@/lib/utils'
|
|
import { Separator } from '../ui/separator'
|
|
import { scaleTime } from 'd3-scale'
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
|
|
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'))
|
|
const MemChart = lazy(() => import('../charts/mem-chart'))
|
|
const ContainerMemChart = lazy(() => import('../charts/container-mem-chart'))
|
|
const DiskChart = lazy(() => import('../charts/disk-chart'))
|
|
const DiskIoChart = lazy(() => import('../charts/disk-io-chart'))
|
|
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)
|
|
const chartTime = useStore($chartTime)
|
|
const [grid, setGrid] = useLocalStorage('grid', true)
|
|
const [ticks, setTicks] = useState([] as number[])
|
|
const [system, setSystem] = useState({} as SystemRecord)
|
|
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
|
|
const [hasDockerStats, setHasDocker] = useState(false)
|
|
const netCardRef = useRef<HTMLDivElement>(null)
|
|
const [dockerCpuChartData, setDockerCpuChartData] = useState<Record<string, number | string>[]>(
|
|
[]
|
|
)
|
|
const [dockerMemChartData, setDockerMemChartData] = useState<Record<string, number | string>[]>(
|
|
[]
|
|
)
|
|
const [dockerNetChartData, setDockerNetChartData] = useState<Record<string, number | number[]>[]>(
|
|
[]
|
|
)
|
|
|
|
useEffect(() => {
|
|
document.title = `${name} / Beszel`
|
|
return () => {
|
|
resetCharts()
|
|
$chartTime.set($userSettings.get().chartTime)
|
|
$containerFilter.set('')
|
|
setHasDocker(false)
|
|
}
|
|
}, [name])
|
|
|
|
function resetCharts() {
|
|
setSystemStats([])
|
|
setDockerCpuChartData([])
|
|
setDockerMemChartData([])
|
|
setDockerNetChartData([])
|
|
}
|
|
|
|
useEffect(resetCharts, [chartTime])
|
|
|
|
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<SystemRecord>('systems').subscribe(system.id, (e) => {
|
|
setSystem(e.record)
|
|
})
|
|
return () => {
|
|
pb.collection('systems').unsubscribe(system.id)
|
|
}
|
|
}, [system])
|
|
|
|
async function getStats<T>(collection: string): Promise<T[]> {
|
|
return await pb.collection<T>(collection).getFullList({
|
|
filter: pb.filter('system={:id} && created > {:created} && type={:type}', {
|
|
id: system.id,
|
|
created: getPbTimestamp(chartTime),
|
|
type: chartTimeData[chartTime].type,
|
|
}),
|
|
fields: 'created,stats',
|
|
sort: 'created',
|
|
})
|
|
}
|
|
|
|
// add empty values between records to make gaps if interval is too large
|
|
function addEmptyValues<T extends SystemStatsRecord | ContainerStatsRecord>(
|
|
records: T[],
|
|
expectedInterval: number
|
|
) {
|
|
const modifiedRecords: T[] = []
|
|
let prevTime = 0
|
|
for (let i = 0; i < records.length; i++) {
|
|
const record = records[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
|
|
}
|
|
|
|
// get stats
|
|
useEffect(() => {
|
|
if (!system.id || !chartTime) {
|
|
return
|
|
}
|
|
Promise.allSettled([
|
|
getStats<SystemStatsRecord>('system_stats'),
|
|
getStats<ContainerStatsRecord>('container_stats'),
|
|
]).then(([systemStats, containerStats]) => {
|
|
const expectedInterval = chartTimeData[chartTime].expectedInterval
|
|
if (containerStats.status === 'fulfilled' && containerStats.value.length) {
|
|
makeContainerData(addEmptyValues(containerStats.value, expectedInterval))
|
|
setHasDocker(true)
|
|
}
|
|
if (systemStats.status === 'fulfilled') {
|
|
setSystemStats(addEmptyValues(systemStats.value, expectedInterval))
|
|
}
|
|
})
|
|
}, [system, chartTime])
|
|
|
|
useEffect(() => {
|
|
if (!systemStats.length) {
|
|
return
|
|
}
|
|
const now = new Date()
|
|
const startTime = chartTimeData[chartTime].getOffset(now)
|
|
const scale = scaleTime([startTime.getTime(), now], [0, systemStats.length])
|
|
setTicks(scale.ticks(chartTimeData[chartTime].ticks).map((d) => d.getTime()))
|
|
}, [chartTime, systemStats])
|
|
|
|
// make container stats for charts
|
|
const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => {
|
|
// console.log('containers', containers)
|
|
const dockerCpuData = []
|
|
const dockerMemData = []
|
|
const dockerNetData = []
|
|
for (let { created, stats } of containers) {
|
|
if (!created) {
|
|
let nullData = { time: null } as unknown
|
|
dockerCpuData.push(nullData as Record<string, number | string>)
|
|
dockerMemData.push(nullData as Record<string, number | string>)
|
|
dockerNetData.push(nullData as Record<string, number | number[]>)
|
|
continue
|
|
}
|
|
const time = new Date(created).getTime()
|
|
let cpuData = { time } as Record<string, number | string>
|
|
let memData = { time } as Record<string, number | string>
|
|
let netData = { time } as Record<string, number | number[]>
|
|
for (let container of stats) {
|
|
cpuData[container.n] = container.c
|
|
memData[container.n] = container.m
|
|
netData[container.n] = [container.ns, container.nr, container.ns + container.nr] // sent, received, total
|
|
}
|
|
dockerCpuData.push(cpuData)
|
|
dockerMemData.push(memData)
|
|
dockerNetData.push(netData)
|
|
}
|
|
setDockerCpuChartData(dockerCpuData)
|
|
setDockerMemChartData(dockerMemData)
|
|
setDockerNetChartData(dockerNetData)
|
|
}, [])
|
|
|
|
const uptime = useMemo(() => {
|
|
let uptime = system.info?.u || 0
|
|
if (uptime < 172800) {
|
|
return `${Math.trunc(uptime / 3600)} hours`
|
|
}
|
|
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 (
|
|
<>
|
|
<div id="chartwrap" className="grid gap-4 mb-10">
|
|
{/* system info */}
|
|
<Card>
|
|
<div className="grid lg:flex items-center gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
|
|
<div>
|
|
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
|
|
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
|
|
<div className="capitalize flex gap-2 items-center">
|
|
<span className={cn('relative flex h-3 w-3')}>
|
|
{system.status === 'up' && (
|
|
<span
|
|
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
|
|
style={{ animationDuration: '1.5s' }}
|
|
></span>
|
|
)}
|
|
<span
|
|
className={cn('relative inline-flex rounded-full h-3 w-3', {
|
|
'bg-green-500': system.status === 'up',
|
|
'bg-red-500': system.status === 'down',
|
|
'bg-primary/40': system.status === 'paused',
|
|
'bg-yellow-500': system.status === 'pending',
|
|
})}
|
|
></span>
|
|
</span>
|
|
{system.status}
|
|
</div>
|
|
<Separator orientation="vertical" className="h-4 bg-primary/30" />
|
|
<div className="flex gap-1.5 items-center">
|
|
<GlobeIcon className="h-4 w-4" /> {system.host}
|
|
</div>
|
|
{system.info?.u && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<Separator orientation="vertical" className="h-4 bg-primary/30" />
|
|
<TooltipTrigger asChild>
|
|
<div className="flex gap-1.5 items-center">
|
|
<ClockArrowUp className="h-4 w-4" /> {uptime}
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Uptime</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
{system.info?.m && (
|
|
<>
|
|
<Separator orientation="vertical" className="h-4 bg-primary/30" />
|
|
<div className="flex gap-1.5 items-center">
|
|
<CpuIcon className="h-4 w-4" />
|
|
{system.info.m} ({system.info.c}c / {system.info.t}t)
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="lg:ml-auto flex items-center gap-2 max-sm:-mb-1">
|
|
<ChartTimeSelect className="w-full lg:w-40" />
|
|
<TooltipProvider delayDuration={100}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
aria-label="Toggle grid"
|
|
className={cn(
|
|
buttonVariants({ variant: 'outline', size: 'icon' }),
|
|
'hidden lg:flex p-0 text-primary'
|
|
)}
|
|
onClick={() => setGrid(!grid)}
|
|
>
|
|
{grid ? (
|
|
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-85" />
|
|
) : (
|
|
<StretchHorizontalIcon className="h-[1.2rem] w-[1.2rem] opacity-85" />
|
|
)}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Toggle grid</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* main charts */}
|
|
<div className="grid lg:grid-cols-2 gap-4">
|
|
<ChartCard
|
|
grid={grid}
|
|
title="Total CPU Usage"
|
|
description="Average system-wide CPU utilization"
|
|
>
|
|
<CpuChart ticks={ticks} systemData={systemStats} />
|
|
</ChartCard>
|
|
|
|
{hasDockerStats && (
|
|
<ChartCard
|
|
grid={grid}
|
|
title="Docker CPU Usage"
|
|
description="CPU utilization of docker containers"
|
|
isContainerChart={true}
|
|
>
|
|
<ContainerCpuChart chartData={dockerCpuChartData} ticks={ticks} />
|
|
</ChartCard>
|
|
)}
|
|
|
|
<ChartCard
|
|
grid={grid}
|
|
title="Total Memory Usage"
|
|
description="Precise utilization at the recorded time"
|
|
>
|
|
<MemChart ticks={ticks} systemData={systemStats} />
|
|
</ChartCard>
|
|
|
|
{hasDockerStats && (
|
|
<ChartCard
|
|
grid={grid}
|
|
title="Docker Memory Usage"
|
|
description="Memory usage of docker containers"
|
|
isContainerChart={true}
|
|
>
|
|
<ContainerMemChart chartData={dockerMemChartData} ticks={ticks} />
|
|
</ChartCard>
|
|
)}
|
|
|
|
{(systemStats.at(-1)?.stats.s ?? 0) > 0 && (
|
|
<ChartCard grid={grid} title="Swap Usage" description="Swap space used by the system">
|
|
<SwapChart ticks={ticks} systemData={systemStats} />
|
|
</ChartCard>
|
|
)}
|
|
|
|
<ChartCard grid={grid} title="Disk Space" description="Usage of root partition">
|
|
<DiskChart ticks={ticks} systemData={systemStats} />
|
|
</ChartCard>
|
|
|
|
<ChartCard grid={grid} title="Disk I/O" description="Throughput of root filesystem">
|
|
<DiskIoChart ticks={ticks} systemData={systemStats} />
|
|
</ChartCard>
|
|
|
|
<ChartCard
|
|
grid={grid}
|
|
title="Bandwidth"
|
|
description="Network traffic of public interfaces"
|
|
>
|
|
<BandwidthChart ticks={ticks} systemData={systemStats} />
|
|
</ChartCard>
|
|
|
|
{hasDockerStats && dockerNetChartData.length > 0 && (
|
|
<div
|
|
ref={netCardRef}
|
|
className={cn({
|
|
'col-span-full': !grid,
|
|
})}
|
|
>
|
|
<ChartCard
|
|
title="Docker Network I/O"
|
|
description="Includes traffic between internal services"
|
|
isContainerChart={true}
|
|
>
|
|
<ContainerNetChart chartData={dockerNetChartData} ticks={ticks} />
|
|
</ChartCard>
|
|
</div>
|
|
)}
|
|
|
|
{systemStats.at(-1)?.stats.t && (
|
|
<ChartCard grid={grid} title="Temperature" description="Temperatures of system sensors">
|
|
<TemperatureChart ticks={ticks} systemData={systemStats} />
|
|
</ChartCard>
|
|
)}
|
|
</div>
|
|
|
|
{/* extra filesystem charts */}
|
|
{Object.keys(systemStats.at(-1)?.stats.efs ?? {}).length > 0 && (
|
|
<div className="grid lg:grid-cols-2 gap-4">
|
|
{Object.keys(systemStats.at(-1)?.stats.efs ?? {}).map((extraFsName) => {
|
|
return (
|
|
<div key={extraFsName} className="contents">
|
|
<ChartCard
|
|
grid={grid}
|
|
title={`${extraFsName} Usage`}
|
|
description={`Disk usage of ${extraFsName}`}
|
|
>
|
|
<ExFsDiskChart ticks={ticks} systemData={systemStats} fs={extraFsName} />
|
|
</ChartCard>
|
|
<ChartCard
|
|
grid={grid}
|
|
title={`${extraFsName} I/O`}
|
|
description={`Throughput of of ${extraFsName}`}
|
|
>
|
|
<ExFsDiskIoChart ticks={ticks} systemData={systemStats} fs={extraFsName} />
|
|
</ChartCard>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* add space for tooltip if more than 12 containers */}
|
|
{bottomSpacing > 0 && <span className="block" style={{ height: bottomSpacing }} />}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function ContainerFilterBar() {
|
|
const containerFilter = useStore($containerFilter)
|
|
|
|
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
$containerFilter.set(e.target.value)
|
|
}, []) // Use an empty dependency array to prevent re-creation
|
|
|
|
return (
|
|
<div className="relative py-1 block sm:w-44 sm:absolute sm:top-2.5 sm:right-3.5">
|
|
<Input
|
|
placeholder="Filter..."
|
|
className="pl-4 pr-8"
|
|
value={containerFilter}
|
|
onChange={handleChange}
|
|
/>
|
|
{containerFilter && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
aria-label="Clear"
|
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
|
onClick={() => $containerFilter.set('')}
|
|
>
|
|
<XIcon className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ChartCard({
|
|
title,
|
|
description,
|
|
children,
|
|
grid,
|
|
isContainerChart,
|
|
}: {
|
|
title: string
|
|
description: string
|
|
children: React.ReactNode
|
|
grid?: boolean
|
|
isContainerChart?: boolean
|
|
}) {
|
|
const target = useRef<HTMLDivElement>(null)
|
|
const [isInViewport, wrappedTargetRef] = useClampedIsInViewport({ target: target })
|
|
|
|
return (
|
|
<Card
|
|
className={cn('pb-2 sm:pb-4 odd:last-of-type:col-span-full', { 'col-span-full': !grid })}
|
|
ref={wrappedTargetRef}
|
|
>
|
|
<CardHeader className="pb-5 pt-4 relative space-y-1 max-sm:py-3 max-sm:px-4">
|
|
<CardTitle className="text-xl sm:text-2xl">{title}</CardTitle>
|
|
<CardDescription>{description}</CardDescription>
|
|
{isContainerChart && <ContainerFilterBar />}
|
|
</CardHeader>
|
|
<CardContent className="pl-0 w-[calc(100%-1.6em)] h-52 relative">
|
|
{<Spinner />}
|
|
{isInViewport && <Suspense>{children}</Suspense>}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|