Compare commits

...

27 Commits

Author SHA1 Message Date
Henry Dollman
23ab1208cd release 0.5.3 2024-10-10 18:36:28 -04:00
Henry Dollman
5b0fac429b move update functions to agent / hub packages 2024-10-10 18:36:01 -04:00
Henry Dollman
efca56ceca add temp debug logs to troubleshoot #196 2024-10-10 18:28:24 -04:00
Henry Dollman
64f0a23969 move fsStats creation to NewAgent function 2024-10-10 18:18:57 -04:00
Henry Dollman
4245da7792 Add caching to the web app to reduce requests for the same data 2024-10-10 18:11:36 -04:00
Henry Dollman
cedf80a869 add max 1m values for cpu, bandwidth, disk io
* removes unused things from chart.tsx
* updates y axis width only if it grows
* add generic area chart component and remove individual cpu, bandwidth, disk io charts
2024-10-10 15:11:48 -04:00
Henry Dollman
76cea9d3c3 increase docker client timeout to 2100ms 2024-10-08 19:17:03 -04:00
Henry Dollman
10ef430826 small refactor in CreateLongerRecords 2024-10-08 12:42:02 -04:00
Henry Dollman
d672017af0 add peak values for cpu and net 2024-10-07 19:20:53 -04:00
Henry Dollman
7a82571921 add time back to memory chart tooltip 2024-10-07 18:19:28 -04:00
Henry Dollman
e81f8ac387 add zfs arc to longer records 2024-10-07 11:45:09 -04:00
Henry Dollman
05faa88e6a release 0.5.2 2024-10-05 18:30:10 -04:00
Henry Dollman
73aae62c2e add ZFS ARC memory accounting 2024-10-05 18:07:42 -04:00
Henry Dollman
af4877ca30 add MEM_CALC env var 2024-10-05 15:29:27 -04:00
Henry Dollman
c407fe9af0 exclude sensor if temp <=0 || temp >= 200 2024-10-05 11:14:20 -04:00
hank
13c9497951 Merge pull request #199 from Bot-wxt1221/main
fix(package-lock.json): fix for nixpkgs
2024-10-04 11:06:58 -04:00
wxt
4274096645 fix(package-lock.json): fix for nixpkgs 2024-10-03 16:18:31 +08:00
Henry Dollman
a213b70a1c release 0.5.1 2024-10-02 19:58:44 -04:00
Henry Dollman
66cc0a4b24 log stats on startup if log level is debug 2024-10-02 19:58:02 -04:00
Henry Dollman
f051f6a5f8 add dockerManager / fix for Docker 24 and older
* dockerManager now handles all docker api interaction and container metrics tracking
* sets unlimited concurrency for docker 24 and older
2024-10-02 19:45:26 -04:00
Henry Dollman
b9f142c28c update go deps (gopsutil v4.24.9) 2024-10-02 19:11:13 -04:00
Henry Dollman
45e1283b83 move system.Info to Agent struct
* cleaner to store entire info struct rather than separate properties for unchanging values
2024-10-02 12:34:42 -04:00
Henry Dollman
94cb5f2798 fix uptime hours pluralization 2024-10-02 12:33:31 -04:00
Henry Dollman
2883467b2b update docker image workflow
- change tag to "*v"
- remove cache
2024-10-02 12:32:19 -04:00
Lindemberg Barbosa
0c77190161 fix: readme link for Monitoring additional disks, partitions, or remote mounts (#195) 2024-10-02 10:40:12 -04:00
Henry Dollman
8d4d072343 fix readme backticks for telnet command 2024-09-30 15:45:46 -04:00
Henry Dollman
d6e0daf52a update js deps and add package-lock.json (#192)
- replaces use-is-in-viewport package with lib/use-intersection-observer.ts due to npm dependency conflict
2024-09-30 14:37:59 -04:00
37 changed files with 5915 additions and 688 deletions

View File

@@ -3,7 +3,7 @@ name: Make docker images
on: on:
push: push:
tags: tags:
- '*' - 'v*'
jobs: jobs:
build: build:
@@ -71,5 +71,3 @@ jobs:
push: ${{ github.ref_type == 'tag' }} push: ${{ github.ref_type == 'tag' }}
tags: ${{ steps.metadata.outputs.tags }} tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }} labels: ${{ steps.metadata.outputs.labels }}
cache-from: type=gha
cache-to: type=gha

1
.gitignore vendored
View File

@@ -11,4 +11,3 @@ dist
beszel/cmd/hub/hub beszel/cmd/hub/hub
beszel/cmd/agent/agent beszel/cmd/agent/agent
node_modules node_modules
package-lock.json

View File

@@ -3,7 +3,6 @@ package main
import ( import (
"beszel" "beszel"
"beszel/internal/agent" "beszel/internal/agent"
"beszel/internal/update"
"fmt" "fmt"
"log" "log"
"os" "os"
@@ -17,7 +16,7 @@ func main() {
case "-v": case "-v":
fmt.Println(beszel.AppName+"-agent", beszel.Version) fmt.Println(beszel.AppName+"-agent", beszel.Version)
case "update": case "update":
update.UpdateBeszelAgent() agent.Update()
} }
os.Exit(0) os.Exit(0)
} }

View File

@@ -3,7 +3,6 @@ package main
import ( import (
"beszel" "beszel"
"beszel/internal/hub" "beszel/internal/hub"
"beszel/internal/update"
_ "beszel/migrations" _ "beszel/migrations"
"github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase"
@@ -22,7 +21,7 @@ func main() {
app.RootCmd.AddCommand(&cobra.Command{ app.RootCmd.AddCommand(&cobra.Command{
Use: "update", Use: "update",
Short: "Update " + beszel.AppName + " to the latest version", Short: "Update " + beszel.AppName + " to the latest version",
Run: func(_ *cobra.Command, _ []string) { update.UpdateBeszel() }, Run: hub.Update,
}) })
hub.NewHub(app).Run() hub.NewHub(app).Run()

View File

@@ -11,7 +11,7 @@ require (
github.com/pocketbase/dbx v1.10.1 github.com/pocketbase/dbx v1.10.1
github.com/pocketbase/pocketbase v0.22.21 github.com/pocketbase/pocketbase v0.22.21
github.com/rhysd/go-github-selfupdate v1.2.3 github.com/rhysd/go-github-selfupdate v1.2.3
github.com/shirou/gopsutil/v4 v4.24.8 github.com/shirou/gopsutil/v4 v4.24.9
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
golang.org/x/crypto v0.27.0 golang.org/x/crypto v0.27.0
) )
@@ -25,7 +25,7 @@ require (
github.com/aws/aws-sdk-go-v2/config v1.27.39 // indirect github.com/aws/aws-sdk-go-v2/config v1.27.39 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.37 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.17.37 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.25 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.26 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
@@ -34,7 +34,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.20 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.20 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.18 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.18 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.63.3 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.64.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.23.3 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.23.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.3 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.31.3 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.31.3 // indirect
@@ -43,6 +43,7 @@ require (
github.com/disintegration/imaging v1.6.2 // indirect github.com/disintegration/imaging v1.6.2 // indirect
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.8.0 // indirect
github.com/fatih/color v1.17.0 // indirect github.com/fatih/color v1.17.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.5 // indirect github.com/gabriel-vasile/mimetype v1.4.5 // indirect
github.com/ganigeorgiev/fexpr v0.4.1 // indirect github.com/ganigeorgiev/fexpr v0.4.1 // indirect
@@ -67,7 +68,6 @@ require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/spf13/cast v1.7.0 // indirect github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/tcnksm/go-gitconfig v0.1.2 // indirect github.com/tcnksm/go-gitconfig v0.1.2 // indirect
@@ -90,8 +90,8 @@ require (
golang.org/x/time v0.6.0 // indirect golang.org/x/time v0.6.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.199.0 // indirect google.golang.org/api v0.199.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240924160255-9d4c2d233b61 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240930140551-af27646dc61f // indirect
google.golang.org/grpc v1.67.0 // indirect google.golang.org/grpc v1.67.1 // indirect
google.golang.org/protobuf v1.34.2 // indirect google.golang.org/protobuf v1.34.2 // indirect
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect
modernc.org/libc v1.61.0 // indirect modernc.org/libc v1.61.0 // indirect

View File

@@ -36,8 +36,8 @@ github.com/aws/aws-sdk-go-v2/credentials v1.17.37 h1:G2aOH01yW8X373JK419THj5QVqu
github.com/aws/aws-sdk-go-v2/credentials v1.17.37/go.mod h1:0ecCjlb7htYCptRD45lXJ6aJDQac6D2NlKGpZqyTG6A= github.com/aws/aws-sdk-go-v2/credentials v1.17.37/go.mod h1:0ecCjlb7htYCptRD45lXJ6aJDQac6D2NlKGpZqyTG6A=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14 h1:C/d03NAmh8C4BZXhuRNboF/DqhBkBCeDiJDcaqIT5pA= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14 h1:C/d03NAmh8C4BZXhuRNboF/DqhBkBCeDiJDcaqIT5pA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14/go.mod h1:7I0Ju7p9mCIdlrfS+JCgqcYD0VXz/N4yozsox+0o078= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14/go.mod h1:7I0Ju7p9mCIdlrfS+JCgqcYD0VXz/N4yozsox+0o078=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.25 h1:HkpHeZMM39sGtMHVYG1buAg93vhj5d7F81y6G0OAbGc= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.26 h1:BTfwWNFVGLxW2bih/V2xhgCsYDQwG1cAWhWoW9Jx7wE=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.25/go.mod h1:j3Vz04ZjaWA6kygOsZRpmWe4CyGqfqq2u3unDTU0QGA= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.26/go.mod h1:LA1/FxoEFFmv7XpkB8KKqLAUz8AePdK9H0Ec7PUKazs=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18 h1:kYQ3H1u0ANr9KEKlGs/jTLrBFPo8P8NaH/w7A01NeeM= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18 h1:kYQ3H1u0ANr9KEKlGs/jTLrBFPo8P8NaH/w7A01NeeM=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18/go.mod h1:r506HmK5JDUh9+Mw4CfGJGSSoqIiLCndAuqXuhbv67Y= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18/go.mod h1:r506HmK5JDUh9+Mw4CfGJGSSoqIiLCndAuqXuhbv67Y=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18 h1:Z7IdFUONvTcvS7YuhtVxN99v2cCoHRXOS4mTr0B/pUc= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18 h1:Z7IdFUONvTcvS7YuhtVxN99v2cCoHRXOS4mTr0B/pUc=
@@ -54,8 +54,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20 h1:Xbwbmk44
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20/go.mod h1:oAfOFzUB14ltPZj1rWwRc3d/6OgD76R8KlvU3EqM9Fg= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20/go.mod h1:oAfOFzUB14ltPZj1rWwRc3d/6OgD76R8KlvU3EqM9Fg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.18 h1:eb+tFOIl9ZsUe2259/BKPeniKuz4/02zZFH/i4Nf8Rg= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.18 h1:eb+tFOIl9ZsUe2259/BKPeniKuz4/02zZFH/i4Nf8Rg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.18/go.mod h1:GVCC2IJNJTmdlyEsSmofEy7EfJncP7DNnXDzRjJ5Keg= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.18/go.mod h1:GVCC2IJNJTmdlyEsSmofEy7EfJncP7DNnXDzRjJ5Keg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.63.3 h1:3zt8qqznMuAZWDTDpcwv9Xr11M/lVj2FsRR7oYBt0OA= github.com/aws/aws-sdk-go-v2/service/s3 v1.64.0 h1:I0p8knB/IDYSQ3dbanaCr4UhiYQ96bvKRhGYxvLyiD8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.63.3/go.mod h1:NLTqRLe3pUNu3nTEHI6XlHLKYmc8fbHUdMxAB6+s41Q= github.com/aws/aws-sdk-go-v2/service/s3 v1.64.0/go.mod h1:NLTqRLe3pUNu3nTEHI6XlHLKYmc8fbHUdMxAB6+s41Q=
github.com/aws/aws-sdk-go-v2/service/sso v1.23.3 h1:rs4JCczF805+FDv2tRhZ1NU0RB2H6ryAvsWPanAr72Y= github.com/aws/aws-sdk-go-v2/service/sso v1.23.3 h1:rs4JCczF805+FDv2tRhZ1NU0RB2H6ryAvsWPanAr72Y=
github.com/aws/aws-sdk-go-v2/service/sso v1.23.3/go.mod h1:XRlMvmad0ZNL+75C5FYdMvbbLkd6qiqz6foR1nA1PXY= github.com/aws/aws-sdk-go-v2/service/sso v1.23.3/go.mod h1:XRlMvmad0ZNL+75C5FYdMvbbLkd6qiqz6foR1nA1PXY=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.3 h1:S7EPdMVZod8BGKQQPTBK+FcX9g7bKR7c4+HxWqHP7Vg= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.3 h1:S7EPdMVZod8BGKQQPTBK+FcX9g7bKR7c4+HxWqHP7Vg=
@@ -84,6 +84,8 @@ github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCO
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE=
github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -227,12 +229,8 @@ github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzx
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.24.8 h1:pVQjIenQkIhqO81mwTaXjTzOMT7d3TZkf43PlVFHENI= github.com/shirou/gopsutil/v4 v4.24.9 h1:KIV+/HaHD5ka5f570RZq+2SaeFsb/pq+fp2DGNWYoOI=
github.com/shirou/gopsutil/v4 v4.24.8/go.mod h1:wE0OrJtj4dG+hYkxqDH3QiBICdKSf04/npcvLLc/oRg= github.com/shirou/gopsutil/v4 v4.24.9/go.mod h1:3fkaHNeYsUFCGZ8+9vZVWtbyM1k2eRnlL+bWO8Bxa/Q=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
@@ -379,15 +377,15 @@ google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eY
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:hL97c3SYopEHblzpxRL4lSs523++l8DYxGM1FQiYmb4= google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:hL97c3SYopEHblzpxRL4lSs523++l8DYxGM1FQiYmb4=
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8= google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8=
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo= google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240924160255-9d4c2d233b61 h1:N9BgCIAUvn/M+p4NJccWPWb3BWh88+zyL0ll9HgbEeM= google.golang.org/genproto/googleapis/rpc v0.0.0-20240930140551-af27646dc61f h1:cUMEy+8oS78BWIH9OWazBkzbr090Od9tWBNtZHkOhf0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240924160255-9d4c2d233b61/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20240930140551-af27646dc61f/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.67.0 h1:IdH9y6PF5MPSdAntIcpjQ+tXO41pcQsfZV2RxtQgVcw= google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
google.golang.org/grpc v1.67.0/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=

View File

@@ -2,44 +2,34 @@
package agent package agent
import ( import (
"beszel/internal/entities/container"
"beszel/internal/entities/system" "beszel/internal/entities/system"
"context" "context"
"log/slog" "log/slog"
"net/http"
"os" "os"
"strings" "strings"
"sync"
"github.com/shirou/gopsutil/v4/common" "github.com/shirou/gopsutil/v4/common"
) )
type Agent struct { type Agent struct {
hostname string // Hostname of the system debug bool // true if LOG_LEVEL is set to debug
kernelVersion string // Kernel version of the system zfs bool // true if system has arcstats
cpuModel string // CPU model of the system memCalc string // Memory calculation formula
cores int // Number of cores of the system fsNames []string // List of filesystem device names being monitored
threads int // Number of threads of the system fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
debug bool // true if LOG_LEVEL is set to debug netInterfaces map[string]struct{} // Stores all valid network interfaces
fsNames []string // List of filesystem device names being monitored netIoStats system.NetIoStats // Keeps track of bandwidth usage
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem dockerManager *dockerManager // Manages Docker API requests
netInterfaces map[string]struct{} // Stores all valid network interfaces sensorsContext context.Context // Sensors context to override sys location
netIoStats system.NetIoStats // Keeps track of bandwidth usage sensorsWhitelist map[string]struct{} // List of sensors to monitor
containerStatsMap map[string]*container.Stats // Keeps track of container stats systemInfo system.Info // Host system info
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to prevContainerStatsMap
dockerClient *http.Client // HTTP client to query docker api
apiContainerList *[]container.ApiInfo // List of containers from docker host
sensorsContext context.Context // Sensors context to override sys location
sensorsWhitelist map[string]struct{} // List of sensors to monitor
} }
func NewAgent() *Agent { func NewAgent() *Agent {
return &Agent{ return &Agent{
containerStatsMap: make(map[string]*container.Stats), sensorsContext: context.Background(),
containerStatsMutex: sync.RWMutex{}, memCalc: os.Getenv("MEM_CALC"),
netIoStats: system.NetIoStats{}, fsStats: make(map[string]*system.FsStats),
dockerClient: newDockerClient(),
sensorsContext: context.Background(),
} }
} }
@@ -73,22 +63,31 @@ func (a *Agent) Run(pubKey []byte, addr string) {
} }
} }
// initialize system info / docker manager
a.initializeSystemInfo() a.initializeSystemInfo()
a.initializeDiskInfo() a.initializeDiskInfo()
a.initializeNetIoStats() a.initializeNetIoStats()
a.dockerManager = newDockerManager()
// if debugging, print stats
if a.debug {
slog.Debug("Stats", "data", a.gatherStats())
}
a.startServer(pubKey, addr) a.startServer(pubKey, addr)
} }
func (a *Agent) gatherStats() system.CombinedData { func (a *Agent) gatherStats() system.CombinedData {
systemInfo, SystemStats := a.getSystemStats() slog.Debug("Getting stats")
systemData := system.CombinedData{ systemData := system.CombinedData{
Stats: SystemStats, Stats: a.getSystemStats(),
Info: systemInfo, Info: a.systemInfo,
} }
// add docker stats // add docker stats
if containerStats, err := a.getDockerStats(); err == nil { if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
systemData.Containers = containerStats systemData.Containers = containerStats
} else {
slog.Debug("Error getting docker stats", "err", err)
} }
// add extra filesystems // add extra filesystems
systemData.Stats.ExtraFs = make(map[string]*system.FsStats) systemData.Stats.ExtraFs = make(map[string]*system.FsStats)

View File

@@ -18,9 +18,6 @@ func (a *Agent) initializeDiskInfo() {
efPath := "/extra-filesystems" efPath := "/extra-filesystems"
hasRoot := false hasRoot := false
// Create map for disk stats
a.fsStats = make(map[string]*system.FsStats)
partitions, err := disk.Partitions(false) partitions, err := disk.Partitions(false)
if err != nil { if err != nil {
slog.Error("Error getting disk partitions", "err", err) slog.Error("Error getting disk partitions", "err", err)

View File

@@ -13,90 +13,110 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/blang/semver"
) )
type dockerManager struct {
client *http.Client // Client to query Docker API
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
sem chan struct{} // Semaphore to limit concurrent container requests
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap
apiContainerList *[]container.ApiInfo // List of containers from Docker API
containerStatsMap map[string]*container.Stats // Keeps track of container stats
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
}
// Add goroutine to the queue
func (d *dockerManager) queue() {
d.sem <- struct{}{}
d.wg.Add(1)
}
// Remove goroutine from the queue
func (d *dockerManager) dequeue() {
<-d.sem
d.wg.Done()
}
// Returns stats for all running containers // Returns stats for all running containers
func (a *Agent) getDockerStats() ([]*container.Stats, error) { func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
resp, err := a.dockerClient.Get("http://localhost/containers/json") resp, err := dm.client.Get("http://localhost/containers/json")
if err != nil { if err != nil {
a.closeIdleConnections(err)
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&a.apiContainerList); err != nil { if err := json.NewDecoder(resp.Body).Decode(&dm.apiContainerList); err != nil {
slog.Error("Error decoding containers", "err", err)
return nil, err return nil, err
} }
containersLength := len(*a.apiContainerList) containersLength := len(*dm.apiContainerList)
containerStats := make([]*container.Stats, containersLength)
// store valid ids to clean up old container ids from map // store valid ids to clean up old container ids from map
validIds := make(map[string]struct{}, containersLength) if dm.validIds == nil {
dm.validIds = make(map[string]struct{}, containersLength)
} else {
clear(dm.validIds)
}
var wg sync.WaitGroup for _, ctr := range *dm.apiContainerList {
for i, ctr := range *a.apiContainerList {
ctr.IdShort = ctr.Id[:12] ctr.IdShort = ctr.Id[:12]
validIds[ctr.IdShort] = struct{}{} dm.validIds[ctr.IdShort] = struct{}{}
// check if container is less than 1 minute old (possible restart) // check if container is less than 1 minute old (possible restart)
// note: can't use Created field because it's not updated on restart // note: can't use Created field because it's not updated on restart
if strings.Contains(ctr.Status, "second") { if strings.Contains(ctr.Status, "second") {
// if so, remove old container data // if so, remove old container data
a.deleteContainerStatsSync(ctr.IdShort) dm.deleteContainerStatsSync(ctr.IdShort)
} }
wg.Add(1) dm.queue()
go func() { go func() {
defer wg.Done() defer dm.dequeue()
stats, err := a.getContainerStats(ctr) err := dm.updateContainerStats(ctr)
if err != nil { if err != nil {
// close idle connections if error is a network timeout dm.deleteContainerStatsSync(ctr.IdShort)
isTimeout := a.closeIdleConnections(err)
// delete container from map if not a timeout
if !isTimeout {
a.deleteContainerStatsSync(ctr.IdShort)
}
// retry once // retry once
stats, err = a.getContainerStats(ctr) err = dm.updateContainerStats(ctr)
if err != nil { if err != nil {
slog.Error("Error getting container stats", "err", err) slog.Error("Error getting container stats", "err", err)
} }
} }
containerStats[i] = stats
}() }()
} }
wg.Wait() dm.wg.Wait()
// remove old / invalid container stats // populate final stats and remove old / invalid container stats
for id := range a.containerStatsMap { stats := make([]*container.Stats, 0, containersLength)
if _, exists := validIds[id]; !exists { for id, v := range dm.containerStatsMap {
delete(a.containerStatsMap, id) if _, exists := dm.validIds[id]; !exists {
delete(dm.containerStatsMap, id)
} else {
stats = append(stats, v)
} }
} }
return containerStats, nil return stats, nil
} }
// Returns stats for individual container // Updates stats for individual container
func (a *Agent) getContainerStats(ctr container.ApiInfo) (*container.Stats, error) { func (dm *dockerManager) updateContainerStats(ctr container.ApiInfo) error {
name := ctr.Names[0][1:] name := ctr.Names[0][1:]
resp, err := a.dockerClient.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1") resp, err := dm.client.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1")
if err != nil { if err != nil {
return &container.Stats{Name: name}, err return err
} }
defer resp.Body.Close() defer resp.Body.Close()
a.containerStatsMutex.Lock() dm.containerStatsMutex.Lock()
defer a.containerStatsMutex.Unlock() defer dm.containerStatsMutex.Unlock()
// add empty values if they doesn't exist in map // add empty values if they doesn't exist in map
stats, initialized := a.containerStatsMap[ctr.IdShort] stats, initialized := dm.containerStatsMap[ctr.IdShort]
if !initialized { if !initialized {
stats = &container.Stats{Name: name} stats = &container.Stats{Name: name}
a.containerStatsMap[ctr.IdShort] = stats dm.containerStatsMap[ctr.IdShort] = stats
} }
// reset current stats // reset current stats
@@ -108,12 +128,12 @@ func (a *Agent) getContainerStats(ctr container.ApiInfo) (*container.Stats, erro
// docker host container stats response // docker host container stats response
var res container.ApiStats var res container.ApiStats
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return stats, err return err
} }
// check if container has valid data, otherwise may be in restart loop (#103) // check if container has valid data, otherwise may be in restart loop (#103)
if res.MemoryStats.Usage == 0 { if res.MemoryStats.Usage == 0 {
return stats, fmt.Errorf("%s - no memory stats - see https://github.com/henrygd/beszel/issues/144", name) return fmt.Errorf("%s - no memory stats - see https://github.com/henrygd/beszel/issues/144", name)
} }
// memory (https://docs.docker.com/reference/cli/docker/container/stats/) // memory (https://docs.docker.com/reference/cli/docker/container/stats/)
@@ -128,7 +148,7 @@ func (a *Agent) getContainerStats(ctr container.ApiInfo) (*container.Stats, erro
systemDelta := res.CPUStats.SystemUsage - stats.PrevCpu[1] systemDelta := res.CPUStats.SystemUsage - stats.PrevCpu[1]
cpuPct := float64(cpuDelta) / float64(systemDelta) * 100 cpuPct := float64(cpuDelta) / float64(systemDelta) * 100
if cpuPct > 100 { if cpuPct > 100 {
return stats, fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct) return fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct)
} }
stats.PrevCpu = [2]uint64{res.CPUStats.CPUUsage.TotalUsage, res.CPUStats.SystemUsage} stats.PrevCpu = [2]uint64{res.CPUStats.CPUUsage.TotalUsage, res.CPUStats.SystemUsage}
@@ -154,11 +174,18 @@ func (a *Agent) getContainerStats(ctr container.ApiInfo) (*container.Stats, erro
stats.NetworkSent = bytesToMegabytes(sent_delta) stats.NetworkSent = bytesToMegabytes(sent_delta)
stats.NetworkRecv = bytesToMegabytes(recv_delta) stats.NetworkRecv = bytesToMegabytes(recv_delta)
return stats, nil return nil
} }
// Creates a new http client for docker api // Delete container stats from map using mutex
func newDockerClient() *http.Client { func (dm *dockerManager) deleteContainerStatsSync(id string) {
dm.containerStatsMutex.Lock()
defer dm.containerStatsMutex.Unlock()
delete(dm.containerStatsMap, id)
}
// Creates a new http client for Docker API
func newDockerManager() *dockerManager {
dockerHost := "unix:///var/run/docker.sock" dockerHost := "unix:///var/run/docker.sock"
if dockerHostEnv, exists := os.LookupEnv("DOCKER_HOST"); exists { if dockerHostEnv, exists := os.LookupEnv("DOCKER_HOST"); exists {
slog.Info("DOCKER_HOST", "host", dockerHostEnv) slog.Info("DOCKER_HOST", "host", dockerHostEnv)
@@ -172,12 +199,8 @@ func newDockerClient() *http.Client {
} }
transport := &http.Transport{ transport := &http.Transport{
ForceAttemptHTTP2: false, DisableCompression: true,
IdleConnTimeout: 90 * time.Second, MaxConnsPerHost: 0,
DisableCompression: true,
MaxConnsPerHost: 10,
MaxIdleConnsPerHost: 10,
DisableKeepAlives: false,
} }
switch parsedURL.Scheme { switch parsedURL.Scheme {
@@ -194,18 +217,37 @@ func newDockerClient() *http.Client {
os.Exit(1) os.Exit(1)
} }
return &http.Client{ dockerClient := &dockerManager{
Timeout: time.Second, client: &http.Client{
Transport: transport, Timeout: time.Millisecond * 2100,
Transport: transport,
},
containerStatsMap: make(map[string]*container.Stats),
} }
}
// Closes idle connections on timeouts to prevent reuse of stale connections // Make sure sem is initialized
func (a *Agent) closeIdleConnections(err error) (isTimeout bool) { concurrency := 200
if netErr, ok := err.(net.Error); ok && netErr.Timeout() { defer func() { dockerClient.sem = make(chan struct{}, concurrency) }()
slog.Warn("Closing idle connections", "err", err)
a.dockerClient.Transport.(*http.Transport).CloseIdleConnections() // Check docker version
return true // (versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch)
var versionInfo struct {
Version string `json:"Version"`
} }
return false resp, err := dockerClient.client.Get("http://localhost/version")
if err != nil {
return dockerClient
}
if err := json.NewDecoder(resp.Body).Decode(&versionInfo); err != nil {
return dockerClient
}
// if version > 25, one-shot works correctly and we can limit concurrent connections / goroutines to 5
if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 {
concurrency = 5
}
slog.Debug("Docker", "version", versionInfo.Version, "concurrency", concurrency)
return dockerClient
} }

View File

@@ -3,9 +3,12 @@ package agent
import ( import (
"beszel" "beszel"
"beszel/internal/entities/system" "beszel/internal/entities/system"
"bufio"
"fmt"
"log/slog" "log/slog"
"os" "os"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/shirou/gopsutil/v4/cpu" "github.com/shirou/gopsutil/v4/cpu"
@@ -18,29 +21,39 @@ import (
// Sets initial / non-changing values about the host system // Sets initial / non-changing values about the host system
func (a *Agent) initializeSystemInfo() { func (a *Agent) initializeSystemInfo() {
a.kernelVersion, _ = host.KernelVersion() a.systemInfo.AgentVersion = beszel.Version
a.hostname, _ = os.Hostname() a.systemInfo.Hostname, _ = os.Hostname()
a.systemInfo.KernelVersion, _ = host.KernelVersion()
// add cpu stats // cpu model
if info, err := cpu.Info(); err == nil && len(info) > 0 { if info, err := cpu.Info(); err == nil && len(info) > 0 {
a.cpuModel = info[0].ModelName a.systemInfo.CpuModel = info[0].ModelName
} }
a.cores, _ = cpu.Counts(false) // cores / threads
a.systemInfo.Cores, _ = cpu.Counts(false)
if threads, err := cpu.Counts(true); err == nil { if threads, err := cpu.Counts(true); err == nil {
if threads > 0 && threads < a.cores { if threads > 0 && threads < a.systemInfo.Cores {
// in lxc logical cores reflects container limits, so use that as cores if lower // in lxc logical cores reflects container limits, so use that as cores if lower
a.cores = threads a.systemInfo.Cores = threads
} else { } else {
a.threads = threads a.systemInfo.Threads = threads
} }
} }
// zfs
if _, err := getARCSize(); err == nil {
a.zfs = true
} else {
slog.Debug("Not monitoring ZFS ARC", "err", err)
}
} }
// Returns current info, stats about the host system // Returns current info, stats about the host system
func (a *Agent) getSystemStats() (system.Info, system.Stats) { func (a *Agent) getSystemStats() system.Stats {
systemStats := system.Stats{} systemStats := system.Stats{}
// cpu percent // cpu percent
slog.Debug("Getting cpu percent")
cpuPct, err := cpu.Percent(0, false) cpuPct, err := cpu.Percent(0, false)
if err != nil { if err != nil {
slog.Error("Error getting cpu percent", "err", err) slog.Error("Error getting cpu percent", "err", err)
@@ -49,16 +62,36 @@ func (a *Agent) getSystemStats() (system.Info, system.Stats) {
} }
// memory // memory
slog.Debug("Getting memory stats")
if v, err := mem.VirtualMemory(); err == nil { if v, err := mem.VirtualMemory(); err == nil {
systemStats.Mem = bytesToGigabytes(v.Total) // swap
systemStats.MemUsed = bytesToGigabytes(v.Used)
systemStats.MemBuffCache = bytesToGigabytes(v.Total - v.Free - v.Used)
systemStats.MemPct = twoDecimals(v.UsedPercent)
systemStats.Swap = bytesToGigabytes(v.SwapTotal) systemStats.Swap = bytesToGigabytes(v.SwapTotal)
systemStats.SwapUsed = bytesToGigabytes(v.SwapTotal - v.SwapFree) systemStats.SwapUsed = bytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached)
// cache + buffers value for default mem calculation
cacheBuff := v.Total - v.Free - v.Used
// htop memory calculation overrides
if a.memCalc == "htop" {
// note: gopsutil automatically adds SReclaimable to v.Cached
cacheBuff = v.Cached + v.Buffers - v.Shared
v.Used = v.Total - (v.Free + cacheBuff)
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
}
// subtract ZFS ARC size from used memory and add as its own category
if a.zfs {
if arcSize, _ := getARCSize(); arcSize > 0 && arcSize < v.Used {
v.Used = v.Used - arcSize
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
systemStats.MemZfsArc = bytesToGigabytes(arcSize)
}
}
systemStats.Mem = bytesToGigabytes(v.Total)
systemStats.MemBuffCache = bytesToGigabytes(cacheBuff)
systemStats.MemUsed = bytesToGigabytes(v.Used)
systemStats.MemPct = twoDecimals(v.UsedPercent)
} }
// disk usage // disk usage
slog.Debug("Getting disk stats")
for _, stats := range a.fsStats { for _, stats := range a.fsStats {
if d, err := disk.Usage(stats.Mountpoint); err == nil { if d, err := disk.Usage(stats.Mountpoint); err == nil {
stats.DiskTotal = bytesToGigabytes(d.Total) stats.DiskTotal = bytesToGigabytes(d.Total)
@@ -79,6 +112,7 @@ func (a *Agent) getSystemStats() (system.Info, system.Stats) {
} }
// disk i/o // disk i/o
slog.Debug("Getting disk I/O stats")
if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil { if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil {
for _, d := range ioCounters { for _, d := range ioCounters {
stats := a.fsStats[d.Name] stats := a.fsStats[d.Name]
@@ -102,6 +136,7 @@ func (a *Agent) getSystemStats() (system.Info, system.Stats) {
} }
// network stats // network stats
slog.Debug("Getting network stats")
if netIO, err := psutilNet.IOCounters(true); err == nil { if netIO, err := psutilNet.IOCounters(true); err == nil {
secondsElapsed := time.Since(a.netIoStats.Time).Seconds() secondsElapsed := time.Since(a.netIoStats.Time).Seconds()
a.netIoStats.Time = time.Now() a.netIoStats.Time = time.Now()
@@ -142,6 +177,7 @@ func (a *Agent) getSystemStats() (system.Info, system.Stats) {
} }
// temperatures // temperatures
slog.Debug("Getting temperatures")
temps, err := sensors.TemperaturesWithContext(a.sensorsContext) temps, err := sensors.TemperaturesWithContext(a.sensorsContext)
if err != nil && a.debug { if err != nil && a.debug {
err.(*sensors.Warnings).Verbose = true err.(*sensors.Warnings).Verbose = true
@@ -152,7 +188,7 @@ func (a *Agent) getSystemStats() (system.Info, system.Stats) {
systemStats.Temperatures = make(map[string]float64, len(temps)) systemStats.Temperatures = make(map[string]float64, len(temps))
for i, sensor := range temps { for i, sensor := range temps {
// skip if temperature is 0 // skip if temperature is 0
if sensor.Temperature == 0 { if sensor.Temperature <= 0 || sensor.Temperature >= 200 {
continue continue
} }
if _, ok := systemStats.Temperatures[sensor.SensorKey]; ok { if _, ok := systemStats.Temperatures[sensor.SensorKey]; ok {
@@ -173,19 +209,37 @@ func (a *Agent) getSystemStats() (system.Info, system.Stats) {
} }
} }
systemInfo := system.Info{ // update base system info
Cpu: systemStats.Cpu, a.systemInfo.Cpu = systemStats.Cpu
MemPct: systemStats.MemPct, a.systemInfo.MemPct = systemStats.MemPct
DiskPct: systemStats.DiskPct, a.systemInfo.DiskPct = systemStats.DiskPct
AgentVersion: beszel.Version, a.systemInfo.Uptime, _ = host.Uptime()
Hostname: a.hostname,
KernelVersion: a.kernelVersion, return systemStats
CpuModel: a.cpuModel, }
Cores: a.cores,
Threads: a.threads, // Returns the size of the ZFS ARC memory cache in bytes
func getARCSize() (uint64, error) {
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
if err != nil {
return 0, err
}
defer file.Close()
// Scan the lines
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "size") {
// Example line: size 4 15032385536
fields := strings.Fields(line)
if len(fields) < 3 {
return 0, err
}
// Return the size as uint64
return strconv.ParseUint(fields[2], 10, 64)
}
} }
systemInfo.Uptime, _ = host.Uptime() return 0, fmt.Errorf("failed to parse size field")
return systemInfo, systemStats
} }

View File

@@ -1,5 +1,4 @@
// Package update handles updating beszel and beszel-agent. package agent
package update
import ( import (
"beszel" "beszel"
@@ -11,51 +10,8 @@ import (
"github.com/rhysd/go-github-selfupdate/selfupdate" "github.com/rhysd/go-github-selfupdate/selfupdate"
) )
func UpdateBeszel() { // Update updates beszel-agent to the latest version
var latest *selfupdate.Release func Update() {
var found bool
var err error
currentVersion := semver.MustParse(beszel.Version)
fmt.Println("beszel", currentVersion)
fmt.Println("Checking for updates...")
updater, _ := selfupdate.NewUpdater(selfupdate.Config{
Filters: []string{"beszel_"},
})
latest, found, err = updater.DetectLatest("henrygd/beszel")
if err != nil {
fmt.Println("Error checking for updates:", err)
os.Exit(1)
}
if !found {
fmt.Println("No updates found")
os.Exit(0)
}
fmt.Println("Latest version:", latest.Version)
if latest.Version.LTE(currentVersion) {
fmt.Println("You are up to date")
return
}
var binaryPath string
fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version)
binaryPath, err = os.Executable()
if err != nil {
fmt.Println("Error getting binary path:", err)
os.Exit(1)
}
err = selfupdate.UpdateTo(latest.AssetURL, binaryPath)
if err != nil {
fmt.Println("Please try rerunning with sudo. Error:", err)
os.Exit(1)
}
fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes))
}
func UpdateBeszelAgent() {
var latest *selfupdate.Release var latest *selfupdate.Release
var found bool var found bool
var err error var err error

View File

@@ -2,13 +2,6 @@ package agent
import "math" import "math"
// delete container stats from map using mutex
func (a *Agent) deleteContainerStatsSync(id string) {
a.containerStatsMutex.Lock()
defer a.containerStatsMutex.Unlock()
delete(a.containerStatsMap, id)
}
func bytesToMegabytes(b float64) float64 { func bytesToMegabytes(b float64) float64 {
return twoDecimals(b / 1048576) return twoDecimals(b / 1048576)
} }

View File

@@ -6,34 +6,42 @@ import (
) )
type Stats struct { type Stats struct {
Cpu float64 `json:"cpu"` Cpu float64 `json:"cpu"`
Mem float64 `json:"m"` MaxCpu float64 `json:"cpum,omitempty"`
MemUsed float64 `json:"mu"` Mem float64 `json:"m"`
MemPct float64 `json:"mp"` MemUsed float64 `json:"mu"`
MemBuffCache float64 `json:"mb"` MemPct float64 `json:"mp"`
Swap float64 `json:"s,omitempty"` MemBuffCache float64 `json:"mb"`
SwapUsed float64 `json:"su,omitempty"` MemZfsArc float64 `json:"mz,omitempty"` // ZFS ARC memory
DiskTotal float64 `json:"d"` Swap float64 `json:"s,omitempty"`
DiskUsed float64 `json:"du"` SwapUsed float64 `json:"su,omitempty"`
DiskPct float64 `json:"dp"` DiskTotal float64 `json:"d"`
DiskReadPs float64 `json:"dr"` DiskUsed float64 `json:"du"`
DiskWritePs float64 `json:"dw"` DiskPct float64 `json:"dp"`
NetworkSent float64 `json:"ns"` DiskReadPs float64 `json:"dr"`
NetworkRecv float64 `json:"nr"` DiskWritePs float64 `json:"dw"`
Temperatures map[string]float64 `json:"t,omitempty"` MaxDiskReadPs float64 `json:"drm,omitempty"`
ExtraFs map[string]*FsStats `json:"efs,omitempty"` MaxDiskWritePs float64 `json:"dwm,omitempty"`
NetworkSent float64 `json:"ns"`
NetworkRecv float64 `json:"nr"`
MaxNetworkSent float64 `json:"nsm,omitempty"`
MaxNetworkRecv float64 `json:"nrm,omitempty"`
Temperatures map[string]float64 `json:"t,omitempty"`
ExtraFs map[string]*FsStats `json:"efs,omitempty"`
} }
type FsStats struct { type FsStats struct {
Time time.Time `json:"-"` Time time.Time `json:"-"`
Root bool `json:"-"` Root bool `json:"-"`
Mountpoint string `json:"-"` Mountpoint string `json:"-"`
DiskTotal float64 `json:"d"` DiskTotal float64 `json:"d"`
DiskUsed float64 `json:"du"` DiskUsed float64 `json:"du"`
TotalRead uint64 `json:"-"` TotalRead uint64 `json:"-"`
TotalWrite uint64 `json:"-"` TotalWrite uint64 `json:"-"`
DiskWritePs float64 `json:"w"` DiskReadPs float64 `json:"r"`
DiskReadPs float64 `json:"r"` DiskWritePs float64 `json:"w"`
MaxDiskReadPS float64 `json:"rm,omitempty"`
MaxDiskWritePS float64 `json:"wm,omitempty"`
} }
type NetIoStats struct { type NetIoStats struct {
@@ -44,17 +52,16 @@ type NetIoStats struct {
} }
type Info struct { type Info struct {
Hostname string `json:"h"` Hostname string `json:"h"`
KernelVersion string `json:"k,omitempty"` KernelVersion string `json:"k,omitempty"`
Cores int `json:"c"` Cores int `json:"c"`
Threads int `json:"t,omitempty"` Threads int `json:"t,omitempty"`
CpuModel string `json:"m"` CpuModel string `json:"m"`
// Os string `json:"o"` Uptime uint64 `json:"u"`
Uptime uint64 `json:"u"` Cpu float64 `json:"cpu"`
Cpu float64 `json:"cpu"` MemPct float64 `json:"mp"`
MemPct float64 `json:"mp"` DiskPct float64 `json:"dp"`
DiskPct float64 `json:"dp"` AgentVersion string `json:"v"`
AgentVersion string `json:"v"`
} }
// Final data structure to return to the hub // Final data structure to return to the hub

View File

@@ -0,0 +1,57 @@
package hub
import (
"beszel"
"fmt"
"os"
"strings"
"github.com/blang/semver"
"github.com/rhysd/go-github-selfupdate/selfupdate"
"github.com/spf13/cobra"
)
// Update updates beszel to the latest version
func Update(_ *cobra.Command, _ []string) {
var latest *selfupdate.Release
var found bool
var err error
currentVersion := semver.MustParse(beszel.Version)
fmt.Println("beszel", currentVersion)
fmt.Println("Checking for updates...")
updater, _ := selfupdate.NewUpdater(selfupdate.Config{
Filters: []string{"beszel_"},
})
latest, found, err = updater.DetectLatest("henrygd/beszel")
if err != nil {
fmt.Println("Error checking for updates:", err)
os.Exit(1)
}
if !found {
fmt.Println("No updates found")
os.Exit(0)
}
fmt.Println("Latest version:", latest.Version)
if latest.Version.LTE(currentVersion) {
fmt.Println("You are up to date")
return
}
var binaryPath string
fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version)
binaryPath, err = os.Executable()
if err != nil {
fmt.Println("Error getting binary path:", err)
os.Exit(1)
}
err = selfupdate.UpdateTo(latest.AssetURL, binaryPath)
if err != nil {
fmt.Println("Please try rerunning with sudo. Error:", err)
os.Exit(1)
}
fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes))
}

View File

@@ -118,17 +118,15 @@ func (rm *RecordManager) CreateLongerRecords() {
continue continue
} }
// average the shorter records and create longer record // average the shorter records and create longer record
var stats interface{}
switch collection.Name {
case "system_stats":
stats = rm.AverageSystemStats(allShorterRecords)
case "container_stats":
stats = rm.AverageContainerStats(allShorterRecords)
}
longerRecord := models.NewRecord(collection) longerRecord := models.NewRecord(collection)
longerRecord.Set("system", system.Id) longerRecord.Set("system", system.Id)
longerRecord.Set("stats", stats)
longerRecord.Set("type", recordData.longerType) longerRecord.Set("type", recordData.longerType)
switch collection.Name {
case "system_stats":
longerRecord.Set("stats", rm.AverageSystemStats(allShorterRecords))
case "container_stats":
longerRecord.Set("stats", rm.AverageContainerStats(allShorterRecords))
}
if err := txDao.SaveRecord(longerRecord); err != nil { if err := txDao.SaveRecord(longerRecord); err != nil {
log.Println("failed to save longer record", "err", err.Error()) log.Println("failed to save longer record", "err", err.Error())
} }
@@ -161,6 +159,7 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
sum.MemUsed += stats.MemUsed sum.MemUsed += stats.MemUsed
sum.MemPct += stats.MemPct sum.MemPct += stats.MemPct
sum.MemBuffCache += stats.MemBuffCache sum.MemBuffCache += stats.MemBuffCache
sum.MemZfsArc += stats.MemZfsArc
sum.Swap += stats.Swap sum.Swap += stats.Swap
sum.SwapUsed += stats.SwapUsed sum.SwapUsed += stats.SwapUsed
sum.DiskTotal += stats.DiskTotal sum.DiskTotal += stats.DiskTotal
@@ -170,6 +169,12 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
sum.DiskWritePs += stats.DiskWritePs sum.DiskWritePs += stats.DiskWritePs
sum.NetworkSent += stats.NetworkSent sum.NetworkSent += stats.NetworkSent
sum.NetworkRecv += stats.NetworkRecv sum.NetworkRecv += stats.NetworkRecv
// set peak values
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent)
sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv)
sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs)
sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs)
// add temps to sum // add temps to sum
if stats.Temperatures != nil { if stats.Temperatures != nil {
tempCount++ tempCount++
@@ -190,25 +195,34 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
sum.ExtraFs[key].DiskUsed += value.DiskUsed sum.ExtraFs[key].DiskUsed += value.DiskUsed
sum.ExtraFs[key].DiskWritePs += value.DiskWritePs sum.ExtraFs[key].DiskWritePs += value.DiskWritePs
sum.ExtraFs[key].DiskReadPs += value.DiskReadPs sum.ExtraFs[key].DiskReadPs += value.DiskReadPs
// peak values
sum.ExtraFs[key].MaxDiskReadPS = max(sum.ExtraFs[key].MaxDiskReadPS, value.MaxDiskReadPS, value.DiskReadPs)
sum.ExtraFs[key].MaxDiskWritePS = max(sum.ExtraFs[key].MaxDiskWritePS, value.MaxDiskWritePS, value.DiskWritePs)
} }
} }
} }
stats = 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),
MemPct: twoDecimals(sum.MemPct / count), MemPct: twoDecimals(sum.MemPct / count),
MemBuffCache: twoDecimals(sum.MemBuffCache / count), MemBuffCache: twoDecimals(sum.MemBuffCache / count),
Swap: twoDecimals(sum.Swap / count), MemZfsArc: twoDecimals(sum.MemZfsArc / count),
SwapUsed: twoDecimals(sum.SwapUsed / count), Swap: twoDecimals(sum.Swap / count),
DiskTotal: twoDecimals(sum.DiskTotal / count), SwapUsed: twoDecimals(sum.SwapUsed / count),
DiskUsed: twoDecimals(sum.DiskUsed / count), DiskTotal: twoDecimals(sum.DiskTotal / count),
DiskPct: twoDecimals(sum.DiskPct / count), DiskUsed: twoDecimals(sum.DiskUsed / count),
DiskReadPs: twoDecimals(sum.DiskReadPs / count), DiskPct: twoDecimals(sum.DiskPct / count),
DiskWritePs: twoDecimals(sum.DiskWritePs / count), DiskReadPs: twoDecimals(sum.DiskReadPs / count),
NetworkSent: twoDecimals(sum.NetworkSent / count), DiskWritePs: twoDecimals(sum.DiskWritePs / count),
NetworkRecv: twoDecimals(sum.NetworkRecv / count), NetworkSent: twoDecimals(sum.NetworkSent / count),
NetworkRecv: twoDecimals(sum.NetworkRecv / count),
MaxCpu: sum.MaxCpu,
MaxDiskReadPs: sum.MaxDiskReadPs,
MaxDiskWritePs: sum.MaxDiskWritePs,
MaxNetworkSent: sum.MaxNetworkSent,
MaxNetworkRecv: sum.MaxNetworkRecv,
} }
if len(sum.Temperatures) != 0 { if len(sum.Temperatures) != 0 {
@@ -222,10 +236,12 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
stats.ExtraFs = make(map[string]*system.FsStats) stats.ExtraFs = make(map[string]*system.FsStats)
for key, value := range sum.ExtraFs { for key, value := range sum.ExtraFs {
stats.ExtraFs[key] = &system.FsStats{ stats.ExtraFs[key] = &system.FsStats{
DiskTotal: twoDecimals(value.DiskTotal / count), DiskTotal: twoDecimals(value.DiskTotal / count),
DiskUsed: twoDecimals(value.DiskUsed / count), DiskUsed: twoDecimals(value.DiskUsed / count),
DiskWritePs: twoDecimals(value.DiskWritePs / count), DiskWritePs: twoDecimals(value.DiskWritePs / count),
DiskReadPs: twoDecimals(value.DiskReadPs / count), DiskReadPs: twoDecimals(value.DiskReadPs / count),
MaxDiskReadPS: value.MaxDiskReadPS,
MaxDiskWritePS: value.MaxDiskWritePS,
} }
} }
} }

Binary file not shown.

4916
beszel/site/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{ {
"name": "site", "name": "beszel",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
@@ -23,7 +23,7 @@
"@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-tooltip": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.2",
"@tanstack/react-table": "^8.20.5", "@tanstack/react-table": "^8.20.5",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.2",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
@@ -34,20 +34,19 @@
"pocketbase": "^0.21.5", "pocketbase": "^0.21.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"recharts": "^2.13.0-alpha.4", "recharts": "^2.13.0-alpha.5",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"use-is-in-viewport": "^1.0.9",
"valibot": "^0.36.0" "valibot": "^0.36.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.1.8", "@types/bun": "^1.1.10",
"@types/react": "^18.3.5", "@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"postcss": "^8.4.44", "postcss": "^8.4.47",
"tailwindcss": "^3.4.10", "tailwindcss": "^3.4.13",
"typescript": "^5.5.4", "typescript": "^5.6.2",
"vite": "^5.4.2" "vite": "^5.4.8"
} }
} }

View File

@@ -0,0 +1,131 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
twoDecimalString,
chartMargin,
} from '@/lib/utils'
// import Spinner from '../spinner'
import { ChartTimes, SystemStatsRecord } from '@/types'
import { useMemo } from 'react'
/** [label, key, color, opacity] */
type DataKeys = [string, string, number, number]
const getNestedValue = (path: string, max = false, data: any): number | null => {
// fallback value (obj?.stats?.cpum ? 0 : null) should only come into play when viewing
// a max value which doesn't exist, or the value was zero and omitted from the stats object.
// so we check if cpum is present. if so, return 0 to make sure the zero value is displayed.
// if not, return null - there is no max data so do not display anything.
return `stats.${path}${max ? 'm' : ''}`
.split('.')
.reduce((acc: any, key: string) => acc?.[key] ?? (data.stats?.cpum ? 0 : null), data)
}
export default function AreaChartDefault({
ticks,
systemData,
showMax = false,
unit = ' MB/s',
chartName,
chartTime,
}: {
ticks: number[]
systemData: SystemStatsRecord[]
showMax?: boolean
unit?: string
chartName: string
chartTime: ChartTimes
}) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const dataKeys: DataKeys[] = useMemo(() => {
// [label, key, color, opacity]
if (chartName === 'CPU Usage') {
return [[chartName, 'cpu', 1, 0.4]]
} else if (chartName === 'dio') {
return [
['Write', 'dw', 3, 0.3],
['Read', 'dr', 1, 0.3],
]
} else if (chartName === 'bw') {
return [
['Sent', 'ns', 5, 0.2],
['Received', 'nr', 2, 0.2],
]
} else if (chartName.startsWith('efs')) {
return [
['Write', `${chartName}.w`, 3, 0.3],
['Read', `${chartName}.r`, 1, 0.3],
]
}
return []
}, [])
return (
<div>
<ChartContainer
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth,
})}
>
<AreaChart accessibilityLayer data={systemData} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
className="tracking-tighter"
width={yAxisWidth}
tickFormatter={(value) => {
const val = toFixedWithoutTrailingZeros(value, 2) + unit
return updateYAxisWidth(val)
}}
tickLine={false}
axisLine={false}
/>
<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}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => twoDecimalString(item.value) + unit}
indicator="line"
/>
}
/>
{dataKeys.map((key, i) => {
const color = `hsl(var(--chart-${key[2]}))`
return (
<Area
key={i}
dataKey={getNestedValue.bind(null, key[1], showMax)}
name={key[0]}
type="monotoneX"
fill={color}
fillOpacity={key[3]}
stroke={color}
isAnimationActive={false}
/>
)
})}
{/* <ChartLegend content={<ChartLegendContent />} /> */}
</AreaChart>
</ChartContainer>
</div>
)
}

View File

@@ -1,105 +0,0 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
twoDecimalString,
} from '@/lib/utils'
// import Spinner from '../spinner'
import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types'
export default function BandwidthChart({
ticks,
systemData,
}: {
ticks: number[]
systemData: SystemStatsRecord[]
}) {
const chartTime = useStore($chartTime)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
return (
<div>
{/* {!yAxisSet && <Spinner />} */}
<ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth,
})}
>
<AreaChart
accessibilityLayer
data={systemData}
margin={{
left: 0,
right: 0,
top: 10,
bottom: 0,
}}
>
<CartesianGrid vertical={false} />
<YAxis
className="tracking-tighter"
width={yAxisWidth}
// domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]}
tickFormatter={(value) => {
const val = toFixedWithoutTrailingZeros(value, 2) + ' MB/s'
return updateYAxisWidth(val)
}}
tickLine={false}
axisLine={false}
// unit={' MB/s'}
/>
<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}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => twoDecimalString(item.value) + ' MB/s'}
indicator="line"
/>
}
/>
<Area
dataKey="stats.ns"
name="Sent"
type="monotoneX"
fill="hsl(var(--chart-5))"
fillOpacity={0.2}
stroke="hsl(var(--chart-5))"
// animationDuration={1200}
isAnimationActive={false}
/>
<Area
dataKey="stats.nr"
name="Received"
type="monotoneX"
fill="hsl(var(--chart-2))"
fillOpacity={0.2}
stroke="hsl(var(--chart-2))"
// animationDuration={1200}
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
</div>
)
}

View File

@@ -6,7 +6,14 @@ import {
ChartTooltipContent, ChartTooltipContent,
} from '@/components/ui/chart' } from '@/components/ui/chart'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useYAxisWidth, chartTimeData, cn, formatShortDate, twoDecimalString } from '@/lib/utils' import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
twoDecimalString,
chartMargin,
} from '@/lib/utils'
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $chartTime, $containerFilter } from '@/lib/stores' import { $chartTime, $containerFilter } from '@/lib/stores'
@@ -65,7 +72,6 @@ export default function ContainerCpuChart({
<div> <div>
{/* {!yAxisSet && <Spinner />} */} {/* {!yAxisSet && <Spinner />} */}
<ChartContainer <ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth, 'opacity-100': yAxisWidth,
})} })}
@@ -74,9 +80,7 @@ export default function ContainerCpuChart({
accessibilityLayer accessibilityLayer
// syncId={'cpu'} // syncId={'cpu'}
data={chartData} data={chartData}
margin={{ margin={chartMargin}
top: 10,
}}
reverseStackOrder={true} reverseStackOrder={true}
> >
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />

View File

@@ -13,6 +13,7 @@ import {
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
twoDecimalString, twoDecimalString,
chartMargin,
} from '@/lib/utils' } from '@/lib/utils'
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
@@ -72,7 +73,6 @@ export default function ContainerMemChart({
<div> <div>
{/* {!yAxisSet && <Spinner />} */} {/* {!yAxisSet && <Spinner />} */}
<ChartContainer <ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth, 'opacity-100': yAxisWidth,
})} })}
@@ -81,9 +81,7 @@ export default function ContainerMemChart({
accessibilityLayer accessibilityLayer
data={chartData} data={chartData}
reverseStackOrder={true} reverseStackOrder={true}
margin={{ margin={chartMargin}
top: 10,
}}
> >
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
<YAxis <YAxis

View File

@@ -13,6 +13,7 @@ import {
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
twoDecimalString, twoDecimalString,
chartMargin,
} from '@/lib/utils' } from '@/lib/utils'
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
@@ -64,15 +65,10 @@ export default function ContainerCpuChart({
return config satisfies ChartConfig return config satisfies ChartConfig
}, [chartData]) }, [chartData])
// if (!chartData.length || !ticks.length) {
// return <Spinner />
// }
return ( return (
<div> <div>
{/* {!yAxisSet && <Spinner />} */} {/* {!yAxisSet && <Spinner />} */}
<ChartContainer <ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth, 'opacity-100': yAxisWidth,
})} })}
@@ -80,9 +76,7 @@ export default function ContainerCpuChart({
<AreaChart <AreaChart
accessibilityLayer accessibilityLayer
data={chartData} data={chartData}
margin={{ margin={chartMargin}
top: 10,
}}
reverseStackOrder={true} reverseStackOrder={true}
> >
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />

View File

@@ -1,11 +1,19 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart' import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import { useYAxisWidth, chartTimeData, cn, formatShortDate, twoDecimalString } from '@/lib/utils' import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
twoDecimalString,
chartMargin,
} from '@/lib/utils'
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores' import { $chartTime, $cpuMax } from '@/lib/stores'
import { SystemStatsRecord } from '@/types' import { SystemStatsRecord } from '@/types'
import { useMemo } from 'react'
export default function CpuChart({ export default function CpuChart({
ticks, ticks,
@@ -16,11 +24,16 @@ export default function CpuChart({
}) { }) {
const chartTime = useStore($chartTime) const chartTime = useStore($chartTime)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const showMax = useStore($cpuMax)
const dataKey = useMemo(
() => `stats.cpu${showMax && chartTime !== '1h' ? 'm' : ''}`,
[showMax, systemData]
)
return ( return (
<div> <div>
<ChartContainer <ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth, 'opacity-100': yAxisWidth,
})} })}
@@ -28,7 +41,7 @@ export default function CpuChart({
<AreaChart <AreaChart
accessibilityLayer accessibilityLayer
data={systemData} data={systemData}
margin={{ top: 10 }} margin={chartMargin}
// syncId={'cpu'} // syncId={'cpu'}
> >
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
@@ -63,16 +76,13 @@ export default function CpuChart({
} }
/> />
<Area <Area
dataKey="stats.cpu" dataKey={dataKey}
name="CPU Usage" name="CPU Usage"
type="monotoneX" type="monotoneX"
fill="hsl(var(--chart-1))" fill="hsl(var(--chart-1))"
fillOpacity={0.4} fillOpacity={0.4}
stroke="hsl(var(--chart-1))" stroke="hsl(var(--chart-1))"
isAnimationActive={false} isAnimationActive={false}
// animationEasing="ease-out"
// animationDuration={1200}
// animateNewValues={true}
/> />
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>

View File

@@ -10,6 +10,7 @@ import {
toFixedFloat, toFixedFloat,
getSizeVal, getSizeVal,
getSizeUnit, getSizeUnit,
chartMargin,
} from '@/lib/utils' } from '@/lib/utils'
// import { useMemo } from 'react' // import { useMemo } from 'react'
// import Spinner from '../spinner' // import Spinner from '../spinner'
@@ -35,21 +36,11 @@ export default function DiskChart({
<div> <div>
{/* {!yAxisSet && <Spinner />} */} {/* {!yAxisSet && <Spinner />} */}
<ChartContainer <ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth, 'opacity-100': yAxisWidth,
})} })}
> >
<AreaChart <AreaChart accessibilityLayer data={systemData} margin={chartMargin}>
accessibilityLayer
data={systemData}
margin={{
left: 0,
right: 0,
top: 10,
bottom: 0,
}}
>
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
<YAxis <YAxis
className="tracking-tighter" className="tracking-tighter"

View File

@@ -1,102 +0,0 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
twoDecimalString,
} from '@/lib/utils'
// import Spinner from '../spinner'
import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types'
export default function DiskIoChart({
ticks,
systemData,
dataKeys,
}: {
ticks: number[]
systemData: SystemStatsRecord[]
dataKeys: string[]
}) {
const chartTime = useStore($chartTime)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
return (
<div>
{/* {!yAxisSet && <Spinner />} */}
<ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth,
})}
>
<AreaChart
accessibilityLayer
data={systemData}
margin={{
left: 0,
right: 0,
top: 10,
bottom: 0,
}}
>
<CartesianGrid vertical={false} />
<YAxis
className="tracking-tighter"
width={yAxisWidth}
// domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]}
tickFormatter={(value) => {
const val = toFixedWithoutTrailingZeros(value, 2) + ' MB/s'
return updateYAxisWidth(val)
}}
tickLine={false}
axisLine={false}
/>
<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}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => twoDecimalString(item.value) + ' MB/s'}
indicator="line"
/>
}
/>
{dataKeys.map((dataKey, i) => {
const action = i ? 'Read' : 'Write'
const color = i ? 'hsl(var(--chart-1))' : 'hsl(var(--chart-3))'
return (
<Area
key={i}
dataKey={dataKey}
name={action}
type="monotoneX"
fill={color}
fillOpacity={0.3}
stroke={color}
isAnimationActive={false}
/>
)
})}
</AreaChart>
</ChartContainer>
</div>
)
}

View File

@@ -5,12 +5,12 @@ import {
useYAxisWidth, useYAxisWidth,
chartTimeData, chartTimeData,
cn, cn,
formatShortDate,
toFixedFloat, toFixedFloat,
twoDecimalString, twoDecimalString,
formatShortDate,
chartMargin,
} from '@/lib/utils' } from '@/lib/utils'
import { useMemo } from 'react' import { useMemo } from 'react'
// import Spinner from '../spinner'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores' import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types' import { SystemStatsRecord } from '@/types'
@@ -33,18 +33,11 @@ export default function MemChart({
<div> <div>
{/* {!yAxisSet && <Spinner />} */} {/* {!yAxisSet && <Spinner />} */}
<ChartContainer <ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth, 'opacity-100': yAxisWidth,
})} })}
> >
<AreaChart <AreaChart accessibilityLayer data={systemData} margin={chartMargin}>
accessibilityLayer
data={systemData}
margin={{
top: 10,
}}
>
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
{totalMem && ( {totalMem && (
<YAxis <YAxis
@@ -79,7 +72,7 @@ export default function MemChart({
content={ content={
<ChartTooltipContent <ChartTooltipContent
// @ts-ignore // @ts-ignore
itemSorter={(a, b) => a.name.localeCompare(b.name)} itemSorter={(a, b) => a.order - b.order}
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => twoDecimalString(item.value) + ' GB'} contentFormatter={(item) => twoDecimalString(item.value) + ' GB'}
indicator="line" indicator="line"
@@ -87,8 +80,9 @@ export default function MemChart({
} }
/> />
<Area <Area
dataKey="stats.mu"
name="Used" name="Used"
order={3}
dataKey="stats.mu"
type="monotoneX" type="monotoneX"
fill="hsl(var(--chart-2))" fill="hsl(var(--chart-2))"
fillOpacity={0.4} fillOpacity={0.4}
@@ -96,9 +90,23 @@ export default function MemChart({
stackId="1" stackId="1"
isAnimationActive={false} isAnimationActive={false}
/> />
{systemData.at(-1)?.stats.mz && (
<Area
name="ZFS ARC"
order={2}
dataKey="stats.mz"
type="monotoneX"
fill="hsla(175 60% 45% / 0.8)"
fillOpacity={0.5}
stroke="hsla(175 60% 45% / 0.8)"
stackId="1"
isAnimationActive={false}
/>
)}
<Area <Area
dataKey="stats.mb"
name="Cache / Buffers" name="Cache / Buffers"
order={1}
dataKey="stats.mb"
type="monotoneX" type="monotoneX"
fill="hsla(160 60% 45% / 0.5)" fill="hsla(160 60% 45% / 0.5)"
fillOpacity={0.4} fillOpacity={0.4}

View File

@@ -8,8 +8,8 @@ import {
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
twoDecimalString, twoDecimalString,
chartMargin,
} from '@/lib/utils' } from '@/lib/utils'
// import Spinner from '../spinner'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores' import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types' import { SystemStatsRecord } from '@/types'
@@ -27,12 +27,11 @@ export default function SwapChart({
return ( return (
<div> <div>
<ChartContainer <ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth, 'opacity-100': yAxisWidth,
})} })}
> >
<AreaChart accessibilityLayer data={systemData} margin={{ top: 10 }}> <AreaChart accessibilityLayer data={systemData} margin={chartMargin}>
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
<YAxis <YAxis
className="tracking-tighter" className="tracking-tighter"

View File

@@ -14,6 +14,7 @@ import {
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
twoDecimalString, twoDecimalString,
chartMargin,
} from '@/lib/utils' } from '@/lib/utils'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores' import { $chartTime } from '@/lib/stores'
@@ -60,21 +61,11 @@ export default function TemperatureChart({
<div> <div>
{/* {!yAxisSet && <Spinner />} */} {/* {!yAxisSet && <Spinner />} */}
<ChartContainer <ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth, 'opacity-100': yAxisWidth,
})} })}
> >
<LineChart <LineChart accessibilityLayer data={newChartData.data} margin={chartMargin}>
accessibilityLayer
data={newChartData.data}
margin={{
left: 0,
right: 0,
top: 10,
bottom: 0,
}}
>
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
<YAxis <YAxis
className="tracking-tighter" className="tracking-tighter"

View File

@@ -1,44 +1,45 @@
import { $systems, pb, $chartTime, $containerFilter, $userSettings } from '@/lib/stores' import { $systems, pb, $chartTime, $containerFilter, $userSettings } from '@/lib/stores'
import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types' import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types'
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../ui/card' import { Card, CardHeader, CardTitle, CardDescription } from '../ui/card'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import Spinner from '../spinner' import Spinner from '../spinner'
import { ClockArrowUp, CpuIcon, GlobeIcon, LayoutGridIcon, MonitorIcon, XIcon } from 'lucide-react' import { ClockArrowUp, CpuIcon, GlobeIcon, LayoutGridIcon, MonitorIcon, XIcon } from 'lucide-react'
import ChartTimeSelect from '../charts/chart-time-select' import ChartTimeSelect from '../charts/chart-time-select'
import { import { chartTimeData, cn, getPbTimestamp, useLocalStorage } from '@/lib/utils'
chartTimeData,
cn,
getPbTimestamp,
useClampedIsInViewport,
useLocalStorage,
} from '@/lib/utils'
import { Separator } from '../ui/separator' import { Separator } from '../ui/separator'
import { scaleTime } from 'd3-scale' import { scaleTime } from 'd3-scale'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import { Button, buttonVariants } from '../ui/button' import { Button, buttonVariants } from '../ui/button'
import { Input } from '../ui/input' import { Input } from '../ui/input'
import { Rows, TuxIcon } from '../ui/icons' import { ChartAverage, ChartMax, Rows, TuxIcon } from '../ui/icons'
import { useIntersectionObserver } from '@/lib/use-intersection-observer'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
const CpuChart = lazy(() => import('../charts/cpu-chart'))
const ContainerCpuChart = lazy(() => import('../charts/container-cpu-chart')) const ContainerCpuChart = lazy(() => import('../charts/container-cpu-chart'))
const MemChart = lazy(() => import('../charts/mem-chart')) const MemChart = lazy(() => import('../charts/mem-chart'))
const ContainerMemChart = lazy(() => import('../charts/container-mem-chart')) const ContainerMemChart = lazy(() => import('../charts/container-mem-chart'))
const DiskChart = lazy(() => import('../charts/disk-chart')) const DiskChart = lazy(() => import('../charts/disk-chart'))
const DiskIoChart = lazy(() => import('../charts/disk-io-chart')) const AreaChartDefault = lazy(() => import('../charts/area-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')) const TemperatureChart = lazy(() => import('../charts/temperature-chart'))
const cache = new Map<string, SystemStatsRecord[] | ContainerStatsRecord[]>()
export default function SystemDetail({ name }: { name: string }) { export default function SystemDetail({ name }: { name: string }) {
const systems = useStore($systems) const systems = useStore($systems)
const chartTime = useStore($chartTime) 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 [grid, setGrid] = useLocalStorage('grid', true)
const [ticks, setTicks] = useState([] as number[]) const [ticks, setTicks] = useState([] as number[])
const [system, setSystem] = useState({} as SystemRecord) const [system, setSystem] = useState({} as SystemRecord)
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[]) const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
const netCardRef = useRef<HTMLDivElement>(null) const netCardRef = useRef<HTMLDivElement>(null)
const [containerFilterBar, setContainerFilterBar] = useState(null as null | JSX.Element)
const [dockerCpuChartData, setDockerCpuChartData] = useState<Record<string, number | string>[]>( const [dockerCpuChartData, setDockerCpuChartData] = useState<Record<string, number | string>[]>(
[] []
) )
@@ -48,15 +49,18 @@ export default function SystemDetail({ name }: { name: string }) {
const [dockerNetChartData, setDockerNetChartData] = useState<Record<string, number | number[]>[]>( const [dockerNetChartData, setDockerNetChartData] = useState<Record<string, number | number[]>[]>(
[] []
) )
const hasDockerStats = dockerCpuChartData.length > 0 const isLongerChart = chartTime !== '1h'
useEffect(() => { useEffect(() => {
document.title = `${name} / Beszel` document.title = `${name} / Beszel`
return () => { return () => {
resetCharts() resetCharts()
$chartTime.set($userSettings.get().chartTime) $chartTime.set($userSettings.get().chartTime)
setContainerFilterBar(null)
$containerFilter.set('') $containerFilter.set('')
// setHasDocker(false) cpuMaxStore[1](false)
bandwidthMaxStore[1](false)
diskIoMaxStore[1](false)
} }
}, [name]) }, [name])
@@ -93,10 +97,12 @@ export default function SystemDetail({ name }: { name: string }) {
}, [system]) }, [system])
async function getStats<T>(collection: string): Promise<T[]> { async function getStats<T>(collection: string): Promise<T[]> {
const lastCached = cache.get(`${system.id}_${chartTime}_${collection}`)?.at(-1)
?.created as number
return await pb.collection<T>(collection).getFullList({ return await pb.collection<T>(collection).getFullList({
filter: pb.filter('system={:id} && created > {:created} && type={:type}', { filter: pb.filter('system={:id} && created > {:created} && type={:type}', {
id: system.id, id: system.id,
created: getPbTimestamp(chartTime), created: getPbTimestamp(chartTime, lastCached ? new Date(lastCached + 1000) : undefined),
type: chartTimeData[chartTime].type, type: chartTimeData[chartTime].type,
}), }),
fields: 'created,stats', fields: 'created,stats',
@@ -137,13 +143,34 @@ export default function SystemDetail({ name }: { name: string }) {
getStats<SystemStatsRecord>('system_stats'), getStats<SystemStatsRecord>('system_stats'),
getStats<ContainerStatsRecord>('container_stats'), getStats<ContainerStatsRecord>('container_stats'),
]).then(([systemStats, containerStats]) => { ]).then(([systemStats, containerStats]) => {
const expectedInterval = chartTimeData[chartTime].expectedInterval 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(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) { if (containerStats.status === 'fulfilled' && containerStats.value.length) {
makeContainerData(addEmptyValues(containerStats.value, expectedInterval)) containerData = containerData.concat(addEmptyValues(containerStats.value, expectedInterval))
if (containerData.length > 120) {
containerData = containerData.slice(-100)
}
cache.set(cs_cache_key, containerData)
} }
if (systemStats.status === 'fulfilled') { if (containerData.length) {
setSystemStats(addEmptyValues(systemStats.value, expectedInterval)) !containerFilterBar && setContainerFilterBar(<ContainerFilterBar />)
} else if (containerFilterBar) {
setContainerFilterBar(null)
} }
makeContainerData(containerData)
}) })
}, [system, chartTime]) }, [system, chartTime])
@@ -154,7 +181,10 @@ export default function SystemDetail({ name }: { name: string }) {
const now = new Date() const now = new Date()
const startTime = chartTimeData[chartTime].getOffset(now) const startTime = chartTimeData[chartTime].getOffset(now)
const scale = scaleTime([startTime.getTime(), now], [0, systemStats.length]) const scale = scaleTime([startTime.getTime(), now], [0, systemStats.length])
setTicks(scale.ticks(chartTimeData[chartTime].ticks).map((d) => d.getTime())) const newTicks = scale.ticks(chartTimeData[chartTime].ticks).map((d) => d.getTime())
if (newTicks[0] !== ticks[0]) {
setTicks(newTicks)
}
}, [chartTime, systemStats]) }, [chartTime, systemStats])
// make container stats for charts // make container stats for charts
@@ -196,7 +226,8 @@ export default function SystemDetail({ name }: { name: string }) {
} }
let uptime: number | string = system.info.u let uptime: number | string = system.info.u
if (system.info.u < 172800) { if (system.info.u < 172800) {
uptime = `${Math.trunc(uptime / 3600)} hours` const hours = Math.trunc(uptime / 3600)
uptime = `${hours} hour${hours == 1 ? '' : 's'}`
} else { } else {
uptime = `${Math.trunc(system.info?.u / 86400)} days` uptime = `${Math.trunc(system.info?.u / 86400)} days`
} }
@@ -243,7 +274,7 @@ export default function SystemDetail({ name }: { name: string }) {
return ( return (
<> <>
<div id="chartwrap" className="grid gap-4 mb-10"> <div id="chartwrap" className="grid gap-4 mb-10 overflow-x-clip">
{/* system info */} {/* system info */}
<Card> <Card>
<div className="grid lg:flex items-center gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5"> <div className="grid lg:flex items-center gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
@@ -328,17 +359,27 @@ export default function SystemDetail({ name }: { name: string }) {
<ChartCard <ChartCard
grid={grid} grid={grid}
title="Total CPU Usage" title="Total CPU Usage"
description="Average system-wide CPU utilization" description={`${
cpuMaxStore[0] && isLongerChart ? 'Max 1 min ' : 'Average'
} system-wide CPU utilization`}
cornerEl={isLongerChart ? <SelectAvgMax store={cpuMaxStore} /> : null}
> >
<CpuChart ticks={ticks} systemData={systemStats} /> <AreaChartDefault
ticks={ticks}
systemData={systemStats}
chartName="CPU Usage"
showMax={isLongerChart && cpuMaxStore[0]}
unit="%"
chartTime={chartTime}
/>
</ChartCard> </ChartCard>
{hasDockerStats && ( {containerFilterBar && (
<ChartCard <ChartCard
grid={grid} grid={grid}
title="Docker CPU Usage" title="Docker CPU Usage"
description="CPU utilization of docker containers" description="Average CPU utilization of containers"
isContainerChart={true} cornerEl={containerFilterBar}
> >
<ContainerCpuChart chartData={dockerCpuChartData} ticks={ticks} /> <ContainerCpuChart chartData={dockerCpuChartData} ticks={ticks} />
</ChartCard> </ChartCard>
@@ -352,12 +393,12 @@ export default function SystemDetail({ name }: { name: string }) {
<MemChart ticks={ticks} systemData={systemStats} /> <MemChart ticks={ticks} systemData={systemStats} />
</ChartCard> </ChartCard>
{hasDockerStats && ( {containerFilterBar && (
<ChartCard <ChartCard
grid={grid} grid={grid}
title="Docker Memory Usage" title="Docker Memory Usage"
description="Memory usage of docker containers" description="Memory usage of docker containers"
isContainerChart={true} cornerEl={containerFilterBar}
> >
<ContainerMemChart chartData={dockerMemChartData} ticks={ticks} /> <ContainerMemChart chartData={dockerMemChartData} ticks={ticks} />
</ChartCard> </ChartCard>
@@ -372,23 +413,37 @@ export default function SystemDetail({ name }: { name: string }) {
/> />
</ChartCard> </ChartCard>
<ChartCard grid={grid} title="Disk I/O" description="Throughput of root filesystem"> <ChartCard
<DiskIoChart grid={grid}
title="Disk I/O"
description="Throughput of root filesystem"
cornerEl={isLongerChart ? <SelectAvgMax store={diskIoMaxStore} /> : null}
>
<AreaChartDefault
ticks={ticks} ticks={ticks}
systemData={systemStats} systemData={systemStats}
dataKeys={['stats.dw', 'stats.dr']} showMax={isLongerChart && diskIoMaxStore[0]}
chartName="dio"
chartTime={chartTime}
/> />
</ChartCard> </ChartCard>
<ChartCard <ChartCard
grid={grid} grid={grid}
title="Bandwidth" title="Bandwidth"
cornerEl={isLongerChart ? <SelectAvgMax store={bandwidthMaxStore} /> : null}
description="Network traffic of public interfaces" description="Network traffic of public interfaces"
> >
<BandwidthChart ticks={ticks} systemData={systemStats} /> <AreaChartDefault
ticks={ticks}
systemData={systemStats}
showMax={isLongerChart && bandwidthMaxStore[0]}
chartName="bw"
chartTime={chartTime}
/>
</ChartCard> </ChartCard>
{hasDockerStats && dockerNetChartData.length > 0 && ( {containerFilterBar && dockerNetChartData.length > 0 && (
<div <div
ref={netCardRef} ref={netCardRef}
className={cn({ className={cn({
@@ -398,7 +453,7 @@ export default function SystemDetail({ name }: { name: string }) {
<ChartCard <ChartCard
title="Docker Network I/O" title="Docker Network I/O"
description="Includes traffic between internal services" description="Includes traffic between internal services"
isContainerChart={true} cornerEl={containerFilterBar}
> >
<ContainerNetChart chartData={dockerNetChartData} ticks={ticks} /> <ContainerNetChart chartData={dockerNetChartData} ticks={ticks} />
</ChartCard> </ChartCard>
@@ -440,11 +495,14 @@ export default function SystemDetail({ name }: { name: string }) {
grid={grid} grid={grid}
title={`${extraFsName} I/O`} title={`${extraFsName} I/O`}
description={`Throughput of ${extraFsName}`} description={`Throughput of ${extraFsName}`}
cornerEl={isLongerChart ? <SelectAvgMax store={diskIoMaxStore} /> : null}
> >
<DiskIoChart <AreaChartDefault
ticks={ticks} ticks={ticks}
systemData={systemStats} systemData={systemStats}
dataKeys={[`stats.efs.${extraFsName}.w`, `stats.efs.${extraFsName}.r`]} showMax={isLongerChart && diskIoMaxStore[0]}
chartName={`efs.${extraFsName}`}
chartTime={chartTime}
/> />
</ChartCard> </ChartCard>
</div> </div>
@@ -465,10 +523,10 @@ function ContainerFilterBar() {
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
$containerFilter.set(e.target.value) $containerFilter.set(e.target.value)
}, []) // Use an empty dependency array to prevent re-creation }, [])
return ( return (
<div className="relative py-1 block sm:w-44 sm:absolute sm:top-2.5 sm:right-3.5"> <>
<Input <Input
placeholder="Filter..." placeholder="Filter..."
className="pl-4 pr-8" className="pl-4 pr-8"
@@ -487,7 +545,33 @@ function ContainerFilterBar() {
<XIcon className="h-4 w-4" /> <XIcon className="h-4 w-4" />
</Button> </Button>
)} )}
</div> </>
)
}
function SelectAvgMax({
store,
}: {
store: [boolean, React.Dispatch<React.SetStateAction<boolean>>]
}) {
const [max, setMax] = store
const Icon = max ? ChartMax : ChartAverage
return (
<Select value={max ? 'max' : 'avg'} onValueChange={(e) => setMax(e === 'max')}>
<SelectTrigger className="relative pl-10 pr-5">
<Icon className="h-4 w-4 absolute left-4 top-1/2 -translate-y-1/2 opacity-85" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem key="avg" value="avg">
Average
</SelectItem>
<SelectItem key="max" value="max">
Max 1 min
</SelectItem>
</SelectContent>
</Select>
) )
} }
@@ -496,31 +580,34 @@ function ChartCard({
description, description,
children, children,
grid, grid,
isContainerChart, cornerEl,
}: { }: {
title: string title: string
description: string description: string
children: React.ReactNode children: React.ReactNode
grid?: boolean grid?: boolean
isContainerChart?: boolean cornerEl?: JSX.Element | null
}) { }) {
const target = useRef<HTMLDivElement>(null) const { isIntersecting, ref } = useIntersectionObserver()
const [isInViewport, wrappedTargetRef] = useClampedIsInViewport({ target: target })
return ( return (
<Card <Card
className={cn('pb-2 sm:pb-4 odd:last-of-type:col-span-full', { 'col-span-full': !grid })} className={cn('pb-2 sm:pb-4 odd:last-of-type:col-span-full', { 'col-span-full': !grid })}
ref={wrappedTargetRef} ref={ref}
> >
<CardHeader className="pb-5 pt-4 relative space-y-1 max-sm:py-3 max-sm:px-4"> <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> <CardTitle className="text-xl sm:text-2xl">{title}</CardTitle>
<CardDescription>{description}</CardDescription> <CardDescription>{description}</CardDescription>
{isContainerChart && <ContainerFilterBar />} {cornerEl && (
<div className="relative py-1 block sm:w-44 sm:absolute sm:top-2.5 sm:right-3.5">
{cornerEl}
</div>
)}
</CardHeader> </CardHeader>
<CardContent className="pl-0 w-[calc(100%-1.6em)] h-52 relative"> <div className="pl-0 w-[calc(100%-1.6em)] h-52 relative">
{<Spinner />} {<Spinner />}
{isInViewport && <Suspense>{children}</Suspense>} {isIntersecting && <Suspense>{children}</Suspense>}
</CardContent> </div>
</Card> </Card>
) )
} }

View File

@@ -16,77 +16,77 @@ export type ChartConfig = {
) )
} }
type ChartContextProps = { // type ChartContextProps = {
config: ChartConfig // config: ChartConfig
} // }
const ChartContext = React.createContext<ChartContextProps | null>(null) // const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() { // function useChart() {
const context = React.useContext(ChartContext) // const context = React.useContext(ChartContext)
if (!context) { // if (!context) {
throw new Error('useChart must be used within a <ChartContainer />') // throw new Error('useChart must be used within a <ChartContainer />')
} // }
return context // return context
} // }
const ChartContainer = React.forwardRef< const ChartContainer = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<'div'> & { React.ComponentProps<'div'> & {
config: ChartConfig // config: ChartConfig
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'] children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children']
} }
>(({ id, className, children, config, ...props }, ref) => { >(({ id, className, children, ...props }, ref) => {
const uniqueId = React.useId() const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}` const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`
return ( return (
<ChartContext.Provider value={{ config }}> //<ChartContext.Provider value={{ config }}>
<div <div
data-chart={chartId} data-chart={chartId}
ref={ref} ref={ref}
className={cn( className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line-line]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none", "text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line-line]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className className
)} )}
{...props} {...props}
> >
<ChartStyle id={chartId} config={config} /> {/* <ChartStyle id={chartId} config={config} /> */}
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer> <RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div> </div>
</ChartContext.Provider> //</ChartContext.Provider>
) )
}) })
ChartContainer.displayName = 'Chart' ChartContainer.displayName = 'Chart'
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { // const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color) // const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color)
if (!colorConfig.length) { // if (!colorConfig.length) {
return null // return null
} // }
return ( // return (
<style // <style
dangerouslySetInnerHTML={{ // dangerouslySetInnerHTML={{
__html: Object.entries(THEMES).map( // __html: Object.entries(THEMES).map(
([theme, prefix]) => ` // ([theme, prefix]) => `
${prefix} [data-chart=${id}] { // ${prefix} [data-chart=${id}] {
${colorConfig // ${colorConfig
.map(([key, itemConfig]) => { // .map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color // const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color
return color ? ` --color-${key}: ${color};` : null // return color ? ` --color-${key}: ${color};` : null
}) // })
.join('\n')} // .join('\n')}
} // }
` // `
), // ),
}} // }}
/> // />
) // )
} // }
const ChartTooltip = RechartsPrimitive.Tooltip const ChartTooltip = RechartsPrimitive.Tooltip
@@ -126,7 +126,8 @@ const ChartTooltipContent = React.forwardRef<
}, },
ref ref
) => { ) => {
const { config } = useChart() // const { config } = useChart()
const config = {}
React.useMemo(() => { React.useMemo(() => {
if (filter) { if (filter) {
@@ -146,10 +147,7 @@ const ChartTooltipContent = React.forwardRef<
const [item] = payload const [item] = payload
const key = `${labelKey || item.dataKey || item.name || 'value'}` const key = `${labelKey || item.dataKey || item.name || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key) const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value = const value = !labelKey && typeof label === 'string' ? label : itemConfig?.label
!labelKey && typeof label === 'string'
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) { if (labelFormatter) {
return ( return (
@@ -262,7 +260,7 @@ const ChartLegendContent = React.forwardRef<
hideIcon?: boolean hideIcon?: boolean
nameKey?: string nameKey?: string
} }
>(({ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey }, ref) => { >(({ className, payload, verticalAlign = 'bottom' }, ref) => {
// const { config } = useChart() // const { config } = useChart()
if (!payload?.length) { if (!payload?.length) {
@@ -342,5 +340,5 @@ export {
ChartTooltipContent, ChartTooltipContent,
ChartLegend, ChartLegend,
ChartLegendContent, ChartLegendContent,
ChartStyle, // ChartStyle,
} }

View File

@@ -23,3 +23,25 @@ export function Rows(props: SVGProps<SVGSVGElement>) {
</svg> </svg>
) )
} }
// IconPark Apache License 2.0 https://github.com/bytedance/IconPark
export function ChartAverage(props: SVGProps<SVGSVGElement>) {
return (
<svg fill="none" viewBox="0 0 48 48" stroke="currentColor" {...props}>
<path strokeWidth="3" d="M4 4v40h40" />
<path strokeWidth="3" d="M10 38S15.3 4 27 4s17 34 17 34" />
<path strokeWidth="4" d="M10 24h34" />
</svg>
)
}
// IconPark Apache License 2.0 https://github.com/bytedance/IconPark
export function ChartMax(props: SVGProps<SVGSVGElement>) {
return (
<svg fill="none" viewBox="0 0 48 48" stroke="currentColor" {...props}>
<path strokeWidth="3" d="M4 4v40h40" />
<path strokeWidth="3" d="M10 38S15.3 4 27 4s17 34 17 34" />
<path strokeWidth="4" d="M10 4h34" />
</svg>
)
}

View File

@@ -0,0 +1,169 @@
import { useEffect, useRef, useState } from 'react'
// adapted from usehooks-ts/use-intersection-observer
/** The hook internal state. */
type State = {
/** A boolean indicating if the element is intersecting. */
isIntersecting: boolean
/** The intersection observer entry. */
entry?: IntersectionObserverEntry
}
/** Represents the options for configuring the Intersection Observer. */
type UseIntersectionObserverOptions = {
/**
* The element that is used as the viewport for checking visibility of the target.
* @default null
*/
root?: Element | Document | null
/**
* A margin around the root.
* @default '0%'
*/
rootMargin?: string
/**
* A threshold indicating the percentage of the target's visibility needed to trigger the callback.
* @default 0
*/
threshold?: number | number[]
/**
* If true, freezes the intersection state once the element becomes visible.
* @default true
*/
freeze?: boolean
/**
* A callback function to be invoked when the intersection state changes.
* @param {boolean} isIntersecting - A boolean indicating if the element is intersecting.
* @param {IntersectionObserverEntry} entry - The intersection observer Entry.
* @default undefined
*/
onChange?: (isIntersecting: boolean, entry: IntersectionObserverEntry) => void
/**
* The initial state of the intersection.
* @default false
*/
initialIsIntersecting?: boolean
}
/**
* The return type of the useIntersectionObserver hook.
*
* Supports both tuple and object destructing.
* @param {(node: Element | null) => void} ref - The ref callback function.
* @param {boolean} isIntersecting - A boolean indicating if the element is intersecting.
* @param {IntersectionObserverEntry | undefined} entry - The intersection observer Entry.
*/
type IntersectionReturn = {
ref: (node?: Element | null) => void
isIntersecting: boolean
entry?: IntersectionObserverEntry
}
/**
* Custom hook that tracks the intersection of a DOM element with its containing element or the viewport using the [`Intersection Observer API`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API).
* @param {UseIntersectionObserverOptions} options - The options for the Intersection Observer.
* @returns {IntersectionReturn} The ref callback, a boolean indicating if the element is intersecting, and the intersection observer entry.
* @example
* ```tsx
* const { ref, isIntersecting, entry } = useIntersectionObserver({ threshold: 0.5 });
* ```
*/
export function useIntersectionObserver({
threshold = 0,
root = null,
rootMargin = '0%',
freeze = true,
initialIsIntersecting = false,
onChange,
}: UseIntersectionObserverOptions = {}): IntersectionReturn {
const [ref, setRef] = useState<Element | null>(null)
const [state, setState] = useState<State>(() => ({
isIntersecting: initialIsIntersecting,
entry: undefined,
}))
const callbackRef = useRef<UseIntersectionObserverOptions['onChange']>()
callbackRef.current = onChange
const frozen = state.entry?.isIntersecting && freeze
useEffect(() => {
// Ensure we have a ref to observe
if (!ref) return
// Ensure the browser supports the Intersection Observer API
if (!('IntersectionObserver' in window)) return
// Skip if frozen
if (frozen) return
let unobserve: (() => void) | undefined
const observer = new IntersectionObserver(
(entries: IntersectionObserverEntry[]): void => {
const thresholds = Array.isArray(observer.thresholds)
? observer.thresholds
: [observer.thresholds]
entries.forEach((entry) => {
const isIntersecting =
entry.isIntersecting &&
thresholds.some((threshold) => entry.intersectionRatio >= threshold)
setState({ isIntersecting, entry })
if (callbackRef.current) {
callbackRef.current(isIntersecting, entry)
}
if (isIntersecting && freeze && unobserve) {
unobserve()
unobserve = undefined
}
})
},
{ threshold, root, rootMargin }
)
observer.observe(ref)
return () => {
observer.disconnect()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
ref,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(threshold),
root,
rootMargin,
frozen,
freeze,
])
// ensures that if the observed element changes, the intersection observer is reinitialized
const prevRef = useRef<Element | null>(null)
useEffect(() => {
if (
!ref &&
state.entry?.target &&
!freeze &&
!frozen &&
prevRef.current !== state.entry.target
) {
prevRef.current = state.entry.target
setState({ isIntersecting: initialIsIntersecting, entry: undefined })
}
}, [ref, state.entry, freeze, frozen, initialIsIntersecting])
return {
ref: setRef,
isIntersecting: !!state.isIntersecting,
entry: state.entry,
} as IntersectionReturn
}

View File

@@ -7,7 +7,6 @@ import { RecordModel, RecordSubscription } from 'pocketbase'
import { WritableAtom } from 'nanostores' import { WritableAtom } from 'nanostores'
import { timeDay, timeHour } from 'd3-time' import { timeDay, timeHour } from 'd3-time'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import useIsInViewport, { CallbackRef, HookOptions } from 'use-is-in-viewport'
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
@@ -137,8 +136,8 @@ export function updateRecordList<T extends RecordModel>(
$store.set(newRecords) $store.set(newRecords)
} }
export function getPbTimestamp(timeString: ChartTimes) { export function getPbTimestamp(timeString: ChartTimes, d?: Date) {
const d = chartTimeData[timeString].getOffset(new Date()) d ||= chartTimeData[timeString].getOffset(new Date())
const year = d.getUTCFullYear() const year = d.getUTCFullYear()
const month = String(d.getUTCMonth() + 1).padStart(2, '0') const month = String(d.getUTCMonth() + 1).padStart(2, '0')
const day = String(d.getUTCDate()).padStart(2, '0') const day = String(d.getUTCDate()).padStart(2, '0')
@@ -205,7 +204,10 @@ export function useYAxisWidth() {
clearTimeout(timeout) clearTimeout(timeout)
timeout = setTimeout(() => { timeout = setTimeout(() => {
document.body.appendChild(div) document.body.appendChild(div)
setYAxisWidth(div.offsetWidth + 24) const width = div.offsetWidth + 24
if (width > yAxisWidth) {
setYAxisWidth(div.offsetWidth + 24)
}
document.body.removeChild(div) document.body.removeChild(div)
}) })
} }
@@ -214,24 +216,6 @@ export function useYAxisWidth() {
return { yAxisWidth, updateYAxisWidth } return { yAxisWidth, updateYAxisWidth }
} }
export function useClampedIsInViewport(options: HookOptions): [boolean | null, CallbackRef] {
const [isInViewport, wrappedTargetRef] = useIsInViewport(options)
const [wasInViewportAtleastOnce, setWasInViewportAtleastOnce] = useState(isInViewport)
useEffect(() => {
setWasInViewportAtleastOnce((prev) => {
// this will clamp it to the first true
// received from useIsInViewport
if (!prev) {
return isInViewport
}
return prev
})
}, [isInViewport])
return [wasInViewportAtleastOnce, wrappedTargetRef]
}
export function toFixedWithoutTrailingZeros(num: number, digits: number) { export function toFixedWithoutTrailingZeros(num: number, digits: number) {
return parseFloat(num.toFixed(digits)).toString() return parseFloat(num.toFixed(digits)).toString()
} }
@@ -304,3 +288,5 @@ export const getSizeUnit = (n: number) => (n >= 1_000 ? ' TB' : ' GB')
* @returns value in GB if less than 1000, otherwise value in TB * @returns value in GB if less than 1000, otherwise value in TB
*/ */
export const getSizeVal = (n: number) => (n >= 1_000 ? n / 1_000 : n) export const getSizeVal = (n: number) => (n >= 1_000 ? n / 1_000 : n)
export const chartMargin = { top: 12 }

View File

@@ -35,6 +35,8 @@ export interface SystemInfo {
export interface SystemStats { export interface SystemStats {
/** cpu percent */ /** cpu percent */
cpu: number cpu: number
/** peak cpu */
cpum?: number
/** total memory (gb) */ /** total memory (gb) */
m: number m: number
/** memory used (gb) */ /** memory used (gb) */
@@ -43,6 +45,8 @@ export interface SystemStats {
mp: number mp: number
/** memory buffer + cache (gb) */ /** memory buffer + cache (gb) */
mb: number mb: number
/** zfs arc memory (gb) */
mz?: number
/** swap space (gb) */ /** swap space (gb) */
s: number s: number
/** swap used (gb) */ /** swap used (gb) */
@@ -57,10 +61,18 @@ export interface SystemStats {
dr: number dr: number
/** disk write (mb) */ /** disk write (mb) */
dw: number dw: number
/** max disk read (mb) */
drm?: number
/** max disk write (mb) */
dwm?: number
/** network sent (mb) */ /** network sent (mb) */
ns: number ns: number
/** network received (mb) */ /** network received (mb) */
nr: number nr: number
/** max network sent (mb) */
nsm?: number
/** max network received (mb) */
nrm?: number
/** temperatures */ /** temperatures */
t?: Record<string, number> t?: Record<string, number>
/** extra filesystems */ /** extra filesystems */
@@ -76,6 +88,10 @@ export interface ExtraFsStats {
r: number r: number
/** total write (mb) */ /** total write (mb) */
w: number w: number
/** max read (mb) */
rm: number
/** max write (mb) */
wm: number
} }
export interface ContainerStatsRecord extends RecordModel { export interface ContainerStatsRecord extends RecordModel {

View File

@@ -1,6 +1,6 @@
package beszel package beszel
const ( const (
Version = "0.5.0" Version = "0.5.3"
AppName = "beszel" AppName = "beszel"
) )

View File

@@ -105,20 +105,21 @@ Use `./beszel update` and `./beszel-agent update` to update to the latest versio
### Agent ### Agent
| Name | Default | Description | | Name | Default | Description |
| ------------------- | ------- | ---------------------------------------------------------------------------------------- | | ------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------- |
| `DOCKER_HOST` | unset | Overrides the docker host (docker.sock) if using a proxy.[^socket] | | `DOCKER_HOST` | unset | Overrides the docker host (docker.sock) if using a proxy.[^socket] |
| `EXTRA_FILESYSTEMS` | unset | See [Monitoring additional disks / partitions](#monitoring-additional-disks--partitions) | | `EXTRA_FILESYSTEMS` | unset | See [Monitoring additional disks, partitions, or remote mounts](#monitoring-additional-disks-partitions-or-remote-mounts) |
| `FILESYSTEM` | unset | Device, partition, or mount point to use for root disk stats. | | `FILESYSTEM` | unset | Device, partition, or mount point to use for root disk stats. |
| `KEY` | unset | Public SSH key to use for authentication. Provided in hub. | | `KEY` | unset | Public SSH key to use for authentication. Provided in hub. |
| `LOG_LEVEL` | info | Logging level. Valid values: "debug", "info", "warn", "error". | | `LOG_LEVEL` | info | Logging level. Valid values: "debug", "info", "warn", "error". |
| `NICS` | unset | Whitelist of network interfaces to monitor for bandwidth chart. | | `MEM_CALC` | unset | Overrides the default memory calculation.[^memcalc] |
| `PORT` | 45876 | Port or address:port to listen on. | | `NICS` | unset | Whitelist of network interfaces to monitor for bandwidth chart. |
| `SENSORS` | unset | Whitelist of temperature sensors to monitor. | | `PORT` | 45876 | Port or address:port to listen on. |
| `SENSORS` | unset | Whitelist of temperature sensors to monitor. |
<!-- | `SYS_SENSORS` | unset | Overrides the sys location for sensors. | --> | `SYS_SENSORS` | unset | Overrides sys path for sensors. See [#160](https://github.com/henrygd/beszel/discussions/160). |
[^socket]: Beszel only needs access to read container information. For [linuxserver/docker-socket-proxy](https://github.com/linuxserver/docker-socket-proxy) you would set `CONTAINERS=1`. [^socket]: Beszel only needs access to read container information. For [linuxserver/docker-socket-proxy](https://github.com/linuxserver/docker-socket-proxy) you would set `CONTAINERS=1`.
[^memcalc]: The default value for used memory is based on gopsutil's [Used](https://pkg.go.dev/github.com/shirou/gopsutil/v4@v4.24.6/mem#VirtualMemoryStat) calculation, which should align fairly closely with `free`. Set `MEM_CALC` to `htop` to align with htop's calculation.
## OAuth / OIDC Setup ## OAuth / OIDC Setup
@@ -217,7 +218,7 @@ Assuming the agent is running, the connection is probably being blocked by a fir
1. Add an inbound rule to the agent system's firewall(s) to allow TCP connections to the port. Check any active firewalls, like iptables, and your cloud provider's firewall settings if applicable. 1. Add an inbound rule to the agent system's firewall(s) to allow TCP connections to the port. Check any active firewalls, like iptables, and your cloud provider's firewall settings if applicable.
2. Alternatively, use software like [Cloudflare Tunnel](https://www.cloudflare.com/products/tunnel/), [WireGuard](https://www.wireguard.com/), or [Tailscale](https://tailscale.com/) to securely bypass your firewall. 2. Alternatively, use software like [Cloudflare Tunnel](https://www.cloudflare.com/products/tunnel/), [WireGuard](https://www.wireguard.com/), or [Tailscale](https://tailscale.com/) to securely bypass your firewall.
You can test connectivity by running telnet `<agent-ip> <port>`. You can test connectivity by running `telnet <agent-ip> <port>`.
### Connecting the hub and agent on the same system using Docker ### Connecting the hub and agent on the same system using Docker