Compare commits

..

24 Commits

Author SHA1 Message Date
Henry Dollman
ee92e338cb update debug log locations 2024-10-16 18:12:43 -04:00
Henry Dollman
1a3ad04e03 release 0.6.0 2024-10-16 18:02:53 -04:00
Henry Dollman
9c061774a3 update alert notification titles 2024-10-16 18:02:38 -04:00
Henry Dollman
3336b0a7d9 fix chart null values connecting after downtime 2024-10-16 17:46:42 -04:00
Henry Dollman
f034eed431 update js packages 2024-10-16 17:39:56 -04:00
Henry Dollman
6b6d3fabc0 change disk alert to monitor usage of any disk, not only root 2024-10-16 17:21:05 -04:00
Henry Dollman
59d541dd1d fix edge case overwriting extra filesystem with root io fallback 2024-10-16 15:26:12 -04:00
Henry Dollman
abff85d61e alerts web ui refactoring 2024-10-16 13:48:36 -04:00
Henry Dollman
02641ec007 time averaged thresholds for alerts 2024-10-15 21:59:53 -04:00
Henry Dollman
92179cbbb2 show active alerts in dashboard 2024-10-15 21:59:05 -04:00
Henry Dollman
299152413a small longer record creation refactoring 2024-10-15 19:54:46 -04:00
Henry Dollman
703a3c41c9 empty info for systems that are paused 2024-10-15 18:31:03 -04:00
Henry Dollman
31d1153916 invert sorting in systems table 2024-10-15 18:20:38 -04:00
Henry Dollman
c1577d3ba5 fix bottom spacing 2024-10-14 19:03:02 -04:00
Henry Dollman
c4400eb0a3 refactor container-chart.tsx 2024-10-14 18:48:19 -04:00
Henry Dollman
a57498f8f7 update alerts dialog and icon imports 2024-10-14 17:53:49 -04:00
Henry Dollman
1b0dffc1ab combine docker charts and chart data 2024-10-14 17:25:21 -04:00
Henry Dollman
bea37d62b4 simplify container chart data and reduce rerenders 2024-10-14 11:48:33 -04:00
Henry Dollman
d53b6be5b9 update collections 2024-10-12 18:28:32 -04:00
Henry Dollman
6c31263e60 add bandwidth alerts 2024-10-12 17:22:25 -04:00
Henry Dollman
b464fa5b3f add net column to systems table 2024-10-12 16:37:09 -04:00
Henry Dollman
c0a3bbeefc change twoDecimalString to use customizable digits 2024-10-12 16:03:51 -04:00
Henry Dollman
10d348c052 temperature alerts 2024-10-12 14:57:46 -04:00
Henry Dollman
6cf6661f2e raise docker client timeout to 8 seconds if version <= 24 2024-10-12 12:24:53 -04:00
36 changed files with 6478 additions and 5961 deletions

View File

@@ -83,9 +83,11 @@ func (a *Agent) gatherStats() 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)
} }
@@ -96,5 +98,6 @@ 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

@@ -44,7 +44,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) key = findFallbackIoDevice(filesystem, diskIoCounters, a.fsStats)
slog.Info("Using I/O fallback", "name", key) slog.Info("Using I/O fallback", "name", key)
} }
} }
@@ -122,7 +122,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) rootDevice := findFallbackIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats)
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 +132,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) string { func findFallbackIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat, fsStats map[string]*system.FsStats) string {
var maxReadBytes uint64 var maxReadBytes uint64
maxReadDevice := "/" maxReadDevice := "/"
for _, d := range diskIoCounters { for _, d := range diskIoCounters {
@@ -140,8 +140,11 @@ func findFallbackIoDevice(filesystem string, diskIoCounters map[string]disk.IOCo
return d.Name return d.Name
} }
if d.ReadBytes > maxReadBytes { if d.ReadBytes > maxReadBytes {
maxReadBytes = d.ReadBytes // don't use if device already exists in fsStats
maxReadDevice = d.Name if _, exists := fsStats[d.Name]; !exists {
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.Millisecond * 2100, Timeout: time.Second * 8,
Transport: transport, Transport: transport,
}, },
containerStatsMap: make(map[string]*container.Stats), containerStatsMap: make(map[string]*container.Stats),
@@ -243,9 +243,10 @@ func newDockerManager() *dockerManager {
return dockerClient return dockerClient
} }
// if version > 25, one-shot works correctly and we can limit concurrent connections / goroutines to 5 // if version > 24, one-shot works correctly and we can limit concurrent operations
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

@@ -25,7 +25,6 @@ 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

@@ -53,7 +53,6 @@ func (a *Agent) getSystemStats() system.Stats {
systemStats := system.Stats{} systemStats := system.Stats{}
// cpu percent // cpu percent
slog.Debug("Getting cpu percent")
cpuPct, err := cpu.Percent(0, false) cpuPct, err := cpu.Percent(0, false)
if err != nil { if err != nil {
slog.Error("Error getting cpu percent", "err", err) slog.Error("Error getting cpu percent", "err", err)
@@ -62,7 +61,6 @@ func (a *Agent) getSystemStats() system.Stats {
} }
// memory // memory
slog.Debug("Getting memory stats")
if v, err := mem.VirtualMemory(); err == nil { if v, err := mem.VirtualMemory(); err == nil {
// swap // swap
systemStats.Swap = bytesToGigabytes(v.SwapTotal) systemStats.Swap = bytesToGigabytes(v.SwapTotal)
@@ -91,7 +89,6 @@ func (a *Agent) getSystemStats() system.Stats {
} }
// disk usage // disk usage
slog.Debug("Getting disk stats")
for _, stats := range a.fsStats { for _, stats := range a.fsStats {
if d, err := disk.Usage(stats.Mountpoint); err == nil { if d, err := disk.Usage(stats.Mountpoint); err == nil {
stats.DiskTotal = bytesToGigabytes(d.Total) stats.DiskTotal = bytesToGigabytes(d.Total)
@@ -112,7 +109,6 @@ func (a *Agent) getSystemStats() system.Stats {
} }
// disk i/o // disk i/o
slog.Debug("Getting disk I/O stats")
if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil { if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil {
for _, d := range ioCounters { for _, d := range ioCounters {
stats := a.fsStats[d.Name] stats := a.fsStats[d.Name]
@@ -136,7 +132,6 @@ func (a *Agent) getSystemStats() system.Stats {
} }
// network stats // network stats
slog.Debug("Getting network stats")
if netIO, err := psutilNet.IOCounters(true); err == nil { if netIO, err := psutilNet.IOCounters(true); err == nil {
secondsElapsed := time.Since(a.netIoStats.Time).Seconds() secondsElapsed := time.Since(a.netIoStats.Time).Seconds()
a.netIoStats.Time = time.Now() a.netIoStats.Time = time.Now()
@@ -177,7 +172,6 @@ func (a *Agent) getSystemStats() system.Stats {
} }
// temperatures // temperatures
slog.Debug("Getting temperatures")
temps, err := sensors.TemperaturesWithContext(a.sensorsContext) temps, err := sensors.TemperaturesWithContext(a.sensorsContext)
if err != nil && a.debug { if err != nil && a.debug {
err.(*sensors.Warnings).Verbose = true err.(*sensors.Warnings).Verbose = true
@@ -214,6 +208,7 @@ 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,21 +6,26 @@ 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 AlertData struct { type AlertMessageData struct {
UserID string UserID string
Title string Title string
Message string Message string
@@ -33,68 +38,304 @@ 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) HandleSystemInfoAlerts(systemRecord *models.Record, systemInfo system.Info) { func (am *AlertManager) HandleSystemAlerts(systemRecord *models.Record, systemInfo system.Info, temperatures map[string]float64, extraFs map[string]*system.FsStats) error {
// 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.GetId()}), dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
) )
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 return nil
} }
// 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", "Memory", "Disk": case "CPU":
if name == "CPU" { val = systemInfo.Cpu
am.handleSlidingValueAlert(systemRecord, alertRecord, name, systemInfo.Cpu) case "Memory":
} else if name == "Memory" { val = systemInfo.MemPct
am.handleSlidingValueAlert(systemRecord, alertRecord, name, systemInfo.MemPct) case "Bandwidth":
} else if name == "Disk" { val = systemInfo.Bandwidth
am.handleSlidingValueAlert(systemRecord, alertRecord, name, systemInfo.DiskPct) unit = " MB/s"
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) handleSlidingValueAlert(systemRecord *models.Record, alertRecord *models.Record, name string, curValue float64) { func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
triggered := alertRecord.GetBool("triggered") // log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold)
threshold := alertRecord.GetFloat("value") systemName := alert.systemRecord.GetString("name")
// fmt.Println(name, curValue, "threshold", threshold, "triggered", triggered)
var subject string // change Disk to Disk usage
var body string if alert.name == "Disk" {
var systemName string alert.name += " usage"
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 {
// fmt.Println(name, "not triggered")
return
} }
if err := am.app.Dao().SaveRecord(alertRecord); err != nil {
// make title alert name lowercase if not CPU
titleAlertName := alert.name
if titleAlertName != "CPU" {
titleAlertName = strings.ToLower(titleAlertName)
}
var subject string
if alert.triggered {
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
} else {
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
}
minutesLabel := "minute"
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(alertRecord, []string{"user"}, nil); len(errs) > 0 { if errs := am.app.Dao().ExpandRecord(alert.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 := alertRecord.ExpandedOne("user"); user != nil { if user := alert.alertRecord.ExpandedOne("user"); user != nil {
am.sendAlert(AlertData{ am.sendAlert(AlertMessageData{
UserID: user.GetId(), UserID: user.GetId(),
Title: subject, Title: subject,
Message: body, Message: body,
@@ -145,7 +386,7 @@ func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *mo
} }
// send alert // send alert
systemName := oldSystemRecord.GetString("name") systemName := oldSystemRecord.GetString("name")
am.sendAlert(AlertData{ am.sendAlert(AlertMessageData{
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),
@@ -156,7 +397,7 @@ func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *mo
return nil return nil
} }
func (am *AlertManager) sendAlert(data AlertData) { func (am *AlertManager) sendAlert(data AlertMessageData) {
// 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

@@ -61,6 +61,7 @@ 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

@@ -174,6 +174,14 @@ 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)
@@ -306,8 +314,10 @@ 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: temp alerts, extra fs alerts) // system info alerts (todo: extra fs alerts)
h.am.HandleSystemInfoAlerts(record, systemData.Info) if err := h.am.HandleSystemAlerts(record, systemData.Info, systemData.Stats.Temperatures, systemData.Stats.ExtraFs); err != nil {
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

View File

@@ -8,6 +8,7 @@ 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"
@@ -31,6 +32,10 @@ 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}
} }
@@ -73,6 +78,7 @@ 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)
@@ -104,16 +110,31 @@ func (rm *RecordManager) CreateLongerRecords() {
} }
} }
// get shorter records from the past x minutes // get shorter records from the past x minutes
allShorterRecords, err := txDao.FindRecordsByExpr( var stats RecordStats
collection.Id,
dbx.NewExp( // allShorterRecords, err := txDao.FindRecordsByExpr(
"type = {:type} AND system = {:system} AND created > {:created}", // collection,
dbx.Params{"type": recordData.shorterType, "system": system.Id, "created": shorterRecordPeriod}, // dbx.NewExp(
), // "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(allShorterRecords) < recordData.minShorterRecords { if err != nil || len(stats) < 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
} }
@@ -123,9 +144,9 @@ func (rm *RecordManager) CreateLongerRecords() {
longerRecord.Set("type", recordData.longerType) longerRecord.Set("type", recordData.longerType)
switch collection.Name { switch collection.Name {
case "system_stats": case "system_stats":
longerRecord.Set("stats", rm.AverageSystemStats(allShorterRecords)) longerRecord.Set("stats", rm.AverageSystemStats(stats))
case "container_stats": case "container_stats":
longerRecord.Set("stats", rm.AverageContainerStats(allShorterRecords)) longerRecord.Set("stats", rm.AverageContainerStats(stats))
} }
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())
@@ -141,7 +162,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 []*models.Record) system.Stats { func (rm *RecordManager) AverageSystemStats(records RecordStats) 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),
@@ -153,7 +174,7 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
var stats system.Stats var stats system.Stats
for _, record := range records { for _, record := range records {
record.UnmarshalJSONField("stats", &stats) json.Unmarshal(record.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
@@ -226,14 +247,14 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
} }
if len(sum.Temperatures) != 0 { if len(sum.Temperatures) != 0 {
stats.Temperatures = make(map[string]float64) stats.Temperatures = make(map[string]float64, len(sum.Temperatures))
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) stats.ExtraFs = make(map[string]*system.FsStats, len(sum.ExtraFs))
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),
@@ -250,13 +271,17 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
} }
// 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 []*models.Record) []container.Stats { func (rm *RecordManager) AverageContainerStats(records RecordStats) []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 {
record.UnmarshalJSONField("stats", &containerStats) // Reset the slice length to 0, but keep the capacity
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

@@ -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-07-28 17:14:24.492Z", "updated": "2024-10-12 18:55:51.623Z",
"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-07-28 17:14:24.492Z", "updated": "2024-10-12 18:55:51.623Z",
"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-07-28 17:14:24.492Z", "updated": "2024-10-12 18:55:51.623Z",
"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-09-12 23:19:36.280Z", "updated": "2024-10-12 22:27:19.081Z",
"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-07-28 17:14:24.492Z", "updated": "2024-10-12 22:27:29.128Z",
"name": "alerts", "name": "alerts",
"type": "base", "type": "base",
"system": false, "system": false,
@@ -367,7 +367,9 @@ func init() {
"Status", "Status",
"CPU", "CPU",
"Memory", "Memory",
"Disk" "Disk",
"Temperature",
"Bandwidth"
] ]
} }
}, },
@@ -385,6 +387,20 @@ 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",
@@ -407,7 +423,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-09-12 21:19:59.114Z", "updated": "2024-10-12 18:55:51.624Z",
"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,42 +11,41 @@
"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.1", "@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.1", "@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.0", "@radix-ui/react-slider": "^1.2.1",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.3",
"@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.407.0", "lucide-react": "^0.452.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-alpha.5", "recharts": "^2.13.0",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"valibot": "^0.36.0" "valibot": "^0.36.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.1.10", "@types/bun": "^1.1.11",
"@types/react": "^18.3.10", "@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.1",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"tailwindcss": "^3.4.13", "tailwindcss": "^3.4.14",
"typescript": "^5.6.2", "typescript": "^5.6.3",
"vite": "^5.4.8" "vite": "^5.4.9"
} }
} }

View File

@@ -7,12 +7,12 @@ import {
cn, cn,
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
twoDecimalString, decimalString,
chartMargin, chartMargin,
} from '@/lib/utils' } from '@/lib/utils'
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { ChartTimes, SystemStatsRecord } from '@/types' import { ChartData } from '@/types'
import { useMemo } from 'react' import { memo, useMemo } from 'react'
/** [label, key, color, opacity] */ /** [label, key, color, opacity] */
type DataKeys = [string, string, number, number] type DataKeys = [string, string, number, number]
@@ -27,23 +27,23 @@ const getNestedValue = (path: string, max = false, data: any): number | null =>
.reduce((acc: any, key: string) => acc?.[key] ?? (data.stats?.cpum ? 0 : null), data) .reduce((acc: any, key: string) => acc?.[key] ?? (data.stats?.cpum ? 0 : null), data)
} }
export default function AreaChartDefault({ export default memo(function AreaChartDefault({
ticks, maxToggled = false,
systemData,
showMax = false,
unit = ' MB/s', unit = ' MB/s',
chartName, chartName,
chartTime, chartData,
}: { }: {
ticks: number[] maxToggled?: boolean
systemData: SystemStatsRecord[]
showMax?: boolean
unit?: string unit?: string
chartName: string chartName: string
chartTime: ChartTimes chartData: ChartData
}) { }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { chartTime } = chartData
const showMax = chartTime !== '1h' && maxToggled
const dataKeys: DataKeys[] = useMemo(() => { const dataKeys: DataKeys[] = useMemo(() => {
// [label, key, color, opacity] // [label, key, color, opacity]
if (chartName === 'CPU Usage') { if (chartName === 'CPU Usage') {
@@ -67,6 +67,8 @@ export default function AreaChartDefault({
return [] return []
}, []) }, [])
// console.log('Rendered at', new Date())
return ( return (
<div> <div>
<ChartContainer <ChartContainer
@@ -74,7 +76,7 @@ export default function AreaChartDefault({
'opacity-100': yAxisWidth, 'opacity-100': yAxisWidth,
})} })}
> >
<AreaChart accessibilityLayer data={systemData} margin={chartMargin}> <AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
<YAxis <YAxis
className="tracking-tighter" className="tracking-tighter"
@@ -88,11 +90,12 @@ export default function AreaChartDefault({
/> />
<XAxis <XAxis
dataKey="created" dataKey="created"
domain={[ticks[0], ticks.at(-1)!]} domain={chartData.domain}
ticks={ticks} ticks={chartData.ticks}
allowDataOverflow
type="number" type="number"
scale={'time'} scale="time"
minTickGap={35} minTickGap={30}
tickMargin={8} tickMargin={8}
axisLine={false} axisLine={false}
tickFormatter={chartTimeData[chartTime].format} tickFormatter={chartTimeData[chartTime].format}
@@ -103,8 +106,8 @@ export default function AreaChartDefault({
content={ content={
<ChartTooltipContent <ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => twoDecimalString(item.value) + unit} contentFormatter={(item) => decimalString(item.value) + unit}
indicator="line" // indicator="line"
/> />
} }
/> />
@@ -128,4 +131,4 @@ export default function AreaChartDefault({
</ChartContainer> </ChartContainer>
</div> </div>
) )
} })

View File

@@ -0,0 +1,200 @@
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

@@ -1,147 +0,0 @@
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,
chartMargin,
} 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
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={chartMargin}
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

@@ -1,147 +0,0 @@
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,
chartMargin,
} 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
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={chartMargin}
>
<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

@@ -1,160 +0,0 @@
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,
chartMargin,
} 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])
return (
<div>
{/* {!yAxisSet && <Spinner />} */}
<ChartContainer
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth,
})}
>
<AreaChart
accessibilityLayer
data={chartData}
margin={chartMargin}
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

@@ -1,91 +0,0 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
twoDecimalString,
chartMargin,
} from '@/lib/utils'
// import Spinner from '../spinner'
import { useStore } from '@nanostores/react'
import { $chartTime, $cpuMax } from '@/lib/stores'
import { SystemStatsRecord } from '@/types'
import { useMemo } from 'react'
export default function CpuChart({
ticks,
systemData,
}: {
ticks: number[]
systemData: SystemStatsRecord[]
}) {
const chartTime = useStore($chartTime)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const showMax = useStore($cpuMax)
const dataKey = useMemo(
() => `stats.cpu${showMax && chartTime !== '1h' ? 'm' : ''}`,
[showMax, systemData]
)
return (
<div>
<ChartContainer
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth,
})}
>
<AreaChart
accessibilityLayer
data={systemData}
margin={chartMargin}
// 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={dataKey}
name="CPU Usage"
type="monotoneX"
fill="hsl(var(--chart-1))"
fillOpacity={0.4}
stroke="hsl(var(--chart-1))"
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
</div>
)
}

View File

@@ -6,41 +6,35 @@ import {
chartTimeData, chartTimeData,
cn, cn,
formatShortDate, formatShortDate,
twoDecimalString, decimalString,
toFixedFloat, toFixedFloat,
getSizeVal,
getSizeUnit,
chartMargin, chartMargin,
getSizeAndUnit,
} from '@/lib/utils' } from '@/lib/utils'
// import { useMemo } from 'react' 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 function DiskChart({ export default memo(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
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth, 'opacity-100': yAxisWidth,
})} })}
> >
<AreaChart accessibilityLayer data={systemData} margin={chartMargin}> <AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
<YAxis <YAxis
className="tracking-tighter" className="tracking-tighter"
@@ -50,20 +44,22 @@ export default function DiskChart({
minTickGap={6} minTickGap={6}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
tickFormatter={(value) => tickFormatter={(value) => {
updateYAxisWidth(toFixedFloat(getSizeVal(value), 2) + getSizeUnit(value)) const { v, u } = getSizeAndUnit(value)
} return updateYAxisWidth(toFixedFloat(v, 2) + u)
}}
/> />
<XAxis <XAxis
dataKey="created" dataKey="created"
domain={[ticks[0], ticks.at(-1)!]} domain={chartData.domain}
ticks={ticks} ticks={chartData.ticks}
allowDataOverflow
type="number" type="number"
scale={'time'} scale="time"
minTickGap={35} minTickGap={30}
tickMargin={8} tickMargin={8}
axisLine={false} axisLine={false}
tickFormatter={chartTimeData[chartTime].format} tickFormatter={chartTimeData[chartData.chartTime].format}
/> />
<ChartTooltip <ChartTooltip
animationEasing="ease-out" animationEasing="ease-out"
@@ -71,10 +67,10 @@ export default function DiskChart({
content={ content={
<ChartTooltipContent <ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={({ value }) => contentFormatter={({ value }) => {
twoDecimalString(getSizeVal(value)) + getSizeUnit(value) const { v, u } = getSizeAndUnit(value)
} return decimalString(v) + u
indicator="line" }}
/> />
} }
/> />
@@ -92,4 +88,4 @@ export default function DiskChart({
</ChartContainer> </ChartContainer>
</div> </div>
) )
} })

View File

@@ -6,28 +6,19 @@ import {
chartTimeData, chartTimeData,
cn, cn,
toFixedFloat, toFixedFloat,
twoDecimalString, decimalString,
formatShortDate, formatShortDate,
chartMargin, chartMargin,
} from '@/lib/utils' } from '@/lib/utils'
import { useMemo } from 'react' import { memo } from 'react'
import { useStore } from '@nanostores/react' import { ChartData } from '@/types'
import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types'
export default function MemChart({ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
ticks,
systemData,
}: {
ticks: number[]
systemData: SystemStatsRecord[]
}) {
const chartTime = useStore($chartTime)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const totalMem = useMemo(() => { const totalMem = toFixedFloat(chartData.systemStats.at(-1)?.stats.m ?? 0, 1)
return toFixedFloat(systemData.at(-1)?.stats.m ?? 0, 1)
}, [systemData]) // console.log('rendered at', new Date())
return ( return (
<div> <div>
@@ -37,7 +28,7 @@ export default function MemChart({
'opacity-100': yAxisWidth, 'opacity-100': yAxisWidth,
})} })}
> >
<AreaChart accessibilityLayer data={systemData} margin={chartMargin}> <AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
{totalMem && ( {totalMem && (
<YAxis <YAxis
@@ -56,14 +47,15 @@ export default function MemChart({
)} )}
<XAxis <XAxis
dataKey="created" dataKey="created"
domain={[ticks[0], ticks.at(-1)!]} domain={chartData.domain}
ticks={ticks} ticks={chartData.ticks}
allowDataOverflow
type="number" type="number"
scale={'time'} scale="time"
minTickGap={35} minTickGap={30}
tickMargin={8} tickMargin={8}
axisLine={false} axisLine={false}
tickFormatter={chartTimeData[chartTime].format} tickFormatter={chartTimeData[chartData.chartTime].format}
/> />
<ChartTooltip <ChartTooltip
// cursor={false} // cursor={false}
@@ -74,8 +66,8 @@ export default function MemChart({
// @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) => twoDecimalString(item.value) + ' GB'} contentFormatter={(item) => decimalString(item.value) + ' GB'}
indicator="line" // indicator="line"
/> />
} }
/> />
@@ -90,7 +82,7 @@ export default function MemChart({
stackId="1" stackId="1"
isAnimationActive={false} isAnimationActive={false}
/> />
{systemData.at(-1)?.stats.mz && ( {chartData.systemStats.at(-1)?.stats.mz && (
<Area <Area
name="ZFS ARC" name="ZFS ARC"
order={2} order={2}
@@ -119,4 +111,4 @@ export default function MemChart({
</ChartContainer> </ChartContainer>
</div> </div>
) )
} })

View File

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

View File

@@ -13,32 +13,23 @@ import {
cn, cn,
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
twoDecimalString, decimalString,
chartMargin, chartMargin,
} from '@/lib/utils' } from '@/lib/utils'
import { useStore } from '@nanostores/react' import { ChartData } from '@/types'
import { $chartTime } from '@/lib/stores' import { memo, useMemo } from 'react'
import { SystemStatsRecord } from '@/types'
import { useMemo } from 'react'
export default function TemperatureChart({ export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) {
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 chartData = { data: [], colors: {} } as { const newChartData = { 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 systemData) { for (let data of chartData.systemStats) {
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++) {
@@ -46,20 +37,21 @@ export default function TemperatureChart({
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]
} }
chartData.data.push(newData) newChartData.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) {
chartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)` newChartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)`
} }
return chartData return newChartData
}, [systemData]) }, [chartData])
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
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth, 'opacity-100': yAxisWidth,
@@ -80,14 +72,15 @@ export default function TemperatureChart({
/> />
<XAxis <XAxis
dataKey="created" dataKey="created"
domain={[ticks[0], ticks.at(-1)!]} domain={chartData.domain}
ticks={ticks} ticks={chartData.ticks}
allowDataOverflow
type="number" type="number"
scale={'time'} scale="time"
minTickGap={35} minTickGap={30}
tickMargin={8} tickMargin={8}
axisLine={false} axisLine={false}
tickFormatter={chartTimeData[chartTime].format} tickFormatter={chartTimeData[chartData.chartTime].format}
/> />
<ChartTooltip <ChartTooltip
animationEasing="ease-out" animationEasing="ease-out"
@@ -97,8 +90,8 @@ export default function TemperatureChart({
content={ content={
<ChartTooltipContent <ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => twoDecimalString(item.value) + ' °C'} contentFormatter={(item) => decimalString(item.value) + ' °C'}
indicator="line" // indicator="line"
/> />
} }
/> />
@@ -119,4 +112,4 @@ export default function TemperatureChart({
</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>Database backups</span> <span>Backups</span>
<CommandShortcut>Admin</CommandShortcut> <CommandShortcut>Admin</CommandShortcut>
</CommandItem> </CommandItem>
<CommandItem <CommandItem

View File

@@ -1,18 +1,35 @@
import { Suspense, lazy, useEffect, useState } from 'react' import { Suspense, lazy, useEffect, useMemo, 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 { updateRecordList, updateSystemList } from '@/lib/utils' import { alertInfo, 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'
@@ -24,17 +41,57 @@ 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">
@@ -61,6 +118,7 @@ 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,5 +1,11 @@
import { $systems, pb, $chartTime, $containerFilter, $userSettings } from '@/lib/stores' import { $systems, pb, $chartTime, $containerFilter, $userSettings } from '@/lib/stores'
import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types' import {
ChartData,
ChartTimes,
ContainerStatsRecord,
SystemRecord,
SystemStatsRecord,
} from '@/types'
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Card, CardHeader, CardTitle, CardDescription } from '../ui/card' import { Card, CardHeader, CardTitle, CardDescription } from '../ui/card'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
@@ -8,24 +14,86 @@ import { ClockArrowUp, CpuIcon, GlobeIcon, LayoutGridIcon, MonitorIcon, XIcon }
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, buttonVariants } from '../ui/button' import { Button } from '../ui/button'
import { Input } from '../ui/input' import { Input } from '../ui/input'
import { ChartAverage, ChartMax, Rows, TuxIcon } from '../ui/icons' import { ChartAverage, ChartMax, 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { timeTicks } from 'd3-time'
const ContainerCpuChart = lazy(() => import('../charts/container-cpu-chart'))
const MemChart = lazy(() => import('../charts/mem-chart'))
const ContainerMemChart = lazy(() => import('../charts/container-mem-chart'))
const DiskChart = lazy(() => import('../charts/disk-chart'))
const AreaChartDefault = lazy(() => import('../charts/area-chart')) const AreaChartDefault = lazy(() => import('../charts/area-chart'))
const ContainerNetChart = lazy(() => import('../charts/container-net-chart')) const ContainerChart = lazy(() => import('../charts/container-chart'))
const MemChart = lazy(() => import('../charts/mem-chart'))
const DiskChart = lazy(() => import('../charts/disk-chart'))
const SwapChart = lazy(() => import('../charts/swap-chart')) const SwapChart = lazy(() => import('../charts/swap-chart'))
const TemperatureChart = lazy(() => import('../charts/temperature-chart')) const TemperatureChart = lazy(() => import('../charts/temperature-chart'))
const cache = new Map<string, SystemStatsRecord[] | ContainerStatsRecord[]>() 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)
@@ -35,27 +103,21 @@ export default function SystemDetail({ name }: { name: string }) {
const bandwidthMaxStore = useState(false) const bandwidthMaxStore = useState(false)
const diskIoMaxStore = 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 [containerFilterBar, setContainerFilterBar] = useState(null as null | JSX.Element)
const [dockerCpuChartData, setDockerCpuChartData] = useState<Record<string, number | string>[]>( const [bottomSpacing, setBottomSpacing] = useState(0)
[]
)
const [dockerMemChartData, setDockerMemChartData] = useState<Record<string, number | string>[]>(
[]
)
const [dockerNetChartData, setDockerNetChartData] = useState<Record<string, number | number[]>[]>(
[]
)
const isLongerChart = chartTime !== '1h' const isLongerChart = chartTime !== '1h'
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) setContainerFilterBar(null)
$containerFilter.set('') $containerFilter.set('')
cpuMaxStore[1](false) cpuMaxStore[1](false)
@@ -64,15 +126,14 @@ export default function SystemDetail({ name }: { name: string }) {
} }
}, [name]) }, [name])
function resetCharts() { // function resetCharts() {
setSystemStats([]) // setSystemStats([])
setDockerCpuChartData([]) // setContainerData([])
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
@@ -96,43 +157,18 @@ export default function SystemDetail({ name }: { name: string }) {
} }
}, [system]) }, [system])
async function getStats<T>(collection: string): Promise<T[]> { const chartData: ChartData = useMemo(() => {
const lastCached = cache.get(`${system.id}_${chartTime}_${collection}`)?.at(-1) const lastCreated = Math.max(
?.created as number (systemStats.at(-1)?.created as number) ?? 0,
return await pb.collection<T>(collection).getFullList({ (containerData.at(-1)?.created as number) ?? 0
filter: pb.filter('system={:id} && created > {:created} && type={:type}', { )
id: system.id, return {
created: getPbTimestamp(chartTime, lastCached ? new Date(lastCached + 1000) : undefined), systemStats,
type: chartTimeData[chartTime].type, containerData,
}), chartTime,
fields: 'created,stats', ...getTimeData(chartTime, lastCreated),
sort: 'created',
})
}
// add empty values between records to make gaps if interval is too large
function addEmptyValues<T extends SystemStatsRecord | ContainerStatsRecord>(
records: T[],
expectedInterval: number
) {
const modifiedRecords: T[] = []
let prevTime = 0
for (let i = 0; i < records.length; i++) {
const record = records[i]
record.created = new Date(record.created).getTime()
if (prevTime) {
const interval = record.created - prevTime
// if interval is too large, add a null record
if (interval > expectedInterval / 2 + expectedInterval) {
// @ts-ignore
modifiedRecords.push({ created: null, stats: null })
}
}
prevTime = record.created
modifiedRecords.push(record)
} }
return modifiedRecords }, [systemStats, containerData])
}
// get stats // get stats
useEffect(() => { useEffect(() => {
@@ -140,15 +176,17 @@ export default function SystemDetail({ name }: { name: string }) {
return return
} }
Promise.allSettled([ Promise.allSettled([
getStats<SystemStatsRecord>('system_stats'), getStats<SystemStatsRecord>('system_stats', system, chartTime),
getStats<ContainerStatsRecord>('container_stats'), getStats<ContainerStatsRecord>('container_stats', system, chartTime),
]).then(([systemStats, containerStats]) => { ]).then(([systemStats, containerStats]) => {
const { expectedInterval } = chartTimeData[chartTime] const { expectedInterval } = chartTimeData[chartTime]
// make new system stats // make new system stats
const ss_cache_key = `${system.id}_${chartTime}_system_stats` const ss_cache_key = `${system.id}_${chartTime}_system_stats`
let systemData = (cache.get(ss_cache_key) || []) as SystemStatsRecord[] let systemData = (cache.get(ss_cache_key) || []) as SystemStatsRecord[]
if (systemStats.status === 'fulfilled' && systemStats.value.length) { if (systemStats.status === 'fulfilled' && systemStats.value.length) {
systemData = systemData.concat(addEmptyValues(systemStats.value, expectedInterval)) systemData = systemData.concat(
addEmptyValues(systemData, systemStats.value, expectedInterval)
)
if (systemData.length > 120) { if (systemData.length > 120) {
systemData = systemData.slice(-100) systemData = systemData.slice(-100)
} }
@@ -159,7 +197,9 @@ export default function SystemDetail({ name }: { name: string }) {
const cs_cache_key = `${system.id}_${chartTime}_container_stats` const cs_cache_key = `${system.id}_${chartTime}_container_stats`
let containerData = (cache.get(cs_cache_key) || []) as ContainerStatsRecord[] 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(addEmptyValues(containerStats.value, expectedInterval)) containerData = containerData.concat(
addEmptyValues(containerData, containerStats.value, expectedInterval)
)
if (containerData.length > 120) { if (containerData.length > 120) {
containerData = containerData.slice(-100) containerData = containerData.slice(-100)
} }
@@ -174,49 +214,24 @@ export default function SystemDetail({ name }: { name: string }) {
}) })
}, [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])
const newTicks = scale.ticks(chartTimeData[chartTime].ticks).map((d) => d.getTime())
if (newTicks[0] !== ticks[0]) {
setTicks(newTicks)
}
}, [chartTime, systemStats])
// make container stats for charts // make container stats for charts
const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => { const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => {
// console.log('containers', containers) const containerData = [] as ChartData['containerData']
const dockerCpuData = []
const dockerMemData = []
const dockerNetData = []
for (let { created, stats } of containers) { for (let { created, stats } of containers) {
if (!created) { if (!created) {
let nullData = { time: null } as unknown // @ts-ignore add null value for gaps
dockerCpuData.push(nullData as Record<string, number | string>) containerData.push({ created: null })
dockerMemData.push(nullData as Record<string, number | string>)
dockerNetData.push(nullData as Record<string, number | number[]>)
continue continue
} }
const time = new Date(created).getTime() created = new Date(created).getTime()
let cpuData = { time } as Record<string, number | string> // @ts-ignore not dealing with this rn
let memData = { time } as Record<string, number | string> let containerStats: ChartData['containerData'][0] = { created }
let netData = { time } as Record<string, number | number[]>
for (let container of stats) { for (let container of stats) {
cpuData[container.n] = container.c containerStats[container.n] = container
memData[container.n] = container.m
netData[container.n] = [container.ns, container.nr, container.ns + container.nr] // sent, received, total
} }
dockerCpuData.push(cpuData) containerData.push(containerStats)
dockerMemData.push(memData)
dockerNetData.push(netData)
} }
setDockerCpuChartData(dockerCpuData) setContainerData(containerData)
setDockerMemChartData(dockerMemData)
setDockerNetChartData(dockerNetData)
}, []) }, [])
// values for system info bar // values for system info bar
@@ -256,17 +271,18 @@ 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 */
const bottomSpacing = useMemo(() => { useEffect(() => {
if (!netCardRef.current || !dockerNetChartData.length) { if (!netCardRef.current || !containerData.length) {
return 0 setBottomSpacing(0)
return
} }
const tooltipHeight = (Object.keys(dockerNetChartData[0]).length - 11) * 17.8 - 40 const tooltipHeight = (Object.keys(containerData[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
return tooltipHeight - distanceToBottom setBottomSpacing(tooltipHeight - distanceToBottom)
}, [netCardRef.current, dockerNetChartData]) }, [netCardRef, containerData])
if (!system.id) { if (!system.id) {
return null return null
@@ -277,7 +293,7 @@ export default function SystemDetail({ name }: { name: string }) {
<div id="chartwrap" className="grid gap-4 mb-10 overflow-x-clip"> <div id="chartwrap" className="grid gap-4 mb-10 overflow-x-clip">
{/* system info */} {/* system info */}
<Card> <Card>
<div className="grid lg:flex items-center gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5"> <div className="grid lg:flex 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">
@@ -334,10 +350,9 @@ export default function SystemDetail({ name }: { name: string }) {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
aria-label="Toggle grid" aria-label="Toggle grid"
className={cn( variant="outline"
buttonVariants({ variant: 'outline', size: 'icon' }), size="icon"
'hidden lg:flex p-0 text-primary' className="hidden lg:flex p-0 text-primary"
)}
onClick={() => setGrid(!grid)} onClick={() => setGrid(!grid)}
> >
{grid ? ( {grid ? (
@@ -365,12 +380,10 @@ export default function SystemDetail({ name }: { name: string }) {
cornerEl={isLongerChart ? <SelectAvgMax store={cpuMaxStore} /> : null} cornerEl={isLongerChart ? <SelectAvgMax store={cpuMaxStore} /> : null}
> >
<AreaChartDefault <AreaChartDefault
ticks={ticks} chartData={chartData}
systemData={systemStats}
chartName="CPU Usage" chartName="CPU Usage"
showMax={isLongerChart && cpuMaxStore[0]} maxToggled={cpuMaxStore[0]}
unit="%" unit="%"
chartTime={chartTime}
/> />
</ChartCard> </ChartCard>
@@ -381,7 +394,7 @@ export default function SystemDetail({ name }: { name: string }) {
description="Average CPU utilization of containers" description="Average CPU utilization of containers"
cornerEl={containerFilterBar} cornerEl={containerFilterBar}
> >
<ContainerCpuChart chartData={dockerCpuChartData} ticks={ticks} /> <ContainerChart chartData={chartData} dataKey="c" chartName="cpu" />
</ChartCard> </ChartCard>
)} )}
@@ -390,7 +403,7 @@ 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 ticks={ticks} systemData={systemStats} /> <MemChart chartData={chartData} />
</ChartCard> </ChartCard>
{containerFilterBar && ( {containerFilterBar && (
@@ -400,14 +413,13 @@ export default function SystemDetail({ name }: { name: string }) {
description="Memory usage of docker containers" description="Memory usage of docker containers"
cornerEl={containerFilterBar} cornerEl={containerFilterBar}
> >
<ContainerMemChart chartData={dockerMemChartData} ticks={ticks} /> <ContainerChart chartData={chartData} chartName="mem" dataKey="m" unit=" MB" />
</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
ticks={ticks} chartData={chartData}
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)}
/> />
@@ -420,11 +432,9 @@ export default function SystemDetail({ name }: { name: string }) {
cornerEl={isLongerChart ? <SelectAvgMax store={diskIoMaxStore} /> : null} cornerEl={isLongerChart ? <SelectAvgMax store={diskIoMaxStore} /> : null}
> >
<AreaChartDefault <AreaChartDefault
ticks={ticks} chartData={chartData}
systemData={systemStats} maxToggled={diskIoMaxStore[0]}
showMax={isLongerChart && diskIoMaxStore[0]}
chartName="dio" chartName="dio"
chartTime={chartTime}
/> />
</ChartCard> </ChartCard>
@@ -435,15 +445,13 @@ export default function SystemDetail({ name }: { name: string }) {
description="Network traffic of public interfaces" description="Network traffic of public interfaces"
> >
<AreaChartDefault <AreaChartDefault
ticks={ticks} chartData={chartData}
systemData={systemStats} maxToggled={bandwidthMaxStore[0]}
showMax={isLongerChart && bandwidthMaxStore[0]}
chartName="bw" chartName="bw"
chartTime={chartTime}
/> />
</ChartCard> </ChartCard>
{containerFilterBar && dockerNetChartData.length > 0 && ( {containerFilterBar && containerData.length > 0 && (
<div <div
ref={netCardRef} ref={netCardRef}
className={cn({ className={cn({
@@ -455,20 +463,21 @@ export default function SystemDetail({ name }: { name: string }) {
description="Includes traffic between internal services" description="Includes traffic between internal services"
cornerEl={containerFilterBar} cornerEl={containerFilterBar}
> >
<ContainerNetChart chartData={dockerNetChartData} ticks={ticks} /> {/* @ts-ignore */}
<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 ticks={ticks} systemData={systemStats} /> <SwapChart chartData={chartData} />
</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 ticks={ticks} systemData={systemStats} /> <TemperatureChart chartData={chartData} />
</ChartCard> </ChartCard>
)} )}
</div> </div>
@@ -485,8 +494,7 @@ export default function SystemDetail({ name }: { name: string }) {
description={`Disk usage of ${extraFsName}`} description={`Disk usage of ${extraFsName}`}
> >
<DiskChart <DiskChart
ticks={ticks} chartData={chartData}
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)}
/> />
@@ -498,11 +506,9 @@ export default function SystemDetail({ name }: { name: string }) {
cornerEl={isLongerChart ? <SelectAvgMax store={diskIoMaxStore} /> : null} cornerEl={isLongerChart ? <SelectAvgMax store={diskIoMaxStore} /> : null}
> >
<AreaChartDefault <AreaChartDefault
ticks={ticks} chartData={chartData}
systemData={systemStats} maxToggled={diskIoMaxStore[0]}
showMax={isLongerChart && diskIoMaxStore[0]}
chartName={`efs.${extraFsName}`} chartName={`efs.${extraFsName}`}
chartTime={chartTime}
/> />
</ChartCard> </ChartCard>
</div> </div>

View File

@@ -44,30 +44,31 @@ import {
import { SystemRecord } from '@/types' import { SystemRecord } from '@/types'
import { import {
MoreHorizontal, MoreHorizontalIcon,
ArrowUpDown, ArrowUpDownIcon,
Server, MemoryStickIcon,
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, isReadOnlyUser } from '@/lib/utils' import { cn, copyToClipboard, decimalString, 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]">{val.toFixed(1)}%</span> <span className="min-w-[3.5em]">{decimalString(val, 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(
@@ -95,7 +96,7 @@ function sortableHeader(
> >
<Icon className="mr-2 h-4 w-4" /> <Icon className="mr-2 h-4 w-4" />
{name} {name}
{!hideSortIcon && <ArrowUpDown className="ml-2 h-4 w-4" />} {!hideSortIcon && <ArrowUpDownIcon className="ml-2 h-4 w-4" />}
</Button> </Button>
) )
} }
@@ -112,7 +113,7 @@ export default function SystemsTable({ filter }: { filter?: string }) {
} }
}, [filter]) }, [filter])
const columns: ColumnDef<SystemRecord>[] = useMemo(() => { const columns = useMemo(() => {
return [ return [
{ {
// size: 200, // size: 200,
@@ -144,26 +145,46 @@ export default function SystemsTable({ filter }: { filter?: string }) {
</span> </span>
) )
}, },
header: ({ column }) => sortableHeader(column, 'System', Server), header: ({ column }) => sortableHeader(column, 'System', ServerIcon),
}, },
{ {
accessorKey: 'info.cpu', accessorKey: 'info.cpu',
invertSorting: true,
cell: CellFormatter, cell: CellFormatter,
header: ({ column }) => sortableHeader(column, 'CPU', Cpu), header: ({ column }) => sortableHeader(column, 'CPU', CpuIcon),
}, },
{ {
accessorKey: 'info.mp', accessorKey: 'info.mp',
invertSorting: true,
cell: CellFormatter, cell: CellFormatter,
header: ({ column }) => sortableHeader(column, 'Memory', MemoryStick), header: ({ column }) => sortableHeader(column, 'Memory', MemoryStickIcon),
}, },
{ {
accessorKey: 'info.dp', accessorKey: 'info.dp',
invertSorting: true,
cell: CellFormatter, cell: CellFormatter,
header: ({ column }) => sortableHeader(column, 'Disk', HardDrive), header: ({ column }) => sortableHeader(column, 'Disk', HardDriveIcon),
},
{
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) {
@@ -182,7 +203,6 @@ export default function SystemsTable({ filter }: { filter?: string }) {
</span> </span>
) )
}, },
header: ({ column }) => sortableHeader(column, 'Agent', WifiIcon, true),
}, },
{ {
id: 'actions', id: 'actions',
@@ -198,7 +218,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>
<MoreHorizontal className="w-5" /> <MoreHorizontalIcon className="w-5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
@@ -259,7 +279,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 } from 'lucide-react' import { BellIcon, ServerIcon } from 'lucide-react'
import { cn } from '@/lib/utils' import { alertInfo, 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,19 +28,22 @@ 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 active = useMemo(() => { const systemAlerts = alerts.filter((alert) => alert.system === system.id) as AlertRecord[]
return alerts.find((alert) => alert.system === system.id)
}, [alerts, system])
const systemAlerts = useMemo(() => { const active = systemAlerts.length > 0
return alerts.filter((alert) => alert.system === system.id) as AlertRecord[]
}, [alerts, system])
return ( return (
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="ghost" size={'icon'} aria-label="Alerts" data-nolink> <Button
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,
@@ -48,41 +51,42 @@ export default function AlertsButton({ system }: { system: SystemRecord }) {
/> />
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-full overflow-auto"> <DialogContent
<DialogHeader> className="max-h-full overflow-auto max-w-[35rem]"
<DialogTitle className="text-xl">{system.name} alerts</DialogTitle> // onCloseAutoFocus={() => setOpened(false)}
<DialogDescription className="mb-1"> >
See{' '} {opened && (
<Link href="/settings/notifications" className="link"> <>
notification settings <DialogHeader>
</Link>{' '} <DialogTitle className="text-xl">{system.name} alerts</DialogTitle>
to configure how you receive alerts. <DialogDescription className="mb-1">
</DialogDescription> See{' '}
</DialogHeader> <Link href="/settings/notifications" className="link">
<div className="grid gap-3"> notification settings
<AlertStatus system={system} alerts={systemAlerts} /> </Link>{' '}
<AlertWithSlider to configure how you receive alerts.
system={system} </DialogDescription>
alerts={systemAlerts} </DialogHeader>
name="CPU" <div className="grid gap-3">
title="CPU Usage" <AlertStatus system={system} alerts={systemAlerts} />
description="Triggers when CPU usage exceeds a threshold." {Object.keys(alertInfo).map((key) => {
/> const alert = alertInfo[key as keyof typeof alertInfo]
<AlertWithSlider return (
system={system} <AlertWithSlider
alerts={systemAlerts} key={key}
name="Memory" system={system}
title="Memory Usage" alerts={systemAlerts}
description="Triggers when memory usage exceeds a threshold." name={key}
/> title={alert.name}
<AlertWithSlider description={alert.desc}
system={system} unit={alert.unit}
alerts={systemAlerts} Icon={alert.icon}
name="Disk" />
title="Disk Usage" )
description="Triggers when root usage exceeds a threshold." })}
/> </div>
</div> </>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )
@@ -91,18 +95,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 = useMemo(() => { const alert = alerts.find((alert) => alert.name === 'Status')
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 p-4 cursor-pointer" 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"
> >
<div className="grid gap-1 select-none"> <div className="grid gap-1 select-none">
<p className="font-semibold">System Status</p> <p className="font-semibold flex gap-3 items-center">
<span className="block text-sm text-foreground opacity-80"> <ServerIcon className="h-4 w-4 opacity-85" /> System status
</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>
@@ -143,38 +147,50 @@ 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(50) const [liveValue, setLiveValue] = useState(80)
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"> <div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group">
<label <label
htmlFor={`alert-${name}`} htmlFor={`s${key}`}
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">{title}</p> <p className="font-semibold flex gap-3 items-center">
<span className="block text-sm text-foreground opacity-80">{description}</span> <Icon className="h-4 w-4 opacity-85" /> {title}
</p>
{!alert && <span className="block text-sm text-muted-foreground">{description}</span>}
</div> </div>
<Switch <Switch
id={`alert-${name}`} id={`s${key}`}
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'}
@@ -192,6 +208,7 @@ function AlertWithSlider({
user: pb.authStore.model!.id, user: pb.authStore.model!.id,
name, name,
value: liveValue, value: liveValue,
min: liveMinutes,
}) })
} }
} catch (e) { } catch (e) {
@@ -203,24 +220,52 @@ function AlertWithSlider({
/> />
</label> </label>
{alert && ( {alert && (
<div className="flex mt-2 mb-3 gap-3 px-4"> <div className="grid sm:grid-cols-2 mt-1.5 gap-5 px-4 pb-5 tabular-nums text-muted-foreground">
<Suspense> <Suspense fallback={<div className="h-10" />}>
<Slider <div>
defaultValue={[liveValue]} <p id={`v${key}`} className="text-sm block h-8">
onValueCommit={(val) => { Average exceeds{' '}
pb.collection('alerts').update(alert.id, { <strong className="text-foreground">
value: val[0], {liveValue}
}) {unit}
}} </strong>
onValueChange={(val) => { </p>
setLiveValue(val[0]) <div className="flex gap-3">
}} <Slider
min={10} aria-labelledby={`v${key}`}
max={99} defaultValue={[liveValue]}
// step={1} onValueCommit={(val) => {
/> 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

@@ -0,0 +1,59 @@
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

@@ -95,7 +95,6 @@ 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
@@ -109,9 +108,8 @@ const ChartTooltipContent = React.forwardRef<
active, active,
payload, payload,
className, className,
indicator = 'dot', indicator = 'line',
hideLabel = false, hideLabel = false,
hideIndicator = false,
label, label,
labelFormatter, labelFormatter,
labelClassName, labelClassName,
@@ -145,7 +143,7 @@ const ChartTooltipContent = React.forwardRef<
} }
const [item] = payload const [item] = payload
const key = `${labelKey || item.dataKey || item.name || 'value'}` const key = `${labelKey || 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' ? label : itemConfig?.label
@@ -166,7 +164,8 @@ 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
@@ -198,26 +197,24 @@ const ChartTooltipContent = React.forwardRef<
{itemConfig?.icon ? ( {itemConfig?.icon ? (
<itemConfig.icon /> <itemConfig.icon />
) : ( ) : (
!hideIndicator && ( <div
<div className={cn(
className={cn( 'shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]',
'shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]', {
{ 'h-2.5 w-2.5': indicator === 'dot',
'h-2.5 w-2.5': indicator === 'dot', 'w-1': indicator === 'line',
'w-1': indicator === 'line', 'w-0 border-[1.5px] border-dashed bg-transparent':
'w-0 border-[1.5px] border-dashed bg-transparent': indicator === 'dashed',
indicator === 'dashed', 'my-0.5': nestLabel && 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(

View File

@@ -45,3 +45,28 @@ export function ChartMax(props: SVGProps<SVGSVGElement>) {
</svg> </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,10 +7,13 @@ 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
@@ -51,7 +54,7 @@ export const updateSystemList = async () => {
export const updateAlerts = () => { export const updateAlerts = () => {
pb.collection('alerts') pb.collection('alerts')
.getFullList<AlertRecord>({ fields: 'id,name,system,value' }) .getFullList<AlertRecord>({ fields: 'id,name,system,value,min,triggered', sort: 'updated' })
.then((records) => { .then((records) => {
$alerts.set(records) $alerts.set(records)
}) })
@@ -224,17 +227,18 @@ export function toFixedFloat(num: number, digits: number) {
return parseFloat(num.toFixed(digits)) return parseFloat(num.toFixed(digits))
} }
let twoDecimalFormatter: Intl.NumberFormat let decimalFormatters: Map<number, Intl.NumberFormat> = new Map()
/** Format number to two decimal places */ /** Format number to x decimal places */
export function twoDecimalString(num: number) { export function decimalString(num: number, digits = 2) {
if (!twoDecimalFormatter) { let formatter = decimalFormatters.get(digits)
twoDecimalFormatter = new Intl.NumberFormat(undefined, { if (!formatter) {
minimumFractionDigits: 2, formatter = new Intl.NumberFormat(undefined, {
maximumFractionDigits: 2, minimumFractionDigits: digits,
maximumFractionDigits: digits,
}) })
decimalFormatters.set(digits, formatter)
} }
// Return a function that formats numbers using the saved formatter return formatter.format(num)
return twoDecimalFormatter.format(num)
} }
/** Get value from local storage */ /** Get value from local storage */
@@ -276,17 +280,53 @@ export async function updateUserSettings() {
} }
/** /**
* Get the unit of size (TB or GB) for a given size in gigabytes * Get the value and unit of size (TB, GB, or MB) for a given size
* @param n size in gigabytes * @param n size in gigabytes or megabytes
* @returns unit of size (TB or GB) * @param isGigabytes boolean indicating if n represents gigabytes (true) or megabytes (false)
* @returns an object containing the value and unit of size
*/ */
export const getSizeUnit = (n: number) => (n >= 1_000 ? ' TB' : ' GB') export const getSizeAndUnit = (n: number, isGigabytes = true) => {
const sizeInGB = isGigabytes ? n : n / 1_000
/** if (sizeInGB >= 1_000) {
* Get the value of number in gigabytes if less than 1000, otherwise in terabytes return { v: sizeInGB / 1_000, u: ' TB' }
* @param n size in gigabytes } else if (sizeInGB >= 1) {
* @returns value in GB if less than 1000, otherwise value in TB return { v: sizeInGB, u: ' GB' }
*/ }
export const getSizeVal = (n: number) => (n >= 1_000 ? n / 1_000 : n) return { v: n, u: ' MB' }
}
export const chartMargin = { top: 12 } 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,6 +30,10 @@ 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 {
@@ -123,6 +127,8 @@ 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
} }
@@ -145,3 +151,17 @@ 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.5.3" Version = "0.6.0"
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 usage, and system status. - **Alerts**: Configurable alerts for CPU, memory, disk, bandwidth, temperature, 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.