Compare commits

..

1 Commits

Author SHA1 Message Date
Henry Dollman
8de2dee4e9 built-in agent 2024-10-07 18:58:57 -04:00
43 changed files with 6423 additions and 6966 deletions

View File

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

View File

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

View File

@@ -29,7 +29,6 @@ func NewAgent() *Agent {
return &Agent{ return &Agent{
sensorsContext: context.Background(), sensorsContext: context.Background(),
memCalc: os.Getenv("MEM_CALC"), memCalc: os.Getenv("MEM_CALC"),
fsStats: make(map[string]*system.FsStats),
} }
} }
@@ -71,23 +70,22 @@ func (a *Agent) Run(pubKey []byte, addr string) {
// if debugging, print stats // if debugging, print stats
if a.debug { if a.debug {
slog.Debug("Stats", "data", a.gatherStats()) slog.Debug("Stats", "data", a.GatherStats())
} }
a.startServer(pubKey, addr) if pubKey != nil {
a.startServer(pubKey, addr)
}
} }
func (a *Agent) gatherStats() system.CombinedData { func (a *Agent) GatherStats() system.CombinedData {
slog.Debug("Getting stats")
systemData := system.CombinedData{ systemData := system.CombinedData{
Stats: a.getSystemStats(), Stats: a.getSystemStats(),
Info: a.systemInfo, Info: a.systemInfo,
} }
slog.Debug("System stats", "data", systemData)
// add docker stats // add docker stats
if containerStats, err := a.dockerManager.getDockerStats(); err == nil { if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
systemData.Containers = containerStats systemData.Containers = containerStats
slog.Debug("Docker stats", "data", systemData.Containers)
} else { } else {
slog.Debug("Error getting docker stats", "err", err) slog.Debug("Error getting docker stats", "err", err)
} }
@@ -98,6 +96,5 @@ func (a *Agent) gatherStats() system.CombinedData {
systemData.Stats.ExtraFs[name] = stats systemData.Stats.ExtraFs[name] = stats
} }
} }
slog.Debug("Extra filesystems", "data", systemData.Stats.ExtraFs)
return systemData return systemData
} }

View File

@@ -18,6 +18,9 @@ func (a *Agent) initializeDiskInfo() {
efPath := "/extra-filesystems" efPath := "/extra-filesystems"
hasRoot := false hasRoot := false
// Create map for disk stats
a.fsStats = make(map[string]*system.FsStats)
partitions, err := disk.Partitions(false) partitions, err := disk.Partitions(false)
if err != nil { if err != nil {
slog.Error("Error getting disk partitions", "err", err) slog.Error("Error getting disk partitions", "err", err)
@@ -44,7 +47,7 @@ func (a *Agent) initializeDiskInfo() {
// check if root device is in /proc/diskstats, use fallback if not // check if root device is in /proc/diskstats, use fallback if not
if _, exists := diskIoCounters[key]; !exists { if _, exists := diskIoCounters[key]; !exists {
slog.Warn("Device not found in diskstats", "name", key) slog.Warn("Device not found in diskstats", "name", key)
key = findFallbackIoDevice(filesystem, diskIoCounters, a.fsStats) key = findFallbackIoDevice(filesystem, diskIoCounters)
slog.Info("Using I/O fallback", "name", key) slog.Info("Using I/O fallback", "name", key)
} }
} }
@@ -122,7 +125,7 @@ func (a *Agent) initializeDiskInfo() {
// If no root filesystem set, use fallback // If no root filesystem set, use fallback
if !hasRoot { if !hasRoot {
rootDevice := findFallbackIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats) rootDevice := findFallbackIoDevice(filepath.Base(filesystem), diskIoCounters)
slog.Info("Root disk", "mountpoint", "/", "io", rootDevice) slog.Info("Root disk", "mountpoint", "/", "io", rootDevice)
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"} a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"}
} }
@@ -132,7 +135,7 @@ func (a *Agent) initializeDiskInfo() {
// Returns the device with the most reads in /proc/diskstats, // Returns the device with the most reads in /proc/diskstats,
// or the device specified by the filesystem argument if it exists // or the device specified by the filesystem argument if it exists
func findFallbackIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat, fsStats map[string]*system.FsStats) string { func findFallbackIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat) string {
var maxReadBytes uint64 var maxReadBytes uint64
maxReadDevice := "/" maxReadDevice := "/"
for _, d := range diskIoCounters { for _, d := range diskIoCounters {
@@ -140,11 +143,8 @@ func findFallbackIoDevice(filesystem string, diskIoCounters map[string]disk.IOCo
return d.Name return d.Name
} }
if d.ReadBytes > maxReadBytes { if d.ReadBytes > maxReadBytes {
// don't use if device already exists in fsStats maxReadBytes = d.ReadBytes
if _, exists := fsStats[d.Name]; !exists { maxReadDevice = d.Name
maxReadBytes = d.ReadBytes
maxReadDevice = d.Name
}
} }
} }
return maxReadDevice return maxReadDevice

View File

@@ -219,7 +219,7 @@ func newDockerManager() *dockerManager {
dockerClient := &dockerManager{ dockerClient := &dockerManager{
client: &http.Client{ client: &http.Client{
Timeout: time.Second * 8, Timeout: time.Millisecond * 1100,
Transport: transport, Transport: transport,
}, },
containerStatsMap: make(map[string]*container.Stats), containerStatsMap: make(map[string]*container.Stats),
@@ -243,10 +243,9 @@ func newDockerManager() *dockerManager {
return dockerClient return dockerClient
} }
// if version > 24, one-shot works correctly and we can limit concurrent operations // if version > 25, one-shot works correctly and we can limit concurrent connections / goroutines to 5
if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 { if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 {
concurrency = 5 concurrency = 5
dockerClient.client.Timeout = time.Millisecond * 1100
} }
slog.Debug("Docker", "version", versionInfo.Version, "concurrency", concurrency) slog.Debug("Docker", "version", versionInfo.Version, "concurrency", concurrency)

View File

@@ -24,7 +24,8 @@ func (a *Agent) startServer(pubKey []byte, addr string) {
} }
func (a *Agent) handleSession(s sshServer.Session) { func (a *Agent) handleSession(s sshServer.Session) {
stats := a.gatherStats() stats := a.GatherStats()
slog.Debug("Sending stats", "data", stats)
if err := json.NewEncoder(s).Encode(stats); err != nil { if err := json.NewEncoder(s).Encode(stats); err != nil {
slog.Error("Error encoding stats", "err", err) slog.Error("Error encoding stats", "err", err)
s.Exit(1) s.Exit(1)

View File

@@ -208,7 +208,6 @@ func (a *Agent) getSystemStats() system.Stats {
a.systemInfo.MemPct = systemStats.MemPct a.systemInfo.MemPct = systemStats.MemPct
a.systemInfo.DiskPct = systemStats.DiskPct a.systemInfo.DiskPct = systemStats.DiskPct
a.systemInfo.Uptime, _ = host.Uptime() a.systemInfo.Uptime, _ = host.Uptime()
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
return systemStats return systemStats
} }

View File

@@ -6,26 +6,21 @@ import (
"fmt" "fmt"
"net/mail" "net/mail"
"net/url" "net/url"
"strings"
"time"
"github.com/containrrr/shoutrrr" "github.com/containrrr/shoutrrr"
"github.com/goccy/go-json"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/apis"
"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"
"github.com/spf13/cast"
) )
type AlertManager struct { type AlertManager struct {
app *pocketbase.PocketBase app *pocketbase.PocketBase
} }
type AlertMessageData struct { type AlertData struct {
UserID string UserID string
Title string Title string
Message string Message string
@@ -38,304 +33,68 @@ type UserNotificationSettings struct {
Webhooks []string `json:"webhooks"` Webhooks []string `json:"webhooks"`
} }
type SystemAlertStats struct {
Cpu float64 `json:"cpu"`
Mem float64 `json:"mp"`
Disk float64 `json:"dp"`
NetSent float64 `json:"ns"`
NetRecv float64 `json:"nr"`
Temperatures map[string]float32 `json:"t"`
}
type SystemAlertData struct {
systemRecord *models.Record
alertRecord *models.Record
name string
unit string
val float64
threshold float64
triggered bool
time time.Time
count uint8
min uint8
mapSums map[string]float32
descriptor string // override descriptor in notification body (for temp sensor, disk partition, etc)
}
func NewAlertManager(app *pocketbase.PocketBase) *AlertManager { func NewAlertManager(app *pocketbase.PocketBase) *AlertManager {
return &AlertManager{ return &AlertManager{
app: app, app: app,
} }
} }
func (am *AlertManager) HandleSystemAlerts(systemRecord *models.Record, systemInfo system.Info, temperatures map[string]float64, extraFs map[string]*system.FsStats) error { func (am *AlertManager) HandleSystemInfoAlerts(systemRecord *models.Record, systemInfo system.Info) {
// start := time.Now()
// defer func() {
// log.Println("alert stats took", time.Since(start))
// }()
alertRecords, err := am.app.Dao().FindRecordsByExpr("alerts", alertRecords, err := am.app.Dao().FindRecordsByExpr("alerts",
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}), dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.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")
return nil return
} }
// log.Println("found alerts", len(alertRecords))
var validAlerts []SystemAlertData
now := systemRecord.Updated.Time().UTC()
oldestTime := now
for _, alertRecord := range alertRecords { for _, alertRecord := range alertRecords {
name := alertRecord.GetString("name") name := alertRecord.GetString("name")
var val float64
unit := "%"
switch name { switch name {
case "CPU": case "CPU", "Memory", "Disk":
val = systemInfo.Cpu if name == "CPU" {
case "Memory": am.handleSlidingValueAlert(systemRecord, alertRecord, name, systemInfo.Cpu)
val = systemInfo.MemPct } else if name == "Memory" {
case "Bandwidth": am.handleSlidingValueAlert(systemRecord, alertRecord, name, systemInfo.MemPct)
val = systemInfo.Bandwidth } else if name == "Disk" {
unit = " MB/s" am.handleSlidingValueAlert(systemRecord, alertRecord, name, systemInfo.DiskPct)
case "Disk":
maxUsedPct := systemInfo.DiskPct
for _, fs := range extraFs {
usedPct := fs.DiskUsed / fs.DiskTotal * 100
if usedPct > maxUsedPct {
maxUsedPct = usedPct
}
}
val = maxUsedPct
case "Temperature":
if temperatures == nil {
continue
}
for _, temp := range temperatures {
if temp > val {
val = temp
}
}
unit = "°C"
}
triggered := alertRecord.GetBool("triggered")
threshold := alertRecord.GetFloat("value")
// CONTINUE
// IF alert is not triggered and curValue is less than threshold
// OR alert is triggered and curValue is greater than threshold
if (!triggered && val <= threshold) || (triggered && val > threshold) {
// log.Printf("Skipping alert %s: val %f | threshold %f | triggered %v\n", name, val, threshold, triggered)
continue
}
min := max(1, cast.ToUint8(alertRecord.Get("min")))
// add time to alert time to make sure it's slighty after record creation
time := now.Add(-time.Duration(min) * time.Minute)
if time.Before(oldestTime) {
oldestTime = time
}
validAlerts = append(validAlerts, SystemAlertData{
systemRecord: systemRecord,
alertRecord: alertRecord,
name: name,
unit: unit,
val: val,
threshold: threshold,
triggered: triggered,
time: time,
min: min,
})
}
systemStats := []struct {
Stats []byte `db:"stats"`
Created types.DateTime `db:"created"`
}{}
err = am.app.Dao().DB().
Select("stats", "created").
From("system_stats").
Where(dbx.NewExp(
"system={:system} AND type='1m' AND created > {:created}",
dbx.Params{
"system": systemRecord.Id,
// subtract some time to give us a bit of buffer
"created": oldestTime.Add(-time.Second * 90),
},
)).
OrderBy("created").
All(&systemStats)
if err != nil {
return err
}
// get oldest record creation time from first record in the slice
oldestRecordTime := systemStats[0].Created.Time()
// log.Println("oldestRecordTime", oldestRecordTime.String())
// delete from validAlerts if time is older than oldestRecord
for i := 0; i < len(validAlerts); i++ {
if validAlerts[i].time.Before(oldestRecordTime) {
// log.Println("deleting alert - time is older than oldestRecord", validAlerts[i].name, oldestRecordTime, validAlerts[i].time)
validAlerts = append(validAlerts[:i], validAlerts[i+1:]...)
}
}
if len(validAlerts) == 0 {
// log.Println("no valid alerts found")
return nil
}
var stats SystemAlertStats
// we can skip the latest systemStats record since it's the current value
for i := 0; i < len(systemStats); i++ {
stat := systemStats[i]
// subtract 10 seconds to give a small time buffer
systemStatsCreation := stat.Created.Time().Add(-time.Second * 10)
if err := json.Unmarshal(stat.Stats, &stats); err != nil {
return err
}
// log.Println("stats", stats)
for j := range validAlerts {
alert := &validAlerts[j]
// reset alert val on first iteration
if i == 0 {
alert.val = 0
}
// continue if system_stats is older than alert time range
if systemStatsCreation.Before(alert.time) {
continue
}
// add to alert value
switch alert.name {
case "CPU":
alert.val += stats.Cpu
case "Memory":
alert.val += stats.Mem
case "Bandwidth":
alert.val += stats.NetSent + stats.NetRecv
case "Disk":
if alert.mapSums == nil {
alert.mapSums = make(map[string]float32, len(extraFs)+1)
}
// add root disk
if _, ok := alert.mapSums["root"]; !ok {
alert.mapSums["root"] = 0.0
}
alert.mapSums["root"] += float32(stats.Disk)
// add extra disks
for key, fs := range extraFs {
if _, ok := alert.mapSums[key]; !ok {
alert.mapSums[key] = 0.0
}
alert.mapSums[key] += float32(fs.DiskUsed / fs.DiskTotal * 100)
}
case "Temperature":
if alert.mapSums == nil {
alert.mapSums = make(map[string]float32, len(stats.Temperatures))
}
for key, temp := range stats.Temperatures {
if _, ok := alert.mapSums[key]; !ok {
alert.mapSums[key] = float32(0)
}
alert.mapSums[key] += temp
}
default:
continue
}
alert.count++
}
}
// sum up vals for each alert
for _, alert := range validAlerts {
switch alert.name {
case "Disk":
maxPct := float32(0)
for key, value := range alert.mapSums {
sumPct := float32(value)
if sumPct > maxPct {
maxPct = sumPct
alert.descriptor = fmt.Sprintf("Usage of %s", key)
}
}
alert.val = float64(maxPct / float32(alert.count))
case "Temperature":
maxTemp := float32(0)
for key, value := range alert.mapSums {
sumTemp := float32(value) / float32(alert.count)
if sumTemp > maxTemp {
maxTemp = sumTemp
alert.descriptor = fmt.Sprintf("Highest sensor %s", key)
}
}
alert.val = float64(maxTemp)
default:
alert.val = alert.val / float64(alert.count)
}
minCount := float32(alert.min) / 1.2
// log.Println("alert", alert.name, "val", alert.val, "threshold", alert.threshold, "triggered", alert.triggered)
// log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold)
// pass through alert if count is greater than or equal to minCount
if float32(alert.count) >= minCount {
if !alert.triggered && alert.val > alert.threshold {
alert.triggered = true
am.sendSystemAlert(alert)
} else if alert.triggered && alert.val <= alert.threshold {
alert.triggered = false
am.sendSystemAlert(alert)
} }
} }
} }
return nil
} }
func (am *AlertManager) sendSystemAlert(alert SystemAlertData) { func (am *AlertManager) handleSlidingValueAlert(systemRecord *models.Record, alertRecord *models.Record, name string, curValue float64) {
// log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold) triggered := alertRecord.GetBool("triggered")
systemName := alert.systemRecord.GetString("name") threshold := alertRecord.GetFloat("value")
// fmt.Println(name, curValue, "threshold", threshold, "triggered", triggered)
// change Disk to Disk usage
if alert.name == "Disk" {
alert.name += " usage"
}
// make title alert name lowercase if not CPU
titleAlertName := alert.name
if titleAlertName != "CPU" {
titleAlertName = strings.ToLower(titleAlertName)
}
var subject string var subject string
if alert.triggered { var body string
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName) var systemName string
if !triggered && curValue > threshold {
alertRecord.Set("triggered", true)
systemName = systemRecord.GetString("name")
subject = fmt.Sprintf("%s usage above threshold on %s", name, systemName)
body = fmt.Sprintf("%s usage on %s is %.1f%%.", name, systemName, curValue)
} else if triggered && curValue <= threshold {
alertRecord.Set("triggered", false)
systemName = systemRecord.GetString("name")
subject = fmt.Sprintf("%s usage below threshold on %s", name, systemName)
body = fmt.Sprintf("%s usage on %s is below threshold at %.1f%%.", name, systemName, curValue)
} else { } else {
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName) // fmt.Println(name, "not triggered")
return
} }
minutesLabel := "minute" if err := am.app.Dao().SaveRecord(alertRecord); err != nil {
if alert.min > 1 {
minutesLabel += "s"
}
if alert.descriptor == "" {
alert.descriptor = alert.name
}
body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", alert.descriptor, alert.val, alert.unit, alert.min, minutesLabel)
alert.alertRecord.Set("triggered", alert.triggered)
if err := am.app.Dao().SaveRecord(alert.alertRecord); err != nil {
// app.Logger().Error("failed to save alert record", "err", err.Error()) // app.Logger().Error("failed to save alert record", "err", err.Error())
return return
} }
// expand the user relation and send the alert // expand the user relation and send the alert
if errs := am.app.Dao().ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 { if errs := am.app.Dao().ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
// app.Logger().Error("failed to expand user relation", "errs", errs) // app.Logger().Error("failed to expand user relation", "errs", errs)
return return
} }
if user := alert.alertRecord.ExpandedOne("user"); user != nil { if user := alertRecord.ExpandedOne("user"); user != nil {
am.sendAlert(AlertMessageData{ am.sendAlert(AlertData{
UserID: user.GetId(), UserID: user.GetId(),
Title: subject, Title: subject,
Message: body, Message: body,
@@ -386,7 +145,7 @@ func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *mo
} }
// send alert // send alert
systemName := oldSystemRecord.GetString("name") systemName := oldSystemRecord.GetString("name")
am.sendAlert(AlertMessageData{ am.sendAlert(AlertData{
UserID: user.GetId(), UserID: user.GetId(),
Title: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji), Title: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji),
Message: fmt.Sprintf("Connection to %s is %s", systemName, alertStatus), Message: fmt.Sprintf("Connection to %s is %s", systemName, alertStatus),
@@ -397,7 +156,7 @@ func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *mo
return nil return nil
} }
func (am *AlertManager) sendAlert(data AlertMessageData) { func (am *AlertManager) sendAlert(data AlertData) {
// get user settings // get user settings
record, err := am.app.Dao().FindFirstRecordByFilter( record, err := am.app.Dao().FindFirstRecordByFilter(
"user_settings", "user={:user}", "user_settings", "user={:user}",

View File

@@ -6,42 +6,35 @@ import (
) )
type Stats struct { type Stats struct {
Cpu float64 `json:"cpu"` Cpu float64 `json:"cpu"`
MaxCpu float64 `json:"cpum,omitempty"` Mem float64 `json:"m"`
Mem float64 `json:"m"` MemUsed float64 `json:"mu"`
MemUsed float64 `json:"mu"` MemPct float64 `json:"mp"`
MemPct float64 `json:"mp"` MemBuffCache float64 `json:"mb"`
MemBuffCache float64 `json:"mb"` MemZfsArc float64 `json:"mz,omitempty"` // ZFS ARC memory
MemZfsArc float64 `json:"mz,omitempty"` // ZFS ARC memory Swap float64 `json:"s,omitempty"`
Swap float64 `json:"s,omitempty"` SwapUsed float64 `json:"su,omitempty"`
SwapUsed float64 `json:"su,omitempty"` DiskTotal float64 `json:"d"`
DiskTotal float64 `json:"d"` DiskUsed float64 `json:"du"`
DiskUsed float64 `json:"du"` DiskPct float64 `json:"dp"`
DiskPct float64 `json:"dp"` DiskReadPs float64 `json:"dr"`
DiskReadPs float64 `json:"dr"` DiskWritePs float64 `json:"dw"`
DiskWritePs float64 `json:"dw"` NetworkSent float64 `json:"ns"`
MaxDiskReadPs float64 `json:"drm,omitempty"` NetworkRecv float64 `json:"nr"`
MaxDiskWritePs float64 `json:"dwm,omitempty"` Temperatures map[string]float64 `json:"t,omitempty"`
NetworkSent float64 `json:"ns"` ExtraFs map[string]*FsStats `json:"efs,omitempty"`
NetworkRecv float64 `json:"nr"`
MaxNetworkSent float64 `json:"nsm,omitempty"`
MaxNetworkRecv float64 `json:"nrm,omitempty"`
Temperatures map[string]float64 `json:"t,omitempty"`
ExtraFs map[string]*FsStats `json:"efs,omitempty"`
} }
type FsStats struct { type FsStats struct {
Time time.Time `json:"-"` Time time.Time `json:"-"`
Root bool `json:"-"` Root bool `json:"-"`
Mountpoint string `json:"-"` Mountpoint string `json:"-"`
DiskTotal float64 `json:"d"` DiskTotal float64 `json:"d"`
DiskUsed float64 `json:"du"` DiskUsed float64 `json:"du"`
TotalRead uint64 `json:"-"` TotalRead uint64 `json:"-"`
TotalWrite uint64 `json:"-"` TotalWrite uint64 `json:"-"`
DiskReadPs float64 `json:"r"` DiskWritePs float64 `json:"w"`
DiskWritePs float64 `json:"w"` DiskReadPs float64 `json:"r"`
MaxDiskReadPS float64 `json:"rm,omitempty"`
MaxDiskWritePS float64 `json:"wm,omitempty"`
} }
type NetIoStats struct { type NetIoStats struct {
@@ -61,7 +54,6 @@ type Info struct {
Cpu float64 `json:"cpu"` Cpu float64 `json:"cpu"`
MemPct float64 `json:"mp"` MemPct float64 `json:"mp"`
DiskPct float64 `json:"dp"` DiskPct float64 `json:"dp"`
Bandwidth float64 `json:"b"`
AgentVersion string `json:"v"` AgentVersion string `json:"v"`
} }

View File

@@ -3,6 +3,7 @@ package hub
import ( import (
"beszel" "beszel"
"beszel/internal/agent"
"beszel/internal/alerts" "beszel/internal/alerts"
"beszel/internal/entities/system" "beszel/internal/entities/system"
"beszel/internal/records" "beszel/internal/records"
@@ -42,6 +43,7 @@ type Hub struct {
am *alerts.AlertManager am *alerts.AlertManager
um *users.UserManager um *users.UserManager
rm *records.RecordManager rm *records.RecordManager
hubAgent *agent.Agent
} }
func NewHub(app *pocketbase.PocketBase) *Hub { func NewHub(app *pocketbase.PocketBase) *Hub {
@@ -56,10 +58,6 @@ func NewHub(app *pocketbase.PocketBase) *Hub {
} }
func (h *Hub) Run() { func (h *Hub) Run() {
// rm := records.NewRecordManager(h.app)
// am := alerts.NewAlertManager(h.app)
// um := users.NewUserManager(h.app)
// loosely check if it was executed using "go run" // loosely check if it was executed using "go run"
isGoRun := strings.HasPrefix(os.Args[0], os.TempDir()) isGoRun := strings.HasPrefix(os.Args[0], os.TempDir())
@@ -73,25 +71,22 @@ func (h *Hub) Run() {
// initial setup // initial setup
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error { h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// create ssh client config // create ssh client config
err := h.createSSHClientConfig() if err := h.createSSHClientConfig(); err != nil {
if err != nil {
log.Fatal(err) log.Fatal(err)
} }
// set auth settings // set auth settings
usersCollection, err := h.app.Dao().FindCollectionByNameOrId("users") if usersCollection, err := h.app.Dao().FindCollectionByNameOrId("users"); err == nil {
if err != nil { usersAuthOptions := usersCollection.AuthOptions()
return err usersAuthOptions.AllowUsernameAuth = false
} if os.Getenv("DISABLE_PASSWORD_AUTH") == "true" {
usersAuthOptions := usersCollection.AuthOptions() usersAuthOptions.AllowEmailAuth = false
usersAuthOptions.AllowUsernameAuth = false } else {
if os.Getenv("DISABLE_PASSWORD_AUTH") == "true" { usersAuthOptions.AllowEmailAuth = true
usersAuthOptions.AllowEmailAuth = false }
} else { usersCollection.SetOptions(usersAuthOptions)
usersAuthOptions.AllowEmailAuth = true if err := h.app.Dao().SaveCollection(usersCollection); err != nil {
} return err
usersCollection.SetOptions(usersAuthOptions) }
if err := h.app.Dao().SaveCollection(usersCollection); err != nil {
return err
} }
return nil return nil
}) })
@@ -159,6 +154,16 @@ func (h *Hub) Run() {
// system creation defaults // system creation defaults
h.app.OnModelBeforeCreate("systems").Add(func(e *core.ModelEvent) error { h.app.OnModelBeforeCreate("systems").Add(func(e *core.ModelEvent) error {
record := e.Model.(*models.Record) record := e.Model.(*models.Record)
if record.GetString("host") == "hubsys" {
// todo: check for hubsys existance and return error if exists (or make sure user is admin)
if record.GetString("name") == "x" {
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "localhost"
}
record.Set("name", hostname)
}
}
record.Set("info", system.Info{}) record.Set("info", system.Info{})
record.Set("status", "pending") record.Set("status", "pending")
return nil return nil
@@ -174,14 +179,6 @@ func (h *Hub) Run() {
h.app.OnModelBeforeCreate("users").Add(h.um.InitializeUserRole) h.app.OnModelBeforeCreate("users").Add(h.um.InitializeUserRole)
h.app.OnModelBeforeCreate("user_settings").Add(h.um.InitializeUserSettings) h.app.OnModelBeforeCreate("user_settings").Add(h.um.InitializeUserSettings)
// empty info for systems that are paused
h.app.OnModelBeforeUpdate("systems").Add(func(e *core.ModelEvent) error {
if e.Model.(*models.Record).GetString("status") == "paused" {
e.Model.(*models.Record).Set("info", system.Info{})
}
return nil
})
// do things after a systems record is updated // do things after a systems record is updated
h.app.OnModelAfterUpdate("systems").Add(func(e *core.ModelEvent) error { h.app.OnModelAfterUpdate("systems").Add(func(e *core.ModelEvent) error {
newRecord := e.Model.(*models.Record) newRecord := e.Model.(*models.Record)
@@ -254,6 +251,26 @@ func (h *Hub) updateSystems() {
} }
func (h *Hub) updateSystem(record *models.Record) { func (h *Hub) updateSystem(record *models.Record) {
switch record.GetString("host") {
case "hubsys":
h.updateHubSystem(record)
default:
h.updateRemoteSystem(record)
}
}
// Update hub system stats with built-in agent
func (h *Hub) updateHubSystem(record *models.Record) {
if h.hubAgent == nil {
h.hubAgent = agent.NewAgent()
h.hubAgent.Run(nil, "")
}
systemData := h.hubAgent.GatherStats()
h.saveSystemStats(record, &systemData)
}
// Connect to remote system and update system stats
func (h *Hub) updateRemoteSystem(record *models.Record) {
var client *ssh.Client var client *ssh.Client
var err error var err error
@@ -281,7 +298,7 @@ func (h *Hub) updateSystem(record *models.Record) {
// if previous connection was closed, try again // if previous connection was closed, try again
h.app.Logger().Error("Existing SSH connection closed. Retrying...", "host", record.GetString("host"), "port", record.GetString("port")) h.app.Logger().Error("Existing SSH connection closed. Retrying...", "host", record.GetString("host"), "port", record.GetString("port"))
h.deleteSystemConnection(record) h.deleteSystemConnection(record)
h.updateSystem(record) h.updateRemoteSystem(record)
return return
} }
h.app.Logger().Error("Failed to get system stats: ", "err", err.Error()) h.app.Logger().Error("Failed to get system stats: ", "err", err.Error())
@@ -289,6 +306,11 @@ func (h *Hub) updateSystem(record *models.Record) {
return return
} }
// update system record // update system record
h.saveSystemStats(record, &systemData)
}
// Update system record with provided system.CombinedData
func (h *Hub) saveSystemStats(record *models.Record, systemData *system.CombinedData) {
record.Set("status", "up") record.Set("status", "up")
record.Set("info", systemData.Info) record.Set("info", systemData.Info)
if err := h.app.Dao().SaveRecord(record); err != nil { if err := h.app.Dao().SaveRecord(record); err != nil {
@@ -314,10 +336,8 @@ func (h *Hub) updateSystem(record *models.Record) {
h.app.Logger().Error("Failed to save record: ", "err", err.Error()) h.app.Logger().Error("Failed to save record: ", "err", err.Error())
} }
} }
// system info alerts (todo: extra fs alerts) // system info alerts (todo: temp alerts, extra fs alerts)
if err := h.am.HandleSystemAlerts(record, systemData.Info, systemData.Stats.Temperatures, systemData.Stats.ExtraFs); err != nil { h.am.HandleSystemInfoAlerts(record, systemData.Info)
h.app.Logger().Error("System alerts error", "err", err.Error())
}
} }
// set system to specified status and save record // set system to specified status and save record
@@ -330,14 +350,20 @@ func (h *Hub) updateSystemStatus(record *models.Record, status string) {
} }
} }
// Deletes the SSH connection (remote) or built-in agent reference
func (h *Hub) deleteSystemConnection(record *models.Record) { func (h *Hub) deleteSystemConnection(record *models.Record) {
if _, ok := h.systemConnections[record.Id]; ok { switch record.GetString("host") {
if h.systemConnections[record.Id] != nil { case "hubsys":
h.systemConnections[record.Id].Close() h.hubAgent = nil
default:
if _, ok := h.systemConnections[record.Id]; ok {
if h.systemConnections[record.Id] != nil {
h.systemConnections[record.Id].Close()
}
h.connectionLock.Lock()
defer h.connectionLock.Unlock()
delete(h.systemConnections, record.Id)
} }
h.connectionLock.Lock()
defer h.connectionLock.Unlock()
delete(h.systemConnections, record.Id)
} }
} }

View File

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

View File

@@ -8,7 +8,6 @@ import (
"math" "math"
"time" "time"
"github.com/goccy/go-json"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/daos"
@@ -32,10 +31,6 @@ type RecordDeletionData struct {
retention time.Duration retention time.Duration
} }
type RecordStats []*struct {
Stats []byte `db:"stats"`
}
func NewRecordManager(app *pocketbase.PocketBase) *RecordManager { func NewRecordManager(app *pocketbase.PocketBase) *RecordManager {
return &RecordManager{app} return &RecordManager{app}
} }
@@ -78,7 +73,6 @@ func (rm *RecordManager) CreateLongerRecords() {
return err return err
} }
// need *models.Collection to create a new record with models.NewRecord
collections := map[string]*models.Collection{} collections := map[string]*models.Collection{}
for _, collectionName := range []string{"system_stats", "container_stats"} { for _, collectionName := range []string{"system_stats", "container_stats"} {
collection, _ := txDao.FindCollectionByNameOrId(collectionName) collection, _ := txDao.FindCollectionByNameOrId(collectionName)
@@ -110,44 +104,31 @@ func (rm *RecordManager) CreateLongerRecords() {
} }
} }
// get shorter records from the past x minutes // get shorter records from the past x minutes
var stats RecordStats allShorterRecords, err := txDao.FindRecordsByExpr(
collection.Id,
// allShorterRecords, err := txDao.FindRecordsByExpr( dbx.NewExp(
// collection, "type = {:type} AND system = {:system} AND created > {:created}",
// dbx.NewExp( dbx.Params{"type": recordData.shorterType, "system": system.Id, "created": shorterRecordPeriod},
// "type = {:type} AND system = {:system} AND created > {:created}", ),
// dbx.Params{"type": recordData.shorterType, "system": system.Id, "created": shorterRecordPeriod}, )
// ),
// )
err := txDao.DB().
Select("stats").
From(collection.Name).
AndWhere(dbx.NewExp(
"type={:type} AND system={:system} AND created > {:created}",
dbx.Params{
"type": recordData.shorterType,
"system": system.Id,
"created": shorterRecordPeriod,
},
)).
All(&stats)
// continue if not enough shorter records // continue if not enough shorter records
if err != nil || len(stats) < recordData.minShorterRecords { if err != nil || len(allShorterRecords) < recordData.minShorterRecords {
// log.Println("not enough shorter records. continue.", len(allShorterRecords), recordData.expectedShorterRecords) // log.Println("not enough shorter records. continue.", len(allShorterRecords), recordData.expectedShorterRecords)
continue continue
} }
// average the shorter records and create longer record // average the shorter records and create longer record
longerRecord := models.NewRecord(collection) var stats interface{}
longerRecord.Set("system", system.Id)
longerRecord.Set("type", recordData.longerType)
switch collection.Name { switch collection.Name {
case "system_stats": case "system_stats":
longerRecord.Set("stats", rm.AverageSystemStats(stats)) stats = rm.AverageSystemStats(allShorterRecords)
case "container_stats": case "container_stats":
longerRecord.Set("stats", rm.AverageContainerStats(stats)) stats = rm.AverageContainerStats(allShorterRecords)
} }
longerRecord := models.NewRecord(collection)
longerRecord.Set("system", system.Id)
longerRecord.Set("stats", stats)
longerRecord.Set("type", recordData.longerType)
if err := txDao.SaveRecord(longerRecord); err != nil { if err := txDao.SaveRecord(longerRecord); err != nil {
log.Println("failed to save longer record", "err", err.Error()) log.Println("failed to save longer record", "err", err.Error())
} }
@@ -162,7 +143,7 @@ func (rm *RecordManager) CreateLongerRecords() {
} }
// Calculate the average stats of a list of system_stats records without reflect // Calculate the average stats of a list of system_stats records without reflect
func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats { func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Stats {
sum := system.Stats{ sum := system.Stats{
Temperatures: make(map[string]float64), Temperatures: make(map[string]float64),
ExtraFs: make(map[string]*system.FsStats), ExtraFs: make(map[string]*system.FsStats),
@@ -174,7 +155,7 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
var stats system.Stats var stats system.Stats
for _, record := range records { for _, record := range records {
json.Unmarshal(record.Stats, &stats) record.UnmarshalJSONField("stats", &stats)
sum.Cpu += stats.Cpu sum.Cpu += stats.Cpu
sum.Mem += stats.Mem sum.Mem += stats.Mem
sum.MemUsed += stats.MemUsed sum.MemUsed += stats.MemUsed
@@ -190,12 +171,6 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
sum.DiskWritePs += stats.DiskWritePs sum.DiskWritePs += stats.DiskWritePs
sum.NetworkSent += stats.NetworkSent sum.NetworkSent += stats.NetworkSent
sum.NetworkRecv += stats.NetworkRecv sum.NetworkRecv += stats.NetworkRecv
// set peak values
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent)
sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv)
sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs)
sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs)
// add temps to sum // add temps to sum
if stats.Temperatures != nil { if stats.Temperatures != nil {
tempCount++ tempCount++
@@ -216,53 +191,43 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
sum.ExtraFs[key].DiskUsed += value.DiskUsed sum.ExtraFs[key].DiskUsed += value.DiskUsed
sum.ExtraFs[key].DiskWritePs += value.DiskWritePs sum.ExtraFs[key].DiskWritePs += value.DiskWritePs
sum.ExtraFs[key].DiskReadPs += value.DiskReadPs sum.ExtraFs[key].DiskReadPs += value.DiskReadPs
// peak values
sum.ExtraFs[key].MaxDiskReadPS = max(sum.ExtraFs[key].MaxDiskReadPS, value.MaxDiskReadPS, value.DiskReadPs)
sum.ExtraFs[key].MaxDiskWritePS = max(sum.ExtraFs[key].MaxDiskWritePS, value.MaxDiskWritePS, value.DiskWritePs)
} }
} }
} }
stats = system.Stats{ stats = system.Stats{
Cpu: twoDecimals(sum.Cpu / count), Cpu: twoDecimals(sum.Cpu / count),
Mem: twoDecimals(sum.Mem / count), Mem: twoDecimals(sum.Mem / count),
MemUsed: twoDecimals(sum.MemUsed / count), MemUsed: twoDecimals(sum.MemUsed / count),
MemPct: twoDecimals(sum.MemPct / count), MemPct: twoDecimals(sum.MemPct / count),
MemBuffCache: twoDecimals(sum.MemBuffCache / count), MemBuffCache: twoDecimals(sum.MemBuffCache / count),
MemZfsArc: twoDecimals(sum.MemZfsArc / count), MemZfsArc: twoDecimals(sum.MemZfsArc / count),
Swap: twoDecimals(sum.Swap / count), Swap: twoDecimals(sum.Swap / count),
SwapUsed: twoDecimals(sum.SwapUsed / count), SwapUsed: twoDecimals(sum.SwapUsed / count),
DiskTotal: twoDecimals(sum.DiskTotal / count), DiskTotal: twoDecimals(sum.DiskTotal / count),
DiskUsed: twoDecimals(sum.DiskUsed / count), DiskUsed: twoDecimals(sum.DiskUsed / count),
DiskPct: twoDecimals(sum.DiskPct / count), DiskPct: twoDecimals(sum.DiskPct / count),
DiskReadPs: twoDecimals(sum.DiskReadPs / count), DiskReadPs: twoDecimals(sum.DiskReadPs / count),
DiskWritePs: twoDecimals(sum.DiskWritePs / count), DiskWritePs: twoDecimals(sum.DiskWritePs / count),
NetworkSent: twoDecimals(sum.NetworkSent / count), NetworkSent: twoDecimals(sum.NetworkSent / count),
NetworkRecv: twoDecimals(sum.NetworkRecv / count), NetworkRecv: twoDecimals(sum.NetworkRecv / count),
MaxCpu: sum.MaxCpu,
MaxDiskReadPs: sum.MaxDiskReadPs,
MaxDiskWritePs: sum.MaxDiskWritePs,
MaxNetworkSent: sum.MaxNetworkSent,
MaxNetworkRecv: sum.MaxNetworkRecv,
} }
if len(sum.Temperatures) != 0 { if len(sum.Temperatures) != 0 {
stats.Temperatures = make(map[string]float64, len(sum.Temperatures)) stats.Temperatures = make(map[string]float64)
for key, value := range sum.Temperatures { for key, value := range sum.Temperatures {
stats.Temperatures[key] = twoDecimals(value / tempCount) stats.Temperatures[key] = twoDecimals(value / tempCount)
} }
} }
if len(sum.ExtraFs) != 0 { if len(sum.ExtraFs) != 0 {
stats.ExtraFs = make(map[string]*system.FsStats, len(sum.ExtraFs)) stats.ExtraFs = make(map[string]*system.FsStats)
for key, value := range sum.ExtraFs { for key, value := range sum.ExtraFs {
stats.ExtraFs[key] = &system.FsStats{ stats.ExtraFs[key] = &system.FsStats{
DiskTotal: twoDecimals(value.DiskTotal / count), DiskTotal: twoDecimals(value.DiskTotal / count),
DiskUsed: twoDecimals(value.DiskUsed / count), DiskUsed: twoDecimals(value.DiskUsed / count),
DiskWritePs: twoDecimals(value.DiskWritePs / count), DiskWritePs: twoDecimals(value.DiskWritePs / count),
DiskReadPs: twoDecimals(value.DiskReadPs / count), DiskReadPs: twoDecimals(value.DiskReadPs / count),
MaxDiskReadPS: value.MaxDiskReadPS,
MaxDiskWritePS: value.MaxDiskWritePS,
} }
} }
} }
@@ -271,17 +236,13 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
} }
// Calculate the average stats of a list of container_stats records // Calculate the average stats of a list of container_stats records
func (rm *RecordManager) AverageContainerStats(records RecordStats) []container.Stats { func (rm *RecordManager) AverageContainerStats(records []*models.Record) []container.Stats {
sums := make(map[string]*container.Stats) sums := make(map[string]*container.Stats)
count := float64(len(records)) count := float64(len(records))
var containerStats []container.Stats var containerStats []container.Stats
for _, record := range records { for _, record := range records {
// Reset the slice length to 0, but keep the capacity record.UnmarshalJSONField("stats", &containerStats)
containerStats = containerStats[:0]
if err := json.Unmarshal(record.Stats, &containerStats); err != nil {
return []container.Stats{}
}
for _, stat := range containerStats { for _, stat := range containerStats {
if _, ok := sums[stat.Name]; !ok { if _, ok := sums[stat.Name]; !ok {
sums[stat.Name] = &container.Stats{Name: stat.Name} sums[stat.Name] = &container.Stats{Name: stat.Name}

View File

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

View File

@@ -15,7 +15,7 @@ func init() {
{ {
"id": "2hz5ncl8tizk5nx", "id": "2hz5ncl8tizk5nx",
"created": "2024-07-07 16:08:20.979Z", "created": "2024-07-07 16:08:20.979Z",
"updated": "2024-10-12 18:55:51.623Z", "updated": "2024-07-28 17:14:24.492Z",
"name": "systems", "name": "systems",
"type": "base", "type": "base",
"system": false, "system": false,
@@ -120,7 +120,7 @@ func init() {
{ {
"id": "ej9oowivz8b2mht", "id": "ej9oowivz8b2mht",
"created": "2024-07-07 16:09:09.179Z", "created": "2024-07-07 16:09:09.179Z",
"updated": "2024-10-12 18:55:51.623Z", "updated": "2024-07-28 17:14:24.492Z",
"name": "system_stats", "name": "system_stats",
"type": "base", "type": "base",
"system": false, "system": false,
@@ -186,7 +186,7 @@ func init() {
{ {
"id": "juohu4jipgc13v7", "id": "juohu4jipgc13v7",
"created": "2024-07-07 16:09:57.976Z", "created": "2024-07-07 16:09:57.976Z",
"updated": "2024-10-12 18:55:51.623Z", "updated": "2024-07-28 17:14:24.492Z",
"name": "container_stats", "name": "container_stats",
"type": "base", "type": "base",
"system": false, "system": false,
@@ -250,7 +250,7 @@ func init() {
{ {
"id": "_pb_users_auth_", "id": "_pb_users_auth_",
"created": "2024-07-14 16:25:18.226Z", "created": "2024-07-14 16:25:18.226Z",
"updated": "2024-10-12 22:27:19.081Z", "updated": "2024-09-12 23:19:36.280Z",
"name": "users", "name": "users",
"type": "auth", "type": "auth",
"system": false, "system": false,
@@ -316,7 +316,7 @@ func init() {
{ {
"id": "elngm8x1l60zi2v", "id": "elngm8x1l60zi2v",
"created": "2024-07-15 01:16:04.044Z", "created": "2024-07-15 01:16:04.044Z",
"updated": "2024-10-12 22:27:29.128Z", "updated": "2024-07-28 17:14:24.492Z",
"name": "alerts", "name": "alerts",
"type": "base", "type": "base",
"system": false, "system": false,
@@ -367,9 +367,7 @@ func init() {
"Status", "Status",
"CPU", "CPU",
"Memory", "Memory",
"Disk", "Disk"
"Temperature",
"Bandwidth"
] ]
} }
}, },
@@ -387,20 +385,6 @@ func init() {
"noDecimal": false "noDecimal": false
} }
}, },
{
"system": false,
"id": "fstdehcq",
"name": "min",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": 60,
"noDecimal": true
}
},
{ {
"system": false, "system": false,
"id": "6hgdf6hs", "id": "6hgdf6hs",
@@ -423,7 +407,7 @@ func init() {
{ {
"id": "4afacsdnlu8q8r2", "id": "4afacsdnlu8q8r2",
"created": "2024-09-12 17:42:55.324Z", "created": "2024-09-12 17:42:55.324Z",
"updated": "2024-10-12 18:55:51.624Z", "updated": "2024-09-12 21:19:59.114Z",
"name": "user_settings", "name": "user_settings",
"type": "base", "type": "base",
"system": false, "system": false,

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -11,41 +11,42 @@
"dependencies": { "dependencies": {
"@nanostores/react": "^0.7.3", "@nanostores/react": "^0.7.3",
"@nanostores/router": "^0.15.1", "@nanostores/router": "^0.15.1",
"@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.2", "@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.1", "@radix-ui/react-slider": "^1.2.0",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-tooltip": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.2",
"@tanstack/react-table": "^8.20.5", "@tanstack/react-table": "^8.20.5",
"@vitejs/plugin-react": "^4.3.2", "@vitejs/plugin-react": "^4.3.2",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0", "d3-time": "^3.1.0",
"lucide-react": "^0.452.0", "lucide-react": "^0.407.0",
"nanostores": "^0.10.3", "nanostores": "^0.10.3",
"pocketbase": "^0.21.5", "pocketbase": "^0.21.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"recharts": "^2.13.0", "recharts": "^2.13.0-alpha.5",
"tailwind-merge": "^2.5.4", "tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"valibot": "^0.36.0" "valibot": "^0.36.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.1.11", "@types/bun": "^1.1.10",
"@types/react": "^18.3.11", "@types/react": "^18.3.10",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.13",
"typescript": "^5.6.3", "typescript": "^5.6.2",
"vite": "^5.4.9" "vite": "^5.4.8"
} }
} }

View File

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

View File

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

View File

@@ -1,200 +0,0 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart'
import { memo, useMemo } from 'react'
import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
decimalString,
chartMargin,
toFixedFloat,
getSizeAndUnit,
toFixedWithoutTrailingZeros,
} from '@/lib/utils'
// import Spinner from '../spinner'
import { useStore } from '@nanostores/react'
import { $containerFilter } from '@/lib/stores'
import { ChartData } from '@/types'
import { Separator } from '../ui/separator'
export default memo(function ContainerChart({
dataKey,
chartData,
chartName,
unit = '%',
}: {
dataKey: string
chartData: ChartData
chartName: string
unit?: string
}) {
const filter = useStore($containerFilter)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { containerData, ticks, domain, chartTime } = chartData
const isNetChart = chartName === 'net'
const chartConfig = useMemo(() => {
let config = {} as Record<
string,
{
label: string
color: string
}
>
const totalUsage = {} as Record<string, number>
for (let stats of containerData) {
for (let key in stats) {
if (!key || key === 'created') {
continue
}
if (!(key in totalUsage)) {
totalUsage[key] = 0
}
if (isNetChart) {
totalUsage[key] += (stats[key]?.nr ?? 0) + (stats[key]?.ns ?? 0)
} else {
// @ts-ignore
totalUsage[key] += stats[key]?.[dataKey] ?? 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])
const { toolTipFormatter, dataFunction, tickFormatter } = useMemo(() => {
const obj = {} as {
toolTipFormatter: (item: any, key: string) => React.ReactNode | string
dataFunction: (key: string, data: any) => number | null
tickFormatter: (value: any) => string
}
// tick formatter
if (chartName === 'cpu') {
obj.tickFormatter = (value) => {
const val = toFixedWithoutTrailingZeros(value, 2) + unit
return updateYAxisWidth(val)
}
} else {
obj.tickFormatter = (value) => {
const { v, u } = getSizeAndUnit(value, false)
return updateYAxisWidth(`${toFixedFloat(v, 2)}${u}${isNetChart ? '/s' : ''}`)
}
}
// tooltip formatter
if (isNetChart) {
obj.toolTipFormatter = (item: any, key: string) => {
try {
const sent = item?.payload?.[key]?.ns ?? 0
const received = item?.payload?.[key]?.nr ?? 0
return (
<span className="flex">
{decimalString(received)} MB/s
<span className="opacity-70 ml-0.5"> rx </span>
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
{decimalString(sent)} MB/s
<span className="opacity-70 ml-0.5"> tx</span>
</span>
)
} catch (e) {
return null
}
}
} else {
obj.toolTipFormatter = (item: any) => decimalString(item.value) + unit
}
// data function
if (isNetChart) {
obj.dataFunction = (key: string, data: any) => (data[key]?.nr ?? 0) + (data[key]?.ns ?? 0)
} else {
obj.dataFunction = (key: string, data: any) => data[key]?.[dataKey] ?? 0
}
return obj
}, [])
// console.log('rendered at', new Date())
return (
<div>
<ChartContainer
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth,
})}
>
<AreaChart
accessibilityLayer
// syncId={'cpu'}
data={containerData}
margin={chartMargin}
reverseStackOrder={true}
>
<CartesianGrid vertical={false} />
<YAxis
className="tracking-tighter"
width={yAxisWidth}
tickFormatter={tickFormatter}
tickLine={false}
axisLine={false}
/>
<XAxis
dataKey="created"
domain={domain}
allowDataOverflow
ticks={ticks}
type="number"
scale="time"
minTickGap={35}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartTime].format}
/>
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
// @ts-ignore
itemSorter={(a, b) => b.value - a.value}
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} />}
/>
{Object.keys(chartConfig).map((key) => {
const filtered = filter && !key.includes(filter)
let fillOpacity = filtered ? 0.05 : 0.4
let strokeOpacity = filtered ? 0.1 : 1
return (
<Area
key={key}
isAnimationActive={false}
dataKey={dataFunction.bind(null, key)}
name={key}
type="monotoneX"
fill={chartConfig[key].color}
fillOpacity={fillOpacity}
stroke={chartConfig[key].color}
strokeOpacity={strokeOpacity}
activeDot={{ opacity: filtered ? 0 : 1 }}
stackId="a"
/>
)
})}
</AreaChart>
</ChartContainer>
</div>
)
})

View File

@@ -0,0 +1,143 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart'
import { useMemo } from 'react'
import { useYAxisWidth, chartTimeData, cn, formatShortDate, twoDecimalString } from '@/lib/utils'
// import Spinner from '../spinner'
import { useStore } from '@nanostores/react'
import { $chartTime, $containerFilter } from '@/lib/stores'
export default function ContainerCpuChart({
chartData,
ticks,
}: {
chartData: Record<string, number | string>[]
ticks: number[]
}) {
const chartTime = useStore($chartTime)
const filter = useStore($containerFilter)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
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 (key === 'time') {
continue
}
if (!(key in totalUsage)) {
totalUsage[key] = 0
}
// @ts-ignore
totalUsage[key] += stats[key]
}
}
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>
{/* {!yAxisSet && <Spinner />} */}
<ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth,
})}
>
<AreaChart
accessibilityLayer
// syncId={'cpu'}
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}
tickFormatter={(x) => {
const val = (x % 1 === 0 ? x : x.toFixed(1)) + '%'
return updateYAxisWidth(val)
}}
/>
<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
filter={filter}
contentFormatter={(item) => twoDecimalString(item.value) + '%'}
indicator="line"
/>
}
/>
{Object.keys(chartConfig).map((key) => {
const filtered = filter && !key.includes(filter)
let fillOpacity = filtered ? 0.05 : 0.4
let strokeOpacity = filtered ? 0.1 : 1
return (
<Area
key={key}
isAnimationActive={false}
dataKey={key}
type="monotoneX"
fill={chartConfig[key].color}
fillOpacity={fillOpacity}
stroke={chartConfig[key].color}
strokeOpacity={strokeOpacity}
activeDot={{ opacity: filtered ? 0 : 1 }}
stackId="a"
/>
)
})}
</AreaChart>
</ChartContainer>
</div>
)
}

View File

@@ -0,0 +1,149 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart'
import { useMemo } from 'react'
import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
twoDecimalString,
} from '@/lib/utils'
// import Spinner from '../spinner'
import { useStore } from '@nanostores/react'
import { $chartTime, $containerFilter } from '@/lib/stores'
export default function ContainerMemChart({
chartData,
ticks,
}: {
chartData: Record<string, number | string>[]
ticks: number[]
}) {
const chartTime = useStore($chartTime)
const filter = useStore($containerFilter)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
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 (key === 'time') {
continue
}
if (!(key in totalUsage)) {
totalUsage[key] = 0
}
// @ts-ignore
totalUsage[key] += stats[key]
}
}
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>
{/* {!yAxisSet && <Spinner />} */}
<ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth,
})}
>
<AreaChart
accessibilityLayer
data={chartData}
reverseStackOrder={true}
margin={{
top: 10,
}}
>
<CartesianGrid vertical={false} />
<YAxis
className="tracking-tighter"
// domain={[0, (max: number) => Math.ceil(max)]}
tickLine={false}
axisLine={false}
width={yAxisWidth}
tickFormatter={(value) => {
const val = toFixedWithoutTrailingZeros(value / 1024, 2) + ' GB'
return updateYAxisWidth(val)
}}
/>
<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
filter={filter}
contentFormatter={(item) => twoDecimalString(item.value) + ' MB'}
indicator="line"
/>
}
/>
{Object.keys(chartConfig).map((key) => {
const filtered = filter && !key.includes(filter)
let fillOpacity = filtered ? 0.05 : 0.4
let strokeOpacity = filtered ? 0.1 : 1
return (
<Area
key={key}
isAnimationActive={false}
dataKey={key}
type="monotoneX"
fill={chartConfig[key].color}
strokeOpacity={strokeOpacity}
fillOpacity={fillOpacity}
stroke={chartConfig[key].color}
activeDot={filtered ? false : {}}
stackId="a"
/>
)
})}
</AreaChart>
</ChartContainer>
</div>
)
}

View File

@@ -0,0 +1,166 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart'
import { useMemo } from 'react'
import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
twoDecimalString,
} from '@/lib/utils'
// import Spinner from '../spinner'
import { useStore } from '@nanostores/react'
import { $chartTime, $containerFilter } 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 filter = useStore($containerFilter)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
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>
{/* {!yAxisSet && <Spinner />} */}
<ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth,
})}
>
<AreaChart
accessibilityLayer
data={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}
tickFormatter={(value) => {
const val = toFixedWithoutTrailingZeros(value, 2) + ' MB/s'
return updateYAxisWidth(val)
}}
/>
<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
filter={filter}
indicator="line"
contentFormatter={(item, key) => {
try {
const sent = item?.payload?.[key][0] ?? 0
const received = item?.payload?.[key][1] ?? 0
return (
<span className="flex">
{twoDecimalString(received)} MB/s
<span className="opacity-70 ml-0.5"> rx </span>
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
{twoDecimalString(sent)} MB/s<span className="opacity-70 ml-0.5"> tx</span>
</span>
)
} catch (e) {
return null
}
}}
/>
}
/>
{Object.keys(chartConfig).map((key) => {
const filtered = filter && !key.includes(filter)
let fillOpacity = filtered ? 0.05 : 0.4
let strokeOpacity = filtered ? 0.1 : 1
return (
<Area
key={key}
name={key}
// animationDuration={1200}
isAnimationActive={false}
dataKey={(data) => data?.[key]?.[2] ?? 0}
type="monotoneX"
fill={chartConfig[key].color}
fillOpacity={fillOpacity}
stroke={chartConfig[key].color}
strokeOpacity={strokeOpacity}
activeDot={{ opacity: filtered ? 0 : 1 }}
stackId="a"
/>
)
})}
</AreaChart>
</ChartContainer>
</div>
)
}

View File

@@ -0,0 +1,81 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import { useYAxisWidth, chartTimeData, cn, formatShortDate, twoDecimalString } from '@/lib/utils'
// import Spinner from '../spinner'
import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types'
export default function CpuChart({
ticks,
systemData,
}: {
ticks: number[]
systemData: SystemStatsRecord[]
}) {
const chartTime = useStore($chartTime)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
return (
<div>
<ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth,
})}
>
<AreaChart
accessibilityLayer
data={systemData}
margin={{ top: 10 }}
// syncId={'cpu'}
>
<CartesianGrid vertical={false} />
<YAxis
className="tracking-tighter"
// domain={[0, (max: number) => Math.ceil(max)]}
width={yAxisWidth}
tickLine={false}
axisLine={false}
tickFormatter={(value) => updateYAxisWidth(value + '%')}
/>
<XAxis
dataKey="created"
domain={[ticks[0], ticks.at(-1)!]}
ticks={ticks}
type="number"
scale={'time'}
minTickGap={35}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartTime].format}
/>
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => twoDecimalString(item.value) + '%'}
indicator="line"
/>
}
/>
<Area
dataKey="stats.cpu"
name="CPU Usage"
type="monotoneX"
fill="hsl(var(--chart-1))"
fillOpacity={0.4}
stroke="hsl(var(--chart-1))"
isAnimationActive={false}
// animationEasing="ease-out"
// animationDuration={1200}
// animateNewValues={true}
/>
</AreaChart>
</ChartContainer>
</div>
)
}

View File

@@ -6,35 +6,50 @@ import {
chartTimeData, chartTimeData,
cn, cn,
formatShortDate, formatShortDate,
decimalString, twoDecimalString,
toFixedFloat, toFixedFloat,
chartMargin, getSizeVal,
getSizeAndUnit, getSizeUnit,
} from '@/lib/utils' } from '@/lib/utils'
import { ChartData } from '@/types' // import { useMemo } from 'react'
import { memo } from 'react' // import Spinner from '../spinner'
import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types'
export default memo(function DiskChart({ export default function DiskChart({
ticks,
systemData,
dataKey, dataKey,
diskSize, diskSize,
chartData,
}: { }: {
ticks: number[]
systemData: SystemStatsRecord[]
dataKey: string dataKey: string
diskSize: number diskSize: number
chartData: ChartData
}) { }) {
const chartTime = useStore($chartTime)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
// console.log('rendered at', new Date())
return ( return (
<div> <div>
{/* {!yAxisSet && <Spinner />} */}
<ChartContainer <ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth, 'opacity-100': yAxisWidth,
})} })}
> >
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}> <AreaChart
accessibilityLayer
data={systemData}
margin={{
left: 0,
right: 0,
top: 10,
bottom: 0,
}}
>
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
<YAxis <YAxis
className="tracking-tighter" className="tracking-tighter"
@@ -44,22 +59,20 @@ export default memo(function DiskChart({
minTickGap={6} minTickGap={6}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
tickFormatter={(value) => { tickFormatter={(value) =>
const { v, u } = getSizeAndUnit(value) updateYAxisWidth(toFixedFloat(getSizeVal(value), 2) + getSizeUnit(value))
return updateYAxisWidth(toFixedFloat(v, 2) + u) }
}}
/> />
<XAxis <XAxis
dataKey="created" dataKey="created"
domain={chartData.domain} domain={[ticks[0], ticks.at(-1)!]}
ticks={chartData.ticks} ticks={ticks}
allowDataOverflow
type="number" type="number"
scale="time" scale={'time'}
minTickGap={30} minTickGap={35}
tickMargin={8} tickMargin={8}
axisLine={false} axisLine={false}
tickFormatter={chartTimeData[chartData.chartTime].format} tickFormatter={chartTimeData[chartTime].format}
/> />
<ChartTooltip <ChartTooltip
animationEasing="ease-out" animationEasing="ease-out"
@@ -67,10 +80,10 @@ export default memo(function DiskChart({
content={ content={
<ChartTooltipContent <ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={({ value }) => { contentFormatter={({ value }) =>
const { v, u } = getSizeAndUnit(value) twoDecimalString(getSizeVal(value)) + getSizeUnit(value)
return decimalString(v) + u }
}} indicator="line"
/> />
} }
/> />
@@ -88,4 +101,4 @@ export default memo(function DiskChart({
</ChartContainer> </ChartContainer>
</div> </div>
) )
}) }

View File

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

View File

@@ -6,29 +6,44 @@ import {
chartTimeData, chartTimeData,
cn, cn,
toFixedFloat, toFixedFloat,
decimalString, twoDecimalString,
formatShortDate, formatShortDate,
chartMargin,
} from '@/lib/utils' } from '@/lib/utils'
import { memo } from 'react' import { useMemo } from 'react'
import { ChartData } from '@/types' import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types'
export default memo(function MemChart({ chartData }: { chartData: ChartData }) { export default function MemChart({
ticks,
systemData,
}: {
ticks: number[]
systemData: SystemStatsRecord[]
}) {
const chartTime = useStore($chartTime)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const totalMem = toFixedFloat(chartData.systemStats.at(-1)?.stats.m ?? 0, 1) const totalMem = useMemo(() => {
return toFixedFloat(systemData.at(-1)?.stats.m ?? 0, 1)
// console.log('rendered at', new Date()) }, [systemData])
return ( return (
<div> <div>
{/* {!yAxisSet && <Spinner />} */} {/* {!yAxisSet && <Spinner />} */}
<ChartContainer <ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth, 'opacity-100': yAxisWidth,
})} })}
> >
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}> <AreaChart
accessibilityLayer
data={systemData}
margin={{
top: 10,
}}
>
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
{totalMem && ( {totalMem && (
<YAxis <YAxis
@@ -47,15 +62,14 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
)} )}
<XAxis <XAxis
dataKey="created" dataKey="created"
domain={chartData.domain} domain={[ticks[0], ticks.at(-1)!]}
ticks={chartData.ticks} ticks={ticks}
allowDataOverflow
type="number" type="number"
scale="time" scale={'time'}
minTickGap={30} minTickGap={35}
tickMargin={8} tickMargin={8}
axisLine={false} axisLine={false}
tickFormatter={chartTimeData[chartData.chartTime].format} tickFormatter={chartTimeData[chartTime].format}
/> />
<ChartTooltip <ChartTooltip
// cursor={false} // cursor={false}
@@ -66,8 +80,8 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
// @ts-ignore // @ts-ignore
itemSorter={(a, b) => a.order - b.order} itemSorter={(a, b) => a.order - b.order}
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => decimalString(item.value) + ' GB'} contentFormatter={(item) => twoDecimalString(item.value) + ' GB'}
// indicator="line" indicator="line"
/> />
} }
/> />
@@ -82,7 +96,7 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
stackId="1" stackId="1"
isAnimationActive={false} isAnimationActive={false}
/> />
{chartData.systemStats.at(-1)?.stats.mz && ( {systemData.at(-1)?.stats.mz && (
<Area <Area
name="ZFS ARC" name="ZFS ARC"
order={2} order={2}
@@ -111,4 +125,4 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
</ChartContainer> </ChartContainer>
</div> </div>
) )
}) }

View File

@@ -7,30 +7,36 @@ import {
cn, cn,
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
decimalString, twoDecimalString,
chartMargin,
} from '@/lib/utils' } from '@/lib/utils'
import { ChartData } from '@/types' // import Spinner from '../spinner'
import { memo } from 'react' import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types'
export default memo(function SwapChart({ chartData }: { chartData: ChartData }) { export default function SwapChart({
ticks,
systemData,
}: {
ticks: number[]
systemData: SystemStatsRecord[]
}) {
const chartTime = useStore($chartTime)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
return ( return (
<div> <div>
<ChartContainer <ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth, 'opacity-100': yAxisWidth,
})} })}
> >
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}> <AreaChart accessibilityLayer data={systemData} margin={{ top: 10 }}>
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
<YAxis <YAxis
className="tracking-tighter" className="tracking-tighter"
domain={[ domain={[0, () => toFixedWithoutTrailingZeros(systemData.at(-1)?.stats.s ?? 0.04, 2)]}
0,
() => toFixedWithoutTrailingZeros(chartData.systemStats.at(-1)?.stats.s ?? 0.04, 2),
]}
width={yAxisWidth} width={yAxisWidth}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
@@ -38,15 +44,14 @@ export default memo(function SwapChart({ chartData }: { chartData: ChartData })
/> />
<XAxis <XAxis
dataKey="created" dataKey="created"
domain={chartData.domain} domain={[ticks[0], ticks.at(-1)!]}
ticks={chartData.ticks} ticks={ticks}
allowDataOverflow
type="number" type="number"
scale="time" scale={'time'}
minTickGap={30} minTickGap={35}
tickMargin={8} tickMargin={8}
axisLine={false} axisLine={false}
tickFormatter={chartTimeData[chartData.chartTime].format} tickFormatter={chartTimeData[chartTime].format}
/> />
<ChartTooltip <ChartTooltip
animationEasing="ease-out" animationEasing="ease-out"
@@ -54,8 +59,8 @@ export default memo(function SwapChart({ chartData }: { chartData: ChartData })
content={ content={
<ChartTooltipContent <ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => decimalString(item.value) + ' GB'} contentFormatter={(item) => twoDecimalString(item.value) + ' GB'}
// indicator="line" indicator="line"
/> />
} }
/> />
@@ -72,4 +77,4 @@ export default memo(function SwapChart({ chartData }: { chartData: ChartData })
</ChartContainer> </ChartContainer>
</div> </div>
) )
}) }

View File

@@ -13,23 +13,31 @@ import {
cn, cn,
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
decimalString, twoDecimalString,
chartMargin,
} from '@/lib/utils' } from '@/lib/utils'
import { ChartData } from '@/types' import { useStore } from '@nanostores/react'
import { memo, useMemo } from 'react' import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types'
import { useMemo } from 'react'
export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) { export default function TemperatureChart({
ticks,
systemData,
}: {
ticks: number[]
systemData: SystemStatsRecord[]
}) {
const chartTime = useStore($chartTime)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
/** Format temperature data for chart and assign colors */ /** Format temperature data for chart and assign colors */
const newChartData = useMemo(() => { const newChartData = useMemo(() => {
const newChartData = { data: [], colors: {} } as { const chartData = { data: [], colors: {} } as {
data: Record<string, number | string>[] data: Record<string, number | string>[]
colors: Record<string, string> colors: Record<string, string>
} }
const tempSums = {} as Record<string, number> const tempSums = {} as Record<string, number>
for (let data of chartData.systemStats) { for (let data of systemData) {
let newData = { created: data.created } as Record<string, number | string> let newData = { created: data.created } as Record<string, number | string>
let keys = Object.keys(data.stats?.t ?? {}) let keys = Object.keys(data.stats?.t ?? {})
for (let i = 0; i < keys.length; i++) { for (let i = 0; i < keys.length; i++) {
@@ -37,27 +45,36 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
newData[key] = data.stats.t![key] newData[key] = data.stats.t![key]
tempSums[key] = (tempSums[key] ?? 0) + newData[key] tempSums[key] = (tempSums[key] ?? 0) + newData[key]
} }
newChartData.data.push(newData) chartData.data.push(newData)
} }
const keys = Object.keys(tempSums).sort((a, b) => tempSums[b] - tempSums[a]) const keys = Object.keys(tempSums).sort((a, b) => tempSums[b] - tempSums[a])
for (let key of keys) { for (let key of keys) {
newChartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)` chartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)`
} }
return newChartData return chartData
}, [chartData]) }, [systemData])
const colors = Object.keys(newChartData.colors) const colors = Object.keys(newChartData.colors)
// console.log('rendered at', new Date())
return ( return (
<div> <div>
{/* {!yAxisSet && <Spinner />} */}
<ChartContainer <ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth, 'opacity-100': yAxisWidth,
})} })}
> >
<LineChart accessibilityLayer data={newChartData.data} margin={chartMargin}> <LineChart
accessibilityLayer
data={newChartData.data}
margin={{
left: 0,
right: 0,
top: 10,
bottom: 0,
}}
>
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
<YAxis <YAxis
className="tracking-tighter" className="tracking-tighter"
@@ -72,15 +89,14 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
/> />
<XAxis <XAxis
dataKey="created" dataKey="created"
domain={chartData.domain} domain={[ticks[0], ticks.at(-1)!]}
ticks={chartData.ticks} ticks={ticks}
allowDataOverflow
type="number" type="number"
scale="time" scale={'time'}
minTickGap={30} minTickGap={35}
tickMargin={8} tickMargin={8}
axisLine={false} axisLine={false}
tickFormatter={chartTimeData[chartData.chartTime].format} tickFormatter={chartTimeData[chartTime].format}
/> />
<ChartTooltip <ChartTooltip
animationEasing="ease-out" animationEasing="ease-out"
@@ -90,8 +106,8 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
content={ content={
<ChartTooltipContent <ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => decimalString(item.value) + ' °C'} contentFormatter={(item) => twoDecimalString(item.value) + ' °C'}
// indicator="line" indicator="line"
/> />
} }
/> />
@@ -112,4 +128,4 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
</ChartContainer> </ChartContainer>
</div> </div>
) )
}) }

View File

@@ -143,7 +143,7 @@ export default function CommandPalette() {
}} }}
> >
<DatabaseBackupIcon className="mr-2 h-4 w-4" /> <DatabaseBackupIcon className="mr-2 h-4 w-4" />
<span>Backups</span> <span>Database backups</span>
<CommandShortcut>Admin</CommandShortcut> <CommandShortcut>Admin</CommandShortcut>
</CommandItem> </CommandItem>
<CommandItem <CommandItem

View File

@@ -94,13 +94,15 @@ export function UserAuthForm({
setErrors({ passwordConfirm: msg }) setErrors({ passwordConfirm: msg })
return return
} }
// create admin user
await pb.admins.create({ await pb.admins.create({
email, email,
password, password,
passwordConfirm: password, passwordConfirm: password,
}) })
await pb.admins.authWithPassword(email, password) await pb.admins.authWithPassword(email, password)
await pb.collection('users').create({ // create regular user
const user = await pb.collection('users').create({
username, username,
email, email,
password, password,
@@ -108,6 +110,13 @@ export function UserAuthForm({
role: 'admin', role: 'admin',
verified: true, verified: true,
}) })
// create hubsys
await pb.collection('systems').create({
name: 'x',
port: 'x',
host: 'hubsys',
users: user.id,
})
await pb.collection('users').authWithPassword(email, password) await pb.collection('users').authWithPassword(email, password)
} else { } else {
await pb.collection('users').authWithPassword(email, password) await pb.collection('users').authWithPassword(email, password)

View File

@@ -1,35 +1,18 @@
import { Suspense, lazy, useEffect, useMemo, useState } from 'react' import { Suspense, lazy, useEffect, useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'
import { $alerts, $hubVersion, $systems, pb } from '@/lib/stores' import { $alerts, $hubVersion, $systems, pb } from '@/lib/stores'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { GithubIcon } from 'lucide-react' import { GithubIcon } from 'lucide-react'
import { Separator } from '../ui/separator' import { Separator } from '../ui/separator'
import { alertInfo, updateRecordList, updateSystemList } from '@/lib/utils' import { updateRecordList, updateSystemList } from '@/lib/utils'
import { AlertRecord, SystemRecord } from '@/types' import { AlertRecord, SystemRecord } from '@/types'
import { Input } from '../ui/input' import { Input } from '../ui/input'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Link } from '../router'
const SystemsTable = lazy(() => import('../systems-table/systems-table')) const SystemsTable = lazy(() => import('../systems-table/systems-table'))
export default function () { export default function () {
const hubVersion = useStore($hubVersion) const hubVersion = useStore($hubVersion)
const [filter, setFilter] = useState<string>() const [filter, setFilter] = useState<string>()
const alerts = useStore($alerts)
const systems = useStore($systems)
// todo: maybe remove active alert if changed
const activeAlerts = useMemo(() => {
const activeAlerts = alerts.filter((alert) => {
const active = alert.triggered && alert.name in alertInfo
if (!active) {
return false
}
alert.sysname = systems.find((system) => system.id === alert.system)?.name
return true
})
return activeAlerts
}, [alerts])
useEffect(() => { useEffect(() => {
document.title = 'Dashboard / Beszel' document.title = 'Dashboard / Beszel'
@@ -41,57 +24,17 @@ export default function () {
pb.collection<SystemRecord>('systems').subscribe('*', (e) => { pb.collection<SystemRecord>('systems').subscribe('*', (e) => {
updateRecordList(e, $systems) updateRecordList(e, $systems)
}) })
// todo: add toast if new triggered alert comes in
pb.collection<AlertRecord>('alerts').subscribe('*', (e) => { pb.collection<AlertRecord>('alerts').subscribe('*', (e) => {
updateRecordList(e, $alerts) updateRecordList(e, $alerts)
}) })
return () => { return () => {
pb.collection('systems').unsubscribe('*') pb.collection('systems').unsubscribe('*')
// pb.collection('alerts').unsubscribe('*') pb.collection('alerts').unsubscribe('*')
} }
}, []) }, [])
return ( return (
<> <>
{/* show active alerts */}
{activeAlerts.length > 0 && (
<Card className="mb-4">
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
<div className="px-2 sm:px-1">
<CardTitle>Active Alerts</CardTitle>
</div>
</CardHeader>
<CardContent className="max-sm:p-2">
{activeAlerts.length > 0 && (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
{activeAlerts.map((alert) => {
const info = alertInfo[alert.name as keyof typeof alertInfo]
return (
<Alert
key={alert.id}
className="hover:-translate-y-[1px] duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black"
>
<info.icon className="h-4 w-4" />
<AlertTitle className="mb-2">
{alert.sysname} {info.name}
</AlertTitle>
<AlertDescription>
Exceeds {alert.value}
{info.unit} average in last {alert.min} min
</AlertDescription>
<Link
href={`/system/${encodeURIComponent(alert.sysname!)}`}
className="absolute inset-0 w-full h-full"
aria-label="View system"
></Link>
</Alert>
)
})}
</div>
)}
</CardContent>
</Card>
)}
<Card> <Card>
<CardHeader className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1"> <CardHeader className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
<div className="grid md:flex gap-3 w-full items-end"> <div className="grid md:flex gap-3 w-full items-end">
@@ -118,7 +61,6 @@ export default function () {
</Suspense> </Suspense>
</CardContent> </CardContent>
</Card> </Card>
{hubVersion && ( {hubVersion && (
<div className="flex gap-1.5 justify-end items-center pr-3 sm:pr-6 mt-3.5 text-xs opacity-80"> <div className="flex gap-1.5 justify-end items-center pr-3 sm:pr-6 mt-3.5 text-xs opacity-80">
<a <a

View File

@@ -1,139 +1,69 @@
import { $systems, pb, $chartTime, $containerFilter, $userSettings } from '@/lib/stores' import { $systems, pb, $chartTime, $containerFilter, $userSettings } from '@/lib/stores'
import { import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types'
ChartData, import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
ChartTimes, import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../ui/card'
ContainerStatsRecord,
SystemRecord,
SystemStatsRecord,
} from '@/types'
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Card, CardHeader, CardTitle, CardDescription } from '../ui/card'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import Spinner from '../spinner' import Spinner from '../spinner'
import { ClockArrowUp, CpuIcon, GlobeIcon, LayoutGridIcon, MonitorIcon, XIcon } from 'lucide-react' import { ClockArrowUp, CpuIcon, GlobeIcon, LayoutGridIcon, MonitorIcon, XIcon } from 'lucide-react'
import ChartTimeSelect from '../charts/chart-time-select' import ChartTimeSelect from '../charts/chart-time-select'
import { chartTimeData, cn, getPbTimestamp, useLocalStorage } from '@/lib/utils' import { chartTimeData, cn, getPbTimestamp, useLocalStorage } from '@/lib/utils'
import { Separator } from '../ui/separator' import { Separator } from '../ui/separator'
import { scaleTime } from 'd3-scale'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import { Button } from '../ui/button' import { Button, buttonVariants } from '../ui/button'
import { Input } from '../ui/input' import { Input } from '../ui/input'
import { ChartAverage, ChartMax, Rows, TuxIcon } from '../ui/icons' import { Rows, TuxIcon } from '../ui/icons'
import { useIntersectionObserver } from '@/lib/use-intersection-observer' import { useIntersectionObserver } from '@/lib/use-intersection-observer'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { timeTicks } from 'd3-time'
const AreaChartDefault = lazy(() => import('../charts/area-chart')) const CpuChart = lazy(() => import('../charts/cpu-chart'))
const ContainerChart = lazy(() => import('../charts/container-chart')) const ContainerCpuChart = lazy(() => import('../charts/container-cpu-chart'))
const MemChart = lazy(() => import('../charts/mem-chart')) const MemChart = lazy(() => import('../charts/mem-chart'))
const ContainerMemChart = lazy(() => import('../charts/container-mem-chart'))
const DiskChart = lazy(() => import('../charts/disk-chart')) const DiskChart = lazy(() => import('../charts/disk-chart'))
const DiskIoChart = lazy(() => import('../charts/disk-io-chart'))
const BandwidthChart = lazy(() => import('../charts/bandwidth-chart'))
const ContainerNetChart = lazy(() => import('../charts/container-net-chart'))
const SwapChart = lazy(() => import('../charts/swap-chart')) const SwapChart = lazy(() => import('../charts/swap-chart'))
const TemperatureChart = lazy(() => import('../charts/temperature-chart')) const TemperatureChart = lazy(() => import('../charts/temperature-chart'))
const cache = new Map<string, any>()
// create ticks and domain for charts
function getTimeData(chartTime: ChartTimes, lastCreated: number) {
const cached = cache.get('td')
if (cached && cached.chartTime === chartTime) {
if (!lastCreated || cached.time >= lastCreated) {
return cached.data
}
}
const now = new Date()
const startTime = chartTimeData[chartTime].getOffset(now)
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) =>
date.getTime()
)
const data = {
ticks,
domain: [chartTimeData[chartTime].getOffset(now).getTime(), now.getTime()],
}
cache.set('td', { time: now.getTime(), data, chartTime })
return data
}
// add empty values between records to make gaps if interval is too large
function addEmptyValues<T extends SystemStatsRecord | ContainerStatsRecord>(
prevRecords: T[],
newRecords: T[],
expectedInterval: number
) {
const modifiedRecords: T[] = []
let prevTime = (prevRecords.at(-1)?.created ?? 0) as number
for (let i = 0; i < newRecords.length; i++) {
const record = newRecords[i]
record.created = new Date(record.created).getTime()
if (prevTime) {
const interval = record.created - prevTime
// if interval is too large, add a null record
if (interval > expectedInterval / 2 + expectedInterval) {
// @ts-ignore
modifiedRecords.push({ created: null, stats: null })
}
}
prevTime = record.created
modifiedRecords.push(record)
}
return modifiedRecords
}
async function getStats<T>(
collection: string,
system: SystemRecord,
chartTime: ChartTimes
): Promise<T[]> {
const lastCached = cache.get(`${system.id}_${chartTime}_${collection}`)?.at(-1)?.created as number
return await pb.collection<T>(collection).getFullList({
filter: pb.filter('system={:id} && created > {:created} && type={:type}', {
id: system.id,
created: getPbTimestamp(chartTime, lastCached ? new Date(lastCached + 1000) : undefined),
type: chartTimeData[chartTime].type,
}),
fields: 'created,stats',
sort: 'created',
})
}
export default function SystemDetail({ name }: { name: string }) { export default function SystemDetail({ name }: { name: string }) {
const systems = useStore($systems) const systems = useStore($systems)
const chartTime = useStore($chartTime) const chartTime = useStore($chartTime)
/** Max CPU toggle value */
const cpuMaxStore = useState(false)
const bandwidthMaxStore = useState(false)
const diskIoMaxStore = useState(false)
const [grid, setGrid] = useLocalStorage('grid', true) const [grid, setGrid] = useLocalStorage('grid', true)
const [ticks, setTicks] = useState([] as number[])
const [system, setSystem] = useState({} as SystemRecord) const [system, setSystem] = useState({} as SystemRecord)
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[]) const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
const [containerData, setContainerData] = useState([] as ChartData['containerData'])
const netCardRef = useRef<HTMLDivElement>(null) const netCardRef = useRef<HTMLDivElement>(null)
const [containerFilterBar, setContainerFilterBar] = useState(null as null | JSX.Element) const [dockerCpuChartData, setDockerCpuChartData] = useState<Record<string, number | string>[]>(
const [bottomSpacing, setBottomSpacing] = useState(0) []
const isLongerChart = chartTime !== '1h' )
const [dockerMemChartData, setDockerMemChartData] = useState<Record<string, number | string>[]>(
[]
)
const [dockerNetChartData, setDockerNetChartData] = useState<Record<string, number | number[]>[]>(
[]
)
const hasDockerStats = dockerCpuChartData.length > 0
useEffect(() => { useEffect(() => {
document.title = `${name} / Beszel` document.title = `${name} / Beszel`
return () => { return () => {
resetCharts()
$chartTime.set($userSettings.get().chartTime) $chartTime.set($userSettings.get().chartTime)
// resetCharts()
setSystemStats([])
setContainerData([])
setContainerFilterBar(null)
$containerFilter.set('') $containerFilter.set('')
cpuMaxStore[1](false) // setHasDocker(false)
bandwidthMaxStore[1](false)
diskIoMaxStore[1](false)
} }
}, [name]) }, [name])
// function resetCharts() { function resetCharts() {
// setSystemStats([]) setSystemStats([])
// setContainerData([]) setDockerCpuChartData([])
// } setDockerMemChartData([])
setDockerNetChartData([])
}
// useEffect(resetCharts, [chartTime]) useEffect(resetCharts, [chartTime])
// find matching system
useEffect(() => { useEffect(() => {
if (system.id && system.name === name) { if (system.id && system.name === name) {
return return
@@ -157,18 +87,41 @@ export default function SystemDetail({ name }: { name: string }) {
} }
}, [system]) }, [system])
const chartData: ChartData = useMemo(() => { async function getStats<T>(collection: string): Promise<T[]> {
const lastCreated = Math.max( return await pb.collection<T>(collection).getFullList({
(systemStats.at(-1)?.created as number) ?? 0, filter: pb.filter('system={:id} && created > {:created} && type={:type}', {
(containerData.at(-1)?.created as number) ?? 0 id: system.id,
) created: getPbTimestamp(chartTime),
return { type: chartTimeData[chartTime].type,
systemStats, }),
containerData, fields: 'created,stats',
chartTime, sort: 'created',
...getTimeData(chartTime, lastCreated), })
}
// add empty values between records to make gaps if interval is too large
function addEmptyValues<T extends SystemStatsRecord | ContainerStatsRecord>(
records: T[],
expectedInterval: number
) {
const modifiedRecords: T[] = []
let prevTime = 0
for (let i = 0; i < records.length; i++) {
const record = records[i]
record.created = new Date(record.created).getTime()
if (prevTime) {
const interval = record.created - prevTime
// if interval is too large, add a null record
if (interval > expectedInterval / 2 + expectedInterval) {
// @ts-ignore
modifiedRecords.push({ created: null, stats: null })
}
}
prevTime = record.created
modifiedRecords.push(record)
} }
}, [systemStats, containerData]) return modifiedRecords
}
// get stats // get stats
useEffect(() => { useEffect(() => {
@@ -176,62 +129,59 @@ export default function SystemDetail({ name }: { name: string }) {
return return
} }
Promise.allSettled([ Promise.allSettled([
getStats<SystemStatsRecord>('system_stats', system, chartTime), getStats<SystemStatsRecord>('system_stats'),
getStats<ContainerStatsRecord>('container_stats', system, chartTime), getStats<ContainerStatsRecord>('container_stats'),
]).then(([systemStats, containerStats]) => { ]).then(([systemStats, containerStats]) => {
const { expectedInterval } = chartTimeData[chartTime] const expectedInterval = chartTimeData[chartTime].expectedInterval
// make new system stats
const ss_cache_key = `${system.id}_${chartTime}_system_stats`
let systemData = (cache.get(ss_cache_key) || []) as SystemStatsRecord[]
if (systemStats.status === 'fulfilled' && systemStats.value.length) {
systemData = systemData.concat(
addEmptyValues(systemData, systemStats.value, expectedInterval)
)
if (systemData.length > 120) {
systemData = systemData.slice(-100)
}
cache.set(ss_cache_key, systemData)
}
setSystemStats(systemData)
// make new container stats
const cs_cache_key = `${system.id}_${chartTime}_container_stats`
let containerData = (cache.get(cs_cache_key) || []) as ContainerStatsRecord[]
if (containerStats.status === 'fulfilled' && containerStats.value.length) { if (containerStats.status === 'fulfilled' && containerStats.value.length) {
containerData = containerData.concat( makeContainerData(addEmptyValues(containerStats.value, expectedInterval))
addEmptyValues(containerData, containerStats.value, expectedInterval)
)
if (containerData.length > 120) {
containerData = containerData.slice(-100)
}
cache.set(cs_cache_key, containerData)
} }
if (containerData.length) { if (systemStats.status === 'fulfilled') {
!containerFilterBar && setContainerFilterBar(<ContainerFilterBar />) setSystemStats(addEmptyValues(systemStats.value, expectedInterval))
} else if (containerFilterBar) {
setContainerFilterBar(null)
} }
makeContainerData(containerData)
}) })
}, [system, chartTime]) }, [system, chartTime])
useEffect(() => {
if (!systemStats.length) {
return
}
const now = new Date()
const startTime = chartTimeData[chartTime].getOffset(now)
const scale = scaleTime([startTime.getTime(), now], [0, systemStats.length])
setTicks(scale.ticks(chartTimeData[chartTime].ticks).map((d) => d.getTime()))
}, [chartTime, systemStats])
// make container stats for charts // make container stats for charts
const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => { const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => {
const containerData = [] as ChartData['containerData'] // console.log('containers', containers)
const dockerCpuData = []
const dockerMemData = []
const dockerNetData = []
for (let { created, stats } of containers) { for (let { created, stats } of containers) {
if (!created) { if (!created) {
// @ts-ignore add null value for gaps let nullData = { time: null } as unknown
containerData.push({ created: null }) dockerCpuData.push(nullData as Record<string, number | string>)
dockerMemData.push(nullData as Record<string, number | string>)
dockerNetData.push(nullData as Record<string, number | number[]>)
continue continue
} }
created = new Date(created).getTime() const time = new Date(created).getTime()
// @ts-ignore not dealing with this rn let cpuData = { time } as Record<string, number | string>
let containerStats: ChartData['containerData'][0] = { created } 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) {
containerStats[container.n] = container cpuData[container.n] = container.c
memData[container.n] = container.m
netData[container.n] = [container.ns, container.nr, container.ns + container.nr] // sent, received, total
} }
containerData.push(containerStats) dockerCpuData.push(cpuData)
dockerMemData.push(memData)
dockerNetData.push(netData)
} }
setContainerData(containerData) setDockerCpuChartData(dockerCpuData)
setDockerMemChartData(dockerMemData)
setDockerNetChartData(dockerNetData)
}, []) }, [])
// values for system info bar // values for system info bar
@@ -242,12 +192,12 @@ export default function SystemDetail({ name }: { name: string }) {
let uptime: number | string = system.info.u let uptime: number | string = system.info.u
if (system.info.u < 172800) { if (system.info.u < 172800) {
const hours = Math.trunc(uptime / 3600) const hours = Math.trunc(uptime / 3600)
uptime = `${hours} hour${hours == 1 ? '' : 's'}` uptime = `${hours} hour${hours > 1 ? 's' : ''}`
} else { } else {
uptime = `${Math.trunc(system.info?.u / 86400)} days` uptime = `${Math.trunc(system.info?.u / 86400)} days`
} }
return [ return [
{ value: system.host, Icon: GlobeIcon }, { value: system.host, Icon: GlobeIcon, hide: system.host === 'hubsys' },
{ {
value: system.info.h, value: system.info.h,
Icon: MonitorIcon, Icon: MonitorIcon,
@@ -271,18 +221,17 @@ export default function SystemDetail({ name }: { name: string }) {
}, [system.info]) }, [system.info])
/** Space for tooltip if more than 12 containers */ /** Space for tooltip if more than 12 containers */
useEffect(() => { const bottomSpacing = useMemo(() => {
if (!netCardRef.current || !containerData.length) { if (!netCardRef.current || !dockerNetChartData.length) {
setBottomSpacing(0) return 0
return
} }
const tooltipHeight = (Object.keys(containerData[0]).length - 11) * 17.8 - 40 const tooltipHeight = (Object.keys(dockerNetChartData[0]).length - 11) * 17.8 - 40
const wrapperEl = document.getElementById('chartwrap') as HTMLDivElement const wrapperEl = document.getElementById('chartwrap') as HTMLDivElement
const wrapperRect = wrapperEl.getBoundingClientRect() const wrapperRect = wrapperEl.getBoundingClientRect()
const chartRect = netCardRef.current.getBoundingClientRect() const chartRect = netCardRef.current.getBoundingClientRect()
const distanceToBottom = wrapperRect.bottom - chartRect.bottom const distanceToBottom = wrapperRect.bottom - chartRect.bottom
setBottomSpacing(tooltipHeight - distanceToBottom) return tooltipHeight - distanceToBottom
}, [netCardRef, containerData]) }, [netCardRef.current, dockerNetChartData])
if (!system.id) { if (!system.id) {
return null return null
@@ -290,10 +239,10 @@ export default function SystemDetail({ name }: { name: string }) {
return ( return (
<> <>
<div id="chartwrap" className="grid gap-4 mb-10 overflow-x-clip"> <div id="chartwrap" className="grid gap-4 mb-10">
{/* system info */} {/* system info */}
<Card> <Card>
<div className="grid lg:flex gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5"> <div className="grid lg:flex items-center gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
<div> <div>
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1> <h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90"> <div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
@@ -350,9 +299,10 @@ export default function SystemDetail({ name }: { name: string }) {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
aria-label="Toggle grid" aria-label="Toggle grid"
variant="outline" className={cn(
size="icon" buttonVariants({ variant: 'outline', size: 'icon' }),
className="hidden lg:flex p-0 text-primary" 'hidden lg:flex p-0 text-primary'
)}
onClick={() => setGrid(!grid)} onClick={() => setGrid(!grid)}
> >
{grid ? ( {grid ? (
@@ -374,27 +324,19 @@ export default function SystemDetail({ name }: { name: string }) {
<ChartCard <ChartCard
grid={grid} grid={grid}
title="Total CPU Usage" title="Total CPU Usage"
description={`${ description="Average system-wide CPU utilization"
cpuMaxStore[0] && isLongerChart ? 'Max 1 min ' : 'Average'
} system-wide CPU utilization`}
cornerEl={isLongerChart ? <SelectAvgMax store={cpuMaxStore} /> : null}
> >
<AreaChartDefault <CpuChart ticks={ticks} systemData={systemStats} />
chartData={chartData}
chartName="CPU Usage"
maxToggled={cpuMaxStore[0]}
unit="%"
/>
</ChartCard> </ChartCard>
{containerFilterBar && ( {hasDockerStats && (
<ChartCard <ChartCard
grid={grid} grid={grid}
title="Docker CPU Usage" title="Docker CPU Usage"
description="Average CPU utilization of containers" description="CPU utilization of docker containers"
cornerEl={containerFilterBar} isContainerChart={true}
> >
<ContainerChart chartData={chartData} dataKey="c" chartName="cpu" /> <ContainerCpuChart chartData={dockerCpuChartData} ticks={ticks} />
</ChartCard> </ChartCard>
)} )}
@@ -403,55 +345,46 @@ export default function SystemDetail({ name }: { name: string }) {
title="Total Memory Usage" title="Total Memory Usage"
description="Precise utilization at the recorded time" description="Precise utilization at the recorded time"
> >
<MemChart chartData={chartData} /> <MemChart ticks={ticks} systemData={systemStats} />
</ChartCard> </ChartCard>
{containerFilterBar && ( {hasDockerStats && (
<ChartCard <ChartCard
grid={grid} grid={grid}
title="Docker Memory Usage" title="Docker Memory Usage"
description="Memory usage of docker containers" description="Memory usage of docker containers"
cornerEl={containerFilterBar} isContainerChart={true}
> >
<ContainerChart chartData={chartData} chartName="mem" dataKey="m" unit=" MB" /> <ContainerMemChart chartData={dockerMemChartData} ticks={ticks} />
</ChartCard> </ChartCard>
)} )}
<ChartCard grid={grid} title="Disk Space" description="Usage of root partition"> <ChartCard grid={grid} title="Disk Space" description="Usage of root partition">
<DiskChart <DiskChart
chartData={chartData} ticks={ticks}
systemData={systemStats}
dataKey="stats.du" dataKey="stats.du"
diskSize={Math.round(systemStats.at(-1)?.stats.d ?? NaN)} diskSize={Math.round(systemStats.at(-1)?.stats.d ?? NaN)}
/> />
</ChartCard> </ChartCard>
<ChartCard <ChartCard grid={grid} title="Disk I/O" description="Throughput of root filesystem">
grid={grid} <DiskIoChart
title="Disk I/O" ticks={ticks}
description="Throughput of root filesystem" systemData={systemStats}
cornerEl={isLongerChart ? <SelectAvgMax store={diskIoMaxStore} /> : null} dataKeys={['stats.dw', 'stats.dr']}
>
<AreaChartDefault
chartData={chartData}
maxToggled={diskIoMaxStore[0]}
chartName="dio"
/> />
</ChartCard> </ChartCard>
<ChartCard <ChartCard
grid={grid} grid={grid}
title="Bandwidth" title="Bandwidth"
cornerEl={isLongerChart ? <SelectAvgMax store={bandwidthMaxStore} /> : null}
description="Network traffic of public interfaces" description="Network traffic of public interfaces"
> >
<AreaChartDefault <BandwidthChart ticks={ticks} systemData={systemStats} />
chartData={chartData}
maxToggled={bandwidthMaxStore[0]}
chartName="bw"
/>
</ChartCard> </ChartCard>
{containerFilterBar && containerData.length > 0 && ( {hasDockerStats && dockerNetChartData.length > 0 && (
<div <div
ref={netCardRef} ref={netCardRef}
className={cn({ className={cn({
@@ -461,23 +394,22 @@ export default function SystemDetail({ name }: { name: string }) {
<ChartCard <ChartCard
title="Docker Network I/O" title="Docker Network I/O"
description="Includes traffic between internal services" description="Includes traffic between internal services"
cornerEl={containerFilterBar} isContainerChart={true}
> >
{/* @ts-ignore */} <ContainerNetChart chartData={dockerNetChartData} ticks={ticks} />
<ContainerChart chartData={chartData} chartName="net" dataKey="n" />
</ChartCard> </ChartCard>
</div> </div>
)} )}
{(systemStats.at(-1)?.stats.su ?? 0) > 0 && ( {(systemStats.at(-1)?.stats.su ?? 0) > 0 && (
<ChartCard grid={grid} title="Swap Usage" description="Swap space used by the system"> <ChartCard grid={grid} title="Swap Usage" description="Swap space used by the system">
<SwapChart chartData={chartData} /> <SwapChart ticks={ticks} systemData={systemStats} />
</ChartCard> </ChartCard>
)} )}
{systemStats.at(-1)?.stats.t && ( {systemStats.at(-1)?.stats.t && (
<ChartCard grid={grid} title="Temperature" description="Temperatures of system sensors"> <ChartCard grid={grid} title="Temperature" description="Temperatures of system sensors">
<TemperatureChart chartData={chartData} /> <TemperatureChart ticks={ticks} systemData={systemStats} />
</ChartCard> </ChartCard>
)} )}
</div> </div>
@@ -494,7 +426,8 @@ export default function SystemDetail({ name }: { name: string }) {
description={`Disk usage of ${extraFsName}`} description={`Disk usage of ${extraFsName}`}
> >
<DiskChart <DiskChart
chartData={chartData} ticks={ticks}
systemData={systemStats}
dataKey={`stats.efs.${extraFsName}.du`} dataKey={`stats.efs.${extraFsName}.du`}
diskSize={Math.round(systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN)} diskSize={Math.round(systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN)}
/> />
@@ -503,12 +436,11 @@ export default function SystemDetail({ name }: { name: string }) {
grid={grid} grid={grid}
title={`${extraFsName} I/O`} title={`${extraFsName} I/O`}
description={`Throughput of ${extraFsName}`} description={`Throughput of ${extraFsName}`}
cornerEl={isLongerChart ? <SelectAvgMax store={diskIoMaxStore} /> : null}
> >
<AreaChartDefault <DiskIoChart
chartData={chartData} ticks={ticks}
maxToggled={diskIoMaxStore[0]} systemData={systemStats}
chartName={`efs.${extraFsName}`} dataKeys={[`stats.efs.${extraFsName}.w`, `stats.efs.${extraFsName}.r`]}
/> />
</ChartCard> </ChartCard>
</div> </div>
@@ -529,10 +461,10 @@ function ContainerFilterBar() {
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
$containerFilter.set(e.target.value) $containerFilter.set(e.target.value)
}, []) }, []) // Use an empty dependency array to prevent re-creation
return ( return (
<> <div className="relative py-1 block sm:w-44 sm:absolute sm:top-2.5 sm:right-3.5">
<Input <Input
placeholder="Filter..." placeholder="Filter..."
className="pl-4 pr-8" className="pl-4 pr-8"
@@ -551,33 +483,7 @@ function ContainerFilterBar() {
<XIcon className="h-4 w-4" /> <XIcon className="h-4 w-4" />
</Button> </Button>
)} )}
</> </div>
)
}
function SelectAvgMax({
store,
}: {
store: [boolean, React.Dispatch<React.SetStateAction<boolean>>]
}) {
const [max, setMax] = store
const Icon = max ? ChartMax : ChartAverage
return (
<Select value={max ? 'max' : 'avg'} onValueChange={(e) => setMax(e === 'max')}>
<SelectTrigger className="relative pl-10 pr-5">
<Icon className="h-4 w-4 absolute left-4 top-1/2 -translate-y-1/2 opacity-85" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem key="avg" value="avg">
Average
</SelectItem>
<SelectItem key="max" value="max">
Max 1 min
</SelectItem>
</SelectContent>
</Select>
) )
} }
@@ -586,13 +492,13 @@ function ChartCard({
description, description,
children, children,
grid, grid,
cornerEl, isContainerChart,
}: { }: {
title: string title: string
description: string description: string
children: React.ReactNode children: React.ReactNode
grid?: boolean grid?: boolean
cornerEl?: JSX.Element | null isContainerChart?: boolean
}) { }) {
const { isIntersecting, ref } = useIntersectionObserver() const { isIntersecting, ref } = useIntersectionObserver()
@@ -604,16 +510,12 @@ function ChartCard({
<CardHeader className="pb-5 pt-4 relative space-y-1 max-sm:py-3 max-sm:px-4"> <CardHeader className="pb-5 pt-4 relative space-y-1 max-sm:py-3 max-sm:px-4">
<CardTitle className="text-xl sm:text-2xl">{title}</CardTitle> <CardTitle className="text-xl sm:text-2xl">{title}</CardTitle>
<CardDescription>{description}</CardDescription> <CardDescription>{description}</CardDescription>
{cornerEl && ( {isContainerChart && <ContainerFilterBar />}
<div className="relative py-1 block sm:w-44 sm:absolute sm:top-2.5 sm:right-3.5">
{cornerEl}
</div>
)}
</CardHeader> </CardHeader>
<div className="pl-0 w-[calc(100%-1.6em)] h-52 relative"> <CardContent className="pl-0 w-[calc(100%-1.6em)] h-52 relative">
{<Spinner />} {<Spinner />}
{isIntersecting && <Suspense>{children}</Suspense>} {isIntersecting && <Suspense>{children}</Suspense>}
</div> </CardContent>
</Card> </Card>
) )
} }

View File

@@ -44,31 +44,30 @@ import {
import { SystemRecord } from '@/types' import { SystemRecord } from '@/types'
import { import {
MoreHorizontalIcon, MoreHorizontal,
ArrowUpDownIcon, ArrowUpDown,
MemoryStickIcon, Server,
Cpu,
MemoryStick,
HardDrive,
CopyIcon, CopyIcon,
PauseCircleIcon, PauseCircleIcon,
PlayCircleIcon, PlayCircleIcon,
Trash2Icon, Trash2Icon,
WifiIcon, WifiIcon,
HardDriveIcon,
ServerIcon,
CpuIcon,
} from 'lucide-react' } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { $hubVersion, $systems, pb } from '@/lib/stores' import { $hubVersion, $systems, pb } from '@/lib/stores'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { cn, copyToClipboard, decimalString, isReadOnlyUser } from '@/lib/utils' import { cn, copyToClipboard, isReadOnlyUser } from '@/lib/utils'
import AlertsButton from '../table-alerts' import AlertsButton from '../table-alerts'
import { navigate } from '../router' import { navigate } from '../router'
import { EthernetIcon } from '../ui/icons'
function CellFormatter(info: CellContext<SystemRecord, unknown>) { function CellFormatter(info: CellContext<SystemRecord, unknown>) {
const val = info.getValue() as number const val = info.getValue() as number
return ( return (
<div className="flex gap-1 items-center tabular-nums tracking-tight"> <div className="flex gap-1 items-center tabular-nums tracking-tight">
<span className="min-w-[3.5em]">{decimalString(val, 1)}%</span> <span className="min-w-[3.5em]">{val.toFixed(1)}%</span>
<span className="grow min-w-10 block bg-muted h-[1em] relative rounded-sm overflow-hidden"> <span className="grow min-w-10 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
<span <span
className={cn( className={cn(
@@ -96,7 +95,7 @@ function sortableHeader(
> >
<Icon className="mr-2 h-4 w-4" /> <Icon className="mr-2 h-4 w-4" />
{name} {name}
{!hideSortIcon && <ArrowUpDownIcon className="ml-2 h-4 w-4" />} {!hideSortIcon && <ArrowUpDown className="ml-2 h-4 w-4" />}
</Button> </Button>
) )
} }
@@ -113,7 +112,7 @@ export default function SystemsTable({ filter }: { filter?: string }) {
} }
}, [filter]) }, [filter])
const columns = useMemo(() => { const columns: ColumnDef<SystemRecord>[] = useMemo(() => {
return [ return [
{ {
// size: 200, // size: 200,
@@ -145,46 +144,26 @@ export default function SystemsTable({ filter }: { filter?: string }) {
</span> </span>
) )
}, },
header: ({ column }) => sortableHeader(column, 'System', ServerIcon), header: ({ column }) => sortableHeader(column, 'System', Server),
}, },
{ {
accessorKey: 'info.cpu', accessorKey: 'info.cpu',
invertSorting: true,
cell: CellFormatter, cell: CellFormatter,
header: ({ column }) => sortableHeader(column, 'CPU', CpuIcon), header: ({ column }) => sortableHeader(column, 'CPU', Cpu),
}, },
{ {
accessorKey: 'info.mp', accessorKey: 'info.mp',
invertSorting: true,
cell: CellFormatter, cell: CellFormatter,
header: ({ column }) => sortableHeader(column, 'Memory', MemoryStickIcon), header: ({ column }) => sortableHeader(column, 'Memory', MemoryStick),
}, },
{ {
accessorKey: 'info.dp', accessorKey: 'info.dp',
invertSorting: true,
cell: CellFormatter, cell: CellFormatter,
header: ({ column }) => sortableHeader(column, 'Disk', HardDriveIcon), header: ({ column }) => sortableHeader(column, 'Disk', HardDrive),
},
{
accessorFn: (originalRow) => originalRow.info.b || 0,
id: 'n',
invertSorting: true,
size: 115,
header: ({ column }) => sortableHeader(column, 'Net', EthernetIcon),
cell: (info) => {
const val = info.getValue() as number
return (
<span className="tabular-nums whitespace-nowrap pl-1">
{decimalString(val, val >= 100 ? 1 : 2)} MB/s
</span>
)
},
}, },
{ {
accessorKey: 'info.v', accessorKey: 'info.v',
invertSorting: true,
size: 50, size: 50,
header: ({ column }) => sortableHeader(column, 'Agent', WifiIcon, true),
cell: (info) => { cell: (info) => {
const version = info.getValue() as string const version = info.getValue() as string
if (!version || !hubVersion) { if (!version || !hubVersion) {
@@ -203,6 +182,7 @@ export default function SystemsTable({ filter }: { filter?: string }) {
</span> </span>
) )
}, },
header: ({ column }) => sortableHeader(column, 'Agent', WifiIcon, true),
}, },
{ {
id: 'actions', id: 'actions',
@@ -218,7 +198,7 @@ export default function SystemsTable({ filter }: { filter?: string }) {
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size={'icon'} data-nolink> <Button variant="ghost" size={'icon'} data-nolink>
<span className="sr-only">Open menu</span> <span className="sr-only">Open menu</span>
<MoreHorizontalIcon className="w-5" /> <MoreHorizontal className="w-5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
@@ -279,7 +259,7 @@ export default function SystemsTable({ filter }: { filter?: string }) {
) )
}, },
}, },
] as ColumnDef<SystemRecord>[] ]
}, [hubVersion]) }, [hubVersion])
const table = useReactTable({ const table = useReactTable({

View File

@@ -8,8 +8,8 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { BellIcon, ServerIcon } from 'lucide-react' import { BellIcon } from 'lucide-react'
import { alertInfo, cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { AlertRecord, SystemRecord } from '@/types' import { AlertRecord, SystemRecord } from '@/types'
@@ -28,22 +28,19 @@ const failedUpdateToast = () =>
export default function AlertsButton({ system }: { system: SystemRecord }) { export default function AlertsButton({ system }: { system: SystemRecord }) {
const alerts = useStore($alerts) const alerts = useStore($alerts)
const [opened, setOpened] = useState(false)
const systemAlerts = alerts.filter((alert) => alert.system === system.id) as AlertRecord[] const active = useMemo(() => {
return alerts.find((alert) => alert.system === system.id)
}, [alerts, system])
const active = systemAlerts.length > 0 const systemAlerts = useMemo(() => {
return alerts.filter((alert) => alert.system === system.id) as AlertRecord[]
}, [alerts, system])
return ( return (
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button <Button variant="ghost" size={'icon'} aria-label="Alerts" data-nolink>
variant="ghost"
size={'icon'}
aria-label="Alerts"
data-nolink
onClick={() => setOpened(true)}
>
<BellIcon <BellIcon
className={cn('h-[1.2em] w-[1.2em] pointer-events-none', { className={cn('h-[1.2em] w-[1.2em] pointer-events-none', {
'fill-foreground': active, 'fill-foreground': active,
@@ -51,42 +48,41 @@ export default function AlertsButton({ system }: { system: SystemRecord }) {
/> />
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent <DialogContent className="max-h-full overflow-auto">
className="max-h-full overflow-auto max-w-[35rem]" <DialogHeader>
// onCloseAutoFocus={() => setOpened(false)} <DialogTitle className="text-xl">{system.name} alerts</DialogTitle>
> <DialogDescription className="mb-1">
{opened && ( See{' '}
<> <Link href="/settings/notifications" className="link">
<DialogHeader> notification settings
<DialogTitle className="text-xl">{system.name} alerts</DialogTitle> </Link>{' '}
<DialogDescription className="mb-1"> to configure how you receive alerts.
See{' '} </DialogDescription>
<Link href="/settings/notifications" className="link"> </DialogHeader>
notification settings <div className="grid gap-3">
</Link>{' '} <AlertStatus system={system} alerts={systemAlerts} />
to configure how you receive alerts. <AlertWithSlider
</DialogDescription> system={system}
</DialogHeader> alerts={systemAlerts}
<div className="grid gap-3"> name="CPU"
<AlertStatus system={system} alerts={systemAlerts} /> title="CPU Usage"
{Object.keys(alertInfo).map((key) => { description="Triggers when CPU usage exceeds a threshold."
const alert = alertInfo[key as keyof typeof alertInfo] />
return ( <AlertWithSlider
<AlertWithSlider system={system}
key={key} alerts={systemAlerts}
system={system} name="Memory"
alerts={systemAlerts} title="Memory Usage"
name={key} description="Triggers when memory usage exceeds a threshold."
title={alert.name} />
description={alert.desc} <AlertWithSlider
unit={alert.unit} system={system}
Icon={alert.icon} alerts={systemAlerts}
/> name="Disk"
) title="Disk Usage"
})} description="Triggers when root usage exceeds a threshold."
</div> />
</> </div>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )
@@ -95,18 +91,18 @@ export default function AlertsButton({ system }: { system: SystemRecord }) {
function AlertStatus({ system, alerts }: { system: SystemRecord; alerts: AlertRecord[] }) { function AlertStatus({ system, alerts }: { system: SystemRecord; alerts: AlertRecord[] }) {
const [pendingChange, setPendingChange] = useState(false) const [pendingChange, setPendingChange] = useState(false)
const alert = alerts.find((alert) => alert.name === 'Status') const alert = useMemo(() => {
return alerts.find((alert) => alert.name === 'Status')
}, [alerts])
return ( return (
<label <label
htmlFor="alert-status" htmlFor="alert-status"
className="flex flex-row items-center justify-between gap-4 rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 p-4 cursor-pointer" className="flex flex-row items-center justify-between gap-4 rounded-lg border p-4 cursor-pointer"
> >
<div className="grid gap-1 select-none"> <div className="grid gap-1 select-none">
<p className="font-semibold flex gap-3 items-center"> <p className="font-semibold">System Status</p>
<ServerIcon className="h-4 w-4 opacity-85" /> System status <span className="block text-sm text-foreground opacity-80">
</p>
<span className="block text-sm text-muted-foreground">
Triggers when status switches between up and down. Triggers when status switches between up and down.
</span> </span>
</div> </div>
@@ -147,50 +143,38 @@ function AlertWithSlider({
name, name,
title, title,
description, description,
unit = '%',
max = 99,
Icon,
}: { }: {
system: SystemRecord system: SystemRecord
alerts: AlertRecord[] alerts: AlertRecord[]
name: string name: string
title: string title: string
description: string description: string
unit?: string
max?: number
Icon: React.FC<React.SVGProps<SVGSVGElement>>
}) { }) {
const [pendingChange, setPendingChange] = useState(false) const [pendingChange, setPendingChange] = useState(false)
const [liveValue, setLiveValue] = useState(80) const [liveValue, setLiveValue] = useState(50)
const [liveMinutes, setLiveMinutes] = useState(10)
const key = name.replaceAll(' ', '-')
const alert = useMemo(() => { const alert = useMemo(() => {
const alert = alerts.find((alert) => alert.name === name) const alert = alerts.find((alert) => alert.name === name)
if (alert) { if (alert) {
setLiveValue(alert.value) setLiveValue(alert.value)
setLiveMinutes(alert.min || 1)
} }
return alert return alert
}, [alerts]) }, [alerts])
return ( return (
<div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group"> <div className="rounded-lg border">
<label <label
htmlFor={`s${key}`} htmlFor={`alert-${name}`}
className={cn('flex flex-row items-center justify-between gap-4 cursor-pointer p-4', { className={cn('flex flex-row items-center justify-between gap-4 cursor-pointer p-4', {
'pb-0': !!alert, 'pb-0': !!alert,
})} })}
> >
<div className="grid gap-1 select-none"> <div className="grid gap-1 select-none">
<p className="font-semibold flex gap-3 items-center"> <p className="font-semibold">{title}</p>
<Icon className="h-4 w-4 opacity-85" /> {title} <span className="block text-sm text-foreground opacity-80">{description}</span>
</p>
{!alert && <span className="block text-sm text-muted-foreground">{description}</span>}
</div> </div>
<Switch <Switch
id={`s${key}`} id={`alert-${name}`}
className={cn('transition-opacity', pendingChange && 'opacity-40')} className={cn('transition-opacity', pendingChange && 'opacity-40')}
checked={!!alert} checked={!!alert}
value={!!alert ? 'on' : 'off'} value={!!alert ? 'on' : 'off'}
@@ -208,7 +192,6 @@ function AlertWithSlider({
user: pb.authStore.model!.id, user: pb.authStore.model!.id,
name, name,
value: liveValue, value: liveValue,
min: liveMinutes,
}) })
} }
} catch (e) { } catch (e) {
@@ -220,52 +203,24 @@ function AlertWithSlider({
/> />
</label> </label>
{alert && ( {alert && (
<div className="grid sm:grid-cols-2 mt-1.5 gap-5 px-4 pb-5 tabular-nums text-muted-foreground"> <div className="flex mt-2 mb-3 gap-3 px-4">
<Suspense fallback={<div className="h-10" />}> <Suspense>
<div> <Slider
<p id={`v${key}`} className="text-sm block h-8"> defaultValue={[liveValue]}
Average exceeds{' '} onValueCommit={(val) => {
<strong className="text-foreground"> pb.collection('alerts').update(alert.id, {
{liveValue} value: val[0],
{unit} })
</strong> }}
</p> onValueChange={(val) => {
<div className="flex gap-3"> setLiveValue(val[0])
<Slider }}
aria-labelledby={`v${key}`} min={10}
defaultValue={[liveValue]} max={99}
onValueCommit={(val) => { // step={1}
pb.collection('alerts').update(alert.id, { />
value: val[0],
})
}}
onValueChange={(val) => setLiveValue(val[0])}
min={1}
max={max}
/>
</div>
</div>
<div>
<p id={`t${key}`} className="text-sm block h-8">
For <strong className="text-foreground">{liveMinutes}</strong> minute
{liveMinutes > 1 && 's'}
</p>
<div className="flex gap-3">
<Slider
aria-labelledby={`v${key}`}
defaultValue={[liveMinutes]}
onValueCommit={(val) => {
pb.collection('alerts').update(alert.id, {
min: val[0],
})
}}
onValueChange={(val) => setLiveMinutes(val[0])}
min={1}
max={60}
/>
</div>
</div>
</Suspense> </Suspense>
<span className="tabular-nums tracking-tighter text-[.92em]">{liveValue}%</span>
</div> </div>
)} )}
</div> </div>

View File

@@ -1,59 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -16,77 +16,77 @@ export type ChartConfig = {
) )
} }
// type ChartContextProps = { type ChartContextProps = {
// config: ChartConfig config: ChartConfig
// } }
// const ChartContext = React.createContext<ChartContextProps | null>(null) const ChartContext = React.createContext<ChartContextProps | null>(null)
// function useChart() { function useChart() {
// const context = React.useContext(ChartContext) const context = React.useContext(ChartContext)
// if (!context) { if (!context) {
// throw new Error('useChart must be used within a <ChartContainer />') throw new Error('useChart must be used within a <ChartContainer />')
// } }
// return context return context
// } }
const ChartContainer = React.forwardRef< const ChartContainer = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<'div'> & { React.ComponentProps<'div'> & {
// config: ChartConfig config: ChartConfig
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'] children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children']
} }
>(({ id, className, children, ...props }, ref) => { >(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId() const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}` const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`
return ( return (
//<ChartContext.Provider value={{ config }}> <ChartContext.Provider value={{ config }}>
<div <div
data-chart={chartId} data-chart={chartId}
ref={ref} ref={ref}
className={cn( className={cn(
"text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line-line]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none", "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line-line]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className className
)} )}
{...props} {...props}
> >
{/* <ChartStyle id={chartId} config={config} /> */} <ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer> <RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div> </div>
//</ChartContext.Provider> </ChartContext.Provider>
) )
}) })
ChartContainer.displayName = 'Chart' ChartContainer.displayName = 'Chart'
// const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
// const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color) const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color)
// if (!colorConfig.length) { if (!colorConfig.length) {
// return null return null
// } }
// return ( return (
// <style <style
// dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
// __html: Object.entries(THEMES).map( __html: Object.entries(THEMES).map(
// ([theme, prefix]) => ` ([theme, prefix]) => `
// ${prefix} [data-chart=${id}] { ${prefix} [data-chart=${id}] {
// ${colorConfig ${colorConfig
// .map(([key, itemConfig]) => { .map(([key, itemConfig]) => {
// const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color
// return color ? ` --color-${key}: ${color};` : null return color ? ` --color-${key}: ${color};` : null
// }) })
// .join('\n')} .join('\n')}
// } }
// ` `
// ), ),
// }} }}
// /> />
// ) )
// } }
const ChartTooltip = RechartsPrimitive.Tooltip const ChartTooltip = RechartsPrimitive.Tooltip
@@ -95,6 +95,7 @@ const ChartTooltipContent = React.forwardRef<
React.ComponentProps<typeof RechartsPrimitive.Tooltip> & React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<'div'> & { React.ComponentProps<'div'> & {
hideLabel?: boolean hideLabel?: boolean
hideIndicator?: boolean
indicator?: 'line' | 'dot' | 'dashed' indicator?: 'line' | 'dot' | 'dashed'
nameKey?: string nameKey?: string
labelKey?: string labelKey?: string
@@ -108,8 +109,9 @@ const ChartTooltipContent = React.forwardRef<
active, active,
payload, payload,
className, className,
indicator = 'line', indicator = 'dot',
hideLabel = false, hideLabel = false,
hideIndicator = false,
label, label,
labelFormatter, labelFormatter,
labelClassName, labelClassName,
@@ -124,8 +126,7 @@ const ChartTooltipContent = React.forwardRef<
}, },
ref ref
) => { ) => {
// const { config } = useChart() const { config } = useChart()
const config = {}
React.useMemo(() => { React.useMemo(() => {
if (filter) { if (filter) {
@@ -143,9 +144,12 @@ const ChartTooltipContent = React.forwardRef<
} }
const [item] = payload const [item] = payload
const key = `${labelKey || item.name || 'value'}` const key = `${labelKey || item.dataKey || item.name || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key) const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value = !labelKey && typeof label === 'string' ? label : itemConfig?.label const value =
!labelKey && typeof label === 'string'
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) { if (labelFormatter) {
return ( return (
@@ -164,8 +168,7 @@ const ChartTooltipContent = React.forwardRef<
return null return null
} }
// const nestLabel = payload.length === 1 && indicator !== 'dot' const nestLabel = payload.length === 1 && indicator !== 'dot'
const nestLabel = false
return ( return (
<div <div
@@ -197,24 +200,26 @@ const ChartTooltipContent = React.forwardRef<
{itemConfig?.icon ? ( {itemConfig?.icon ? (
<itemConfig.icon /> <itemConfig.icon />
) : ( ) : (
<div !hideIndicator && (
className={cn( <div
'shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]', className={cn(
{ 'shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]',
'h-2.5 w-2.5': indicator === 'dot', {
'w-1': indicator === 'line', 'h-2.5 w-2.5': indicator === 'dot',
'w-0 border-[1.5px] border-dashed bg-transparent': 'w-1': indicator === 'line',
indicator === 'dashed', 'w-0 border-[1.5px] border-dashed bg-transparent':
'my-0.5': nestLabel && indicator === 'dashed', indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
}
)}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
} }
)} />
style={ )
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
/>
)} )}
<div <div
className={cn( className={cn(
@@ -257,7 +262,7 @@ const ChartLegendContent = React.forwardRef<
hideIcon?: boolean hideIcon?: boolean
nameKey?: string nameKey?: string
} }
>(({ className, payload, verticalAlign = 'bottom' }, ref) => { >(({ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey }, ref) => {
// const { config } = useChart() // const { config } = useChart()
if (!payload?.length) { if (!payload?.length) {
@@ -337,5 +342,5 @@ export {
ChartTooltipContent, ChartTooltipContent,
ChartLegend, ChartLegend,
ChartLegendContent, ChartLegendContent,
// ChartStyle, ChartStyle,
} }

View File

@@ -23,50 +23,3 @@ export function Rows(props: SVGProps<SVGSVGElement>) {
</svg> </svg>
) )
} }
// IconPark Apache License 2.0 https://github.com/bytedance/IconPark
export function ChartAverage(props: SVGProps<SVGSVGElement>) {
return (
<svg fill="none" viewBox="0 0 48 48" stroke="currentColor" {...props}>
<path strokeWidth="3" d="M4 4v40h40" />
<path strokeWidth="3" d="M10 38S15.3 4 27 4s17 34 17 34" />
<path strokeWidth="4" d="M10 24h34" />
</svg>
)
}
// IconPark Apache License 2.0 https://github.com/bytedance/IconPark
export function ChartMax(props: SVGProps<SVGSVGElement>) {
return (
<svg fill="none" viewBox="0 0 48 48" stroke="currentColor" {...props}>
<path strokeWidth="3" d="M4 4v40h40" />
<path strokeWidth="3" d="M10 38S15.3 4 27 4s17 34 17 34" />
<path strokeWidth="4" d="M10 4h34" />
</svg>
)
}
// Lucide https://github.com/lucide-icons/lucide (not in package for some reason)
export function EthernetIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
viewBox="0 0 24 24"
{...props}
>
<path d="m15 20 3-3h2a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2l3 3zM6 8v1m4-1v1m4-1v1m4-1v1" />
</svg>
)
}
// Phosphor MIT https://github.com/phosphor-icons/core
export function ThermometerIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 256 256" {...props} fill="currentColor">
<path d="M212 56a28 28 0 1 0 28 28 28 28 0 0 0-28-28m0 40a12 12 0 1 1 12-12 12 12 0 0 1-12 12m-60 50V40a32 32 0 0 0-64 0v106a56 56 0 1 0 64 0m-16-42h-32V40a16 16 0 0 1 32 0Z" />
</svg>
)
}

View File

@@ -7,13 +7,10 @@ import { RecordModel, RecordSubscription } from 'pocketbase'
import { WritableAtom } from 'nanostores' import { WritableAtom } from 'nanostores'
import { timeDay, timeHour } from 'd3-time' import { timeDay, timeHour } from 'd3-time'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { CpuIcon, HardDriveIcon, MemoryStickIcon } from 'lucide-react'
import { EthernetIcon, ThermometerIcon } from '@/components/ui/icons'
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
// export const cn = clsx
export async function copyToClipboard(content: string) { export async function copyToClipboard(content: string) {
const duration = 1500 const duration = 1500
@@ -54,7 +51,7 @@ export const updateSystemList = async () => {
export const updateAlerts = () => { export const updateAlerts = () => {
pb.collection('alerts') pb.collection('alerts')
.getFullList<AlertRecord>({ fields: 'id,name,system,value,min,triggered', sort: 'updated' }) .getFullList<AlertRecord>({ fields: 'id,name,system,value' })
.then((records) => { .then((records) => {
$alerts.set(records) $alerts.set(records)
}) })
@@ -139,8 +136,8 @@ export function updateRecordList<T extends RecordModel>(
$store.set(newRecords) $store.set(newRecords)
} }
export function getPbTimestamp(timeString: ChartTimes, d?: Date) { export function getPbTimestamp(timeString: ChartTimes) {
d ||= chartTimeData[timeString].getOffset(new Date()) const d = chartTimeData[timeString].getOffset(new Date())
const year = d.getUTCFullYear() const year = d.getUTCFullYear()
const month = String(d.getUTCMonth() + 1).padStart(2, '0') const month = String(d.getUTCMonth() + 1).padStart(2, '0')
const day = String(d.getUTCDate()).padStart(2, '0') const day = String(d.getUTCDate()).padStart(2, '0')
@@ -207,10 +204,7 @@ export function useYAxisWidth() {
clearTimeout(timeout) clearTimeout(timeout)
timeout = setTimeout(() => { timeout = setTimeout(() => {
document.body.appendChild(div) document.body.appendChild(div)
const width = div.offsetWidth + 24 setYAxisWidth(div.offsetWidth + 24)
if (width > yAxisWidth) {
setYAxisWidth(div.offsetWidth + 24)
}
document.body.removeChild(div) document.body.removeChild(div)
}) })
} }
@@ -227,18 +221,17 @@ export function toFixedFloat(num: number, digits: number) {
return parseFloat(num.toFixed(digits)) return parseFloat(num.toFixed(digits))
} }
let decimalFormatters: Map<number, Intl.NumberFormat> = new Map() let twoDecimalFormatter: Intl.NumberFormat
/** Format number to x decimal places */ /** Format number to two decimal places */
export function decimalString(num: number, digits = 2) { export function twoDecimalString(num: number) {
let formatter = decimalFormatters.get(digits) if (!twoDecimalFormatter) {
if (!formatter) { twoDecimalFormatter = new Intl.NumberFormat(undefined, {
formatter = new Intl.NumberFormat(undefined, { minimumFractionDigits: 2,
minimumFractionDigits: digits, maximumFractionDigits: 2,
maximumFractionDigits: digits,
}) })
decimalFormatters.set(digits, formatter)
} }
return formatter.format(num) // Return a function that formats numbers using the saved formatter
return twoDecimalFormatter.format(num)
} }
/** Get value from local storage */ /** Get value from local storage */
@@ -280,53 +273,15 @@ export async function updateUserSettings() {
} }
/** /**
* Get the value and unit of size (TB, GB, or MB) for a given size * Get the unit of size (TB or GB) for a given size in gigabytes
* @param n size in gigabytes or megabytes * @param n size in gigabytes
* @param isGigabytes boolean indicating if n represents gigabytes (true) or megabytes (false) * @returns unit of size (TB or GB)
* @returns an object containing the value and unit of size
*/ */
export const getSizeAndUnit = (n: number, isGigabytes = true) => { export const getSizeUnit = (n: number) => (n >= 1_000 ? ' TB' : ' GB')
const sizeInGB = isGigabytes ? n : n / 1_000
if (sizeInGB >= 1_000) { /**
return { v: sizeInGB / 1_000, u: ' TB' } * Get the value of number in gigabytes if less than 1000, otherwise in terabytes
} else if (sizeInGB >= 1) { * @param n size in gigabytes
return { v: sizeInGB, u: ' GB' } * @returns value in GB if less than 1000, otherwise value in TB
} */
return { v: n, u: ' MB' } export const getSizeVal = (n: number) => (n >= 1_000 ? n / 1_000 : n)
}
export const chartMargin = { top: 12 }
export const alertInfo = {
CPU: {
name: 'CPU usage',
unit: '%',
icon: CpuIcon,
desc: 'Triggers when CPU usage exceeds a threshold.',
},
Memory: {
name: 'Memory usage',
unit: '%',
icon: MemoryStickIcon,
desc: 'Triggers when memory usage exceeds a threshold.',
},
Disk: {
name: 'Disk usage',
unit: '%',
icon: HardDriveIcon,
desc: 'Triggers when usage of any disk exceeds a threshold.',
},
Bandwidth: {
name: 'Bandwidth',
unit: ' MB/s',
icon: EthernetIcon,
desc: 'Triggers when combined up/down exceeds a threshold.',
},
Temperature: {
name: 'Temperature',
unit: '°C',
icon: ThermometerIcon,
desc: 'Triggers when any sensor exceeds a threshold.',
},
}

View File

@@ -70,9 +70,9 @@ const App = () => {
$hubVersion.set(data.v) $hubVersion.set(data.v)
}) })
// get servers / alerts / settings // get servers / alerts / settings
updateSystemList()
updateAlerts()
updateUserSettings() updateUserSettings()
// get alerts after system list is loaded
updateSystemList().then(updateAlerts)
}, []) }, [])
// update favicon // update favicon

View File

@@ -30,17 +30,11 @@ export interface SystemInfo {
mp: number mp: number
/** disk percent */ /** disk percent */
dp: number dp: number
/** bandwidth (mb) */
b: number
/** agent version */
v: string
} }
export interface SystemStats { export interface SystemStats {
/** cpu percent */ /** cpu percent */
cpu: number cpu: number
/** peak cpu */
cpum?: number
/** total memory (gb) */ /** total memory (gb) */
m: number m: number
/** memory used (gb) */ /** memory used (gb) */
@@ -65,18 +59,10 @@ export interface SystemStats {
dr: number dr: number
/** disk write (mb) */ /** disk write (mb) */
dw: number dw: number
/** max disk read (mb) */
drm?: number
/** max disk write (mb) */
dwm?: number
/** network sent (mb) */ /** network sent (mb) */
ns: number ns: number
/** network received (mb) */ /** network received (mb) */
nr: number nr: number
/** max network sent (mb) */
nsm?: number
/** max network received (mb) */
nrm?: number
/** temperatures */ /** temperatures */
t?: Record<string, number> t?: Record<string, number>
/** extra filesystems */ /** extra filesystems */
@@ -92,10 +78,6 @@ export interface ExtraFsStats {
r: number r: number
/** total write (mb) */ /** total write (mb) */
w: number w: number
/** max read (mb) */
rm: number
/** max write (mb) */
wm: number
} }
export interface ContainerStatsRecord extends RecordModel { export interface ContainerStatsRecord extends RecordModel {
@@ -127,8 +109,6 @@ export interface AlertRecord extends RecordModel {
id: string id: string
system: string system: string
name: string name: string
triggered: boolean
sysname?: string
// user: string // user: string
} }
@@ -151,17 +131,3 @@ export type UserSettings = {
emails?: string[] emails?: string[]
webhooks?: string[] webhooks?: string[]
} }
type ChartDataContainer = {
created: number | null
} & {
[key: string]: key extends 'created' ? never : ContainerStats
}
export interface ChartData {
systemStats: SystemStatsRecord[]
containerData: ChartDataContainer[]
ticks: number[]
domain: number[]
chartTime: ChartTimes
}

View File

@@ -1,6 +1,6 @@
package beszel package beszel
const ( const (
Version = "0.6.0" Version = "0.5.2"
AppName = "beszel" AppName = "beszel"
) )

View File

@@ -12,7 +12,7 @@ A lightweight server resource monitoring hub with historical data, docker stats,
- **Lightweight**: Smaller and less resource-intensive than leading solutions. - **Lightweight**: Smaller and less resource-intensive than leading solutions.
- **Simple**: Easy setup, no need for public internet exposure. - **Simple**: Easy setup, no need for public internet exposure.
- **Docker stats**: Tracks CPU, memory, and network usage history for each container. - **Docker stats**: Tracks CPU, memory, and network usage history for each container.
- **Alerts**: Configurable alerts for CPU, memory, disk, bandwidth, temperature, and system status. - **Alerts**: Configurable alerts for CPU, memory, disk usage, and system status.
- **Multi-user**: Each user manages their own systems. Admins can share systems across users. - **Multi-user**: Each user manages their own systems. Admins can share systems across users.
- **OAuth / OIDC**: Supports multiple OAuth2 providers. Password authentication can be disabled. - **OAuth / OIDC**: Supports multiple OAuth2 providers. Password authentication can be disabled.
- **Automatic backups**: Save and restore data from disk or S3-compatible storage. - **Automatic backups**: Save and restore data from disk or S3-compatible storage.