├── .gitattributes ├── .github ├── DISCUSSION_TEMPLATE │ └── support.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── funding.yml └── workflows │ ├── docker-images.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── SECURITY.md ├── beszel ├── .goreleaser.yml ├── Makefile ├── cmd │ ├── agent │ │ ├── agent.go │ │ └── agent_test.go │ └── hub │ │ └── hub.go ├── dockerfile_Agent ├── dockerfile_Hub ├── go.mod ├── go.sum ├── internal │ ├── agent │ │ ├── agent.go │ │ ├── agent_cache.go │ │ ├── agent_cache_test.go │ │ ├── disk.go │ │ ├── docker.go │ │ ├── gpu.go │ │ ├── gpu_test.go │ │ ├── health.go │ │ ├── health_test.go │ │ ├── network.go │ │ ├── sensors.go │ │ ├── sensors_test.go │ │ ├── server.go │ │ ├── server_test.go │ │ ├── system.go │ │ ├── update.go │ │ └── utils.go │ ├── alerts │ │ ├── alerts.go │ │ ├── alerts_status.go │ │ └── alerts_system.go │ ├── common │ │ └── common.go │ ├── entities │ │ ├── container │ │ │ └── container.go │ │ └── system │ │ │ └── system.go │ ├── hub │ │ ├── config.go │ │ ├── hub.go │ │ ├── hub_test.go │ │ ├── systems │ │ │ ├── systems.go │ │ │ ├── systems_test.go │ │ │ └── systems_test_helpers.go │ │ └── update.go │ ├── records │ │ └── records.go │ ├── tests │ │ └── hub.go │ └── users │ │ └── users.go ├── migrations │ ├── collections_snapshot_0_10_2.go │ └── initial-settings.go ├── site │ ├── .gitignore │ ├── .prettierrc │ ├── bun.lockb │ ├── components.json │ ├── embed.go │ ├── index.html │ ├── lingui.config.ts │ ├── package-lock.json │ ├── package.json │ ├── postcss.config.js │ ├── public │ │ └── static │ │ │ ├── InterVariable.woff2 │ │ │ ├── favicon-green.svg │ │ │ ├── favicon-red.svg │ │ │ ├── favicon.svg │ │ │ ├── icon.png │ │ │ └── manifest.json │ ├── src │ │ ├── components │ │ │ ├── add-system.tsx │ │ │ ├── alerts │ │ │ │ ├── alert-button.tsx │ │ │ │ └── alerts-system.tsx │ │ │ ├── charts │ │ │ │ ├── area-chart.tsx │ │ │ │ ├── chart-time-select.tsx │ │ │ │ ├── container-chart.tsx │ │ │ │ ├── disk-chart.tsx │ │ │ │ ├── gpu-power-chart.tsx │ │ │ │ ├── mem-chart.tsx │ │ │ │ ├── swap-chart.tsx │ │ │ │ └── temperature-chart.tsx │ │ │ ├── command-palette.tsx │ │ │ ├── copy-to-clipboard.tsx │ │ │ ├── lang-toggle.tsx │ │ │ ├── login │ │ │ │ ├── auth-form.tsx │ │ │ │ ├── forgot-pass-form.tsx │ │ │ │ └── login.tsx │ │ │ ├── logo.tsx │ │ │ ├── mode-toggle.tsx │ │ │ ├── navbar.tsx │ │ │ ├── router.tsx │ │ │ ├── routes │ │ │ │ ├── home.tsx │ │ │ │ ├── settings │ │ │ │ │ ├── config-yaml.tsx │ │ │ │ │ ├── general.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── notifications.tsx │ │ │ │ │ └── sidebar-nav.tsx │ │ │ │ └── system.tsx │ │ │ ├── spinner.tsx │ │ │ ├── systems-table │ │ │ │ └── systems-table.tsx │ │ │ ├── theme-provider.tsx │ │ │ └── ui │ │ │ │ ├── alert-dialog.tsx │ │ │ │ ├── alert.tsx │ │ │ │ ├── badge.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── chart.tsx │ │ │ │ ├── checkbox.tsx │ │ │ │ ├── command.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── dropdown-menu.tsx │ │ │ │ ├── icons.tsx │ │ │ │ ├── input-tags.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── label.tsx │ │ │ │ ├── select.tsx │ │ │ │ ├── separator.tsx │ │ │ │ ├── slider.tsx │ │ │ │ ├── switch.tsx │ │ │ │ ├── table.tsx │ │ │ │ ├── tabs.tsx │ │ │ │ ├── textarea.tsx │ │ │ │ ├── toast.tsx │ │ │ │ ├── toaster.tsx │ │ │ │ ├── tooltip.tsx │ │ │ │ └── use-toast.ts │ │ ├── index.css │ │ ├── lib │ │ │ ├── enums.ts │ │ │ ├── i18n.ts │ │ │ ├── languages.ts │ │ │ ├── stores.ts │ │ │ ├── use-intersection-observer.ts │ │ │ └── utils.ts │ │ ├── locales │ │ │ ├── ar │ │ │ │ └── ar.po │ │ │ ├── bg │ │ │ │ └── bg.po │ │ │ ├── cs │ │ │ │ └── cs.po │ │ │ ├── da │ │ │ │ └── da.po │ │ │ ├── de │ │ │ │ └── de.po │ │ │ ├── en │ │ │ │ └── en.po │ │ │ ├── es │ │ │ │ └── es.po │ │ │ ├── fa │ │ │ │ └── fa.po │ │ │ ├── fr │ │ │ │ └── fr.po │ │ │ ├── hr │ │ │ │ └── hr.po │ │ │ ├── hu │ │ │ │ └── hu.po │ │ │ ├── is │ │ │ │ └── is.po │ │ │ ├── it │ │ │ │ └── it.po │ │ │ ├── ja │ │ │ │ └── ja.po │ │ │ ├── ko │ │ │ │ └── ko.po │ │ │ ├── nl │ │ │ │ └── nl.po │ │ │ ├── no │ │ │ │ └── no.po │ │ │ ├── pl │ │ │ │ └── pl.po │ │ │ ├── pt │ │ │ │ └── pt.po │ │ │ ├── ru │ │ │ │ └── ru.po │ │ │ ├── sl │ │ │ │ └── sl.po │ │ │ ├── sv │ │ │ │ └── sv.po │ │ │ ├── tr │ │ │ │ └── tr.po │ │ │ ├── uk │ │ │ │ └── uk.po │ │ │ ├── vi │ │ │ │ └── vi.po │ │ │ ├── zh-CN │ │ │ │ └── zh-CN.po │ │ │ ├── zh-HK │ │ │ │ └── zh-HK.po │ │ │ └── zh │ │ │ │ └── zh.po │ │ ├── main.tsx │ │ ├── types.d.ts │ │ └── vite-env.d.ts │ ├── tailwind.config.js │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts └── version.go ├── i18n.yml ├── readme.md └── supplemental ├── debian ├── beszel-agent.service ├── config.sh ├── copyright ├── lintian-overrides ├── postinstall.sh ├── postrm.sh ├── prerm.sh └── templates ├── docker ├── agent │ └── docker-compose.yml ├── hub │ └── docker-compose.yml └── same-system │ └── docker-compose.yml ├── guides └── systemd.md ├── kubernetes └── beszel-hub │ └── charts │ ├── .helmignore │ ├── Chart.yaml │ ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── deployment.yaml │ ├── ingress.yaml │ ├── service.yaml │ ├── tests │ │ └── test-beszel-hub-endpoint.yaml │ └── volume-claim.yaml │ └── values.yaml └── scripts ├── install-agent-brew.sh ├── install-agent.ps1 ├── install-agent.sh └── install-hub.sh /.gitattributes: -------------------------------------------------------------------------------- 1 | *.tsx linguist-language=Go -------------------------------------------------------------------------------- /.github/DISCUSSION_TEMPLATE/support.yml: -------------------------------------------------------------------------------- 1 | body: 2 | - type: markdown 3 | attributes: 4 | value: | 5 | ### Before opening a discussion: 6 | 7 | - Check the [common issues guide](https://beszel.dev/guide/common-issues). 8 | - Search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed). 9 | 10 | - type: textarea 11 | id: description 12 | attributes: 13 | label: Description 14 | description: A clear and concise description of the issue or question. If applicable, add screenshots to help explain your problem. 15 | validations: 16 | required: true 17 | - type: input 18 | id: system 19 | attributes: 20 | label: OS / Architecture 21 | placeholder: linux/amd64 (agent), freebsd/arm64 (hub) 22 | validations: 23 | required: true 24 | - type: input 25 | id: version 26 | attributes: 27 | label: Beszel version 28 | placeholder: 0.9.1 29 | validations: 30 | required: true 31 | - type: dropdown 32 | id: install-method 33 | attributes: 34 | label: Installation method 35 | options: 36 | - Docker 37 | - Binary 38 | - Nix 39 | - Unraid 40 | - Coolify 41 | - Other (please describe above) 42 | validations: 43 | required: true 44 | - type: textarea 45 | id: config 46 | attributes: 47 | label: Configuration 48 | description: Please provide any relevant service configuration 49 | render: yaml 50 | - type: textarea 51 | id: hub-logs 52 | attributes: 53 | label: Hub Logs 54 | description: Check the logs page in PocketBase (`/_/#/logs`) for relevant errors (copy JSON). 55 | render: json 56 | - type: textarea 57 | id: agent-logs 58 | attributes: 59 | label: Agent Logs 60 | description: Please provide any logs from the agent, if relevant. Use `LOG_LEVEL=debug` for more info. 61 | render: shell 62 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug report 2 | description: Report a new bug or issue. 3 | title: '[Bug]: ' 4 | labels: ['bug'] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | ### Thanks for taking the time to fill out this bug report! 10 | 11 | - For more general support, please [start a support thread](https://github.com/henrygd/beszel/discussions/new?category=support). 12 | - To request a change or feature, use the [feature request form](https://github.com/henrygd/beszel/issues/new?template=feature_request.yml). 13 | - Please do not submit bugs that are specific to ZFS. We plan to add integration with ZFS utilities in the near future. 14 | 15 | ### Before submitting a bug report: 16 | 17 | - Check the [common issues guide](https://beszel.dev/guide/common-issues). 18 | - Search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed). 19 | - type: textarea 20 | id: description 21 | attributes: 22 | label: Description 23 | description: Explain the issue you experienced clearly and concisely. 24 | placeholder: I went to the coffee pot and it was empty. 25 | validations: 26 | required: true 27 | - type: textarea 28 | id: expected-behavior 29 | attributes: 30 | label: Expected Behavior 31 | description: In a perfect world, what should have happened? 32 | placeholder: When I got to the coffee pot, it should have been full. 33 | validations: 34 | required: true 35 | - type: textarea 36 | id: steps-to-reproduce 37 | attributes: 38 | label: Steps to Reproduce 39 | description: Describe how to reproduce the issue in repeatable steps. 40 | placeholder: | 41 | 1. Go to the coffee pot. 42 | 2. Make more coffee. 43 | 3. Pour it into a cup. 44 | validations: 45 | required: true 46 | - type: input 47 | id: system 48 | attributes: 49 | label: OS / Architecture 50 | placeholder: linux/amd64 (agent), freebsd/arm64 (hub) 51 | validations: 52 | required: true 53 | - type: input 54 | id: version 55 | attributes: 56 | label: Beszel version 57 | placeholder: 0.9.1 58 | validations: 59 | required: true 60 | - type: dropdown 61 | id: install-method 62 | attributes: 63 | label: Installation method 64 | default: 0 65 | options: 66 | - Docker 67 | - Binary 68 | - Nix 69 | - Unraid 70 | - Coolify 71 | - Other (please describe above) 72 | validations: 73 | required: true 74 | - type: textarea 75 | id: config 76 | attributes: 77 | label: Configuration 78 | description: Please provide any relevant service configuration 79 | render: yaml 80 | - type: textarea 81 | id: hub-logs 82 | attributes: 83 | label: Hub Logs 84 | description: Check the logs page in PocketBase (`/_/#/logs`) for relevant errors (copy JSON). 85 | render: json 86 | - type: textarea 87 | id: agent-logs 88 | attributes: 89 | label: Agent Logs 90 | description: Please provide any logs from the agent, if relevant. Use `LOG_LEVEL=debug` for more info. 91 | render: shell 92 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 💬 Support and questions 4 | url: https://github.com/henrygd/beszel/discussions 5 | about: Ask and answer questions here. 6 | - name: ℹ️ View the Common Issues page 7 | url: https://beszel.dev/guide/common-issues 8 | about: Find information about commonly encountered problems. 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Feature request 2 | description: Request a new feature or change. 3 | title: "[Feature]: " 4 | labels: ["enhancement"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Before submitting, please search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed). 9 | - type: textarea 10 | attributes: 11 | label: Describe the feature you would like to see 12 | validations: 13 | required: true 14 | - type: textarea 15 | attributes: 16 | label: Describe how you would like to see this feature implemented 17 | validations: 18 | required: true 19 | -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: henrygd 2 | -------------------------------------------------------------------------------- /.github/workflows/docker-images.yml: -------------------------------------------------------------------------------- 1 | name: Make docker images 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | include: 15 | - image: henrygd/beszel 16 | context: ./beszel 17 | dockerfile: ./beszel/dockerfile_Hub 18 | registry: docker.io 19 | username_secret: DOCKERHUB_USERNAME 20 | password_secret: DOCKERHUB_TOKEN 21 | - image: henrygd/beszel-agent 22 | context: ./beszel 23 | dockerfile: ./beszel/dockerfile_Agent 24 | registry: docker.io 25 | username_secret: DOCKERHUB_USERNAME 26 | password_secret: DOCKERHUB_TOKEN 27 | - image: ghcr.io/${{ github.repository }}/beszel 28 | context: ./beszel 29 | dockerfile: ./beszel/dockerfile_Hub 30 | registry: ghcr.io 31 | username: ${{ github.actor }} 32 | password_secret: GITHUB_TOKEN 33 | - image: ghcr.io/${{ github.repository }}/beszel-agent 34 | context: ./beszel 35 | dockerfile: ./beszel/dockerfile_Agent 36 | registry: ghcr.io 37 | username: ${{ github.actor }} 38 | password_secret: GITHUB_TOKEN 39 | permissions: 40 | contents: read 41 | packages: write 42 | 43 | steps: 44 | - name: Checkout 45 | uses: actions/checkout@v4 46 | 47 | - name: Set up bun 48 | uses: oven-sh/setup-bun@v2 49 | 50 | - name: Install dependencies 51 | run: bun install --no-save --cwd ./beszel/site 52 | 53 | - name: Build site 54 | run: bun run --cwd ./beszel/site build 55 | 56 | - name: Set up QEMU 57 | uses: docker/setup-qemu-action@v3 58 | 59 | - name: Set up Docker Buildx 60 | uses: docker/setup-buildx-action@v3 61 | 62 | - name: Docker metadata 63 | id: metadata 64 | uses: docker/metadata-action@v5 65 | with: 66 | images: ${{ matrix.image }} 67 | tags: | 68 | type=semver,pattern={{version}} 69 | type=semver,pattern={{major}}.{{minor}} 70 | type=semver,pattern={{major}} 71 | type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} 72 | 73 | # https://github.com/docker/login-action 74 | - name: Login to Docker Hub 75 | if: github.event_name != 'pull_request' 76 | uses: docker/login-action@v3 77 | with: 78 | username: ${{ matrix.username || secrets[matrix.username_secret] }} 79 | password: ${{ secrets[matrix.password_secret] }} 80 | registry: ${{ matrix.registry }} 81 | 82 | # Build and push Docker image with Buildx (don't push on PR) 83 | # https://github.com/docker/build-push-action 84 | - name: Build and push Docker image 85 | uses: docker/build-push-action@v5 86 | with: 87 | context: '${{ matrix.context }}' 88 | file: ${{ matrix.dockerfile }} 89 | platforms: linux/amd64,linux/arm64,linux/arm/v7 90 | push: ${{ github.ref_type == 'tag' }} 91 | tags: ${{ steps.metadata.outputs.tags }} 92 | labels: ${{ steps.metadata.outputs.labels }} 93 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Make release and binaries 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Set up bun 21 | uses: oven-sh/setup-bun@v2 22 | 23 | - name: Install dependencies 24 | run: bun install --no-save --cwd ./beszel/site 25 | 26 | - name: Build site 27 | run: bun run --cwd ./beszel/site build 28 | 29 | - name: Set up Go 30 | uses: actions/setup-go@v5 31 | with: 32 | go-version: '^1.22.1' 33 | 34 | - name: GoReleaser beszel 35 | uses: goreleaser/goreleaser-action@v6 36 | with: 37 | workdir: ./beszel 38 | distribution: goreleaser 39 | version: latest 40 | args: release --clean 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.TOKEN }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea.md 2 | pb_data 3 | data 4 | temp 5 | .vscode 6 | beszel-agent 7 | beszel_data 8 | beszel_data* 9 | dist 10 | *.exe 11 | beszel/cmd/hub/hub 12 | beszel/cmd/agent/agent 13 | node_modules 14 | beszel/build 15 | *timestamp* 16 | .swc 17 | beszel/site/src/locales/**/*.ts 18 | *.bak 19 | __debug_* 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 henrygd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | If you find a vulnerability in the latest version, please [submit a private advisory](https://github.com/henrygd/beszel/security/advisories/new). 6 | 7 | If it's low severity (use best judgement) you may open an issue instead of an advisory. 8 | -------------------------------------------------------------------------------- /beszel/Makefile: -------------------------------------------------------------------------------- 1 | # Default OS/ARCH values 2 | OS ?= $(shell go env GOOS) 3 | ARCH ?= $(shell go env GOARCH) 4 | # Skip building the web UI if true 5 | SKIP_WEB ?= false 6 | 7 | .PHONY: tidy build-agent build-hub build clean lint dev-server dev-agent dev-hub dev generate-locales 8 | .DEFAULT_GOAL := build 9 | 10 | clean: 11 | go clean 12 | rm -rf ./build 13 | 14 | lint: 15 | golangci-lint run 16 | 17 | test: export GOEXPERIMENT=synctest 18 | test: 19 | go test -tags=testing ./... 20 | 21 | tidy: 22 | go mod tidy 23 | 24 | build-web-ui: 25 | @if command -v bun >/dev/null 2>&1; then \ 26 | bun install --cwd ./site && \ 27 | bun run --cwd ./site build; \ 28 | else \ 29 | npm install --prefix ./site && \ 30 | npm run --prefix ./site build; \ 31 | fi 32 | 33 | build-agent: tidy 34 | GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH) -ldflags "-w -s" beszel/cmd/agent 35 | 36 | build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui) 37 | GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH) -ldflags "-w -s" beszel/cmd/hub 38 | 39 | build: build-agent build-hub 40 | 41 | generate-locales: 42 | @if [ ! -f ./site/src/locales/en/en.ts ]; then \ 43 | echo "Generating locales..."; \ 44 | command -v bun >/dev/null 2>&1 && cd ./site && bun install && bun run sync || cd ./site && npm install && npm run sync; \ 45 | fi 46 | 47 | dev-server: generate-locales 48 | cd ./site 49 | @if command -v bun >/dev/null 2>&1; then \ 50 | cd ./site && bun run dev; \ 51 | else \ 52 | cd ./site && npm run dev; \ 53 | fi 54 | 55 | dev-hub: export ENV=dev 56 | dev-hub: 57 | mkdir -p ./site/dist && touch ./site/dist/index.html 58 | @if command -v entr >/dev/null 2>&1; then \ 59 | find ./cmd/hub/*.go ./internal/{alerts,hub,records,users}/*.go | entr -r -s "cd ./cmd/hub && go run . serve"; \ 60 | else \ 61 | cd ./cmd/hub && go run . serve; \ 62 | fi 63 | 64 | dev-agent: 65 | @if command -v entr >/dev/null 2>&1; then \ 66 | find ./cmd/agent/*.go ./internal/agent/*.go | entr -r go run beszel/cmd/agent; \ 67 | else \ 68 | go run beszel/cmd/agent; \ 69 | fi 70 | 71 | # KEY="..." make -j dev 72 | dev: dev-server dev-hub dev-agent 73 | -------------------------------------------------------------------------------- /beszel/cmd/agent/agent.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "beszel" 5 | "beszel/internal/agent" 6 | "flag" 7 | "fmt" 8 | "log" 9 | "os" 10 | 11 | "golang.org/x/crypto/ssh" 12 | ) 13 | 14 | // cli options 15 | type cmdOptions struct { 16 | key string // key is the public key(s) for SSH authentication. 17 | listen string // listen is the address or port to listen on. 18 | } 19 | 20 | // parse parses the command line flags and populates the config struct. 21 | // It returns true if a subcommand was handled and the program should exit. 22 | func (opts *cmdOptions) parse() bool { 23 | flag.StringVar(&opts.key, "key", "", "Public key(s) for SSH authentication") 24 | flag.StringVar(&opts.listen, "listen", "", "Address or port to listen on") 25 | 26 | flag.Usage = func() { 27 | fmt.Printf("Usage: %s [command] [flags]\n", os.Args[0]) 28 | fmt.Println("\nCommands:") 29 | fmt.Println(" health Check if the agent is running") 30 | fmt.Println(" help Display this help message") 31 | fmt.Println(" update Update to the latest version") 32 | fmt.Println(" version Display the version") 33 | fmt.Println("\nFlags:") 34 | flag.PrintDefaults() 35 | } 36 | 37 | subcommand := "" 38 | if len(os.Args) > 1 { 39 | subcommand = os.Args[1] 40 | } 41 | 42 | switch subcommand { 43 | case "-v", "version": 44 | fmt.Println(beszel.AppName+"-agent", beszel.Version) 45 | return true 46 | case "help": 47 | flag.Usage() 48 | return true 49 | case "update": 50 | agent.Update() 51 | return true 52 | case "health": 53 | // for health, we need to parse flags first to get the listen address 54 | args := append(os.Args[2:], subcommand) 55 | flag.CommandLine.Parse(args) 56 | addr := opts.getAddress() 57 | network := agent.GetNetwork(addr) 58 | err := agent.Health(addr, network) 59 | if err != nil { 60 | log.Fatal(err) 61 | } 62 | fmt.Print("ok") 63 | return true 64 | } 65 | 66 | flag.Parse() 67 | return false 68 | } 69 | 70 | // loadPublicKeys loads the public keys from the command line flag, environment variable, or key file. 71 | func (opts *cmdOptions) loadPublicKeys() ([]ssh.PublicKey, error) { 72 | // Try command line flag first 73 | if opts.key != "" { 74 | return agent.ParseKeys(opts.key) 75 | } 76 | 77 | // Try environment variable 78 | if key, ok := agent.GetEnv("KEY"); ok && key != "" { 79 | return agent.ParseKeys(key) 80 | } 81 | 82 | // Try key file 83 | keyFile, ok := agent.GetEnv("KEY_FILE") 84 | if !ok { 85 | return nil, fmt.Errorf("no key provided: must set -key flag, KEY env var, or KEY_FILE env var. Use 'beszel-agent help' for usage") 86 | } 87 | 88 | pubKey, err := os.ReadFile(keyFile) 89 | if err != nil { 90 | return nil, fmt.Errorf("failed to read key file: %w", err) 91 | } 92 | return agent.ParseKeys(string(pubKey)) 93 | } 94 | 95 | func (opts *cmdOptions) getAddress() string { 96 | return agent.GetAddress(opts.listen) 97 | } 98 | 99 | func main() { 100 | var opts cmdOptions 101 | subcommandHandled := opts.parse() 102 | 103 | if subcommandHandled { 104 | return 105 | } 106 | 107 | var serverConfig agent.ServerOptions 108 | var err error 109 | serverConfig.Keys, err = opts.loadPublicKeys() 110 | if err != nil { 111 | log.Fatal("Failed to load public keys:", err) 112 | } 113 | 114 | addr := opts.getAddress() 115 | serverConfig.Addr = addr 116 | serverConfig.Network = agent.GetNetwork(addr) 117 | 118 | agent := agent.NewAgent() 119 | if err := agent.StartServer(serverConfig); err != nil { 120 | log.Fatal("Failed to start server:", err) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /beszel/cmd/hub/hub.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "beszel" 5 | "beszel/internal/hub" 6 | _ "beszel/migrations" 7 | "fmt" 8 | "log" 9 | "net/http" 10 | "os" 11 | "time" 12 | 13 | "github.com/pocketbase/pocketbase" 14 | "github.com/pocketbase/pocketbase/plugins/migratecmd" 15 | "github.com/spf13/cobra" 16 | ) 17 | 18 | func main() { 19 | // handle health check first to prevent unneeded execution 20 | if len(os.Args) > 3 && os.Args[1] == "health" { 21 | url := os.Args[3] 22 | if err := checkHealth(url); err != nil { 23 | log.Fatal(err) 24 | } 25 | fmt.Print("ok") 26 | return 27 | } 28 | 29 | baseApp := getBaseApp() 30 | h := hub.NewHub(baseApp) 31 | if err := h.StartHub(); err != nil { 32 | log.Fatal(err) 33 | } 34 | } 35 | 36 | // getBaseApp creates a new PocketBase app with the default config 37 | func getBaseApp() *pocketbase.PocketBase { 38 | isDev := os.Getenv("ENV") == "dev" 39 | 40 | baseApp := pocketbase.NewWithConfig(pocketbase.Config{ 41 | DefaultDataDir: beszel.AppName + "_data", 42 | DefaultDev: isDev, 43 | }) 44 | baseApp.RootCmd.Version = beszel.Version 45 | baseApp.RootCmd.Use = beszel.AppName 46 | baseApp.RootCmd.Short = "" 47 | // add update command 48 | baseApp.RootCmd.AddCommand(&cobra.Command{ 49 | Use: "update", 50 | Short: "Update " + beszel.AppName + " to the latest version", 51 | Run: hub.Update, 52 | }) 53 | // add health command 54 | baseApp.RootCmd.AddCommand(newHealthCmd()) 55 | 56 | // enable auto creation of migration files when making collection changes in the Admin UI 57 | migratecmd.MustRegister(baseApp, baseApp.RootCmd, migratecmd.Config{ 58 | Automigrate: isDev, 59 | Dir: "../../migrations", 60 | }) 61 | 62 | return baseApp 63 | } 64 | 65 | func newHealthCmd() *cobra.Command { 66 | var baseURL string 67 | 68 | healthCmd := &cobra.Command{ 69 | Use: "health", 70 | Short: "Check health of running hub", 71 | Run: func(cmd *cobra.Command, args []string) { 72 | if err := checkHealth(baseURL); err != nil { 73 | log.Fatal(err) 74 | } 75 | os.Exit(0) 76 | }, 77 | } 78 | healthCmd.Flags().StringVar(&baseURL, "url", "", "base URL") 79 | healthCmd.MarkFlagRequired("url") 80 | return healthCmd 81 | } 82 | 83 | // checkHealth checks the health of the hub. 84 | func checkHealth(baseURL string) error { 85 | client := &http.Client{ 86 | Timeout: time.Second * 3, 87 | } 88 | healthURL := baseURL + "/api/health" 89 | resp, err := client.Get(healthURL) 90 | if err != nil { 91 | return err 92 | } 93 | defer resp.Body.Close() 94 | 95 | if resp.StatusCode != 200 { 96 | return fmt.Errorf("%s returned status %d", healthURL, resp.StatusCode) 97 | } 98 | return nil 99 | } 100 | -------------------------------------------------------------------------------- /beszel/dockerfile_Agent: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM golang:alpine AS builder 2 | 3 | WORKDIR /app 4 | 5 | COPY go.mod go.sum ./ 6 | # RUN go mod download 7 | COPY *.go ./ 8 | COPY cmd ./cmd 9 | COPY internal ./internal 10 | 11 | # Build 12 | ARG TARGETOS TARGETARCH 13 | RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent 14 | 15 | # ? ------------------------- 16 | FROM scratch 17 | 18 | COPY --from=builder /agent /agent 19 | 20 | ENTRYPOINT ["/agent"] -------------------------------------------------------------------------------- /beszel/dockerfile_Hub: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM golang:alpine AS builder 2 | 3 | WORKDIR /app 4 | 5 | # Download Go modules 6 | COPY go.mod go.sum ./ 7 | RUN go mod download 8 | 9 | # Copy source files 10 | COPY *.go ./ 11 | COPY cmd ./cmd 12 | COPY internal ./internal 13 | COPY migrations ./migrations 14 | COPY site/dist ./site/dist 15 | COPY site/*.go ./site 16 | 17 | RUN apk add --no-cache \ 18 | unzip \ 19 | ca-certificates 20 | 21 | RUN update-ca-certificates 22 | 23 | # Build 24 | ARG TARGETOS TARGETARCH 25 | RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /beszel ./cmd/hub 26 | 27 | # ? ------------------------- 28 | FROM scratch 29 | 30 | COPY --from=builder /beszel / 31 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 32 | 33 | EXPOSE 8090 34 | 35 | ENTRYPOINT [ "/beszel" ] 36 | CMD ["serve", "--http=0.0.0.0:8090"] -------------------------------------------------------------------------------- /beszel/go.mod: -------------------------------------------------------------------------------- 1 | module beszel 2 | 3 | go 1.24.2 4 | 5 | // lock shoutrrr to specific version to allow review before updating 6 | replace github.com/nicholas-fedor/shoutrrr => github.com/nicholas-fedor/shoutrrr v0.8.8 7 | 8 | require ( 9 | github.com/blang/semver v3.5.1+incompatible 10 | github.com/gliderlabs/ssh v0.3.8 11 | github.com/goccy/go-json v0.10.5 12 | github.com/nicholas-fedor/shoutrrr v0.8.8 13 | github.com/pocketbase/dbx v1.11.0 14 | github.com/pocketbase/pocketbase v0.27.1 15 | github.com/rhysd/go-github-selfupdate v1.2.3 16 | github.com/shirou/gopsutil/v4 v4.25.3 17 | github.com/spf13/cast v1.7.1 18 | github.com/spf13/cobra v1.9.1 19 | github.com/stretchr/testify v1.10.0 20 | golang.org/x/crypto v0.37.0 21 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 22 | gopkg.in/yaml.v3 v3.0.1 23 | ) 24 | 25 | require ( 26 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 27 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 28 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 29 | github.com/disintegration/imaging v1.6.2 // indirect 30 | github.com/domodwyer/mailyak/v3 v3.6.2 // indirect 31 | github.com/dustin/go-humanize v1.0.1 // indirect 32 | github.com/ebitengine/purego v0.8.2 // indirect 33 | github.com/fatih/color v1.18.0 // indirect 34 | github.com/gabriel-vasile/mimetype v1.4.9 // indirect 35 | github.com/ganigeorgiev/fexpr v0.5.0 // indirect 36 | github.com/go-ole/go-ole v1.3.0 // indirect 37 | github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect 38 | github.com/go-sql-driver/mysql v1.9.1 // indirect 39 | github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 40 | github.com/google/go-github/v30 v30.1.0 // indirect 41 | github.com/google/go-querystring v1.1.0 // indirect 42 | github.com/google/uuid v1.6.0 // indirect 43 | github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect 44 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 45 | github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect 46 | github.com/mattn/go-colorable v0.1.14 // indirect 47 | github.com/mattn/go-isatty v0.0.20 // indirect 48 | github.com/ncruces/go-strftime v0.1.9 // indirect 49 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 50 | github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect 51 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 52 | github.com/spf13/pflag v1.0.6 // indirect 53 | github.com/tcnksm/go-gitconfig v0.1.2 // indirect 54 | github.com/tklauser/go-sysconf v0.3.15 // indirect 55 | github.com/tklauser/numcpus v0.10.0 // indirect 56 | github.com/ulikunitz/xz v0.5.12 // indirect 57 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 58 | golang.org/x/image v0.26.0 // indirect 59 | golang.org/x/net v0.39.0 // indirect 60 | golang.org/x/oauth2 v0.29.0 // indirect 61 | golang.org/x/sync v0.13.0 // indirect 62 | golang.org/x/sys v0.32.0 // indirect 63 | golang.org/x/text v0.24.0 // indirect 64 | modernc.org/libc v1.64.0 // indirect 65 | modernc.org/mathutil v1.7.1 // indirect 66 | modernc.org/memory v1.10.0 // indirect 67 | modernc.org/sqlite v1.37.0 // indirect 68 | ) 69 | -------------------------------------------------------------------------------- /beszel/internal/agent/agent.go: -------------------------------------------------------------------------------- 1 | // Package agent handles the agent's SSH server and system stats collection. 2 | package agent 3 | 4 | import ( 5 | "beszel" 6 | "beszel/internal/entities/system" 7 | "log/slog" 8 | "os" 9 | "strings" 10 | "sync" 11 | "time" 12 | ) 13 | 14 | type Agent struct { 15 | sync.Mutex // Used to lock agent while collecting data 16 | debug bool // true if LOG_LEVEL is set to debug 17 | zfs bool // true if system has arcstats 18 | memCalc string // Memory calculation formula 19 | fsNames []string // List of filesystem device names being monitored 20 | fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem 21 | netInterfaces map[string]struct{} // Stores all valid network interfaces 22 | netIoStats system.NetIoStats // Keeps track of bandwidth usage 23 | dockerManager *dockerManager // Manages Docker API requests 24 | sensorConfig *SensorConfig // Sensors config 25 | systemInfo system.Info // Host system info 26 | gpuManager *GPUManager // Manages GPU data 27 | cache *SessionCache // Cache for system stats based on primary session ID 28 | } 29 | 30 | func NewAgent() *Agent { 31 | agent := &Agent{ 32 | fsStats: make(map[string]*system.FsStats), 33 | cache: NewSessionCache(69 * time.Second), 34 | } 35 | agent.memCalc, _ = GetEnv("MEM_CALC") 36 | agent.sensorConfig = agent.newSensorConfig() 37 | // Set up slog with a log level determined by the LOG_LEVEL env var 38 | if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists { 39 | switch strings.ToLower(logLevelStr) { 40 | case "debug": 41 | agent.debug = true 42 | slog.SetLogLoggerLevel(slog.LevelDebug) 43 | case "warn": 44 | slog.SetLogLoggerLevel(slog.LevelWarn) 45 | case "error": 46 | slog.SetLogLoggerLevel(slog.LevelError) 47 | } 48 | } 49 | 50 | slog.Debug(beszel.Version) 51 | 52 | // initialize system info / docker manager 53 | agent.initializeSystemInfo() 54 | agent.initializeDiskInfo() 55 | agent.initializeNetIoStats() 56 | agent.dockerManager = newDockerManager(agent) 57 | 58 | // initialize GPU manager 59 | if gm, err := NewGPUManager(); err != nil { 60 | slog.Debug("GPU", "err", err) 61 | } else { 62 | agent.gpuManager = gm 63 | } 64 | 65 | // if debugging, print stats 66 | if agent.debug { 67 | slog.Debug("Stats", "data", agent.gatherStats("")) 68 | } 69 | 70 | return agent 71 | } 72 | 73 | // GetEnv retrieves an environment variable with a "BESZEL_AGENT_" prefix, or falls back to the unprefixed key. 74 | func GetEnv(key string) (value string, exists bool) { 75 | if value, exists = os.LookupEnv("BESZEL_AGENT_" + key); exists { 76 | return value, exists 77 | } 78 | // Fallback to the old unprefixed key 79 | return os.LookupEnv(key) 80 | } 81 | 82 | func (a *Agent) gatherStats(sessionID string) *system.CombinedData { 83 | a.Lock() 84 | defer a.Unlock() 85 | 86 | cachedData, ok := a.cache.Get(sessionID) 87 | if ok { 88 | slog.Debug("Cached stats", "session", sessionID) 89 | return cachedData 90 | } 91 | 92 | *cachedData = system.CombinedData{ 93 | Stats: a.getSystemStats(), 94 | Info: a.systemInfo, 95 | } 96 | slog.Debug("System stats", "data", cachedData) 97 | 98 | if a.dockerManager != nil { 99 | if containerStats, err := a.dockerManager.getDockerStats(); err == nil { 100 | cachedData.Containers = containerStats 101 | slog.Debug("Docker stats", "data", cachedData.Containers) 102 | } else { 103 | slog.Debug("Docker stats", "err", err) 104 | } 105 | } 106 | 107 | cachedData.Stats.ExtraFs = make(map[string]*system.FsStats) 108 | for name, stats := range a.fsStats { 109 | if !stats.Root && stats.DiskTotal > 0 { 110 | cachedData.Stats.ExtraFs[name] = stats 111 | } 112 | } 113 | slog.Debug("Extra filesystems", "data", cachedData.Stats.ExtraFs) 114 | 115 | a.cache.Set(sessionID, cachedData) 116 | return cachedData 117 | } 118 | -------------------------------------------------------------------------------- /beszel/internal/agent/agent_cache.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "beszel/internal/entities/system" 5 | "time" 6 | ) 7 | 8 | // Not thread safe since we only access from gatherStats which is already locked 9 | type SessionCache struct { 10 | data *system.CombinedData 11 | lastUpdate time.Time 12 | primarySession string 13 | leaseTime time.Duration 14 | } 15 | 16 | func NewSessionCache(leaseTime time.Duration) *SessionCache { 17 | return &SessionCache{ 18 | leaseTime: leaseTime, 19 | data: &system.CombinedData{}, 20 | } 21 | } 22 | 23 | func (c *SessionCache) Get(sessionID string) (stats *system.CombinedData, isCached bool) { 24 | if sessionID != c.primarySession && time.Since(c.lastUpdate) < c.leaseTime { 25 | return c.data, true 26 | } 27 | return c.data, false 28 | } 29 | 30 | func (c *SessionCache) Set(sessionID string, data *system.CombinedData) { 31 | if data != nil { 32 | *c.data = *data 33 | } 34 | c.primarySession = sessionID 35 | c.lastUpdate = time.Now() 36 | } 37 | -------------------------------------------------------------------------------- /beszel/internal/agent/agent_cache_test.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "beszel/internal/entities/system" 5 | "testing" 6 | "testing/synctest" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestSessionCache_GetSet(t *testing.T) { 14 | synctest.Run(func() { 15 | cache := NewSessionCache(69 * time.Second) 16 | 17 | testData := &system.CombinedData{ 18 | Info: system.Info{ 19 | Hostname: "test-host", 20 | Cores: 4, 21 | }, 22 | Stats: system.Stats{ 23 | Cpu: 50.0, 24 | MemPct: 30.0, 25 | DiskPct: 40.0, 26 | }, 27 | } 28 | 29 | // Test initial state - should not be cached 30 | data, isCached := cache.Get("session1") 31 | assert.False(t, isCached, "Expected no cached data initially") 32 | assert.NotNil(t, data, "Expected data to be initialized") 33 | // Set data for session1 34 | cache.Set("session1", testData) 35 | 36 | time.Sleep(15 * time.Second) 37 | 38 | // Get data for a different session - should be cached 39 | data, isCached = cache.Get("session2") 40 | assert.True(t, isCached, "Expected data to be cached for non-primary session") 41 | require.NotNil(t, data, "Expected cached data to be returned") 42 | assert.Equal(t, "test-host", data.Info.Hostname, "Hostname should match test data") 43 | assert.Equal(t, 4, data.Info.Cores, "Cores should match test data") 44 | assert.Equal(t, 50.0, data.Stats.Cpu, "CPU should match test data") 45 | assert.Equal(t, 30.0, data.Stats.MemPct, "Memory percentage should match test data") 46 | assert.Equal(t, 40.0, data.Stats.DiskPct, "Disk percentage should match test data") 47 | 48 | time.Sleep(10 * time.Second) 49 | 50 | // Get data for the primary session - should not be cached 51 | data, isCached = cache.Get("session1") 52 | assert.False(t, isCached, "Expected data not to be cached for primary session") 53 | require.NotNil(t, data, "Expected data to be returned even if not cached") 54 | assert.Equal(t, "test-host", data.Info.Hostname, "Hostname should match test data") 55 | // if not cached, agent will update the data 56 | cache.Set("session1", testData) 57 | 58 | time.Sleep(45 * time.Second) 59 | 60 | // Get data for a different session - should still be cached 61 | _, isCached = cache.Get("session2") 62 | assert.True(t, isCached, "Expected data to be cached for non-primary session") 63 | 64 | // Wait for the lease to expire 65 | time.Sleep(30 * time.Second) 66 | 67 | // Get data for session2 - should not be cached 68 | _, isCached = cache.Get("session2") 69 | assert.False(t, isCached, "Expected data not to be cached after lease expiration") 70 | }) 71 | } 72 | 73 | func TestSessionCache_NilData(t *testing.T) { 74 | // Create a new SessionCache 75 | cache := NewSessionCache(30 * time.Second) 76 | 77 | // Test setting nil data (should not panic) 78 | assert.NotPanics(t, func() { 79 | cache.Set("session1", nil) 80 | }, "Setting nil data should not panic") 81 | 82 | // Get data - should not be nil even though we set nil 83 | data, _ := cache.Get("session2") 84 | assert.NotNil(t, data, "Expected data to not be nil after setting nil data") 85 | } 86 | -------------------------------------------------------------------------------- /beszel/internal/agent/health.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "net" 5 | "time" 6 | ) 7 | 8 | // Health checks if the agent's server is running by attempting to connect to it. 9 | // 10 | // If an error occurs when attempting to connect to the server, it returns the error. 11 | func Health(addr string, network string) error { 12 | conn, err := net.DialTimeout(network, addr, 4*time.Second) 13 | if err != nil { 14 | return err 15 | } 16 | conn.Close() 17 | return nil 18 | } 19 | -------------------------------------------------------------------------------- /beszel/internal/agent/health_test.go: -------------------------------------------------------------------------------- 1 | //go:build testing 2 | // +build testing 3 | 4 | package agent_test 5 | 6 | import ( 7 | "fmt" 8 | "net" 9 | "os" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/require" 13 | 14 | "beszel/internal/agent" 15 | ) 16 | 17 | // setupTestServer creates a temporary server for testing 18 | func setupTestServer(t *testing.T) (string, func()) { 19 | // Create a temporary socket file for Unix socket testing 20 | tempSockFile := os.TempDir() + "/beszel_health_test.sock" 21 | 22 | // Clean up any existing socket file 23 | os.Remove(tempSockFile) 24 | 25 | // Create a listener 26 | listener, err := net.Listen("unix", tempSockFile) 27 | require.NoError(t, err, "Failed to create test listener") 28 | 29 | // Start a simple server in a goroutine 30 | go func() { 31 | conn, err := listener.Accept() 32 | if err != nil { 33 | return // Listener closed 34 | } 35 | defer conn.Close() 36 | // Just accept the connection and do nothing 37 | }() 38 | 39 | // Return the socket file path and a cleanup function 40 | return tempSockFile, func() { 41 | listener.Close() 42 | os.Remove(tempSockFile) 43 | } 44 | } 45 | 46 | // setupTCPTestServer creates a temporary TCP server for testing 47 | func setupTCPTestServer(t *testing.T) (string, func()) { 48 | // Listen on a random available port 49 | listener, err := net.Listen("tcp", "127.0.0.1:0") 50 | require.NoError(t, err, "Failed to create test listener") 51 | 52 | // Get the port that was assigned 53 | addr := listener.Addr().(*net.TCPAddr) 54 | port := addr.Port 55 | 56 | // Start a simple server in a goroutine 57 | go func() { 58 | conn, err := listener.Accept() 59 | if err != nil { 60 | return // Listener closed 61 | } 62 | defer conn.Close() 63 | // Just accept the connection and do nothing 64 | }() 65 | 66 | // Return the address and a cleanup function 67 | return fmt.Sprintf("127.0.0.1:%d", port), func() { 68 | listener.Close() 69 | } 70 | } 71 | 72 | func TestHealth(t *testing.T) { 73 | t.Run("server is running (unix socket)", func(t *testing.T) { 74 | // Setup a test server 75 | sockFile, cleanup := setupTestServer(t) 76 | defer cleanup() 77 | 78 | // Run the health check with explicit parameters 79 | err := agent.Health(sockFile, "unix") 80 | require.NoError(t, err, "Failed to check health") 81 | }) 82 | 83 | t.Run("server is running (tcp address)", func(t *testing.T) { 84 | // Setup a test server 85 | addr, cleanup := setupTCPTestServer(t) 86 | defer cleanup() 87 | 88 | // Run the health check with explicit parameters 89 | err := agent.Health(addr, "tcp") 90 | require.NoError(t, err, "Failed to check health") 91 | }) 92 | 93 | t.Run("server is not running", func(t *testing.T) { 94 | // Use an address that's likely not in use 95 | addr := "127.0.0.1:65535" 96 | 97 | // Run the health check with explicit parameters 98 | err := agent.Health(addr, "tcp") 99 | require.Error(t, err, "Health check should return an error when server is not running") 100 | }) 101 | 102 | t.Run("invalid network", func(t *testing.T) { 103 | // Use an invalid network type 104 | err := agent.Health("127.0.0.1:8080", "invalid_network") 105 | require.Error(t, err, "Health check should return an error with invalid network") 106 | }) 107 | 108 | t.Run("unix socket not found", func(t *testing.T) { 109 | // Use a non-existent unix socket 110 | nonExistentSocket := os.TempDir() + "/non_existent_socket.sock" 111 | 112 | // Make sure it really doesn't exist 113 | os.Remove(nonExistentSocket) 114 | 115 | err := agent.Health(nonExistentSocket, "unix") 116 | require.Error(t, err, "Health check should return an error when socket doesn't exist") 117 | }) 118 | } 119 | -------------------------------------------------------------------------------- /beszel/internal/agent/network.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "log/slog" 5 | "strings" 6 | "time" 7 | 8 | psutilNet "github.com/shirou/gopsutil/v4/net" 9 | ) 10 | 11 | func (a *Agent) initializeNetIoStats() { 12 | // reset valid network interfaces 13 | a.netInterfaces = make(map[string]struct{}, 0) 14 | 15 | // map of network interface names passed in via NICS env var 16 | var nicsMap map[string]struct{} 17 | nics, nicsEnvExists := GetEnv("NICS") 18 | if nicsEnvExists { 19 | nicsMap = make(map[string]struct{}, 0) 20 | for nic := range strings.SplitSeq(nics, ",") { 21 | nicsMap[nic] = struct{}{} 22 | } 23 | } 24 | 25 | // reset network I/O stats 26 | a.netIoStats.BytesSent = 0 27 | a.netIoStats.BytesRecv = 0 28 | 29 | // get intial network I/O stats 30 | if netIO, err := psutilNet.IOCounters(true); err == nil { 31 | a.netIoStats.Time = time.Now() 32 | for _, v := range netIO { 33 | switch { 34 | // skip if nics exists and the interface is not in the list 35 | case nicsEnvExists: 36 | if _, nameInNics := nicsMap[v.Name]; !nameInNics { 37 | continue 38 | } 39 | // otherwise run the interface name through the skipNetworkInterface function 40 | default: 41 | if a.skipNetworkInterface(v) { 42 | continue 43 | } 44 | } 45 | slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv) 46 | a.netIoStats.BytesSent += v.BytesSent 47 | a.netIoStats.BytesRecv += v.BytesRecv 48 | // store as a valid network interface 49 | a.netInterfaces[v.Name] = struct{}{} 50 | } 51 | } 52 | } 53 | 54 | func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool { 55 | switch { 56 | case strings.HasPrefix(v.Name, "lo"), 57 | strings.HasPrefix(v.Name, "docker"), 58 | strings.HasPrefix(v.Name, "br-"), 59 | strings.HasPrefix(v.Name, "veth"), 60 | v.BytesRecv == 0, 61 | v.BytesSent == 0: 62 | return true 63 | default: 64 | return false 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /beszel/internal/agent/server.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "beszel/internal/common" 5 | "encoding/json" 6 | "fmt" 7 | "log/slog" 8 | "net" 9 | "os" 10 | "strings" 11 | 12 | "github.com/gliderlabs/ssh" 13 | gossh "golang.org/x/crypto/ssh" 14 | ) 15 | 16 | type ServerOptions struct { 17 | Addr string 18 | Network string 19 | Keys []gossh.PublicKey 20 | } 21 | 22 | func (a *Agent) StartServer(opts ServerOptions) error { 23 | slog.Info("Starting SSH server", "addr", opts.Addr, "network", opts.Network) 24 | 25 | if opts.Network == "unix" { 26 | // remove existing socket file if it exists 27 | if err := os.Remove(opts.Addr); err != nil && !os.IsNotExist(err) { 28 | return err 29 | } 30 | } 31 | 32 | // start listening on the address 33 | ln, err := net.Listen(opts.Network, opts.Addr) 34 | if err != nil { 35 | return err 36 | } 37 | defer ln.Close() 38 | 39 | // base config (limit to allowed algorithms) 40 | config := &gossh.ServerConfig{} 41 | config.KeyExchanges = common.DefaultKeyExchanges 42 | config.MACs = common.DefaultMACs 43 | config.Ciphers = common.DefaultCiphers 44 | 45 | // set default handler 46 | ssh.Handle(a.handleSession) 47 | 48 | server := ssh.Server{ 49 | ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig { 50 | return config 51 | }, 52 | // check public key(s) 53 | PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool { 54 | for _, pubKey := range opts.Keys { 55 | if ssh.KeysEqual(key, pubKey) { 56 | return true 57 | } 58 | } 59 | return false 60 | }, 61 | // disable pty 62 | PtyCallback: func(ctx ssh.Context, pty ssh.Pty) bool { 63 | return false 64 | }, 65 | // log failed connections 66 | ConnectionFailedCallback: func(conn net.Conn, err error) { 67 | slog.Warn("Failed connection attempt", "addr", conn.RemoteAddr().String(), "err", err) 68 | }, 69 | } 70 | 71 | // Start SSH server on the listener 72 | return server.Serve(ln) 73 | } 74 | 75 | func (a *Agent) handleSession(s ssh.Session) { 76 | slog.Debug("New session", "client", s.RemoteAddr()) 77 | stats := a.gatherStats(s.Context().SessionID()) 78 | if err := json.NewEncoder(s).Encode(stats); err != nil { 79 | slog.Error("Error encoding stats", "err", err, "stats", stats) 80 | s.Exit(1) 81 | return 82 | } 83 | s.Exit(0) 84 | } 85 | 86 | // ParseKeys parses a string containing SSH public keys in authorized_keys format. 87 | // It returns a slice of ssh.PublicKey and an error if any key fails to parse. 88 | func ParseKeys(input string) ([]gossh.PublicKey, error) { 89 | var parsedKeys []gossh.PublicKey 90 | for line := range strings.Lines(input) { 91 | line = strings.TrimSpace(line) 92 | // Skip empty lines or comments 93 | if len(line) == 0 || strings.HasPrefix(line, "#") { 94 | continue 95 | } 96 | // Parse the key 97 | parsedKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(line)) 98 | if err != nil { 99 | return nil, fmt.Errorf("failed to parse key: %s, error: %w", line, err) 100 | } 101 | parsedKeys = append(parsedKeys, parsedKey) 102 | } 103 | return parsedKeys, nil 104 | } 105 | 106 | // GetAddress gets the address to listen on or connect to from environment variables or default value. 107 | func GetAddress(addr string) string { 108 | if addr == "" { 109 | addr, _ = GetEnv("LISTEN") 110 | } 111 | if addr == "" { 112 | // Legacy PORT environment variable support 113 | addr, _ = GetEnv("PORT") 114 | } 115 | if addr == "" { 116 | return ":45876" 117 | } 118 | // prefix with : if only port was provided 119 | if GetNetwork(addr) != "unix" && !strings.Contains(addr, ":") { 120 | addr = ":" + addr 121 | } 122 | return addr 123 | } 124 | 125 | // GetNetwork returns the network type to use based on the address 126 | func GetNetwork(addr string) string { 127 | if network, ok := GetEnv("NETWORK"); ok && network != "" { 128 | return network 129 | } 130 | if strings.HasPrefix(addr, "/") { 131 | return "unix" 132 | } 133 | return "tcp" 134 | } 135 | -------------------------------------------------------------------------------- /beszel/internal/agent/update.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "beszel" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/blang/semver" 10 | "github.com/rhysd/go-github-selfupdate/selfupdate" 11 | ) 12 | 13 | // Update updates beszel-agent to the latest version 14 | func Update() { 15 | var latest *selfupdate.Release 16 | var found bool 17 | var err error 18 | currentVersion := semver.MustParse(beszel.Version) 19 | fmt.Println("beszel-agent", currentVersion) 20 | fmt.Println("Checking for updates...") 21 | updater, _ := selfupdate.NewUpdater(selfupdate.Config{ 22 | Filters: []string{"beszel-agent"}, 23 | }) 24 | latest, found, err = updater.DetectLatest("henrygd/beszel") 25 | 26 | if err != nil { 27 | fmt.Println("Error checking for updates:", err) 28 | os.Exit(1) 29 | } 30 | 31 | if !found { 32 | fmt.Println("No updates found") 33 | os.Exit(0) 34 | } 35 | 36 | fmt.Println("Latest version:", latest.Version) 37 | 38 | if latest.Version.LTE(currentVersion) { 39 | fmt.Println("You are up to date") 40 | return 41 | } 42 | 43 | var binaryPath string 44 | fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version) 45 | binaryPath, err = os.Executable() 46 | if err != nil { 47 | fmt.Println("Error getting binary path:", err) 48 | os.Exit(1) 49 | } 50 | err = selfupdate.UpdateTo(latest.AssetURL, binaryPath) 51 | if err != nil { 52 | fmt.Println("Please try rerunning with sudo. Error:", err) 53 | os.Exit(1) 54 | } 55 | fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes)) 56 | } 57 | -------------------------------------------------------------------------------- /beszel/internal/agent/utils.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import "math" 4 | 5 | func bytesToMegabytes(b float64) float64 { 6 | return twoDecimals(b / 1048576) 7 | } 8 | 9 | func bytesToGigabytes(b uint64) float64 { 10 | return twoDecimals(float64(b) / 1073741824) 11 | } 12 | 13 | func twoDecimals(value float64) float64 { 14 | return math.Round(value*100) / 100 15 | } 16 | -------------------------------------------------------------------------------- /beszel/internal/common/common.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | var ( 4 | DefaultKeyExchanges = []string{"curve25519-sha256"} 5 | DefaultMACs = []string{"hmac-sha2-256-etm@openssh.com"} 6 | DefaultCiphers = []string{"chacha20-poly1305@openssh.com"} 7 | ) 8 | -------------------------------------------------------------------------------- /beszel/internal/entities/container/container.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import "time" 4 | 5 | // Docker container info from /containers/json 6 | type ApiInfo struct { 7 | Id string 8 | IdShort string 9 | Names []string 10 | Status string 11 | // Image string 12 | // ImageID string 13 | // Command string 14 | // Created int64 15 | // Ports []Port 16 | // SizeRw int64 `json:",omitempty"` 17 | // SizeRootFs int64 `json:",omitempty"` 18 | // Labels map[string]string 19 | // State string 20 | // HostConfig struct { 21 | // NetworkMode string `json:",omitempty"` 22 | // Annotations map[string]string `json:",omitempty"` 23 | // } 24 | // NetworkSettings *SummaryNetworkSettings 25 | // Mounts []MountPoint 26 | } 27 | 28 | // Docker container resources from /containers/{id}/stats 29 | type ApiStats struct { 30 | Read time.Time `json:"read"` // Time of stats generation 31 | NumProcs uint32 `json:"num_procs,omitzero"` // Windows specific, not populated on Linux. 32 | Networks map[string]NetworkStats 33 | CPUStats CPUStats `json:"cpu_stats"` 34 | MemoryStats MemoryStats `json:"memory_stats"` 35 | } 36 | 37 | func (s *ApiStats) CalculateCpuPercentLinux(prevCpuUsage [2]uint64) float64 { 38 | cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuUsage[0] 39 | systemDelta := s.CPUStats.SystemUsage - prevCpuUsage[1] 40 | return float64(cpuDelta) / float64(systemDelta) * 100 41 | } 42 | 43 | // from: https://github.com/docker/cli/blob/master/cli/command/container/stats_helpers.go#L185 44 | func (s *ApiStats) CalculateCpuPercentWindows(prevCpuUsage uint64, prevRead time.Time) float64 { 45 | // Max number of 100ns intervals between the previous time read and now 46 | possIntervals := uint64(s.Read.Sub(prevRead).Nanoseconds()) 47 | possIntervals /= 100 // Convert to number of 100ns intervals 48 | possIntervals *= uint64(s.NumProcs) // Multiple by the number of processors 49 | 50 | // Intervals used 51 | intervalsUsed := s.CPUStats.CPUUsage.TotalUsage - prevCpuUsage 52 | 53 | // Percentage avoiding divide-by-zero 54 | if possIntervals > 0 { 55 | return float64(intervalsUsed) / float64(possIntervals) * 100.0 56 | } 57 | return 0.00 58 | } 59 | 60 | type CPUStats struct { 61 | // CPU Usage. Linux and Windows. 62 | CPUUsage CPUUsage `json:"cpu_usage"` 63 | // System Usage. Linux only. 64 | SystemUsage uint64 `json:"system_cpu_usage,omitempty"` 65 | } 66 | 67 | type CPUUsage struct { 68 | // Total CPU time consumed. 69 | // Units: nanoseconds (Linux) 70 | // Units: 100's of nanoseconds (Windows) 71 | TotalUsage uint64 `json:"total_usage"` 72 | } 73 | 74 | type MemoryStats struct { 75 | // current res_counter usage for memory 76 | Usage uint64 `json:"usage,omitempty"` 77 | // all the stats exported via memory.stat. 78 | Stats MemoryStatsStats `json:"stats"` 79 | // private working set (Windows only) 80 | PrivateWorkingSet uint64 `json:"privateworkingset,omitempty"` 81 | } 82 | 83 | type MemoryStatsStats struct { 84 | Cache uint64 `json:"cache,omitempty"` 85 | InactiveFile uint64 `json:"inactive_file,omitempty"` 86 | } 87 | 88 | type NetworkStats struct { 89 | // Bytes received. Windows and Linux. 90 | RxBytes uint64 `json:"rx_bytes"` 91 | // Bytes sent. Windows and Linux. 92 | TxBytes uint64 `json:"tx_bytes"` 93 | } 94 | 95 | type prevNetStats struct { 96 | Sent uint64 97 | Recv uint64 98 | } 99 | 100 | // Docker container stats 101 | type Stats struct { 102 | Name string `json:"n"` 103 | Cpu float64 `json:"c"` 104 | Mem float64 `json:"m"` 105 | NetworkSent float64 `json:"ns"` 106 | NetworkRecv float64 `json:"nr"` 107 | PrevCpu [2]uint64 `json:"-"` 108 | PrevNet prevNetStats `json:"-"` 109 | PrevRead time.Time `json:"-"` 110 | } 111 | -------------------------------------------------------------------------------- /beszel/internal/entities/system/system.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | // TODO: this is confusing, make common package with common/types common/helpers etc 4 | 5 | import ( 6 | "beszel/internal/entities/container" 7 | "time" 8 | ) 9 | 10 | type Stats struct { 11 | Cpu float64 `json:"cpu"` 12 | MaxCpu float64 `json:"cpum,omitempty"` 13 | Mem float64 `json:"m"` 14 | MemUsed float64 `json:"mu"` 15 | MemPct float64 `json:"mp"` 16 | MemBuffCache float64 `json:"mb"` 17 | MemZfsArc float64 `json:"mz,omitempty"` // ZFS ARC memory 18 | Swap float64 `json:"s,omitempty"` 19 | SwapUsed float64 `json:"su,omitempty"` 20 | DiskTotal float64 `json:"d"` 21 | DiskUsed float64 `json:"du"` 22 | DiskPct float64 `json:"dp"` 23 | DiskReadPs float64 `json:"dr"` 24 | DiskWritePs float64 `json:"dw"` 25 | MaxDiskReadPs float64 `json:"drm,omitempty"` 26 | MaxDiskWritePs float64 `json:"dwm,omitempty"` 27 | NetworkSent float64 `json:"ns"` 28 | NetworkRecv float64 `json:"nr"` 29 | MaxNetworkSent float64 `json:"nsm,omitempty"` 30 | MaxNetworkRecv float64 `json:"nrm,omitempty"` 31 | Temperatures map[string]float64 `json:"t,omitempty"` 32 | ExtraFs map[string]*FsStats `json:"efs,omitempty"` 33 | GPUData map[string]GPUData `json:"g,omitempty"` 34 | } 35 | 36 | type GPUData struct { 37 | Name string `json:"n"` 38 | Temperature float64 `json:"-"` 39 | MemoryUsed float64 `json:"mu,omitempty"` 40 | MemoryTotal float64 `json:"mt,omitempty"` 41 | Usage float64 `json:"u"` 42 | Power float64 `json:"p,omitempty"` 43 | Count float64 `json:"-"` 44 | } 45 | 46 | type FsStats struct { 47 | Time time.Time `json:"-"` 48 | Root bool `json:"-"` 49 | Mountpoint string `json:"-"` 50 | DiskTotal float64 `json:"d"` 51 | DiskUsed float64 `json:"du"` 52 | TotalRead uint64 `json:"-"` 53 | TotalWrite uint64 `json:"-"` 54 | DiskReadPs float64 `json:"r"` 55 | DiskWritePs float64 `json:"w"` 56 | MaxDiskReadPS float64 `json:"rm,omitempty"` 57 | MaxDiskWritePS float64 `json:"wm,omitempty"` 58 | } 59 | 60 | type NetIoStats struct { 61 | BytesRecv uint64 62 | BytesSent uint64 63 | Time time.Time 64 | Name string 65 | } 66 | 67 | type Os uint8 68 | 69 | const ( 70 | Linux Os = iota 71 | Darwin 72 | Windows 73 | Freebsd 74 | ) 75 | 76 | type Info struct { 77 | Hostname string `json:"h"` 78 | KernelVersion string `json:"k,omitempty"` 79 | Cores int `json:"c"` 80 | Threads int `json:"t,omitempty"` 81 | CpuModel string `json:"m"` 82 | Uptime uint64 `json:"u"` 83 | Cpu float64 `json:"cpu"` 84 | MemPct float64 `json:"mp"` 85 | DiskPct float64 `json:"dp"` 86 | Bandwidth float64 `json:"b"` 87 | AgentVersion string `json:"v"` 88 | Podman bool `json:"p,omitempty"` 89 | GpuPct float64 `json:"g,omitempty"` 90 | DashboardTemp float64 `json:"dt,omitempty"` 91 | Os Os `json:"os"` 92 | } 93 | 94 | // Final data structure to return to the hub 95 | type CombinedData struct { 96 | Stats Stats `json:"stats"` 97 | Info Info `json:"info"` 98 | Containers []*container.Stats `json:"container"` 99 | } 100 | -------------------------------------------------------------------------------- /beszel/internal/hub/systems/systems_test_helpers.go: -------------------------------------------------------------------------------- 1 | //go:build testing 2 | // +build testing 3 | 4 | package systems 5 | 6 | import ( 7 | entities "beszel/internal/entities/system" 8 | "context" 9 | "fmt" 10 | ) 11 | 12 | // GetSystemCount returns the number of systems in the store 13 | func (sm *SystemManager) GetSystemCount() int { 14 | return sm.systems.Length() 15 | } 16 | 17 | // HasSystem checks if a system with the given ID exists in the store 18 | func (sm *SystemManager) HasSystem(systemID string) bool { 19 | return sm.systems.Has(systemID) 20 | } 21 | 22 | // GetSystemStatusFromStore returns the status of a system with the given ID 23 | // Returns an empty string if the system doesn't exist 24 | func (sm *SystemManager) GetSystemStatusFromStore(systemID string) string { 25 | sys, ok := sm.systems.GetOk(systemID) 26 | if !ok { 27 | return "" 28 | } 29 | return sys.Status 30 | } 31 | 32 | // GetSystemContextFromStore returns the context and cancel function for a system 33 | func (sm *SystemManager) GetSystemContextFromStore(systemID string) (context.Context, context.CancelFunc, error) { 34 | sys, ok := sm.systems.GetOk(systemID) 35 | if !ok { 36 | return nil, nil, fmt.Errorf("no system") 37 | } 38 | return sys.ctx, sys.cancel, nil 39 | } 40 | 41 | // GetSystemFromStore returns a store from the system 42 | func (sm *SystemManager) GetSystemFromStore(systemID string) (*System, error) { 43 | sys, ok := sm.systems.GetOk(systemID) 44 | if !ok { 45 | return nil, fmt.Errorf("no system") 46 | } 47 | return sys, nil 48 | } 49 | 50 | // GetAllSystemIDs returns a slice of all system IDs in the store 51 | func (sm *SystemManager) GetAllSystemIDs() []string { 52 | data := sm.systems.GetAll() 53 | ids := make([]string, 0, len(data)) 54 | for id := range data { 55 | ids = append(ids, id) 56 | } 57 | return ids 58 | } 59 | 60 | // GetSystemData returns the combined data for a system with the given ID 61 | // Returns nil if the system doesn't exist 62 | // This method is intended for testing 63 | func (sm *SystemManager) GetSystemData(systemID string) *entities.CombinedData { 64 | sys, ok := sm.systems.GetOk(systemID) 65 | if !ok { 66 | return nil 67 | } 68 | return sys.data 69 | } 70 | 71 | // GetSystemHostPort returns the host and port for a system with the given ID 72 | // Returns empty strings if the system doesn't exist 73 | func (sm *SystemManager) GetSystemHostPort(systemID string) (string, string) { 74 | sys, ok := sm.systems.GetOk(systemID) 75 | if !ok { 76 | return "", "" 77 | } 78 | return sys.Host, sys.Port 79 | } 80 | 81 | // DisableAutoUpdater disables the automatic updater for a system 82 | // This is intended for testing 83 | // Returns false if the system doesn't exist 84 | // func (sm *SystemManager) DisableAutoUpdater(systemID string) bool { 85 | // sys, ok := sm.systems.GetOk(systemID) 86 | // if !ok { 87 | // return false 88 | // } 89 | // if sys.cancel != nil { 90 | // sys.cancel() 91 | // sys.cancel = nil 92 | // } 93 | // return true 94 | // } 95 | 96 | // SetSystemStatusInDB sets the status of a system directly and updates the database record 97 | // This is intended for testing 98 | // Returns false if the system doesn't exist 99 | func (sm *SystemManager) SetSystemStatusInDB(systemID string, status string) bool { 100 | if !sm.HasSystem(systemID) { 101 | return false 102 | } 103 | 104 | // Update the database record 105 | record, err := sm.hub.FindRecordById("systems", systemID) 106 | if err != nil { 107 | return false 108 | } 109 | 110 | record.Set("status", status) 111 | err = sm.hub.Save(record) 112 | if err != nil { 113 | return false 114 | } 115 | 116 | return true 117 | } 118 | -------------------------------------------------------------------------------- /beszel/internal/hub/update.go: -------------------------------------------------------------------------------- 1 | package hub 2 | 3 | import ( 4 | "beszel" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/blang/semver" 10 | "github.com/rhysd/go-github-selfupdate/selfupdate" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // Update updates beszel to the latest version 15 | func Update(_ *cobra.Command, _ []string) { 16 | var latest *selfupdate.Release 17 | var found bool 18 | var err error 19 | currentVersion := semver.MustParse(beszel.Version) 20 | fmt.Println("beszel", currentVersion) 21 | fmt.Println("Checking for updates...") 22 | updater, _ := selfupdate.NewUpdater(selfupdate.Config{ 23 | Filters: []string{"beszel_"}, 24 | }) 25 | latest, found, err = updater.DetectLatest("henrygd/beszel") 26 | 27 | if err != nil { 28 | fmt.Println("Error checking for updates:", err) 29 | os.Exit(1) 30 | } 31 | 32 | if !found { 33 | fmt.Println("No updates found") 34 | os.Exit(0) 35 | } 36 | 37 | fmt.Println("Latest version:", latest.Version) 38 | 39 | if latest.Version.LTE(currentVersion) { 40 | fmt.Println("You are up to date") 41 | return 42 | } 43 | 44 | var binaryPath string 45 | fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version) 46 | binaryPath, err = os.Executable() 47 | if err != nil { 48 | fmt.Println("Error getting binary path:", err) 49 | os.Exit(1) 50 | } 51 | err = selfupdate.UpdateTo(latest.AssetURL, binaryPath) 52 | if err != nil { 53 | fmt.Println("Please try rerunning with sudo. Error:", err) 54 | os.Exit(1) 55 | } 56 | fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes)) 57 | } 58 | -------------------------------------------------------------------------------- /beszel/internal/tests/hub.go: -------------------------------------------------------------------------------- 1 | // Package tests provides helpers for testing the application. 2 | package tests 3 | 4 | import ( 5 | "beszel/internal/hub" 6 | 7 | "github.com/pocketbase/pocketbase/core" 8 | "github.com/pocketbase/pocketbase/tests" 9 | 10 | _ "github.com/pocketbase/pocketbase/migrations" 11 | ) 12 | 13 | // TestHub is a wrapper hub instance used for testing. 14 | type TestHub struct { 15 | core.App 16 | *tests.TestApp 17 | *hub.Hub 18 | } 19 | 20 | // NewTestHub creates and initializes a test application instance. 21 | // 22 | // It is the caller's responsibility to call app.Cleanup() when the app is no longer needed. 23 | func NewTestHub(optTestDataDir ...string) (*TestHub, error) { 24 | var testDataDir string 25 | if len(optTestDataDir) > 0 { 26 | testDataDir = optTestDataDir[0] 27 | } 28 | 29 | return NewTestHubWithConfig(core.BaseAppConfig{ 30 | DataDir: testDataDir, 31 | EncryptionEnv: "pb_test_env", 32 | }) 33 | } 34 | 35 | // NewTestHubWithConfig creates and initializes a test application instance 36 | // from the provided config. 37 | // 38 | // If config.DataDir is not set it fallbacks to the default internal test data directory. 39 | // 40 | // config.DataDir is cloned for each new test application instance. 41 | // 42 | // It is the caller's responsibility to call app.Cleanup() when the app is no longer needed. 43 | func NewTestHubWithConfig(config core.BaseAppConfig) (*TestHub, error) { 44 | testApp, err := tests.NewTestAppWithConfig(config) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | hub := hub.NewHub(testApp) 50 | 51 | t := &TestHub{ 52 | App: testApp, 53 | TestApp: testApp, 54 | Hub: hub, 55 | } 56 | 57 | return t, nil 58 | } 59 | -------------------------------------------------------------------------------- /beszel/internal/users/users.go: -------------------------------------------------------------------------------- 1 | // Package users handles user-related custom functionality. 2 | package users 3 | 4 | import ( 5 | "beszel/migrations" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/pocketbase/pocketbase/core" 10 | ) 11 | 12 | type UserManager struct { 13 | app core.App 14 | } 15 | 16 | type UserSettings struct { 17 | ChartTime string `json:"chartTime"` 18 | NotificationEmails []string `json:"emails"` 19 | NotificationWebhooks []string `json:"webhooks"` 20 | // Language string `json:"lang"` 21 | } 22 | 23 | func NewUserManager(app core.App) *UserManager { 24 | return &UserManager{ 25 | app: app, 26 | } 27 | } 28 | 29 | // Initialize user role if not set 30 | func (um *UserManager) InitializeUserRole(e *core.RecordEvent) error { 31 | if e.Record.GetString("role") == "" { 32 | e.Record.Set("role", "user") 33 | } 34 | return e.Next() 35 | } 36 | 37 | // Initialize user settings with defaults if not set 38 | func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error { 39 | record := e.Record 40 | // intialize settings with defaults 41 | settings := UserSettings{ 42 | // Language: "en", 43 | ChartTime: "1h", 44 | NotificationEmails: []string{}, 45 | NotificationWebhooks: []string{}, 46 | } 47 | record.UnmarshalJSONField("settings", &settings) 48 | if len(settings.NotificationEmails) == 0 { 49 | // get user email from auth record 50 | if errs := um.app.ExpandRecord(record, []string{"user"}, nil); len(errs) == 0 { 51 | // app.Logger().Error("failed to expand user relation", "errs", errs) 52 | if user := record.ExpandedOne("user"); user != nil { 53 | settings.NotificationEmails = []string{user.GetString("email")} 54 | } else { 55 | log.Println("Failed to get user email from auth record") 56 | } 57 | } else { 58 | log.Println("failed to expand user relation", "errs", errs) 59 | } 60 | } 61 | // if len(settings.NotificationWebhooks) == 0 { 62 | // settings.NotificationWebhooks = []string{""} 63 | // } 64 | record.Set("settings", settings) 65 | return e.Next() 66 | } 67 | 68 | // Custom API endpoint to create the first user. 69 | // Mimics previous default behavior in PocketBase < 0.23.0 allowing user to be created through the Beszel UI. 70 | func (um *UserManager) CreateFirstUser(e *core.RequestEvent) error { 71 | // check that there are no users 72 | totalUsers, err := um.app.CountRecords("users") 73 | if err != nil || totalUsers > 0 { 74 | return e.JSON(http.StatusForbidden, map[string]string{"err": "Forbidden"}) 75 | } 76 | // check that there is only one superuser and the email matches the email of the superuser we set up in initial-settings.go 77 | adminUsers, err := um.app.FindAllRecords(core.CollectionNameSuperusers) 78 | if err != nil || len(adminUsers) != 1 || adminUsers[0].GetString("email") != migrations.TempAdminEmail { 79 | return e.JSON(http.StatusForbidden, map[string]string{"err": "Forbidden"}) 80 | } 81 | // create first user using supplied email and password in request body 82 | data := struct { 83 | Email string `json:"email"` 84 | Password string `json:"password"` 85 | }{} 86 | if err := e.BindBody(&data); err != nil { 87 | return e.JSON(http.StatusBadRequest, map[string]string{"err": err.Error()}) 88 | } 89 | if data.Email == "" || data.Password == "" { 90 | return e.JSON(http.StatusBadRequest, map[string]string{"err": "Bad request"}) 91 | } 92 | 93 | collection, _ := um.app.FindCollectionByNameOrId("users") 94 | user := core.NewRecord(collection) 95 | user.SetEmail(data.Email) 96 | user.SetPassword(data.Password) 97 | user.Set("role", "admin") 98 | user.Set("verified", true) 99 | if err := um.app.Save(user); err != nil { 100 | return e.JSON(http.StatusInternalServerError, map[string]string{"err": err.Error()}) 101 | } 102 | // create superuser using the email of the first user 103 | collection, _ = um.app.FindCollectionByNameOrId(core.CollectionNameSuperusers) 104 | adminUser := core.NewRecord(collection) 105 | adminUser.SetEmail(data.Email) 106 | adminUser.SetPassword(data.Password) 107 | if err := um.app.Save(adminUser); err != nil { 108 | return e.JSON(http.StatusInternalServerError, map[string]string{"err": err.Error()}) 109 | } 110 | // delete the intial superuser 111 | if err := um.app.Delete(adminUsers[0]); err != nil { 112 | return e.JSON(http.StatusInternalServerError, map[string]string{"err": err.Error()}) 113 | } 114 | return e.JSON(http.StatusOK, map[string]string{"msg": "User created"}) 115 | } 116 | -------------------------------------------------------------------------------- /beszel/migrations/initial-settings.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/pocketbase/pocketbase/core" 5 | m "github.com/pocketbase/pocketbase/migrations" 6 | ) 7 | 8 | var ( 9 | TempAdminEmail = "_@b.b" 10 | ) 11 | 12 | func init() { 13 | m.Register(func(app core.App) error { 14 | // initial settings 15 | settings := app.Settings() 16 | settings.Meta.AppName = "Beszel" 17 | settings.Meta.HideControls = true 18 | settings.Logs.MinLevel = 4 19 | if err := app.Save(settings); err != nil { 20 | return err 21 | } 22 | // create superuser 23 | collection, _ := app.FindCollectionByNameOrId(core.CollectionNameSuperusers) 24 | user := core.NewRecord(collection) 25 | user.SetEmail(TempAdminEmail) 26 | user.SetRandomPassword() 27 | return app.Save(user) 28 | }, nil) 29 | } 30 | -------------------------------------------------------------------------------- /beszel/site/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /beszel/site/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "useTabs": true, 4 | "tabWidth": 2, 5 | "semi": false, 6 | "singleQuote": false, 7 | "printWidth": 120 8 | } 9 | -------------------------------------------------------------------------------- /beszel/site/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henrygd/beszel/68009c85a5e6a5badb8c135e1c9a09ab8d2e42c4/beszel/site/bun.lockb -------------------------------------------------------------------------------- /beszel/site/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "gray", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /beszel/site/embed.go: -------------------------------------------------------------------------------- 1 | // Package site handles the Beszel frontend embedding. 2 | package site 3 | 4 | import ( 5 | "embed" 6 | "io/fs" 7 | ) 8 | 9 | //go:embed all:dist 10 | var distDir embed.FS 11 | 12 | // DistDirFS contains the embedded dist directory files (without the "dist" prefix) 13 | var DistDirFS, _ = fs.Sub(distDir, "dist") 14 | -------------------------------------------------------------------------------- /beszel/site/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Beszel 9 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /beszel/site/lingui.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@lingui/cli" 2 | 3 | export default defineConfig({ 4 | locales: [ 5 | "en", 6 | "ar", 7 | "bg", 8 | "cs", 9 | "da", 10 | "de", 11 | "es", 12 | "fa", 13 | "fr", 14 | "hr", 15 | "hu", 16 | "it", 17 | "is", 18 | "ja", 19 | "ko", 20 | "nl", 21 | "no", 22 | "pl", 23 | "pt", 24 | "tr", 25 | "ru", 26 | "sl", 27 | "sv", 28 | "uk", 29 | "vi", 30 | "zh", 31 | "zh-CN", 32 | "zh-HK", 33 | ], 34 | sourceLocale: "en", 35 | compileNamespace: "ts", 36 | formatOptions: { 37 | lineNumbers: false, 38 | }, 39 | catalogs: [ 40 | { 41 | path: "/src/locales/{locale}/{locale}", 42 | include: ["src"], 43 | }, 44 | ], 45 | }) 46 | -------------------------------------------------------------------------------- /beszel/site/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "beszel", 3 | "private": true, 4 | "version": "0.11.1", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "lingui extract --overwrite && lingui compile && vite build", 9 | "preview": "vite preview", 10 | "sync": "lingui extract --overwrite && lingui compile", 11 | "sync_and_purge": "lingui extract --overwrite --clean && lingui compile" 12 | }, 13 | "dependencies": { 14 | "@henrygd/queue": "^1.0.7", 15 | "@henrygd/semaphore": "^0.0.2", 16 | "@lingui/detect-locale": "^5.2.0", 17 | "@lingui/macro": "^5.2.0", 18 | "@lingui/react": "^5.2.0", 19 | "@nanostores/react": "^0.7.3", 20 | "@nanostores/router": "^0.11.0", 21 | "@radix-ui/react-alert-dialog": "^1.1.6", 22 | "@radix-ui/react-checkbox": "^1.1.4", 23 | "@radix-ui/react-dialog": "^1.1.6", 24 | "@radix-ui/react-direction": "^1.1.0", 25 | "@radix-ui/react-dropdown-menu": "^2.1.6", 26 | "@radix-ui/react-label": "^2.1.2", 27 | "@radix-ui/react-select": "^2.1.6", 28 | "@radix-ui/react-separator": "^1.1.2", 29 | "@radix-ui/react-slider": "^1.2.3", 30 | "@radix-ui/react-slot": "^1.1.2", 31 | "@radix-ui/react-switch": "^1.1.3", 32 | "@radix-ui/react-tabs": "^1.1.3", 33 | "@radix-ui/react-toast": "^1.2.6", 34 | "@radix-ui/react-tooltip": "^1.1.8", 35 | "@tanstack/react-table": "^8.21.2", 36 | "class-variance-authority": "^0.7.1", 37 | "clsx": "^2.1.1", 38 | "cmdk": "^1.0.4", 39 | "d3-time": "^3.1.0", 40 | "lucide-react": "^0.452.0", 41 | "nanostores": "^0.11.4", 42 | "pocketbase": "^0.25.2", 43 | "react": "^18.3.1", 44 | "react-dom": "^18.3.1", 45 | "recharts": "^2.15.1", 46 | "tailwind-merge": "^2.6.0", 47 | "tailwindcss-animate": "^1.0.7", 48 | "valibot": "^0.42.0" 49 | }, 50 | "devDependencies": { 51 | "@lingui/cli": "^5.2.0", 52 | "@lingui/swc-plugin": "^5.5.0", 53 | "@lingui/vite-plugin": "^5.2.0", 54 | "@types/bun": "^1.2.4", 55 | "@types/react": "^18.3.1", 56 | "@types/react-dom": "^18.3.1", 57 | "@vitejs/plugin-react-swc": "^3.8.0", 58 | "autoprefixer": "^10.4.20", 59 | "postcss": "^8.5.3", 60 | "tailwindcss": "^3.4.17", 61 | "tailwindcss-rtl": "^0.9.0", 62 | "typescript": "^5.8.2", 63 | "vite": "^6.2.0" 64 | }, 65 | "overrides": { 66 | "@nanostores/router": { 67 | "nanostores": "^0.11.3" 68 | } 69 | }, 70 | "optionalDependencies": { 71 | "@esbuild/linux-arm64": "^0.21.5" 72 | } 73 | } -------------------------------------------------------------------------------- /beszel/site/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /beszel/site/public/static/InterVariable.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henrygd/beszel/68009c85a5e6a5badb8c135e1c9a09ab8d2e42c4/beszel/site/public/static/InterVariable.woff2 -------------------------------------------------------------------------------- /beszel/site/public/static/favicon-green.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /beszel/site/public/static/favicon-red.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /beszel/site/public/static/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /beszel/site/public/static/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henrygd/beszel/68009c85a5e6a5badb8c135e1c9a09ab8d2e42c4/beszel/site/public/static/icon.png -------------------------------------------------------------------------------- /beszel/site/public/static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Beszel", 3 | "icons": [ 4 | { 5 | "src": "icon.png", 6 | "sizes": "512x512", 7 | "type": "image/png" 8 | } 9 | ], 10 | "start_url": "../", 11 | "display": "standalone", 12 | "background_color": "#202225", 13 | "theme_color": "#202225" 14 | } 15 | -------------------------------------------------------------------------------- /beszel/site/src/components/charts/chart-time-select.tsx: -------------------------------------------------------------------------------- 1 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" 2 | import { $chartTime } from "@/lib/stores" 3 | import { chartTimeData, cn } from "@/lib/utils" 4 | import { ChartTimes } from "@/types" 5 | import { useStore } from "@nanostores/react" 6 | import { HistoryIcon } from "lucide-react" 7 | 8 | export default function ChartTimeSelect({ className }: { className?: string }) { 9 | const chartTime = useStore($chartTime) 10 | 11 | return ( 12 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /beszel/site/src/components/charts/disk-chart.tsx: -------------------------------------------------------------------------------- 1 | import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" 2 | import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" 3 | import { 4 | useYAxisWidth, 5 | cn, 6 | formatShortDate, 7 | decimalString, 8 | toFixedFloat, 9 | chartMargin, 10 | getSizeAndUnit, 11 | } from "@/lib/utils" 12 | import { ChartData } from "@/types" 13 | import { memo } from "react" 14 | import { useLingui } from "@lingui/react/macro" 15 | 16 | export default memo(function DiskChart({ 17 | dataKey, 18 | diskSize, 19 | chartData, 20 | }: { 21 | dataKey: string 22 | diskSize: number 23 | chartData: ChartData 24 | }) { 25 | const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() 26 | const { t } = useLingui() 27 | 28 | // round to nearest GB 29 | if (diskSize >= 100) { 30 | diskSize = Math.round(diskSize) 31 | } 32 | 33 | if (chartData.systemStats.length === 0) { 34 | return null 35 | } 36 | 37 | return ( 38 |
39 | 44 | 45 | 46 | { 57 | const { v, u } = getSizeAndUnit(value) 58 | return updateYAxisWidth(toFixedFloat(v, 2) + u) 59 | }} 60 | /> 61 | {xAxis(chartData)} 62 | formatShortDate(data[0].payload.created)} 68 | contentFormatter={({ value }) => { 69 | const { v, u } = getSizeAndUnit(value) 70 | return decimalString(v) + u 71 | }} 72 | /> 73 | } 74 | /> 75 | 85 | 86 | 87 |
88 | ) 89 | }) 90 | -------------------------------------------------------------------------------- /beszel/site/src/components/charts/gpu-power-chart.tsx: -------------------------------------------------------------------------------- 1 | import { CartesianGrid, Line, LineChart, YAxis } from "recharts" 2 | 3 | import { 4 | ChartContainer, 5 | ChartLegend, 6 | ChartLegendContent, 7 | ChartTooltip, 8 | ChartTooltipContent, 9 | xAxis, 10 | } from "@/components/ui/chart" 11 | import { 12 | useYAxisWidth, 13 | cn, 14 | formatShortDate, 15 | toFixedWithoutTrailingZeros, 16 | decimalString, 17 | chartMargin, 18 | } from "@/lib/utils" 19 | import { ChartData } from "@/types" 20 | import { memo, useMemo } from "react" 21 | 22 | export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData }) { 23 | const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() 24 | 25 | if (chartData.systemStats.length === 0) { 26 | return null 27 | } 28 | 29 | /** Format temperature data for chart and assign colors */ 30 | const newChartData = useMemo(() => { 31 | const newChartData = { data: [], colors: {} } as { 32 | data: Record[] 33 | colors: Record 34 | } 35 | const powerSums = {} as Record 36 | for (let data of chartData.systemStats) { 37 | let newData = { created: data.created } as Record 38 | 39 | for (let gpu of Object.values(data.stats?.g ?? {})) { 40 | if (gpu.p) { 41 | const name = gpu.n 42 | newData[name] = gpu.p 43 | powerSums[name] = (powerSums[name] ?? 0) + newData[name] 44 | } 45 | } 46 | newChartData.data.push(newData) 47 | } 48 | const keys = Object.keys(powerSums).sort((a, b) => powerSums[b] - powerSums[a]) 49 | for (let key of keys) { 50 | newChartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)` 51 | } 52 | return newChartData 53 | }, [chartData]) 54 | 55 | const colors = Object.keys(newChartData.colors) 56 | 57 | // console.log('rendered at', new Date()) 58 | 59 | return ( 60 |
61 | 66 | 67 | 68 | { 75 | const val = toFixedWithoutTrailingZeros(value, 2) 76 | return updateYAxisWidth(val + "W") 77 | }} 78 | tickLine={false} 79 | axisLine={false} 80 | /> 81 | {xAxis(chartData)} 82 | b.value - a.value} 87 | content={ 88 | formatShortDate(data[0].payload.created)} 90 | contentFormatter={(item) => decimalString(item.value) + "W"} 91 | // indicator="line" 92 | /> 93 | } 94 | /> 95 | {colors.map((key) => ( 96 | 106 | ))} 107 | {colors.length > 1 && } />} 108 | 109 | 110 |
111 | ) 112 | }) 113 | -------------------------------------------------------------------------------- /beszel/site/src/components/charts/mem-chart.tsx: -------------------------------------------------------------------------------- 1 | import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" 2 | import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" 3 | import { useYAxisWidth, cn, toFixedFloat, decimalString, formatShortDate, chartMargin } from "@/lib/utils" 4 | import { memo } from "react" 5 | import { ChartData } from "@/types" 6 | import { useLingui } from "@lingui/react/macro" 7 | 8 | export default memo(function MemChart({ chartData }: { chartData: ChartData }) { 9 | const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() 10 | const { t } = useLingui() 11 | 12 | const totalMem = toFixedFloat(chartData.systemStats.at(-1)?.stats.m ?? 0, 1) 13 | 14 | // console.log('rendered at', new Date()) 15 | 16 | if (chartData.systemStats.length === 0) { 17 | return null 18 | } 19 | 20 | return ( 21 |
22 | {/* {!yAxisSet && } */} 23 | 28 | 29 | 30 | {totalMem && ( 31 | { 42 | const val = toFixedFloat(value, 1) 43 | return updateYAxisWidth(val + " GB") 44 | }} 45 | /> 46 | )} 47 | {xAxis(chartData)} 48 | a.order - b.order} 56 | labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} 57 | contentFormatter={(item) => decimalString(item.value) + " GB"} 58 | // indicator="line" 59 | /> 60 | } 61 | /> 62 | 73 | {chartData.systemStats.at(-1)?.stats.mz && ( 74 | 85 | )} 86 | 98 | 99 | 100 |
101 | ) 102 | }) 103 | -------------------------------------------------------------------------------- /beszel/site/src/components/charts/swap-chart.tsx: -------------------------------------------------------------------------------- 1 | import { t } from "@lingui/core/macro"; 2 | 3 | import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" 4 | import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" 5 | import { 6 | useYAxisWidth, 7 | cn, 8 | formatShortDate, 9 | toFixedWithoutTrailingZeros, 10 | decimalString, 11 | chartMargin, 12 | } from "@/lib/utils" 13 | import { ChartData } from "@/types" 14 | import { memo } from "react" 15 | 16 | export default memo(function SwapChart({ chartData }: { chartData: ChartData }) { 17 | const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() 18 | 19 | if (chartData.systemStats.length === 0) { 20 | return null 21 | } 22 | 23 | return ( 24 |
25 | 30 | 31 | 32 | toFixedWithoutTrailingZeros(chartData.systemStats.at(-1)?.stats.s ?? 0.04, 2)]} 37 | width={yAxisWidth} 38 | tickLine={false} 39 | axisLine={false} 40 | tickFormatter={(value) => updateYAxisWidth(value + " GB")} 41 | /> 42 | {xAxis(chartData)} 43 | formatShortDate(data[0].payload.created)} 49 | contentFormatter={(item) => decimalString(item.value) + " GB"} 50 | // indicator="line" 51 | /> 52 | } 53 | /> 54 | 63 | 64 | 65 |
66 | ) 67 | }) 68 | -------------------------------------------------------------------------------- /beszel/site/src/components/charts/temperature-chart.tsx: -------------------------------------------------------------------------------- 1 | import { CartesianGrid, Line, LineChart, YAxis } from "recharts" 2 | 3 | import { 4 | ChartContainer, 5 | ChartLegend, 6 | ChartLegendContent, 7 | ChartTooltip, 8 | ChartTooltipContent, 9 | xAxis, 10 | } from "@/components/ui/chart" 11 | import { 12 | useYAxisWidth, 13 | cn, 14 | formatShortDate, 15 | toFixedWithoutTrailingZeros, 16 | decimalString, 17 | chartMargin, 18 | } from "@/lib/utils" 19 | import { ChartData } from "@/types" 20 | import { memo, useMemo } from "react" 21 | import { $temperatureFilter } from "@/lib/stores" 22 | import { useStore } from "@nanostores/react" 23 | 24 | export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) { 25 | const filter = useStore($temperatureFilter) 26 | const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() 27 | 28 | if (chartData.systemStats.length === 0) { 29 | return null 30 | } 31 | 32 | /** Format temperature data for chart and assign colors */ 33 | const newChartData = useMemo(() => { 34 | const newChartData = { data: [], colors: {} } as { 35 | data: Record[] 36 | colors: Record 37 | } 38 | const tempSums = {} as Record 39 | for (let data of chartData.systemStats) { 40 | let newData = { created: data.created } as Record 41 | let keys = Object.keys(data.stats?.t ?? {}) 42 | for (let i = 0; i < keys.length; i++) { 43 | let key = keys[i] 44 | newData[key] = data.stats.t![key] 45 | tempSums[key] = (tempSums[key] ?? 0) + newData[key] 46 | } 47 | newChartData.data.push(newData) 48 | } 49 | const keys = Object.keys(tempSums).sort((a, b) => tempSums[b] - tempSums[a]) 50 | for (let key of keys) { 51 | newChartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)` 52 | } 53 | return newChartData 54 | }, [chartData]) 55 | 56 | const colors = Object.keys(newChartData.colors) 57 | 58 | // console.log('rendered at', new Date()) 59 | 60 | return ( 61 |
62 | 67 | 68 | 69 | { 76 | const val = toFixedWithoutTrailingZeros(value, 2) 77 | return updateYAxisWidth(val + " °C") 78 | }} 79 | tickLine={false} 80 | axisLine={false} 81 | /> 82 | {xAxis(chartData)} 83 | b.value - a.value} 88 | content={ 89 | formatShortDate(data[0].payload.created)} 91 | contentFormatter={(item) => decimalString(item.value) + " °C"} 92 | filter={filter} 93 | /> 94 | } 95 | /> 96 | {colors.map((key) => { 97 | const filtered = filter && !key.toLowerCase().includes(filter.toLowerCase()) 98 | let strokeOpacity = filtered ? 0.1 : 1 99 | return ( 100 | 112 | ) 113 | })} 114 | {colors.length < 12 && } />} 115 | 116 | 117 |
118 | ) 119 | }) 120 | -------------------------------------------------------------------------------- /beszel/site/src/components/copy-to-clipboard.tsx: -------------------------------------------------------------------------------- 1 | import { Trans } from "@lingui/react/macro"; 2 | import { useEffect, useMemo, useRef } from "react" 3 | import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog" 4 | import { Textarea } from "./ui/textarea" 5 | import { $copyContent } from "@/lib/stores" 6 | 7 | export default function CopyToClipboard({ content }: { content: string }) { 8 | return ( 9 | 10 | 11 | 12 | 13 | Copy text 14 | 15 | 16 | Automatic copy requires a secure context. 17 | 18 | 19 | 20 | 21 | 22 | ) 23 | } 24 | 25 | function CopyTextarea({ content }: { content: string }) { 26 | const textareaRef = useRef(null) 27 | 28 | const rows = useMemo(() => { 29 | return content.split("\n").length 30 | }, [content]) 31 | 32 | useEffect(() => { 33 | if (textareaRef.current) { 34 | textareaRef.current.select() 35 | } 36 | }, [textareaRef]) 37 | 38 | useEffect(() => { 39 | return () => $copyContent.set("") 40 | }, []) 41 | 42 | return ( 43 |