├── .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 |
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 |
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/beszel/site/src/components/lang-toggle.tsx:
--------------------------------------------------------------------------------
1 | import { LanguagesIcon } from "lucide-react"
2 |
3 | import { Button } from "@/components/ui/button"
4 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
5 | import languages from "@/lib/languages"
6 | import { cn } from "@/lib/utils"
7 | import { useLingui } from "@lingui/react/macro"
8 | import { dynamicActivate } from "@/lib/i18n"
9 |
10 | export function LangToggle() {
11 | const { i18n } = useLingui()
12 |
13 | return (
14 |
15 |
16 |
20 |
21 |
22 | {languages.map(({ lang, label, e }) => (
23 | dynamicActivate(lang)}
27 | >
28 | {e} {label}
29 |
30 | ))}
31 |
32 |
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/beszel/site/src/components/login/forgot-pass-form.tsx:
--------------------------------------------------------------------------------
1 | import { Trans } from "@lingui/react/macro";
2 | import { t } from "@lingui/core/macro";
3 | import { LoaderCircle, MailIcon, SendHorizonalIcon } from "lucide-react"
4 | import { Input } from "../ui/input"
5 | import { Label } from "../ui/label"
6 | import { useCallback, useState } from "react"
7 | import { toast } from "../ui/use-toast"
8 | import { buttonVariants } from "../ui/button"
9 | import { cn } from "@/lib/utils"
10 | import { pb } from "@/lib/stores"
11 | import { Dialog, DialogHeader } from "../ui/dialog"
12 | import { DialogContent, DialogTrigger, DialogTitle } from "../ui/dialog"
13 |
14 | const showLoginFaliedToast = () => {
15 | toast({
16 | title: t`Login attempt failed`,
17 | description: t`Please check your credentials and try again`,
18 | variant: "destructive",
19 | })
20 | }
21 |
22 | export default function ForgotPassword() {
23 | const [isLoading, setIsLoading] = useState(false)
24 | const [email, setEmail] = useState("")
25 |
26 | const handleSubmit = useCallback(
27 | async (e: React.FormEvent) => {
28 | e.preventDefault()
29 | setIsLoading(true)
30 | try {
31 | // console.log(email)
32 | await pb.collection("users").requestPasswordReset(email)
33 | toast({
34 | title: t`Password reset request received`,
35 | description: t`Check ${email} for a reset link.`,
36 | })
37 | } catch (e) {
38 | showLoginFaliedToast()
39 | } finally {
40 | setIsLoading(false)
41 | setEmail("")
42 | }
43 | },
44 | [email]
45 | )
46 |
47 | return (
48 | <>
49 |
81 |
109 | >
110 | )
111 | }
112 |
--------------------------------------------------------------------------------
/beszel/site/src/components/login/login.tsx:
--------------------------------------------------------------------------------
1 | import { t } from "@lingui/core/macro";
2 | import { UserAuthForm } from "@/components/login/auth-form"
3 | import { Logo } from "../logo"
4 | import { useEffect, useMemo, useState } from "react"
5 | import { pb } from "@/lib/stores"
6 | import { useStore } from "@nanostores/react"
7 | import ForgotPassword from "./forgot-pass-form"
8 | import { $router } from "../router"
9 | import { AuthMethodsList } from "pocketbase"
10 | import { useTheme } from "../theme-provider"
11 |
12 | export default function () {
13 | const page = useStore($router)
14 | const [isFirstRun, setFirstRun] = useState(false)
15 | const [authMethods, setAuthMethods] = useState()
16 | const { theme } = useTheme()
17 |
18 | useEffect(() => {
19 | document.title = t`Login` + " / Beszel"
20 |
21 | pb.send("/api/beszel/first-run", {}).then(({ firstRun }) => {
22 | setFirstRun(firstRun)
23 | })
24 | }, [])
25 |
26 | useEffect(() => {
27 | pb.collection("users")
28 | .listAuthMethods()
29 | .then((methods) => {
30 | setAuthMethods(methods)
31 | })
32 | }, [])
33 |
34 | const subtitle = useMemo(() => {
35 | if (isFirstRun) {
36 | return t`Please create an admin account`
37 | } else if (page?.route === "forgot_password") {
38 | return t`Enter email address to reset password`
39 | } else {
40 | return t`Please sign in to your account`
41 | }
42 | }, [isFirstRun, page])
43 |
44 | if (!authMethods) {
45 | return null
46 | }
47 |
48 | return (
49 |
50 |
55 |
56 |
57 |
58 | Beszel
59 |
60 |
{subtitle}
61 |
62 | {page?.route === "forgot_password" ? (
63 |
64 | ) : (
65 |
66 | )}
67 |
68 |
69 | )
70 | }
71 |
--------------------------------------------------------------------------------
/beszel/site/src/components/logo.tsx:
--------------------------------------------------------------------------------
1 | export function Logo({ className }: { className?: string }) {
2 | return (
3 | // Righteous
4 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/beszel/site/src/components/mode-toggle.tsx:
--------------------------------------------------------------------------------
1 | import { Trans } from "@lingui/react/macro";
2 | import { t } from "@lingui/core/macro";
3 | import { LaptopIcon, MoonStarIcon, SunIcon } from "lucide-react"
4 |
5 | import { Button } from "@/components/ui/button"
6 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
7 | import { useTheme } from "@/components/theme-provider"
8 | import { cn } from "@/lib/utils"
9 |
10 | export function ModeToggle() {
11 | const { theme, setTheme } = useTheme()
12 |
13 | const options = [
14 | {
15 | theme: "light",
16 | Icon: SunIcon,
17 | label: Light,
18 | },
19 | {
20 | theme: "dark",
21 | Icon: MoonStarIcon,
22 | label: Dark,
23 | },
24 | {
25 | theme: "system",
26 | Icon: LaptopIcon,
27 | label: System,
28 | },
29 | ]
30 |
31 | return (
32 |
33 |
34 |
38 |
39 |
40 | {options.map((opt) => {
41 | const selected = opt.theme === theme
42 | return (
43 | setTheme(opt.theme as "dark" | "light" | "system")}
47 | >
48 |
49 | {opt.label}
50 |
51 | )
52 | })}
53 |
54 |
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/beszel/site/src/components/router.tsx:
--------------------------------------------------------------------------------
1 | import { createRouter } from "@nanostores/router"
2 |
3 | const routes = {
4 | home: "/",
5 | system: `/system/:name`,
6 | settings: `/settings/:name?`,
7 | forgot_password: `/forgot-password`,
8 | } as const
9 |
10 | /**
11 | * The base path of the application.
12 | * This is used to prepend the base path to all routes.
13 | */
14 | export const basePath = globalThis.BESZEL.BASE_PATH || ""
15 |
16 | /**
17 | * Prepends the base path to the given path.
18 | * @param path The path to prepend the base path to.
19 | * @returns The path with the base path prepended.
20 | */
21 | export const prependBasePath = (path: string) => (basePath + path).replaceAll("//", "/")
22 |
23 | // prepend base path to routes
24 | for (const route in routes) {
25 | // @ts-ignore need as const above to get nanostores to parse types properly
26 | routes[route] = prependBasePath(routes[route])
27 | }
28 |
29 | export const $router = createRouter(routes, { links: false })
30 |
31 | /** Navigate to url using router
32 | * Base path is automatically prepended if serving from subpath
33 | */
34 | export const navigate = (urlString: string) => {
35 | $router.open(urlString)
36 | }
37 |
38 | function onClick(e: React.MouseEvent) {
39 | e.preventDefault()
40 | $router.open(new URL((e.currentTarget as HTMLAnchorElement).href).pathname)
41 | }
42 |
43 | export const Link = (props: React.AnchorHTMLAttributes) => {
44 | return
45 | }
46 |
--------------------------------------------------------------------------------
/beszel/site/src/components/routes/home.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense, lazy, memo, useEffect, useMemo } from "react"
2 | import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
3 | import { $alerts, $systems, pb } from "@/lib/stores"
4 | import { useStore } from "@nanostores/react"
5 | import { GithubIcon } from "lucide-react"
6 | import { Separator } from "../ui/separator"
7 | import { alertInfo, updateRecordList, updateSystemList } from "@/lib/utils"
8 | import { AlertRecord, SystemRecord } from "@/types"
9 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
10 | import { $router, Link } from "../router"
11 | import { Plural, Trans, useLingui } from "@lingui/react/macro"
12 | import { getPagePath } from "@nanostores/router"
13 |
14 | const SystemsTable = lazy(() => import("../systems-table/systems-table"))
15 |
16 | export const Home = memo(() => {
17 | const alerts = useStore($alerts)
18 | const systems = useStore($systems)
19 | const { t } = useLingui()
20 |
21 | let alertsKey = ""
22 | const activeAlerts = useMemo(() => {
23 | const activeAlerts = alerts.filter((alert) => {
24 | const active = alert.triggered && alert.name in alertInfo
25 | if (!active) {
26 | return false
27 | }
28 | alert.sysname = systems.find((system) => system.id === alert.system)?.name
29 | alertsKey += alert.id
30 | return true
31 | })
32 | return activeAlerts
33 | }, [systems, alerts])
34 |
35 | useEffect(() => {
36 | document.title = t`Dashboard` + " / Beszel"
37 | }, [t])
38 |
39 | useEffect(() => {
40 | // make sure we have the latest list of systems
41 | updateSystemList()
42 |
43 | // subscribe to real time updates for systems / alerts
44 | pb.collection("systems").subscribe("*", (e) => {
45 | updateRecordList(e, $systems)
46 | })
47 | pb.collection("alerts").subscribe("*", (e) => {
48 | updateRecordList(e, $alerts)
49 | })
50 | return () => {
51 | pb.collection("systems").unsubscribe("*")
52 | // pb.collection('alerts').unsubscribe('*')
53 | }
54 | }, [])
55 |
56 | return useMemo(
57 | () => (
58 | <>
59 | {/* show active alerts */}
60 | {activeAlerts.length > 0 && }
61 |
62 |
63 |
64 |
65 |
82 | >
83 | ),
84 | [alertsKey]
85 | )
86 | })
87 |
88 | const ActiveAlerts = memo(({ activeAlerts }: { activeAlerts: AlertRecord[] }) => {
89 | return (
90 |
91 |
92 |
93 |
94 | Active Alerts
95 |
96 |
97 |
98 |
99 | {activeAlerts.length > 0 && (
100 |
101 | {activeAlerts.map((alert) => {
102 | const info = alertInfo[alert.name as keyof typeof alertInfo]
103 | return (
104 |
108 |
109 |
110 | {alert.sysname} {info.name().toLowerCase().replace("cpu", "CPU")}
111 |
112 |
113 |
114 | Exceeds {alert.value}
115 | {info.unit} in last
116 |
117 |
118 |
123 |
124 | )
125 | })}
126 |
127 | )}
128 |
129 |
130 | )
131 | })
132 |
--------------------------------------------------------------------------------
/beszel/site/src/components/routes/settings/config-yaml.tsx:
--------------------------------------------------------------------------------
1 | import { t } from "@lingui/core/macro";
2 | import { Trans } from "@lingui/react/macro";
3 | import { isAdmin } from "@/lib/utils"
4 | import { Separator } from "@/components/ui/separator"
5 | import { Button } from "@/components/ui/button"
6 | import { redirectPage } from "@nanostores/router"
7 | import { $router } from "@/components/router"
8 | import { AlertCircleIcon, FileSlidersIcon, LoaderCircleIcon } from "lucide-react"
9 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
10 | import { pb } from "@/lib/stores"
11 | import { useState } from "react"
12 | import { Textarea } from "@/components/ui/textarea"
13 | import { toast } from "@/components/ui/use-toast"
14 | import clsx from "clsx"
15 |
16 | export default function ConfigYaml() {
17 | const [configContent, setConfigContent] = useState("")
18 | const [isLoading, setIsLoading] = useState(false)
19 |
20 | const ButtonIcon = isLoading ? LoaderCircleIcon : FileSlidersIcon
21 |
22 | async function fetchConfig() {
23 | try {
24 | setIsLoading(true)
25 | const { config } = await pb.send<{ config: string }>("/api/beszel/config-yaml", {})
26 | setConfigContent(config)
27 | } catch (error: any) {
28 | toast({
29 | title: t`Error`,
30 | description: error.message,
31 | variant: "destructive",
32 | })
33 | } finally {
34 | setIsLoading(false)
35 | }
36 | }
37 |
38 | if (!isAdmin()) {
39 | redirectPage($router, "settings", { name: "general" })
40 | }
41 |
42 | return (
43 |
44 |
45 |
46 | YAML Configuration
47 |
48 |
49 | Export your current systems configuration.
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | Systems may be managed in a config.yml
file
58 | inside your data directory.
59 |
60 |
61 |
62 |
63 | On each restart, systems in the database will be updated to match the systems defined in the file.
64 |
65 |
66 |
67 |
68 |
69 | Caution - potential data loss
70 |
71 |
72 |
73 |
74 | Existing systems not defined in config.yml
will be deleted. Please make regular backups.
75 |
76 |
77 |
78 |
79 |
80 | {configContent && (
81 |
89 | )}
90 |
91 |
92 |
96 |
97 | )
98 | }
99 |
--------------------------------------------------------------------------------
/beszel/site/src/components/routes/settings/general.tsx:
--------------------------------------------------------------------------------
1 | import { Trans } from "@lingui/react/macro"
2 | import { Button } from "@/components/ui/button"
3 | import { Label } from "@/components/ui/label"
4 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
5 | import { chartTimeData } from "@/lib/utils"
6 | import { Separator } from "@/components/ui/separator"
7 | import { LanguagesIcon, LoaderCircleIcon, SaveIcon } from "lucide-react"
8 | import { UserSettings } from "@/types"
9 | import { saveSettings } from "./layout"
10 | import { useState } from "react"
11 | import languages from "@/lib/languages"
12 | import { dynamicActivate } from "@/lib/i18n"
13 | import { useLingui } from "@lingui/react/macro"
14 | // import { setLang } from "@/lib/i18n"
15 |
16 | export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
17 | const [isLoading, setIsLoading] = useState(false)
18 | const { i18n } = useLingui()
19 |
20 | async function handleSubmit(e: React.FormEvent) {
21 | e.preventDefault()
22 | setIsLoading(true)
23 | const formData = new FormData(e.target as HTMLFormElement)
24 | const data = Object.fromEntries(formData) as Partial
25 | await saveSettings(data)
26 | setIsLoading(false)
27 | }
28 |
29 | return (
30 |
31 |
32 |
33 | General
34 |
35 |
36 | Change general application options.
37 |
38 |
39 |
40 |
109 |
110 | )
111 | }
112 |
--------------------------------------------------------------------------------
/beszel/site/src/components/routes/settings/layout.tsx:
--------------------------------------------------------------------------------
1 | import { t } from "@lingui/core/macro"
2 | import { Trans } from "@lingui/react/macro"
3 | import { useEffect } from "react"
4 | import { Separator } from "../../ui/separator"
5 | import { SidebarNav } from "./sidebar-nav.tsx"
6 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx"
7 | import { useStore } from "@nanostores/react"
8 | import { $router } from "@/components/router.tsx"
9 | import { getPagePath, redirectPage } from "@nanostores/router"
10 | import { BellIcon, FileSlidersIcon, SettingsIcon } from "lucide-react"
11 | import { $userSettings, pb } from "@/lib/stores.ts"
12 | import { toast } from "@/components/ui/use-toast.ts"
13 | import { UserSettings } from "@/types.js"
14 | import General from "./general.tsx"
15 | import Notifications from "./notifications.tsx"
16 | import ConfigYaml from "./config-yaml.tsx"
17 | import { useLingui } from "@lingui/react/macro"
18 |
19 | export async function saveSettings(newSettings: Partial) {
20 | try {
21 | // get fresh copy of settings
22 | const req = await pb.collection("user_settings").getFirstListItem("", {
23 | fields: "id,settings",
24 | })
25 | // update user settings
26 | const updatedSettings = await pb.collection("user_settings").update(req.id, {
27 | settings: {
28 | ...req.settings,
29 | ...newSettings,
30 | },
31 | })
32 | $userSettings.set(updatedSettings.settings)
33 | toast({
34 | title: t`Settings saved`,
35 | description: t`Your user settings have been updated.`,
36 | })
37 | } catch (e) {
38 | // console.error('update settings', e)
39 | toast({
40 | title: t`Failed to save settings`,
41 | description: t`Check logs for more details.`,
42 | variant: "destructive",
43 | })
44 | }
45 | }
46 |
47 | export default function SettingsLayout() {
48 | const { t } = useLingui()
49 |
50 | const sidebarNavItems = [
51 | {
52 | title: t({ message: `General`, comment: "Context: General settings" }),
53 | href: getPagePath($router, "settings", { name: "general" }),
54 | icon: SettingsIcon,
55 | },
56 | {
57 | title: t`Notifications`,
58 | href: getPagePath($router, "settings", { name: "notifications" }),
59 | icon: BellIcon,
60 | },
61 | {
62 | title: t`YAML Config`,
63 | href: getPagePath($router, "settings", { name: "config" }),
64 | icon: FileSlidersIcon,
65 | admin: true,
66 | },
67 | ]
68 |
69 | const page = useStore($router)
70 |
71 | useEffect(() => {
72 | document.title = t`Settings` + " / Beszel"
73 | // @ts-ignore redirect to account page if no page is specified
74 | if (!page?.params?.name) {
75 | redirectPage($router, "settings", { name: "general" })
76 | }
77 | }, [])
78 |
79 | return (
80 |
81 |
82 |
83 | Settings
84 |
85 |
86 | Manage display and notification preferences.
87 |
88 |
89 |
90 |
91 |
92 |
95 |
96 | {/* @ts-ignore */}
97 |
98 |
99 |
100 |
101 |
102 | )
103 | }
104 |
105 | function SettingsContent({ name }: { name: string }) {
106 | const userSettings = useStore($userSettings)
107 |
108 | switch (name) {
109 | case "general":
110 | return
111 | case "notifications":
112 | return
113 | case "config":
114 | return
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/beszel/site/src/components/routes/settings/sidebar-nav.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { cn, isAdmin } from "@/lib/utils"
3 | import { buttonVariants } from "../../ui/button"
4 | import { $router, Link, navigate } from "../../router"
5 | import { useStore } from "@nanostores/react"
6 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
7 | import { Separator } from "@/components/ui/separator"
8 |
9 | interface SidebarNavProps extends React.HTMLAttributes {
10 | items: {
11 | href: string
12 | title: string
13 | icon?: React.FC>
14 | admin?: boolean
15 | }[]
16 | }
17 |
18 | export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
19 | const page = useStore($router)
20 |
21 | return (
22 | <>
23 | {/* Mobile View */}
24 |
25 |
43 |
44 |
45 |
46 | {/* Desktop View */}
47 |
69 | >
70 | )
71 | }
72 |
--------------------------------------------------------------------------------
/beszel/site/src/components/spinner.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 | import { LoaderCircleIcon } from "lucide-react"
3 |
4 | export default function ({ msg, className }: { msg?: string; className?: string }) {
5 | return (
6 |
7 | {msg ? (
8 |
{msg}
9 | ) : (
10 |
11 | )}
12 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/beszel/site/src/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useEffect, useState } from "react"
2 |
3 | type Theme = "dark" | "light" | "system"
4 |
5 | type ThemeProviderProps = {
6 | children: React.ReactNode
7 | defaultTheme?: Theme
8 | storageKey?: string
9 | }
10 |
11 | type ThemeProviderState = {
12 | theme: Theme
13 | setTheme: (theme: Theme) => void
14 | }
15 |
16 | const initialState: ThemeProviderState = {
17 | theme: "system",
18 | setTheme: () => null,
19 | }
20 |
21 | const ThemeProviderContext = createContext(initialState)
22 |
23 | export function ThemeProvider({
24 | children,
25 | defaultTheme = "system",
26 | storageKey = "ui-theme",
27 | ...props
28 | }: ThemeProviderProps) {
29 | const [theme, setTheme] = useState(() => (localStorage.getItem(storageKey) as Theme) || defaultTheme)
30 |
31 | useEffect(() => {
32 | const root = window.document.documentElement
33 |
34 | root.classList.remove("light", "dark")
35 |
36 | if (theme === "system") {
37 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
38 |
39 | root.classList.add(systemTheme)
40 | return
41 | }
42 |
43 | root.classList.add(theme)
44 | }, [theme])
45 |
46 | const value = {
47 | theme,
48 | setTheme: (theme: Theme) => {
49 | localStorage.setItem(storageKey, theme)
50 | setTheme(theme)
51 | },
52 | }
53 |
54 | return (
55 |
56 | {children}
57 |
58 | )
59 | }
60 |
61 | export const useTheme = () => useContext(ThemeProviderContext)
62 |
--------------------------------------------------------------------------------
/beszel/site/src/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
3 |
4 | import { cn } from "@/lib/utils"
5 | import { buttonVariants } from "@/components/ui/button"
6 |
7 | const AlertDialog = AlertDialogPrimitive.Root
8 |
9 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger
10 |
11 | const AlertDialogPortal = AlertDialogPrimitive.Portal
12 |
13 | const AlertDialogOverlay = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef
16 | >(({ className, ...props }, ref) => (
17 |
25 | ))
26 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
27 |
28 | const AlertDialogContent = React.forwardRef<
29 | React.ElementRef,
30 | React.ComponentPropsWithoutRef
31 | >(({ className, ...props }, ref) => (
32 |
33 |
34 |
42 |
43 | ))
44 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
45 |
46 | const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
47 |
48 | )
49 | AlertDialogHeader.displayName = "AlertDialogHeader"
50 |
51 | const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
52 |
53 | )
54 | AlertDialogFooter.displayName = "AlertDialogFooter"
55 |
56 | const AlertDialogTitle = React.forwardRef<
57 | React.ElementRef,
58 | React.ComponentPropsWithoutRef
59 | >(({ className, ...props }, ref) => (
60 |
61 | ))
62 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
63 |
64 | const AlertDialogDescription = React.forwardRef<
65 | React.ElementRef,
66 | React.ComponentPropsWithoutRef
67 | >(({ className, ...props }, ref) => (
68 |
69 | ))
70 | AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
71 |
72 | const AlertDialogAction = React.forwardRef<
73 | React.ElementRef,
74 | React.ComponentPropsWithoutRef
75 | >(({ className, ...props }, ref) => (
76 |
77 | ))
78 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
79 |
80 | const AlertDialogCancel = React.forwardRef<
81 | React.ElementRef,
82 | React.ComponentPropsWithoutRef
83 | >(({ className, ...props }, ref) => (
84 |
89 | ))
90 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
91 |
92 | export {
93 | AlertDialog,
94 | AlertDialogPortal,
95 | AlertDialogOverlay,
96 | AlertDialogTrigger,
97 | AlertDialogContent,
98 | AlertDialogHeader,
99 | AlertDialogFooter,
100 | AlertDialogTitle,
101 | AlertDialogDescription,
102 | AlertDialogAction,
103 | AlertDialogCancel,
104 | }
105 |
--------------------------------------------------------------------------------
/beszel/site/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | // import { cva, type VariantProps } from 'class-variance-authority'
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | // const alertVariants = cva(
7 | // "relative w-full rounded-lg border p-4 [&>svg~*]:ps-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
8 | // {
9 | // variants: {
10 | // variant: {
11 | // default: "bg-background text-foreground",
12 | // destructive:
13 | // "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14 | // },
15 | // },
16 | // defaultVariants: {
17 | // variant: "default",
18 | // },
19 | // }
20 | // )
21 |
22 | const Alert = React.forwardRef<
23 | HTMLDivElement,
24 | // React.HTMLAttributes & VariantProps
25 | // >(({ className, variant, ...props }, ref) => (
26 | React.HTMLAttributes
27 | >(({ className, ...props }, ref) => (
28 | svg~*]:ps-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground bg-background text-foreground",
33 | className
34 | )}
35 | {...props}
36 | />
37 | ))
38 | Alert.displayName = "Alert"
39 |
40 | const AlertTitle = React.forwardRef
>(
41 | ({ className, ...props }, ref) => (
42 |
43 | )
44 | )
45 | AlertTitle.displayName = "AlertTitle"
46 |
47 | const AlertDescription = React.forwardRef>(
48 | ({ className, ...props }, ref) => (
49 |
50 | )
51 | )
52 | AlertDescription.displayName = "AlertDescription"
53 |
54 | export { Alert, AlertTitle, AlertDescription }
55 |
--------------------------------------------------------------------------------
/beszel/site/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8 | {
9 | variants: {
10 | variant: {
11 | default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
12 | secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
13 | destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
14 | outline: "text-foreground",
15 | },
16 | },
17 | defaultVariants: {
18 | variant: "default",
19 | },
20 | }
21 | )
22 |
23 | export interface BadgeProps extends React.HTMLAttributes, VariantProps {}
24 |
25 | function Badge({ className, variant, ...props }: BadgeProps) {
26 | return
27 | }
28 |
29 | export { Badge, badgeVariants }
30 |
--------------------------------------------------------------------------------
/beszel/site/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
14 | outline: "border bg-background hover:bg-accent/70 dark:hover:bg-accent/50 hover:text-accent-foreground",
15 | secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
16 | ghost: "hover:bg-accent hover:text-accent-foreground",
17 | link: "text-primary underline-offset-4 hover:underline",
18 | },
19 | size: {
20 | default: "h-10 px-4 py-2",
21 | sm: "h-9 rounded-md px-3",
22 | lg: "h-11 rounded-md px-8",
23 | icon: "h-10 w-10",
24 | },
25 | },
26 | defaultVariants: {
27 | variant: "default",
28 | size: "default",
29 | },
30 | }
31 | )
32 |
33 | export interface ButtonProps
34 | extends React.ButtonHTMLAttributes,
35 | VariantProps {
36 | asChild?: boolean
37 | }
38 |
39 | const Button = React.forwardRef(
40 | ({ className, variant, size, asChild = false, ...props }, ref) => {
41 | const Comp = asChild ? Slot : "button"
42 | return
43 | }
44 | )
45 | Button.displayName = "Button"
46 |
47 | export { Button, buttonVariants }
48 |
--------------------------------------------------------------------------------
/beszel/site/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef>(({ className, ...props }, ref) => (
6 |
11 | ))
12 | Card.displayName = "Card"
13 |
14 | const CardHeader = React.forwardRef>(
15 | ({ className, ...props }, ref) => (
16 |
17 | )
18 | )
19 | CardHeader.displayName = "CardHeader"
20 |
21 | const CardTitle = React.forwardRef>(
22 | ({ className, ...props }, ref) => (
23 |
24 | )
25 | )
26 | CardTitle.displayName = "CardTitle"
27 |
28 | const CardDescription = React.forwardRef>(
29 | ({ className, ...props }, ref) => (
30 |
31 | )
32 | )
33 | CardDescription.displayName = "CardDescription"
34 |
35 | const CardContent = React.forwardRef>(
36 | ({ className, ...props }, ref) =>
37 | )
38 | CardContent.displayName = "CardContent"
39 |
40 | const CardFooter = React.forwardRef>(
41 | ({ className, ...props }, ref) =>
42 | )
43 | CardFooter.displayName = "CardFooter"
44 |
45 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
46 |
--------------------------------------------------------------------------------
/beszel/site/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
3 | import { Check } from "lucide-react"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const Checkbox = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, ...props }, ref) => (
11 |
19 |
20 |
21 |
22 |
23 | ))
24 | Checkbox.displayName = CheckboxPrimitive.Root.displayName
25 |
26 | export { Checkbox }
27 |
--------------------------------------------------------------------------------
/beszel/site/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as DialogPrimitive from "@radix-ui/react-dialog"
3 | import { X } from "lucide-react"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const Dialog = DialogPrimitive.Root
8 |
9 | const DialogTrigger = DialogPrimitive.Trigger
10 |
11 | const DialogPortal = DialogPrimitive.Portal
12 |
13 | const DialogClose = DialogPrimitive.Close
14 |
15 | const DialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ))
28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
29 |
30 | const DialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, children, ...props }, ref) => (
34 |
35 |
36 |
44 | {children}
45 |
46 |
47 | Close
48 |
49 |
50 |
51 | ))
52 | DialogContent.displayName = DialogPrimitive.Content.displayName
53 |
54 | const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
55 |
56 | )
57 | DialogHeader.displayName = "DialogHeader"
58 |
59 | const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
60 |
61 | )
62 | DialogFooter.displayName = "DialogFooter"
63 |
64 | const DialogTitle = React.forwardRef<
65 | React.ElementRef,
66 | React.ComponentPropsWithoutRef
67 | >(({ className, ...props }, ref) => (
68 |
73 | ))
74 | DialogTitle.displayName = DialogPrimitive.Title.displayName
75 |
76 | const DialogDescription = React.forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef
79 | >(({ className, ...props }, ref) => (
80 |
81 | ))
82 | DialogDescription.displayName = DialogPrimitive.Description.displayName
83 |
84 | export {
85 | Dialog,
86 | DialogPortal,
87 | DialogOverlay,
88 | DialogClose,
89 | DialogTrigger,
90 | DialogContent,
91 | DialogHeader,
92 | DialogFooter,
93 | DialogTitle,
94 | DialogDescription,
95 | }
96 |
--------------------------------------------------------------------------------
/beszel/site/src/components/ui/input-tags.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Badge } from "@/components/ui/badge"
3 | import { Button } from "@/components/ui/button"
4 | import { XIcon } from "lucide-react"
5 | import { type InputProps } from "./input"
6 | import { cn } from "@/lib/utils"
7 |
8 | type InputTagsProps = Omit & {
9 | value: string[]
10 | onChange: React.Dispatch>
11 | }
12 |
13 | const InputTags = React.forwardRef(
14 | ({ className, value, onChange, ...props }, ref) => {
15 | const [pendingDataPoint, setPendingDataPoint] = React.useState("")
16 |
17 | React.useEffect(() => {
18 | if (pendingDataPoint.includes(",")) {
19 | const newDataPoints = new Set([...value, ...pendingDataPoint.split(",").map((chunk) => chunk.trim())])
20 | onChange(Array.from(newDataPoints))
21 | setPendingDataPoint("")
22 | }
23 | }, [pendingDataPoint, onChange, value])
24 |
25 | const addPendingDataPoint = () => {
26 | if (pendingDataPoint) {
27 | const newDataPoints = new Set([...value, pendingDataPoint])
28 | onChange(Array.from(newDataPoints))
29 | setPendingDataPoint("")
30 | }
31 | }
32 |
33 | return (
34 |
40 | {value.map((item) => (
41 |
42 | {item}
43 |
53 |
54 | ))}
55 | setPendingDataPoint(e.target.value)}
59 | onKeyDown={(e) => {
60 | if (e.key === "Enter" || e.key === ",") {
61 | e.preventDefault()
62 | addPendingDataPoint()
63 | } else if (e.key === "Backspace" && pendingDataPoint.length === 0 && value.length > 0) {
64 | e.preventDefault()
65 | onChange(value.slice(0, -1))
66 | }
67 | }}
68 | {...props}
69 | ref={ref}
70 | />
71 |
72 | )
73 | }
74 | )
75 |
76 | InputTags.displayName = "InputTags"
77 |
78 | export { InputTags }
79 |
--------------------------------------------------------------------------------
/beszel/site/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps extends React.InputHTMLAttributes {}
6 |
7 | const Input = React.forwardRef(({ className, type, ...props }, ref) => {
8 | return (
9 |
18 | )
19 | })
20 | Input.displayName = "Input"
21 |
22 | export { Input }
23 |
--------------------------------------------------------------------------------
/beszel/site/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70")
8 |
9 | const Label = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef & VariantProps
12 | >(({ className, ...props }, ref) => (
13 |
14 | ))
15 | Label.displayName = LabelPrimitive.Root.displayName
16 |
17 | export { Label }
18 |
--------------------------------------------------------------------------------
/beszel/site/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Separator = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
10 |
17 | ))
18 | Separator.displayName = SeparatorPrimitive.Root.displayName
19 |
20 | export { Separator }
21 |
--------------------------------------------------------------------------------
/beszel/site/src/components/ui/slider.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SliderPrimitive from "@radix-ui/react-slider"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Slider = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
15 |
16 |
17 |
18 |
19 |
20 | ))
21 | Slider.displayName = SliderPrimitive.Root.displayName
22 |
23 | export default Slider
24 |
--------------------------------------------------------------------------------
/beszel/site/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SwitchPrimitives from "@radix-ui/react-switch"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Switch = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 |
23 |
24 | ))
25 | Switch.displayName = SwitchPrimitives.Root.displayName
26 |
27 | export { Switch }
28 |
--------------------------------------------------------------------------------
/beszel/site/src/components/ui/table.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Table = React.forwardRef>(
6 | ({ className, ...props }, ref) => (
7 |
10 | )
11 | )
12 | Table.displayName = "Table"
13 |
14 | const TableHeader = React.forwardRef>(
15 | ({ className, ...props }, ref) => (
16 |
17 | )
18 | )
19 | TableHeader.displayName = "TableHeader"
20 |
21 | const TableBody = React.forwardRef>(
22 | ({ className, ...props }, ref) => (
23 |
24 | )
25 | )
26 | TableBody.displayName = "TableBody"
27 |
28 | const TableFooter = React.forwardRef>(
29 | ({ className, ...props }, ref) => (
30 | tr]:last:border-b-0", className)} {...props} />
31 | )
32 | )
33 | TableFooter.displayName = "TableFooter"
34 |
35 | const TableRow = React.forwardRef>(
36 | ({ className, ...props }, ref) => (
37 |
45 | )
46 | )
47 | TableRow.displayName = "TableRow"
48 |
49 | const TableHead = React.forwardRef>(
50 | ({ className, ...props }, ref) => (
51 | |
59 | )
60 | )
61 | TableHead.displayName = "TableHead"
62 |
63 | const TableCell = React.forwardRef>(
64 | ({ className, ...props }, ref) => (
65 | |
66 | )
67 | )
68 | TableCell.displayName = "TableCell"
69 |
70 | const TableCaption = React.forwardRef>(
71 | ({ className, ...props }, ref) => (
72 |
73 | )
74 | )
75 | TableCaption.displayName = "TableCaption"
76 |
77 | export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }
78 |
--------------------------------------------------------------------------------
/beszel/site/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as TabsPrimitive from "@radix-ui/react-tabs"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Tabs = TabsPrimitive.Root
7 |
8 | const TabsList = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | TabsList.displayName = TabsPrimitive.List.displayName
22 |
23 | const TabsTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
35 | ))
36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
37 |
38 | const TabsContent = React.forwardRef<
39 | React.ElementRef,
40 | React.ComponentPropsWithoutRef
41 | >(({ className, ...props }, ref) => (
42 |
50 | ))
51 | TabsContent.displayName = TabsPrimitive.Content.displayName
52 |
53 | export { Tabs, TabsList, TabsTrigger, TabsContent }
54 |
--------------------------------------------------------------------------------
/beszel/site/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface TextareaProps extends React.TextareaHTMLAttributes {}
6 |
7 | const Textarea = React.forwardRef(({ className, ...props }, ref) => {
8 | return (
9 |
17 | )
18 | })
19 | Textarea.displayName = "Textarea"
20 |
21 | export { Textarea }
22 |
--------------------------------------------------------------------------------
/beszel/site/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast"
2 | import { useToast } from "@/components/ui/use-toast"
3 |
4 | export function Toaster() {
5 | const { toasts } = useToast()
6 |
7 | return (
8 |
9 | {toasts.map(function ({ id, title, description, action, ...props }) {
10 | return (
11 |
12 |
13 | {title && {title}}
14 | {description && {description}}
15 |
16 | {action}
17 |
18 |
19 | )
20 | })}
21 |
22 |
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/beszel/site/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const TooltipProvider = TooltipPrimitive.Provider
7 |
8 | const Tooltip = TooltipPrimitive.Root
9 |
10 | const TooltipTrigger = TooltipPrimitive.Trigger
11 |
12 | const TooltipContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, sideOffset = 4, ...props }, ref) => (
16 |
25 | ))
26 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
27 |
28 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
29 |
--------------------------------------------------------------------------------
/beszel/site/src/components/ui/use-toast.ts:
--------------------------------------------------------------------------------
1 | // Inspired by react-hot-toast library
2 | import * as React from "react"
3 |
4 | import type { ToastActionElement, ToastProps } from "@/components/ui/toast"
5 |
6 | const TOAST_LIMIT = 1
7 | const TOAST_REMOVE_DELAY = 1000000
8 |
9 | type ToasterToast = ToastProps & {
10 | id: string
11 | title?: React.ReactNode
12 | description?: React.ReactNode
13 | action?: ToastActionElement
14 | }
15 |
16 | const actionTypes = {
17 | ADD_TOAST: "ADD_TOAST",
18 | UPDATE_TOAST: "UPDATE_TOAST",
19 | DISMISS_TOAST: "DISMISS_TOAST",
20 | REMOVE_TOAST: "REMOVE_TOAST",
21 | } as const
22 |
23 | let count = 0
24 |
25 | function genId() {
26 | count = (count + 1) % Number.MAX_SAFE_INTEGER
27 | return count.toString()
28 | }
29 |
30 | type ActionType = typeof actionTypes
31 |
32 | type Action =
33 | | {
34 | type: ActionType["ADD_TOAST"]
35 | toast: ToasterToast
36 | }
37 | | {
38 | type: ActionType["UPDATE_TOAST"]
39 | toast: Partial
40 | }
41 | | {
42 | type: ActionType["DISMISS_TOAST"]
43 | toastId?: ToasterToast["id"]
44 | }
45 | | {
46 | type: ActionType["REMOVE_TOAST"]
47 | toastId?: ToasterToast["id"]
48 | }
49 |
50 | interface State {
51 | toasts: ToasterToast[]
52 | }
53 |
54 | const toastTimeouts = new Map>()
55 |
56 | const addToRemoveQueue = (toastId: string) => {
57 | if (toastTimeouts.has(toastId)) {
58 | return
59 | }
60 |
61 | const timeout = setTimeout(() => {
62 | toastTimeouts.delete(toastId)
63 | dispatch({
64 | type: "REMOVE_TOAST",
65 | toastId: toastId,
66 | })
67 | }, TOAST_REMOVE_DELAY)
68 |
69 | toastTimeouts.set(toastId, timeout)
70 | }
71 |
72 | export const reducer = (state: State, action: Action): State => {
73 | switch (action.type) {
74 | case "ADD_TOAST":
75 | return {
76 | ...state,
77 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
78 | }
79 |
80 | case "UPDATE_TOAST":
81 | return {
82 | ...state,
83 | toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
84 | }
85 |
86 | case "DISMISS_TOAST": {
87 | const { toastId } = action
88 |
89 | // ! Side effects ! - This could be extracted into a dismissToast() action,
90 | // but I'll keep it here for simplicity
91 | if (toastId) {
92 | addToRemoveQueue(toastId)
93 | } else {
94 | state.toasts.forEach((toast) => {
95 | addToRemoveQueue(toast.id)
96 | })
97 | }
98 |
99 | return {
100 | ...state,
101 | toasts: state.toasts.map((t) =>
102 | t.id === toastId || toastId === undefined
103 | ? {
104 | ...t,
105 | open: false,
106 | }
107 | : t
108 | ),
109 | }
110 | }
111 | case "REMOVE_TOAST":
112 | if (action.toastId === undefined) {
113 | return {
114 | ...state,
115 | toasts: [],
116 | }
117 | }
118 | return {
119 | ...state,
120 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
121 | }
122 | }
123 | }
124 |
125 | const listeners: Array<(state: State) => void> = []
126 |
127 | let memoryState: State = { toasts: [] }
128 |
129 | function dispatch(action: Action) {
130 | memoryState = reducer(memoryState, action)
131 | listeners.forEach((listener) => {
132 | listener(memoryState)
133 | })
134 | }
135 |
136 | type Toast = Omit
137 |
138 | function toast({ ...props }: Toast) {
139 | const id = genId()
140 |
141 | const update = (props: ToasterToast) =>
142 | dispatch({
143 | type: "UPDATE_TOAST",
144 | toast: { ...props, id },
145 | })
146 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
147 |
148 | dispatch({
149 | type: "ADD_TOAST",
150 | toast: {
151 | ...props,
152 | id,
153 | open: true,
154 | onOpenChange: (open) => {
155 | if (!open) dismiss()
156 | },
157 | },
158 | })
159 |
160 | return {
161 | id: id,
162 | dismiss,
163 | update,
164 | }
165 | }
166 |
167 | function useToast() {
168 | const [state, setState] = React.useState(memoryState)
169 |
170 | React.useEffect(() => {
171 | listeners.push(setState)
172 | return () => {
173 | const index = listeners.indexOf(setState)
174 | if (index > -1) {
175 | listeners.splice(index, 1)
176 | }
177 | }
178 | }, [state])
179 |
180 | return {
181 | ...state,
182 | toast,
183 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
184 | }
185 | }
186 |
187 | export { useToast, toast }
188 |
--------------------------------------------------------------------------------
/beszel/site/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 30 8% 98.5%;
8 | --foreground: 30 0% 0%;
9 | --card: 30 0% 100%;
10 | --card-foreground: 240 6.67% 2.94%;
11 | --popover: 30 0% 100%;
12 | --popover-foreground: 240 10% 6.2%;
13 | --primary: 240 5.88% 10%;
14 | --primary-foreground: 30 0% 100%;
15 | --secondary: 240 4.76% 95.88%;
16 | --secondary-foreground: 240 5.88% 10%;
17 | --muted: 26 6% 94%;
18 | --muted-foreground: 24 2.79% 35.1%;
19 | --accent: 20 23.08% 94%;
20 | --accent-foreground: 240 5.88% 10%;
21 | --destructive: 0 66% 53%;
22 | --destructive-foreground: 0 0% 98.04%;
23 | --border: 30 8.11% 85.49%;
24 | --input: 30 4.29% 72.55%;
25 | --ring: 30 3.97% 49.41%;
26 | --radius: 0.8rem;
27 | /* charts */
28 | --chart-1: 220 70% 50%;
29 | --chart-2: 160 60% 45%;
30 | --chart-3: 30 80% 55%;
31 | --chart-4: 280 65% 60%;
32 | --chart-5: 340 75% 55%;
33 | }
34 |
35 | .dark {
36 | color-scheme: dark;
37 | --background: 220 5.5% 9%;
38 | --foreground: 220 2% 97%;
39 | --card: 220 5.5% 10.5%;
40 | --card-foreground: 220 2% 97%;
41 | --popover: 220 5.5% 9%;
42 | --popover-foreground: 220 2% 97%;
43 | --primary: 220 2% 96%;
44 | --primary-foreground: 220 4% 10%;
45 | --secondary: 220 4% 16%;
46 | --secondary-foreground: 220 0% 98%;
47 | --muted: 220 6% 16%;
48 | --muted-foreground: 220 4% 67%;
49 | --accent: 220 5% 15.5%;
50 | --accent-foreground: 220 2% 98%;
51 | --destructive: 0 62% 46%;
52 | --destructive-foreground: 0 0% 97%;
53 | --border: 220 3% 16%;
54 | --input: 220 4% 22%;
55 | --ring: 220 4% 80%;
56 | --radius: 0.8rem;
57 | }
58 | }
59 |
60 | /* Fonts */
61 | @supports (font-variation-settings: normal) {
62 | :root {
63 | font-family: Inter, InterVariable, sans-serif;
64 | }
65 | }
66 | @font-face {
67 | font-family: InterVariable;
68 | font-style: normal;
69 | font-weight: 100 900;
70 | font-display: swap;
71 | src: url("/static/InterVariable.woff2?v=4.0") format("woff2");
72 | }
73 |
74 | @layer base {
75 | * {
76 | @apply border-border;
77 | overflow-anchor: none;
78 | }
79 | body {
80 | @apply bg-background text-foreground;
81 | }
82 | }
83 |
84 | @layer utilities {
85 | .link {
86 | @apply text-primary font-medium underline-offset-4 hover:underline;
87 | }
88 | /* New system dialog width */
89 | .ns-dialog {
90 | min-width: 30.3rem;
91 | }
92 | :where(:lang(zh), :lang(zh-CN), :lang(ko)) .ns-dialog {
93 | min-width: 27.9rem;
94 | }
95 | }
96 |
97 | .recharts-tooltip-wrapper {
98 | z-index: 1;
99 | }
100 |
101 | .recharts-yAxis {
102 | @apply tabular-nums;
103 | }
104 |
--------------------------------------------------------------------------------
/beszel/site/src/lib/enums.ts:
--------------------------------------------------------------------------------
1 | export enum Os {
2 | Linux = 0,
3 | Darwin,
4 | Windows,
5 | FreeBSD,
6 | }
7 |
8 | export enum ChartType {
9 | Memory,
10 | Disk,
11 | Network,
12 | CPU,
13 | }
14 |
--------------------------------------------------------------------------------
/beszel/site/src/lib/i18n.ts:
--------------------------------------------------------------------------------
1 | import { $direction } from "./stores"
2 | import { i18n } from "@lingui/core"
3 | import type { Messages } from "@lingui/core"
4 | import languages from "@/lib/languages"
5 | import { detect, fromStorage, fromNavigator } from "@lingui/detect-locale"
6 | import { messages as enMessages } from "@/locales/en/en"
7 |
8 | // activates locale
9 | function activateLocale(locale: string, messages: Messages = enMessages) {
10 | i18n.load(locale, messages)
11 | i18n.activate(locale)
12 | document.documentElement.lang = locale
13 | localStorage.setItem("lang", locale)
14 | $direction.set(locale.startsWith("ar") || locale.startsWith("fa") ? "rtl" : "ltr")
15 | }
16 |
17 | // dynamically loads translations for the given locale
18 | export async function dynamicActivate(locale: string) {
19 | if (locale == "en") {
20 | activateLocale(locale)
21 | } else {
22 | try {
23 | const { messages }: { messages: Messages } = await import(`../locales/${locale}/${locale}.ts`)
24 | activateLocale(locale, messages)
25 | } catch (error) {
26 | console.error(`Error loading ${locale}`, error)
27 | activateLocale("en")
28 | }
29 | }
30 | }
31 |
32 | export function getLocale() {
33 | // let locale = detect(fromUrl("lang"), fromStorage("lang"), fromNavigator(), "en")
34 | let locale = detect(fromStorage("lang"), fromNavigator(), "en")
35 | // log if dev
36 | if (import.meta.env.DEV) {
37 | console.log("detected locale", locale)
38 | }
39 | // handle zh variants
40 | if (locale?.startsWith("zh-")) {
41 | // map zh variants to zh-CN
42 | const zhVariantMap: Record = {
43 | "zh-HK": "zh-HK",
44 | "zh-TW": "zh",
45 | "zh-MO": "zh",
46 | "zh-Hant": "zh",
47 | }
48 | return zhVariantMap[locale] || "zh-CN"
49 | }
50 | locale = (locale || "en").split("-")[0]
51 | // use en if locale is not in languages
52 | if (!languages.some((l) => l.lang === locale)) {
53 | locale = "en"
54 | }
55 | return locale
56 | }
57 |
--------------------------------------------------------------------------------
/beszel/site/src/lib/languages.ts:
--------------------------------------------------------------------------------
1 | export default [
2 | {
3 | lang: "ar",
4 | label: "العربية",
5 | e: "🇵🇸",
6 | },
7 | {
8 | lang: "bg",
9 | label: "Български",
10 | e: "🇧🇬",
11 | },
12 | {
13 | lang: "cs",
14 | label: "Čeština",
15 | e: "🇨🇿",
16 | },
17 | {
18 | lang: "da",
19 | label: "Dansk",
20 | e: "🇩🇰",
21 | },
22 | {
23 | lang: "de",
24 | label: "Deutsch",
25 | e: "🇩🇪",
26 | },
27 | {
28 | lang: "en",
29 | label: "English",
30 | e: "🇺🇸",
31 | },
32 | {
33 | lang: "es",
34 | label: "Español",
35 | e: "🇲🇽",
36 | },
37 | {
38 | lang: "fa",
39 | label: "فارسی",
40 | e: "🇮🇷",
41 | },
42 | {
43 | lang: "fr",
44 | label: "Français",
45 | e: "🇫🇷",
46 | },
47 | {
48 | lang: "hr",
49 | label: "Hrvatski",
50 | e: "🇭🇷",
51 | },
52 | {
53 | lang: "hu",
54 | label: "Magyar",
55 | e: "🇭🇺",
56 | },
57 | {
58 | lang: "it",
59 | label: "Italiano",
60 | e: "🇮🇹",
61 | },
62 | {
63 | lang: "ja",
64 | label: "日本語",
65 | e: "🇯🇵",
66 | },
67 | {
68 | lang: "ko",
69 | label: "한국어",
70 | e: "🇰🇷",
71 | },
72 | {
73 | lang: "nl",
74 | label: "Nederlands",
75 | e: "🇳🇱",
76 | },
77 | {
78 | lang: "no",
79 | label: "Norsk",
80 | e: "🇳🇴",
81 | },
82 | {
83 | lang: "pl",
84 | label: "Polski",
85 | e: "🇵🇱",
86 | },
87 | {
88 | lang: "pt",
89 | label: "Português",
90 | e: "🇧🇷",
91 | },
92 | {
93 | lang: "tr",
94 | label: "Türkçe",
95 | e: "🇹🇷",
96 | },
97 | {
98 | lang: "ru",
99 | label: "Русский",
100 | e: "🇷🇺",
101 | },
102 | {
103 | lang: "sl",
104 | label: "Slovenščina",
105 | e: "🇸🇮",
106 | },
107 | {
108 | lang: "sv",
109 | label: "Svenska",
110 | e: "🇸🇪",
111 | },
112 | {
113 | lang: "uk",
114 | label: "Українська",
115 | e: "🇺🇦",
116 | },
117 | {
118 | lang: "vi",
119 | label: "Tiếng Việt",
120 | e: "🇻🇳",
121 | },
122 | {
123 | lang: "zh-CN",
124 | label: "简体中文",
125 | e: "🇨🇳",
126 | },
127 | {
128 | lang: "zh-HK",
129 | label: "繁體中文",
130 | e: "🇭🇰",
131 | },
132 | {
133 | lang: "zh",
134 | label: "繁體中文",
135 | e: "🇹🇼",
136 | },
137 | ] as const
138 |
--------------------------------------------------------------------------------
/beszel/site/src/lib/stores.ts:
--------------------------------------------------------------------------------
1 | import PocketBase from "pocketbase"
2 | import { atom, map, PreinitializedWritableAtom } from "nanostores"
3 | import { AlertRecord, ChartTimes, SystemRecord, UserSettings } from "@/types"
4 | import { basePath } from "@/components/router"
5 |
6 | /** PocketBase JS Client */
7 | export const pb = new PocketBase(basePath)
8 |
9 | /** Store if user is authenticated */
10 | export const $authenticated = atom(pb.authStore.isValid)
11 |
12 | /** List of system records */
13 | export const $systems = atom([] as SystemRecord[])
14 |
15 | /** List of alert records */
16 | export const $alerts = atom([] as AlertRecord[])
17 |
18 | /** SSH public key */
19 | export const $publicKey = atom("")
20 |
21 | /** Chart time period */
22 | export const $chartTime = atom("1h") as PreinitializedWritableAtom
23 |
24 | /** Whether to display average or max chart values */
25 | export const $maxValues = atom(false)
26 |
27 | /** User settings */
28 | export const $userSettings = map({
29 | chartTime: "1h",
30 | emails: [pb.authStore.record?.email || ""],
31 | })
32 | // update local storage on change
33 | $userSettings.subscribe((value) => {
34 | // console.log('user settings changed', value)
35 | $chartTime.set(value.chartTime)
36 | })
37 |
38 | /** Container chart filter */
39 | export const $containerFilter = atom("")
40 |
41 | /** Temperature chart filter */
42 | export const $temperatureFilter = atom("")
43 |
44 | /** Fallback copy to clipboard dialog content */
45 | export const $copyContent = atom("")
46 |
47 | /** Direction for localization */
48 | export const $direction = atom<"ltr" | "rtl">("ltr")
49 |
--------------------------------------------------------------------------------
/beszel/site/src/main.tsx:
--------------------------------------------------------------------------------
1 | import "./index.css"
2 | // import { Suspense, lazy, useEffect, StrictMode } from "react"
3 | import { Suspense, lazy, memo, useEffect } from "react"
4 | import ReactDOM from "react-dom/client"
5 | import { Home } from "./components/routes/home.tsx"
6 | import { ThemeProvider } from "./components/theme-provider.tsx"
7 | import { DirectionProvider } from "@radix-ui/react-direction"
8 | import { $authenticated, $systems, pb, $publicKey, $copyContent, $direction } from "./lib/stores.ts"
9 | import { updateUserSettings, updateAlerts, updateFavicon, updateSystemList } from "./lib/utils.ts"
10 | import { useStore } from "@nanostores/react"
11 | import { Toaster } from "./components/ui/toaster.tsx"
12 | import { $router } from "./components/router.tsx"
13 | import SystemDetail from "./components/routes/system.tsx"
14 | import Navbar from "./components/navbar.tsx"
15 | import { I18nProvider } from "@lingui/react"
16 | import { i18n } from "@lingui/core"
17 | import { getLocale, dynamicActivate } from "./lib/i18n.ts"
18 |
19 | // const ServerDetail = lazy(() => import('./components/routes/system.tsx'))
20 | const LoginPage = lazy(() => import("./components/login/login.tsx"))
21 | const CopyToClipboardDialog = lazy(() => import("./components/copy-to-clipboard.tsx"))
22 | const Settings = lazy(() => import("./components/routes/settings/layout.tsx"))
23 |
24 | const App = memo(() => {
25 | const page = useStore($router)
26 | const authenticated = useStore($authenticated)
27 | const systems = useStore($systems)
28 |
29 | useEffect(() => {
30 | // change auth store on auth change
31 | pb.authStore.onChange(() => {
32 | $authenticated.set(pb.authStore.isValid)
33 | })
34 | // get version / public key
35 | pb.send("/api/beszel/getkey", {}).then((data) => {
36 | $publicKey.set(data.key)
37 | })
38 | // get servers / alerts / settings
39 | updateUserSettings()
40 | // get alerts after system list is loaded
41 | updateSystemList().then(updateAlerts)
42 |
43 | return () => updateFavicon("favicon.svg")
44 | }, [])
45 |
46 | // update favicon
47 | useEffect(() => {
48 | if (!systems.length || !authenticated) {
49 | updateFavicon("favicon.svg")
50 | } else {
51 | let up = false
52 | for (const system of systems) {
53 | if (system.status === "down") {
54 | updateFavicon("favicon-red.svg")
55 | return
56 | } else if (system.status === "up") {
57 | up = true
58 | }
59 | }
60 | updateFavicon(up ? "favicon-green.svg" : "favicon.svg")
61 | }
62 | }, [systems])
63 |
64 | if (!page) {
65 | return 404
66 | } else if (page.route === "home") {
67 | return
68 | } else if (page.route === "system") {
69 | return
70 | } else if (page.route === "settings") {
71 | return (
72 |
73 |
74 |
75 | )
76 | }
77 | })
78 |
79 | const Layout = () => {
80 | const authenticated = useStore($authenticated)
81 | const copyContent = useStore($copyContent)
82 | const direction = useStore($direction)
83 |
84 | useEffect(() => {
85 | document.documentElement.dir = direction
86 | }, [direction])
87 |
88 | return (
89 |
90 | {!authenticated ? (
91 |
92 |
93 |
94 | ) : (
95 | <>
96 |
97 |
98 |
99 |
100 |
101 | {copyContent && (
102 |
103 |
104 |
105 | )}
106 |
107 | >
108 | )}
109 |
110 | )
111 | }
112 |
113 | const I18nApp = () => {
114 | useEffect(() => {
115 | dynamicActivate(getLocale())
116 | }, [])
117 |
118 | return (
119 |
120 |
121 |
122 |
123 |
124 |
125 | )
126 | }
127 |
128 | ReactDOM.createRoot(document.getElementById("app")!).render(
129 | // strict mode in dev mounts / unmounts components twice
130 | // and breaks the clipboard dialog
131 | //
132 |
133 | //
134 | )
135 |
--------------------------------------------------------------------------------
/beszel/site/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/beszel/site/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: ["./pages/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}", "./src/**/*.{ts,tsx}"],
5 | prefix: "",
6 | theme: {
7 | container: {
8 | center: true,
9 | padding: "1rem",
10 | screens: {
11 | "2xl": "1420px",
12 | },
13 | },
14 | extend: {
15 | fontFamily: {
16 | sans: "Inter, sans-serif",
17 | // body: ['Inter', 'sans-serif'],
18 | // display: ['Inter', 'sans-serif'],
19 | },
20 | screens: {
21 | xs: "425px",
22 | 450: "450px",
23 | },
24 | colors: {
25 | green: {
26 | 50: "#EBF9F0",
27 | 100: "#D8F3E1",
28 | 200: "#ADE6C0",
29 | 300: "#85DBA2",
30 | 400: "#5ACE81",
31 | 500: "#38BB63",
32 | 600: "#2D954F",
33 | 700: "#22723D",
34 | 800: "#164B28",
35 | 900: "#0C2715",
36 | 950: "#06140A",
37 | },
38 | border: "hsl(var(--border))",
39 | input: "hsl(var(--input))",
40 | ring: "hsl(var(--ring))",
41 | background: "hsl(var(--background))",
42 | foreground: "hsl(var(--foreground))",
43 | primary: {
44 | DEFAULT: "hsl(var(--primary))",
45 | foreground: "hsl(var(--primary-foreground))",
46 | },
47 | secondary: {
48 | DEFAULT: "hsl(var(--secondary))",
49 | foreground: "hsl(var(--secondary-foreground))",
50 | },
51 | destructive: {
52 | DEFAULT: "hsl(var(--destructive))",
53 | foreground: "hsl(var(--destructive-foreground))",
54 | },
55 | muted: {
56 | DEFAULT: "hsl(var(--muted))",
57 | foreground: "hsl(var(--muted-foreground))",
58 | },
59 | accent: {
60 | DEFAULT: "hsl(var(--accent))",
61 | foreground: "hsl(var(--accent-foreground))",
62 | },
63 | popover: {
64 | DEFAULT: "hsl(var(--popover))",
65 | foreground: "hsl(var(--popover-foreground))",
66 | },
67 | card: {
68 | DEFAULT: "hsl(var(--card))",
69 | foreground: "hsl(var(--card-foreground))",
70 | },
71 | },
72 | borderRadius: {
73 | lg: "var(--radius)",
74 | md: "calc(var(--radius) - 2px)",
75 | sm: "calc(var(--radius) - 4px)",
76 | },
77 | keyframes: {
78 | "accordion-down": {
79 | from: { height: "0" },
80 | to: { height: "var(--radix-accordion-content-height)" },
81 | },
82 | "accordion-up": {
83 | from: { height: "var(--radix-accordion-content-height)" },
84 | to: { height: "0" },
85 | },
86 | },
87 | animation: {
88 | "accordion-down": "accordion-down 0.2s ease-out",
89 | "accordion-up": "accordion-up 0.2s ease-out",
90 | },
91 | },
92 | },
93 | plugins: [
94 | require("tailwindcss-animate"),
95 | require("tailwindcss-rtl"),
96 | function ({ addVariant }) {
97 | addVariant("light", ".light &")
98 | },
99 | ],
100 | }
101 |
--------------------------------------------------------------------------------
/beszel/site/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
5 | "target": "ES2020",
6 | "useDefineForClassFields": true,
7 | "module": "ESNext",
8 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
9 | "skipLibCheck": true,
10 | "baseUrl": ".",
11 | "paths": {
12 | "@/*": ["./src/*"]
13 | },
14 |
15 | /* Bundler mode */
16 | "moduleResolution": "bundler",
17 | "allowImportingTsExtensions": true,
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "moduleDetection": "force",
21 | "noEmit": true,
22 | "jsx": "react-jsx",
23 |
24 | /* Linting */
25 | "strict": true,
26 | "noUnusedLocals": true,
27 | "noUnusedParameters": true,
28 | "noFallthroughCasesInSwitch": true
29 | },
30 | "include": ["src"]
31 | }
32 |
--------------------------------------------------------------------------------
/beszel/site/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | {
5 | "path": "./tsconfig.app.json"
6 | },
7 | {
8 | "path": "./tsconfig.node.json"
9 | }
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/beszel/site/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
5 | "skipLibCheck": true,
6 | "module": "ESNext",
7 | "moduleResolution": "bundler",
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "noEmit": true
11 | },
12 | "include": ["vite.config.ts"]
13 | }
14 |
--------------------------------------------------------------------------------
/beszel/site/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite"
2 | import path from "path"
3 | import react from "@vitejs/plugin-react-swc"
4 | import { lingui } from "@lingui/vite-plugin"
5 | import { version } from "./package.json"
6 |
7 | export default defineConfig({
8 | base: "./",
9 | plugins: [
10 | react({
11 | plugins: [["@lingui/swc-plugin", {}]],
12 | }),
13 | lingui(),
14 | {
15 | name: "replace version in index.html during dev",
16 | apply: "serve",
17 | transformIndexHtml(html) {
18 | return html.replace("{{V}}", version)
19 | },
20 | },
21 | ],
22 | esbuild: {
23 | legalComments: "external",
24 | },
25 | resolve: {
26 | alias: {
27 | "@": path.resolve(__dirname, "./src"),
28 | },
29 | },
30 | })
31 |
--------------------------------------------------------------------------------
/beszel/version.go:
--------------------------------------------------------------------------------
1 | package beszel
2 |
3 | const (
4 | Version = "0.11.1"
5 | AppName = "beszel"
6 | )
7 |
--------------------------------------------------------------------------------
/i18n.yml:
--------------------------------------------------------------------------------
1 | files:
2 | - source: /beszel/site/src/locales/en/en.po
3 | translation: /beszel/site/src/locales/%two_letters_code%/%two_letters_code%.po
4 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Beszel
2 |
3 | Beszel is a lightweight server monitoring platform that includes Docker statistics, historical data, and alert functions.
4 |
5 | It has a friendly web interface, simple configuration, and is ready to use out of the box. It supports automatic backup, multi-user, OAuth authentication, and API access.
6 |
7 | [](https://hub.docker.com/r/henrygd/beszel-agent)
8 | [](https://hub.docker.com/r/henrygd/beszel)
9 | [](https://github.com/henrygd/beszel/blob/main/LICENSE)
10 | [](https://crowdin.com/project/beszel)
11 |
12 | 
13 |
14 | ## Features
15 |
16 | - **Lightweight**: Smaller and less resource-intensive than leading solutions.
17 | - **Simple**: Easy setup with little manual configuration required.
18 | - **Docker stats**: Tracks CPU, memory, and network usage history for each container.
19 | - **Alerts**: Configurable alerts for CPU, memory, disk, bandwidth, temperature, and status.
20 | - **Multi-user**: Users manage their own systems. Admins can share systems across users.
21 | - **OAuth / OIDC**: Supports many OAuth2 providers. Password auth can be disabled.
22 | - **Automatic backups**: Save to and restore from disk or S3-compatible storage.
23 |
24 |
25 | ## Architecture
26 |
27 | Beszel consists of two main components: the **hub** and the **agent**.
28 |
29 | - **Hub**: A web application built on [PocketBase](https://pocketbase.io/) that provides a dashboard for viewing and managing connected systems.
30 | - **Agent**: Runs on each system you want to monitor, creating a minimal SSH server to communicate system metrics to the hub.
31 |
32 | ## Getting started
33 |
34 | The [quick start guide](https://beszel.dev/guide/getting-started) and other documentation is available on our website, [beszel.dev](https://beszel.dev). You'll be up and running in a few minutes.
35 |
36 | ## Screenshots
37 |
38 | 
39 | 
40 | 
41 |
42 | ## Supported metrics
43 |
44 | - **CPU usage** - Host system and Docker / Podman containers.
45 | - **Memory usage** - Host system and containers. Includes swap and ZFS ARC.
46 | - **Disk usage** - Host system. Supports multiple partitions and devices.
47 | - **Disk I/O** - Host system. Supports multiple partitions and devices.
48 | - **Network usage** - Host system and containers.
49 | - **Temperature** - Host system sensors.
50 | - **GPU usage / temperature / power draw** - Nvidia and AMD only. Must use binary agent.
51 |
52 | ## Help and discussion
53 |
54 | Please search existing issues and discussions before opening a new one. I try my best to respond, but may not always have time to do so.
55 |
56 | #### Bug reports and feature requests
57 |
58 | Bug reports and detailed feature requests should be posted on [GitHub issues](https://github.com/henrygd/beszel/issues).
59 |
60 | #### Support and general discussion
61 |
62 | Support requests and general discussion can be posted on [GitHub discussions](https://github.com/henrygd/beszel/discussions) or the community-run [Matrix room](https://matrix.to/#/#beszel:matrix.org): `#beszel:matrix.org`.
63 |
64 | ## License
65 |
66 | Beszel is licensed under the MIT License. See the [LICENSE](LICENSE) file for more details.
67 |
--------------------------------------------------------------------------------
/supplemental/debian/beszel-agent.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=Beszel Agent Service
3 | Wants=network-online.target
4 | After=network-online.target
5 |
6 | [Service]
7 | Environment="PORT=45876"
8 | # Port number can be overridden in beszel-agent.conf if needed
9 | EnvironmentFile=/etc/beszel-agent.conf
10 | ExecStart=/usr/bin/beszel-agent
11 | User=beszel
12 | Restart=on-failure
13 | StateDirectory=beszel-agent
14 |
15 | # Security/sandboxing settings
16 | KeyringMode=private
17 | LockPersonality=yes
18 | NoNewPrivileges=yes
19 | PrivateTmp=yes
20 | ProtectClock=yes
21 | ProtectHome=read-only
22 | ProtectHostname=yes
23 | ProtectKernelLogs=yes
24 | ProtectSystem=strict
25 | RemoveIPC=yes
26 | RestrictSUIDSGID=true
27 | SystemCallArchitectures=native
28 |
29 | [Install]
30 | WantedBy=multi-user.target
31 |
--------------------------------------------------------------------------------
/supplemental/debian/config.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 |
4 | . /usr/share/debconf/confmodule
5 | db_version 2.0
6 |
7 | db_input high beszel-agent/key || true
8 | db_go
9 |
--------------------------------------------------------------------------------
/supplemental/debian/copyright:
--------------------------------------------------------------------------------
1 | Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
2 | Upstream-Name: Beszel
3 | Upstream-Contact: henrygd
4 | Source: https://beszel.dev/
5 |
6 | Files: *
7 | Copyright: 2024 henrygd
8 | License: MIT
9 |
--------------------------------------------------------------------------------
/supplemental/debian/lintian-overrides:
--------------------------------------------------------------------------------
1 | # No changelog in the repo at the moment. This would be good to fix
2 | beszel-agent: no-changelog
3 | # Current unable to fix these due to Goreleaser bug
4 | # https://github.com/goreleaser/goreleaser/issues/5487
5 | beszel-agent: no-debconf-config
6 | beszel-agent: postinst-uses-db-input
7 | # Needs to be fixed in Beszel build
8 | beszel-agent: hardening-no-pie
9 | beszel-agent: hardening-no-relro
10 | # Maybe one day
11 | beszel-agent: no-manual-page
12 |
--------------------------------------------------------------------------------
/supplemental/debian/postinstall.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 |
4 | [ "$1" = "configure" ] || exit 0
5 |
6 | CONFIG_FILE=/etc/beszel-agent.conf
7 | SERVICE=beszel-agent
8 | SERVICE_USER=beszel
9 |
10 | . /usr/share/debconf/confmodule
11 |
12 | # This would normally be in the config control file, however this is currently
13 | # broken in goreleaser. Temporarily do it here.
14 | # https://github.com/goreleaser/goreleaser/issues/5487
15 | db_version 2.0
16 | db_input high beszel-agent/key || true
17 | db_go
18 |
19 | # Create group and user
20 | if ! getent group "$SERVICE_USER" >/dev/null; then
21 | echo "Creating $SERVICE_USER group"
22 | addgroup --quiet --system "$SERVICE_USER"
23 | fi
24 |
25 | if ! getent passwd "$SERVICE_USER" >/dev/null; then
26 | echo "Creating $SERVICE_USER user"
27 | adduser --quiet --system "$SERVICE_USER" \
28 | --ingroup "$SERVICE_USER" \
29 | --no-create-home \
30 | --home /nonexistent \
31 | --gecos "System user for $SERVICE"
32 | fi
33 |
34 | # Enable docker
35 | if ! getent group docker | grep -q "$SERVICE_USER"; then
36 | echo "Adding $SERVICE_USER to docker group"
37 | usermod -aG docker "$SERVICE_USER"
38 | fi
39 |
40 | # Create config file if it doesn't already exist
41 | if [ ! -f "$CONFIG_FILE" ]; then
42 | touch "$CONFIG_FILE"
43 | chmod 0600 "$CONFIG_FILE"
44 | chown "$SERVICE_USER":"$SERVICE_USER" "$CONFIG_FILE"
45 | fi;
46 |
47 | # Only add key to config if it's not already present
48 | if ! grep -q "^KEY=" "$CONFIG_FILE"; then
49 | db_get beszel-agent/key
50 | echo "KEY=$RET" > "$CONFIG_FILE"
51 | fi;
52 |
53 | deb-systemd-helper enable "$SERVICE".service
54 | systemctl daemon-reload
55 | deb-systemd-invoke start "$SERVICE".service || echo "could not start $SERVICE.service!"
56 |
--------------------------------------------------------------------------------
/supplemental/debian/postrm.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 |
4 | if [ "$1" = "purge" ]; then
5 | . /usr/share/debconf/confmodule
6 | db_purge
7 | rm /etc/beszel-agent.conf
8 | fi
9 |
--------------------------------------------------------------------------------
/supplemental/debian/prerm.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 |
4 | SERVICE=beszel-agent
5 |
6 | deb-systemd-invoke stop "$SERVICE".service
7 | if [ "$1" = "remove" ]; then
8 | deb-systemd-helper purge "$SERVICE".service
9 | fi
10 |
--------------------------------------------------------------------------------
/supplemental/debian/templates:
--------------------------------------------------------------------------------
1 | Template: beszel-agent/key
2 | Type: string
3 | Description: SSH public key provided by beszel hub:
4 | If you leave this blank, you will need to configure it in
5 | /etc/beszel-agent.conf before starting Beszel.
6 |
--------------------------------------------------------------------------------
/supplemental/docker/agent/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | beszel-agent:
3 | image: 'henrygd/beszel-agent'
4 | container_name: 'beszel-agent'
5 | restart: unless-stopped
6 | network_mode: host
7 | volumes:
8 | - /var/run/docker.sock:/var/run/docker.sock:ro
9 | # monitor other disks / partitions by mounting a folder in /extra-filesystems
10 | # - /mnt/disk/.beszel:/extra-filesystems/sda1:ro
11 | environment:
12 | PORT: 45876
13 | KEY: 'ssh-ed25519 YOUR_PUBLIC_KEY'
14 |
--------------------------------------------------------------------------------
/supplemental/docker/hub/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | beszel:
3 | image: 'henrygd/beszel'
4 | container_name: 'beszel'
5 | restart: unless-stopped
6 | ports:
7 | - '8090:8090'
8 | volumes:
9 | - ./beszel_data:/beszel_data
10 |
--------------------------------------------------------------------------------
/supplemental/docker/same-system/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | beszel:
3 | image: 'henrygd/beszel'
4 | container_name: 'beszel'
5 | restart: unless-stopped
6 | ports:
7 | - '8090:8090'
8 | volumes:
9 | - ./beszel_data:/beszel_data
10 | extra_hosts:
11 | - 'host.docker.internal:host-gateway'
12 |
13 | beszel-agent:
14 | image: 'henrygd/beszel-agent'
15 | container_name: 'beszel-agent'
16 | restart: unless-stopped
17 | network_mode: host
18 | volumes:
19 | - /var/run/docker.sock:/var/run/docker.sock:ro
20 | environment:
21 | PORT: 45876
22 | KEY: '...'
23 | # FILESYSTEM: /dev/sda1 # set to the correct filesystem for disk I/O stats
24 |
--------------------------------------------------------------------------------
/supplemental/guides/systemd.md:
--------------------------------------------------------------------------------
1 | # Installing as a Linux systemd service
2 |
3 | This is useful if you want to run the hub or agent in the background continuously, including after a reboot.
4 |
5 | ## Install script (recommended)
6 |
7 | There are two scripts, one for the hub and one for the agent. You can run either one, or both.
8 |
9 | The install script creates a dedicated user for the service (`beszel`), downloads the latest release, and installs the service.
10 |
11 | If you need to edit the service -- for instance, to change an environment variable -- you can edit the file(s) in `/etc/systemd/system/`. Then reload the systemd daemon and restart the service.
12 |
13 | > [!NOTE]
14 | > You need system administrator privileges to run the install script. If you encounter a problem, please [open an issue](https://github.com/henrygd/beszel/issues/new).
15 |
16 | ### Hub
17 |
18 | Download the script:
19 |
20 | ```bash
21 | curl -sL https://raw.githubusercontent.com/henrygd/beszel/main/supplemental/scripts/install-hub.sh -o install-hub.sh && chmod +x install-hub.sh
22 | ```
23 |
24 | #### Install
25 |
26 | You may specify a port number with the `-p` flag. The default port is `8090`.
27 |
28 | ```bash
29 | ./install-hub.sh
30 | ```
31 |
32 | #### Uninstall
33 |
34 | ```bash
35 | ./install-hub.sh -u
36 | ```
37 |
38 | #### Update
39 |
40 | ```bash
41 | sudo /opt/beszel/beszel update && sudo systemctl restart beszel-hub
42 | ```
43 |
44 | ### Agent
45 |
46 | Download the script:
47 |
48 | ```bash
49 | curl -sL https://raw.githubusercontent.com/henrygd/beszel/main/supplemental/scripts/install-agent.sh -o install-agent.sh && chmod +x install-agent.sh
50 | ```
51 |
52 | #### Install
53 |
54 | You may optionally include the SSH key and port as arguments. Run `./install-agent.sh -h` for more info.
55 |
56 | If specifying your key with `-k`, please make sure to enclose it in quotes.
57 |
58 | ```bash
59 | ./install-agent.sh
60 | ```
61 |
62 | #### Uninstall
63 |
64 | ```bash
65 | ./install-agent.sh -u
66 | ```
67 |
68 | #### Update
69 |
70 | ```bash
71 | sudo /opt/beszel-agent/beszel-agent update && sudo systemctl restart beszel-agent
72 | ```
73 |
74 | ## Manual install
75 |
76 | ### Hub
77 |
78 | 1. Create the system service at `/etc/systemd/system/beszel.service`
79 |
80 | ```bash
81 | [Unit]
82 | Description=Beszel Hub Service
83 | After=network.target
84 |
85 | [Service]
86 | # update the values in the curly braces below (remove the braces)
87 | ExecStart={/path/to/working/directory}/beszel serve
88 | WorkingDirectory={/path/to/working/directory}
89 | User={YOUR_USERNAME}
90 | Restart=always
91 |
92 | [Install]
93 | WantedBy=multi-user.target
94 | ```
95 |
96 | 2. Start and enable the service to let it run after system boot
97 |
98 | ```bash
99 | sudo systemctl daemon-reload
100 | sudo systemctl enable beszel.service
101 | sudo systemctl start beszel.service
102 | ```
103 |
104 | ### Agent
105 |
106 | 1. Create the system service at `/etc/systemd/system/beszel-agent.service`
107 |
108 | ```bash
109 | [Unit]
110 | Description=Beszel Agent Service
111 | After=network.target
112 |
113 | [Service]
114 | # update the values in curly braces below (remove the braces)
115 | Environment="PORT={PASTE_YOUR_PORT_HERE}"
116 | Environment="KEY={PASTE_YOUR_KEY_HERE}"
117 | # Environment="EXTRA_FILESYSTEMS={sdb}"
118 | ExecStart={/path/to/directory}/beszel-agent
119 | User={YOUR_USERNAME}
120 | Restart=always
121 |
122 | [Install]
123 | WantedBy=multi-user.target
124 | ```
125 |
126 | 2. Start and enable the service to let it run after system boot
127 |
128 | ```bash
129 | sudo systemctl daemon-reload
130 | sudo systemctl enable beszel-agent.service
131 | sudo systemctl start beszel-agent.service
132 | ```
133 |
--------------------------------------------------------------------------------
/supplemental/kubernetes/beszel-hub/charts/.helmignore:
--------------------------------------------------------------------------------
1 | # Patterns to ignore when building packages.
2 | # This supports shell glob matching, relative path matching, and
3 | # negation (prefixed with !). Only one pattern per line.
4 | .DS_Store
5 | # Common VCS dirs
6 | .git/
7 | .gitignore
8 | .bzr/
9 | .bzrignore
10 | .hg/
11 | .hgignore
12 | .svn/
13 | # Common backup files
14 | *.swp
15 | *.bak
16 | *.tmp
17 | *.orig
18 | *~
19 | # Various IDEs
20 | .project
21 | .idea/
22 | *.tmproj
23 | .vscode/
24 |
--------------------------------------------------------------------------------
/supplemental/kubernetes/beszel-hub/charts/Chart.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | description: Installs beszel-hub in kubernetes
3 | home: https://github.com/dnikoloski/beszel-kubernetes/tree/main/charts/beszel-hub
4 | name: beszel-hub
5 | appVersion: "0.9"
6 | # Do not touch will be updated during release
7 | version: 0.1.0
8 | sources:
9 | - https://github.com/dnikoloski/beszel-kubernetes/tree/main/charts/beszel-hub
10 | - https://www.beszel.dev/
11 | - https://github.com/henrygd/beszel
12 | icon: https://repository-images.githubusercontent.com/825470378/2710c6db-f934-4a8b-a2c4-7a0abbcd2ad6
13 | maintainers:
14 | - name: dnikoloski
15 | email: nikoloskid@pm.me
16 |
--------------------------------------------------------------------------------
/supplemental/kubernetes/beszel-hub/charts/templates/NOTES.txt:
--------------------------------------------------------------------------------
1 | 1. Get the application URL by running these commands:
2 | {{- if .Values.ingress.enabled }}
3 | {{- range $host := .Values.ingress.hosts }}
4 | {{- range .paths }}
5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
6 | {{- end }}
7 | {{- end }}
8 | {{- else if contains "NodePort" .Values.service.type }}
9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "beszel.fullname" . }}-web)
10 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
11 | echo http://$NODE_IP:$NODE_PORT
12 | {{- else if contains "LoadBalancer" .Values.service.type }}
13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available.
14 | You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "beszel.fullname" . }}'
15 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "beszel.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
16 | echo http://$SERVICE_IP:{{ .Values.service.port }}
17 | {{- else if contains "ClusterIP" .Values.service.type }}
18 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "beszel.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
19 | export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
20 | echo "Visit http://127.0.0.1:8090 to use your application"
21 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8090:$CONTAINER_PORT
22 | {{- end }}
23 |
--------------------------------------------------------------------------------
/supplemental/kubernetes/beszel-hub/charts/templates/_helpers.tpl:
--------------------------------------------------------------------------------
1 | {{/*
2 | Expand the name of the chart.
3 | */}}
4 | {{- define "beszel.name" -}}
5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
6 | {{- end }}
7 |
8 | {{/*
9 | Create a default fully qualified app name.
10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
11 | If release name contains chart name it will be used as a full name.
12 | */}}
13 | {{- define "beszel.fullname" -}}
14 | {{- if .Values.fullnameOverride }}
15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
16 | {{- else }}
17 | {{- $name := default .Chart.Name .Values.nameOverride }}
18 | {{- if contains $name .Release.Name }}
19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }}
20 | {{- else }}
21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
22 | {{- end }}
23 | {{- end }}
24 | {{- end }}
25 |
26 | {{/*
27 | Create chart name and version as used by the chart label.
28 | */}}
29 | {{- define "beszel.chart" -}}
30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
31 | {{- end }}
32 |
33 | {{/*
34 | Common labels
35 | */}}
36 | {{- define "beszel.labels" -}}
37 | helm.sh/chart: {{ include "beszel.chart" . }}
38 | {{ include "beszel.selectorLabels" . }}
39 | {{- if .Chart.AppVersion }}
40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
41 | {{- end }}
42 | app.kubernetes.io/managed-by: {{ .Release.Service }}
43 | {{- end }}
44 |
45 | {{/*
46 | Selector labels
47 | */}}
48 | {{- define "beszel.selectorLabels" -}}
49 | app.kubernetes.io/name: {{ include "beszel.name" . }}
50 | app.kubernetes.io/instance: {{ .Release.Name }}
51 | {{- end }}
52 |
--------------------------------------------------------------------------------
/supplemental/kubernetes/beszel-hub/charts/templates/deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 | metadata:
4 | name: {{ include "beszel.fullname" . }}
5 | labels:
6 | {{- include "beszel.labels" . | nindent 4 }}
7 | spec:
8 | replicas: {{ .Values.replicaCount }}
9 | strategy:
10 | type: {{ .Values.strategyType }}
11 | {{- if eq .Values.strategyType "RollingUpdate" }}
12 | rollingUpdate:
13 | maxSurge: {{ .Values.maxSurge }}
14 | maxUnavailable: {{ .Values.maxUnavailable }}
15 | {{- end }}
16 | selector:
17 | matchLabels:
18 | {{- include "beszel.selectorLabels" . | nindent 6 }}
19 | template:
20 | metadata:
21 | {{- with .Values.podAnnotations }}
22 | annotations:
23 | {{- toYaml . | nindent 8 }}
24 | {{- end }}
25 | labels:
26 | {{- include "beszel.labels" . | nindent 8 }}
27 | {{- with .Values.podLabels }}
28 | {{- toYaml . | nindent 8 }}
29 | {{- end }}
30 | spec:
31 | {{- with .Values.imagePullSecrets }}
32 | imagePullSecrets:
33 | {{- toYaml . | nindent 8 }}
34 | {{- end }}
35 | {{- with .Values.podSecurityContext }}
36 | securityContext:
37 | {{- toYaml . | nindent 8 }}
38 | {{- end }}
39 | hostname: {{ .Values.hostname }}
40 | hostNetwork: {{ .Values.hostNetwork }}
41 | containers:
42 | - name: {{ .Chart.Name }}
43 | {{- with .Values.securityContext }}
44 | securityContext:
45 | {{- toYaml . | nindent 12 }}
46 | {{- end }}
47 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
48 | imagePullPolicy: {{ .Values.image.pullPolicy }}
49 | ports:
50 | - name: http
51 | containerPort: {{ .Values.service.port }}
52 | protocol: TCP
53 | {{- with .Values.livenessProbe }}
54 | livenessProbe:
55 | {{- toYaml . | nindent 12 }}
56 | {{- end }}
57 | {{- with .Values.readinessProbe }}
58 | readinessProbe:
59 | {{- toYaml . | nindent 12 }}
60 | {{- end }}
61 | {{- with .Values.resources }}
62 | resources:
63 | {{- toYaml . | nindent 12 }}
64 | {{- end }}
65 | {{- if .Values.persistentVolumeClaim.enabled }}
66 | volumeMounts:
67 | - name: data
68 | mountPath: /beszel_data
69 | {{- with .Values.volumeMounts }}
70 | {{- toYaml . | nindent 12 }}
71 | {{- end }}
72 | {{- else if .Values.volumeMounts }}
73 | volumeMounts:
74 | {{- toYaml .Values.volumeMounts | nindent 12 }}
75 | {{- end }}
76 | {{- if .Values.persistentVolumeClaim.enabled }}
77 | volumes:
78 | - name: data
79 | persistentVolumeClaim:
80 | claimName: {{ .Values.persistentVolumeClaim.existingClaim | default (include "beszel.fullname" .) }}
81 | {{- with .Values.volumes }}
82 | {{- toYaml . | nindent 8 }}
83 | {{- end }}
84 | {{- else if .Values.volumes }}
85 | volumes:
86 | {{- toYaml .Values.volumes | nindent 8 }}
87 | {{- end }}
88 | {{- with .Values.nodeSelector }}
89 | nodeSelector:
90 | {{- toYaml . | nindent 8 }}
91 | {{- end }}
92 | {{- with .Values.affinity }}
93 | affinity:
94 | {{- toYaml . | nindent 8 }}
95 | {{- end }}
96 | {{- with .Values.tolerations }}
97 | tolerations:
98 | {{- toYaml . | nindent 8 }}
99 | {{- end }}
100 |
--------------------------------------------------------------------------------
/supplemental/kubernetes/beszel-hub/charts/templates/ingress.yaml:
--------------------------------------------------------------------------------
1 | {{- if .Values.ingress.enabled -}}
2 | apiVersion: networking.k8s.io/v1
3 | kind: Ingress
4 | metadata:
5 | name: {{ include "beszel.fullname" . }}
6 | labels:
7 | {{- include "beszel.labels" . | nindent 4 }}
8 | {{- with .Values.ingress.annotations }}
9 | annotations:
10 | {{- toYaml . | nindent 4 }}
11 | {{- end }}
12 | spec:
13 | {{- with .Values.ingress.className }}
14 | ingressClassName: {{ . }}
15 | {{- end }}
16 | {{- if .Values.ingress.tls }}
17 | tls:
18 | {{- range .Values.ingress.tls }}
19 | - hosts:
20 | {{- range .hosts }}
21 | - {{ . | quote }}
22 | {{- end }}
23 | secretName: {{ .secretName }}
24 | {{- end }}
25 | {{- end }}
26 | rules:
27 | {{- range .Values.ingress.hosts }}
28 | - host: {{ .host | quote }}
29 | http:
30 | paths:
31 | {{- range .paths }}
32 | - path: {{ .path }}
33 | {{- with .pathType }}
34 | pathType: {{ . }}
35 | {{- end }}
36 | backend:
37 | service:
38 | name: {{ include "beszel.fullname" $ }}
39 | port:
40 | number: {{ $.Values.service.port }}
41 | {{- end }}
42 | {{- end }}
43 | {{- end }}
44 |
--------------------------------------------------------------------------------
/supplemental/kubernetes/beszel-hub/charts/templates/service.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: {{ include "beszel.fullname" . }}-web
5 | labels:
6 | {{- include "beszel.labels" . | nindent 4 }}
7 | {{- if .Values.service.annotations }}
8 | annotations:
9 | {{ toYaml .Values.service.annotations | indent 4 }}
10 | {{- end }}
11 | spec:
12 | type: {{ .Values.service.type }}
13 | ports:
14 | - port: {{ .Values.service.port }}
15 | targetPort: http
16 | protocol: TCP
17 | name: http
18 | {{- if .Values.service.loadBalancerIP }}
19 | loadBalancerIP: {{ .Values.service.loadBalancerIP }}
20 | {{- end }}
21 | selector:
22 | {{- include "beszel.selectorLabels" . | nindent 4 }}
23 |
--------------------------------------------------------------------------------
/supplemental/kubernetes/beszel-hub/charts/templates/tests/test-beszel-hub-endpoint.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Pod
3 | metadata:
4 | name: "{{ .Release.Name }}-smoke-test"
5 | annotations:
6 | "helm.sh/hook": test
7 | spec:
8 | containers:
9 | - name: hook1-container
10 | image: curlimages/curl
11 | imagePullPolicy: IfNotPresent
12 | command: ['sh', '-c', 'curl http://{{ template "beszel.fullname" . }}-web:8090/']
13 | restartPolicy: Never
14 | terminationGracePeriodSeconds: 0
--------------------------------------------------------------------------------
/supplemental/kubernetes/beszel-hub/charts/templates/volume-claim.yaml:
--------------------------------------------------------------------------------
1 | {{- if .Values.persistentVolumeClaim.enabled -}}
2 | {{- if not .Values.persistentVolumeClaim.existingClaim -}}
3 | apiVersion: v1
4 | kind: PersistentVolumeClaim
5 | metadata:
6 | {{- if .Values.persistentVolumeClaim.annotations }}
7 | annotations:
8 | {{ toYaml .Values.persistentVolumeClaim.annotations | indent 4 }}
9 | {{- end }}
10 | labels:
11 | {{- include "beszel.labels" . | nindent 4 }}
12 | name: {{ template "beszel.fullname" . }}
13 | spec:
14 | accessModes:
15 | {{ toYaml .Values.persistentVolumeClaim.accessModes | indent 4 }}
16 | {{- if .Values.persistentVolumeClaim.storageClass }}
17 | {{- if (eq "-" .Values.persistentVolumeClaim.storageClass) }}
18 | storageClassName: ""
19 | {{- else }}
20 | storageClassName: {{ .Values.persistentVolumeClaim.storageClass | quote }}
21 | {{- end }}
22 | {{- end }}
23 | resources:
24 | requests:
25 | storage: {{ .Values.persistentVolumeClaim.size | quote }}
26 | {{- end -}}
27 | {{- end -}}
--------------------------------------------------------------------------------
/supplemental/kubernetes/beszel-hub/charts/values.yaml:
--------------------------------------------------------------------------------
1 | # Default values for beszel-hub.
2 | # This is a YAML-formatted file.
3 | # Declare variables to be passed into your templates.
4 |
5 | # -- The number of replicas
6 | replicaCount: 1
7 |
8 | image:
9 | repository: henrygd/beszel
10 | pullPolicy: IfNotPresent
11 | tag: ""
12 |
13 | imagePullSecrets: []
14 | nameOverride: ""
15 | fullnameOverride: ""
16 |
17 |
18 | podAnnotations: {}
19 | podLabels: {}
20 |
21 | podSecurityContext: {}
22 |
23 | securityContext: {}
24 | # capabilities:
25 | # drop:
26 | # - ALL
27 | # readOnlyRootFilesystem: true
28 | # runAsNonRoot: true
29 | # runAsUser: 1000
30 |
31 | service:
32 | enabled: true
33 | type: LoadBalancer
34 | loadBalancerIP: "10.0.10.251"
35 | port: 8090
36 | # -- Annotations for the DHCP service
37 | annotations:
38 | metallb.universe.tf/address-pool: pool
39 | metallb.universe.tf/allow-shared-ip: beszel-hub-web
40 | # -- Labels for the DHCP service
41 |
42 | ingress:
43 | enabled: false
44 | className: ""
45 | annotations: {}
46 | # kubernetes.io/ingress.class: nginx
47 | # kubernetes.io/tls-acme: "true"
48 | hosts:
49 | - host: chart-example.local
50 | paths:
51 | - path: /
52 | pathType: ImplementationSpecific
53 | tls: []
54 | # - secretName: chart-example-tls
55 | # hosts:
56 | # - chart-example.local
57 |
58 | resources: {}
59 | # limits:
60 | # cpu: 100m
61 | # memory: 128Mi
62 | # requests:
63 | # cpu: 100m
64 | # memory: 128Mi
65 |
66 | livenessProbe:
67 | httpGet:
68 | path: /
69 | port: http
70 | readinessProbe:
71 | httpGet:
72 | path: /
73 | port: http
74 |
75 | autoscaling:
76 | enabled: false
77 | minReplicas: 1
78 | maxReplicas: 100
79 | targetCPUUtilizationPercentage: 80
80 |
81 | # volumes: {}
82 |
83 | # volumeMounts: {}
84 |
85 | # -- `spec.PersitentVolumeClaim` configuration
86 | persistentVolumeClaim:
87 | # -- set to true to use pvc
88 | enabled: true
89 |
90 | # -- specify an existing `PersistentVolumeClaim` to use
91 | # existingClaim: ""
92 |
93 | # -- Annotations for the `PersitentVolumeClaim`
94 | annotations: {}
95 |
96 | accessModes:
97 | - ReadWriteOnce
98 |
99 | storageClass: "retain-local-path"
100 |
101 | # -- volume claim size
102 | size: "500Mi"
103 |
104 | # -- hostname of pod
105 | hostname: ""
106 |
107 | # -- should the container use host network
108 | hostNetwork: "false"
109 |
110 | nodeSelector: {}
111 |
112 | tolerations: []
113 |
114 | affinity: {}
115 |
--------------------------------------------------------------------------------
/supplemental/scripts/install-agent-brew.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | PORT=45876
4 | KEY=""
5 |
6 | usage() {
7 | printf "Beszel Agent homebrew installation script\n\n"
8 | printf "Usage: ./install-agent-brew.sh [options]\n\n"
9 | printf "Options: \n"
10 | printf " -k SSH key (required, or interactive if not provided)\n"
11 | printf " -p Port (default: $PORT)\n"
12 | printf " -h, --help Display this help message\n"
13 | exit 0
14 | }
15 |
16 | # Handle --help explicitly since getopts doesn't handle long options
17 | if [ "$1" = "--help" ]; then
18 | usage
19 | fi
20 |
21 | # Parse arguments with getopts
22 | while getopts "k:p:h" opt; do
23 | case ${opt} in
24 | k)
25 | KEY="$OPTARG"
26 | ;;
27 | p)
28 | PORT="$OPTARG"
29 | ;;
30 | h)
31 | usage
32 | ;;
33 | \?)
34 | echo "Invalid option: -$OPTARG" >&2
35 | usage
36 | ;;
37 | :)
38 | echo "Option -$OPTARG requires an argument." >&2
39 | usage
40 | ;;
41 | esac
42 | done
43 |
44 | # Check if brew is installed, prompt to install if not
45 | if ! command -v brew &>/dev/null; then
46 | read -p "Homebrew is not installed. Would you like to install it now? (y/n): " install_brew
47 | if [[ $install_brew =~ ^[Yy]$ ]]; then
48 | echo "Installing Homebrew..."
49 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
50 |
51 | # Verify installation was successful
52 | if ! command -v brew &>/dev/null; then
53 | echo "Homebrew installation failed. Please install manually and try again."
54 | exit 1
55 | fi
56 | echo "Homebrew installed successfully."
57 | else
58 | echo "Homebrew is required. Please install Homebrew and try again."
59 | exit 1
60 | fi
61 | fi
62 |
63 | if [ -z "$KEY" ]; then
64 | read -p "Enter SSH key: " KEY
65 | fi
66 |
67 | mkdir -p ~/.config/beszel ~/.cache/beszel
68 |
69 | echo "KEY=\"$KEY\"" >~/.config/beszel/beszel-agent.env
70 | echo "LISTEN=$PORT" >>~/.config/beszel/beszel-agent.env
71 |
72 | brew tap henrygd/beszel
73 | brew install beszel-agent
74 | brew services start beszel-agent
75 |
76 | printf "\nCheck status: brew services info beszel-agent\n"
77 | echo "Stop: brew services stop beszel-agent"
78 | echo "Start: brew services start beszel-agent"
79 | echo "Restart: brew services restart beszel-agent"
80 | echo "Upgrade: brew upgrade beszel-agent"
81 | echo "Uninstall: brew uninstall beszel-agent"
82 | echo "View logs in ~/.cache/beszel/beszel-agent.log"
83 | printf "Change environment variables in ~/.config/beszel/beszel-agent.env\n"
84 |
--------------------------------------------------------------------------------