Compare commits

...

22 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
Henry Dollman
14c7e2db8f 0.0.1-alpha.6 2024-07-22 16:52:11 -04:00
Henry Dollman
51ed130b53 update bun install github action to not save lockfile 2024-07-22 16:26:08 -04:00
Henry Dollman
b23034a2a8 0.0.1-alpha.5 2024-07-22 16:16:08 -04:00
Henry Dollman
c060e294f9 alerts for cpu, memory, and disk 2024-07-22 16:14:55 -04:00
Henry Dollman
b1d994a0ff verify auth if error fetching systems 2024-07-22 11:05:14 -04:00
Henry Dollman
8f4659b356 docker updates 2024-07-22 10:40:54 -04:00
Henry Dollman
a0bb97f3e8 remove fallback pub key 2024-07-22 10:40:24 -04:00
Henry Dollman
b81c09c358 change server verbiage to system 2024-07-21 22:37:56 -04:00
Henry Dollman
67cc6cf0bb add license 2024-07-21 22:30:53 -04:00
Henry Dollman
be4a583126 0.0.1-alpha.3 2024-07-21 20:59:43 -04:00
Henry Dollman
75f1cb619b update hub dockerfile 2024-07-21 20:30:32 -04:00
Henry Dollman
09806e8688 fix update scripts 2024-07-21 20:03:14 -04:00
Henry Dollman
bb295ca297 0.0.1-alpha.2 2024-07-21 19:20:42 -04:00
Henry Dollman
765a4c707b update the update scripts 2024-07-21 19:20:20 -04:00
Henry Dollman
683032f919 add action for docker images 2024-07-21 19:19:58 -04:00
30 changed files with 637 additions and 256 deletions

74
.github/workflows/docker-images.yml vendored Normal file
View File

@@ -0,0 +1,74 @@
name: Make docker images
on:
push:
tags:
- '*'
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- image: henrygd/beszel
context: ./hub
dockerfile: ./hub/Dockerfile
- image: henrygd/beszel-agent
context: ./agent
dockerfile: ./agent/Dockerfile
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --no-save --cwd ./hub/site
- name: Build site
run: bun run --cwd ./hub/site build
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker metadata
id: metadata
uses: docker/metadata-action@v5
with:
images: ${{ matrix.image }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# https://github.com/docker/login-action
- name: Login to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: '${{ matrix.context }}'
file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64
push: ${{ github.ref_type == 'tag' }}
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}
cache-from: type=gha
cache-to: type=gha

View File

@@ -21,7 +21,7 @@ jobs:
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --cwd ./hub/site
run: bun install --no-save --cwd ./hub/site
- name: Build site
run: bun run --cwd ./hub/site build

1
.gitignore vendored
View File

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

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 henrygd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -5,8 +5,8 @@ services:
restart: unless-stopped
network_mode: host
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- PORT=45876
- KEY="ssh-ed25519 YOUR_PUBLIC_KEY"
# - FILESYSTEM=/dev/sda1 # set to the correct filesystem for disk I/O stats
PORT: 45876
KEY: 'ssh-ed25519 YOUR_PUBLIC_KEY'
# FILESYSTEM: /dev/sda1 # set to the correct filesystem for disk I/O stats

View File

@@ -2,7 +2,6 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
@@ -11,7 +10,6 @@ import (
"net/http"
"os"
"strings"
"sync"
"time"
sshServer "github.com/gliderlabs/ssh"
@@ -23,10 +21,11 @@ import (
psutilNet "github.com/shirou/gopsutil/v4/net"
)
var Version = "0.0.1-alpha.0"
var Version = "0.0.1-alpha.7"
var containerCpuMap = make(map[string][2]uint64)
var containerCpuMutex = &sync.Mutex{}
// var containerCpuMutex = &sync.Mutex{}
var diskIoStats = DiskIoStats{
Read: 0,
@@ -44,16 +43,16 @@ var netIoStats = NetIoStats{
// client for docker engine api
var client = &http.Client{
Timeout: time.Second * 5,
Timeout: time.Second,
Transport: &http.Transport{
Dial: func(proto, addr string) (net.Conn, error) {
return net.Dial("unix", "/var/run/docker.sock")
},
ForceAttemptHTTP2: false,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
DisableKeepAlives: false,
ForceAttemptHTTP2: false,
IdleConnTimeout: 90 * time.Second,
DisableCompression: true,
MaxIdleConns: 10,
DisableKeepAlives: false,
},
}
@@ -194,20 +193,19 @@ func getDockerStats() ([]ContainerStats, error) {
panic(err)
}
var wg sync.WaitGroup
var containerStats []ContainerStats
for _, ctr := range containers {
wg.Add(1)
go func() {
defer wg.Done()
cstats, err := getContainerStats(ctr)
cstats, err := getContainerStats(ctr)
if err != nil {
// retry once
cstats, err = getContainerStats(ctr)
if err != nil {
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
@@ -221,8 +219,6 @@ func getDockerStats() ([]ContainerStats, error) {
}
}
wg.Wait()
return containerStats, nil
}
@@ -246,8 +242,8 @@ func getContainerStats(ctr Container) (ContainerStats, error) {
// cpu
// add default values to containerCpu if it doesn't exist
containerCpuMutex.Lock()
defer containerCpuMutex.Unlock()
// containerCpuMutex.Lock()
// defer containerCpuMutex.Unlock()
if _, ok := containerCpuMap[name]; !ok {
containerCpuMap[name] = [2]uint64{0, 0}
}
@@ -255,7 +251,7 @@ func getContainerStats(ctr Container) (ContainerStats, error) {
systemDelta := statsJson.CPUStats.SystemUsage - containerCpuMap[name][1]
cpuPct := float64(cpuDelta) / float64(systemDelta) * 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}
@@ -320,8 +316,7 @@ func main() {
if pubKeyEnv, exists := os.LookupEnv("KEY"); exists {
pubKey = []byte(pubKeyEnv)
} else {
pubKey = []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJgPK8kpPOwPFIq6BIa7Bu/xwrjt5VRQCz3az3Glt4jp")
// log.Fatal("KEY environment variable is not set")
log.Fatal("KEY environment variable is not set")
}
if filesystem, exists := os.LookupEnv("FILESYSTEM"); exists {

View File

@@ -2,7 +2,6 @@ package main
import (
"fmt"
"log"
"os"
"strings"
@@ -17,7 +16,10 @@ func updateBeszel() {
currentVersion := semver.MustParse(Version)
fmt.Println("beszel-agent", currentVersion)
fmt.Println("Checking for updates...")
latest, found, err = selfupdate.DetectLatest("henrygd/beszel")
updater, _ := selfupdate.NewUpdater(selfupdate.Config{
Filters: []string{"beszel-agent"},
})
latest, found, err = updater.DetectLatest("henrygd/beszel")
if err != nil {
fmt.Println("Error checking for updates:", err)
@@ -29,7 +31,7 @@ func updateBeszel() {
os.Exit(0)
}
fmt.Println("Latest version", "v", latest.Version)
fmt.Println("Latest version:", latest.Version)
if latest.Version.LTE(currentVersion) {
fmt.Println("You are up to date")
@@ -37,7 +39,7 @@ func updateBeszel() {
}
var binaryPath string
fmt.Printf("Updating from %s to %s...", currentVersion, latest.Version)
fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version)
binaryPath, err = os.Executable()
if err != nil {
fmt.Println("Error getting binary path:", err)
@@ -48,5 +50,5 @@ func updateBeszel() {
fmt.Println("Please try rerunning with sudo. Error:", err)
os.Exit(1)
}
log.Printf("Successfully updated: %s -> %s\n\n%s", currentVersion, latest.Version, strings.TrimSpace(latest.ReleaseNotes))
fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes))
}

142
hub/alerts.go Normal file
View File

@@ -0,0 +1,142 @@
package main
import (
"encoding/json"
"fmt"
"net/mail"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/types"
)
func handleSystemAlerts(newStatus string, newRecord *models.Record, oldRecord *models.Record) {
alertRecords, err := app.Dao().FindRecordsByExpr("alerts",
dbx.NewExp("system = {:system}", dbx.Params{"system": oldRecord.Get("id")}),
)
if err != nil || len(alertRecords) == 0 {
// log.Println("no alerts found for system")
return
}
// log.Println("found alerts", len(alertRecords))
var systemInfo *SystemInfo
for _, alertRecord := range alertRecords {
name := alertRecord.Get("name").(string)
switch name {
case "Status":
handleStatusAlerts(newStatus, oldRecord, alertRecord)
case "CPU", "Memory", "Disk":
if newStatus != "up" {
continue
}
if systemInfo == nil {
systemInfo = getSystemInfo(newRecord)
}
if name == "CPU" {
handleSlidingValueAlert(newRecord, alertRecord, name, systemInfo.Cpu)
} else if name == "Memory" {
handleSlidingValueAlert(newRecord, alertRecord, name, systemInfo.MemPct)
} else if name == "Disk" {
handleSlidingValueAlert(newRecord, alertRecord, name, systemInfo.DiskPct)
}
}
}
}
func getSystemInfo(record *models.Record) *SystemInfo {
var SystemInfo SystemInfo
json.Unmarshal([]byte(record.Get("info").(types.JsonRaw)), &SystemInfo)
return &SystemInfo
}
func handleSlidingValueAlert(newRecord *models.Record, alertRecord *models.Record, name string, curValue float64) {
triggered := alertRecord.Get("triggered").(bool)
threshold := alertRecord.Get("value").(float64)
// fmt.Println(name, curValue, "threshold", threshold, "triggered", triggered)
var subject string
var body string
if !triggered && curValue > threshold {
alertRecord.Set("triggered", true)
systemName := newRecord.Get("name").(string)
subject = fmt.Sprintf("%s usage threshold exceeded on %s", name, systemName)
body = fmt.Sprintf("%s usage on %s is %.1f%%.\n\n- Beszel", name, systemName, curValue)
} else if triggered && curValue <= threshold {
alertRecord.Set("triggered", false)
systemName := newRecord.Get("name").(string)
subject = fmt.Sprintf("%s usage returned below threshold on %s", name, systemName)
body = fmt.Sprintf("%s usage on %s is below threshold at %.1f%%.\n\n%s\n\n- Beszel", name, systemName, curValue, app.Settings().Meta.AppUrl+"/system/"+systemName)
} else {
// fmt.Println(name, "not triggered")
return
}
if err := app.Dao().SaveRecord(alertRecord); err != nil {
// app.Logger().Error("failed to save alert record", "err", err.Error())
return
}
// expand the user relation and send the alert
if errs := app.Dao().ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
// app.Logger().Error("failed to expand user relation", "errs", errs)
return
}
if user := alertRecord.ExpandedOne("user"); user != nil {
sendAlert(EmailData{
to: user.Get("email").(string),
subj: subject,
body: body,
})
}
}
func handleStatusAlerts(newStatus string, oldRecord *models.Record, alertRecord *models.Record) error {
var alertStatus string
switch newStatus {
case "up":
if oldRecord.Get("status") == "down" {
alertStatus = "up"
}
case "down":
if oldRecord.Get("status") == "up" {
alertStatus = "down"
}
}
if alertStatus == "" {
return nil
}
// expand the user relation
if errs := app.Dao().ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
return fmt.Errorf("failed to expand: %v", errs)
}
user := alertRecord.ExpandedOne("user")
if user == nil {
return nil
}
emoji := "\U0001F534"
if alertStatus == "up" {
emoji = "\u2705"
}
// send alert
systemName := oldRecord.Get("name").(string)
sendAlert(EmailData{
to: user.Get("email").(string),
subj: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji),
body: fmt.Sprintf("Connection to %s is %s\n\n- Beszel", systemName, alertStatus),
})
return nil
}
func sendAlert(data EmailData) {
// fmt.Println("sending alert", "to", data.to, "subj", data.subj, "body", data.body)
message := &mailer.Message{
From: mail.Address{
Address: app.Settings().Meta.SenderAddress,
Name: app.Settings().Meta.SenderName,
},
To: []mail.Address{{Address: data.to}},
Subject: data.subj,
Text: data.body,
}
if err := app.NewMailClient().Send(message); err != nil {
app.Logger().Error("Failed to send alert: ", "err", err.Error())
}
}

View File

@@ -6,26 +6,29 @@ WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
# Copy source files
COPY *.go ./
COPY migrations ./migrations
COPY site/dist ./site/dist
COPY site/*.go ./site
RUN apk add --no-cache \
unzip \
ca-certificates
RUN update-ca-certificates
# Build
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /beszel .
# ? -------------------------
FROM alpine:latest
RUN apk add --no-cache \
unzip \
ca-certificates
FROM scratch
COPY --from=builder /beszel /
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY ./site/dist /site/dist
EXPOSE 8080
EXPOSE 8090
ENTRYPOINT [ "/beszel" ]
CMD ["serve", "--http=0.0.0.0:8080"]
CMD ["serve", "--http=0.0.0.0:8090"]

View File

@@ -12,29 +12,29 @@ import (
"log"
"net/http"
"net/http/httputil"
"net/mail"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
"github.com/pocketbase/pocketbase/tools/cron"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh"
)
var Version = "0.0.1-alpha.0"
var Version = "0.0.1-alpha.7"
var app *pocketbase.PocketBase
var serverConnections = make(map[string]Server)
var serverConnections = make(map[string]*Server)
var serverConnectionsLock = sync.Mutex{}
func main() {
app = pocketbase.NewWithConfig(pocketbase.Config{
@@ -175,7 +175,7 @@ func main() {
}
// alerts
handleStatusAlerts(newStatus, oldRecord)
handleSystemAlerts(newStatus, newRecord, oldRecord)
return nil
})
@@ -209,49 +209,53 @@ func startSystemUpdateTicker() {
}
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(
"2hz5ncl8tizk5nx", // collection
"status != 'paused' && updated < {:updated}", // filter
"updated", // sort
numServers, // limit
0, // offset
dbx.Params{"updated": fiftyEightSecondsAgo},
"2hz5ncl8tizk5nx", // collection
"status != 'paused'", // filter
"updated", // sort
-1, // limit
0, // offset
)
// log.Println("records", len(records))
if err != nil {
app.Logger().Error("Failed to query systems: ", "err", err.Error())
return
}
for _, record := range records {
updateSystem(record)
fiftyFiveSecondsAgo := time.Now().UTC().Add(-55 * time.Second)
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) {
var server Server
var server *Server
// check if server connection data exists
if _, ok := serverConnections[record.Id]; ok {
server = serverConnections[record.Id]
} else {
// create server connection struct
server = Server{
server = &Server{
Host: record.Get("host").(string),
Port: record.Get("port").(string),
}
client, err := getServerConnection(&server)
client, err := getServerConnection(server)
if err != nil {
app.Logger().Error("Failed to connect:", "err", err.Error(), "server", server.Host, "port", server.Port)
updateServerStatus(record, "down")
return
}
server.Client = client
serverConnectionsLock.Lock()
serverConnections[record.Id] = server
serverConnectionsLock.Unlock()
}
// get server stats from agent
systemData, err := requestJson(&server)
systemData, err := requestJson(server)
if err != nil {
if err.Error() == "retry" {
// if previous connection was closed, try again
@@ -312,6 +316,8 @@ func deleteServerConnection(record *models.Record) {
if serverConnections[record.Id].Client != nil {
serverConnections[record.Id].Client.Close()
}
serverConnectionsLock.Lock()
defer serverConnectionsLock.Unlock()
delete(serverConnections, record.Id)
}
}
@@ -378,69 +384,6 @@ func requestJson(server *Server) (SystemData, error) {
return systemData, nil
}
func sendAlert(data EmailData) {
message := &mailer.Message{
From: mail.Address{
Address: app.Settings().Meta.SenderAddress,
Name: app.Settings().Meta.SenderName,
},
To: []mail.Address{{Address: data.to}},
Subject: data.subj,
Text: data.body,
}
if err := app.NewMailClient().Send(message); err != nil {
app.Logger().Error("Failed to send alert: ", "err", err.Error())
}
}
func handleStatusAlerts(newStatus string, oldRecord *models.Record) error {
var alertStatus string
switch newStatus {
case "up":
if oldRecord.Get("status") == "down" {
alertStatus = "up"
}
case "down":
if oldRecord.Get("status") == "up" {
alertStatus = "down"
}
}
if alertStatus == "" {
return nil
}
alerts, err := app.Dao().FindRecordsByFilter("alerts", "name = 'status' && system = {:system}", "-created", -1, 0, dbx.Params{
"system": oldRecord.Get("id")})
if err != nil {
log.Println("failed to get users", "err", err.Error())
return nil
}
if len(alerts) == 0 {
return nil
}
// expand the user relation
if errs := app.Dao().ExpandRecords(alerts, []string{"user"}, nil); len(errs) > 0 {
return fmt.Errorf("failed to expand: %v", errs)
}
systemName := oldRecord.Get("name").(string)
emoji := "\U0001F534"
if alertStatus == "up" {
emoji = "\u2705"
}
for _, alert := range alerts {
user := alert.ExpandedOne("user")
if user == nil {
continue
}
// send alert
sendAlert(EmailData{
to: user.Get("email").(string),
subj: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji),
body: fmt.Sprintf("Connection to %s is %s\n\n- Beszel", systemName, alertStatus),
})
}
return nil
}
func getSSHKey() ([]byte, error) {
dataDir := app.DataDir()
// check if the key pair already exists

View File

@@ -15,7 +15,7 @@ func init() {
{
"id": "2hz5ncl8tizk5nx",
"created": "2024-07-07 16:08:20.979Z",
"updated": "2024-07-17 15:27:00.429Z",
"updated": "2024-07-22 19:39:17.434Z",
"name": "systems",
"type": "base",
"system": false,
@@ -102,7 +102,7 @@ func init() {
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"cascadeDelete": true,
"minSelect": null,
"maxSelect": null,
"displayFields": null
@@ -250,7 +250,7 @@ func init() {
{
"id": "_pb_users_auth_",
"created": "2024-07-14 16:25:18.226Z",
"updated": "2024-07-20 00:55:02.071Z",
"updated": "2024-07-22 20:10:20.670Z",
"name": "users",
"type": "auth",
"system": false,
@@ -304,7 +304,7 @@ func init() {
"options": {
"allowEmailAuth": true,
"allowOAuth2Auth": true,
"allowUsernameAuth": true,
"allowUsernameAuth": false,
"exceptEmailDomains": null,
"manageRule": null,
"minPasswordLength": 8,
@@ -316,7 +316,7 @@ func init() {
{
"id": "elngm8x1l60zi2v",
"created": "2024-07-15 01:16:04.044Z",
"updated": "2024-07-15 22:44:12.297Z",
"updated": "2024-07-22 19:13:16.498Z",
"name": "alerts",
"type": "base",
"system": false,
@@ -364,16 +364,43 @@ func init() {
"options": {
"maxSelect": 1,
"values": [
"status"
"Status",
"CPU",
"Memory",
"Disk"
]
}
},
{
"system": false,
"id": "o2ablxvn",
"name": "value",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
},
{
"system": false,
"id": "6hgdf6hs",
"name": "triggered",
"type": "bool",
"required": false,
"presentable": false,
"unique": false,
"options": {}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"viewRule": "",
"createRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"updateRule": null,
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"options": {}
}

Binary file not shown.

View File

@@ -17,6 +17,7 @@
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",

View File

@@ -19,7 +19,7 @@ import { useStore } from '@nanostores/react'
import { copyToClipboard } from '@/lib/utils'
import { SystemStats } from '@/types'
export function AddServerButton() {
export function AddSystemButton() {
const [open, setOpen] = useState(false)
const port = useRef() as MutableRefObject<HTMLInputElement>
const publicKey = useStore($publicKey)
@@ -27,16 +27,16 @@ export function AddServerButton() {
function copyDockerCompose(port: string) {
copyToClipboard(`services:
beszel-agent:
image: 'henrygd/beszel-agent'
container_name: 'beszel-agent'
image: "henrygd/beszel-agent"
container_name: "beszel-agent"
restart: unless-stopped
network_mode: host
volumes:
- /var/run/docker.sock:/var/run/docker.sock
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- PORT=${port}
- KEY="${publicKey}"
# - FILESYSTEM=/dev/sda1 # set to the correct filesystem for disk I/O stats`)
PORT: ${port}
KEY: "${publicKey}"
# FILESYSTEM: /dev/sda1 # set to the correct filesystem for disk I/O stats`)
}
useEffect(() => {

View File

@@ -47,7 +47,13 @@ export default function BandwidthChart({
<YAxis
className="tracking-tighter"
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}
axisLine={false}
unit={' MB/s'}

View File

@@ -69,7 +69,7 @@ export default function ContainerCpuChart({
>
<CartesianGrid vertical={false} />
<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}
tickLine={false}
axisLine={false}

View File

@@ -47,7 +47,7 @@ export default function DiskIoChart({
<YAxis
className="tracking-tighter"
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)

View File

@@ -1,7 +1,4 @@
'use client'
import {
Database,
DatabaseBackupIcon,
Github,
LayoutDashboard,
@@ -30,7 +27,7 @@ import { navigate } from './router'
export default function CommandPalette() {
const [open, setOpen] = useState(false)
const servers = useStore($systems)
const systems = useStore($systems)
useEffect(() => {
const down = (e: KeyboardEvent) => {
@@ -72,22 +69,26 @@ export default function CommandPalette() {
<CommandShortcut>GitHub</CommandShortcut>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Servers">
{servers.map((server) => (
<CommandItem
key={server.id}
onSelect={() => {
navigate(`/server/${server.name}`)
setOpen((open) => !open)
}}
>
<Server className="mr-2 h-4 w-4" />
<span>{server.name}</span>
<CommandShortcut>{server.host}</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
{systems.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Systems">
{systems.map((system) => (
<CommandItem
key={system.id}
onSelect={() => {
navigate(`/system/${system.name}`)
setOpen(false)
}}
>
<Server className="mr-2 h-4 w-4" />
<span>{system.name}</span>
<CommandShortcut>{system.host}</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
</>
)}
{isAdmin() && (
<>
<CommandSeparator />
@@ -95,6 +96,7 @@ export default function CommandPalette() {
<CommandItem
keywords={['pocketbase']}
onSelect={() => {
setOpen(false)
window.open('/_/', '_blank')
}}
>
@@ -104,6 +106,7 @@ export default function CommandPalette() {
</CommandItem>
<CommandItem
onSelect={() => {
setOpen(false)
window.open('/_/#/logs', '_blank')
}}
>
@@ -113,6 +116,7 @@ export default function CommandPalette() {
</CommandItem>
<CommandItem
onSelect={() => {
setOpen(false)
window.open('/_/#/settings/backups', '_blank')
}}
>
@@ -123,6 +127,7 @@ export default function CommandPalette() {
<CommandItem
keywords={['oauth', 'oicd']}
onSelect={() => {
setOpen(false)
window.open('/_/#/settings/auth-providers', '_blank')
}}
>
@@ -133,6 +138,7 @@ export default function CommandPalette() {
<CommandItem
keywords={['email']}
onSelect={() => {
setOpen(false)
window.open('/_/#/settings/mail', '_blank')
}}
>

View File

@@ -281,7 +281,7 @@ export function UserAuthForm({
<Dialog>
<DialogTrigger asChild>
<button type="button" className={cn(buttonVariants({ variant: 'outline' }))}>
<img className="mr-2 h-4 w-4 dark:invert" src="/icons/github.svg" alt="" />
<img className="mr-2 h-4 w-4 dark:invert" src="/static/github.svg" alt="" />
<span className="translate-y-[1px]">GitHub</span>
</button>
</DialogTrigger>

View File

@@ -3,7 +3,7 @@ import { createRouter } from '@nanostores/router'
export const $router = createRouter(
{
home: '/',
server: '/server/:name',
server: '/system/:name',
'forgot-password': '/forgot-password',
},
{ links: false }

View File

@@ -1,7 +1,7 @@
import { Suspense, lazy, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'
const SystemsTable = lazy(() => import('../server-table/systems-table'))
const SystemsTable = lazy(() => import('../systems-table/systems-table'))
export default function () {
useEffect(() => {

View File

@@ -20,14 +20,14 @@ const DiskIoChart = lazy(() => import('../charts/disk-io-chart'))
const BandwidthChart = lazy(() => import('../charts/bandwidth-chart'))
export default function ServerDetail({ name }: { name: string }) {
const servers = useStore($systems)
const systems = useStore($systems)
const updatedSystem = useStore($updatedSystem)
const chartTime = useStore($chartTime)
const [ticks, setTicks] = useState([] as number[])
const [server, setServer] = useState({} as SystemRecord)
const [containers, setContainers] = useState([] as ContainerStatsRecord[])
const [serverStats, setServerStats] = useState([] as SystemStatsRecord[])
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
const [cpuChartData, setCpuChartData] = useState([] as { time: number; cpu: number }[])
const [memChartData, setMemChartData] = useState(
[] as { time: number; mem: number; memUsed: number; memCache: number }[]
@@ -57,7 +57,7 @@ export default function ServerDetail({ name }: { name: string }) {
}, [name])
const resetCharts = useCallback(() => {
setServerStats([])
setSystemStats([])
setCpuChartData([])
setMemChartData([])
setDiskChartData([])
@@ -72,11 +72,11 @@ export default function ServerDetail({ name }: { name: string }) {
if (server.id && server.name === name) {
return
}
const matchingServer = servers.find((s) => s.name === name) as SystemRecord
const matchingServer = systems.find((s) => s.name === name) as SystemRecord
if (matchingServer) {
setServer(matchingServer)
}
}, [name, server, servers])
}, [name, server, systems])
// get stats
useEffect(() => {
@@ -95,7 +95,7 @@ export default function ServerDetail({ name }: { name: string }) {
})
.then((records) => {
// console.log('sctats', records)
setServerStats(records)
setSystemStats(records)
})
}, [server, chartTime])
@@ -107,7 +107,7 @@ export default function ServerDetail({ name }: { name: string }) {
// create cpu / mem / disk data for charts
useEffect(() => {
if (!serverStats.length) {
if (!systemStats.length) {
return
}
const cpuData = [] as typeof cpuChartData
@@ -115,7 +115,7 @@ export default function ServerDetail({ name }: { name: string }) {
const diskData = [] as typeof diskChartData
const diskIoData = [] as typeof diskIoChartData
const networkData = [] as typeof bandwidthChartData
for (let { created, stats } of serverStats) {
for (let { created, stats } of systemStats) {
const time = new Date(created).getTime()
cpuData.push({ time, cpu: stats.cpu })
memData.push({
@@ -133,17 +133,17 @@ export default function ServerDetail({ name }: { name: string }) {
setDiskChartData(diskData)
setDiskIoChartData(diskIoData)
setBandwidthChartData(networkData)
}, [serverStats])
}, [systemStats])
useEffect(() => {
if (!serverStats.length) {
if (!systemStats.length) {
return
}
const now = new Date()
const startTime = chartTimeData[chartTime].getOffset(now)
const scale = scaleTime([startTime.getTime(), now], [0, cpuChartData.length])
setTicks(scale.ticks().map((d) => d.getTime()))
}, [chartTime, serverStats])
}, [chartTime, systemStats])
// get container stats
useEffect(() => {

View File

@@ -59,7 +59,7 @@ import {
import { useMemo, useState } from 'react'
import { $systems, pb } from '@/lib/stores'
import { useStore } from '@nanostores/react'
import { AddServerButton } from '../add-server'
import { AddSystemButton } from '../add-system'
import { cn, copyToClipboard, isReadOnlyUser } from '@/lib/utils'
import AlertsButton from '../table-alerts'
import { navigate } from '../router'
@@ -133,7 +133,7 @@ export default function SystemsTable() {
</span>
)
},
header: ({ column }) => sortableHeader(column, 'Server', Server),
header: ({ column }) => sortableHeader(column, 'System', Server),
},
{
accessorKey: 'info.cpu',
@@ -259,7 +259,7 @@ export default function SystemsTable() {
className="max-w-sm"
/>
<div className={cn('ml-auto flex gap-2', isReadOnlyUser() && 'hidden')}>
<AddServerButton />
<AddSystemButton />
</div>
</div>
<div className="rounded-md border overflow-hidden">
@@ -291,7 +291,7 @@ export default function SystemsTable() {
onClick={(e) => {
const target = e.target as HTMLElement
if (!target.closest('[data-nolink]') && e.currentTarget.contains(target)) {
navigate(`/server/${row.original.name}`)
navigate(`/system/${row.original.name}`)
}
}}
>
@@ -314,7 +314,7 @@ export default function SystemsTable() {
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No servers found
No systems found
</TableCell>
</TableRow>
)}

View File

@@ -13,9 +13,18 @@ import { cn, isAdmin } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { AlertRecord, SystemRecord } from '@/types'
import { useMemo, useState } from 'react'
import { lazy, Suspense, useMemo, useState } from 'react'
import { toast } from './ui/use-toast'
const Slider = lazy(() => import('./ui/slider'))
const failedUpdateToast = () =>
toast({
title: 'Failed to update alert',
description: 'Please check logs for more details.',
variant: 'destructive',
})
export default function AlertsButton({ system }: { system: SystemRecord }) {
const alerts = useStore($alerts)
@@ -38,7 +47,7 @@ export default function AlertsButton({ system }: { system: SystemRecord }) {
/>
</Button>
</DialogTrigger>
<DialogContent>
<DialogContent className="max-h-full overflow-auto">
<DialogHeader>
<DialogTitle className="mb-1">Alerts for {system.name}</DialogTitle>
<DialogDescription>
@@ -54,38 +63,57 @@ export default function AlertsButton({ system }: { system: SystemRecord }) {
to ensure alerts are delivered.{' '}
</span>
)}
Webhook delivery and more alert options will be added in the future.
</DialogDescription>
</DialogHeader>
<Alert system={system} alerts={systemAlerts} />
<div className="grid gap-3">
<AlertStatus system={system} alerts={systemAlerts} />
<AlertWithSlider
system={system}
alerts={systemAlerts}
name="CPU"
title="CPU Usage"
description="Triggers when CPU usage exceeds a threshold."
/>
<AlertWithSlider
system={system}
alerts={systemAlerts}
name="Memory"
title="Memory Usage"
description="Triggers when memory usage exceeds a threshold."
/>
<AlertWithSlider
system={system}
alerts={systemAlerts}
name="Disk"
title="Disk Usage"
description="Triggers when disk usage exceeds a threshold."
/>
</div>
</DialogContent>
</Dialog>
)
}
function Alert({ system, alerts }: { system: SystemRecord; alerts: AlertRecord[] }) {
function AlertStatus({ system, alerts }: { system: SystemRecord; alerts: AlertRecord[] }) {
const [pendingChange, setPendingChange] = useState(false)
const alert = useMemo(() => {
return alerts.find((alert) => alert.name === 'status')
return alerts.find((alert) => alert.name === 'Status')
}, [alerts])
return (
<label
htmlFor="status"
className="space-y-2 flex flex-row items-center justify-between rounded-lg border p-4 cursor-pointer"
htmlFor="alert-status"
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">
<p className="font-medium text-base">System status</p>
<span
id=":r3m:-form-item-description"
className="block text-[0.8rem] text-foreground opacity-80"
>
<div className="grid gap-1 select-none">
<p className="font-semibold">System Status</p>
<span className="block text-sm text-foreground opacity-80">
Triggers when status switches between up and down.
</span>
</div>
<Switch
id="status"
id="alert-status"
className={cn('transition-opacity', pendingChange && 'opacity-40')}
checked={!!alert}
value={!!alert ? 'on' : 'off'}
@@ -101,15 +129,11 @@ function Alert({ system, alerts }: { system: SystemRecord; alerts: AlertRecord[]
pb.collection('alerts').create({
system: system.id,
user: pb.authStore.model!.id,
name: 'status',
name: 'Status',
})
}
} catch (e) {
toast({
title: 'Failed to update alert',
description: 'Please check logs for more details.',
variant: 'destructive',
})
failedUpdateToast()
} finally {
setPendingChange(false)
}
@@ -118,3 +142,93 @@ function Alert({ system, alerts }: { system: SystemRecord; alerts: AlertRecord[]
</label>
)
}
function AlertWithSlider({
system,
alerts,
name,
title,
description,
}: {
system: SystemRecord
alerts: AlertRecord[]
name: string
title: string
description: string
}) {
const [pendingChange, setPendingChange] = useState(false)
const [liveValue, setLiveValue] = useState(50)
const alert = useMemo(() => {
const alert = alerts.find((alert) => alert.name === name)
if (alert) {
setLiveValue(alert.value)
}
return alert
}, [alerts])
return (
<div className="rounded-lg border">
<label
htmlFor={`alert-${name}`}
className={cn('flex flex-row items-center justify-between gap-4 cursor-pointer p-4', {
'pb-0': !!alert,
})}
>
<div className="grid gap-1 select-none">
<p className="font-semibold">{title}</p>
<span className="block text-sm text-foreground opacity-80">{description}</span>
</div>
<Switch
id={`alert-${name}`}
className={cn('transition-opacity', pendingChange && 'opacity-40')}
checked={!!alert}
value={!!alert ? 'on' : 'off'}
onCheckedChange={async (active) => {
if (pendingChange) {
return
}
setPendingChange(true)
try {
if (!active && alert) {
await pb.collection('alerts').delete(alert.id)
} else if (active) {
pb.collection('alerts').create({
system: system.id,
user: pb.authStore.model!.id,
name,
value: liveValue,
})
}
} catch (e) {
failedUpdateToast()
} finally {
setPendingChange(false)
}
}}
/>
</label>
{alert && (
<div className="flex mt-2 mb-3 gap-3 px-4">
<Suspense>
<Slider
defaultValue={[liveValue]}
onValueCommit={(val) => {
pb.collection('alerts').update(alert.id, {
value: val[0],
})
}}
onValueChange={(val) => {
setLiveValue(val[0])
}}
min={10}
max={99}
// step={1}
/>
</Suspense>
<span className="tabular-nums tracking-tighter text-[.92em]">{liveValue}%</span>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,23 @@
import * as React from 'react'
import * as SliderPrimitive from '@radix-ui/react-slider'
import { cn } from '@/lib/utils'
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn('relative flex w-full touch-none select-none items-center', className)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export default Slider

View File

@@ -26,18 +26,26 @@ export async function copyToClipboard(content: string) {
})
}
}
export const updateServerList = () => {
pb.collection<SystemRecord>('systems')
.getFullList({ sort: '+name' })
.then((records) => {
$systems.set(records)
const verifyAuth = () => {
pb.collection('users')
.authRefresh()
.catch(() => {
pb.authStore.clear()
})
}
export const updateSystemList = async () => {
try {
const records = await pb.collection<SystemRecord>('systems').getFullList({ sort: '+name' })
$systems.set(records)
} catch (e) {
verifyAuth()
}
}
export const updateAlerts = () => {
pb.collection('alerts')
.getFullList<AlertRecord>({ fields: 'id,name,system' })
.getFullList<AlertRecord>({ fields: 'id,name,system,value' })
.then((records) => {
$alerts.set(records)
})

View File

@@ -11,7 +11,7 @@ import {
updateAlerts,
updateFavicon,
updateRecordList,
updateServerList,
updateSystemList,
} from './lib/utils.ts'
import { buttonVariants } from './components/ui/button.tsx'
import {
@@ -44,16 +44,16 @@ import {
} from './components/ui/dropdown-menu.tsx'
import { AlertRecord, SystemRecord } from './types'
import { $router, Link, navigate } from './components/router.tsx'
import ServerDetail from './components/routes/server.tsx'
import ServerDetail from './components/routes/system.tsx'
// const ServerDetail = lazy(() => import('./components/routes/server.tsx'))
// const ServerDetail = lazy(() => import('./components/routes/system.tsx'))
const CommandPalette = lazy(() => import('./components/command-palette.tsx'))
const LoginPage = lazy(() => import('./components/login/login.tsx'))
const App = () => {
const page = useStore($router)
const authenticated = useStore($authenticated)
const servers = useStore($systems)
const systems = useStore($systems)
useEffect(() => {
// change auth store on auth change
@@ -61,7 +61,7 @@ const App = () => {
$authenticated.set(pb.authStore.isValid)
})
// get servers / alerts
updateServerList()
updateSystemList()
updateAlerts()
// subscribe to real time updates for systems / alerts
pb.collection<SystemRecord>('systems').subscribe('*', (e) => {
@@ -79,15 +79,15 @@ const App = () => {
// update favicon
useEffect(() => {
if (!authenticated || !servers.length) {
if (!authenticated || !systems.length) {
updateFavicon('favicon.svg')
} else {
let up = false
for (const server of servers) {
if (server.status === 'down') {
for (const system of systems) {
if (system.status === 'down') {
updateFavicon('favicon-red.svg')
return () => updateFavicon('favicon.svg')
} else if (server.status === 'up') {
} else if (system.status === 'up') {
up = true
}
}
@@ -97,7 +97,7 @@ const App = () => {
return () => {
updateFavicon('favicon.svg')
}
}, [authenticated, servers])
}, [authenticated, systems])
if (!page) {
return <h1 className="text-3xl text-center my-14">404</h1>

View File

@@ -2,7 +2,6 @@ package main
import (
"fmt"
"log"
"os"
"strings"
@@ -18,7 +17,10 @@ func updateBeszel(cmd *cobra.Command, args []string) {
currentVersion := semver.MustParse(Version)
fmt.Println("beszel", currentVersion)
fmt.Println("Checking for updates...")
latest, found, err = selfupdate.DetectLatest("henrygd/beszel")
updater, _ := selfupdate.NewUpdater(selfupdate.Config{
Filters: []string{"beszel_"},
})
latest, found, err = updater.DetectLatest("henrygd/beszel")
if err != nil {
fmt.Println("Error checking for updates:", err)
@@ -30,7 +32,7 @@ func updateBeszel(cmd *cobra.Command, args []string) {
os.Exit(0)
}
fmt.Println("Latest version", "v", latest.Version)
fmt.Println("Latest version:", latest.Version)
if latest.Version.LTE(currentVersion) {
fmt.Println("You are up to date")
@@ -38,7 +40,7 @@ func updateBeszel(cmd *cobra.Command, args []string) {
}
var binaryPath string
fmt.Printf("Updating from %s to %s...", currentVersion, latest.Version)
fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version)
binaryPath, err = os.Executable()
if err != nil {
fmt.Println("Error getting binary path:", err)
@@ -49,5 +51,5 @@ func updateBeszel(cmd *cobra.Command, args []string) {
fmt.Println("Please try rerunning with sudo. Error:", err)
os.Exit(1)
}
log.Printf("Successfully updated: %s -> %s\n\n%s", currentVersion, latest.Version, strings.TrimSpace(latest.ReleaseNotes))
fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes))
}

View File

@@ -1,50 +1,57 @@
# Beszel \*WIP\*
# Beszel
A lightweight server resource monitoring hub with historical data, docker stats, and alerts.
<!-- <table width="100%">
<tbody>
<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> -->
[![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)
[![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)
<!-- ## Features
![Screenshot of the hub](https://henrygd-assets.b-cdn.net/beszel/screenshot.png)
## Features
- **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.
- **Alerts**: Lorem ipsum dolor sit amet, consectetur adipiscing elit.
- **Simple**: Lorem ipsum dolor sit amet, consectetur adipiscing elit.
- **Alerts**: Configurable alerts for CPU, memory, and disk usage, and system status.
- **Multi-user**: Each user has their own systems. Admins can share systems across users.
- **Secure**: Lorem ipsum dolor sit amet, consectetur adipiscing elit.
- **Oauth / OIDC**: Supports many OAuth2 providers and password auth can be disabled.
- **Automated backups**: Automatically back up your data to S3-compatible storage.
- **Open source**: MIT license and no paywalled features. -->
- **Simple**: Easy setup and doesn't require anything to be publicly available online.
- **OAuth / OIDC**: Supports many OAuth2 providers. Password auth can be disabled.
- **Automated backups**: Automatically back up your data to disk or S3-compatible storage.
- **REST API**: Use your metrics in your own scripts and applications.
## Introduction
Beszel has two components: the hub and the agent.
The hub is a web application, built on top of [PocketBase](https://pocketbase.io/), that provides a dashboard to view and manage your connected systems.
The hub is a web application that provides a dashboard to view and manage your connected systems. It's built on top of [PocketBase](https://pocketbase.io/).
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.
## Getting started
If using the binary instead of docker, ignore 4-5 and run the agent using the binary instead.
1. Start the hub (see [Installation](#installation)). The binary command is `beszel serve`.
2. Open http://localhost:8090 and create an admin user.
3. Click "Add system." Enter the name and host of the system you want to monitor.
4. Click "Copy docker compose" to copy the agent's docker-compose.yml file to your clipboard.
5. On the agent system, create the compose file and run `docker compose up` to start the agent.
6. Back in the hub, click the "Add system" button in the dialog to finish adding the system.
If all goes well, you should see the system flip to green. If it goes red, check the Logs page, and see [troubleshooting tips](#faq--troubleshooting).
## Installation
The hub and agent are distributed as single binary files, as well as docker images.
You may choose to install the hub and agent as single binaries, or as docker images.
### Docker
**Hub**: See the example [docker-compose.yml](/hub/docker-compose.yml) file.
**Agent**: The hub provides compose content when adding a system to monitor, but you can also reference the example [docker-compose.yml](/agent/docker-compose.yml) file.
**Agent**: The hub provides compose content for the agent, but you can also reference the example [docker-compose.yml](/agent/docker-compose.yml) file.
The agent uses the `host` network mode, which automatically exposes the port. So change the port using an environment variable if you need to. It's set up this way so that can access stats for your host network interfaces.
The agent uses the host network mode so it can access network interface stats. This automatically exposes the port, so change the port using an environment variable if you need to.
If you don't want to use the host network, you may remove that line from the compose file and manually expose the port. This will prevent the network stats from populating.
If you don't need network stats, remove that line from the compose file and map the port manually.
> **Note**: The docker version of the agent cannot automatically detect the filesystem to use for disk I/O stats, so include the `FILESYSTEM` environment variable if you want that to work ([instructions here](#finding-the-correct-filesystem)).
@@ -78,10 +85,11 @@ Use `beszel update` and `beszel-agent update` to update to the latest version.
### Agent
| Name | Default | Description |
| ------------ | ------- | ------------------------------------------------ |
| `FILESYSTEM` | unset | Filesystem / partition to use for disk I/O stats |
| `PORT` | 45876 | Port to listen on |
| Name | Default | Description |
| ------------ | ------- | ---------------------------------------------------------- |
| `FILESYSTEM` | unset | Filesystem / partition to use for disk I/O stats |
| `KEY` | unset | Public SSH key to use for authentication. Provided in hub. |
| `PORT` | 45876 | Port to listen on |
## OAuth / OIDC setup
@@ -152,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.
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. -->