mirror of
https://github.com/fankes/beszel.git
synced 2025-10-20 02:09:28 +08:00
add temperature chart
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -23,6 +24,7 @@ import (
|
|||||||
"github.com/shirou/gopsutil/v4/disk"
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
"github.com/shirou/gopsutil/v4/host"
|
"github.com/shirou/gopsutil/v4/host"
|
||||||
"github.com/shirou/gopsutil/v4/mem"
|
"github.com/shirou/gopsutil/v4/mem"
|
||||||
|
"github.com/shirou/gopsutil/v4/sensors"
|
||||||
|
|
||||||
sshServer "github.com/gliderlabs/ssh"
|
sshServer "github.com/gliderlabs/ssh"
|
||||||
psutilNet "github.com/shirou/gopsutil/v4/net"
|
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||||
@@ -140,6 +142,21 @@ func (a *Agent) getSystemStats() (*system.Info, *system.Stats) {
|
|||||||
a.netIoStats.Time = time.Now()
|
a.netIoStats.Time = time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// temperatures
|
||||||
|
if temps, err := sensors.SensorsTemperatures(); err == nil {
|
||||||
|
systemStats.Temperatures = make(map[string]float64)
|
||||||
|
// log.Printf("Temperatures: %+v\n", temps)
|
||||||
|
for i, temp := range temps {
|
||||||
|
if _, ok := systemStats.Temperatures[temp.SensorKey]; ok {
|
||||||
|
// if key already exists, append int to key
|
||||||
|
systemStats.Temperatures[temp.SensorKey+"_"+strconv.Itoa(i)] = twoDecimals(temp.Temperature)
|
||||||
|
} else {
|
||||||
|
systemStats.Temperatures[temp.SensorKey] = twoDecimals(temp.Temperature)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// log.Printf("Temperature map: %+v\n", systemStats.Temperatures)
|
||||||
|
}
|
||||||
|
|
||||||
systemInfo := &system.Info{
|
systemInfo := &system.Info{
|
||||||
Cpu: systemStats.Cpu,
|
Cpu: systemStats.Cpu,
|
||||||
MemPct: systemStats.MemPct,
|
MemPct: systemStats.MemPct,
|
||||||
|
@@ -6,20 +6,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
Cpu float64 `json:"cpu"`
|
Cpu float64 `json:"cpu"`
|
||||||
Mem float64 `json:"m"`
|
Mem float64 `json:"m"`
|
||||||
MemUsed float64 `json:"mu"`
|
MemUsed float64 `json:"mu"`
|
||||||
MemPct float64 `json:"mp"`
|
MemPct float64 `json:"mp"`
|
||||||
MemBuffCache float64 `json:"mb"`
|
MemBuffCache float64 `json:"mb"`
|
||||||
Swap float64 `json:"s"`
|
Swap float64 `json:"s"`
|
||||||
SwapUsed float64 `json:"su"`
|
SwapUsed float64 `json:"su"`
|
||||||
Disk float64 `json:"d"`
|
Disk float64 `json:"d"`
|
||||||
DiskUsed float64 `json:"du"`
|
DiskUsed float64 `json:"du"`
|
||||||
DiskPct float64 `json:"dp"`
|
DiskPct float64 `json:"dp"`
|
||||||
DiskRead float64 `json:"dr"`
|
DiskRead float64 `json:"dr"`
|
||||||
DiskWrite float64 `json:"dw"`
|
DiskWrite float64 `json:"dw"`
|
||||||
NetworkSent float64 `json:"ns"`
|
NetworkSent float64 `json:"ns"`
|
||||||
NetworkRecv float64 `json:"nr"`
|
NetworkRecv float64 `json:"nr"`
|
||||||
|
Temperatures map[string]float64 `json:"t,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DiskIoStats struct {
|
type DiskIoStats struct {
|
||||||
|
@@ -166,7 +166,11 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
// Calculate the average stats of a list of system_stats records without reflect
|
// Calculate the average stats of a list of system_stats records without reflect
|
||||||
func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Stats {
|
func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Stats {
|
||||||
var sum system.Stats
|
var sum system.Stats
|
||||||
|
sum.Temperatures = make(map[string]float64)
|
||||||
|
|
||||||
count := float64(len(records))
|
count := float64(len(records))
|
||||||
|
// use different counter for temps in case some records don't have them
|
||||||
|
tempCount := float64(0)
|
||||||
|
|
||||||
var stats system.Stats
|
var stats system.Stats
|
||||||
for _, record := range records {
|
for _, record := range records {
|
||||||
@@ -185,9 +189,18 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
|
|||||||
sum.DiskWrite += stats.DiskWrite
|
sum.DiskWrite += stats.DiskWrite
|
||||||
sum.NetworkSent += stats.NetworkSent
|
sum.NetworkSent += stats.NetworkSent
|
||||||
sum.NetworkRecv += stats.NetworkRecv
|
sum.NetworkRecv += stats.NetworkRecv
|
||||||
|
if stats.Temperatures != nil {
|
||||||
|
tempCount++
|
||||||
|
for key, value := range stats.Temperatures {
|
||||||
|
if _, ok := sum.Temperatures[key]; !ok {
|
||||||
|
sum.Temperatures[key] = 0
|
||||||
|
}
|
||||||
|
sum.Temperatures[key] += value
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return system.Stats{
|
stats = system.Stats{
|
||||||
Cpu: twoDecimals(sum.Cpu / count),
|
Cpu: twoDecimals(sum.Cpu / count),
|
||||||
Mem: twoDecimals(sum.Mem / count),
|
Mem: twoDecimals(sum.Mem / count),
|
||||||
MemUsed: twoDecimals(sum.MemUsed / count),
|
MemUsed: twoDecimals(sum.MemUsed / count),
|
||||||
@@ -203,6 +216,15 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
|
|||||||
NetworkSent: twoDecimals(sum.NetworkSent / count),
|
NetworkSent: twoDecimals(sum.NetworkSent / count),
|
||||||
NetworkRecv: twoDecimals(sum.NetworkRecv / count),
|
NetworkRecv: twoDecimals(sum.NetworkRecv / count),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(sum.Temperatures) != 0 {
|
||||||
|
stats.Temperatures = make(map[string]float64)
|
||||||
|
for key, value := range sum.Temperatures {
|
||||||
|
stats.Temperatures[key] = twoDecimals(value / tempCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the average stats of a list of container_stats records
|
// Calculate the average stats of a list of container_stats records
|
||||||
|
@@ -62,7 +62,7 @@ export function AddSystemButton({ className }: { className?: string }) {
|
|||||||
className={cn('flex gap-1 max-xs:h-[2.4rem]', className, isReadOnlyUser() && 'hidden')}
|
className={cn('flex gap-1 max-xs:h-[2.4rem]', className, isReadOnlyUser() && 'hidden')}
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-4 w-4 -ml-1" />
|
<PlusIcon className="h-4 w-4 -ml-1" />
|
||||||
Add <span className="hidden sm:inline">System</span>
|
Add <span className="hidden xs:inline">System</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="w-[90%] sm:max-w-[425px] rounded-lg">
|
<DialogContent className="w-[90%] sm:max-w-[425px] rounded-lg">
|
||||||
|
129
beszel/site/src/components/charts/temperature-chart.tsx
Normal file
129
beszel/site/src/components/charts/temperature-chart.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
} from '@/components/ui/chart'
|
||||||
|
import {
|
||||||
|
chartTimeData,
|
||||||
|
cn,
|
||||||
|
formatShortDate,
|
||||||
|
toFixedWithoutTrailingZeros,
|
||||||
|
useYaxisWidth,
|
||||||
|
} from '@/lib/utils'
|
||||||
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { $chartTime } from '@/lib/stores'
|
||||||
|
import { SystemStatsRecord } from '@/types'
|
||||||
|
import { useMemo, useRef } from 'react'
|
||||||
|
|
||||||
|
export default function TemperatureChart({
|
||||||
|
ticks,
|
||||||
|
systemData,
|
||||||
|
}: {
|
||||||
|
ticks: number[]
|
||||||
|
systemData: SystemStatsRecord[]
|
||||||
|
}) {
|
||||||
|
const chartRef = useRef<HTMLDivElement>(null)
|
||||||
|
const yAxisWidth = useYaxisWidth(chartRef)
|
||||||
|
const chartTime = useStore($chartTime)
|
||||||
|
|
||||||
|
/** Format temperature data for chart and assign colors */
|
||||||
|
const newChartData = useMemo(() => {
|
||||||
|
const chartData = { data: [], colors: {} } as {
|
||||||
|
data: Record<string, number | string>[]
|
||||||
|
colors: Record<string, string>
|
||||||
|
}
|
||||||
|
const tempSums = {} as Record<string, number>
|
||||||
|
for (let data of systemData) {
|
||||||
|
let newData = { created: data.created } as Record<string, number | string>
|
||||||
|
let keys = Object.keys(data.stats?.t ?? {})
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
let key = keys[i]
|
||||||
|
newData[key] = data.stats.t![key]
|
||||||
|
tempSums[key] = (tempSums[key] ?? 0) + newData[key]
|
||||||
|
}
|
||||||
|
chartData.data.push(newData)
|
||||||
|
}
|
||||||
|
const keys = Object.keys(tempSums).sort((a, b) => tempSums[b] - tempSums[a])
|
||||||
|
for (let key of keys) {
|
||||||
|
chartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)`
|
||||||
|
}
|
||||||
|
return chartData
|
||||||
|
}, [systemData])
|
||||||
|
|
||||||
|
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={chartRef}>
|
||||||
|
{/* {!yAxisSet && <Spinner />} */}
|
||||||
|
<ChartContainer
|
||||||
|
config={{}}
|
||||||
|
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||||
|
'opacity-100': yAxisSet,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<LineChart
|
||||||
|
accessibilityLayer
|
||||||
|
data={newChartData.data}
|
||||||
|
margin={{
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 10,
|
||||||
|
bottom: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<YAxis
|
||||||
|
className="tracking-tighter"
|
||||||
|
width={yAxisWidth}
|
||||||
|
tickFormatter={(value) => toFixedWithoutTrailingZeros(value, 2)}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
unit={' °C'}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="created"
|
||||||
|
domain={[ticks[0], ticks.at(-1)!]}
|
||||||
|
ticks={ticks}
|
||||||
|
type="number"
|
||||||
|
scale={'time'}
|
||||||
|
minTickGap={35}
|
||||||
|
tickMargin={8}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={chartTimeData[chartTime].format}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
animationEasing="ease-out"
|
||||||
|
animationDuration={150}
|
||||||
|
// @ts-ignore
|
||||||
|
itemSorter={(a, b) => b.value - a.value}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
unit=" °C"
|
||||||
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
|
indicator="line"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{Object.keys(newChartData.colors).map((key) => (
|
||||||
|
<Line
|
||||||
|
key={key}
|
||||||
|
dataKey={key}
|
||||||
|
name={key}
|
||||||
|
type="monotoneX"
|
||||||
|
dot={false}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
fill="hsl(360, 60%, 55%)"
|
||||||
|
stroke={newChartData.colors[key]}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@@ -20,6 +20,7 @@ const DiskIoChart = lazy(() => import('../charts/disk-io-chart'))
|
|||||||
const BandwidthChart = lazy(() => import('../charts/bandwidth-chart'))
|
const BandwidthChart = lazy(() => import('../charts/bandwidth-chart'))
|
||||||
const ContainerNetChart = lazy(() => import('../charts/container-net-chart'))
|
const ContainerNetChart = lazy(() => import('../charts/container-net-chart'))
|
||||||
const SwapChart = lazy(() => import('../charts/swap-chart'))
|
const SwapChart = lazy(() => import('../charts/swap-chart'))
|
||||||
|
const TemperatureChart = lazy(() => import('../charts/temperature-chart'))
|
||||||
|
|
||||||
export default function SystemDetail({ name }: { name: string }) {
|
export default function SystemDetail({ name }: { name: string }) {
|
||||||
const systems = useStore($systems)
|
const systems = useStore($systems)
|
||||||
@@ -271,6 +272,12 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
</ChartCard>
|
</ChartCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{systemStats.at(-1)?.stats.t && (
|
||||||
|
<ChartCard title="Temperature" description="Temperature of system components">
|
||||||
|
<TemperatureChart ticks={ticks} systemData={systemStats} />
|
||||||
|
</ChartCard>
|
||||||
|
)}
|
||||||
|
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title="Disk Usage"
|
title="Disk Usage"
|
||||||
description="Usage of partition where the root filesystem is mounted"
|
description="Usage of partition where the root filesystem is mounted"
|
||||||
|
@@ -21,7 +21,6 @@ import {
|
|||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
|
|
||||||
import { Button, buttonVariants } from '@/components/ui/button'
|
import { Button, buttonVariants } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
@@ -258,7 +258,7 @@ const ChartLegendContent = React.forwardRef<
|
|||||||
nameKey?: string
|
nameKey?: string
|
||||||
}
|
}
|
||||||
>(({ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey }, ref) => {
|
>(({ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey }, ref) => {
|
||||||
const { config } = useChart()
|
// const { config } = useChart()
|
||||||
|
|
||||||
if (!payload?.length) {
|
if (!payload?.length) {
|
||||||
return null
|
return null
|
||||||
@@ -268,33 +268,35 @@ const ChartLegendContent = React.forwardRef<
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-center gap-4',
|
'flex items-center justify-center gap-4 gap-y-1 flex-wrap',
|
||||||
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
|
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{payload.map((item) => {
|
{payload.map((item) => {
|
||||||
const key = `${nameKey || item.dataKey || 'value'}`
|
// const key = `${nameKey || item.dataKey || 'value'}`
|
||||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
// const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.value}
|
key={item.value}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground'
|
// 'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground text-muted-foreground'
|
||||||
|
'flex items-center gap-1.5 text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{itemConfig?.icon && !hideIcon ? (
|
{/* {itemConfig?.icon && !hideIcon ? (
|
||||||
<itemConfig.icon />
|
<itemConfig.icon />
|
||||||
) : (
|
) : ( */}
|
||||||
<div
|
<div
|
||||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: item.color,
|
backgroundColor: item.color,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
{item.value}
|
||||||
{itemConfig?.label}
|
{/* )} */}
|
||||||
|
{/* {itemConfig?.label} */}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
2
beszel/site/src/types.d.ts
vendored
2
beszel/site/src/types.d.ts
vendored
@@ -57,6 +57,8 @@ export interface SystemStats {
|
|||||||
ns: number
|
ns: number
|
||||||
/** network received (mb) */
|
/** network received (mb) */
|
||||||
nr: number
|
nr: number
|
||||||
|
/** temperatures */
|
||||||
|
t?: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContainerStatsRecord extends RecordModel {
|
export interface ContainerStatsRecord extends RecordModel {
|
||||||
|
Reference in New Issue
Block a user