mirror of
https://github.com/henrygd/beszel.git
synced 2025-11-21 20:46:10 +00:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a04a9bed6 | ||
|
|
3f692ce528 | ||
|
|
876fb6e02e | ||
|
|
2eb691661c | ||
|
|
f4332d69d5 | ||
|
|
b958e84572 | ||
|
|
7ce6f76315 | ||
|
|
cdd10a3011 | ||
|
|
dcdee1d943 | ||
|
|
f4e82ecd59 | ||
|
|
b8a2d0f32f | ||
|
|
f13f0b2f8a | ||
|
|
fdf0ce22dc | ||
|
|
f36b0a4528 | ||
|
|
a73a01fe37 | ||
|
|
c6b9f1ab77 | ||
|
|
8ef30e0733 | ||
|
|
e3ed07a999 | ||
|
|
b05184a654 | ||
|
|
2a3b228668 | ||
|
|
c3e3d483b0 | ||
|
|
b0c6151664 | ||
|
|
c9196def32 | ||
|
|
59cbaf3009 | ||
|
|
bc3f7257c0 | ||
|
|
4ae65f061c | ||
|
|
0f9aa11255 | ||
|
|
092f09b084 | ||
|
|
4841b95a8d | ||
|
|
0ab9ba0614 | ||
|
|
d809704ab3 | ||
|
|
8d71e95d0b | ||
|
|
e204bcf9ce | ||
|
|
e26e9fce03 | ||
|
|
4dd201de0d | ||
|
|
de7e07963d | ||
|
|
ac6f50c40c | ||
|
|
4c680a2ab9 |
@@ -12,10 +12,15 @@ builds:
|
|||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
- darwin
|
- darwin
|
||||||
|
- freebsd
|
||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
- arm
|
- arm
|
||||||
|
- mips64
|
||||||
|
ignore:
|
||||||
|
- goos: freebsd
|
||||||
|
goarch: arm
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- format: tar.gz
|
- format: tar.gz
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM --platform=$BUILDPLATFORM golang:alpine as builder
|
FROM --platform=$BUILDPLATFORM golang:alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ require (
|
|||||||
github.com/blang/semver v3.5.1+incompatible
|
github.com/blang/semver v3.5.1+incompatible
|
||||||
github.com/gliderlabs/ssh v0.3.7
|
github.com/gliderlabs/ssh v0.3.7
|
||||||
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.6
|
github.com/shirou/gopsutil/v4 v4.24.7
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -23,8 +23,8 @@ require (
|
|||||||
github.com/tklauser/numcpus v0.8.0 // indirect
|
github.com/tklauser/numcpus v0.8.0 // indirect
|
||||||
github.com/ulikunitz/xz v0.5.12 // indirect
|
github.com/ulikunitz/xz v0.5.12 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/crypto v0.25.0 // indirect
|
golang.org/x/crypto v0.26.0 // indirect
|
||||||
golang.org/x/net v0.27.0 // indirect
|
golang.org/x/net v0.27.0 // indirect
|
||||||
golang.org/x/oauth2 v0.21.0 // indirect
|
golang.org/x/oauth2 v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.22.0 // indirect
|
golang.org/x/sys v0.23.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
24
agent/go.sum
24
agent/go.sum
@@ -37,8 +37,8 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt
|
|||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag=
|
github.com/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag=
|
||||||
github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzxazpPAODuqarmPDe2Rg=
|
github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzxazpPAODuqarmPDe2Rg=
|
||||||
github.com/shirou/gopsutil/v4 v4.24.6 h1:9qqCSYF2pgOU+t+NgJtp7Co5+5mHF/HyKBUckySQL64=
|
github.com/shirou/gopsutil/v4 v4.24.7 h1:V9UGTK4gQ8HvcnPKf6Zt3XHyQq/peaekfxpJ2HSocJk=
|
||||||
github.com/shirou/gopsutil/v4 v4.24.6/go.mod h1:aoebb2vxetJ/yIDZISmduFvVNPHqXQ9SEJwRXxkf0RA=
|
github.com/shirou/gopsutil/v4 v4.24.7/go.mod h1:0uW/073rP7FYLOkvxolUQM5rMOLTNmRXnFKafpb71rw=
|
||||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
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/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 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||||
@@ -58,8 +58,8 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
|
|||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||||
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||||
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
@@ -68,8 +68,8 @@ golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
|||||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
|
golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA=
|
||||||
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@@ -77,15 +77,15 @@ golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
|
||||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
|
golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU=
|
||||||
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
|
golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
||||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
|
|||||||
144
agent/main.go
144
agent/main.go
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -8,6 +9,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -22,10 +24,10 @@ import (
|
|||||||
psutilNet "github.com/shirou/gopsutil/v4/net"
|
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Version = "0.1.1"
|
var Version = "0.1.2"
|
||||||
|
|
||||||
var containerCpuMap = make(map[string][2]uint64)
|
var containerStatsMap = make(map[string]*PrevContainerStats)
|
||||||
var containerCpuMutex = &sync.Mutex{}
|
var containerStatsMutex = &sync.Mutex{}
|
||||||
|
|
||||||
var sem = make(chan struct{}, 15)
|
var sem = make(chan struct{}, 15)
|
||||||
|
|
||||||
@@ -52,19 +54,7 @@ var netIoStats = NetIoStats{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// client for docker engine api
|
// client for docker engine api
|
||||||
var client = &http.Client{
|
var dockerClient = newDockerClient()
|
||||||
Timeout: time.Second,
|
|
||||||
Transport: &http.Transport{
|
|
||||||
Dial: func(proto, addr string) (net.Conn, error) {
|
|
||||||
return net.Dial("unix", "/var/run/docker.sock")
|
|
||||||
},
|
|
||||||
ForceAttemptHTTP2: false,
|
|
||||||
IdleConnTimeout: 90 * time.Second,
|
|
||||||
DisableCompression: true,
|
|
||||||
MaxIdleConnsPerHost: 50,
|
|
||||||
DisableKeepAlives: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func getSystemStats() (*SystemInfo, *SystemStats) {
|
func getSystemStats() (*SystemInfo, *SystemStats) {
|
||||||
systemStats := &SystemStats{}
|
systemStats := &SystemStats{}
|
||||||
@@ -83,6 +73,8 @@ func getSystemStats() (*SystemInfo, *SystemStats) {
|
|||||||
systemStats.MemUsed = bytesToGigabytes(v.Used)
|
systemStats.MemUsed = bytesToGigabytes(v.Used)
|
||||||
systemStats.MemBuffCache = bytesToGigabytes(v.Total - v.Free - v.Used)
|
systemStats.MemBuffCache = bytesToGigabytes(v.Total - v.Free - v.Used)
|
||||||
systemStats.MemPct = twoDecimals(v.UsedPercent)
|
systemStats.MemPct = twoDecimals(v.UsedPercent)
|
||||||
|
systemStats.Swap = bytesToGigabytes(v.SwapTotal)
|
||||||
|
systemStats.SwapUsed = bytesToGigabytes(v.SwapTotal - v.SwapFree)
|
||||||
}
|
}
|
||||||
|
|
||||||
// disk usage
|
// disk usage
|
||||||
@@ -159,15 +151,17 @@ func getSystemStats() (*SystemInfo, *SystemStats) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getDockerStats() ([]*ContainerStats, error) {
|
func getDockerStats() ([]*ContainerStats, error) {
|
||||||
resp, err := client.Get("http://localhost/containers/json")
|
resp, err := dockerClient.Get("http://localhost/containers/json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
closeIdleConnections(err)
|
||||||
return []*ContainerStats{}, err
|
return []*ContainerStats{}, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
var containers []*Container
|
var containers []*Container
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&containers); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&containers); err != nil {
|
||||||
panic(err)
|
log.Printf("Error decoding containers: %+v\n", err)
|
||||||
|
return []*ContainerStats{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
containerStats := make([]*ContainerStats, 0, len(containers))
|
containerStats := make([]*ContainerStats, 0, len(containers))
|
||||||
@@ -184,15 +178,22 @@ func getDockerStats() ([]*ContainerStats, error) {
|
|||||||
// 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.HasSuffix(ctr.Status, "seconds") {
|
if strings.HasSuffix(ctr.Status, "seconds") {
|
||||||
// if so, remove old container data
|
// if so, remove old container data
|
||||||
delete(containerCpuMap, ctr.IdShort)
|
deleteContainerStatsSync(ctr.IdShort)
|
||||||
}
|
}
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
cstats, err := getContainerStats(ctr)
|
cstats, err := getContainerStats(ctr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// delete container from map and retry once
|
// Check if the error is a network timeout
|
||||||
delete(containerCpuMap, ctr.IdShort)
|
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||||
|
// Close idle connections to prevent reuse of stale connections
|
||||||
|
closeIdleConnections(err)
|
||||||
|
} else {
|
||||||
|
// otherwise delete container from map
|
||||||
|
deleteContainerStatsSync(ctr.IdShort)
|
||||||
|
}
|
||||||
|
// retry once
|
||||||
cstats, err = getContainerStats(ctr)
|
cstats, err = getContainerStats(ctr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error getting container stats: %+v\n", err)
|
log.Printf("Error getting container stats: %+v\n", err)
|
||||||
@@ -205,10 +206,10 @@ func getDockerStats() ([]*ContainerStats, error) {
|
|||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
for id := range containerCpuMap {
|
for id := range containerStatsMap {
|
||||||
if _, exists := validIds[id]; !exists {
|
if _, exists := validIds[id]; !exists {
|
||||||
// log.Printf("Removing container cpu map entry: %+v\n", id)
|
// log.Printf("Removing container cpu map entry: %+v\n", id)
|
||||||
delete(containerCpuMap, id)
|
delete(containerStatsMap, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +220,7 @@ func getContainerStats(ctr *Container) (*ContainerStats, error) {
|
|||||||
// use semaphore to limit concurrency
|
// use semaphore to limit concurrency
|
||||||
acquireSemaphore()
|
acquireSemaphore()
|
||||||
defer releaseSemaphore()
|
defer releaseSemaphore()
|
||||||
resp, err := client.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1")
|
resp, err := dockerClient.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &ContainerStats{}, err
|
return &ContainerStats{}, err
|
||||||
}
|
}
|
||||||
@@ -238,32 +239,61 @@ func getContainerStats(ctr *Container) (*ContainerStats, error) {
|
|||||||
memCache = statsJson.MemoryStats.Stats["cache"]
|
memCache = statsJson.MemoryStats.Stats["cache"]
|
||||||
}
|
}
|
||||||
usedMemory := statsJson.MemoryStats.Usage - memCache
|
usedMemory := statsJson.MemoryStats.Usage - memCache
|
||||||
// pctMemory := float64(usedMemory) / float64(statsJson.MemoryStats.Limit) * 100
|
|
||||||
|
containerStatsMutex.Lock()
|
||||||
|
defer containerStatsMutex.Unlock()
|
||||||
|
|
||||||
|
// add empty values if they doesn't exist in map
|
||||||
|
stats, initialized := containerStatsMap[ctr.IdShort]
|
||||||
|
if !initialized {
|
||||||
|
stats = &PrevContainerStats{}
|
||||||
|
containerStatsMap[ctr.IdShort] = stats
|
||||||
|
}
|
||||||
|
|
||||||
// cpu
|
// cpu
|
||||||
// add default values to containerCpu if it doesn't exist
|
cpuDelta := statsJson.CPUStats.CPUUsage.TotalUsage - stats.Cpu[0]
|
||||||
containerCpuMutex.Lock()
|
systemDelta := statsJson.CPUStats.SystemUsage - stats.Cpu[1]
|
||||||
defer containerCpuMutex.Unlock()
|
|
||||||
if _, ok := containerCpuMap[ctr.IdShort]; !ok {
|
|
||||||
containerCpuMap[ctr.IdShort] = [2]uint64{0, 0}
|
|
||||||
}
|
|
||||||
cpuDelta := statsJson.CPUStats.CPUUsage.TotalUsage - containerCpuMap[ctr.IdShort][0]
|
|
||||||
systemDelta := statsJson.CPUStats.SystemUsage - containerCpuMap[ctr.IdShort][1]
|
|
||||||
cpuPct := float64(cpuDelta) / float64(systemDelta) * 100
|
cpuPct := float64(cpuDelta) / float64(systemDelta) * 100
|
||||||
if cpuPct > 100 {
|
if cpuPct > 100 {
|
||||||
return &ContainerStats{}, fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct)
|
return &ContainerStats{}, fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct)
|
||||||
}
|
}
|
||||||
containerCpuMap[ctr.IdShort] = [2]uint64{statsJson.CPUStats.CPUUsage.TotalUsage, statsJson.CPUStats.SystemUsage}
|
stats.Cpu = [2]uint64{statsJson.CPUStats.CPUUsage.TotalUsage, statsJson.CPUStats.SystemUsage}
|
||||||
|
|
||||||
|
// network
|
||||||
|
var total_sent, total_recv uint64
|
||||||
|
for _, v := range statsJson.Networks {
|
||||||
|
total_sent += v.TxBytes
|
||||||
|
total_recv += v.RxBytes
|
||||||
|
}
|
||||||
|
var sent_delta, recv_delta float64
|
||||||
|
// prevent first run from sending all prev sent/recv bytes
|
||||||
|
if initialized {
|
||||||
|
secondsElapsed := time.Since(stats.Net.Time).Seconds()
|
||||||
|
sent_delta = float64(total_sent-stats.Net.Sent) / secondsElapsed
|
||||||
|
recv_delta = float64(total_recv-stats.Net.Recv) / secondsElapsed
|
||||||
|
// log.Printf("sent delta: %+v, recv delta: %+v\n", sent_delta, recv_delta)
|
||||||
|
}
|
||||||
|
stats.Net.Sent = total_sent
|
||||||
|
stats.Net.Recv = total_recv
|
||||||
|
stats.Net.Time = time.Now()
|
||||||
|
|
||||||
cStats := &ContainerStats{
|
cStats := &ContainerStats{
|
||||||
Name: name,
|
Name: name,
|
||||||
Cpu: twoDecimals(cpuPct),
|
Cpu: twoDecimals(cpuPct),
|
||||||
Mem: bytesToMegabytes(float64(usedMemory)),
|
Mem: bytesToMegabytes(float64(usedMemory)),
|
||||||
// MemPct: twoDecimals(pctMemory),
|
NetworkSent: bytesToMegabytes(sent_delta),
|
||||||
|
NetworkRecv: bytesToMegabytes(recv_delta),
|
||||||
}
|
}
|
||||||
return cStats, nil
|
return cStats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// delete container stats from map using mutex
|
||||||
|
func deleteContainerStatsSync(id string) {
|
||||||
|
containerStatsMutex.Lock()
|
||||||
|
defer containerStatsMutex.Unlock()
|
||||||
|
delete(containerStatsMap, id)
|
||||||
|
}
|
||||||
|
|
||||||
func gatherStats() *SystemData {
|
func gatherStats() *SystemData {
|
||||||
systemInfo, systemStats := getSystemStats()
|
systemInfo, systemStats := getSystemStats()
|
||||||
stats := &SystemData{
|
stats := &SystemData{
|
||||||
@@ -404,3 +434,47 @@ func initializeNetIoStats() {
|
|||||||
netIoStats.Time = time.Now()
|
netIoStats.Time = time.Now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newDockerClient() *http.Client {
|
||||||
|
dockerHost := "unix:///var/run/docker.sock"
|
||||||
|
if dockerHostEnv, exists := os.LookupEnv("DOCKER_HOST"); exists {
|
||||||
|
dockerHost = dockerHostEnv
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedURL, err := url.Parse(dockerHost)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Error parsing DOCKER_HOST: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
transport := &http.Transport{
|
||||||
|
ForceAttemptHTTP2: false,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
DisableCompression: true,
|
||||||
|
MaxIdleConnsPerHost: 20,
|
||||||
|
DisableKeepAlives: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch parsedURL.Scheme {
|
||||||
|
case "unix":
|
||||||
|
transport.DialContext = func(ctx context.Context, proto, addr string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, "unix", parsedURL.Path)
|
||||||
|
}
|
||||||
|
case "tcp", "http", "https":
|
||||||
|
log.Println("Using DOCKER_HOST: " + dockerHost)
|
||||||
|
transport.DialContext = func(ctx context.Context, proto, addr string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, "tcp", parsedURL.Host)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
log.Fatal("Unsupported DOCKER_HOST: " + parsedURL.Scheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: time.Second,
|
||||||
|
Transport: transport,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeIdleConnections(err error) {
|
||||||
|
log.Printf("Closing idle connections. Error: %+v\n", err)
|
||||||
|
dockerClient.Transport.(*http.Transport).CloseIdleConnections()
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ type SystemStats struct {
|
|||||||
MemUsed float64 `json:"mu"`
|
MemUsed float64 `json:"mu"`
|
||||||
MemPct float64 `json:"mp"`
|
MemPct float64 `json:"mp"`
|
||||||
MemBuffCache float64 `json:"mb"`
|
MemBuffCache float64 `json:"mb"`
|
||||||
|
Swap float64 `json:"s"`
|
||||||
|
SwapUsed float64 `json:"su"`
|
||||||
Disk float64 `json:"d"`
|
Disk float64 `json:"d"`
|
||||||
DiskUsed float64 `json:"du"`
|
DiskUsed float64 `json:"du"`
|
||||||
DiskPct float64 `json:"dp"`
|
DiskPct float64 `json:"dp"`
|
||||||
@@ -38,7 +40,8 @@ type ContainerStats struct {
|
|||||||
Name string `json:"n"`
|
Name string `json:"n"`
|
||||||
Cpu float64 `json:"c"`
|
Cpu float64 `json:"c"`
|
||||||
Mem float64 `json:"m"`
|
Mem float64 `json:"m"`
|
||||||
// MemPct float64 `json:"mp"`
|
NetworkSent float64 `json:"ns"`
|
||||||
|
NetworkRecv float64 `json:"nr"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Container struct {
|
type Container struct {
|
||||||
@@ -65,20 +68,22 @@ type Container struct {
|
|||||||
|
|
||||||
type CStats struct {
|
type CStats struct {
|
||||||
// Common stats
|
// Common stats
|
||||||
Read time.Time `json:"read"`
|
// Read time.Time `json:"read"`
|
||||||
PreRead time.Time `json:"preread"`
|
// PreRead time.Time `json:"preread"`
|
||||||
|
|
||||||
// Linux specific stats, not populated on Windows.
|
// Linux specific stats, not populated on Windows.
|
||||||
// PidsStats PidsStats `json:"pids_stats,omitempty"`
|
// PidsStats PidsStats `json:"pids_stats,omitempty"`
|
||||||
// BlkioStats BlkioStats `json:"blkio_stats,omitempty"`
|
// BlkioStats BlkioStats `json:"blkio_stats,omitempty"`
|
||||||
|
|
||||||
// Windows specific stats, not populated on Linux.
|
// Windows specific stats, not populated on Linux.
|
||||||
NumProcs uint32 `json:"num_procs"`
|
// NumProcs uint32 `json:"num_procs"`
|
||||||
// StorageStats StorageStats `json:"storage_stats,omitempty"`
|
// StorageStats StorageStats `json:"storage_stats,omitempty"`
|
||||||
|
// Networks request version >=1.21
|
||||||
|
Networks map[string]NetworkStats
|
||||||
|
|
||||||
// Shared stats
|
// Shared stats
|
||||||
CPUStats CPUStats `json:"cpu_stats,omitempty"`
|
CPUStats CPUStats `json:"cpu_stats,omitempty"`
|
||||||
PreCPUStats CPUStats `json:"precpu_stats,omitempty"` // "Pre"="Previous"
|
// PreCPUStats CPUStats `json:"precpu_stats,omitempty"` // "Pre"="Previous"
|
||||||
MemoryStats MemoryStats `json:"memory_stats,omitempty"`
|
MemoryStats MemoryStats `json:"memory_stats,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +95,7 @@ type CPUStats struct {
|
|||||||
SystemUsage uint64 `json:"system_cpu_usage,omitempty"`
|
SystemUsage uint64 `json:"system_cpu_usage,omitempty"`
|
||||||
|
|
||||||
// Online CPUs. Linux only.
|
// Online CPUs. Linux only.
|
||||||
OnlineCPUs uint32 `json:"online_cpus,omitempty"`
|
// OnlineCPUs uint32 `json:"online_cpus,omitempty"`
|
||||||
|
|
||||||
// Throttling Data. Linux only.
|
// Throttling Data. Linux only.
|
||||||
// ThrottlingData ThrottlingData `json:"throttling_data,omitempty"`
|
// ThrottlingData ThrottlingData `json:"throttling_data,omitempty"`
|
||||||
@@ -104,19 +109,19 @@ type CPUUsage struct {
|
|||||||
|
|
||||||
// Total CPU time consumed per core (Linux). Not used on Windows.
|
// Total CPU time consumed per core (Linux). Not used on Windows.
|
||||||
// Units: nanoseconds.
|
// Units: nanoseconds.
|
||||||
PercpuUsage []uint64 `json:"percpu_usage,omitempty"`
|
// PercpuUsage []uint64 `json:"percpu_usage,omitempty"`
|
||||||
|
|
||||||
// Time spent by tasks of the cgroup in kernel mode (Linux).
|
// Time spent by tasks of the cgroup in kernel mode (Linux).
|
||||||
// Time spent by all container processes in kernel mode (Windows).
|
// Time spent by all container processes in kernel mode (Windows).
|
||||||
// Units: nanoseconds (Linux).
|
// Units: nanoseconds (Linux).
|
||||||
// Units: 100's of nanoseconds (Windows). Not populated for Hyper-V Containers.
|
// Units: 100's of nanoseconds (Windows). Not populated for Hyper-V Containers.
|
||||||
UsageInKernelmode uint64 `json:"usage_in_kernelmode"`
|
// UsageInKernelmode uint64 `json:"usage_in_kernelmode"`
|
||||||
|
|
||||||
// Time spent by tasks of the cgroup in user mode (Linux).
|
// Time spent by tasks of the cgroup in user mode (Linux).
|
||||||
// Time spent by all container processes in user mode (Windows).
|
// Time spent by all container processes in user mode (Windows).
|
||||||
// Units: nanoseconds (Linux).
|
// Units: nanoseconds (Linux).
|
||||||
// Units: 100's of nanoseconds (Windows). Not populated for Hyper-V Containers
|
// Units: 100's of nanoseconds (Windows). Not populated for Hyper-V Containers
|
||||||
UsageInUsermode uint64 `json:"usage_in_usermode"`
|
// UsageInUsermode uint64 `json:"usage_in_usermode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MemoryStats struct {
|
type MemoryStats struct {
|
||||||
@@ -125,20 +130,27 @@ type MemoryStats struct {
|
|||||||
Usage uint64 `json:"usage,omitempty"`
|
Usage uint64 `json:"usage,omitempty"`
|
||||||
Cache uint64 `json:"cache,omitempty"`
|
Cache uint64 `json:"cache,omitempty"`
|
||||||
// maximum usage ever recorded.
|
// maximum usage ever recorded.
|
||||||
MaxUsage uint64 `json:"max_usage,omitempty"`
|
// MaxUsage uint64 `json:"max_usage,omitempty"`
|
||||||
// TODO(vishh): Export these as stronger types.
|
// TODO(vishh): Export these as stronger types.
|
||||||
// all the stats exported via memory.stat.
|
// all the stats exported via memory.stat.
|
||||||
Stats map[string]uint64 `json:"stats,omitempty"`
|
Stats map[string]uint64 `json:"stats,omitempty"`
|
||||||
// number of times memory usage hits limits.
|
// number of times memory usage hits limits.
|
||||||
Failcnt uint64 `json:"failcnt,omitempty"`
|
// Failcnt uint64 `json:"failcnt,omitempty"`
|
||||||
Limit uint64 `json:"limit,omitempty"`
|
// Limit uint64 `json:"limit,omitempty"`
|
||||||
|
|
||||||
// committed bytes
|
// // committed bytes
|
||||||
Commit uint64 `json:"commitbytes,omitempty"`
|
// Commit uint64 `json:"commitbytes,omitempty"`
|
||||||
// peak committed bytes
|
// // peak committed bytes
|
||||||
CommitPeak uint64 `json:"commitpeakbytes,omitempty"`
|
// CommitPeak uint64 `json:"commitpeakbytes,omitempty"`
|
||||||
// private working set
|
// // private working set
|
||||||
PrivateWorkingSet uint64 `json:"privateworkingset,omitempty"`
|
// PrivateWorkingSet uint64 `json:"privateworkingset,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NetworkStats struct {
|
||||||
|
// Bytes received. Windows and Linux.
|
||||||
|
RxBytes uint64 `json:"rx_bytes"`
|
||||||
|
// Bytes sent. Windows and Linux.
|
||||||
|
TxBytes uint64 `json:"tx_bytes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DiskIoStats struct {
|
type DiskIoStats struct {
|
||||||
@@ -154,3 +166,12 @@ type NetIoStats struct {
|
|||||||
Time time.Time
|
Time time.Time
|
||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PrevContainerStats struct {
|
||||||
|
Cpu [2]uint64
|
||||||
|
Net struct {
|
||||||
|
Sent uint64
|
||||||
|
Recv uint64
|
||||||
|
Time time.Time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/models"
|
"github.com/pocketbase/pocketbase/models"
|
||||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func handleSystemAlerts(newStatus string, newRecord *models.Record, oldRecord *models.Record) {
|
func handleSystemAlerts(newStatus string, newRecord *models.Record, oldRecord *models.Record) {
|
||||||
alertRecords, err := app.Dao().FindRecordsByExpr("alerts",
|
alertRecords, err := app.Dao().FindRecordsByExpr("alerts",
|
||||||
dbx.NewExp("system = {:system}", dbx.Params{"system": oldRecord.Get("id")}),
|
dbx.NewExp("system = {:system}", dbx.Params{"system": oldRecord.GetId()}),
|
||||||
)
|
)
|
||||||
if err != nil || len(alertRecords) == 0 {
|
if err != nil || len(alertRecords) == 0 {
|
||||||
// log.Println("no alerts found for system")
|
// log.Println("no alerts found for system")
|
||||||
@@ -22,7 +20,7 @@ func handleSystemAlerts(newStatus string, newRecord *models.Record, oldRecord *m
|
|||||||
// log.Println("found alerts", len(alertRecords))
|
// log.Println("found alerts", len(alertRecords))
|
||||||
var systemInfo *SystemInfo
|
var systemInfo *SystemInfo
|
||||||
for _, alertRecord := range alertRecords {
|
for _, alertRecord := range alertRecords {
|
||||||
name := alertRecord.Get("name").(string)
|
name := alertRecord.GetString("name")
|
||||||
switch name {
|
switch name {
|
||||||
case "Status":
|
case "Status":
|
||||||
handleStatusAlerts(newStatus, oldRecord, alertRecord)
|
handleStatusAlerts(newStatus, oldRecord, alertRecord)
|
||||||
@@ -46,24 +44,24 @@ func handleSystemAlerts(newStatus string, newRecord *models.Record, oldRecord *m
|
|||||||
|
|
||||||
func getSystemInfo(record *models.Record) *SystemInfo {
|
func getSystemInfo(record *models.Record) *SystemInfo {
|
||||||
var SystemInfo SystemInfo
|
var SystemInfo SystemInfo
|
||||||
json.Unmarshal([]byte(record.Get("info").(types.JsonRaw)), &SystemInfo)
|
record.UnmarshalJSONField("info", &SystemInfo)
|
||||||
return &SystemInfo
|
return &SystemInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSlidingValueAlert(newRecord *models.Record, alertRecord *models.Record, name string, curValue float64) {
|
func handleSlidingValueAlert(newRecord *models.Record, alertRecord *models.Record, name string, curValue float64) {
|
||||||
triggered := alertRecord.Get("triggered").(bool)
|
triggered := alertRecord.GetBool("triggered")
|
||||||
threshold := alertRecord.Get("value").(float64)
|
threshold := alertRecord.GetFloat("value")
|
||||||
// fmt.Println(name, curValue, "threshold", threshold, "triggered", triggered)
|
// fmt.Println(name, curValue, "threshold", threshold, "triggered", triggered)
|
||||||
var subject string
|
var subject string
|
||||||
var body string
|
var body string
|
||||||
if !triggered && curValue > threshold {
|
if !triggered && curValue > threshold {
|
||||||
alertRecord.Set("triggered", true)
|
alertRecord.Set("triggered", true)
|
||||||
systemName := newRecord.Get("name").(string)
|
systemName := newRecord.GetString("name")
|
||||||
subject = fmt.Sprintf("%s usage threshold exceeded on %s", name, systemName)
|
subject = fmt.Sprintf("%s usage threshold exceeded on %s", name, systemName)
|
||||||
body = fmt.Sprintf("%s usage on %s is %.1f%%.\n\n- Beszel", name, systemName, curValue)
|
body = fmt.Sprintf("%s usage on %s is %.1f%%.\n\n%s\n\n- Beszel", name, systemName, curValue, app.Settings().Meta.AppUrl+"/system/"+systemName)
|
||||||
} else if triggered && curValue <= threshold {
|
} else if triggered && curValue <= threshold {
|
||||||
alertRecord.Set("triggered", false)
|
alertRecord.Set("triggered", false)
|
||||||
systemName := newRecord.Get("name").(string)
|
systemName := newRecord.GetString("name")
|
||||||
subject = fmt.Sprintf("%s usage returned below threshold on %s", name, systemName)
|
subject = fmt.Sprintf("%s usage returned below threshold on %s", name, systemName)
|
||||||
body = fmt.Sprintf("%s usage on %s is below threshold at %.1f%%.\n\n%s\n\n- Beszel", name, systemName, curValue, app.Settings().Meta.AppUrl+"/system/"+systemName)
|
body = fmt.Sprintf("%s usage on %s is below threshold at %.1f%%.\n\n%s\n\n- Beszel", name, systemName, curValue, app.Settings().Meta.AppUrl+"/system/"+systemName)
|
||||||
} else {
|
} else {
|
||||||
@@ -81,7 +79,7 @@ func handleSlidingValueAlert(newRecord *models.Record, alertRecord *models.Recor
|
|||||||
}
|
}
|
||||||
if user := alertRecord.ExpandedOne("user"); user != nil {
|
if user := alertRecord.ExpandedOne("user"); user != nil {
|
||||||
sendAlert(EmailData{
|
sendAlert(EmailData{
|
||||||
to: user.Get("email").(string),
|
to: user.GetString("email"),
|
||||||
subj: subject,
|
subj: subject,
|
||||||
body: body,
|
body: body,
|
||||||
})
|
})
|
||||||
@@ -92,11 +90,11 @@ func handleStatusAlerts(newStatus string, oldRecord *models.Record, alertRecord
|
|||||||
var alertStatus string
|
var alertStatus string
|
||||||
switch newStatus {
|
switch newStatus {
|
||||||
case "up":
|
case "up":
|
||||||
if oldRecord.Get("status") == "down" {
|
if oldRecord.GetString("status") == "down" {
|
||||||
alertStatus = "up"
|
alertStatus = "up"
|
||||||
}
|
}
|
||||||
case "down":
|
case "down":
|
||||||
if oldRecord.Get("status") == "up" {
|
if oldRecord.GetString("status") == "up" {
|
||||||
alertStatus = "down"
|
alertStatus = "down"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,9 +114,9 @@ func handleStatusAlerts(newStatus string, oldRecord *models.Record, alertRecord
|
|||||||
emoji = "\u2705"
|
emoji = "\u2705"
|
||||||
}
|
}
|
||||||
// send alert
|
// send alert
|
||||||
systemName := oldRecord.Get("name").(string)
|
systemName := oldRecord.GetString("name")
|
||||||
sendAlert(EmailData{
|
sendAlert(EmailData{
|
||||||
to: user.Get("email").(string),
|
to: user.GetString("email"),
|
||||||
subj: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji),
|
subj: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji),
|
||||||
body: fmt.Sprintf("Connection to %s is %s\n\n- Beszel", systemName, alertStatus),
|
body: fmt.Sprintf("Connection to %s is %s\n\n- Beszel", systemName, alertStatus),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM --platform=$BUILDPLATFORM golang:alpine as builder
|
FROM --platform=$BUILDPLATFORM golang:alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
46
hub/main.go
46
hub/main.go
@@ -25,12 +25,11 @@ import (
|
|||||||
"github.com/pocketbase/pocketbase/models"
|
"github.com/pocketbase/pocketbase/models"
|
||||||
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
||||||
"github.com/pocketbase/pocketbase/tools/cron"
|
"github.com/pocketbase/pocketbase/tools/cron"
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Version = "0.1.1"
|
var Version = "0.1.2"
|
||||||
|
|
||||||
var app *pocketbase.PocketBase
|
var app *pocketbase.PocketBase
|
||||||
var serverConnections = make(map[string]*Server)
|
var serverConnections = make(map[string]*Server)
|
||||||
@@ -105,16 +104,12 @@ func main() {
|
|||||||
// cron job to delete old records
|
// cron job to delete old records
|
||||||
scheduler := cron.New()
|
scheduler := cron.New()
|
||||||
scheduler.MustAdd("delete old records", "8 * * * *", func() {
|
scheduler.MustAdd("delete old records", "8 * * * *", func() {
|
||||||
deleteOldRecords("system_stats", "1m", time.Hour)
|
collections := []string{"system_stats", "container_stats"}
|
||||||
deleteOldRecords("container_stats", "1m", time.Hour)
|
deleteOldRecords(collections, "1m", time.Hour)
|
||||||
deleteOldRecords("system_stats", "10m", 12*time.Hour)
|
deleteOldRecords(collections, "10m", 12*time.Hour)
|
||||||
deleteOldRecords("container_stats", "10m", 12*time.Hour)
|
deleteOldRecords(collections, "20m", 24*time.Hour)
|
||||||
deleteOldRecords("system_stats", "20m", 24*time.Hour)
|
deleteOldRecords(collections, "120m", 7*24*time.Hour)
|
||||||
deleteOldRecords("container_stats", "20m", 24*time.Hour)
|
deleteOldRecords(collections, "480m", 30*24*time.Hour)
|
||||||
deleteOldRecords("system_stats", "120m", 7*24*time.Hour)
|
|
||||||
deleteOldRecords("container_stats", "120m", 7*24*time.Hour)
|
|
||||||
deleteOldRecords("system_stats", "480m", 30*24*time.Hour)
|
|
||||||
deleteOldRecords("container_stats", "480m", 30*24*time.Hour)
|
|
||||||
})
|
})
|
||||||
scheduler.Start()
|
scheduler.Start()
|
||||||
return nil
|
return nil
|
||||||
@@ -155,7 +150,7 @@ func main() {
|
|||||||
// user creation - set default role to user if unset
|
// user creation - set default role to user if unset
|
||||||
app.OnModelBeforeCreate("users").Add(func(e *core.ModelEvent) error {
|
app.OnModelBeforeCreate("users").Add(func(e *core.ModelEvent) error {
|
||||||
user := e.Model.(*models.Record)
|
user := e.Model.(*models.Record)
|
||||||
if user.Get("role") == "" {
|
if user.GetString("role") == "" {
|
||||||
user.Set("role", "user")
|
user.Set("role", "user")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -164,8 +159,7 @@ func main() {
|
|||||||
// system creation defaults
|
// system creation defaults
|
||||||
app.OnModelBeforeCreate("systems").Add(func(e *core.ModelEvent) error {
|
app.OnModelBeforeCreate("systems").Add(func(e *core.ModelEvent) error {
|
||||||
record := e.Model.(*models.Record)
|
record := e.Model.(*models.Record)
|
||||||
var info = SystemInfo{}
|
record.Set("info", SystemInfo{})
|
||||||
record.Set("info", info)
|
|
||||||
record.Set("status", "pending")
|
record.Set("status", "pending")
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -180,7 +174,7 @@ func main() {
|
|||||||
app.OnModelAfterUpdate("systems").Add(func(e *core.ModelEvent) error {
|
app.OnModelAfterUpdate("systems").Add(func(e *core.ModelEvent) error {
|
||||||
newRecord := e.Model.(*models.Record)
|
newRecord := e.Model.(*models.Record)
|
||||||
oldRecord := newRecord.OriginalCopy()
|
oldRecord := newRecord.OriginalCopy()
|
||||||
newStatus := newRecord.Get("status").(string)
|
newStatus := newRecord.GetString("status")
|
||||||
|
|
||||||
// if server is disconnected and connection exists, remove it
|
// if server is disconnected and connection exists, remove it
|
||||||
if newStatus == "down" || newStatus == "paused" {
|
if newStatus == "down" || newStatus == "paused" {
|
||||||
@@ -241,12 +235,18 @@ func updateSystems() {
|
|||||||
}
|
}
|
||||||
fiftySecondsAgo := time.Now().UTC().Add(-50 * time.Second)
|
fiftySecondsAgo := time.Now().UTC().Add(-50 * time.Second)
|
||||||
batchSize := len(records)/4 + 1
|
batchSize := len(records)/4 + 1
|
||||||
for i := 0; i < batchSize; i++ {
|
done := 0
|
||||||
if records[i].Get("updated").(types.DateTime).Time().After(fiftySecondsAgo) {
|
for _, record := range records {
|
||||||
|
// break if batch size reached or if the system was updated less than 50 seconds ago
|
||||||
|
if done >= batchSize || record.GetDateTime("updated").Time().After(fiftySecondsAgo) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// log.Println("updating", records[i].Get(("name")))
|
// don't increment for down systems to avoid them jamming the queue
|
||||||
go updateSystem(records[i])
|
// because they're always first when sorted by least recently updated
|
||||||
|
if record.GetString("status") != "down" {
|
||||||
|
done++
|
||||||
|
}
|
||||||
|
go updateSystem(record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,8 +258,8 @@ func updateSystem(record *models.Record) {
|
|||||||
} else {
|
} else {
|
||||||
// create server connection struct
|
// create server connection struct
|
||||||
server = &Server{
|
server = &Server{
|
||||||
Host: record.Get("host").(string),
|
Host: record.GetString("host"),
|
||||||
Port: record.Get("port").(string),
|
Port: record.GetString("port"),
|
||||||
}
|
}
|
||||||
client, err := getServerConnection(server)
|
client, err := getServerConnection(server)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -321,7 +321,7 @@ func updateServerStatus(record *models.Record, status string) {
|
|||||||
// if status == "down" || status == "paused" {
|
// if status == "down" || status == "paused" {
|
||||||
// deleteServerConnection(record)
|
// deleteServerConnection(record)
|
||||||
// }
|
// }
|
||||||
if record.Get("status") != status {
|
if record.GetString("status") != status {
|
||||||
record.Set("status", status)
|
record.Set("status", status)
|
||||||
if err := app.Dao().SaveRecord(record); err != nil {
|
if err := app.Dao().SaveRecord(record); err != nil {
|
||||||
app.Logger().Error("Failed to update record: ", "err", err.Error())
|
app.Logger().Error("Failed to update record: ", "err", err.Error())
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"math"
|
"math"
|
||||||
"reflect"
|
"reflect"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/daos"
|
||||||
"github.com/pocketbase/pocketbase/models"
|
"github.com/pocketbase/pocketbase/models"
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func createLongerRecords(collectionName string, shorterRecord *models.Record) {
|
func createLongerRecords(collectionName string, shorterRecord *models.Record) {
|
||||||
shorterRecordType := shorterRecord.Get("type").(string)
|
shorterRecordType := shorterRecord.GetString("type")
|
||||||
systemId := shorterRecord.Get("system").(string)
|
systemId := shorterRecord.GetString("system")
|
||||||
// fmt.Println("create longer records", "recordType", shorterRecordType, "systemId", systemId)
|
// fmt.Println("create longer records", "recordType", shorterRecordType, "systemId", systemId)
|
||||||
var longerRecordType string
|
var longerRecordType string
|
||||||
var timeAgo time.Duration
|
var timeAgo time.Duration
|
||||||
@@ -75,11 +73,11 @@ func createLongerRecords(collectionName string, shorterRecord *models.Record) {
|
|||||||
stats = averageContainerStats(allShorterRecords)
|
stats = averageContainerStats(allShorterRecords)
|
||||||
}
|
}
|
||||||
collection, _ := app.Dao().FindCollectionByNameOrId(collectionName)
|
collection, _ := app.Dao().FindCollectionByNameOrId(collectionName)
|
||||||
tenMinRecord := models.NewRecord(collection)
|
longerRecord := models.NewRecord(collection)
|
||||||
tenMinRecord.Set("system", systemId)
|
longerRecord.Set("system", systemId)
|
||||||
tenMinRecord.Set("stats", stats)
|
longerRecord.Set("stats", stats)
|
||||||
tenMinRecord.Set("type", longerRecordType)
|
longerRecord.Set("type", longerRecordType)
|
||||||
if err := app.Dao().SaveRecord(tenMinRecord); err != nil {
|
if err := app.Dao().SaveRecord(longerRecord); err != nil {
|
||||||
fmt.Println("failed to save longer record", "err", err.Error())
|
fmt.Println("failed to save longer record", "err", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +90,7 @@ func averageSystemStats(records []*models.Record) SystemStats {
|
|||||||
|
|
||||||
for _, record := range records {
|
for _, record := range records {
|
||||||
var stats SystemStats
|
var stats SystemStats
|
||||||
json.Unmarshal([]byte(record.Get("stats").(types.JsonRaw)), &stats)
|
record.UnmarshalJSONField("stats", &stats)
|
||||||
statValue := reflect.ValueOf(stats)
|
statValue := reflect.ValueOf(stats)
|
||||||
for i := 0; i < statValue.NumField(); i++ {
|
for i := 0; i < statValue.NumField(); i++ {
|
||||||
field := sum.Field(i)
|
field := sum.Field(i)
|
||||||
@@ -114,13 +112,15 @@ func averageContainerStats(records []*models.Record) (stats []ContainerStats) {
|
|||||||
count := float64(len(records))
|
count := float64(len(records))
|
||||||
for _, record := range records {
|
for _, record := range records {
|
||||||
var stats []ContainerStats
|
var stats []ContainerStats
|
||||||
json.Unmarshal([]byte(record.Get("stats").(types.JsonRaw)), &stats)
|
record.UnmarshalJSONField("stats", &stats)
|
||||||
for _, stat := range stats {
|
for _, stat := range stats {
|
||||||
if _, ok := sums[stat.Name]; !ok {
|
if _, ok := sums[stat.Name]; !ok {
|
||||||
sums[stat.Name] = &ContainerStats{Name: stat.Name, Cpu: 0, Mem: 0}
|
sums[stat.Name] = &ContainerStats{Name: stat.Name, Cpu: 0, Mem: 0}
|
||||||
}
|
}
|
||||||
sums[stat.Name].Cpu += stat.Cpu
|
sums[stat.Name].Cpu += stat.Cpu
|
||||||
sums[stat.Name].Mem += stat.Mem
|
sums[stat.Name].Mem += stat.Mem
|
||||||
|
sums[stat.Name].NetworkSent += stat.NetworkSent
|
||||||
|
sums[stat.Name].NetworkRecv += stat.NetworkRecv
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, value := range sums {
|
for _, value := range sums {
|
||||||
@@ -128,6 +128,8 @@ func averageContainerStats(records []*models.Record) (stats []ContainerStats) {
|
|||||||
Name: value.Name,
|
Name: value.Name,
|
||||||
Cpu: twoDecimals(value.Cpu / count),
|
Cpu: twoDecimals(value.Cpu / count),
|
||||||
Mem: twoDecimals(value.Mem / count),
|
Mem: twoDecimals(value.Mem / count),
|
||||||
|
NetworkSent: twoDecimals(value.NetworkSent / count),
|
||||||
|
NetworkRecv: twoDecimals(value.NetworkRecv / count),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return stats
|
return stats
|
||||||
@@ -138,16 +140,28 @@ func twoDecimals(value float64) float64 {
|
|||||||
return math.Round(value*100) / 100
|
return math.Round(value*100) / 100
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Delete records of specified collection and type that are older than timeLimit */
|
/* Delete records of specified collections and type that are older than timeLimit */
|
||||||
func deleteOldRecords(collection string, recordType string, timeLimit time.Duration) {
|
func deleteOldRecords(collections []string, recordType string, timeLimit time.Duration) {
|
||||||
timeLimitStamp := time.Now().UTC().Add(-timeLimit).Format("2006-01-02 15:04:05")
|
timeLimitStamp := time.Now().UTC().Add(-timeLimit).Format("2006-01-02 15:04:05")
|
||||||
records, _ := app.Dao().FindRecordsByExpr(collection,
|
|
||||||
dbx.NewExp("type = {:type}", dbx.Params{"type": recordType}),
|
// db query
|
||||||
dbx.NewExp("created < {:created}", dbx.Params{"created": timeLimitStamp}),
|
expType := dbx.NewExp("type = {:type}", dbx.Params{"type": recordType})
|
||||||
)
|
expCreated := dbx.NewExp("created < {:created}", dbx.Params{"created": timeLimitStamp})
|
||||||
|
|
||||||
|
var records []*models.Record
|
||||||
|
for _, collection := range collections {
|
||||||
|
if collectionRecords, err := app.Dao().FindRecordsByExpr(collection, expType, expCreated); err == nil {
|
||||||
|
records = append(records, collectionRecords...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||||
for _, record := range records {
|
for _, record := range records {
|
||||||
if err := app.Dao().DeleteRecord(record); err != nil {
|
err := txDao.DeleteRecord(record)
|
||||||
log.Fatal(err)
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -9,8 +9,8 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nanostores/react": "^0.7.2",
|
"@nanostores/react": "^0.7.3",
|
||||||
"@nanostores/router": "^0.15.0",
|
"@nanostores/router": "^0.15.1",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.1",
|
"@radix-ui/react-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"@radix-ui/react-switch": "^1.1.0",
|
"@radix-ui/react-switch": "^1.1.0",
|
||||||
"@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.19.3",
|
"@tanstack/react-table": "^8.20.1",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -37,13 +37,14 @@
|
|||||||
"recharts": "^2.13.0-alpha.4",
|
"recharts": "^2.13.0-alpha.4",
|
||||||
"tailwind-merge": "^2.4.0",
|
"tailwind-merge": "^2.4.0",
|
||||||
"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.6",
|
"@types/bun": "^1.1.6",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.4.40",
|
"postcss": "^8.4.40",
|
||||||
"tailwindcss": "^3.4.7",
|
"tailwindcss": "^3.4.7",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { Copy, Plus } from 'lucide-react'
|
|||||||
import { useState, useRef, MutableRefObject, useEffect } from 'react'
|
import { useState, useRef, MutableRefObject, useEffect } from 'react'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { copyToClipboard } from '@/lib/utils'
|
import { copyToClipboard } from '@/lib/utils'
|
||||||
import { SystemStats } from '@/types'
|
|
||||||
|
|
||||||
export function AddSystemButton() {
|
export function AddSystemButton() {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@@ -75,7 +74,7 @@ export function AddSystemButton() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="mb-2">Add New System</DialogTitle>
|
<DialogTitle className="mb-2">Add New System</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
The agent must be running on the server to connect. Copy the{' '}
|
The agent must be running on the system to connect. Copy the{' '}
|
||||||
<code className="bg-muted px-1 rounded-sm">docker-compose.yml</code> for the agent
|
<code className="bg-muted px-1 rounded-sm">docker-compose.yml</code> for the agent
|
||||||
below.
|
below.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|||||||
@@ -1,45 +1,44 @@
|
|||||||
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 {
|
import {
|
||||||
ChartConfig,
|
chartTimeData,
|
||||||
ChartContainer,
|
cn,
|
||||||
ChartTooltip,
|
formatShortDate,
|
||||||
ChartTooltipContent,
|
toFixedWithoutTrailingZeros,
|
||||||
} from '@/components/ui/chart'
|
useYaxisWidth,
|
||||||
import { chartTimeData, formatShortDate } from '@/lib/utils'
|
} 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 } from '@/lib/stores'
|
||||||
|
import { SystemStatsRecord } from '@/types'
|
||||||
const chartConfig = {
|
import { useMemo, useRef } from 'react'
|
||||||
recv: {
|
|
||||||
label: 'Received',
|
|
||||||
color: 'hsl(var(--chart-2))',
|
|
||||||
},
|
|
||||||
sent: {
|
|
||||||
label: 'Sent',
|
|
||||||
color: 'hsl(var(--chart-5))',
|
|
||||||
},
|
|
||||||
} satisfies ChartConfig
|
|
||||||
|
|
||||||
export default function BandwidthChart({
|
export default function BandwidthChart({
|
||||||
chartData,
|
|
||||||
ticks,
|
ticks,
|
||||||
|
systemData,
|
||||||
}: {
|
}: {
|
||||||
chartData: { time: number; sent: number; recv: number }[]
|
|
||||||
ticks: number[]
|
ticks: number[]
|
||||||
|
systemData: SystemStatsRecord[]
|
||||||
}) {
|
}) {
|
||||||
|
const chartRef = useRef<HTMLDivElement>(null)
|
||||||
|
const yAxisWidth = useYaxisWidth(chartRef)
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
|
|
||||||
if (!chartData.length || !ticks.length) {
|
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
||||||
return <Spinner />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContainer config={chartConfig} className="h-full w-full absolute aspect-auto">
|
<div ref={chartRef}>
|
||||||
|
{/* {!yAxisSet && <Spinner />} */}
|
||||||
|
<ChartContainer
|
||||||
|
config={{}}
|
||||||
|
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||||
|
'opacity-100': yAxisSet,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<AreaChart
|
<AreaChart
|
||||||
accessibilityLayer
|
accessibilityLayer
|
||||||
data={chartData}
|
data={systemData}
|
||||||
margin={{
|
margin={{
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
@@ -50,20 +49,15 @@ export default function BandwidthChart({
|
|||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<YAxis
|
<YAxis
|
||||||
className="tracking-tighter"
|
className="tracking-tighter"
|
||||||
width={75}
|
width={yAxisWidth}
|
||||||
domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]}
|
// domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]}
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(value) => toFixedWithoutTrailingZeros(value, 2)}
|
||||||
if (value >= 100) {
|
|
||||||
return value.toFixed(0)
|
|
||||||
}
|
|
||||||
return value.toFixed((value * 100) % 1 === 0 ? 1 : 2)
|
|
||||||
}}
|
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
unit={' MB/s'}
|
unit={' MB/s'}
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="time"
|
dataKey="created"
|
||||||
domain={[ticks[0], ticks.at(-1)!]}
|
domain={[ticks[0], ticks.at(-1)!]}
|
||||||
ticks={ticks}
|
ticks={ticks}
|
||||||
type="number"
|
type="number"
|
||||||
@@ -79,28 +73,33 @@ export default function BandwidthChart({
|
|||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
unit=" MB/s"
|
unit=" MB/s"
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.time)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
indicator="line"
|
indicator="line"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
dataKey="sent"
|
dataKey="stats.ns"
|
||||||
|
name="Sent"
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill="var(--color-sent)"
|
fill="hsl(var(--chart-5))"
|
||||||
fillOpacity={0.4}
|
fillOpacity={0.4}
|
||||||
stroke="var(--color-sent)"
|
stroke="hsl(var(--chart-5))"
|
||||||
animationDuration={1200}
|
// animationDuration={1200}
|
||||||
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
dataKey="recv"
|
dataKey="stats.nr"
|
||||||
|
name="Received"
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill="var(--color-recv)"
|
fill="hsl(var(--chart-2))"
|
||||||
fillOpacity={0.4}
|
fillOpacity={0.4}
|
||||||
stroke="var(--color-recv)"
|
stroke="hsl(var(--chart-2))"
|
||||||
animationDuration={1200}
|
// animationDuration={1200}
|
||||||
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||||
import {
|
import {
|
||||||
ChartConfig,
|
ChartConfig,
|
||||||
@@ -7,9 +5,9 @@ import {
|
|||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
} from '@/components/ui/chart'
|
} from '@/components/ui/chart'
|
||||||
import { useMemo } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import { chartTimeData, formatShortDate } from '@/lib/utils'
|
import { chartTimeData, cn, formatShortDate, useYaxisWidth } 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 } from '@/lib/stores'
|
||||||
|
|
||||||
@@ -20,8 +18,12 @@ export default function ContainerCpuChart({
|
|||||||
chartData: Record<string, number | string>[]
|
chartData: Record<string, number | string>[]
|
||||||
ticks: number[]
|
ticks: number[]
|
||||||
}) {
|
}) {
|
||||||
|
const chartRef = useRef<HTMLDivElement>(null)
|
||||||
|
const yAxisWidth = useYaxisWidth(chartRef)
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
|
|
||||||
|
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
||||||
|
|
||||||
const chartConfig = useMemo(() => {
|
const chartConfig = useMemo(() => {
|
||||||
let config = {} as Record<
|
let config = {} as Record<
|
||||||
string,
|
string,
|
||||||
@@ -57,12 +59,19 @@ export default function ContainerCpuChart({
|
|||||||
return config satisfies ChartConfig
|
return config satisfies ChartConfig
|
||||||
}, [chartData])
|
}, [chartData])
|
||||||
|
|
||||||
if (!chartData.length || !ticks.length) {
|
// if (!chartData.length || !ticks.length) {
|
||||||
return <Spinner />
|
// return <Spinner />
|
||||||
}
|
// }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContainer config={chartConfig} className="h-full w-full absolute aspect-auto">
|
<div ref={chartRef}>
|
||||||
|
{/* {!yAxisSet && <Spinner />} */}
|
||||||
|
<ChartContainer
|
||||||
|
config={{}}
|
||||||
|
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||||
|
'opacity-100': yAxisSet,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<AreaChart
|
<AreaChart
|
||||||
accessibilityLayer
|
accessibilityLayer
|
||||||
data={chartData}
|
data={chartData}
|
||||||
@@ -73,8 +82,9 @@ export default function ContainerCpuChart({
|
|||||||
>
|
>
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<YAxis
|
<YAxis
|
||||||
|
className="tracking-tighter"
|
||||||
// domain={[0, (max: number) => Math.max(Math.ceil(max), 0.4)]}
|
// domain={[0, (max: number) => Math.max(Math.ceil(max), 0.4)]}
|
||||||
width={47}
|
width={yAxisWidth}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
unit={'%'}
|
unit={'%'}
|
||||||
@@ -104,8 +114,9 @@ export default function ContainerCpuChart({
|
|||||||
<Area
|
<Area
|
||||||
key={key}
|
key={key}
|
||||||
// isAnimationActive={chartData.length < 20}
|
// isAnimationActive={chartData.length < 20}
|
||||||
animateNewValues={false}
|
isAnimationActive={false}
|
||||||
animationDuration={1200}
|
// animateNewValues={false}
|
||||||
|
// animationDuration={1200}
|
||||||
dataKey={key}
|
dataKey={key}
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill={chartConfig[key].color}
|
fill={chartConfig[key].color}
|
||||||
@@ -116,5 +127,6 @@ export default function ContainerCpuChart({
|
|||||||
))}
|
))}
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||||
import {
|
import {
|
||||||
ChartConfig,
|
ChartConfig,
|
||||||
@@ -7,9 +5,15 @@ import {
|
|||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
} from '@/components/ui/chart'
|
} from '@/components/ui/chart'
|
||||||
import { useMemo } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import { chartTimeData, formatShortDate } from '@/lib/utils'
|
import {
|
||||||
import Spinner from '../spinner'
|
chartTimeData,
|
||||||
|
cn,
|
||||||
|
formatShortDate,
|
||||||
|
toFixedWithoutTrailingZeros,
|
||||||
|
useYaxisWidth,
|
||||||
|
} 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'
|
||||||
|
|
||||||
@@ -21,6 +25,10 @@ export default function ContainerMemChart({
|
|||||||
ticks: number[]
|
ticks: number[]
|
||||||
}) {
|
}) {
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
|
const chartRef = useRef<HTMLDivElement>(null)
|
||||||
|
const yAxisWidth = useYaxisWidth(chartRef)
|
||||||
|
|
||||||
|
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
||||||
|
|
||||||
const chartConfig = useMemo(() => {
|
const chartConfig = useMemo(() => {
|
||||||
let config = {} as Record<
|
let config = {} as Record<
|
||||||
@@ -57,12 +65,19 @@ export default function ContainerMemChart({
|
|||||||
return config satisfies ChartConfig
|
return config satisfies ChartConfig
|
||||||
}, [chartData])
|
}, [chartData])
|
||||||
|
|
||||||
if (!chartData.length || !ticks.length) {
|
// if (!chartData.length || !ticks.length) {
|
||||||
return <Spinner />
|
// return <Spinner />
|
||||||
}
|
// }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContainer config={chartConfig} className="h-full w-full absolute aspect-auto">
|
<div ref={chartRef}>
|
||||||
|
{/* {!yAxisSet && <Spinner />} */}
|
||||||
|
<ChartContainer
|
||||||
|
config={{}}
|
||||||
|
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||||
|
'opacity-100': yAxisSet,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<AreaChart
|
<AreaChart
|
||||||
accessibilityLayer
|
accessibilityLayer
|
||||||
data={chartData}
|
data={chartData}
|
||||||
@@ -70,20 +85,16 @@ export default function ContainerMemChart({
|
|||||||
margin={{
|
margin={{
|
||||||
top: 10,
|
top: 10,
|
||||||
}}
|
}}
|
||||||
|
|
||||||
// reverseStackOrder={true}
|
|
||||||
>
|
>
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<YAxis
|
<YAxis
|
||||||
|
className="tracking-tighter"
|
||||||
// domain={[0, (max: number) => Math.ceil(max)]}
|
// domain={[0, (max: number) => Math.ceil(max)]}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
unit={' GB'}
|
unit={' GB'}
|
||||||
width={70}
|
width={yAxisWidth}
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(value) => toFixedWithoutTrailingZeros(value / 1024, 2)}
|
||||||
value = value / 1024
|
|
||||||
return value.toFixed((value * 100) % 1 === 0 ? 1 : 2)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="time"
|
dataKey="time"
|
||||||
@@ -103,14 +114,13 @@ export default function ContainerMemChart({
|
|||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.time)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.time)}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
itemSorter={(a, b) => b.value - a.value}
|
itemSorter={(a, b) => b.value - a.value}
|
||||||
content={<ChartTooltipContent unit=" MiB" indicator="line" />}
|
content={<ChartTooltipContent unit=" MB" indicator="line" />}
|
||||||
/>
|
/>
|
||||||
{Object.keys(chartConfig).map((key) => (
|
{Object.keys(chartConfig).map((key) => (
|
||||||
<Area
|
<Area
|
||||||
key={key}
|
key={key}
|
||||||
isAnimationActive={chartData.length < 20}
|
// animationDuration={1200}
|
||||||
animateNewValues={false}
|
isAnimationActive={false}
|
||||||
animationDuration={1200}
|
|
||||||
dataKey={key}
|
dataKey={key}
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill={chartConfig[key].color}
|
fill={chartConfig[key].color}
|
||||||
@@ -121,5 +131,6 @@ export default function ContainerMemChart({
|
|||||||
))}
|
))}
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
157
hub/site/src/components/charts/container-net-chart.tsx
Normal file
157
hub/site/src/components/charts/container-net-chart.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||||
|
import {
|
||||||
|
ChartConfig,
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
} from '@/components/ui/chart'
|
||||||
|
import { useMemo, useRef } from 'react'
|
||||||
|
import {
|
||||||
|
chartTimeData,
|
||||||
|
cn,
|
||||||
|
formatShortDate,
|
||||||
|
toFixedWithoutTrailingZeros,
|
||||||
|
useYaxisWidth,
|
||||||
|
} from '@/lib/utils'
|
||||||
|
// import Spinner from '../spinner'
|
||||||
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { $chartTime } from '@/lib/stores'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
|
||||||
|
export default function ContainerCpuChart({
|
||||||
|
chartData,
|
||||||
|
ticks,
|
||||||
|
}: {
|
||||||
|
chartData: Record<string, number | number[]>[]
|
||||||
|
ticks: number[]
|
||||||
|
}) {
|
||||||
|
const chartTime = useStore($chartTime)
|
||||||
|
const chartRef = useRef<HTMLDivElement>(null)
|
||||||
|
const yAxisWidth = useYaxisWidth(chartRef)
|
||||||
|
|
||||||
|
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
||||||
|
|
||||||
|
const chartConfig = useMemo(() => {
|
||||||
|
let config = {} as Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
>
|
||||||
|
const totalUsage = {} as Record<string, number>
|
||||||
|
for (let stats of chartData) {
|
||||||
|
for (let key in stats) {
|
||||||
|
if (!Array.isArray(stats[key])) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!(key in totalUsage)) {
|
||||||
|
totalUsage[key] = 0
|
||||||
|
}
|
||||||
|
totalUsage[key] += stats[key][2] ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let keys = Object.keys(totalUsage)
|
||||||
|
keys.sort((a, b) => (totalUsage[a] > totalUsage[b] ? -1 : 1))
|
||||||
|
const length = keys.length
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
const key = keys[i]
|
||||||
|
const hue = ((i * 360) / length) % 360
|
||||||
|
config[key] = {
|
||||||
|
label: key,
|
||||||
|
color: `hsl(${hue}, 60%, 55%)`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config satisfies ChartConfig
|
||||||
|
}, [chartData])
|
||||||
|
|
||||||
|
// if (!chartData.length || !ticks.length) {
|
||||||
|
// return <Spinner />
|
||||||
|
// }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={chartRef}>
|
||||||
|
{/* {!yAxisSet && <Spinner />} */}
|
||||||
|
<ChartContainer
|
||||||
|
config={{}}
|
||||||
|
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||||
|
'opacity-100': yAxisSet,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<AreaChart
|
||||||
|
accessibilityLayer
|
||||||
|
data={chartData}
|
||||||
|
margin={{
|
||||||
|
top: 10,
|
||||||
|
}}
|
||||||
|
reverseStackOrder={true}
|
||||||
|
>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<YAxis
|
||||||
|
className="tracking-tighter"
|
||||||
|
// domain={[0, (max: number) => Math.max(Math.ceil(max), 0.4)]}
|
||||||
|
width={yAxisWidth}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
unit={' MB/s'}
|
||||||
|
tickFormatter={(value) => toFixedWithoutTrailingZeros(value, 2)}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="time"
|
||||||
|
domain={[ticks[0], ticks.at(-1)!]}
|
||||||
|
ticks={ticks}
|
||||||
|
type="number"
|
||||||
|
scale={'time'}
|
||||||
|
minTickGap={35}
|
||||||
|
tickMargin={8}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={chartTimeData[chartTime].format}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
// cursor={false}
|
||||||
|
animationEasing="ease-out"
|
||||||
|
animationDuration={150}
|
||||||
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.time)}
|
||||||
|
// @ts-ignore
|
||||||
|
itemSorter={(a, b) => b.value - a.value}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
indicator="line"
|
||||||
|
contentFormatter={(item, key) => {
|
||||||
|
try {
|
||||||
|
const sent = item?.payload?.[key][0] ?? 0
|
||||||
|
const received = item?.payload?.[key][1] ?? 0
|
||||||
|
return (
|
||||||
|
<span className="flex">
|
||||||
|
{received.toLocaleString()} MB/s
|
||||||
|
<span className="opacity-70 ml-0.5"> rx </span>
|
||||||
|
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
|
||||||
|
{sent.toLocaleString()} MB/s<span className="opacity-70 ml-0.5"> tx</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{Object.keys(chartConfig).map((key) => (
|
||||||
|
<Area
|
||||||
|
key={key}
|
||||||
|
name={key}
|
||||||
|
// animationDuration={1200}
|
||||||
|
isAnimationActive={false}
|
||||||
|
dataKey={(data) => data?.[key]?.[2] ?? 0}
|
||||||
|
type="monotoneX"
|
||||||
|
fill={chartConfig[key].color}
|
||||||
|
fillOpacity={0.4}
|
||||||
|
stroke={chartConfig[key].color}
|
||||||
|
stackId="a"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,49 +1,46 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||||
|
|
||||||
import {
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
||||||
ChartConfig,
|
import { chartTimeData, cn, formatShortDate, useYaxisWidth } from '@/lib/utils'
|
||||||
ChartContainer,
|
// import Spinner from '../spinner'
|
||||||
ChartTooltip,
|
|
||||||
ChartTooltipContent,
|
|
||||||
} from '@/components/ui/chart'
|
|
||||||
import { chartTimeData, formatShortDate } 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'
|
||||||
const chartConfig = {
|
import { useMemo, useRef } from 'react'
|
||||||
cpu: {
|
|
||||||
label: 'CPU Usage',
|
|
||||||
color: 'hsl(var(--chart-1))',
|
|
||||||
},
|
|
||||||
} satisfies ChartConfig
|
|
||||||
|
|
||||||
export default function CpuChart({
|
export default function CpuChart({
|
||||||
chartData,
|
|
||||||
ticks,
|
ticks,
|
||||||
|
systemData,
|
||||||
}: {
|
}: {
|
||||||
chartData: { time: number; cpu: number }[]
|
|
||||||
ticks: number[]
|
ticks: number[]
|
||||||
|
systemData: SystemStatsRecord[]
|
||||||
}) {
|
}) {
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
|
const chartRef = useRef<HTMLDivElement>(null)
|
||||||
|
const yAxisWidth = useYaxisWidth(chartRef)
|
||||||
|
|
||||||
if (!chartData.length || !ticks.length) {
|
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
||||||
return <Spinner />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContainer config={chartConfig} className="h-full w-full absolute aspect-auto">
|
<div ref={chartRef}>
|
||||||
<AreaChart accessibilityLayer data={chartData} margin={{ top: 10 }}>
|
<ChartContainer
|
||||||
|
config={{}}
|
||||||
|
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||||
|
'opacity-100': yAxisSet,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<AreaChart accessibilityLayer data={systemData} margin={{ top: 10 }}>
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<YAxis
|
<YAxis
|
||||||
|
className="tracking-tighter"
|
||||||
// domain={[0, (max: number) => Math.ceil(max)]}
|
// domain={[0, (max: number) => Math.ceil(max)]}
|
||||||
width={48}
|
width={yAxisWidth}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
unit={'%'}
|
unit={'%'}
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="time"
|
dataKey="created"
|
||||||
domain={[ticks[0], ticks.at(-1)!]}
|
domain={[ticks[0], ticks.at(-1)!]}
|
||||||
ticks={ticks}
|
ticks={ticks}
|
||||||
type="number"
|
type="number"
|
||||||
@@ -59,22 +56,25 @@ export default function CpuChart({
|
|||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
unit="%"
|
unit="%"
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.time)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
indicator="line"
|
indicator="line"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
dataKey="cpu"
|
dataKey="stats.cpu"
|
||||||
|
name="CPU Usage"
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill="var(--color-cpu)"
|
fill="hsl(var(--chart-1))"
|
||||||
fillOpacity={0.4}
|
fillOpacity={0.4}
|
||||||
stroke="var(--color-cpu)"
|
stroke="hsl(var(--chart-1))"
|
||||||
animationDuration={1200}
|
isAnimationActive={false}
|
||||||
// animationEasing="ease-out"
|
// animationEasing="ease-out"
|
||||||
// animateNewValues={false}
|
// animationDuration={1200}
|
||||||
|
// animateNewValues={true}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,29 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||||
|
|
||||||
import {
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
||||||
ChartConfig,
|
import { chartTimeData, cn, formatShortDate, useYaxisWidth } from '@/lib/utils'
|
||||||
ChartContainer,
|
import { useMemo, useRef } from 'react'
|
||||||
ChartTooltip,
|
// import Spinner from '../spinner'
|
||||||
ChartTooltipContent,
|
|
||||||
} from '@/components/ui/chart'
|
|
||||||
import { chartTimeData, formatShortDate, hourWithMinutes } from '@/lib/utils'
|
|
||||||
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'
|
||||||
const chartConfig = {
|
|
||||||
diskUsed: {
|
|
||||||
label: 'Disk Usage',
|
|
||||||
color: 'hsl(var(--chart-4))',
|
|
||||||
},
|
|
||||||
} satisfies ChartConfig
|
|
||||||
|
|
||||||
export default function DiskChart({
|
export default function DiskChart({
|
||||||
chartData,
|
|
||||||
ticks,
|
ticks,
|
||||||
|
systemData,
|
||||||
}: {
|
}: {
|
||||||
chartData: { time: number; disk: number; diskUsed: number }[]
|
|
||||||
ticks: number[]
|
ticks: number[]
|
||||||
|
systemData: SystemStatsRecord[]
|
||||||
}) {
|
}) {
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
|
const chartRef = useRef<HTMLDivElement>(null)
|
||||||
|
const yAxisWidth = useYaxisWidth(chartRef)
|
||||||
|
|
||||||
|
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
||||||
|
|
||||||
const diskSize = useMemo(() => {
|
const diskSize = useMemo(() => {
|
||||||
return Math.round(chartData[0]?.disk)
|
return Math.round(systemData[0]?.stats.d)
|
||||||
}, [chartData])
|
}, [systemData])
|
||||||
|
|
||||||
// const ticks = useMemo(() => {
|
// const ticks = useMemo(() => {
|
||||||
// let ticks = [0]
|
// let ticks = [0]
|
||||||
@@ -41,15 +34,22 @@ export default function DiskChart({
|
|||||||
// return ticks
|
// return ticks
|
||||||
// }, [diskSize])
|
// }, [diskSize])
|
||||||
|
|
||||||
if (!chartData.length || !ticks.length) {
|
// if (!systemData.length || !ticks.length) {
|
||||||
return <Spinner />
|
// return <Spinner />
|
||||||
}
|
// }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContainer config={chartConfig} className="h-full w-full absolute aspect-auto">
|
<div ref={chartRef}>
|
||||||
|
{/* {!yAxisSet && <Spinner />} */}
|
||||||
|
<ChartContainer
|
||||||
|
config={{}}
|
||||||
|
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||||
|
'opacity-100': yAxisSet,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<AreaChart
|
<AreaChart
|
||||||
accessibilityLayer
|
accessibilityLayer
|
||||||
data={chartData}
|
data={systemData}
|
||||||
margin={{
|
margin={{
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
@@ -60,7 +60,7 @@ export default function DiskChart({
|
|||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<YAxis
|
<YAxis
|
||||||
className="tracking-tighter"
|
className="tracking-tighter"
|
||||||
width={diskSize >= 1000 ? 75 : 65}
|
width={yAxisWidth}
|
||||||
domain={[0, diskSize]}
|
domain={[0, diskSize]}
|
||||||
tickCount={9}
|
tickCount={9}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
@@ -68,7 +68,7 @@ export default function DiskChart({
|
|||||||
unit={' GB'}
|
unit={' GB'}
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="time"
|
dataKey="created"
|
||||||
domain={[ticks[0], ticks.at(-1)!]}
|
domain={[ticks[0], ticks.at(-1)!]}
|
||||||
ticks={ticks}
|
ticks={ticks}
|
||||||
type="number"
|
type="number"
|
||||||
@@ -84,20 +84,23 @@ export default function DiskChart({
|
|||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
unit=" GB"
|
unit=" GB"
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.time)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
indicator="line"
|
indicator="line"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
dataKey="diskUsed"
|
dataKey="stats.du"
|
||||||
|
name="Disk Usage"
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill="var(--color-diskUsed)"
|
fill="hsl(var(--chart-4))"
|
||||||
fillOpacity={0.4}
|
fillOpacity={0.4}
|
||||||
stroke="var(--color-diskUsed)"
|
stroke="hsl(var(--chart-4))"
|
||||||
animationDuration={1200}
|
// animationDuration={1200}
|
||||||
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,48 @@
|
|||||||
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 {
|
import {
|
||||||
ChartConfig,
|
chartTimeData,
|
||||||
ChartContainer,
|
cn,
|
||||||
ChartTooltip,
|
formatShortDate,
|
||||||
ChartTooltipContent,
|
toFixedWithoutTrailingZeros,
|
||||||
} from '@/components/ui/chart'
|
useYaxisWidth,
|
||||||
import { chartTimeData, formatShortDate } from '@/lib/utils'
|
} 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 } from '@/lib/stores'
|
||||||
|
import { SystemStatsRecord } from '@/types'
|
||||||
const chartConfig = {
|
import { useMemo, useRef } from 'react'
|
||||||
read: {
|
|
||||||
label: 'Read',
|
|
||||||
color: 'hsl(var(--chart-1))',
|
|
||||||
},
|
|
||||||
write: {
|
|
||||||
label: 'Write',
|
|
||||||
color: 'hsl(var(--chart-3))',
|
|
||||||
},
|
|
||||||
} satisfies ChartConfig
|
|
||||||
|
|
||||||
export default function DiskIoChart({
|
export default function DiskIoChart({
|
||||||
chartData,
|
|
||||||
ticks,
|
ticks,
|
||||||
|
systemData,
|
||||||
}: {
|
}: {
|
||||||
chartData: { time: number; read: number; write: number }[]
|
|
||||||
ticks: number[]
|
ticks: number[]
|
||||||
|
systemData: SystemStatsRecord[]
|
||||||
}) {
|
}) {
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
|
const chartRef = useRef<HTMLDivElement>(null)
|
||||||
|
const yAxisWidth = useYaxisWidth(chartRef)
|
||||||
|
|
||||||
if (!chartData.length || !ticks.length) {
|
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
||||||
return <Spinner />
|
|
||||||
}
|
// if (!systemData.length || !ticks.length) {
|
||||||
|
// return <Spinner />
|
||||||
|
// }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContainer config={chartConfig} className="h-full w-full absolute aspect-auto">
|
<div ref={chartRef}>
|
||||||
|
{/* {!yAxisSet && <Spinner />} */}
|
||||||
|
<ChartContainer
|
||||||
|
config={{}}
|
||||||
|
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||||
|
'opacity-100': yAxisSet,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<AreaChart
|
<AreaChart
|
||||||
accessibilityLayer
|
accessibilityLayer
|
||||||
data={chartData}
|
data={systemData}
|
||||||
margin={{
|
margin={{
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
@@ -50,20 +53,15 @@ export default function DiskIoChart({
|
|||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<YAxis
|
<YAxis
|
||||||
className="tracking-tighter"
|
className="tracking-tighter"
|
||||||
width={75}
|
width={yAxisWidth}
|
||||||
domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]}
|
// domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]}
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(value) => toFixedWithoutTrailingZeros(value, 2)}
|
||||||
if (value >= 100) {
|
|
||||||
return value.toFixed(0)
|
|
||||||
}
|
|
||||||
return value.toFixed((value * 100) % 1 === 0 ? 1 : 2)
|
|
||||||
}}
|
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
unit={' MB/s'}
|
unit={' MB/s'}
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="time"
|
dataKey="created"
|
||||||
domain={[ticks[0], ticks.at(-1)!]}
|
domain={[ticks[0], ticks.at(-1)!]}
|
||||||
ticks={ticks}
|
ticks={ticks}
|
||||||
type="number"
|
type="number"
|
||||||
@@ -79,28 +77,33 @@ export default function DiskIoChart({
|
|||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
unit=" MB/s"
|
unit=" MB/s"
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.time)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
indicator="line"
|
indicator="line"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
dataKey="write"
|
dataKey="stats.dw"
|
||||||
|
name="Write"
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill="var(--color-write)"
|
fill="hsl(var(--chart-3))"
|
||||||
fillOpacity={0.4}
|
fillOpacity={0.4}
|
||||||
stroke="var(--color-write)"
|
stroke="hsl(var(--chart-3))"
|
||||||
animationDuration={1200}
|
// animationDuration={1200}
|
||||||
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
dataKey="read"
|
dataKey="stats.dr"
|
||||||
|
name="Read"
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill="var(--color-read)"
|
fill="hsl(var(--chart-1))"
|
||||||
fillOpacity={0.4}
|
fillOpacity={0.4}
|
||||||
stroke="var(--color-read)"
|
stroke="hsl(var(--chart-1))"
|
||||||
animationDuration={1200}
|
// animationDuration={1200}
|
||||||
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +1,60 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||||
|
|
||||||
import {
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
||||||
ChartConfig,
|
import { chartTimeData, cn, formatShortDate, toFixedFloat, useYaxisWidth } from '@/lib/utils'
|
||||||
ChartContainer,
|
import { useMemo, useRef } from 'react'
|
||||||
ChartTooltip,
|
// import Spinner from '../spinner'
|
||||||
ChartTooltipContent,
|
|
||||||
} from '@/components/ui/chart'
|
|
||||||
import { chartTimeData, formatShortDate } from '@/lib/utils'
|
|
||||||
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'
|
||||||
|
|
||||||
export default function MemChart({
|
export default function MemChart({
|
||||||
chartData,
|
|
||||||
ticks,
|
ticks,
|
||||||
|
systemData,
|
||||||
}: {
|
}: {
|
||||||
chartData: { time: number; mem: number; memUsed: number; memCache: number }[]
|
|
||||||
ticks: number[]
|
ticks: number[]
|
||||||
|
systemData: SystemStatsRecord[]
|
||||||
}) {
|
}) {
|
||||||
|
const chartRef = useRef<HTMLDivElement>(null)
|
||||||
|
const yAxisWidth = useYaxisWidth(chartRef)
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
|
|
||||||
|
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
||||||
|
|
||||||
const totalMem = useMemo(() => {
|
const totalMem = useMemo(() => {
|
||||||
const maxMem = Math.ceil(chartData[0]?.mem)
|
return toFixedFloat(systemData.at(-1)?.stats.m ?? 0, 1)
|
||||||
return maxMem > 2 && maxMem % 2 !== 0 ? maxMem + 1 : maxMem
|
}, [systemData])
|
||||||
}, [chartData])
|
|
||||||
|
|
||||||
const chartConfig = useMemo(
|
|
||||||
() => ({
|
|
||||||
memCache: {
|
|
||||||
label: 'Cache / Buffers',
|
|
||||||
color: 'hsl(var(--chart-2))',
|
|
||||||
},
|
|
||||||
memUsed: {
|
|
||||||
label: 'Used',
|
|
||||||
color: 'hsl(var(--chart-2))',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
) satisfies ChartConfig
|
|
||||||
|
|
||||||
if (!chartData.length || !ticks.length) {
|
|
||||||
return <Spinner />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContainer config={chartConfig} className="h-full w-full absolute aspect-auto">
|
<div ref={chartRef}>
|
||||||
|
{/* {!yAxisSet && <Spinner />} */}
|
||||||
|
<ChartContainer
|
||||||
|
config={{}}
|
||||||
|
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||||
|
'opacity-100': yAxisSet,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<AreaChart
|
<AreaChart
|
||||||
accessibilityLayer
|
accessibilityLayer
|
||||||
data={chartData}
|
data={systemData}
|
||||||
margin={{
|
margin={{
|
||||||
top: 10,
|
top: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
|
{totalMem && (
|
||||||
<YAxis
|
<YAxis
|
||||||
// use "ticks" instead of domain / tickcount if need more control
|
// use "ticks" instead of domain / tickcount if need more control
|
||||||
domain={[0, totalMem]}
|
domain={[0, totalMem]}
|
||||||
|
className="tracking-tighter"
|
||||||
|
width={yAxisWidth}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
width={totalMem >= 100 ? 65 : 58}
|
|
||||||
// allowDecimals={false}
|
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
unit={' GB'}
|
unit={' GB'}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="time"
|
dataKey="created"
|
||||||
domain={[ticks[0], ticks.at(-1)!]}
|
domain={[ticks[0], ticks.at(-1)!]}
|
||||||
ticks={ticks}
|
ticks={ticks}
|
||||||
type="number"
|
type="number"
|
||||||
@@ -83,31 +73,34 @@ export default function MemChart({
|
|||||||
unit=" GB"
|
unit=" GB"
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
itemSorter={(a, b) => a.name.localeCompare(b.name)}
|
itemSorter={(a, b) => a.name.localeCompare(b.name)}
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.time)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
indicator="line"
|
indicator="line"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
dataKey="memUsed"
|
dataKey="stats.mu"
|
||||||
|
name="Used"
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill="var(--color-memUsed)"
|
fill="hsl(var(--chart-2))"
|
||||||
fillOpacity={0.4}
|
fillOpacity={0.4}
|
||||||
stroke="var(--color-memUsed)"
|
stroke="hsl(var(--chart-2))"
|
||||||
stackId="a"
|
stackId="1"
|
||||||
animationDuration={1200}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
dataKey="memCache"
|
dataKey="stats.mb"
|
||||||
|
name="Cache / Buffers"
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill="var(--color-memCache)"
|
fill="hsl(var(--chart-2))"
|
||||||
fillOpacity={0.2}
|
fillOpacity={0.2}
|
||||||
strokeOpacity={0.3}
|
strokeOpacity={0.3}
|
||||||
stroke="var(--color-memCache)"
|
stroke="hsl(var(--chart-2))"
|
||||||
stackId="a"
|
stackId="1"
|
||||||
animationDuration={1200}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
83
hub/site/src/components/charts/swap-chart.tsx
Normal file
83
hub/site/src/components/charts/swap-chart.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||||
|
|
||||||
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
||||||
|
import {
|
||||||
|
chartTimeData,
|
||||||
|
cn,
|
||||||
|
formatShortDate,
|
||||||
|
toFixedWithoutTrailingZeros,
|
||||||
|
useYaxisWidth,
|
||||||
|
} from '@/lib/utils'
|
||||||
|
// import Spinner from '../spinner'
|
||||||
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { $chartTime } from '@/lib/stores'
|
||||||
|
import { SystemStatsRecord } from '@/types'
|
||||||
|
import { useMemo, useRef } from 'react'
|
||||||
|
|
||||||
|
export default function SwapChart({
|
||||||
|
ticks,
|
||||||
|
systemData,
|
||||||
|
}: {
|
||||||
|
ticks: number[]
|
||||||
|
systemData: SystemStatsRecord[]
|
||||||
|
}) {
|
||||||
|
const chartTime = useStore($chartTime)
|
||||||
|
const chartRef = useRef<HTMLDivElement>(null)
|
||||||
|
const yAxisWidth = useYaxisWidth(chartRef)
|
||||||
|
|
||||||
|
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={chartRef}>
|
||||||
|
<ChartContainer
|
||||||
|
config={{}}
|
||||||
|
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||||
|
'opacity-100': yAxisSet,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<AreaChart accessibilityLayer data={systemData} margin={{ top: 10 }}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<YAxis
|
||||||
|
className="tracking-tighter"
|
||||||
|
domain={[0, () => toFixedWithoutTrailingZeros(systemData.at(-1)?.stats.s ?? 0.04, 2)]}
|
||||||
|
width={yAxisWidth}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
unit={' GB'}
|
||||||
|
/>
|
||||||
|
<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
|
||||||
|
unit=" GB"
|
||||||
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
|
indicator="line"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
dataKey="stats.su"
|
||||||
|
name="Swap Usage"
|
||||||
|
type="monotoneX"
|
||||||
|
fill="hsl(var(--chart-2))"
|
||||||
|
fillOpacity={0.4}
|
||||||
|
stroke="hsl(var(--chart-2))"
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,10 +9,9 @@ export default function () {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-5">
|
<CardHeader className="pb-2 md:pb-5 px-4 sm:px-7 max-sm:pt-5">
|
||||||
<CardTitle className={'mb-1.5'}>All Systems</CardTitle>
|
<CardTitle className="mb-1.5">All Systems</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Updated in real time. Press{' '}
|
Updated in real time. Press{' '}
|
||||||
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-0.5 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100">
|
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-0.5 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100">
|
||||||
@@ -21,12 +20,11 @@ export default function () {
|
|||||||
to open the command palette.
|
to open the command palette.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="max-sm:p-2">
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<SystemsTable />
|
<SystemsTable />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { $updatedSystem, $systems, pb, $chartTime } from '@/lib/stores'
|
import { $updatedSystem, $systems, pb, $chartTime } from '@/lib/stores'
|
||||||
import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types'
|
import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types'
|
||||||
import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from 'react'
|
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../ui/card'
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent } 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 } from 'lucide-react'
|
import { ClockArrowUp, CpuIcon, GlobeIcon } from 'lucide-react'
|
||||||
import ChartTimeSelect from '../charts/chart-time-select'
|
import ChartTimeSelect from '../charts/chart-time-select'
|
||||||
import { chartTimeData, cn, getPbTimestamp } from '@/lib/utils'
|
import { chartTimeData, cn, getPbTimestamp, useClampedIsInViewport } 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'
|
||||||
@@ -18,6 +18,8 @@ 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 DiskIoChart = lazy(() => import('../charts/disk-io-chart'))
|
||||||
const BandwidthChart = lazy(() => import('../charts/bandwidth-chart'))
|
const BandwidthChart = lazy(() => import('../charts/bandwidth-chart'))
|
||||||
|
const ContainerNetChart = lazy(() => import('../charts/container-net-chart'))
|
||||||
|
const SwapChart = lazy(() => import('../charts/swap-chart'))
|
||||||
|
|
||||||
export default function ServerDetail({ name }: { name: string }) {
|
export default function ServerDetail({ name }: { name: string }) {
|
||||||
const systems = useStore($systems)
|
const systems = useStore($systems)
|
||||||
@@ -25,27 +27,16 @@ export default function ServerDetail({ name }: { name: string }) {
|
|||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
const [ticks, setTicks] = useState([] as number[])
|
const [ticks, setTicks] = useState([] as number[])
|
||||||
const [server, setServer] = useState({} as SystemRecord)
|
const [server, setServer] = useState({} as SystemRecord)
|
||||||
const [containers, setContainers] = useState([] as ContainerStatsRecord[])
|
|
||||||
|
|
||||||
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
|
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
|
||||||
const [cpuChartData, setCpuChartData] = useState([] as { time: number; cpu: number }[])
|
const [hasDockerStats, setHasDocker] = useState(false)
|
||||||
const [memChartData, setMemChartData] = useState(
|
const [dockerCpuChartData, setDockerCpuChartData] = useState<Record<string, number | string>[]>(
|
||||||
[] as { time: number; mem: number; memUsed: number; memCache: number }[]
|
[]
|
||||||
)
|
)
|
||||||
const [diskChartData, setDiskChartData] = useState(
|
const [dockerMemChartData, setDockerMemChartData] = useState<Record<string, number | string>[]>(
|
||||||
[] as { time: number; disk: number; diskUsed: number }[]
|
[]
|
||||||
)
|
)
|
||||||
const [diskIoChartData, setDiskIoChartData] = useState(
|
const [dockerNetChartData, setDockerNetChartData] = useState<Record<string, number | number[]>[]>(
|
||||||
[] as { time: number; read: number; write: number }[]
|
[]
|
||||||
)
|
|
||||||
const [bandwidthChartData, setBandwidthChartData] = useState(
|
|
||||||
[] as { time: number; sent: number; recv: number }[]
|
|
||||||
)
|
|
||||||
const [dockerCpuChartData, setDockerCpuChartData] = useState(
|
|
||||||
[] as Record<string, number | string>[]
|
|
||||||
)
|
|
||||||
const [dockerMemChartData, setDockerMemChartData] = useState(
|
|
||||||
[] as Record<string, number | string>[]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -53,18 +44,16 @@ export default function ServerDetail({ name }: { name: string }) {
|
|||||||
return () => {
|
return () => {
|
||||||
resetCharts()
|
resetCharts()
|
||||||
$chartTime.set('1h')
|
$chartTime.set('1h')
|
||||||
|
setHasDocker(false)
|
||||||
}
|
}
|
||||||
}, [name])
|
}, [name])
|
||||||
|
|
||||||
const resetCharts = useCallback(() => {
|
function resetCharts() {
|
||||||
setSystemStats([])
|
setSystemStats([])
|
||||||
setCpuChartData([])
|
|
||||||
setMemChartData([])
|
|
||||||
setDiskChartData([])
|
|
||||||
setBandwidthChartData([])
|
|
||||||
setDockerCpuChartData([])
|
setDockerCpuChartData([])
|
||||||
setDockerMemChartData([])
|
setDockerMemChartData([])
|
||||||
}, [])
|
setDockerNetChartData([])
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(resetCharts, [chartTime])
|
useEffect(resetCharts, [chartTime])
|
||||||
|
|
||||||
@@ -78,13 +67,15 @@ export default function ServerDetail({ name }: { name: string }) {
|
|||||||
}
|
}
|
||||||
}, [name, server, systems])
|
}, [name, server, systems])
|
||||||
|
|
||||||
// get stats
|
// update server when new data is available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!server.id || !chartTime) {
|
if (updatedSystem.id === server.id) {
|
||||||
return
|
setServer(updatedSystem)
|
||||||
}
|
}
|
||||||
pb.collection<SystemStatsRecord>('system_stats')
|
}, [updatedSystem])
|
||||||
.getFullList({
|
|
||||||
|
async function getStats<T>(collection: string): Promise<T[]> {
|
||||||
|
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: server.id,
|
id: server.id,
|
||||||
created: getPbTimestamp(chartTime),
|
created: getPbTimestamp(chartTime),
|
||||||
@@ -93,47 +84,29 @@ export default function ServerDetail({ name }: { name: string }) {
|
|||||||
fields: 'created,stats',
|
fields: 'created,stats',
|
||||||
sort: 'created',
|
sort: 'created',
|
||||||
})
|
})
|
||||||
.then((records) => {
|
|
||||||
// console.log('sctats', records)
|
|
||||||
setSystemStats(records)
|
|
||||||
})
|
|
||||||
}, [server, chartTime])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (updatedSystem.id === server.id) {
|
|
||||||
setServer(updatedSystem)
|
|
||||||
}
|
}
|
||||||
}, [updatedSystem])
|
|
||||||
|
|
||||||
// create cpu / mem / disk data for charts
|
// get stats
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!systemStats.length) {
|
if (!server.id || !chartTime) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const cpuData = [] as typeof cpuChartData
|
Promise.allSettled([
|
||||||
const memData = [] as typeof memChartData
|
getStats<SystemStatsRecord>('system_stats'),
|
||||||
const diskData = [] as typeof diskChartData
|
getStats<ContainerStatsRecord>('container_stats'),
|
||||||
const diskIoData = [] as typeof diskIoChartData
|
]).then(([systemStats, containerStats]) => {
|
||||||
const networkData = [] as typeof bandwidthChartData
|
if (containerStats.status === 'fulfilled' && containerStats.value.length) {
|
||||||
for (let { created, stats } of systemStats) {
|
setHasDocker(true)
|
||||||
const time = new Date(created).getTime()
|
makeContainerData(containerStats.value)
|
||||||
cpuData.push({ time, cpu: stats.cpu })
|
|
||||||
memData.push({
|
|
||||||
time,
|
|
||||||
mem: stats.m,
|
|
||||||
memUsed: stats.mu,
|
|
||||||
memCache: stats.mb,
|
|
||||||
})
|
|
||||||
diskData.push({ time, disk: stats.d, diskUsed: stats.du })
|
|
||||||
diskIoData.push({ time, read: stats.dr, write: stats.dw })
|
|
||||||
networkData.push({ time, sent: stats.ns, recv: stats.nr })
|
|
||||||
}
|
}
|
||||||
setCpuChartData(cpuData)
|
if (systemStats.status === 'fulfilled') {
|
||||||
setMemChartData(memData)
|
for (const record of systemStats.value) {
|
||||||
setDiskChartData(diskData)
|
record.created = new Date(record.created).getTime()
|
||||||
setDiskIoChartData(diskIoData)
|
}
|
||||||
setBandwidthChartData(networkData)
|
setSystemStats(systemStats.value)
|
||||||
}, [systemStats])
|
}
|
||||||
|
})
|
||||||
|
}, [server, chartTime])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!systemStats.length) {
|
if (!systemStats.length) {
|
||||||
@@ -141,51 +114,36 @@ export default function ServerDetail({ 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, cpuChartData.length])
|
const scale = scaleTime([startTime.getTime(), now], [0, systemStats.length])
|
||||||
setTicks(scale.ticks(chartTimeData[chartTime].ticks).map((d) => d.getTime()))
|
setTicks(scale.ticks(chartTimeData[chartTime].ticks).map((d) => d.getTime()))
|
||||||
}, [chartTime, systemStats])
|
}, [chartTime, systemStats])
|
||||||
|
|
||||||
// get container stats
|
// make container stats for charts
|
||||||
useEffect(() => {
|
const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => {
|
||||||
if (!server.id || !chartTime) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pb.collection<ContainerStatsRecord>('container_stats')
|
|
||||||
.getFullList({
|
|
||||||
filter: pb.filter('system={:id} && created > {:created} && type={:type}', {
|
|
||||||
id: server.id,
|
|
||||||
created: getPbTimestamp(chartTime),
|
|
||||||
type: chartTimeData[chartTime].type,
|
|
||||||
}),
|
|
||||||
fields: 'created,stats',
|
|
||||||
sort: 'created',
|
|
||||||
})
|
|
||||||
.then((records) => {
|
|
||||||
setContainers(records)
|
|
||||||
})
|
|
||||||
}, [server, chartTime])
|
|
||||||
|
|
||||||
// container stats for charts
|
|
||||||
useEffect(() => {
|
|
||||||
// console.log('containers', containers)
|
// console.log('containers', containers)
|
||||||
const dockerCpuData = [] as Record<string, number | string>[]
|
const dockerCpuData = []
|
||||||
const dockerMemData = [] as Record<string, number | string>[]
|
const dockerMemData = []
|
||||||
|
const dockerNetData = []
|
||||||
for (let { created, stats } of containers) {
|
for (let { created, stats } of containers) {
|
||||||
const time = new Date(created).getTime()
|
const time = new Date(created).getTime()
|
||||||
let cpuData = { time } as (typeof dockerCpuChartData)[0]
|
let cpuData = { time } as Record<string, number | string>
|
||||||
let memData = { time } as (typeof dockerMemChartData)[0]
|
let memData = { time } as Record<string, number | string>
|
||||||
|
let netData = { time } as Record<string, number | number[]>
|
||||||
for (let container of stats) {
|
for (let container of stats) {
|
||||||
cpuData[container.n] = container.c
|
cpuData[container.n] = container.c
|
||||||
memData[container.n] = container.m
|
memData[container.n] = container.m
|
||||||
|
netData[container.n] = [container.ns, container.nr, container.ns + container.nr] // sent, received, total
|
||||||
}
|
}
|
||||||
dockerCpuData.push(cpuData)
|
dockerCpuData.push(cpuData)
|
||||||
dockerMemData.push(memData)
|
dockerMemData.push(memData)
|
||||||
|
dockerNetData.push(netData)
|
||||||
}
|
}
|
||||||
// console.log('containerMemData', containerMemData)
|
// console.log('dockerMemData', dockerMemData)
|
||||||
setDockerCpuChartData(dockerCpuData)
|
setDockerCpuChartData(dockerCpuData)
|
||||||
setDockerMemChartData(dockerMemData)
|
setDockerMemChartData(dockerMemData)
|
||||||
}, [containers])
|
setDockerNetChartData(dockerNetData)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const uptime = useMemo(() => {
|
const uptime = useMemo(() => {
|
||||||
let uptime = server.info?.u || 0
|
let uptime = server.info?.u || 0
|
||||||
if (uptime < 172800) {
|
if (uptime < 172800) {
|
||||||
@@ -201,7 +159,7 @@ export default function ServerDetail({ name }: { name: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className="grid gap-4 mb-10">
|
<div className="grid gap-4 mb-10">
|
||||||
<Card>
|
<Card>
|
||||||
<div className="grid gap-2 px-6 pt-4 pb-5">
|
<div className="grid gap-2 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
|
||||||
<h1 className="text-[1.6rem] font-semibold">{server.name}</h1>
|
<h1 className="text-[1.6rem] font-semibold">{server.name}</h1>
|
||||||
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
|
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
|
||||||
<div className="capitalize flex gap-2 items-center">
|
<div className="capitalize flex gap-2 items-center">
|
||||||
@@ -250,43 +208,69 @@ export default function ServerDetail({ name }: { name: string }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<ChartTimeSelect className="mt-2 -ml-1 sm:hidden" />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<ChartCard title="Total CPU Usage" description="Average system-wide CPU utilization">
|
<ChartCard title="Total CPU Usage" description="Average system-wide CPU utilization">
|
||||||
<CpuChart chartData={cpuChartData} ticks={ticks} />
|
<CpuChart ticks={ticks} systemData={systemStats} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
{dockerCpuChartData.length > 0 && (
|
{hasDockerStats && (
|
||||||
<ChartCard title="Docker CPU Usage" description="CPU utilization of docker containers">
|
<ChartCard title="Docker CPU Usage" description="CPU utilization of docker containers">
|
||||||
<ContainerCpuChart chartData={dockerCpuChartData} ticks={ticks} />
|
<ContainerCpuChart chartData={dockerCpuChartData} ticks={ticks} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ChartCard title="Total Memory Usage" description="Precise utilization at the recorded time">
|
<ChartCard title="Total Memory Usage" description="Precise utilization at the recorded time">
|
||||||
<MemChart chartData={memChartData} ticks={ticks} />
|
<MemChart ticks={ticks} systemData={systemStats} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
{dockerMemChartData.length > 0 && (
|
{hasDockerStats && (
|
||||||
<ChartCard title="Docker Memory Usage" description="Memory usage of docker containers">
|
<ChartCard title="Docker Memory Usage" description="Memory usage of docker containers">
|
||||||
<ContainerMemChart chartData={dockerMemChartData} ticks={ticks} />
|
<ContainerMemChart chartData={dockerMemChartData} ticks={ticks} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(systemStats.at(-1)?.stats.s ?? 0) > 0 && (
|
||||||
|
<ChartCard title="Swap Usage" description="Swap space used by the system">
|
||||||
|
<SwapChart ticks={ticks} systemData={systemStats} />
|
||||||
|
</ChartCard>
|
||||||
|
)}
|
||||||
|
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title="Disk Usage"
|
title="Disk Usage"
|
||||||
description="Usage of partition where the root filesystem is mounted"
|
description="Usage of partition where the root filesystem is mounted"
|
||||||
>
|
>
|
||||||
<DiskChart chartData={diskChartData} ticks={ticks} />
|
<DiskChart ticks={ticks} systemData={systemStats} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
<ChartCard title="Disk I/O" description="Throughput of root filesystem">
|
<ChartCard title="Disk I/O" description="Throughput of root filesystem">
|
||||||
<DiskIoChart chartData={diskIoChartData} ticks={ticks} />
|
<DiskIoChart ticks={ticks} systemData={systemStats} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
<ChartCard title="Bandwidth" description="Network traffic of public interfaces">
|
<ChartCard title="Bandwidth" description="Network traffic of public interfaces">
|
||||||
<BandwidthChart chartData={bandwidthChartData} ticks={ticks} />
|
<BandwidthChart ticks={ticks} systemData={systemStats} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
|
{hasDockerStats && dockerNetChartData.length > 0 && (
|
||||||
|
<>
|
||||||
|
<ChartCard
|
||||||
|
title="Docker Network I/O"
|
||||||
|
description="Includes traffic between internal services"
|
||||||
|
>
|
||||||
|
<ContainerNetChart chartData={dockerNetChartData} ticks={ticks} />
|
||||||
|
</ChartCard>
|
||||||
|
{/* add space for tooltip if more than 12 containers */}
|
||||||
|
{Object.keys(dockerNetChartData[0]).length > 12 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: (Object.keys(dockerNetChartData[0]).length - 13) * 18,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -300,17 +284,20 @@ function ChartCard({
|
|||||||
description: string
|
description: string
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
|
const target = useRef<HTMLDivElement>(null)
|
||||||
|
const [isInViewport, wrappedTargetRef] = useClampedIsInViewport({ target: target })
|
||||||
return (
|
return (
|
||||||
<Card className="pb-4 col-span-full">
|
<Card className="pb-2 sm:pb-4 col-span-full" ref={wrappedTargetRef}>
|
||||||
<CardHeader className="pb-5 pt-4 relative space-y-1">
|
<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>
|
||||||
<div className="w-full pt-1 sm:w-40 sm:absolute top-1.5 right-3.5">
|
<div className="w-full pt-1 sm:w-40 hidden sm:block absolute top-1.5 right-3.5">
|
||||||
<ChartTimeSelect />
|
<ChartTimeSelect />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className={'pl-1 w-[calc(100%-1.6em)] h-52 relative'}>
|
<CardContent className="pl-1 w-[calc(100%-1.6em)] h-52 relative">
|
||||||
<Suspense fallback={<Spinner />}>{children}</Suspense>
|
{<Spinner />}
|
||||||
|
{isInViewport && <Suspense>{children}</Suspense>}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
nameKey?: string
|
nameKey?: string
|
||||||
labelKey?: string
|
labelKey?: string
|
||||||
unit?: string
|
unit?: string
|
||||||
|
contentFormatter?: (item: any, key: string) => React.ReactNode | string
|
||||||
}
|
}
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
@@ -119,6 +120,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
labelKey,
|
labelKey,
|
||||||
unit,
|
unit,
|
||||||
itemSorter,
|
itemSorter,
|
||||||
|
contentFormatter: content = undefined,
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
@@ -180,7 +182,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.dataKey}
|
key={item?.name || item.dataKey}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
|
'flex w-full items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
|
||||||
indicator === 'dot' && 'items-center'
|
indicator === 'dot' && 'items-center'
|
||||||
@@ -228,7 +230,9 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
</div>
|
</div>
|
||||||
{item.value !== undefined && (
|
{item.value !== undefined && (
|
||||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||||
{item.value.toLocaleString() + (unit ? unit : '')}
|
{content && typeof content === 'function'
|
||||||
|
? content(item, key)
|
||||||
|
: item.value.toLocaleString() + (unit ? unit : '')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { AlertRecord, ChartTimeData, ChartTimes, SystemRecord } from '@/types'
|
|||||||
import { RecordModel, RecordSubscription } from 'pocketbase'
|
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 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))
|
||||||
@@ -32,16 +34,27 @@ const verifyAuth = () => {
|
|||||||
.authRefresh()
|
.authRefresh()
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
pb.authStore.clear()
|
pb.authStore.clear()
|
||||||
|
toast({
|
||||||
|
title: 'Failed to authenticate',
|
||||||
|
description: 'Please log in again',
|
||||||
|
variant: 'destructive',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateSystemList = async () => {
|
export const updateSystemList = async () => {
|
||||||
try {
|
// try {
|
||||||
const records = await pb.collection<SystemRecord>('systems').getFullList({ sort: '+name' })
|
const records = await pb.collection<SystemRecord>('systems').getFullList({ sort: '+name' })
|
||||||
|
if (records.length) {
|
||||||
$systems.set(records)
|
$systems.set(records)
|
||||||
} catch (e) {
|
} else {
|
||||||
verifyAuth()
|
verifyAuth()
|
||||||
}
|
}
|
||||||
|
// }
|
||||||
|
// catch (e) {
|
||||||
|
// console.log('verifying auth error', e)
|
||||||
|
// verifyAuth()
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateAlerts = () => {
|
export const updateAlerts = () => {
|
||||||
@@ -179,3 +192,47 @@ export const chartTimeData: ChartTimeData = {
|
|||||||
getOffset: (endTime: Date) => timeDay.offset(endTime, -30),
|
getOffset: (endTime: Date) => timeDay.offset(endTime, -30),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hacky solution to set the correct width of the yAxis in recharts */
|
||||||
|
export function useYaxisWidth(chartRef: React.RefObject<HTMLDivElement>) {
|
||||||
|
const [yAxisWidth, setYAxisWidth] = useState(180)
|
||||||
|
useEffect(() => {
|
||||||
|
let interval = setInterval(() => {
|
||||||
|
// console.log('chartRef', chartRef.current)
|
||||||
|
const yAxisElement = chartRef?.current?.querySelector('.yAxis')
|
||||||
|
if (yAxisElement) {
|
||||||
|
// console.log('yAxisElement', yAxisElement)
|
||||||
|
clearInterval(interval)
|
||||||
|
setYAxisWidth(yAxisElement.getBoundingClientRect().width + 22)
|
||||||
|
}
|
||||||
|
}, 16)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
return yAxisWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return parseFloat(num.toFixed(digits)).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toFixedFloat(num: number, digits: number) {
|
||||||
|
return parseFloat(num.toFixed(digits))
|
||||||
|
}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ const Layout = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="flex items-center h-16 bg-card px-6 border bt-0 rounded-md my-4">
|
<div className="flex items-center h-14 md:h-16 bg-card px-6 border bt-0 rounded-md my-4">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
aria-label="Home"
|
aria-label="Home"
|
||||||
|
|||||||
10
hub/site/src/types.d.ts
vendored
10
hub/site/src/types.d.ts
vendored
@@ -38,6 +38,10 @@ export interface SystemStats {
|
|||||||
mp: number
|
mp: number
|
||||||
/** memory buffer + cache (gb) */
|
/** memory buffer + cache (gb) */
|
||||||
mb: number
|
mb: number
|
||||||
|
/** swap space (gb) */
|
||||||
|
s: number
|
||||||
|
/** swap used (gb) */
|
||||||
|
su: number
|
||||||
/** disk size (gb) */
|
/** disk size (gb) */
|
||||||
d: number
|
d: number
|
||||||
/** disk used (gb) */
|
/** disk used (gb) */
|
||||||
@@ -57,6 +61,7 @@ export interface SystemStats {
|
|||||||
export interface ContainerStatsRecord extends RecordModel {
|
export interface ContainerStatsRecord extends RecordModel {
|
||||||
system: string
|
system: string
|
||||||
stats: ContainerStats[]
|
stats: ContainerStats[]
|
||||||
|
created: string | number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContainerStats {
|
interface ContainerStats {
|
||||||
@@ -66,11 +71,16 @@ interface ContainerStats {
|
|||||||
c: number
|
c: number
|
||||||
/** memory used (gb) */
|
/** memory used (gb) */
|
||||||
m: number
|
m: number
|
||||||
|
// network sent (mb)
|
||||||
|
ns: number
|
||||||
|
// network received (mb)
|
||||||
|
nr: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemStatsRecord extends RecordModel {
|
export interface SystemStatsRecord extends RecordModel {
|
||||||
system: string
|
system: string
|
||||||
stats: SystemStats
|
stats: SystemStats
|
||||||
|
created: string | number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AlertRecord extends RecordModel {
|
export interface AlertRecord extends RecordModel {
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ type SystemStats struct {
|
|||||||
MemUsed float64 `json:"mu"`
|
MemUsed float64 `json:"mu"`
|
||||||
MemPct float64 `json:"mp"`
|
MemPct float64 `json:"mp"`
|
||||||
MemBuffCache float64 `json:"mb"`
|
MemBuffCache float64 `json:"mb"`
|
||||||
|
Swap float64 `json:"s"`
|
||||||
|
SwapUsed float64 `json:"su"`
|
||||||
Disk float64 `json:"d"`
|
Disk float64 `json:"d"`
|
||||||
DiskUsed float64 `json:"du"`
|
DiskUsed float64 `json:"du"`
|
||||||
DiskPct float64 `json:"dp"`
|
DiskPct float64 `json:"dp"`
|
||||||
@@ -47,6 +49,8 @@ type ContainerStats struct {
|
|||||||
Name string `json:"n"`
|
Name string `json:"n"`
|
||||||
Cpu float64 `json:"c"`
|
Cpu float64 `json:"c"`
|
||||||
Mem float64 `json:"m"`
|
Mem float64 `json:"m"`
|
||||||
|
NetworkSent float64 `json:"ns"`
|
||||||
|
NetworkRecv float64 `json:"nr"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type EmailData struct {
|
type EmailData struct {
|
||||||
|
|||||||
106
readme.md
106
readme.md
@@ -45,7 +45,7 @@ Pour le tutoriel en français, consultez https://belginux.com/installer-beszel-a
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
You may choose to install the hub and agent as single binaries, or as docker images.
|
You may install the hub and agent as single binaries, or by using Docker.
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
@@ -61,96 +61,38 @@ If you don't need network stats, remove that line from the compose file and map
|
|||||||
|
|
||||||
### Binary
|
### Binary
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> If using Linux, see [guides/systemd.md](/supplemental/guides/systemd.md) for a script to install the hub or agent as a system service. The agent installer will be built into the web UI in the future.
|
||||||
|
|
||||||
Download and run the latest binaries from the [releases page](https://github.com/henrygd/beszel/releases) or use the commands below.
|
Download and run the latest binaries from the [releases page](https://github.com/henrygd/beszel/releases) or use the commands below.
|
||||||
|
|
||||||
#### Hub:
|
#### Hub
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sL "https://github.com/henrygd/beszel/releases/latest/download/beszel_$(uname -s)_$(uname -m | sed 's/x86_64/amd64/' | sed 's/armv7l/arm/' | sed 's/aarch64/arm64/').tar.gz" | tar -xz -O beszel | tee ./beszel >/dev/null && chmod +x beszel && ls beszel
|
curl -sL "https://github.com/henrygd/beszel/releases/latest/download/beszel_$(uname -s)_$(uname -m | sed 's/x86_64/amd64/' | sed 's/armv7l/arm/' | sed 's/aarch64/arm64/').tar.gz" | tar -xz -O beszel | tee ./beszel >/dev/null && chmod +x beszel && ls beszel
|
||||||
```
|
```
|
||||||
|
|
||||||
##### Running the hub directly
|
Running the hub directly:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./beszel serve
|
./beszel serve
|
||||||
```
|
```
|
||||||
|
|
||||||
##### Running the hub as a system service (Linux)
|
#### Agent
|
||||||
|
|
||||||
This runs the hub in the background continuously.
|
|
||||||
|
|
||||||
1. Create the system service at `/etc/systemd/system/beszel.service`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
[Unit]
|
|
||||||
Description=Beszel Hub Service
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
# update the values in the curly braces below (remove the braces)
|
|
||||||
ExecStart={/path/to/working/directory}/beszel serve
|
|
||||||
WorkingDirectory={/path/to/working/directory}
|
|
||||||
User={YOUR_USERNAME}
|
|
||||||
Restart=always
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Start and enable the service to let it run after system boot
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable beszel.service
|
|
||||||
sudo systemctl start beszel.service
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Agent:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sL "https://github.com/henrygd/beszel/releases/latest/download/beszel-agent_$(uname -s)_$(uname -m | sed 's/x86_64/amd64/' | sed 's/armv7l/arm/' | sed 's/aarch64/arm64/').tar.gz" | tar -xz -O beszel-agent | tee ./beszel-agent >/dev/null && chmod +x beszel-agent && ls beszel-agent
|
curl -sL "https://github.com/henrygd/beszel/releases/latest/download/beszel-agent_$(uname -s)_$(uname -m | sed 's/x86_64/amd64/' | sed 's/armv7l/arm/' | sed 's/aarch64/arm64/').tar.gz" | tar -xz -O beszel-agent | tee ./beszel-agent >/dev/null && chmod +x beszel-agent && ls beszel-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
##### Running the agent directly
|
Running the agent directly:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PORT=45876 KEY="{PASTE_YOUR_KEY}" ./beszel-agent
|
PORT=45876 KEY="{PASTE_YOUR_KEY}" ./beszel-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
##### Running the agent as a system service (Linux)
|
|
||||||
|
|
||||||
This runs the agent in the background continuously.
|
|
||||||
|
|
||||||
1. Create the system service at `/etc/systemd/system/beszel-agent.service`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
[Unit]
|
|
||||||
Description=Beszel Agent Service
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
# update the values in curly braces below (remove the braces)
|
|
||||||
Environment="PORT={PASTE_YOUR_PORT_HERE}"
|
|
||||||
Environment="KEY={PASTE_YOUR_KEY_HERE}"
|
|
||||||
ExecStart={/path/to/directory}/beszel-agent
|
|
||||||
User={YOUR_USERNAME}
|
|
||||||
Restart=always
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Start and enable the service to let it run after system boot
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable beszel-agent.service
|
|
||||||
sudo systemctl start beszel-agent.service
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Updating
|
#### Updating
|
||||||
|
|
||||||
Use `beszel update` and `beszel-agent update` to update to the latest version.
|
Use `./beszel update` and `./beszel-agent update` to update to the latest version.
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
@@ -163,18 +105,19 @@ Use `beszel update` and `beszel-agent update` to update to the latest version.
|
|||||||
### Agent
|
### Agent
|
||||||
|
|
||||||
| Name | Default | Description |
|
| Name | Default | Description |
|
||||||
| ------------ | ------- | ---------------------------------------------------------- |
|
| ------------- | ------- | ------------------------------------------------------------------ |
|
||||||
| `FILESYSTEM` | unset | Filesystem / partition to use for disk I/O stats |
|
| `DOCKER_HOST` | unset | Overrides the docker host (docker.sock) if using a proxy.[^socket] |
|
||||||
|
| `FILESYSTEM` | unset | Filesystem / partition to use for disk I/O 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. |
|
||||||
| `PORT` | 45876 | Port to listen on |
|
| `PORT` | 45876 | Port or address:port to listen on. |
|
||||||
|
|
||||||
|
[^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`.
|
||||||
|
|
||||||
## OAuth / OIDC setup
|
## OAuth / OIDC setup
|
||||||
|
|
||||||
Beszel supports OpenID Connect and many OAuth2 authentication providers (see list below). To enable, do the following:
|
Beszel supports OpenID Connect and many OAuth2 authentication providers (see list below).
|
||||||
|
|
||||||
1. Make sure your "Application URL" is set correctly in the PocketBase settings.
|
Visit the "Auth providers" page to enable your provider. The redirect / callback URL should be `<your-beszel-url>/api/oauth2-redirect`.
|
||||||
2. Create an OAuth2 application using your provider of choice. The redirect / callback URL should be `<your-beszel-url>/api/oauth2-redirect`.
|
|
||||||
3. When you have the client ID and secret, go to the "Auth providers" page and enable your provider.
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Supported provider list</summary>
|
<summary>Supported provider list</summary>
|
||||||
@@ -237,9 +180,20 @@ Cannot create systems, but can view any system that has been shared with them by
|
|||||||
|
|
||||||
### Agent is not connecting
|
### Agent is not connecting
|
||||||
|
|
||||||
Assuming the agent is running, the connection is probably being blocked by a firewall. You need to add an inbound rule on the agent system to allow TCP connections to the port. Check any active firewalls, like iptables or ufw, and in your cloud provider account if applicable.
|
Assuming the agent is running, the connection is probably being blocked by a firewall. You have two options:
|
||||||
|
|
||||||
Connectivity can be tested by running `telnet <agent-ip> <port>` or `nc -zv <agent-ip> <port>` from a remote machine.
|
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 in your cloud provider account if applicable.
|
||||||
|
2. Alternatively, software like [Cloudflare Tunnel](https://www.cloudflare.com/products/tunnel/), [WireGuard](https://www.wireguard.com/), or [Tailscale](https://tailscale.com/) can be used to securely bypass your firewall.
|
||||||
|
|
||||||
|
Connectivity can be tested by running `telnet <agent-ip> <port>`.
|
||||||
|
|
||||||
|
### Connecting the hub and agent on the same system using Docker
|
||||||
|
|
||||||
|
If using host network mode for the agent but not the hub, you can add your system using the hostname `host.docker.internal`, which resolves to the internal IP address used by the host. See [example docker-compose.yml](/supplemental/docker/same-system/docker-compose.yml).
|
||||||
|
|
||||||
|
If using host network for both, you can use `localhost` as the hostname.
|
||||||
|
|
||||||
|
Otherwise you can use the agent's `container_name` as the hostname if both are in the same docker network.
|
||||||
|
|
||||||
### Finding the correct filesystem
|
### Finding the correct filesystem
|
||||||
|
|
||||||
|
|||||||
23
supplemental/docker/same-system/docker-compose.yml
Normal file
23
supplemental/docker/same-system/docker-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
services:
|
||||||
|
beszel:
|
||||||
|
image: 'henrygd/beszel'
|
||||||
|
container_name: 'beszel'
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '8090:8090'
|
||||||
|
volumes:
|
||||||
|
- ./beszel_data:/beszel_data
|
||||||
|
extra_hosts:
|
||||||
|
- 'host.docker.internal:host-gateway'
|
||||||
|
|
||||||
|
beszel-agent:
|
||||||
|
image: 'henrygd/beszel-agent'
|
||||||
|
container_name: 'beszel-agent'
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
environment:
|
||||||
|
PORT: 45876
|
||||||
|
KEY: '...'
|
||||||
|
# FILESYSTEM: /dev/sda1 # set to the correct filesystem for disk I/O stats
|
||||||
127
supplemental/guides/systemd.md
Normal file
127
supplemental/guides/systemd.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# Installing as a Linux systemd service
|
||||||
|
|
||||||
|
This is useful if you want to run the hub or agent in the background continuously, including after a reboot.
|
||||||
|
|
||||||
|
## Install script (recommended)
|
||||||
|
|
||||||
|
There are two scripts, one for the hub and one for the agent. You can run either one, or both.
|
||||||
|
|
||||||
|
The install script creates a dedicated user for the service (`beszel`), downloads the latest release, and installs the service.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> You need system administrator privileges to run the install script. If you encounter a problem, please [open an issue](https://github.com/henrygd/beszel/issues/new).
|
||||||
|
|
||||||
|
### Hub
|
||||||
|
|
||||||
|
Download the script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sL https://raw.githubusercontent.com/henrygd/beszel/main/supplemental/scripts/install-hub.sh -o install-hub.sh && chmod +x install-hub.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./install-hub.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Uninstall
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./install-hub.sh -u
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Update
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo /opt/beszel/beszel update && sudo systemctl restart beszel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agent
|
||||||
|
|
||||||
|
Download the script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sL https://raw.githubusercontent.com/henrygd/beszel/main/supplemental/scripts/install-agent.sh -o install-agent.sh && chmod +x install-agent.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Install
|
||||||
|
|
||||||
|
You may optionally include the SSH key and port as arguments. Run `./install-agent.sh -h` for more info.
|
||||||
|
|
||||||
|
If specifying your key with `-k`, please make sure to enclose it in quotes.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./install-agent.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Uninstall
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./install-agent.sh -u
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Update
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo /opt/beszel-agent/beszel-agent update && sudo systemctl restart beszel-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual install
|
||||||
|
|
||||||
|
1. Create the system service at `/etc/systemd/system/beszel.service`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
[Unit]
|
||||||
|
Description=Beszel Hub Service
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
# update the values in the curly braces below (remove the braces)
|
||||||
|
ExecStart={/path/to/working/directory}/beszel serve
|
||||||
|
WorkingDirectory={/path/to/working/directory}
|
||||||
|
User={YOUR_USERNAME}
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start and enable the service to let it run after system boot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable beszel.service
|
||||||
|
sudo systemctl start beszel.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run the agent as a system service (Linux)
|
||||||
|
|
||||||
|
This runs the agent in the background continuously using systemd.
|
||||||
|
|
||||||
|
1. Create the system service at `/etc/systemd/system/beszel-agent.service`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
[Unit]
|
||||||
|
Description=Beszel Agent Service
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
# update the values in curly braces below (remove the braces)
|
||||||
|
Environment="PORT={PASTE_YOUR_PORT_HERE}"
|
||||||
|
Environment="KEY={PASTE_YOUR_KEY_HERE}"
|
||||||
|
ExecStart={/path/to/directory}/beszel-agent
|
||||||
|
User={YOUR_USERNAME}
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start and enable the service to let it run after system boot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable beszel-agent.service
|
||||||
|
sudo systemctl start beszel-agent.service
|
||||||
|
```
|
||||||
134
supplemental/scripts/install-agent.sh
Executable file
134
supplemental/scripts/install-agent.sh
Executable file
@@ -0,0 +1,134 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
version=0.0.1
|
||||||
|
# Define default values
|
||||||
|
PORT=45876
|
||||||
|
|
||||||
|
# Read command line options
|
||||||
|
while getopts ":k:p:uh" opt; do
|
||||||
|
case $opt in
|
||||||
|
k) KEY="$OPTARG";;
|
||||||
|
p) PORT="$OPTARG";;
|
||||||
|
u) UNINSTALL="true";;
|
||||||
|
h) printf "Beszel Agent installation script\n\n"
|
||||||
|
printf "Usage: ./install-agent.sh [options]\n\n"
|
||||||
|
printf "Options: \n"
|
||||||
|
printf " -k : SSH key (required, or interactive if not provided)\n"
|
||||||
|
printf " -p : Port (default: $PORT)\n"
|
||||||
|
printf " -u : Uninstall the Beszel Agent\n"
|
||||||
|
printf " -h : Display this help message\n"
|
||||||
|
exit 0;;
|
||||||
|
\?) echo "Invalid option: -$OPTARG"; exit 1;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$UNINSTALL" = "true" ]; then
|
||||||
|
# Stop and disable the Beszel Agent service
|
||||||
|
echo "Stopping and disabling the Beszel Agent service..."
|
||||||
|
sudo systemctl stop beszel-agent.service
|
||||||
|
sudo systemctl disable beszel-agent.service
|
||||||
|
|
||||||
|
# Remove the systemd service file
|
||||||
|
echo "Removing the systemd service file..."
|
||||||
|
sudo rm /etc/systemd/system/beszel-agent.service
|
||||||
|
|
||||||
|
# Reload the systemd daemon
|
||||||
|
echo "Reloading the systemd daemon..."
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
|
||||||
|
# Remove the Beszel Agent directory
|
||||||
|
echo "Removing the Beszel Agent directory..."
|
||||||
|
sudo rm -rf /opt/beszel-agent
|
||||||
|
|
||||||
|
# Remove the dedicated user for the Beszel Agent service
|
||||||
|
echo "Removing the dedicated user for the Beszel Agent service..."
|
||||||
|
sudo userdel beszel
|
||||||
|
|
||||||
|
echo "The Beszel Agent has been uninstalled successfully!"
|
||||||
|
else
|
||||||
|
# Function to check if a package is installed
|
||||||
|
package_installed() {
|
||||||
|
command -v "$1" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for package manager and install necessary packages if not installed
|
||||||
|
if package_installed apt-get; then
|
||||||
|
if ! package_installed tar || ! package_installed curl; then
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y tar curl
|
||||||
|
fi
|
||||||
|
elif package_installed yum; then
|
||||||
|
if ! package_installed tar || ! package_installed curl; then
|
||||||
|
sudo yum install -y tar curl
|
||||||
|
fi
|
||||||
|
elif package_installed pacman; then
|
||||||
|
if ! package_installed tar || ! package_installed curl; then
|
||||||
|
sudo pacman -Sy --noconfirm tar curl
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Warning: Please ensure 'tar' and 'curl' are installed."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If no SSH key is provided, ask for the SSH key interactively
|
||||||
|
if [ -z "$KEY" ]; then
|
||||||
|
read -p "Enter your SSH key: " KEY
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create a dedicated user for the service if it doesn't exist
|
||||||
|
if ! id -u beszel > /dev/null 2>&1; then
|
||||||
|
echo "Creating a dedicated user for the Beszel Agent service..."
|
||||||
|
sudo useradd -M -s /bin/false beszel
|
||||||
|
fi
|
||||||
|
# Add the user to the docker group to allow access to the Docker socket
|
||||||
|
sudo usermod -aG docker beszel
|
||||||
|
|
||||||
|
# Create the directory for the Beszel Agent
|
||||||
|
if [ ! -d "/opt/beszel-agent" ]; then
|
||||||
|
echo "Creating the directory for the Beszel Agent..."
|
||||||
|
sudo mkdir -p /opt/beszel-agent
|
||||||
|
sudo chown beszel:beszel /opt/beszel-agent
|
||||||
|
sudo chmod 755 /opt/beszel-agent
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Download and install the Beszel Agent
|
||||||
|
echo "Downloading and installing the Beszel Agent..."
|
||||||
|
curl -sL "https://github.com/henrygd/beszel/releases/latest/download/beszel-agent_$(uname -s)_$(uname -m | sed 's/x86_64/amd64/' | sed 's/armv7l/arm/' | sed 's/aarch64/arm64/').tar.gz" | tar -xz -O beszel-agent | tee ./beszel-agent >/dev/null
|
||||||
|
sudo mv ./beszel-agent /opt/beszel-agent/beszel-agent
|
||||||
|
sudo chown beszel:beszel /opt/beszel-agent/beszel-agent
|
||||||
|
sudo chmod 755 /opt/beszel-agent/beszel-agent
|
||||||
|
|
||||||
|
# Create the systemd service
|
||||||
|
echo "Creating the systemd service for the Beszel Agent..."
|
||||||
|
sudo tee /etc/systemd/system/beszel-agent.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Beszel Agent Service
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Environment="PORT=$PORT"
|
||||||
|
Environment="KEY=$KEY"
|
||||||
|
ExecStart=/opt/beszel-agent/beszel-agent
|
||||||
|
User=beszel
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Load and start the service
|
||||||
|
printf "\nLoading and starting the Beszel Agent service...\n"
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable beszel-agent.service
|
||||||
|
sudo systemctl start beszel-agent.service
|
||||||
|
|
||||||
|
# Wait for the service to start or fail
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# Check if the service is running
|
||||||
|
if [ "$(systemctl is-active beszel-agent.service)" != "active" ]; then
|
||||||
|
echo "Error: The Beszel Agent service is not running."
|
||||||
|
echo "$(systemctl status beszel-agent.service)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "The Beszel Agent has been installed and configured successfully! It is now running on port $PORT."
|
||||||
|
fi
|
||||||
117
supplemental/scripts/install-hub.sh
Executable file
117
supplemental/scripts/install-hub.sh
Executable file
@@ -0,0 +1,117 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
version=0.0.1
|
||||||
|
# Define default values
|
||||||
|
|
||||||
|
# Read command line options
|
||||||
|
while getopts ":uh" opt; do
|
||||||
|
case $opt in
|
||||||
|
u) UNINSTALL="true";;
|
||||||
|
h) printf "Beszel Hub installation script\n\n";
|
||||||
|
printf "Usage: ./install-hub.sh [options]\n\n";
|
||||||
|
printf "Options: \n"
|
||||||
|
printf " -u : Uninstall the Beszel Hub\n";
|
||||||
|
echo " -h : Display this help message";
|
||||||
|
exit 0;;
|
||||||
|
\?) echo "Invalid option: -$OPTARG"; exit 1;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$UNINSTALL" = "true" ]; then
|
||||||
|
# Stop and disable the Beszel Hub service
|
||||||
|
echo "Stopping and disabling the Beszel Hub service..."
|
||||||
|
sudo systemctl stop beszel-hub.service
|
||||||
|
sudo systemctl disable beszel-hub.service
|
||||||
|
|
||||||
|
# Remove the systemd service file
|
||||||
|
echo "Removing the systemd service file..."
|
||||||
|
sudo rm /etc/systemd/system/beszel-hub.service
|
||||||
|
|
||||||
|
# Reload the systemd daemon
|
||||||
|
echo "Reloading the systemd daemon..."
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
|
||||||
|
# Remove the Beszel Hub binary
|
||||||
|
echo "Removing the Beszel Hub binary..."
|
||||||
|
sudo rm /opt/beszel/beszel
|
||||||
|
|
||||||
|
# Remove the Beszel Hub directory
|
||||||
|
echo "Removing the Beszel Hub directory..."
|
||||||
|
sudo rm -rf /opt/beszel
|
||||||
|
|
||||||
|
# Remove the dedicated user
|
||||||
|
echo "Removing the dedicated user..."
|
||||||
|
sudo userdel beszel
|
||||||
|
|
||||||
|
echo "The Beszel Hub has been uninstalled successfully!"
|
||||||
|
else
|
||||||
|
# Function to check if a package is installed
|
||||||
|
package_installed() {
|
||||||
|
command -v "$1" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for package manager and install necessary packages if not installed
|
||||||
|
if package_installed apt-get; then
|
||||||
|
if ! package_installed tar || ! package_installed curl; then
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y tar curl
|
||||||
|
fi
|
||||||
|
elif package_installed yum; then
|
||||||
|
if ! package_installed tar || ! package_installed curl; then
|
||||||
|
sudo yum install -y tar curl
|
||||||
|
fi
|
||||||
|
elif package_installed pacman; then
|
||||||
|
if ! package_installed tar || ! package_installed curl; then
|
||||||
|
sudo pacman -Sy --noconfirm tar curl
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Warning: Please ensure 'tar' and 'curl' are installed."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create a dedicated user for the service if it doesn't exist
|
||||||
|
if ! id -u beszel > /dev/null 2>&1; then
|
||||||
|
echo "Creating a dedicated user for the Beszel Hub service..."
|
||||||
|
sudo useradd -M -s /bin/false beszel
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Download and install the Beszel Hub
|
||||||
|
echo "Downloading and installing the Beszel Hub..."
|
||||||
|
curl -sL "https://github.com/henrygd/beszel/releases/latest/download/beszel_$(uname -s)_$(uname -m | sed 's/x86_64/amd64/' | sed 's/armv7l/arm/' | sed 's/aarch64/arm64/').tar.gz" | tar -xz -O beszel | tee ./beszel >/dev/null && chmod +x beszel
|
||||||
|
sudo mkdir -p /opt/beszel/beszel_data
|
||||||
|
sudo mv ./beszel /opt/beszel/beszel
|
||||||
|
sudo chown -R beszel:beszel /opt/beszel
|
||||||
|
|
||||||
|
# Create the systemd service
|
||||||
|
printf "Creating the systemd service for the Beszel Hub...\n\n"
|
||||||
|
sudo tee /etc/systemd/system/beszel-hub.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Beszel Hub Service
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/opt/beszel/beszel serve
|
||||||
|
WorkingDirectory=/opt/beszel
|
||||||
|
User=beszel
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Load and start the service
|
||||||
|
printf "\nLoading and starting the Beszel Hub service...\n"
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable beszel-hub.service
|
||||||
|
sudo systemctl start beszel-hub.service
|
||||||
|
|
||||||
|
# Wait for the service to start or fail
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Check if the service is running
|
||||||
|
if [ "$(systemctl is-active beszel-hub.service)" != "active" ]; then
|
||||||
|
echo "Error: The Beszel Hub service is not running."
|
||||||
|
echo "$(systemctl status beszel-hub.service)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "The Beszel Hub has been installed and configured successfully! It is now accessible on port 8090."
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user