mirror of
				https://github.com/henrygd/beszel.git
				synced 2025-11-04 04:24:45 +00:00 
			
		
		
		
	Compare commits
	
		
			76 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					5f4d3dc730 | ||
| 
						 | 
					8fa9aece63 | ||
| 
						 | 
					2f1a022e2a | ||
| 
						 | 
					4815cd29bc | ||
| 
						 | 
					e49bfaf5d7 | ||
| 
						 | 
					b13915b76f | ||
| 
						 | 
					e2a57dc43b | ||
| 
						 | 
					7222224b40 | ||
| 
						 | 
					02ff475b84 | ||
| 
						 | 
					09cd8d0db9 | ||
| 
						 | 
					36f1a0c53b | ||
| 
						 | 
					0b0e94e045 | ||
| 
						 | 
					20ca6edf81 | ||
| 
						 | 
					1990f8c6df | ||
| 
						 | 
					6e9dbf863f | ||
| 
						 | 
					fa921d77f1 | ||
| 
						 | 
					ff854d481d | ||
| 
						 | 
					4ce491fe48 | ||
| 
						 | 
					493bae7eb6 | ||
| 
						 | 
					ae5532aa36 | ||
| 
						 | 
					a1eae6413a | ||
| 
						 | 
					ee52bf1fbf | ||
| 
						 | 
					2ff0bd6b44 | ||
| 
						 | 
					a385233b7d | ||
| 
						 | 
					f5648a415d | ||
| 
						 | 
					556fb18953 | ||
| 
						 | 
					a482f78739 | ||
| 
						 | 
					4a580ce972 | ||
| 
						 | 
					e07558237f | ||
| 
						 | 
					fb3c70a1bc | ||
| 
						 | 
					cba4d60895 | ||
| 
						 | 
					8b655ef2b9 | ||
| 
						 | 
					0188418055 | ||
| 
						 | 
					72334c42d0 | ||
| 
						 | 
					0638ff3c21 | ||
| 
						 | 
					b64318d9e8 | ||
| 
						 | 
					0f5b1b5157 | ||
| 
						 | 
					3c4ae46f50 | ||
| 
						 | 
					c158b1aeeb | ||
| 
						 | 
					684d92c497 | ||
| 
						 | 
					bbd9595ec0 | ||
| 
						 | 
					bbebb3e301 | ||
| 
						 | 
					9d25181d1d | ||
| 
						 | 
					7ba1f366ba | ||
| 
						 | 
					37c6b920f9 | ||
| 
						 | 
					49db81dac8 | ||
| 
						 | 
					a9e90ec19c | ||
| 
						 | 
					2ad60507b7 | ||
| 
						 | 
					12059ee3db | ||
| 
						 | 
					de56544ca3 | ||
| 
						 | 
					065c7facb6 | ||
| 
						 | 
					630c92c139 | ||
| 
						 | 
					e11d452d91 | ||
| 
						 | 
					99c7f7bd8a | ||
| 
						 | 
					8af3a0eb5b | ||
| 
						 | 
					5f7950b474 | ||
| 
						 | 
					df9e2dec28 | ||
| 
						 | 
					a0f271545a | ||
| 
						 | 
					aa2bc9f118 | ||
| 
						 | 
					b22ae87022 | ||
| 
						 | 
					79e79079bc | ||
| 
						 | 
					1811ebdee4 | ||
| 
						 | 
					137f3f3e24 | ||
| 
						 | 
					ed1d1e77c0 | ||
| 
						 | 
					8c36dd1caa | ||
| 
						 | 
					57bfe72486 | ||
| 
						 | 
					75f66b0246 | ||
| 
						 | 
					ce93d54aa7 | ||
| 
						 | 
					39dbe0eac5 | ||
| 
						 | 
					7282044f80 | ||
| 
						 | 
					d77c37c0b0 | ||
| 
						 | 
					e362cbbca5 | ||
| 
						 | 
					118544926b | ||
| 
						 | 
					d4bb0a0a30 | ||
| 
						 | 
					fe5e35d1a9 | ||
| 
						 | 
					60a6ae2caa | 
							
								
								
									
										30
									
								
								.github/workflows/docker-images.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										30
									
								
								.github/workflows/docker-images.yml
									
									
									
									
										vendored
									
									
								
							@@ -14,28 +14,48 @@ jobs:
 | 
			
		||||
        include:
 | 
			
		||||
          - image: henrygd/beszel
 | 
			
		||||
            context: ./beszel
 | 
			
		||||
            dockerfile: ./beszel/dockerfile_Hub
 | 
			
		||||
            dockerfile: ./beszel/dockerfile_hub
 | 
			
		||||
            registry: docker.io
 | 
			
		||||
            username_secret: DOCKERHUB_USERNAME
 | 
			
		||||
            password_secret: DOCKERHUB_TOKEN
 | 
			
		||||
 | 
			
		||||
          - image: henrygd/beszel-agent
 | 
			
		||||
            context: ./beszel
 | 
			
		||||
            dockerfile: ./beszel/dockerfile_Agent
 | 
			
		||||
            dockerfile: ./beszel/dockerfile_agent
 | 
			
		||||
            registry: docker.io
 | 
			
		||||
            username_secret: DOCKERHUB_USERNAME
 | 
			
		||||
            password_secret: DOCKERHUB_TOKEN
 | 
			
		||||
 | 
			
		||||
          - image: henrygd/beszel-agent-nvidia
 | 
			
		||||
            context: ./beszel
 | 
			
		||||
            dockerfile: ./beszel/dockerfile_agent_nvidia
 | 
			
		||||
            platforms: linux/amd64
 | 
			
		||||
            registry: docker.io
 | 
			
		||||
            username_secret: DOCKERHUB_USERNAME
 | 
			
		||||
            password_secret: DOCKERHUB_TOKEN
 | 
			
		||||
 | 
			
		||||
          - image: ghcr.io/${{ github.repository }}/beszel
 | 
			
		||||
            context: ./beszel
 | 
			
		||||
            dockerfile: ./beszel/dockerfile_Hub
 | 
			
		||||
            dockerfile: ./beszel/dockerfile_hub
 | 
			
		||||
            registry: ghcr.io
 | 
			
		||||
            username: ${{ github.actor }}
 | 
			
		||||
            password_secret: GITHUB_TOKEN
 | 
			
		||||
 | 
			
		||||
          - image: ghcr.io/${{ github.repository }}/beszel-agent
 | 
			
		||||
            context: ./beszel
 | 
			
		||||
            dockerfile: ./beszel/dockerfile_Agent
 | 
			
		||||
            dockerfile: ./beszel/dockerfile_agent
 | 
			
		||||
            registry: ghcr.io
 | 
			
		||||
            username: ${{ github.actor }}
 | 
			
		||||
            password_secret: GITHUB_TOKEN
 | 
			
		||||
 | 
			
		||||
          - image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
 | 
			
		||||
            context: ./beszel
 | 
			
		||||
            dockerfile: ./beszel/dockerfile_agent_nvidia
 | 
			
		||||
            platforms: linux/amd64
 | 
			
		||||
            registry: ghcr.io
 | 
			
		||||
            username: ${{ github.actor }}
 | 
			
		||||
            password_secret: GITHUB_TOKEN
 | 
			
		||||
 | 
			
		||||
    permissions:
 | 
			
		||||
      contents: read
 | 
			
		||||
      packages: write
 | 
			
		||||
@@ -87,7 +107,7 @@ jobs:
 | 
			
		||||
        with:
 | 
			
		||||
          context: "${{ matrix.context }}"
 | 
			
		||||
          file: ${{ matrix.dockerfile }}
 | 
			
		||||
          platforms: linux/amd64,linux/arm64,linux/arm/v7
 | 
			
		||||
          platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v7' }}
 | 
			
		||||
          push: ${{ github.ref_type == 'tag' }}
 | 
			
		||||
          tags: ${{ steps.metadata.outputs.tags }}
 | 
			
		||||
          labels: ${{ steps.metadata.outputs.labels }}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										15
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										15
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							@@ -3,7 +3,7 @@ name: Make release and binaries
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    tags:
 | 
			
		||||
      - 'v*'
 | 
			
		||||
      - "v*"
 | 
			
		||||
 | 
			
		||||
permissions:
 | 
			
		||||
  contents: write
 | 
			
		||||
@@ -29,7 +29,17 @@ jobs:
 | 
			
		||||
      - name: Set up Go
 | 
			
		||||
        uses: actions/setup-go@v5
 | 
			
		||||
        with:
 | 
			
		||||
          go-version: '^1.22.1'
 | 
			
		||||
          go-version: "^1.22.1"
 | 
			
		||||
 | 
			
		||||
      - name: Set up .NET
 | 
			
		||||
        uses: actions/setup-dotnet@v4
 | 
			
		||||
        with:
 | 
			
		||||
          dotnet-version: "9.0.x"
 | 
			
		||||
 | 
			
		||||
      - name: Build .NET LHM executable for Windows sensors
 | 
			
		||||
        run: |
 | 
			
		||||
          dotnet build -c Release ./beszel/internal/agent/lhm/beszel_lhm.csproj
 | 
			
		||||
        shell: bash
 | 
			
		||||
 | 
			
		||||
      - name: GoReleaser beszel
 | 
			
		||||
        uses: goreleaser/goreleaser-action@v6
 | 
			
		||||
@@ -40,3 +50,4 @@ jobs:
 | 
			
		||||
          args: release --clean
 | 
			
		||||
        env:
 | 
			
		||||
          GITHUB_TOKEN: ${{ secrets.TOKEN || secrets.GITHUB_TOKEN }}
 | 
			
		||||
          WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -17,3 +17,6 @@ beszel/build
 | 
			
		||||
beszel/site/src/locales/**/*.ts
 | 
			
		||||
*.bak
 | 
			
		||||
__debug_*
 | 
			
		||||
beszel/internal/agent/lhm/obj
 | 
			
		||||
beszel/internal/agent/lhm/bin
 | 
			
		||||
dockerfile_agent_dev
 | 
			
		||||
 
 | 
			
		||||
@@ -202,13 +202,14 @@ winget:
 | 
			
		||||
      owner: henrygd
 | 
			
		||||
      name: beszel-winget
 | 
			
		||||
      branch: henrygd.beszel-agent-{{ .Version }}
 | 
			
		||||
      pull_request:
 | 
			
		||||
        enabled: true
 | 
			
		||||
        draft: false
 | 
			
		||||
        base:
 | 
			
		||||
          owner: microsoft
 | 
			
		||||
          name: winget-pkgs
 | 
			
		||||
          branch: master
 | 
			
		||||
      token: "{{ .Env.WINGET_TOKEN }}"
 | 
			
		||||
      # pull_request:
 | 
			
		||||
      #   enabled: true
 | 
			
		||||
      #   draft: false
 | 
			
		||||
      #   base:
 | 
			
		||||
      #     owner: microsoft
 | 
			
		||||
      #     name: winget-pkgs
 | 
			
		||||
      #     branch: master
 | 
			
		||||
 | 
			
		||||
release:
 | 
			
		||||
  draft: true
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,9 @@ ARCH ?= $(shell go env GOARCH)
 | 
			
		||||
# Skip building the web UI if true
 | 
			
		||||
SKIP_WEB ?= false
 | 
			
		||||
 | 
			
		||||
# Set executable extension based on target OS
 | 
			
		||||
EXE_EXT := $(if $(filter windows,$(OS)),.exe,)
 | 
			
		||||
 | 
			
		||||
.PHONY: tidy build-agent build-hub build clean lint dev-server dev-agent dev-hub dev generate-locales
 | 
			
		||||
.DEFAULT_GOAL := build
 | 
			
		||||
 | 
			
		||||
@@ -30,11 +33,25 @@ build-web-ui:
 | 
			
		||||
		npm run --prefix ./site build; \
 | 
			
		||||
	fi
 | 
			
		||||
 | 
			
		||||
build-agent: tidy
 | 
			
		||||
	GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH) -ldflags "-w -s" beszel/cmd/agent
 | 
			
		||||
# Conditional .NET build - only for Windows
 | 
			
		||||
build-dotnet-conditional:
 | 
			
		||||
	@if [ "$(OS)" = "windows" ]; then \
 | 
			
		||||
		echo "Building .NET executable for Windows..."; \
 | 
			
		||||
		if command -v dotnet >/dev/null 2>&1; then \
 | 
			
		||||
			rm -rf ./internal/agent/lhm/bin; \
 | 
			
		||||
			dotnet build -c Release ./internal/agent/lhm/beszel_lhm.csproj; \
 | 
			
		||||
		else \
 | 
			
		||||
			echo "Error: dotnet not found. Install .NET SDK to build Windows agent."; \
 | 
			
		||||
			exit 1; \
 | 
			
		||||
		fi; \
 | 
			
		||||
	fi
 | 
			
		||||
 | 
			
		||||
# Update build-agent to include conditional .NET build
 | 
			
		||||
build-agent: tidy build-dotnet-conditional
 | 
			
		||||
	GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" beszel/cmd/agent
 | 
			
		||||
 | 
			
		||||
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
 | 
			
		||||
	GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH) -ldflags "-w -s" beszel/cmd/hub
 | 
			
		||||
	GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" beszel/cmd/hub
 | 
			
		||||
 | 
			
		||||
build: build-agent build-hub
 | 
			
		||||
 | 
			
		||||
@@ -67,6 +84,15 @@ dev-agent:
 | 
			
		||||
	else \
 | 
			
		||||
		go run beszel/cmd/agent; \
 | 
			
		||||
	fi
 | 
			
		||||
	
 | 
			
		||||
build-dotnet:
 | 
			
		||||
	@if command -v dotnet >/dev/null 2>&1; then \
 | 
			
		||||
		rm -rf ./internal/agent/lhm/bin; \
 | 
			
		||||
		dotnet build -c Release ./internal/agent/lhm/beszel_lhm.csproj; \
 | 
			
		||||
	else \
 | 
			
		||||
		echo "dotnet not found"; \
 | 
			
		||||
	fi
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# KEY="..." make -j dev
 | 
			
		||||
dev: dev-server dev-hub dev-agent
 | 
			
		||||
 
 | 
			
		||||
@@ -8,6 +8,7 @@ import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"log"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"golang.org/x/crypto/ssh"
 | 
			
		||||
)
 | 
			
		||||
@@ -25,13 +26,16 @@ func (opts *cmdOptions) parse() bool {
 | 
			
		||||
	flag.StringVar(&opts.listen, "listen", "", "Address or port to listen on")
 | 
			
		||||
 | 
			
		||||
	flag.Usage = func() {
 | 
			
		||||
		fmt.Printf("Usage: %s [command] [flags]\n", os.Args[0])
 | 
			
		||||
		fmt.Println("\nCommands:")
 | 
			
		||||
		fmt.Println("  health    Check if the agent is running")
 | 
			
		||||
		fmt.Println("  help      Display this help message")
 | 
			
		||||
		fmt.Println("  update    Update to the latest version")
 | 
			
		||||
		fmt.Println("  version   Display the version")
 | 
			
		||||
		fmt.Println("\nFlags:")
 | 
			
		||||
		builder := strings.Builder{}
 | 
			
		||||
		builder.WriteString("Usage: ")
 | 
			
		||||
		builder.WriteString(os.Args[0])
 | 
			
		||||
		builder.WriteString(" [command] [flags]\n")
 | 
			
		||||
		builder.WriteString("\nCommands:\n")
 | 
			
		||||
		builder.WriteString("  health    Check if the agent is running\n")
 | 
			
		||||
		builder.WriteString("  help      Display this help message\n")
 | 
			
		||||
		builder.WriteString("  update    Update to the latest version\n")
 | 
			
		||||
		builder.WriteString("\nFlags:\n")
 | 
			
		||||
		fmt.Print(builder.String())
 | 
			
		||||
		flag.PrintDefaults()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -111,12 +115,12 @@ func main() {
 | 
			
		||||
	serverConfig.Addr = addr
 | 
			
		||||
	serverConfig.Network = agent.GetNetwork(addr)
 | 
			
		||||
 | 
			
		||||
	agent, err := agent.NewAgent("")
 | 
			
		||||
	a, err := agent.NewAgent()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatal("Failed to create agent: ", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := agent.Start(serverConfig); err != nil {
 | 
			
		||||
	if err := a.Start(serverConfig); err != nil {
 | 
			
		||||
		log.Fatal("Failed to start server: ", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										26
									
								
								beszel/dockerfile_agent
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								beszel/dockerfile_agent
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
FROM --platform=$BUILDPLATFORM golang:alpine AS builder
 | 
			
		||||
 | 
			
		||||
WORKDIR /app
 | 
			
		||||
 | 
			
		||||
COPY go.mod go.sum ./
 | 
			
		||||
# RUN go mod download
 | 
			
		||||
COPY *.go ./
 | 
			
		||||
COPY cmd ./cmd
 | 
			
		||||
COPY internal ./internal
 | 
			
		||||
 | 
			
		||||
# Build
 | 
			
		||||
ARG TARGETOS TARGETARCH
 | 
			
		||||
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent
 | 
			
		||||
 | 
			
		||||
RUN rm -rf /tmp/*
 | 
			
		||||
 | 
			
		||||
# --------------------------
 | 
			
		||||
# Final image: default scratch-based agent
 | 
			
		||||
# --------------------------
 | 
			
		||||
FROM scratch
 | 
			
		||||
COPY --from=builder /agent /agent
 | 
			
		||||
 | 
			
		||||
# this is so we don't need to create the /tmp directory in the scratch container
 | 
			
		||||
COPY --from=builder /tmp /tmp
 | 
			
		||||
 | 
			
		||||
ENTRYPOINT ["/agent"]
 | 
			
		||||
@@ -12,15 +12,10 @@ COPY internal ./internal
 | 
			
		||||
ARG TARGETOS TARGETARCH
 | 
			
		||||
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent
 | 
			
		||||
 | 
			
		||||
RUN rm -rf /tmp/*
 | 
			
		||||
 | 
			
		||||
# ? -------------------------
 | 
			
		||||
FROM scratch
 | 
			
		||||
 | 
			
		||||
# --------------------------
 | 
			
		||||
# Final image: GPU-enabled agent with nvidia-smi
 | 
			
		||||
# --------------------------
 | 
			
		||||
FROM nvidia/cuda:12.9.1-base-ubuntu22.04
 | 
			
		||||
COPY --from=builder /agent /agent
 | 
			
		||||
 | 
			
		||||
# this is so we don't need to create the 
 | 
			
		||||
# /tmp directory in the scratch container
 | 
			
		||||
COPY --from=builder /tmp /tmp
 | 
			
		||||
 | 
			
		||||
ENTRYPOINT ["/agent"]
 | 
			
		||||
@@ -7,20 +7,20 @@ replace github.com/nicholas-fedor/shoutrrr => github.com/nicholas-fedor/shoutrrr
 | 
			
		||||
 | 
			
		||||
require (
 | 
			
		||||
	github.com/blang/semver v3.5.1+incompatible
 | 
			
		||||
	github.com/distatus/battery v0.11.0
 | 
			
		||||
	github.com/fxamacker/cbor/v2 v2.9.0
 | 
			
		||||
	github.com/gliderlabs/ssh v0.3.8
 | 
			
		||||
	github.com/google/uuid v1.6.0
 | 
			
		||||
	github.com/lxzan/gws v1.8.9
 | 
			
		||||
	github.com/nicholas-fedor/shoutrrr v0.8.15
 | 
			
		||||
	github.com/nicholas-fedor/shoutrrr v0.8.17
 | 
			
		||||
	github.com/pocketbase/dbx v1.11.0
 | 
			
		||||
	github.com/pocketbase/pocketbase v0.29.0
 | 
			
		||||
	github.com/rhysd/go-github-selfupdate v1.2.3
 | 
			
		||||
	github.com/pocketbase/pocketbase v0.29.3
 | 
			
		||||
	github.com/shirou/gopsutil/v4 v4.25.6
 | 
			
		||||
	github.com/spf13/cast v1.9.2
 | 
			
		||||
	github.com/spf13/cobra v1.9.1
 | 
			
		||||
	github.com/stretchr/testify v1.10.0
 | 
			
		||||
	golang.org/x/crypto v0.40.0
 | 
			
		||||
	golang.org/x/exp v0.0.0-20250718183923-645b1fa84792
 | 
			
		||||
	github.com/stretchr/testify v1.11.0
 | 
			
		||||
	golang.org/x/crypto v0.41.0
 | 
			
		||||
	golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b
 | 
			
		||||
	gopkg.in/yaml.v3 v3.0.1
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@@ -39,13 +39,10 @@ require (
 | 
			
		||||
	github.com/go-ole/go-ole v1.3.0 // indirect
 | 
			
		||||
	github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
 | 
			
		||||
	github.com/go-sql-driver/mysql v1.9.1 // indirect
 | 
			
		||||
	github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
 | 
			
		||||
	github.com/google/go-github/v30 v30.1.0 // indirect
 | 
			
		||||
	github.com/google/go-querystring v1.1.0 // indirect
 | 
			
		||||
	github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
 | 
			
		||||
	github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
 | 
			
		||||
	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 | 
			
		||||
	github.com/klauspost/compress v1.18.0 // indirect
 | 
			
		||||
	github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
 | 
			
		||||
	github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d // indirect
 | 
			
		||||
	github.com/mattn/go-colorable v0.1.14 // indirect
 | 
			
		||||
	github.com/mattn/go-isatty v0.0.20 // indirect
 | 
			
		||||
	github.com/ncruces/go-strftime v0.1.9 // indirect
 | 
			
		||||
@@ -53,20 +50,20 @@ require (
 | 
			
		||||
	github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
 | 
			
		||||
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 | 
			
		||||
	github.com/spf13/pflag v1.0.7 // indirect
 | 
			
		||||
	github.com/tcnksm/go-gitconfig v0.1.2 // indirect
 | 
			
		||||
	github.com/tklauser/go-sysconf v0.3.15 // indirect
 | 
			
		||||
	github.com/tklauser/numcpus v0.10.0 // indirect
 | 
			
		||||
	github.com/ulikunitz/xz v0.5.12 // indirect
 | 
			
		||||
	github.com/x448/float16 v0.8.4 // indirect
 | 
			
		||||
	github.com/yusufpapurcu/wmi v1.2.4 // indirect
 | 
			
		||||
	golang.org/x/image v0.29.0 // indirect
 | 
			
		||||
	golang.org/x/net v0.42.0 // indirect
 | 
			
		||||
	golang.org/x/image v0.30.0 // indirect
 | 
			
		||||
	golang.org/x/net v0.43.0 // indirect
 | 
			
		||||
	golang.org/x/oauth2 v0.30.0 // indirect
 | 
			
		||||
	golang.org/x/sync v0.16.0 // indirect
 | 
			
		||||
	golang.org/x/sys v0.34.0 // indirect
 | 
			
		||||
	golang.org/x/text v0.27.0 // indirect
 | 
			
		||||
	modernc.org/libc v1.65.10 // indirect
 | 
			
		||||
	golang.org/x/sys v0.35.0 // indirect
 | 
			
		||||
	golang.org/x/text v0.28.0 // indirect
 | 
			
		||||
	gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
 | 
			
		||||
	howett.net/plist v1.0.1 // indirect
 | 
			
		||||
	modernc.org/libc v1.66.3 // indirect
 | 
			
		||||
	modernc.org/mathutil v1.7.1 // indirect
 | 
			
		||||
	modernc.org/memory v1.11.0 // indirect
 | 
			
		||||
	modernc.org/sqlite v1.38.0 // indirect
 | 
			
		||||
	modernc.org/sqlite v1.38.2 // indirect
 | 
			
		||||
)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										107
									
								
								beszel/go.sum
									
									
									
									
									
								
							
							
						
						
									
										107
									
								
								beszel/go.sum
									
									
									
									
									
								
							@@ -13,6 +13,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
 | 
			
		||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 | 
			
		||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
 | 
			
		||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
 | 
			
		||||
github.com/distatus/battery v0.11.0 h1:KJk89gz90Iq/wJtbjjM9yUzBXV+ASV/EG2WOOL7N8lc=
 | 
			
		||||
github.com/distatus/battery v0.11.0/go.mod h1:KmVkE8A8hpIX4T78QRdMktYpEp35QfOL8A8dwZBxq2k=
 | 
			
		||||
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
 | 
			
		||||
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
 | 
			
		||||
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
 | 
			
		||||
@@ -25,7 +27,6 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
 | 
			
		||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
 | 
			
		||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
 | 
			
		||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
 | 
			
		||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 | 
			
		||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
 | 
			
		||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
 | 
			
		||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
 | 
			
		||||
@@ -46,41 +47,28 @@ github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtS
 | 
			
		||||
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
 | 
			
		||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
 | 
			
		||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
 | 
			
		||||
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
 | 
			
		||||
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
 | 
			
		||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 | 
			
		||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
 | 
			
		||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
 | 
			
		||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 | 
			
		||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 | 
			
		||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 | 
			
		||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 | 
			
		||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
 | 
			
		||||
github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo=
 | 
			
		||||
github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8=
 | 
			
		||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
 | 
			
		||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
 | 
			
		||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
 | 
			
		||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
 | 
			
		||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
 | 
			
		||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 | 
			
		||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 | 
			
		||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 | 
			
		||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8=
 | 
			
		||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
 | 
			
		||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
 | 
			
		||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
 | 
			
		||||
github.com/jarcoal/httpmock v1.4.0 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k=
 | 
			
		||||
github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
 | 
			
		||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
 | 
			
		||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
 | 
			
		||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
 | 
			
		||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 | 
			
		||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 | 
			
		||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
 | 
			
		||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 | 
			
		||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 | 
			
		||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 | 
			
		||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 | 
			
		||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
 | 
			
		||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
 | 
			
		||||
github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d h1:vFzYZc8yji+9DmNRhpEbs8VBK4CgV/DPfGzeVJSSp/8=
 | 
			
		||||
github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
 | 
			
		||||
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
 | 
			
		||||
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
 | 
			
		||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
 | 
			
		||||
@@ -91,11 +79,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
 | 
			
		||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
 | 
			
		||||
github.com/nicholas-fedor/shoutrrr v0.8.8 h1:F/oyoatWK5cbHPPgkjRZrA0262TP7KWuUQz9KskRtR8=
 | 
			
		||||
github.com/nicholas-fedor/shoutrrr v0.8.8/go.mod h1:T30Y+eoZFEjDk4HtOItcHQioZSOe3Z6a6aNfSz6jc5c=
 | 
			
		||||
github.com/onsi/ginkgo v1.6.0 h1:Ix8l273rp3QzYgXSR+c8d1fTG7UPgYkOSELPhiY/YGw=
 | 
			
		||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
 | 
			
		||||
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
 | 
			
		||||
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
 | 
			
		||||
github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
 | 
			
		||||
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
 | 
			
		||||
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
 | 
			
		||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 | 
			
		||||
@@ -103,14 +88,12 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
 | 
			
		||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 | 
			
		||||
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
 | 
			
		||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
 | 
			
		||||
github.com/pocketbase/pocketbase v0.29.0 h1:oL6qvkU2QSybClVtQdaq9Z1F3Wk59iKYCfIaf1R8KUs=
 | 
			
		||||
github.com/pocketbase/pocketbase v0.29.0/go.mod h1:SqyH7o/3e+/uLySATlJqxH4S8gyU6R0adG56ZSV1vuU=
 | 
			
		||||
github.com/pocketbase/pocketbase v0.29.3 h1:Mj8o5awsbVJIdIoTuQNhfC2oL/c4aImQ3RyfFZlzFVg=
 | 
			
		||||
github.com/pocketbase/pocketbase v0.29.3/go.mod h1:oGpT67LObxCFK4V2fSL7J9YnPbBnnshOpJ5v3zcneww=
 | 
			
		||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
 | 
			
		||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
 | 
			
		||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
 | 
			
		||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 | 
			
		||||
github.com/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag=
 | 
			
		||||
github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzxazpPAODuqarmPDe2Rg=
 | 
			
		||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
 | 
			
		||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
 | 
			
		||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 | 
			
		||||
@@ -125,17 +108,12 @@ github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
 | 
			
		||||
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 | 
			
		||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 | 
			
		||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 | 
			
		||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
 | 
			
		||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 | 
			
		||||
github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw=
 | 
			
		||||
github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE=
 | 
			
		||||
github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
 | 
			
		||||
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
 | 
			
		||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
 | 
			
		||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
 | 
			
		||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
 | 
			
		||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
 | 
			
		||||
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
 | 
			
		||||
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
 | 
			
		||||
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
 | 
			
		||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
 | 
			
		||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
 | 
			
		||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
 | 
			
		||||
@@ -143,65 +121,50 @@ github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQ
 | 
			
		||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
 | 
			
		||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
 | 
			
		||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 | 
			
		||||
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
 | 
			
		||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
 | 
			
		||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
 | 
			
		||||
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4=
 | 
			
		||||
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
 | 
			
		||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
 | 
			
		||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
 | 
			
		||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
 | 
			
		||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
 | 
			
		||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
 | 
			
		||||
golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas=
 | 
			
		||||
golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA=
 | 
			
		||||
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
 | 
			
		||||
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
 | 
			
		||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 | 
			
		||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 | 
			
		||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 | 
			
		||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 | 
			
		||||
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
 | 
			
		||||
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
 | 
			
		||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
 | 
			
		||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
 | 
			
		||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
 | 
			
		||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
 | 
			
		||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
 | 
			
		||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 | 
			
		||||
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 | 
			
		||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
 | 
			
		||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
 | 
			
		||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
 | 
			
		||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
 | 
			
		||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 | 
			
		||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
 | 
			
		||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
 | 
			
		||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 | 
			
		||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 | 
			
		||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 | 
			
		||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 | 
			
		||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 | 
			
		||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
 | 
			
		||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 | 
			
		||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 | 
			
		||||
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
 | 
			
		||||
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
 | 
			
		||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
 | 
			
		||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 | 
			
		||||
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
 | 
			
		||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
 | 
			
		||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 | 
			
		||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 | 
			
		||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 | 
			
		||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
 | 
			
		||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
 | 
			
		||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
 | 
			
		||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
 | 
			
		||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 | 
			
		||||
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
 | 
			
		||||
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
 | 
			
		||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 | 
			
		||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 | 
			
		||||
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 | 
			
		||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
 | 
			
		||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
 | 
			
		||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
 | 
			
		||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
 | 
			
		||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
 | 
			
		||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 | 
			
		||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
 | 
			
		||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 | 
			
		||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
 | 
			
		||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
 | 
			
		||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 | 
			
		||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
 | 
			
		||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 | 
			
		||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 | 
			
		||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 | 
			
		||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
 | 
			
		||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
 | 
			
		||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
 | 
			
		||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
 | 
			
		||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
 | 
			
		||||
@@ -212,8 +175,6 @@ modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
 | 
			
		||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
 | 
			
		||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
 | 
			
		||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
 | 
			
		||||
modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc=
 | 
			
		||||
modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po=
 | 
			
		||||
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
 | 
			
		||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
 | 
			
		||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
 | 
			
		||||
@@ -224,8 +185,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
 | 
			
		||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
 | 
			
		||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
 | 
			
		||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
 | 
			
		||||
modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI=
 | 
			
		||||
modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE=
 | 
			
		||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
 | 
			
		||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
 | 
			
		||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
 | 
			
		||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
 | 
			
		||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
 | 
			
		||||
 
 | 
			
		||||
@@ -40,13 +40,13 @@ type Agent struct {
 | 
			
		||||
 | 
			
		||||
// NewAgent creates a new agent with the given data directory for persisting data.
 | 
			
		||||
// If the data directory is not set, it will attempt to find the optimal directory.
 | 
			
		||||
func NewAgent(dataDir string) (agent *Agent, err error) {
 | 
			
		||||
func NewAgent(dataDir ...string) (agent *Agent, err error) {
 | 
			
		||||
	agent = &Agent{
 | 
			
		||||
		fsStats: make(map[string]*system.FsStats),
 | 
			
		||||
		cache:   NewSessionCache(69 * time.Second),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	agent.dataDir, err = getDataDir(dataDir)
 | 
			
		||||
	agent.dataDir, err = getDataDir(dataDir...)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		slog.Warn("Data directory not found")
 | 
			
		||||
	} else {
 | 
			
		||||
@@ -113,37 +113,37 @@ func (a *Agent) gatherStats(sessionID string) *system.CombinedData {
 | 
			
		||||
	a.Lock()
 | 
			
		||||
	defer a.Unlock()
 | 
			
		||||
 | 
			
		||||
	cachedData, ok := a.cache.Get(sessionID)
 | 
			
		||||
	if ok {
 | 
			
		||||
		slog.Debug("Cached stats", "session", sessionID)
 | 
			
		||||
		return cachedData
 | 
			
		||||
	data, isCached := a.cache.Get(sessionID)
 | 
			
		||||
	if isCached {
 | 
			
		||||
		slog.Debug("Cached data", "session", sessionID)
 | 
			
		||||
		return data
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	*cachedData = system.CombinedData{
 | 
			
		||||
	*data = system.CombinedData{
 | 
			
		||||
		Stats: a.getSystemStats(),
 | 
			
		||||
		Info:  a.systemInfo,
 | 
			
		||||
	}
 | 
			
		||||
	slog.Debug("System stats", "data", cachedData)
 | 
			
		||||
	slog.Debug("System data", "data", data)
 | 
			
		||||
 | 
			
		||||
	if a.dockerManager != nil {
 | 
			
		||||
		if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
 | 
			
		||||
			cachedData.Containers = containerStats
 | 
			
		||||
			slog.Debug("Docker stats", "data", cachedData.Containers)
 | 
			
		||||
			data.Containers = containerStats
 | 
			
		||||
			slog.Debug("Containers", "data", data.Containers)
 | 
			
		||||
		} else {
 | 
			
		||||
			slog.Debug("Docker stats", "err", err)
 | 
			
		||||
			slog.Debug("Containers", "err", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cachedData.Stats.ExtraFs = make(map[string]*system.FsStats)
 | 
			
		||||
	data.Stats.ExtraFs = make(map[string]*system.FsStats)
 | 
			
		||||
	for name, stats := range a.fsStats {
 | 
			
		||||
		if !stats.Root && stats.DiskTotal > 0 {
 | 
			
		||||
			cachedData.Stats.ExtraFs[name] = stats
 | 
			
		||||
			data.Stats.ExtraFs[name] = stats
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	slog.Debug("Extra filesystems", "data", cachedData.Stats.ExtraFs)
 | 
			
		||||
	slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
 | 
			
		||||
 | 
			
		||||
	a.cache.Set(sessionID, cachedData)
 | 
			
		||||
	return cachedData
 | 
			
		||||
	a.cache.Set(sessionID, data)
 | 
			
		||||
	return data
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// StartAgent initializes and starts the agent with optional WebSocket connection
 | 
			
		||||
 
 | 
			
		||||
@@ -14,7 +14,7 @@ import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestSessionCache_GetSet(t *testing.T) {
 | 
			
		||||
	synctest.Run(func() {
 | 
			
		||||
	synctest.Test(t, func(t *testing.T) {
 | 
			
		||||
		cache := NewSessionCache(69 * time.Second)
 | 
			
		||||
 | 
			
		||||
		testData := &system.CombinedData{
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										53
									
								
								beszel/internal/agent/battery/battery.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								beszel/internal/agent/battery/battery.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,53 @@
 | 
			
		||||
//go:build !freebsd
 | 
			
		||||
 | 
			
		||||
// Package battery provides functions to check if the system has a battery and to get the battery stats.
 | 
			
		||||
package battery
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"errors"
 | 
			
		||||
	"log/slog"
 | 
			
		||||
 | 
			
		||||
	"github.com/distatus/battery"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var systemHasBattery = false
 | 
			
		||||
var haveCheckedBattery = false
 | 
			
		||||
 | 
			
		||||
// HasReadableBattery checks if the system has a battery and returns true if it does.
 | 
			
		||||
func HasReadableBattery() bool {
 | 
			
		||||
	if haveCheckedBattery {
 | 
			
		||||
		return systemHasBattery
 | 
			
		||||
	}
 | 
			
		||||
	haveCheckedBattery = true
 | 
			
		||||
	bat, err := battery.Get(0)
 | 
			
		||||
	if err == nil && bat != nil {
 | 
			
		||||
		systemHasBattery = true
 | 
			
		||||
	} else {
 | 
			
		||||
		slog.Debug("No battery found", "err", err)
 | 
			
		||||
	}
 | 
			
		||||
	return systemHasBattery
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetBatteryStats returns the current battery percent and charge state
 | 
			
		||||
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
 | 
			
		||||
	if !systemHasBattery {
 | 
			
		||||
		return batteryPercent, batteryState, errors.ErrUnsupported
 | 
			
		||||
	}
 | 
			
		||||
	batteries, err := battery.GetAll()
 | 
			
		||||
	if err != nil || len(batteries) == 0 {
 | 
			
		||||
		return batteryPercent, batteryState, err
 | 
			
		||||
	}
 | 
			
		||||
	totalCapacity := float64(0)
 | 
			
		||||
	totalCharge := float64(0)
 | 
			
		||||
	for _, bat := range batteries {
 | 
			
		||||
		if bat.Design != 0 {
 | 
			
		||||
			totalCapacity += bat.Design
 | 
			
		||||
		} else {
 | 
			
		||||
			totalCapacity += bat.Full
 | 
			
		||||
		}
 | 
			
		||||
		totalCharge += bat.Current
 | 
			
		||||
	}
 | 
			
		||||
	batteryPercent = uint8(totalCharge / totalCapacity * 100)
 | 
			
		||||
	batteryState = uint8(batteries[0].State.Raw)
 | 
			
		||||
	return batteryPercent, batteryState, nil
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										13
									
								
								beszel/internal/agent/battery/battery_freebsd.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								beszel/internal/agent/battery/battery_freebsd.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
			
		||||
//go:build freebsd
 | 
			
		||||
 | 
			
		||||
package battery
 | 
			
		||||
 | 
			
		||||
import "errors"
 | 
			
		||||
 | 
			
		||||
func HasReadableBattery() bool {
 | 
			
		||||
	return false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func GetBatteryStats() (uint8, uint8, error) {
 | 
			
		||||
	return 0, 0, errors.ErrUnsupported
 | 
			
		||||
}
 | 
			
		||||
@@ -39,7 +39,7 @@ func TestHealth(t *testing.T) {
 | 
			
		||||
	// This test uses synctest to simulate time passing.
 | 
			
		||||
	// NOTE: This test requires GOEXPERIMENT=synctest to run.
 | 
			
		||||
	t.Run("check with simulated time", func(t *testing.T) {
 | 
			
		||||
		synctest.Run(func() {
 | 
			
		||||
		synctest.Test(t, func(t *testing.T) {
 | 
			
		||||
			// Update the file to set the initial timestamp.
 | 
			
		||||
			require.NoError(t, Update(), "Update() failed inside synctest")
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										80
									
								
								beszel/internal/agent/lhm/beszel_lhm.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								beszel/internal/agent/lhm/beszel_lhm.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,80 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Globalization;
 | 
			
		||||
using LibreHardwareMonitor.Hardware;
 | 
			
		||||
 | 
			
		||||
class Program
 | 
			
		||||
{
 | 
			
		||||
  static void Main()
 | 
			
		||||
  {
 | 
			
		||||
    var computer = new Computer
 | 
			
		||||
    {
 | 
			
		||||
      IsCpuEnabled = true,
 | 
			
		||||
      IsGpuEnabled = true,
 | 
			
		||||
      IsMemoryEnabled = true,
 | 
			
		||||
      IsMotherboardEnabled = true,
 | 
			
		||||
      IsStorageEnabled = true,
 | 
			
		||||
      // IsPsuEnabled = true,
 | 
			
		||||
      // IsNetworkEnabled = true,
 | 
			
		||||
    };
 | 
			
		||||
    computer.Open();
 | 
			
		||||
 | 
			
		||||
    var reader = Console.In;
 | 
			
		||||
    var writer = Console.Out;
 | 
			
		||||
 | 
			
		||||
    string line;
 | 
			
		||||
    while ((line = reader.ReadLine()) != null)
 | 
			
		||||
    {
 | 
			
		||||
      if (line.Trim().Equals("getTemps", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
      {
 | 
			
		||||
        foreach (var hw in computer.Hardware)
 | 
			
		||||
        {
 | 
			
		||||
          // process main hardware sensors
 | 
			
		||||
          ProcessSensors(hw, writer);
 | 
			
		||||
 | 
			
		||||
          // process subhardware sensors
 | 
			
		||||
          foreach (var subhardware in hw.SubHardware)
 | 
			
		||||
          {
 | 
			
		||||
            ProcessSensors(subhardware, writer);
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
        // send empty line to signal end of sensor data
 | 
			
		||||
        writer.WriteLine();
 | 
			
		||||
        writer.Flush();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    computer.Close();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static void ProcessSensors(IHardware hardware, System.IO.TextWriter writer)
 | 
			
		||||
  {
 | 
			
		||||
    var updated = false;
 | 
			
		||||
    foreach (var sensor in hardware.Sensors)
 | 
			
		||||
    {
 | 
			
		||||
      var validTemp = sensor.SensorType == SensorType.Temperature && sensor.Value.HasValue;
 | 
			
		||||
      if (!validTemp || sensor.Name.Contains("Distance"))
 | 
			
		||||
      {
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (!updated)
 | 
			
		||||
      {
 | 
			
		||||
        hardware.Update();
 | 
			
		||||
        updated = true;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      var name = sensor.Name;
 | 
			
		||||
      // if sensor.Name starts with "Temperature" replace with hardware.Identifier but retain the rest of the name.
 | 
			
		||||
      // usually this is a number like Temperature 3
 | 
			
		||||
      if (sensor.Name.StartsWith("Temperature"))
 | 
			
		||||
      {
 | 
			
		||||
        name = hardware.Identifier.ToString().Replace("/", "_").TrimStart('_') + sensor.Name.Substring(11);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // invariant culture assures the value is parsable as a float
 | 
			
		||||
      var value = sensor.Value.Value.ToString("0.##", CultureInfo.InvariantCulture);
 | 
			
		||||
      // write the name and value to the writer
 | 
			
		||||
      writer.WriteLine($"{name}|{value}");
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										11
									
								
								beszel/internal/agent/lhm/beszel_lhm.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								beszel/internal/agent/lhm/beszel_lhm.csproj
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
<Project Sdk="Microsoft.NET.Sdk">
 | 
			
		||||
  <PropertyGroup>
 | 
			
		||||
    <OutputType>Exe</OutputType>
 | 
			
		||||
    <TargetFramework>net48</TargetFramework>
 | 
			
		||||
    <Platforms>x64</Platforms>
 | 
			
		||||
  </PropertyGroup>
 | 
			
		||||
 | 
			
		||||
  <ItemGroup>
 | 
			
		||||
    <PackageReference Include="LibreHardwareMonitorLib" Version="0.9.4" />
 | 
			
		||||
  </ItemGroup>
 | 
			
		||||
</Project>
 | 
			
		||||
@@ -84,10 +84,10 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
 | 
			
		||||
	// reset high temp
 | 
			
		||||
	a.systemInfo.DashboardTemp = 0
 | 
			
		||||
 | 
			
		||||
	temps, err := a.getTempsWithPanicRecovery(sensors.TemperaturesWithContext)
 | 
			
		||||
	temps, err := a.getTempsWithPanicRecovery(getSensorTemps)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		// retry once on panic (gopsutil/issues/1832)
 | 
			
		||||
		temps, err = a.getTempsWithPanicRecovery(sensors.TemperaturesWithContext)
 | 
			
		||||
		temps, err = a.getTempsWithPanicRecovery(getSensorTemps)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			slog.Warn("Error updating temperatures", "err", err)
 | 
			
		||||
			if len(systemStats.Temperatures) > 0 {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										9
									
								
								beszel/internal/agent/sensors_default.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								beszel/internal/agent/sensors_default.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
			
		||||
//go:build !windows
 | 
			
		||||
 | 
			
		||||
package agent
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"github.com/shirou/gopsutil/v4/sensors"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var getSensorTemps = sensors.TemperaturesWithContext
 | 
			
		||||
							
								
								
									
										281
									
								
								beszel/internal/agent/sensors_windows.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										281
									
								
								beszel/internal/agent/sensors_windows.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,281 @@
 | 
			
		||||
//go:build windows
 | 
			
		||||
 | 
			
		||||
//go:generate dotnet build -c Release lhm/beszel_lhm.csproj
 | 
			
		||||
 | 
			
		||||
package agent
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bufio"
 | 
			
		||||
	"context"
 | 
			
		||||
	"embed"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io"
 | 
			
		||||
	"log/slog"
 | 
			
		||||
	"os"
 | 
			
		||||
	"os/exec"
 | 
			
		||||
	"path"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"sync"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"github.com/shirou/gopsutil/v4/sensors"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Note: This is always called from Agent.gatherStats() which holds Agent.Lock(),
 | 
			
		||||
// so no internal concurrency protection is needed.
 | 
			
		||||
 | 
			
		||||
// lhmProcess is a wrapper around the LHM .NET process.
 | 
			
		||||
type lhmProcess struct {
 | 
			
		||||
	cmd                  *exec.Cmd
 | 
			
		||||
	stdin                io.WriteCloser
 | 
			
		||||
	stdout               io.ReadCloser
 | 
			
		||||
	scanner              *bufio.Scanner
 | 
			
		||||
	isRunning            bool
 | 
			
		||||
	stoppedNoSensors     bool
 | 
			
		||||
	consecutiveNoSensors uint8
 | 
			
		||||
	execPath             string
 | 
			
		||||
	tempDir              string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
//go:embed all:lhm/bin/Release/net48
 | 
			
		||||
var lhmFs embed.FS
 | 
			
		||||
 | 
			
		||||
var (
 | 
			
		||||
	beszelLhm     *lhmProcess
 | 
			
		||||
	beszelLhmOnce sync.Once
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var errNoSensors = errors.New("no sensors found (try running as admin)")
 | 
			
		||||
 | 
			
		||||
// newlhmProcess copies the embedded LHM executable to a temporary directory and starts it.
 | 
			
		||||
func newlhmProcess() (*lhmProcess, error) {
 | 
			
		||||
	destDir := filepath.Join(os.TempDir(), "beszel")
 | 
			
		||||
	execPath := filepath.Join(destDir, "beszel_lhm.exe")
 | 
			
		||||
 | 
			
		||||
	if err := os.MkdirAll(destDir, 0755); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to create temp directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Only copy if executable doesn't exist
 | 
			
		||||
	if _, err := os.Stat(execPath); os.IsNotExist(err) {
 | 
			
		||||
		if err := copyEmbeddedDir(lhmFs, "lhm/bin/Release/net48", destDir); err != nil {
 | 
			
		||||
			return nil, fmt.Errorf("failed to copy embedded directory: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	lhm := &lhmProcess{
 | 
			
		||||
		execPath: execPath,
 | 
			
		||||
		tempDir:  destDir,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := lhm.startProcess(); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to start process: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return lhm, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// startProcess starts the external LHM process
 | 
			
		||||
func (lhm *lhmProcess) startProcess() error {
 | 
			
		||||
	// Clean up any existing process
 | 
			
		||||
	lhm.cleanupProcess()
 | 
			
		||||
 | 
			
		||||
	cmd := exec.Command(lhm.execPath)
 | 
			
		||||
	stdin, err := cmd.StdinPipe()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	stdout, err := cmd.StdoutPipe()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		stdin.Close()
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := cmd.Start(); err != nil {
 | 
			
		||||
		stdin.Close()
 | 
			
		||||
		stdout.Close()
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Update process state
 | 
			
		||||
	lhm.cmd = cmd
 | 
			
		||||
	lhm.stdin = stdin
 | 
			
		||||
	lhm.stdout = stdout
 | 
			
		||||
	lhm.scanner = bufio.NewScanner(stdout)
 | 
			
		||||
	lhm.isRunning = true
 | 
			
		||||
 | 
			
		||||
	// Give process a moment to initialize
 | 
			
		||||
	time.Sleep(100 * time.Millisecond)
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// cleanupProcess terminates the process and closes resources but preserves files
 | 
			
		||||
func (lhm *lhmProcess) cleanupProcess() {
 | 
			
		||||
	lhm.isRunning = false
 | 
			
		||||
 | 
			
		||||
	if lhm.cmd != nil && lhm.cmd.Process != nil {
 | 
			
		||||
		lhm.cmd.Process.Kill()
 | 
			
		||||
		lhm.cmd.Wait()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if lhm.stdin != nil {
 | 
			
		||||
		lhm.stdin.Close()
 | 
			
		||||
		lhm.stdin = nil
 | 
			
		||||
	}
 | 
			
		||||
	if lhm.stdout != nil {
 | 
			
		||||
		lhm.stdout.Close()
 | 
			
		||||
		lhm.stdout = nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	lhm.cmd = nil
 | 
			
		||||
	lhm.scanner = nil
 | 
			
		||||
	lhm.stoppedNoSensors = false
 | 
			
		||||
	lhm.consecutiveNoSensors = 0
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (lhm *lhmProcess) getTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) {
 | 
			
		||||
	if lhm.stoppedNoSensors {
 | 
			
		||||
		// Fall back to gopsutil if we can't get sensors from LHM
 | 
			
		||||
		return sensors.TemperaturesWithContext(ctx)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Start process if it's not running
 | 
			
		||||
	if !lhm.isRunning || lhm.stdin == nil || lhm.scanner == nil {
 | 
			
		||||
		err := lhm.startProcess()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return temps, err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Send command to process
 | 
			
		||||
	_, err = fmt.Fprintln(lhm.stdin, "getTemps")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		lhm.isRunning = false
 | 
			
		||||
		return temps, fmt.Errorf("failed to send command: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Read all sensor lines until we hit an empty line or EOF
 | 
			
		||||
	for lhm.scanner.Scan() {
 | 
			
		||||
		line := strings.TrimSpace(lhm.scanner.Text())
 | 
			
		||||
		if line == "" {
 | 
			
		||||
			break
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		parts := strings.Split(line, "|")
 | 
			
		||||
		if len(parts) != 2 {
 | 
			
		||||
			slog.Debug("Invalid sensor format", "line", line)
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		name := strings.TrimSpace(parts[0])
 | 
			
		||||
		valueStr := strings.TrimSpace(parts[1])
 | 
			
		||||
 | 
			
		||||
		value, err := strconv.ParseFloat(valueStr, 64)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			slog.Debug("Failed to parse sensor", "err", err, "line", line)
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if name == "" || value <= 0 || value > 150 {
 | 
			
		||||
			slog.Debug("Invalid sensor", "name", name, "val", value, "line", line)
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		temps = append(temps, sensors.TemperatureStat{
 | 
			
		||||
			SensorKey:   name,
 | 
			
		||||
			Temperature: value,
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := lhm.scanner.Err(); err != nil {
 | 
			
		||||
		lhm.isRunning = false
 | 
			
		||||
		return temps, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Handle no sensors case
 | 
			
		||||
	if len(temps) == 0 {
 | 
			
		||||
		lhm.consecutiveNoSensors++
 | 
			
		||||
		if lhm.consecutiveNoSensors >= 3 {
 | 
			
		||||
			lhm.stoppedNoSensors = true
 | 
			
		||||
			slog.Warn(errNoSensors.Error())
 | 
			
		||||
			lhm.cleanup()
 | 
			
		||||
		}
 | 
			
		||||
		return sensors.TemperaturesWithContext(ctx)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	lhm.consecutiveNoSensors = 0
 | 
			
		||||
 | 
			
		||||
	return temps, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// getSensorTemps attempts to pull sensor temperatures from the embedded LHM process.
 | 
			
		||||
// NB: LibreHardwareMonitorLib requires admin privileges to access all available sensors.
 | 
			
		||||
func getSensorTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) {
 | 
			
		||||
	defer func() {
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			slog.Debug("Error reading sensors", "err", err)
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	// Initialize process once
 | 
			
		||||
	beszelLhmOnce.Do(func() {
 | 
			
		||||
		beszelLhm, err = newlhmProcess()
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return temps, fmt.Errorf("failed to initialize lhm: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if beszelLhm == nil {
 | 
			
		||||
		return temps, fmt.Errorf("lhm not available")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return beszelLhm.getTemps(ctx)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// cleanup terminates the process and closes resources
 | 
			
		||||
func (lhm *lhmProcess) cleanup() {
 | 
			
		||||
	lhm.cleanupProcess()
 | 
			
		||||
	if lhm.tempDir != "" {
 | 
			
		||||
		os.RemoveAll(lhm.tempDir)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// copyEmbeddedDir copies the embedded directory to the destination path
 | 
			
		||||
func copyEmbeddedDir(fs embed.FS, srcPath, destPath string) error {
 | 
			
		||||
	entries, err := fs.ReadDir(srcPath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	if err := os.MkdirAll(destPath, 0755); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, entry := range entries {
 | 
			
		||||
		srcEntryPath := path.Join(srcPath, entry.Name())
 | 
			
		||||
		destEntryPath := filepath.Join(destPath, entry.Name())
 | 
			
		||||
 | 
			
		||||
		if entry.IsDir() {
 | 
			
		||||
			if err := copyEmbeddedDir(fs, srcEntryPath, destEntryPath); err != nil {
 | 
			
		||||
				return err
 | 
			
		||||
			}
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		data, err := fs.ReadFile(srcEntryPath)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := os.WriteFile(destEntryPath, data, 0755); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
@@ -2,6 +2,7 @@ package agent
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"beszel"
 | 
			
		||||
	"beszel/internal/agent/battery"
 | 
			
		||||
	"beszel/internal/entities/system"
 | 
			
		||||
	"bufio"
 | 
			
		||||
	"fmt"
 | 
			
		||||
@@ -59,10 +60,10 @@ func (a *Agent) initializeSystemInfo() {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// zfs
 | 
			
		||||
	if _, err := getARCSize(); err == nil {
 | 
			
		||||
		a.zfs = true
 | 
			
		||||
	} else {
 | 
			
		||||
	if _, err := getARCSize(); err != nil {
 | 
			
		||||
		slog.Debug("Not monitoring ZFS ARC", "err", err)
 | 
			
		||||
	} else {
 | 
			
		||||
		a.zfs = true
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -70,6 +71,11 @@ func (a *Agent) initializeSystemInfo() {
 | 
			
		||||
func (a *Agent) getSystemStats() system.Stats {
 | 
			
		||||
	systemStats := system.Stats{}
 | 
			
		||||
 | 
			
		||||
	// battery
 | 
			
		||||
	if battery.HasReadableBattery() {
 | 
			
		||||
		systemStats.Battery[0], systemStats.Battery[1], _ = battery.GetBatteryStats()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// cpu percent
 | 
			
		||||
	cpuPct, err := cpu.Percent(0, false)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@@ -80,7 +86,6 @@ func (a *Agent) getSystemStats() system.Stats {
 | 
			
		||||
 | 
			
		||||
	// load average
 | 
			
		||||
	if avgstat, err := load.Avg(); err == nil {
 | 
			
		||||
		// TODO: remove these in future release in favor of load avg array
 | 
			
		||||
		systemStats.LoadAvg[0] = avgstat.Load1
 | 
			
		||||
		systemStats.LoadAvg[1] = avgstat.Load5
 | 
			
		||||
		systemStats.LoadAvg[2] = avgstat.Load15
 | 
			
		||||
 
 | 
			
		||||
@@ -1,56 +1,147 @@
 | 
			
		||||
package agent
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"beszel"
 | 
			
		||||
	"beszel/internal/ghupdate"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"log"
 | 
			
		||||
	"os"
 | 
			
		||||
	"os/exec"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"github.com/blang/semver"
 | 
			
		||||
	"github.com/rhysd/go-github-selfupdate/selfupdate"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Update updates beszel-agent to the latest version
 | 
			
		||||
func Update() {
 | 
			
		||||
	var latest *selfupdate.Release
 | 
			
		||||
	var found bool
 | 
			
		||||
	var err error
 | 
			
		||||
	currentVersion := semver.MustParse(beszel.Version)
 | 
			
		||||
	fmt.Println("beszel-agent", currentVersion)
 | 
			
		||||
	fmt.Println("Checking for updates...")
 | 
			
		||||
	updater, _ := selfupdate.NewUpdater(selfupdate.Config{
 | 
			
		||||
		Filters: []string{"beszel-agent"},
 | 
			
		||||
	})
 | 
			
		||||
	latest, found, err = updater.DetectLatest("henrygd/beszel")
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		fmt.Println("Error checking for updates:", err)
 | 
			
		||||
		os.Exit(1)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if !found {
 | 
			
		||||
		fmt.Println("No updates found")
 | 
			
		||||
		os.Exit(0)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fmt.Println("Latest version:", latest.Version)
 | 
			
		||||
 | 
			
		||||
	if latest.Version.LTE(currentVersion) {
 | 
			
		||||
		fmt.Println("You are up to date")
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var binaryPath string
 | 
			
		||||
	fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version)
 | 
			
		||||
	binaryPath, err = os.Executable()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		fmt.Println("Error getting binary path:", err)
 | 
			
		||||
		os.Exit(1)
 | 
			
		||||
	}
 | 
			
		||||
	err = selfupdate.UpdateTo(latest.AssetURL, binaryPath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		fmt.Println("Please try rerunning with sudo. Error:", err)
 | 
			
		||||
		os.Exit(1)
 | 
			
		||||
	}
 | 
			
		||||
	fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes))
 | 
			
		||||
// restarter knows how to restart the beszel-agent service.
 | 
			
		||||
type restarter interface {
 | 
			
		||||
	Restart() error
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type systemdRestarter struct{ cmd string }
 | 
			
		||||
 | 
			
		||||
func (s *systemdRestarter) Restart() error {
 | 
			
		||||
	// Only restart if the service is active
 | 
			
		||||
	if err := exec.Command(s.cmd, "is-active", "beszel-agent.service").Run(); err != nil {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
	ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent.service via systemd…")
 | 
			
		||||
	return exec.Command(s.cmd, "restart", "beszel-agent.service").Run()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type openRCRestarter struct{ cmd string }
 | 
			
		||||
 | 
			
		||||
func (o *openRCRestarter) Restart() error {
 | 
			
		||||
	if err := exec.Command(o.cmd, "status", "beszel-agent").Run(); err != nil {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
	ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via OpenRC…")
 | 
			
		||||
	return exec.Command(o.cmd, "restart", "beszel-agent").Run()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type openWRTRestarter struct{ cmd string }
 | 
			
		||||
 | 
			
		||||
func (w *openWRTRestarter) Restart() error {
 | 
			
		||||
	if err := exec.Command(w.cmd, "running", "beszel-agent").Run(); err != nil {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
	ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via procd…")
 | 
			
		||||
	return exec.Command(w.cmd, "restart", "beszel-agent").Run()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func detectRestarter() restarter {
 | 
			
		||||
	if path, err := exec.LookPath("systemctl"); err == nil {
 | 
			
		||||
		return &systemdRestarter{cmd: path}
 | 
			
		||||
	}
 | 
			
		||||
	if path, err := exec.LookPath("rc-service"); err == nil {
 | 
			
		||||
		return &openRCRestarter{cmd: path}
 | 
			
		||||
	}
 | 
			
		||||
	if path, err := exec.LookPath("service"); err == nil {
 | 
			
		||||
		return &openWRTRestarter{cmd: path}
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Update checks GitHub for a newer release of beszel-agent, applies it,
 | 
			
		||||
// fixes SELinux context if needed, and restarts the service.
 | 
			
		||||
func Update() error {
 | 
			
		||||
	exePath, _ := os.Executable()
 | 
			
		||||
 | 
			
		||||
	dataDir, err := getDataDir()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		dataDir = os.TempDir()
 | 
			
		||||
	}
 | 
			
		||||
	updated, err := ghupdate.Update(ghupdate.Config{
 | 
			
		||||
		ArchiveExecutable: "beszel-agent",
 | 
			
		||||
		DataDir:           dataDir,
 | 
			
		||||
	})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatal(err)
 | 
			
		||||
	}
 | 
			
		||||
	if !updated {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// make sure the file is executable
 | 
			
		||||
	if err := os.Chmod(exePath, 0755); err != nil {
 | 
			
		||||
		ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to set executable permissions: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	// set ownership to beszel:beszel if possible
 | 
			
		||||
	if chownPath, err := exec.LookPath("chown"); err == nil {
 | 
			
		||||
		if err := exec.Command(chownPath, "beszel:beszel", exePath).Run(); err != nil {
 | 
			
		||||
			ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to set file ownership: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 6) Fix SELinux context if necessary
 | 
			
		||||
	if err := handleSELinuxContext(exePath); err != nil {
 | 
			
		||||
		ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: SELinux context handling: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 7) Restart service if running under a recognised init system
 | 
			
		||||
	if r := detectRestarter(); r != nil {
 | 
			
		||||
		if err := r.Restart(); err != nil {
 | 
			
		||||
			ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to restart service: %v", err)
 | 
			
		||||
			ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually.")
 | 
			
		||||
		}
 | 
			
		||||
	} else {
 | 
			
		||||
		ghupdate.ColorPrint(ghupdate.ColorYellow, "No supported init system detected; please restart manually if needed.")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleSELinuxContext restores or applies the correct SELinux label to the binary.
 | 
			
		||||
func handleSELinuxContext(path string) error {
 | 
			
		||||
	out, err := exec.Command("getenforce").Output()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		// SELinux not enabled or getenforce not available
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
	state := strings.TrimSpace(string(out))
 | 
			
		||||
	if state == "Disabled" {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ghupdate.ColorPrint(ghupdate.ColorYellow, "SELinux is enabled; applying context…")
 | 
			
		||||
	var errs []string
 | 
			
		||||
 | 
			
		||||
	// Try persistent context via semanage+restorecon
 | 
			
		||||
	if semanagePath, err := exec.LookPath("semanage"); err == nil {
 | 
			
		||||
		if err := exec.Command(semanagePath, "fcontext", "-a", "-t", "bin_t", path).Run(); err != nil {
 | 
			
		||||
			errs = append(errs, "semanage fcontext failed: "+err.Error())
 | 
			
		||||
		} else if restoreconPath, err := exec.LookPath("restorecon"); err == nil {
 | 
			
		||||
			if err := exec.Command(restoreconPath, "-v", path).Run(); err != nil {
 | 
			
		||||
				errs = append(errs, "restorecon failed: "+err.Error())
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Fallback to temporary context via chcon
 | 
			
		||||
	if chconPath, err := exec.LookPath("chcon"); err == nil {
 | 
			
		||||
		if err := exec.Command(chconPath, "-t", "bin_t", path).Run(); err != nil {
 | 
			
		||||
			errs = append(errs, "chcon failed: "+err.Error())
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(errs) > 0 {
 | 
			
		||||
		return fmt.Errorf("SELinux context errors: %s", strings.Join(errs, "; "))
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -10,7 +10,6 @@ import (
 | 
			
		||||
 | 
			
		||||
	"github.com/nicholas-fedor/shoutrrr"
 | 
			
		||||
	"github.com/pocketbase/dbx"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/apis"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/core"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/tools/mailer"
 | 
			
		||||
)
 | 
			
		||||
@@ -206,16 +205,14 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
 | 
			
		||||
	info, _ := e.RequestInfo()
 | 
			
		||||
	if info.Auth == nil {
 | 
			
		||||
		return apis.NewForbiddenError("Forbidden", nil)
 | 
			
		||||
	var data struct {
 | 
			
		||||
		URL string `json:"url"`
 | 
			
		||||
	}
 | 
			
		||||
	url := e.Request.URL.Query().Get("url")
 | 
			
		||||
	// log.Println("url", url)
 | 
			
		||||
	if url == "" {
 | 
			
		||||
		return e.JSON(200, map[string]string{"err": "URL is required"})
 | 
			
		||||
	err := e.BindBody(&data)
 | 
			
		||||
	if err != nil || data.URL == "" {
 | 
			
		||||
		return e.BadRequestError("URL is required", err)
 | 
			
		||||
	}
 | 
			
		||||
	err := am.SendShoutrrrAlert(url, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
 | 
			
		||||
	err = am.SendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return e.JSON(200, map[string]string{"err": err.Error()})
 | 
			
		||||
	}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										119
									
								
								beszel/internal/alerts/alerts_api.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								beszel/internal/alerts/alerts_api.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,119 @@
 | 
			
		||||
package alerts
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"database/sql"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"net/http"
 | 
			
		||||
 | 
			
		||||
	"github.com/pocketbase/dbx"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/core"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// UpsertUserAlerts handles API request to create or update alerts for a user
 | 
			
		||||
// across multiple systems (POST /api/beszel/user-alerts)
 | 
			
		||||
func UpsertUserAlerts(e *core.RequestEvent) error {
 | 
			
		||||
	userID := e.Auth.Id
 | 
			
		||||
 | 
			
		||||
	reqData := struct {
 | 
			
		||||
		Min       uint8    `json:"min"`
 | 
			
		||||
		Value     float64  `json:"value"`
 | 
			
		||||
		Name      string   `json:"name"`
 | 
			
		||||
		Systems   []string `json:"systems"`
 | 
			
		||||
		Overwrite bool     `json:"overwrite"`
 | 
			
		||||
	}{}
 | 
			
		||||
	err := e.BindBody(&reqData)
 | 
			
		||||
	if err != nil || userID == "" || reqData.Name == "" || len(reqData.Systems) == 0 {
 | 
			
		||||
		return e.BadRequestError("Bad data", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	alertsCollection, err := e.App.FindCachedCollectionByNameOrId("alerts")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = e.App.RunInTransaction(func(txApp core.App) error {
 | 
			
		||||
		for _, systemId := range reqData.Systems {
 | 
			
		||||
			// find existing matching alert
 | 
			
		||||
			alertRecord, err := txApp.FindFirstRecordByFilter(alertsCollection,
 | 
			
		||||
				"system={:system} && name={:name} && user={:user}",
 | 
			
		||||
				dbx.Params{"system": systemId, "name": reqData.Name, "user": userID})
 | 
			
		||||
 | 
			
		||||
			if err != nil && !errors.Is(err, sql.ErrNoRows) {
 | 
			
		||||
				return err
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// skip if alert already exists and overwrite is not set
 | 
			
		||||
			if !reqData.Overwrite && alertRecord != nil {
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// create new alert if it doesn't exist
 | 
			
		||||
			if alertRecord == nil {
 | 
			
		||||
				alertRecord = core.NewRecord(alertsCollection)
 | 
			
		||||
				alertRecord.Set("user", userID)
 | 
			
		||||
				alertRecord.Set("system", systemId)
 | 
			
		||||
				alertRecord.Set("name", reqData.Name)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			alertRecord.Set("value", reqData.Value)
 | 
			
		||||
			alertRecord.Set("min", reqData.Min)
 | 
			
		||||
 | 
			
		||||
			if err := txApp.SaveNoValidate(alertRecord); err != nil {
 | 
			
		||||
				return err
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		return nil
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return e.JSON(http.StatusOK, map[string]any{"success": true})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// DeleteUserAlerts handles API request to delete alerts for a user across multiple systems
 | 
			
		||||
// (DELETE /api/beszel/user-alerts)
 | 
			
		||||
func DeleteUserAlerts(e *core.RequestEvent) error {
 | 
			
		||||
	userID := e.Auth.Id
 | 
			
		||||
 | 
			
		||||
	reqData := struct {
 | 
			
		||||
		AlertName string   `json:"name"`
 | 
			
		||||
		Systems   []string `json:"systems"`
 | 
			
		||||
	}{}
 | 
			
		||||
	err := e.BindBody(&reqData)
 | 
			
		||||
	if err != nil || userID == "" || reqData.AlertName == "" || len(reqData.Systems) == 0 {
 | 
			
		||||
		return e.BadRequestError("Bad data", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var numDeleted uint16
 | 
			
		||||
 | 
			
		||||
	err = e.App.RunInTransaction(func(txApp core.App) error {
 | 
			
		||||
		for _, systemId := range reqData.Systems {
 | 
			
		||||
			// Find existing alert to delete
 | 
			
		||||
			alertRecord, err := txApp.FindFirstRecordByFilter("alerts",
 | 
			
		||||
				"system={:system} && name={:name} && user={:user}",
 | 
			
		||||
				dbx.Params{"system": systemId, "name": reqData.AlertName, "user": userID})
 | 
			
		||||
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				if errors.Is(err, sql.ErrNoRows) {
 | 
			
		||||
					// alert doesn't exist, continue to next system
 | 
			
		||||
					continue
 | 
			
		||||
				}
 | 
			
		||||
				return err
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if err := txApp.Delete(alertRecord); err != nil {
 | 
			
		||||
				return err
 | 
			
		||||
			}
 | 
			
		||||
			numDeleted++
 | 
			
		||||
		}
 | 
			
		||||
		return nil
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return e.JSON(http.StatusOK, map[string]any{"success": true, "count": numDeleted})
 | 
			
		||||
}
 | 
			
		||||
@@ -12,7 +12,7 @@ func resolveHistoryOnAlertDelete(e *core.RecordEvent) error {
 | 
			
		||||
	if !e.Record.GetBool("triggered") {
 | 
			
		||||
		return e.Next()
 | 
			
		||||
	}
 | 
			
		||||
	_ = resolveAlertHistoryRecord(e.App, e.Record)
 | 
			
		||||
	_ = resolveAlertHistoryRecord(e.App, e.Record.Id)
 | 
			
		||||
	return e.Next()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -36,19 +36,19 @@ func updateHistoryOnAlertUpdate(e *core.RecordEvent) error {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// if new state is not triggered, check for matching alert history record and set it to resolved
 | 
			
		||||
	_ = resolveAlertHistoryRecord(e.App, new)
 | 
			
		||||
	_ = resolveAlertHistoryRecord(e.App, new.Id)
 | 
			
		||||
	return e.Next()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// resolveAlertHistoryRecord sets the resolved field to the current time
 | 
			
		||||
func resolveAlertHistoryRecord(app core.App, alertRecord *core.Record) error {
 | 
			
		||||
func resolveAlertHistoryRecord(app core.App, alertRecordID string) error {
 | 
			
		||||
	alertHistoryRecords, err := app.FindRecordsByFilter(
 | 
			
		||||
		"alerts_history",
 | 
			
		||||
		"alert_id={:alert_id} && resolved=null",
 | 
			
		||||
		"-created",
 | 
			
		||||
		1,
 | 
			
		||||
		0,
 | 
			
		||||
		dbx.Params{"alert_id": alertRecord.Id},
 | 
			
		||||
		dbx.Params{"alert_id": alertRecordID},
 | 
			
		||||
	)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
 
 | 
			
		||||
@@ -293,18 +293,11 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
 | 
			
		||||
		// app.Logger().Error("failed to save alert record", "err", err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	// expand the user relation and send the alert
 | 
			
		||||
	if errs := am.hub.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
 | 
			
		||||
		// app.Logger().Error("failed to expand user relation", "errs", errs)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	if user := alert.alertRecord.ExpandedOne("user"); user != nil {
 | 
			
		||||
		am.SendAlert(AlertMessageData{
 | 
			
		||||
			UserID:   user.Id,
 | 
			
		||||
			Title:    subject,
 | 
			
		||||
			Message:  body,
 | 
			
		||||
			Link:     am.hub.MakeLink("system", systemName),
 | 
			
		||||
			LinkText: "View " + systemName,
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
	am.SendAlert(AlertMessageData{
 | 
			
		||||
		UserID:   alert.alertRecord.GetString("user"),
 | 
			
		||||
		Title:    subject,
 | 
			
		||||
		Message:  body,
 | 
			
		||||
		Link:     am.hub.MakeLink("system", systemName),
 | 
			
		||||
		LinkText: "View " + systemName,
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										368
									
								
								beszel/internal/alerts/alerts_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										368
									
								
								beszel/internal/alerts/alerts_test.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,368 @@
 | 
			
		||||
//go:build testing
 | 
			
		||||
// +build testing
 | 
			
		||||
 | 
			
		||||
package alerts_test
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bytes"
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"io"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	beszelTests "beszel/internal/tests"
 | 
			
		||||
 | 
			
		||||
	"github.com/pocketbase/dbx"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/core"
 | 
			
		||||
	pbTests "github.com/pocketbase/pocketbase/tests"
 | 
			
		||||
	"github.com/stretchr/testify/assert"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
 | 
			
		||||
func jsonReader(v any) io.Reader {
 | 
			
		||||
	data, err := json.Marshal(v)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		panic(err)
 | 
			
		||||
	}
 | 
			
		||||
	return bytes.NewReader(data)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestUserAlertsApi(t *testing.T) {
 | 
			
		||||
	hub, _ := beszelTests.NewTestHub(t.TempDir())
 | 
			
		||||
	defer hub.Cleanup()
 | 
			
		||||
 | 
			
		||||
	hub.StartHub()
 | 
			
		||||
 | 
			
		||||
	user1, _ := beszelTests.CreateUser(hub, "alertstest@example.com", "password")
 | 
			
		||||
	user1Token, _ := user1.NewAuthToken()
 | 
			
		||||
 | 
			
		||||
	user2, _ := beszelTests.CreateUser(hub, "alertstest2@example.com", "password")
 | 
			
		||||
	user2Token, _ := user2.NewAuthToken()
 | 
			
		||||
 | 
			
		||||
	system1, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
 | 
			
		||||
		"name":  "system1",
 | 
			
		||||
		"users": []string{user1.Id},
 | 
			
		||||
		"host":  "127.0.0.1",
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	system2, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
 | 
			
		||||
		"name":  "system2",
 | 
			
		||||
		"users": []string{user1.Id, user2.Id},
 | 
			
		||||
		"host":  "127.0.0.2",
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	userRecords, _ := hub.CountRecords("users")
 | 
			
		||||
	assert.EqualValues(t, 2, userRecords, "all users should be created")
 | 
			
		||||
 | 
			
		||||
	systemRecords, _ := hub.CountRecords("systems")
 | 
			
		||||
	assert.EqualValues(t, 2, systemRecords, "all systems should be created")
 | 
			
		||||
 | 
			
		||||
	testAppFactory := func(t testing.TB) *pbTests.TestApp {
 | 
			
		||||
		return hub.TestApp
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	scenarios := []beszelTests.ApiScenario{
 | 
			
		||||
		{
 | 
			
		||||
			Name:            "GET not implemented - returns index",
 | 
			
		||||
			Method:          http.MethodGet,
 | 
			
		||||
			URL:             "/api/beszel/user-alerts",
 | 
			
		||||
			ExpectedStatus:  200,
 | 
			
		||||
			ExpectedContent: []string{"<html ", "globalThis.BESZEL"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:            "POST no auth",
 | 
			
		||||
			Method:          http.MethodPost,
 | 
			
		||||
			URL:             "/api/beszel/user-alerts",
 | 
			
		||||
			ExpectedStatus:  401,
 | 
			
		||||
			ExpectedContent: []string{"requires valid"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "POST no body",
 | 
			
		||||
			Method: http.MethodPost,
 | 
			
		||||
			URL:    "/api/beszel/user-alerts",
 | 
			
		||||
			Headers: map[string]string{
 | 
			
		||||
				"Authorization": user1Token,
 | 
			
		||||
			},
 | 
			
		||||
			ExpectedStatus:  400,
 | 
			
		||||
			ExpectedContent: []string{"Bad data"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "POST bad data",
 | 
			
		||||
			Method: http.MethodPost,
 | 
			
		||||
			URL:    "/api/beszel/user-alerts",
 | 
			
		||||
			Headers: map[string]string{
 | 
			
		||||
				"Authorization": user1Token,
 | 
			
		||||
			},
 | 
			
		||||
			ExpectedStatus:  400,
 | 
			
		||||
			ExpectedContent: []string{"Bad data"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
			Body: jsonReader(map[string]any{
 | 
			
		||||
				"invalidField": "this should cause validation error",
 | 
			
		||||
				"threshold":    "not a number",
 | 
			
		||||
			}),
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "POST malformed JSON",
 | 
			
		||||
			Method: http.MethodPost,
 | 
			
		||||
			URL:    "/api/beszel/user-alerts",
 | 
			
		||||
			Headers: map[string]string{
 | 
			
		||||
				"Authorization": user1Token,
 | 
			
		||||
			},
 | 
			
		||||
			ExpectedStatus:  400,
 | 
			
		||||
			ExpectedContent: []string{"Bad data"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
			Body:            strings.NewReader(`{"alertType": "cpu", "threshold": 80, "enabled": true,}`),
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "POST valid alert data multiple systems",
 | 
			
		||||
			Method: http.MethodPost,
 | 
			
		||||
			URL:    "/api/beszel/user-alerts",
 | 
			
		||||
			Headers: map[string]string{
 | 
			
		||||
				"Authorization": user1Token,
 | 
			
		||||
			},
 | 
			
		||||
			ExpectedStatus:  200,
 | 
			
		||||
			ExpectedContent: []string{"\"success\":true"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
			Body: jsonReader(map[string]any{
 | 
			
		||||
				"name":      "CPU",
 | 
			
		||||
				"value":     69,
 | 
			
		||||
				"min":       9,
 | 
			
		||||
				"systems":   []string{system1.Id, system2.Id},
 | 
			
		||||
				"overwrite": false,
 | 
			
		||||
			}),
 | 
			
		||||
			AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
 | 
			
		||||
				// check total alerts
 | 
			
		||||
				alerts, _ := app.CountRecords("alerts")
 | 
			
		||||
				assert.EqualValues(t, 2, alerts, "should have 2 alerts")
 | 
			
		||||
				// check alert has correct values
 | 
			
		||||
				matchingAlerts, _ := app.CountRecords("alerts", dbx.HashExp{"name": "CPU", "user": user1.Id, "system": system1.Id, "value": 69, "min": 9})
 | 
			
		||||
				assert.EqualValues(t, 1, matchingAlerts, "should have 1 alert")
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "POST valid alert data single system",
 | 
			
		||||
			Method: http.MethodPost,
 | 
			
		||||
			URL:    "/api/beszel/user-alerts",
 | 
			
		||||
			Headers: map[string]string{
 | 
			
		||||
				"Authorization": user1Token,
 | 
			
		||||
			},
 | 
			
		||||
			ExpectedStatus:  200,
 | 
			
		||||
			ExpectedContent: []string{"\"success\":true"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
			Body: jsonReader(map[string]any{
 | 
			
		||||
				"name":    "Memory",
 | 
			
		||||
				"systems": []string{system1.Id},
 | 
			
		||||
				"value":   90,
 | 
			
		||||
				"min":     10,
 | 
			
		||||
			}),
 | 
			
		||||
			AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
 | 
			
		||||
				user1Alerts, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
 | 
			
		||||
				assert.EqualValues(t, 3, user1Alerts, "should have 3 alerts")
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "Overwrite: false, should not overwrite existing alert",
 | 
			
		||||
			Method: http.MethodPost,
 | 
			
		||||
			URL:    "/api/beszel/user-alerts",
 | 
			
		||||
			Headers: map[string]string{
 | 
			
		||||
				"Authorization": user1Token,
 | 
			
		||||
			},
 | 
			
		||||
			ExpectedStatus:  200,
 | 
			
		||||
			ExpectedContent: []string{"\"success\":true"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
			Body: jsonReader(map[string]any{
 | 
			
		||||
				"name":      "CPU",
 | 
			
		||||
				"value":     45,
 | 
			
		||||
				"min":       5,
 | 
			
		||||
				"systems":   []string{system1.Id},
 | 
			
		||||
				"overwrite": false,
 | 
			
		||||
			}),
 | 
			
		||||
			BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
 | 
			
		||||
				beszelTests.ClearCollection(t, app, "alerts")
 | 
			
		||||
				beszelTests.CreateRecord(app, "alerts", map[string]any{
 | 
			
		||||
					"name":   "CPU",
 | 
			
		||||
					"system": system1.Id,
 | 
			
		||||
					"user":   user1.Id,
 | 
			
		||||
					"value":  80,
 | 
			
		||||
					"min":    10,
 | 
			
		||||
				})
 | 
			
		||||
			},
 | 
			
		||||
			AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
 | 
			
		||||
				alerts, _ := app.CountRecords("alerts")
 | 
			
		||||
				assert.EqualValues(t, 1, alerts, "should have 1 alert")
 | 
			
		||||
				alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user1.Id})
 | 
			
		||||
				assert.EqualValues(t, 80, alert.Get("value"), "should have 80 as value")
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "Overwrite: true, should overwrite existing alert",
 | 
			
		||||
			Method: http.MethodPost,
 | 
			
		||||
			URL:    "/api/beszel/user-alerts",
 | 
			
		||||
			Headers: map[string]string{
 | 
			
		||||
				"Authorization": user2Token,
 | 
			
		||||
			},
 | 
			
		||||
			ExpectedStatus:  200,
 | 
			
		||||
			ExpectedContent: []string{"\"success\":true"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
			Body: jsonReader(map[string]any{
 | 
			
		||||
				"name":      "CPU",
 | 
			
		||||
				"value":     45,
 | 
			
		||||
				"min":       5,
 | 
			
		||||
				"systems":   []string{system2.Id},
 | 
			
		||||
				"overwrite": true,
 | 
			
		||||
			}),
 | 
			
		||||
			BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
 | 
			
		||||
				beszelTests.ClearCollection(t, app, "alerts")
 | 
			
		||||
				beszelTests.CreateRecord(app, "alerts", map[string]any{
 | 
			
		||||
					"name":   "CPU",
 | 
			
		||||
					"system": system2.Id,
 | 
			
		||||
					"user":   user2.Id,
 | 
			
		||||
					"value":  80,
 | 
			
		||||
					"min":    10,
 | 
			
		||||
				})
 | 
			
		||||
			},
 | 
			
		||||
			AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
 | 
			
		||||
				alerts, _ := app.CountRecords("alerts")
 | 
			
		||||
				assert.EqualValues(t, 1, alerts, "should have 1 alert")
 | 
			
		||||
				alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user2.Id})
 | 
			
		||||
				assert.EqualValues(t, 45, alert.Get("value"), "should have 45 as value")
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:            "DELETE no auth",
 | 
			
		||||
			Method:          http.MethodDelete,
 | 
			
		||||
			URL:             "/api/beszel/user-alerts",
 | 
			
		||||
			ExpectedStatus:  401,
 | 
			
		||||
			ExpectedContent: []string{"requires valid"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
			Body: jsonReader(map[string]any{
 | 
			
		||||
				"name":    "CPU",
 | 
			
		||||
				"systems": []string{system1.Id},
 | 
			
		||||
			}),
 | 
			
		||||
			BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
 | 
			
		||||
				beszelTests.ClearCollection(t, app, "alerts")
 | 
			
		||||
				beszelTests.CreateRecord(app, "alerts", map[string]any{
 | 
			
		||||
					"name":   "CPU",
 | 
			
		||||
					"system": system1.Id,
 | 
			
		||||
					"user":   user1.Id,
 | 
			
		||||
					"value":  80,
 | 
			
		||||
					"min":    10,
 | 
			
		||||
				})
 | 
			
		||||
			},
 | 
			
		||||
			AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
 | 
			
		||||
				alerts, _ := app.CountRecords("alerts")
 | 
			
		||||
				assert.EqualValues(t, 1, alerts, "should have 1 alert")
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "DELETE alert",
 | 
			
		||||
			Method: http.MethodDelete,
 | 
			
		||||
			URL:    "/api/beszel/user-alerts",
 | 
			
		||||
			Headers: map[string]string{
 | 
			
		||||
				"Authorization": user1Token,
 | 
			
		||||
			},
 | 
			
		||||
			ExpectedStatus:  200,
 | 
			
		||||
			ExpectedContent: []string{"\"count\":1", "\"success\":true"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
			Body: jsonReader(map[string]any{
 | 
			
		||||
				"name":    "CPU",
 | 
			
		||||
				"systems": []string{system1.Id},
 | 
			
		||||
			}),
 | 
			
		||||
			BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
 | 
			
		||||
				beszelTests.ClearCollection(t, app, "alerts")
 | 
			
		||||
				beszelTests.CreateRecord(app, "alerts", map[string]any{
 | 
			
		||||
					"name":   "CPU",
 | 
			
		||||
					"system": system1.Id,
 | 
			
		||||
					"user":   user1.Id,
 | 
			
		||||
					"value":  80,
 | 
			
		||||
					"min":    10,
 | 
			
		||||
				})
 | 
			
		||||
			},
 | 
			
		||||
			AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
 | 
			
		||||
				alerts, _ := app.CountRecords("alerts")
 | 
			
		||||
				assert.Zero(t, alerts, "should have 0 alerts")
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "DELETE alert multiple systems",
 | 
			
		||||
			Method: http.MethodDelete,
 | 
			
		||||
			URL:    "/api/beszel/user-alerts",
 | 
			
		||||
			Headers: map[string]string{
 | 
			
		||||
				"Authorization": user1Token,
 | 
			
		||||
			},
 | 
			
		||||
			ExpectedStatus:  200,
 | 
			
		||||
			ExpectedContent: []string{"\"count\":2", "\"success\":true"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
			Body: jsonReader(map[string]any{
 | 
			
		||||
				"name":    "Memory",
 | 
			
		||||
				"systems": []string{system1.Id, system2.Id},
 | 
			
		||||
			}),
 | 
			
		||||
			BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
 | 
			
		||||
				beszelTests.ClearCollection(t, app, "alerts")
 | 
			
		||||
				for _, systemId := range []string{system1.Id, system2.Id} {
 | 
			
		||||
					_, err := beszelTests.CreateRecord(app, "alerts", map[string]any{
 | 
			
		||||
						"name":   "Memory",
 | 
			
		||||
						"system": systemId,
 | 
			
		||||
						"user":   user1.Id,
 | 
			
		||||
						"value":  90,
 | 
			
		||||
						"min":    10,
 | 
			
		||||
					})
 | 
			
		||||
					assert.NoError(t, err, "should create alert")
 | 
			
		||||
				}
 | 
			
		||||
				alerts, _ := app.CountRecords("alerts")
 | 
			
		||||
				assert.EqualValues(t, 2, alerts, "should have 2 alerts")
 | 
			
		||||
			},
 | 
			
		||||
			AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
 | 
			
		||||
				alerts, _ := app.CountRecords("alerts")
 | 
			
		||||
				assert.Zero(t, alerts, "should have 0 alerts")
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "User 2 should not be able to delete alert of user 1",
 | 
			
		||||
			Method: http.MethodDelete,
 | 
			
		||||
			URL:    "/api/beszel/user-alerts",
 | 
			
		||||
			Headers: map[string]string{
 | 
			
		||||
				"Authorization": user2Token,
 | 
			
		||||
			},
 | 
			
		||||
			ExpectedStatus:  200,
 | 
			
		||||
			ExpectedContent: []string{"\"count\":1", "\"success\":true"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
			Body: jsonReader(map[string]any{
 | 
			
		||||
				"name":    "CPU",
 | 
			
		||||
				"systems": []string{system2.Id},
 | 
			
		||||
			}),
 | 
			
		||||
			BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
 | 
			
		||||
				beszelTests.ClearCollection(t, app, "alerts")
 | 
			
		||||
				for _, user := range []string{user1.Id, user2.Id} {
 | 
			
		||||
					beszelTests.CreateRecord(app, "alerts", map[string]any{
 | 
			
		||||
						"name":   "CPU",
 | 
			
		||||
						"system": system2.Id,
 | 
			
		||||
						"user":   user,
 | 
			
		||||
						"value":  80,
 | 
			
		||||
						"min":    10,
 | 
			
		||||
					})
 | 
			
		||||
				}
 | 
			
		||||
				alerts, _ := app.CountRecords("alerts")
 | 
			
		||||
				assert.EqualValues(t, 2, alerts, "should have 2 alerts")
 | 
			
		||||
				user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
 | 
			
		||||
				assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
 | 
			
		||||
				user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
 | 
			
		||||
				assert.EqualValues(t, 1, user2AlertCount, "should have 1 alert")
 | 
			
		||||
			},
 | 
			
		||||
			AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
 | 
			
		||||
				user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
 | 
			
		||||
				assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
 | 
			
		||||
				user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
 | 
			
		||||
				assert.Zero(t, user2AlertCount, "should have 0 alerts")
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, scenario := range scenarios {
 | 
			
		||||
		scenario.Test(t)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@@ -36,8 +36,9 @@ type Stats struct {
 | 
			
		||||
	LoadAvg15      float64             `json:"l15,omitempty" cbor:"25,keyasint,omitempty"`
 | 
			
		||||
	Bandwidth      [2]uint64           `json:"b,omitzero" cbor:"26,keyasint,omitzero"`  // [sent bytes, recv bytes]
 | 
			
		||||
	MaxBandwidth   [2]uint64           `json:"bm,omitzero" cbor:"27,keyasint,omitzero"` // [sent bytes, recv bytes]
 | 
			
		||||
	LoadAvg        [3]float64          `json:"la,omitempty" cbor:"28,keyasint"`
 | 
			
		||||
	// TODO: remove other load fields in future release in favor of load avg array
 | 
			
		||||
	LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
 | 
			
		||||
	Battery [2]uint8   `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type GPUData struct {
 | 
			
		||||
@@ -81,27 +82,27 @@ const (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type Info struct {
 | 
			
		||||
	Hostname       string     `json:"h" cbor:"0,keyasint"`
 | 
			
		||||
	KernelVersion  string     `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
 | 
			
		||||
	Cores          int        `json:"c" cbor:"2,keyasint"`
 | 
			
		||||
	Threads        int        `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
 | 
			
		||||
	CpuModel       string     `json:"m" cbor:"4,keyasint"`
 | 
			
		||||
	Uptime         uint64     `json:"u" cbor:"5,keyasint"`
 | 
			
		||||
	Cpu            float64    `json:"cpu" cbor:"6,keyasint"`
 | 
			
		||||
	MemPct         float64    `json:"mp" cbor:"7,keyasint"`
 | 
			
		||||
	DiskPct        float64    `json:"dp" cbor:"8,keyasint"`
 | 
			
		||||
	Bandwidth      float64    `json:"b" cbor:"9,keyasint"`
 | 
			
		||||
	AgentVersion   string     `json:"v" cbor:"10,keyasint"`
 | 
			
		||||
	Podman         bool       `json:"p,omitempty" cbor:"11,keyasint,omitempty"`
 | 
			
		||||
	GpuPct         float64    `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
 | 
			
		||||
	DashboardTemp  float64    `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
 | 
			
		||||
	Os             Os         `json:"os" cbor:"14,keyasint"`
 | 
			
		||||
	LoadAvg1       float64    `json:"l1,omitempty" cbor:"15,keyasint,omitempty"`
 | 
			
		||||
	LoadAvg5       float64    `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
 | 
			
		||||
	LoadAvg15      float64    `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
 | 
			
		||||
	BandwidthBytes uint64     `json:"bb" cbor:"18,keyasint"`
 | 
			
		||||
	LoadAvg        [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
 | 
			
		||||
	Hostname       string  `json:"h" cbor:"0,keyasint"`
 | 
			
		||||
	KernelVersion  string  `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
 | 
			
		||||
	Cores          int     `json:"c" cbor:"2,keyasint"`
 | 
			
		||||
	Threads        int     `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
 | 
			
		||||
	CpuModel       string  `json:"m" cbor:"4,keyasint"`
 | 
			
		||||
	Uptime         uint64  `json:"u" cbor:"5,keyasint"`
 | 
			
		||||
	Cpu            float64 `json:"cpu" cbor:"6,keyasint"`
 | 
			
		||||
	MemPct         float64 `json:"mp" cbor:"7,keyasint"`
 | 
			
		||||
	DiskPct        float64 `json:"dp" cbor:"8,keyasint"`
 | 
			
		||||
	Bandwidth      float64 `json:"b" cbor:"9,keyasint"`
 | 
			
		||||
	AgentVersion   string  `json:"v" cbor:"10,keyasint"`
 | 
			
		||||
	Podman         bool    `json:"p,omitempty" cbor:"11,keyasint,omitempty"`
 | 
			
		||||
	GpuPct         float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
 | 
			
		||||
	DashboardTemp  float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
 | 
			
		||||
	Os             Os      `json:"os" cbor:"14,keyasint"`
 | 
			
		||||
	LoadAvg1       float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"`
 | 
			
		||||
	LoadAvg5       float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
 | 
			
		||||
	LoadAvg15      float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
 | 
			
		||||
	BandwidthBytes uint64  `json:"bb" cbor:"18,keyasint"`
 | 
			
		||||
	// TODO: remove load fields in future release in favor of load avg array
 | 
			
		||||
	LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Final data structure to return to the hub
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										140
									
								
								beszel/internal/ghupdate/extract.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										140
									
								
								beszel/internal/ghupdate/extract.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,140 @@
 | 
			
		||||
package ghupdate
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"archive/tar"
 | 
			
		||||
	"archive/zip"
 | 
			
		||||
	"compress/gzip"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"strings"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// extract extracts an archive file to the destination directory.
 | 
			
		||||
// Supports .zip and .tar.gz files based on the file extension.
 | 
			
		||||
func extract(srcPath, destDir string) error {
 | 
			
		||||
	if strings.HasSuffix(srcPath, ".tar.gz") {
 | 
			
		||||
		return extractTarGz(srcPath, destDir)
 | 
			
		||||
	}
 | 
			
		||||
	// Default to zip extraction
 | 
			
		||||
	return extractZip(srcPath, destDir)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// extractTarGz extracts a tar.gz archive to the destination directory.
 | 
			
		||||
func extractTarGz(srcPath, destDir string) error {
 | 
			
		||||
	src, err := os.Open(srcPath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer src.Close()
 | 
			
		||||
 | 
			
		||||
	gz, err := gzip.NewReader(src)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer gz.Close()
 | 
			
		||||
 | 
			
		||||
	tr := tar.NewReader(gz)
 | 
			
		||||
 | 
			
		||||
	for {
 | 
			
		||||
		header, err := tr.Next()
 | 
			
		||||
		if err == io.EOF {
 | 
			
		||||
			break
 | 
			
		||||
		}
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if header.Typeflag == tar.TypeDir {
 | 
			
		||||
			if err := os.MkdirAll(filepath.Join(destDir, header.Name), 0755); err != nil {
 | 
			
		||||
				return err
 | 
			
		||||
			}
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := os.MkdirAll(filepath.Dir(filepath.Join(destDir, header.Name)), 0755); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		outFile, err := os.Create(filepath.Join(destDir, header.Name))
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if _, err := io.Copy(outFile, tr); err != nil {
 | 
			
		||||
			outFile.Close()
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
		outFile.Close()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// extractZip extracts the zip archive at "src" to "dest".
 | 
			
		||||
//
 | 
			
		||||
// Note that only dirs and regular files will be extracted.
 | 
			
		||||
// Symbolic links, named pipes, sockets, or any other irregular files
 | 
			
		||||
// are skipped because they come with too many edge cases and ambiguities.
 | 
			
		||||
func extractZip(src, dest string) error {
 | 
			
		||||
	zr, err := zip.OpenReader(src)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer zr.Close()
 | 
			
		||||
 | 
			
		||||
	// normalize dest path to check later for Zip Slip
 | 
			
		||||
	dest = filepath.Clean(dest) + string(os.PathSeparator)
 | 
			
		||||
 | 
			
		||||
	for _, f := range zr.File {
 | 
			
		||||
		err := extractFile(f, dest)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// extractFile extracts the provided zipFile into "basePath/zipFileName" path,
 | 
			
		||||
// creating all the necessary path directories.
 | 
			
		||||
func extractFile(zipFile *zip.File, basePath string) error {
 | 
			
		||||
	path := filepath.Join(basePath, zipFile.Name)
 | 
			
		||||
 | 
			
		||||
	// check for Zip Slip
 | 
			
		||||
	if !strings.HasPrefix(path, basePath) {
 | 
			
		||||
		return fmt.Errorf("invalid file path: %s", path)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	r, err := zipFile.Open()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer r.Close()
 | 
			
		||||
 | 
			
		||||
	// allow only dirs or regular files
 | 
			
		||||
	if zipFile.FileInfo().IsDir() {
 | 
			
		||||
		if err := os.MkdirAll(path, os.ModePerm); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
	} else if zipFile.FileInfo().Mode().IsRegular() {
 | 
			
		||||
		// ensure that the file path directories are created
 | 
			
		||||
		if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, zipFile.Mode())
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
		defer f.Close()
 | 
			
		||||
 | 
			
		||||
		_, err = io.Copy(f, r)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										349
									
								
								beszel/internal/ghupdate/ghupdate.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										349
									
								
								beszel/internal/ghupdate/ghupdate.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,349 @@
 | 
			
		||||
// Package ghupdate implements a new command to self update the current
 | 
			
		||||
// executable with the latest GitHub release. This is based on PocketBase's
 | 
			
		||||
// ghupdate package with modifications.
 | 
			
		||||
package ghupdate
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"beszel"
 | 
			
		||||
	"context"
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io"
 | 
			
		||||
	"log/slog"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"runtime"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"github.com/blang/semver"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Minimal color functions using ANSI escape codes
 | 
			
		||||
const (
 | 
			
		||||
	colorReset  = "\033[0m"
 | 
			
		||||
	ColorYellow = "\033[33m"
 | 
			
		||||
	colorGreen  = "\033[32m"
 | 
			
		||||
	colorCyan   = "\033[36m"
 | 
			
		||||
	colorGray   = "\033[90m"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func ColorPrint(color, text string) {
 | 
			
		||||
	fmt.Println(color + text + colorReset)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func ColorPrintf(color, format string, args ...interface{}) {
 | 
			
		||||
	fmt.Printf(color+format+colorReset+"\n", args...)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// HttpClient is a base HTTP client interface (usually used for test purposes).
 | 
			
		||||
type HttpClient interface {
 | 
			
		||||
	Do(req *http.Request) (*http.Response, error)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Config defines the config options of the ghupdate plugin.
 | 
			
		||||
//
 | 
			
		||||
// NB! This plugin is considered experimental and its config options may change in the future.
 | 
			
		||||
type Config struct {
 | 
			
		||||
	// Owner specifies the account owner of the repository (default to "pocketbase").
 | 
			
		||||
	Owner string
 | 
			
		||||
 | 
			
		||||
	// Repo specifies the name of the repository (default to "pocketbase").
 | 
			
		||||
	Repo string
 | 
			
		||||
 | 
			
		||||
	// ArchiveExecutable specifies the name of the executable file in the release archive
 | 
			
		||||
	// (default to "pocketbase"; an additional ".exe" check is also performed as a fallback).
 | 
			
		||||
	ArchiveExecutable string
 | 
			
		||||
 | 
			
		||||
	// Optional context to use when fetching and downloading the latest release.
 | 
			
		||||
	Context context.Context
 | 
			
		||||
 | 
			
		||||
	// The HTTP client to use when fetching and downloading the latest release.
 | 
			
		||||
	// Defaults to `http.DefaultClient`.
 | 
			
		||||
	HttpClient HttpClient
 | 
			
		||||
 | 
			
		||||
	// The data directory to use when fetching and downloading the latest release.
 | 
			
		||||
	DataDir string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type updater struct {
 | 
			
		||||
	config         Config
 | 
			
		||||
	currentVersion string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func Update(config Config) (updated bool, err error) {
 | 
			
		||||
	p := &updater{
 | 
			
		||||
		currentVersion: beszel.Version,
 | 
			
		||||
		config:         config,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return p.update()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (p *updater) update() (updated bool, err error) {
 | 
			
		||||
	ColorPrint(ColorYellow, "Fetching release information...")
 | 
			
		||||
 | 
			
		||||
	if p.config.DataDir == "" {
 | 
			
		||||
		p.config.DataDir = os.TempDir()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if p.config.Owner == "" {
 | 
			
		||||
		p.config.Owner = "henrygd"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if p.config.Repo == "" {
 | 
			
		||||
		p.config.Repo = "beszel"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if p.config.Context == nil {
 | 
			
		||||
		p.config.Context = context.Background()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if p.config.HttpClient == nil {
 | 
			
		||||
		p.config.HttpClient = http.DefaultClient
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var latest *release
 | 
			
		||||
	var useMirror bool
 | 
			
		||||
 | 
			
		||||
	latest, err = fetchLatestRelease(
 | 
			
		||||
		p.config.Context,
 | 
			
		||||
		p.config.HttpClient,
 | 
			
		||||
		fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", p.config.Owner, p.config.Repo),
 | 
			
		||||
	)
 | 
			
		||||
	// if the first fetch fails, try the beszel.dev API (fallback for China)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		ColorPrint(ColorYellow, "Failed to fetch release. Trying beszel.dev mirror...")
 | 
			
		||||
		useMirror = true
 | 
			
		||||
		latest, err = fetchLatestRelease(
 | 
			
		||||
			p.config.Context,
 | 
			
		||||
			p.config.HttpClient,
 | 
			
		||||
			fmt.Sprintf("https://gh.beszel.dev/repos/%s/%s/releases/latest?api=true", p.config.Owner, p.config.Repo),
 | 
			
		||||
		)
 | 
			
		||||
	}
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return false, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	currentVersion := semver.MustParse(strings.TrimPrefix(p.currentVersion, "v"))
 | 
			
		||||
	newVersion := semver.MustParse(strings.TrimPrefix(latest.Tag, "v"))
 | 
			
		||||
 | 
			
		||||
	if newVersion.LTE(currentVersion) {
 | 
			
		||||
		ColorPrintf(colorGreen, "You already have the latest version %s.", p.currentVersion)
 | 
			
		||||
		return false, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	suffix := archiveSuffix(p.config.ArchiveExecutable, runtime.GOOS, runtime.GOARCH)
 | 
			
		||||
	asset, err := latest.findAssetBySuffix(suffix)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return false, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	releaseDir := filepath.Join(p.config.DataDir, ".beszel_update")
 | 
			
		||||
	defer os.RemoveAll(releaseDir)
 | 
			
		||||
 | 
			
		||||
	ColorPrintf(ColorYellow, "Downloading %s...", asset.Name)
 | 
			
		||||
 | 
			
		||||
	// download the release asset
 | 
			
		||||
	assetPath := filepath.Join(releaseDir, asset.Name)
 | 
			
		||||
	if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetPath, useMirror); err != nil {
 | 
			
		||||
		return false, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ColorPrintf(ColorYellow, "Extracting %s...", asset.Name)
 | 
			
		||||
 | 
			
		||||
	extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name)
 | 
			
		||||
	defer os.RemoveAll(extractDir)
 | 
			
		||||
 | 
			
		||||
	// Extract the archive (automatically detects format)
 | 
			
		||||
	if err := extract(assetPath, extractDir); err != nil {
 | 
			
		||||
		return false, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ColorPrint(ColorYellow, "Replacing the executable...")
 | 
			
		||||
 | 
			
		||||
	oldExec, err := os.Executable()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return false, err
 | 
			
		||||
	}
 | 
			
		||||
	renamedOldExec := oldExec + ".old"
 | 
			
		||||
	defer os.Remove(renamedOldExec)
 | 
			
		||||
 | 
			
		||||
	newExec := filepath.Join(extractDir, p.config.ArchiveExecutable)
 | 
			
		||||
	if _, err := os.Stat(newExec); err != nil {
 | 
			
		||||
		// try again with an .exe extension
 | 
			
		||||
		newExec = newExec + ".exe"
 | 
			
		||||
		if _, fallbackErr := os.Stat(newExec); fallbackErr != nil {
 | 
			
		||||
			return false, fmt.Errorf("the executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// rename the current executable
 | 
			
		||||
	if err := os.Rename(oldExec, renamedOldExec); err != nil {
 | 
			
		||||
		return false, fmt.Errorf("failed to rename the current executable: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	tryToRevertExecChanges := func() {
 | 
			
		||||
		if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil {
 | 
			
		||||
			slog.Debug(
 | 
			
		||||
				"Failed to revert executable",
 | 
			
		||||
				slog.String("old", renamedOldExec),
 | 
			
		||||
				slog.String("new", oldExec),
 | 
			
		||||
				slog.String("error", revertErr.Error()),
 | 
			
		||||
			)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// replace with the extracted binary
 | 
			
		||||
	if err := os.Rename(newExec, oldExec); err != nil {
 | 
			
		||||
		// If rename fails due to cross-device link, try copying instead
 | 
			
		||||
		if isCrossDeviceError(err) {
 | 
			
		||||
			if err := copyFile(newExec, oldExec); err != nil {
 | 
			
		||||
				tryToRevertExecChanges()
 | 
			
		||||
				return false, fmt.Errorf("failed replacing the executable: %w", err)
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			tryToRevertExecChanges()
 | 
			
		||||
			return false, fmt.Errorf("failed replacing the executable: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ColorPrint(colorGray, "---")
 | 
			
		||||
	ColorPrint(colorGreen, "Update completed successfully! You can start the executable as usual.")
 | 
			
		||||
 | 
			
		||||
	// print the release notes
 | 
			
		||||
	if latest.Body != "" {
 | 
			
		||||
		fmt.Print("\n")
 | 
			
		||||
		ColorPrintf(colorCyan, "Here is a list with some of the %s changes:", latest.Tag)
 | 
			
		||||
		// remove the update command note to avoid "stuttering"
 | 
			
		||||
		// (@todo consider moving to a config option)
 | 
			
		||||
		releaseNotes := strings.TrimSpace(strings.Replace(latest.Body, "> _To update the prebuilt executable you can run `./"+p.config.ArchiveExecutable+" update`._", "", 1))
 | 
			
		||||
		ColorPrint(colorCyan, releaseNotes)
 | 
			
		||||
		fmt.Print("\n")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return true, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func fetchLatestRelease(
 | 
			
		||||
	ctx context.Context,
 | 
			
		||||
	client HttpClient,
 | 
			
		||||
	url string,
 | 
			
		||||
) (*release, error) {
 | 
			
		||||
	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	res, err := client.Do(req)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	defer res.Body.Close()
 | 
			
		||||
 | 
			
		||||
	rawBody, err := io.ReadAll(res.Body)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// http.Client doesn't treat non 2xx responses as error
 | 
			
		||||
	if res.StatusCode >= 400 {
 | 
			
		||||
		return nil, fmt.Errorf(
 | 
			
		||||
			"(%d) failed to fetch latest releases:\n%s",
 | 
			
		||||
			res.StatusCode,
 | 
			
		||||
			string(rawBody),
 | 
			
		||||
		)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	result := &release{}
 | 
			
		||||
	if err := json.Unmarshal(rawBody, result); err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return result, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func downloadFile(
 | 
			
		||||
	ctx context.Context,
 | 
			
		||||
	client HttpClient,
 | 
			
		||||
	url string,
 | 
			
		||||
	destPath string,
 | 
			
		||||
	useMirror bool,
 | 
			
		||||
) error {
 | 
			
		||||
	if useMirror {
 | 
			
		||||
		url = strings.Replace(url, "github.com", "gh.beszel.dev", 1)
 | 
			
		||||
	}
 | 
			
		||||
	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	res, err := client.Do(req)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer res.Body.Close()
 | 
			
		||||
 | 
			
		||||
	// http.Client doesn't treat non 2xx responses as error
 | 
			
		||||
	if res.StatusCode >= 400 {
 | 
			
		||||
		return fmt.Errorf("(%d) failed to send download file request", res.StatusCode)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// ensure that the dest parent dir(s) exist
 | 
			
		||||
	if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	dest, err := os.Create(destPath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer dest.Close()
 | 
			
		||||
 | 
			
		||||
	if _, err := io.Copy(dest, res.Body); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// isCrossDeviceError checks if the error is due to a cross-device link
 | 
			
		||||
func isCrossDeviceError(err error) bool {
 | 
			
		||||
	return err != nil && (strings.Contains(err.Error(), "cross-device") ||
 | 
			
		||||
		strings.Contains(err.Error(), "EXDEV"))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// copyFile copies a file from src to dst, preserving permissions
 | 
			
		||||
func copyFile(src, dst string) error {
 | 
			
		||||
	sourceFile, err := os.Open(src)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer sourceFile.Close()
 | 
			
		||||
 | 
			
		||||
	destFile, err := os.Create(dst)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer destFile.Close()
 | 
			
		||||
 | 
			
		||||
	// Copy the file contents
 | 
			
		||||
	if _, err := io.Copy(destFile, sourceFile); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Preserve the original file permissions
 | 
			
		||||
	sourceInfo, err := sourceFile.Stat()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return destFile.Chmod(sourceInfo.Mode())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func archiveSuffix(binaryName, goos, goarch string) string {
 | 
			
		||||
	if goos == "windows" {
 | 
			
		||||
		return fmt.Sprintf("%s_%s_%s.zip", binaryName, goos, goarch)
 | 
			
		||||
	}
 | 
			
		||||
	return fmt.Sprintf("%s_%s_%s.tar.gz", binaryName, goos, goarch)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										45
									
								
								beszel/internal/ghupdate/ghupdate_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								beszel/internal/ghupdate/ghupdate_test.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,45 @@
 | 
			
		||||
package ghupdate
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"testing"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestReleaseFindAssetBySuffix(t *testing.T) {
 | 
			
		||||
	r := release{
 | 
			
		||||
		Assets: []*releaseAsset{
 | 
			
		||||
			{Name: "test1.zip", Id: 1},
 | 
			
		||||
			{Name: "test2.zip", Id: 2},
 | 
			
		||||
			{Name: "test22.zip", Id: 22},
 | 
			
		||||
			{Name: "test3.zip", Id: 3},
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	asset, err := r.findAssetBySuffix("2.zip")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Expected nil, got err: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if asset.Id != 2 {
 | 
			
		||||
		t.Fatalf("Expected asset with id %d, got %v", 2, asset)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestExtractFailure(t *testing.T) {
 | 
			
		||||
	testDir := t.TempDir()
 | 
			
		||||
 | 
			
		||||
	// Test with missing zip file
 | 
			
		||||
	missingZipPath := filepath.Join(testDir, "missing_test.zip")
 | 
			
		||||
	extractedPath := filepath.Join(testDir, "zip_extract")
 | 
			
		||||
 | 
			
		||||
	if err := extract(missingZipPath, extractedPath); err == nil {
 | 
			
		||||
		t.Fatal("Expected Extract to fail due to missing zip file")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Test with missing tar.gz file
 | 
			
		||||
	missingTarPath := filepath.Join(testDir, "missing_test.tar.gz")
 | 
			
		||||
 | 
			
		||||
	if err := extract(missingTarPath, extractedPath); err == nil {
 | 
			
		||||
		t.Fatal("Expected Extract to fail due to missing tar.gz file")
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										36
									
								
								beszel/internal/ghupdate/release.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								beszel/internal/ghupdate/release.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,36 @@
 | 
			
		||||
package ghupdate
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"errors"
 | 
			
		||||
	"strings"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type releaseAsset struct {
 | 
			
		||||
	Name        string `json:"name"`
 | 
			
		||||
	DownloadUrl string `json:"browser_download_url"`
 | 
			
		||||
	Id          int    `json:"id"`
 | 
			
		||||
	Size        int    `json:"size"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type release struct {
 | 
			
		||||
	Name      string          `json:"name"`
 | 
			
		||||
	Tag       string          `json:"tag_name"`
 | 
			
		||||
	Published string          `json:"published_at"`
 | 
			
		||||
	Url       string          `json:"html_url"`
 | 
			
		||||
	Body      string          `json:"body"`
 | 
			
		||||
	Assets    []*releaseAsset `json:"assets"`
 | 
			
		||||
	Id        int             `json:"id"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// findAssetBySuffix returns the first available asset containing the specified suffix.
 | 
			
		||||
func (r *release) findAssetBySuffix(suffix string) (*releaseAsset, error) {
 | 
			
		||||
	if suffix != "" {
 | 
			
		||||
		for _, asset := range r.Assets {
 | 
			
		||||
			if strings.HasSuffix(asset.Name, suffix) {
 | 
			
		||||
				return asset, nil
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil, errors.New("missing asset containing " + suffix)
 | 
			
		||||
}
 | 
			
		||||
@@ -10,7 +10,6 @@ import (
 | 
			
		||||
 | 
			
		||||
	"github.com/google/uuid"
 | 
			
		||||
	"github.com/pocketbase/dbx"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/apis"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/core"
 | 
			
		||||
	"github.com/spf13/cast"
 | 
			
		||||
	"gopkg.in/yaml.v3"
 | 
			
		||||
@@ -279,9 +278,8 @@ func createFingerprintRecord(app core.App, systemID, token string) error {
 | 
			
		||||
 | 
			
		||||
// Returns the current config.yml file as a JSON object
 | 
			
		||||
func GetYamlConfig(e *core.RequestEvent) error {
 | 
			
		||||
	info, _ := e.RequestInfo()
 | 
			
		||||
	if info.Auth == nil || info.Auth.GetString("role") != "admin" {
 | 
			
		||||
		return apis.NewForbiddenError("Forbidden", nil)
 | 
			
		||||
	if e.Auth.GetString("role") != "admin" {
 | 
			
		||||
		return e.ForbiddenError("Requires admin role", nil)
 | 
			
		||||
	}
 | 
			
		||||
	configContent, err := generateYAML(e.App)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
 
 | 
			
		||||
@@ -224,48 +224,48 @@ func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
 | 
			
		||||
 | 
			
		||||
// custom api routes
 | 
			
		||||
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
 | 
			
		||||
	// returns public key and version
 | 
			
		||||
	se.Router.GET("/api/beszel/getkey", func(e *core.RequestEvent) error {
 | 
			
		||||
		info, _ := e.RequestInfo()
 | 
			
		||||
		if info.Auth == nil {
 | 
			
		||||
			return apis.NewForbiddenError("Forbidden", nil)
 | 
			
		||||
		}
 | 
			
		||||
		return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version})
 | 
			
		||||
	})
 | 
			
		||||
	// auth protected routes
 | 
			
		||||
	apiAuth := se.Router.Group("/api/beszel")
 | 
			
		||||
	apiAuth.Bind(apis.RequireAuth())
 | 
			
		||||
	// auth optional routes
 | 
			
		||||
	apiNoAuth := se.Router.Group("/api/beszel")
 | 
			
		||||
 | 
			
		||||
	// create first user endpoint only needed if no users exist
 | 
			
		||||
	if totalUsers, _ := se.App.CountRecords("users"); totalUsers == 0 {
 | 
			
		||||
		apiNoAuth.POST("/create-user", h.um.CreateFirstUser)
 | 
			
		||||
	}
 | 
			
		||||
	// check if first time setup on login page
 | 
			
		||||
	se.Router.GET("/api/beszel/first-run", func(e *core.RequestEvent) error {
 | 
			
		||||
		total, err := h.CountRecords("users")
 | 
			
		||||
	apiNoAuth.GET("/first-run", func(e *core.RequestEvent) error {
 | 
			
		||||
		total, err := e.App.CountRecords("users")
 | 
			
		||||
		return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0})
 | 
			
		||||
	})
 | 
			
		||||
	// get public key and version
 | 
			
		||||
	apiAuth.GET("/getkey", func(e *core.RequestEvent) error {
 | 
			
		||||
		return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version})
 | 
			
		||||
	})
 | 
			
		||||
	// send test notification
 | 
			
		||||
	se.Router.GET("/api/beszel/send-test-notification", h.SendTestNotification)
 | 
			
		||||
	// API endpoint to get config.yml content
 | 
			
		||||
	se.Router.GET("/api/beszel/config-yaml", config.GetYamlConfig)
 | 
			
		||||
	apiAuth.POST("/test-notification", h.SendTestNotification)
 | 
			
		||||
	// get config.yml content
 | 
			
		||||
	apiAuth.GET("/config-yaml", config.GetYamlConfig)
 | 
			
		||||
	// handle agent websocket connection
 | 
			
		||||
	se.Router.GET("/api/beszel/agent-connect", h.handleAgentConnect)
 | 
			
		||||
	apiNoAuth.GET("/agent-connect", h.handleAgentConnect)
 | 
			
		||||
	// get or create universal tokens
 | 
			
		||||
	se.Router.GET("/api/beszel/universal-token", h.getUniversalToken)
 | 
			
		||||
	// create first user endpoint only needed if no users exist
 | 
			
		||||
	if totalUsers, _ := h.CountRecords("users"); totalUsers == 0 {
 | 
			
		||||
		se.Router.POST("/api/beszel/create-user", h.um.CreateFirstUser)
 | 
			
		||||
	}
 | 
			
		||||
	apiAuth.GET("/universal-token", h.getUniversalToken)
 | 
			
		||||
	// update / delete user alerts
 | 
			
		||||
	apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
 | 
			
		||||
	apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Handler for universal token API endpoint (create, read, delete)
 | 
			
		||||
func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
 | 
			
		||||
	info, err := e.RequestInfo()
 | 
			
		||||
	if err != nil || info.Auth == nil {
 | 
			
		||||
		return apis.NewForbiddenError("Forbidden", nil)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	tokenMap := universalTokenMap.GetMap()
 | 
			
		||||
	userID := info.Auth.Id
 | 
			
		||||
	userID := e.Auth.Id
 | 
			
		||||
	query := e.Request.URL.Query()
 | 
			
		||||
	token := query.Get("token")
 | 
			
		||||
	tokenSet := token != ""
 | 
			
		||||
 | 
			
		||||
	if !tokenSet {
 | 
			
		||||
	if token == "" {
 | 
			
		||||
		// return existing token if it exists
 | 
			
		||||
		if token, _, ok := tokenMap.GetByValue(userID); ok {
 | 
			
		||||
			return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true})
 | 
			
		||||
 
 | 
			
		||||
@@ -4,27 +4,37 @@
 | 
			
		||||
package hub_test
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"beszel/internal/tests"
 | 
			
		||||
	beszelTests "beszel/internal/tests"
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	"bytes"
 | 
			
		||||
	"crypto/ed25519"
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"encoding/pem"
 | 
			
		||||
	"io"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"github.com/pocketbase/pocketbase/core"
 | 
			
		||||
	pbTests "github.com/pocketbase/pocketbase/tests"
 | 
			
		||||
	"github.com/stretchr/testify/assert"
 | 
			
		||||
	"github.com/stretchr/testify/require"
 | 
			
		||||
	"golang.org/x/crypto/ssh"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func getTestHub(t testing.TB) *tests.TestHub {
 | 
			
		||||
	hub, _ := tests.NewTestHub(t.TempDir())
 | 
			
		||||
	return hub
 | 
			
		||||
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
 | 
			
		||||
func jsonReader(v any) io.Reader {
 | 
			
		||||
	data, err := json.Marshal(v)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		panic(err)
 | 
			
		||||
	}
 | 
			
		||||
	return bytes.NewReader(data)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestMakeLink(t *testing.T) {
 | 
			
		||||
	hub := getTestHub(t)
 | 
			
		||||
	hub, _ := beszelTests.NewTestHub(t.TempDir())
 | 
			
		||||
 | 
			
		||||
	tests := []struct {
 | 
			
		||||
		name     string
 | 
			
		||||
@@ -114,7 +124,7 @@ func TestMakeLink(t *testing.T) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestGetSSHKey(t *testing.T) {
 | 
			
		||||
	hub := getTestHub(t)
 | 
			
		||||
	hub, _ := beszelTests.NewTestHub(t.TempDir())
 | 
			
		||||
 | 
			
		||||
	// Test Case 1: Key generation (no existing key)
 | 
			
		||||
	t.Run("KeyGeneration", func(t *testing.T) {
 | 
			
		||||
@@ -254,3 +264,340 @@ func TestGetSSHKey(t *testing.T) {
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestApiRoutesAuthentication(t *testing.T) {
 | 
			
		||||
	hub, _ := beszelTests.NewTestHub(t.TempDir())
 | 
			
		||||
	defer hub.Cleanup()
 | 
			
		||||
 | 
			
		||||
	hub.StartHub()
 | 
			
		||||
 | 
			
		||||
	// Create test user and get auth token
 | 
			
		||||
	user, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123")
 | 
			
		||||
	require.NoError(t, err, "Failed to create test user")
 | 
			
		||||
 | 
			
		||||
	adminUser, err := beszelTests.CreateRecord(hub, "users", map[string]any{
 | 
			
		||||
		"email":    "admin@example.com",
 | 
			
		||||
		"password": "password123",
 | 
			
		||||
		"role":     "admin",
 | 
			
		||||
	})
 | 
			
		||||
	require.NoError(t, err, "Failed to create admin user")
 | 
			
		||||
	adminUserToken, err := adminUser.NewAuthToken()
 | 
			
		||||
 | 
			
		||||
	// superUser, err := beszelTests.CreateRecord(hub, core.CollectionNameSuperusers, map[string]any{
 | 
			
		||||
	// 	"email":    "superuser@example.com",
 | 
			
		||||
	// 	"password": "password123",
 | 
			
		||||
	// })
 | 
			
		||||
	// require.NoError(t, err, "Failed to create superuser")
 | 
			
		||||
 | 
			
		||||
	userToken, err := user.NewAuthToken()
 | 
			
		||||
	require.NoError(t, err, "Failed to create auth token")
 | 
			
		||||
 | 
			
		||||
	// Create test system for user-alerts endpoints
 | 
			
		||||
	system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
 | 
			
		||||
		"name":  "test-system",
 | 
			
		||||
		"users": []string{user.Id},
 | 
			
		||||
		"host":  "127.0.0.1",
 | 
			
		||||
	})
 | 
			
		||||
	require.NoError(t, err, "Failed to create test system")
 | 
			
		||||
 | 
			
		||||
	testAppFactory := func(t testing.TB) *pbTests.TestApp {
 | 
			
		||||
		return hub.TestApp
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	scenarios := []beszelTests.ApiScenario{
 | 
			
		||||
		// Auth Protected Routes - Should require authentication
 | 
			
		||||
		{
 | 
			
		||||
			Name:            "POST /test-notification - no auth should fail",
 | 
			
		||||
			Method:          http.MethodPost,
 | 
			
		||||
			URL:             "/api/beszel/test-notification",
 | 
			
		||||
			ExpectedStatus:  401,
 | 
			
		||||
			ExpectedContent: []string{"requires valid"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
			Body: jsonReader(map[string]any{
 | 
			
		||||
				"url": "generic://127.0.0.1",
 | 
			
		||||
			}),
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:           "POST /test-notification - with auth should succeed",
 | 
			
		||||
			Method:         http.MethodPost,
 | 
			
		||||
			URL:            "/api/beszel/test-notification",
 | 
			
		||||
			TestAppFactory: testAppFactory,
 | 
			
		||||
			Headers: map[string]string{
 | 
			
		||||
				"Authorization": userToken,
 | 
			
		||||
			},
 | 
			
		||||
			Body: jsonReader(map[string]any{
 | 
			
		||||
				"url": "generic://127.0.0.1",
 | 
			
		||||
			}),
 | 
			
		||||
			ExpectedStatus:  200,
 | 
			
		||||
			ExpectedContent: []string{"sending message"},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:            "GET /config-yaml - no auth should fail",
 | 
			
		||||
			Method:          http.MethodGet,
 | 
			
		||||
			URL:             "/api/beszel/config-yaml",
 | 
			
		||||
			ExpectedStatus:  401,
 | 
			
		||||
			ExpectedContent: []string{"requires valid"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "GET /config-yaml - with user auth should fail",
 | 
			
		||||
			Method: http.MethodGet,
 | 
			
		||||
			URL:    "/api/beszel/config-yaml",
 | 
			
		||||
			Headers: map[string]string{
 | 
			
		||||
				"Authorization": userToken,
 | 
			
		||||
			},
 | 
			
		||||
			ExpectedStatus:  403,
 | 
			
		||||
			ExpectedContent: []string{"Requires admin"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "GET /config-yaml - with admin auth should succeed",
 | 
			
		||||
			Method: http.MethodGet,
 | 
			
		||||
			URL:    "/api/beszel/config-yaml",
 | 
			
		||||
			Headers: map[string]string{
 | 
			
		||||
				"Authorization": adminUserToken,
 | 
			
		||||
			},
 | 
			
		||||
			ExpectedStatus:  200,
 | 
			
		||||
			ExpectedContent: []string{"test-system"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:            "GET /universal-token - no auth should fail",
 | 
			
		||||
			Method:          http.MethodGet,
 | 
			
		||||
			URL:             "/api/beszel/universal-token",
 | 
			
		||||
			ExpectedStatus:  401,
 | 
			
		||||
			ExpectedContent: []string{"requires valid"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "GET /universal-token - with auth should succeed",
 | 
			
		||||
			Method: http.MethodGet,
 | 
			
		||||
			URL:    "/api/beszel/universal-token",
 | 
			
		||||
			Headers: map[string]string{
 | 
			
		||||
				"Authorization": userToken,
 | 
			
		||||
			},
 | 
			
		||||
			ExpectedStatus:  200,
 | 
			
		||||
			ExpectedContent: []string{"active", "token"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:            "POST /user-alerts - no auth should fail",
 | 
			
		||||
			Method:          http.MethodPost,
 | 
			
		||||
			URL:             "/api/beszel/user-alerts",
 | 
			
		||||
			ExpectedStatus:  401,
 | 
			
		||||
			ExpectedContent: []string{"requires valid"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
			Body: jsonReader(map[string]any{
 | 
			
		||||
				"name":    "CPU",
 | 
			
		||||
				"value":   80,
 | 
			
		||||
				"min":     10,
 | 
			
		||||
				"systems": []string{system.Id},
 | 
			
		||||
			}),
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "POST /user-alerts - with auth should succeed",
 | 
			
		||||
			Method: http.MethodPost,
 | 
			
		||||
			URL:    "/api/beszel/user-alerts",
 | 
			
		||||
			Headers: map[string]string{
 | 
			
		||||
				"Authorization": userToken,
 | 
			
		||||
			},
 | 
			
		||||
			ExpectedStatus:  200,
 | 
			
		||||
			ExpectedContent: []string{"\"success\":true"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
			Body: jsonReader(map[string]any{
 | 
			
		||||
				"name":    "CPU",
 | 
			
		||||
				"value":   80,
 | 
			
		||||
				"min":     10,
 | 
			
		||||
				"systems": []string{system.Id},
 | 
			
		||||
			}),
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:            "DELETE /user-alerts - no auth should fail",
 | 
			
		||||
			Method:          http.MethodDelete,
 | 
			
		||||
			URL:             "/api/beszel/user-alerts",
 | 
			
		||||
			ExpectedStatus:  401,
 | 
			
		||||
			ExpectedContent: []string{"requires valid"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
			Body: jsonReader(map[string]any{
 | 
			
		||||
				"name":    "CPU",
 | 
			
		||||
				"systems": []string{system.Id},
 | 
			
		||||
			}),
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "DELETE /user-alerts - with auth should succeed",
 | 
			
		||||
			Method: http.MethodDelete,
 | 
			
		||||
			URL:    "/api/beszel/user-alerts",
 | 
			
		||||
			Headers: map[string]string{
 | 
			
		||||
				"Authorization": userToken,
 | 
			
		||||
			},
 | 
			
		||||
			ExpectedStatus:  200,
 | 
			
		||||
			ExpectedContent: []string{"\"success\":true"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
			Body: jsonReader(map[string]any{
 | 
			
		||||
				"name":    "CPU",
 | 
			
		||||
				"systems": []string{system.Id},
 | 
			
		||||
			}),
 | 
			
		||||
			BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
 | 
			
		||||
				// Create an alert to delete
 | 
			
		||||
				beszelTests.CreateRecord(app, "alerts", map[string]any{
 | 
			
		||||
					"name":   "CPU",
 | 
			
		||||
					"system": system.Id,
 | 
			
		||||
					"user":   user.Id,
 | 
			
		||||
					"value":  80,
 | 
			
		||||
					"min":    10,
 | 
			
		||||
				})
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		// Auth Optional Routes - Should work without authentication
 | 
			
		||||
		{
 | 
			
		||||
			Name:            "GET /getkey - no auth should fail",
 | 
			
		||||
			Method:          http.MethodGet,
 | 
			
		||||
			URL:             "/api/beszel/getkey",
 | 
			
		||||
			ExpectedStatus:  401,
 | 
			
		||||
			ExpectedContent: []string{"requires valid"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "GET /getkey - with auth should also succeed",
 | 
			
		||||
			Method: http.MethodGet,
 | 
			
		||||
			URL:    "/api/beszel/getkey",
 | 
			
		||||
			Headers: map[string]string{
 | 
			
		||||
				"Authorization": userToken,
 | 
			
		||||
			},
 | 
			
		||||
			ExpectedStatus:  200,
 | 
			
		||||
			ExpectedContent: []string{"\"key\":", "\"v\":"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:            "GET /first-run - no auth should succeed",
 | 
			
		||||
			Method:          http.MethodGet,
 | 
			
		||||
			URL:             "/api/beszel/first-run",
 | 
			
		||||
			ExpectedStatus:  200,
 | 
			
		||||
			ExpectedContent: []string{"\"firstRun\":false"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "GET /first-run - with auth should also succeed",
 | 
			
		||||
			Method: http.MethodGet,
 | 
			
		||||
			URL:    "/api/beszel/first-run",
 | 
			
		||||
			Headers: map[string]string{
 | 
			
		||||
				"Authorization": userToken,
 | 
			
		||||
			},
 | 
			
		||||
			ExpectedStatus:  200,
 | 
			
		||||
			ExpectedContent: []string{"\"firstRun\":false"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:            "GET /agent-connect - no auth should succeed (websocket upgrade fails but route is accessible)",
 | 
			
		||||
			Method:          http.MethodGet,
 | 
			
		||||
			URL:             "/api/beszel/agent-connect",
 | 
			
		||||
			ExpectedStatus:  400,
 | 
			
		||||
			ExpectedContent: []string{},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "POST /test-notification - invalid auth token should fail",
 | 
			
		||||
			Method: http.MethodPost,
 | 
			
		||||
			URL:    "/api/beszel/test-notification",
 | 
			
		||||
			Body: jsonReader(map[string]any{
 | 
			
		||||
				"url": "generic://127.0.0.1",
 | 
			
		||||
			}),
 | 
			
		||||
			Headers: map[string]string{
 | 
			
		||||
				"Authorization": "invalid-token",
 | 
			
		||||
			},
 | 
			
		||||
			ExpectedStatus:  401,
 | 
			
		||||
			ExpectedContent: []string{"requires valid"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "POST /user-alerts - invalid auth token should fail",
 | 
			
		||||
			Method: http.MethodPost,
 | 
			
		||||
			URL:    "/api/beszel/user-alerts",
 | 
			
		||||
			Headers: map[string]string{
 | 
			
		||||
				"Authorization": "invalid-token",
 | 
			
		||||
			},
 | 
			
		||||
			ExpectedStatus:  401,
 | 
			
		||||
			ExpectedContent: []string{"requires valid"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
			Body: jsonReader(map[string]any{
 | 
			
		||||
				"name":    "CPU",
 | 
			
		||||
				"value":   80,
 | 
			
		||||
				"min":     10,
 | 
			
		||||
				"systems": []string{system.Id},
 | 
			
		||||
			}),
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, scenario := range scenarios {
 | 
			
		||||
		scenario.Test(t)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestCreateUserEndpointAvailability(t *testing.T) {
 | 
			
		||||
	t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
 | 
			
		||||
		hub, _ := beszelTests.NewTestHub(t.TempDir())
 | 
			
		||||
		defer hub.Cleanup()
 | 
			
		||||
 | 
			
		||||
		// Ensure no users exist
 | 
			
		||||
		userCount, err := hub.CountRecords("users")
 | 
			
		||||
		require.NoError(t, err)
 | 
			
		||||
		require.Zero(t, userCount, "Should start with no users")
 | 
			
		||||
 | 
			
		||||
		hub.StartHub()
 | 
			
		||||
 | 
			
		||||
		testAppFactory := func(t testing.TB) *pbTests.TestApp {
 | 
			
		||||
			return hub.TestApp
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		scenario := beszelTests.ApiScenario{
 | 
			
		||||
			Name:   "POST /create-user - should be available when no users exist",
 | 
			
		||||
			Method: http.MethodPost,
 | 
			
		||||
			URL:    "/api/beszel/create-user",
 | 
			
		||||
			Body: jsonReader(map[string]any{
 | 
			
		||||
				"email":    "firstuser@example.com",
 | 
			
		||||
				"password": "password123",
 | 
			
		||||
			}),
 | 
			
		||||
			ExpectedStatus:  200,
 | 
			
		||||
			ExpectedContent: []string{"User created"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		scenario.Test(t)
 | 
			
		||||
 | 
			
		||||
		// Verify user was created
 | 
			
		||||
		userCount, err = hub.CountRecords("users")
 | 
			
		||||
		require.NoError(t, err)
 | 
			
		||||
		require.EqualValues(t, 1, userCount, "Should have created one user")
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	t.Run("CreateUserEndpoint not available when users exist", func(t *testing.T) {
 | 
			
		||||
		hub, _ := beszelTests.NewTestHub(t.TempDir())
 | 
			
		||||
		defer hub.Cleanup()
 | 
			
		||||
 | 
			
		||||
		// Create a user first
 | 
			
		||||
		_, err := beszelTests.CreateUser(hub, "existing@example.com", "password")
 | 
			
		||||
		require.NoError(t, err)
 | 
			
		||||
 | 
			
		||||
		hub.StartHub()
 | 
			
		||||
 | 
			
		||||
		testAppFactory := func(t testing.TB) *pbTests.TestApp {
 | 
			
		||||
			return hub.TestApp
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		scenario := beszelTests.ApiScenario{
 | 
			
		||||
			Name:   "POST /create-user - should not be available when users exist",
 | 
			
		||||
			Method: http.MethodPost,
 | 
			
		||||
			URL:    "/api/beszel/create-user",
 | 
			
		||||
			Body: jsonReader(map[string]any{
 | 
			
		||||
				"email":    "another@example.com",
 | 
			
		||||
				"password": "password123",
 | 
			
		||||
			}),
 | 
			
		||||
			ExpectedStatus:  404,
 | 
			
		||||
			ExpectedContent: []string{"wasn't found"},
 | 
			
		||||
			TestAppFactory:  testAppFactory,
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		scenario.Test(t)
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -29,7 +29,7 @@ func TestSystemManagerNew(t *testing.T) {
 | 
			
		||||
	user, err := tests.CreateUser(hub, "test@test.com", "testtesttest")
 | 
			
		||||
	require.NoError(t, err)
 | 
			
		||||
 | 
			
		||||
	synctest.Run(func() {
 | 
			
		||||
	synctest.Test(t, func(t *testing.T) {
 | 
			
		||||
		sm.Initialize()
 | 
			
		||||
 | 
			
		||||
		record, err := tests.CreateRecord(hub, "systems", map[string]any{
 | 
			
		||||
@@ -110,9 +110,11 @@ func TestSystemManagerNew(t *testing.T) {
 | 
			
		||||
		err = hub.Delete(record)
 | 
			
		||||
		require.NoError(t, err)
 | 
			
		||||
		assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after deletion")
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
		testOld(t, hub)
 | 
			
		||||
	testOld(t, hub)
 | 
			
		||||
 | 
			
		||||
	synctest.Test(t, func(t *testing.T) {
 | 
			
		||||
		time.Sleep(time.Second)
 | 
			
		||||
		synctest.Wait()
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,57 +1,73 @@
 | 
			
		||||
package hub
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"beszel"
 | 
			
		||||
	"beszel/internal/ghupdate"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"log"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"os/exec"
 | 
			
		||||
 | 
			
		||||
	"github.com/blang/semver"
 | 
			
		||||
	"github.com/rhysd/go-github-selfupdate/selfupdate"
 | 
			
		||||
	"github.com/spf13/cobra"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Update updates beszel to the latest version
 | 
			
		||||
func Update(_ *cobra.Command, _ []string) {
 | 
			
		||||
	var latest *selfupdate.Release
 | 
			
		||||
	var found bool
 | 
			
		||||
	var err error
 | 
			
		||||
	currentVersion := semver.MustParse(beszel.Version)
 | 
			
		||||
	fmt.Println("beszel", currentVersion)
 | 
			
		||||
	fmt.Println("Checking for updates...")
 | 
			
		||||
	updater, _ := selfupdate.NewUpdater(selfupdate.Config{
 | 
			
		||||
		Filters: []string{"beszel_"},
 | 
			
		||||
	dataDir := os.TempDir()
 | 
			
		||||
 | 
			
		||||
	// set dataDir to ./beszel_data if it exists
 | 
			
		||||
	if _, err := os.Stat("./beszel_data"); err == nil {
 | 
			
		||||
		dataDir = "./beszel_data"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	updated, err := ghupdate.Update(ghupdate.Config{
 | 
			
		||||
		ArchiveExecutable: "beszel",
 | 
			
		||||
		DataDir:           dataDir,
 | 
			
		||||
	})
 | 
			
		||||
	latest, found, err = updater.DetectLatest("henrygd/beszel")
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		fmt.Println("Error checking for updates:", err)
 | 
			
		||||
		os.Exit(1)
 | 
			
		||||
		log.Fatal(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if !found {
 | 
			
		||||
		fmt.Println("No updates found")
 | 
			
		||||
		os.Exit(0)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fmt.Println("Latest version:", latest.Version)
 | 
			
		||||
 | 
			
		||||
	if latest.Version.LTE(currentVersion) {
 | 
			
		||||
		fmt.Println("You are up to date")
 | 
			
		||||
	if !updated {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var binaryPath string
 | 
			
		||||
	fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version)
 | 
			
		||||
	binaryPath, err = os.Executable()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		fmt.Println("Error getting binary path:", err)
 | 
			
		||||
		os.Exit(1)
 | 
			
		||||
	}
 | 
			
		||||
	err = selfupdate.UpdateTo(latest.AssetURL, binaryPath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		fmt.Println("Please try rerunning with sudo. Error:", err)
 | 
			
		||||
		os.Exit(1)
 | 
			
		||||
	}
 | 
			
		||||
	fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes))
 | 
			
		||||
	// Try to restart the service if it's running
 | 
			
		||||
	restartService()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// restartService attempts to restart the beszel service
 | 
			
		||||
func restartService() {
 | 
			
		||||
	// Check if we're running as a service by looking for systemd
 | 
			
		||||
	if _, err := exec.LookPath("systemctl"); err == nil {
 | 
			
		||||
		// Check if beszel service exists and is active
 | 
			
		||||
		cmd := exec.Command("systemctl", "is-active", "beszel.service")
 | 
			
		||||
		if err := cmd.Run(); err == nil {
 | 
			
		||||
			fmt.Println("Restarting beszel service...")
 | 
			
		||||
			restartCmd := exec.Command("systemctl", "restart", "beszel.service")
 | 
			
		||||
			if err := restartCmd.Run(); err != nil {
 | 
			
		||||
				fmt.Printf("Warning: Failed to restart service: %v\n", err)
 | 
			
		||||
				fmt.Println("Please restart the service manually: sudo systemctl restart beszel")
 | 
			
		||||
			} else {
 | 
			
		||||
				fmt.Println("Service restarted successfully")
 | 
			
		||||
			}
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check for OpenRC (Alpine Linux)
 | 
			
		||||
	if _, err := exec.LookPath("rc-service"); err == nil {
 | 
			
		||||
		cmd := exec.Command("rc-service", "beszel", "status")
 | 
			
		||||
		if err := cmd.Run(); err == nil {
 | 
			
		||||
			fmt.Println("Restarting beszel service...")
 | 
			
		||||
			restartCmd := exec.Command("rc-service", "beszel", "restart")
 | 
			
		||||
			if err := restartCmd.Run(); err != nil {
 | 
			
		||||
				fmt.Printf("Warning: Failed to restart service: %v\n", err)
 | 
			
		||||
				fmt.Println("Please restart the service manually: sudo rc-service beszel restart")
 | 
			
		||||
			} else {
 | 
			
		||||
				fmt.Println("Service restarted successfully")
 | 
			
		||||
			}
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fmt.Println("Note: Service restart not attempted. If running as a service, restart manually.")
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -172,6 +172,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
 | 
			
		||||
	tempStats = system.Stats{}
 | 
			
		||||
	sum := &sumStats
 | 
			
		||||
	stats := &tempStats
 | 
			
		||||
	// necessary because uint8 is not big enough for the sum
 | 
			
		||||
	batterySum := 0
 | 
			
		||||
 | 
			
		||||
	count := float64(len(records))
 | 
			
		||||
	tempCount := float64(0)
 | 
			
		||||
@@ -208,6 +210,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
 | 
			
		||||
		sum.LoadAvg[2] += stats.LoadAvg[2]
 | 
			
		||||
		sum.Bandwidth[0] += stats.Bandwidth[0]
 | 
			
		||||
		sum.Bandwidth[1] += stats.Bandwidth[1]
 | 
			
		||||
		batterySum += int(stats.Battery[0])
 | 
			
		||||
		sum.Battery[1] = stats.Battery[1]
 | 
			
		||||
		// Set peak values
 | 
			
		||||
		sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
 | 
			
		||||
		sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent)
 | 
			
		||||
@@ -290,6 +294,7 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
 | 
			
		||||
		sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
 | 
			
		||||
		sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
 | 
			
		||||
		sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
 | 
			
		||||
		sum.Battery[0] = uint8(batterySum / int(count))
 | 
			
		||||
		// Average temperatures
 | 
			
		||||
		if sum.Temperatures != nil && tempCount > 0 {
 | 
			
		||||
			for key := range sum.Temperatures {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										309
									
								
								beszel/internal/tests/api.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										309
									
								
								beszel/internal/tests/api.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,309 @@
 | 
			
		||||
package tests
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bytes"
 | 
			
		||||
	"context"
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io"
 | 
			
		||||
	"maps"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"net/http/httptest"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"testing"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"github.com/pocketbase/pocketbase/apis"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/core"
 | 
			
		||||
	pbtests "github.com/pocketbase/pocketbase/tests"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/tools/hook"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// NOTE: This is a copy of https://github.com/pocketbase/pocketbase/blob/master/tests/api.go
 | 
			
		||||
// with the following changes:
 | 
			
		||||
// - Removed automatic cleanup of the test app in ApiScenario.Test (Aug 17 2025)
 | 
			
		||||
 | 
			
		||||
// ApiScenario defines a single api request test case/scenario.
 | 
			
		||||
type ApiScenario struct {
 | 
			
		||||
	// Name is the test name.
 | 
			
		||||
	Name string
 | 
			
		||||
 | 
			
		||||
	// Method is the HTTP method of the test request to use.
 | 
			
		||||
	Method string
 | 
			
		||||
 | 
			
		||||
	// URL is the url/path of the endpoint you want to test.
 | 
			
		||||
	URL string
 | 
			
		||||
 | 
			
		||||
	// Body specifies the body to send with the request.
 | 
			
		||||
	//
 | 
			
		||||
	// For example:
 | 
			
		||||
	//
 | 
			
		||||
	//	strings.NewReader(`{"title":"abc"}`)
 | 
			
		||||
	Body io.Reader
 | 
			
		||||
 | 
			
		||||
	// Headers specifies the headers to send with the request (e.g. "Authorization": "abc")
 | 
			
		||||
	Headers map[string]string
 | 
			
		||||
 | 
			
		||||
	// Delay adds a delay before checking the expectations usually
 | 
			
		||||
	// to ensure that all fired non-awaited go routines have finished
 | 
			
		||||
	Delay time.Duration
 | 
			
		||||
 | 
			
		||||
	// Timeout specifies how long to wait before cancelling the request context.
 | 
			
		||||
	//
 | 
			
		||||
	// A zero or negative value means that there will be no timeout.
 | 
			
		||||
	Timeout time.Duration
 | 
			
		||||
 | 
			
		||||
	// expectations
 | 
			
		||||
	// ---------------------------------------------------------------
 | 
			
		||||
 | 
			
		||||
	// ExpectedStatus specifies the expected response HTTP status code.
 | 
			
		||||
	ExpectedStatus int
 | 
			
		||||
 | 
			
		||||
	// List of keywords that MUST exist in the response body.
 | 
			
		||||
	//
 | 
			
		||||
	// Either ExpectedContent or NotExpectedContent must be set if the response body is non-empty.
 | 
			
		||||
	// Leave both fields empty if you want to ensure that the response didn't have any body (e.g. 204).
 | 
			
		||||
	ExpectedContent []string
 | 
			
		||||
 | 
			
		||||
	// List of keywords that MUST NOT exist in the response body.
 | 
			
		||||
	//
 | 
			
		||||
	// Either ExpectedContent or NotExpectedContent must be set if the response body is non-empty.
 | 
			
		||||
	// Leave both fields empty if you want to ensure that the response didn't have any body (e.g. 204).
 | 
			
		||||
	NotExpectedContent []string
 | 
			
		||||
 | 
			
		||||
	// List of hook events to check whether they were fired or not.
 | 
			
		||||
	//
 | 
			
		||||
	// You can use the wildcard "*" event key if you want to ensure
 | 
			
		||||
	// that no other hook events except those listed have been fired.
 | 
			
		||||
	//
 | 
			
		||||
	// For example:
 | 
			
		||||
	//
 | 
			
		||||
	//	map[string]int{ "*": 0 } // no hook events were fired
 | 
			
		||||
	//	map[string]int{ "*": 0, "EventA": 2 } // no hook events, except EventA were fired
 | 
			
		||||
	//	map[string]int{ "EventA": 2, "EventB": 0 } // ensures that EventA was fired exactly 2 times and EventB exactly 0 times.
 | 
			
		||||
	ExpectedEvents map[string]int
 | 
			
		||||
 | 
			
		||||
	// test hooks
 | 
			
		||||
	// ---------------------------------------------------------------
 | 
			
		||||
 | 
			
		||||
	TestAppFactory func(t testing.TB) *pbtests.TestApp
 | 
			
		||||
	BeforeTestFunc func(t testing.TB, app *pbtests.TestApp, e *core.ServeEvent)
 | 
			
		||||
	AfterTestFunc  func(t testing.TB, app *pbtests.TestApp, res *http.Response)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Test executes the test scenario.
 | 
			
		||||
//
 | 
			
		||||
// Example:
 | 
			
		||||
//
 | 
			
		||||
//	func TestListExample(t *testing.T) {
 | 
			
		||||
//	    scenario := tests.ApiScenario{
 | 
			
		||||
//	        Name:           "list example collection",
 | 
			
		||||
//	        Method:         http.MethodGet,
 | 
			
		||||
//	        URL:            "/api/collections/example/records",
 | 
			
		||||
//	        ExpectedStatus: 200,
 | 
			
		||||
//	        ExpectedContent: []string{
 | 
			
		||||
//	            `"totalItems":3`,
 | 
			
		||||
//	            `"id":"0yxhwia2amd8gec"`,
 | 
			
		||||
//	            `"id":"achvryl401bhse3"`,
 | 
			
		||||
//	            `"id":"llvuca81nly1qls"`,
 | 
			
		||||
//	        },
 | 
			
		||||
//	        ExpectedEvents: map[string]int{
 | 
			
		||||
//	            "OnRecordsListRequest": 1,
 | 
			
		||||
//	            "OnRecordEnrich":       3,
 | 
			
		||||
//	        },
 | 
			
		||||
//	    }
 | 
			
		||||
//
 | 
			
		||||
//	    scenario.Test(t)
 | 
			
		||||
//	}
 | 
			
		||||
func (scenario *ApiScenario) Test(t *testing.T) {
 | 
			
		||||
	t.Run(scenario.normalizedName(), func(t *testing.T) {
 | 
			
		||||
		scenario.test(t)
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Benchmark benchmarks the test scenario.
 | 
			
		||||
//
 | 
			
		||||
// Example:
 | 
			
		||||
//
 | 
			
		||||
//	func BenchmarkListExample(b *testing.B) {
 | 
			
		||||
//	    scenario := tests.ApiScenario{
 | 
			
		||||
//	        Name:           "list example collection",
 | 
			
		||||
//	        Method:         http.MethodGet,
 | 
			
		||||
//	        URL:            "/api/collections/example/records",
 | 
			
		||||
//	        ExpectedStatus: 200,
 | 
			
		||||
//	        ExpectedContent: []string{
 | 
			
		||||
//	            `"totalItems":3`,
 | 
			
		||||
//	            `"id":"0yxhwia2amd8gec"`,
 | 
			
		||||
//	            `"id":"achvryl401bhse3"`,
 | 
			
		||||
//	            `"id":"llvuca81nly1qls"`,
 | 
			
		||||
//	        },
 | 
			
		||||
//	        ExpectedEvents: map[string]int{
 | 
			
		||||
//	            "OnRecordsListRequest": 1,
 | 
			
		||||
//	            "OnRecordEnrich":       3,
 | 
			
		||||
//	        },
 | 
			
		||||
//	    }
 | 
			
		||||
//
 | 
			
		||||
//	    scenario.Benchmark(b)
 | 
			
		||||
//	}
 | 
			
		||||
func (scenario *ApiScenario) Benchmark(b *testing.B) {
 | 
			
		||||
	b.Run(scenario.normalizedName(), func(b *testing.B) {
 | 
			
		||||
		for i := 0; i < b.N; i++ {
 | 
			
		||||
			scenario.test(b)
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (scenario *ApiScenario) normalizedName() string {
 | 
			
		||||
	var name = scenario.Name
 | 
			
		||||
 | 
			
		||||
	if name == "" {
 | 
			
		||||
		name = fmt.Sprintf("%s:%s", scenario.Method, scenario.URL)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return name
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (scenario *ApiScenario) test(t testing.TB) {
 | 
			
		||||
	var testApp *pbtests.TestApp
 | 
			
		||||
	if scenario.TestAppFactory != nil {
 | 
			
		||||
		testApp = scenario.TestAppFactory(t)
 | 
			
		||||
		if testApp == nil {
 | 
			
		||||
			t.Fatal("TestAppFactory must return a non-nill app instance")
 | 
			
		||||
		}
 | 
			
		||||
	} else {
 | 
			
		||||
		var testAppErr error
 | 
			
		||||
		testApp, testAppErr = pbtests.NewTestApp()
 | 
			
		||||
		if testAppErr != nil {
 | 
			
		||||
			t.Fatalf("Failed to initialize the test app instance: %v", testAppErr)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	// defer testApp.Cleanup()
 | 
			
		||||
 | 
			
		||||
	baseRouter, err := apis.NewRouter(testApp)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatal(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// manually trigger the serve event to ensure that custom app routes and middlewares are registered
 | 
			
		||||
	serveEvent := new(core.ServeEvent)
 | 
			
		||||
	serveEvent.App = testApp
 | 
			
		||||
	serveEvent.Router = baseRouter
 | 
			
		||||
 | 
			
		||||
	serveErr := testApp.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
 | 
			
		||||
		if scenario.BeforeTestFunc != nil {
 | 
			
		||||
			scenario.BeforeTestFunc(t, testApp, e)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// reset the event counters in case a hook was triggered from a before func (eg. db save)
 | 
			
		||||
		testApp.ResetEventCalls()
 | 
			
		||||
 | 
			
		||||
		// add middleware to timeout long-running requests (eg. keep-alive routes)
 | 
			
		||||
		e.Router.Bind(&hook.Handler[*core.RequestEvent]{
 | 
			
		||||
			Func: func(re *core.RequestEvent) error {
 | 
			
		||||
				slowTimer := time.AfterFunc(3*time.Second, func() {
 | 
			
		||||
					t.Logf("[WARN] Long running test %q", scenario.Name)
 | 
			
		||||
				})
 | 
			
		||||
				defer slowTimer.Stop()
 | 
			
		||||
 | 
			
		||||
				if scenario.Timeout > 0 {
 | 
			
		||||
					ctx, cancelFunc := context.WithTimeout(re.Request.Context(), scenario.Timeout)
 | 
			
		||||
					defer cancelFunc()
 | 
			
		||||
					re.Request = re.Request.Clone(ctx)
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				return re.Next()
 | 
			
		||||
			},
 | 
			
		||||
			Priority: -9999,
 | 
			
		||||
		})
 | 
			
		||||
 | 
			
		||||
		recorder := httptest.NewRecorder()
 | 
			
		||||
 | 
			
		||||
		req := httptest.NewRequest(scenario.Method, scenario.URL, scenario.Body)
 | 
			
		||||
 | 
			
		||||
		// set default header
 | 
			
		||||
		req.Header.Set("content-type", "application/json")
 | 
			
		||||
 | 
			
		||||
		// set scenario headers
 | 
			
		||||
		for k, v := range scenario.Headers {
 | 
			
		||||
			req.Header.Set(k, v)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// execute request
 | 
			
		||||
		mux, err := e.Router.BuildMux()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to build router mux: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
		mux.ServeHTTP(recorder, req)
 | 
			
		||||
 | 
			
		||||
		res := recorder.Result()
 | 
			
		||||
 | 
			
		||||
		if res.StatusCode != scenario.ExpectedStatus {
 | 
			
		||||
			t.Errorf("Expected status code %d, got %d", scenario.ExpectedStatus, res.StatusCode)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if scenario.Delay > 0 {
 | 
			
		||||
			time.Sleep(scenario.Delay)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if len(scenario.ExpectedContent) == 0 && len(scenario.NotExpectedContent) == 0 {
 | 
			
		||||
			if len(recorder.Body.Bytes()) != 0 {
 | 
			
		||||
				t.Errorf("Expected empty body, got \n%v", recorder.Body.String())
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			// normalize json response format
 | 
			
		||||
			buffer := new(bytes.Buffer)
 | 
			
		||||
			err := json.Compact(buffer, recorder.Body.Bytes())
 | 
			
		||||
			var normalizedBody string
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				// not a json...
 | 
			
		||||
				normalizedBody = recorder.Body.String()
 | 
			
		||||
			} else {
 | 
			
		||||
				normalizedBody = buffer.String()
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			for _, item := range scenario.ExpectedContent {
 | 
			
		||||
				if !strings.Contains(normalizedBody, item) {
 | 
			
		||||
					t.Errorf("Cannot find %v in response body \n%v", item, normalizedBody)
 | 
			
		||||
					break
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			for _, item := range scenario.NotExpectedContent {
 | 
			
		||||
				if strings.Contains(normalizedBody, item) {
 | 
			
		||||
					t.Errorf("Didn't expect %v in response body \n%v", item, normalizedBody)
 | 
			
		||||
					break
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		remainingEvents := maps.Clone(testApp.EventCalls)
 | 
			
		||||
 | 
			
		||||
		var noOtherEventsShouldRemain bool
 | 
			
		||||
		for event, expectedNum := range scenario.ExpectedEvents {
 | 
			
		||||
			if event == "*" && expectedNum <= 0 {
 | 
			
		||||
				noOtherEventsShouldRemain = true
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			actualNum := remainingEvents[event]
 | 
			
		||||
			if actualNum != expectedNum {
 | 
			
		||||
				t.Errorf("Expected event %s to be called %d, got %d", event, expectedNum, actualNum)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			delete(remainingEvents, event)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if noOtherEventsShouldRemain && len(remainingEvents) > 0 {
 | 
			
		||||
			t.Errorf("Missing expected remaining events:\n%#v\nAll triggered app events are:\n%#v", remainingEvents, testApp.EventCalls)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if scenario.AfterTestFunc != nil {
 | 
			
		||||
			scenario.AfterTestFunc(t, testApp, res)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return nil
 | 
			
		||||
	})
 | 
			
		||||
	if serveErr != nil {
 | 
			
		||||
		t.Fatalf("Failed to trigger app serve hook: %v", serveErr)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@@ -6,9 +6,12 @@ package tests
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"beszel/internal/hub"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	"github.com/pocketbase/pocketbase/core"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/tests"
 | 
			
		||||
	"github.com/stretchr/testify/assert"
 | 
			
		||||
 | 
			
		||||
	_ "github.com/pocketbase/pocketbase/migrations"
 | 
			
		||||
)
 | 
			
		||||
@@ -86,3 +89,10 @@ func CreateRecord(app core.App, collectionName string, fields map[string]any) (*
 | 
			
		||||
 | 
			
		||||
	return record, app.Save(record)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func ClearCollection(t testing.TB, app core.App, collectionName string) error {
 | 
			
		||||
	_, err := app.DB().NewQuery(fmt.Sprintf("DELETE from %s", collectionName)).Execute()
 | 
			
		||||
	recordCount, err := app.CountRecords(collectionName)
 | 
			
		||||
	assert.EqualValues(t, recordCount, 0, "should have 0 records after clearing")
 | 
			
		||||
	return err
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,7 @@ import (
 | 
			
		||||
	"log"
 | 
			
		||||
	"net/http"
 | 
			
		||||
 | 
			
		||||
	"github.com/pocketbase/dbx"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/core"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@@ -13,18 +14,6 @@ type UserManager struct {
 | 
			
		||||
	app core.App
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type UserSettings struct {
 | 
			
		||||
	ChartTime            string   `json:"chartTime"`
 | 
			
		||||
	NotificationEmails   []string `json:"emails"`
 | 
			
		||||
	NotificationWebhooks []string `json:"webhooks"`
 | 
			
		||||
	// UnitTemp             uint8    `json:"unitTemp"` // 0 for Celsius, 1 for Fahrenheit
 | 
			
		||||
	// UnitNet              uint8    `json:"unitNet"`  // 0 for bytes, 1 for bits
 | 
			
		||||
	// UnitDisk             uint8    `json:"unitDisk"` // 0 for bytes, 1 for bits
 | 
			
		||||
 | 
			
		||||
	// New field for alert history retention (e.g., "1m", "3m", "6m", "1y")
 | 
			
		||||
	AlertHistoryRetention string `json:"alertHistoryRetention,omitempty"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func NewUserManager(app core.App) *UserManager {
 | 
			
		||||
	return &UserManager{
 | 
			
		||||
		app: app,
 | 
			
		||||
@@ -42,29 +31,26 @@ func (um *UserManager) InitializeUserRole(e *core.RecordEvent) error {
 | 
			
		||||
// Initialize user settings with defaults if not set
 | 
			
		||||
func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
 | 
			
		||||
	record := e.Record
 | 
			
		||||
	// intialize settings with defaults
 | 
			
		||||
	settings := UserSettings{
 | 
			
		||||
		ChartTime:            "1h",
 | 
			
		||||
		NotificationEmails:   []string{},
 | 
			
		||||
		NotificationWebhooks: []string{},
 | 
			
		||||
	// intialize settings with defaults (zero values can be ignored)
 | 
			
		||||
	settings := struct {
 | 
			
		||||
		ChartTime string   `json:"chartTime"`
 | 
			
		||||
		Emails    []string `json:"emails"`
 | 
			
		||||
	}{
 | 
			
		||||
		ChartTime: "1h",
 | 
			
		||||
	}
 | 
			
		||||
	record.UnmarshalJSONField("settings", &settings)
 | 
			
		||||
	if len(settings.NotificationEmails) == 0 {
 | 
			
		||||
		// get user email from auth record
 | 
			
		||||
		if errs := um.app.ExpandRecord(record, []string{"user"}, nil); len(errs) == 0 {
 | 
			
		||||
			// app.Logger().Error("failed to expand user relation", "errs", errs)
 | 
			
		||||
			if user := record.ExpandedOne("user"); user != nil {
 | 
			
		||||
				settings.NotificationEmails = []string{user.GetString("email")}
 | 
			
		||||
			} else {
 | 
			
		||||
				log.Println("Failed to get user email from auth record")
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			log.Println("failed to expand user relation", "errs", errs)
 | 
			
		||||
		}
 | 
			
		||||
	// get user email from auth record
 | 
			
		||||
	var user struct {
 | 
			
		||||
		Email string `db:"email"`
 | 
			
		||||
	}
 | 
			
		||||
	// if len(settings.NotificationWebhooks) == 0 {
 | 
			
		||||
	// 	settings.NotificationWebhooks = []string{""}
 | 
			
		||||
	// }
 | 
			
		||||
	err := e.App.DB().NewQuery("SELECT email FROM users WHERE id = {:id}").Bind(dbx.Params{
 | 
			
		||||
		"id": record.GetString("user"),
 | 
			
		||||
	}).One(&user)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Println("failed to get user email", "err", err)
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	settings.Emails = []string{user.Email}
 | 
			
		||||
	record.Set("settings", settings)
 | 
			
		||||
	return e.Next()
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										2541
									
								
								beszel/site/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2541
									
								
								beszel/site/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -1,7 +1,7 @@
 | 
			
		||||
{
 | 
			
		||||
	"name": "beszel",
 | 
			
		||||
	"private": true,
 | 
			
		||||
	"version": "0.12.1",
 | 
			
		||||
	"version": "0.12.5",
 | 
			
		||||
	"type": "module",
 | 
			
		||||
	"scripts": {
 | 
			
		||||
		"dev": "vite",
 | 
			
		||||
@@ -13,25 +13,25 @@
 | 
			
		||||
	"dependencies": {
 | 
			
		||||
		"@henrygd/queue": "^1.0.7",
 | 
			
		||||
		"@henrygd/semaphore": "^0.0.2",
 | 
			
		||||
		"@lingui/detect-locale": "^5.3.3",
 | 
			
		||||
		"@lingui/macro": "^5.3.3",
 | 
			
		||||
		"@lingui/react": "^5.3.3",
 | 
			
		||||
		"@lingui/detect-locale": "^5.4.1",
 | 
			
		||||
		"@lingui/macro": "^5.4.1",
 | 
			
		||||
		"@lingui/react": "^5.4.1",
 | 
			
		||||
		"@nanostores/react": "^0.7.3",
 | 
			
		||||
		"@nanostores/router": "^0.11.0",
 | 
			
		||||
		"@radix-ui/react-alert-dialog": "^1.1.14",
 | 
			
		||||
		"@radix-ui/react-checkbox": "^1.3.2",
 | 
			
		||||
		"@radix-ui/react-dialog": "^1.1.14",
 | 
			
		||||
		"@radix-ui/react-alert-dialog": "^1.1.15",
 | 
			
		||||
		"@radix-ui/react-checkbox": "^1.3.3",
 | 
			
		||||
		"@radix-ui/react-dialog": "^1.1.15",
 | 
			
		||||
		"@radix-ui/react-direction": "^1.1.1",
 | 
			
		||||
		"@radix-ui/react-dropdown-menu": "^2.1.15",
 | 
			
		||||
		"@radix-ui/react-dropdown-menu": "^2.1.16",
 | 
			
		||||
		"@radix-ui/react-label": "^2.1.7",
 | 
			
		||||
		"@radix-ui/react-select": "^2.2.5",
 | 
			
		||||
		"@radix-ui/react-select": "^2.2.6",
 | 
			
		||||
		"@radix-ui/react-separator": "^1.1.7",
 | 
			
		||||
		"@radix-ui/react-slider": "^1.3.5",
 | 
			
		||||
		"@radix-ui/react-slider": "^1.3.6",
 | 
			
		||||
		"@radix-ui/react-slot": "^1.2.3",
 | 
			
		||||
		"@radix-ui/react-switch": "^1.2.5",
 | 
			
		||||
		"@radix-ui/react-tabs": "^1.1.12",
 | 
			
		||||
		"@radix-ui/react-toast": "^1.2.14",
 | 
			
		||||
		"@radix-ui/react-tooltip": "^1.2.7",
 | 
			
		||||
		"@radix-ui/react-switch": "^1.2.6",
 | 
			
		||||
		"@radix-ui/react-tabs": "^1.1.13",
 | 
			
		||||
		"@radix-ui/react-toast": "^1.2.15",
 | 
			
		||||
		"@radix-ui/react-tooltip": "^1.2.8",
 | 
			
		||||
		"@tanstack/react-table": "^8.21.3",
 | 
			
		||||
		"class-variance-authority": "^0.7.1",
 | 
			
		||||
		"clsx": "^2.1.1",
 | 
			
		||||
@@ -40,28 +40,26 @@
 | 
			
		||||
		"lucide-react": "^0.452.0",
 | 
			
		||||
		"nanostores": "^0.11.4",
 | 
			
		||||
		"pocketbase": "^0.26.2",
 | 
			
		||||
		"react": "^18.3.1",
 | 
			
		||||
		"react-dom": "^18.3.1",
 | 
			
		||||
		"react": "^19.1.1",
 | 
			
		||||
		"react-dom": "^19.1.1",
 | 
			
		||||
		"recharts": "^2.15.4",
 | 
			
		||||
		"tailwind-merge": "^2.6.0",
 | 
			
		||||
		"tailwindcss-animate": "^1.0.7",
 | 
			
		||||
		"tailwind-merge": "^3.3.1",
 | 
			
		||||
		"valibot": "^0.42.1"
 | 
			
		||||
	},
 | 
			
		||||
	"devDependencies": {
 | 
			
		||||
		"@lingui/cli": "^5.3.3",
 | 
			
		||||
		"@lingui/swc-plugin": "^5.5.2",
 | 
			
		||||
		"@lingui/vite-plugin": "^5.3.3",
 | 
			
		||||
		"@lingui/cli": "^5.4.1",
 | 
			
		||||
		"@lingui/swc-plugin": "^5.6.1",
 | 
			
		||||
		"@lingui/vite-plugin": "^5.4.1",
 | 
			
		||||
		"@tailwindcss/container-queries": "^0.1.1",
 | 
			
		||||
		"@types/bun": "^1.2.19",
 | 
			
		||||
		"@types/react": "^18.3.23",
 | 
			
		||||
		"@types/react-dom": "^18.3.7",
 | 
			
		||||
		"@vitejs/plugin-react-swc": "^3.11.0",
 | 
			
		||||
		"autoprefixer": "^10.4.21",
 | 
			
		||||
		"postcss": "^8.5.6",
 | 
			
		||||
		"tailwindcss": "^3.4.17",
 | 
			
		||||
		"tailwindcss-rtl": "^0.9.0",
 | 
			
		||||
		"typescript": "^5.8.3",
 | 
			
		||||
		"vite": "^6.3.5"
 | 
			
		||||
		"@tailwindcss/vite": "^4.1.12",
 | 
			
		||||
		"@types/bun": "^1.2.20",
 | 
			
		||||
		"@types/react": "^19.1.11",
 | 
			
		||||
		"@types/react-dom": "^19.1.7",
 | 
			
		||||
		"@vitejs/plugin-react-swc": "^4.0.1",
 | 
			
		||||
		"tailwindcss": "^4.1.12",
 | 
			
		||||
		"tw-animate-css": "^1.3.7",
 | 
			
		||||
		"typescript": "^5.9.2",
 | 
			
		||||
		"vite": "^7.1.3"
 | 
			
		||||
	},
 | 
			
		||||
	"overrides": {
 | 
			
		||||
		"@nanostores/router": {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +0,0 @@
 | 
			
		||||
export default {
 | 
			
		||||
	plugins: {
 | 
			
		||||
		tailwindcss: {},
 | 
			
		||||
		autoprefixer: {},
 | 
			
		||||
	},
 | 
			
		||||
}
 | 
			
		||||
@@ -20,6 +20,7 @@ import { ChevronDownIcon, ExternalLinkIcon, PlusIcon } from "lucide-react"
 | 
			
		||||
import { memo, useEffect, useRef, useState } from "react"
 | 
			
		||||
import { $router, basePath, Link, navigate } from "./router"
 | 
			
		||||
import { SystemRecord } from "@/types"
 | 
			
		||||
import { SystemStatus } from "@/lib/enums"
 | 
			
		||||
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "./ui/icons"
 | 
			
		||||
import { InputCopy } from "./ui/input-copy"
 | 
			
		||||
import { getPagePath } from "@nanostores/router"
 | 
			
		||||
@@ -105,7 +106,7 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
 | 
			
		||||
		try {
 | 
			
		||||
			setOpen(false)
 | 
			
		||||
			if (system) {
 | 
			
		||||
				await pb.collection("systems").update(system.id, { ...data, status: "pending" })
 | 
			
		||||
				await pb.collection("systems").update(system.id, { ...data, status: SystemStatus.Pending })
 | 
			
		||||
			} else {
 | 
			
		||||
				const createdSystem = await pb.collection("systems").create(data)
 | 
			
		||||
				await pb.collection("fingerprints").create({
 | 
			
		||||
@@ -165,9 +166,7 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
 | 
			
		||||
						<Trans>
 | 
			
		||||
							Copy the installation command for the agent below, or register agents automatically with a{" "}
 | 
			
		||||
							<Link
 | 
			
		||||
								onClick={() => {
 | 
			
		||||
									setOpen(false)
 | 
			
		||||
								}}
 | 
			
		||||
								onClick={() => setOpen(false)}
 | 
			
		||||
								href={getPagePath($router, "settings", { name: "tokens" })}
 | 
			
		||||
								className="link"
 | 
			
		||||
							>
 | 
			
		||||
@@ -274,7 +273,7 @@ interface CopyButtonProps {
 | 
			
		||||
	text: string
 | 
			
		||||
	onClick: () => void
 | 
			
		||||
	dropdownItems: DropdownItem[]
 | 
			
		||||
	icon?: React.ReactElement
 | 
			
		||||
	icon?: React.ReactElement<any>
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const CopyButton = memo((props: CopyButtonProps) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,8 @@ import { ColumnDef } from "@tanstack/react-table"
 | 
			
		||||
import { AlertsHistoryRecord } from "@/types"
 | 
			
		||||
import { Button } from "@/components/ui/button"
 | 
			
		||||
import { Badge } from "@/components/ui/badge"
 | 
			
		||||
import { alertInfo, formatShortDate, toFixedFloat, formatDuration, cn } from "@/lib/utils"
 | 
			
		||||
import { formatShortDate, toFixedFloat, formatDuration, cn } from "@/lib/utils"
 | 
			
		||||
import { alertInfo } from "@/lib/alerts"
 | 
			
		||||
import { Trans } from "@lingui/react/macro"
 | 
			
		||||
import { t } from "@lingui/core/macro"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,153 +1,36 @@
 | 
			
		||||
import { t } from "@lingui/core/macro"
 | 
			
		||||
import { Trans } from "@lingui/react/macro"
 | 
			
		||||
import { memo, useMemo, useState } from "react"
 | 
			
		||||
import { useStore } from "@nanostores/react"
 | 
			
		||||
import { $alerts } from "@/lib/stores"
 | 
			
		||||
import {
 | 
			
		||||
	Dialog,
 | 
			
		||||
	DialogTrigger,
 | 
			
		||||
	DialogContent,
 | 
			
		||||
	DialogDescription,
 | 
			
		||||
	DialogHeader,
 | 
			
		||||
	DialogTitle,
 | 
			
		||||
} from "@/components/ui/dialog"
 | 
			
		||||
import { BellIcon, GlobeIcon, ServerIcon } from "lucide-react"
 | 
			
		||||
import { alertInfo, cn } from "@/lib/utils"
 | 
			
		||||
import { BellIcon } from "lucide-react"
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
import { Button } from "@/components/ui/button"
 | 
			
		||||
import { AlertRecord, SystemRecord } from "@/types"
 | 
			
		||||
import { $router, Link } from "../router"
 | 
			
		||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
 | 
			
		||||
import { Checkbox } from "../ui/checkbox"
 | 
			
		||||
import { SystemAlert, SystemAlertGlobal } from "./alerts-system"
 | 
			
		||||
import { getPagePath } from "@nanostores/router"
 | 
			
		||||
import { SystemRecord } from "@/types"
 | 
			
		||||
import { AlertDialogContent } from "./alerts-sheet"
 | 
			
		||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
 | 
			
		||||
 | 
			
		||||
export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
 | 
			
		||||
	const alerts = useStore($alerts)
 | 
			
		||||
	const [opened, setOpened] = useState(false)
 | 
			
		||||
	const alerts = useStore($alerts)
 | 
			
		||||
 | 
			
		||||
	const hasAlert = alerts.some((alert) => alert.system === system.id)
 | 
			
		||||
 | 
			
		||||
	const hasSystemAlert = alerts[system.id]?.size > 0
 | 
			
		||||
	return useMemo(
 | 
			
		||||
		() => (
 | 
			
		||||
			<Dialog>
 | 
			
		||||
				<DialogTrigger asChild>
 | 
			
		||||
			<Sheet>
 | 
			
		||||
				<SheetTrigger asChild>
 | 
			
		||||
					<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
 | 
			
		||||
						<BellIcon
 | 
			
		||||
							className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
 | 
			
		||||
								"fill-primary": hasAlert,
 | 
			
		||||
								"fill-primary": hasSystemAlert,
 | 
			
		||||
							})}
 | 
			
		||||
						/>
 | 
			
		||||
					</Button>
 | 
			
		||||
				</DialogTrigger>
 | 
			
		||||
				<DialogContent className="max-h-full sm:max-h-[95svh] overflow-auto max-w-[37rem]">
 | 
			
		||||
				</SheetTrigger>
 | 
			
		||||
				<SheetContent className="max-h-full overflow-auto w-145 !max-w-full p-4 sm:p-6">
 | 
			
		||||
					{opened && <AlertDialogContent system={system} />}
 | 
			
		||||
				</DialogContent>
 | 
			
		||||
			</Dialog>
 | 
			
		||||
				</SheetContent>
 | 
			
		||||
			</Sheet>
 | 
			
		||||
		),
 | 
			
		||||
		[opened, hasAlert]
 | 
			
		||||
		[opened, hasSystemAlert]
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	// return useMemo(
 | 
			
		||||
	// 	() => (
 | 
			
		||||
	// 		<Sheet>
 | 
			
		||||
	// 			<SheetTrigger asChild>
 | 
			
		||||
	// 				<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
 | 
			
		||||
	// 					<BellIcon
 | 
			
		||||
	// 						className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
 | 
			
		||||
	// 							"fill-primary": hasAlert,
 | 
			
		||||
	// 						})}
 | 
			
		||||
	// 					/>
 | 
			
		||||
	// 				</Button>
 | 
			
		||||
	// 			</SheetTrigger>
 | 
			
		||||
	// 			<SheetContent className="max-h-full overflow-auto w-[35em] p-4 sm:p-5">
 | 
			
		||||
	// 				{opened && <AlertDialogContent system={system} />}
 | 
			
		||||
	// 			</SheetContent>
 | 
			
		||||
	// 		</Sheet>
 | 
			
		||||
	// 	),
 | 
			
		||||
	// 	[opened, hasAlert]
 | 
			
		||||
	// )
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
function AlertDialogContent({ system }: { system: SystemRecord }) {
 | 
			
		||||
	const alerts = useStore($alerts)
 | 
			
		||||
	const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
 | 
			
		||||
 | 
			
		||||
	// alertsSignature changes only when alerts for this system change
 | 
			
		||||
	let alertsSignature = ""
 | 
			
		||||
	const systemAlerts = alerts.filter((alert) => {
 | 
			
		||||
		if (alert.system === system.id) {
 | 
			
		||||
			alertsSignature += alert.name + alert.min + alert.value
 | 
			
		||||
			return true
 | 
			
		||||
		}
 | 
			
		||||
		return false
 | 
			
		||||
	}) as AlertRecord[]
 | 
			
		||||
 | 
			
		||||
	return useMemo(() => {
 | 
			
		||||
		// console.log("render modal", system.name, alertsSignature)
 | 
			
		||||
		const data = Object.keys(alertInfo).map((name) => {
 | 
			
		||||
			const alert = alertInfo[name as keyof typeof alertInfo]
 | 
			
		||||
			return {
 | 
			
		||||
				name: name as keyof typeof alertInfo,
 | 
			
		||||
				alert,
 | 
			
		||||
				system,
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
 | 
			
		||||
		return (
 | 
			
		||||
			<>
 | 
			
		||||
				<DialogHeader>
 | 
			
		||||
					<DialogTitle className="text-xl">
 | 
			
		||||
						<Trans>Alerts</Trans>
 | 
			
		||||
					</DialogTitle>
 | 
			
		||||
					<DialogDescription>
 | 
			
		||||
						<Trans>
 | 
			
		||||
							See{" "}
 | 
			
		||||
							<Link href={getPagePath($router, "settings", { name: "notifications" })} className="link">
 | 
			
		||||
								notification settings
 | 
			
		||||
							</Link>{" "}
 | 
			
		||||
							to configure how you receive alerts.
 | 
			
		||||
						</Trans>
 | 
			
		||||
					</DialogDescription>
 | 
			
		||||
				</DialogHeader>
 | 
			
		||||
				<Tabs defaultValue="system">
 | 
			
		||||
					<TabsList className="mb-1 -mt-0.5">
 | 
			
		||||
						<TabsTrigger value="system">
 | 
			
		||||
							<ServerIcon className="me-2 h-3.5 w-3.5" />
 | 
			
		||||
							{system.name}
 | 
			
		||||
						</TabsTrigger>
 | 
			
		||||
						<TabsTrigger value="global">
 | 
			
		||||
							<GlobeIcon className="me-1.5 h-3.5 w-3.5" />
 | 
			
		||||
							<Trans>All Systems</Trans>
 | 
			
		||||
						</TabsTrigger>
 | 
			
		||||
					</TabsList>
 | 
			
		||||
					<TabsContent value="system">
 | 
			
		||||
						<div className="grid gap-3">
 | 
			
		||||
							{data.map((d) => (
 | 
			
		||||
								<SystemAlert key={d.name} system={system} data={d} systemAlerts={systemAlerts} />
 | 
			
		||||
							))}
 | 
			
		||||
						</div>
 | 
			
		||||
					</TabsContent>
 | 
			
		||||
					<TabsContent value="global">
 | 
			
		||||
						<label
 | 
			
		||||
							htmlFor="ovw"
 | 
			
		||||
							className="mb-3 flex gap-2 items-center justify-center cursor-pointer border rounded-sm py-3 px-4 border-destructive text-destructive font-semibold text-sm"
 | 
			
		||||
						>
 | 
			
		||||
							<Checkbox
 | 
			
		||||
								id="ovw"
 | 
			
		||||
								className="text-destructive border-destructive data-[state=checked]:bg-destructive"
 | 
			
		||||
								checked={overwriteExisting}
 | 
			
		||||
								onCheckedChange={setOverwriteExisting}
 | 
			
		||||
							/>
 | 
			
		||||
							<Trans>Overwrite existing alerts</Trans>
 | 
			
		||||
						</label>
 | 
			
		||||
						<div className="grid gap-3">
 | 
			
		||||
							{data.map((d) => (
 | 
			
		||||
								<SystemAlertGlobal key={d.name} data={d} overwrite={overwriteExisting} />
 | 
			
		||||
							))}
 | 
			
		||||
						</div>
 | 
			
		||||
					</TabsContent>
 | 
			
		||||
				</Tabs>
 | 
			
		||||
			</>
 | 
			
		||||
		)
 | 
			
		||||
	}, [alertsSignature, overwriteExisting])
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										298
									
								
								beszel/site/src/components/alerts/alerts-sheet.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										298
									
								
								beszel/site/src/components/alerts/alerts-sheet.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,298 @@
 | 
			
		||||
import { t } from "@lingui/core/macro"
 | 
			
		||||
import { Trans, Plural } from "@lingui/react/macro"
 | 
			
		||||
import { $alerts, $systems, pb } from "@/lib/stores"
 | 
			
		||||
import { cn, debounce } from "@/lib/utils"
 | 
			
		||||
import { alertInfo } from "@/lib/alerts"
 | 
			
		||||
import { Switch } from "@/components/ui/switch"
 | 
			
		||||
import { AlertInfo, AlertRecord, SystemRecord } from "@/types"
 | 
			
		||||
import { lazy, memo, Suspense, useMemo, useState } from "react"
 | 
			
		||||
import { toast } from "@/components/ui/use-toast"
 | 
			
		||||
import { useStore } from "@nanostores/react"
 | 
			
		||||
import { getPagePath } from "@nanostores/router"
 | 
			
		||||
import { Checkbox } from "@/components/ui/checkbox"
 | 
			
		||||
import { DialogTitle, DialogDescription } from "@/components/ui/dialog"
 | 
			
		||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
 | 
			
		||||
import { ServerIcon, GlobeIcon } from "lucide-react"
 | 
			
		||||
import { $router, Link } from "@/components/router"
 | 
			
		||||
import { DialogHeader } from "@/components/ui/dialog"
 | 
			
		||||
 | 
			
		||||
const Slider = lazy(() => import("@/components/ui/slider"))
 | 
			
		||||
 | 
			
		||||
const endpoint = "/api/beszel/user-alerts"
 | 
			
		||||
 | 
			
		||||
const alertDebounce = 100
 | 
			
		||||
 | 
			
		||||
const alertKeys = Object.keys(alertInfo) as (keyof typeof alertInfo)[]
 | 
			
		||||
 | 
			
		||||
const failedUpdateToast = (error: unknown) => {
 | 
			
		||||
	console.error(error)
 | 
			
		||||
	toast({
 | 
			
		||||
		title: t`Failed to update alert`,
 | 
			
		||||
		description: t`Please check logs for more details.`,
 | 
			
		||||
		variant: "destructive",
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** Create or update alerts for a given name and systems */
 | 
			
		||||
const upsertAlerts = debounce(
 | 
			
		||||
	async ({ name, value, min, systems }: { name: string; value: number; min: number; systems: string[] }) => {
 | 
			
		||||
		try {
 | 
			
		||||
			await pb.send<{ success: boolean }>(endpoint, {
 | 
			
		||||
				method: "POST",
 | 
			
		||||
				// overwrite is always true because we've done filtering client side
 | 
			
		||||
				body: { name, value, min, systems, overwrite: true },
 | 
			
		||||
			})
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			failedUpdateToast(error)
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
	alertDebounce
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
/** Delete alerts for a given name and systems */
 | 
			
		||||
const deleteAlerts = debounce(async ({ name, systems }: { name: string; systems: string[] }) => {
 | 
			
		||||
	try {
 | 
			
		||||
		await pb.send<{ success: boolean }>(endpoint, {
 | 
			
		||||
			method: "DELETE",
 | 
			
		||||
			body: { name, systems },
 | 
			
		||||
		})
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		failedUpdateToast(error)
 | 
			
		||||
	}
 | 
			
		||||
}, alertDebounce)
 | 
			
		||||
 | 
			
		||||
export const AlertDialogContent = memo(function AlertDialogContent({ system }: { system: SystemRecord }) {
 | 
			
		||||
	const alerts = useStore($alerts)
 | 
			
		||||
	const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
 | 
			
		||||
	const [currentTab, setCurrentTab] = useState("system")
 | 
			
		||||
 | 
			
		||||
	const systemAlerts = alerts[system.id] ?? new Map()
 | 
			
		||||
 | 
			
		||||
	// We need to keep a copy of alerts when we switch to global tab. If we always compare to
 | 
			
		||||
	// current alerts, it will only be updated when first checked, then won't be updated because
 | 
			
		||||
	// after that it exists.
 | 
			
		||||
	const alertsWhenGlobalSelected = useMemo(() => {
 | 
			
		||||
		return currentTab === "global" ? structuredClone(alerts) : alerts
 | 
			
		||||
	}, [currentTab])
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<>
 | 
			
		||||
			<DialogHeader>
 | 
			
		||||
				<DialogTitle className="text-xl">
 | 
			
		||||
					<Trans>Alerts</Trans>
 | 
			
		||||
				</DialogTitle>
 | 
			
		||||
				<DialogDescription>
 | 
			
		||||
					<Trans>
 | 
			
		||||
						See{" "}
 | 
			
		||||
						<Link href={getPagePath($router, "settings", { name: "notifications" })} className="link">
 | 
			
		||||
							notification settings
 | 
			
		||||
						</Link>{" "}
 | 
			
		||||
						to configure how you receive alerts.
 | 
			
		||||
					</Trans>
 | 
			
		||||
				</DialogDescription>
 | 
			
		||||
			</DialogHeader>
 | 
			
		||||
			<Tabs defaultValue="system" onValueChange={setCurrentTab}>
 | 
			
		||||
				<TabsList className="mb-1 -mt-0.5">
 | 
			
		||||
					<TabsTrigger value="system">
 | 
			
		||||
						<ServerIcon className="me-2 h-3.5 w-3.5" />
 | 
			
		||||
						{system.name}
 | 
			
		||||
					</TabsTrigger>
 | 
			
		||||
					<TabsTrigger value="global">
 | 
			
		||||
						<GlobeIcon className="me-1.5 h-3.5 w-3.5" />
 | 
			
		||||
						<Trans>All Systems</Trans>
 | 
			
		||||
					</TabsTrigger>
 | 
			
		||||
				</TabsList>
 | 
			
		||||
				<TabsContent value="system">
 | 
			
		||||
					<div className="grid gap-3">
 | 
			
		||||
						{alertKeys.map((name) => (
 | 
			
		||||
							<AlertContent
 | 
			
		||||
								key={name}
 | 
			
		||||
								alertKey={name}
 | 
			
		||||
								data={alertInfo[name as keyof typeof alertInfo]}
 | 
			
		||||
								alert={systemAlerts.get(name)}
 | 
			
		||||
								system={system}
 | 
			
		||||
							/>
 | 
			
		||||
						))}
 | 
			
		||||
					</div>
 | 
			
		||||
				</TabsContent>
 | 
			
		||||
				<TabsContent value="global">
 | 
			
		||||
					<label
 | 
			
		||||
						htmlFor="ovw"
 | 
			
		||||
						className="mb-3 flex gap-2 items-center justify-center cursor-pointer border rounded-sm py-3 px-4 border-destructive text-destructive font-semibold text-sm"
 | 
			
		||||
					>
 | 
			
		||||
						<Checkbox
 | 
			
		||||
							id="ovw"
 | 
			
		||||
							className="text-destructive border-destructive data-[state=checked]:bg-destructive"
 | 
			
		||||
							checked={overwriteExisting}
 | 
			
		||||
							onCheckedChange={setOverwriteExisting}
 | 
			
		||||
						/>
 | 
			
		||||
						<Trans>Overwrite existing alerts</Trans>
 | 
			
		||||
					</label>
 | 
			
		||||
					<div className="grid gap-3">
 | 
			
		||||
						{alertKeys.map((name) => (
 | 
			
		||||
							<AlertContent
 | 
			
		||||
								key={name}
 | 
			
		||||
								alertKey={name}
 | 
			
		||||
								system={system}
 | 
			
		||||
								alert={systemAlerts.get(name)}
 | 
			
		||||
								data={alertInfo[name as keyof typeof alertInfo]}
 | 
			
		||||
								global={true}
 | 
			
		||||
								overwriteExisting={!!overwriteExisting}
 | 
			
		||||
								initialAlertsState={alertsWhenGlobalSelected}
 | 
			
		||||
							/>
 | 
			
		||||
						))}
 | 
			
		||||
					</div>
 | 
			
		||||
				</TabsContent>
 | 
			
		||||
			</Tabs>
 | 
			
		||||
		</>
 | 
			
		||||
	)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
export function AlertContent({
 | 
			
		||||
	alertKey,
 | 
			
		||||
	data: alertData,
 | 
			
		||||
	system,
 | 
			
		||||
	alert,
 | 
			
		||||
	global = false,
 | 
			
		||||
	overwriteExisting = false,
 | 
			
		||||
	initialAlertsState = {},
 | 
			
		||||
}: {
 | 
			
		||||
	alertKey: string
 | 
			
		||||
	data: AlertInfo
 | 
			
		||||
	system: SystemRecord
 | 
			
		||||
	alert?: AlertRecord
 | 
			
		||||
	global?: boolean
 | 
			
		||||
	overwriteExisting?: boolean
 | 
			
		||||
	initialAlertsState?: Record<string, Map<string, AlertRecord>>
 | 
			
		||||
}) {
 | 
			
		||||
	const { name } = alertData
 | 
			
		||||
 | 
			
		||||
	const singleDescription = alertData.singleDesc?.()
 | 
			
		||||
 | 
			
		||||
	const [checked, setChecked] = useState(global ? false : !!alert)
 | 
			
		||||
	const [min, setMin] = useState(alert?.min || 10)
 | 
			
		||||
	const [value, setValue] = useState(alert?.value || (singleDescription ? 0 : alertData.start ?? 80))
 | 
			
		||||
 | 
			
		||||
	const Icon = alertData.icon
 | 
			
		||||
 | 
			
		||||
	/** Get system ids to update */
 | 
			
		||||
	function getSystemIds(): string[] {
 | 
			
		||||
		// if not global, update only the current system
 | 
			
		||||
		if (!global) {
 | 
			
		||||
			return [system.id]
 | 
			
		||||
		}
 | 
			
		||||
		// if global, update all systems when overwriteExisting is true
 | 
			
		||||
		// update only systems without an existing alert when overwriteExisting is false
 | 
			
		||||
		const allSystems = $systems.get()
 | 
			
		||||
		const systemIds: string[] = []
 | 
			
		||||
		for (const system of allSystems) {
 | 
			
		||||
			if (overwriteExisting || !initialAlertsState[system.id]?.has(alertKey)) {
 | 
			
		||||
				systemIds.push(system.id)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		return systemIds
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	function sendUpsert(min: number, value: number) {
 | 
			
		||||
		const systems = getSystemIds()
 | 
			
		||||
		systems.length &&
 | 
			
		||||
			upsertAlerts({
 | 
			
		||||
				name: alertKey,
 | 
			
		||||
				value,
 | 
			
		||||
				min,
 | 
			
		||||
				systems,
 | 
			
		||||
			})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group">
 | 
			
		||||
			<label
 | 
			
		||||
				htmlFor={`s${name}`}
 | 
			
		||||
				className={cn("flex flex-row items-center justify-between gap-4 cursor-pointer p-4", {
 | 
			
		||||
					"pb-0": checked,
 | 
			
		||||
				})}
 | 
			
		||||
			>
 | 
			
		||||
				<div className="grid gap-1 select-none">
 | 
			
		||||
					<p className="font-semibold flex gap-3 items-center">
 | 
			
		||||
						<Icon className="h-4 w-4 opacity-85" /> {alertData.name()}
 | 
			
		||||
					</p>
 | 
			
		||||
					{!checked && <span className="block text-sm text-muted-foreground">{alertData.desc()}</span>}
 | 
			
		||||
				</div>
 | 
			
		||||
				<Switch
 | 
			
		||||
					id={`s${name}`}
 | 
			
		||||
					checked={checked}
 | 
			
		||||
					onCheckedChange={(newChecked) => {
 | 
			
		||||
						setChecked(newChecked)
 | 
			
		||||
						if (newChecked) {
 | 
			
		||||
							// if alert checked, create or update alert
 | 
			
		||||
							sendUpsert(min, value)
 | 
			
		||||
						} else {
 | 
			
		||||
							// if unchecked, delete alert (unless global and overwriteExisting is false)
 | 
			
		||||
							deleteAlerts({ name: alertKey, systems: getSystemIds() })
 | 
			
		||||
							// when force deleting all alerts of a type, also remove them from initialAlertsState
 | 
			
		||||
							if (overwriteExisting) {
 | 
			
		||||
								for (const curAlerts of Object.values(initialAlertsState)) {
 | 
			
		||||
									curAlerts.delete(alertKey)
 | 
			
		||||
								}
 | 
			
		||||
							}
 | 
			
		||||
						}
 | 
			
		||||
					}}
 | 
			
		||||
				/>
 | 
			
		||||
			</label>
 | 
			
		||||
			{checked && (
 | 
			
		||||
				<div className="grid sm:grid-cols-2 mt-1.5 gap-5 px-4 pb-5 tabular-nums text-muted-foreground">
 | 
			
		||||
					<Suspense fallback={<div className="h-10" />}>
 | 
			
		||||
						{!singleDescription && (
 | 
			
		||||
							<div>
 | 
			
		||||
								<p id={`v${name}`} className="text-sm block h-8">
 | 
			
		||||
									<Trans>
 | 
			
		||||
										Average exceeds{" "}
 | 
			
		||||
										<strong className="text-foreground">
 | 
			
		||||
											{value}
 | 
			
		||||
											{alertData.unit}
 | 
			
		||||
										</strong>
 | 
			
		||||
									</Trans>
 | 
			
		||||
								</p>
 | 
			
		||||
								<div className="flex gap-3">
 | 
			
		||||
									<Slider
 | 
			
		||||
										aria-labelledby={`v${name}`}
 | 
			
		||||
										defaultValue={[value]}
 | 
			
		||||
										onValueCommit={(val) => sendUpsert(min, val[0])}
 | 
			
		||||
										onValueChange={(val) => setValue(val[0])}
 | 
			
		||||
										step={alertData.step ?? 1}
 | 
			
		||||
										min={alertData.min ?? 1}
 | 
			
		||||
										max={alertData.max ?? 99}
 | 
			
		||||
									/>
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
						)}
 | 
			
		||||
						<div className={cn(singleDescription && "col-span-full lowercase")}>
 | 
			
		||||
							<p id={`t${name}`} className="text-sm block h-8 first-letter:uppercase">
 | 
			
		||||
								{singleDescription && (
 | 
			
		||||
									<>
 | 
			
		||||
										{singleDescription}
 | 
			
		||||
										{` `}
 | 
			
		||||
									</>
 | 
			
		||||
								)}
 | 
			
		||||
								<Trans>
 | 
			
		||||
									For <strong className="text-foreground">{min}</strong>{" "}
 | 
			
		||||
									<Plural value={min} one="minute" other="minutes" />
 | 
			
		||||
								</Trans>
 | 
			
		||||
							</p>
 | 
			
		||||
							<div className="flex gap-3">
 | 
			
		||||
								<Slider
 | 
			
		||||
									aria-labelledby={`v${name}`}
 | 
			
		||||
									defaultValue={[min]}
 | 
			
		||||
									onValueCommit={(minVal) => sendUpsert(minVal[0], value)}
 | 
			
		||||
									onValueChange={(val) => setMin(val[0])}
 | 
			
		||||
									min={1}
 | 
			
		||||
									max={60}
 | 
			
		||||
								/>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					</Suspense>
 | 
			
		||||
				</div>
 | 
			
		||||
			)}
 | 
			
		||||
		</div>
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
@@ -1,311 +0,0 @@
 | 
			
		||||
import { t } from "@lingui/core/macro"
 | 
			
		||||
import { Trans, Plural } from "@lingui/react/macro"
 | 
			
		||||
import { $alerts, $systems, pb } from "@/lib/stores"
 | 
			
		||||
import { alertInfo, cn } from "@/lib/utils"
 | 
			
		||||
import { Switch } from "@/components/ui/switch"
 | 
			
		||||
import { AlertInfo, AlertRecord, SystemRecord } from "@/types"
 | 
			
		||||
import { lazy, Suspense, useMemo, useState } from "react"
 | 
			
		||||
import { toast } from "../ui/use-toast"
 | 
			
		||||
import { BatchService } from "pocketbase"
 | 
			
		||||
import { getSemaphore } from "@henrygd/semaphore"
 | 
			
		||||
 | 
			
		||||
interface AlertData {
 | 
			
		||||
	checked?: boolean
 | 
			
		||||
	val?: number
 | 
			
		||||
	min?: number
 | 
			
		||||
	updateAlert?: (checked: boolean, value: number, min: number) => void
 | 
			
		||||
	name: keyof typeof alertInfo
 | 
			
		||||
	alert: AlertInfo
 | 
			
		||||
	system: SystemRecord
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const Slider = lazy(() => import("@/components/ui/slider"))
 | 
			
		||||
 | 
			
		||||
const failedUpdateToast = () =>
 | 
			
		||||
	toast({
 | 
			
		||||
		title: t`Failed to update alert`,
 | 
			
		||||
		description: t`Please check logs for more details.`,
 | 
			
		||||
		variant: "destructive",
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
export function SystemAlert({
 | 
			
		||||
	system,
 | 
			
		||||
	systemAlerts,
 | 
			
		||||
	data,
 | 
			
		||||
}: {
 | 
			
		||||
	system: SystemRecord
 | 
			
		||||
	systemAlerts: AlertRecord[]
 | 
			
		||||
	data: AlertData
 | 
			
		||||
}) {
 | 
			
		||||
	const alert = systemAlerts.find((alert) => alert.name === data.name)
 | 
			
		||||
 | 
			
		||||
	data.updateAlert = async (checked: boolean, value: number, min: number) => {
 | 
			
		||||
		try {
 | 
			
		||||
			if (alert && !checked) {
 | 
			
		||||
				await pb.collection("alerts").delete(alert.id)
 | 
			
		||||
			} else if (alert && checked) {
 | 
			
		||||
				await pb.collection("alerts").update(alert.id, { value, min, triggered: false })
 | 
			
		||||
			} else if (checked) {
 | 
			
		||||
				pb.collection("alerts").create({
 | 
			
		||||
					system: system.id,
 | 
			
		||||
					user: pb.authStore.record!.id,
 | 
			
		||||
					name: data.name,
 | 
			
		||||
					value: value,
 | 
			
		||||
					min: min,
 | 
			
		||||
				})
 | 
			
		||||
			}
 | 
			
		||||
		} catch (e) {
 | 
			
		||||
			failedUpdateToast()
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (alert) {
 | 
			
		||||
		data.checked = true
 | 
			
		||||
		data.val = alert.value
 | 
			
		||||
		data.min = alert.min || 1
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return <AlertContent data={data} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const SystemAlertGlobal = ({ data, overwrite }: { data: AlertData; overwrite: boolean | "indeterminate" }) => {
 | 
			
		||||
	data.checked = false
 | 
			
		||||
	data.val = data.min = 0
 | 
			
		||||
 | 
			
		||||
	// set of system ids that have an alert for this name when the component is mounted
 | 
			
		||||
	const existingAlertsSystems = useMemo(() => {
 | 
			
		||||
		const map = new Set<string>()
 | 
			
		||||
		const alerts = $alerts.get()
 | 
			
		||||
		for (const alert of alerts) {
 | 
			
		||||
			if (alert.name === data.name) {
 | 
			
		||||
				map.add(alert.system)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		return map
 | 
			
		||||
	}, [])
 | 
			
		||||
 | 
			
		||||
	data.updateAlert = async (checked: boolean, value: number, min: number) => {
 | 
			
		||||
		const sem = getSemaphore("alerts")
 | 
			
		||||
		await sem.acquire()
 | 
			
		||||
		try {
 | 
			
		||||
			// if another update is waiting behind, don't start this one
 | 
			
		||||
			if (sem.size() > 1) {
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			const recordData: Partial<AlertRecord> = {
 | 
			
		||||
				value,
 | 
			
		||||
				min,
 | 
			
		||||
				triggered: false,
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			const batch = batchWrapper("alerts", 25)
 | 
			
		||||
			const systems = $systems.get()
 | 
			
		||||
			const currentAlerts = $alerts.get()
 | 
			
		||||
 | 
			
		||||
			// map of current alerts with this name right now by system id
 | 
			
		||||
			const currentAlertsSystems = new Map<string, AlertRecord>()
 | 
			
		||||
			for (const alert of currentAlerts) {
 | 
			
		||||
				if (alert.name === data.name) {
 | 
			
		||||
					currentAlertsSystems.set(alert.system, alert)
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if (overwrite) {
 | 
			
		||||
				existingAlertsSystems.clear()
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			const processSystem = async (system: SystemRecord): Promise<void> => {
 | 
			
		||||
				const existingAlert = existingAlertsSystems.has(system.id)
 | 
			
		||||
 | 
			
		||||
				if (!overwrite && existingAlert) {
 | 
			
		||||
					return
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				const currentAlert = currentAlertsSystems.get(system.id)
 | 
			
		||||
 | 
			
		||||
				// delete existing alert if unchecked
 | 
			
		||||
				if (!checked && currentAlert) {
 | 
			
		||||
					return batch.remove(currentAlert.id)
 | 
			
		||||
				}
 | 
			
		||||
				if (checked && currentAlert) {
 | 
			
		||||
					// update existing alert if checked
 | 
			
		||||
					return batch.update(currentAlert.id, recordData)
 | 
			
		||||
				}
 | 
			
		||||
				if (checked) {
 | 
			
		||||
					// create new alert if checked and not existing
 | 
			
		||||
					return batch.create({
 | 
			
		||||
						system: system.id,
 | 
			
		||||
						user: pb.authStore.record!.id,
 | 
			
		||||
						name: data.name,
 | 
			
		||||
						...recordData,
 | 
			
		||||
					})
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// make sure current system is updated in the first batch
 | 
			
		||||
			await processSystem(data.system)
 | 
			
		||||
			for (const system of systems) {
 | 
			
		||||
				if (system.id === data.system.id) {
 | 
			
		||||
					continue
 | 
			
		||||
				}
 | 
			
		||||
				if (sem.size() > 1) {
 | 
			
		||||
					return
 | 
			
		||||
				}
 | 
			
		||||
				await processSystem(system)
 | 
			
		||||
			}
 | 
			
		||||
			await batch.send()
 | 
			
		||||
		} finally {
 | 
			
		||||
			sem.release()
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return <AlertContent data={data} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Creates a wrapper for performing batch operations on a specified collection.
 | 
			
		||||
 */
 | 
			
		||||
function batchWrapper(collection: string, batchSize: number) {
 | 
			
		||||
	let batch: BatchService | undefined
 | 
			
		||||
	let count = 0
 | 
			
		||||
 | 
			
		||||
	const create = async <T extends Record<string, any>>(options: T) => {
 | 
			
		||||
		batch ||= pb.createBatch()
 | 
			
		||||
		batch.collection(collection).create(options)
 | 
			
		||||
		if (++count >= batchSize) {
 | 
			
		||||
			await send()
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const update = async <T extends Record<string, any>>(id: string, data: T) => {
 | 
			
		||||
		batch ||= pb.createBatch()
 | 
			
		||||
		batch.collection(collection).update(id, data)
 | 
			
		||||
		if (++count >= batchSize) {
 | 
			
		||||
			await send()
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const remove = async (id: string) => {
 | 
			
		||||
		batch ||= pb.createBatch()
 | 
			
		||||
		batch.collection(collection).delete(id)
 | 
			
		||||
		if (++count >= batchSize) {
 | 
			
		||||
			await send()
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const send = async () => {
 | 
			
		||||
		if (count) {
 | 
			
		||||
			await batch?.send({ requestKey: null })
 | 
			
		||||
			batch = undefined
 | 
			
		||||
			count = 0
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return {
 | 
			
		||||
		update,
 | 
			
		||||
		remove,
 | 
			
		||||
		send,
 | 
			
		||||
		create,
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function AlertContent({ data }: { data: AlertData }) {
 | 
			
		||||
	const { name } = data
 | 
			
		||||
 | 
			
		||||
	const singleDescription = data.alert.singleDesc?.()
 | 
			
		||||
 | 
			
		||||
	const [checked, setChecked] = useState(data.checked || false)
 | 
			
		||||
	const [min, setMin] = useState(data.min || 10)
 | 
			
		||||
	const [value, setValue] = useState(data.val || (singleDescription ? 0 : data.alert.start ?? 80))
 | 
			
		||||
 | 
			
		||||
	const Icon = alertInfo[name].icon
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group">
 | 
			
		||||
			<label
 | 
			
		||||
				htmlFor={`s${name}`}
 | 
			
		||||
				className={cn("flex flex-row items-center justify-between gap-4 cursor-pointer p-4", {
 | 
			
		||||
					"pb-0": checked,
 | 
			
		||||
				})}
 | 
			
		||||
			>
 | 
			
		||||
				<div className="grid gap-1 select-none">
 | 
			
		||||
					<p className="font-semibold flex gap-3 items-center">
 | 
			
		||||
						<Icon className="h-4 w-4 opacity-85" /> {data.alert.name()}
 | 
			
		||||
					</p>
 | 
			
		||||
					{!checked && <span className="block text-sm text-muted-foreground">{data.alert.desc()}</span>}
 | 
			
		||||
				</div>
 | 
			
		||||
				<Switch
 | 
			
		||||
					id={`s${name}`}
 | 
			
		||||
					checked={checked}
 | 
			
		||||
					onCheckedChange={(newChecked) => {
 | 
			
		||||
						setChecked(newChecked)
 | 
			
		||||
						data.updateAlert?.(newChecked, value, min)
 | 
			
		||||
					}}
 | 
			
		||||
				/>
 | 
			
		||||
			</label>
 | 
			
		||||
			{checked && (
 | 
			
		||||
				<div className="grid sm:grid-cols-2 mt-1.5 gap-5 px-4 pb-5 tabular-nums text-muted-foreground">
 | 
			
		||||
					<Suspense fallback={<div className="h-10" />}>
 | 
			
		||||
						{!singleDescription && (
 | 
			
		||||
							<div>
 | 
			
		||||
								<p id={`v${name}`} className="text-sm block h-8">
 | 
			
		||||
									<Trans>
 | 
			
		||||
										Average exceeds{" "}
 | 
			
		||||
										<strong className="text-foreground">
 | 
			
		||||
											{value}
 | 
			
		||||
											{data.alert.unit}
 | 
			
		||||
										</strong>
 | 
			
		||||
									</Trans>
 | 
			
		||||
								</p>
 | 
			
		||||
								<div className="flex gap-3">
 | 
			
		||||
									<Slider
 | 
			
		||||
										aria-labelledby={`v${name}`}
 | 
			
		||||
										defaultValue={[value]}
 | 
			
		||||
										onValueCommit={(val) => {
 | 
			
		||||
											data.updateAlert?.(true, val[0], min)
 | 
			
		||||
										}}
 | 
			
		||||
										onValueChange={(val) => {
 | 
			
		||||
											setValue(val[0])
 | 
			
		||||
										}}
 | 
			
		||||
										step={data.alert.step ?? 1}
 | 
			
		||||
										min={data.alert.min ?? 1}
 | 
			
		||||
										max={alertInfo[name].max ?? 99}
 | 
			
		||||
									/>
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
						)}
 | 
			
		||||
						<div className={cn(singleDescription && "col-span-full lowercase")}>
 | 
			
		||||
							<p id={`t${name}`} className="text-sm block h-8 first-letter:uppercase">
 | 
			
		||||
								{singleDescription && (
 | 
			
		||||
									<>
 | 
			
		||||
										{singleDescription}
 | 
			
		||||
										{` `}
 | 
			
		||||
									</>
 | 
			
		||||
								)}
 | 
			
		||||
								<Trans>
 | 
			
		||||
									For <strong className="text-foreground">{min}</strong>{" "}
 | 
			
		||||
									<Plural value={min} one="minute" other="minutes" />
 | 
			
		||||
								</Trans>
 | 
			
		||||
							</p>
 | 
			
		||||
							<div className="flex gap-3">
 | 
			
		||||
								<Slider
 | 
			
		||||
									aria-labelledby={`v${name}`}
 | 
			
		||||
									defaultValue={[min]}
 | 
			
		||||
									onValueCommit={(min) => {
 | 
			
		||||
										data.updateAlert?.(true, value, min[0])
 | 
			
		||||
									}}
 | 
			
		||||
									onValueChange={(val) => {
 | 
			
		||||
										setMin(val[0])
 | 
			
		||||
									}}
 | 
			
		||||
									min={1}
 | 
			
		||||
									max={60}
 | 
			
		||||
								/>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					</Suspense>
 | 
			
		||||
				</div>
 | 
			
		||||
			)}
 | 
			
		||||
		</div>
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
@@ -18,6 +18,7 @@ export default function AreaChartDefault({
 | 
			
		||||
	tickFormatter,
 | 
			
		||||
	contentFormatter,
 | 
			
		||||
	dataPoints,
 | 
			
		||||
	domain,
 | 
			
		||||
}: // logRender = false,
 | 
			
		||||
{
 | 
			
		||||
	chartData: ChartData
 | 
			
		||||
@@ -26,6 +27,7 @@ export default function AreaChartDefault({
 | 
			
		||||
	tickFormatter: (value: number, index: number) => string
 | 
			
		||||
	contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
 | 
			
		||||
	dataPoints?: DataPoint[]
 | 
			
		||||
	domain?: [number, number]
 | 
			
		||||
	// logRender?: boolean
 | 
			
		||||
}) {
 | 
			
		||||
	const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
 | 
			
		||||
@@ -51,7 +53,7 @@ export default function AreaChartDefault({
 | 
			
		||||
							orientation={chartData.orientation}
 | 
			
		||||
							className="tracking-tighter"
 | 
			
		||||
							width={yAxisWidth}
 | 
			
		||||
							domain={[0, max ?? "auto"]}
 | 
			
		||||
							domain={domain ?? [0, max ?? "auto"]}
 | 
			
		||||
							tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
 | 
			
		||||
							tickLine={false}
 | 
			
		||||
							axisLine={false}
 | 
			
		||||
@@ -68,7 +70,7 @@ export default function AreaChartDefault({
 | 
			
		||||
							}
 | 
			
		||||
						/>
 | 
			
		||||
						{dataPoints?.map((dataPoint, i) => {
 | 
			
		||||
							const color = `hsl(var(--chart-${dataPoint.color}))`
 | 
			
		||||
							const color = `var(--chart-${dataPoint.color})`
 | 
			
		||||
							return (
 | 
			
		||||
								<Area
 | 
			
		||||
									key={i}
 | 
			
		||||
@@ -87,5 +89,5 @@ export default function AreaChartDefault({
 | 
			
		||||
				</ChartContainer>
 | 
			
		||||
			</div>
 | 
			
		||||
		)
 | 
			
		||||
	}, [chartData.systemStats.length, yAxisWidth, maxToggled])
 | 
			
		||||
	}, [chartData.systemStats.at(-1), yAxisWidth, maxToggled])
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -29,41 +29,36 @@ export default memo(function ContainerChart({
 | 
			
		||||
	const isNetChart = chartType === ChartType.Network
 | 
			
		||||
 | 
			
		||||
	const chartConfig = useMemo(() => {
 | 
			
		||||
		let config = {} as Record<
 | 
			
		||||
			string,
 | 
			
		||||
			{
 | 
			
		||||
				label: string
 | 
			
		||||
				color: string
 | 
			
		||||
			}
 | 
			
		||||
		>
 | 
			
		||||
		const totalUsage = {} as Record<string, number>
 | 
			
		||||
		for (let stats of containerData) {
 | 
			
		||||
			for (let key in stats) {
 | 
			
		||||
				if (!key || key === "created") {
 | 
			
		||||
					continue
 | 
			
		||||
				}
 | 
			
		||||
				if (!(key in totalUsage)) {
 | 
			
		||||
					totalUsage[key] = 0
 | 
			
		||||
				}
 | 
			
		||||
				if (isNetChart) {
 | 
			
		||||
					totalUsage[key] += (stats[key]?.nr ?? 0) + (stats[key]?.ns ?? 0)
 | 
			
		||||
				} else {
 | 
			
		||||
					// @ts-ignore
 | 
			
		||||
					totalUsage[key] += stats[key]?.[dataKey] ?? 0
 | 
			
		||||
				}
 | 
			
		||||
		const config = {} as Record<string, { label: string; color: string }>
 | 
			
		||||
		const totalUsage = new Map<string, number>()
 | 
			
		||||
 | 
			
		||||
		// calculate total usage of each container
 | 
			
		||||
		for (const stats of containerData) {
 | 
			
		||||
			for (const key in stats) {
 | 
			
		||||
				if (!key || key === "created") continue
 | 
			
		||||
 | 
			
		||||
				const currentTotal = totalUsage.get(key) ?? 0
 | 
			
		||||
				const increment = isNetChart
 | 
			
		||||
					? (stats[key]?.nr ?? 0) + (stats[key]?.ns ?? 0)
 | 
			
		||||
					: // @ts-ignore
 | 
			
		||||
					  stats[key]?.[dataKey] ?? 0
 | 
			
		||||
 | 
			
		||||
				totalUsage.set(key, currentTotal + increment)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		let keys = Object.keys(totalUsage)
 | 
			
		||||
		keys.sort((a, b) => (totalUsage[a] > totalUsage[b] ? -1 : 1))
 | 
			
		||||
		const length = keys.length
 | 
			
		||||
		for (let i = 0; i < length; i++) {
 | 
			
		||||
			const key = keys[i]
 | 
			
		||||
 | 
			
		||||
		// Sort keys and generate colors based on usage
 | 
			
		||||
		const sortedEntries = Array.from(totalUsage.entries()).sort(([, a], [, b]) => b - a)
 | 
			
		||||
 | 
			
		||||
		const length = sortedEntries.length
 | 
			
		||||
		sortedEntries.forEach(([key], i) => {
 | 
			
		||||
			const hue = ((i * 360) / length) % 360
 | 
			
		||||
			config[key] = {
 | 
			
		||||
				label: key,
 | 
			
		||||
				color: `hsl(${hue}, 60%, 55%)`,
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		})
 | 
			
		||||
 | 
			
		||||
		return config satisfies ChartConfig
 | 
			
		||||
	}, [chartData])
 | 
			
		||||
 | 
			
		||||
@@ -124,6 +119,8 @@ export default memo(function ContainerChart({
 | 
			
		||||
		return obj
 | 
			
		||||
	}, [])
 | 
			
		||||
 | 
			
		||||
	const filterLower = filter?.toLowerCase()
 | 
			
		||||
 | 
			
		||||
	// console.log('rendered at', new Date())
 | 
			
		||||
 | 
			
		||||
	if (containerData.length === 0) {
 | 
			
		||||
@@ -165,7 +162,7 @@ export default memo(function ContainerChart({
 | 
			
		||||
						content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} />}
 | 
			
		||||
					/>
 | 
			
		||||
					{Object.keys(chartConfig).map((key) => {
 | 
			
		||||
						const filtered = filter && !key.toLowerCase().includes(filter.toLowerCase())
 | 
			
		||||
						const filtered = filterLower && !key.toLowerCase().includes(filterLower)
 | 
			
		||||
						let fillOpacity = filtered ? 0.05 : 0.4
 | 
			
		||||
						let strokeOpacity = filtered ? 0.1 : 1
 | 
			
		||||
						return (
 | 
			
		||||
 
 | 
			
		||||
@@ -69,9 +69,9 @@ export default memo(function DiskChart({
 | 
			
		||||
						dataKey={dataKey}
 | 
			
		||||
						name={t`Disk Usage`}
 | 
			
		||||
						type="monotoneX"
 | 
			
		||||
						fill="hsl(var(--chart-4))"
 | 
			
		||||
						fill="var(--chart-4)"
 | 
			
		||||
						fillOpacity={0.4}
 | 
			
		||||
						stroke="hsl(var(--chart-4))"
 | 
			
		||||
						stroke="var(--chart-4)"
 | 
			
		||||
						// animationDuration={1200}
 | 
			
		||||
						isAnimationActive={false}
 | 
			
		||||
					/>
 | 
			
		||||
 
 | 
			
		||||
@@ -68,9 +68,9 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
 | 
			
		||||
						order={3}
 | 
			
		||||
						dataKey="stats.mu"
 | 
			
		||||
						type="monotoneX"
 | 
			
		||||
						fill="hsl(var(--chart-2))"
 | 
			
		||||
						fill="var(--chart-2)"
 | 
			
		||||
						fillOpacity={0.4}
 | 
			
		||||
						stroke="hsl(var(--chart-2))"
 | 
			
		||||
						stroke="var(--chart-2)"
 | 
			
		||||
						stackId="1"
 | 
			
		||||
						isAnimationActive={false}
 | 
			
		||||
					/>
 | 
			
		||||
 
 | 
			
		||||
@@ -58,9 +58,9 @@ export default memo(function SwapChart({ chartData }: { chartData: ChartData })
 | 
			
		||||
						dataKey="stats.su"
 | 
			
		||||
						name={t`Used`}
 | 
			
		||||
						type="monotoneX"
 | 
			
		||||
						fill="hsl(var(--chart-2))"
 | 
			
		||||
						fill="var(--chart-2)"
 | 
			
		||||
						fillOpacity={0.4}
 | 
			
		||||
						stroke="hsl(var(--chart-2))"
 | 
			
		||||
						stroke="var(--chart-2)"
 | 
			
		||||
						isAnimationActive={false}
 | 
			
		||||
					/>
 | 
			
		||||
				</AreaChart>
 | 
			
		||||
 
 | 
			
		||||
@@ -22,7 +22,7 @@ export function LangToggle() {
 | 
			
		||||
				{languages.map(({ lang, label, e }) => (
 | 
			
		||||
					<DropdownMenuItem
 | 
			
		||||
						key={lang}
 | 
			
		||||
						className={cn("px-2.5 flex gap-2.5", lang === i18n.locale && "font-semibold")}
 | 
			
		||||
						className={cn("px-2.5 flex gap-2.5 cursor-pointer", lang === i18n.locale && "bg-accent/70 font-medium")}
 | 
			
		||||
						onClick={() => dynamicActivate(lang)}
 | 
			
		||||
					>
 | 
			
		||||
						<span>{e}</span> {label}
 | 
			
		||||
 
 | 
			
		||||
@@ -288,7 +288,7 @@ export function UserAuthForm({
 | 
			
		||||
									// }}
 | 
			
		||||
								/>
 | 
			
		||||
							)}
 | 
			
		||||
							<span className="translate-y-[1px]">{provider.displayName}</span>
 | 
			
		||||
							<span className="translate-y-px">{provider.displayName}</span>
 | 
			
		||||
						</button>
 | 
			
		||||
					))}
 | 
			
		||||
				</div>
 | 
			
		||||
@@ -299,7 +299,7 @@ export function UserAuthForm({
 | 
			
		||||
					<DialogTrigger asChild>
 | 
			
		||||
						<button type="button" className={cn(buttonVariants({ variant: "outline" }))}>
 | 
			
		||||
							<img className="me-2 h-4 w-4 dark:invert" src={prependBasePath("/_/images/oauth2/github.svg")} alt="" />
 | 
			
		||||
							<span className="translate-y-[1px]">GitHub</span>
 | 
			
		||||
							<span className="translate-y-px">GitHub</span>
 | 
			
		||||
						</button>
 | 
			
		||||
					</DialogTrigger>
 | 
			
		||||
					<DialogContent style={{ maxWidth: 440, width: "90%" }}>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,56 +1,21 @@
 | 
			
		||||
import { Trans } from "@lingui/react/macro";
 | 
			
		||||
import { t } from "@lingui/core/macro";
 | 
			
		||||
import { LaptopIcon, MoonStarIcon, SunIcon } from "lucide-react"
 | 
			
		||||
import { t } from "@lingui/core/macro"
 | 
			
		||||
import { MoonStarIcon, SunIcon } from "lucide-react"
 | 
			
		||||
 | 
			
		||||
import { Button } from "@/components/ui/button"
 | 
			
		||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
 | 
			
		||||
import { useTheme } from "@/components/theme-provider"
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
 | 
			
		||||
export function ModeToggle() {
 | 
			
		||||
	const { theme, setTheme } = useTheme()
 | 
			
		||||
 | 
			
		||||
	const options = [
 | 
			
		||||
		{
 | 
			
		||||
			theme: "light",
 | 
			
		||||
			Icon: SunIcon,
 | 
			
		||||
			label: <Trans comment="Light theme">Light</Trans>,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			theme: "dark",
 | 
			
		||||
			Icon: MoonStarIcon,
 | 
			
		||||
			label: <Trans comment="Dark theme">Dark</Trans>,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			theme: "system",
 | 
			
		||||
			Icon: LaptopIcon,
 | 
			
		||||
			label: <Trans comment="System theme">System</Trans>,
 | 
			
		||||
		},
 | 
			
		||||
	]
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<DropdownMenu>
 | 
			
		||||
			<DropdownMenuTrigger asChild>
 | 
			
		||||
				<Button variant={"ghost"} size="icon" aria-label={t`Toggle theme`}>
 | 
			
		||||
					<SunIcon className="h-[1.2rem] w-[1.2rem] dark:opacity-0" />
 | 
			
		||||
					<MoonStarIcon className="absolute h-[1.2rem] w-[1.2rem] opacity-0 dark:opacity-100" />
 | 
			
		||||
				</Button>
 | 
			
		||||
			</DropdownMenuTrigger>
 | 
			
		||||
			<DropdownMenuContent>
 | 
			
		||||
				{options.map((opt) => {
 | 
			
		||||
					const selected = opt.theme === theme
 | 
			
		||||
					return (
 | 
			
		||||
						<DropdownMenuItem
 | 
			
		||||
							key={opt.theme}
 | 
			
		||||
							className={cn("px-2.5", selected ? "font-semibold" : "")}
 | 
			
		||||
							onClick={() => setTheme(opt.theme as "dark" | "light" | "system")}
 | 
			
		||||
						>
 | 
			
		||||
							<opt.Icon className={cn("me-2 h-4 w-4 opacity-80", selected && "opacity-100")} />
 | 
			
		||||
							{opt.label}
 | 
			
		||||
						</DropdownMenuItem>
 | 
			
		||||
					)
 | 
			
		||||
				})}
 | 
			
		||||
			</DropdownMenuContent>
 | 
			
		||||
		</DropdownMenu>
 | 
			
		||||
		<Button
 | 
			
		||||
			variant={"ghost"}
 | 
			
		||||
			size="icon"
 | 
			
		||||
			aria-label={t`Toggle theme`}
 | 
			
		||||
			onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
 | 
			
		||||
		>
 | 
			
		||||
			<SunIcon className="h-[1.2rem] w-[1.2rem] transition-all -rotate-90 dark:opacity-0 dark:rotate-0" />
 | 
			
		||||
			<MoonStarIcon className="absolute h-[1.2rem] w-[1.2rem] transition-all opacity-0 -rotate-90 dark:opacity-100 dark:rotate-0" />
 | 
			
		||||
		</Button>
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { Trans } from "@lingui/react/macro";
 | 
			
		||||
import { Trans } from "@lingui/react/macro"
 | 
			
		||||
import { useState, lazy, Suspense } from "react"
 | 
			
		||||
import { Button, buttonVariants } from "@/components/ui/button"
 | 
			
		||||
import {
 | 
			
		||||
 
 | 
			
		||||
@@ -35,18 +35,20 @@ export const navigate = (urlString: string) => {
 | 
			
		||||
	$router.open(urlString)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function onClick(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {
 | 
			
		||||
	e.preventDefault()
 | 
			
		||||
	$router.open(new URL((e.currentTarget as HTMLAnchorElement).href).pathname)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const Link = (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
 | 
			
		||||
	let clickFn = onClick
 | 
			
		||||
	if (props.onClick) {
 | 
			
		||||
		clickFn = (e) => {
 | 
			
		||||
			onClick(e)
 | 
			
		||||
			props.onClick?.(e)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return <a {...props} onClick={clickFn}></a>
 | 
			
		||||
export function Link(props: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
 | 
			
		||||
	return (
 | 
			
		||||
		<a
 | 
			
		||||
			{...props}
 | 
			
		||||
			onClick={(e) => {
 | 
			
		||||
				e.preventDefault()
 | 
			
		||||
				const href = props.href || ""
 | 
			
		||||
				if (e.ctrlKey || e.metaKey) {
 | 
			
		||||
					window.open(href, "_blank")
 | 
			
		||||
				} else {
 | 
			
		||||
					navigate(href)
 | 
			
		||||
					props.onClick?.(e)
 | 
			
		||||
				}
 | 
			
		||||
			}}
 | 
			
		||||
		></a>
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -4,34 +4,19 @@ import { $alerts, $systems, pb } from "@/lib/stores"
 | 
			
		||||
import { useStore } from "@nanostores/react"
 | 
			
		||||
import { GithubIcon } from "lucide-react"
 | 
			
		||||
import { Separator } from "../ui/separator"
 | 
			
		||||
import { alertInfo, updateRecordList, updateSystemList } from "@/lib/utils"
 | 
			
		||||
import { getSystemNameFromId, updateRecordList, updateSystemList } from "@/lib/utils"
 | 
			
		||||
import { AlertRecord, SystemRecord } from "@/types"
 | 
			
		||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
 | 
			
		||||
import { $router, Link } from "../router"
 | 
			
		||||
import { Plural, Trans, useLingui } from "@lingui/react/macro"
 | 
			
		||||
import { getPagePath } from "@nanostores/router"
 | 
			
		||||
import { alertInfo } from "@/lib/alerts"
 | 
			
		||||
 | 
			
		||||
const SystemsTable = lazy(() => import("../systems-table/systems-table"))
 | 
			
		||||
 | 
			
		||||
export const Home = memo(() => {
 | 
			
		||||
	const alerts = useStore($alerts)
 | 
			
		||||
	const systems = useStore($systems)
 | 
			
		||||
export default memo(function () {
 | 
			
		||||
	const { t } = useLingui()
 | 
			
		||||
 | 
			
		||||
	let alertsKey = ""
 | 
			
		||||
	const activeAlerts = useMemo(() => {
 | 
			
		||||
		const activeAlerts = alerts.filter((alert) => {
 | 
			
		||||
			const active = alert.triggered && alert.name in alertInfo
 | 
			
		||||
			if (!active) {
 | 
			
		||||
				return false
 | 
			
		||||
			}
 | 
			
		||||
			alert.sysname = systems.find((system) => system.id === alert.system)?.name
 | 
			
		||||
			alertsKey += alert.id
 | 
			
		||||
			return true
 | 
			
		||||
		})
 | 
			
		||||
		return activeAlerts
 | 
			
		||||
	}, [systems, alerts])
 | 
			
		||||
 | 
			
		||||
	useEffect(() => {
 | 
			
		||||
		document.title = t`Dashboard` + " / Beszel"
 | 
			
		||||
	}, [t])
 | 
			
		||||
@@ -44,20 +29,15 @@ export const Home = memo(() => {
 | 
			
		||||
		pb.collection<SystemRecord>("systems").subscribe("*", (e) => {
 | 
			
		||||
			updateRecordList(e, $systems)
 | 
			
		||||
		})
 | 
			
		||||
		pb.collection<AlertRecord>("alerts").subscribe("*", (e) => {
 | 
			
		||||
			updateRecordList(e, $alerts)
 | 
			
		||||
		})
 | 
			
		||||
		return () => {
 | 
			
		||||
			pb.collection("systems").unsubscribe("*")
 | 
			
		||||
			// pb.collection('alerts').unsubscribe('*')
 | 
			
		||||
		}
 | 
			
		||||
	}, [])
 | 
			
		||||
 | 
			
		||||
	return useMemo(
 | 
			
		||||
		() => (
 | 
			
		||||
			<>
 | 
			
		||||
				{/* show active alerts */}
 | 
			
		||||
				{activeAlerts.length > 0 && <ActiveAlerts key={activeAlerts.length} activeAlerts={activeAlerts} />}
 | 
			
		||||
				<ActiveAlerts />
 | 
			
		||||
				<Suspense>
 | 
			
		||||
					<SystemsTable />
 | 
			
		||||
				</Suspense>
 | 
			
		||||
@@ -81,55 +61,79 @@ export const Home = memo(() => {
 | 
			
		||||
				</div>
 | 
			
		||||
			</>
 | 
			
		||||
		),
 | 
			
		||||
		[alertsKey]
 | 
			
		||||
		[]
 | 
			
		||||
	)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const ActiveAlerts = memo(({ activeAlerts }: { activeAlerts: AlertRecord[] }) => {
 | 
			
		||||
	return (
 | 
			
		||||
		<Card className="mb-4">
 | 
			
		||||
			<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
 | 
			
		||||
				<div className="px-2 sm:px-1">
 | 
			
		||||
					<CardTitle>
 | 
			
		||||
						<Trans>Active Alerts</Trans>
 | 
			
		||||
					</CardTitle>
 | 
			
		||||
				</div>
 | 
			
		||||
			</CardHeader>
 | 
			
		||||
			<CardContent className="max-sm:p-2">
 | 
			
		||||
				{activeAlerts.length > 0 && (
 | 
			
		||||
					<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
 | 
			
		||||
						{activeAlerts.map((alert) => {
 | 
			
		||||
							const info = alertInfo[alert.name as keyof typeof alertInfo]
 | 
			
		||||
							return (
 | 
			
		||||
								<Alert
 | 
			
		||||
									key={alert.id}
 | 
			
		||||
									className="hover:-translate-y-[1px] duration-200 bg-transparent border-foreground/10  hover:shadow-md shadow-black"
 | 
			
		||||
								>
 | 
			
		||||
									<info.icon className="h-4 w-4" />
 | 
			
		||||
									<AlertTitle>
 | 
			
		||||
										{alert.sysname} {info.name().toLowerCase().replace("cpu", "CPU")}
 | 
			
		||||
									</AlertTitle>
 | 
			
		||||
									<AlertDescription>
 | 
			
		||||
										{alert.name === "Status" ? (
 | 
			
		||||
											<Trans>Connection is down</Trans>
 | 
			
		||||
										) : (
 | 
			
		||||
											<Trans>
 | 
			
		||||
												Exceeds {alert.value}
 | 
			
		||||
												{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
 | 
			
		||||
											</Trans>
 | 
			
		||||
										)}
 | 
			
		||||
									</AlertDescription>
 | 
			
		||||
									<Link
 | 
			
		||||
										href={getPagePath($router, "system", { name: alert.sysname! })}
 | 
			
		||||
										className="absolute inset-0 w-full h-full"
 | 
			
		||||
										aria-label="View system"
 | 
			
		||||
									></Link>
 | 
			
		||||
								</Alert>
 | 
			
		||||
							)
 | 
			
		||||
						})}
 | 
			
		||||
const ActiveAlerts = () => {
 | 
			
		||||
	const alerts = useStore($alerts)
 | 
			
		||||
 | 
			
		||||
	const { activeAlerts, alertsKey } = useMemo(() => {
 | 
			
		||||
		const activeAlerts: AlertRecord[] = []
 | 
			
		||||
		// key to prevent re-rendering if alerts change but active alerts didn't
 | 
			
		||||
		const alertsKey: string[] = []
 | 
			
		||||
 | 
			
		||||
		for (const systemId of Object.keys(alerts)) {
 | 
			
		||||
			for (const alert of alerts[systemId].values()) {
 | 
			
		||||
				if (alert.triggered && alert.name in alertInfo) {
 | 
			
		||||
					activeAlerts.push(alert)
 | 
			
		||||
					alertsKey.push(`${alert.system}${alert.value}${alert.min}`)
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return { activeAlerts, alertsKey }
 | 
			
		||||
	}, [alerts])
 | 
			
		||||
 | 
			
		||||
	return useMemo(() => {
 | 
			
		||||
		if (activeAlerts.length === 0) {
 | 
			
		||||
			return null
 | 
			
		||||
		}
 | 
			
		||||
		return (
 | 
			
		||||
			<Card className="mb-4">
 | 
			
		||||
				<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
 | 
			
		||||
					<div className="px-2 sm:px-1">
 | 
			
		||||
						<CardTitle>
 | 
			
		||||
							<Trans>Active Alerts</Trans>
 | 
			
		||||
						</CardTitle>
 | 
			
		||||
					</div>
 | 
			
		||||
				)}
 | 
			
		||||
			</CardContent>
 | 
			
		||||
		</Card>
 | 
			
		||||
	)
 | 
			
		||||
})
 | 
			
		||||
				</CardHeader>
 | 
			
		||||
				<CardContent className="max-sm:p-2">
 | 
			
		||||
					{activeAlerts.length > 0 && (
 | 
			
		||||
						<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
 | 
			
		||||
							{activeAlerts.map((alert) => {
 | 
			
		||||
								const info = alertInfo[alert.name as keyof typeof alertInfo]
 | 
			
		||||
								return (
 | 
			
		||||
									<Alert
 | 
			
		||||
										key={alert.id}
 | 
			
		||||
										className="hover:-translate-y-px duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black/5"
 | 
			
		||||
									>
 | 
			
		||||
										<info.icon className="h-4 w-4" />
 | 
			
		||||
										<AlertTitle>
 | 
			
		||||
											{getSystemNameFromId(alert.system)} {info.name().toLowerCase().replace("cpu", "CPU")}
 | 
			
		||||
										</AlertTitle>
 | 
			
		||||
										<AlertDescription>
 | 
			
		||||
											{alert.name === "Status" ? (
 | 
			
		||||
												<Trans>Connection is down</Trans>
 | 
			
		||||
											) : (
 | 
			
		||||
												<Trans>
 | 
			
		||||
													Exceeds {alert.value}
 | 
			
		||||
													{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
 | 
			
		||||
												</Trans>
 | 
			
		||||
											)}
 | 
			
		||||
										</AlertDescription>
 | 
			
		||||
										<Link
 | 
			
		||||
											href={getPagePath($router, "system", { name: getSystemNameFromId(alert.system) })}
 | 
			
		||||
											className="absolute inset-0 w-full h-full"
 | 
			
		||||
											aria-label="View system"
 | 
			
		||||
										></Link>
 | 
			
		||||
									</Alert>
 | 
			
		||||
								)
 | 
			
		||||
							})}
 | 
			
		||||
						</div>
 | 
			
		||||
					)}
 | 
			
		||||
				</CardContent>
 | 
			
		||||
			</Card>
 | 
			
		||||
		)
 | 
			
		||||
	}, [alertsKey.join("")])
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
import { pb } from "@/lib/stores"
 | 
			
		||||
import { alertInfo, cn, formatDuration, formatShortDate } from "@/lib/utils"
 | 
			
		||||
import { cn, formatDuration, formatShortDate } from "@/lib/utils"
 | 
			
		||||
import { alertInfo } from "@/lib/alerts"
 | 
			
		||||
import { AlertsHistoryRecord } from "@/types"
 | 
			
		||||
import {
 | 
			
		||||
	getCoreRowModel,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
import { t } from "@lingui/core/macro";
 | 
			
		||||
import { Trans } from "@lingui/react/macro";
 | 
			
		||||
import { t } from "@lingui/core/macro"
 | 
			
		||||
import { Trans } from "@lingui/react/macro"
 | 
			
		||||
import { isAdmin } from "@/lib/utils"
 | 
			
		||||
import { Separator } from "@/components/ui/separator"
 | 
			
		||||
import { Button } from "@/components/ui/button"
 | 
			
		||||
 
 | 
			
		||||
@@ -11,6 +11,7 @@ import { useState } from "react"
 | 
			
		||||
import languages from "@/lib/languages"
 | 
			
		||||
import { dynamicActivate } from "@/lib/i18n"
 | 
			
		||||
import { useLingui } from "@lingui/react/macro"
 | 
			
		||||
import { Input } from "@/components/ui/input"
 | 
			
		||||
import { Unit } from "@/lib/enums"
 | 
			
		||||
 | 
			
		||||
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
 | 
			
		||||
@@ -38,8 +39,8 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
 | 
			
		||||
			</div>
 | 
			
		||||
			<Separator className="my-4" />
 | 
			
		||||
			<form onSubmit={handleSubmit} className="space-y-5">
 | 
			
		||||
				<div className="space-y-2">
 | 
			
		||||
					<div className="mb-4">
 | 
			
		||||
				<div className="grid gap-2">
 | 
			
		||||
					<div className="mb-2">
 | 
			
		||||
						<h3 className="mb-1 text-lg font-medium flex items-center gap-2">
 | 
			
		||||
							<LanguagesIcon className="h-4 w-4" />
 | 
			
		||||
							<Trans>Language</Trans>
 | 
			
		||||
@@ -72,8 +73,8 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
 | 
			
		||||
					</Select>
 | 
			
		||||
				</div>
 | 
			
		||||
				<Separator />
 | 
			
		||||
				<div className="space-y-2">
 | 
			
		||||
					<div className="mb-4">
 | 
			
		||||
				<div className="grid gap-2">
 | 
			
		||||
					<div className="mb-2">
 | 
			
		||||
						<h3 className="mb-1 text-lg font-medium">
 | 
			
		||||
							<Trans>Chart options</Trans>
 | 
			
		||||
						</h3>
 | 
			
		||||
@@ -101,8 +102,8 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
 | 
			
		||||
					</p>
 | 
			
		||||
				</div>
 | 
			
		||||
				<Separator />
 | 
			
		||||
				<div className="space-y-2">
 | 
			
		||||
					<div className="mb-4">
 | 
			
		||||
				<div className="grid gap-2">
 | 
			
		||||
					<div className="mb-2">
 | 
			
		||||
						<h3 className="mb-1 text-lg font-medium">
 | 
			
		||||
							<Trans comment="Temperature / network units">Unit preferences</Trans>
 | 
			
		||||
						</h3>
 | 
			
		||||
@@ -111,7 +112,7 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
 | 
			
		||||
						</p>
 | 
			
		||||
					</div>
 | 
			
		||||
					<div className="grid sm:grid-cols-3 gap-4">
 | 
			
		||||
						<div className="space-y-2">
 | 
			
		||||
						<div className="grid gap-2">
 | 
			
		||||
							<Label className="block" htmlFor="unitTemp">
 | 
			
		||||
								<Trans>Temperature unit</Trans>
 | 
			
		||||
							</Label>
 | 
			
		||||
@@ -133,8 +134,7 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
 | 
			
		||||
								</SelectContent>
 | 
			
		||||
							</Select>
 | 
			
		||||
						</div>
 | 
			
		||||
 | 
			
		||||
						<div className="space-y-2">
 | 
			
		||||
						<div className="grid gap-2">
 | 
			
		||||
							<Label className="block" htmlFor="unitNet">
 | 
			
		||||
								<Trans comment="Context: Bytes or bits">Network unit</Trans>
 | 
			
		||||
							</Label>
 | 
			
		||||
@@ -156,8 +156,7 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
 | 
			
		||||
								</SelectContent>
 | 
			
		||||
							</Select>
 | 
			
		||||
						</div>
 | 
			
		||||
 | 
			
		||||
						<div className="space-y-2">
 | 
			
		||||
						<div className="grid gap-2">
 | 
			
		||||
							<Label className="block" htmlFor="unitDisk">
 | 
			
		||||
								<Trans>Disk unit</Trans>
 | 
			
		||||
							</Label>
 | 
			
		||||
@@ -182,6 +181,47 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
				<Separator />
 | 
			
		||||
				<div className="grid gap-2">
 | 
			
		||||
					<div className="mb-2">
 | 
			
		||||
						<h3 className="mb-1 text-lg font-medium">
 | 
			
		||||
							<Trans>Warning thresholds</Trans>
 | 
			
		||||
						</h3>
 | 
			
		||||
						<p className="text-sm text-muted-foreground leading-relaxed">
 | 
			
		||||
							<Trans>Set percentage thresholds for meter colors.</Trans>
 | 
			
		||||
						</p>
 | 
			
		||||
					</div>
 | 
			
		||||
					<div className="grid grid-cols-2 lg:grid-cols-3 gap-4 items-end">
 | 
			
		||||
						<div className="grid gap-2">
 | 
			
		||||
							<Label htmlFor="colorWarn">
 | 
			
		||||
								<Trans>Warning (%)</Trans>
 | 
			
		||||
							</Label>
 | 
			
		||||
							<Input
 | 
			
		||||
								id="colorWarn"
 | 
			
		||||
								name="colorWarn"
 | 
			
		||||
								type="number"
 | 
			
		||||
								min={1}
 | 
			
		||||
								max={100}
 | 
			
		||||
								className="min-w-24"
 | 
			
		||||
								defaultValue={userSettings.colorWarn ?? 65}
 | 
			
		||||
							/>
 | 
			
		||||
						</div>
 | 
			
		||||
						<div className="grid gap-1">
 | 
			
		||||
							<Label htmlFor="colorCrit">
 | 
			
		||||
								<Trans>Critical (%)</Trans>
 | 
			
		||||
							</Label>
 | 
			
		||||
							<Input
 | 
			
		||||
								id="colorCrit"
 | 
			
		||||
								name="colorCrit"
 | 
			
		||||
								type="number"
 | 
			
		||||
								min={1}
 | 
			
		||||
								max={100}
 | 
			
		||||
								className="min-w-24"
 | 
			
		||||
								defaultValue={userSettings.colorCrit ?? 90}
 | 
			
		||||
							/>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
				<Separator />
 | 
			
		||||
				<Button type="submit" className="flex items-center gap-1.5 disabled:opacity-100" disabled={isLoading}>
 | 
			
		||||
					{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
 | 
			
		||||
					<Trans>Save Settings</Trans>
 | 
			
		||||
 
 | 
			
		||||
@@ -10,7 +10,7 @@ import { getPagePath, redirectPage } from "@nanostores/router"
 | 
			
		||||
import { BellIcon, FileSlidersIcon, FingerprintIcon, SettingsIcon, AlertOctagonIcon } from "lucide-react"
 | 
			
		||||
import { $userSettings, pb } from "@/lib/stores.ts"
 | 
			
		||||
import { toast } from "@/components/ui/use-toast.ts"
 | 
			
		||||
import { UserSettings } from "@/types.js"
 | 
			
		||||
import { UserSettings } from "@/types"
 | 
			
		||||
import General from "./general.tsx"
 | 
			
		||||
import Notifications from "./notifications.tsx"
 | 
			
		||||
import ConfigYaml from "./config-yaml.tsx"
 | 
			
		||||
 
 | 
			
		||||
@@ -87,8 +87,8 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
 | 
			
		||||
			</div>
 | 
			
		||||
			<Separator className="my-4" />
 | 
			
		||||
			<div className="space-y-5">
 | 
			
		||||
				<div className="space-y-2">
 | 
			
		||||
					<div className="mb-4">
 | 
			
		||||
				<div className="grid gap-2">
 | 
			
		||||
					<div className="mb-2">
 | 
			
		||||
						<h3 className="mb-1 text-lg font-medium">
 | 
			
		||||
							<Trans>Email notifications</Trans>
 | 
			
		||||
						</h3>
 | 
			
		||||
@@ -178,7 +178,7 @@ const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) =
 | 
			
		||||
 | 
			
		||||
	const sendTestNotification = async () => {
 | 
			
		||||
		setIsLoading(true)
 | 
			
		||||
		const res = await pb.send("/api/beszel/send-test-notification", { url })
 | 
			
		||||
		const res = await pb.send("/api/beszel/test-notification", { method: "POST", body: { url } })
 | 
			
		||||
		if ("err" in res && !res.err) {
 | 
			
		||||
			toast({
 | 
			
		||||
				title: t`Test notification sent`,
 | 
			
		||||
 
 | 
			
		||||
@@ -56,8 +56,8 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
 | 
			
		||||
							href={item.href}
 | 
			
		||||
							className={cn(
 | 
			
		||||
								buttonVariants({ variant: "ghost" }),
 | 
			
		||||
								"flex items-center gap-3 justify-start truncate",
 | 
			
		||||
								page?.path === item.href ? "bg-muted hover:bg-muted" : "hover:bg-muted/50"
 | 
			
		||||
								"flex items-center gap-3 justify-start truncate duration-50",
 | 
			
		||||
								page?.path === item.href ? "bg-muted hover:bg-accent/70" : "hover:bg-accent/50"
 | 
			
		||||
							)}
 | 
			
		||||
						>
 | 
			
		||||
							{item.icon && <item.icon className="size-4 shrink-0" />}
 | 
			
		||||
 
 | 
			
		||||
@@ -292,11 +292,11 @@ const SectionTable = memo(({ fingerprints = [] }: { fingerprints: FingerprintRec
 | 
			
		||||
				<TableBody className="whitespace-pre">
 | 
			
		||||
					{fingerprints.map((fingerprint, i) => (
 | 
			
		||||
						<TableRow key={i}>
 | 
			
		||||
							<TableCell className="font-medium ps-5 py-2.5">{fingerprint.expand.system.name}</TableCell>
 | 
			
		||||
							<TableCell className="font-mono text-[0.95em] py-2.5">{fingerprint.token}</TableCell>
 | 
			
		||||
							<TableCell className="font-mono text-[0.95em] py-2.5">{fingerprint.fingerprint}</TableCell>
 | 
			
		||||
							<TableCell className="font-medium ps-5 py-2">{fingerprint.expand.system.name}</TableCell>
 | 
			
		||||
							<TableCell className="font-mono text-[0.95em] py-2">{fingerprint.token}</TableCell>
 | 
			
		||||
							<TableCell className="font-mono text-[0.95em] py-2">{fingerprint.fingerprint}</TableCell>
 | 
			
		||||
							{!isReadOnly && (
 | 
			
		||||
								<TableCell className="py-2.5 px-4 xl:px-2">
 | 
			
		||||
								<TableCell className="py-2 px-4 xl:px-2">
 | 
			
		||||
									<ActionsButtonTable fingerprint={fingerprint} />
 | 
			
		||||
								</TableCell>
 | 
			
		||||
							)}
 | 
			
		||||
 
 | 
			
		||||
@@ -11,8 +11,8 @@ import {
 | 
			
		||||
	$temperatureFilter,
 | 
			
		||||
} from "@/lib/stores"
 | 
			
		||||
import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
 | 
			
		||||
import { ChartType, Unit, Os } from "@/lib/enums"
 | 
			
		||||
import React, { lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
 | 
			
		||||
import { ChartType, Unit, Os, SystemStatus } from "@/lib/enums"
 | 
			
		||||
import React, { lazy, memo, useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react"
 | 
			
		||||
import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card"
 | 
			
		||||
import { useStore } from "@nanostores/react"
 | 
			
		||||
import Spinner from "../spinner"
 | 
			
		||||
@@ -41,6 +41,7 @@ import { timeTicks } from "d3-time"
 | 
			
		||||
import { useLingui } from "@lingui/react/macro"
 | 
			
		||||
import { $router, navigate } from "../router"
 | 
			
		||||
import { getPagePath } from "@nanostores/router"
 | 
			
		||||
import { batteryStateTranslations } from "@/lib/i18n"
 | 
			
		||||
 | 
			
		||||
const AreaChartDefault = lazy(() => import("../charts/area-chart"))
 | 
			
		||||
const ContainerChart = lazy(() => import("../charts/container-chart"))
 | 
			
		||||
@@ -382,9 +383,9 @@ export default function SystemDetail({ name }: { name: string }) {
 | 
			
		||||
	const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined)
 | 
			
		||||
 | 
			
		||||
	let translatedStatus: string = system.status
 | 
			
		||||
	if (system.status === "up") {
 | 
			
		||||
	if (system.status === SystemStatus.Up) {
 | 
			
		||||
		translatedStatus = t({ message: "Up", comment: "Context: System is up" })
 | 
			
		||||
	} else if (system.status === "down") {
 | 
			
		||||
	} else if (system.status === SystemStatus.Down) {
 | 
			
		||||
		translatedStatus = t({ message: "Down", comment: "Context: System is down" })
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -399,7 +400,7 @@ export default function SystemDetail({ name }: { name: string }) {
 | 
			
		||||
							<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
 | 
			
		||||
								<div className="capitalize flex gap-2 items-center">
 | 
			
		||||
									<span className={cn("relative flex h-3 w-3")}>
 | 
			
		||||
										{system.status === "up" && (
 | 
			
		||||
										{system.status === SystemStatus.Up && (
 | 
			
		||||
											<span
 | 
			
		||||
												className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
 | 
			
		||||
												style={{ animationDuration: "1.5s" }}
 | 
			
		||||
@@ -407,10 +408,10 @@ export default function SystemDetail({ name }: { name: string }) {
 | 
			
		||||
										)}
 | 
			
		||||
										<span
 | 
			
		||||
											className={cn("relative inline-flex rounded-full h-3 w-3", {
 | 
			
		||||
												"bg-green-500": system.status === "up",
 | 
			
		||||
												"bg-red-500": system.status === "down",
 | 
			
		||||
												"bg-primary/40": system.status === "paused",
 | 
			
		||||
												"bg-yellow-500": system.status === "pending",
 | 
			
		||||
												"bg-green-500": system.status === SystemStatus.Up,
 | 
			
		||||
												"bg-red-500": system.status === SystemStatus.Down,
 | 
			
		||||
												"bg-primary/40": system.status === SystemStatus.Paused,
 | 
			
		||||
												"bg-yellow-500": system.status === SystemStatus.Pending,
 | 
			
		||||
											})}
 | 
			
		||||
										></span>
 | 
			
		||||
									</span>
 | 
			
		||||
@@ -456,9 +457,9 @@ export default function SystemDetail({ name }: { name: string }) {
 | 
			
		||||
											onClick={() => setGrid(!grid)}
 | 
			
		||||
										>
 | 
			
		||||
											{grid ? (
 | 
			
		||||
												<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-85" />
 | 
			
		||||
												<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-75" />
 | 
			
		||||
											) : (
 | 
			
		||||
												<Rows className="h-[1.3rem] w-[1.3rem] opacity-85" />
 | 
			
		||||
												<Rows className="h-[1.3rem] w-[1.3rem] opacity-75" />
 | 
			
		||||
											)}
 | 
			
		||||
										</Button>
 | 
			
		||||
									</TooltipTrigger>
 | 
			
		||||
@@ -668,6 +669,35 @@ export default function SystemDetail({ name }: { name: string }) {
 | 
			
		||||
						</ChartCard>
 | 
			
		||||
					)}
 | 
			
		||||
 | 
			
		||||
					{/* Battery chart */}
 | 
			
		||||
					{systemStats.at(-1)?.stats.bat && (
 | 
			
		||||
						<ChartCard
 | 
			
		||||
							empty={dataEmpty}
 | 
			
		||||
							grid={grid}
 | 
			
		||||
							title={t`Battery`}
 | 
			
		||||
							description={`${t({
 | 
			
		||||
								message: "Current state",
 | 
			
		||||
								comment: "Context: Battery state",
 | 
			
		||||
							})}: ${batteryStateTranslations[systemStats.at(-1)?.stats.bat![1] ?? 0]()}`}
 | 
			
		||||
						>
 | 
			
		||||
							<AreaChartDefault
 | 
			
		||||
								chartData={chartData}
 | 
			
		||||
								maxToggled={maxValues}
 | 
			
		||||
								dataPoints={[
 | 
			
		||||
									{
 | 
			
		||||
										label: t`Charge`,
 | 
			
		||||
										dataKey: ({ stats }) => stats?.bat?.[0],
 | 
			
		||||
										color: "1",
 | 
			
		||||
										opacity: 0.35,
 | 
			
		||||
									},
 | 
			
		||||
								]}
 | 
			
		||||
								domain={[0, 100]}
 | 
			
		||||
								tickFormatter={(val) => `${val}%`}
 | 
			
		||||
								contentFormatter={({ value }) => `${value}%`}
 | 
			
		||||
							/>
 | 
			
		||||
						</ChartCard>
 | 
			
		||||
					)}
 | 
			
		||||
 | 
			
		||||
					{/* GPU power draw chart */}
 | 
			
		||||
					{hasGpuPowerData && (
 | 
			
		||||
						<ChartCard
 | 
			
		||||
@@ -872,10 +902,10 @@ function ChartCard({
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<Card className={cn("pb-2 sm:pb-4 odd:last-of-type:col-span-full", { "col-span-full": !grid })} ref={ref}>
 | 
			
		||||
			<CardHeader className="pb-5 pt-4 relative space-y-1 max-sm:py-3 max-sm:px-4">
 | 
			
		||||
			<CardHeader className="pb-5 pt-4 gap-1 relative max-sm:py-3 max-sm:px-4">
 | 
			
		||||
				<CardTitle className="text-xl sm:text-2xl">{title}</CardTitle>
 | 
			
		||||
				<CardDescription>{description}</CardDescription>
 | 
			
		||||
				{cornerEl && <div className="relative py-1 block sm:w-44 sm:absolute sm:top-2.5 sm:end-3.5">{cornerEl}</div>}
 | 
			
		||||
				{cornerEl && <div className="relative py-1 block sm:w-44 sm:absolute sm:top-3.5 sm:end-3.5">{cornerEl}</div>}
 | 
			
		||||
			</CardHeader>
 | 
			
		||||
			<div className="ps-0 w-[calc(100%-1.5em)] h-48 md:h-52 relative group">
 | 
			
		||||
				{
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,448 @@
 | 
			
		||||
import { SystemRecord } from "@/types"
 | 
			
		||||
import { CellContext, ColumnDef, HeaderContext } from "@tanstack/react-table"
 | 
			
		||||
import { ClassValue } from "clsx"
 | 
			
		||||
import {
 | 
			
		||||
	ArrowUpDownIcon,
 | 
			
		||||
	CopyIcon,
 | 
			
		||||
	CpuIcon,
 | 
			
		||||
	HardDriveIcon,
 | 
			
		||||
	MemoryStickIcon,
 | 
			
		||||
	MoreHorizontalIcon,
 | 
			
		||||
	PauseCircleIcon,
 | 
			
		||||
	PenBoxIcon,
 | 
			
		||||
	PlayCircleIcon,
 | 
			
		||||
	ServerIcon,
 | 
			
		||||
	Trash2Icon,
 | 
			
		||||
	WifiIcon,
 | 
			
		||||
} from "lucide-react"
 | 
			
		||||
import { Button } from "../ui/button"
 | 
			
		||||
import {
 | 
			
		||||
	cn,
 | 
			
		||||
	copyToClipboard,
 | 
			
		||||
	decimalString,
 | 
			
		||||
	formatBytes,
 | 
			
		||||
	formatTemperature,
 | 
			
		||||
	getMeterState,
 | 
			
		||||
	isReadOnlyUser,
 | 
			
		||||
	parseSemVer,
 | 
			
		||||
} from "@/lib/utils"
 | 
			
		||||
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons"
 | 
			
		||||
import { useStore } from "@nanostores/react"
 | 
			
		||||
import { $userSettings, pb } from "@/lib/stores"
 | 
			
		||||
import { Trans, useLingui } from "@lingui/react/macro"
 | 
			
		||||
import { useMemo, useRef, useState } from "react"
 | 
			
		||||
import { memo } from "react"
 | 
			
		||||
import {
 | 
			
		||||
	DropdownMenu,
 | 
			
		||||
	DropdownMenuContent,
 | 
			
		||||
	DropdownMenuItem,
 | 
			
		||||
	DropdownMenuSeparator,
 | 
			
		||||
	DropdownMenuTrigger,
 | 
			
		||||
} from "../ui/dropdown-menu"
 | 
			
		||||
import AlertButton from "../alerts/alert-button"
 | 
			
		||||
import { Dialog } from "../ui/dialog"
 | 
			
		||||
import { SystemDialog } from "../add-system"
 | 
			
		||||
import { AlertDialog } from "../ui/alert-dialog"
 | 
			
		||||
import {
 | 
			
		||||
	AlertDialogAction,
 | 
			
		||||
	AlertDialogCancel,
 | 
			
		||||
	AlertDialogContent,
 | 
			
		||||
	AlertDialogDescription,
 | 
			
		||||
	AlertDialogFooter,
 | 
			
		||||
	AlertDialogHeader,
 | 
			
		||||
	AlertDialogTitle,
 | 
			
		||||
} from "../ui/alert-dialog"
 | 
			
		||||
import { buttonVariants } from "../ui/button"
 | 
			
		||||
import { t } from "@lingui/core/macro"
 | 
			
		||||
import { MeterState, SystemStatus } from "@/lib/enums"
 | 
			
		||||
import { $router, Link } from "../router"
 | 
			
		||||
import { getPagePath } from "@nanostores/router"
 | 
			
		||||
 | 
			
		||||
const STATUS_COLORS = {
 | 
			
		||||
	[SystemStatus.Up]: "bg-green-500",
 | 
			
		||||
	[SystemStatus.Down]: "bg-red-500",
 | 
			
		||||
	[SystemStatus.Paused]: "bg-primary/40",
 | 
			
		||||
	[SystemStatus.Pending]: "bg-yellow-500",
 | 
			
		||||
} as const
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @param viewMode - "table" or "grid"
 | 
			
		||||
 * @returns - Column definitions for the systems table
 | 
			
		||||
 */
 | 
			
		||||
export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<SystemRecord>[] {
 | 
			
		||||
	return [
 | 
			
		||||
		{
 | 
			
		||||
			size: 200,
 | 
			
		||||
			minSize: 0,
 | 
			
		||||
			accessorKey: "name",
 | 
			
		||||
			id: "system",
 | 
			
		||||
			name: () => t`System`,
 | 
			
		||||
			filterFn: (() => {
 | 
			
		||||
				let filterInput = ""
 | 
			
		||||
				let filterInputLower = ""
 | 
			
		||||
				const nameCache = new Map<string, string>()
 | 
			
		||||
				const statusTranslations = {
 | 
			
		||||
					[SystemStatus.Up]: t`Up`.toLowerCase(),
 | 
			
		||||
					[SystemStatus.Down]: t`Down`.toLowerCase(),
 | 
			
		||||
					[SystemStatus.Paused]: t`Paused`.toLowerCase(),
 | 
			
		||||
				} as const
 | 
			
		||||
 | 
			
		||||
				// match filter value against name or translated status
 | 
			
		||||
				return (row, _, newFilterInput) => {
 | 
			
		||||
					const { name, status } = row.original
 | 
			
		||||
					if (newFilterInput !== filterInput) {
 | 
			
		||||
						filterInput = newFilterInput
 | 
			
		||||
						filterInputLower = newFilterInput.toLowerCase()
 | 
			
		||||
					}
 | 
			
		||||
					let nameLower = nameCache.get(name)
 | 
			
		||||
					if (nameLower === undefined) {
 | 
			
		||||
						nameLower = name.toLowerCase()
 | 
			
		||||
						nameCache.set(name, nameLower)
 | 
			
		||||
					}
 | 
			
		||||
					if (nameLower.includes(filterInputLower)) {
 | 
			
		||||
						return true
 | 
			
		||||
					}
 | 
			
		||||
					const statusLower = statusTranslations[status as keyof typeof statusTranslations]
 | 
			
		||||
					return statusLower?.includes(filterInputLower) || false
 | 
			
		||||
				}
 | 
			
		||||
			})(),
 | 
			
		||||
			enableHiding: false,
 | 
			
		||||
			invertSorting: false,
 | 
			
		||||
			Icon: ServerIcon,
 | 
			
		||||
			cell: (info) => {
 | 
			
		||||
				const { name } = info.row.original
 | 
			
		||||
				return (
 | 
			
		||||
					<>
 | 
			
		||||
						<span className="flex gap-2 items-center font-medium text-sm text-nowrap md:ps-1 md:pe-5">
 | 
			
		||||
							<IndicatorDot system={info.row.original} />
 | 
			
		||||
							{name}
 | 
			
		||||
						</span>
 | 
			
		||||
						<Link
 | 
			
		||||
							href={getPagePath($router, "system", { name })}
 | 
			
		||||
							className="inset-0 absolute size-full"
 | 
			
		||||
							aria-label={name}
 | 
			
		||||
						></Link>
 | 
			
		||||
					</>
 | 
			
		||||
				)
 | 
			
		||||
			},
 | 
			
		||||
			header: sortableHeader,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			accessorFn: ({ info }) => info.cpu,
 | 
			
		||||
			id: "cpu",
 | 
			
		||||
			name: () => t`CPU`,
 | 
			
		||||
			cell: TableCellWithMeter,
 | 
			
		||||
			Icon: CpuIcon,
 | 
			
		||||
			header: sortableHeader,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			// accessorKey: "info.mp",
 | 
			
		||||
			accessorFn: ({ info }) => info.mp,
 | 
			
		||||
			id: "memory",
 | 
			
		||||
			name: () => t`Memory`,
 | 
			
		||||
			cell: TableCellWithMeter,
 | 
			
		||||
			Icon: MemoryStickIcon,
 | 
			
		||||
			header: sortableHeader,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			accessorFn: ({ info }) => info.dp,
 | 
			
		||||
			id: "disk",
 | 
			
		||||
			name: () => t`Disk`,
 | 
			
		||||
			cell: TableCellWithMeter,
 | 
			
		||||
			Icon: HardDriveIcon,
 | 
			
		||||
			header: sortableHeader,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			accessorFn: ({ info }) => info.g,
 | 
			
		||||
			id: "gpu",
 | 
			
		||||
			name: () => "GPU",
 | 
			
		||||
			cell: TableCellWithMeter,
 | 
			
		||||
			Icon: GpuIcon,
 | 
			
		||||
			header: sortableHeader,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			id: "loadAverage",
 | 
			
		||||
			accessorFn: ({ info }) => {
 | 
			
		||||
				const sum = info.la?.reduce((acc, curr) => acc + curr, 0)
 | 
			
		||||
				// TODO: remove this in future release in favor of la array
 | 
			
		||||
				if (!sum) {
 | 
			
		||||
					return (info.l1 ?? 0) + (info.l5 ?? 0) + (info.l15 ?? 0)
 | 
			
		||||
				}
 | 
			
		||||
				return sum
 | 
			
		||||
			},
 | 
			
		||||
			name: () => t({ message: "Load Avg", comment: "Short label for load average" }),
 | 
			
		||||
			size: 0,
 | 
			
		||||
			Icon: HourglassIcon,
 | 
			
		||||
			header: sortableHeader,
 | 
			
		||||
			cell(info: CellContext<SystemRecord, unknown>) {
 | 
			
		||||
				const { info: sysInfo, status } = info.row.original
 | 
			
		||||
				// agent version
 | 
			
		||||
				const { minor, patch } = parseSemVer(sysInfo.v)
 | 
			
		||||
				let loadAverages = sysInfo.la
 | 
			
		||||
 | 
			
		||||
				// use legacy load averages if agent version is less than 12.1.0
 | 
			
		||||
				if (!loadAverages || (minor === 12 && patch < 1)) {
 | 
			
		||||
					loadAverages = [sysInfo.l1 ?? 0, sysInfo.l5 ?? 0, sysInfo.l15 ?? 0]
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				const max = Math.max(...loadAverages)
 | 
			
		||||
				if (max === 0 && (status === SystemStatus.Paused || minor < 12)) {
 | 
			
		||||
					return null
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				const normalizedLoad = max / (sysInfo.t ?? 1)
 | 
			
		||||
				const threshold = getMeterState(normalizedLoad * 100)
 | 
			
		||||
 | 
			
		||||
				return (
 | 
			
		||||
					<div className="flex items-center gap-[.35em] w-full tabular-nums tracking-tight">
 | 
			
		||||
						<span
 | 
			
		||||
							className={cn("inline-block size-2 rounded-full me-0.5", {
 | 
			
		||||
								[STATUS_COLORS[SystemStatus.Up]]: threshold === MeterState.Good,
 | 
			
		||||
								[STATUS_COLORS[SystemStatus.Pending]]: threshold === MeterState.Warn,
 | 
			
		||||
								[STATUS_COLORS[SystemStatus.Down]]: threshold === MeterState.Crit,
 | 
			
		||||
								[STATUS_COLORS[SystemStatus.Paused]]: status !== SystemStatus.Up,
 | 
			
		||||
							})}
 | 
			
		||||
						/>
 | 
			
		||||
						{loadAverages?.map((la, i) => (
 | 
			
		||||
							<span key={i}>{decimalString(la, la >= 10 ? 1 : 2)}</span>
 | 
			
		||||
						))}
 | 
			
		||||
					</div>
 | 
			
		||||
				)
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024,
 | 
			
		||||
			id: "net",
 | 
			
		||||
			name: () => t`Net`,
 | 
			
		||||
			size: 0,
 | 
			
		||||
			Icon: EthernetIcon,
 | 
			
		||||
			header: sortableHeader,
 | 
			
		||||
			cell(info) {
 | 
			
		||||
				const sys = info.row.original
 | 
			
		||||
				const userSettings = useStore($userSettings, { keys: ["unitNet"] })
 | 
			
		||||
				if (sys.status === SystemStatus.Paused) {
 | 
			
		||||
					return null
 | 
			
		||||
				}
 | 
			
		||||
				const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, false)
 | 
			
		||||
				return (
 | 
			
		||||
					<span className="tabular-nums whitespace-nowrap">
 | 
			
		||||
						{decimalString(value, value >= 100 ? 1 : 2)} {unit}
 | 
			
		||||
					</span>
 | 
			
		||||
				)
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			accessorFn: ({ info }) => info.dt,
 | 
			
		||||
			id: "temp",
 | 
			
		||||
			name: () => t({ message: "Temp", comment: "Temperature label in systems table" }),
 | 
			
		||||
			size: 50,
 | 
			
		||||
			hideSort: true,
 | 
			
		||||
			Icon: ThermometerIcon,
 | 
			
		||||
			header: sortableHeader,
 | 
			
		||||
			cell(info) {
 | 
			
		||||
				const val = info.getValue() as number
 | 
			
		||||
				const userSettings = useStore($userSettings, { keys: ["unitTemp"] })
 | 
			
		||||
				if (!val) {
 | 
			
		||||
					return null
 | 
			
		||||
				}
 | 
			
		||||
				const { value, unit } = formatTemperature(val, userSettings.unitTemp)
 | 
			
		||||
				return (
 | 
			
		||||
					<span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-0.5")}>
 | 
			
		||||
						{decimalString(value, value >= 100 ? 1 : 2)} {unit}
 | 
			
		||||
					</span>
 | 
			
		||||
				)
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			accessorFn: ({ info }) => info.v,
 | 
			
		||||
			id: "agent",
 | 
			
		||||
			name: () => t`Agent`,
 | 
			
		||||
			// invertSorting: true,
 | 
			
		||||
			size: 50,
 | 
			
		||||
			Icon: WifiIcon,
 | 
			
		||||
			hideSort: true,
 | 
			
		||||
			header: sortableHeader,
 | 
			
		||||
			cell(info) {
 | 
			
		||||
				const version = info.getValue() as string
 | 
			
		||||
				if (!version) {
 | 
			
		||||
					return null
 | 
			
		||||
				}
 | 
			
		||||
				const system = info.row.original
 | 
			
		||||
				return (
 | 
			
		||||
					<span className={cn("flex gap-1.5 items-center md:pe-5 tabular-nums", viewMode === "table" && "ps-0.5")}>
 | 
			
		||||
						<IndicatorDot
 | 
			
		||||
							system={system}
 | 
			
		||||
							className={
 | 
			
		||||
								(system.status !== SystemStatus.Up && STATUS_COLORS[SystemStatus.Paused]) ||
 | 
			
		||||
								(version === globalThis.BESZEL.HUB_VERSION && STATUS_COLORS[SystemStatus.Up]) ||
 | 
			
		||||
								STATUS_COLORS[SystemStatus.Pending]
 | 
			
		||||
							}
 | 
			
		||||
						/>
 | 
			
		||||
						<span className="truncate max-w-14">{info.getValue() as string}</span>
 | 
			
		||||
					</span>
 | 
			
		||||
				)
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			id: "actions",
 | 
			
		||||
			// @ts-ignore
 | 
			
		||||
			name: () => t({ message: "Actions", comment: "Table column" }),
 | 
			
		||||
			size: 50,
 | 
			
		||||
			cell: ({ row }) => (
 | 
			
		||||
				<div className="relative z-10 flex justify-end items-center gap-1 -ms-3">
 | 
			
		||||
					<AlertButton system={row.original} />
 | 
			
		||||
					<ActionsButton system={row.original} />
 | 
			
		||||
				</div>
 | 
			
		||||
			),
 | 
			
		||||
		},
 | 
			
		||||
	] as ColumnDef<SystemRecord>[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function sortableHeader(context: HeaderContext<SystemRecord, unknown>) {
 | 
			
		||||
	const { column } = context
 | 
			
		||||
	// @ts-ignore
 | 
			
		||||
	const { Icon, hideSort, name }: { Icon: React.ElementType; name: () => string; hideSort: boolean } = column.columnDef
 | 
			
		||||
	return (
 | 
			
		||||
		<Button
 | 
			
		||||
			variant="ghost"
 | 
			
		||||
			className="h-9 px-3 flex"
 | 
			
		||||
			onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
 | 
			
		||||
		>
 | 
			
		||||
			{Icon && <Icon className="me-2 size-4" />}
 | 
			
		||||
			{name()}
 | 
			
		||||
			{hideSort || <ArrowUpDownIcon className="ms-2 size-4" />}
 | 
			
		||||
		</Button>
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
 | 
			
		||||
	const val = Number(info.getValue()) || 0
 | 
			
		||||
	const threshold = getMeterState(val)
 | 
			
		||||
	const meterClass = cn(
 | 
			
		||||
		"h-full",
 | 
			
		||||
		(info.row.original.status !== SystemStatus.Up && STATUS_COLORS.paused) ||
 | 
			
		||||
			(threshold === MeterState.Good && STATUS_COLORS.up) ||
 | 
			
		||||
			(threshold === MeterState.Warn && STATUS_COLORS.pending) ||
 | 
			
		||||
			STATUS_COLORS.down
 | 
			
		||||
	)
 | 
			
		||||
	return (
 | 
			
		||||
		<div className="flex gap-2 items-center tabular-nums tracking-tight">
 | 
			
		||||
			<span className="min-w-8">{decimalString(val, val >= 10 ? 1 : 2)}%</span>
 | 
			
		||||
			<span className="grow min-w-8 grid bg-muted h-[1em] rounded-sm overflow-hidden">
 | 
			
		||||
				<span className={meterClass} style={{ width: `${val}%` }}></span>
 | 
			
		||||
			</span>
 | 
			
		||||
		</div>
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
 | 
			
		||||
	className ||= STATUS_COLORS[system.status as keyof typeof STATUS_COLORS] || ""
 | 
			
		||||
	return (
 | 
			
		||||
		<span
 | 
			
		||||
			className={cn("shrink-0 size-2 rounded-full", className)}
 | 
			
		||||
			// style={{ marginBottom: "-1px" }}
 | 
			
		||||
		/>
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
 | 
			
		||||
	const [deleteOpen, setDeleteOpen] = useState(false)
 | 
			
		||||
	const [editOpen, setEditOpen] = useState(false)
 | 
			
		||||
	let editOpened = useRef(false)
 | 
			
		||||
	const { t } = useLingui()
 | 
			
		||||
	const { id, status, host, name } = system
 | 
			
		||||
 | 
			
		||||
	return useMemo(() => {
 | 
			
		||||
		return (
 | 
			
		||||
			<>
 | 
			
		||||
				<DropdownMenu>
 | 
			
		||||
					<DropdownMenuTrigger asChild>
 | 
			
		||||
						<Button variant="ghost" size={"icon"}>
 | 
			
		||||
							<span className="sr-only">
 | 
			
		||||
								<Trans>Open menu</Trans>
 | 
			
		||||
							</span>
 | 
			
		||||
							<MoreHorizontalIcon className="w-5" />
 | 
			
		||||
						</Button>
 | 
			
		||||
					</DropdownMenuTrigger>
 | 
			
		||||
					<DropdownMenuContent align="end">
 | 
			
		||||
						{!isReadOnlyUser() && (
 | 
			
		||||
							<DropdownMenuItem
 | 
			
		||||
								onSelect={() => {
 | 
			
		||||
									editOpened.current = true
 | 
			
		||||
									setEditOpen(true)
 | 
			
		||||
								}}
 | 
			
		||||
							>
 | 
			
		||||
								<PenBoxIcon className="me-2.5 size-4" />
 | 
			
		||||
								<Trans>Edit</Trans>
 | 
			
		||||
							</DropdownMenuItem>
 | 
			
		||||
						)}
 | 
			
		||||
						<DropdownMenuItem
 | 
			
		||||
							className={cn(isReadOnlyUser() && "hidden")}
 | 
			
		||||
							onClick={() => {
 | 
			
		||||
								pb.collection("systems").update(id, {
 | 
			
		||||
									status: status === SystemStatus.Paused ? SystemStatus.Pending : SystemStatus.Paused,
 | 
			
		||||
								})
 | 
			
		||||
							}}
 | 
			
		||||
						>
 | 
			
		||||
							{status === SystemStatus.Paused ? (
 | 
			
		||||
								<>
 | 
			
		||||
									<PlayCircleIcon className="me-2.5 size-4" />
 | 
			
		||||
									<Trans>Resume</Trans>
 | 
			
		||||
								</>
 | 
			
		||||
							) : (
 | 
			
		||||
								<>
 | 
			
		||||
									<PauseCircleIcon className="me-2.5 size-4" />
 | 
			
		||||
									<Trans>Pause</Trans>
 | 
			
		||||
								</>
 | 
			
		||||
							)}
 | 
			
		||||
						</DropdownMenuItem>
 | 
			
		||||
						<DropdownMenuItem onClick={() => copyToClipboard(name)}>
 | 
			
		||||
							<CopyIcon className="me-2.5 size-4" />
 | 
			
		||||
							<Trans>Copy name</Trans>
 | 
			
		||||
						</DropdownMenuItem>
 | 
			
		||||
						<DropdownMenuItem onClick={() => copyToClipboard(host)}>
 | 
			
		||||
							<CopyIcon className="me-2.5 size-4" />
 | 
			
		||||
							<Trans>Copy host</Trans>
 | 
			
		||||
						</DropdownMenuItem>
 | 
			
		||||
						<DropdownMenuSeparator className={cn(isReadOnlyUser() && "hidden")} />
 | 
			
		||||
						<DropdownMenuItem className={cn(isReadOnlyUser() && "hidden")} onSelect={() => setDeleteOpen(true)}>
 | 
			
		||||
							<Trash2Icon className="me-2.5 size-4" />
 | 
			
		||||
							<Trans>Delete</Trans>
 | 
			
		||||
						</DropdownMenuItem>
 | 
			
		||||
					</DropdownMenuContent>
 | 
			
		||||
				</DropdownMenu>
 | 
			
		||||
				{/* edit dialog */}
 | 
			
		||||
				<Dialog open={editOpen} onOpenChange={setEditOpen}>
 | 
			
		||||
					{editOpened.current && <SystemDialog system={system} setOpen={setEditOpen} />}
 | 
			
		||||
				</Dialog>
 | 
			
		||||
				{/* deletion dialog */}
 | 
			
		||||
				<AlertDialog open={deleteOpen} onOpenChange={(open) => setDeleteOpen(open)}>
 | 
			
		||||
					<AlertDialogContent>
 | 
			
		||||
						<AlertDialogHeader>
 | 
			
		||||
							<AlertDialogTitle>
 | 
			
		||||
								<Trans>Are you sure you want to delete {name}?</Trans>
 | 
			
		||||
							</AlertDialogTitle>
 | 
			
		||||
							<AlertDialogDescription>
 | 
			
		||||
								<Trans>
 | 
			
		||||
									This action cannot be undone. This will permanently delete all current records for {name} from the
 | 
			
		||||
									database.
 | 
			
		||||
								</Trans>
 | 
			
		||||
							</AlertDialogDescription>
 | 
			
		||||
						</AlertDialogHeader>
 | 
			
		||||
						<AlertDialogFooter>
 | 
			
		||||
							<AlertDialogCancel>
 | 
			
		||||
								<Trans>Cancel</Trans>
 | 
			
		||||
							</AlertDialogCancel>
 | 
			
		||||
							<AlertDialogAction
 | 
			
		||||
								className={cn(buttonVariants({ variant: "destructive" }))}
 | 
			
		||||
								onClick={() => pb.collection("systems").delete(id)}
 | 
			
		||||
							>
 | 
			
		||||
								<Trans>Continue</Trans>
 | 
			
		||||
							</AlertDialogAction>
 | 
			
		||||
						</AlertDialogFooter>
 | 
			
		||||
					</AlertDialogContent>
 | 
			
		||||
				</AlertDialog>
 | 
			
		||||
			</>
 | 
			
		||||
		)
 | 
			
		||||
	}, [id, status, host, name, t, deleteOpen, editOpen])
 | 
			
		||||
})
 | 
			
		||||
@@ -1,5 +1,4 @@
 | 
			
		||||
import {
 | 
			
		||||
	CellContext,
 | 
			
		||||
	ColumnDef,
 | 
			
		||||
	ColumnFiltersState,
 | 
			
		||||
	getFilteredRowModel,
 | 
			
		||||
@@ -9,14 +8,13 @@ import {
 | 
			
		||||
	VisibilityState,
 | 
			
		||||
	getCoreRowModel,
 | 
			
		||||
	useReactTable,
 | 
			
		||||
	HeaderContext,
 | 
			
		||||
	Row,
 | 
			
		||||
	Table as TableType,
 | 
			
		||||
} from "@tanstack/react-table"
 | 
			
		||||
 | 
			
		||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
 | 
			
		||||
 | 
			
		||||
import { Button, buttonVariants } from "@/components/ui/button"
 | 
			
		||||
import { Button } from "@/components/ui/button"
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
	DropdownMenu,
 | 
			
		||||
@@ -29,105 +27,31 @@ import {
 | 
			
		||||
	DropdownMenuSeparator,
 | 
			
		||||
	DropdownMenuTrigger,
 | 
			
		||||
} from "@/components/ui/dropdown-menu"
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
	AlertDialog,
 | 
			
		||||
	AlertDialogAction,
 | 
			
		||||
	AlertDialogCancel,
 | 
			
		||||
	AlertDialogContent,
 | 
			
		||||
	AlertDialogDescription,
 | 
			
		||||
	AlertDialogFooter,
 | 
			
		||||
	AlertDialogHeader,
 | 
			
		||||
	AlertDialogTitle,
 | 
			
		||||
} from "@/components/ui/alert-dialog"
 | 
			
		||||
 | 
			
		||||
import { SystemRecord } from "@/types"
 | 
			
		||||
import {
 | 
			
		||||
	MoreHorizontalIcon,
 | 
			
		||||
	ArrowUpDownIcon,
 | 
			
		||||
	MemoryStickIcon,
 | 
			
		||||
	CopyIcon,
 | 
			
		||||
	PauseCircleIcon,
 | 
			
		||||
	PlayCircleIcon,
 | 
			
		||||
	Trash2Icon,
 | 
			
		||||
	WifiIcon,
 | 
			
		||||
	HardDriveIcon,
 | 
			
		||||
	ServerIcon,
 | 
			
		||||
	CpuIcon,
 | 
			
		||||
	LayoutGridIcon,
 | 
			
		||||
	LayoutListIcon,
 | 
			
		||||
	ArrowDownIcon,
 | 
			
		||||
	ArrowUpIcon,
 | 
			
		||||
	Settings2Icon,
 | 
			
		||||
	EyeIcon,
 | 
			
		||||
	PenBoxIcon,
 | 
			
		||||
} from "lucide-react"
 | 
			
		||||
import { memo, useEffect, useMemo, useRef, useState } from "react"
 | 
			
		||||
import { $systems, $userSettings, pb } from "@/lib/stores"
 | 
			
		||||
import { memo, useEffect, useMemo, useState } from "react"
 | 
			
		||||
import { $systems } from "@/lib/stores"
 | 
			
		||||
import { useStore } from "@nanostores/react"
 | 
			
		||||
import {
 | 
			
		||||
	cn,
 | 
			
		||||
	copyToClipboard,
 | 
			
		||||
	isReadOnlyUser,
 | 
			
		||||
	useLocalStorage,
 | 
			
		||||
	formatTemperature,
 | 
			
		||||
	decimalString,
 | 
			
		||||
	formatBytes,
 | 
			
		||||
	parseSemVer,
 | 
			
		||||
} from "@/lib/utils"
 | 
			
		||||
import AlertsButton from "../alerts/alert-button"
 | 
			
		||||
import { $router, Link, navigate } from "../router"
 | 
			
		||||
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons"
 | 
			
		||||
import { cn, useLocalStorage } from "@/lib/utils"
 | 
			
		||||
import { $router, Link } from "../router"
 | 
			
		||||
import { useLingui, Trans } from "@lingui/react/macro"
 | 
			
		||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
 | 
			
		||||
import { Input } from "../ui/input"
 | 
			
		||||
import { ClassValue } from "clsx"
 | 
			
		||||
import { getPagePath } from "@nanostores/router"
 | 
			
		||||
import { SystemDialog } from "../add-system"
 | 
			
		||||
import { Dialog } from "../ui/dialog"
 | 
			
		||||
import SystemsTableColumns, { ActionsButton, IndicatorDot } from "./systems-table-columns"
 | 
			
		||||
import AlertButton from "../alerts/alert-button"
 | 
			
		||||
import { SystemStatus } from "@/lib/enums"
 | 
			
		||||
 | 
			
		||||
type ViewMode = "table" | "grid"
 | 
			
		||||
 | 
			
		||||
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
 | 
			
		||||
	const val = Number(info.getValue()) || 0
 | 
			
		||||
	return (
 | 
			
		||||
		<div className="flex gap-2 items-center tabular-nums tracking-tight">
 | 
			
		||||
			<span className="min-w-8">{decimalString(val, val >= 10 ? 1 : 2)}%</span>
 | 
			
		||||
			<span className="grow min-w-8 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
 | 
			
		||||
				<span
 | 
			
		||||
					className={cn(
 | 
			
		||||
						"absolute inset-0 w-full h-full origin-left",
 | 
			
		||||
						(info.row.original.status !== "up" && "bg-primary/30") ||
 | 
			
		||||
							(val < 65 && "bg-green-500") ||
 | 
			
		||||
							(val < 90 && "bg-yellow-500") ||
 | 
			
		||||
							"bg-red-600"
 | 
			
		||||
					)}
 | 
			
		||||
					style={{
 | 
			
		||||
						transform: `scalex(${val / 100})`,
 | 
			
		||||
					}}
 | 
			
		||||
				></span>
 | 
			
		||||
			</span>
 | 
			
		||||
		</div>
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function sortableHeader(context: HeaderContext<SystemRecord, unknown>) {
 | 
			
		||||
	const { column } = context
 | 
			
		||||
	// @ts-ignore
 | 
			
		||||
	const { Icon, hideSort, name }: { Icon: React.ElementType; name: () => string; hideSort: boolean } = column.columnDef
 | 
			
		||||
	return (
 | 
			
		||||
		<Button
 | 
			
		||||
			variant="ghost"
 | 
			
		||||
			className="h-9 px-3 flex"
 | 
			
		||||
			onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
 | 
			
		||||
		>
 | 
			
		||||
			{Icon && <Icon className="me-2 size-4" />}
 | 
			
		||||
			{name()}
 | 
			
		||||
			{hideSort || <ArrowUpDownIcon className="ms-2 size-4" />}
 | 
			
		||||
		</Button>
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function SystemsTable() {
 | 
			
		||||
	const data = useStore($systems)
 | 
			
		||||
	const { i18n, t } = useLingui()
 | 
			
		||||
@@ -145,218 +69,7 @@ export default function SystemsTable() {
 | 
			
		||||
		}
 | 
			
		||||
	}, [filter])
 | 
			
		||||
 | 
			
		||||
	const columnDefs = useMemo(() => {
 | 
			
		||||
		const statusTranslations = {
 | 
			
		||||
			up: () => t`Up`.toLowerCase(),
 | 
			
		||||
			down: () => t`Down`.toLowerCase(),
 | 
			
		||||
			paused: () => t`Paused`.toLowerCase(),
 | 
			
		||||
		}
 | 
			
		||||
		return [
 | 
			
		||||
			{
 | 
			
		||||
				size: 200,
 | 
			
		||||
				minSize: 0,
 | 
			
		||||
				accessorKey: "name",
 | 
			
		||||
				id: "system",
 | 
			
		||||
				name: () => t`System`,
 | 
			
		||||
				filterFn: (row, _, filterVal) => {
 | 
			
		||||
					const filterLower = filterVal.toLowerCase()
 | 
			
		||||
					const { name, status } = row.original
 | 
			
		||||
					// Check if the filter matches the name or status for this row
 | 
			
		||||
					if (
 | 
			
		||||
						name.toLowerCase().includes(filterLower) ||
 | 
			
		||||
						statusTranslations[status as keyof typeof statusTranslations]?.().includes(filterLower)
 | 
			
		||||
					) {
 | 
			
		||||
						return true
 | 
			
		||||
					}
 | 
			
		||||
					return false
 | 
			
		||||
				},
 | 
			
		||||
				enableHiding: false,
 | 
			
		||||
				invertSorting: false,
 | 
			
		||||
				Icon: ServerIcon,
 | 
			
		||||
				cell: (info) => (
 | 
			
		||||
					<span className="flex gap-0.5 items-center text-base md:ps-1 md:pe-5">
 | 
			
		||||
						<IndicatorDot system={info.row.original} />
 | 
			
		||||
						<Button
 | 
			
		||||
							data-nolink
 | 
			
		||||
							variant={"ghost"}
 | 
			
		||||
							className="text-primary/90 h-7 px-1.5 gap-1.5"
 | 
			
		||||
							onClick={() => copyToClipboard(info.getValue() as string)}
 | 
			
		||||
						>
 | 
			
		||||
							{info.getValue() as string}
 | 
			
		||||
							<CopyIcon className="size-2.5" />
 | 
			
		||||
						</Button>
 | 
			
		||||
					</span>
 | 
			
		||||
				),
 | 
			
		||||
				header: sortableHeader,
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				accessorFn: ({ info }) => info.cpu,
 | 
			
		||||
				id: "cpu",
 | 
			
		||||
				name: () => t`CPU`,
 | 
			
		||||
				cell: CellFormatter,
 | 
			
		||||
				Icon: CpuIcon,
 | 
			
		||||
				header: sortableHeader,
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				// accessorKey: "info.mp",
 | 
			
		||||
				accessorFn: ({ info }) => info.mp,
 | 
			
		||||
				id: "memory",
 | 
			
		||||
				name: () => t`Memory`,
 | 
			
		||||
				cell: CellFormatter,
 | 
			
		||||
				Icon: MemoryStickIcon,
 | 
			
		||||
				header: sortableHeader,
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				accessorFn: ({ info }) => info.dp,
 | 
			
		||||
				id: "disk",
 | 
			
		||||
				name: () => t`Disk`,
 | 
			
		||||
				cell: CellFormatter,
 | 
			
		||||
				Icon: HardDriveIcon,
 | 
			
		||||
				header: sortableHeader,
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				accessorFn: ({ info }) => info.g,
 | 
			
		||||
				id: "gpu",
 | 
			
		||||
				name: () => "GPU",
 | 
			
		||||
				cell: CellFormatter,
 | 
			
		||||
				Icon: GpuIcon,
 | 
			
		||||
				header: sortableHeader,
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				id: "loadAverage",
 | 
			
		||||
				accessorFn: ({ info }) => {
 | 
			
		||||
					const sum = info.la?.reduce((acc, curr) => acc + curr, 0)
 | 
			
		||||
					// TODO: remove this in future release in favor of la array
 | 
			
		||||
					if (!sum) {
 | 
			
		||||
						return (info.l1 ?? 0) + (info.l5 ?? 0) + (info.l15 ?? 0)
 | 
			
		||||
					}
 | 
			
		||||
					return sum
 | 
			
		||||
				},
 | 
			
		||||
				name: () => t({ message: "Load Avg", comment: "Short label for load average" }),
 | 
			
		||||
				size: 0,
 | 
			
		||||
				Icon: HourglassIcon,
 | 
			
		||||
				header: sortableHeader,
 | 
			
		||||
				cell(info: CellContext<SystemRecord, unknown>) {
 | 
			
		||||
					const { info: sysInfo, status } = info.row.original
 | 
			
		||||
					// agent version
 | 
			
		||||
					const { minor, patch } = parseSemVer(sysInfo.v)
 | 
			
		||||
					let loadAverages = sysInfo.la
 | 
			
		||||
 | 
			
		||||
					// use legacy load averages if agent version is less than 12.1.0
 | 
			
		||||
					if (!loadAverages || (minor === 12 && patch < 1)) {
 | 
			
		||||
						loadAverages = [sysInfo.l1 ?? 0, sysInfo.l5 ?? 0, sysInfo.l15 ?? 0]
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					const max = Math.max(...loadAverages)
 | 
			
		||||
					if (max === 0 && (status === "paused" || minor < 12)) {
 | 
			
		||||
						return null
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					function getDotColor() {
 | 
			
		||||
						const normalized = max / (sysInfo.t ?? 1)
 | 
			
		||||
						if (status !== "up") return "bg-primary/30"
 | 
			
		||||
						if (normalized < 0.7) return "bg-green-500"
 | 
			
		||||
						if (normalized < 1) return "bg-yellow-500"
 | 
			
		||||
						return "bg-red-600"
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					return (
 | 
			
		||||
						<div className="flex items-center gap-[.35em] w-full tabular-nums tracking-tight">
 | 
			
		||||
							<span className={cn("inline-block size-2 rounded-full me-0.5", getDotColor())} />
 | 
			
		||||
							{loadAverages?.map((la, i) => (
 | 
			
		||||
								<span key={i}>{decimalString(la, la >= 10 ? 1 : 2)}</span>
 | 
			
		||||
							))}
 | 
			
		||||
						</div>
 | 
			
		||||
					)
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024,
 | 
			
		||||
				id: "net",
 | 
			
		||||
				name: () => t`Net`,
 | 
			
		||||
				size: 0,
 | 
			
		||||
				Icon: EthernetIcon,
 | 
			
		||||
				header: sortableHeader,
 | 
			
		||||
				cell(info) {
 | 
			
		||||
					const sys = info.row.original
 | 
			
		||||
					if (sys.status === "paused") {
 | 
			
		||||
						return null
 | 
			
		||||
					}
 | 
			
		||||
					const userSettings = useStore($userSettings)
 | 
			
		||||
					const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, false)
 | 
			
		||||
					return (
 | 
			
		||||
						<span className="tabular-nums whitespace-nowrap">
 | 
			
		||||
							{decimalString(value, value >= 100 ? 1 : 2)} {unit}
 | 
			
		||||
						</span>
 | 
			
		||||
					)
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				accessorFn: ({ info }) => info.dt,
 | 
			
		||||
				id: "temp",
 | 
			
		||||
				name: () => t({ message: "Temp", comment: "Temperature label in systems table" }),
 | 
			
		||||
				size: 50,
 | 
			
		||||
				hideSort: true,
 | 
			
		||||
				Icon: ThermometerIcon,
 | 
			
		||||
				header: sortableHeader,
 | 
			
		||||
				cell(info) {
 | 
			
		||||
					const val = info.getValue() as number
 | 
			
		||||
					if (!val) {
 | 
			
		||||
						return null
 | 
			
		||||
					}
 | 
			
		||||
					const userSettings = useStore($userSettings)
 | 
			
		||||
					const { value, unit } = formatTemperature(val, userSettings.unitTemp)
 | 
			
		||||
					return (
 | 
			
		||||
						<span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-0.5")}>
 | 
			
		||||
							{decimalString(value, value >= 100 ? 1 : 2)} {unit}
 | 
			
		||||
						</span>
 | 
			
		||||
					)
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				accessorFn: ({ info }) => info.v,
 | 
			
		||||
				id: "agent",
 | 
			
		||||
				name: () => t`Agent`,
 | 
			
		||||
				// invertSorting: true,
 | 
			
		||||
				size: 50,
 | 
			
		||||
				Icon: WifiIcon,
 | 
			
		||||
				hideSort: true,
 | 
			
		||||
				header: sortableHeader,
 | 
			
		||||
				cell(info) {
 | 
			
		||||
					const version = info.getValue() as string
 | 
			
		||||
					if (!version) {
 | 
			
		||||
						return null
 | 
			
		||||
					}
 | 
			
		||||
					const system = info.row.original
 | 
			
		||||
					return (
 | 
			
		||||
						<span className={cn("flex gap-2 items-center md:pe-5 tabular-nums", viewMode === "table" && "ps-0.5")}>
 | 
			
		||||
							<IndicatorDot
 | 
			
		||||
								system={system}
 | 
			
		||||
								className={
 | 
			
		||||
									(system.status !== "up" && "bg-primary/30") ||
 | 
			
		||||
									(version === globalThis.BESZEL.HUB_VERSION && "bg-green-500") ||
 | 
			
		||||
									"bg-yellow-500"
 | 
			
		||||
								}
 | 
			
		||||
							/>
 | 
			
		||||
							<span className="truncate max-w-14">{info.getValue() as string}</span>
 | 
			
		||||
						</span>
 | 
			
		||||
					)
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				id: "actions",
 | 
			
		||||
				// @ts-ignore
 | 
			
		||||
				name: () => t({ message: "Actions", comment: "Table column" }),
 | 
			
		||||
				size: 50,
 | 
			
		||||
				cell: ({ row }) => (
 | 
			
		||||
					<div className="flex justify-end items-center gap-1 -ms-3">
 | 
			
		||||
						<AlertsButton system={row.original} />
 | 
			
		||||
						<ActionsButton system={row.original} />
 | 
			
		||||
					</div>
 | 
			
		||||
				),
 | 
			
		||||
			},
 | 
			
		||||
		] as ColumnDef<SystemRecord>[]
 | 
			
		||||
	}, [])
 | 
			
		||||
	const columnDefs = useMemo(() => SystemsTableColumns(viewMode), [viewMode])
 | 
			
		||||
 | 
			
		||||
	const table = useReactTable({
 | 
			
		||||
		data,
 | 
			
		||||
@@ -579,15 +292,9 @@ const SystemTableRow = memo(
 | 
			
		||||
			return (
 | 
			
		||||
				<TableRow
 | 
			
		||||
					// data-state={row.getIsSelected() && "selected"}
 | 
			
		||||
					className={cn("cursor-pointer transition-opacity", {
 | 
			
		||||
						"opacity-50": system.status === "paused",
 | 
			
		||||
					className={cn("cursor-pointer transition-opacity relative", {
 | 
			
		||||
						"opacity-50": system.status === SystemStatus.Paused,
 | 
			
		||||
					})}
 | 
			
		||||
					onClick={(e) => {
 | 
			
		||||
						const target = e.target as HTMLElement
 | 
			
		||||
						if (!target.closest("[data-nolink]") && e.currentTarget.contains(target)) {
 | 
			
		||||
							navigate(getPagePath($router, "system", { name: system.name }))
 | 
			
		||||
						}
 | 
			
		||||
					}}
 | 
			
		||||
				>
 | 
			
		||||
					{row.getVisibleCells().map((cell) => (
 | 
			
		||||
						<TableCell
 | 
			
		||||
@@ -595,7 +302,7 @@ const SystemTableRow = memo(
 | 
			
		||||
							style={{
 | 
			
		||||
								width: cell.column.getSize(),
 | 
			
		||||
							}}
 | 
			
		||||
							className={cn("overflow-hidden relative", length > 10 ? "py-2" : "py-2.5")}
 | 
			
		||||
							className={length > 10 ? "py-2" : "py-2.5"}
 | 
			
		||||
						>
 | 
			
		||||
							{flexRender(cell.column.columnDef.cell, cell.getContext())}
 | 
			
		||||
						</TableCell>
 | 
			
		||||
@@ -618,7 +325,7 @@ const SystemCard = memo(
 | 
			
		||||
					className={cn(
 | 
			
		||||
						"cursor-pointer hover:shadow-md transition-all bg-transparent w-full dark:border-border duration-200 relative",
 | 
			
		||||
						{
 | 
			
		||||
							"opacity-50": system.status === "paused",
 | 
			
		||||
							"opacity-50": system.status === SystemStatus.Paused,
 | 
			
		||||
						}
 | 
			
		||||
					)}
 | 
			
		||||
				>
 | 
			
		||||
@@ -633,14 +340,14 @@ const SystemCard = memo(
 | 
			
		||||
								</div>
 | 
			
		||||
							</CardTitle>
 | 
			
		||||
							{table.getColumn("actions")?.getIsVisible() && (
 | 
			
		||||
								<div className="flex gap-1 flex-shrink-0 relative z-10">
 | 
			
		||||
									<AlertsButton system={system} />
 | 
			
		||||
								<div className="flex gap-1 shrink-0 relative z-10">
 | 
			
		||||
									<AlertButton system={system} />
 | 
			
		||||
									<ActionsButton system={system} />
 | 
			
		||||
								</div>
 | 
			
		||||
							)}
 | 
			
		||||
						</div>
 | 
			
		||||
					</CardHeader>
 | 
			
		||||
					<CardContent className="space-y-2.5 text-sm px-5 pt-3.5 pb-4">
 | 
			
		||||
					<CardContent className="grid gap-2.5 text-sm px-5 pt-3.5 pb-4">
 | 
			
		||||
						{table.getAllColumns().map((column) => {
 | 
			
		||||
							if (!column.getIsVisible() || column.id === "system" || column.id === "actions") return null
 | 
			
		||||
							const cell = row.getAllCells().find((cell) => cell.column.id === column.id)
 | 
			
		||||
@@ -669,116 +376,3 @@ const SystemCard = memo(
 | 
			
		||||
		}, [system, colLength, t])
 | 
			
		||||
	}
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
 | 
			
		||||
	const [deleteOpen, setDeleteOpen] = useState(false)
 | 
			
		||||
	const [editOpen, setEditOpen] = useState(false)
 | 
			
		||||
	let editOpened = useRef(false)
 | 
			
		||||
	const { t } = useLingui()
 | 
			
		||||
	const { id, status, host, name } = system
 | 
			
		||||
 | 
			
		||||
	return useMemo(() => {
 | 
			
		||||
		return (
 | 
			
		||||
			<>
 | 
			
		||||
				<DropdownMenu>
 | 
			
		||||
					<DropdownMenuTrigger asChild>
 | 
			
		||||
						<Button variant="ghost" size={"icon"} data-nolink>
 | 
			
		||||
							<span className="sr-only">
 | 
			
		||||
								<Trans>Open menu</Trans>
 | 
			
		||||
							</span>
 | 
			
		||||
							<MoreHorizontalIcon className="w-5" />
 | 
			
		||||
						</Button>
 | 
			
		||||
					</DropdownMenuTrigger>
 | 
			
		||||
					<DropdownMenuContent align="end">
 | 
			
		||||
						{!isReadOnlyUser() && (
 | 
			
		||||
							<DropdownMenuItem
 | 
			
		||||
								onSelect={() => {
 | 
			
		||||
									editOpened.current = true
 | 
			
		||||
									setEditOpen(true)
 | 
			
		||||
								}}
 | 
			
		||||
							>
 | 
			
		||||
								<PenBoxIcon className="me-2.5 size-4" />
 | 
			
		||||
								<Trans>Edit</Trans>
 | 
			
		||||
							</DropdownMenuItem>
 | 
			
		||||
						)}
 | 
			
		||||
						<DropdownMenuItem
 | 
			
		||||
							className={cn(isReadOnlyUser() && "hidden")}
 | 
			
		||||
							onClick={() => {
 | 
			
		||||
								pb.collection("systems").update(id, {
 | 
			
		||||
									status: status === "paused" ? "pending" : "paused",
 | 
			
		||||
								})
 | 
			
		||||
							}}
 | 
			
		||||
						>
 | 
			
		||||
							{status === "paused" ? (
 | 
			
		||||
								<>
 | 
			
		||||
									<PlayCircleIcon className="me-2.5 size-4" />
 | 
			
		||||
									<Trans>Resume</Trans>
 | 
			
		||||
								</>
 | 
			
		||||
							) : (
 | 
			
		||||
								<>
 | 
			
		||||
									<PauseCircleIcon className="me-2.5 size-4" />
 | 
			
		||||
									<Trans>Pause</Trans>
 | 
			
		||||
								</>
 | 
			
		||||
							)}
 | 
			
		||||
						</DropdownMenuItem>
 | 
			
		||||
						<DropdownMenuItem onClick={() => copyToClipboard(host)}>
 | 
			
		||||
							<CopyIcon className="me-2.5 size-4" />
 | 
			
		||||
							<Trans>Copy host</Trans>
 | 
			
		||||
						</DropdownMenuItem>
 | 
			
		||||
						<DropdownMenuSeparator className={cn(isReadOnlyUser() && "hidden")} />
 | 
			
		||||
						<DropdownMenuItem className={cn(isReadOnlyUser() && "hidden")} onSelect={() => setDeleteOpen(true)}>
 | 
			
		||||
							<Trash2Icon className="me-2.5 size-4" />
 | 
			
		||||
							<Trans>Delete</Trans>
 | 
			
		||||
						</DropdownMenuItem>
 | 
			
		||||
					</DropdownMenuContent>
 | 
			
		||||
				</DropdownMenu>
 | 
			
		||||
				{/* edit dialog */}
 | 
			
		||||
				<Dialog open={editOpen} onOpenChange={setEditOpen}>
 | 
			
		||||
					{editOpened.current && <SystemDialog system={system} setOpen={setEditOpen} />}
 | 
			
		||||
				</Dialog>
 | 
			
		||||
				{/* deletion dialog */}
 | 
			
		||||
				<AlertDialog open={deleteOpen} onOpenChange={(open) => setDeleteOpen(open)}>
 | 
			
		||||
					<AlertDialogContent>
 | 
			
		||||
						<AlertDialogHeader>
 | 
			
		||||
							<AlertDialogTitle>
 | 
			
		||||
								<Trans>Are you sure you want to delete {name}?</Trans>
 | 
			
		||||
							</AlertDialogTitle>
 | 
			
		||||
							<AlertDialogDescription>
 | 
			
		||||
								<Trans>
 | 
			
		||||
									This action cannot be undone. This will permanently delete all current records for {name} from the
 | 
			
		||||
									database.
 | 
			
		||||
								</Trans>
 | 
			
		||||
							</AlertDialogDescription>
 | 
			
		||||
						</AlertDialogHeader>
 | 
			
		||||
						<AlertDialogFooter>
 | 
			
		||||
							<AlertDialogCancel>
 | 
			
		||||
								<Trans>Cancel</Trans>
 | 
			
		||||
							</AlertDialogCancel>
 | 
			
		||||
							<AlertDialogAction
 | 
			
		||||
								className={cn(buttonVariants({ variant: "destructive" }))}
 | 
			
		||||
								onClick={() => pb.collection("systems").delete(id)}
 | 
			
		||||
							>
 | 
			
		||||
								<Trans>Continue</Trans>
 | 
			
		||||
							</AlertDialogAction>
 | 
			
		||||
						</AlertDialogFooter>
 | 
			
		||||
					</AlertDialogContent>
 | 
			
		||||
				</AlertDialog>
 | 
			
		||||
			</>
 | 
			
		||||
		)
 | 
			
		||||
	}, [id, status, host, name, t, deleteOpen, editOpen])
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
 | 
			
		||||
	className ||= {
 | 
			
		||||
		"bg-green-500": system.status === "up",
 | 
			
		||||
		"bg-red-500": system.status === "down",
 | 
			
		||||
		"bg-primary/40": system.status === "paused",
 | 
			
		||||
		"bg-yellow-500": system.status === "pending",
 | 
			
		||||
	}
 | 
			
		||||
	return (
 | 
			
		||||
		<span
 | 
			
		||||
			className={cn("flex-shrink-0 size-2 rounded-full", className)}
 | 
			
		||||
			// style={{ marginBottom: "-1px" }}
 | 
			
		||||
		/>
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef<
 | 
			
		||||
		<AlertDialogPrimitive.Content
 | 
			
		||||
			ref={ref}
 | 
			
		||||
			className={cn(
 | 
			
		||||
				"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
 | 
			
		||||
				"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-50% data-[state=closed]:slide-out-to-top-48% data-[state=open]:slide-in-from-left-50% data-[state=open]:slide-in-from-top-48% sm:rounded-lg",
 | 
			
		||||
				className
 | 
			
		||||
			)}
 | 
			
		||||
			{...props}
 | 
			
		||||
@@ -44,7 +44,7 @@ const AlertDialogContent = React.forwardRef<
 | 
			
		||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
 | 
			
		||||
 | 
			
		||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
 | 
			
		||||
	<div className={cn("flex flex-col space-y-2 text-center sm:text-start", className)} {...props} />
 | 
			
		||||
	<div className={cn("grid gap-2 text-center sm:text-start", className)} {...props} />
 | 
			
		||||
)
 | 
			
		||||
AlertDialogHeader.displayName = "AlertDialogHeader"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
 | 
			
		||||
const badgeVariants = cva(
 | 
			
		||||
	"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",
 | 
			
		||||
	"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
 | 
			
		||||
	{
 | 
			
		||||
		variants: {
 | 
			
		||||
			variant: {
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
 | 
			
		||||
const buttonVariants = cva(
 | 
			
		||||
	"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",
 | 
			
		||||
	"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer",
 | 
			
		||||
	{
 | 
			
		||||
		variants: {
 | 
			
		||||
			variant: {
 | 
			
		||||
@@ -13,7 +13,7 @@ const buttonVariants = cva(
 | 
			
		||||
				destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
 | 
			
		||||
				outline: "border bg-background hover:bg-accent/70 dark:hover:bg-accent/50 hover:text-accent-foreground",
 | 
			
		||||
				secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
 | 
			
		||||
				ghost: "hover:bg-accent hover:text-accent-foreground",
 | 
			
		||||
				ghost: "hover:bg-accent/70 hover:text-accent-foreground",
 | 
			
		||||
				link: "text-primary underline-offset-4 hover:underline",
 | 
			
		||||
			},
 | 
			
		||||
			size: {
 | 
			
		||||
 
 | 
			
		||||
@@ -5,16 +5,14 @@ import { cn } from "@/lib/utils"
 | 
			
		||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
 | 
			
		||||
	<div
 | 
			
		||||
		ref={ref}
 | 
			
		||||
		className={cn("rounded-lg border border-border/60 bg-card text-card-foreground shadow-sm", className)}
 | 
			
		||||
		className={cn("rounded-lg border border-border/60 bg-card text-card-foreground shadow-xs", className)}
 | 
			
		||||
		{...props}
 | 
			
		||||
	/>
 | 
			
		||||
))
 | 
			
		||||
Card.displayName = "Card"
 | 
			
		||||
 | 
			
		||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
 | 
			
		||||
	({ className, ...props }, ref) => (
 | 
			
		||||
		<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
 | 
			
		||||
	)
 | 
			
		||||
	({ className, ...props }, ref) => <div ref={ref} className={cn("grid gap-1.5 p-6", className)} {...props} />
 | 
			
		||||
)
 | 
			
		||||
CardHeader.displayName = "CardHeader"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,8 @@ import * as RechartsPrimitive from "recharts"
 | 
			
		||||
import { chartTimeData, cn } from "@/lib/utils"
 | 
			
		||||
import { ChartData } from "@/types"
 | 
			
		||||
 | 
			
		||||
import type { JSX } from "react"
 | 
			
		||||
 | 
			
		||||
// Format: { THEME_NAME: CSS_SELECTOR }
 | 
			
		||||
const THEMES = { light: "", dark: ".dark" } as const
 | 
			
		||||
 | 
			
		||||
@@ -42,11 +44,12 @@ const ChartContainer = React.forwardRef<
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		//<ChartContext.Provider value={{ config }}>
 | 
			
		||||
		//</ChartContext.Provider>
 | 
			
		||||
		<div
 | 
			
		||||
			data-chart={chartId}
 | 
			
		||||
			ref={ref}
 | 
			
		||||
			className={cn(
 | 
			
		||||
				"text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line-line]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
 | 
			
		||||
				"text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line-line]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-hidden [&_.recharts-surface]:outline-hidden",
 | 
			
		||||
				className
 | 
			
		||||
			)}
 | 
			
		||||
			{...props}
 | 
			
		||||
@@ -54,7 +57,6 @@ const ChartContainer = React.forwardRef<
 | 
			
		||||
			{/* <ChartStyle id={chartId} config={config} /> */}
 | 
			
		||||
			<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
 | 
			
		||||
		</div>
 | 
			
		||||
		//</ChartContext.Provider>
 | 
			
		||||
	)
 | 
			
		||||
})
 | 
			
		||||
ChartContainer.displayName = "Chart"
 | 
			
		||||
@@ -169,7 +171,7 @@ const ChartTooltipContent = React.forwardRef<
 | 
			
		||||
			<div
 | 
			
		||||
				ref={ref}
 | 
			
		||||
				className={cn(
 | 
			
		||||
					"grid min-w-[7rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
 | 
			
		||||
					"grid min-w-28 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
 | 
			
		||||
					className
 | 
			
		||||
				)}
 | 
			
		||||
			>
 | 
			
		||||
@@ -196,7 +198,7 @@ const ChartTooltipContent = React.forwardRef<
 | 
			
		||||
											<itemConfig.icon />
 | 
			
		||||
										) : (
 | 
			
		||||
											<div
 | 
			
		||||
												className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
 | 
			
		||||
												className={cn("shrink-0 rounded-[2px] border-border bg-(--color-bg)", {
 | 
			
		||||
													"h-2.5 w-2.5": indicator === "dot",
 | 
			
		||||
													"w-1": indicator === "line",
 | 
			
		||||
													"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
 | 
			
		||||
@@ -226,7 +228,7 @@ const ChartTooltipContent = React.forwardRef<
 | 
			
		||||
												{itemConfig?.label || item.name}
 | 
			
		||||
											</span>
 | 
			
		||||
											{item.value !== undefined && (
 | 
			
		||||
												<span className="font-medium tabular-nums text-foreground">
 | 
			
		||||
												<span className="font-medium text-foreground">
 | 
			
		||||
													{content && typeof content === "function"
 | 
			
		||||
														? content(item, key)
 | 
			
		||||
														: item.value.toLocaleString() + (unit ? unit : "")}
 | 
			
		||||
@@ -265,7 +267,7 @@ const ChartLegendContent = React.forwardRef<
 | 
			
		||||
		<div
 | 
			
		||||
			ref={ref}
 | 
			
		||||
			className={cn(
 | 
			
		||||
				"flex items-center justify-center gap-4 gap-y-1 flex-wrap",
 | 
			
		||||
				"flex items-center justify-center gap-4 gap-y-1 flex-wrap ps-4",
 | 
			
		||||
				verticalAlign === "top" ? "pb-3" : "pt-3",
 | 
			
		||||
				className
 | 
			
		||||
			)}
 | 
			
		||||
 
 | 
			
		||||
@@ -13,7 +13,7 @@ const Checkbox = React.forwardRef<
 | 
			
		||||
	<CheckboxPrimitive.Root
 | 
			
		||||
		ref={ref}
 | 
			
		||||
		className={cn(
 | 
			
		||||
			"peer size-4 flex items-center justify-center shrink-0 rounded-[.3em] border border-input ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
 | 
			
		||||
			"peer size-4 flex items-center justify-center shrink-0 rounded-[.3em] border border-input ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
 | 
			
		||||
 | 
			
		||||
			className
 | 
			
		||||
		)}
 | 
			
		||||
 
 | 
			
		||||
@@ -12,7 +12,7 @@ const Command = React.forwardRef<
 | 
			
		||||
>(({ className, ...props }, ref) => (
 | 
			
		||||
	<CommandPrimitive
 | 
			
		||||
		ref={ref}
 | 
			
		||||
		className={cn("flex h-full w-full flex-col overflow-hidden bg-popover text-popover-foreground", className)}
 | 
			
		||||
		className={cn("flex h-full w-full flex-col overflow-hidden bg-card", className)}
 | 
			
		||||
		{...props}
 | 
			
		||||
	/>
 | 
			
		||||
))
 | 
			
		||||
@@ -44,7 +44,7 @@ const CommandInput = React.forwardRef<
 | 
			
		||||
		<CommandPrimitive.Input
 | 
			
		||||
			ref={ref}
 | 
			
		||||
			className={cn(
 | 
			
		||||
				"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
 | 
			
		||||
				"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
 | 
			
		||||
				className
 | 
			
		||||
			)}
 | 
			
		||||
			{...props}
 | 
			
		||||
@@ -105,7 +105,7 @@ const CommandItem = React.forwardRef<
 | 
			
		||||
	<CommandPrimitive.Item
 | 
			
		||||
		ref={ref}
 | 
			
		||||
		className={cn(
 | 
			
		||||
			"relative flex cursor-default opacity-70 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:opacity-90 data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50",
 | 
			
		||||
			"relative flex cursor-default opacity-70 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden aria-selected:bg-accent/60 aria-selected:opacity-90 data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50",
 | 
			
		||||
			className
 | 
			
		||||
		)}
 | 
			
		||||
		{...props}
 | 
			
		||||
 
 | 
			
		||||
@@ -36,13 +36,13 @@ const DialogContent = React.forwardRef<
 | 
			
		||||
		<DialogPrimitive.Content
 | 
			
		||||
			ref={ref}
 | 
			
		||||
			className={cn(
 | 
			
		||||
				"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
 | 
			
		||||
				"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-50% data-[state=closed]:slide-out-to-top-48% data-[state=open]:slide-in-from-left-50% data-[state=open]:slide-in-from-top-48% sm:rounded-lg",
 | 
			
		||||
				className
 | 
			
		||||
			)}
 | 
			
		||||
			{...props}
 | 
			
		||||
		>
 | 
			
		||||
			{children}
 | 
			
		||||
			<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
 | 
			
		||||
			<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
 | 
			
		||||
				<X className="h-4 w-4" />
 | 
			
		||||
				<span className="sr-only">Close</span>
 | 
			
		||||
			</DialogPrimitive.Close>
 | 
			
		||||
@@ -52,7 +52,7 @@ const DialogContent = React.forwardRef<
 | 
			
		||||
DialogContent.displayName = DialogPrimitive.Content.displayName
 | 
			
		||||
 | 
			
		||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
 | 
			
		||||
	<div className={cn("flex flex-col space-y-1.5 text-center sm:text-start", className)} {...props} />
 | 
			
		||||
	<div className={cn("grid gap-1.5 text-center sm:text-start", className)} {...props} />
 | 
			
		||||
)
 | 
			
		||||
DialogHeader.displayName = "DialogHeader"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -25,7 +25,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
 | 
			
		||||
	<DropdownMenuPrimitive.SubTrigger
 | 
			
		||||
		ref={ref}
 | 
			
		||||
		className={cn(
 | 
			
		||||
			"flex cursor-default select-none items-center rounded-sm px-2.5 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
 | 
			
		||||
			"flex select-none items-center rounded-sm px-2.5 py-1.5 text-sm outline-hidden focus:bg-accent/70 data-[state=open]:bg-accent/70",
 | 
			
		||||
			inset && "ps-8",
 | 
			
		||||
			className
 | 
			
		||||
		)}
 | 
			
		||||
@@ -44,7 +44,7 @@ const DropdownMenuSubContent = React.forwardRef<
 | 
			
		||||
	<DropdownMenuPrimitive.SubContent
 | 
			
		||||
		ref={ref}
 | 
			
		||||
		className={cn(
 | 
			
		||||
			"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
 | 
			
		||||
			"z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
 | 
			
		||||
			className
 | 
			
		||||
		)}
 | 
			
		||||
		{...props}
 | 
			
		||||
@@ -61,7 +61,7 @@ const DropdownMenuContent = React.forwardRef<
 | 
			
		||||
			ref={ref}
 | 
			
		||||
			sideOffset={sideOffset}
 | 
			
		||||
			className={cn(
 | 
			
		||||
				"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
 | 
			
		||||
				"z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
 | 
			
		||||
				className
 | 
			
		||||
			)}
 | 
			
		||||
			{...props}
 | 
			
		||||
@@ -79,7 +79,7 @@ const DropdownMenuItem = React.forwardRef<
 | 
			
		||||
	<DropdownMenuPrimitive.Item
 | 
			
		||||
		ref={ref}
 | 
			
		||||
		className={cn(
 | 
			
		||||
			"relative flex cursor-default select-none items-center rounded-sm px-2.5 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
 | 
			
		||||
			"relative flex select-none items-center rounded-sm px-2.5 py-1.5 text-sm outline-hidden focus:bg-accent/70 focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
 | 
			
		||||
			inset && "ps-8",
 | 
			
		||||
			className
 | 
			
		||||
		)}
 | 
			
		||||
@@ -95,7 +95,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
 | 
			
		||||
	<DropdownMenuPrimitive.CheckboxItem
 | 
			
		||||
		ref={ref}
 | 
			
		||||
		className={cn(
 | 
			
		||||
			"relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
 | 
			
		||||
			"relative flex select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-hidden focus:bg-accent/70 focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
 | 
			
		||||
			className
 | 
			
		||||
		)}
 | 
			
		||||
		checked={checked}
 | 
			
		||||
@@ -118,7 +118,7 @@ const DropdownMenuRadioItem = React.forwardRef<
 | 
			
		||||
	<DropdownMenuPrimitive.RadioItem
 | 
			
		||||
		ref={ref}
 | 
			
		||||
		className={cn(
 | 
			
		||||
			"relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
 | 
			
		||||
			"relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-hidden focus:bg-accent/70 focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
 | 
			
		||||
			className
 | 
			
		||||
		)}
 | 
			
		||||
		{...props}
 | 
			
		||||
 
 | 
			
		||||
@@ -11,11 +11,11 @@ export function InputCopy({ value, id, name }: { value: string; id: string; name
 | 
			
		||||
			<Input readOnly id={id} name={name} value={value} required></Input>
 | 
			
		||||
			<div
 | 
			
		||||
				className={
 | 
			
		||||
					"h-6 w-24 bg-gradient-to-r rtl:bg-gradient-to-l from-transparent to-background to-65% absolute top-2 end-1 pointer-events-none"
 | 
			
		||||
					"h-6 w-24 bg-linear-to-r rtl:bg-linear-to-l from-transparent to-background to-65% absolute top-2 end-1 pointer-events-none"
 | 
			
		||||
				}
 | 
			
		||||
			></div>
 | 
			
		||||
			<TooltipProvider delayDuration={100} disableHoverableContent>
 | 
			
		||||
				<Tooltip>
 | 
			
		||||
				<Tooltip disableHoverableContent={true}>
 | 
			
		||||
					<TooltipTrigger asChild>
 | 
			
		||||
						<Button
 | 
			
		||||
							type="button"
 | 
			
		||||
 
 | 
			
		||||
@@ -33,7 +33,7 @@ const InputTags = React.forwardRef<HTMLInputElement, InputTagsProps>(
 | 
			
		||||
		return (
 | 
			
		||||
			<div
 | 
			
		||||
				className={cn(
 | 
			
		||||
					"bg-background min-h-10 flex w-full flex-wrap gap-2 rounded-md border px-3 py-2 text-sm  placeholder:text-muted-foreground has-[:focus-visible]:outline-none ring-offset-background has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
 | 
			
		||||
					"bg-background min-h-10 flex w-full flex-wrap gap-2 rounded-md border px-3 py-2 text-sm  placeholder:text-muted-foreground has-focus-visible:outline-hidden ring-offset-background has-focus-visible:ring-2 has-focus-visible:ring-ring has-focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
 | 
			
		||||
					className
 | 
			
		||||
				)}
 | 
			
		||||
			>
 | 
			
		||||
@@ -53,7 +53,7 @@ const InputTags = React.forwardRef<HTMLInputElement, InputTagsProps>(
 | 
			
		||||
					</Badge>
 | 
			
		||||
				))}
 | 
			
		||||
				<input
 | 
			
		||||
					className="flex-1 outline-none bg-background placeholder:text-muted-foreground"
 | 
			
		||||
					className="flex-1 outline-hidden bg-background placeholder:text-muted-foreground"
 | 
			
		||||
					value={pendingDataPoint}
 | 
			
		||||
					onChange={(e) => setPendingDataPoint(e.target.value)}
 | 
			
		||||
					onKeyDown={(e) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type,
 | 
			
		||||
		<input
 | 
			
		||||
			type={type}
 | 
			
		||||
			className={cn(
 | 
			
		||||
				"flex h-10 w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
 | 
			
		||||
				"flex h-10 w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
 | 
			
		||||
				className
 | 
			
		||||
			)}
 | 
			
		||||
			ref={ref}
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
 | 
			
		||||
	<SelectPrimitive.Trigger
 | 
			
		||||
		ref={ref}
 | 
			
		||||
		className={cn(
 | 
			
		||||
			"flex h-10 w-full items-center justify-between rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
 | 
			
		||||
			"flex h-10 w-full items-center justify-between rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
 | 
			
		||||
			className
 | 
			
		||||
		)}
 | 
			
		||||
		{...props}
 | 
			
		||||
@@ -66,7 +66,7 @@ const SelectContent = React.forwardRef<
 | 
			
		||||
		<SelectPrimitive.Content
 | 
			
		||||
			ref={ref}
 | 
			
		||||
			className={cn(
 | 
			
		||||
				"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
 | 
			
		||||
				"relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
 | 
			
		||||
				position === "popper" &&
 | 
			
		||||
					"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
 | 
			
		||||
				className
 | 
			
		||||
@@ -79,7 +79,7 @@ const SelectContent = React.forwardRef<
 | 
			
		||||
				className={cn(
 | 
			
		||||
					"p-1",
 | 
			
		||||
					position === "popper" &&
 | 
			
		||||
						"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
 | 
			
		||||
						"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)"
 | 
			
		||||
				)}
 | 
			
		||||
			>
 | 
			
		||||
				{children}
 | 
			
		||||
@@ -105,7 +105,7 @@ const SelectItem = React.forwardRef<
 | 
			
		||||
	<SelectPrimitive.Item
 | 
			
		||||
		ref={ref}
 | 
			
		||||
		className={cn(
 | 
			
		||||
			"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
 | 
			
		||||
			"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-hidden focus:bg-accent/70 focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
 | 
			
		||||
			className
 | 
			
		||||
		)}
 | 
			
		||||
		{...props}
 | 
			
		||||
 
 | 
			
		||||
@@ -11,7 +11,7 @@ const Separator = React.forwardRef<
 | 
			
		||||
		ref={ref}
 | 
			
		||||
		decorative={decorative}
 | 
			
		||||
		orientation={orientation}
 | 
			
		||||
		className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className)}
 | 
			
		||||
		className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-px w-full" : "h-full w-px", className)}
 | 
			
		||||
		{...props}
 | 
			
		||||
	/>
 | 
			
		||||
))
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										101
									
								
								beszel/site/src/components/ui/sheet.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								beszel/site/src/components/ui/sheet.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,101 @@
 | 
			
		||||
import * as React from "react"
 | 
			
		||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
 | 
			
		||||
import { XIcon } from "lucide-react"
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
 | 
			
		||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
 | 
			
		||||
	return <SheetPrimitive.Root data-slot="sheet" {...props} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
 | 
			
		||||
	return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
 | 
			
		||||
	return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
 | 
			
		||||
	return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
 | 
			
		||||
	return (
 | 
			
		||||
		<SheetPrimitive.Overlay
 | 
			
		||||
			data-slot="sheet-overlay"
 | 
			
		||||
			className={cn(
 | 
			
		||||
				"data-[state=open]:animate-in duration-500 isolate data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/40",
 | 
			
		||||
				className
 | 
			
		||||
			)}
 | 
			
		||||
			{...props}
 | 
			
		||||
		/>
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SheetContent({
 | 
			
		||||
	className,
 | 
			
		||||
	children,
 | 
			
		||||
	side = "right",
 | 
			
		||||
	...props
 | 
			
		||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
 | 
			
		||||
	side?: "top" | "right" | "bottom" | "left"
 | 
			
		||||
}) {
 | 
			
		||||
	return (
 | 
			
		||||
		<SheetPortal>
 | 
			
		||||
			<SheetOverlay />
 | 
			
		||||
			<SheetPrimitive.Content
 | 
			
		||||
				data-slot="sheet-content"
 | 
			
		||||
				className={cn(
 | 
			
		||||
					"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-[400ms]",
 | 
			
		||||
					side === "right" &&
 | 
			
		||||
						"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
 | 
			
		||||
					side === "left" &&
 | 
			
		||||
						"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
 | 
			
		||||
					side === "top" &&
 | 
			
		||||
						"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
 | 
			
		||||
					side === "bottom" &&
 | 
			
		||||
						"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
 | 
			
		||||
					className
 | 
			
		||||
				)}
 | 
			
		||||
				{...props}
 | 
			
		||||
			>
 | 
			
		||||
				{children}
 | 
			
		||||
				<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
 | 
			
		||||
					<XIcon className="size-4" />
 | 
			
		||||
					<span className="sr-only">Close</span>
 | 
			
		||||
				</SheetPrimitive.Close>
 | 
			
		||||
			</SheetPrimitive.Content>
 | 
			
		||||
		</SheetPortal>
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
 | 
			
		||||
	return <div data-slot="sheet-header" className={cn("flex flex-col gap-1.5 p-4", className)} {...props} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
 | 
			
		||||
	return <div data-slot="sheet-footer" className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
 | 
			
		||||
	return (
 | 
			
		||||
		<SheetPrimitive.Title
 | 
			
		||||
			data-slot="sheet-title"
 | 
			
		||||
			className={cn("text-foreground font-semibold", className)}
 | 
			
		||||
			{...props}
 | 
			
		||||
		/>
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) {
 | 
			
		||||
	return (
 | 
			
		||||
		<SheetPrimitive.Description
 | 
			
		||||
			data-slot="sheet-description"
 | 
			
		||||
			className={cn("text-muted-foreground text-sm", className)}
 | 
			
		||||
			{...props}
 | 
			
		||||
		/>
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription }
 | 
			
		||||
@@ -15,7 +15,7 @@ const Slider = React.forwardRef<
 | 
			
		||||
		<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
 | 
			
		||||
			<SliderPrimitive.Range className="absolute h-full bg-primary" />
 | 
			
		||||
		</SliderPrimitive.Track>
 | 
			
		||||
		<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
 | 
			
		||||
		<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
 | 
			
		||||
	</SliderPrimitive.Root>
 | 
			
		||||
))
 | 
			
		||||
Slider.displayName = SliderPrimitive.Root.displayName
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ const Switch = React.forwardRef<
 | 
			
		||||
>(({ className, ...props }, ref) => (
 | 
			
		||||
	<SwitchPrimitives.Root
 | 
			
		||||
		className={cn(
 | 
			
		||||
			"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
 | 
			
		||||
			"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
 | 
			
		||||
			className
 | 
			
		||||
		)}
 | 
			
		||||
		{...props}
 | 
			
		||||
@@ -17,7 +17,7 @@ const Switch = React.forwardRef<
 | 
			
		||||
	>
 | 
			
		||||
		<SwitchPrimitives.Thumb
 | 
			
		||||
			className={cn(
 | 
			
		||||
				"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 rtl:data-[state=checked]:-translate-x-5 data-[state=unchecked]:translate-x-0"
 | 
			
		||||
				"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=checked]:rtl:-translate-x-5 data-[state=unchecked]:translate-x-0"
 | 
			
		||||
			)}
 | 
			
		||||
		/>
 | 
			
		||||
	</SwitchPrimitives.Root>
 | 
			
		||||
 
 | 
			
		||||
@@ -27,7 +27,7 @@ TableBody.displayName = "TableBody"
 | 
			
		||||
 | 
			
		||||
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
 | 
			
		||||
	({ className, ...props }, ref) => (
 | 
			
		||||
		<tfoot ref={ref} className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)} {...props} />
 | 
			
		||||
		<tfoot ref={ref} className={cn("border-t bg-muted/50 font-medium last:[&>tr]:border-b-0", className)} {...props} />
 | 
			
		||||
	)
 | 
			
		||||
)
 | 
			
		||||
TableFooter.displayName = "TableFooter"
 | 
			
		||||
@@ -37,7 +37,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
 | 
			
		||||
		<tr
 | 
			
		||||
			ref={ref}
 | 
			
		||||
			className={cn(
 | 
			
		||||
				"border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:!bg-muted",
 | 
			
		||||
				"border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:bg-muted!",
 | 
			
		||||
				className
 | 
			
		||||
			)}
 | 
			
		||||
			{...props}
 | 
			
		||||
 
 | 
			
		||||
@@ -27,7 +27,7 @@ const TabsTrigger = React.forwardRef<
 | 
			
		||||
	<TabsPrimitive.Trigger
 | 
			
		||||
		ref={ref}
 | 
			
		||||
		className={cn(
 | 
			
		||||
			"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
 | 
			
		||||
			"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs cursor-pointer",
 | 
			
		||||
			className
 | 
			
		||||
		)}
 | 
			
		||||
		{...props}
 | 
			
		||||
@@ -42,7 +42,7 @@ const TabsContent = React.forwardRef<
 | 
			
		||||
	<TabsPrimitive.Content
 | 
			
		||||
		ref={ref}
 | 
			
		||||
		className={cn(
 | 
			
		||||
			"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
 | 
			
		||||
			"mt-2 ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
 | 
			
		||||
			className
 | 
			
		||||
		)}
 | 
			
		||||
		{...props}
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ classNa
 | 
			
		||||
	return (
 | 
			
		||||
		<textarea
 | 
			
		||||
			className={cn(
 | 
			
		||||
				"flex min-h-14 w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
 | 
			
		||||
				"flex min-h-14 w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
 | 
			
		||||
				className
 | 
			
		||||
			)}
 | 
			
		||||
			ref={ref}
 | 
			
		||||
 
 | 
			
		||||
@@ -14,7 +14,7 @@ const ToastViewport = React.forwardRef<
 | 
			
		||||
	<ToastPrimitives.Viewport
 | 
			
		||||
		ref={ref}
 | 
			
		||||
		className={cn(
 | 
			
		||||
			"fixed top-0 z-[100] flex max-h-dvh w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
 | 
			
		||||
			"fixed top-0 z-100 flex max-h-dvh w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
 | 
			
		||||
			className
 | 
			
		||||
		)}
 | 
			
		||||
		{...props}
 | 
			
		||||
@@ -23,7 +23,7 @@ const ToastViewport = React.forwardRef<
 | 
			
		||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
 | 
			
		||||
 | 
			
		||||
const toastVariants = cva(
 | 
			
		||||
	"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pe-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
 | 
			
		||||
	"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pe-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-(--radix-toast-swipe-end-x) data-[swipe=move]:translate-x-(--radix-toast-swipe-move-x) data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full sm:data-[state=open]:slide-in-from-bottom-full",
 | 
			
		||||
	{
 | 
			
		||||
		variants: {
 | 
			
		||||
			variant: {
 | 
			
		||||
@@ -52,7 +52,7 @@ const ToastAction = React.forwardRef<
 | 
			
		||||
	<ToastPrimitives.Action
 | 
			
		||||
		ref={ref}
 | 
			
		||||
		className={cn(
 | 
			
		||||
			"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
 | 
			
		||||
			"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 hover:group-[.destructive]:border-destructive/30 hover:group-[.destructive]:bg-destructive hover:group-[.destructive]:text-destructive-foreground focus:group-[.destructive]:ring-destructive",
 | 
			
		||||
			className
 | 
			
		||||
		)}
 | 
			
		||||
		{...props}
 | 
			
		||||
@@ -67,7 +67,7 @@ const ToastClose = React.forwardRef<
 | 
			
		||||
	<ToastPrimitives.Close
 | 
			
		||||
		ref={ref}
 | 
			
		||||
		className={cn(
 | 
			
		||||
			"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
 | 
			
		||||
			"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-hidden focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 hover:group-[.destructive]:text-red-50 focus:group-[.destructive]:ring-red-400 focus:group-[.destructive]:ring-offset-red-600",
 | 
			
		||||
			className
 | 
			
		||||
		)}
 | 
			
		||||
		toast-close=""
 | 
			
		||||
 
 | 
			
		||||
@@ -3,26 +3,47 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip"
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
 | 
			
		||||
const TooltipProvider = TooltipPrimitive.Provider
 | 
			
		||||
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
 | 
			
		||||
	return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const Tooltip = TooltipPrimitive.Root
 | 
			
		||||
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
 | 
			
		||||
	return (
 | 
			
		||||
		<TooltipProvider>
 | 
			
		||||
			<TooltipPrimitive.Root data-slot="tooltip" {...props} />
 | 
			
		||||
		</TooltipProvider>
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const TooltipTrigger = TooltipPrimitive.Trigger
 | 
			
		||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
 | 
			
		||||
	return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const TooltipContent = React.forwardRef<
 | 
			
		||||
	React.ElementRef<typeof TooltipPrimitive.Content>,
 | 
			
		||||
	React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
 | 
			
		||||
>(({ className, sideOffset = 4, ...props }, ref) => (
 | 
			
		||||
	<TooltipPrimitive.Content
 | 
			
		||||
		ref={ref}
 | 
			
		||||
		sideOffset={sideOffset}
 | 
			
		||||
		className={cn(
 | 
			
		||||
			"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
 | 
			
		||||
			className
 | 
			
		||||
		)}
 | 
			
		||||
		{...props}
 | 
			
		||||
	/>
 | 
			
		||||
))
 | 
			
		||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
 | 
			
		||||
function TooltipContent({
 | 
			
		||||
	className,
 | 
			
		||||
	sideOffset = 0,
 | 
			
		||||
	children,
 | 
			
		||||
	...props
 | 
			
		||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
 | 
			
		||||
	return (
 | 
			
		||||
		<TooltipPrimitive.Portal>
 | 
			
		||||
			<TooltipPrimitive.Content
 | 
			
		||||
				data-slot="tooltip-content"
 | 
			
		||||
				sideOffset={sideOffset}
 | 
			
		||||
				className={cn(
 | 
			
		||||
					"bg-popover text-popover-foreground border animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-sm text-balance",
 | 
			
		||||
					className
 | 
			
		||||
				)}
 | 
			
		||||
				{...props}
 | 
			
		||||
			>
 | 
			
		||||
				{children}
 | 
			
		||||
				<TooltipPrimitive.Arrow
 | 
			
		||||
					className="bg-popover border z-50 fill-popover size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] will-change-transform"
 | 
			
		||||
					style={{ clipPath: "inset(25% 0 0 25%)" }}
 | 
			
		||||
				/>
 | 
			
		||||
			</TooltipPrimitive.Content>
 | 
			
		||||
		</TooltipPrimitive.Portal>
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,101 +1,157 @@
 | 
			
		||||
@tailwind base;
 | 
			
		||||
@tailwind components;
 | 
			
		||||
@tailwind utilities;
 | 
			
		||||
@import "tailwindcss";
 | 
			
		||||
@import "tw-animate-css";
 | 
			
		||||
 | 
			
		||||
@layer base {
 | 
			
		||||
	:root {
 | 
			
		||||
		--background: 30 8% 98.5%;
 | 
			
		||||
		--foreground: 30 0% 0%;
 | 
			
		||||
		--card: 30 0% 100%;
 | 
			
		||||
		--card-foreground: 240 6.67% 2.94%;
 | 
			
		||||
		--popover: 30 0% 100%;
 | 
			
		||||
		--popover-foreground: 240 10% 6.2%;
 | 
			
		||||
		--primary: 240 5.88% 10%;
 | 
			
		||||
		--primary-foreground: 30 0% 100%;
 | 
			
		||||
		--secondary: 240 4.76% 95.88%;
 | 
			
		||||
		--secondary-foreground: 240 5.88% 10%;
 | 
			
		||||
		--muted: 26 6% 94%;
 | 
			
		||||
		--muted-foreground: 24 2.79% 35.1%;
 | 
			
		||||
		--accent: 20 23.08% 94%;
 | 
			
		||||
		--accent-foreground: 240 5.88% 10%;
 | 
			
		||||
		--destructive: 0 66% 53%;
 | 
			
		||||
		--destructive-foreground: 0 0% 98.04%;
 | 
			
		||||
		--border: 30 8.11% 85.49%;
 | 
			
		||||
		--input: 30 4.29% 72.55%;
 | 
			
		||||
		--ring: 30 3.97% 49.41%;
 | 
			
		||||
		--radius: 0.8rem;
 | 
			
		||||
		/* charts */
 | 
			
		||||
		--chart-1: 220 70% 50%;
 | 
			
		||||
		--chart-2: 160 60% 45%;
 | 
			
		||||
		--chart-3: 30 80% 55%;
 | 
			
		||||
		--chart-4: 280 65% 60%;
 | 
			
		||||
		--chart-5: 340 75% 55%;
 | 
			
		||||
	}
 | 
			
		||||
@custom-variant dark (&:is(.dark *));
 | 
			
		||||
 | 
			
		||||
	.dark {
 | 
			
		||||
		color-scheme: dark;
 | 
			
		||||
		--background: 220 5.5% 9%;
 | 
			
		||||
		--foreground: 220 2% 97%;
 | 
			
		||||
		--card: 220 5.5% 10.5%;
 | 
			
		||||
		--card-foreground: 220 2% 97%;
 | 
			
		||||
		--popover: 220 5.5% 9%;
 | 
			
		||||
		--popover-foreground: 220 2% 97%;
 | 
			
		||||
		--primary: 220 2% 96%;
 | 
			
		||||
		--primary-foreground: 220 4% 10%;
 | 
			
		||||
		--secondary: 220 4% 16%;
 | 
			
		||||
		--secondary-foreground: 220 0% 98%;
 | 
			
		||||
		--muted: 220 6% 16%;
 | 
			
		||||
		--muted-foreground: 220 4% 67%;
 | 
			
		||||
		--accent: 220 5% 15.5%;
 | 
			
		||||
		--accent-foreground: 220 2% 98%;
 | 
			
		||||
		--destructive: 0 62% 46%;
 | 
			
		||||
		--destructive-foreground: 0 0% 97%;
 | 
			
		||||
		--border: 220 3% 16%;
 | 
			
		||||
		--input: 220 4% 22%;
 | 
			
		||||
		--ring: 220 4% 80%;
 | 
			
		||||
		--radius: 0.8rem;
 | 
			
		||||
	}
 | 
			
		||||
:root {
 | 
			
		||||
	--background: hsl(30 8% 98%);
 | 
			
		||||
	--foreground: hsl(30 0% 0%);
 | 
			
		||||
	--card: hsl(30 0% 100%);
 | 
			
		||||
	--card-foreground: hsl(240 6.67% 2.94%);
 | 
			
		||||
	--popover: hsl(30 0% 100%);
 | 
			
		||||
	--popover-foreground: hsl(240 10% 6.2%);
 | 
			
		||||
	--primary: hsl(240 5.88% 10%);
 | 
			
		||||
	--primary-foreground: hsl(30 0% 100%);
 | 
			
		||||
	--secondary: hsl(240 4.76% 95.88%);
 | 
			
		||||
	--secondary-foreground: hsl(240 5.88% 10%);
 | 
			
		||||
	--muted: hsl(26 6% 94%);
 | 
			
		||||
	--muted-foreground: hsl(24 2.79% 35.1%);
 | 
			
		||||
	--accent: hsl(20 23.08% 94%);
 | 
			
		||||
	--accent-foreground: hsl(240 5.88% 10%);
 | 
			
		||||
	--destructive: hsl(0 66% 53%);
 | 
			
		||||
	--destructive-foreground: hsl(0 0% 98.04%);
 | 
			
		||||
	--border: hsl(30 8.11% 85.49%);
 | 
			
		||||
	--input: hsl(30 4.29% 72.55%);
 | 
			
		||||
	--ring: hsl(30 3.97% 49.41%);
 | 
			
		||||
	--radius: 0.8rem;
 | 
			
		||||
	--chart-1: hsl(220 70% 50%);
 | 
			
		||||
	--chart-2: hsl(160 60% 45%);
 | 
			
		||||
	--chart-3: hsl(30 80% 55%);
 | 
			
		||||
	--chart-4: hsl(280 65% 60%);
 | 
			
		||||
	--chart-5: hsl(340 75% 55%);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Fonts */
 | 
			
		||||
@supports (font-variation-settings: normal) {
 | 
			
		||||
	:root {
 | 
			
		||||
		font-family: Inter, InterVariable, sans-serif;
 | 
			
		||||
	}
 | 
			
		||||
.dark {
 | 
			
		||||
	color-scheme: dark;
 | 
			
		||||
	--background: hsl(220 5.5% 9%);
 | 
			
		||||
	--foreground: hsl(220 2% 97%);
 | 
			
		||||
	--card: hsl(220 5.5% 10.5%);
 | 
			
		||||
	--card-foreground: hsl(220 2% 97%);
 | 
			
		||||
	--popover: hsl(220 5.5% 9%);
 | 
			
		||||
	--popover-foreground: hsl(220 2% 97%);
 | 
			
		||||
	--primary: hsl(220 2% 96%);
 | 
			
		||||
	--primary-foreground: hsl(220 4% 10%);
 | 
			
		||||
	--secondary: hsl(220 4% 16%);
 | 
			
		||||
	--secondary-foreground: hsl(220 0% 98%);
 | 
			
		||||
	--muted: hsl(220 6% 16%);
 | 
			
		||||
	--muted-foreground: hsl(220 4% 67%);
 | 
			
		||||
	--accent: hsl(220 5% 15.5%);
 | 
			
		||||
	--accent-foreground: hsl(220 2% 98%);
 | 
			
		||||
	--destructive: hsl(0 62% 46%);
 | 
			
		||||
	--destructive-foreground: hsl(0 0% 97%);
 | 
			
		||||
	--border: hsl(220 3% 16%);
 | 
			
		||||
	--input: hsl(220 4% 22%);
 | 
			
		||||
	--ring: hsl(220 4% 80%);
 | 
			
		||||
	--radius: 0.8rem;
 | 
			
		||||
}
 | 
			
		||||
@font-face {
 | 
			
		||||
	font-family: InterVariable;
 | 
			
		||||
	font-style: normal;
 | 
			
		||||
	font-weight: 100 900;
 | 
			
		||||
	font-display: swap;
 | 
			
		||||
	src: url("/static/InterVariable.woff2?v=4.0") format("woff2");
 | 
			
		||||
 | 
			
		||||
@theme inline {
 | 
			
		||||
	--font-sans: Inter, InterVariable, sans-serif;
 | 
			
		||||
 | 
			
		||||
	--breakpoint-xs: 26.6rem;
 | 
			
		||||
	--breakpoint-450: 28rem;
 | 
			
		||||
	--breakpoint-2xl: 90rem;
 | 
			
		||||
 | 
			
		||||
	--radius-sm: calc(var(--radius) - 4px);
 | 
			
		||||
	--radius-md: calc(var(--radius) - 2px);
 | 
			
		||||
	--radius-lg: var(--radius);
 | 
			
		||||
	--radius-xl: calc(var(--radius) + 4px);
 | 
			
		||||
 | 
			
		||||
	--color-green-50: hsl(140 60% 95%);
 | 
			
		||||
	--color-green-100: hsl(140 50% 90%);
 | 
			
		||||
	--color-green-200: hsl(140 49% 80%);
 | 
			
		||||
	--color-green-300: hsl(140 48% 70%);
 | 
			
		||||
	--color-green-400: hsl(140 49% 60%);
 | 
			
		||||
	--color-green-500: hsl(140 50% 48%);
 | 
			
		||||
	--color-green-600: hsl(140 52% 38%);
 | 
			
		||||
	--color-green-700: hsl(140 53% 29%);
 | 
			
		||||
	--color-green-800: hsl(140 54% 20%);
 | 
			
		||||
	--color-green-900: hsl(140 54% 12%);
 | 
			
		||||
	--color-green-950: hsl(140 57% 6%);
 | 
			
		||||
 | 
			
		||||
	--color-background: var(--background);
 | 
			
		||||
	--color-foreground: var(--foreground);
 | 
			
		||||
	--color-card: var(--card);
 | 
			
		||||
	--color-card-foreground: var(--card-foreground);
 | 
			
		||||
	--color-popover: var(--popover);
 | 
			
		||||
	--color-popover-foreground: var(--popover-foreground);
 | 
			
		||||
	--color-primary: var(--primary);
 | 
			
		||||
	--color-primary-foreground: var(--primary-foreground);
 | 
			
		||||
	--color-secondary: var(--secondary);
 | 
			
		||||
	--color-secondary-foreground: var(--secondary-foreground);
 | 
			
		||||
	--color-muted: var(--muted);
 | 
			
		||||
	--color-muted-foreground: var(--muted-foreground);
 | 
			
		||||
	--color-accent: var(--accent);
 | 
			
		||||
	--color-accent-foreground: var(--accent-foreground);
 | 
			
		||||
	--color-destructive: var(--destructive);
 | 
			
		||||
	--color-border: var(--border);
 | 
			
		||||
	--color-input: var(--input);
 | 
			
		||||
	--color-ring: var(--ring);
 | 
			
		||||
	--color-chart-1: var(--chart-1);
 | 
			
		||||
	--color-chart-2: var(--chart-2);
 | 
			
		||||
	--color-chart-3: var(--chart-3);
 | 
			
		||||
	--color-chart-4: var(--chart-4);
 | 
			
		||||
	--color-chart-5: var(--chart-5);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@layer utilities {
 | 
			
		||||
	/* Fonts */
 | 
			
		||||
	@supports (font-variation-settings: normal) {
 | 
			
		||||
		:root {
 | 
			
		||||
			font-family: Inter, InterVariable, sans-serif;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	@font-face {
 | 
			
		||||
		font-family: InterVariable;
 | 
			
		||||
		font-style: normal;
 | 
			
		||||
		font-weight: 100 900;
 | 
			
		||||
		font-display: swap;
 | 
			
		||||
		src: url("/static/InterVariable.woff2?v=4.0") format("woff2");
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@layer base {
 | 
			
		||||
	* {
 | 
			
		||||
		@apply border-border;
 | 
			
		||||
		@apply border-border outline-ring/50;
 | 
			
		||||
		overflow-anchor: none;
 | 
			
		||||
	}
 | 
			
		||||
	body {
 | 
			
		||||
		@apply bg-background text-foreground;
 | 
			
		||||
	}
 | 
			
		||||
	button {
 | 
			
		||||
		cursor: pointer;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@layer utilities {
 | 
			
		||||
	.link {
 | 
			
		||||
		@apply text-primary font-medium underline-offset-4 hover:underline;
 | 
			
		||||
	}
 | 
			
		||||
@utility container {
 | 
			
		||||
	@apply max-w-360 mx-auto px-4;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@utility link {
 | 
			
		||||
	@apply text-primary font-medium underline-offset-4 hover:underline;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@utility ns-dialog {
 | 
			
		||||
	/* New system dialog width */
 | 
			
		||||
	.ns-dialog {
 | 
			
		||||
		min-width: 30.3rem;
 | 
			
		||||
	}
 | 
			
		||||
	:where(:lang(zh), :lang(zh-CN), :lang(ko)) .ns-dialog {
 | 
			
		||||
	min-width: 30.3rem;
 | 
			
		||||
	:where(:lang(zh), :lang(zh-CN), :lang(ko)) & {
 | 
			
		||||
		min-width: 27.9rem;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.recharts-tooltip-wrapper {
 | 
			
		||||
	z-index: 1;
 | 
			
		||||
	@apply tabular-nums;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.recharts-yAxis {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										170
									
								
								beszel/site/src/lib/alerts.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										170
									
								
								beszel/site/src/lib/alerts.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,170 @@
 | 
			
		||||
import type { AlertInfo, AlertRecord } from "@/types"
 | 
			
		||||
import type { RecordSubscription } from "pocketbase"
 | 
			
		||||
import { pb, $alerts } from "@/lib/stores"
 | 
			
		||||
import { EthernetIcon } from "@/components/ui/icons"
 | 
			
		||||
import { ServerIcon, CpuIcon, MemoryStickIcon, HardDriveIcon, ThermometerIcon, HourglassIcon } from "lucide-react"
 | 
			
		||||
import { t } from "@lingui/core/macro"
 | 
			
		||||
 | 
			
		||||
/** Alert info for each alert type */
 | 
			
		||||
export const alertInfo: Record<string, AlertInfo> = {
 | 
			
		||||
	Status: {
 | 
			
		||||
		name: () => t`Status`,
 | 
			
		||||
		unit: "",
 | 
			
		||||
		icon: ServerIcon,
 | 
			
		||||
		desc: () => t`Triggers when status switches between up and down`,
 | 
			
		||||
		/** "for x minutes" is appended to desc when only one value */
 | 
			
		||||
		singleDesc: () => t`System` + " " + t`Down`,
 | 
			
		||||
	},
 | 
			
		||||
	CPU: {
 | 
			
		||||
		name: () => t`CPU Usage`,
 | 
			
		||||
		unit: "%",
 | 
			
		||||
		icon: CpuIcon,
 | 
			
		||||
		desc: () => t`Triggers when CPU usage exceeds a threshold`,
 | 
			
		||||
	},
 | 
			
		||||
	Memory: {
 | 
			
		||||
		name: () => t`Memory Usage`,
 | 
			
		||||
		unit: "%",
 | 
			
		||||
		icon: MemoryStickIcon,
 | 
			
		||||
		desc: () => t`Triggers when memory usage exceeds a threshold`,
 | 
			
		||||
	},
 | 
			
		||||
	Disk: {
 | 
			
		||||
		name: () => t`Disk Usage`,
 | 
			
		||||
		unit: "%",
 | 
			
		||||
		icon: HardDriveIcon,
 | 
			
		||||
		desc: () => t`Triggers when usage of any disk exceeds a threshold`,
 | 
			
		||||
	},
 | 
			
		||||
	Bandwidth: {
 | 
			
		||||
		name: () => t`Bandwidth`,
 | 
			
		||||
		unit: " MB/s",
 | 
			
		||||
		icon: EthernetIcon,
 | 
			
		||||
		desc: () => t`Triggers when combined up/down exceeds a threshold`,
 | 
			
		||||
		max: 125,
 | 
			
		||||
	},
 | 
			
		||||
	Temperature: {
 | 
			
		||||
		name: () => t`Temperature`,
 | 
			
		||||
		unit: "°C",
 | 
			
		||||
		icon: ThermometerIcon,
 | 
			
		||||
		desc: () => t`Triggers when any sensor exceeds a threshold`,
 | 
			
		||||
	},
 | 
			
		||||
	LoadAvg1: {
 | 
			
		||||
		name: () => t`Load Average 1m`,
 | 
			
		||||
		unit: "",
 | 
			
		||||
		icon: HourglassIcon,
 | 
			
		||||
		max: 100,
 | 
			
		||||
		min: 0.1,
 | 
			
		||||
		start: 10,
 | 
			
		||||
		step: 0.1,
 | 
			
		||||
		desc: () => t`Triggers when 1 minute load average exceeds a threshold`,
 | 
			
		||||
	},
 | 
			
		||||
	LoadAvg5: {
 | 
			
		||||
		name: () => t`Load Average 5m`,
 | 
			
		||||
		unit: "",
 | 
			
		||||
		icon: HourglassIcon,
 | 
			
		||||
		max: 100,
 | 
			
		||||
		min: 0.1,
 | 
			
		||||
		start: 10,
 | 
			
		||||
		step: 0.1,
 | 
			
		||||
		desc: () => t`Triggers when 5 minute load average exceeds a threshold`,
 | 
			
		||||
	},
 | 
			
		||||
	LoadAvg15: {
 | 
			
		||||
		name: () => t`Load Average 15m`,
 | 
			
		||||
		unit: "",
 | 
			
		||||
		icon: HourglassIcon,
 | 
			
		||||
		min: 0.1,
 | 
			
		||||
		max: 100,
 | 
			
		||||
		start: 10,
 | 
			
		||||
		step: 0.1,
 | 
			
		||||
		desc: () => t`Triggers when 15 minute load average exceeds a threshold`,
 | 
			
		||||
	},
 | 
			
		||||
} as const
 | 
			
		||||
 | 
			
		||||
/** Helper to manage user alerts */
 | 
			
		||||
export const alertManager = (() => {
 | 
			
		||||
	const collection = pb.collection<AlertRecord>("alerts")
 | 
			
		||||
	let unsub: () => void
 | 
			
		||||
 | 
			
		||||
	/** Fields to fetch from alerts collection */
 | 
			
		||||
	const fields = "id,name,system,value,min,triggered"
 | 
			
		||||
 | 
			
		||||
	/** Fetch alerts from collection */
 | 
			
		||||
	async function fetchAlerts(): Promise<AlertRecord[]> {
 | 
			
		||||
		return await collection.getFullList<AlertRecord>({ fields, sort: "updated" })
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/** Format alerts into a map of system id to alert name to alert record */
 | 
			
		||||
	function add(alerts: AlertRecord[]) {
 | 
			
		||||
		for (const alert of alerts) {
 | 
			
		||||
			const systemId = alert.system
 | 
			
		||||
			const systemAlerts = $alerts.get()[systemId] ?? new Map()
 | 
			
		||||
			const newAlerts = new Map(systemAlerts)
 | 
			
		||||
			newAlerts.set(alert.name, alert)
 | 
			
		||||
			$alerts.setKey(systemId, newAlerts)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	function remove(alerts: Pick<AlertRecord, "name" | "system">[]) {
 | 
			
		||||
		for (const alert of alerts) {
 | 
			
		||||
			const systemId = alert.system
 | 
			
		||||
			const systemAlerts = $alerts.get()[systemId]
 | 
			
		||||
			const newAlerts = new Map(systemAlerts)
 | 
			
		||||
			newAlerts.delete(alert.name)
 | 
			
		||||
			$alerts.setKey(systemId, newAlerts)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const actionFns = {
 | 
			
		||||
		create: add,
 | 
			
		||||
		update: add,
 | 
			
		||||
		delete: remove,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// batch alert updates to prevent unnecessary re-renders when adding many alerts at once
 | 
			
		||||
	const batchUpdate = (() => {
 | 
			
		||||
		const batch = new Map<string, RecordSubscription<AlertRecord>>()
 | 
			
		||||
		let timeout: ReturnType<typeof setTimeout>
 | 
			
		||||
 | 
			
		||||
		return (data: RecordSubscription<AlertRecord>) => {
 | 
			
		||||
			const { record } = data
 | 
			
		||||
			batch.set(`${record.system}${record.name}`, data)
 | 
			
		||||
			clearTimeout(timeout!)
 | 
			
		||||
			timeout = setTimeout(() => {
 | 
			
		||||
				const groups = { create: [], update: [], delete: [] } as Record<string, AlertRecord[]>
 | 
			
		||||
				for (const { action, record } of batch.values()) {
 | 
			
		||||
					groups[action]?.push(record)
 | 
			
		||||
				}
 | 
			
		||||
				for (const key in groups) {
 | 
			
		||||
					if (groups[key].length) {
 | 
			
		||||
						actionFns[key as keyof typeof actionFns]?.(groups[key])
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
				batch.clear()
 | 
			
		||||
			}, 50)
 | 
			
		||||
		}
 | 
			
		||||
	})()
 | 
			
		||||
 | 
			
		||||
	async function subscribe() {
 | 
			
		||||
		unsub = await collection.subscribe("*", batchUpdate, { fields })
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	function unsubscribe() {
 | 
			
		||||
		unsub?.()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async function refresh() {
 | 
			
		||||
		const records = await fetchAlerts()
 | 
			
		||||
		add(records)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return {
 | 
			
		||||
		/** Add alerts to store */
 | 
			
		||||
		add,
 | 
			
		||||
		/** Remove alerts from store */
 | 
			
		||||
		remove,
 | 
			
		||||
		/** Subscribe to alerts */
 | 
			
		||||
		subscribe,
 | 
			
		||||
		/** Unsubscribe from alerts */
 | 
			
		||||
		unsubscribe,
 | 
			
		||||
		/** Refresh alerts with latest data from hub */
 | 
			
		||||
		refresh,
 | 
			
		||||
	}
 | 
			
		||||
})()
 | 
			
		||||
@@ -21,3 +21,28 @@ export enum Unit {
 | 
			
		||||
	Celsius,
 | 
			
		||||
	Fahrenheit,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** Meter state for color */
 | 
			
		||||
export enum MeterState {
 | 
			
		||||
	Good,
 | 
			
		||||
	Warn,
 | 
			
		||||
	Crit,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** System status states */
 | 
			
		||||
export enum SystemStatus {
 | 
			
		||||
	Up = "up",
 | 
			
		||||
	Down = "down",
 | 
			
		||||
	Pending = "pending",
 | 
			
		||||
	Paused = "paused",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** Battery state */
 | 
			
		||||
export enum BatteryState {
 | 
			
		||||
	Unknown,
 | 
			
		||||
	Empty,
 | 
			
		||||
	Full,
 | 
			
		||||
	Charging,
 | 
			
		||||
	Discharging,
 | 
			
		||||
	Idle,
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,8 @@ import type { Messages } from "@lingui/core"
 | 
			
		||||
import languages from "@/lib/languages"
 | 
			
		||||
import { detect, fromStorage, fromNavigator } from "@lingui/detect-locale"
 | 
			
		||||
import { messages as enMessages } from "@/locales/en/en"
 | 
			
		||||
import { BatteryState } from "./enums"
 | 
			
		||||
import { t } from "@lingui/core/macro"
 | 
			
		||||
 | 
			
		||||
// activates locale
 | 
			
		||||
function activateLocale(locale: string, messages: Messages = enMessages) {
 | 
			
		||||
@@ -54,3 +56,14 @@ export function getLocale() {
 | 
			
		||||
	}
 | 
			
		||||
	return locale
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
////////////////////////////////////////////////////////
 | 
			
		||||
 | 
			
		||||
export const batteryStateTranslations = {
 | 
			
		||||
	[BatteryState.Unknown]: () => t({ message: "Unknown", comment: "Context: Battery state" }),
 | 
			
		||||
	[BatteryState.Empty]: () => t({ message: "Empty", comment: "Context: Battery state" }),
 | 
			
		||||
	[BatteryState.Full]: () => t({ message: "Full", comment: "Context: Battery state" }),
 | 
			
		||||
	[BatteryState.Charging]: () => t({ message: "Charging", comment: "Context: Battery state" }),
 | 
			
		||||
	[BatteryState.Discharging]: () => t({ message: "Discharging", comment: "Context: Battery state" }),
 | 
			
		||||
	[BatteryState.Idle]: () => t({ message: "Idle", comment: "Context: Battery state" }),
 | 
			
		||||
} as const
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,8 @@
 | 
			
		||||
import PocketBase from "pocketbase"
 | 
			
		||||
import { atom, map, PreinitializedWritableAtom } from "nanostores"
 | 
			
		||||
import { AlertRecord, ChartTimes, SystemRecord, UserSettings } from "@/types"
 | 
			
		||||
import { atom, map } from "nanostores"
 | 
			
		||||
import { AlertMap, ChartTimes, SystemRecord, UserSettings } from "@/types"
 | 
			
		||||
import { basePath } from "@/components/router"
 | 
			
		||||
import { Unit } from "./enums"
 | 
			
		||||
 | 
			
		||||
/** PocketBase JS Client */
 | 
			
		||||
export const pb = new PocketBase(basePath)
 | 
			
		||||
@@ -10,33 +11,40 @@ export const pb = new PocketBase(basePath)
 | 
			
		||||
export const $authenticated = atom(pb.authStore.isValid)
 | 
			
		||||
 | 
			
		||||
/** List of system records */
 | 
			
		||||
export const $systems = atom([] as SystemRecord[])
 | 
			
		||||
export const $systems = atom<SystemRecord[]>([])
 | 
			
		||||
 | 
			
		||||
/** List of alert records */
 | 
			
		||||
export const $alerts = atom([] as AlertRecord[])
 | 
			
		||||
/** Map of alert records by system id and alert name */
 | 
			
		||||
export const $alerts = map<AlertMap>({})
 | 
			
		||||
 | 
			
		||||
/** SSH public key */
 | 
			
		||||
export const $publicKey = atom("")
 | 
			
		||||
 | 
			
		||||
/** Chart time period */
 | 
			
		||||
export const $chartTime = atom("1h") as PreinitializedWritableAtom<ChartTimes>
 | 
			
		||||
export const $chartTime = atom<ChartTimes>("1h")
 | 
			
		||||
 | 
			
		||||
/** Whether to display average or max chart values */
 | 
			
		||||
export const $maxValues = atom(false)
 | 
			
		||||
 | 
			
		||||
// export const UserSettingsSchema = v.object({
 | 
			
		||||
// 	chartTime: v.picklist(["1h", "12h", "24h", "1w", "30d"]),
 | 
			
		||||
// 	emails: v.optional(v.array(v.pipe(v.string(), v.email())), [pb?.authStore?.record?.email ?? ""]),
 | 
			
		||||
// 	webhooks: v.optional(v.array(v.string())),
 | 
			
		||||
// 	colorWarn: v.optional(v.pipe(v.number(), v.minValue(1), v.maxValue(100))),
 | 
			
		||||
// 	colorDanger: v.optional(v.pipe(v.number(), v.minValue(1), v.maxValue(100))),
 | 
			
		||||
// 	unitTemp: v.optional(v.enum(Unit)),
 | 
			
		||||
// 	unitNet: v.optional(v.enum(Unit)),
 | 
			
		||||
// 	unitDisk: v.optional(v.enum(Unit)),
 | 
			
		||||
// })
 | 
			
		||||
 | 
			
		||||
/** User settings */
 | 
			
		||||
export const $userSettings = map<UserSettings>({
 | 
			
		||||
	chartTime: "1h",
 | 
			
		||||
	emails: [pb.authStore.record?.email || ""],
 | 
			
		||||
	// unitTemp: "celsius",
 | 
			
		||||
	// unitNet: "mbps",
 | 
			
		||||
	// unitDisk: "mbps",
 | 
			
		||||
})
 | 
			
		||||
// update local storage on change
 | 
			
		||||
$userSettings.subscribe((value) => {
 | 
			
		||||
	// console.log('user settings changed', value)
 | 
			
		||||
	$chartTime.set(value.chartTime)
 | 
			
		||||
	unitNet: Unit.Bytes,
 | 
			
		||||
	unitTemp: Unit.Celsius,
 | 
			
		||||
})
 | 
			
		||||
// update chart time on change
 | 
			
		||||
$userSettings.subscribe((value) => $chartTime.set(value.chartTime))
 | 
			
		||||
 | 
			
		||||
/** Container chart filter */
 | 
			
		||||
export const $containerFilter = atom("")
 | 
			
		||||
 
 | 
			
		||||
@@ -84,7 +84,7 @@ export function useIntersectionObserver({
 | 
			
		||||
		entry: undefined,
 | 
			
		||||
	}))
 | 
			
		||||
 | 
			
		||||
	const callbackRef = useRef<UseIntersectionObserverOptions["onChange"]>()
 | 
			
		||||
	const callbackRef = useRef<UseIntersectionObserverOptions["onChange"]>(undefined)
 | 
			
		||||
 | 
			
		||||
	callbackRef.current = onChange
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user