Compare commits

...

7 Commits

Author SHA1 Message Date
Henry Dollman
ae0f5c938f 0.0.1-alpha.7 2024-07-23 15:47:45 -04:00
Henry Dollman
78dc269538 update logic for batch updating servers 2024-07-23 15:47:15 -04:00
Henry Dollman
f6967eab35 update gitignore / readme 2024-07-23 15:43:26 -04:00
Henry Dollman
e787b6ea1b update docker compose to make docker sock read only 2024-07-23 15:21:03 -04:00
Henry Dollman
844b95dfd0 get container stats synchronously 2024-07-23 15:19:04 -04:00
Henry Dollman
c5776541a0 style / chart axis updates 2024-07-23 14:48:33 -04:00
Henry Dollman
5ba7568acf update readme 2024-07-22 19:01:05 -04:00
10 changed files with 85 additions and 78 deletions

1
.gitignore vendored
View File

@@ -6,4 +6,5 @@ temp
beszel beszel
beszel-agent beszel-agent
beszel_data beszel_data
beszel_data*
dist dist

View File

@@ -5,7 +5,7 @@ services:
restart: unless-stopped restart: unless-stopped
network_mode: host network_mode: host
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock:ro
environment: environment:
PORT: 45876 PORT: 45876
KEY: 'ssh-ed25519 YOUR_PUBLIC_KEY' KEY: 'ssh-ed25519 YOUR_PUBLIC_KEY'

View File

@@ -2,7 +2,6 @@ package main
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
@@ -11,7 +10,6 @@ import (
"net/http" "net/http"
"os" "os"
"strings" "strings"
"sync"
"time" "time"
sshServer "github.com/gliderlabs/ssh" sshServer "github.com/gliderlabs/ssh"
@@ -23,10 +21,11 @@ import (
psutilNet "github.com/shirou/gopsutil/v4/net" psutilNet "github.com/shirou/gopsutil/v4/net"
) )
var Version = "0.0.1-alpha.6" var Version = "0.0.1-alpha.7"
var containerCpuMap = make(map[string][2]uint64) var containerCpuMap = make(map[string][2]uint64)
var containerCpuMutex = &sync.Mutex{}
// var containerCpuMutex = &sync.Mutex{}
var diskIoStats = DiskIoStats{ var diskIoStats = DiskIoStats{
Read: 0, Read: 0,
@@ -44,16 +43,16 @@ var netIoStats = NetIoStats{
// client for docker engine api // client for docker engine api
var client = &http.Client{ var client = &http.Client{
Timeout: time.Second * 5, Timeout: time.Second,
Transport: &http.Transport{ Transport: &http.Transport{
Dial: func(proto, addr string) (net.Conn, error) { Dial: func(proto, addr string) (net.Conn, error) {
return net.Dial("unix", "/var/run/docker.sock") return net.Dial("unix", "/var/run/docker.sock")
}, },
ForceAttemptHTTP2: false, ForceAttemptHTTP2: false,
MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second,
MaxIdleConnsPerHost: 100, DisableCompression: true,
IdleConnTimeout: 90 * time.Second, MaxIdleConns: 10,
DisableKeepAlives: false, DisableKeepAlives: false,
}, },
} }
@@ -194,20 +193,19 @@ func getDockerStats() ([]ContainerStats, error) {
panic(err) panic(err)
} }
var wg sync.WaitGroup
var containerStats []ContainerStats var containerStats []ContainerStats
for _, ctr := range containers { for _, ctr := range containers {
wg.Add(1) cstats, err := getContainerStats(ctr)
go func() { if err != nil {
defer wg.Done() // retry once
cstats, err := getContainerStats(ctr) cstats, err = getContainerStats(ctr)
if err != nil { if err != nil {
log.Printf("Error getting container stats: %+v\n", err) log.Printf("Error getting container stats: %+v\n", err)
return continue
} }
containerStats = append(containerStats, cstats) }
}() containerStats = append(containerStats, cstats)
} }
// clean up old containers from map // clean up old containers from map
@@ -221,8 +219,6 @@ func getDockerStats() ([]ContainerStats, error) {
} }
} }
wg.Wait()
return containerStats, nil return containerStats, nil
} }
@@ -246,8 +242,8 @@ func getContainerStats(ctr Container) (ContainerStats, error) {
// cpu // cpu
// add default values to containerCpu if it doesn't exist // add default values to containerCpu if it doesn't exist
containerCpuMutex.Lock() // containerCpuMutex.Lock()
defer containerCpuMutex.Unlock() // defer containerCpuMutex.Unlock()
if _, ok := containerCpuMap[name]; !ok { if _, ok := containerCpuMap[name]; !ok {
containerCpuMap[name] = [2]uint64{0, 0} containerCpuMap[name] = [2]uint64{0, 0}
} }
@@ -255,7 +251,7 @@ func getContainerStats(ctr Container) (ContainerStats, error) {
systemDelta := statsJson.CPUStats.SystemUsage - containerCpuMap[name][1] systemDelta := statsJson.CPUStats.SystemUsage - containerCpuMap[name][1]
cpuPct := float64(cpuDelta) / float64(systemDelta) * 100 cpuPct := float64(cpuDelta) / float64(systemDelta) * 100
if cpuPct > 100 { if cpuPct > 100 {
return ContainerStats{}, errors.New("cpu pct is greater than 100") return ContainerStats{}, fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct)
} }
containerCpuMap[name] = [2]uint64{statsJson.CPUStats.CPUUsage.TotalUsage, statsJson.CPUStats.SystemUsage} containerCpuMap[name] = [2]uint64{statsJson.CPUStats.CPUUsage.TotalUsage, statsJson.CPUStats.SystemUsage}

View File

@@ -15,24 +15,26 @@ import (
"net/url" "net/url"
"os" "os"
"strings" "strings"
"sync"
"time" "time"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"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/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/plugins/migratecmd" "github.com/pocketbase/pocketbase/plugins/migratecmd"
"github.com/pocketbase/pocketbase/tools/cron" "github.com/pocketbase/pocketbase/tools/cron"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
var Version = "0.0.1-alpha.6" var Version = "0.0.1-alpha.7"
var app *pocketbase.PocketBase var app *pocketbase.PocketBase
var serverConnections = make(map[string]Server) var serverConnections = make(map[string]*Server)
var serverConnectionsLock = sync.Mutex{}
func main() { func main() {
app = pocketbase.NewWithConfig(pocketbase.Config{ app = pocketbase.NewWithConfig(pocketbase.Config{
@@ -207,49 +209,53 @@ func startSystemUpdateTicker() {
} }
func updateSystems() { func updateSystems() {
// handle max of 1/3 + 1 servers at a time
numServers := len(serverConnections)/3 + 1
// find systems that are not paused and updated more than 58 seconds ago
fiftyEightSecondsAgo := time.Now().UTC().Add(-58 * time.Second).Format("2006-01-02 15:04:05")
records, err := app.Dao().FindRecordsByFilter( records, err := app.Dao().FindRecordsByFilter(
"2hz5ncl8tizk5nx", // collection "2hz5ncl8tizk5nx", // collection
"status != 'paused' && updated < {:updated}", // filter "status != 'paused'", // filter
"updated", // sort "updated", // sort
numServers, // limit -1, // limit
0, // offset 0, // offset
dbx.Params{"updated": fiftyEightSecondsAgo},
) )
// log.Println("records", len(records))
if err != nil { if err != nil {
app.Logger().Error("Failed to query systems: ", "err", err.Error()) app.Logger().Error("Failed to query systems: ", "err", err.Error())
return return
} }
for _, record := range records { fiftyFiveSecondsAgo := time.Now().UTC().Add(-55 * time.Second)
updateSystem(record) batchSize := len(records)/4 + 1
for i := 0; i < batchSize; i++ {
if records[i].Get("updated").(types.DateTime).Time().After(fiftyFiveSecondsAgo) {
break
}
// log.Println("updating", records[i].Get(("name")))
go updateSystem(records[i])
} }
} }
func updateSystem(record *models.Record) { func updateSystem(record *models.Record) {
var server Server var server *Server
// check if server connection data exists // check if server connection data exists
if _, ok := serverConnections[record.Id]; ok { if _, ok := serverConnections[record.Id]; ok {
server = serverConnections[record.Id] server = serverConnections[record.Id]
} else { } else {
// create server connection struct // create server connection struct
server = Server{ server = &Server{
Host: record.Get("host").(string), Host: record.Get("host").(string),
Port: record.Get("port").(string), Port: record.Get("port").(string),
} }
client, err := getServerConnection(&server) client, err := getServerConnection(server)
if err != nil { if err != nil {
app.Logger().Error("Failed to connect:", "err", err.Error(), "server", server.Host, "port", server.Port) app.Logger().Error("Failed to connect:", "err", err.Error(), "server", server.Host, "port", server.Port)
updateServerStatus(record, "down") updateServerStatus(record, "down")
return return
} }
server.Client = client server.Client = client
serverConnectionsLock.Lock()
serverConnections[record.Id] = server serverConnections[record.Id] = server
serverConnectionsLock.Unlock()
} }
// get server stats from agent // get server stats from agent
systemData, err := requestJson(&server) systemData, err := requestJson(server)
if err != nil { if err != nil {
if err.Error() == "retry" { if err.Error() == "retry" {
// if previous connection was closed, try again // if previous connection was closed, try again
@@ -310,6 +316,8 @@ func deleteServerConnection(record *models.Record) {
if serverConnections[record.Id].Client != nil { if serverConnections[record.Id].Client != nil {
serverConnections[record.Id].Client.Close() serverConnections[record.Id].Client.Close()
} }
serverConnectionsLock.Lock()
defer serverConnectionsLock.Unlock()
delete(serverConnections, record.Id) delete(serverConnections, record.Id)
} }
} }

View File

@@ -32,7 +32,7 @@ export function AddSystemButton() {
restart: unless-stopped restart: unless-stopped
network_mode: host network_mode: host
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock:ro
environment: environment:
PORT: ${port} PORT: ${port}
KEY: "${publicKey}" KEY: "${publicKey}"

View File

@@ -47,7 +47,13 @@ export default function BandwidthChart({
<YAxis <YAxis
className="tracking-tighter" className="tracking-tighter"
width={75} width={75}
domain={[0, (max: number) => (max < 0.4 ? 0.4 : Math.ceil(max))]} domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]}
tickFormatter={(value) => {
if (value >= 100) {
return value.toFixed(0)
}
return value.toFixed((value * 100) % 1 === 0 ? 1 : 2)
}}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
unit={' MB/s'} unit={' MB/s'}

View File

@@ -69,7 +69,7 @@ export default function ContainerCpuChart({
> >
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
<YAxis <YAxis
domain={[0, (max: number) => Math.max(Math.ceil(max), 0.4)]} // domain={[0, (max: number) => Math.max(Math.ceil(max), 0.4)]}
width={47} width={47}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}

View File

@@ -47,7 +47,7 @@ export default function DiskIoChart({
<YAxis <YAxis
className="tracking-tighter" className="tracking-tighter"
width={75} width={75}
domain={[0, (max: number) => (max < 0.4 ? 0.4 : Math.ceil(max))]} domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]}
tickFormatter={(value) => { tickFormatter={(value) => {
if (value >= 100) { if (value >= 100) {
return value.toFixed(0) return value.toFixed(0)

View File

@@ -71,21 +71,21 @@ export default function AlertsButton({ system }: { system: SystemRecord }) {
system={system} system={system}
alerts={systemAlerts} alerts={systemAlerts}
name="CPU" name="CPU"
title="CPU usage" title="CPU Usage"
description="Triggers when CPU usage exceeds a threshold." description="Triggers when CPU usage exceeds a threshold."
/> />
<AlertWithSlider <AlertWithSlider
system={system} system={system}
alerts={systemAlerts} alerts={systemAlerts}
name="Memory" name="Memory"
title="Memory usage" title="Memory Usage"
description="Triggers when memory usage exceeds a threshold." description="Triggers when memory usage exceeds a threshold."
/> />
<AlertWithSlider <AlertWithSlider
system={system} system={system}
alerts={systemAlerts} alerts={systemAlerts}
name="Disk" name="Disk"
title="Disk usage" title="Disk Usage"
description="Triggers when disk usage exceeds a threshold." description="Triggers when disk usage exceeds a threshold."
/> />
</div> </div>
@@ -104,11 +104,11 @@ function AlertStatus({ system, alerts }: { system: SystemRecord; alerts: AlertRe
return ( return (
<label <label
htmlFor="alert-status" htmlFor="alert-status"
className="space-y-2 flex flex-row items-center justify-between rounded-lg border p-4 cursor-pointer" className="flex flex-row items-center justify-between gap-4 rounded-lg border p-4 cursor-pointer"
> >
<div className="grid gap-0.5 select-none"> <div className="grid gap-1 select-none">
<p className="font-medium text-[1.05em]">System status</p> <p className="font-semibold">System Status</p>
<span className="block text-[0.85em] text-foreground opacity-80"> <span className="block text-sm text-foreground opacity-80">
Triggers when status switches between up and down. Triggers when status switches between up and down.
</span> </span>
</div> </div>
@@ -171,13 +171,13 @@ function AlertWithSlider({
<div className="rounded-lg border"> <div className="rounded-lg border">
<label <label
htmlFor={`alert-${name}`} htmlFor={`alert-${name}`}
className={cn('space-y-2 flex flex-row items-center justify-between 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-0.5 select-none"> <div className="grid gap-1 select-none">
<p className="font-medium text-[1.05em]">{title}</p> <p className="font-semibold">{title}</p>
<span className="block text-[0.85em] text-foreground opacity-80">{description}</span> <span className="block text-sm text-foreground opacity-80">{description}</span>
</div> </div>
<Switch <Switch
id={`alert-${name}`} id={`alert-${name}`}

View File

@@ -1,28 +1,22 @@
# Beszel \*WIP\* # Beszel
A lightweight server resource monitoring hub with historical data, docker stats, and alerts. A lightweight server resource monitoring hub with historical data, docker stats, and alerts.
<!-- <table width="100%"> [![Docker Image Size (tag)](https://img.shields.io/docker/image-size/henrygd/beszel-agent/0.0.1-alpha.6?logo=docker&label=agent%20image%20size)](https://hub.docker.com/r/henrygd/beszel-agent)
<tbody> [![Docker Image Size (tag)](https://img.shields.io/docker/image-size/henrygd/beszel/0.0.1-alpha.6?logo=docker&label=hub%20image%20size)](https://hub.docker.com/r/henrygd/beszel)
<tr>
<td width="50%"><img src="https://henrygd-assets.b-cdn.net/social-image-server/before-capture.png" alt="example of turso.tech/pricing link which is missing an og:image as of may 11 2024"/></td>
<td width="50%"><img src="https://henrygd-assets.b-cdn.net/social-image-server/after-capture.webp" alt="example of turso.tech/pricing link using an image generated by the server as it's og:image"/></td>
</tr>
</tbody>
</table> -->
<!-- ## Features ![Screenshot of the hub](https://henrygd-assets.b-cdn.net/beszel/screenshot.png)
## Features
- **Lightweight**: Much smaller and less demanding than leading solutions. - **Lightweight**: Much smaller and less demanding than leading solutions.
- **Historical data**: Stats are available for up to 30 days.
- **Docker stats**: CPU and memory usage history for each container. - **Docker stats**: CPU and memory usage history for each container.
- **Alerts**: Lorem ipsum dolor sit amet, consectetur adipiscing elit. - **Alerts**: Configurable alerts for CPU, memory, and disk usage, and system status.
- **Simple**: Lorem ipsum dolor sit amet, consectetur adipiscing elit.
- **Multi-user**: Each user has their own systems. Admins can share systems across users. - **Multi-user**: Each user has their own systems. Admins can share systems across users.
- **Secure**: Lorem ipsum dolor sit amet, consectetur adipiscing elit. - **Simple**: Easy setup and doesn't require anything to be publicly available online.
- **Oauth / OIDC**: Supports many OAuth2 providers and password auth can be disabled. - **OAuth / OIDC**: Supports many OAuth2 providers. Password auth can be disabled.
- **Automated backups**: Automatically back up your data to S3-compatible storage. - **Automated backups**: Automatically back up your data to disk or S3-compatible storage.
- **Open source**: MIT license and no paywalled features. --> - **REST API**: Use your metrics in your own scripts and applications.
## Introduction ## Introduction
@@ -32,9 +26,6 @@ The hub is a web application that provides a dashboard to view and manage your c
The agent runs on each system you want to monitor. It creates a minimal SSH server through which it communicates system metrics to the hub. The agent runs on each system you want to monitor. It creates a minimal SSH server through which it communicates system metrics to the hub.
[![Docker Image Size (tag)](https://img.shields.io/docker/image-size/henrygd/beszel-agent/0.0.1-alpha.3?logo=docker&label=agent%20image%20size)](https://hub.docker.com/r/henrygd/beszel-agent)
[![Docker Image Size (tag)](https://img.shields.io/docker/image-size/henrygd/beszel/0.0.1-alpha.3?logo=docker&label=hub%20image%20size)](https://hub.docker.com/r/henrygd/beszel)
## Getting started ## Getting started
If using the binary instead of docker, ignore 4-5 and run the agent using the binary instead. If using the binary instead of docker, ignore 4-5 and run the agent using the binary instead.
@@ -169,3 +160,8 @@ If it's not set, the agent will try to find the filesystem mounted on `/` and us
Records for longer time periods are made by averaging stats from the shorter time periods. They require the agent to be running uninterrupted for long enough to get a full set of data. Records for longer time periods are made by averaging stats from the shorter time periods. They require the agent to be running uninterrupted for long enough to get a full set of data.
If you pause / unpause the agent for longer than one minute, the data will be incomplete and the timing for the current interval will reset. If you pause / unpause the agent for longer than one minute, the data will be incomplete and the timing for the current interval will reset.
<!--
## Support
My country, the USA, and many others, are actively involved in the genocide of the Palestinian people. I would greatly appreciate any effort you could make to pressure your government to stop enabling this violence. -->