Compare commits

...

21 Commits

Author SHA1 Message Date
henrygd
71f081da20 add :edge image tag 2025-07-08 20:40:51 -04:00
henrygd
11c61bcf42 update translations for 0.12.0 beta 2025-07-08 19:28:30 -04:00
henrygd
402a1584d7 Add CBOR and agent initiated WebSocket connections (#51, #490, #646, #845, etc)
- Add version exchange between hub and agent.
- Introduce ConnectionManager for managing WebSocket and SSH connections.
- Implement fingerprint generation and storage in agent.
- Create expiry map package to store universal tokens.
- Update config.yml configuration to include tokens.
- Enhance system management with new methods for handling system states and alerts.
- Update front-end components to support token / fingerprint management features.
- Introduce utility functions for token generation and hub URL retrieval.

Co-authored-by: nhas <jordanatararimu@gmail.com>
2025-07-08 18:41:36 -04:00
henrygd
99d61a0193 update makefile and other tiny refactoring
- remove goccy/json dep
- add explicit types in gpu.go
2025-07-08 18:21:14 -04:00
henrygd
5ddb200a75 improve memory efficiency of records.go 2025-07-08 18:03:49 -04:00
henrygd
faa247dbda add agent data directory handling 2025-07-08 16:45:14 -04:00
henrygd
6d1cec3c42 new agent healthcheck to support non-ssh connections 2025-07-08 16:43:33 -04:00
henrygd
529df84273 make sure agent container has /tmp directory 2025-07-08 15:35:50 -04:00
henrygd
e0e21eedd6 update deps 2025-07-08 15:35:06 -04:00
henrygd
4356ffbe9b add install scripts for beta versions 2025-07-08 15:33:26 -04:00
henrygd
be1366b785 update docker workflow to clearly handle beta / rc tags 2025-07-08 15:28:23 -04:00
henrygd
3dc7e02ed0 exclude bond network interfaces by default 2025-07-08 15:27:33 -04:00
henrygd
d67d638a6b Refactor dockerManager
- Introduced a reusable buffer and JSON decoder in dockerManager for efficient decoding of Docker API responses.
- Adjusted network statistics calculations to ensure accurate data handling.
2025-07-08 15:27:01 -04:00
henrygd
7b36036455 add vulncheck workflow 2025-07-03 21:06:26 -04:00
Sven van Ginkel
1b58560acf Update MakeFile to serve outside local host (#934) 2025-06-27 21:41:16 -04:00
henrygd
1627c41f84 fix gpu name issue introduced in previous commit 2025-06-27 18:00:47 -04:00
henrygd
4395520a28 Probable fix for Jetson gpu issue (#895) 2025-06-26 22:11:48 -04:00
Alexander Mnich
8c52f30a71 add GITHUB_TOKEN fallback for goreleaser (#925)
adding the fallback to the GITHUB_TOKEN allows execution of goreleaser in a fork without additional configuration
2025-06-26 21:03:19 -04:00
SSU
46316ebffa fix(install): suppress scoop output to avoid nssm path pollution (#918)
Suppressed the output of “scoop install beszel-agent” to ensure the NSSM service path
contains only the executable location.

Closes #915

Co-authored-by: suseol <suseol@geosr.com>
2025-06-25 13:52:45 -04:00
henrygd
0b04f60b6c Add panic recovery for sensors.TemperaturesWithContext (#796) 2025-06-23 19:50:11 -04:00
HansAndreManfredson
20b822d072 Fix missing groups #892 (#893) 2025-06-17 16:08:32 -04:00
102 changed files with 9675 additions and 1734 deletions

View File

@@ -3,7 +3,7 @@ name: Make docker images
on:
push:
tags:
- 'v*'
- "v*"
jobs:
build:
@@ -65,6 +65,7 @@ jobs:
with:
images: ${{ matrix.image }}
tags: |
type=edge,enable=true
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
@@ -84,7 +85,7 @@ jobs:
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: '${{ matrix.context }}'
context: "${{ matrix.context }}"
file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: ${{ github.ref_type == 'tag' }}

View File

@@ -39,4 +39,4 @@ jobs:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.TOKEN }}
GITHUB_TOKEN: ${{ secrets.TOKEN || secrets.GITHUB_TOKEN }}

33
.github/workflows/vulncheck.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
# https://github.com/minio/minio/blob/master/.github/workflows/vulncheck.yml
name: VulnCheck
on:
pull_request:
branches:
- main
push:
branches:
- main
permissions:
contents: read # to fetch code (actions/checkout)
jobs:
vulncheck:
name: Analysis
runs-on: ubuntu-latest
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: 1.24.x
cached: false
- name: Get official govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
shell: bash
- name: Run govulncheck
run: govulncheck -C ./beszel -show verbose ./...
shell: bash

View File

@@ -47,18 +47,18 @@ generate-locales:
dev-server: generate-locales
cd ./site
@if command -v bun >/dev/null 2>&1; then \
cd ./site && bun run dev; \
cd ./site && bun run dev --host 0.0.0.0; \
else \
cd ./site && npm run dev; \
cd ./site && npm run dev --host 0.0.0.0; \
fi
dev-hub: export ENV=dev
dev-hub:
mkdir -p ./site/dist && touch ./site/dist/index.html
@if command -v entr >/dev/null 2>&1; then \
find ./cmd/hub/*.go ./internal/{alerts,hub,records,users}/*.go | entr -r -s "cd ./cmd/hub && go run . serve"; \
find ./cmd/hub ./internal/{alerts,hub,records,users} -name "*.go" | entr -r -s "cd ./cmd/hub && go run . serve"; \
else \
cd ./cmd/hub && go run . serve; \
cd ./cmd/hub && go run . serve --http 0.0.0.0:8090; \
fi
dev-agent:

View File

@@ -3,6 +3,7 @@ package main
import (
"beszel"
"beszel/internal/agent"
"beszel/internal/agent/health"
"flag"
"fmt"
"log"
@@ -50,12 +51,7 @@ func (opts *cmdOptions) parse() bool {
agent.Update()
return true
case "health":
// for health, we need to parse flags first to get the listen address
args := append(os.Args[2:], subcommand)
flag.CommandLine.Parse(args)
addr := opts.getAddress()
network := agent.GetNetwork(addr)
err := agent.Health(addr, network)
err := health.Check()
if err != nil {
log.Fatal(err)
}
@@ -115,8 +111,12 @@ func main() {
serverConfig.Addr = addr
serverConfig.Network = agent.GetNetwork(addr)
agent := agent.NewAgent()
if err := agent.StartServer(serverConfig); err != nil {
log.Fatal("Failed to start server:", err)
agent, err := agent.NewAgent("")
if err != nil {
log.Fatal("Failed to create agent: ", err)
}
if err := agent.Start(serverConfig); err != nil {
log.Fatal("Failed to start server: ", err)
}
}

View File

@@ -12,9 +12,15 @@ COPY internal ./internal
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent
RUN rm -rf /tmp/*
# ? -------------------------
FROM scratch
COPY --from=builder /agent /agent
ENTRYPOINT ["/agent"]
# this is so we don't need to create the
# /tmp directory in the scratch container
COPY --from=builder /tmp /tmp
ENTRYPOINT ["/agent"]

View File

@@ -1,24 +1,26 @@
module beszel
go 1.24.2
go 1.24.4
// lock shoutrrr to specific version to allow review before updating
replace github.com/nicholas-fedor/shoutrrr => github.com/nicholas-fedor/shoutrrr v0.8.8
require (
github.com/blang/semver v3.5.1+incompatible
github.com/fxamacker/cbor/v2 v2.8.0
github.com/gliderlabs/ssh v0.3.8
github.com/goccy/go-json v0.10.5
github.com/nicholas-fedor/shoutrrr v0.8.8
github.com/google/uuid v1.6.0
github.com/lxzan/gws v1.8.9
github.com/nicholas-fedor/shoutrrr v0.8.15
github.com/pocketbase/dbx v1.11.0
github.com/pocketbase/pocketbase v0.27.1
github.com/pocketbase/pocketbase v0.28.4
github.com/rhysd/go-github-selfupdate v1.2.3
github.com/shirou/gopsutil/v4 v4.25.3
github.com/spf13/cast v1.7.1
github.com/shirou/gopsutil/v4 v4.25.6
github.com/spf13/cast v1.9.2
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.37.0
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0
golang.org/x/crypto v0.39.0
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
gopkg.in/yaml.v3 v3.0.1
)
@@ -27,9 +29,10 @@ require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/dolthub/maphash v0.1.0 // indirect
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.8.2 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
@@ -39,9 +42,9 @@ require (
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/google/go-github/v30 v30.1.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -54,15 +57,16 @@ require (
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/ulikunitz/xz v0.5.12 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/image v0.26.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/oauth2 v0.29.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
modernc.org/libc v1.64.0 // indirect
golang.org/x/image v0.28.0 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.26.0 // indirect
modernc.org/libc v1.65.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.10.0 // indirect
modernc.org/sqlite v1.37.0 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.38.0 // indirect
)

View File

@@ -13,17 +13,21 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
@@ -42,8 +46,6 @@ github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtS
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -68,6 +70,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jarcoal/httpmock v1.4.0 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k=
github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -77,6 +81,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -97,8 +103,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.27.1 h1:KGCsS8idUVTC5QHxTj91qHDhIXOb5Yb50wwHhNvJRTQ=
github.com/pocketbase/pocketbase v0.27.1/go.mod h1:aTpwwloVJzeJ7MlwTRrbI/x62QNR2/kkCrovmyrXpqs=
github.com/pocketbase/pocketbase v0.28.4 h1:RmhWXDcfKrFM9/W0G0Zrlv4eKBM8/s/v4SQKytjgD20=
github.com/pocketbase/pocketbase v0.28.4/go.mod h1:jSuN93vE/oeJVOz2D2ZxcYyr2bYNmDOMCUkM+JhyJQ0=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -108,10 +114,10 @@ github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzx
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE=
github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
@@ -129,35 +135,37 @@ github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XV
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -165,19 +173,19 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -193,26 +201,26 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA=
modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc=
modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY=
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA=
modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.64.0 h1:U0k8BD2d3cD3e9I8RLcZgJBHAcsJzbXx5mKGSb5pyJA=
modernc.org/libc v1.64.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc=
modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI=
modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -4,34 +4,55 @@ package agent
import (
"beszel"
"beszel/internal/entities/system"
"crypto/sha256"
"encoding/hex"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/gliderlabs/ssh"
"github.com/shirou/gopsutil/v4/host"
gossh "golang.org/x/crypto/ssh"
)
type Agent struct {
sync.Mutex // Used to lock agent while collecting data
debug bool // true if LOG_LEVEL is set to debug
zfs bool // true if system has arcstats
memCalc string // Memory calculation formula
fsNames []string // List of filesystem device names being monitored
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
netInterfaces map[string]struct{} // Stores all valid network interfaces
netIoStats system.NetIoStats // Keeps track of bandwidth usage
dockerManager *dockerManager // Manages Docker API requests
sensorConfig *SensorConfig // Sensors config
systemInfo system.Info // Host system info
gpuManager *GPUManager // Manages GPU data
cache *SessionCache // Cache for system stats based on primary session ID
sync.Mutex // Used to lock agent while collecting data
debug bool // true if LOG_LEVEL is set to debug
zfs bool // true if system has arcstats
memCalc string // Memory calculation formula
fsNames []string // List of filesystem device names being monitored
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
netInterfaces map[string]struct{} // Stores all valid network interfaces
netIoStats system.NetIoStats // Keeps track of bandwidth usage
dockerManager *dockerManager // Manages Docker API requests
sensorConfig *SensorConfig // Sensors config
systemInfo system.Info // Host system info
gpuManager *GPUManager // Manages GPU data
cache *SessionCache // Cache for system stats based on primary session ID
connectionManager *ConnectionManager // Channel to signal connection events
server *ssh.Server // SSH server
dataDir string // Directory for persisting data
keys []gossh.PublicKey // SSH public keys
}
func NewAgent() *Agent {
agent := &Agent{
// NewAgent creates a new agent with the given data directory for persisting data.
// If the data directory is not set, it will attempt to find the optimal directory.
func NewAgent(dataDir string) (agent *Agent, err error) {
agent = &Agent{
fsStats: make(map[string]*system.FsStats),
cache: NewSessionCache(69 * time.Second),
}
agent.dataDir, err = getDataDir(dataDir)
if err != nil {
slog.Warn("Data directory not found")
} else {
slog.Info("Data directory", "path", agent.dataDir)
}
agent.memCalc, _ = GetEnv("MEM_CALC")
agent.sensorConfig = agent.newSensorConfig()
// Set up slog with a log level determined by the LOG_LEVEL env var
@@ -49,10 +70,19 @@ func NewAgent() *Agent {
slog.Debug(beszel.Version)
// initialize system info / docker manager
// initialize system info
agent.initializeSystemInfo()
// initialize connection manager
agent.connectionManager = newConnectionManager(agent)
// initialize disk info
agent.initializeDiskInfo()
// initialize net io stats
agent.initializeNetIoStats()
// initialize docker manager
agent.dockerManager = newDockerManager(agent)
// initialize GPU manager
@@ -67,7 +97,7 @@ func NewAgent() *Agent {
slog.Debug("Stats", "data", agent.gatherStats(""))
}
return agent
return agent, nil
}
// GetEnv retrieves an environment variable with a "BESZEL_AGENT_" prefix, or falls back to the unprefixed key.
@@ -115,3 +145,38 @@ func (a *Agent) gatherStats(sessionID string) *system.CombinedData {
a.cache.Set(sessionID, cachedData)
return cachedData
}
// StartAgent initializes and starts the agent with optional WebSocket connection
func (a *Agent) Start(serverOptions ServerOptions) error {
a.keys = serverOptions.Keys
return a.connectionManager.Start(serverOptions)
}
func (a *Agent) getFingerprint() string {
// first look for a fingerprint in the data directory
if a.dataDir != "" {
if fp, err := os.ReadFile(filepath.Join(a.dataDir, "fingerprint")); err == nil {
return string(fp)
}
}
// if no fingerprint is found, generate one
fingerprint, err := host.HostID()
if err != nil || fingerprint == "" {
fingerprint = a.systemInfo.Hostname + a.systemInfo.CpuModel
}
// hash fingerprint
sum := sha256.Sum256([]byte(fingerprint))
fingerprint = hex.EncodeToString(sum[:24])
// save fingerprint to data directory
if a.dataDir != "" {
err = os.WriteFile(filepath.Join(a.dataDir, "fingerprint"), []byte(fingerprint), 0644)
if err != nil {
slog.Warn("Failed to save fingerprint", "err", err)
}
}
return fingerprint
}

View File

@@ -1,3 +1,6 @@
//go:build testing
// +build testing
package agent
import (

View File

@@ -0,0 +1,9 @@
//go:build testing
// +build testing
package agent
// TESTING ONLY: GetConnectionManager is a helper function to get the connection manager for testing.
func (a *Agent) GetConnectionManager() *ConnectionManager {
return a.connectionManager
}

View File

@@ -0,0 +1,243 @@
package agent
import (
"beszel"
"beszel/internal/common"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"path"
"strings"
"time"
"github.com/fxamacker/cbor/v2"
"github.com/lxzan/gws"
"golang.org/x/crypto/ssh"
)
const (
wsDeadline = 70 * time.Second
)
// WebSocketClient manages the WebSocket connection between the agent and hub.
// It handles authentication, message routing, and connection lifecycle management.
type WebSocketClient struct {
gws.BuiltinEventHandler
options *gws.ClientOption // WebSocket client configuration options
agent *Agent // Reference to the parent agent
Conn *gws.Conn // Active WebSocket connection
hubURL *url.URL // Parsed hub URL for connection
token string // Authentication token for hub registration
fingerprint string // System fingerprint for identification
hubRequest *common.HubRequest[cbor.RawMessage] // Reusable request structure for message parsing
lastConnectAttempt time.Time // Timestamp of last connection attempt
hubVerified bool // Whether the hub has been cryptographically verified
}
// newWebSocketClient creates a new WebSocket client for the given agent.
// It reads configuration from environment variables and validates the hub URL.
func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
hubURLStr, exists := GetEnv("HUB_URL")
if !exists {
return nil, errors.New("HUB_URL environment variable not set")
}
client = &WebSocketClient{}
client.hubURL, err = url.Parse(hubURLStr)
if err != nil {
return nil, errors.New("invalid hub URL")
}
// get registration token
client.token, _ = GetEnv("TOKEN")
if client.token == "" {
return nil, errors.New("TOKEN environment variable not set")
}
client.agent = agent
client.hubRequest = &common.HubRequest[cbor.RawMessage]{}
client.fingerprint = agent.getFingerprint()
return client, nil
}
// getOptions returns the WebSocket client options, creating them if necessary.
// It configures the connection URL, TLS settings, and authentication headers.
func (client *WebSocketClient) getOptions() *gws.ClientOption {
if client.options != nil {
return client.options
}
// update the hub url to use websocket scheme and api path
if client.hubURL.Scheme == "https" {
client.hubURL.Scheme = "wss"
} else {
client.hubURL.Scheme = "ws"
}
client.hubURL.Path = path.Join(client.hubURL.Path, "api/beszel/agent-connect")
client.options = &gws.ClientOption{
Addr: client.hubURL.String(),
TlsConfig: &tls.Config{InsecureSkipVerify: true},
RequestHeader: http.Header{
"User-Agent": []string{getUserAgent()},
"X-Token": []string{client.token},
"X-Beszel": []string{beszel.Version},
},
}
return client.options
}
// Connect establishes a WebSocket connection to the hub.
// It closes any existing connection before attempting to reconnect.
func (client *WebSocketClient) Connect() (err error) {
client.lastConnectAttempt = time.Now()
// make sure previous connection is closed
client.Close()
client.Conn, _, err = gws.NewClient(client, client.getOptions())
if err != nil {
return err
}
go client.Conn.ReadLoop()
return nil
}
// OnOpen handles WebSocket connection establishment.
// It sets a deadline for the connection to prevent hanging.
func (client *WebSocketClient) OnOpen(conn *gws.Conn) {
conn.SetDeadline(time.Now().Add(wsDeadline))
}
// OnClose handles WebSocket connection closure.
// It logs the closure reason and notifies the connection manager.
func (client *WebSocketClient) OnClose(conn *gws.Conn, err error) {
slog.Warn("Connection closed", "err", strings.TrimPrefix(err.Error(), "gws: "))
client.agent.connectionManager.eventChan <- WebSocketDisconnect
}
// OnMessage handles incoming WebSocket messages from the hub.
// It decodes CBOR messages and routes them to appropriate handlers.
func (client *WebSocketClient) OnMessage(conn *gws.Conn, message *gws.Message) {
defer message.Close()
conn.SetDeadline(time.Now().Add(wsDeadline))
if message.Opcode != gws.OpcodeBinary {
return
}
if err := cbor.NewDecoder(message.Data).Decode(client.hubRequest); err != nil {
slog.Error("Error parsing message", "err", err)
return
}
if err := client.handleHubRequest(client.hubRequest); err != nil {
slog.Error("Error handling message", "err", err)
}
}
// OnPing handles WebSocket ping frames.
// It responds with a pong and updates the connection deadline.
func (client *WebSocketClient) OnPing(conn *gws.Conn, message []byte) {
conn.SetDeadline(time.Now().Add(wsDeadline))
conn.WritePong(message)
}
// handleAuthChallenge verifies the authenticity of the hub and returns the system's fingerprint.
func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.RawMessage]) (err error) {
var authRequest common.FingerprintRequest
if err := cbor.Unmarshal(msg.Data, &authRequest); err != nil {
return err
}
if err := client.verifySignature(authRequest.Signature); err != nil {
return err
}
client.hubVerified = true
client.agent.connectionManager.eventChan <- WebSocketConnect
response := &common.FingerprintResponse{
Fingerprint: client.fingerprint,
}
if authRequest.NeedSysInfo {
response.Hostname = client.agent.systemInfo.Hostname
serverAddr := client.agent.connectionManager.serverOptions.Addr
_, response.Port, _ = net.SplitHostPort(serverAddr)
}
return client.sendMessage(response)
}
// verifySignature verifies the signature of the token using the public keys.
func (client *WebSocketClient) verifySignature(signature []byte) (err error) {
for _, pubKey := range client.agent.keys {
sig := ssh.Signature{
Format: pubKey.Type(),
Blob: signature,
}
if err = pubKey.Verify([]byte(client.token), &sig); err == nil {
return nil
}
}
return errors.New("invalid signature - check KEY value")
}
// Close closes the WebSocket connection gracefully.
// This method is safe to call multiple times.
func (client *WebSocketClient) Close() {
if client.Conn != nil {
_ = client.Conn.WriteClose(1000, nil)
}
}
// handleHubRequest routes the request to the appropriate handler.
// It ensures the hub is verified before processing most requests.
func (client *WebSocketClient) handleHubRequest(msg *common.HubRequest[cbor.RawMessage]) error {
if !client.hubVerified && msg.Action != common.CheckFingerprint {
return errors.New("hub not verified")
}
switch msg.Action {
case common.GetData:
return client.sendSystemData()
case common.CheckFingerprint:
return client.handleAuthChallenge(msg)
}
return nil
}
// sendSystemData gathers and sends current system statistics to the hub.
func (client *WebSocketClient) sendSystemData() error {
sysStats := client.agent.gatherStats(client.token)
return client.sendMessage(sysStats)
}
// sendMessage encodes the given data to CBOR and sends it as a binary message over the WebSocket connection to the hub.
func (client *WebSocketClient) sendMessage(data any) error {
bytes, err := cbor.Marshal(data)
if err != nil {
return err
}
return client.Conn.WriteMessage(gws.OpcodeBinary, bytes)
}
// getUserAgent returns one of two User-Agent strings based on current time.
// This is used to avoid being blocked by Cloudflare or other anti-bot measures.
func getUserAgent() string {
const (
uaBase = "Mozilla/5.0 (%s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
uaWindows = "Windows NT 11.0; Win64; x64"
uaMac = "Macintosh; Intel Mac OS X 14_0_0"
)
if time.Now().UnixNano()%2 == 0 {
return fmt.Sprintf(uaBase, uaWindows)
}
return fmt.Sprintf(uaBase, uaMac)
}

View File

@@ -0,0 +1,220 @@
package agent
import (
"beszel/internal/agent/health"
"errors"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
)
// ConnectionManager manages the connection state and events for the agent.
// It handles both WebSocket and SSH connections, automatically switching between
// them based on availability and managing reconnection attempts.
type ConnectionManager struct {
agent *Agent // Reference to the parent agent
State ConnectionState // Current connection state
eventChan chan ConnectionEvent // Channel for connection events
wsClient *WebSocketClient // WebSocket client for hub communication
serverOptions ServerOptions // Configuration for SSH server
wsTicker *time.Ticker // Ticker for WebSocket connection attempts
isConnecting bool // Prevents multiple simultaneous reconnection attempts
}
// ConnectionState represents the current connection state of the agent.
type ConnectionState uint8
// ConnectionEvent represents connection-related events that can occur.
type ConnectionEvent uint8
// Connection states
const (
Disconnected ConnectionState = iota // No active connection
WebSocketConnected // Connected via WebSocket
SSHConnected // Connected via SSH
)
// Connection events
const (
WebSocketConnect ConnectionEvent = iota // WebSocket connection established
WebSocketDisconnect // WebSocket connection lost
SSHConnect // SSH connection established
SSHDisconnect // SSH connection lost
)
const wsTickerInterval = 10 * time.Second
// newConnectionManager creates a new connection manager for the given agent.
func newConnectionManager(agent *Agent) *ConnectionManager {
cm := &ConnectionManager{
agent: agent,
State: Disconnected,
}
return cm
}
// startWsTicker starts or resets the WebSocket connection attempt ticker.
func (c *ConnectionManager) startWsTicker() {
if c.wsTicker == nil {
c.wsTicker = time.NewTicker(wsTickerInterval)
} else {
c.wsTicker.Reset(wsTickerInterval)
}
}
// stopWsTicker stops the WebSocket connection attempt ticker.
func (c *ConnectionManager) stopWsTicker() {
if c.wsTicker != nil {
c.wsTicker.Stop()
}
}
// Start begins connection attempts and enters the main event loop.
// It handles connection events, periodic health updates, and graceful shutdown.
func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
if c.eventChan != nil {
return errors.New("already started")
}
wsClient, err := newWebSocketClient(c.agent)
if err != nil {
slog.Warn("Error creating WebSocket client", "err", err)
}
c.wsClient = wsClient
c.serverOptions = serverOptions
c.eventChan = make(chan ConnectionEvent, 1)
// signal handling for shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
c.startWsTicker()
c.connect()
// update health status immediately and every 90 seconds
_ = health.Update()
healthTicker := time.Tick(90 * time.Second)
for {
select {
case connectionEvent := <-c.eventChan:
c.handleEvent(connectionEvent)
case <-c.wsTicker.C:
_ = c.startWebSocketConnection()
case <-healthTicker:
_ = health.Update()
case <-sigChan:
slog.Info("Shutting down")
_ = c.agent.StopServer()
c.closeWebSocket()
return health.CleanUp()
}
}
}
// handleEvent processes connection events and updates the connection state accordingly.
func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
switch event {
case WebSocketConnect:
c.handleStateChange(WebSocketConnected)
case SSHConnect:
c.handleStateChange(SSHConnected)
case WebSocketDisconnect:
if c.State == WebSocketConnected {
c.handleStateChange(Disconnected)
}
case SSHDisconnect:
if c.State == SSHConnected {
c.handleStateChange(Disconnected)
}
}
}
// handleStateChange updates the connection state and performs necessary actions
// based on the new state, including stopping services and initiating reconnections.
func (c *ConnectionManager) handleStateChange(newState ConnectionState) {
if c.State == newState {
return
}
c.State = newState
switch newState {
case WebSocketConnected:
slog.Info("WebSocket connected", "host", c.wsClient.hubURL.Host)
c.stopWsTicker()
_ = c.agent.StopServer()
c.isConnecting = false
case SSHConnected:
// stop new ws connection attempts
slog.Info("SSH connection established")
c.stopWsTicker()
c.isConnecting = false
case Disconnected:
if c.isConnecting {
// Already handling reconnection, avoid duplicate attempts
return
}
c.isConnecting = true
slog.Warn("Disconnected from hub")
// make sure old ws connection is closed
c.closeWebSocket()
// reconnect
go c.connect()
}
}
// connect handles the connection logic with proper delays and priority.
// It attempts WebSocket connection first, falling back to SSH server if needed.
func (c *ConnectionManager) connect() {
c.isConnecting = true
defer func() {
c.isConnecting = false
}()
if c.wsClient != nil && time.Since(c.wsClient.lastConnectAttempt) < 5*time.Second {
time.Sleep(5 * time.Second)
}
// Try WebSocket first, if it fails, start SSH server
err := c.startWebSocketConnection()
if err != nil && c.State == Disconnected {
c.startSSHServer()
c.startWsTicker()
}
}
// startWebSocketConnection attempts to establish a WebSocket connection to the hub.
func (c *ConnectionManager) startWebSocketConnection() error {
if c.State != Disconnected {
return errors.New("already connected")
}
if c.wsClient == nil {
return errors.New("WebSocket client not initialized")
}
if time.Since(c.wsClient.lastConnectAttempt) < 5*time.Second {
return errors.New("already connecting")
}
err := c.wsClient.Connect()
if err != nil {
slog.Warn("WebSocket connection failed", "err", err)
c.closeWebSocket()
}
return err
}
// startSSHServer starts the SSH server if the agent is currently disconnected.
func (c *ConnectionManager) startSSHServer() {
if c.State == Disconnected {
go c.agent.StartServer(c.serverOptions)
}
}
// closeWebSocket closes the WebSocket connection if it exists.
func (c *ConnectionManager) closeWebSocket() {
if c.wsClient != nil {
c.wsClient.Close()
}
}

View File

@@ -0,0 +1,315 @@
//go:build testing
// +build testing
package agent
import (
"crypto/ed25519"
"fmt"
"net"
"net/url"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
func createTestAgent(t *testing.T) *Agent {
dataDir := t.TempDir()
agent, err := NewAgent(dataDir)
require.NoError(t, err)
return agent
}
func createTestServerOptions(t *testing.T) ServerOptions {
// Generate test key pair
_, privKey, err := ed25519.GenerateKey(nil)
require.NoError(t, err)
sshPubKey, err := ssh.NewPublicKey(privKey.Public().(ed25519.PublicKey))
require.NoError(t, err)
// Find available port
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
port := listener.Addr().(*net.TCPAddr).Port
listener.Close()
return ServerOptions{
Network: "tcp",
Addr: fmt.Sprintf("127.0.0.1:%d", port),
Keys: []ssh.PublicKey{sshPubKey},
}
}
// TestConnectionManager_NewConnectionManager tests connection manager creation
func TestConnectionManager_NewConnectionManager(t *testing.T) {
agent := createTestAgent(t)
cm := newConnectionManager(agent)
assert.NotNil(t, cm, "Connection manager should not be nil")
assert.Equal(t, agent, cm.agent, "Agent reference should be set")
assert.Equal(t, Disconnected, cm.State, "Initial state should be Disconnected")
assert.Nil(t, cm.eventChan, "Event channel should be nil initially")
assert.Nil(t, cm.wsClient, "WebSocket client should be nil initially")
assert.Nil(t, cm.wsTicker, "WebSocket ticker should be nil initially")
assert.False(t, cm.isConnecting, "isConnecting should be false initially")
}
// TestConnectionManager_StateTransitions tests basic state transitions
func TestConnectionManager_StateTransitions(t *testing.T) {
agent := createTestAgent(t)
cm := agent.connectionManager
initialState := cm.State
cm.wsClient = &WebSocketClient{
hubURL: &url.URL{
Host: "localhost:8080",
},
}
assert.NotNil(t, cm, "Connection manager should not be nil")
assert.Equal(t, Disconnected, initialState, "Initial state should be Disconnected")
// Test state transitions
cm.handleStateChange(WebSocketConnected)
assert.Equal(t, WebSocketConnected, cm.State, "State should change to WebSocketConnected")
cm.handleStateChange(SSHConnected)
assert.Equal(t, SSHConnected, cm.State, "State should change to SSHConnected")
cm.handleStateChange(Disconnected)
assert.Equal(t, Disconnected, cm.State, "State should change to Disconnected")
// Test that same state doesn't trigger changes
cm.State = WebSocketConnected
cm.handleStateChange(WebSocketConnected)
assert.Equal(t, WebSocketConnected, cm.State, "Same state should not trigger change")
}
// TestConnectionManager_EventHandling tests event handling logic
func TestConnectionManager_EventHandling(t *testing.T) {
agent := createTestAgent(t)
cm := agent.connectionManager
cm.wsClient = &WebSocketClient{
hubURL: &url.URL{
Host: "localhost:8080",
},
}
testCases := []struct {
name string
initialState ConnectionState
event ConnectionEvent
expectedState ConnectionState
}{
{
name: "WebSocket connect from disconnected",
initialState: Disconnected,
event: WebSocketConnect,
expectedState: WebSocketConnected,
},
{
name: "SSH connect from disconnected",
initialState: Disconnected,
event: SSHConnect,
expectedState: SSHConnected,
},
{
name: "WebSocket disconnect from connected",
initialState: WebSocketConnected,
event: WebSocketDisconnect,
expectedState: Disconnected,
},
{
name: "SSH disconnect from connected",
initialState: SSHConnected,
event: SSHDisconnect,
expectedState: Disconnected,
},
{
name: "WebSocket disconnect from SSH connected (no change)",
initialState: SSHConnected,
event: WebSocketDisconnect,
expectedState: SSHConnected,
},
{
name: "SSH disconnect from WebSocket connected (no change)",
initialState: WebSocketConnected,
event: SSHDisconnect,
expectedState: WebSocketConnected,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
cm.State = tc.initialState
cm.handleEvent(tc.event)
assert.Equal(t, tc.expectedState, cm.State, "State should match expected after event")
})
}
}
// TestConnectionManager_TickerManagement tests WebSocket ticker management
func TestConnectionManager_TickerManagement(t *testing.T) {
agent := createTestAgent(t)
cm := agent.connectionManager
// Test starting ticker
cm.startWsTicker()
assert.NotNil(t, cm.wsTicker, "Ticker should be created")
// Test stopping ticker (should not panic)
assert.NotPanics(t, func() {
cm.stopWsTicker()
}, "Stopping ticker should not panic")
// Test stopping nil ticker (should not panic)
cm.wsTicker = nil
assert.NotPanics(t, func() {
cm.stopWsTicker()
}, "Stopping nil ticker should not panic")
// Test restarting ticker
cm.startWsTicker()
assert.NotNil(t, cm.wsTicker, "Ticker should be recreated")
// Test resetting existing ticker
firstTicker := cm.wsTicker
cm.startWsTicker()
assert.Equal(t, firstTicker, cm.wsTicker, "Same ticker instance should be reused")
cm.stopWsTicker()
}
// TestConnectionManager_WebSocketConnectionFlow tests WebSocket connection logic
func TestConnectionManager_WebSocketConnectionFlow(t *testing.T) {
if testing.Short() {
t.Skip("Skipping WebSocket connection test in short mode")
}
agent := createTestAgent(t)
cm := agent.connectionManager
// Test WebSocket connection without proper environment
err := cm.startWebSocketConnection()
assert.Error(t, err, "WebSocket connection should fail without proper environment")
assert.Equal(t, Disconnected, cm.State, "State should remain Disconnected after failed connection")
// Test with invalid URL
os.Setenv("BESZEL_AGENT_HUB_URL", "invalid-url")
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
// Test with missing token
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
os.Unsetenv("BESZEL_AGENT_TOKEN")
_, err2 := newWebSocketClient(agent)
assert.Error(t, err2, "WebSocket client creation should fail without token")
}
// TestConnectionManager_ReconnectionLogic tests reconnection prevention logic
func TestConnectionManager_ReconnectionLogic(t *testing.T) {
agent := createTestAgent(t)
cm := agent.connectionManager
cm.eventChan = make(chan ConnectionEvent, 1)
// Test that isConnecting flag prevents duplicate reconnection attempts
// Start from connected state, then simulate disconnect
cm.State = WebSocketConnected
cm.isConnecting = false
// First disconnect should trigger reconnection logic
cm.handleStateChange(Disconnected)
assert.Equal(t, Disconnected, cm.State, "Should change to disconnected")
assert.True(t, cm.isConnecting, "Should set isConnecting flag")
}
// TestConnectionManager_ConnectWithRateLimit tests connection rate limiting
func TestConnectionManager_ConnectWithRateLimit(t *testing.T) {
agent := createTestAgent(t)
cm := agent.connectionManager
// Set up environment for WebSocket client creation
os.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
// Create WebSocket client
wsClient, err := newWebSocketClient(agent)
require.NoError(t, err)
cm.wsClient = wsClient
// Set recent connection attempt
cm.wsClient.lastConnectAttempt = time.Now()
// Test that connection is rate limited
err = cm.startWebSocketConnection()
assert.Error(t, err, "Should error due to rate limiting")
assert.Contains(t, err.Error(), "already connecting", "Error should indicate rate limiting")
// Test connection after rate limit expires
cm.wsClient.lastConnectAttempt = time.Now().Add(-10 * time.Second)
err = cm.startWebSocketConnection()
// This will fail due to no actual server, but should not be rate limited
assert.Error(t, err, "Connection should fail but not due to rate limiting")
assert.NotContains(t, err.Error(), "already connecting", "Error should not indicate rate limiting")
}
// TestConnectionManager_StartWithInvalidConfig tests starting with invalid configuration
func TestConnectionManager_StartWithInvalidConfig(t *testing.T) {
agent := createTestAgent(t)
cm := agent.connectionManager
serverOptions := createTestServerOptions(t)
// Test starting when already started
cm.eventChan = make(chan ConnectionEvent, 5)
err := cm.Start(serverOptions)
assert.Error(t, err, "Should error when starting already started connection manager")
}
// TestConnectionManager_CloseWebSocket tests WebSocket closing
func TestConnectionManager_CloseWebSocket(t *testing.T) {
agent := createTestAgent(t)
cm := agent.connectionManager
// Test closing when no WebSocket client exists
assert.NotPanics(t, func() {
cm.closeWebSocket()
}, "Should not panic when closing nil WebSocket client")
// Set up environment and create WebSocket client
os.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
wsClient, err := newWebSocketClient(agent)
require.NoError(t, err)
cm.wsClient = wsClient
// Test closing when WebSocket client exists
assert.NotPanics(t, func() {
cm.closeWebSocket()
}, "Should not panic when closing WebSocket client")
}
// TestConnectionManager_ConnectFlow tests the connect method
func TestConnectionManager_ConnectFlow(t *testing.T) {
agent := createTestAgent(t)
cm := agent.connectionManager
// Test connect without WebSocket client
assert.NotPanics(t, func() {
cm.connect()
}, "Connect should not panic without WebSocket client")
}

View File

@@ -0,0 +1,122 @@
package agent
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
)
// getDataDir returns the path to the data directory for the agent and an error
// if the directory is not valid. Pass an empty string to attempt to find the
// optimal data directory.
func getDataDir(dataDir string) (string, error) {
if dataDir == "" {
dataDir, _ = GetEnv("DATA_DIR")
}
if dataDir != "" {
return testDataDirs([]string{dataDir})
}
var dirsToTry []string
if runtime.GOOS == "windows" {
dirsToTry = []string{
filepath.Join(os.Getenv("APPDATA"), "beszel-agent"),
filepath.Join(os.Getenv("LOCALAPPDATA"), "beszel-agent"),
}
} else {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
dirsToTry = []string{
"/var/lib/beszel-agent",
filepath.Join(homeDir, ".config", "beszel"),
}
}
return testDataDirs(dirsToTry)
}
func testDataDirs(paths []string) (string, error) {
// first check if the directory exists and is writable
for _, path := range paths {
if valid, _ := isValidDataDir(path, false); valid {
return path, nil
}
}
// if the directory doesn't exist, try to create it
for _, path := range paths {
exists, _ := directoryExists(path)
if exists {
continue
}
if err := os.MkdirAll(path, 0755); err != nil {
continue
}
// Verify the created directory is actually writable
writable, _ := directoryIsWritable(path)
if !writable {
continue
}
return path, nil
}
return "", errors.New("data directory not found")
}
func isValidDataDir(path string, createIfNotExists bool) (bool, error) {
exists, err := directoryExists(path)
if err != nil {
return false, err
}
if !exists {
if !createIfNotExists {
return false, nil
}
if err = os.MkdirAll(path, 0755); err != nil {
return false, err
}
}
// Always check if the directory is writable
writable, err := directoryIsWritable(path)
if err != nil {
return false, err
}
return writable, nil
}
// directoryExists checks if a directory exists
func directoryExists(path string) (bool, error) {
// Check if directory exists
stat, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
if !stat.IsDir() {
return false, fmt.Errorf("%s is not a directory", path)
}
return true, nil
}
// directoryIsWritable tests if a directory is writable by creating and removing a temporary file
func directoryIsWritable(path string) (bool, error) {
testFile := filepath.Join(path, ".write-test")
file, err := os.Create(testFile)
if err != nil {
return false, err
}
defer file.Close()
defer os.Remove(testFile)
return true, nil
}

View File

@@ -0,0 +1,263 @@
//go:build testing
// +build testing
package agent
import (
"os"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetDataDir(t *testing.T) {
// Test with explicit dataDir parameter
t.Run("explicit data dir", func(t *testing.T) {
tempDir := t.TempDir()
result, err := getDataDir(tempDir)
require.NoError(t, err)
assert.Equal(t, tempDir, result)
})
// Test with explicit non-existent dataDir that can be created
t.Run("explicit data dir - create new", func(t *testing.T) {
tempDir := t.TempDir()
newDir := filepath.Join(tempDir, "new-data-dir")
result, err := getDataDir(newDir)
require.NoError(t, err)
assert.Equal(t, newDir, result)
// Verify directory was created
stat, err := os.Stat(newDir)
require.NoError(t, err)
assert.True(t, stat.IsDir())
})
// Test with DATA_DIR environment variable
t.Run("DATA_DIR environment variable", func(t *testing.T) {
tempDir := t.TempDir()
// Set environment variable
oldValue := os.Getenv("DATA_DIR")
defer func() {
if oldValue == "" {
os.Unsetenv("DATA_DIR")
} else {
os.Setenv("DATA_DIR", oldValue)
}
}()
os.Setenv("DATA_DIR", tempDir)
result, err := getDataDir("")
require.NoError(t, err)
assert.Equal(t, tempDir, result)
})
// Test with invalid explicit dataDir
t.Run("invalid explicit data dir", func(t *testing.T) {
invalidPath := "/invalid/path/that/cannot/be/created"
_, err := getDataDir(invalidPath)
assert.Error(t, err)
})
// Test fallback behavior (empty dataDir, no env var)
t.Run("fallback to default directories", func(t *testing.T) {
// Clear DATA_DIR environment variable
oldValue := os.Getenv("DATA_DIR")
defer func() {
if oldValue == "" {
os.Unsetenv("DATA_DIR")
} else {
os.Setenv("DATA_DIR", oldValue)
}
}()
os.Unsetenv("DATA_DIR")
// This will try platform-specific defaults, which may or may not work
// We're mainly testing that it doesn't panic and returns some result
result, err := getDataDir("")
// We don't assert success/failure here since it depends on system permissions
// Just verify we get a string result if no error
if err == nil {
assert.NotEmpty(t, result)
}
})
}
func TestTestDataDirs(t *testing.T) {
// Test with existing valid directory
t.Run("existing valid directory", func(t *testing.T) {
tempDir := t.TempDir()
result, err := testDataDirs([]string{tempDir})
require.NoError(t, err)
assert.Equal(t, tempDir, result)
})
// Test with multiple directories, first one valid
t.Run("multiple dirs - first valid", func(t *testing.T) {
tempDir := t.TempDir()
invalidDir := "/invalid/path"
result, err := testDataDirs([]string{tempDir, invalidDir})
require.NoError(t, err)
assert.Equal(t, tempDir, result)
})
// Test with multiple directories, second one valid
t.Run("multiple dirs - second valid", func(t *testing.T) {
tempDir := t.TempDir()
invalidDir := "/invalid/path"
result, err := testDataDirs([]string{invalidDir, tempDir})
require.NoError(t, err)
assert.Equal(t, tempDir, result)
})
// Test with non-existing directory that can be created
t.Run("create new directory", func(t *testing.T) {
tempDir := t.TempDir()
newDir := filepath.Join(tempDir, "new-dir")
result, err := testDataDirs([]string{newDir})
require.NoError(t, err)
assert.Equal(t, newDir, result)
// Verify directory was created
stat, err := os.Stat(newDir)
require.NoError(t, err)
assert.True(t, stat.IsDir())
})
// Test with no valid directories
t.Run("no valid directories", func(t *testing.T) {
invalidPaths := []string{"/invalid/path1", "/invalid/path2"}
_, err := testDataDirs(invalidPaths)
assert.Error(t, err)
assert.Contains(t, err.Error(), "data directory not found")
})
}
func TestIsValidDataDir(t *testing.T) {
// Test with existing directory
t.Run("existing directory", func(t *testing.T) {
tempDir := t.TempDir()
valid, err := isValidDataDir(tempDir, false)
require.NoError(t, err)
assert.True(t, valid)
})
// Test with non-existing directory, createIfNotExists=false
t.Run("non-existing dir - no create", func(t *testing.T) {
tempDir := t.TempDir()
nonExistentDir := filepath.Join(tempDir, "does-not-exist")
valid, err := isValidDataDir(nonExistentDir, false)
require.NoError(t, err)
assert.False(t, valid)
})
// Test with non-existing directory, createIfNotExists=true
t.Run("non-existing dir - create", func(t *testing.T) {
tempDir := t.TempDir()
newDir := filepath.Join(tempDir, "new-dir")
valid, err := isValidDataDir(newDir, true)
require.NoError(t, err)
assert.True(t, valid)
// Verify directory was created
stat, err := os.Stat(newDir)
require.NoError(t, err)
assert.True(t, stat.IsDir())
})
// Test with file instead of directory
t.Run("file instead of directory", func(t *testing.T) {
tempDir := t.TempDir()
tempFile := filepath.Join(tempDir, "testfile")
err := os.WriteFile(tempFile, []byte("test"), 0644)
require.NoError(t, err)
valid, err := isValidDataDir(tempFile, false)
assert.Error(t, err)
assert.False(t, valid)
assert.Contains(t, err.Error(), "is not a directory")
})
}
func TestDirectoryExists(t *testing.T) {
// Test with existing directory
t.Run("existing directory", func(t *testing.T) {
tempDir := t.TempDir()
exists, err := directoryExists(tempDir)
require.NoError(t, err)
assert.True(t, exists)
})
// Test with non-existing directory
t.Run("non-existing directory", func(t *testing.T) {
tempDir := t.TempDir()
nonExistentDir := filepath.Join(tempDir, "does-not-exist")
exists, err := directoryExists(nonExistentDir)
require.NoError(t, err)
assert.False(t, exists)
})
// Test with file instead of directory
t.Run("file instead of directory", func(t *testing.T) {
tempDir := t.TempDir()
tempFile := filepath.Join(tempDir, "testfile")
err := os.WriteFile(tempFile, []byte("test"), 0644)
require.NoError(t, err)
exists, err := directoryExists(tempFile)
assert.Error(t, err)
assert.False(t, exists)
assert.Contains(t, err.Error(), "is not a directory")
})
}
func TestDirectoryIsWritable(t *testing.T) {
// Test with writable directory
t.Run("writable directory", func(t *testing.T) {
tempDir := t.TempDir()
writable, err := directoryIsWritable(tempDir)
require.NoError(t, err)
assert.True(t, writable)
})
// Test with non-existing directory
t.Run("non-existing directory", func(t *testing.T) {
tempDir := t.TempDir()
nonExistentDir := filepath.Join(tempDir, "does-not-exist")
writable, err := directoryIsWritable(nonExistentDir)
assert.Error(t, err)
assert.False(t, writable)
})
// Test with non-writable directory (Unix-like systems only)
t.Run("non-writable directory", func(t *testing.T) {
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
t.Skip("Skipping non-writable directory test on", runtime.GOOS)
}
tempDir := t.TempDir()
readOnlyDir := filepath.Join(tempDir, "readonly")
// Create the directory
err := os.Mkdir(readOnlyDir, 0755)
require.NoError(t, err)
// Make it read-only
err = os.Chmod(readOnlyDir, 0444)
require.NoError(t, err)
// Restore permissions after test for cleanup
defer func() {
os.Chmod(readOnlyDir, 0755)
}()
writable, err := directoryIsWritable(readOnlyDir)
assert.Error(t, err)
assert.False(t, writable)
})
}

View File

@@ -2,6 +2,7 @@ package agent
import (
"beszel/internal/entities/container"
"bytes"
"context"
"encoding/json"
"fmt"
@@ -27,6 +28,9 @@ type dockerManager struct {
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
isWindows bool // Whether the Docker Engine API is running on Windows
buf *bytes.Buffer // Buffer to store and read response bodies
decoder *json.Decoder // Reusable JSON decoder that reads from buf
apiStats *container.ApiStats // Reusable API stats object
}
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
@@ -63,10 +67,9 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
if err != nil {
return nil, err
}
defer resp.Body.Close()
dm.apiContainerList = dm.apiContainerList[:0]
if err := json.NewDecoder(resp.Body).Decode(&dm.apiContainerList); err != nil {
if err := dm.decode(resp, &dm.apiContainerList); err != nil {
return nil, err
}
@@ -83,7 +86,8 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
var failedContainers []*container.ApiInfo
for _, ctr := range dm.apiContainerList {
for i := range dm.apiContainerList {
ctr := dm.apiContainerList[i]
ctr.IdShort = ctr.Id[:12]
dm.validIds[ctr.IdShort] = struct{}{}
// check if container is less than 1 minute old (possible restart)
@@ -111,7 +115,8 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
// retry failed containers separately so we can run them in parallel (docker 24 bug)
if len(failedContainers) > 0 {
slog.Debug("Retrying failed containers", "count", len(failedContainers))
for _, ctr := range failedContainers {
for i := range failedContainers {
ctr := failedContainers[i]
dm.queue()
go func() {
defer dm.dequeue()
@@ -164,8 +169,13 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
stats.NetworkRecv = 0
// docker host container stats response
var res container.ApiStats
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
// res := dm.getApiStats()
// defer dm.putApiStats(res)
//
res := dm.apiStats
res.Networks = nil
if err := dm.decode(resp, res); err != nil {
return err
}
@@ -173,9 +183,14 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
var usedMemory uint64
var cpuPct float64
// store current cpu stats
prevCpuContainer, prevCpuSystem := stats.CpuContainer, stats.CpuSystem
stats.CpuContainer = res.CPUStats.CPUUsage.TotalUsage
stats.CpuSystem = res.CPUStats.SystemUsage
if dm.isWindows {
usedMemory = res.MemoryStats.PrivateWorkingSet
cpuPct = res.CalculateCpuPercentWindows(stats.PrevCpu[0], stats.PrevRead)
cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, stats.PrevReadTime)
} else {
// check if container has valid data, otherwise may be in restart loop (#103)
if res.MemoryStats.Usage == 0 {
@@ -187,13 +202,12 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
}
usedMemory = res.MemoryStats.Usage - memCache
cpuPct = res.CalculateCpuPercentLinux(stats.PrevCpu)
cpuPct = res.CalculateCpuPercentLinux(prevCpuContainer, prevCpuSystem)
}
if cpuPct > 100 {
return fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct)
}
stats.PrevCpu = [2]uint64{res.CPUStats.CPUUsage.TotalUsage, res.CPUStats.SystemUsage}
// network
var total_sent, total_recv uint64
@@ -201,21 +215,25 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
total_sent += v.TxBytes
total_recv += v.RxBytes
}
var sent_delta, recv_delta float64
// prevent first run from sending all prev sent/recv bytes
if initialized {
secondsElapsed := time.Since(stats.PrevRead).Seconds()
sent_delta = float64(total_sent-stats.PrevNet.Sent) / secondsElapsed
recv_delta = float64(total_recv-stats.PrevNet.Recv) / secondsElapsed
var sent_delta, recv_delta uint64
millisecondsElapsed := uint64(time.Since(stats.PrevReadTime).Milliseconds())
if initialized && millisecondsElapsed > 0 {
// get bytes per second
sent_delta = (total_sent - stats.PrevNet.Sent) * 1000 / millisecondsElapsed
recv_delta = (total_recv - stats.PrevNet.Recv) * 1000 / millisecondsElapsed
// check for unrealistic network values (> 5GB/s)
if sent_delta > 5e9 || recv_delta > 5e9 {
slog.Warn("Bad network delta", "container", name)
sent_delta, recv_delta = 0, 0
}
}
stats.PrevNet.Sent = total_sent
stats.PrevNet.Recv = total_recv
stats.PrevNet.Sent, stats.PrevNet.Recv = total_sent, total_recv
stats.Cpu = twoDecimals(cpuPct)
stats.Mem = bytesToMegabytes(float64(usedMemory))
stats.NetworkSent = bytesToMegabytes(sent_delta)
stats.NetworkRecv = bytesToMegabytes(recv_delta)
stats.PrevRead = res.Read
stats.NetworkSent = bytesToMegabytes(float64(sent_delta))
stats.NetworkRecv = bytesToMegabytes(float64(recv_delta))
stats.PrevReadTime = res.Read
return nil
}
@@ -231,7 +249,6 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
func newDockerManager(a *Agent) *dockerManager {
dockerHost, exists := GetEnv("DOCKER_HOST")
if exists {
slog.Info("DOCKER_HOST", "host", dockerHost)
// return nil if set to empty string
if dockerHost == "" {
return nil
@@ -242,7 +259,6 @@ func newDockerManager(a *Agent) *dockerManager {
parsedURL, err := url.Parse(dockerHost)
if err != nil {
slog.Error("Error parsing DOCKER_HOST", "err", err)
os.Exit(1)
}
@@ -290,6 +306,7 @@ func newDockerManager(a *Agent) *dockerManager {
containerStatsMap: make(map[string]*container.Stats),
sem: make(chan struct{}, 5),
apiContainerList: []*container.ApiInfo{},
apiStats: &container.ApiStats{},
}
// If using podman, return client
@@ -308,9 +325,8 @@ func newDockerManager(a *Agent) *dockerManager {
if err != nil {
return manager
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&versionInfo); err != nil {
if err := manager.decode(resp, &versionInfo); err != nil {
return manager
}
@@ -324,6 +340,22 @@ func newDockerManager(a *Agent) *dockerManager {
return manager
}
// Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe.
func (dm *dockerManager) decode(resp *http.Response, d any) error {
if dm.buf == nil {
// initialize buffer with 256kb starting size
dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*256))
dm.decoder = json.NewDecoder(dm.buf)
}
defer resp.Body.Close()
defer dm.buf.Reset()
_, err := dm.buf.ReadFrom(resp.Body)
if err != nil {
return err
}
return dm.decoder.Decode(d)
}
// Test docker / podman sockets and return if one exists
func getDockerHost() string {
scheme := "unix://"

View File

@@ -18,24 +18,24 @@ import (
const (
// Commands
nvidiaSmiCmd = "nvidia-smi"
rocmSmiCmd = "rocm-smi"
tegraStatsCmd = "tegrastats"
nvidiaSmiCmd string = "nvidia-smi"
rocmSmiCmd string = "rocm-smi"
tegraStatsCmd string = "tegrastats"
// Polling intervals
nvidiaSmiInterval = "4" // in seconds
tegraStatsInterval = "3700" // in milliseconds
rocmSmiInterval = 4300 * time.Millisecond
nvidiaSmiInterval string = "4" // in seconds
tegraStatsInterval string = "3700" // in milliseconds
rocmSmiInterval time.Duration = 4300 * time.Millisecond
// Command retry and timeout constants
retryWaitTime = 5 * time.Second
maxFailureRetries = 5
retryWaitTime time.Duration = 5 * time.Second
maxFailureRetries int = 5
cmdBufferSize = 10 * 1024
cmdBufferSize uint16 = 10 * 1024
// Unit Conversions
mebibytesInAMegabyte = 1.024 // nvidia-smi reports memory in MiB
milliwattsInAWatt = 1000.0 // tegrastats reports power in mW
mebibytesInAMegabyte float64 = 1.024 // nvidia-smi reports memory in MiB
milliwattsInAWatt float64 = 1000.0 // tegrastats reports power in mW
)
// GPUManager manages data collection for GPUs (either Nvidia or AMD)
@@ -243,21 +243,26 @@ func (gm *GPUManager) GetCurrentData() map[string]system.GPUData {
// copy / reset the data
gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap))
for id, gpu := range gm.GpuDataMap {
// sum the data
gpu.Temperature = twoDecimals(gpu.Temperature)
gpu.MemoryUsed = twoDecimals(gpu.MemoryUsed)
gpu.MemoryTotal = twoDecimals(gpu.MemoryTotal)
gpu.Usage = twoDecimals(gpu.Usage / gpu.Count)
gpu.Power = twoDecimals(gpu.Power / gpu.Count)
// reset the count
gpu.Count = 1
// dereference to avoid overwriting anything else
gpuCopy := *gpu
gpuAvg := *gpu
gpuAvg.Temperature = twoDecimals(gpu.Temperature)
gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
// avoid division by zero
if gpu.Count > 0 {
gpuAvg.Usage = twoDecimals(gpu.Usage / gpu.Count)
gpuAvg.Power = twoDecimals(gpu.Power / gpu.Count)
}
// reset accumulators in the original
gpu.Usage, gpu.Power, gpu.Count = 0, 0, 0
// append id to the name if there are multiple GPUs with the same name
if nameCounts[gpu.Name] > 1 {
gpuCopy.Name = fmt.Sprintf("%s %s", gpu.Name, id)
gpuAvg.Name = fmt.Sprintf("%s %s", gpu.Name, id)
}
gpuData[id] = gpuCopy
gpuData[id] = gpuAvg
}
slog.Debug("GPU", "data", gpuData)
return gpuData

View File

@@ -279,6 +279,19 @@ func TestParseJetsonData(t *testing.T) {
Count: 1,
},
},
{
name: "orin nano",
input: "06-18-2025 11:25:24 RAM 3452/7620MB (lfb 25x4MB) SWAP 1518/16384MB (cached 174MB) CPU [1%@1420,2%@1420,0%@1420,2%@1420,2%@729,1%@729] GR3D_FREQ 0% cpu@50.031C soc2@49.031C soc0@50C gpu@49.031C tj@50.25C soc1@50.25C VDD_IN 4824mW/4824mW VDD_CPU_GPU_CV 518mW/518mW VDD_SOC 1475mW/1475mW",
wantMetrics: &system.GPUData{
Name: "GPU",
MemoryUsed: 3452.0,
MemoryTotal: 7620.0,
Usage: 0.0,
Temperature: 50.25,
Power: 0.518,
Count: 1,
},
},
{
name: "missing temperature",
input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW",
@@ -318,44 +331,85 @@ func TestParseJetsonData(t *testing.T) {
}
func TestGetCurrentData(t *testing.T) {
gm := &GPUManager{
GpuDataMap: map[string]*system.GPUData{
"0": {
Name: "GPU1",
Temperature: 50,
MemoryUsed: 2048,
MemoryTotal: 4096,
Usage: 100, // 100 over 2 counts = 50 avg
Power: 200, // 200 over 2 counts = 100 avg
Count: 2,
t.Run("calculates averages and resets accumulators", func(t *testing.T) {
gm := &GPUManager{
GpuDataMap: map[string]*system.GPUData{
"0": {
Name: "GPU1",
Temperature: 50,
MemoryUsed: 2048,
MemoryTotal: 4096,
Usage: 100, // 100 over 2 counts = 50 avg
Power: 200, // 200 over 2 counts = 100 avg
Count: 2,
},
"1": {
Name: "GPU1",
Temperature: 60,
MemoryUsed: 3072,
MemoryTotal: 8192,
Usage: 30,
Power: 60,
Count: 1,
},
"2": {
Name: "GPU 2",
Temperature: 70,
MemoryUsed: 4096,
MemoryTotal: 8192,
Usage: 200,
Power: 400,
Count: 1,
},
},
"1": {
Name: "GPU1",
Temperature: 60,
MemoryUsed: 3072,
MemoryTotal: 8192,
Usage: 30,
Power: 60,
Count: 1,
}
result := gm.GetCurrentData()
// Verify name disambiguation
assert.Equal(t, "GPU1 0", result["0"].Name)
assert.Equal(t, "GPU1 1", result["1"].Name)
assert.Equal(t, "GPU 2", result["2"].Name)
// Check averaged values in the result
assert.InDelta(t, 50.0, result["0"].Usage, 0.01)
assert.InDelta(t, 100.0, result["0"].Power, 0.01)
assert.InDelta(t, 30.0, result["1"].Usage, 0.01)
assert.InDelta(t, 60.0, result["1"].Power, 0.01)
// Verify that accumulators in the original map are reset
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Count, "GPU 0 Count should be reset")
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Usage, "GPU 0 Usage should be reset")
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Power, "GPU 0 Power should be reset")
assert.Equal(t, float64(0), gm.GpuDataMap["1"].Count, "GPU 1 Count should be reset")
assert.Equal(t, float64(0), gm.GpuDataMap["1"].Usage, "GPU 1 Usage should be reset")
assert.Equal(t, float64(0), gm.GpuDataMap["1"].Power, "GPU 1 Power should be reset")
})
t.Run("handles zero count without panicking", func(t *testing.T) {
gm := &GPUManager{
GpuDataMap: map[string]*system.GPUData{
"0": {
Name: "TestGPU",
Count: 0,
Usage: 0,
Power: 0,
},
},
},
}
}
result := gm.GetCurrentData()
var result map[string]system.GPUData
assert.NotPanics(t, func() {
result = gm.GetCurrentData()
})
// Verify name disambiguation
assert.Equal(t, "GPU1 0", result["0"].Name)
assert.Equal(t, "GPU1 1", result["1"].Name)
// Check that usage and power are 0
assert.Equal(t, 0.0, result["0"].Usage)
assert.Equal(t, 0.0, result["0"].Power)
// Check averaged values
assert.InDelta(t, 50.0, result["0"].Usage, 0.01)
assert.InDelta(t, 100.0, result["0"].Power, 0.01)
assert.InDelta(t, 30.0, result["1"].Usage, 0.01)
assert.InDelta(t, 60.0, result["1"].Power, 0.01)
// Verify reset counts
assert.Equal(t, float64(1), gm.GpuDataMap["0"].Count)
assert.Equal(t, float64(1), gm.GpuDataMap["1"].Count)
// Verify reset count
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Count)
})
}
func TestDetectGPUs(t *testing.T) {
@@ -722,6 +776,18 @@ func TestAccumulation(t *testing.T) {
assert.InDelta(t, expected.avgUsage, gpu.Usage, 0.01, "Average usage in GetCurrentData should match")
assert.InDelta(t, expected.avgPower, gpu.Power, 0.01, "Average power in GetCurrentData should match")
}
// Verify that accumulators in the original map are reset
for id := range tt.expectedValues {
gpu, exists := gm.GpuDataMap[id]
assert.True(t, exists, "GPU with ID %s should still exist after GetCurrentData", id)
if !exists {
continue
}
assert.Equal(t, float64(0), gpu.Count, "Count should be reset for GPU ID %s", id)
assert.Equal(t, float64(0), gpu.Usage, "Usage should be reset for GPU ID %s", id)
assert.Equal(t, float64(0), gpu.Power, "Power should be reset for GPU ID %s", id)
}
})
}
}

View File

@@ -1,18 +0,0 @@
package agent
import (
"net"
"time"
)
// Health checks if the agent's server is running by attempting to connect to it.
//
// If an error occurs when attempting to connect to the server, it returns the error.
func Health(addr string, network string) error {
conn, err := net.DialTimeout(network, addr, 4*time.Second)
if err != nil {
return err
}
conn.Close()
return nil
}

View File

@@ -0,0 +1,43 @@
// Package health provides functions to check and update the health of the agent.
// It uses a file in the temp directory to store the timestamp of the last connection attempt.
// If the timestamp is older than 90 seconds, the agent is considered unhealthy.
// NB: The agent must be started with the Start() method to be considered healthy.
package health
import (
"errors"
"log"
"os"
"path/filepath"
"time"
)
// healthFile is the path to the health file
var healthFile = filepath.Join(os.TempDir(), "beszel_health")
// Check checks if the agent is connected by checking the modification time of the health file
func Check() error {
fileInfo, err := os.Stat(healthFile)
if err != nil {
return err
}
if time.Since(fileInfo.ModTime()) > 91*time.Second {
log.Println("over 90 seconds since last connection")
return errors.New("unhealthy")
}
return nil
}
// Update updates the modification time of the health file
func Update() error {
file, err := os.Create(healthFile)
if err != nil {
return err
}
return file.Close()
}
// CleanUp removes the health file
func CleanUp() error {
return os.Remove(healthFile)
}

View File

@@ -0,0 +1,67 @@
//go:build testing
// +build testing
package health
import (
"os"
"path/filepath"
"testing"
"time"
"testing/synctest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHealth(t *testing.T) {
// Override healthFile to use a temporary directory for this test.
originalHealthFile := healthFile
tmpDir := t.TempDir()
healthFile = filepath.Join(tmpDir, "beszel_health_test")
defer func() { healthFile = originalHealthFile }()
t.Run("check with no health file", func(t *testing.T) {
err := Check()
require.Error(t, err)
assert.True(t, os.IsNotExist(err), "expected a file-not-exist error, but got: %v", err)
})
t.Run("update and check", func(t *testing.T) {
err := Update()
require.NoError(t, err, "Update() failed")
err = Check()
assert.NoError(t, err, "Check() failed immediately after Update()")
})
// This test uses synctest to simulate time passing.
// NOTE: This test requires GOEXPERIMENT=synctest to run.
t.Run("check with simulated time", func(t *testing.T) {
synctest.Run(func() {
// Update the file to set the initial timestamp.
require.NoError(t, Update(), "Update() failed inside synctest")
// Set the mtime to the current fake time to align the file's timestamp with the simulated clock.
now := time.Now()
require.NoError(t, os.Chtimes(healthFile, now, now), "Chtimes failed")
// Wait a duration less than the threshold.
time.Sleep(89 * time.Second)
synctest.Wait()
// The check should still pass.
assert.NoError(t, Check(), "Check() failed after 89s")
// Wait for the total duration to exceed the threshold.
time.Sleep(5 * time.Second)
synctest.Wait()
// The check should now fail as unhealthy.
err := Check()
require.Error(t, err, "Check() should have failed after 91s")
assert.Equal(t, "unhealthy", err.Error(), "Check() returned wrong error")
})
})
}

View File

@@ -1,118 +0,0 @@
//go:build testing
// +build testing
package agent_test
import (
"fmt"
"net"
"os"
"testing"
"github.com/stretchr/testify/require"
"beszel/internal/agent"
)
// setupTestServer creates a temporary server for testing
func setupTestServer(t *testing.T) (string, func()) {
// Create a temporary socket file for Unix socket testing
tempSockFile := os.TempDir() + "/beszel_health_test.sock"
// Clean up any existing socket file
os.Remove(tempSockFile)
// Create a listener
listener, err := net.Listen("unix", tempSockFile)
require.NoError(t, err, "Failed to create test listener")
// Start a simple server in a goroutine
go func() {
conn, err := listener.Accept()
if err != nil {
return // Listener closed
}
defer conn.Close()
// Just accept the connection and do nothing
}()
// Return the socket file path and a cleanup function
return tempSockFile, func() {
listener.Close()
os.Remove(tempSockFile)
}
}
// setupTCPTestServer creates a temporary TCP server for testing
func setupTCPTestServer(t *testing.T) (string, func()) {
// Listen on a random available port
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err, "Failed to create test listener")
// Get the port that was assigned
addr := listener.Addr().(*net.TCPAddr)
port := addr.Port
// Start a simple server in a goroutine
go func() {
conn, err := listener.Accept()
if err != nil {
return // Listener closed
}
defer conn.Close()
// Just accept the connection and do nothing
}()
// Return the address and a cleanup function
return fmt.Sprintf("127.0.0.1:%d", port), func() {
listener.Close()
}
}
func TestHealth(t *testing.T) {
t.Run("server is running (unix socket)", func(t *testing.T) {
// Setup a test server
sockFile, cleanup := setupTestServer(t)
defer cleanup()
// Run the health check with explicit parameters
err := agent.Health(sockFile, "unix")
require.NoError(t, err, "Failed to check health")
})
t.Run("server is running (tcp address)", func(t *testing.T) {
// Setup a test server
addr, cleanup := setupTCPTestServer(t)
defer cleanup()
// Run the health check with explicit parameters
err := agent.Health(addr, "tcp")
require.NoError(t, err, "Failed to check health")
})
t.Run("server is not running", func(t *testing.T) {
// Use an address that's likely not in use
addr := "127.0.0.1:65535"
// Run the health check with explicit parameters
err := agent.Health(addr, "tcp")
require.Error(t, err, "Health check should return an error when server is not running")
})
t.Run("invalid network", func(t *testing.T) {
// Use an invalid network type
err := agent.Health("127.0.0.1:8080", "invalid_network")
require.Error(t, err, "Health check should return an error with invalid network")
})
t.Run("unix socket not found", func(t *testing.T) {
// Use a non-existent unix socket
nonExistentSocket := os.TempDir() + "/non_existent_socket.sock"
// Make sure it really doesn't exist
os.Remove(nonExistentSocket)
err := agent.Health(nonExistentSocket, "unix")
require.Error(t, err, "Health check should return an error when socket doesn't exist")
})
}

View File

@@ -57,6 +57,7 @@ func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool {
strings.HasPrefix(v.Name, "docker"),
strings.HasPrefix(v.Name, "br-"),
strings.HasPrefix(v.Name, "veth"),
strings.HasPrefix(v.Name, "bond"),
v.BytesRecv == 0,
v.BytesSent == 0:
return true

View File

@@ -3,6 +3,7 @@ package agent
import (
"beszel/internal/entities/system"
"context"
"fmt"
"log/slog"
"path"
"strconv"
@@ -30,6 +31,9 @@ func (a *Agent) newSensorConfig() *SensorConfig {
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, skipCollection)
}
// Matches sensors.TemperaturesWithContext to allow for panic recovery (gopsutil/issues/1832)
type getTempsFn func(ctx context.Context) ([]sensors.TemperatureStat, error)
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal string, skipCollection bool) *SensorConfig {
@@ -78,8 +82,18 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
// reset high temp
a.systemInfo.DashboardTemp = 0
// get sensor data
temps, _ := sensors.TemperaturesWithContext(a.sensorConfig.context)
temps, err := a.getTempsWithPanicRecovery(sensors.TemperaturesWithContext)
if err != nil {
// retry once on panic (gopsutil/issues/1832)
temps, err = a.getTempsWithPanicRecovery(sensors.TemperaturesWithContext)
if err != nil {
slog.Warn("Error updating temperatures", "err", err)
if len(systemStats.Temperatures) > 0 {
systemStats.Temperatures = make(map[string]float64)
}
return
}
}
slog.Debug("Temperature", "sensors", temps)
// return if no sensors
@@ -107,15 +121,28 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
continue
}
// set dashboard temperature
if a.sensorConfig.primarySensor == "" {
switch a.sensorConfig.primarySensor {
case "":
a.systemInfo.DashboardTemp = max(a.systemInfo.DashboardTemp, sensor.Temperature)
} else if a.sensorConfig.primarySensor == sensorName {
case sensorName:
a.systemInfo.DashboardTemp = sensor.Temperature
}
systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature)
}
}
// getTempsWithPanicRecovery wraps sensors.TemperaturesWithContext to recover from panics (gopsutil/issues/1832)
func (a *Agent) getTempsWithPanicRecovery(getTemps getTempsFn) (temps []sensors.TemperatureStat, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
// get sensor data (error ignored intentionally as it may be only with one sensor)
temps, _ = getTemps(a.sensorConfig.context)
return
}
// isValidSensor checks if a sensor is valid based on the sensor name and the sensor config
func isValidSensor(sensorName string, config *SensorConfig) bool {
// if no sensors configured, everything is valid

View File

@@ -4,11 +4,14 @@
package agent
import (
"beszel/internal/entities/system"
"context"
"fmt"
"os"
"testing"
"github.com/shirou/gopsutil/v4/common"
"github.com/shirou/gopsutil/v4/sensors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -454,3 +457,97 @@ func TestScaleTemperatureLogic(t *testing.T) {
result, expected)
})
}
func TestGetTempsWithPanicRecovery(t *testing.T) {
agent := &Agent{
systemInfo: system.Info{},
sensorConfig: &SensorConfig{
context: context.Background(),
},
}
tests := []struct {
name string
getTempsFn getTempsFn
expectError bool
errorMsg string
}{
{
name: "successful_function_call",
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
return []sensors.TemperatureStat{
{SensorKey: "test_sensor", Temperature: 45.0},
}, nil
},
expectError: false,
},
{
name: "function_returns_error",
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
return []sensors.TemperatureStat{
{SensorKey: "test_sensor", Temperature: 45.0},
}, fmt.Errorf("sensor error")
},
expectError: false, // getTempsWithPanicRecovery ignores errors from the function
},
{
name: "function_panics_with_string",
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
panic("test panic")
},
expectError: true,
errorMsg: "panic: test panic",
},
{
name: "function_panics_with_error",
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
panic(fmt.Errorf("panic error"))
},
expectError: true,
errorMsg: "panic:",
},
{
name: "function_panics_with_index_out_of_bounds",
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
slice := []int{1, 2, 3}
_ = slice[10] // out of bounds panic
return nil, nil
},
expectError: true,
errorMsg: "panic:",
},
{
name: "function_panics_with_any_conversion",
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
var i any = "string"
_ = i.(int) // type assertion panic
return nil, nil
},
expectError: true,
errorMsg: "panic:",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var temps []sensors.TemperatureStat
var err error
// The function should not panic, regardless of what the injected function does
assert.NotPanics(t, func() {
temps, err = agent.getTempsWithPanicRecovery(tt.getTempsFn)
}, "getTempsWithPanicRecovery should not panic")
if tt.expectError {
assert.Error(t, err, "Expected an error to be returned")
if tt.errorMsg != "" {
assert.Contains(t, err.Error(), tt.errorMsg,
"Error message should contain expected text")
}
assert.Nil(t, temps, "Temps should be nil when panic occurs")
} else {
assert.NoError(t, err, "Should not return error for successful calls")
}
})
}
}

View File

@@ -1,25 +1,44 @@
package agent
import (
"beszel"
"beszel/internal/common"
"beszel/internal/entities/system"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"os"
"strings"
"time"
"github.com/blang/semver"
"github.com/fxamacker/cbor/v2"
"github.com/gliderlabs/ssh"
gossh "golang.org/x/crypto/ssh"
)
// ServerOptions contains configuration options for starting the SSH server.
type ServerOptions struct {
Addr string
Network string
Keys []gossh.PublicKey
Addr string // Network address to listen on (e.g., ":45876" or "/path/to/socket")
Network string // Network type ("tcp" or "unix")
Keys []gossh.PublicKey // SSH public keys for authentication
}
// hubVersions caches hub versions by session ID to avoid repeated parsing.
var hubVersions map[string]semver.Version
// StartServer starts the SSH server with the provided options.
// It configures the server with secure defaults, sets up authentication,
// and begins listening for connections. Returns an error if the server
// is already running or if there's an issue starting the server.
func (a *Agent) StartServer(opts ServerOptions) error {
if a.server != nil {
return errors.New("server already started")
}
slog.Info("Starting SSH server", "addr", opts.Addr, "network", opts.Network)
if opts.Network == "unix" {
@@ -37,7 +56,9 @@ func (a *Agent) StartServer(opts ServerOptions) error {
defer ln.Close()
// base config (limit to allowed algorithms)
config := &gossh.ServerConfig{}
config := &gossh.ServerConfig{
ServerVersion: fmt.Sprintf("SSH-2.0-%s_%s", beszel.AppName, beszel.Version),
}
config.KeyExchanges = common.DefaultKeyExchanges
config.MACs = common.DefaultMACs
config.Ciphers = common.DefaultCiphers
@@ -45,42 +66,92 @@ func (a *Agent) StartServer(opts ServerOptions) error {
// set default handler
ssh.Handle(a.handleSession)
server := ssh.Server{
a.server = &ssh.Server{
ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig {
return config
},
// check public key(s)
PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool {
remoteAddr := ctx.RemoteAddr()
for _, pubKey := range opts.Keys {
if ssh.KeysEqual(key, pubKey) {
slog.Info("SSH connected", "addr", remoteAddr)
return true
}
}
slog.Warn("Invalid SSH key", "addr", remoteAddr)
return false
},
// disable pty
PtyCallback: func(ctx ssh.Context, pty ssh.Pty) bool {
return false
},
// log failed connections
ConnectionFailedCallback: func(conn net.Conn, err error) {
slog.Warn("Failed connection attempt", "addr", conn.RemoteAddr().String(), "err", err)
},
// close idle connections after 70 seconds
IdleTimeout: 70 * time.Second,
}
// Start SSH server on the listener
return server.Serve(ln)
return a.server.Serve(ln)
}
// getHubVersion retrieves and caches the hub version for a given session.
// It extracts the version from the SSH client version string and caches
// it to avoid repeated parsing. Returns a zero version if parsing fails.
func (a *Agent) getHubVersion(sessionId string, sessionCtx ssh.Context) semver.Version {
if hubVersions == nil {
hubVersions = make(map[string]semver.Version, 1)
}
hubVersion, ok := hubVersions[sessionId]
if ok {
return hubVersion
}
// Extract hub version from SSH client version
clientVersion := sessionCtx.Value(ssh.ContextKeyClientVersion)
if versionStr, ok := clientVersion.(string); ok {
hubVersion, _ = extractHubVersion(versionStr)
}
hubVersions[sessionId] = hubVersion
return hubVersion
}
// handleSession handles an incoming SSH session by gathering system statistics
// and sending them to the hub. It signals connection events, determines the
// appropriate encoding format based on hub version, and exits with appropriate
// status codes.
func (a *Agent) handleSession(s ssh.Session) {
slog.Debug("New session", "client", s.RemoteAddr())
stats := a.gatherStats(s.Context().SessionID())
if err := json.NewEncoder(s).Encode(stats); err != nil {
a.connectionManager.eventChan <- SSHConnect
sessionCtx := s.Context()
sessionID := sessionCtx.SessionID()
hubVersion := a.getHubVersion(sessionID, sessionCtx)
stats := a.gatherStats(sessionID)
err := a.writeToSession(s, stats, hubVersion)
if err != nil {
slog.Error("Error encoding stats", "err", err, "stats", stats)
s.Exit(1)
return
} else {
s.Exit(0)
}
s.Exit(0)
}
// writeToSession encodes and writes system statistics to the session.
// It chooses between CBOR and JSON encoding based on the hub version,
// using CBOR for newer versions and JSON for legacy compatibility.
func (a *Agent) writeToSession(w io.Writer, stats *system.CombinedData, hubVersion semver.Version) error {
if hubVersion.GTE(beszel.MinVersionCbor) {
return cbor.NewEncoder(w).Encode(stats)
}
return json.NewEncoder(w).Encode(stats)
}
// extractHubVersion extracts the beszel version from SSH client version string.
// Expected format: "SSH-2.0-beszel_X.Y.Z" or "beszel_X.Y.Z"
func extractHubVersion(versionString string) (semver.Version, error) {
_, after, _ := strings.Cut(versionString, "_")
return semver.Parse(after)
}
// ParseKeys parses a string containing SSH public keys in authorized_keys format.
@@ -103,7 +174,9 @@ func ParseKeys(input string) ([]gossh.PublicKey, error) {
return parsedKeys, nil
}
// GetAddress gets the address to listen on or connect to from environment variables or default value.
// GetAddress determines the network address to listen on from various sources.
// It checks the provided address, then environment variables (LISTEN, PORT),
// and finally defaults to ":45876".
func GetAddress(addr string) string {
if addr == "" {
addr, _ = GetEnv("LISTEN")
@@ -122,7 +195,9 @@ func GetAddress(addr string) string {
return addr
}
// GetNetwork returns the network type to use based on the address
// GetNetwork determines the network type based on the address format.
// It checks the NETWORK environment variable first, then infers from
// the address format: addresses starting with "/" are "unix", others are "tcp".
func GetNetwork(addr string) string {
if network, ok := GetEnv("NETWORK"); ok && network != "" {
return network
@@ -132,3 +207,17 @@ func GetNetwork(addr string) string {
}
return "tcp"
}
// StopServer stops the SSH server if it's running.
// It returns an error if the server is not running or if there's an error stopping it.
func (a *Agent) StopServer() error {
if a.server == nil {
return errors.New("SSH server not running")
}
slog.Info("Stopping SSH server")
_ = a.server.Close()
a.server = nil
a.connectionManager.eventChan <- SSHDisconnect
return nil
}

View File

@@ -1,34 +1,43 @@
package agent
import (
"beszel/internal/entities/container"
"beszel/internal/entities/system"
"context"
"crypto/ed25519"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/blang/semver"
"github.com/fxamacker/cbor/v2"
"github.com/gliderlabs/ssh"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
gossh "golang.org/x/crypto/ssh"
)
func TestStartServer(t *testing.T) {
// Generate a test key pair
pubKey, privKey, err := ed25519.GenerateKey(nil)
require.NoError(t, err)
signer, err := ssh.NewSignerFromKey(privKey)
signer, err := gossh.NewSignerFromKey(privKey)
require.NoError(t, err)
sshPubKey, err := ssh.NewPublicKey(pubKey)
sshPubKey, err := gossh.NewPublicKey(pubKey)
require.NoError(t, err)
// Generate a different key pair for bad key test
badPubKey, badPrivKey, err := ed25519.GenerateKey(nil)
require.NoError(t, err)
badSigner, err := ssh.NewSignerFromKey(badPrivKey)
badSigner, err := gossh.NewSignerFromKey(badPrivKey)
require.NoError(t, err)
sshBadPubKey, err := ssh.NewPublicKey(badPubKey)
sshBadPubKey, err := gossh.NewPublicKey(badPubKey)
require.NoError(t, err)
socketFile := filepath.Join(t.TempDir(), "beszel-test.sock")
@@ -46,7 +55,7 @@ func TestStartServer(t *testing.T) {
config: ServerOptions{
Network: "tcp",
Addr: ":45987",
Keys: []ssh.PublicKey{sshPubKey},
Keys: []gossh.PublicKey{sshPubKey},
},
},
{
@@ -54,7 +63,7 @@ func TestStartServer(t *testing.T) {
config: ServerOptions{
Network: "tcp4",
Addr: "127.0.0.1:45988",
Keys: []ssh.PublicKey{sshPubKey},
Keys: []gossh.PublicKey{sshPubKey},
},
},
{
@@ -62,7 +71,7 @@ func TestStartServer(t *testing.T) {
config: ServerOptions{
Network: "tcp6",
Addr: "[::1]:45989",
Keys: []ssh.PublicKey{sshPubKey},
Keys: []gossh.PublicKey{sshPubKey},
},
},
{
@@ -70,7 +79,7 @@ func TestStartServer(t *testing.T) {
config: ServerOptions{
Network: "unix",
Addr: socketFile,
Keys: []ssh.PublicKey{sshPubKey},
Keys: []gossh.PublicKey{sshPubKey},
},
setup: func() error {
// Create a socket file that should be removed
@@ -89,7 +98,7 @@ func TestStartServer(t *testing.T) {
config: ServerOptions{
Network: "tcp",
Addr: ":45987",
Keys: []ssh.PublicKey{sshBadPubKey},
Keys: []gossh.PublicKey{sshBadPubKey},
},
wantErr: true,
errContains: "ssh: handshake failed",
@@ -99,7 +108,7 @@ func TestStartServer(t *testing.T) {
config: ServerOptions{
Network: "tcp",
Addr: ":45987",
Keys: []ssh.PublicKey{sshPubKey},
Keys: []gossh.PublicKey{sshPubKey},
},
},
}
@@ -115,7 +124,8 @@ func TestStartServer(t *testing.T) {
defer tt.cleanup()
}
agent := NewAgent()
agent, err := NewAgent("")
require.NoError(t, err)
// Start server in a goroutine since it blocks
errChan := make(chan error, 1)
@@ -127,8 +137,7 @@ func TestStartServer(t *testing.T) {
time.Sleep(100 * time.Millisecond)
// Try to connect to verify server is running
var client *ssh.Client
var err error
var client *gossh.Client
// Choose the appropriate signer based on the test case
testSigner := signer
@@ -136,23 +145,23 @@ func TestStartServer(t *testing.T) {
testSigner = badSigner
}
sshClientConfig := &ssh.ClientConfig{
sshClientConfig := &gossh.ClientConfig{
User: "a",
Auth: []ssh.AuthMethod{
ssh.PublicKeys(testSigner),
Auth: []gossh.AuthMethod{
gossh.PublicKeys(testSigner),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
Timeout: 4 * time.Second,
}
switch tt.config.Network {
case "unix":
client, err = ssh.Dial("unix", tt.config.Addr, sshClientConfig)
client, err = gossh.Dial("unix", tt.config.Addr, sshClientConfig)
default:
if !strings.Contains(tt.config.Addr, ":") {
tt.config.Addr = ":" + tt.config.Addr
}
client, err = ssh.Dial("tcp", tt.config.Addr, sshClientConfig)
client, err = gossh.Dial("tcp", tt.config.Addr, sshClientConfig)
}
if tt.wantErr {
@@ -287,3 +296,310 @@ func TestParseInvalidKey(t *testing.T) {
t.Fatalf("Expected error message to contain '%s', got: %v", expectedErrMsg, err)
}
}
/////////////////////////////////////////////////////////////////
//////////////////// Hub Version Tests //////////////////////////
/////////////////////////////////////////////////////////////////
func TestExtractHubVersion(t *testing.T) {
tests := []struct {
name string
clientVersion string
expectedVersion string
expectError bool
}{
{
name: "valid beszel client version with underscore",
clientVersion: "SSH-2.0-beszel_0.11.1",
expectedVersion: "0.11.1",
expectError: false,
},
{
name: "valid beszel client version with beta",
clientVersion: "SSH-2.0-beszel_1.0.0-beta",
expectedVersion: "1.0.0-beta",
expectError: false,
},
{
name: "valid beszel client version with rc",
clientVersion: "SSH-2.0-beszel_0.12.0-rc1",
expectedVersion: "0.12.0-rc1",
expectError: false,
},
{
name: "different SSH client",
clientVersion: "SSH-2.0-OpenSSH_8.0",
expectedVersion: "8.0",
expectError: true,
},
{
name: "malformed version string without underscore",
clientVersion: "SSH-2.0-beszel",
expectError: true,
},
{
name: "empty version string",
clientVersion: "",
expectError: true,
},
{
name: "version string with underscore but no version",
clientVersion: "beszel_",
expectedVersion: "",
expectError: true,
},
{
name: "version with patch and build metadata",
clientVersion: "SSH-2.0-beszel_1.2.3+build.123",
expectedVersion: "1.2.3+build.123",
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := extractHubVersion(tt.clientVersion)
if tt.expectError {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expectedVersion, result.String())
})
}
}
/////////////////////////////////////////////////////////////////
/////////////// Hub Version Detection Tests ////////////////////
/////////////////////////////////////////////////////////////////
func TestGetHubVersion(t *testing.T) {
agent, err := NewAgent("")
require.NoError(t, err)
// Mock SSH context that implements the ssh.Context interface
mockCtx := &mockSSHContext{
sessionID: "test-session-123",
clientVersion: "SSH-2.0-beszel_0.12.0",
}
// Test first call - should extract and cache version
version := agent.getHubVersion("test-session-123", mockCtx)
assert.Equal(t, "0.12.0", version.String())
// Test second call - should return cached version
mockCtx.clientVersion = "SSH-2.0-beszel_0.11.0" // Change version but should still return cached
version = agent.getHubVersion("test-session-123", mockCtx)
assert.Equal(t, "0.12.0", version.String()) // Should still be cached version
// Test different session - should extract new version
version = agent.getHubVersion("different-session", mockCtx)
assert.Equal(t, "0.11.0", version.String())
// Test with invalid version string (non-beszel client)
mockCtx.clientVersion = "SSH-2.0-OpenSSH_8.0"
version = agent.getHubVersion("invalid-session", mockCtx)
assert.Equal(t, "0.0.0", version.String()) // Should be empty version for non-beszel clients
// Test with no client version
mockCtx.clientVersion = ""
version = agent.getHubVersion("no-version-session", mockCtx)
assert.True(t, version.EQ(semver.Version{})) // Should be empty version
}
// mockSSHContext implements ssh.Context for testing
type mockSSHContext struct {
context.Context
sync.Mutex
sessionID string
clientVersion string
}
func (m *mockSSHContext) SessionID() string {
return m.sessionID
}
func (m *mockSSHContext) ClientVersion() string {
return m.clientVersion
}
func (m *mockSSHContext) ServerVersion() string {
return "SSH-2.0-beszel_test"
}
func (m *mockSSHContext) Value(key interface{}) interface{} {
if key == ssh.ContextKeyClientVersion {
return m.clientVersion
}
return nil
}
func (m *mockSSHContext) User() string { return "test-user" }
func (m *mockSSHContext) RemoteAddr() net.Addr { return nil }
func (m *mockSSHContext) LocalAddr() net.Addr { return nil }
func (m *mockSSHContext) Permissions() *ssh.Permissions { return nil }
func (m *mockSSHContext) SetValue(key, value interface{}) {}
/////////////////////////////////////////////////////////////////
/////////////// CBOR vs JSON Encoding Tests ////////////////////
/////////////////////////////////////////////////////////////////
// TestWriteToSessionEncoding tests that writeToSession actually encodes data in the correct format
func TestWriteToSessionEncoding(t *testing.T) {
tests := []struct {
name string
hubVersion string
expectedUsesCbor bool
}{
{
name: "old hub version should use JSON",
hubVersion: "0.11.1",
expectedUsesCbor: false,
},
{
name: "non-beta release should use CBOR",
hubVersion: "0.12.0",
expectedUsesCbor: true,
},
{
name: "even newer hub version should use CBOR",
hubVersion: "0.16.4",
expectedUsesCbor: true,
},
{
name: "beta version below release threshold should use JSON",
hubVersion: "0.12.0-beta0",
expectedUsesCbor: false,
},
{
name: "matching beta version should use CBOR",
hubVersion: "0.12.0-beta1",
expectedUsesCbor: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset the global hubVersions map to ensure clean state for each test
hubVersions = nil
agent, err := NewAgent("")
require.NoError(t, err)
// Parse the test version
version, err := semver.Parse(tt.hubVersion)
require.NoError(t, err)
// Create test data to encode
testData := createTestCombinedData()
var buf strings.Builder
err = agent.writeToSession(&buf, testData, version)
require.NoError(t, err)
encodedData := buf.String()
require.NotEmpty(t, encodedData)
// Verify the encoding format by attempting to decode
if tt.expectedUsesCbor {
var decodedCbor system.CombinedData
err = cbor.Unmarshal([]byte(encodedData), &decodedCbor)
assert.NoError(t, err, "Should be valid CBOR data")
var decodedJson system.CombinedData
err = json.Unmarshal([]byte(encodedData), &decodedJson)
assert.Error(t, err, "Should not be valid JSON data")
assert.Equal(t, testData.Info.Hostname, decodedCbor.Info.Hostname)
assert.Equal(t, testData.Stats.Cpu, decodedCbor.Stats.Cpu)
} else {
// Should be JSON - try to decode as JSON
var decodedJson system.CombinedData
err = json.Unmarshal([]byte(encodedData), &decodedJson)
assert.NoError(t, err, "Should be valid JSON data")
var decodedCbor system.CombinedData
err = cbor.Unmarshal([]byte(encodedData), &decodedCbor)
assert.Error(t, err, "Should not be valid CBOR data")
// Verify the decoded JSON data matches our test data
assert.Equal(t, testData.Info.Hostname, decodedJson.Info.Hostname)
assert.Equal(t, testData.Stats.Cpu, decodedJson.Stats.Cpu)
// Verify it looks like JSON (starts with '{' and contains readable field names)
assert.True(t, strings.HasPrefix(encodedData, "{"), "JSON should start with '{'")
assert.Contains(t, encodedData, `"info"`, "JSON should contain readable field names")
assert.Contains(t, encodedData, `"stats"`, "JSON should contain readable field names")
}
})
}
}
// Helper function to create test data for encoding tests
func createTestCombinedData() *system.CombinedData {
return &system.CombinedData{
Stats: system.Stats{
Cpu: 25.5,
Mem: 8589934592, // 8GB
MemUsed: 4294967296, // 4GB
MemPct: 50.0,
DiskTotal: 1099511627776, // 1TB
DiskUsed: 549755813888, // 512GB
DiskPct: 50.0,
},
Info: system.Info{
Hostname: "test-host",
Cores: 8,
CpuModel: "Test CPU Model",
Uptime: 3600,
AgentVersion: "0.12.0",
Os: system.Linux,
},
Containers: []*container.Stats{
{
Name: "test-container",
Cpu: 10.5,
Mem: 1073741824, // 1GB
},
},
}
}
func TestHubVersionCaching(t *testing.T) {
// Reset the global hubVersions map to ensure clean state
hubVersions = nil
agent, err := NewAgent("")
require.NoError(t, err)
ctx1 := &mockSSHContext{
sessionID: "session1",
clientVersion: "SSH-2.0-beszel_0.12.0",
}
ctx2 := &mockSSHContext{
sessionID: "session2",
clientVersion: "SSH-2.0-beszel_0.11.0",
}
// First calls should cache the versions
v1 := agent.getHubVersion("session1", ctx1)
v2 := agent.getHubVersion("session2", ctx2)
assert.Equal(t, "0.12.0", v1.String())
assert.Equal(t, "0.11.0", v2.String())
// Verify caching by changing context but keeping same session ID
ctx1.clientVersion = "SSH-2.0-beszel_0.10.0"
v1Cached := agent.getHubVersion("session1", ctx1)
assert.Equal(t, "0.12.0", v1Cached.String()) // Should still be cached version
// New session should get new version
ctx3 := &mockSSHContext{
sessionID: "session3",
clientVersion: "SSH-2.0-beszel_0.13.0",
}
v3 := agent.getHubVersion("session3", ctx3)
assert.Equal(t, "0.13.0", v3.String())
}

View File

@@ -2,11 +2,11 @@ package alerts
import (
"beszel/internal/entities/system"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/goccy/go-json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"

View File

@@ -0,0 +1,10 @@
package common
var (
// Allowed ssh key exchanges
DefaultKeyExchanges = []string{"curve25519-sha256"}
// Allowed ssh macs
DefaultMACs = []string{"hmac-sha2-256-etm@openssh.com"}
// Allowed ssh ciphers
DefaultCiphers = []string{"chacha20-poly1305@openssh.com"}
)

View File

@@ -0,0 +1,32 @@
package common
type WebSocketAction = uint8
// Not implemented yet
// type AgentError = uint8
const (
// Request system data from agent
GetData WebSocketAction = iota
// Check the fingerprint of the agent
CheckFingerprint
)
// HubRequest defines the structure for requests sent from hub to agent.
type HubRequest[T any] struct {
Action WebSocketAction `cbor:"0,keyasint"`
Data T `cbor:"1,keyasint,omitempty,omitzero"`
// Error AgentError `cbor:"error,omitempty,omitzero"`
}
type FingerprintRequest struct {
Signature []byte `cbor:"0,keyasint"`
NeedSysInfo bool `cbor:"1,keyasint"` // For universal token system creation
}
type FingerprintResponse struct {
Fingerprint string `cbor:"0,keyasint"`
// Optional system info for universal token system creation
Hostname string `cbor:"1,keyasint,omitempty,omitzero"`
Port string `cbor:"2,keyasint,omitempty,omitzero"`
}

View File

@@ -1,7 +0,0 @@
package common
var (
DefaultKeyExchanges = []string{"curve25519-sha256"}
DefaultMACs = []string{"hmac-sha2-256-etm@openssh.com"}
DefaultCiphers = []string{"chacha20-poly1305@openssh.com"}
)

View File

@@ -34,10 +34,16 @@ type ApiStats struct {
MemoryStats MemoryStats `json:"memory_stats"`
}
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuUsage [2]uint64) float64 {
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuUsage[0]
systemDelta := s.CPUStats.SystemUsage - prevCpuUsage[1]
return float64(cpuDelta) / float64(systemDelta) * 100
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer
systemDelta := s.CPUStats.SystemUsage - prevCpuSystem
// Avoid division by zero and handle first run case
if systemDelta == 0 || prevCpuContainer == 0 {
return 0.0
}
return float64(cpuDelta) / float64(systemDelta) * 100.0
}
// from: https://github.com/docker/cli/blob/master/cli/command/container/stats_helpers.go#L185
@@ -99,12 +105,14 @@ type prevNetStats struct {
// Docker container stats
type Stats struct {
Name string `json:"n"`
Cpu float64 `json:"c"`
Mem float64 `json:"m"`
NetworkSent float64 `json:"ns"`
NetworkRecv float64 `json:"nr"`
PrevCpu [2]uint64 `json:"-"`
PrevNet prevNetStats `json:"-"`
PrevRead time.Time `json:"-"`
Name string `json:"n" cbor:"0,keyasint"`
Cpu float64 `json:"c" cbor:"1,keyasint"`
Mem float64 `json:"m" cbor:"2,keyasint"`
NetworkSent float64 `json:"ns" cbor:"3,keyasint"`
NetworkRecv float64 `json:"nr" cbor:"4,keyasint"`
// PrevCpu [2]uint64 `json:"-"`
CpuSystem uint64 `json:"-"`
CpuContainer uint64 `json:"-"`
PrevNet prevNetStats `json:"-"`
PrevReadTime time.Time `json:"-"`
}

View File

@@ -8,38 +8,38 @@ import (
)
type Stats struct {
Cpu float64 `json:"cpu"`
MaxCpu float64 `json:"cpum,omitempty"`
Mem float64 `json:"m"`
MemUsed float64 `json:"mu"`
MemPct float64 `json:"mp"`
MemBuffCache float64 `json:"mb"`
MemZfsArc float64 `json:"mz,omitempty"` // ZFS ARC memory
Swap float64 `json:"s,omitempty"`
SwapUsed float64 `json:"su,omitempty"`
DiskTotal float64 `json:"d"`
DiskUsed float64 `json:"du"`
DiskPct float64 `json:"dp"`
DiskReadPs float64 `json:"dr"`
DiskWritePs float64 `json:"dw"`
MaxDiskReadPs float64 `json:"drm,omitempty"`
MaxDiskWritePs float64 `json:"dwm,omitempty"`
NetworkSent float64 `json:"ns"`
NetworkRecv float64 `json:"nr"`
MaxNetworkSent float64 `json:"nsm,omitempty"`
MaxNetworkRecv float64 `json:"nrm,omitempty"`
Temperatures map[string]float64 `json:"t,omitempty"`
ExtraFs map[string]*FsStats `json:"efs,omitempty"`
GPUData map[string]GPUData `json:"g,omitempty"`
Cpu float64 `json:"cpu" cbor:"0,keyasint"`
MaxCpu float64 `json:"cpum,omitempty" cbor:"1,keyasint,omitempty"`
Mem float64 `json:"m" cbor:"2,keyasint"`
MemUsed float64 `json:"mu" cbor:"3,keyasint"`
MemPct float64 `json:"mp" cbor:"4,keyasint"`
MemBuffCache float64 `json:"mb" cbor:"5,keyasint"`
MemZfsArc float64 `json:"mz,omitempty" cbor:"6,keyasint,omitempty"` // ZFS ARC memory
Swap float64 `json:"s,omitempty" cbor:"7,keyasint,omitempty"`
SwapUsed float64 `json:"su,omitempty" cbor:"8,keyasint,omitempty"`
DiskTotal float64 `json:"d" cbor:"9,keyasint"`
DiskUsed float64 `json:"du" cbor:"10,keyasint"`
DiskPct float64 `json:"dp" cbor:"11,keyasint"`
DiskReadPs float64 `json:"dr" cbor:"12,keyasint"`
DiskWritePs float64 `json:"dw" cbor:"13,keyasint"`
MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"14,keyasint,omitempty"`
MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"15,keyasint,omitempty"`
NetworkSent float64 `json:"ns" cbor:"16,keyasint"`
NetworkRecv float64 `json:"nr" cbor:"17,keyasint"`
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"18,keyasint,omitempty"`
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"19,keyasint,omitempty"`
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
}
type GPUData struct {
Name string `json:"n"`
Name string `json:"n" cbor:"0,keyasint"`
Temperature float64 `json:"-"`
MemoryUsed float64 `json:"mu,omitempty"`
MemoryTotal float64 `json:"mt,omitempty"`
Usage float64 `json:"u"`
Power float64 `json:"p,omitempty"`
MemoryUsed float64 `json:"mu,omitempty" cbor:"1,keyasint,omitempty"`
MemoryTotal float64 `json:"mt,omitempty" cbor:"2,keyasint,omitempty"`
Usage float64 `json:"u" cbor:"3,keyasint"`
Power float64 `json:"p,omitempty" cbor:"4,keyasint,omitempty"`
Count float64 `json:"-"`
}
@@ -47,14 +47,14 @@ type FsStats struct {
Time time.Time `json:"-"`
Root bool `json:"-"`
Mountpoint string `json:"-"`
DiskTotal float64 `json:"d"`
DiskUsed float64 `json:"du"`
DiskTotal float64 `json:"d" cbor:"0,keyasint"`
DiskUsed float64 `json:"du" cbor:"1,keyasint"`
TotalRead uint64 `json:"-"`
TotalWrite uint64 `json:"-"`
DiskReadPs float64 `json:"r"`
DiskWritePs float64 `json:"w"`
MaxDiskReadPS float64 `json:"rm,omitempty"`
MaxDiskWritePS float64 `json:"wm,omitempty"`
DiskReadPs float64 `json:"r" cbor:"2,keyasint"`
DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"4,keyasint,omitempty"`
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"5,keyasint,omitempty"`
}
type NetIoStats struct {
@@ -64,7 +64,7 @@ type NetIoStats struct {
Name string
}
type Os uint8
type Os = uint8
const (
Linux Os = iota
@@ -74,26 +74,26 @@ const (
)
type Info struct {
Hostname string `json:"h"`
KernelVersion string `json:"k,omitempty"`
Cores int `json:"c"`
Threads int `json:"t,omitempty"`
CpuModel string `json:"m"`
Uptime uint64 `json:"u"`
Cpu float64 `json:"cpu"`
MemPct float64 `json:"mp"`
DiskPct float64 `json:"dp"`
Bandwidth float64 `json:"b"`
AgentVersion string `json:"v"`
Podman bool `json:"p,omitempty"`
GpuPct float64 `json:"g,omitempty"`
DashboardTemp float64 `json:"dt,omitempty"`
Os Os `json:"os"`
Hostname string `json:"h" cbor:"0,keyasint"`
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
Cores int `json:"c" cbor:"2,keyasint"`
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
CpuModel string `json:"m" cbor:"4,keyasint"`
Uptime uint64 `json:"u" cbor:"5,keyasint"`
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
MemPct float64 `json:"mp" cbor:"7,keyasint"`
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
Bandwidth float64 `json:"b" cbor:"9,keyasint"`
AgentVersion string `json:"v" cbor:"10,keyasint"`
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"`
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
Os Os `json:"os" cbor:"14,keyasint"`
}
// Final data structure to return to the hub
type CombinedData struct {
Stats Stats `json:"stats"`
Info Info `json:"info"`
Containers []*container.Stats `json:"container"`
Stats Stats `json:"stats" cbor:"0,keyasint"`
Info Info `json:"info" cbor:"1,keyasint"`
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
}

View File

@@ -0,0 +1,247 @@
package hub
import (
"beszel/internal/common"
"beszel/internal/hub/expirymap"
"beszel/internal/hub/ws"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/blang/semver"
"github.com/lxzan/gws"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
// tokenMap maps tokens to user IDs for universal tokens
var tokenMap *expirymap.ExpiryMap[string]
type agentConnectRequest struct {
token string
agentSemVer semver.Version
// for universal token
isUniversalToken bool
userId string
remoteAddr string
}
// validateAgentHeaders validates the required headers from agent connection requests.
func (h *Hub) validateAgentHeaders(headers http.Header) (string, string, error) {
token := headers.Get("X-Token")
agentVersion := headers.Get("X-Beszel")
if agentVersion == "" || token == "" || len(token) > 512 {
return "", "", errors.New("")
}
return token, agentVersion, nil
}
// getFingerprintRecord retrieves fingerprint data from the database by token.
func (h *Hub) getFingerprintRecord(token string, recordData *ws.FingerprintRecord) error {
err := h.DB().NewQuery("SELECT id, system, fingerprint, token FROM fingerprints WHERE token = {:token}").
Bind(dbx.Params{
"token": token,
}).
One(recordData)
return err
}
// sendResponseError sends an HTTP error response with the given status code and message.
func sendResponseError(res http.ResponseWriter, code int, message string) error {
res.WriteHeader(code)
if message != "" {
res.Write([]byte(message))
}
return nil
}
// handleAgentConnect handles the incoming connection request from the agent.
func (h *Hub) handleAgentConnect(e *core.RequestEvent) error {
if err := h.agentConnect(e.Request, e.Response); err != nil {
return err
}
return nil
}
// agentConnect handles agent connection requests, validating credentials and upgrading to WebSocket.
func (h *Hub) agentConnect(req *http.Request, res http.ResponseWriter) (err error) {
var agentConnectRequest agentConnectRequest
var agentVersion string
// check if user agent and token are valid
agentConnectRequest.token, agentVersion, err = h.validateAgentHeaders(req.Header)
if err != nil {
return sendResponseError(res, http.StatusUnauthorized, "")
}
// Pull fingerprint from database matching token
var fpRecord ws.FingerprintRecord
err = h.getFingerprintRecord(agentConnectRequest.token, &fpRecord)
// if no existing record, check if token is a universal token
if err != nil {
if err = checkUniversalToken(&agentConnectRequest); err == nil {
// if this is a universal token, set the remote address and new record token
agentConnectRequest.remoteAddr = getRealIP(req)
fpRecord.Token = agentConnectRequest.token
}
}
// If no matching token, return unauthorized
if err != nil {
return sendResponseError(res, http.StatusUnauthorized, "Invalid token")
}
// Validate agent version
agentConnectRequest.agentSemVer, err = semver.Parse(agentVersion)
if err != nil {
return sendResponseError(res, http.StatusUnauthorized, "Invalid agent version")
}
// Upgrade connection to WebSocket
conn, err := ws.GetUpgrader().Upgrade(res, req)
if err != nil {
return sendResponseError(res, http.StatusInternalServerError, "WebSocket upgrade failed")
}
go h.verifyWsConn(conn, agentConnectRequest, fpRecord)
return nil
}
// verifyWsConn verifies the WebSocket connection using agent's fingerprint and SSH key signature.
func (h *Hub) verifyWsConn(conn *gws.Conn, acr agentConnectRequest, fpRecord ws.FingerprintRecord) (err error) {
wsConn := ws.NewWsConnection(conn)
// must be set before the read loop
conn.Session().Store("wsConn", wsConn)
// make sure connection is closed if there is an error
defer func() {
if err != nil {
wsConn.Close()
h.Logger().Error("WebSocket error", "error", err, "system", fpRecord.SystemId)
}
}()
go conn.ReadLoop()
signer, err := h.GetSSHKey("")
if err != nil {
return err
}
agentFingerprint, err := wsConn.GetFingerprint(acr.token, signer, acr.isUniversalToken)
if err != nil {
return err
}
// Create system if using universal token
if acr.isUniversalToken {
if acr.userId == "" {
return errors.New("token user not found")
}
fpRecord.SystemId, err = h.createSystemFromAgentData(&acr, agentFingerprint)
if err != nil {
return fmt.Errorf("failed to create system from universal token: %w", err)
}
}
switch {
// If no current fingerprint, update with new fingerprint (first time connecting)
case fpRecord.Fingerprint == "":
if err := h.SetFingerprint(&fpRecord, agentFingerprint.Fingerprint); err != nil {
return err
}
// Abort if fingerprint exists but doesn't match (different machine)
case fpRecord.Fingerprint != agentFingerprint.Fingerprint:
return errors.New("fingerprint mismatch")
}
return h.sm.AddWebSocketSystem(fpRecord.SystemId, acr.agentSemVer, wsConn)
}
// createSystemFromAgentData creates a new system record using data from the agent
func (h *Hub) createSystemFromAgentData(acr *agentConnectRequest, agentFingerprint common.FingerprintResponse) (recordId string, err error) {
systemsCollection, err := h.FindCollectionByNameOrId("systems")
if err != nil {
return "", fmt.Errorf("failed to find systems collection: %w", err)
}
// separate port from address
if agentFingerprint.Hostname == "" {
agentFingerprint.Hostname = acr.remoteAddr
}
if agentFingerprint.Port == "" {
agentFingerprint.Port = "45876"
}
// create new record
systemRecord := core.NewRecord(systemsCollection)
systemRecord.Set("name", agentFingerprint.Hostname)
systemRecord.Set("host", acr.remoteAddr)
systemRecord.Set("port", agentFingerprint.Port)
systemRecord.Set("users", []string{acr.userId})
return systemRecord.Id, h.Save(systemRecord)
}
// SetFingerprint updates the fingerprint for a given record ID.
func (h *Hub) SetFingerprint(fpRecord *ws.FingerprintRecord, fingerprint string) (err error) {
// // can't use raw query here because it doesn't trigger SSE
var record *core.Record
switch fpRecord.Id {
case "":
// create new record for universal token
collection, _ := h.FindCachedCollectionByNameOrId("fingerprints")
record = core.NewRecord(collection)
record.Set("system", fpRecord.SystemId)
default:
record, err = h.FindRecordById("fingerprints", fpRecord.Id)
}
if err != nil {
return err
}
record.Set("token", fpRecord.Token)
record.Set("fingerprint", fingerprint)
return h.SaveNoValidate(record)
}
func getTokenMap() *expirymap.ExpiryMap[string] {
if tokenMap == nil {
tokenMap = expirymap.New[string](time.Hour)
}
return tokenMap
}
func checkUniversalToken(acr *agentConnectRequest) (err error) {
if tokenMap == nil {
tokenMap = expirymap.New[string](time.Hour)
}
acr.userId, acr.isUniversalToken = tokenMap.GetOk(acr.token)
if !acr.isUniversalToken {
return errors.New("invalid token")
}
return nil
}
// getRealIP attempts to extract the real IP address from the request headers.
func getRealIP(r *http.Request) string {
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
return ip
}
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
// X-Forwarded-For can contain a comma-separated list: "client_ip, proxy1, proxy2"
// Take the first one
ips := strings.Split(ip, ",")
if len(ips) > 0 {
return strings.TrimSpace(ips[0])
}
}
// Fallback to RemoteAddr
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return ip
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
package hub
// Package config provides functions for syncing systems with the config.yml file
package config
import (
"beszel/internal/entities/system"
@@ -7,6 +8,7 @@ import (
"os"
"path/filepath"
"github.com/google/uuid"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
@@ -14,19 +16,20 @@ import (
"gopkg.in/yaml.v3"
)
type Config struct {
Systems []SystemConfig `yaml:"systems"`
type config struct {
Systems []systemConfig `yaml:"systems"`
}
type SystemConfig struct {
type systemConfig struct {
Name string `yaml:"name"`
Host string `yaml:"host"`
Port uint16 `yaml:"port,omitempty"`
Token string `yaml:"token,omitempty"`
Users []string `yaml:"users"`
}
// Syncs systems with the config.yml file
func syncSystemsWithConfig(e *core.ServeEvent) error {
func SyncSystems(e *core.ServeEvent) error {
h := e.App
configPath := filepath.Join(h.DataDir(), "config.yml")
configData, err := os.ReadFile(configPath)
@@ -34,7 +37,7 @@ func syncSystemsWithConfig(e *core.ServeEvent) error {
return nil
}
var config Config
var config config
err = yaml.Unmarshal(configData, &config)
if err != nil {
return fmt.Errorf("failed to parse config.yml: %v", err)
@@ -107,6 +110,14 @@ func syncSystemsWithConfig(e *core.ServeEvent) error {
if err := h.Save(existingSystem); err != nil {
return err
}
// Only update token if one is specified in config, otherwise preserve existing token
if sysConfig.Token != "" {
if err := updateFingerprintToken(h, existingSystem.Id, sysConfig.Token); err != nil {
return err
}
}
delete(existingSystemsMap, key)
} else {
// Create new system
@@ -124,10 +135,21 @@ func syncSystemsWithConfig(e *core.ServeEvent) error {
if err := h.Save(newSystem); err != nil {
return fmt.Errorf("failed to create new system: %v", err)
}
// For new systems, generate token if not provided
token := sysConfig.Token
if token == "" {
token = uuid.New().String()
}
// Create fingerprint record for new system
if err := createFingerprintRecord(h, newSystem.Id, token); err != nil {
return err
}
}
}
// Delete systems not in config
// Delete systems not in config (and their fingerprint records will cascade delete)
for _, system := range existingSystemsMap {
if err := h.Delete(system); err != nil {
return err
@@ -139,7 +161,7 @@ func syncSystemsWithConfig(e *core.ServeEvent) error {
}
// Generates content for the config.yml file as a YAML string
func (h *Hub) generateConfigYAML() (string, error) {
func generateYAML(h core.App) (string, error) {
// Fetch all systems from the database
systems, err := h.FindRecordsByFilter("systems", "id != ''", "name", -1, 0)
if err != nil {
@@ -147,8 +169,8 @@ func (h *Hub) generateConfigYAML() (string, error) {
}
// Create a Config struct to hold the data
config := Config{
Systems: make([]SystemConfig, 0, len(systems)),
config := config{
Systems: make([]systemConfig, 0, len(systems)),
}
// Fetch all users at once
@@ -156,11 +178,29 @@ func (h *Hub) generateConfigYAML() (string, error) {
for _, system := range systems {
allUserIDs = append(allUserIDs, system.GetStringSlice("users")...)
}
userEmailMap, err := h.getUserEmailMap(allUserIDs)
userEmailMap, err := getUserEmailMap(h, allUserIDs)
if err != nil {
return "", err
}
// Fetch all fingerprint records to get tokens
type fingerprintData struct {
ID string `db:"id"`
System string `db:"system"`
Token string `db:"token"`
}
var fingerprints []fingerprintData
err = h.DB().NewQuery("SELECT id, system, token FROM fingerprints").All(&fingerprints)
if err != nil {
return "", err
}
// Create a map of system ID to token
systemTokenMap := make(map[string]string)
for _, fingerprint := range fingerprints {
systemTokenMap[fingerprint.System] = fingerprint.Token
}
// Populate the Config struct with system data
for _, system := range systems {
userIDs := system.GetStringSlice("users")
@@ -171,11 +211,12 @@ func (h *Hub) generateConfigYAML() (string, error) {
}
}
sysConfig := SystemConfig{
sysConfig := systemConfig{
Name: system.GetString("name"),
Host: system.GetString("host"),
Port: cast.ToUint16(system.Get("port")),
Users: userEmails,
Token: systemTokenMap[system.Id],
}
config.Systems = append(config.Systems, sysConfig)
}
@@ -187,13 +228,13 @@ func (h *Hub) generateConfigYAML() (string, error) {
}
// Add a header to the YAML
yamlData = append([]byte("# Values for port and users are optional.\n# Defaults are port 45876 and the first created user.\n\n"), yamlData...)
yamlData = append([]byte("# Values for port, users, and token are optional.\n# Defaults are port 45876, the first created user, and a generated UUID token.\n\n"), yamlData...)
return string(yamlData), nil
}
// New helper function to get a map of user IDs to emails
func (h *Hub) getUserEmailMap(userIDs []string) (map[string]string, error) {
func getUserEmailMap(h core.App, userIDs []string) (map[string]string, error) {
users, err := h.FindRecordsByIds("users", userIDs)
if err != nil {
return nil, err
@@ -207,13 +248,42 @@ func (h *Hub) getUserEmailMap(userIDs []string) (map[string]string, error) {
return userEmailMap, nil
}
// Helper function to update or create fingerprint token for an existing system
func updateFingerprintToken(app core.App, systemID, token string) error {
// Try to find existing fingerprint record
fingerprint, err := app.FindFirstRecordByFilter("fingerprints", "system = {:system}", dbx.Params{"system": systemID})
if err != nil {
// If no fingerprint record exists, create one
return createFingerprintRecord(app, systemID, token)
}
// Update existing fingerprint record with new token (keep existing fingerprint)
fingerprint.Set("token", token)
return app.Save(fingerprint)
}
// Helper function to create a new fingerprint record for a system
func createFingerprintRecord(app core.App, systemID, token string) error {
fingerprintsCollection, err := app.FindCollectionByNameOrId("fingerprints")
if err != nil {
return fmt.Errorf("failed to find fingerprints collection: %v", err)
}
newFingerprint := core.NewRecord(fingerprintsCollection)
newFingerprint.Set("system", systemID)
newFingerprint.Set("token", token)
newFingerprint.Set("fingerprint", "") // Empty fingerprint, will be set on first connection
return app.Save(newFingerprint)
}
// Returns the current config.yml file as a JSON object
func (h *Hub) getYamlConfig(e *core.RequestEvent) error {
func GetYamlConfig(e *core.RequestEvent) error {
info, _ := e.RequestInfo()
if info.Auth == nil || info.Auth.GetString("role") != "admin" {
return apis.NewForbiddenError("Forbidden", nil)
}
configContent, err := h.generateConfigYAML()
configContent, err := generateYAML(e.App)
if err != nil {
return err
}

View File

@@ -0,0 +1,245 @@
//go:build testing
// +build testing
package config_test
import (
"beszel/internal/hub/config"
"beszel/internal/tests"
"os"
"path/filepath"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
// Config struct for testing (copied from config package since it's not exported)
type testConfig struct {
Systems []testSystemConfig `yaml:"systems"`
}
type testSystemConfig struct {
Name string `yaml:"name"`
Host string `yaml:"host"`
Port uint16 `yaml:"port,omitempty"`
Users []string `yaml:"users"`
Token string `yaml:"token,omitempty"`
}
// Helper function to create a test system for config tests
// func createConfigTestSystem(app core.App, name, host string, port uint16, userIDs []string) (*core.Record, error) {
// systemCollection, err := app.FindCollectionByNameOrId("systems")
// if err != nil {
// return nil, err
// }
// system := core.NewRecord(systemCollection)
// system.Set("name", name)
// system.Set("host", host)
// system.Set("port", port)
// system.Set("users", userIDs)
// system.Set("status", "pending")
// return system, app.Save(system)
// }
// Helper function to create a fingerprint record
func createConfigTestFingerprint(app core.App, systemID, token, fingerprint string) (*core.Record, error) {
fingerprintCollection, err := app.FindCollectionByNameOrId("fingerprints")
if err != nil {
return nil, err
}
fp := core.NewRecord(fingerprintCollection)
fp.Set("system", systemID)
fp.Set("token", token)
fp.Set("fingerprint", fingerprint)
return fp, app.Save(fp)
}
// TestConfigSyncWithTokens tests the config.SyncSystems function with various token scenarios
func TestConfigSyncWithTokens(t *testing.T) {
testHub, err := tests.NewTestHub()
require.NoError(t, err)
defer testHub.Cleanup()
// Create test user
user, err := tests.CreateUser(testHub.App, "admin@example.com", "testtesttest")
require.NoError(t, err)
testCases := []struct {
name string
setupFunc func() (string, *core.Record, *core.Record) // Returns: existing token, system record, fingerprint record
configYAML string
expectToken string // Expected token after sync
description string
}{
{
name: "new system with token in config",
setupFunc: func() (string, *core.Record, *core.Record) {
return "", nil, nil // No existing system
},
configYAML: `systems:
- name: "new-server"
host: "new.example.com"
port: 45876
users:
- "admin@example.com"
token: "explicit-token-123"`,
expectToken: "explicit-token-123",
description: "New system should use token from config",
},
{
name: "existing system without token in config (preserve existing)",
setupFunc: func() (string, *core.Record, *core.Record) {
// Create existing system and fingerprint
system, err := tests.CreateRecord(testHub.App, "systems", map[string]any{
"name": "preserve-server",
"host": "preserve.example.com",
"port": 45876,
"users": []string{user.Id},
})
require.NoError(t, err)
fingerprint, err := createConfigTestFingerprint(testHub.App, system.Id, "preserve-token-999", "preserve-fingerprint")
require.NoError(t, err)
return "preserve-token-999", system, fingerprint
},
configYAML: `systems:
- name: "preserve-server"
host: "preserve.example.com"
port: 45876
users:
- "admin@example.com"`,
expectToken: "preserve-token-999",
description: "Existing system should preserve original token when config doesn't specify one",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Setup test data
_, existingSystem, existingFingerprint := tc.setupFunc()
// Write config file
configPath := filepath.Join(testHub.DataDir(), "config.yml")
err := os.WriteFile(configPath, []byte(tc.configYAML), 0644)
require.NoError(t, err)
// Create serve event and sync
event := &core.ServeEvent{App: testHub.App}
err = config.SyncSystems(event)
require.NoError(t, err)
// Parse the config to get the system name for verification
var configData testConfig
err = yaml.Unmarshal([]byte(tc.configYAML), &configData)
require.NoError(t, err)
require.Len(t, configData.Systems, 1)
systemName := configData.Systems[0].Name
// Find the system after sync
systems, err := testHub.FindRecordsByFilter("systems", "name = {:name}", "", -1, 0, map[string]any{"name": systemName})
require.NoError(t, err)
require.Len(t, systems, 1)
system := systems[0]
// Find the fingerprint record
fingerprints, err := testHub.FindRecordsByFilter("fingerprints", "system = {:system}", "", -1, 0, map[string]any{"system": system.Id})
require.NoError(t, err)
require.Len(t, fingerprints, 1)
fingerprint := fingerprints[0]
// Verify token
actualToken := fingerprint.GetString("token")
if tc.expectToken == "" {
// For generated tokens, just verify it's not empty and is a valid UUID format
assert.NotEmpty(t, actualToken, tc.description)
assert.Len(t, actualToken, 36, "Generated token should be UUID format") // UUID length
} else {
assert.Equal(t, tc.expectToken, actualToken, tc.description)
}
// For existing systems, verify fingerprint is preserved
if existingFingerprint != nil {
actualFingerprint := fingerprint.GetString("fingerprint")
expectedFingerprint := existingFingerprint.GetString("fingerprint")
assert.Equal(t, expectedFingerprint, actualFingerprint, "Fingerprint should be preserved")
}
// Cleanup for next test
if existingSystem != nil {
testHub.Delete(existingSystem)
}
if existingFingerprint != nil {
testHub.Delete(existingFingerprint)
}
// Clean up the new records
testHub.Delete(system)
testHub.Delete(fingerprint)
})
}
}
// TestConfigMigrationScenario tests the specific migration scenario mentioned in the discussion
func TestConfigMigrationScenario(t *testing.T) {
testHub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer testHub.Cleanup()
// Create test user
user, err := tests.CreateUser(testHub.App, "admin@example.com", "testtesttest")
require.NoError(t, err)
// Simulate migration scenario: system exists with token from migration
existingSystem, err := tests.CreateRecord(testHub.App, "systems", map[string]any{
"name": "migrated-server",
"host": "migrated.example.com",
"port": 45876,
"users": []string{user.Id},
})
require.NoError(t, err)
migrationToken := "migration-generated-token-123"
existingFingerprint, err := createConfigTestFingerprint(testHub.App, existingSystem.Id, migrationToken, "existing-fingerprint-from-agent")
require.NoError(t, err)
// User exports config BEFORE this update (so no token field in YAML)
oldConfigYAML := `systems:
- name: "migrated-server"
host: "migrated.example.com"
port: 45876
users:
- "admin@example.com"`
// Write old config file and import
configPath := filepath.Join(testHub.DataDir(), "config.yml")
err = os.WriteFile(configPath, []byte(oldConfigYAML), 0644)
require.NoError(t, err)
event := &core.ServeEvent{App: testHub.App}
err = config.SyncSystems(event)
require.NoError(t, err)
// Verify the original token is preserved
updatedFingerprint, err := testHub.FindRecordById("fingerprints", existingFingerprint.Id)
require.NoError(t, err)
actualToken := updatedFingerprint.GetString("token")
assert.Equal(t, migrationToken, actualToken, "Migration token should be preserved when config doesn't specify a token")
// Verify fingerprint is also preserved
actualFingerprint := updatedFingerprint.GetString("fingerprint")
assert.Equal(t, "existing-fingerprint-from-agent", actualFingerprint, "Existing fingerprint should be preserved")
// Verify system still exists and is updated correctly
updatedSystem, err := testHub.FindRecordById("systems", existingSystem.Id)
require.NoError(t, err)
assert.Equal(t, "migrated-server", updatedSystem.GetString("name"))
assert.Equal(t, "migrated.example.com", updatedSystem.GetString("host"))
}

View File

@@ -0,0 +1,104 @@
package expirymap
import (
"reflect"
"time"
"github.com/pocketbase/pocketbase/tools/store"
)
type val[T any] struct {
value T
expires time.Time
}
type ExpiryMap[T any] struct {
store *store.Store[string, *val[T]]
cleanupInterval time.Duration
}
// New creates a new expiry map with custom cleanup interval
func New[T any](cleanupInterval time.Duration) *ExpiryMap[T] {
m := &ExpiryMap[T]{
store: store.New(map[string]*val[T]{}),
cleanupInterval: cleanupInterval,
}
m.startCleaner()
return m
}
// Set stores a value with the given TTL
func (m *ExpiryMap[T]) Set(key string, value T, ttl time.Duration) {
m.store.Set(key, &val[T]{
value: value,
expires: time.Now().Add(ttl),
})
}
// GetOk retrieves a value and checks if it exists and hasn't expired
// Performs lazy cleanup of expired entries on access
func (m *ExpiryMap[T]) GetOk(key string) (T, bool) {
value, ok := m.store.GetOk(key)
if !ok {
return *new(T), false
}
// Check if expired and perform lazy cleanup
if value.expires.Before(time.Now()) {
m.store.Remove(key)
return *new(T), false
}
return value.value, true
}
// GetByValue retrieves a value by value
func (m *ExpiryMap[T]) GetByValue(val T) (key string, value T, ok bool) {
for key, v := range m.store.GetAll() {
if reflect.DeepEqual(v.value, val) {
// check if expired
if v.expires.Before(time.Now()) {
m.store.Remove(key)
break
}
return key, v.value, true
}
}
return "", *new(T), false
}
// Remove explicitly removes a key
func (m *ExpiryMap[T]) Remove(key string) {
m.store.Remove(key)
}
// RemovebyValue removes a value by value
func (m *ExpiryMap[T]) RemovebyValue(value T) (T, bool) {
for key, val := range m.store.GetAll() {
if reflect.DeepEqual(val.value, value) {
m.store.Remove(key)
return val.value, true
}
}
return *new(T), false
}
// startCleaner runs the background cleanup process
func (m *ExpiryMap[T]) startCleaner() {
go func() {
tick := time.Tick(m.cleanupInterval)
for range tick {
m.cleanup()
}
}()
}
// cleanup removes all expired entries
func (m *ExpiryMap[T]) cleanup() {
now := time.Now()
for key, val := range m.store.GetAll() {
if val.expires.Before(now) {
m.store.Remove(key)
}
}
}

View File

@@ -0,0 +1,477 @@
//go:build testing
// +build testing
package expirymap
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Not using the following methods but are useful for testing
// TESTING: Has checks if a key exists and hasn't expired
func (m *ExpiryMap[T]) Has(key string) bool {
_, ok := m.GetOk(key)
return ok
}
// TESTING: Get retrieves a value, returns zero value if not found or expired
func (m *ExpiryMap[T]) Get(key string) T {
value, _ := m.GetOk(key)
return value
}
// TESTING: Len returns the number of non-expired entries
func (m *ExpiryMap[T]) Len() int {
count := 0
now := time.Now()
for _, val := range m.store.Values() {
if val.expires.After(now) {
count++
}
}
return count
}
func TestExpiryMap_BasicOperations(t *testing.T) {
em := New[string](time.Hour)
// Test Set and GetOk
em.Set("key1", "value1", time.Hour)
value, ok := em.GetOk("key1")
assert.True(t, ok)
assert.Equal(t, "value1", value)
// Test Get
value = em.Get("key1")
assert.Equal(t, "value1", value)
// Test Has
assert.True(t, em.Has("key1"))
assert.False(t, em.Has("nonexistent"))
// Test Remove
em.Remove("key1")
assert.False(t, em.Has("key1"))
}
func TestExpiryMap_Expiration(t *testing.T) {
em := New[string](time.Hour)
// Set a value with very short TTL
em.Set("shortlived", "value", time.Millisecond*10)
// Should exist immediately
assert.True(t, em.Has("shortlived"))
// Wait for expiration
time.Sleep(time.Millisecond * 20)
// Should be expired and automatically cleaned up on access
assert.False(t, em.Has("shortlived"))
value, ok := em.GetOk("shortlived")
assert.False(t, ok)
assert.Equal(t, "", value) // zero value for string
}
func TestExpiryMap_LazyCleanup(t *testing.T) {
em := New[int](time.Hour)
// Set multiple values with short TTL
em.Set("key1", 1, time.Millisecond*10)
em.Set("key2", 2, time.Millisecond*10)
em.Set("key3", 3, time.Hour) // This one won't expire
// Wait for expiration
time.Sleep(time.Millisecond * 20)
// Access expired keys should trigger lazy cleanup
_, ok := em.GetOk("key1")
assert.False(t, ok)
// Non-expired key should still exist
value, ok := em.GetOk("key3")
assert.True(t, ok)
assert.Equal(t, 3, value)
}
func TestExpiryMap_Len(t *testing.T) {
em := New[string](time.Hour)
// Initially empty
assert.Equal(t, 0, em.Len())
// Add some values
em.Set("key1", "value1", time.Hour)
em.Set("key2", "value2", time.Hour)
em.Set("key3", "value3", time.Millisecond*10) // Will expire soon
// Should count all initially
assert.Equal(t, 3, em.Len())
// Wait for one to expire
time.Sleep(time.Millisecond * 20)
// Len should reflect only non-expired entries
assert.Equal(t, 2, em.Len())
}
func TestExpiryMap_CustomInterval(t *testing.T) {
// Create with very short cleanup interval for testing
em := New[string](time.Millisecond * 50)
// Set a value that expires quickly
em.Set("test", "value", time.Millisecond*10)
// Should exist initially
assert.True(t, em.Has("test"))
// Wait for expiration + cleanup cycle
time.Sleep(time.Millisecond * 100)
// Should be cleaned up by background process
// Note: This test might be flaky due to timing, but demonstrates the concept
assert.False(t, em.Has("test"))
}
func TestExpiryMap_GenericTypes(t *testing.T) {
// Test with different types
t.Run("Int", func(t *testing.T) {
em := New[int](time.Hour)
em.Set("num", 42, time.Hour)
value, ok := em.GetOk("num")
assert.True(t, ok)
assert.Equal(t, 42, value)
})
t.Run("Struct", func(t *testing.T) {
type TestStruct struct {
Name string
Age int
}
em := New[TestStruct](time.Hour)
expected := TestStruct{Name: "John", Age: 30}
em.Set("person", expected, time.Hour)
value, ok := em.GetOk("person")
assert.True(t, ok)
assert.Equal(t, expected, value)
})
t.Run("Pointer", func(t *testing.T) {
em := New[*string](time.Hour)
str := "hello"
em.Set("ptr", &str, time.Hour)
value, ok := em.GetOk("ptr")
assert.True(t, ok)
require.NotNil(t, value)
assert.Equal(t, "hello", *value)
})
}
func TestExpiryMap_ZeroValues(t *testing.T) {
em := New[string](time.Hour)
// Test getting non-existent key returns zero value
value := em.Get("nonexistent")
assert.Equal(t, "", value)
// Test getting expired key returns zero value
em.Set("expired", "value", time.Millisecond*10)
time.Sleep(time.Millisecond * 20)
value = em.Get("expired")
assert.Equal(t, "", value)
}
func TestExpiryMap_Concurrent(t *testing.T) {
em := New[int](time.Hour)
// Simple concurrent access test
done := make(chan bool, 2)
// Writer goroutine
go func() {
for i := 0; i < 100; i++ {
em.Set("key", i, time.Hour)
time.Sleep(time.Microsecond)
}
done <- true
}()
// Reader goroutine
go func() {
for i := 0; i < 100; i++ {
_ = em.Get("key")
time.Sleep(time.Microsecond)
}
done <- true
}()
// Wait for both to complete
<-done
<-done
// Should not panic and should have some value
assert.True(t, em.Has("key"))
}
func TestExpiryMap_GetByValue(t *testing.T) {
em := New[string](time.Hour)
// Test getting by value when value exists
em.Set("key1", "value1", time.Hour)
em.Set("key2", "value2", time.Hour)
em.Set("key3", "value1", time.Hour) // Duplicate value - should return first match
// Test successful retrieval
key, value, ok := em.GetByValue("value1")
assert.True(t, ok)
assert.Equal(t, "value1", value)
assert.Contains(t, []string{"key1", "key3"}, key) // Should be one of the keys with this value
// Test retrieval of unique value
key, value, ok = em.GetByValue("value2")
assert.True(t, ok)
assert.Equal(t, "value2", value)
assert.Equal(t, "key2", key)
// Test getting non-existent value
key, value, ok = em.GetByValue("nonexistent")
assert.False(t, ok)
assert.Equal(t, "", value) // zero value for string
assert.Equal(t, "", key) // zero value for string
}
func TestExpiryMap_GetByValue_Expiration(t *testing.T) {
em := New[string](time.Hour)
// Set a value with short TTL
em.Set("shortkey", "shortvalue", time.Millisecond*10)
em.Set("longkey", "longvalue", time.Hour)
// Should find the short-lived value initially
key, value, ok := em.GetByValue("shortvalue")
assert.True(t, ok)
assert.Equal(t, "shortvalue", value)
assert.Equal(t, "shortkey", key)
// Wait for expiration
time.Sleep(time.Millisecond * 20)
// Should not find expired value and should trigger lazy cleanup
key, value, ok = em.GetByValue("shortvalue")
assert.False(t, ok)
assert.Equal(t, "", value)
assert.Equal(t, "", key)
// Should still find non-expired value
key, value, ok = em.GetByValue("longvalue")
assert.True(t, ok)
assert.Equal(t, "longvalue", value)
assert.Equal(t, "longkey", key)
}
func TestExpiryMap_GetByValue_GenericTypes(t *testing.T) {
t.Run("Int", func(t *testing.T) {
em := New[int](time.Hour)
em.Set("num1", 42, time.Hour)
em.Set("num2", 84, time.Hour)
key, value, ok := em.GetByValue(42)
assert.True(t, ok)
assert.Equal(t, 42, value)
assert.Equal(t, "num1", key)
key, value, ok = em.GetByValue(99)
assert.False(t, ok)
assert.Equal(t, 0, value)
assert.Equal(t, "", key)
})
t.Run("Struct", func(t *testing.T) {
type TestStruct struct {
Name string
Age int
}
em := New[TestStruct](time.Hour)
person1 := TestStruct{Name: "John", Age: 30}
person2 := TestStruct{Name: "Jane", Age: 25}
em.Set("person1", person1, time.Hour)
em.Set("person2", person2, time.Hour)
key, value, ok := em.GetByValue(person1)
assert.True(t, ok)
assert.Equal(t, person1, value)
assert.Equal(t, "person1", key)
nonexistent := TestStruct{Name: "Bob", Age: 40}
key, value, ok = em.GetByValue(nonexistent)
assert.False(t, ok)
assert.Equal(t, TestStruct{}, value)
assert.Equal(t, "", key)
})
}
func TestExpiryMap_RemoveValue(t *testing.T) {
em := New[string](time.Hour)
// Test removing existing value
em.Set("key1", "value1", time.Hour)
em.Set("key2", "value2", time.Hour)
em.Set("key3", "value1", time.Hour) // Duplicate value
// Remove by value should remove one instance
removedValue, ok := em.RemovebyValue("value1")
assert.True(t, ok)
assert.Equal(t, "value1", removedValue)
// Should still have the other instance or value2
assert.True(t, em.Has("key2")) // value2 should still exist
// Check if one of the duplicate values was removed
// At least one key with "value1" should be gone
key1Exists := em.Has("key1")
key3Exists := em.Has("key3")
assert.False(t, key1Exists && key3Exists) // Both shouldn't exist
assert.True(t, key1Exists || key3Exists) // At least one should be gone
// Test removing non-existent value
removedValue, ok = em.RemovebyValue("nonexistent")
assert.False(t, ok)
assert.Equal(t, "", removedValue) // zero value for string
}
func TestExpiryMap_RemoveValue_GenericTypes(t *testing.T) {
t.Run("Int", func(t *testing.T) {
em := New[int](time.Hour)
em.Set("num1", 42, time.Hour)
em.Set("num2", 84, time.Hour)
// Remove existing value
removedValue, ok := em.RemovebyValue(42)
assert.True(t, ok)
assert.Equal(t, 42, removedValue)
assert.False(t, em.Has("num1"))
assert.True(t, em.Has("num2"))
// Remove non-existent value
removedValue, ok = em.RemovebyValue(99)
assert.False(t, ok)
assert.Equal(t, 0, removedValue)
})
t.Run("Struct", func(t *testing.T) {
type TestStruct struct {
Name string
Age int
}
em := New[TestStruct](time.Hour)
person1 := TestStruct{Name: "John", Age: 30}
person2 := TestStruct{Name: "Jane", Age: 25}
em.Set("person1", person1, time.Hour)
em.Set("person2", person2, time.Hour)
// Remove existing struct
removedValue, ok := em.RemovebyValue(person1)
assert.True(t, ok)
assert.Equal(t, person1, removedValue)
assert.False(t, em.Has("person1"))
assert.True(t, em.Has("person2"))
// Remove non-existent struct
nonexistent := TestStruct{Name: "Bob", Age: 40}
removedValue, ok = em.RemovebyValue(nonexistent)
assert.False(t, ok)
assert.Equal(t, TestStruct{}, removedValue)
})
}
func TestExpiryMap_RemoveValue_WithExpiration(t *testing.T) {
em := New[string](time.Hour)
// Set values with different TTLs
em.Set("key1", "value1", time.Millisecond*10) // Will expire
em.Set("key2", "value2", time.Hour) // Won't expire
em.Set("key3", "value1", time.Hour) // Won't expire, duplicate value
// Wait for first value to expire
time.Sleep(time.Millisecond * 20)
// Try to remove the expired value - should remove one of the "value1" entries
removedValue, ok := em.RemovebyValue("value1")
assert.True(t, ok)
assert.Equal(t, "value1", removedValue)
// Should still have key2 (different value)
assert.True(t, em.Has("key2"))
// Should have removed one of the "value1" entries (either key1 or key3)
// But we can't predict which one due to map iteration order
key1Exists := em.Has("key1")
key3Exists := em.Has("key3")
// Exactly one of key1 or key3 should be gone
assert.False(t, key1Exists && key3Exists) // Both shouldn't exist
assert.True(t, key1Exists || key3Exists) // At least one should still exist
}
func TestExpiryMap_ValueOperations_Integration(t *testing.T) {
em := New[string](time.Hour)
// Test integration of GetByValue and RemoveValue
em.Set("key1", "shared", time.Hour)
em.Set("key2", "unique", time.Hour)
em.Set("key3", "shared", time.Hour)
// Find shared value
key, value, ok := em.GetByValue("shared")
assert.True(t, ok)
assert.Equal(t, "shared", value)
assert.Contains(t, []string{"key1", "key3"}, key)
// Remove shared value
removedValue, ok := em.RemovebyValue("shared")
assert.True(t, ok)
assert.Equal(t, "shared", removedValue)
// Should still be able to find the other shared value
key, value, ok = em.GetByValue("shared")
assert.True(t, ok)
assert.Equal(t, "shared", value)
assert.Contains(t, []string{"key1", "key3"}, key)
// Remove the other shared value
removedValue, ok = em.RemovebyValue("shared")
assert.True(t, ok)
assert.Equal(t, "shared", removedValue)
// Should not find shared value anymore
key, value, ok = em.GetByValue("shared")
assert.False(t, ok)
assert.Equal(t, "", value)
assert.Equal(t, "", key)
// Unique value should still exist
key, value, ok = em.GetByValue("unique")
assert.True(t, ok)
assert.Equal(t, "unique", value)
assert.Equal(t, "key2", key)
}

View File

@@ -4,6 +4,7 @@ package hub
import (
"beszel"
"beszel/internal/alerts"
"beszel/internal/hub/config"
"beszel/internal/hub/systems"
"beszel/internal/records"
"beszel/internal/users"
@@ -18,7 +19,9 @@ import (
"os"
"path"
"strings"
"time"
"github.com/google/uuid"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
@@ -32,6 +35,7 @@ type Hub struct {
rm *records.RecordManager
sm *systems.SystemManager
pubKey string
signer ssh.Signer
appURL string
}
@@ -64,7 +68,7 @@ func (h *Hub) StartHub() error {
return err
}
// sync systems with config
if err := syncSystemsWithConfig(e); err != nil {
if err := config.SyncSystems(e); err != nil {
return err
}
// register api routes
@@ -112,6 +116,9 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
if h.appURL != "" {
settings.Meta.AppURL = h.appURL
}
if err := e.App.Save(settings); err != nil {
return err
}
// set auth settings
usersCollection, err := e.App.FindCollectionByNameOrId("users")
if err != nil {
@@ -181,6 +188,7 @@ func (h *Hub) startServer(se *core.ServeEvent) error {
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
indexContent := strings.ReplaceAll(string(indexFile), "./", basePath)
indexContent = strings.Replace(indexContent, "{{V}}", beszel.Version, 1)
indexContent = strings.Replace(indexContent, "{{HUB_URL}}", h.appURL, 1)
// set up static asset serving
staticPaths := [2]string{"/static/", "/assets/"}
serveStatic := apis.Static(site.DistDirFS, false)
@@ -232,7 +240,11 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
// send test notification
se.Router.GET("/api/beszel/send-test-notification", h.SendTestNotification)
// API endpoint to get config.yml content
se.Router.GET("/api/beszel/config-yaml", h.getYamlConfig)
se.Router.GET("/api/beszel/config-yaml", config.GetYamlConfig)
// handle agent websocket connection
se.Router.GET("/api/beszel/agent-connect", h.handleAgentConnect)
// get or create universal tokens
se.Router.GET("/api/beszel/universal-token", h.getUniversalToken)
// create first user endpoint only needed if no users exist
if totalUsers, _ := h.CountRecords("users"); totalUsers == 0 {
se.Router.POST("/api/beszel/create-user", h.um.CreateFirstUser)
@@ -240,8 +252,49 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
return nil
}
// Handler for universal token API endpoint (create, read, delete)
func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
info, err := e.RequestInfo()
if err != nil || info.Auth == nil {
return apis.NewForbiddenError("Forbidden", nil)
}
tokenMap := getTokenMap()
userID := info.Auth.Id
query := e.Request.URL.Query()
token := query.Get("token")
tokenSet := token != ""
if !tokenSet {
// return existing token if it exists
if token, _, ok := tokenMap.GetByValue(userID); ok {
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true})
}
// if no token is provided, generate a new one
token = uuid.New().String()
}
response := map[string]any{"token": token}
switch query.Get("enable") {
case "1":
tokenMap.Set(token, userID, time.Hour)
case "0":
tokenMap.RemovebyValue(userID)
}
_, response["active"] = tokenMap.GetOk(token)
return e.JSON(http.StatusOK, response)
}
// generates key pair if it doesn't exist and returns signer
func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) {
if h.signer != nil {
return h.signer, nil
}
if dataDir == "" {
dataDir = h.DataDir()
}
privateKeyPath := path.Join(dataDir, "id_ed25519")
// check if the key pair already exists
@@ -260,12 +313,10 @@ func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) {
}
// Generate the Ed25519 key pair
pubKey, privKey, err := ed25519.GenerateKey(nil)
_, privKey, err := ed25519.GenerateKey(nil)
if err != nil {
return nil, err
}
// Get the private key in OpenSSH format
privKeyPem, err := ssh.MarshalPrivateKey(privKey, "")
if err != nil {
return nil, err
@@ -276,13 +327,11 @@ func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) {
}
// These are fine to ignore the errors on, as we've literally just created a crypto.PublicKey | crypto.Signer
sshPubKey, _ := ssh.NewPublicKey(pubKey)
sshPrivate, _ := ssh.NewSignerFromSigner(privKey)
pubKeyBytes := ssh.MarshalAuthorizedKey(sshPubKey)
pubKeyBytes := ssh.MarshalAuthorizedKey(sshPrivate.PublicKey())
h.pubKey = strings.TrimSuffix(string(pubKeyBytes), "\n")
h.Logger().Info("ed25519 SSH key pair generated successfully.")
h.Logger().Info("ed25519 key pair generated successfully.")
h.Logger().Info("Saved to: " + privateKeyPath)
return sshPrivate, err

View File

@@ -1,9 +1,10 @@
//go:build testing
// +build testing
package hub
package hub_test
import (
"beszel/internal/tests"
"testing"
"crypto/ed25519"
@@ -12,20 +13,18 @@ import (
"path/filepath"
"strings"
"github.com/pocketbase/pocketbase"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
func getTestHub() *Hub {
app := pocketbase.New()
return NewHub(app)
func getTestHub(t testing.TB) *tests.TestHub {
hub, _ := tests.NewTestHub(t.TempDir())
return hub
}
func TestMakeLink(t *testing.T) {
hub := getTestHub()
hub := getTestHub(t)
tests := []struct {
name string
@@ -115,14 +114,14 @@ func TestMakeLink(t *testing.T) {
}
func TestGetSSHKey(t *testing.T) {
hub := getTestHub()
hub := getTestHub(t)
// Test Case 1: Key generation (no existing key)
t.Run("KeyGeneration", func(t *testing.T) {
tempDir := t.TempDir()
// Ensure pubKey is initially empty or different to ensure GetSSHKey sets it
hub.pubKey = ""
hub.SetPubkey("")
signer, err := hub.GetSSHKey(tempDir)
assert.NoError(t, err, "GetSSHKey should not error when generating a new key")
@@ -135,8 +134,8 @@ func TestGetSSHKey(t *testing.T) {
assert.False(t, info.IsDir(), "Private key path should be a file, not a directory")
// Check if h.pubKey was set
assert.NotEmpty(t, hub.pubKey, "h.pubKey should be set after key generation")
assert.True(t, strings.HasPrefix(hub.pubKey, "ssh-ed25519 "), "h.pubKey should start with 'ssh-ed25519 '")
assert.NotEmpty(t, hub.GetPubkey(), "h.pubKey should be set after key generation")
assert.True(t, strings.HasPrefix(hub.GetPubkey(), "ssh-ed25519 "), "h.pubKey should start with 'ssh-ed25519 '")
// Verify the generated private key is parsable
keyData, err := os.ReadFile(privateKeyPath)
@@ -170,14 +169,14 @@ func TestGetSSHKey(t *testing.T) {
expectedPubKeyStr := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPubKey)))
// Reset h.pubKey to ensure it's set by GetSSHKey from the file
hub.pubKey = ""
hub.SetPubkey("")
signer, err := hub.GetSSHKey(tempDir)
assert.NoError(t, err, "GetSSHKey should not error when reading an existing key")
assert.NotNil(t, signer, "GetSSHKey should return a non-nil signer for an existing key")
// Check if h.pubKey was set correctly to the public key from the file
assert.Equal(t, expectedPubKeyStr, hub.pubKey, "h.pubKey should match the existing public key")
assert.Equal(t, expectedPubKeyStr, hub.GetPubkey(), "h.pubKey should match the existing public key")
// Verify the signer's public key matches the original public key
signerPubKey := signer.PublicKey()
@@ -241,7 +240,7 @@ func TestGetSSHKey(t *testing.T) {
require.NoError(t, err, "Setup failed")
// Reset h.pubKey before each test case
hub.pubKey = ""
hub.SetPubkey("")
// Attempt to get SSH key
_, err = hub.GetSSHKey(tempDir)
@@ -250,8 +249,10 @@ func TestGetSSHKey(t *testing.T) {
tc.errorCheck(t, err)
// Check that pubKey was not set in error cases
assert.Empty(t, hub.pubKey, "h.pubKey should not be set if there was an error")
assert.Empty(t, hub.GetPubkey(), "h.pubKey should not be set if there was an error")
})
}
})
}
// Helper function to create test records

View File

@@ -0,0 +1,21 @@
//go:build testing
// +build testing
package hub
import "beszel/internal/hub/systems"
// TESTING ONLY: GetSystemManager returns the system manager
func (h *Hub) GetSystemManager() *systems.SystemManager {
return h.sm
}
// TESTING ONLY: GetPubkey returns the public key
func (h *Hub) GetPubkey() string {
return h.pubKey
}
// TESTING ONLY: SetPubkey sets the public key
func (h *Hub) SetPubkey(pubkey string) {
h.pubKey = pubkey
}

View File

@@ -0,0 +1,387 @@
package systems
import (
"beszel"
"beszel/internal/entities/system"
"beszel/internal/hub/ws"
"context"
"encoding/json"
"errors"
"fmt"
"math/rand"
"net"
"strings"
"time"
"github.com/blang/semver"
"github.com/fxamacker/cbor/v2"
"github.com/pocketbase/pocketbase/core"
"golang.org/x/crypto/ssh"
)
type System struct {
Id string `db:"id"`
Host string `db:"host"`
Port string `db:"port"`
Status string `db:"status"`
manager *SystemManager // Manager that this system belongs to
client *ssh.Client // SSH client for fetching data
data *system.CombinedData // system data from agent
ctx context.Context // Context for stopping the updater
cancel context.CancelFunc // Stops and removes system from updater
WsConn *ws.WsConn // Handler for agent WebSocket connection
agentVersion semver.Version // Agent version
updateTicker *time.Ticker // Ticker for updating the system
}
func (sm *SystemManager) NewSystem(systemId string) *System {
system := &System{
Id: systemId,
data: &system.CombinedData{},
}
system.ctx, system.cancel = system.getContext()
return system
}
// StartUpdater starts the system updater.
// It first fetches the data from the agent then updates the records.
// If the data is not found or the system is down, it sets the system down.
func (sys *System) StartUpdater() {
// Channel that can be used to set the system down. Currently only used to
// allow a short delay for reconnection after websocket connection is closed.
var downChan chan struct{}
// Add random jitter to first WebSocket connection to prevent
// clustering if all agents are started at the same time.
// SSH connections during hub startup are already staggered.
var jitter <-chan time.Time
if sys.WsConn != nil {
jitter = getJitter()
// use the websocket connection's down channel to set the system down
downChan = sys.WsConn.DownChan
} else {
// if the system does not have a websocket connection, wait before updating
// to allow the agent to connect via websocket (makes sure fingerprint is set).
time.Sleep(11 * time.Second)
}
// update immediately if system is not paused (only for ws connections)
// we'll wait a minute before connecting via SSH to prioritize ws connections
if sys.Status != paused && sys.ctx.Err() == nil {
if err := sys.update(); err != nil {
_ = sys.setDown(err)
}
}
sys.updateTicker = time.NewTicker(time.Duration(interval) * time.Millisecond)
// Go 1.23+ will automatically stop the ticker when the system is garbage collected, however we seem to need this or testing/synctest will block even if calling runtime.GC()
defer sys.updateTicker.Stop()
for {
select {
case <-sys.ctx.Done():
return
case <-sys.updateTicker.C:
if err := sys.update(); err != nil {
_ = sys.setDown(err)
}
case <-downChan:
sys.WsConn = nil
downChan = nil
_ = sys.setDown(nil)
case <-jitter:
sys.updateTicker.Reset(time.Duration(interval) * time.Millisecond)
if err := sys.update(); err != nil {
_ = sys.setDown(err)
}
}
}
}
// update updates the system data and records.
func (sys *System) update() error {
if sys.Status == paused {
sys.handlePaused()
return nil
}
data, err := sys.fetchDataFromAgent()
if err == nil {
_, err = sys.createRecords(data)
}
return err
}
func (sys *System) handlePaused() {
if sys.WsConn == nil {
// if the system is paused and there's no websocket connection, remove the system
_ = sys.manager.RemoveSystem(sys.Id)
} else {
// Send a ping to the agent to keep the connection alive if the system is paused
if err := sys.WsConn.Ping(); err != nil {
sys.manager.hub.Logger().Warn("Failed to ping agent", "system", sys.Id, "err", err)
_ = sys.manager.RemoveSystem(sys.Id)
}
}
}
// createRecords updates the system record and adds system_stats and container_stats records
func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error) {
systemRecord, err := sys.getRecord()
if err != nil {
return nil, err
}
hub := sys.manager.hub
// add system_stats and container_stats records
systemStatsCollection, err := hub.FindCachedCollectionByNameOrId("system_stats")
if err != nil {
return nil, err
}
systemStatsRecord := core.NewRecord(systemStatsCollection)
systemStatsRecord.Set("system", systemRecord.Id)
systemStatsRecord.Set("stats", data.Stats)
systemStatsRecord.Set("type", "1m")
if err := hub.SaveNoValidate(systemStatsRecord); err != nil {
return nil, err
}
// add new container_stats record
if len(data.Containers) > 0 {
containerStatsCollection, err := hub.FindCachedCollectionByNameOrId("container_stats")
if err != nil {
return nil, err
}
containerStatsRecord := core.NewRecord(containerStatsCollection)
containerStatsRecord.Set("system", systemRecord.Id)
containerStatsRecord.Set("stats", data.Containers)
containerStatsRecord.Set("type", "1m")
if err := hub.SaveNoValidate(containerStatsRecord); err != nil {
return nil, err
}
}
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
systemRecord.Set("status", up)
systemRecord.Set("info", data.Info)
if err := hub.SaveNoValidate(systemRecord); err != nil {
return nil, err
}
return systemRecord, nil
}
// getRecord retrieves the system record from the database.
// If the record is not found, it removes the system from the manager.
func (sys *System) getRecord() (*core.Record, error) {
record, err := sys.manager.hub.FindRecordById("systems", sys.Id)
if err != nil || record == nil {
_ = sys.manager.RemoveSystem(sys.Id)
return nil, err
}
return record, nil
}
// setDown marks a system as down in the database.
// It takes the original error that caused the system to go down and returns any error
// encountered during the process of updating the system status.
func (sys *System) setDown(originalError error) error {
if sys.Status == down || sys.Status == paused {
return nil
}
record, err := sys.getRecord()
if err != nil {
return err
}
if originalError != nil {
sys.manager.hub.Logger().Error("System down", "system", record.GetString("name"), "err", originalError)
}
record.Set("status", down)
return sys.manager.hub.SaveNoValidate(record)
}
func (sys *System) getContext() (context.Context, context.CancelFunc) {
if sys.ctx == nil {
sys.ctx, sys.cancel = context.WithCancel(context.Background())
}
return sys.ctx, sys.cancel
}
// fetchDataFromAgent attempts to fetch data from the agent,
// prioritizing WebSocket if available.
func (sys *System) fetchDataFromAgent() (*system.CombinedData, error) {
if sys.data == nil {
sys.data = &system.CombinedData{}
}
if sys.WsConn != nil && sys.WsConn.IsConnected() {
wsData, err := sys.fetchDataViaWebSocket()
if err == nil {
return wsData, nil
}
// close the WebSocket connection if error and try SSH
sys.closeWebSocketConnection()
}
sshData, err := sys.fetchDataViaSSH()
if err != nil {
return nil, err
}
return sshData, nil
}
func (sys *System) fetchDataViaWebSocket() (*system.CombinedData, error) {
if sys.WsConn == nil || !sys.WsConn.IsConnected() {
return nil, errors.New("no websocket connection")
}
err := sys.WsConn.RequestSystemData(sys.data)
if err != nil {
return nil, err
}
return sys.data, nil
}
// fetchDataViaSSH handles fetching data using SSH.
// This function encapsulates the original SSH logic.
// It updates sys.data directly upon successful fetch.
func (sys *System) fetchDataViaSSH() (*system.CombinedData, error) {
maxRetries := 1
for attempt := 0; attempt <= maxRetries; attempt++ {
if sys.client == nil || sys.Status == down {
if err := sys.createSSHClient(); err != nil {
return nil, err
}
}
session, err := sys.createSessionWithTimeout(4 * time.Second)
if err != nil {
if attempt >= maxRetries {
return nil, err
}
sys.manager.hub.Logger().Warn("Session closed. Retrying...", "host", sys.Host, "port", sys.Port, "err", err)
sys.closeSSHConnection()
// Reset format detection on connection failure - agent might have been upgraded
continue
}
defer session.Close()
stdout, err := session.StdoutPipe()
if err != nil {
return nil, err
}
if err := session.Shell(); err != nil {
return nil, err
}
*sys.data = system.CombinedData{}
if sys.agentVersion.GTE(beszel.MinVersionCbor) {
err = cbor.NewDecoder(stdout).Decode(sys.data)
} else {
err = json.NewDecoder(stdout).Decode(sys.data)
}
if err != nil {
sys.closeSSHConnection()
if attempt < maxRetries {
continue
}
return nil, err
}
// wait for the session to complete
if err := session.Wait(); err != nil {
return nil, err
}
return sys.data, nil
}
// this should never be reached due to the return in the loop
return nil, fmt.Errorf("failed to fetch data")
}
// createSSHClient creates a new SSH client for the system
func (s *System) createSSHClient() error {
if s.manager.sshConfig == nil {
if err := s.manager.createSSHClientConfig(); err != nil {
return err
}
}
network := "tcp"
host := s.Host
if strings.HasPrefix(host, "/") {
network = "unix"
} else {
host = net.JoinHostPort(host, s.Port)
}
var err error
s.client, err = ssh.Dial(network, host, s.manager.sshConfig)
if err != nil {
return err
}
s.agentVersion, _ = extractAgentVersion(string(s.client.Conn.ServerVersion()))
return nil
}
// createSessionWithTimeout creates a new SSH session with a timeout to avoid hanging
// in case of network issues
func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session, error) {
if sys.client == nil {
return nil, fmt.Errorf("client not initialized")
}
ctx, cancel := context.WithTimeout(sys.ctx, timeout)
defer cancel()
sessionChan := make(chan *ssh.Session, 1)
errChan := make(chan error, 1)
go func() {
if session, err := sys.client.NewSession(); err != nil {
errChan <- err
} else {
sessionChan <- session
}
}()
select {
case session := <-sessionChan:
return session, nil
case err := <-errChan:
return nil, err
case <-ctx.Done():
return nil, fmt.Errorf("timeout")
}
}
// closeSSHConnection closes the SSH connection but keeps the system in the manager
func (sys *System) closeSSHConnection() {
if sys.client != nil {
sys.client.Close()
sys.client = nil
}
}
// closeWebSocketConnection closes the WebSocket connection but keeps the system in the manager
// to allow updating via SSH. It will be removed if the WS connection is re-established.
// The system will be set as down a few seconds later if the connection is not re-established.
func (sys *System) closeWebSocketConnection() {
if sys.WsConn != nil {
sys.WsConn.Close()
}
}
// extractAgentVersion extracts the beszel version from SSH server version string
func extractAgentVersion(versionString string) (semver.Version, error) {
_, after, _ := strings.Cut(versionString, "_")
return semver.Parse(after)
}
// getJitter returns a channel that will be triggered after a random delay
// between 40% and 90% of the interval.
// This is used to stagger the initial WebSocket connections to prevent clustering.
func getJitter() <-chan time.Time {
minPercent := 40
maxPercent := 90
jitterRange := maxPercent - minPercent
msDelay := (interval * minPercent / 100) + rand.Intn(interval*jitterRange/100)
return time.After(time.Duration(msDelay) * time.Millisecond)
}

View File

@@ -0,0 +1,345 @@
package systems
import (
"beszel"
"beszel/internal/common"
"beszel/internal/entities/system"
"beszel/internal/hub/ws"
"errors"
"fmt"
"time"
"github.com/blang/semver"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/store"
"golang.org/x/crypto/ssh"
)
// System status constants
const (
up string = "up" // System is online and responding
down string = "down" // System is offline or not responding
paused string = "paused" // System monitoring is paused
pending string = "pending" // System is waiting on initial connection result
// interval is the default update interval in milliseconds (60 seconds)
interval int = 60_000
// interval int = 10_000 // Debug interval for faster updates
// sessionTimeout is the maximum time to wait for SSH connections
sessionTimeout = 4 * time.Second
)
var (
// errSystemExists is returned when attempting to add a system that already exists
errSystemExists = errors.New("system exists")
)
// SystemManager manages a collection of monitored systems and their connections.
// It handles system lifecycle, status updates, and maintains both SSH and WebSocket connections.
type SystemManager struct {
hub hubLike // Hub interface for database and alert operations
systems *store.Store[string, *System] // Thread-safe store of active systems
sshConfig *ssh.ClientConfig // SSH client configuration for system connections
}
// hubLike defines the interface requirements for the hub dependency.
// It extends core.App with system-specific functionality.
type hubLike interface {
core.App
GetSSHKey(dataDir string) (ssh.Signer, error)
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
HandleStatusAlerts(status string, systemRecord *core.Record) error
}
// NewSystemManager creates a new SystemManager instance with the provided hub.
// The hub must implement the hubLike interface to provide database and alert functionality.
func NewSystemManager(hub hubLike) *SystemManager {
return &SystemManager{
systems: store.New(map[string]*System{}),
hub: hub,
}
}
// Initialize sets up the system manager by binding event hooks and starting existing systems.
// It configures SSH client settings and begins monitoring all non-paused systems from the database.
// Systems are started with staggered delays to prevent overwhelming the hub during startup.
func (sm *SystemManager) Initialize() error {
sm.bindEventHooks()
// Initialize SSH client configuration
err := sm.createSSHClientConfig()
if err != nil {
return err
}
// Load existing systems from database (excluding paused ones)
var systems []*System
err = sm.hub.DB().NewQuery("SELECT id, host, port, status FROM systems WHERE status != 'paused'").All(&systems)
if err != nil || len(systems) == 0 {
return err
}
// Start systems in background with staggered timing
go func() {
// Calculate staggered delay between system starts (max 2 seconds per system)
delta := interval / max(1, len(systems))
delta = min(delta, 2_000)
sleepTime := time.Duration(delta) * time.Millisecond
for _, system := range systems {
time.Sleep(sleepTime)
_ = sm.AddSystem(system)
}
}()
return nil
}
// bindEventHooks registers event handlers for system and fingerprint record changes.
// These hooks ensure the system manager stays synchronized with database changes.
func (sm *SystemManager) bindEventHooks() {
sm.hub.OnRecordCreate("systems").BindFunc(sm.onRecordCreate)
sm.hub.OnRecordAfterCreateSuccess("systems").BindFunc(sm.onRecordAfterCreateSuccess)
sm.hub.OnRecordUpdate("systems").BindFunc(sm.onRecordUpdate)
sm.hub.OnRecordAfterUpdateSuccess("systems").BindFunc(sm.onRecordAfterUpdateSuccess)
sm.hub.OnRecordAfterDeleteSuccess("systems").BindFunc(sm.onRecordAfterDeleteSuccess)
sm.hub.OnRecordAfterUpdateSuccess("fingerprints").BindFunc(sm.onTokenRotated)
}
// onTokenRotated handles fingerprint token rotation events.
// When a system's authentication token is rotated, any existing WebSocket connection
// must be closed to force re-authentication with the new token.
func (sm *SystemManager) onTokenRotated(e *core.RecordEvent) error {
systemID := e.Record.GetString("system")
system, ok := sm.systems.GetOk(systemID)
if !ok {
return e.Next()
}
// No need to close connection if not connected via websocket
if system.WsConn == nil {
return e.Next()
}
system.setDown(nil)
sm.RemoveSystem(systemID)
return e.Next()
}
// onRecordCreate is called before a new system record is committed to the database.
// It initializes the record with default values: empty info and pending status.
func (sm *SystemManager) onRecordCreate(e *core.RecordEvent) error {
e.Record.Set("info", system.Info{})
e.Record.Set("status", pending)
return e.Next()
}
// onRecordAfterCreateSuccess is called after a new system record is successfully created.
// It adds the new system to the manager to begin monitoring.
func (sm *SystemManager) onRecordAfterCreateSuccess(e *core.RecordEvent) error {
if err := sm.AddRecord(e.Record, nil); err != nil {
e.App.Logger().Error("Error adding record", "err", err)
}
return e.Next()
}
// onRecordUpdate is called before a system record is updated in the database.
// It clears system info when the status is changed to paused.
func (sm *SystemManager) onRecordUpdate(e *core.RecordEvent) error {
if e.Record.GetString("status") == paused {
e.Record.Set("info", system.Info{})
}
return e.Next()
}
// onRecordAfterUpdateSuccess handles system record updates after they're committed to the database.
// It manages system lifecycle based on status changes and triggers appropriate alerts.
// Status transitions are handled as follows:
// - paused: Closes SSH connection and deactivates alerts
// - pending: Starts monitoring (reuses WebSocket if available)
// - up: Triggers system alerts
// - down: Triggers status change alerts
func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
newStatus := e.Record.GetString("status")
system, ok := sm.systems.GetOk(e.Record.Id)
if ok {
system.Status = newStatus
}
switch newStatus {
case paused:
if ok {
// Pause monitoring but keep system in manager for potential resume
system.closeSSHConnection()
}
_ = deactivateAlerts(e.App, e.Record.Id)
return e.Next()
case pending:
// Resume monitoring, preferring existing WebSocket connection
if ok && system.WsConn != nil {
go system.update()
return e.Next()
}
// Start new monitoring session
if err := sm.AddRecord(e.Record, nil); err != nil {
e.App.Logger().Error("Error adding record", "err", err)
}
return e.Next()
}
// Handle systems not in manager
if !ok {
return sm.AddRecord(e.Record, nil)
}
prevStatus := system.Status
// Trigger system alerts when system comes online
if newStatus == up {
if err := sm.hub.HandleSystemAlerts(e.Record, system.data); err != nil {
e.App.Logger().Error("Error handling system alerts", "err", err)
}
}
// Trigger status change alerts for up/down transitions
if (newStatus == down && prevStatus == up) || (newStatus == up && prevStatus == down) {
if err := sm.hub.HandleStatusAlerts(newStatus, e.Record); err != nil {
e.App.Logger().Error("Error handling status alerts", "err", err)
}
}
return e.Next()
}
// onRecordAfterDeleteSuccess is called after a system record is successfully deleted.
// It removes the system from the manager and cleans up all associated resources.
func (sm *SystemManager) onRecordAfterDeleteSuccess(e *core.RecordEvent) error {
sm.RemoveSystem(e.Record.Id)
return e.Next()
}
// AddSystem adds a system to the manager and starts monitoring it.
// It validates required fields, initializes the system context, and starts the update goroutine.
// Returns error if a system with the same ID already exists.
func (sm *SystemManager) AddSystem(sys *System) error {
if sm.systems.Has(sys.Id) {
return errSystemExists
}
if sys.Id == "" || sys.Host == "" {
return errors.New("system missing required fields")
}
// Initialize system for monitoring
sys.manager = sm
sys.ctx, sys.cancel = sys.getContext()
sys.data = &system.CombinedData{}
sm.systems.Set(sys.Id, sys)
// Start monitoring in background
go sys.StartUpdater()
return nil
}
// RemoveSystem removes a system from the manager and cleans up all associated resources.
// It cancels the system's context, closes all connections, and removes it from the store.
// Returns an error if the system is not found.
func (sm *SystemManager) RemoveSystem(systemID string) error {
system, ok := sm.systems.GetOk(systemID)
if !ok {
return errors.New("system not found")
}
// Stop the update goroutine
if system.cancel != nil {
system.cancel()
}
// Clean up all connections
system.closeSSHConnection()
system.closeWebSocketConnection()
sm.systems.Remove(systemID)
return nil
}
// AddRecord creates a System instance from a database record and adds it to the manager.
// If a system with the same ID already exists, it's removed first to ensure clean state.
// If no system instance is provided, a new one is created.
// This method is typically called when systems are created or their status changes to pending.
func (sm *SystemManager) AddRecord(record *core.Record, system *System) (err error) {
// Remove existing system to ensure clean state
if sm.systems.Has(record.Id) {
_ = sm.RemoveSystem(record.Id)
}
// Create new system if none provided
if system == nil {
system = sm.NewSystem(record.Id)
}
// Populate system from record
system.Status = record.GetString("status")
system.Host = record.GetString("host")
system.Port = record.GetString("port")
return sm.AddSystem(system)
}
// AddWebSocketSystem creates and adds a system with an established WebSocket connection.
// This method is called when an agent connects via WebSocket with valid authentication.
// The system is immediately added to monitoring with the provided connection and version info.
func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver.Version, wsConn *ws.WsConn) error {
systemRecord, err := sm.hub.FindRecordById("systems", systemId)
if err != nil {
return err
}
system := sm.NewSystem(systemId)
system.WsConn = wsConn
system.agentVersion = agentVersion
if err := sm.AddRecord(systemRecord, system); err != nil {
return err
}
return nil
}
// createSSHClientConfig initializes the SSH client configuration for connecting to an agent's server
func (sm *SystemManager) createSSHClientConfig() error {
privateKey, err := sm.hub.GetSSHKey("")
if err != nil {
return err
}
sm.sshConfig = &ssh.ClientConfig{
User: "u",
Auth: []ssh.AuthMethod{
ssh.PublicKeys(privateKey),
},
Config: ssh.Config{
Ciphers: common.DefaultCiphers,
KeyExchanges: common.DefaultKeyExchanges,
MACs: common.DefaultMACs,
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
ClientVersion: fmt.Sprintf("SSH-2.0-%s_%s", beszel.AppName, beszel.Version),
Timeout: sessionTimeout,
}
return nil
}
// deactivateAlerts finds all triggered alerts for a system and sets them to inactive.
// This is called when a system is paused or goes offline to prevent continued alerts.
func deactivateAlerts(app core.App, systemID string) error {
// Note: Direct SQL updates don't trigger SSE, so we use the PocketBase API
// _, err := app.DB().NewQuery(fmt.Sprintf("UPDATE alerts SET triggered = false WHERE system = '%s'", systemID)).Execute()
alerts, err := app.FindRecordsByFilter("alerts", fmt.Sprintf("system = '%s' && triggered = 1", systemID), "", -1, 0)
if err != nil {
return err
}
for _, alert := range alerts {
alert.Set("triggered", false)
if err := app.SaveNoValidate(alert); err != nil {
return err
}
}
return nil
}

View File

@@ -1,457 +0,0 @@
package systems
import (
"beszel/internal/common"
"beszel/internal/entities/system"
"context"
"fmt"
"net"
"strings"
"time"
"github.com/goccy/go-json"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/store"
"golang.org/x/crypto/ssh"
)
const (
up string = "up"
down string = "down"
paused string = "paused"
pending string = "pending"
interval int = 60_000
sessionTimeout = 4 * time.Second
)
type SystemManager struct {
hub hubLike
systems *store.Store[string, *System]
sshConfig *ssh.ClientConfig
}
type System struct {
Id string `db:"id"`
Host string `db:"host"`
Port string `db:"port"`
Status string `db:"status"`
manager *SystemManager
client *ssh.Client
data *system.CombinedData
ctx context.Context
cancel context.CancelFunc
}
type hubLike interface {
core.App
GetSSHKey(dataDir string) (ssh.Signer, error)
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
HandleStatusAlerts(status string, systemRecord *core.Record) error
}
func NewSystemManager(hub hubLike) *SystemManager {
return &SystemManager{
systems: store.New(map[string]*System{}),
hub: hub,
}
}
// Initialize initializes the system manager.
// It binds the event hooks and starts updating existing systems.
func (sm *SystemManager) Initialize() error {
sm.bindEventHooks()
// ssh setup
err := sm.createSSHClientConfig()
if err != nil {
return err
}
// start updating existing systems
var systems []*System
err = sm.hub.DB().NewQuery("SELECT id, host, port, status FROM systems WHERE status != 'paused'").All(&systems)
if err != nil || len(systems) == 0 {
return err
}
go func() {
// time between initial system updates
delta := interval / max(1, len(systems))
delta = min(delta, 2_000)
sleepTime := time.Duration(delta) * time.Millisecond
for _, system := range systems {
time.Sleep(sleepTime)
_ = sm.AddSystem(system)
}
}()
return nil
}
func (sm *SystemManager) bindEventHooks() {
sm.hub.OnRecordCreate("systems").BindFunc(sm.onRecordCreate)
sm.hub.OnRecordAfterCreateSuccess("systems").BindFunc(sm.onRecordAfterCreateSuccess)
sm.hub.OnRecordUpdate("systems").BindFunc(sm.onRecordUpdate)
sm.hub.OnRecordAfterUpdateSuccess("systems").BindFunc(sm.onRecordAfterUpdateSuccess)
sm.hub.OnRecordAfterDeleteSuccess("systems").BindFunc(sm.onRecordAfterDeleteSuccess)
}
// Runs before the record is committed to the database
func (sm *SystemManager) onRecordCreate(e *core.RecordEvent) error {
e.Record.Set("info", system.Info{})
e.Record.Set("status", pending)
return e.Next()
}
// Runs after the record is committed to the database
func (sm *SystemManager) onRecordAfterCreateSuccess(e *core.RecordEvent) error {
if err := sm.AddRecord(e.Record); err != nil {
e.App.Logger().Error("Error adding record", "err", err)
}
return e.Next()
}
// Runs before the record is updated
func (sm *SystemManager) onRecordUpdate(e *core.RecordEvent) error {
if e.Record.GetString("status") == paused {
e.Record.Set("info", system.Info{})
}
return e.Next()
}
// Runs after the record is updated
func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
newStatus := e.Record.GetString("status")
switch newStatus {
case paused:
_ = sm.RemoveSystem(e.Record.Id)
_ = deactivateAlerts(e.App, e.Record.Id)
return e.Next()
case pending:
if err := sm.AddRecord(e.Record); err != nil {
e.App.Logger().Error("Error adding record", "err", err)
}
return e.Next()
}
system, ok := sm.systems.GetOk(e.Record.Id)
if !ok {
return sm.AddRecord(e.Record)
}
prevStatus := system.Status
system.Status = newStatus
// system alerts if system is up
if system.Status == up {
if err := sm.hub.HandleSystemAlerts(e.Record, system.data); err != nil {
e.App.Logger().Error("Error handling system alerts", "err", err)
}
}
if (system.Status == down && prevStatus == up) || (system.Status == up && prevStatus == down) {
if err := sm.hub.HandleStatusAlerts(system.Status, e.Record); err != nil {
e.App.Logger().Error("Error handling status alerts", "err", err)
}
}
return e.Next()
}
// Runs after the record is deleted
func (sm *SystemManager) onRecordAfterDeleteSuccess(e *core.RecordEvent) error {
sm.RemoveSystem(e.Record.Id)
return e.Next()
}
// AddSystem adds a system to the manager
func (sm *SystemManager) AddSystem(sys *System) error {
if sm.systems.Has(sys.Id) {
return fmt.Errorf("system exists")
}
if sys.Id == "" || sys.Host == "" {
return fmt.Errorf("system is missing required fields")
}
sys.manager = sm
sys.ctx, sys.cancel = context.WithCancel(context.Background())
sys.data = &system.CombinedData{}
sm.systems.Set(sys.Id, sys)
go sys.StartUpdater()
return nil
}
// RemoveSystem removes a system from the manager
func (sm *SystemManager) RemoveSystem(systemID string) error {
system, ok := sm.systems.GetOk(systemID)
if !ok {
return fmt.Errorf("system not found")
}
// cancel the context to signal stop
if system.cancel != nil {
system.cancel()
}
system.resetSSHClient()
sm.systems.Remove(systemID)
return nil
}
// AddRecord adds a record to the system manager.
// It first removes any existing system with the same ID, then creates a new System
// instance from the record data and adds it to the manager.
// This function is typically called when a new system is created or when an existing
// system's status changes to pending.
func (sm *SystemManager) AddRecord(record *core.Record) (err error) {
_ = sm.RemoveSystem(record.Id)
system := &System{
Id: record.Id,
Status: record.GetString("status"),
Host: record.GetString("host"),
Port: record.GetString("port"),
}
return sm.AddSystem(system)
}
// StartUpdater starts the system updater.
// It first fetches the data from the agent then updates the records.
// If the data is not found or the system is down, it sets the system down.
func (sys *System) StartUpdater() {
if sys.data == nil {
sys.data = &system.CombinedData{}
}
if err := sys.update(); err != nil {
_ = sys.setDown(err)
}
c := time.Tick(time.Duration(interval) * time.Millisecond)
for {
select {
case <-sys.ctx.Done():
return
case <-c:
err := sys.update()
if err != nil {
_ = sys.setDown(err)
}
}
}
}
// update updates the system data and records.
// It first fetches the data from the agent then updates the records.
func (sys *System) update() error {
_, err := sys.fetchDataFromAgent()
if err == nil {
_, err = sys.createRecords()
}
return err
}
// createRecords updates the system record and adds system_stats and container_stats records
func (sys *System) createRecords() (*core.Record, error) {
systemRecord, err := sys.getRecord()
if err != nil {
return nil, err
}
hub := sys.manager.hub
// add system_stats and container_stats records
systemStats, err := hub.FindCachedCollectionByNameOrId("system_stats")
if err != nil {
return nil, err
}
systemStatsRecord := core.NewRecord(systemStats)
systemStatsRecord.Set("system", systemRecord.Id)
systemStatsRecord.Set("stats", sys.data.Stats)
systemStatsRecord.Set("type", "1m")
if err := hub.SaveNoValidate(systemStatsRecord); err != nil {
return nil, err
}
// add new container_stats record
if len(sys.data.Containers) > 0 {
containerStats, err := hub.FindCachedCollectionByNameOrId("container_stats")
if err != nil {
return nil, err
}
containerStatsRecord := core.NewRecord(containerStats)
containerStatsRecord.Set("system", systemRecord.Id)
containerStatsRecord.Set("stats", sys.data.Containers)
containerStatsRecord.Set("type", "1m")
if err := hub.SaveNoValidate(containerStatsRecord); err != nil {
return nil, err
}
}
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
systemRecord.Set("status", up)
systemRecord.Set("info", sys.data.Info)
if err := hub.SaveNoValidate(systemRecord); err != nil {
return nil, err
}
return systemRecord, nil
}
// getRecord retrieves the system record from the database.
// If the record is not found or the system is paused, it removes the system from the manager.
func (sys *System) getRecord() (*core.Record, error) {
record, err := sys.manager.hub.FindRecordById("systems", sys.Id)
if err != nil || record == nil {
_ = sys.manager.RemoveSystem(sys.Id)
return nil, err
}
return record, nil
}
// setDown marks a system as down in the database.
// It takes the original error that caused the system to go down and returns any error
// encountered during the process of updating the system status.
func (sys *System) setDown(OriginalError error) error {
if sys.Status == down {
return nil
}
record, err := sys.getRecord()
if err != nil {
return err
}
sys.manager.hub.Logger().Error("System down", "system", record.GetString("name"), "err", OriginalError)
record.Set("status", down)
err = sys.manager.hub.SaveNoValidate(record)
if err != nil {
return err
}
return nil
}
// fetchDataFromAgent fetches the data from the agent.
// It first creates a new SSH client if it doesn't exist or the system is down.
// Then it creates a new SSH session and fetches the data from the agent.
// If the data is not found or the system is down, it sets the system down.
func (sys *System) fetchDataFromAgent() (*system.CombinedData, error) {
maxRetries := 1
for attempt := 0; attempt <= maxRetries; attempt++ {
if sys.client == nil || sys.Status == down {
if err := sys.createSSHClient(); err != nil {
return nil, err
}
}
session, err := sys.createSessionWithTimeout(4 * time.Second)
if err != nil {
if attempt >= maxRetries {
return nil, err
}
sys.manager.hub.Logger().Warn("Session closed. Retrying...", "host", sys.Host, "port", sys.Port, "err", err)
sys.resetSSHClient()
continue
}
defer session.Close()
stdout, err := session.StdoutPipe()
if err != nil {
return nil, err
}
if err := session.Shell(); err != nil {
return nil, err
}
// this is initialized in startUpdater, should never be nil
*sys.data = system.CombinedData{}
if err := json.NewDecoder(stdout).Decode(sys.data); err != nil {
return nil, err
}
// wait for the session to complete
if err := session.Wait(); err != nil {
return nil, err
}
return sys.data, nil
}
// this should never be reached due to the return in the loop
return nil, fmt.Errorf("failed to fetch data")
}
// createSSHClientConfig initializes the ssh config for the system manager
func (sm *SystemManager) createSSHClientConfig() error {
privateKey, err := sm.hub.GetSSHKey(sm.hub.DataDir())
if err != nil {
return err
}
sm.sshConfig = &ssh.ClientConfig{
User: "u",
Auth: []ssh.AuthMethod{
ssh.PublicKeys(privateKey),
},
Config: ssh.Config{
Ciphers: common.DefaultCiphers,
KeyExchanges: common.DefaultKeyExchanges,
MACs: common.DefaultMACs,
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: sessionTimeout,
}
return nil
}
// createSSHClient creates a new SSH client for the system
func (s *System) createSSHClient() error {
network := "tcp"
host := s.Host
if strings.HasPrefix(host, "/") {
network = "unix"
} else {
host = net.JoinHostPort(host, s.Port)
}
var err error
s.client, err = ssh.Dial(network, host, s.manager.sshConfig)
if err != nil {
return err
}
return nil
}
// createSessionWithTimeout creates a new SSH session with a timeout to avoid hanging
// in case of network issues
func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session, error) {
if sys.client == nil {
return nil, fmt.Errorf("client not initialized")
}
ctx, cancel := context.WithTimeout(sys.ctx, timeout)
defer cancel()
sessionChan := make(chan *ssh.Session, 1)
errChan := make(chan error, 1)
go func() {
if session, err := sys.client.NewSession(); err != nil {
errChan <- err
} else {
sessionChan <- session
}
}()
select {
case session := <-sessionChan:
return session, nil
case err := <-errChan:
return nil, err
case <-ctx.Done():
return nil, fmt.Errorf("timeout")
}
}
// resetSSHClient closes the SSH connection and resets the client to nil
func (sys *System) resetSSHClient() {
if sys.client != nil {
sys.client.Close()
}
sys.client = nil
}
// deactivateAlerts finds all triggered alerts for a system and sets them to false
func deactivateAlerts(app core.App, systemID string) error {
// we can't use an UPDATE query because it doesn't work with realtime updates
// _, err := e.App.DB().NewQuery(fmt.Sprintf("UPDATE alerts SET triggered = false WHERE system = '%s'", e.Record.Id)).Execute()
alerts, err := app.FindRecordsByFilter("alerts", fmt.Sprintf("system = '%s' && triggered = 1", systemID), "", -1, 0)
if err != nil {
return err
}
for _, alert := range alerts {
alert.Set("triggered", false)
if err := app.SaveNoValidate(alert); err != nil {
return err
}
}
return nil
}

View File

@@ -11,70 +11,133 @@ import (
"fmt"
"sync"
"testing"
"testing/synctest"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// createTestSystem creates a test system record with a unique host name
// and returns the created record and any error
func createTestSystem(t *testing.T, hub *tests.TestHub, options map[string]any) (*core.Record, error) {
collection, err := hub.FindCachedCollectionByNameOrId("systems")
if err != nil {
return nil, err
}
// get user record
var firstUser *core.Record
users, err := hub.FindAllRecords("users", dbx.NewExp("id != ''"))
if err != nil {
t.Fatal(err)
}
if len(users) > 0 {
firstUser = users[0]
}
// Generate a unique host name to ensure we're adding a new system
uniqueHost := fmt.Sprintf("test-host-%d.example.com", time.Now().UnixNano())
// Create the record
record := core.NewRecord(collection)
record.Set("name", uniqueHost)
record.Set("host", uniqueHost)
record.Set("port", "45876")
record.Set("status", "pending")
record.Set("users", []string{firstUser.Id})
// Apply any custom options
for key, value := range options {
record.Set(key, value)
}
// Save the record to the database
err = hub.Save(record)
if err != nil {
return nil, err
}
return record, nil
}
func TestSystemManagerIntegration(t *testing.T) {
// Create a test hub
hub, err := tests.NewTestHub()
func TestSystemManagerNew(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer hub.Cleanup()
sm := hub.GetSystemManager()
// Create independent system manager
sm := systems.NewSystemManager(hub)
user, err := tests.CreateUser(hub, "test@test.com", "testtesttest")
require.NoError(t, err)
synctest.Run(func() {
sm.Initialize()
record, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "it-was-coney-island",
"host": "the-playground-of-the-world",
"port": "33914",
"users": []string{user.Id},
})
require.NoError(t, err)
assert.Equal(t, "pending", record.GetString("status"), "System status should be 'pending'")
assert.Equal(t, "pending", sm.GetSystemStatusFromStore(record.Id), "System status should be 'pending'")
// Verify the system host and port
host, port := sm.GetSystemHostPort(record.Id)
assert.Equal(t, record.GetString("host"), host, "System host should match")
assert.Equal(t, record.GetString("port"), port, "System port should match")
time.Sleep(13 * time.Second)
synctest.Wait()
assert.Equal(t, "pending", record.Fresh().GetString("status"), "System status should be 'pending'")
// Verify the system was added by checking if it exists
assert.True(t, sm.HasSystem(record.Id), "System should exist in the store")
time.Sleep(10 * time.Second)
synctest.Wait()
// system should be set to down after 15 seconds (no websocket connection)
assert.Equal(t, "down", sm.GetSystemStatusFromStore(record.Id), "System status should be 'down'")
// make sure the system is down in the db
record, err = hub.FindRecordById("systems", record.Id)
require.NoError(t, err)
assert.Equal(t, "down", record.GetString("status"), "System status should be 'down'")
assert.Equal(t, 1, sm.GetSystemCount(), "System count should be 1")
err = sm.RemoveSystem(record.Id)
assert.NoError(t, err)
assert.Equal(t, 0, sm.GetSystemCount(), "System count should be 0")
assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after removal")
// let's also make sure a system is removed from the store when the record is deleted
record, err = tests.CreateRecord(hub, "systems", map[string]any{
"name": "there-was-no-place-like-it",
"host": "in-the-whole-world",
"port": "33914",
"users": []string{user.Id},
})
require.NoError(t, err)
assert.True(t, sm.HasSystem(record.Id), "System should exist in the store after creation")
time.Sleep(8 * time.Second)
synctest.Wait()
assert.Equal(t, "pending", sm.GetSystemStatusFromStore(record.Id), "System status should be 'pending'")
sm.SetSystemStatusInDB(record.Id, "up")
time.Sleep(time.Second)
synctest.Wait()
assert.Equal(t, "up", sm.GetSystemStatusFromStore(record.Id), "System status should be 'up'")
// make sure the system switches to down after 11 seconds
sm.RemoveSystem(record.Id)
sm.AddRecord(record, nil)
assert.Equal(t, "pending", sm.GetSystemStatusFromStore(record.Id), "System status should be 'pending'")
time.Sleep(12 * time.Second)
synctest.Wait()
assert.Equal(t, "down", sm.GetSystemStatusFromStore(record.Id), "System status should be 'down'")
// sm.SetSystemStatusInDB(record.Id, "paused")
// time.Sleep(time.Second)
// synctest.Wait()
// assert.Equal(t, "paused", sm.GetSystemStatusFromStore(record.Id), "System status should be 'paused'")
// delete the record
err = hub.Delete(record)
require.NoError(t, err)
assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after deletion")
testOld(t, hub)
time.Sleep(time.Second)
synctest.Wait()
for _, systemId := range sm.GetAllSystemIDs() {
err = sm.RemoveSystem(systemId)
require.NoError(t, err)
assert.False(t, sm.HasSystem(systemId), "System should not exist in the store after deletion")
}
assert.Equal(t, 0, sm.GetSystemCount(), "System count should be 0")
// TODO: test with websocket client
})
}
func testOld(t *testing.T, hub *tests.TestHub) {
user, err := tests.CreateUser(hub, "test@testy.com", "testtesttest")
require.NoError(t, err)
sm := hub.GetSystemManager()
assert.NotNil(t, sm)
// Test initialization
sm.Initialize()
// error expected when creating a user with a duplicate email
_, err = tests.CreateUser(hub, "test@test.com", "testtesttest")
require.Error(t, err)
// Test collection existence. todo: move to hub package tests
t.Run("CollectionExistence", func(t *testing.T) {
@@ -92,81 +155,17 @@ func TestSystemManagerIntegration(t *testing.T) {
assert.NotNil(t, containerStats)
})
// Test adding a system record
t.Run("AddRecord", func(t *testing.T) {
var wg sync.WaitGroup
wg.Add(2)
// Get the count before adding the system
countBefore := sm.GetSystemCount()
// record should be pending on create
hub.OnRecordCreate("systems").BindFunc(func(e *core.RecordEvent) error {
record := e.Record
if record.GetString("name") == "welcometoarcoampm" {
assert.Equal(t, "pending", e.Record.GetString("status"), "System status should be 'pending'")
wg.Done()
}
return e.Next()
})
// record should be down on update
hub.OnRecordAfterUpdateSuccess("systems").BindFunc(func(e *core.RecordEvent) error {
record := e.Record
if record.GetString("name") == "welcometoarcoampm" {
assert.Equal(t, "down", e.Record.GetString("status"), "System status should be 'pending'")
wg.Done()
}
return e.Next()
})
// Create a test system with the first user assigned
record, err := createTestSystem(t, hub, map[string]any{
"name": "welcometoarcoampm",
"host": "localhost",
"port": "33914",
})
require.NoError(t, err)
wg.Wait()
// system should be down if grabbed from the store
assert.Equal(t, "down", sm.GetSystemStatusFromStore(record.Id), "System status should be 'down'")
// Check that the system count increased
countAfter := sm.GetSystemCount()
assert.Equal(t, countBefore+1, countAfter, "System count should increase after adding a system via event hook")
// Verify the system was added by checking if it exists
assert.True(t, sm.HasSystem(record.Id), "System should exist in the store")
// Verify the system host and port
host, port := sm.GetSystemHostPort(record.Id)
assert.Equal(t, record.Get("host"), host, "System host should match")
assert.Equal(t, record.Get("port"), port, "System port should match")
// Verify the system is in the list of all system IDs
ids := sm.GetAllSystemIDs()
assert.Contains(t, ids, record.Id, "System ID should be in the list of all system IDs")
// Verify the system was added by checking if removing it works
err = sm.RemoveSystem(record.Id)
assert.NoError(t, err, "System should exist and be removable")
// Verify the system no longer exists
assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after removal")
// Verify the system is not in the list of all system IDs
newIds := sm.GetAllSystemIDs()
assert.NotContains(t, newIds, record.Id, "System ID should not be in the list of all system IDs after removal")
})
t.Run("RemoveSystem", func(t *testing.T) {
// Get the count before adding the system
countBefore := sm.GetSystemCount()
// Create a test system record
record, err := createTestSystem(t, hub, map[string]any{})
record, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "i-even-got-lost-at-coney-island",
"host": "but-they-found-me",
"port": "33914",
"users": []string{user.Id},
})
require.NoError(t, err)
// Verify the system count increased
@@ -202,11 +201,16 @@ func TestSystemManagerIntegration(t *testing.T) {
t.Run("NewRecordPending", func(t *testing.T) {
// Create a test system
record, err := createTestSystem(t, hub, map[string]any{})
record, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "and-you-know",
"host": "i-feel-very-bad",
"port": "33914",
"users": []string{user.Id},
})
require.NoError(t, err)
// Add the record to the system manager
err = sm.AddRecord(record)
err = sm.AddRecord(record, nil)
require.NoError(t, err)
// Test filtering records by status - should be "pending" now
@@ -218,11 +222,16 @@ func TestSystemManagerIntegration(t *testing.T) {
t.Run("SystemStatusUpdate", func(t *testing.T) {
// Create a test system record
record, err := createTestSystem(t, hub, map[string]any{})
record, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "we-used-to-sleep-on-the-beach",
"host": "sleep-overnight-here",
"port": "33914",
"users": []string{user.Id},
})
require.NoError(t, err)
// Add the record to the system manager
err = sm.AddRecord(record)
err = sm.AddRecord(record, nil)
require.NoError(t, err)
// Test status changes
@@ -244,7 +253,12 @@ func TestSystemManagerIntegration(t *testing.T) {
t.Run("HandleSystemData", func(t *testing.T) {
// Create a test system record
record, err := createTestSystem(t, hub, map[string]any{})
record, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "things-changed-you-know",
"host": "they-dont-sleep-anymore-on-the-beach",
"port": "33914",
"users": []string{user.Id},
})
require.NoError(t, err)
// Create test system data
@@ -295,54 +309,14 @@ func TestSystemManagerIntegration(t *testing.T) {
assert.Error(t, err)
})
t.Run("DeleteRecord", func(t *testing.T) {
var wg sync.WaitGroup
wg.Add(2)
runs := 0
hub.OnRecordUpdate("systems").BindFunc(func(e *core.RecordEvent) error {
runs++
record := e.Record
if record.GetString("name") == "deadflagblues" {
if runs == 1 {
assert.Equal(t, "up", e.Record.GetString("status"), "System status should be 'up'")
wg.Done()
} else if runs == 2 {
assert.Equal(t, "paused", e.Record.GetString("status"), "System status should be 'paused'")
wg.Done()
}
}
return e.Next()
})
// Create a test system record
record, err := createTestSystem(t, hub, map[string]any{
"name": "deadflagblues",
})
require.NoError(t, err)
// Verify the system exists
assert.True(t, sm.HasSystem(record.Id), "System should exist in the store")
// set the status manually to up
sm.SetSystemStatusInDB(record.Id, "up")
// verify the status is up
assert.Equal(t, "up", sm.GetSystemStatusFromStore(record.Id), "System status should be 'up'")
// Set the status to "paused" which should cause it to be deleted from the store
sm.SetSystemStatusInDB(record.Id, "paused")
wg.Wait()
// Verify the system no longer exists
assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after deletion")
})
t.Run("ConcurrentOperations", func(t *testing.T) {
// Create a test system
record, err := createTestSystem(t, hub, map[string]any{})
record, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "jfkjahkfajs",
"host": "localhost",
"port": "33914",
"users": []string{user.Id},
})
require.NoError(t, err)
// Run concurrent operations
@@ -377,7 +351,12 @@ func TestSystemManagerIntegration(t *testing.T) {
t.Run("ContextCancellation", func(t *testing.T) {
// Create a test system record
record, err := createTestSystem(t, hub, map[string]any{})
record, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "lkhsdfsjf",
"host": "localhost",
"port": "33914",
"users": []string{user.Id},
})
require.NoError(t, err)
// Verify the system exists in the store
@@ -420,7 +399,7 @@ func TestSystemManagerIntegration(t *testing.T) {
assert.Error(t, err, "RemoveSystem should fail for non-existent system")
// Add the system back
err = sm.AddRecord(record)
err = sm.AddRecord(record, nil)
require.NoError(t, err, "AddRecord should succeed")
// Verify the system is back in the store

View File

@@ -9,17 +9,17 @@ import (
"fmt"
)
// GetSystemCount returns the number of systems in the store
// TESTING ONLY: GetSystemCount returns the number of systems in the store
func (sm *SystemManager) GetSystemCount() int {
return sm.systems.Length()
}
// HasSystem checks if a system with the given ID exists in the store
// TESTING ONLY: HasSystem checks if a system with the given ID exists in the store
func (sm *SystemManager) HasSystem(systemID string) bool {
return sm.systems.Has(systemID)
}
// GetSystemStatusFromStore returns the status of a system with the given ID
// TESTING ONLY: GetSystemStatusFromStore returns the status of a system with the given ID
// Returns an empty string if the system doesn't exist
func (sm *SystemManager) GetSystemStatusFromStore(systemID string) string {
sys, ok := sm.systems.GetOk(systemID)
@@ -29,7 +29,7 @@ func (sm *SystemManager) GetSystemStatusFromStore(systemID string) string {
return sys.Status
}
// GetSystemContextFromStore returns the context and cancel function for a system
// TESTING ONLY: GetSystemContextFromStore returns the context and cancel function for a system
func (sm *SystemManager) GetSystemContextFromStore(systemID string) (context.Context, context.CancelFunc, error) {
sys, ok := sm.systems.GetOk(systemID)
if !ok {
@@ -38,7 +38,7 @@ func (sm *SystemManager) GetSystemContextFromStore(systemID string) (context.Con
return sys.ctx, sys.cancel, nil
}
// GetSystemFromStore returns a store from the system
// TESTING ONLY: GetSystemFromStore returns a store from the system
func (sm *SystemManager) GetSystemFromStore(systemID string) (*System, error) {
sys, ok := sm.systems.GetOk(systemID)
if !ok {
@@ -47,7 +47,7 @@ func (sm *SystemManager) GetSystemFromStore(systemID string) (*System, error) {
return sys, nil
}
// GetAllSystemIDs returns a slice of all system IDs in the store
// TESTING ONLY: GetAllSystemIDs returns a slice of all system IDs in the store
func (sm *SystemManager) GetAllSystemIDs() []string {
data := sm.systems.GetAll()
ids := make([]string, 0, len(data))
@@ -57,7 +57,7 @@ func (sm *SystemManager) GetAllSystemIDs() []string {
return ids
}
// GetSystemData returns the combined data for a system with the given ID
// TESTING ONLY: GetSystemData returns the combined data for a system with the given ID
// Returns nil if the system doesn't exist
// This method is intended for testing
func (sm *SystemManager) GetSystemData(systemID string) *entities.CombinedData {
@@ -68,7 +68,7 @@ func (sm *SystemManager) GetSystemData(systemID string) *entities.CombinedData {
return sys.data
}
// GetSystemHostPort returns the host and port for a system with the given ID
// TESTING ONLY: GetSystemHostPort returns the host and port for a system with the given ID
// Returns empty strings if the system doesn't exist
func (sm *SystemManager) GetSystemHostPort(systemID string) (string, string) {
sys, ok := sm.systems.GetOk(systemID)
@@ -78,22 +78,7 @@ func (sm *SystemManager) GetSystemHostPort(systemID string) (string, string) {
return sys.Host, sys.Port
}
// DisableAutoUpdater disables the automatic updater for a system
// This is intended for testing
// Returns false if the system doesn't exist
// func (sm *SystemManager) DisableAutoUpdater(systemID string) bool {
// sys, ok := sm.systems.GetOk(systemID)
// if !ok {
// return false
// }
// if sys.cancel != nil {
// sys.cancel()
// sys.cancel = nil
// }
// return true
// }
// SetSystemStatusInDB sets the status of a system directly and updates the database record
// TESTING ONLY: SetSystemStatusInDB sets the status of a system directly and updates the database record
// This is intended for testing
// Returns false if the system doesn't exist
func (sm *SystemManager) SetSystemStatusInDB(systemID string, status string) bool {

View File

@@ -0,0 +1,181 @@
package ws
import (
"beszel/internal/common"
"beszel/internal/entities/system"
"errors"
"time"
"weak"
"github.com/fxamacker/cbor/v2"
"github.com/lxzan/gws"
"golang.org/x/crypto/ssh"
)
const (
deadline = 70 * time.Second
)
// Handler implements the WebSocket event handler for agent connections.
type Handler struct {
gws.BuiltinEventHandler
}
// WsConn represents a WebSocket connection to an agent.
type WsConn struct {
conn *gws.Conn
responseChan chan *gws.Message
DownChan chan struct{}
}
// FingerprintRecord is fingerprints collection record data in the hub
type FingerprintRecord struct {
Id string `db:"id"`
SystemId string `db:"system"`
Fingerprint string `db:"fingerprint"`
Token string `db:"token"`
}
var upgrader *gws.Upgrader
// GetUpgrader returns a singleton WebSocket upgrader instance.
func GetUpgrader() *gws.Upgrader {
if upgrader != nil {
return upgrader
}
handler := &Handler{}
upgrader = gws.NewUpgrader(handler, &gws.ServerOption{})
return upgrader
}
// NewWsConnection creates a new WebSocket connection wrapper.
func NewWsConnection(conn *gws.Conn) *WsConn {
return &WsConn{
conn: conn,
responseChan: make(chan *gws.Message, 1),
DownChan: make(chan struct{}, 1),
}
}
// OnOpen sets a deadline for the WebSocket connection.
func (h *Handler) OnOpen(conn *gws.Conn) {
conn.SetDeadline(time.Now().Add(deadline))
}
// OnMessage routes incoming WebSocket messages to the response channel.
func (h *Handler) OnMessage(conn *gws.Conn, message *gws.Message) {
conn.SetDeadline(time.Now().Add(deadline))
if message.Opcode != gws.OpcodeBinary || message.Data.Len() == 0 {
return
}
wsConn, ok := conn.Session().Load("wsConn")
if !ok {
_ = conn.WriteClose(1000, nil)
return
}
select {
case wsConn.(*WsConn).responseChan <- message:
default:
// close if the connection is not expecting a response
wsConn.(*WsConn).Close()
}
}
// OnClose handles WebSocket connection closures and triggers system down status after delay.
func (h *Handler) OnClose(conn *gws.Conn, err error) {
wsConn, ok := conn.Session().Load("wsConn")
if !ok {
return
}
wsConn.(*WsConn).conn = nil
// wait 5 seconds to allow reconnection before setting system down
// use a weak pointer to avoid keeping references if the system is removed
go func(downChan weak.Pointer[chan struct{}]) {
time.Sleep(5 * time.Second)
downChanValue := downChan.Value()
if downChanValue != nil {
*downChanValue <- struct{}{}
}
}(weak.Make(&wsConn.(*WsConn).DownChan))
}
// Close terminates the WebSocket connection gracefully.
func (ws *WsConn) Close() {
if ws.IsConnected() {
ws.conn.WriteClose(1000, nil)
}
}
// Ping sends a ping frame to keep the connection alive.
func (ws *WsConn) Ping() error {
ws.conn.SetDeadline(time.Now().Add(deadline))
return ws.conn.WritePing(nil)
}
// sendMessage encodes data to CBOR and sends it as a binary message to the agent.
func (ws *WsConn) sendMessage(data common.HubRequest[any]) error {
bytes, err := cbor.Marshal(data)
if err != nil {
return err
}
return ws.conn.WriteMessage(gws.OpcodeBinary, bytes)
}
// RequestSystemData requests system metrics from the agent and unmarshals the response.
func (ws *WsConn) RequestSystemData(data *system.CombinedData) error {
var message *gws.Message
ws.sendMessage(common.HubRequest[any]{
Action: common.GetData,
})
select {
case <-time.After(10 * time.Second):
ws.Close()
return gws.ErrConnClosed
case message = <-ws.responseChan:
}
defer message.Close()
return cbor.Unmarshal(message.Data.Bytes(), data)
}
// GetFingerprint authenticates with the agent using SSH signature and returns the agent's fingerprint.
func (ws *WsConn) GetFingerprint(token string, signer ssh.Signer, needSysInfo bool) (common.FingerprintResponse, error) {
challenge := []byte(token)
signature, err := signer.Sign(nil, challenge)
if err != nil {
return common.FingerprintResponse{}, err
}
err = ws.sendMessage(common.HubRequest[any]{
Action: common.CheckFingerprint,
Data: common.FingerprintRequest{
Signature: signature.Blob,
NeedSysInfo: needSysInfo,
},
})
if err != nil {
return common.FingerprintResponse{}, err
}
var message *gws.Message
var clientFingerprint common.FingerprintResponse
select {
case message = <-ws.responseChan:
case <-time.After(10 * time.Second):
return common.FingerprintResponse{}, errors.New("request expired")
}
defer message.Close()
err = cbor.Unmarshal(message.Data.Bytes(), &clientFingerprint)
if err != nil {
return common.FingerprintResponse{}, err
}
return clientFingerprint, nil
}
// IsConnected returns true if the WebSocket connection is active.
func (ws *WsConn) IsConnected() bool {
return ws.conn != nil
}

View File

@@ -4,13 +4,13 @@ package records
import (
"beszel/internal/entities/container"
"beszel/internal/entities/system"
"encoding/json"
"fmt"
"log"
"math"
"strings"
"time"
"github.com/goccy/go-json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
@@ -26,14 +26,26 @@ type LongerRecordData struct {
minShorterRecords int
}
type RecordStats []struct {
Stats []byte `db:"stats"`
type RecordIds []struct {
Id string `db:"id"`
}
func NewRecordManager(app core.App) *RecordManager {
return &RecordManager{app}
}
type StatsRecord struct {
Stats []byte `db:"stats"`
}
// global variables for reusing allocations
var statsRecord StatsRecord
var containerStats []container.Stats
var sumStats system.Stats
var tempStats system.Stats
var queryParams = make(dbx.Params, 1)
var containerSums = make(map[string]*container.Stats)
// Create longer records by averaging shorter records
func (rm *RecordManager) CreateLongerRecords() {
// start := time.Now()
@@ -76,11 +88,10 @@ func (rm *RecordManager) CreateLongerRecords() {
if err != nil {
return err
}
var systems []struct {
Id string `db:"id"`
}
var systems RecordIds
db := txApp.DB()
txApp.DB().NewQuery("SELECT id FROM systems WHERE status='up'").All(&systems)
db.NewQuery("SELECT id FROM systems WHERE status='up'").All(&systems)
// loop through all active systems, time periods, and collections
for _, system := range systems {
@@ -96,22 +107,23 @@ func (rm *RecordManager) CreateLongerRecords() {
for _, collection := range collections {
// check creation time of last longer record if not 10m, since 10m is created every run
if recordData.longerType != "10m" {
lastLongerRecord, err := txApp.FindFirstRecordByFilter(
count, err := txApp.CountRecords(
collection.Id,
"system = {:system} && type = {:type} && created > {:created}",
dbx.Params{"type": recordData.longerType, "system": system.Id, "created": longerRecordPeriod},
dbx.NewExp(
"system = {:system} AND type = {:type} AND created > {:created}",
dbx.Params{"type": recordData.longerType, "system": system.Id, "created": longerRecordPeriod},
),
)
// continue if longer record exists
if err == nil || lastLongerRecord != nil {
// log.Println("longer record found. continuing")
if err != nil || count > 0 {
continue
}
}
// get shorter records from the past x minutes
var stats RecordStats
var recordIds RecordIds
err := txApp.DB().
Select("stats").
Select("id").
From(collection.Name).
AndWhere(dbx.NewExp(
"system={:system} AND type={:type} AND created > {:created}",
@@ -121,10 +133,10 @@ func (rm *RecordManager) CreateLongerRecords() {
"created": shorterRecordPeriod,
},
)).
All(&stats)
All(&recordIds)
// continue if not enough shorter records
if err != nil || len(stats) < recordData.minShorterRecords {
if err != nil || len(recordIds) < recordData.minShorterRecords {
continue
}
// average the shorter records and create longer record
@@ -133,9 +145,10 @@ func (rm *RecordManager) CreateLongerRecords() {
longerRecord.Set("type", recordData.longerType)
switch collection.Name {
case "system_stats":
longerRecord.Set("stats", rm.AverageSystemStats(stats))
longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds))
case "container_stats":
longerRecord.Set("stats", rm.AverageContainerStats(stats))
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds))
}
if err := txApp.SaveNoValidate(longerRecord); err != nil {
log.Println("failed to save longer record", "err", err)
@@ -147,24 +160,34 @@ func (rm *RecordManager) CreateLongerRecords() {
return nil
})
statsRecord.Stats = statsRecord.Stats[:0]
// log.Println("finished creating longer records", "time (ms)", time.Since(start).Milliseconds())
}
// Calculate the average stats of a list of system_stats records without reflect
func (rm *RecordManager) AverageSystemStats(records RecordStats) *system.Stats {
sum := &system.Stats{}
func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *system.Stats {
// Clear/reset global structs for reuse
sumStats = system.Stats{}
tempStats = system.Stats{}
sum := &sumStats
stats := &tempStats
count := float64(len(records))
tempCount := float64(0)
// Temporary struct for unmarshaling
stats := &system.Stats{}
// Accumulate totals
for i := range records {
*stats = system.Stats{} // Reset tempStats for unmarshaling
if err := json.Unmarshal(records[i].Stats, stats); err != nil {
for _, record := range records {
id := record.Id
// clear global statsRecord for reuse
statsRecord.Stats = statsRecord.Stats[:0]
queryParams["id"] = id
db.NewQuery("SELECT stats FROM system_stats WHERE id = {:id}").Bind(queryParams).One(&statsRecord)
if err := json.Unmarshal(statsRecord.Stats, stats); err != nil {
continue
}
sum.Cpu += stats.Cpu
sum.Mem += stats.Mem
sum.MemUsed += stats.MemUsed
@@ -293,14 +316,24 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) *system.Stats {
}
// Calculate the average stats of a list of container_stats records
func (rm *RecordManager) AverageContainerStats(records RecordStats) []container.Stats {
sums := make(map[string]*container.Stats)
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds) []container.Stats {
// Clear global map for reuse
for k := range containerSums {
delete(containerSums, k)
}
sums := containerSums
count := float64(len(records))
containerStats := make([]container.Stats, 0, 50)
for i := range records {
// reset slice
id := records[i].Id
// clear global statsRecord and containerStats for reuse
statsRecord.Stats = statsRecord.Stats[:0]
containerStats = containerStats[:0]
if err := json.Unmarshal(records[i].Stats, &containerStats); err != nil {
queryParams["id"] = id
db.NewQuery("SELECT stats FROM container_stats WHERE id = {:id}").Bind(queryParams).One(&statsRecord)
if err := json.Unmarshal(statsRecord.Stats, &containerStats); err != nil {
return []container.Stats{}
}
for i := range containerStats {
@@ -331,7 +364,7 @@ func (rm *RecordManager) AverageContainerStats(records RecordStats) []container.
// Deletes records older than what is displayed in the UI
func (rm *RecordManager) DeleteOldRecords() {
// Define the collections to process
collections := []string{"system_stats", "container_stats"}
collections := [2]string{"system_stats", "container_stats"}
// Define record types and their retention periods
type RecordDeletionData struct {
@@ -346,17 +379,19 @@ func (rm *RecordManager) DeleteOldRecords() {
{recordType: "480m", retention: 30 * 24 * time.Hour}, // 30 days
}
// Process each collection
now := time.Now().UTC()
for _, collection := range collections {
// Build the WHERE clause dynamically
var conditionParts []string
var params dbx.Params = make(map[string]any)
for i, rd := range recordData {
for i := range recordData {
rd := recordData[i]
// Create parameterized condition for this record type
dateParam := fmt.Sprintf("date%d", i)
conditionParts = append(conditionParts, fmt.Sprintf("(type = '%s' AND created < {:%s})", rd.recordType, dateParam))
params[dateParam] = time.Now().UTC().Add(-rd.retention)
params[dateParam] = now.Add(-rd.retention)
}
// Combine conditions with OR

View File

@@ -1,3 +1,6 @@
//go:build testing
// +build testing
// Package tests provides helpers for testing the application.
package tests
@@ -56,3 +59,30 @@ func NewTestHubWithConfig(config core.BaseAppConfig) (*TestHub, error) {
return t, nil
}
// Helper function to create a test user for config tests
func CreateUser(app core.App, email string, password string) (*core.Record, error) {
userCollection, err := app.FindCachedCollectionByNameOrId("users")
if err != nil {
return nil, err
}
user := core.NewRecord(userCollection)
user.Set("email", email)
user.Set("password", password)
return user, app.Save(user)
}
// Helper function to create a test record
func CreateRecord(app core.App, collectionName string, fields map[string]any) (*core.Record, error) {
collection, err := app.FindCachedCollectionByNameOrId(collectionName)
if err != nil {
return nil, err
}
record := core.NewRecord(collection)
record.Load(fields)
return record, app.Save(record)
}

View File

@@ -1,23 +1,14 @@
package migrations
import (
"github.com/google/uuid"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
// delete duplicate alerts
app.DB().NewQuery(`
DELETE FROM alerts
WHERE rowid NOT IN (
SELECT MAX(rowid)
FROM alerts
GROUP BY user, system, name
);
`).Execute()
// import collections
// update collections
jsonData := `[
{
"id": "elngm8x1l60zi2v",
@@ -236,6 +227,88 @@ func init() {
],
"system": false
},
{
"id": "pbc_3663931638",
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
"viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
"createRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id && @request.auth.role != \"readonly\"",
"updateRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id && @request.auth.role != \"readonly\"",
"deleteRule": null,
"name": "fingerprints",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{9}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 9,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "2hz5ncl8tizk5nx",
"hidden": false,
"id": "relation3377271179",
"maxSelect": 1,
"minSelect": 0,
"name": "system",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "[a-zA-Z9-9]{20}",
"hidden": false,
"id": "text1597481275",
"max": 255,
"min": 9,
"name": "token",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text4228609354",
"max": 255,
"min": 9,
"name": "fingerprint",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"indexes": [
"CREATE INDEX ` + "`" + `idx_p9qZlu26po` + "`" + ` ON ` + "`" + `fingerprints` + "`" + ` (` + "`" + `token` + "`" + `)",
"CREATE UNIQUE INDEX ` + "`" + `idx_ngboulGMYw` + "`" + ` ON ` + "`" + `fingerprints` + "`" + ` (` + "`" + `system` + "`" + `)"
],
"system": false
},
{
"id": "ej9oowivz8b2mht",
"listRule": "@request.auth.id != \"\"",
@@ -669,7 +742,38 @@ func init() {
}
]`
return app.ImportCollectionsByMarshaledJSON([]byte(jsonData), false)
err := app.ImportCollectionsByMarshaledJSON([]byte(jsonData), false)
if err != nil {
return err
}
// Get all systems that don't have fingerprint records
var systemIds []string
err = app.DB().NewQuery(`
SELECT s.id FROM systems s
LEFT JOIN fingerprints f ON s.id = f.system
WHERE f.system IS NULL
`).Column(&systemIds)
if err != nil {
return err
}
// Create fingerprint records with unique UUID tokens for each system
for _, systemId := range systemIds {
token := uuid.New().String()
_, err = app.DB().NewQuery(`
INSERT INTO fingerprints (system, token)
VALUES ({:system}, {:token})
`).Bind(map[string]any{
"system": systemId,
"token": token,
}).Execute()
if err != nil {
return err
}
}
return nil
}, func(app core.App) error {
return nil
})

Binary file not shown.

View File

@@ -9,7 +9,8 @@
<script>
globalThis.BESZEL = {
BASE_PATH: "%BASE_URL%",
HUB_VERSION: "{{V}}"
HUB_VERSION: "{{V}}",
HUB_URL: "{{HUB_URL}}"
}
</script>
</head>

View File

@@ -1,7 +1,7 @@
{
"name": "beszel",
"private": true,
"version": "0.11.1",
"version": "0.12.0-beta1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -13,54 +13,54 @@
"dependencies": {
"@henrygd/queue": "^1.0.7",
"@henrygd/semaphore": "^0.0.2",
"@lingui/detect-locale": "^5.2.0",
"@lingui/macro": "^5.2.0",
"@lingui/react": "^5.2.0",
"@lingui/detect-locale": "^5.3.2",
"@lingui/macro": "^5.3.2",
"@lingui/react": "^5.3.2",
"@nanostores/react": "^0.7.3",
"@nanostores/router": "^0.11.0",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-direction": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8",
"@tanstack/react-table": "^8.21.2",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-direction": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"cmdk": "^1.1.1",
"d3-time": "^3.1.0",
"lucide-react": "^0.452.0",
"nanostores": "^0.11.4",
"pocketbase": "^0.25.2",
"pocketbase": "^0.26.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.15.1",
"recharts": "^2.15.3",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"valibot": "^0.42.0"
"valibot": "^0.42.1"
},
"devDependencies": {
"@lingui/cli": "^5.2.0",
"@lingui/swc-plugin": "^5.5.0",
"@lingui/vite-plugin": "^5.2.0",
"@types/bun": "^1.2.4",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react-swc": "^3.8.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.3",
"@lingui/cli": "^5.3.2",
"@lingui/swc-plugin": "^5.5.2",
"@lingui/vite-plugin": "^5.3.2",
"@types/bun": "^1.2.15",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react-swc": "^3.10.1",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.4",
"tailwindcss": "^3.4.17",
"tailwindcss-rtl": "^0.9.0",
"typescript": "^5.8.2",
"vite": "^6.2.0"
"typescript": "^5.8.3",
"vite": "^6.3.5"
},
"overrides": {
"@nanostores/router": {

View File

@@ -11,20 +11,27 @@ import {
DialogTrigger,
} from "@/components/ui/dialog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { $publicKey, pb } from "@/lib/stores"
import { cn, copyToClipboard, isReadOnlyUser, useLocalStorage } from "@/lib/utils"
import { i18n } from "@lingui/core"
import { cn, generateToken, isReadOnlyUser, tokenMap, useLocalStorage } from "@/lib/utils"
import { useStore } from "@nanostores/react"
import { ChevronDownIcon, Copy, ExternalLinkIcon, PlusIcon } from "lucide-react"
import { memo, useRef, useState } from "react"
import { basePath, navigate } from "./router"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"
import { ChevronDownIcon, ExternalLinkIcon, PlusIcon } from "lucide-react"
import { memo, useEffect, useRef, useState } from "react"
import { $router, basePath, Link, navigate } from "./router"
import { SystemRecord } from "@/types"
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "./ui/icons"
import { InputCopy } from "./ui/input-copy"
import { getPagePath } from "@nanostores/router"
import {
copyDockerCompose,
copyDockerRun,
copyLinuxCommand,
copyWindowsCommand,
DropdownItem,
InstallDropdown,
} from "./install-dropdowns"
import { DropdownMenu, DropdownMenuTrigger } from "./ui/dropdown-menu"
export function AddSystemButton({ className }: { className?: string }) {
const [open, setOpen] = useState(false)
@@ -51,44 +58,11 @@ export function AddSystemButton({ className }: { className?: string }) {
)
}
function copyDockerCompose(port = "45876", publicKey: string) {
copyToClipboard(`services:
beszel-agent:
image: "henrygd/beszel-agent"
container_name: "beszel-agent"
restart: unless-stopped
network_mode: host
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
# monitor other disks / partitions by mounting a folder in /extra-filesystems
# - /mnt/disk/.beszel:/extra-filesystems/sda1:ro
environment:
LISTEN: ${port}
KEY: "${publicKey}"`)
}
function copyDockerRun(port = "45876", publicKey: string) {
copyToClipboard(
`docker run -d --name beszel-agent --network host --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock:ro -e KEY="${publicKey}" -e LISTEN=${port} henrygd/beszel-agent:latest`
)
}
function copyLinuxCommand(port = "45876", publicKey: string, brew = false) {
let cmd = `curl -sL https://get.beszel.dev${
brew ? "/brew" : ""
} -o /tmp/install-agent.sh && chmod +x /tmp/install-agent.sh && /tmp/install-agent.sh -p ${port} -k "${publicKey}"`
// brew script does not support --china-mirrors
if (!brew && (i18n.locale + navigator.language).includes("zh-CN")) {
cmd += ` --china-mirrors`
}
copyToClipboard(cmd)
}
function copyWindowsCommand(port = "45876", publicKey: string) {
copyToClipboard(
`& iwr -useb https://get.beszel.dev -OutFile "$env:TEMP\\install-agent.ps1"; & Powershell -ExecutionPolicy Bypass -File "$env:TEMP\\install-agent.ps1" -Key "${publicKey}" -Port ${port}`
)
}
/**
* Token to be used for the next system.
* Prevents token changing if user copies config, then closes dialog and opens again.
*/
let nextSystemToken: string | null = null
/**
* SystemDialog component for adding or editing a system.
@@ -96,12 +70,32 @@ function copyWindowsCommand(port = "45876", publicKey: string) {
* @param {function} props.setOpen - Function to set the open state of the dialog.
* @param {SystemRecord} [props.system] - Optional system record for editing an existing system.
*/
export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean) => void; system?: SystemRecord }) => {
export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) => void; system?: SystemRecord }) => {
const publicKey = useStore($publicKey)
const port = useRef<HTMLInputElement>(null)
const [hostValue, setHostValue] = useState(system?.host ?? "")
const isUnixSocket = hostValue.startsWith("/")
const [tab, setTab] = useLocalStorage("as-tab", "docker")
const [token, setToken] = useState(system?.token ?? "")
useEffect(() => {
;(async () => {
// if no system, generate a new token
if (!system) {
nextSystemToken ||= generateToken()
return setToken(nextSystemToken)
}
// if system exists,get the token from the fingerprint record
if (tokenMap.has(system.id)) {
return setToken(tokenMap.get(system.id)!)
}
const { token } = await pb.collection("fingerprints").getFirstListItem(`system = "${system.id}"`, {
fields: "token",
})
tokenMap.set(system.id, token)
setToken(token)
})()
}, [system?.id])
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
@@ -113,12 +107,18 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
if (system) {
await pb.collection("systems").update(system.id, { ...data, status: "pending" })
} else {
await pb.collection("systems").create(data)
const createdSystem = await pb.collection("systems").create(data)
await pb.collection("fingerprints").create({
system: createdSystem.id,
token,
})
// Reset the current token after successful system
// creation so next system gets a new token
nextSystemToken = null
}
navigate(basePath)
// console.log(record)
} catch (e) {
console.log(e)
console.error(e)
}
}
@@ -143,18 +143,37 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
</DialogHeader>
{/* Docker (set tab index to prevent auto focusing content in edit system dialog) */}
<TabsContent value="docker" tabIndex={-1}>
<DialogDescription className="mb-4 leading-normal w-0 min-w-full">
<DialogDescription className="mb-3 leading-relaxed w-0 min-w-full">
<Trans>
The agent must be running on the system to connect. Copy the
<code className="bg-muted px-1 rounded-sm leading-3">docker-compose.yml</code> for the agent below.
Copy the
<code className="bg-muted px-1 rounded-sm leading-3">docker-compose.yml</code> content for the agent
below, or register agents automatically with a{" "}
<Link
onClick={() => setOpen(false)}
href={getPagePath($router, "settings", { name: "tokens" })}
className="link"
>
universal token
</Link>
.
</Trans>
</DialogDescription>
</TabsContent>
{/* Binary */}
<TabsContent value="binary" tabIndex={-1}>
<DialogDescription className="mb-4 leading-normal w-0 min-w-full">
<DialogDescription className="mb-3 leading-relaxed w-0 min-w-full">
<Trans>
The agent must be running on the system to connect. Copy the installation command for the agent below.
Copy the installation command for the agent below, or register agents automatically with a{" "}
<Link
onClick={() => {
setOpen(false)
}}
href={getPagePath($router, "settings", { name: "tokens" })}
className="link"
>
universal token
</Link>
.
</Trans>
</DialogDescription>
</TabsContent>
@@ -190,46 +209,27 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
<Label htmlFor="pkey" className="xs:text-end whitespace-pre">
<Trans comment="Use 'Key' if your language requires many more characters">Public Key</Trans>
</Label>
<div className="relative">
<Input readOnly id="pkey" value={publicKey} required></Input>
<div
className={
"h-6 w-24 bg-gradient-to-r rtl:bg-gradient-to-l from-transparent to-background to-65% absolute top-2 end-1 pointer-events-none"
}
></div>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant={"link"}
className="absolute end-0 top-0"
onClick={() => copyToClipboard(publicKey)}
>
<Copy className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
<Trans>Click to copy</Trans>
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<InputCopy value={publicKey} id="pkey" name="pkey" />
<Label htmlFor="tkn" className="xs:text-end whitespace-pre">
<Trans>Token</Trans>
</Label>
<InputCopy value={token} id="tkn" name="tkn" />
</div>
<DialogFooter className="flex justify-end gap-x-2 gap-y-3 flex-col mt-5">
{/* Docker */}
<TabsContent value="docker" className="contents">
<CopyButton
text={t({ message: "Copy docker compose", context: "Button to copy docker compose file content" })}
onClick={() => copyDockerCompose(isUnixSocket ? hostValue : port.current?.value, publicKey)}
onClick={async () =>
copyDockerCompose(isUnixSocket ? hostValue : port.current?.value, publicKey, token)
}
icon={<DockerIcon className="size-4 -me-0.5" />}
dropdownItems={[
{
text: t({ message: "Copy docker run", context: "Button to copy docker run command" }),
onClick: () => copyDockerRun(isUnixSocket ? hostValue : port.current?.value, publicKey),
icons: [<DockerIcon className="size-4" />],
onClick: async () =>
copyDockerRun(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
icons: [DockerIcon],
},
]}
/>
@@ -239,22 +239,24 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
<CopyButton
text={t`Copy Linux command`}
icon={<TuxIcon className="size-4" />}
onClick={() => copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey)}
onClick={async () => copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token)}
dropdownItems={[
{
text: t({ message: "Homebrew command", context: "Button to copy install command" }),
onClick: () => copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, true),
icons: [<AppleIcon className="size-4" />, <TuxIcon className="w-4 h-4" />],
onClick: async () =>
copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token, true),
icons: [AppleIcon, TuxIcon],
},
{
text: t({ message: "Windows command", context: "Button to copy install command" }),
onClick: () => copyWindowsCommand(isUnixSocket ? hostValue : port.current?.value, publicKey),
icons: [<WindowsIcon className="size-4" />],
onClick: async () =>
copyWindowsCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
icons: [WindowsIcon],
},
{
text: t`Manual setup instructions`,
url: "https://beszel.dev/guide/agent-installation#binary",
icons: [<ExternalLinkIcon className="size-4" />],
icons: [ExternalLinkIcon],
},
]}
/>
@@ -266,20 +268,13 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
</Tabs>
</DialogContent>
)
})
interface DropdownItem {
text: string
onClick?: () => void
url?: string
icons?: React.ReactNode[]
}
interface CopyButtonProps {
text: string
onClick: () => void
dropdownItems: DropdownItem[]
icon?: React.ReactNode
icon?: React.ReactElement
}
const CopyButton = memo((props: CopyButtonProps) => {
@@ -300,22 +295,7 @@ const CopyButton = memo((props: CopyButtonProps) => {
<ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{props.dropdownItems.map((item, index) => {
const className = "cursor-pointer flex items-center gap-1.5"
return item.url ? (
<DropdownMenuItem key={index} asChild>
<a href={item.url} className={className} target="_blank" rel="noopener noreferrer">
{item.text} {item.icons?.map((icon) => icon)}
</a>
</DropdownMenuItem>
) : (
<DropdownMenuItem key={index} onClick={item.onClick} className={className}>
{item.text} {item.icons?.map((icon) => icon)}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
<InstallDropdown items={props.dropdownItems} />
</DropdownMenu>
</div>
)

View File

@@ -1,6 +1,7 @@
import {
BookIcon,
DatabaseBackupIcon,
FingerprintIcon,
LayoutDashboard,
LogsIcon,
MailIcon,
@@ -40,6 +41,16 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
return useMemo(() => {
const systems = $systems.get()
const SettingsShortcut = (
<CommandShortcut>
<Trans>Settings</Trans>
</CommandShortcut>
)
const AdminShortcut = (
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
)
return (
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder={t`Search for systems or settings...`} />
@@ -93,9 +104,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
<span>
<Trans>Settings</Trans>
</span>
<CommandShortcut>
<Trans>Settings</Trans>
</CommandShortcut>
{SettingsShortcut}
</CommandItem>
<CommandItem
keywords={["alerts"]}
@@ -108,9 +117,19 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
<span>
<Trans>Notifications</Trans>
</span>
<CommandShortcut>
<Trans>Settings</Trans>
</CommandShortcut>
{SettingsShortcut}
</CommandItem>
<CommandItem
onSelect={() => {
navigate(getPagePath($router, "settings", { name: "tokens" }))
setOpen(false)
}}
>
<FingerprintIcon className="me-2 h-4 w-4" />
<span>
<Trans>Tokens & Fingerprints</Trans>
</span>
{SettingsShortcut}
</CommandItem>
<CommandItem
keywords={["help", "oauth", "oidc"]}
@@ -140,9 +159,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
<span>
<Trans>Users</Trans>
</span>
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
{AdminShortcut}
</CommandItem>
<CommandItem
onSelect={() => {
@@ -154,9 +171,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
<span>
<Trans>Logs</Trans>
</span>
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
{AdminShortcut}
</CommandItem>
<CommandItem
onSelect={() => {
@@ -168,9 +183,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
<span>
<Trans>Backups</Trans>
</span>
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
{AdminShortcut}
</CommandItem>
<CommandItem
keywords={["email"]}
@@ -183,9 +196,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
<span>
<Trans>SMTP settings</Trans>
</span>
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
{AdminShortcut}
</CommandItem>
</CommandGroup>
</>

View File

@@ -0,0 +1,97 @@
import { memo } from "react"
import { DropdownMenuContent, DropdownMenuItem } from "./ui/dropdown-menu"
import { copyToClipboard, getHubURL } from "@/lib/utils"
import { i18n } from "@lingui/core"
const isBeta = BESZEL.HUB_VERSION.includes("beta")
const imageTag = isBeta ? ":edge" : ""
/**
* Get the URL of the script to install the agent.
* @param path - The path to the script (e.g. "/brew").
* @returns The URL for the script.
*/
const getScriptUrl = (path: string = "") => {
const url = new URL("https://get.beszel.dev")
url.pathname = path
if (isBeta) {
url.searchParams.set("beta", "1")
}
return url.toString()
}
export function copyDockerCompose(port = "45876", publicKey: string, token: string) {
copyToClipboard(`services:
beszel-agent:
image: henrygd/beszel-agent${imageTag}
container_name: beszel-agent
restart: unless-stopped
network_mode: host
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./beszel_agent_data:/var/lib/beszel-agent
# monitor other disks / partitions by mounting a folder in /extra-filesystems
# - /mnt/disk/.beszel:/extra-filesystems/sda1:ro
environment:
LISTEN: ${port}
KEY: '${publicKey}'
TOKEN: ${token}
HUB_URL: ${getHubURL()}`)
}
export function copyDockerRun(port = "45876", publicKey: string, token: string) {
copyToClipboard(
`docker run -d --name beszel-agent --network host --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock:ro -v ./beszel_agent_data:/var/lib/beszel-agent -e KEY="${publicKey}" -e LISTEN=${port} -e TOKEN="${token}" -e HUB_URL="${getHubURL()}" henrygd/beszel-agent${imageTag}`
)
}
export function copyLinuxCommand(port = "45876", publicKey: string, token: string, brew = false) {
let cmd = `curl -sL ${getScriptUrl(
brew ? "/brew" : ""
)} -o /tmp/install-agent.sh && chmod +x /tmp/install-agent.sh && /tmp/install-agent.sh -p ${port} -k "${publicKey}" -t "${token}" -url "${getHubURL()}"`
// brew script does not support --china-mirrors
if (!brew && (i18n.locale + navigator.language).includes("zh-CN")) {
cmd += ` --china-mirrors`
}
copyToClipboard(cmd)
}
export function copyWindowsCommand(port = "45876", publicKey: string, token: string) {
copyToClipboard(
`& iwr -useb ${getScriptUrl()} -OutFile "$env:TEMP\\install-agent.ps1"; & Powershell -ExecutionPolicy Bypass -File "$env:TEMP\\install-agent.ps1" -Key "${publicKey}" -Port ${port} -Token "${token}" -Url "${getHubURL()}"`
)
}
export interface DropdownItem {
text: string
onClick?: () => void
url?: string
icons?: React.ComponentType<React.SVGProps<SVGSVGElement>>[]
}
export const InstallDropdown = memo(({ items }: { items: DropdownItem[] }) => {
return (
<DropdownMenuContent align="end">
{items.map((item, index) => {
const className = "cursor-pointer flex items-center gap-1.5"
return item.url ? (
<DropdownMenuItem key={index} asChild>
<a href={item.url} className={className} target="_blank" rel="noopener noreferrer">
{item.text}{" "}
{item.icons?.map((Icon, iconIndex) => (
<Icon key={iconIndex} className="size-4" />
))}
</a>
</DropdownMenuItem>
) : (
<DropdownMenuItem key={index} onClick={item.onClick} className={className}>
{item.text}{" "}
{item.icons?.map((Icon, iconIndex) => (
<Icon key={iconIndex} className="size-4" />
))}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
)
})

View File

@@ -11,7 +11,7 @@ const routes = {
* The base path of the application.
* This is used to prepend the base path to all routes.
*/
export const basePath = globalThis.BESZEL.BASE_PATH || ""
export const basePath = BESZEL?.BASE_PATH || ""
/**
* Prepends the base path to the given path.
@@ -41,5 +41,12 @@ function onClick(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {
}
export const Link = (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
return <a onClick={onClick} {...props}></a>
let clickFn = onClick
if (props.onClick) {
clickFn = (e) => {
onClick(e)
props.onClick?.(e)
}
}
return <a {...props} onClick={clickFn}></a>
}

View File

@@ -7,7 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { useStore } from "@nanostores/react"
import { $router } from "@/components/router.tsx"
import { getPagePath, redirectPage } from "@nanostores/router"
import { BellIcon, FileSlidersIcon, SettingsIcon } from "lucide-react"
import { BellIcon, FileSlidersIcon, FingerprintIcon, SettingsIcon } from "lucide-react"
import { $userSettings, pb } from "@/lib/stores.ts"
import { toast } from "@/components/ui/use-toast.ts"
import { UserSettings } from "@/types.js"
@@ -15,6 +15,7 @@ import General from "./general.tsx"
import Notifications from "./notifications.tsx"
import ConfigYaml from "./config-yaml.tsx"
import { useLingui } from "@lingui/react/macro"
import Fingerprints from "./tokens-fingerprints.tsx"
export async function saveSettings(newSettings: Partial<UserSettings>) {
try {
@@ -58,6 +59,12 @@ export default function SettingsLayout() {
href: getPagePath($router, "settings", { name: "notifications" }),
icon: BellIcon,
},
{
title: t`Tokens & Fingerprints`,
href: getPagePath($router, "settings", { name: "tokens" }),
icon: FingerprintIcon,
// admin: true,
},
{
title: t`YAML Config`,
href: getPagePath($router, "settings", { name: "config" }),
@@ -77,7 +84,7 @@ export default function SettingsLayout() {
}, [])
return (
<Card className="pt-5 px-4 pb-8 sm:pt-6 sm:px-7">
<Card className="pt-5 px-4 pb-8 min-h-96 sm:pt-6 sm:px-7">
<CardHeader className="p-0">
<CardTitle className="mb-1">
<Trans>Settings</Trans>
@@ -89,10 +96,10 @@ export default function SettingsLayout() {
<CardContent className="p-0">
<Separator className="hidden md:block my-5" />
<div className="flex flex-col gap-3.5 md:flex-row md:gap-5 lg:gap-10">
<aside className="md:w-48 w-full">
<aside className="md:max-w-44 min-w-40">
<SidebarNav items={sidebarNavItems} />
</aside>
<div className="flex-1">
<div className="flex-1 min-w-0">
{/* @ts-ignore */}
<SettingsContent name={page?.params?.name ?? "general"} />
</div>
@@ -112,5 +119,7 @@ function SettingsContent({ name }: { name: string }) {
return <Notifications userSettings={userSettings} />
case "config":
return <ConfigYaml />
case "tokens":
return <Fingerprints />
}
}

View File

@@ -31,9 +31,9 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
if (item.admin && !isAdmin()) return null
return (
<SelectItem key={item.href} value={item.href}>
<span className="flex items-center gap-2">
<span className="flex items-center gap-2 truncate">
{item.icon && <item.icon className="h-4 w-4" />}
{item.title}
<span className="truncate">{item.title}</span>
</span>
</SelectItem>
)
@@ -55,13 +55,12 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
href={item.href}
className={cn(
buttonVariants({ variant: "ghost" }),
"flex items-center gap-3",
page?.path === item.href ? "bg-muted hover:bg-muted" : "hover:bg-muted/50",
"justify-start"
"flex items-center gap-3 justify-start truncate",
page?.path === item.href ? "bg-muted hover:bg-muted" : "hover:bg-muted/50"
)}
>
{item.icon && <item.icon className="h-4 w-4" />}
{item.title}
{item.icon && <item.icon className="h-4 w-4 shrink-0" />}
<span className="truncate">{item.title}</span>
</Link>
)
})}

View File

@@ -0,0 +1,352 @@
import { Trans } from "@lingui/react/macro"
import { t } from "@lingui/core/macro"
import { $publicKey, pb } from "@/lib/stores"
import { memo, useEffect, useMemo, useState } from "react"
import { Table, TableCell, TableHead, TableBody, TableRow, TableHeader } from "@/components/ui/table"
import { FingerprintRecord } from "@/types"
import {
CopyIcon,
FingerprintIcon,
KeyIcon,
MoreHorizontalIcon,
RotateCwIcon,
ServerIcon,
Trash2Icon,
} from "lucide-react"
import { toast } from "@/components/ui/use-toast"
import { cn, copyToClipboard, generateToken, getHubURL, isReadOnlyUser, tokenMap } from "@/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import {
copyDockerCompose,
copyDockerRun,
copyLinuxCommand,
copyWindowsCommand,
DropdownItem,
InstallDropdown,
} from "@/components/install-dropdowns"
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "@/components/ui/icons"
const pbFingerprintOptions = {
expand: "system",
fields: "id,fingerprint,token,system,expand.system.name",
}
const SettingsFingerprintsPage = memo(() => {
const [fingerprints, setFingerprints] = useState<FingerprintRecord[]>([])
// Get fingerprint records on mount
useEffect(() => {
pb.collection("fingerprints")
.getFullList(pbFingerprintOptions)
// @ts-ignore
.then(setFingerprints)
}, [])
// Subscribe to fingerprint updates
useEffect(() => {
let unsubscribe: (() => void) | undefined
;(async () => {
// subscribe to fingerprint updates
unsubscribe = await pb.collection("fingerprints").subscribe(
"*",
(res) => {
setFingerprints((currentFingerprints) => {
if (res.action === "create") {
return [...currentFingerprints, res.record as FingerprintRecord]
}
if (res.action === "update") {
return currentFingerprints.map((fingerprint) => {
if (fingerprint.id === res.record.id) {
return { ...fingerprint, ...res.record } as FingerprintRecord
}
return fingerprint
})
}
if (res.action === "delete") {
return currentFingerprints.filter((fingerprint) => fingerprint.id !== res.record.id)
}
return currentFingerprints
})
},
pbFingerprintOptions
)
})()
// unsubscribe on unmount
return () => unsubscribe?.()
}, [])
// Update token map whenever fingerprints change
useEffect(() => {
for (const fingerprint of fingerprints) {
tokenMap.set(fingerprint.system, fingerprint.token)
}
}, [fingerprints])
return (
<>
<SectionIntro />
<Separator className="my-4" />
<SectionUniversalToken />
<Separator className="my-4" />
<SectionTable fingerprints={fingerprints} />
</>
)
})
const SectionIntro = memo(() => {
return (
<div>
<h3 className="text-xl font-medium mb-2">
<Trans>Tokens & Fingerprints</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>Tokens and fingerprints are used to authenticate WebSocket connections to the hub.</Trans>
</p>
<p className="text-sm text-muted-foreground leading-relaxed mt-1.5">
<Trans>
Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on
first connection.
</Trans>
</p>
</div>
)
})
const SectionUniversalToken = memo(() => {
const [token, setToken] = useState("")
const [isLoading, setIsLoading] = useState(true)
const [checked, setChecked] = useState(false)
async function updateToken(enable: number = -1) {
// enable: 0 for disable, 1 for enable, -1 (unset) for get current state
const data = await pb.send(`/api/beszel/universal-token`, {
query: {
token,
enable,
},
})
setToken(data.token)
setChecked(data.active)
setIsLoading(false)
}
useEffect(() => {
updateToken()
}, [])
return (
<div>
<h3 className="text-lg font-medium mb-2">
<Trans>Universal token</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>
When enabled, this token allows agents to self-register without prior system creation. Expires after one hour
or on hub restart.
</Trans>
</p>
<div className="min-h-16 overflow-auto max-w-full inline-flex items-center gap-5 mt-3 border py-2 pl-5 pr-4 rounded-md">
{!isLoading && (
<>
<Switch
defaultChecked={checked}
onCheckedChange={(checked) => {
updateToken(checked ? 1 : 0)
}}
/>
<span
className={cn(
"text-sm text-primary opacity-60 transition-opacity",
checked ? "opacity-100" : "select-none"
)}
>
{token}
</span>
<ActionsButtonUniversalToken token={token} checked={checked} />
</>
)}
</div>
</div>
)
})
const ActionsButtonUniversalToken = memo(({ token, checked }: { token: string; checked: boolean }) => {
const publicKey = $publicKey.get()
const port = "45876"
const dropdownItems: DropdownItem[] = [
{
text: "Copy Docker Compose",
onClick: () => copyDockerCompose(port, publicKey, token),
icons: [DockerIcon],
},
{
text: "Copy Docker Run",
onClick: () => copyDockerRun(port, publicKey, token),
icons: [DockerIcon],
},
{
text: "Copy Linux Command",
onClick: () => copyLinuxCommand(port, publicKey, token),
icons: [TuxIcon],
},
{
text: "Copy Brew Command",
onClick: () => copyLinuxCommand(port, publicKey, token, true),
icons: [TuxIcon, AppleIcon],
},
{
text: "Copy Windows Command",
onClick: () => copyWindowsCommand(port, publicKey, token),
icons: [WindowsIcon],
},
]
return (
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={!checked}
className={cn("transition-opacity", !checked && "opacity-50")}
>
<span className="sr-only">
<Trans>Open menu</Trans>
</span>
<MoreHorizontalIcon className="w-5" />
</Button>
</DropdownMenuTrigger>
<InstallDropdown items={dropdownItems} />
</DropdownMenu>
</div>
)
})
const SectionTable = memo(({ fingerprints = [] }: { fingerprints: FingerprintRecord[] }) => {
const isReadOnly = isReadOnlyUser()
const headerCols = useMemo(
() => [
{
label: "System",
Icon: ServerIcon,
w: "11em",
},
{
label: "Token",
Icon: KeyIcon,
w: "20em",
},
{
label: "Fingerprint",
Icon: FingerprintIcon,
w: "20em",
},
],
[]
)
return (
<div className="rounded-md border overflow-hidden w-full mt-4">
<Table>
<TableHeader>
<TableRow>
{headerCols.map((col) => (
<TableHead key={col.label} style={{ minWidth: col.w }}>
<span className="flex items-center gap-2">
<col.Icon className="size-4" />
{col.label}
</span>
</TableHead>
))}
{!isReadOnly && (
<TableHead className="w-0">
<span className="sr-only">
<Trans>Actions</Trans>
</span>
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody className="whitespace-pre">
{fingerprints.map((fingerprint, i) => (
<TableRow key={i}>
<TableCell className="font-medium ps-5 py-2.5">{fingerprint.expand.system.name}</TableCell>
<TableCell className="font-mono text-[0.95em] py-2.5">{fingerprint.token}</TableCell>
<TableCell className="font-mono text-[0.95em] py-2.5">{fingerprint.fingerprint}</TableCell>
{!isReadOnly && (
<TableCell className="py-2.5 px-4 xl:px-2">
<ActionsButtonTable fingerprint={fingerprint} />
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
)
})
async function updateFingerprint(fingerprint: FingerprintRecord, rotateToken = false) {
try {
await pb.collection("fingerprints").update(fingerprint.id, {
fingerprint: "",
token: rotateToken ? generateToken() : fingerprint.token,
})
} catch (error: any) {
toast({
title: t`Error`,
description: error.message,
})
}
}
const ActionsButtonTable = memo(({ fingerprint }: { fingerprint: FingerprintRecord }) => {
const envVar = `HUB_URL=${getHubURL()}\nTOKEN=${fingerprint.token}`
const copyEnv = () => copyToClipboard(envVar)
const copyYaml = () => copyToClipboard(envVar.replaceAll("=", ": "))
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size={"icon"} data-nolink>
<span className="sr-only">
<Trans>Open menu</Trans>
</span>
<MoreHorizontalIcon className="w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={copyYaml}>
<CopyIcon className="me-2.5 size-4" />
<Trans>Copy YAML</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={copyEnv}>
<CopyIcon className="me-2.5 size-4" />
<Trans context="Environment variables">Copy env</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => updateFingerprint(fingerprint, true)}>
<RotateCwIcon className="me-2.5 size-4" />
<Trans>Rotate token</Trans>
</DropdownMenuItem>
{fingerprint.fingerprint && (
<DropdownMenuItem onSelect={() => updateFingerprint(fingerprint)}>
<Trash2Icon className="me-2.5 size-4" />
<Trans>Delete fingerprint</Trans>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
})
export default SettingsFingerprintsPage

View File

@@ -293,7 +293,7 @@ export default function SystemsTable() {
"bg-yellow-500"
}
/>
<span>{info.getValue() as string}</span>
<span className="truncate max-w-14">{info.getValue() as string}</span>
</span>
)
},

View File

@@ -0,0 +1,38 @@
import { copyToClipboard } from "@/lib/utils"
import { Input } from "./input"
import { Trans } from "@lingui/react/macro"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip"
import { CopyIcon } from "lucide-react"
import { Button } from "./button"
export function InputCopy({ value, id, name }: { value: string; id: string; name: string }) {
return (
<div className="relative">
<Input readOnly id={id} name={name} value={value} required></Input>
<div
className={
"h-6 w-24 bg-gradient-to-r rtl:bg-gradient-to-l from-transparent to-background to-65% absolute top-2 end-1 pointer-events-none"
}
></div>
<TooltipProvider delayDuration={100} disableHoverableContent>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant={"link"}
className="absolute end-0 top-0"
onClick={() => copyToClipboard(value)}
>
<CopyIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
<Trans>Click to copy</Trans>
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)
}

View File

@@ -1,9 +1,9 @@
import { t } from "@lingui/core/macro";
import { t } from "@lingui/core/macro"
import { toast } from "@/components/ui/use-toast"
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
import { $alerts, $copyContent, $systems, $userSettings, pb } from "./stores"
import { AlertInfo, AlertRecord, ChartTimeData, ChartTimes, SystemRecord } from "@/types"
import { AlertInfo, AlertRecord, ChartTimeData, ChartTimes, FingerprintRecord, SystemRecord } from "@/types"
import { RecordModel, RecordSubscription } from "pocketbase"
import { WritableAtom } from "nanostores"
import { timeDay, timeHour } from "d3-time"
@@ -17,13 +17,9 @@ export function cn(...inputs: ClassValue[]) {
}
/** Adds event listener to node and returns function that removes the listener */
export function listen<T extends Event = Event>(
node: Node,
event: string,
handler: (event: T) => void
) {
node.addEventListener(event, handler as EventListener)
return () => node.removeEventListener(event, handler as EventListener)
export function listen<T extends Event = Event>(node: Node, event: string, handler: (event: T) => void) {
node.addEventListener(event, handler as EventListener)
return () => node.removeEventListener(event, handler as EventListener)
}
export async function copyToClipboard(content: string) {
@@ -355,3 +351,12 @@ export const alertInfo: Record<string, AlertInfo> = {
* const hostname = getHostDisplayValue(system) // hostname will be "beszel.sock"
*/
export const getHostDisplayValue = (system: SystemRecord): string => system.host.slice(system.host.lastIndexOf("/") + 1)
/** Generate a random token for the agent */
export const generateToken = () => crypto?.randomUUID() ?? (performance.now() * Math.random()).toString(16)
/** Get the hub URL from the global BESZEL object */
export const getHubURL = () => BESZEL?.HUB_URL || window.location.origin
/** Map of system IDs to their corresponding tokens (used to avoid fetching in add-system dialog) */
export const tokenMap = new Map<SystemRecord["id"], FingerprintRecord["token"]>()

View File

@@ -49,6 +49,7 @@ msgstr "30 يومًا"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "إجراءات"
@@ -76,9 +77,6 @@ msgstr "إضافة عنوان URL"
msgid "Adjust display options for charts."
msgstr "تعديل خيارات العرض للرسوم البيانية."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "تحقق من السجلات لمزيد من التفاصيل."
msgid "Check your notification service"
msgstr "تحقق من خدمة الإشعارات الخاصة بك"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "انقر للنسخ"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "نسخ docker run"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr "نسخ متغيرات البيئة"
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "نسخ المضيف"
@@ -234,6 +237,18 @@ msgstr "نسخ أمر لينكس"
msgid "Copy text"
msgstr "نسخ النص"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr "انسخ أمر التثبيت للوكيل أدناه، أو سجل الوكلاء تلقائياً باستخدام <0>رمز مميز عالمي</0>."
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "انسخ محتوى <0>docker-compose.yml</0> للوكيل أدناه، أو سجل الوكلاء تلقائياً باستخدام <1>رمز مميز عالمي</1>."
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "نسخ YAML"
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "المعالج"
@@ -266,6 +281,10 @@ msgstr "الفترة الزمنية الافتراضية"
msgid "Delete"
msgstr "حذف"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr "حذف البصمة"
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "القرص"
@@ -329,6 +348,7 @@ msgstr "أدخل عنوان البريد الإشباكي لإعادة تعيي
msgid "Enter email address..."
msgstr "أدخل عنوان البريد الإشباكي..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "في كل إعادة تشغيل، سيتم تحديث الأنظمة في قاعدة البيانات لتتطابق مع الأنظمة المعرفة في الملف."
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "فتح القائمة"
@@ -635,6 +657,10 @@ msgstr "إعادة تعيين كلمة المرور"
msgid "Resume"
msgstr "استئناف"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr "تدوير الرمز المميز"
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "احفظ العنوان باستخدام مفتاح الإدخال أو الفاصلة. اتركه فارغًا لتعطيل إشعارات البريد الإشباكي."
@@ -669,7 +695,6 @@ msgstr "تم الإرسال"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "يحدد النطاق الزمني الافتراضي للرسوم البيانية عند عرض النظام."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "اختبار <0>URL</0>"
msgid "Test notification sent"
msgstr "تم إرسال إشعار الاختبار"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "يجب أن يكون الوكيل قيد التشغيل على النظام للاتصال. انسخ أمر التثبيت للوكيل أدناه."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "يجب أن يكون الوكيل قيد التشغيل على النظام للاتصال. انسخ <0>docker-compose.yml</0> للوكيل أدناه."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "ثم قم بتسجيل الدخول إلى الواجهة الخلفية وأعد تعيين كلمة مرور حساب المستخدم الخاص بك في جدول المستخدمين."
@@ -783,6 +800,24 @@ msgstr "تبديل الشبكة"
msgid "Toggle theme"
msgstr "تبديل السمة"
#: src/components/add-system.tsx
msgid "Token"
msgstr "رمز مميز"
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr "الرموز المميزة والبصمات"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr "تسمح الرموز المميزة للوكلاء بالاتصال والتسجيل. البصمات هي معرفات مستقرة فريدة لكل نظام، يتم تعيينها عند الاتصال الأول."
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "تُستخدم الرموز المميزة والبصمات للمصادقة على اتصالات WebSocket إلى المحور."
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "يتم التفعيل عندما يتجاوز أي مستشعر عتبة معينة"
@@ -807,6 +842,10 @@ msgstr "يتم التفعيل عندما يتغير الحالة بين التش
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "يتم التفعيل عندما يتجاوز استخدام أي قرص عتبة معينة"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr "رمز مميز عالمي"
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "هل تريد مساعدتنا في تحسين ترجماتنا؟ تحق
msgid "Webhook / Push notifications"
msgstr "إشعارات Webhook / Push"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr "عند التفعيل، يسمح هذا الرمز المميز للوكلاء بالتسجيل الذاتي دون إنشاء نظام مسبق. ينتهي بعد ساعة واحدة أو عند إعادة تشغيل المحور."
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30 дни"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "Действия"
@@ -76,9 +77,6 @@ msgstr "Добави URL"
msgid "Adjust display options for charts."
msgstr "Настрой опциите за показване на диаграмите."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "Провери log-овете за повече информация."
msgid "Check your notification service"
msgstr "Провери услугата си за удостоверяване"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "Настисни за да копираш"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "Копирай docker run"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "Копирай хоста"
@@ -234,6 +237,18 @@ msgstr "Копирай linux командата"
msgid "Copy text"
msgstr "Копирай текста"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr ""
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "Процесор"
@@ -266,6 +281,10 @@ msgstr "Времеви диапазон по подразбиране"
msgid "Delete"
msgstr "Изтрий"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "Диск"
@@ -329,6 +348,7 @@ msgstr "Въведи имейл адрес за да нулираш парола
msgid "Enter email address..."
msgstr "Въведи имейл адрес..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "На всеки рестарт, системите в датабазата ще бъдат обновени да съвпадат със системите зададени във файла."
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "Отвори менюто"
@@ -635,6 +657,10 @@ msgstr "Нулиране на парола"
msgid "Resume"
msgstr "Възобнови"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr ""
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Запази адреса с enter или запетая. Остави празно за да изключиш нотификациите чрез имейл."
@@ -669,7 +695,6 @@ msgstr "Изпратени"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Задава диапазона за време за диаграмите, когато се разглежда система."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "Тествай <0>URL</0>"
msgid "Test notification sent"
msgstr "Тестова нотификация изпратена"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "Агента трябва да работи на системата за да се свърже. Копирай инсталационната команда за агента долу."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "Агемта трябва да работи на системата за да се свърже. Копирай <0>docker-compose.yml</0> файла за агента долу."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "След това влез в backend-а и нулирай паролата за потребителския акаунт в таблицата за потребители."
@@ -783,6 +800,24 @@ msgstr "Превключване на мрежа"
msgid "Toggle theme"
msgstr "Включи тема"
#: src/components/add-system.tsx
msgid "Token"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Задейства се, когато някой даден сензор надвиши зададен праг"
@@ -807,6 +842,10 @@ msgstr "Задейства се, когато статуса превключв
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Задейства се, когато употребата на някой диск надивши зададен праг"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr ""
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "Искаш да помогнеш да направиш преводит
msgid "Webhook / Push notifications"
msgstr "Webhook / Пуш нотификации"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr ""
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30 dní"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "Akce"
@@ -76,9 +77,6 @@ msgstr "Přidat URL"
msgid "Adjust display options for charts."
msgstr "Upravit možnosti zobrazení pro grafy."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "Pro více informací zkontrolujte logy."
msgid "Check your notification service"
msgstr "Zkontrolujte službu upozornění"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "Klikněte pro zkopírování"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "Kopírovat hostitele"
@@ -234,6 +237,18 @@ msgstr "Kopírovat příkaz Linux"
msgid "Copy text"
msgstr "Kopírovat text"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr ""
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "Procesor"
@@ -266,6 +281,10 @@ msgstr "Výchozí doba"
msgid "Delete"
msgstr "Odstranit"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "Disk"
@@ -329,6 +348,7 @@ msgstr "Zadejte e-mailovou adresu pro obnovu hesla"
msgid "Enter email address..."
msgstr "Zadejte e-mailovou adresu..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "Při každém restartu budou systémy v databázi aktualizovány tak, aby odpovídaly systémům definovaným v souboru."
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "Otevřít menu"
@@ -635,6 +657,10 @@ msgstr "Obnovit heslo"
msgid "Resume"
msgstr "Pokračovat"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr ""
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Adresu uložte pomocí klávesy enter nebo čárky. Pro deaktivaci e-mailových oznámení ponechte prázdné pole."
@@ -669,7 +695,6 @@ msgstr "Odeslat"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Nastaví výchozí časový rozsah grafů, když je systém zobrazen."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "Test <0>URL</0>"
msgid "Test notification sent"
msgstr "Testovací oznámení odesláno"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "Agent musí být v systému spuštěn, aby se mohl připojit. Zkopírujte níže uvedený instalační příkaz pro agenta."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "Agent musí být v systému spuštěn, aby se mohl připojit. Zkopírujte níže uvedený soubor<0>docker-compose.yml</0> pro agenta."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Poté se přihlaste do backendu a obnovte heslo k uživatelskému účtu v tabulce uživatelů."
@@ -783,6 +800,24 @@ msgstr "Přepnout mřížku"
msgid "Toggle theme"
msgstr "Přepnout motiv"
#: src/components/add-system.tsx
msgid "Token"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Spustí se, když některý senzor překročí prahovou hodnotu"
@@ -807,6 +842,10 @@ msgstr "Spouští se, když se změní dostupnost"
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Spustí se, když využití disku překročí prahovou hodnotu"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr ""
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "Chcete nám pomoci s našimi překlady ještě lépe? Podívejte se na <
msgid "Webhook / Push notifications"
msgstr "Webhook / Push oznámení"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr ""
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30 dage"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "Handlinger"
@@ -76,9 +77,6 @@ msgstr "Tilføj URL"
msgid "Adjust display options for charts."
msgstr "Juster visningsindstillinger for diagrammer."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "Tjek logfiler for flere detaljer."
msgid "Check your notification service"
msgstr "Tjek din notifikationstjeneste"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "Klik for at kopiere"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "Kopiér docker run"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "Kopier host"
@@ -234,6 +237,18 @@ msgstr "Kopier Linux kommando"
msgid "Copy text"
msgstr "Kopier tekst"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr ""
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "CPU"
@@ -266,6 +281,10 @@ msgstr "Standard tidsperiode"
msgid "Delete"
msgstr "Slet"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "Disk"
@@ -329,6 +348,7 @@ msgstr "Indtast e-mailadresse for at nulstille adgangskoden"
msgid "Enter email address..."
msgstr "Indtast e-mailadresse..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "Ved hver genstart vil systemer i databasen blive opdateret til at matche de systemer, der er defineret i filen."
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "Åbn menu"
@@ -635,6 +657,10 @@ msgstr "Nulstil adgangskode"
msgid "Resume"
msgstr "Genoptag"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr ""
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Gem adresse ved hjælp af enter eller komma. Lad feltet stå tomt for at deaktivere e-mail-meddelelser."
@@ -669,7 +695,6 @@ msgstr "Sendt"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Sætter standardtidsintervallet for diagrammer når et system vises."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "Test <0>URL</0>"
msgid "Test notification sent"
msgstr "Test notifikation sendt"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "Agenten skal køre på systemet for at forbinde. Kopier installationskommandoen for agenten nedenfor."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "Agenten skal køre på systemet for at forbinde. Kopier <0>docker-compose.yml</0> for agenten nedenfor."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Log derefter ind på backend og nulstil adgangskoden til din brugerkonto i tabellen brugere."
@@ -783,6 +800,24 @@ msgstr "Slå gitter til/fra"
msgid "Toggle theme"
msgstr "Skift tema"
#: src/components/add-system.tsx
msgid "Token"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Udløser når en sensor overstiger en tærskel"
@@ -807,6 +842,10 @@ msgstr "Udløser når status skifter mellem op og ned"
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Udløser når brugen af en disk overstiger en tærskel"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr ""
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "Vil du hjælpe os med at gøre vores oversættelser endnu bedre? Tjek <0
msgid "Webhook / Push notifications"
msgstr "Webhook / Push notifikationer"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr ""
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30 Tage"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "Aktionen"
@@ -76,9 +77,6 @@ msgstr "URL hinzufügen"
msgid "Adjust display options for charts."
msgstr "Anzeigeoptionen für Diagramme anpassen."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "Überprüfe die Protokolle für weitere Details."
msgid "Check your notification service"
msgstr "Überprüfe deinen Benachrichtigungsdienst"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "Zum Kopieren klicken"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "Docker run kopieren"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr "Umgebungsvariablen kopieren"
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "Host kopieren"
@@ -234,6 +237,18 @@ msgstr "Linux-Befehl kopieren"
msgid "Copy text"
msgstr "Text kopieren"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr "Kopieren Sie den Installationsbefehl für den Agent unten oder registrieren Sie Agents automatisch mit einem <0>universellen Token</0>."
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "Kopieren Sie den<0>docker-compose.yml</0> Inhalt für den Agent unten oder registrieren Sie Agents automatisch mit einem <1>universellen Token</1>."
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "YAML kopieren"
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "CPU"
@@ -266,6 +281,10 @@ msgstr "Standardzeitraum"
msgid "Delete"
msgstr "Löschen"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr "Fingerabdruck löschen"
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "Festplatte"
@@ -329,6 +348,7 @@ msgstr "E-Mail-Adresse eingeben, um das Passwort zurückzusetzen"
msgid "Enter email address..."
msgstr "E-Mail-Adresse eingeben..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "Bei jedem Neustart werden die Systeme in der Datenbank aktualisiert, um den in der Datei definierten Systemen zu entsprechen."
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "Menü öffnen"
@@ -635,6 +657,10 @@ msgstr "Passwort zurücksetzen"
msgid "Resume"
msgstr "Fortsetzen"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr "Token rotieren"
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Adresse mit der Enter-Taste oder Komma speichern. Leer lassen, um E-Mail-Benachrichtigungen zu deaktivieren."
@@ -669,7 +695,6 @@ msgstr "Gesendet"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Legt den Standardzeitraum für Diagramme fest, wenn ein System angezeigt wird."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "Test <0>URL</0>"
msgid "Test notification sent"
msgstr "Testbenachrichtigung gesendet"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "Der Agent muss auf dem System laufen, um eine Verbindung herzustellen. Kopiere den Installationsbefehl für den Agent unten."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "Der Agent muss auf dem System laufen, um eine Verbindung herzustellen. Kopiere die <0>docker-compose.yml</0> für den Agent unten."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Melde dich dann im Backend an und setze dein Benutzerkontopasswort in der Benutzertabelle zurück."
@@ -783,6 +800,24 @@ msgstr "Raster umschalten"
msgid "Toggle theme"
msgstr "Darstellung umschalten"
#: src/components/add-system.tsx
msgid "Token"
msgstr "Token"
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr "Tokens & Fingerabdrücke"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr "Tokens ermöglichen es Agents, sich zu verbinden und zu registrieren. Fingerabdrücke sind stabile, eindeutige Identifikatoren für jedes System, die bei der ersten Verbindung gesetzt werden."
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "Tokens und Fingerabdrücke werden verwendet, um WebSocket-Verbindungen zum Hub zu authentifizieren."
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Löst aus, wenn ein Sensor einen Schwellenwert überschreitet"
@@ -807,6 +842,10 @@ msgstr "Löst aus, wenn der Status zwischen online und offline wechselt"
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Löst aus, wenn die Nutzung einer Festplatte einen Schwellenwert überschreitet"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr "Universeller Token"
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "Möchtest du uns helfen, unsere Übersetzungen noch besser zu machen? Sc
msgid "Webhook / Push notifications"
msgstr "Webhook / Push-Benachrichtigungen"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr "Wenn aktiviert, ermöglicht dieser Token Agents, sich selbst zu registrieren, ohne vorherige Systemerstellung. Läuft nach einer Stunde oder beim Hub-Neustart ab."
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -44,6 +44,7 @@ msgstr "30 days"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "Actions"
@@ -71,9 +72,6 @@ msgstr "Add URL"
msgid "Adjust display options for charts."
msgstr "Adjust display options for charts."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -181,7 +179,7 @@ msgstr "Check logs for more details."
msgid "Check your notification service"
msgstr "Check your notification service"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "Click to copy"
@@ -217,6 +215,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "Copy docker run"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr "Copy env"
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "Copy host"
@@ -229,6 +232,18 @@ msgstr "Copy Linux command"
msgid "Copy text"
msgstr "Copy text"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "Copy YAML"
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "CPU"
@@ -261,6 +276,10 @@ msgstr "Default time period"
msgid "Delete"
msgstr "Delete"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr "Delete fingerprint"
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "Disk"
@@ -324,6 +343,7 @@ msgstr "Enter email address to reset password"
msgid "Enter email address..."
msgstr "Enter email address..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -516,6 +536,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "On each restart, systems in the database will be updated to match the systems defined in the file."
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "Open menu"
@@ -630,6 +652,10 @@ msgstr "Reset Password"
msgid "Resume"
msgstr "Resume"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr "Rotate token"
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Save address using enter key or comma. Leave blank to disable email notifications."
@@ -664,7 +690,6 @@ msgstr "Sent"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Sets the default time range for charts when a system is viewed."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -741,14 +766,6 @@ msgstr "Test <0>URL</0>"
msgid "Test notification sent"
msgstr "Test notification sent"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "The agent must be running on the system to connect. Copy the installation command for the agent below."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Then log into the backend and reset your user account password in the users table."
@@ -778,6 +795,24 @@ msgstr "Toggle grid"
msgid "Toggle theme"
msgstr "Toggle theme"
#: src/components/add-system.tsx
msgid "Token"
msgstr "Token"
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr "Tokens & Fingerprints"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Triggers when any sensor exceeds a threshold"
@@ -802,6 +837,10 @@ msgstr "Triggers when status switches between up and down"
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Triggers when usage of any disk exceeds a threshold"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr "Universal token"
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -857,6 +896,10 @@ msgstr "Want to help improve our translations? Check <0>Crowdin</0> for details.
msgid "Webhook / Push notifications"
msgstr "Webhook / Push notifications"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30 días"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "Acciones"
@@ -76,9 +77,6 @@ msgstr "Agregar URL"
msgid "Adjust display options for charts."
msgstr "Ajustar las opciones de visualización para los gráficos."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "Revise los registros para más detalles."
msgid "Check your notification service"
msgstr "Verifique su servicio de notificaciones"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "Haga clic para copiar"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "Copiar docker run"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr "Copiar env"
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "Copiar host"
@@ -234,6 +237,18 @@ msgstr "Copiar comando de Linux"
msgid "Copy text"
msgstr "Copiar texto"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr "Copia el comando de instalación del agente a continuación, o registra agentes automáticamente con un <0>token universal</0>."
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "Copia el contenido del<0>docker-compose.yml</0> para el agente a continuación, o registra agentes automáticamente con un <1>token universal</1>."
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "Copiar YAML"
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "CPU"
@@ -266,6 +281,10 @@ msgstr "Período de tiempo predeterminado"
msgid "Delete"
msgstr "Eliminar"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr "Eliminar huella digital"
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "Disco"
@@ -329,6 +348,7 @@ msgstr "Ingrese la dirección de correo electrónico para restablecer la contras
msgid "Enter email address..."
msgstr "Ingrese dirección de correo..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "En cada reinicio, los sistemas en la base de datos se actualizarán para coincidir con los sistemas definidos en el archivo."
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "Abrir menú"
@@ -635,6 +657,10 @@ msgstr "Restablecer Contraseña"
msgid "Resume"
msgstr "Reanudar"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr "Rotar token"
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Guarde la dirección usando la tecla enter o coma. Deje en blanco para desactivar las notificaciones por correo."
@@ -669,7 +695,6 @@ msgstr "Enviado"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Establece el rango de tiempo predeterminado para los gráficos cuando se visualiza un sistema."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "Probar <0>URL</0>"
msgid "Test notification sent"
msgstr "Notificación de prueba enviada"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "El agente debe estar ejecutándose en el sistema para conectarse. Copie el comando de instalación para el agente a continuación."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "El agente debe estar ejecutándose en el sistema para conectarse. Copie el <0>docker-compose.yml</0> para el agente a continuación."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Luego inicie sesión en el backend y restablezca la contraseña de su cuenta de usuario en la tabla de usuarios."
@@ -783,6 +800,24 @@ msgstr "Alternar cuadrícula"
msgid "Toggle theme"
msgstr "Alternar tema"
#: src/components/add-system.tsx
msgid "Token"
msgstr "Token"
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr "Tokens y Huellas Digitales"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr "Los tokens permiten que los agentes se conecten y registren. Las huellas digitales son identificadores estables únicos para cada sistema, establecidos en la primera conexión."
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "Los tokens y las huellas digitales se utilizan para autenticar las conexiones WebSocket al hub."
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Se activa cuando cualquier sensor supera un umbral"
@@ -807,6 +842,10 @@ msgstr "Se activa cuando el estado cambia entre activo e inactivo"
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Se activa cuando el uso de cualquier disco supera un umbral"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr "Token universal"
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "¿Quieres ayudarnos a mejorar nuestras traducciones? Consulta <0>Crowdin
msgid "Webhook / Push notifications"
msgstr "Notificaciones Webhook / Push"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr "Cuando está habilitado, este token permite que los agentes se auto-registren sin crear previamente el sistema. Expira después de una hora o al reiniciar el hub."
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "۳۰ روز"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "عملیات"
@@ -76,9 +77,6 @@ msgstr "افزودن آدرس اینترنتی"
msgid "Adjust display options for charts."
msgstr "تنظیم گزینه‌های نمایش برای نمودارها."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "برای جزئیات بیشتر، لاگ‌ها را بررسی کنی
msgid "Check your notification service"
msgstr "سرویس اطلاع‌رسانی خود را بررسی کنید"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "برای کپی کردن کلیک کنید"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "کپی docker run"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr "کپی متغیرهای محیط"
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "کپی میزبان"
@@ -234,6 +237,18 @@ msgstr "کپی دستور لینوکس"
msgid "Copy text"
msgstr "کپی متن"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr "دستور نصب عامل زیر را کپی کنید، یا عامل‌ها را به طور خودکار با <0>توکن جهانی</0> ثبت کنید."
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "محتوای <0>docker-compose.yml</0> عامل زیر را کپی کنید، یا عامل‌ها را به طور خودکار با <1>توکن جهانی</1> ثبت کنید."
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "کپی YAML"
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "پردازنده"
@@ -266,6 +281,10 @@ msgstr "بازه زمانی پیش‌فرض"
msgid "Delete"
msgstr "حذف"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr "حذف اثر انگشت"
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "دیسک"
@@ -329,6 +348,7 @@ msgstr "آدرس ایمیل را برای بازنشانی رمز عبور وا
msgid "Enter email address..."
msgstr "آدرس ایمیل را وارد کنید..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "در هر بار راه‌اندازی مجدد، سیستم‌های موجود در پایگاه داده با سیستم‌های تعریف شده در فایل مطابقت داده می‌شوند."
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "باز کردن منو"
@@ -635,6 +657,10 @@ msgstr "بازنشانی رمز عبور"
msgid "Resume"
msgstr "ادامه"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr "چرخش توکن"
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "آدرس را با استفاده از کلید Enter یا کاما ذخیره کنید. برای غیرفعال کردن اعلان‌های ایمیلی، خالی بگذارید."
@@ -669,7 +695,6 @@ msgstr "ارسال شد"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "بازه زمانی پیش‌فرض برای نمودارها هنگام مشاهده یک سیستم را تعیین می‌کند."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "تست <0>آدرس اینترنتی</0>"
msgid "Test notification sent"
msgstr "اعلان آزمایشی ارسال شد"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "برای اتصال، عامل باید روی سیستم در حال اجرا باشد. دستور نصب عامل را از زیر کپی کنید."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "برای اتصال، عامل باید روی سیستم در حال اجرا باشد. <0>docker-compose.yml</0> مربوط به عامل را از زیر کپی کنید."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "سپس وارد بخش پشتیبان شوید و رمز عبور حساب کاربری خود را در جدول کاربران بازنشانی کنید."
@@ -783,6 +800,24 @@ msgstr "تغییر نمایش جدول"
msgid "Toggle theme"
msgstr "تغییر تم"
#: src/components/add-system.tsx
msgid "Token"
msgstr "توکن"
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr "توکن‌ها و اثرات انگشت"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr "توکن‌ها به عامل‌ها اجازه اتصال و ثبت‌نام می‌دهند. اثرات انگشت شناسه‌های پایدار منحصر به فرد هر سیستم هستند که در اولین اتصال تنظیم می‌شوند."
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "توکن‌ها و اثرات انگشت برای احراز هویت اتصالات WebSocket به هاب استفاده می‌شوند."
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "هنگامی که هر حسگری از یک آستانه فراتر رود، فعال می‌شود"
@@ -807,6 +842,10 @@ msgstr "هنگامی که وضعیت بین بالا و پایین تغییر م
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "هنگامی که استفاده از هر دیسکی از یک آستانه فراتر رود، فعال می‌شود"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr "توکن جهانی"
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "می‌خواهید به ما کمک کنید تا ترجمه‌های
msgid "Webhook / Push notifications"
msgstr "اعلان‌های Webhook / Push"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr "هنگامی که فعال است، این توکن به عامل‌ها اجازه خودثبت‌نامی بدون ایجاد سیستم قبلی می‌دهد. پس از یک ساعت یا در راه‌اندازی مجدد هاب منقضی می‌شود."
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30 jours"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "Actions"
@@ -76,9 +77,6 @@ msgstr "Ajouter URL"
msgid "Adjust display options for charts."
msgstr "Ajuster les options d'affichage pour les graphiques."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "Vérifiez les journaux pour plus de détails."
msgid "Check your notification service"
msgstr "Vérifiez votre service de notification"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "Cliquez pour copier"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "Copier docker run"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr "Copier env"
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "Copier l'hôte"
@@ -234,6 +237,18 @@ msgstr "Copier la commande Linux"
msgid "Copy text"
msgstr "Copier le texte"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr "Copiez la commande d'installation de l'agent ci-dessous, ou enregistrez les agents automatiquement avec un <0>token universel</0>."
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "Copiez le contenu du<0>docker-compose.yml</0> pour l'agent ci-dessous, ou enregistrez les agents automatiquement avec un <1>token universel</1>."
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "Copier YAML"
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "CPU"
@@ -266,6 +281,10 @@ msgstr "Période par défaut"
msgid "Delete"
msgstr "Supprimer"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr "Supprimer l'empreinte"
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "Disque"
@@ -329,6 +348,7 @@ msgstr "Entrez l'adresse email pour réinitialiser le mot de passe"
msgid "Enter email address..."
msgstr "Entrez l'adresse email..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "À chaque redémarrage, les systèmes dans la base de données seront mis à jour pour correspondre aux systèmes définis dans le fichier."
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "Ouvrir le menu"
@@ -635,6 +657,10 @@ msgstr "Réinitialiser le mot de passe"
msgid "Resume"
msgstr "Reprendre"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr "Faire tourner le token"
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Enregistrez l'adresse en utilisant la touche Entrée ou la virgule. Laissez vide pour désactiver les notifications par email."
@@ -669,7 +695,6 @@ msgstr "Envoyé"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Définit la plage de temps par défaut pour les graphiques lorsqu'un système est consulté."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "Tester <0>URL</0>"
msgid "Test notification sent"
msgstr "Notification de test envoyée"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "L'agent doit être en cours d'exécution sur le système pour se connecter. Copiez la commande d'installation pour l'agent ci-dessous."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "L'agent doit être en cours d'exécution sur le système pour se connecter. Copiez le <0>docker-compose.yml</0> pour l'agent ci-dessous."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Ensuite, connectez-vous au backend et réinitialisez le mot de passe de votre compte utilisateur dans la table des utilisateurs."
@@ -783,6 +800,24 @@ msgstr "Basculer la grille"
msgid "Toggle theme"
msgstr "Changer le thème"
#: src/components/add-system.tsx
msgid "Token"
msgstr "Token"
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr "Tokens et Empreintes"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr "Les tokens permettent aux agents de se connecter et de s'enregistrer. Les empreintes sont des identifiants stables uniques à chaque système, définis lors de la première connexion."
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "Les tokens et les empreintes sont utilisés pour authentifier les connexions WebSocket vers le hub."
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Déclenchement lorsque tout capteur dépasse un seuil"
@@ -807,6 +842,10 @@ msgstr "Se déclenche lorsque le statut passe de \"Joignable\" à \"Injoignable\
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Déclenchement lorsque l'utilisation de tout disque dépasse un seuil"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr "Token universel"
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "Vous voulez nous aider à améliorer nos traductions ? Consultez <0>Crow
msgid "Webhook / Push notifications"
msgstr "Notifications Webhook / Push"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr "Lorsqu'il est activé, ce token permet aux agents de s'auto-enregistrer sans création préalable du système. Expire après une heure ou au redémarrage du hub."
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30 dana"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "Akcije"
@@ -76,9 +77,6 @@ msgstr "Dodaj URL"
msgid "Adjust display options for charts."
msgstr "Podesite opcije prikaza za grafikone."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "Provjerite logove za više detalja."
msgid "Check your notification service"
msgstr "Provjerite Vaš servis notifikacija"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "Pritisnite za kopiranje"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "Kopiraj docker run"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "Kopiraj hosta"
@@ -234,6 +237,18 @@ msgstr "Kopiraj Linux komandu"
msgid "Copy text"
msgstr "Kopiraj tekst"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr ""
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "Procesor"
@@ -266,6 +281,10 @@ msgstr "Zadano vremensko razdoblje"
msgid "Delete"
msgstr "Izbriši"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "Disk"
@@ -329,6 +348,7 @@ msgstr "Unesite email adresu za resetiranje lozinke"
msgid "Enter email address..."
msgstr "Unesite email adresu..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "Prilikom svakog ponovnog pokretanja, sustavi u bazi podataka biti će ažurirani kako bi odgovarali sustavima definiranim u datoteci."
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "Otvori menu"
@@ -635,6 +657,10 @@ msgstr "Resetiraj Lozinku"
msgid "Resume"
msgstr "Nastavi"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr ""
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Spremite adresu pomoću tipke enter ili zareza. Ostavite prazno kako biste onemogućili obavijesti e-poštom."
@@ -669,7 +695,6 @@ msgstr "Poslano"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Postavlja zadani vremenski raspon za grafikone kada se sustav gleda."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "Testni <0>URL</0>"
msgid "Test notification sent"
msgstr "Testna obavijest poslana"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "Agent mora biti pokrenut na sistemu da bi se spojio. Kopirajte instalacijske komande za agenta ispod."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "Agent mora biti pokrenut na sistemu da bi se spojio. Kopirajte <0>docker-compose.yml</0> za agenta ispod."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Zatim se prijavite u backend i resetirajte lozinku korisničkog računa u tablici korisnika."
@@ -783,6 +800,24 @@ msgstr "Uključi/isključi rešetku"
msgid "Toggle theme"
msgstr "Uključi/isključi temu"
#: src/components/add-system.tsx
msgid "Token"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Pokreće se kada bilo koji senzor prijeđe prag"
@@ -807,6 +842,10 @@ msgstr "Pokreće se kada se status sistema promijeni"
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Pokreće se kada iskorištenost bilo kojeg diska premaši prag"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr ""
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "Želite li nam pomoći da naše prijevode učinimo još boljim? Posjetit
msgid "Webhook / Push notifications"
msgstr "Webhook / Push obavijest"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr ""
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30 nap"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "Műveletek"
@@ -76,9 +77,6 @@ msgstr "URL hozzáadása"
msgid "Adjust display options for charts."
msgstr "Állítsa be a diagram megjelenítését."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "Ellenőrizd a naplót a további részletekért."
msgid "Check your notification service"
msgstr "Ellenőrizd az értesítési szolgáltatásodat"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "Kattints a másoláshoz"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "Docker run másolása"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "Hoszt másolása"
@@ -234,6 +237,18 @@ msgstr "Linux parancs másolása"
msgid "Copy text"
msgstr "Szöveg másolása"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr ""
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "CPU"
@@ -266,6 +281,10 @@ msgstr "Alapértelmezett időszak"
msgid "Delete"
msgstr "Törlés"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "Lemez"
@@ -329,6 +348,7 @@ msgstr "E-mail cím megadása a jelszó visszaállításához"
msgid "Enter email address..."
msgstr "Adja meg az e-mail címet..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "Minden újraindításkor az adatbázisban lévő rendszerek frissítésre kerülnek, hogy megfeleljenek a fájlban meghatározott rendszereknek."
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "Menü megnyitása"
@@ -635,6 +657,10 @@ msgstr "Jelszó visszaállítása"
msgid "Resume"
msgstr "Folytatás"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr ""
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Mentse el a címet az Enter billentyű vagy a vessző használatával. Hagyja üresen az e-mail értesítések letiltásához."
@@ -669,7 +695,6 @@ msgstr "Elküldve"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Beállítja az alapértelmezett időtartamot a diagramokhoz, amikor egy rendszert néznek."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "Teszt <0>URL</0>"
msgid "Test notification sent"
msgstr "Teszt értesítés elküldve"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "A csatlakozáshoz az ügynöknek futnia kell a rendszerben. Másolja ki az alábbi telepítési parancsot az ügynök telepítéséhez."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "A csatlakozáshoz az ügynöknek futnia kell a rendszerben. Másolja az<0>docker-compose.yml</0> fájlt az ügynök futtatásához."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Ezután jelentkezzen be a backendbe, és állítsa vissza a felhasználói fiók jelszavát a felhasználók táblázatban."
@@ -783,6 +800,24 @@ msgstr "Rács ki- és bekapcsolása"
msgid "Toggle theme"
msgstr "Téma váltása"
#: src/components/add-system.tsx
msgid "Token"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Bekapcsol, ha bármelyik érzékelő túllép egy küszöbértéket"
@@ -807,6 +842,10 @@ msgstr "Bekapcsol, amikor az állapot fel és le között változik"
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Bekapcsol, ha a lemez érzékelő túllép egy küszöbértéket"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr ""
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "Szeretne segíteni nekünk abban, hogy fordításaink még jobbak legyen
msgid "Webhook / Push notifications"
msgstr "Webhook / Push értesítések"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr ""
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30 dagar"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "Aðgerðir"
@@ -76,9 +77,6 @@ msgstr "Bæta við léni"
msgid "Adjust display options for charts."
msgstr ""
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "Skoðaðu logga til að sjá meiri upplýsingar."
msgid "Check your notification service"
msgstr "Athugaðu tilkynningaþjónustuna þína"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "Smelltu til að afrita"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "Afrita docker run"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "Afrita host"
@@ -234,6 +237,18 @@ msgstr "Afrita Linux aðgerð"
msgid "Copy text"
msgstr "Afrita texta"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr ""
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "Örgjörvi"
@@ -266,6 +281,10 @@ msgstr "Sjálfgefið tímabil"
msgid "Delete"
msgstr "Eyða"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "Diskur"
@@ -329,6 +348,7 @@ msgstr "Settu netfang til að endursetja lykilorð"
msgid "Enter email address..."
msgstr "Settu inn Netfang..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr ""
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "Opna valmynd"
@@ -635,6 +657,10 @@ msgstr "Endurstilla lykilorð"
msgid "Resume"
msgstr "Halda áfram"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr ""
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr ""
@@ -669,7 +695,6 @@ msgstr "Sent"
msgid "Sets the default time range for charts when a system is viewed."
msgstr ""
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "Prufa <0>URL</0>"
msgid "Test notification sent"
msgstr "Prufu tilkynning send"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr ""
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr ""
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Skráðu þig þá inní bakendann og endurstilltu lykilorðið þitt inni í notenda töflunni."
@@ -783,6 +800,24 @@ msgstr ""
msgid "Toggle theme"
msgstr "Velja þema"
#: src/components/add-system.tsx
msgid "Token"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Virkjast þegar einhver skynjari fer yfir þröskuld"
@@ -807,6 +842,10 @@ msgstr "Virkjast þegar staða breytist milli virkur og óvirkur"
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Virkjast þegar diska notkun fer yfir þröskuld"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr ""
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr ""
msgid "Webhook / Push notifications"
msgstr "Webhook / Tilkynningar"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr ""
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30 giorni"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "Azioni"
@@ -76,9 +77,6 @@ msgstr "Aggiungi URL"
msgid "Adjust display options for charts."
msgstr "Regola le opzioni di visualizzazione per i grafici."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "Controlla i log per maggiori dettagli."
msgid "Check your notification service"
msgstr "Controlla il tuo servizio di notifica"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "Clicca per copiare"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "Copia docker run"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr "Copia env"
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "Copia host"
@@ -234,6 +237,18 @@ msgstr "Copia comando Linux"
msgid "Copy text"
msgstr "Copia testo"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr "Copia il comando di installazione per l'agente qui sotto, o registra gli agenti automaticamente con un <0>token universale</0>."
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "Copia il contenuto<0>docker-compose.yml</0> per l'agente qui sotto, o registra gli agenti automaticamente con un <1>token universale</1>."
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "Copia YAML"
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "CPU"
@@ -266,6 +281,10 @@ msgstr "Periodo di tempo predefinito"
msgid "Delete"
msgstr "Elimina"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr "Elimina impronta digitale"
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "Disco"
@@ -329,6 +348,7 @@ msgstr "Inserisci l'indirizzo email per reimpostare la password"
msgid "Enter email address..."
msgstr "Inserisci l'indirizzo email..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "Ad ogni riavvio, i sistemi nel database verranno aggiornati per corrispondere ai sistemi definiti nel file."
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "Apri menu"
@@ -635,6 +657,10 @@ msgstr "Reimposta Password"
msgid "Resume"
msgstr "Riprendi"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr "Ruota token"
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Salva l'indirizzo usando il tasto invio o la virgola. Lascia vuoto per disabilitare le notifiche email."
@@ -669,7 +695,6 @@ msgstr "Inviato"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Imposta l'intervallo di tempo predefinito per i grafici quando viene visualizzato un sistema."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "Test <0>URL</0>"
msgid "Test notification sent"
msgstr "Notifica di test inviata"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "L'agente deve essere in esecuzione sul sistema per connettersi. Copia il comando di installazione per l'agente qui sotto."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "L'agente deve essere in esecuzione sul sistema per connettersi. Copia il<0>docker-compose.yml</0> per l'agente qui sotto."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Quindi accedi al backend e reimposta la password del tuo account utente nella tabella degli utenti."
@@ -783,6 +800,24 @@ msgstr "Attiva/disattiva griglia"
msgid "Toggle theme"
msgstr "Attiva/disattiva tema"
#: src/components/add-system.tsx
msgid "Token"
msgstr "Token"
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr "Token e Impronte Digitali"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr "I token consentono agli agenti di connettersi e registrarsi. Le impronte digitali sono identificatori stabili unici per ogni sistema, impostati alla prima connessione."
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "I token e le impronte digitali vengono utilizzati per autenticare le connessioni WebSocket all'hub."
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Attiva quando un sensore supera una soglia"
@@ -807,6 +842,10 @@ msgstr "Attiva quando lo stato passa tra up e down"
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Attiva quando l'utilizzo di un disco supera una soglia"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr "Token universale"
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "Vuoi aiutarci a migliorare ulteriormente le nostre traduzioni? Dai un'oc
msgid "Webhook / Push notifications"
msgstr "Notifiche Webhook / Push"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr "Quando abilitato, questo token consente agli agenti di auto-registrarsi senza creazione preventiva del sistema. Scade dopo un'ora o al riavvio dell'hub."
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30日間"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "アクション"
@@ -76,9 +77,6 @@ msgstr "URLを追加"
msgid "Adjust display options for charts."
msgstr "チャートの表示オプションを調整します。"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "詳細についてはログを確認してください。"
msgid "Check your notification service"
msgstr "通知サービスを確認してください"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "クリックしてコピー"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "docker run をコピー"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr "環境変数をコピー"
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "ホストをコピー"
@@ -234,6 +237,18 @@ msgstr "Linuxコマンドをコピー"
msgid "Copy text"
msgstr "テキストをコピー"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr "下記のエージェントのインストールコマンドをコピーするか、<0>ユニバーサルトークン</0>を使用してエージェントを自動登録してください。"
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "下記のエージェントの<0>docker-compose.yml</0>内容をコピーするか、<1>ユニバーサルトークン</1>を使用してエージェントを自動登録してください。"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "YAMLをコピー"
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "CPU"
@@ -266,6 +281,10 @@ msgstr "デフォルトの期間"
msgid "Delete"
msgstr "削除"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr "フィンガープリントを削除"
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "ディスク"
@@ -329,6 +348,7 @@ msgstr "パスワードをリセットするためにメールアドレスを入
msgid "Enter email address..."
msgstr "メールアドレスを入力..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "再起動のたびに、データベース内のシステムはファイルに定義されたシステムに一致するように更新されます。"
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "メニューを開く"
@@ -635,6 +657,10 @@ msgstr "パスワードをリセット"
msgid "Resume"
msgstr "再開"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr "トークンをローテート"
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Enterキーまたはカンマを使用してアドレスを保存します。空白のままにするとメール通知が無効になります。"
@@ -669,7 +695,6 @@ msgstr "送信"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "システムを表示する際のチャートのデフォルトの時間範囲を設定します。"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "テスト<0>URL</0>"
msgid "Test notification sent"
msgstr "テスト通知が送信されました"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "接続するにはエージェントがシステム上で実行されている必要があります。以下のエージェントのインストールコマンドをコピーしてください。"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "接続するにはエージェントがシステム上で実行されている必要があります。以下のエージェント用<0>docker-compose.yml</0>をコピーしてください。"
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "その後、バックエンドにログインして、ユーザーテーブルでユーザーアカウントのパスワードをリセットしてください。"
@@ -783,6 +800,24 @@ msgstr "グリッドを切り替え"
msgid "Toggle theme"
msgstr "テーマを切り替え"
#: src/components/add-system.tsx
msgid "Token"
msgstr "トークン"
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr "トークンとフィンガープリント"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr "トークンはエージェントの接続と登録を可能にします。フィンガープリントは各システム固有の安定した識別子で、初回接続時に設定されます。"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "トークンとフィンガープリントは、ハブへのWebSocket接続の認証に使用されます。"
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "センサーがしきい値を超えたときにトリガーされます"
@@ -807,6 +842,10 @@ msgstr "ステータスが上から下に切り替わるときにトリガーさ
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "ディスクの使用量がしきい値を超えたときにトリガーされます"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr "ユニバーサルトークン"
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "翻訳をさらに良くするためにご協力いただけますか?
msgid "Webhook / Push notifications"
msgstr "Webhook / プッシュ通知"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr "有効にすると、このトークンはエージェントが事前のシステム作成なしに自己登録することを可能にします。1時間後またはハブの再起動時に期限切れになります。"
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30일"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "작업"
@@ -76,9 +77,6 @@ msgstr "URL 추가"
msgid "Adjust display options for charts."
msgstr "차트 표시 옵션 변경."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "자세한 내용은 로그를 확인하세요."
msgid "Check your notification service"
msgstr "알림 서비스를 확인하세요."
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "클릭하여 복사"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "docker run 복사"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "호스트 복사"
@@ -234,6 +237,18 @@ msgstr "리눅스 명령어 복사"
msgid "Copy text"
msgstr "텍스트 복사"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr ""
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "CPU"
@@ -266,6 +281,10 @@ msgstr "기본 기간"
msgid "Delete"
msgstr "삭제"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "디스크"
@@ -329,6 +348,7 @@ msgstr "비밀번호를 재설정하려면 이메일 주소를 입력하세요"
msgid "Enter email address..."
msgstr "이메일 주소 입력..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "매 시작 시, 데이터베이스가 파일에 정의된 시스템과 일치하도록 업데이트됩니다."
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "메뉴 열기"
@@ -635,6 +657,10 @@ msgstr "비밀번호 재설정"
msgid "Resume"
msgstr "재개"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr ""
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Enter 키 또는 쉼표를 사용하여 주소를 저장하세요. 이메일 알림을 비활성화하려면 비워 두세요."
@@ -669,7 +695,6 @@ msgstr "보냄"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "시스템을 볼 때 차트의 기본 시간 범위를 설정합니다."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "테스트 <0>URL</0>"
msgid "Test notification sent"
msgstr "테스트 알림이 전송되었습니다."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "에이전트가 시스템에서 실행 중이어야 연결할 수 있습니다. 아래의 에이전트 설치 명령을 복사하세요."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "에이전트가 시스템에서 실행 중이어야 연결할 수 있습니다. 아래의 <0>docker-compose.yml</0>을 복사하세요."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "그런 다음 백엔드에 로그인하여 사용자 테이블에서 사용자 계정 비밀번호를 재설정하세요."
@@ -783,6 +800,24 @@ msgstr "그리드 전환"
msgid "Toggle theme"
msgstr "테마 전환"
#: src/components/add-system.tsx
msgid "Token"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "센서가 임계값을 초과할 때 트리거됩니다."
@@ -807,6 +842,10 @@ msgstr "시스템의 전원이 켜지거나 꺼질때 트리거됩니다."
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "디스크 사용량이 임계값을 초과할 때 트리거됩니다."
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr ""
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "번역을 더 좋게 만드는 데 도움을 주시겠습니까? 자세
msgid "Webhook / Push notifications"
msgstr "Webhook / 푸시 알림"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr ""
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30 dagen"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "Acties"
@@ -76,9 +77,6 @@ msgstr "Voeg URL toe"
msgid "Adjust display options for charts."
msgstr "Weergaveopties voor grafieken aanpassen."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "Controleer de logs voor meer details."
msgid "Check your notification service"
msgstr "Controleer je meldingsservice"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "Klik om te kopiëren"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "Docker run kopiëren"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "Kopieer host"
@@ -234,6 +237,18 @@ msgstr "Kopieer Linux-opdracht"
msgid "Copy text"
msgstr "Kopieer tekst"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr ""
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "CPU"
@@ -266,6 +281,10 @@ msgstr "Standaard tijdsduur"
msgid "Delete"
msgstr "Verwijderen"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "Schijf"
@@ -329,6 +348,7 @@ msgstr "Voer een e-mailadres in om het wachtwoord opnieuw in te stellen"
msgid "Enter email address..."
msgstr "Voer een e-mailadres in..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "Bij elke herstart zullen systemen in de database worden bijgewerkt om overeen te komen met de systemen die in het bestand zijn gedefinieerd."
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "Open menu"
@@ -635,6 +657,10 @@ msgstr "Wachtwoord resetten"
msgid "Resume"
msgstr "Hervatten"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr ""
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Bewaar het adres met de enter-toets of komma. Laat leeg om e-mailmeldingen uit te schakelen."
@@ -669,7 +695,6 @@ msgstr "Verzonden"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Stelt het standaard tijdsbereik voor grafieken in wanneer een systeem wordt bekeken."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "Test <0>URL</0>"
msgid "Test notification sent"
msgstr "Testmelding verzonden"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "De agent moet op het systeem draaien om te verbinden. Kopieer het installatiecommando voor de agent hieronder."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "De agent moet op het systeem draaien om te verbinden. Kopieer de<0>docker-compose.yml</0> voor de agent hieronder."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Log vervolgens in op de backend en reset het wachtwoord van je gebruikersaccount in het gebruikersoverzicht."
@@ -783,6 +800,24 @@ msgstr "Schakel raster"
msgid "Toggle theme"
msgstr "Schakel thema"
#: src/components/add-system.tsx
msgid "Token"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Triggert wanneer een sensor een drempelwaarde overschrijdt"
@@ -807,6 +842,10 @@ msgstr "Triggert wanneer de status schakelt tussen up en down"
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Triggert wanneer het gebruik van een schijf een drempelwaarde overschrijdt"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr ""
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "Wil je ons helpen onze vertalingen nog beter te maken? Bekijk <0>Crowdin
msgid "Webhook / Push notifications"
msgstr "Webhook / Pushmeldingen"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr ""
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30 dager"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "Handlinger"
@@ -76,9 +77,6 @@ msgstr "Legg Til URL"
msgid "Adjust display options for charts."
msgstr "Juster visningsalternativer for diagrammer."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "Sjekk loggene for flere detaljer."
msgid "Check your notification service"
msgstr "Sjekk din meldingstjeneste"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "Klikk for å kopiere"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "Kopier docker run"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "Kopier vert"
@@ -234,6 +237,18 @@ msgstr "Kopier Linux-kommando"
msgid "Copy text"
msgstr "Kopier tekst"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr ""
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "CPU"
@@ -266,6 +281,10 @@ msgstr "Standard tidsperiode"
msgid "Delete"
msgstr "Slett"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "Disk"
@@ -329,6 +348,7 @@ msgstr "Skriv inn e-postadresse for å nullstille passordet"
msgid "Enter email address..."
msgstr "Skriv inn e-postadresse..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "Ved hver omstart vil systemer i databasen bli oppdatert til å matche systemene definert i fila."
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "Åpne meny"
@@ -635,6 +657,10 @@ msgstr "Nullstill Passord"
msgid "Resume"
msgstr "Gjenoppta"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr ""
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Lagre adressen med Enter-tasten eller komma. La feltet være tomt for å deaktivere e-postvarsler."
@@ -669,7 +695,6 @@ msgstr "Sendt"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Angir standard tidsperiode for diagrammer når et system vises."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "Test <0>URL</0>"
msgid "Test notification sent"
msgstr "Test-varsling sendt"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "Agenten må kjøre på systemet du vil koble til. Kopier installasjons-kommandoen for agenten under."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "Agenten må kjøre på systemet du vil koble til. Kopier <0>docker-compose.yml</0> for agenten under."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Logg deretter inn i backend og nullstill passordet på din konto i users-tabellen."
@@ -783,6 +800,24 @@ msgstr "Rutenett av/på"
msgid "Toggle theme"
msgstr "Tema av/på"
#: src/components/add-system.tsx
msgid "Token"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Slår inn når enhver sensor overstiger en grenseverdi"
@@ -807,6 +842,10 @@ msgstr "Slår inn når statusen veksler mellom oppe og nede"
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Slår inn når forbruk av hvilken som helst disk overstiger en grenseverdi"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr ""
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "Vil du hjelpe oss med å gjøre oversettelsene enda bedre? Ta en titt p
msgid "Webhook / Push notifications"
msgstr "Webhook / Push-varslinger"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr ""
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30 dni"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "Akcje"
@@ -76,9 +77,6 @@ msgstr "Dodaj URL"
msgid "Adjust display options for charts."
msgstr "Dostosuj opcje wyświetlania wykresów."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "Sprawdź logi, aby uzyskać więcej informacji."
msgid "Check your notification service"
msgstr "Sprawdź swój serwis powiadomień"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "Kliknij, aby skopiować"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "Skopiuj docker run"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "Kopiuj host"
@@ -234,6 +237,18 @@ msgstr "Kopiuj polecenie Linux"
msgid "Copy text"
msgstr "Kopiuj tekst"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr ""
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "Procesor"
@@ -266,6 +281,10 @@ msgstr "Domyślny przedział czasu"
msgid "Delete"
msgstr "Usuń"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "Dysk"
@@ -329,6 +348,7 @@ msgstr "Wprowadź adres e-mail, aby zresetować hasło"
msgid "Enter email address..."
msgstr "Wprowadź adres e-mail..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "Przy każdym ponownym uruchomieniu systemy w bazie danych będą aktualizowane, aby odpowiadały systemom zdefiniowanym w pliku."
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "Otwórz menu"
@@ -635,6 +657,10 @@ msgstr "Resetuj hasło"
msgid "Resume"
msgstr "Wznów"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr ""
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Zapisz adres, używając klawisza enter lub przecinka. Pozostaw puste, aby wyłączyć powiadomienia e-mail."
@@ -669,7 +695,6 @@ msgstr "Wysłane"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Ustawia domyślny zakres czasowy dla wykresów, gdy system jest wyświetlony."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "Test <0>URL</0>"
msgid "Test notification sent"
msgstr "Testowe powiadomienie wysłane."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "Agent musi być uruchomiony na systemie, aby nawiązać połączenie. Skopiuj poniżej polecenie instalacji agenta."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "Agent musi być uruchomiony na systemie, aby nawiązać połączenie. Skopiuj poniżej plik <0>docker-compose.yml</0> dla agenta."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Następnie zaloguj się do panelu administracyjnego i zresetuj hasło do konta użytkownika w tabeli użytkowników."
@@ -783,6 +800,24 @@ msgstr "Przełącz siatkę"
msgid "Toggle theme"
msgstr "Zmień motyw"
#: src/components/add-system.tsx
msgid "Token"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Wyzwalane, gdy jakikolwiek czujnik przekroczy ustalony próg."
@@ -807,6 +842,10 @@ msgstr "Wyzwalane, gdy status przełącza się między stanem aktywnym a nieakty
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Wyzwalane, gdy wykorzystanie któregokolwiek dysku przekroczy ustalony próg"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr ""
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "Chcesz pomóc nam uczynić nasze tłumaczenia jeszcze lepszymi? Sprawdź
msgid "Webhook / Push notifications"
msgstr "Webhook / Powiadomienia push"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr ""
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30 dias"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "Ações"
@@ -76,9 +77,6 @@ msgstr "Adicionar URL"
msgid "Adjust display options for charts."
msgstr "Ajustar opções de exibição para gráficos."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "Verifique os logs para mais detalhes."
msgid "Check your notification service"
msgstr "Verifique seu serviço de notificação"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "Clique para copiar"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "Copiar docker run"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr "Copiar variáveis de ambiente"
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "Copiar host"
@@ -234,6 +237,18 @@ msgstr "Copiar comando Linux"
msgid "Copy text"
msgstr "Copiar texto"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr "Copie o comando de instalação do agente abaixo, ou registre agentes automaticamente com um <0>token universal</0>."
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "Copie o conteúdo do <0>docker-compose.yml</0> do agente abaixo, ou registre agentes automaticamente com um <1>token universal</1>."
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "Copiar YAML"
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "CPU"
@@ -266,6 +281,10 @@ msgstr "Período de tempo padrão"
msgid "Delete"
msgstr "Excluir"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr "Excluir impressão digital"
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "Disco"
@@ -329,6 +348,7 @@ msgstr "Digite o endereço de email para redefinir a senha"
msgid "Enter email address..."
msgstr "Digite o endereço de email..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "A cada reinício, os sistemas no banco de dados serão atualizados para corresponder aos sistemas definidos no arquivo."
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "Abrir menu"
@@ -635,6 +657,10 @@ msgstr "Redefinir Senha"
msgid "Resume"
msgstr "Retomar"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr "Rotacionar token"
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Salve o endereço usando a tecla enter ou vírgula. Deixe em branco para desativar notificações por email."
@@ -669,7 +695,6 @@ msgstr "Enviado"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Define o intervalo de tempo padrão para gráficos quando um sistema é visualizado."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "Testar <0>URL</0>"
msgid "Test notification sent"
msgstr "Notificação de teste enviada"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "O agente deve estar em execução no sistema para conectar. Copie o comando de instalação para o agente abaixo."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "O agente deve estar em execução no sistema para conectar. Copie o <0>docker-compose.yml</0> para o agente abaixo."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Em seguida, faça login no backend e redefina a senha da sua conta de usuário na tabela de usuários."
@@ -783,6 +800,24 @@ msgstr "Alternar grade"
msgid "Toggle theme"
msgstr "Alternar tema"
#: src/components/add-system.tsx
msgid "Token"
msgstr "Token"
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr "Tokens e Impressões Digitais"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr "Os tokens permitem que os agentes se conectem e registrem. As impressões digitais são identificadores estáveis únicos para cada sistema, definidos na primeira conexão."
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "Tokens e impressões digitais são usados para autenticar conexões WebSocket ao hub."
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Dispara quando qualquer sensor excede um limite"
@@ -807,6 +842,10 @@ msgstr "Dispara quando o status alterna entre ativo e inativo"
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Dispara quando o uso de qualquer disco excede um limite"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr "Token universal"
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "Quer nos ajudar a melhorar ainda mais nossas traduções? Confira <0>Cro
msgid "Webhook / Push notifications"
msgstr "Notificações Webhook / Push"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr "Quando habilitado, este token permite que os agentes se registrem automaticamente sem criação prévia do sistema. Expira após uma hora ou na reinicialização do hub."
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30 дней"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "Действия"
@@ -76,9 +77,6 @@ msgstr "Добавить URL"
msgid "Adjust display options for charts."
msgstr "Настроить параметры отображения для графиков."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "Проверьте журналы для получения более
msgid "Check your notification service"
msgstr "Проверьте ваш сервис уведомлений"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "Нажмите, чтобы скопировать"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "Скопировать docker run"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "Копировать хост"
@@ -234,6 +237,18 @@ msgstr "Копировать команду Linux"
msgid "Copy text"
msgstr "Копировать текст"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr ""
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "CPU"
@@ -266,6 +281,10 @@ msgstr "Период по умолчанию"
msgid "Delete"
msgstr "Удалить"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "Диск"
@@ -329,6 +348,7 @@ msgstr "Введите адрес электронной почты для сб
msgid "Enter email address..."
msgstr "Введите адрес электронной почты..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "При каждом перезапуске системы в базе данных будут обновлены в соответствии с системами, определенными в файле."
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "Открыть меню"
@@ -635,6 +657,10 @@ msgstr "Сбросить пароль"
msgid "Resume"
msgstr "Возобновить"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr ""
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Сохраните адрес, используя клавишу ввода или запятую. Оставьте пустым, чтобы отключить уведомления по электронной почте."
@@ -669,7 +695,6 @@ msgstr "Отправлено"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Устанавливает диапазон времени по умолчанию для графиков при просмотре системы."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "Тест <0>URL</0>"
msgid "Test notification sent"
msgstr "Тестовое уведомление отправлено"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "Агент должен работать на системе для подключения. Скопируйте команду установки агента ниже."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "Агент должен работать на системе для подключения. Скопируйте <0>docker-compose.yml</0> для агента ниже."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Затем войдите в бэкенд и сбросьте пароль вашей учетной записи в таблице пользователей."
@@ -783,6 +800,24 @@ msgstr "Переключить сетку"
msgid "Toggle theme"
msgstr "Переключить тему"
#: src/components/add-system.tsx
msgid "Token"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Срабатывает, когда любой датчик превышает порог"
@@ -807,6 +842,10 @@ msgstr "Срабатывает, когда статус переключаетс
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Срабатывает, когда использование любого диска превышает порог"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr ""
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "Хотите помочь нам улучшить наши перево
msgid "Webhook / Push notifications"
msgstr "Webhook / Push уведомления"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr ""
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30 dni"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "Dejanja"
@@ -76,9 +77,6 @@ msgstr "Dodaj URL"
msgid "Adjust display options for charts."
msgstr "Prilagodi možnosti prikaza za grafikone."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "Za več podrobnosti preverite dnevnike."
msgid "Check your notification service"
msgstr "Preverite storitev obveščanja"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "Klikni za kopiranje"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "Kopiraj docker run"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "Kopiraj gostitelja"
@@ -234,6 +237,18 @@ msgstr "Kopiraj Linux ukaz"
msgid "Copy text"
msgstr "Kopiraj besedilo"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr ""
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "CPU"
@@ -266,6 +281,10 @@ msgstr "Privzeto časovno obdobje"
msgid "Delete"
msgstr "Izbriši"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "Disk"
@@ -329,6 +348,7 @@ msgstr "Vnesite e-poštni naslov za ponastavitev gesla"
msgid "Enter email address..."
msgstr "Vnesite e-poštni naslov..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "Ob vsakem ponovnem zagonu bodo sistemi v zbirki podatkov posodobljeni, da se bodo ujemali s sistemi, definiranimi v datoteki."
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "Odpri menu"
@@ -635,6 +657,10 @@ msgstr "Ponastavi geslo"
msgid "Resume"
msgstr "Nadaljuj"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr ""
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Shranite naslov s tipko enter ali vejico. Pustite prazno, da onemogočite e-poštna obvestila."
@@ -669,7 +695,6 @@ msgstr "Poslano"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Nastavi privzeti časovni obseg za grafikone, ko si ogledujete sistem."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "Preveri <0>URL</0>"
msgid "Test notification sent"
msgstr "Testno obvestilo je poslano"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "Za vzpostavitev povezave mora biti agent zagnan v sistemu. Kopirajte spodnji namestitveni ukaz za agenta."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "Za vzpostavitev povezave mora biti agent zagnan v sistemu. Kopirajte <0>docker-compose.yml</0> za spodnjega agenta."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Nato se prijavite v zaledni sistem in ponastavite geslo svojega uporabniškega računa v tabeli uporabnikov."
@@ -783,6 +800,24 @@ msgstr "Preklopi način mreže"
msgid "Toggle theme"
msgstr "Obrni temo"
#: src/components/add-system.tsx
msgid "Token"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Sproži se, ko kateri koli senzor preseže prag"
@@ -807,6 +842,10 @@ msgstr "Sproži se, ko se stanje preklaplja med gor in dol"
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Sproži se, ko uporaba katerega koli diska preseže prag"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr ""
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "Ali nam želite pomagati, da bomo naše prevode še izboljšali? Za več
msgid "Webhook / Push notifications"
msgstr "Webhook / potisna obvestila"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr ""
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30 dagar"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "Åtgärder"
@@ -76,9 +77,6 @@ msgstr "Lägg till URL"
msgid "Adjust display options for charts."
msgstr "Justera visningsalternativ för diagram."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "Kontrollera loggarna för mer information."
msgid "Check your notification service"
msgstr "Kontrollera din aviseringstjänst"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "Klicka för att kopiera"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "Kopiera docker run"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "Kopiera värd"
@@ -234,6 +237,18 @@ msgstr "Kopiera Linux-kommando"
msgid "Copy text"
msgstr "Kopiera text"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr ""
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "CPU"
@@ -266,6 +281,10 @@ msgstr "Standardtidsperiod"
msgid "Delete"
msgstr "Ta bort"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "Disk"
@@ -329,6 +348,7 @@ msgstr "Ange e-postadress för att återställa lösenord"
msgid "Enter email address..."
msgstr "Ange e-postadress..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "Vid varje omstart kommer systemen i databasen att uppdateras för att matcha systemen som definieras i filen."
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "Öppna menyn"
@@ -635,6 +657,10 @@ msgstr "Återställ lösenord"
msgid "Resume"
msgstr "Återuppta"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr ""
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Spara adressen med Enter-tangenten eller komma. Lämna tomt för att inaktivera e-postaviseringar."
@@ -669,7 +695,6 @@ msgstr "Skickat"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Anger standardtidsintervallet för diagram när ett system visas."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "Testa <0>URL</0>"
msgid "Test notification sent"
msgstr "Testavisering skickad"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "Agenten måste köras på systemet för att ansluta. Kopiera installationskommandot för agenten nedan."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "Agenten måste köras på systemet för att ansluta. Kopiera <0>docker-compose.yml</0> för agenten nedan."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Logga sedan in på backend och återställ ditt användarkontos lösenord i användartabellen."
@@ -783,6 +800,24 @@ msgstr "Växla rutnät"
msgid "Toggle theme"
msgstr "Växla tema"
#: src/components/add-system.tsx
msgid "Token"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Utlöses när någon sensor överskrider ett tröskelvärde"
@@ -807,6 +842,10 @@ msgstr "Utlöses när status växlar mellan upp och ner"
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Utlöses när användningen av någon disk överskrider ett tröskelvärde"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr ""
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "Vill du hjälpa oss att göra våra översättningar ännu bättre? Koll
msgid "Webhook / Push notifications"
msgstr "Webhook / Push-aviseringar"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr ""
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30 gün"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "Eylemler"
@@ -76,9 +77,6 @@ msgstr "URL Ekle"
msgid "Adjust display options for charts."
msgstr "Grafikler için görüntüleme seçeneklerini ayarlayın."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "Daha fazla ayrıntı için günlükleri kontrol edin."
msgid "Check your notification service"
msgstr "Bildirim hizmetinizi kontrol edin"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "Kopyalamak için tıklayın"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "Docker run kopyala"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr "Ortam değişkenlerini kopyala"
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "Ana bilgisayarı kopyala"
@@ -234,6 +237,18 @@ msgstr "Linux komutunu kopyala"
msgid "Copy text"
msgstr "Metni kopyala"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr "Aşağıdaki agent için kurulum komutunu kopyalayın veya <0>evrensel token</0> ile agentları otomatik olarak kaydedin."
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "Aşağıdaki agent için <0>docker-compose.yml</0> içeriğini kopyalayın veya <1>evrensel token</1> ile agentları otomatik olarak kaydedin."
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "YAML'ı kopyala"
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "CPU"
@@ -266,6 +281,10 @@ msgstr "Varsayılan zaman dilimi"
msgid "Delete"
msgstr "Sil"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr "Parmak izini sil"
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "Disk"
@@ -329,6 +348,7 @@ msgstr "Şifreyi sıfırlamak için e-posta adresini girin"
msgid "Enter email address..."
msgstr "E-posta adresini girin..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "Her yeniden başlatmada, veritabanındaki sistemler dosyada tanımlanan sistemlerle eşleşecek şekilde güncellenecektir."
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "Menüyü aç"
@@ -635,6 +657,10 @@ msgstr "Şifreyi Sıfırla"
msgid "Resume"
msgstr "Devam et"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr "Token'ı döndür"
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Adresleri enter tuşu veya virgül ile kaydedin. E-posta bildirimlerini devre dışı bırakmak için boş bırakın."
@@ -669,7 +695,6 @@ msgstr "Gönderildi"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Bir sistem görüntülendiğinde grafikler için varsayılan zaman aralığını ayarlar."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "Test <0>URL</0>"
msgid "Test notification sent"
msgstr "Test bildirimi gönderildi"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "Bağlanmak için aracının sistemde çalışıyor olması gerekir. Aşağıdaki aracı kurulum komutunu kopyalayın."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "Bağlanmak için aracının sistemde çalışıyor olması gerekir. Aşağıdaki <0>docker-compose.yml</0> dosyasını kopyalayın."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Ardından arka uca giriş yapın ve kullanıcılar tablosunda kullanıcı hesabı şifrenizi sıfırlayın."
@@ -783,6 +800,24 @@ msgstr "Izgarayı değiştir"
msgid "Toggle theme"
msgstr "Temayı değiştir"
#: src/components/add-system.tsx
msgid "Token"
msgstr "Token"
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr "Token'lar ve Parmak İzleri"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr "Token'lar agentların bağlanıp kaydolmasına izin verir. Parmak izleri her sisteme özgü kararlı tanımlayıcılardır ve ilk bağlantıda ayarlanır."
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "Token'lar ve parmak izleri hub'a WebSocket bağlantılarını doğrulamak için kullanılır."
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Herhangi bir sensör bir eşiği aştığında tetiklenir"
@@ -807,6 +842,10 @@ msgstr "Durum yukarı ve aşağı arasında değiştiğinde tetiklenir"
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Herhangi bir diskin kullanımı bir eşiği aştığında tetiklenir"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr "Evrensel token"
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "Çevirilerimizi daha iyi hale getirmemize yardımcı olmak ister misiniz
msgid "Webhook / Push notifications"
msgstr "Webhook / Anlık bildirimler"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr "Etkinleştirildiğinde, bu token agentların önceden sistem oluşturmadan kendilerini kaydetmelerine izin verir. Bir saat sonra veya hub yeniden başlatıldığında sona erer."
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30 днів"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "Дії"
@@ -76,9 +77,6 @@ msgstr "Додати URL"
msgid "Adjust display options for charts."
msgstr "Налаштуйте параметри відображення для графіків."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "Перевірте журнали для отримання додатк
msgid "Check your notification service"
msgstr "Перевірте свій сервіс сповіщень"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "Натисніть, щоб скопіювати"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "Копіювати docker run"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr "Копіювати змінні середовища"
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "Копіювати хост"
@@ -234,6 +237,18 @@ msgstr "Копіювати команду Linux"
msgid "Copy text"
msgstr "Копіювати текст"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr "Скопіюйте команду встановлення для агента нижче, або зареєструйте агентів автоматично за допомогою <0>універсального токена</0>."
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "Скопіюйте вміст <0>docker-compose.yml</0> для агента нижче, або зареєструйте агентів автоматично за допомогою <1>універсального токена</1>."
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "Копіювати YAML"
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "ЦП"
@@ -266,6 +281,10 @@ msgstr "Стандартний період часу"
msgid "Delete"
msgstr "Видалити"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr "Видалити відбиток"
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "Диск"
@@ -329,6 +348,7 @@ msgstr "Введіть адресу електронної пошти для с
msgid "Enter email address..."
msgstr "Введіть адресу електронної пошти..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "При кожному перезапуску системи в базі даних будуть оновлені, щоб відповідати системам, визначеним у файлі."
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "Відкрити меню"
@@ -635,6 +657,10 @@ msgstr "Скинути пароль"
msgid "Resume"
msgstr "Відновити"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr "Оновити токен"
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Збережіть адресу, використовуючи клавішу Enter або кому. Залиште порожнім, щоб вимкнути сповіщення електронною поштою."
@@ -669,7 +695,6 @@ msgstr "Відправлено"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Встановлює стандартний діапазон часу для графіків при перегляді системи."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "Тест <0>URL</0>"
msgid "Test notification sent"
msgstr "Тестове сповіщення надіслано"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "Агент повинен працювати на системі для підключення. Скопіюйте команду встановлення для агента нижче."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "Агент повинен працювати на системі для підключення. Скопіюйте <0>docker-compose.yml</0> для агента нижче."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Потім увійдіть у бекенд і скиньте пароль вашого облікового запису користувача в таблиці користувачів."
@@ -783,6 +800,24 @@ msgstr "Перемкнути сітку"
msgid "Toggle theme"
msgstr "Перемкнути тему"
#: src/components/add-system.tsx
msgid "Token"
msgstr "Токен"
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr "Токени та Відбитки"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr "Токени дозволяють агентам підключатися та реєструватися. Відбитки - це стабільні ідентифікатори, унікальні для кожної системи, встановлюються при першому підключенні."
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "Токени та відбитки використовуються для автентифікації WebSocket з'єднань до хабу."
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Спрацьовує, коли будь-який датчик перевищує поріг"
@@ -807,6 +842,10 @@ msgstr "Спрацьовує, коли статус перемикається
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Спрацьовує, коли використання будь-якого диска перевищує поріг"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr "Універсальний токен"
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "Хочете допомогти нам зробити наші пере
msgid "Webhook / Push notifications"
msgstr "Webhook / Push сповіщення"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr "Коли увімкнено, цей токен дозволяє агентам самостійно реєструватися без попереднього створення системи. Термін дії закінчується через годину або при перезапуску хабу."
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30 ngày"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "Hành động"
@@ -76,9 +77,6 @@ msgstr "Thêm URL"
msgid "Adjust display options for charts."
msgstr "Điều chỉnh tùy chọn hiển thị cho biểu đồ."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "Kiểm tra nhật ký để biết thêm chi tiết."
msgid "Check your notification service"
msgstr "Kiểm tra dịch vụ thông báo của bạn"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "Nhấp để sao chép"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "Sao chép docker run"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "Sao chép máy chủ"
@@ -234,6 +237,18 @@ msgstr "Sao chép lệnh Linux"
msgid "Copy text"
msgstr "Sao chép văn bản"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr ""
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "CPU"
@@ -266,6 +281,10 @@ msgstr "Thời gian mặc định"
msgid "Delete"
msgstr "Xóa"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "Đĩa"
@@ -329,6 +348,7 @@ msgstr "Nhập địa chỉ email để đặt lại mật khẩu"
msgid "Enter email address..."
msgstr "Nhập địa chỉ email..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "Mỗi khi khởi động lại, các hệ thống trong cơ sở dữ liệu sẽ được cập nhật để khớp với các hệ thống được định nghĩa trong tệp."
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "Mở menu"
@@ -635,6 +657,10 @@ msgstr "Đặt lại Mật khẩu"
msgid "Resume"
msgstr "Tiếp tục"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr ""
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Lưu địa chỉ bằng cách sử dụng phím enter hoặc dấu phẩy. Để trống để vô hiệu hóa thông báo email."
@@ -669,7 +695,6 @@ msgstr "Đã gửi"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Đặt phạm vi thời gian mặc định cho biểu đồ khi một hệ thống được xem."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "Kiểm tra <0>URL</0>"
msgid "Test notification sent"
msgstr "Thông báo thử nghiệm đã được gửi"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "Tác nhân phải đang chạy trên hệ thống để kết nối. Sao chép lệnh cài đặt cho tác nhân bên dưới."
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "Tác nhân phải đang chạy trên hệ thống để kết nối. Sao chép <0>docker-compose.yml</0> cho tác nhân bên dưới."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Sau đó đăng nhập vào backend và đặt lại mật khẩu tài khoản người dùng của bạn trong bảng người dùng."
@@ -783,6 +800,24 @@ msgstr "Chuyển đổi lưới"
msgid "Toggle theme"
msgstr "Chuyển đổi chủ đề"
#: src/components/add-system.tsx
msgid "Token"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr ""
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Kích hoạt khi bất kỳ cảm biến nào vượt quá ngưỡng"
@@ -807,6 +842,10 @@ msgstr "Kích hoạt khi trạng thái chuyển đổi giữa lên và xuống"
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Kích hoạt khi sử dụng bất kỳ đĩa nào vượt quá ngưỡng"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr ""
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "Muốn giúp chúng tôi cải thiện bản dịch của mình? Xem <0>
msgid "Webhook / Push notifications"
msgstr "Thông báo Webhook / Push"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr ""
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30天"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "操作"
@@ -76,9 +77,6 @@ msgstr "添加URL"
msgid "Adjust display options for charts."
msgstr "调整图表的显示选项。"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "检查日志以获取更多详细信息。"
msgid "Check your notification service"
msgstr "检查您的通知服务"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "点击复制"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "复制 docker run 命令"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr "复制环境变量"
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "复制主机名"
@@ -234,6 +237,18 @@ msgstr "复制 Linux 安装命令"
msgid "Copy text"
msgstr "复制文本"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr "复制下面的客户端安装命令,或使用<0>通用令牌</0>自动注册客户端。"
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "复制下面的客户端<0>docker-compose.yml</0>内容,或使用<1>通用令牌</1>自动注册客户端。"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "复制YAML"
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "CPU"
@@ -266,6 +281,10 @@ msgstr "默认时间段"
msgid "Delete"
msgstr "删除"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr "删除指纹"
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "磁盘"
@@ -329,6 +348,7 @@ msgstr "输入电子邮件地址以重置密码"
msgid "Enter email address..."
msgstr "输入电子邮件地址..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "每次重启时,数据库中的系统将更新以匹配文件中定义的系统。"
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "打开菜单"
@@ -635,6 +657,10 @@ msgstr "重置密码"
msgid "Resume"
msgstr "恢复"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr "轮换令牌"
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "使用回车键或逗号保存地址。留空以禁用电子邮件通知。"
@@ -669,7 +695,6 @@ msgstr "发送"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "设置查看系统时图表的默认时间范围。"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "测试<0>URL</0>"
msgid "Test notification sent"
msgstr "测试通知已发送"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "必须在系统上运行客户端之后才能连接。复制下面的客户端安装命令。"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "必须在系统上运行客户端之后才能连接。复制下面的<0>docker-compose.yml</0>。"
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "然后登录到后台并在用户表中重置您的用户账户密码。"
@@ -783,6 +800,24 @@ msgstr "切换网格"
msgid "Toggle theme"
msgstr "切换主题"
#: src/components/add-system.tsx
msgid "Token"
msgstr "令牌"
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr "令牌和指纹"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr "令牌允许客户端连接和注册。指纹是每个系统唯一的稳定标识符,在首次连接时设置。"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "令牌和指纹用于验证到中心的WebSocket连接。"
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "当任何传感器超过阈值时触发"
@@ -807,6 +842,10 @@ msgstr "当状态在上线与掉线之间切换时触发"
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "当任何磁盘的使用率超过阈值时触发"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr "通用令牌"
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "想帮助我们改进翻译吗?查看<0>Crowdin</0>以获取更多详
msgid "Webhook / Push notifications"
msgstr "Webhook / 推送通知"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr "启用时,此令牌允许客户端在无需预先创建系统的情况下自动注册。在一小时后或中心重启时过期。"
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30天"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "操作"
@@ -76,9 +77,6 @@ msgstr "添加 URL"
msgid "Adjust display options for charts."
msgstr "調整圖表的顯示選項。"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "檢查日誌以取得更多資訊。"
msgid "Check your notification service"
msgstr "檢查您的通知服務"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "點擊以複製"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "複製 docker run"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr "複製環境變數"
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "複製主機"
@@ -234,6 +237,18 @@ msgstr "複製 Linux 指令"
msgid "Copy text"
msgstr "複製文本"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr "複製下面的代理程式安裝指令,或使用<0>通用令牌</0>自動註冊代理程式。"
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "複製下面的代理程式<0>docker-compose.yml</0>內容,或使用<1>通用令牌</1>自動註冊代理程式。"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "複製YAML"
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "CPU"
@@ -266,6 +281,10 @@ msgstr "預設時間段"
msgid "Delete"
msgstr "刪除"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr "刪除指紋"
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "磁碟"
@@ -329,6 +348,7 @@ msgstr "輸入電子郵件地址以重置密碼"
msgid "Enter email address..."
msgstr "輸入電子郵件地址..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "每次重新啟動時,將會以檔案中的系統定義更新資料庫。"
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "開啟選單"
@@ -635,6 +657,10 @@ msgstr "重設密碼"
msgid "Resume"
msgstr "恢復"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr "輪換令牌"
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "使用回車鍵或逗號保存地址。留空以禁用電子郵件通知。"
@@ -669,7 +695,6 @@ msgstr "發送"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "設置查看系統時圖表的默認時間範圍。"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "測試<0>URL</0>"
msgid "Test notification sent"
msgstr "測試通知已發送"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "代理必須在系統上運行才能連接。複製下面的代理安裝命令。"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "代理必須在系統上運行才能連接。複製下面的<0>docker-compose.yml</0>。"
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "然後登錄到後端並在用戶表中重置您的用戶帳戶密碼。"
@@ -783,6 +800,24 @@ msgstr "切換網格"
msgid "Toggle theme"
msgstr "切換主題"
#: src/components/add-system.tsx
msgid "Token"
msgstr "令牌"
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr "令牌和指紋"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr "令牌允許代理程式連接和註冊。指紋是每個系統唯一的穩定識別符,在首次連接時設置。"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "令牌和指紋用於驗證到中心的WebSocket連接。"
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "當任何傳感器超過閾值時觸發"
@@ -807,6 +842,10 @@ msgstr "當狀態在上和下之間切換時觸發"
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "當任何磁碟的使用超過閾值時觸發"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr "通用令牌"
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "想幫助我們改進翻譯嗎?查看<0>Crowdin</0>以獲取更多詳
msgid "Webhook / Push notifications"
msgstr "Webhook / 推送通知"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr "啟用時,此令牌允許代理程式在無需預先創建系統的情況下自動註冊。在一小時後或中心重啟時過期。"
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -49,6 +49,7 @@ msgstr "30天"
#. Table column
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Actions"
msgstr "操作"
@@ -76,9 +77,6 @@ msgstr "新增 URL"
msgid "Adjust display options for charts."
msgstr "調整圖表的顯示選項。"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
@@ -186,7 +184,7 @@ msgstr "檢查系統記錄以取得更多資訊。"
msgid "Check your notification service"
msgstr "檢查您的通知服務"
#: src/components/add-system.tsx
#: src/components/ui/input-copy.tsx
msgid "Click to copy"
msgstr "點擊複製"
@@ -222,6 +220,11 @@ msgctxt "Button to copy docker run command"
msgid "Copy docker run"
msgstr "复制 docker run"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Environment variables"
msgid "Copy env"
msgstr "複製環境變數"
#: src/components/systems-table/systems-table.tsx
msgid "Copy host"
msgstr "複製主機"
@@ -234,6 +237,18 @@ msgstr "複製 Linux 指令"
msgid "Copy text"
msgstr "複製文字"
#: src/components/add-system.tsx
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
msgstr "複製下面的代理程式安裝指令,或使用<0>通用令牌</0>自動註冊代理程式。"
#: src/components/add-system.tsx
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "複製下面的代理程式<0>docker-compose.yml</0>內容,或使用<1>通用令牌</1>自動註冊代理程式。"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "複製YAML"
#: src/components/systems-table/systems-table.tsx
msgid "CPU"
msgstr "CPU"
@@ -266,6 +281,10 @@ msgstr "預設時間段"
msgid "Delete"
msgstr "刪除"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Delete fingerprint"
msgstr "刪除指紋"
#: src/components/systems-table/systems-table.tsx
msgid "Disk"
msgstr "磁碟"
@@ -329,6 +348,7 @@ msgstr "輸入電子郵件地址以重設密碼"
msgid "Enter email address..."
msgstr "輸入電子郵件地址..."
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/login/auth-form.tsx
@@ -521,6 +541,8 @@ msgid "On each restart, systems in the database will be updated to match the sys
msgstr "每次重新啟動時,將會以檔案中的系統定義更新資料庫。"
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Open menu"
msgstr "開啟選單"
@@ -635,6 +657,10 @@ msgstr "重設密碼"
msgid "Resume"
msgstr "繼續"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
msgstr "輪換令牌"
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "使用 Enter 鍵或逗號儲存地址。留空以停用電子郵件通知。"
@@ -669,7 +695,6 @@ msgstr "傳送"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "設定顯示系統圖表的預設時間範圍。"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -746,14 +771,6 @@ msgstr "測試<0>URL</0>"
msgid "Test notification sent"
msgstr "已發送測試通知"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "必須在系統上執行代理程式才能連線,複製以下代理程式的安裝指令。"
#: src/components/add-system.tsx
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "必須在系統上執行代理程式才能連線,複製以下代理程式的<0>docker-compose.yml</0>。"
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "然後登入後台並在使用者列表中重設您的帳號密碼。"
@@ -783,6 +800,24 @@ msgstr "切換網格"
msgid "Toggle theme"
msgstr "切換主題"
#: src/components/add-system.tsx
msgid "Token"
msgstr "令牌"
#: src/components/command-palette.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/layout.tsx
msgid "Tokens & Fingerprints"
msgstr "令牌和指紋"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
msgstr "令牌允許代理程式連接和註冊。指紋是每個系統唯一的穩定識別符,在首次連接時設置。"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "令牌和指紋用於驗證到中心的WebSocket連接。"
#: src/lib/utils.ts
msgid "Triggers when any sensor exceeds a threshold"
msgstr "當任何感應器超過閾值時觸發"
@@ -807,6 +842,10 @@ msgstr "當連線和離線時觸發"
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "當任何磁碟使用率超過閾值時觸發"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Universal token"
msgstr "通用令牌"
#. Context: System is up
#: src/components/systems-table/systems-table.tsx
#: src/components/routes/system.tsx
@@ -862,6 +901,10 @@ msgstr "想幫助我們改善翻譯嗎?查看<0>Crowdin</0>以取得更多詳
msgid "Webhook / Push notifications"
msgstr "Webhook / 推送通知"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr "啟用時,此令牌允許代理程式在無需預先創建系統的情況下自動註冊。在一小時後或中心重啟時過期。"
#: src/components/add-system.tsx
msgctxt "Button to copy install command"
msgid "Windows command"

View File

@@ -6,6 +6,19 @@ declare global {
var BESZEL: {
BASE_PATH: string
HUB_VERSION: string
HUB_URL: string
}
}
export interface FingerprintRecord extends RecordModel {
id: string
system: string
fingerprint: string
token: string
expand: {
system: {
name: string
}
}
}

View File

@@ -2,10 +2,10 @@
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"target": "ES2021",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"baseUrl": ".",
"paths": {

View File

@@ -15,7 +15,7 @@ export default defineConfig({
name: "replace version in index.html during dev",
apply: "serve",
transformIndexHtml(html) {
return html.replace("{{V}}", version)
return html.replace("{{V}}", version).replace("{{HUB_URL}}", "")
},
},
],

View File

@@ -1,6 +1,10 @@
package beszel
import "github.com/blang/semver"
const (
Version = "0.11.1"
Version = "0.12.0-beta1"
AppName = "beszel"
)
var MinVersionCbor = semver.MustParse("0.12.0-beta1")

View File

@@ -0,0 +1,605 @@
param (
[switch]$Elevated,
[Parameter(Mandatory=$true)]
[string]$Key,
[Parameter(Mandatory=$true)]
[string]$Token,
[Parameter(Mandatory=$true)]
[string]$Url,
[int]$Port = 45876,
[string]$AgentPath = "",
[string]$NSSMPath = ""
)
# Check if required parameters are provided
if ([string]::IsNullOrWhiteSpace($Key)) {
Write-Host "ERROR: SSH Key is required." -ForegroundColor Red
Write-Host "Usage: .\install-agent.ps1 -Key 'your-ssh-key-here' -Token 'your-token-here' -Url 'your-hub-url-here' [-Port port-number]" -ForegroundColor Yellow
exit 1
}
if ([string]::IsNullOrWhiteSpace($Token)) {
Write-Host "ERROR: Token is required." -ForegroundColor Red
Write-Host "Usage: .\install-agent.ps1 -Key 'your-ssh-key-here' -Token 'your-token-here' -Url 'your-hub-url-here' [-Port port-number]" -ForegroundColor Yellow
exit 1
}
if ([string]::IsNullOrWhiteSpace($Url)) {
Write-Host "ERROR: Hub URL is required." -ForegroundColor Red
Write-Host "Usage: .\install-agent.ps1 -Key 'your-ssh-key-here' -Token 'your-token-here' -Url 'your-hub-url-here' [-Port port-number]" -ForegroundColor Yellow
exit 1
}
# Stop on first error
$ErrorActionPreference = "Stop"
#region Utility Functions
# Function to check if running as admin
function Test-Admin {
return ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
# Function to check if a command exists
function Test-CommandExists {
param (
[Parameter(Mandatory=$true)]
[string]$Command
)
return (Get-Command $Command -ErrorAction SilentlyContinue)
}
# Function to find beszel-agent in common installation locations
function Find-BeszelAgent {
# First check if it's in PATH
$agentCmd = Get-Command "beszel-agent" -ErrorAction SilentlyContinue
if ($agentCmd) {
return $agentCmd.Source
}
# Common installation paths to check
$commonPaths = @(
"$env:USERPROFILE\scoop\apps\beszel-agent\current\beszel-agent.exe",
"$env:ProgramData\scoop\apps\beszel-agent\current\beszel-agent.exe",
"$env:LOCALAPPDATA\Microsoft\WinGet\Packages\henrygd.beszel-agent*\beszel-agent.exe",
"$env:ProgramFiles\WinGet\Packages\henrygd.beszel-agent*\beszel-agent.exe",
"${env:ProgramFiles(x86)}\WinGet\Packages\henrygd.beszel-agent*\beszel-agent.exe",
"$env:ProgramFiles\beszel-agent\beszel-agent.exe",
"$env:ProgramFiles(x86)\beszel-agent\beszel-agent.exe",
"$env:SystemDrive\Users\*\scoop\apps\beszel-agent\current\beszel-agent.exe"
)
foreach ($path in $commonPaths) {
# Handle wildcard paths
if ($path.Contains("*")) {
$foundPaths = Get-ChildItem -Path $path -ErrorAction SilentlyContinue
if ($foundPaths) {
return $foundPaths[0].FullName
}
} else {
if (Test-Path $path) {
return $path
}
}
}
return $null
}
# Function to find NSSM in common installation locations
function Find-NSSM {
# First check if it's in PATH
$nssmCmd = Get-Command "nssm" -ErrorAction SilentlyContinue
if ($nssmCmd) {
return $nssmCmd.Source
}
# Common installation paths to check
$commonPaths = @(
"$env:USERPROFILE\scoop\apps\nssm\current\nssm.exe",
"$env:ProgramData\scoop\apps\nssm\current\nssm.exe",
"$env:LOCALAPPDATA\Microsoft\WinGet\Packages\NSSM.NSSM*\nssm.exe",
"$env:ProgramFiles\WinGet\Packages\NSSM.NSSM*\nssm.exe",
"${env:ProgramFiles(x86)}\WinGet\Packages\NSSM.NSSM*\nssm.exe",
"$env:SystemDrive\Users\*\scoop\apps\nssm\current\nssm.exe"
)
foreach ($path in $commonPaths) {
# Handle wildcard paths
if ($path.Contains("*")) {
$foundPaths = Get-ChildItem -Path $path -ErrorAction SilentlyContinue
if ($foundPaths) {
return $foundPaths[0].FullName
}
} else {
if (Test-Path $path) {
return $path
}
}
}
return $null
}
#endregion
#region Installation Methods
# Function to install Scoop
function Install-Scoop {
Write-Host "Installing Scoop..."
# Check if running as admin - Scoop should not be installed as admin
if (Test-Admin) {
throw "Scoop cannot be installed with administrator privileges. Please run this script as a regular user first to install Scoop and beszel-agent, then run as admin to configure the service."
}
try {
Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression
if (-not (Test-CommandExists "scoop")) {
throw "Failed to install Scoop - command not available after installation"
}
Write-Host "Scoop installed successfully."
}
catch {
throw "Failed to install Scoop: $($_.Exception.Message)"
}
}
# Function to install Git via Scoop
function Install-Git {
if (Test-CommandExists "git") {
Write-Host "Git is already installed."
return
}
Write-Host "Installing Git..."
scoop install git
if (-not (Test-CommandExists "git")) {
throw "Failed to install Git"
}
}
# Function to install NSSM
function Install-NSSM {
param (
[string]$Method = "Scoop" # Default to Scoop method
)
if (Test-CommandExists "nssm") {
Write-Host "NSSM is already installed."
return
}
Write-Host "Installing NSSM..."
if ($Method -eq "Scoop") {
scoop install nssm
}
elseif ($Method -eq "WinGet") {
winget install -e --id NSSM.NSSM --accept-source-agreements --accept-package-agreements
# Refresh PATH environment variable to make NSSM available in current session
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
}
else {
throw "Unsupported installation method: $Method"
}
if (-not (Test-CommandExists "nssm")) {
throw "Failed to install NSSM"
}
}
# Function to install beszel-agent with Scoop
function Install-BeszelAgentWithScoop {
Write-Host "Adding beszel bucket..."
scoop bucket add beszel https://github.com/henrygd/beszel-scoops | Out-Null
Write-Host "Installing / updating beszel-agent..."
scoop install beszel-agent
if (-not (Test-CommandExists "beszel-agent")) {
throw "Failed to install beszel-agent"
}
return $(Join-Path -Path $(scoop prefix beszel-agent) -ChildPath "beszel-agent.exe")
}
# Function to install beszel-agent with WinGet
function Install-BeszelAgentWithWinGet {
Write-Host "Installing / updating beszel-agent..."
# Temporarily change ErrorActionPreference to allow WinGet to complete and show output
$originalErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"
# Use call operator (&) and capture exit code properly
& winget install --exact --id henrygd.beszel-agent --accept-source-agreements --accept-package-agreements | Out-Null
$wingetExitCode = $LASTEXITCODE
# Restore original ErrorActionPreference
$ErrorActionPreference = $originalErrorActionPreference
# WinGet exit codes:
# 0 = Success
# -1978335212 (0x8A150014) = No applicable upgrade found (package is up to date)
# -1978335189 (0x8A15002B) = Another "no upgrade needed" variant
# Other codes indicate actual errors
if ($wingetExitCode -eq -1978335212 -or $wingetExitCode -eq -1978335189) {
Write-Host "Package is already up to date." -ForegroundColor Green
} elseif ($wingetExitCode -ne 0) {
Write-Host "WinGet exit code: $wingetExitCode" -ForegroundColor Yellow
}
# Refresh PATH environment variable to make beszel-agent available in current session
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
# Find the path to the beszel-agent executable
$agentPath = (Get-Command beszel-agent -ErrorAction SilentlyContinue).Source
if (-not $agentPath) {
throw "Could not find beszel-agent executable path after installation"
}
return $agentPath
}
# Function to install using Scoop
function Install-WithScoop {
param (
[string]$Key,
[int]$Port
)
try {
# Ensure Scoop is installed
if (-not (Test-CommandExists "scoop")) {
Install-Scoop | Out-Null
}
else {
Write-Host "Scoop is already installed."
}
# Install Git (required for Scoop buckets)
Install-Git | Out-Null
# Install NSSM
Install-NSSM -Method "Scoop" | Out-Null
# Install beszel-agent
$agentPath = Install-BeszelAgentWithScoop
return $agentPath
}
catch {
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Installation failed. Please check the error message above." -ForegroundColor Red
Write-Host "Press any key to exit..." -ForegroundColor Red
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
exit 1
}
}
# Function to install using WinGet
function Install-WithWinGet {
param (
[string]$Key,
[int]$Port
)
try {
# Install NSSM
Install-NSSM -Method "WinGet" | Out-Null
# Install beszel-agent
$agentPath = Install-BeszelAgentWithWinGet
return $agentPath
}
catch {
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Installation failed. Please check the error message above." -ForegroundColor Red
Write-Host "Press any key to exit..." -ForegroundColor Red
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
exit 1
}
}
#endregion
#region Service Configuration
# Function to install and configure the NSSM service
function Install-NSSMService {
param (
[Parameter(Mandatory=$true)]
[string]$AgentPath,
[Parameter(Mandatory=$true)]
[string]$Key,
[Parameter(Mandatory=$true)]
[string]$Token,
[Parameter(Mandatory=$true)]
[string]$HubUrl,
[Parameter(Mandatory=$true)]
[int]$Port,
[string]$NSSMPath = ""
)
Write-Host "Installing beszel-agent service..."
# Determine the NSSM executable to use
$nssmCommand = "nssm"
if ($NSSMPath -and (Test-Path $NSSMPath)) {
$nssmCommand = $NSSMPath
Write-Host "Using NSSM from: $NSSMPath"
} elseif (-not (Test-CommandExists "nssm")) {
throw "NSSM is not available in PATH and no valid NSSMPath was provided"
}
# Check if service already exists
$existingService = Get-Service -Name "beszel-agent" -ErrorAction SilentlyContinue
if ($existingService) {
Write-Host "Service already exists. Stopping and removing existing service..."
try {
& $nssmCommand stop beszel-agent
& $nssmCommand remove beszel-agent confirm
} catch {
Write-Host "Warning: Failed to remove existing service: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
& $nssmCommand install beszel-agent $AgentPath
if ($LASTEXITCODE -ne 0) {
throw "Failed to install beszel-agent service"
}
Write-Host "Configuring service environment variables..."
& $nssmCommand set beszel-agent AppEnvironmentExtra "+KEY=$Key"
& $nssmCommand set beszel-agent AppEnvironmentExtra "+TOKEN=$Token"
& $nssmCommand set beszel-agent AppEnvironmentExtra "+HUB_URL=$HubUrl"
& $nssmCommand set beszel-agent AppEnvironmentExtra "+PORT=$Port"
# Configure log files
$logDir = "$env:ProgramData\beszel-agent\logs"
if (-not (Test-Path $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
}
$logFile = "$logDir\beszel-agent.log"
& $nssmCommand set beszel-agent AppStdout $logFile
& $nssmCommand set beszel-agent AppStderr $logFile
}
# Function to configure firewall rules
function Configure-Firewall {
param (
[Parameter(Mandatory=$true)]
[int]$Port
)
# Create a firewall rule if it doesn't exist
$ruleName = "Allow beszel-agent"
$existingRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
# Remove existing rule if found
if ($existingRule) {
Write-Host "Removing existing firewall rule..."
try {
Remove-NetFirewallRule -DisplayName $ruleName
Write-Host "Existing firewall rule removed successfully."
} catch {
Write-Host "Warning: Failed to remove existing firewall rule: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
# Create new rule with current settings
Write-Host "Creating firewall rule for beszel-agent on port $Port..."
try {
New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Action Allow -Protocol TCP -LocalPort $Port
Write-Host "Firewall rule created successfully."
} catch {
Write-Host "Warning: Failed to create firewall rule: $($_.Exception.Message)" -ForegroundColor Yellow
Write-Host "You may need to manually create a firewall rule for port $Port." -ForegroundColor Yellow
}
}
# Function to start and monitor the service
function Start-BeszelAgentService {
param (
[string]$NSSMPath = ""
)
Write-Host "Starting beszel-agent service..."
# Determine the NSSM executable to use
$nssmCommand = "nssm"
if ($NSSMPath -and (Test-Path $NSSMPath)) {
$nssmCommand = $NSSMPath
} elseif (-not (Test-CommandExists "nssm")) {
throw "NSSM is not available in PATH and no valid NSSMPath was provided"
}
& $nssmCommand start beszel-agent
$startResult = $LASTEXITCODE
# Only enter the status check loop if the NSSM start command failed
if ($startResult -ne 0) {
Write-Host "NSSM start command returned error code: $startResult" -ForegroundColor Yellow
Write-Host "This could be due to 'SERVICE_START_PENDING' state. Checking service status..."
# Allow up to 10 seconds for the service to start, checking every second
$maxWaitTime = 10 # seconds
$elapsedTime = 0
$serviceStarted = $false
while (-not $serviceStarted -and $elapsedTime -lt $maxWaitTime) {
Start-Sleep -Seconds 1
$elapsedTime += 1
$serviceStatus = & $nssmCommand status beszel-agent
if ($serviceStatus -eq "SERVICE_RUNNING") {
$serviceStarted = $true
Write-Host "Success! The beszel-agent service is now running." -ForegroundColor Green
}
elseif ($serviceStatus -like "*PENDING*") {
Write-Host "Service is still starting (status: $serviceStatus)... waiting" -ForegroundColor Yellow
}
else {
Write-Host "Warning: The service status is '$serviceStatus' instead of 'SERVICE_RUNNING'." -ForegroundColor Yellow
Write-Host "You may need to troubleshoot the service installation." -ForegroundColor Yellow
break
}
}
if (-not $serviceStarted) {
Write-Host "Service did not reach running state." -ForegroundColor Yellow
Write-Host "You can check status manually with 'nssm status beszel-agent'" -ForegroundColor Yellow
}
} else {
# NSSM start command was successful
Write-Host "Success! The beszel-agent service is running properly." -ForegroundColor Green
}
}
#endregion
#region Main Script Execution
# Check if we're running as admin
$isAdmin = Test-Admin
try {
# First: Install the agent (doesn't require admin)
if (-not $AgentPath) {
# Check for problematic case: running as admin and need Scoop
if ($isAdmin -and -not (Test-CommandExists "scoop") -and -not (Test-CommandExists "winget")) {
Write-Host "ERROR: You're running as administrator but neither Scoop nor WinGet is available." -ForegroundColor Red
Write-Host "Scoop should be installed without admin privileges." -ForegroundColor Red
Write-Host ""
Write-Host "Please either:" -ForegroundColor Yellow
Write-Host "1. Run this script again without administrator privileges" -ForegroundColor Yellow
Write-Host "2. Install WinGet and run this script again" -ForegroundColor Yellow
exit 1
}
if (Test-CommandExists "scoop") {
Write-Host "Using Scoop for installation..."
$AgentPath = Install-WithScoop -Key $Key -Port $Port
}
elseif (Test-CommandExists "winget") {
Write-Host "Using WinGet for installation..."
$AgentPath = Install-WithWinGet -Key $Key -Port $Port
}
else {
Write-Host "Neither Scoop nor WinGet is installed. Installing Scoop..."
$AgentPath = Install-WithScoop -Key $Key -Port $Port
}
}
if (-not $AgentPath) {
throw "Could not find beszel-agent executable. Make sure it was properly installed."
}
# Find NSSM path if not already provided
if (-not $NSSMPath) {
$NSSMPath = Find-NSSM
if (-not $NSSMPath -and (Test-CommandExists "nssm")) {
$NSSMPath = (Get-Command "nssm" -ErrorAction SilentlyContinue).Source
}
# If we still don't have NSSM, try to install it if we have package managers
if (-not $NSSMPath) {
if (Test-CommandExists "winget") {
Write-Host "NSSM not found. Attempting to install via WinGet..."
try {
Install-NSSM -Method "WinGet"
$NSSMPath = Find-NSSM
if (-not $NSSMPath -and (Test-CommandExists "nssm")) {
$NSSMPath = (Get-Command "nssm" -ErrorAction SilentlyContinue).Source
}
} catch {
Write-Host "Failed to install NSSM via WinGet: $($_.Exception.Message)" -ForegroundColor Yellow
}
} elseif (Test-CommandExists "scoop") {
Write-Host "NSSM not found. Attempting to install via Scoop..."
try {
Install-NSSM -Method "Scoop"
$NSSMPath = Find-NSSM
if (-not $NSSMPath -and (Test-CommandExists "nssm")) {
$NSSMPath = (Get-Command "nssm" -ErrorAction SilentlyContinue).Source
}
} catch {
Write-Host "Failed to install NSSM via Scoop: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
# Final check - if we still don't have NSSM and we're admin, we have a problem
if (-not $NSSMPath -and ($isAdmin -or $Elevated)) {
throw "NSSM is required for service installation but was not found and could not be installed. Please install NSSM manually or run as a regular user to install it."
}
}
}
# Second: If we need admin rights for service installation and we don't have them, relaunch
if (-not $isAdmin -and -not $Elevated) {
Write-Host "Admin privileges required for service installation. Relaunching as admin..." -ForegroundColor Yellow
Write-Host "Check service status with 'nssm status beszel-agent'"
Write-Host "Edit service configuration with 'nssm edit beszel-agent'"
# Prepare arguments for the elevated script
$argumentList = @(
"-ExecutionPolicy", "Bypass",
"-File", "`"$PSCommandPath`"",
"-Elevated",
"-Key", "`"$Key`"",
"-Token", "`"$Token`"",
"-Url", "`"$Url`"",
"-Port", $Port,
"-AgentPath", "`"$AgentPath`""
)
# Add NSSMPath if we found it
if ($NSSMPath) {
$argumentList += "-NSSMPath"
$argumentList += "`"$NSSMPath`""
}
# Relaunch the script with the -Elevated switch and pass parameters
Start-Process powershell.exe -Verb RunAs -ArgumentList $argumentList
exit
}
# Third: If we have admin rights, install service and configure firewall
if ($isAdmin -or $Elevated) {
# Install the service
Install-NSSMService -AgentPath $AgentPath -Key $Key -Token $Token -HubUrl $Url -Port $Port -NSSMPath $NSSMPath
# Configure firewall
Configure-Firewall -Port $Port
# Start the service
Start-BeszelAgentService -NSSMPath $NSSMPath
# Pause to see results if this is an elevated window
if ($Elevated) {
Write-Host "Press any key to exit..." -ForegroundColor Cyan
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
}
}
}
catch {
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Installation failed. Please check the error message above." -ForegroundColor Red
# Pause if this is likely a new window
if ($Elevated -or (-not $isAdmin)) {
Write-Host "Press any key to exit..." -ForegroundColor Red
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
}
exit 1
}
#endregion

View File

@@ -0,0 +1,757 @@
#!/bin/sh
is_alpine() {
[ -f /etc/alpine-release ]
}
is_openwrt() {
cat /etc/os-release | grep -q "OpenWrt"
}
# If SELinux is enabled, set the context of the binary
set_selinux_context() {
# Check if SELinux is enabled and in enforcing or permissive mode
if command -v getenforce >/dev/null 2>&1; then
SELINUX_MODE=$(getenforce)
if [ "$SELINUX_MODE" != "Disabled" ]; then
echo "SELinux is enabled (${SELINUX_MODE} mode). Setting appropriate context..."
# First try to set persistent context if semanage is available
if command -v semanage >/dev/null 2>&1; then
echo "Attempting to set persistent SELinux context..."
if semanage fcontext -a -t bin_t "/opt/beszel-agent/beszel-agent" >/dev/null 2>&1; then
restorecon -v /opt/beszel-agent/beszel-agent >/dev/null 2>&1
else
echo "Warning: Failed to set persistent context, falling back to temporary context."
fi
fi
# Fall back to chcon if semanage failed or isn't available
if command -v chcon >/dev/null 2>&1; then
# Set context for both the directory and binary
chcon -t bin_t /opt/beszel-agent/beszel-agent || echo "Warning: Failed to set SELinux context for binary."
chcon -R -t bin_t /opt/beszel-agent || echo "Warning: Failed to set SELinux context for directory."
else
if [ "$SELINUX_MODE" = "Enforcing" ]; then
echo "Warning: SELinux is in enforcing mode but chcon command not found. The service may fail to start."
echo "Consider installing the policycoreutils package or temporarily setting SELinux to permissive mode."
else
echo "Warning: SELinux is in permissive mode but chcon command not found."
fi
fi
fi
fi
}
# Clean up SELinux contexts if they were set
cleanup_selinux_context() {
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
echo "Cleaning up SELinux contexts..."
# Remove persistent context if semanage is available
if command -v semanage >/dev/null 2>&1; then
semanage fcontext -d "/opt/beszel-agent/beszel-agent" 2>/dev/null || true
fi
fi
}
# Ensure the proxy URL ends with a /
ensure_trailing_slash() {
if [ -n "$1" ]; then
case "$1" in
*/) echo "$1" ;;
*) echo "$1/" ;;
esac
else
echo "$1"
fi
}
# Default values
PORT=45876
UNINSTALL=false
GITHUB_URL="https://github.com"
GITHUB_API_URL="https://api.github.com" # not blocked in China currently
GITHUB_PROXY_URL=""
KEY=""
TOKEN=""
HUB_URL=""
AUTO_UPDATE_FLAG="" # empty string means prompt, "true" means auto-enable, "false" means skip
VERSION="latest"
# Check for help flag
case "$1" in
-h | --help)
printf "Beszel Agent installation script\n\n"
printf "Usage: ./install-agent.sh [options]\n\n"
printf "Options: \n"
printf " -k : SSH key (required, or interactive if not provided)\n"
printf " -p : Port (default: $PORT)\n"
printf " -t : Token (required, or interactive if not provided)\n"
printf " -url : Hub URL (required, or interactive if not provided)\n"
printf " -v, --version : Version to install (default: latest)\n"
printf " -u : Uninstall Beszel Agent\n"
printf " --auto-update [VALUE] : Control automatic daily updates\n"
printf " VALUE can be true (enable) or false (disable). If not specified, will prompt.\n"
printf " --china-mirrors [URL] : Use GitHub proxy to resolve network timeout issues in mainland China\n"
printf " URL: optional custom proxy URL (default: https://gh.beszel.dev)\n"
printf " -h, --help : Display this help message\n"
exit 0
;;
esac
# Build sudo args by properly quoting everything
build_sudo_args() {
QUOTED_ARGS=""
while [ $# -gt 0 ]; do
if [ -n "$QUOTED_ARGS" ]; then
QUOTED_ARGS="$QUOTED_ARGS "
fi
QUOTED_ARGS="$QUOTED_ARGS'$(echo "$1" | sed "s/'/'\\\\''/g")'"
shift
done
echo "$QUOTED_ARGS"
}
# Check if running as root and re-execute with sudo if needed
if [ "$(id -u)" != "0" ]; then
if command -v sudo >/dev/null 2>&1; then
SUDO_ARGS=$(build_sudo_args "$@")
eval "exec sudo $0 $SUDO_ARGS"
else
echo "This script must be run as root. Please either:"
echo "1. Run this script as root (su root)"
echo "2. Install sudo and run with sudo"
exit 1
fi
fi
# Parse arguments
while [ $# -gt 0 ]; do
case "$1" in
-k)
shift
KEY="$1"
;;
-p)
shift
PORT="$1"
;;
-t)
shift
TOKEN="$1"
;;
-url)
shift
HUB_URL="$1"
;;
-v|--version)
shift
VERSION="$1"
;;
-u)
UNINSTALL=true
;;
--china-mirrors*)
# Check if there's a value after the = sign
if echo "$1" | grep -q "="; then
# Extract the value after =
CUSTOM_PROXY=$(echo "$1" | cut -d'=' -f2)
if [ -n "$CUSTOM_PROXY" ]; then
GITHUB_PROXY_URL="$CUSTOM_PROXY"
GITHUB_URL="$(ensure_trailing_slash "$CUSTOM_PROXY")https://github.com"
else
GITHUB_PROXY_URL="https://gh.beszel.dev"
GITHUB_URL="$GITHUB_PROXY_URL"
fi
elif [ "$2" != "" ] && ! echo "$2" | grep -q '^-'; then
# use custom proxy URL provided as next argument
GITHUB_PROXY_URL="$2"
GITHUB_URL="$(ensure_trailing_slash "$2")https://github.com"
shift
else
# No value specified, use default
GITHUB_PROXY_URL="https://gh.beszel.dev"
GITHUB_URL="$GITHUB_PROXY_URL"
fi
;;
--auto-update*)
# Check if there's a value after the = sign
if echo "$1" | grep -q "="; then
# Extract the value after =
AUTO_UPDATE_VALUE=$(echo "$1" | cut -d'=' -f2)
if [ "$AUTO_UPDATE_VALUE" = "true" ]; then
AUTO_UPDATE_FLAG="true"
elif [ "$AUTO_UPDATE_VALUE" = "false" ]; then
AUTO_UPDATE_FLAG="false"
else
echo "Invalid value for --auto-update flag: $AUTO_UPDATE_VALUE. Using default (prompt)."
fi
elif [ "$2" = "true" ] || [ "$2" = "false" ]; then
# Value provided as next argument
AUTO_UPDATE_FLAG="$2"
shift
else
# No value specified, use true
AUTO_UPDATE_FLAG="true"
fi
;;
*)
echo "Invalid option: $1" >&2
exit 1
;;
esac
shift
done
# Uninstall process
if [ "$UNINSTALL" = true ]; then
# Clean up SELinux contexts before removing files
cleanup_selinux_context
if is_alpine; then
echo "Stopping and disabling the agent service..."
rc-service beszel-agent stop
rc-update del beszel-agent default
echo "Removing the OpenRC service files..."
rm -f /etc/init.d/beszel-agent
# Remove the update service if it exists
echo "Removing the daily update service..."
rc-service beszel-agent-update stop 2>/dev/null
rc-update del beszel-agent-update default 2>/dev/null
rm -f /etc/init.d/beszel-agent-update
# Remove log files
echo "Removing log files..."
rm -f /var/log/beszel-agent.log /var/log/beszel-agent.err
elif is_openwrt; then
echo "Stopping and disabling the agent service..."
service beszel-agent stop
service beszel-agent disable
echo "Removing the OpenWRT service files..."
rm -f /etc/init.d/beszel-agent
# Remove the update service if it exists
echo "Removing the daily update service..."
rm -f /etc/crontabs/beszel
else
echo "Stopping and disabling the agent service..."
systemctl stop beszel-agent.service
systemctl disable beszel-agent.service
echo "Removing the systemd service file..."
rm /etc/systemd/system/beszel-agent.service
# Remove the update timer and service if they exist
echo "Removing the daily update service and timer..."
systemctl stop beszel-agent-update.timer 2>/dev/null
systemctl disable beszel-agent-update.timer 2>/dev/null
rm -f /etc/systemd/system/beszel-agent-update.service
rm -f /etc/systemd/system/beszel-agent-update.timer
systemctl daemon-reload
fi
echo "Removing the Beszel Agent directory..."
rm -rf /opt/beszel-agent
echo "Removing the dedicated user for the agent service..."
killall beszel-agent 2>/dev/null
if is_alpine || is_openwrt; then
deluser beszel 2>/dev/null
else
userdel beszel 2>/dev/null
fi
echo "Beszel Agent has been uninstalled successfully!"
exit 0
fi
# Confirm the use of GitHub mirrors for downloads
if [ -n "$GITHUB_PROXY_URL" ]; then
printf "\nConfirm use of GitHub mirror (%s) for downloading beszel-agent?\nThis helps to install properly in mainland China. (Y/n): " "$GITHUB_PROXY_URL"
read USE_MIRROR
USE_MIRROR=${USE_MIRROR:-Y}
if [ "$USE_MIRROR" = "Y" ] || [ "$USE_MIRROR" = "y" ]; then
echo "Using GitHub Mirror ($GITHUB_PROXY_URL) for downloads..."
else
GITHUB_URL="https://github.com"
fi
fi
# Check if a package is installed
package_installed() {
command -v "$1" >/dev/null 2>&1
}
# Check for package manager and install necessary packages if not installed
if is_alpine; then
if ! package_installed tar || ! package_installed curl || ! package_installed coreutils; then
apk update
apk add tar curl coreutils shadow
fi
elif is_openwrt; then
if ! package_installed tar || ! package_installed curl || ! package_installed coreutils; then
opkg update
opkg install tar curl coreutils
fi
elif package_installed apt-get; then
if ! package_installed tar || ! package_installed curl || ! package_installed sha256sum; then
apt-get update
apt-get install -y tar curl coreutils
fi
elif package_installed yum; then
if ! package_installed tar || ! package_installed curl || ! package_installed sha256sum; then
yum install -y tar curl coreutils
fi
elif package_installed pacman; then
if ! package_installed tar || ! package_installed curl || ! package_installed sha256sum; then
pacman -Sy --noconfirm tar curl coreutils
fi
else
echo "Warning: Please ensure 'tar' and 'curl' and 'sha256sum (coreutils)' are installed."
fi
# If no SSH key is provided, ask for the SSH key interactively
if [ -z "$KEY" ]; then
printf "Enter your SSH key: "
read KEY
fi
# If no token is provided, ask for the token interactively
if [ -z "$TOKEN" ]; then
printf "Enter your token: "
read TOKEN
fi
# If no hub URL is provided, ask for the hub URL interactively
if [ -z "$HUB_URL" ]; then
printf "Enter your hub URL: "
read HUB_URL
fi
# Verify checksum
if command -v sha256sum >/dev/null; then
CHECK_CMD="sha256sum"
elif command -v md5 >/dev/null; then
CHECK_CMD="md5 -q"
else
echo "No MD5 checksum utility found"
exit 1
fi
# Create a dedicated user for the service if it doesn't exist
if is_alpine; then
if ! id -u beszel >/dev/null 2>&1; then
echo "Creating a dedicated group for the Beszel Agent service..."
addgroup beszel
echo "Creating a dedicated user for the Beszel Agent service..."
adduser -S -D -H -s /sbin/nologin -G beszel beszel
fi
# Add the user to the docker group to allow access to the Docker socket if group docker exists
if getent group docker; then
echo "Adding besel to docker group"
usermod -aG docker beszel
fi
else
if ! id -u beszel >/dev/null 2>&1; then
echo "Creating a dedicated user for the Beszel Agent service..."
useradd --system --home-dir /nonexistent --shell /bin/false beszel
fi
# Add the user to the docker group to allow access to the Docker socket if group docker exists
if getent group docker; then
echo "Adding besel to docker group"
usermod -aG docker beszel
fi
fi
# Create the directory for the Beszel Agent
if [ ! -d "/opt/beszel-agent" ]; then
echo "Creating the directory for the Beszel Agent..."
mkdir -p /opt/beszel-agent
chown beszel:beszel /opt/beszel-agent
chmod 755 /opt/beszel-agent
fi
# Download and install the Beszel Agent
echo "Downloading and installing the agent..."
OS=$(uname -s | sed -e 'y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/')
ARCH=$(uname -m | sed -e 's/x86_64/amd64/' -e 's/armv6l/arm/' -e 's/armv7l/arm/' -e 's/aarch64/arm64/' -e 's/mips/mipsle/')
FILE_NAME="beszel-agent_${OS}_${ARCH}.tar.gz"
# Determine version to install
if [ "$VERSION" = "latest" ]; then
INSTALL_VERSION=$(curl -s "$GITHUB_API_URL""/repos/henrygd/beszel/releases/latest" | grep -o '"tag_name": "v[^"]*"' | cut -d'"' -f4 | tr -d 'v')
if [ -z "$INSTALL_VERSION" ]; then
echo "Failed to get latest version"
exit 1
fi
else
INSTALL_VERSION="$VERSION"
# Remove 'v' prefix if present
INSTALL_VERSION=$(echo "$INSTALL_VERSION" | sed 's/^v//')
fi
echo "Downloading and installing agent version ${INSTALL_VERSION} from ${GITHUB_URL} ..."
# Download checksums file
TEMP_DIR=$(mktemp -d)
cd "$TEMP_DIR" || exit 1
CHECKSUM=$(curl -sL "$GITHUB_URL/henrygd/beszel/releases/download/v${INSTALL_VERSION}/beszel_${INSTALL_VERSION}_checksums.txt" | grep "$FILE_NAME" | cut -d' ' -f1)
if [ -z "$CHECKSUM" ] || ! echo "$CHECKSUM" | grep -qE "^[a-fA-F0-9]{64}$"; then
echo "Failed to get checksum or invalid checksum format"
exit 1
fi
if ! curl -#L "$GITHUB_URL/henrygd/beszel/releases/download/v${INSTALL_VERSION}/$FILE_NAME" -o "$FILE_NAME"; then
echo "Failed to download the agent from ""$GITHUB_URL/henrygd/beszel/releases/download/v${INSTALL_VERSION}/$FILE_NAME"
rm -rf "$TEMP_DIR"
exit 1
fi
if [ "$($CHECK_CMD "$FILE_NAME" | cut -d' ' -f1)" != "$CHECKSUM" ]; then
echo "Checksum verification failed: $($CHECK_CMD "$FILE_NAME" | cut -d' ' -f1) & $CHECKSUM"
rm -rf "$TEMP_DIR"
exit 1
fi
if ! tar -xzf "$FILE_NAME" beszel-agent; then
echo "Failed to extract the agent"
rm -rf "$TEMP_DIR"
exit 1
fi
mv beszel-agent /opt/beszel-agent/beszel-agent
chown beszel:beszel /opt/beszel-agent/beszel-agent
chmod 755 /opt/beszel-agent/beszel-agent
# Set SELinux context if needed
set_selinux_context
# Cleanup
rm -rf "$TEMP_DIR"
# Check for NVIDIA GPUs and grant device permissions for systemd service
detect_nvidia_devices() {
local devices=""
for i in /dev/nvidia*; do
if [ -e "$i" ]; then
devices="${devices}DeviceAllow=$i rw\n"
fi
done
echo "$devices"
}
# Modify service installation part, add Alpine check before systemd service creation
if is_alpine; then
echo "Creating OpenRC service for Alpine Linux..."
cat >/etc/init.d/beszel-agent <<EOF
#!/sbin/openrc-run
name="beszel-agent"
description="Beszel Agent Service"
command="/opt/beszel-agent/beszel-agent"
command_user="beszel"
command_background="yes"
pidfile="/run/\${RC_SVCNAME}.pid"
output_log="/var/log/beszel-agent.log"
error_log="/var/log/beszel-agent.err"
start_pre() {
checkpath -f -m 0644 -o beszel:beszel "\$output_log" "\$error_log"
}
export PORT="$PORT"
export KEY="$KEY"
export TOKEN="$TOKEN"
export HUB_URL="$HUB_URL"
depend() {
need net
after firewall
}
EOF
chmod +x /etc/init.d/beszel-agent
rc-update add beszel-agent default
# Create log files with proper permissions
touch /var/log/beszel-agent.log /var/log/beszel-agent.err
chown beszel:beszel /var/log/beszel-agent.log /var/log/beszel-agent.err
# Start the service
rc-service beszel-agent restart
# Check if service started successfully
sleep 2
if ! rc-service beszel-agent status | grep -q "started"; then
echo "Error: The Beszel Agent service failed to start. Checking logs..."
tail -n 20 /var/log/beszel-agent.err
exit 1
fi
# Auto-update service for Alpine
if [ "$AUTO_UPDATE_FLAG" = "true" ]; then
AUTO_UPDATE="y"
elif [ "$AUTO_UPDATE_FLAG" = "false" ]; then
AUTO_UPDATE="n"
else
printf "\nWould you like to enable automatic daily updates for beszel-agent? (y/n): "
read AUTO_UPDATE
fi
case "$AUTO_UPDATE" in
[Yy]*)
echo "Setting up daily automatic updates for beszel-agent..."
cat >/etc/init.d/beszel-agent-update <<EOF
#!/sbin/openrc-run
name="beszel-agent-update"
description="Update beszel-agent if needed"
depend() {
need beszel-agent
}
start() {
ebegin "Checking for beszel-agent updates"
if /opt/beszel-agent/beszel-agent update | grep -q "Successfully updated"; then
rc-service beszel-agent restart
fi
eend $?
}
EOF
chmod +x /etc/init.d/beszel-agent-update
rc-update add beszel-agent-update default
rc-service beszel-agent-update start
printf "\nAutomatic daily updates have been enabled.\n"
;;
esac
# Check service status
if ! rc-service beszel-agent status >/dev/null 2>&1; then
echo "Error: The Beszel Agent service is not running."
rc-service beszel-agent status
exit 1
fi
elif is_openwrt; then
echo "Creating procd init script service for OpenWRT..."
cat >/etc/init.d/beszel-agent <<EOF
#!/bin/sh /etc/rc.common
USE_PROCD=1
START=99
start_service() {
procd_open_instance
procd_set_param command /opt/beszel-agent/beszel-agent
procd_set_param user beszel
procd_set_param pidfile /var/run/beszel-agent.pid
procd_set_param env PORT="$PORT"
procd_set_param env KEY="$KEY"
procd_set_param env TOKEN="$TOKEN"
procd_set_param env HUB_URL="$HUB_URL"
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
stop_service() {
killall beszel-agent
}
# Extra command to trigger agent update
EXTRA_COMMANDS="update"
EXTRA_HELP=" update Update the Beszel agent"
update() {
if /opt/beszel-agent/beszel-agent update | grep -q "Successfully updated"; then
start_service
fi
}
EOF
# Enable the service
chmod +x /etc/init.d/beszel-agent
service beszel-agent enable
# Start the service
service beszel-agent restart
# Auto-update service for OpenWRT using a crontab job
if [ "$AUTO_UPDATE_FLAG" = "true" ]; then
AUTO_UPDATE="y"
sleep 1 # give time for the service to start
elif [ "$AUTO_UPDATE_FLAG" = "false" ]; then
AUTO_UPDATE="n"
sleep 1 # give time for the service to start
else
printf "\nWould you like to enable automatic daily updates for beszel-agent? (y/n): "
read AUTO_UPDATE
fi
case "$AUTO_UPDATE" in
[Yy]*)
echo "Setting up daily automatic updates for beszel-agent..."
cat >/etc/crontabs/beszel <<EOF
0 0 * * * /etc/init.d/beszel-agent update
EOF
/etc/init.d/cron restart
printf "\nAutomatic daily updates have been enabled.\n"
;;
esac
# Check service status
if ! service beszel-agent running >/dev/null 2>&1; then
echo "Error: The Beszel Agent service is not running."
service beszel-agent status
exit 1
fi
else
# Original systemd service installation code
echo "Creating the systemd service for the agent..."
# Detect NVIDIA devices and grant device permissions
NVIDIA_DEVICES=$(detect_nvidia_devices)
cat >/etc/systemd/system/beszel-agent.service <<EOF
[Unit]
Description=Beszel Agent Service
Wants=network-online.target
After=network-online.target
[Service]
Environment="PORT=$PORT"
Environment="KEY=$KEY"
Environment="TOKEN=$TOKEN"
Environment="HUB_URL=$HUB_URL"
# Environment="EXTRA_FILESYSTEMS=sdb"
ExecStart=/opt/beszel-agent/beszel-agent
User=beszel
Restart=on-failure
RestartSec=5
StateDirectory=beszel-agent
# Security/sandboxing settings
KeyringMode=private
LockPersonality=yes
NoNewPrivileges=yes
ProtectClock=yes
ProtectHome=read-only
ProtectHostname=yes
ProtectKernelLogs=yes
ProtectSystem=strict
RemoveIPC=yes
RestrictSUIDSGID=true
$(if [ -n "$NVIDIA_DEVICES" ]; then printf "%b" "# NVIDIA device permissions\n${NVIDIA_DEVICES}"; fi)
[Install]
WantedBy=multi-user.target
EOF
# Load and start the service
printf "\nLoading and starting the agent service...\n"
systemctl daemon-reload
systemctl enable beszel-agent.service
systemctl start beszel-agent.service
# Create the update script
echo "Creating the update script..."
cat >/opt/beszel-agent/run-update.sh <<'EOF'
#!/bin/sh
set -e
if /opt/beszel-agent/beszel-agent update | grep -q "Successfully updated"; then
echo "Update found, checking SELinux context."
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
echo "SELinux enabled, applying context..."
if command -v chcon >/dev/null 2>&1; then
chcon -t bin_t /opt/beszel-agent/beszel-agent || echo "Warning: chcon command failed to apply context."
fi
if command -v restorecon >/dev/null 2>&1; then
restorecon -v /opt/beszel-agent/beszel-agent >/dev/null 2>&1 || echo "Warning: restorecon command failed to apply context."
fi
fi
echo "Restarting beszel-agent service..."
systemctl restart beszel-agent
echo "Update process finished."
else
echo "No updates found or applied."
fi
exit 0
EOF
chown root:root /opt/beszel-agent/run-update.sh
chmod +x /opt/beszel-agent/run-update.sh
# Prompt for auto-update setup
if [ "$AUTO_UPDATE_FLAG" = "true" ]; then
AUTO_UPDATE="y"
sleep 1 # give time for the service to start
elif [ "$AUTO_UPDATE_FLAG" = "false" ]; then
AUTO_UPDATE="n"
sleep 1 # give time for the service to start
else
printf "\nWould you like to enable automatic daily updates for beszel-agent? (y/n): "
read AUTO_UPDATE
fi
case "$AUTO_UPDATE" in
[Yy]*)
echo "Setting up daily automatic updates for beszel-agent..."
# Create systemd service for the daily update
cat >/etc/systemd/system/beszel-agent-update.service <<EOF
[Unit]
Description=Update beszel-agent if needed
Wants=beszel-agent.service
[Service]
Type=oneshot
ExecStart=/opt/beszel-agent/run-update.sh
EOF
# Create systemd timer for the daily update
cat >/etc/systemd/system/beszel-agent-update.timer <<EOF
[Unit]
Description=Run beszel-agent update daily
[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=4h
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now beszel-agent-update.timer
printf "\nAutomatic daily updates have been enabled.\n"
;;
esac
# Wait for the service to start or fail
if [ "$(systemctl is-active beszel-agent.service)" != "active" ]; then
echo "Error: The Beszel Agent service is not running."
echo "$(systemctl status beszel-agent.service)"
exit 1
fi
fi
printf "\n\033[32mBeszel Agent has been installed successfully! It is now running on port $PORT.\033[0m\n"

View File

@@ -0,0 +1,104 @@
#!/bin/bash
PORT=45876
KEY=""
TOKEN=""
HUB_URL=""
usage() {
printf "Beszel Agent homebrew installation script\n\n"
printf "Usage: ./install-agent-brew.sh [options]\n\n"
printf "Options: \n"
printf " -k SSH key (required, or interactive if not provided)\n"
printf " -p Port (default: $PORT)\n"
printf " -t Token (required, or interactive if not provided)\n"
printf " -url Hub URL (required, or interactive if not provided)\n"
printf " -h, --help Display this help message\n"
exit 0
}
# Handle --help explicitly since getopts doesn't handle long options
if [ "$1" = "--help" ]; then
usage
fi
# Parse arguments (handling both short and long options)
while [ $# -gt 0 ]; do
case "$1" in
-k)
shift
KEY="$1"
;;
-p)
shift
PORT="$1"
;;
-t)
shift
TOKEN="$1"
;;
-url)
shift
HUB_URL="$1"
;;
-h | --help)
usage
;;
*)
echo "Invalid option: $1" >&2
usage
;;
esac
shift
done
# Check if brew is installed, prompt to install if not
if ! command -v brew &>/dev/null; then
read -p "Homebrew is not installed. Would you like to install it now? (y/n): " install_brew
if [[ $install_brew =~ ^[Yy]$ ]]; then
echo "Installing Homebrew..."
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Verify installation was successful
if ! command -v brew &>/dev/null; then
echo "Homebrew installation failed. Please install manually and try again."
exit 1
fi
echo "Homebrew installed successfully."
else
echo "Homebrew is required. Please install Homebrew and try again."
exit 1
fi
fi
if [ -z "$KEY" ]; then
read -p "Enter SSH key: " KEY
fi
if [ -z "$TOKEN" ]; then
read -p "Enter token: " TOKEN
fi
if [ -z "$HUB_URL" ]; then
read -p "Enter hub URL: " HUB_URL
fi
mkdir -p ~/.config/beszel ~/.cache/beszel
echo "KEY=\"$KEY\"" >~/.config/beszel/beszel-agent.env
echo "LISTEN=$PORT" >>~/.config/beszel/beszel-agent.env
echo "TOKEN=\"$TOKEN\"" >>~/.config/beszel/beszel-agent.env
echo "HUB_URL=\"$HUB_URL\"" >>~/.config/beszel/beszel-agent.env
brew tap henrygd/beszel
brew install beszel-agent
brew services start beszel-agent
printf "\nCheck status: brew services info beszel-agent\n"
echo "Stop: brew services stop beszel-agent"
echo "Start: brew services start beszel-agent"
echo "Restart: brew services restart beszel-agent"
echo "Upgrade: brew upgrade beszel-agent"
echo "Uninstall: brew uninstall beszel-agent"
echo "View logs in ~/.cache/beszel/beszel-agent.log"
printf "Change environment variables in ~/.config/beszel/beszel-agent.env\n"

Some files were not shown because too many files have changed in this diff Show More