mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-22 21:46:18 +01:00
Compare commits
121 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9fb9b856f | ||
|
|
66bca11d36 | ||
|
|
86e87f0d47 | ||
|
|
fadfc5d81d | ||
|
|
fc39ff1e4d | ||
|
|
82ccfc66e0 | ||
|
|
890bad1c39 | ||
|
|
9c458885f1 | ||
|
|
d2aed0dc72 | ||
|
|
3dbcb5d7da | ||
|
|
57a1a8b39e | ||
|
|
ab81c04569 | ||
|
|
0c32be3bea | ||
|
|
81d43fbf6e | ||
|
|
96f441de40 | ||
|
|
0e95caaee9 | ||
|
|
7697a12b42 | ||
|
|
94245a9ba4 | ||
|
|
b084814aea | ||
|
|
cce74246ee | ||
|
|
a3420b8c67 | ||
|
|
e1bb17ee9e | ||
|
|
52983f60b7 | ||
|
|
1f053fd85d | ||
|
|
a989d121d3 | ||
|
|
50d2406423 | ||
|
|
059d2d0a5b | ||
|
|
621bef30b5 | ||
|
|
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 | ||
|
|
80338d36aa | ||
|
|
f0d2c242e8 | ||
|
|
559f83d99c | ||
|
|
d3a751ee6c | ||
|
|
fb70a166fa | ||
|
|
c12457b707 | ||
|
|
3e53d73d56 | ||
|
|
80338c5e98 | ||
|
|
249cd8ad19 | ||
|
|
ccdff46370 | ||
|
|
91679b5cc0 | ||
|
|
6953edf59e | ||
|
|
b91c77ec92 | ||
|
|
3ac0b185d1 | ||
|
|
1e675cabb5 | ||
|
|
5f44965c2c | ||
|
|
f080929296 |
36
.github/workflows/docker-images.yml
vendored
36
.github/workflows/docker-images.yml
vendored
@@ -14,28 +14,48 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
- image: henrygd/beszel
|
- image: henrygd/beszel
|
||||||
context: ./beszel
|
context: ./beszel
|
||||||
dockerfile: ./beszel/dockerfile_Hub
|
dockerfile: ./beszel/dockerfile_hub
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
|
|
||||||
- image: henrygd/beszel-agent
|
- image: henrygd/beszel-agent
|
||||||
context: ./beszel
|
context: ./beszel
|
||||||
dockerfile: ./beszel/dockerfile_Agent
|
dockerfile: ./beszel/dockerfile_agent
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
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
|
- image: ghcr.io/${{ github.repository }}/beszel
|
||||||
context: ./beszel
|
context: ./beszel
|
||||||
dockerfile: ./beszel/dockerfile_Hub
|
dockerfile: ./beszel/dockerfile_hub
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
|
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
||||||
context: ./beszel
|
context: ./beszel
|
||||||
dockerfile: ./beszel/dockerfile_Agent
|
dockerfile: ./beszel/dockerfile_agent
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
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:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
@@ -73,7 +93,9 @@ jobs:
|
|||||||
|
|
||||||
# https://github.com/docker/login-action
|
# https://github.com/docker/login-action
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: github.event_name != 'pull_request'
|
env:
|
||||||
|
password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }}
|
||||||
|
if: github.event_name != 'pull_request' && env.password_secret_exists == 'true'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ matrix.username || secrets[matrix.username_secret] }}
|
username: ${{ matrix.username || secrets[matrix.username_secret] }}
|
||||||
@@ -87,7 +109,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: "${{ matrix.context }}"
|
context: "${{ matrix.context }}"
|
||||||
file: ${{ matrix.dockerfile }}
|
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' }}
|
push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }}
|
||||||
tags: ${{ steps.metadata.outputs.tags }}
|
tags: ${{ steps.metadata.outputs.tags }}
|
||||||
labels: ${{ steps.metadata.outputs.labels }}
|
labels: ${{ steps.metadata.outputs.labels }}
|
||||||
|
|||||||
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
@@ -3,7 +3,7 @@ name: Make release and binaries
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- "v*"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -29,7 +29,17 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
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
|
- name: GoReleaser beszel
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v6
|
||||||
@@ -40,3 +50,5 @@ jobs:
|
|||||||
args: release --clean
|
args: release --clean
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.TOKEN || secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
|
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
|
||||||
|
IS_FORK: ${{ github.repository_owner != 'henrygd' }}
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -17,3 +17,6 @@ beszel/build
|
|||||||
beszel/site/src/locales/**/*.ts
|
beszel/site/src/locales/**/*.ts
|
||||||
*.bak
|
*.bak
|
||||||
__debug_*
|
__debug_*
|
||||||
|
beszel/internal/agent/lhm/obj
|
||||||
|
beszel/internal/agent/lhm/bin
|
||||||
|
dockerfile_agent_dev
|
||||||
|
|||||||
@@ -38,12 +38,25 @@ builds:
|
|||||||
- mips64
|
- mips64
|
||||||
- riscv64
|
- riscv64
|
||||||
- mipsle
|
- mipsle
|
||||||
|
- mips
|
||||||
- ppc64le
|
- ppc64le
|
||||||
|
gomips:
|
||||||
|
- hardfloat
|
||||||
|
- softfloat
|
||||||
ignore:
|
ignore:
|
||||||
- goos: freebsd
|
- goos: freebsd
|
||||||
goarch: arm
|
goarch: arm
|
||||||
- goos: openbsd
|
- goos: openbsd
|
||||||
goarch: arm
|
goarch: arm
|
||||||
|
- goos: linux
|
||||||
|
goarch: mips64
|
||||||
|
gomips: softfloat
|
||||||
|
- goos: linux
|
||||||
|
goarch: mipsle
|
||||||
|
gomips: hardfloat
|
||||||
|
- goos: linux
|
||||||
|
goarch: mips
|
||||||
|
gomips: hardfloat
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: arm
|
goarch: arm
|
||||||
- goos: darwin
|
- goos: darwin
|
||||||
@@ -54,7 +67,7 @@ builds:
|
|||||||
archives:
|
archives:
|
||||||
- id: beszel-agent
|
- id: beszel-agent
|
||||||
formats: [tar.gz]
|
formats: [tar.gz]
|
||||||
builds:
|
ids:
|
||||||
- beszel-agent
|
- beszel-agent
|
||||||
name_template: >-
|
name_template: >-
|
||||||
{{ .Binary }}_
|
{{ .Binary }}_
|
||||||
@@ -66,7 +79,7 @@ archives:
|
|||||||
|
|
||||||
- id: beszel
|
- id: beszel
|
||||||
formats: [tar.gz]
|
formats: [tar.gz]
|
||||||
builds:
|
ids:
|
||||||
- beszel
|
- beszel
|
||||||
name_template: >-
|
name_template: >-
|
||||||
{{ .Binary }}_
|
{{ .Binary }}_
|
||||||
@@ -85,7 +98,7 @@ nfpms:
|
|||||||
API access.
|
API access.
|
||||||
maintainer: henrygd <hank@henrygd.me>
|
maintainer: henrygd <hank@henrygd.me>
|
||||||
section: net
|
section: net
|
||||||
builds:
|
ids:
|
||||||
- beszel-agent
|
- beszel-agent
|
||||||
formats:
|
formats:
|
||||||
- deb
|
- deb
|
||||||
@@ -122,6 +135,7 @@ scoops:
|
|||||||
homepage: "https://beszel.dev"
|
homepage: "https://beszel.dev"
|
||||||
description: "Agent for Beszel, a lightweight server monitoring platform."
|
description: "Agent for Beszel, a lightweight server monitoring platform."
|
||||||
license: MIT
|
license: MIT
|
||||||
|
skip_upload: "{{ if .Env.IS_FORK }}true{{ else }}auto{{ end }}"
|
||||||
|
|
||||||
# # Needs choco installed, so doesn't build on linux / default gh workflow :(
|
# # Needs choco installed, so doesn't build on linux / default gh workflow :(
|
||||||
# chocolateys:
|
# chocolateys:
|
||||||
@@ -155,7 +169,7 @@ brews:
|
|||||||
homepage: "https://beszel.dev"
|
homepage: "https://beszel.dev"
|
||||||
description: "Agent for Beszel, a lightweight server monitoring platform."
|
description: "Agent for Beszel, a lightweight server monitoring platform."
|
||||||
license: MIT
|
license: MIT
|
||||||
skip_upload: auto
|
skip_upload: "{{ if .Env.IS_FORK }}true{{ else }}auto{{ end }}"
|
||||||
extra_install: |
|
extra_install: |
|
||||||
(bin/"beszel-agent-launcher").write <<~EOS
|
(bin/"beszel-agent-launcher").write <<~EOS
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
@@ -173,7 +187,6 @@ brews:
|
|||||||
error_log_path "#{Dir.home}/.cache/beszel/beszel-agent.log"
|
error_log_path "#{Dir.home}/.cache/beszel/beszel-agent.log"
|
||||||
keep_alive true
|
keep_alive true
|
||||||
restart_delay 5
|
restart_delay 5
|
||||||
name beszel-agent
|
|
||||||
process_type :background
|
process_type :background
|
||||||
|
|
||||||
winget:
|
winget:
|
||||||
@@ -188,7 +201,7 @@ winget:
|
|||||||
release_notes_url: "https://github.com/henrygd/beszel/releases/tag/v{{ .Version }}"
|
release_notes_url: "https://github.com/henrygd/beszel/releases/tag/v{{ .Version }}"
|
||||||
publisher_support_url: "https://github.com/henrygd/beszel/issues"
|
publisher_support_url: "https://github.com/henrygd/beszel/issues"
|
||||||
short_description: "Agent for Beszel, a lightweight server monitoring platform."
|
short_description: "Agent for Beszel, a lightweight server monitoring platform."
|
||||||
skip_upload: auto
|
skip_upload: "{{ if .Env.IS_FORK }}true{{ else }}auto{{ end }}"
|
||||||
description: |
|
description: |
|
||||||
Beszel is a lightweight server monitoring platform that includes Docker
|
Beszel is a lightweight server monitoring platform that includes Docker
|
||||||
statistics, historical data, and alert functions. It has a friendly web
|
statistics, historical data, and alert functions. It has a friendly web
|
||||||
@@ -203,13 +216,14 @@ winget:
|
|||||||
owner: henrygd
|
owner: henrygd
|
||||||
name: beszel-winget
|
name: beszel-winget
|
||||||
branch: henrygd.beszel-agent-{{ .Version }}
|
branch: henrygd.beszel-agent-{{ .Version }}
|
||||||
pull_request:
|
token: "{{ .Env.WINGET_TOKEN }}"
|
||||||
enabled: false
|
# pull_request:
|
||||||
draft: false
|
# enabled: true
|
||||||
base:
|
# draft: false
|
||||||
owner: microsoft
|
# base:
|
||||||
name: winget-pkgs
|
# owner: microsoft
|
||||||
branch: master
|
# name: winget-pkgs
|
||||||
|
# branch: master
|
||||||
|
|
||||||
release:
|
release:
|
||||||
draft: true
|
draft: true
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ ARCH ?= $(shell go env GOARCH)
|
|||||||
# Skip building the web UI if true
|
# Skip building the web UI if true
|
||||||
SKIP_WEB ?= false
|
SKIP_WEB ?= false
|
||||||
|
|
||||||
.PHONY: tidy build-agent build-hub build clean lint dev-server dev-agent dev-hub dev generate-locales
|
# Set executable extension based on target OS
|
||||||
|
EXE_EXT := $(if $(filter windows,$(OS)),.exe,)
|
||||||
|
|
||||||
|
.PHONY: tidy build-agent build-hub build-hub-dev build clean lint dev-server dev-agent dev-hub dev generate-locales
|
||||||
.DEFAULT_GOAL := build
|
.DEFAULT_GOAL := build
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
@@ -30,11 +33,29 @@ build-web-ui:
|
|||||||
npm run --prefix ./site build; \
|
npm run --prefix ./site build; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
build-agent: tidy
|
# Conditional .NET build - only for Windows
|
||||||
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH) -ldflags "-w -s" beszel/cmd/agent
|
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)
|
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-hub-dev: tidy
|
||||||
|
mkdir -p ./site/dist && touch ./site/dist/index.html
|
||||||
|
GOOS=$(OS) GOARCH=$(ARCH) go build -tags development -o ./build/beszel-dev_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" beszel/cmd/hub
|
||||||
|
|
||||||
build: build-agent build-hub
|
build: build-agent build-hub
|
||||||
|
|
||||||
@@ -56,9 +77,9 @@ dev-hub: export ENV=dev
|
|||||||
dev-hub:
|
dev-hub:
|
||||||
mkdir -p ./site/dist && touch ./site/dist/index.html
|
mkdir -p ./site/dist && touch ./site/dist/index.html
|
||||||
@if command -v entr >/dev/null 2>&1; then \
|
@if command -v entr >/dev/null 2>&1; then \
|
||||||
find ./cmd/hub/*.go ./internal/{alerts,hub,records,users}/*.go | entr -r -s "cd ./cmd/hub && go run . serve --http 0.0.0.0:8090"; \
|
find ./cmd/hub/*.go ./internal/{alerts,hub,records,users}/*.go | entr -r -s "cd ./cmd/hub && go run -tags development . serve --http 0.0.0.0:8090"; \
|
||||||
else \
|
else \
|
||||||
cd ./cmd/hub && go run . serve --http 0.0.0.0:8090; \
|
cd ./cmd/hub && go run -tags development . serve --http 0.0.0.0:8090; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
dev-agent:
|
dev-agent:
|
||||||
@@ -67,6 +88,15 @@ dev-agent:
|
|||||||
else \
|
else \
|
||||||
go run beszel/cmd/agent; \
|
go run beszel/cmd/agent; \
|
||||||
fi
|
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
|
# KEY="..." make -j dev
|
||||||
dev: dev-server dev-hub dev-agent
|
dev: dev-server dev-hub dev-agent
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import (
|
|||||||
"beszel"
|
"beszel"
|
||||||
"beszel/internal/agent"
|
"beszel/internal/agent"
|
||||||
"beszel/internal/agent/health"
|
"beszel/internal/agent/health"
|
||||||
"flag"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/pflag"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,40 +17,24 @@ import (
|
|||||||
type cmdOptions struct {
|
type cmdOptions struct {
|
||||||
key string // key is the public key(s) for SSH authentication.
|
key string // key is the public key(s) for SSH authentication.
|
||||||
listen string // listen is the address or port to listen on.
|
listen string // listen is the address or port to listen on.
|
||||||
|
// TODO: add hubURL and token
|
||||||
|
// hubURL string // hubURL is the URL of the hub to use.
|
||||||
|
// token string // token is the token to use for authentication.
|
||||||
}
|
}
|
||||||
|
|
||||||
// parse parses the command line flags and populates the config struct.
|
// parse parses the command line flags and populates the config struct.
|
||||||
// It returns true if a subcommand was handled and the program should exit.
|
// It returns true if a subcommand was handled and the program should exit.
|
||||||
func (opts *cmdOptions) parse() bool {
|
func (opts *cmdOptions) parse() bool {
|
||||||
flag.StringVar(&opts.key, "key", "", "Public key(s) for SSH authentication")
|
|
||||||
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:")
|
|
||||||
flag.PrintDefaults()
|
|
||||||
}
|
|
||||||
|
|
||||||
subcommand := ""
|
subcommand := ""
|
||||||
if len(os.Args) > 1 {
|
if len(os.Args) > 1 {
|
||||||
subcommand = os.Args[1]
|
subcommand = os.Args[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subcommands that don't require any pflag parsing
|
||||||
switch subcommand {
|
switch subcommand {
|
||||||
case "-v", "version":
|
case "-v", "version":
|
||||||
fmt.Println(beszel.AppName+"-agent", beszel.Version)
|
fmt.Println(beszel.AppName+"-agent", beszel.Version)
|
||||||
return true
|
return true
|
||||||
case "help":
|
|
||||||
flag.Usage()
|
|
||||||
return true
|
|
||||||
case "update":
|
|
||||||
agent.Update()
|
|
||||||
return true
|
|
||||||
case "health":
|
case "health":
|
||||||
err := health.Check()
|
err := health.Check()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -59,7 +44,57 @@ func (opts *cmdOptions) parse() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
flag.Parse()
|
// pflag.CommandLine.ParseErrorsWhitelist.UnknownFlags = true
|
||||||
|
pflag.StringVarP(&opts.key, "key", "k", "", "Public key(s) for SSH authentication")
|
||||||
|
pflag.StringVarP(&opts.listen, "listen", "l", "", "Address or port to listen on")
|
||||||
|
// pflag.StringVarP(&opts.hubURL, "hub-url", "u", "", "URL of the hub to use")
|
||||||
|
// pflag.StringVarP(&opts.token, "token", "t", "", "Token to use for authentication")
|
||||||
|
chinaMirrors := pflag.BoolP("china-mirrors", "c", false, "Use mirror for update (gh.beszel.dev) instead of GitHub")
|
||||||
|
help := pflag.BoolP("help", "h", false, "Show this help message")
|
||||||
|
|
||||||
|
// Convert old single-dash long flags to double-dash for backward compatibility
|
||||||
|
flagsToConvert := []string{"key", "listen"}
|
||||||
|
for i, arg := range os.Args {
|
||||||
|
for _, flag := range flagsToConvert {
|
||||||
|
singleDash := "-" + flag
|
||||||
|
doubleDash := "--" + flag
|
||||||
|
if arg == singleDash {
|
||||||
|
os.Args[i] = doubleDash
|
||||||
|
break
|
||||||
|
} else if strings.HasPrefix(arg, singleDash+"=") {
|
||||||
|
os.Args[i] = doubleDash + arg[len(singleDash):]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pflag.Usage = func() {
|
||||||
|
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())
|
||||||
|
pflag.PrintDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse all arguments with pflag
|
||||||
|
pflag.Parse()
|
||||||
|
|
||||||
|
// Must run after pflag.Parse()
|
||||||
|
switch {
|
||||||
|
case *help || subcommand == "help":
|
||||||
|
pflag.Usage()
|
||||||
|
return true
|
||||||
|
case subcommand == "update":
|
||||||
|
agent.Update(*chinaMirrors)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,12 +146,12 @@ func main() {
|
|||||||
serverConfig.Addr = addr
|
serverConfig.Addr = addr
|
||||||
serverConfig.Network = agent.GetNetwork(addr)
|
serverConfig.Network = agent.GetNetwork(addr)
|
||||||
|
|
||||||
agent, err := agent.NewAgent("")
|
a, err := agent.NewAgent()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Failed to create agent: ", err)
|
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)
|
log.Fatal("Failed to start server: ", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ package main
|
|||||||
import (
|
import (
|
||||||
"beszel/internal/agent"
|
"beszel/internal/agent"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"flag"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/pflag"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
@@ -245,7 +245,7 @@ func TestParseFlags(t *testing.T) {
|
|||||||
oldArgs := os.Args
|
oldArgs := os.Args
|
||||||
defer func() {
|
defer func() {
|
||||||
os.Args = oldArgs
|
os.Args = oldArgs
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
pflag.CommandLine = pflag.NewFlagSet(os.Args[0], pflag.ExitOnError)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -269,6 +269,22 @@ func TestParseFlags(t *testing.T) {
|
|||||||
listen: "",
|
listen: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "key flag double dash",
|
||||||
|
args: []string{"cmd", "--key", "testkey"},
|
||||||
|
expected: cmdOptions{
|
||||||
|
key: "testkey",
|
||||||
|
listen: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "key flag short",
|
||||||
|
args: []string{"cmd", "-k", "testkey"},
|
||||||
|
expected: cmdOptions{
|
||||||
|
key: "testkey",
|
||||||
|
listen: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "addr flag only",
|
name: "addr flag only",
|
||||||
args: []string{"cmd", "-listen", ":8080"},
|
args: []string{"cmd", "-listen", ":8080"},
|
||||||
@@ -277,6 +293,22 @@ func TestParseFlags(t *testing.T) {
|
|||||||
listen: ":8080",
|
listen: ":8080",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "addr flag double dash",
|
||||||
|
args: []string{"cmd", "--listen", ":8080"},
|
||||||
|
expected: cmdOptions{
|
||||||
|
key: "",
|
||||||
|
listen: ":8080",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "addr flag short",
|
||||||
|
args: []string{"cmd", "-l", ":8080"},
|
||||||
|
expected: cmdOptions{
|
||||||
|
key: "",
|
||||||
|
listen: ":8080",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "both flags",
|
name: "both flags",
|
||||||
args: []string{"cmd", "-key", "testkey", "-listen", ":8080"},
|
args: []string{"cmd", "-key", "testkey", "-listen", ":8080"},
|
||||||
@@ -290,12 +322,12 @@ func TestParseFlags(t *testing.T) {
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Reset flags for each test
|
// Reset flags for each test
|
||||||
flag.CommandLine = flag.NewFlagSet(tt.args[0], flag.ExitOnError)
|
pflag.CommandLine = pflag.NewFlagSet(tt.args[0], pflag.ExitOnError)
|
||||||
os.Args = tt.args
|
os.Args = tt.args
|
||||||
|
|
||||||
var opts cmdOptions
|
var opts cmdOptions
|
||||||
opts.parse()
|
opts.parse()
|
||||||
flag.Parse()
|
pflag.Parse()
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, opts)
|
assert.Equal(t, tt.expected, opts)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -45,11 +45,13 @@ func getBaseApp() *pocketbase.PocketBase {
|
|||||||
baseApp.RootCmd.Use = beszel.AppName
|
baseApp.RootCmd.Use = beszel.AppName
|
||||||
baseApp.RootCmd.Short = ""
|
baseApp.RootCmd.Short = ""
|
||||||
// add update command
|
// add update command
|
||||||
baseApp.RootCmd.AddCommand(&cobra.Command{
|
updateCmd := &cobra.Command{
|
||||||
Use: "update",
|
Use: "update",
|
||||||
Short: "Update " + beszel.AppName + " to the latest version",
|
Short: "Update " + beszel.AppName + " to the latest version",
|
||||||
Run: hub.Update,
|
Run: hub.Update,
|
||||||
})
|
}
|
||||||
|
updateCmd.Flags().Bool("china-mirrors", false, "Use mirror (gh.beszel.dev) instead of GitHub")
|
||||||
|
baseApp.RootCmd.AddCommand(updateCmd)
|
||||||
// add health command
|
// add health command
|
||||||
baseApp.RootCmd.AddCommand(newHealthCmd())
|
baseApp.RootCmd.AddCommand(newHealthCmd())
|
||||||
|
|
||||||
|
|||||||
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
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent
|
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: GPU-enabled agent with nvidia-smi
|
||||||
# ? -------------------------
|
# --------------------------
|
||||||
FROM scratch
|
FROM nvidia/cuda:12.2.2-base-ubuntu22.04
|
||||||
|
|
||||||
COPY --from=builder /agent /agent
|
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"]
|
ENTRYPOINT ["/agent"]
|
||||||
@@ -7,20 +7,21 @@ replace github.com/nicholas-fedor/shoutrrr => github.com/nicholas-fedor/shoutrrr
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/blang/semver v3.5.1+incompatible
|
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/fxamacker/cbor/v2 v2.9.0
|
||||||
github.com/gliderlabs/ssh v0.3.8
|
github.com/gliderlabs/ssh v0.3.8
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/lxzan/gws v1.8.9
|
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/dbx v1.11.0
|
||||||
github.com/pocketbase/pocketbase v0.29.0
|
github.com/pocketbase/pocketbase v0.29.3
|
||||||
github.com/rhysd/go-github-selfupdate v1.2.3
|
|
||||||
github.com/shirou/gopsutil/v4 v4.25.6
|
github.com/shirou/gopsutil/v4 v4.25.6
|
||||||
github.com/spf13/cast v1.9.2
|
github.com/spf13/cast v1.9.2
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/spf13/pflag v1.0.7
|
||||||
golang.org/x/crypto v0.40.0
|
github.com/stretchr/testify v1.11.0
|
||||||
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792
|
golang.org/x/crypto v0.41.0
|
||||||
|
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,34 +40,30 @@ require (
|
|||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/go-ozzo/ozzo-validation/v4 v4.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/go-sql-driver/mysql v1.9.1 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.0 // 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/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.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-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/go-sysconf v0.3.15 // indirect
|
||||||
github.com/tklauser/numcpus v0.10.0 // 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/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/image v0.29.0 // indirect
|
golang.org/x/image v0.30.0 // indirect
|
||||||
golang.org/x/net v0.42.0 // indirect
|
golang.org/x/net v0.43.0 // indirect
|
||||||
golang.org/x/oauth2 v0.30.0 // indirect
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
golang.org/x/sync v0.16.0 // indirect
|
golang.org/x/sync v0.16.0 // indirect
|
||||||
golang.org/x/sys v0.34.0 // indirect
|
golang.org/x/sys v0.35.0 // indirect
|
||||||
golang.org/x/text v0.27.0 // indirect
|
golang.org/x/text v0.28.0 // indirect
|
||||||
modernc.org/libc v1.65.10 // 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/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // 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/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 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
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 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
|
||||||
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
|
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
github.com/domodwyer/mailyak/v3 v3.6.2 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/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 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
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 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
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=
|
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-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 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
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.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
|
||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
|
||||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
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 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k=
|
||||||
github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
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 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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-20250821153705-5981dea3221d h1:vFzYZc8yji+9DmNRhpEbs8VBK4CgV/DPfGzeVJSSp/8=
|
||||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
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 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
|
||||||
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
|
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 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/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 h1:F/oyoatWK5cbHPPgkjRZrA0262TP7KWuUQz9KskRtR8=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.8.8/go.mod h1:T30Y+eoZFEjDk4HtOItcHQioZSOe3Z6a6aNfSz6jc5c=
|
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 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
||||||
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
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 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
|
||||||
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
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=
|
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/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 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
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.3 h1:Mj8o5awsbVJIdIoTuQNhfC2oL/c4aImQ3RyfFZlzFVg=
|
||||||
github.com/pocketbase/pocketbase v0.29.0/go.mod h1:SqyH7o/3e+/uLySATlJqxH4S8gyU6R0adG56ZSV1vuU=
|
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 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/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/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/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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
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/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
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/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 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
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 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 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 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
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-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.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
|
||||||
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4=
|
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
||||||
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
|
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
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.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
|
||||||
golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA=
|
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
|
||||||
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
|
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||||
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
|
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
|
||||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
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/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 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
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-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-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.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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.35.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.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||||
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
|
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||||
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
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.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.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
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.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
|
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||||
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=
|
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
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 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
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 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 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||||
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.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
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/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
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 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
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/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI=
|
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||||
modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE=
|
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 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
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.
|
// 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.
|
// 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{
|
agent = &Agent{
|
||||||
fsStats: make(map[string]*system.FsStats),
|
fsStats: make(map[string]*system.FsStats),
|
||||||
cache: NewSessionCache(69 * time.Second),
|
cache: NewSessionCache(69 * time.Second),
|
||||||
}
|
}
|
||||||
|
|
||||||
agent.dataDir, err = getDataDir(dataDir)
|
agent.dataDir, err = getDataDir(dataDir...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("Data directory not found")
|
slog.Warn("Data directory not found")
|
||||||
} else {
|
} else {
|
||||||
@@ -113,37 +113,37 @@ func (a *Agent) gatherStats(sessionID string) *system.CombinedData {
|
|||||||
a.Lock()
|
a.Lock()
|
||||||
defer a.Unlock()
|
defer a.Unlock()
|
||||||
|
|
||||||
cachedData, ok := a.cache.Get(sessionID)
|
data, isCached := a.cache.Get(sessionID)
|
||||||
if ok {
|
if isCached {
|
||||||
slog.Debug("Cached stats", "session", sessionID)
|
slog.Debug("Cached data", "session", sessionID)
|
||||||
return cachedData
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
*cachedData = system.CombinedData{
|
*data = system.CombinedData{
|
||||||
Stats: a.getSystemStats(),
|
Stats: a.getSystemStats(),
|
||||||
Info: a.systemInfo,
|
Info: a.systemInfo,
|
||||||
}
|
}
|
||||||
slog.Debug("System stats", "data", cachedData)
|
slog.Debug("System data", "data", data)
|
||||||
|
|
||||||
if a.dockerManager != nil {
|
if a.dockerManager != nil {
|
||||||
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
|
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
|
||||||
cachedData.Containers = containerStats
|
data.Containers = containerStats
|
||||||
slog.Debug("Docker stats", "data", cachedData.Containers)
|
slog.Debug("Containers", "data", data.Containers)
|
||||||
} else {
|
} 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 {
|
for name, stats := range a.fsStats {
|
||||||
if !stats.Root && stats.DiskTotal > 0 {
|
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)
|
a.cache.Set(sessionID, data)
|
||||||
return cachedData
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartAgent initializes and starts the agent with optional WebSocket connection
|
// StartAgent initializes and starts the agent with optional WebSocket connection
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestSessionCache_GetSet(t *testing.T) {
|
func TestSessionCache_GetSet(t *testing.T) {
|
||||||
synctest.Run(func() {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
cache := NewSessionCache(69 * time.Second)
|
cache := NewSessionCache(69 * time.Second)
|
||||||
|
|
||||||
testData := &system.CombinedData{
|
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
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -53,9 +54,9 @@ func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
|||||||
return nil, errors.New("invalid hub URL")
|
return nil, errors.New("invalid hub URL")
|
||||||
}
|
}
|
||||||
// get registration token
|
// get registration token
|
||||||
client.token, _ = GetEnv("TOKEN")
|
client.token, err = getToken()
|
||||||
if client.token == "" {
|
if err != nil {
|
||||||
return nil, errors.New("TOKEN environment variable not set")
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
client.agent = agent
|
client.agent = agent
|
||||||
@@ -65,6 +66,27 @@ func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
|||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getToken returns the token for the WebSocket client.
|
||||||
|
// It first checks the TOKEN environment variable, then the TOKEN_FILE environment variable.
|
||||||
|
// If neither is set, it returns an error.
|
||||||
|
func getToken() (string, error) {
|
||||||
|
// get token from env var
|
||||||
|
token, _ := GetEnv("TOKEN")
|
||||||
|
if token != "" {
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
// get token from file
|
||||||
|
tokenFile, _ := GetEnv("TOKEN_FILE")
|
||||||
|
if tokenFile == "" {
|
||||||
|
return "", errors.New("must set TOKEN or TOKEN_FILE")
|
||||||
|
}
|
||||||
|
tokenBytes, err := os.ReadFile(tokenFile)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(tokenBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
// getOptions returns the WebSocket client options, creating them if necessary.
|
// getOptions returns the WebSocket client options, creating them if necessary.
|
||||||
// It configures the connection URL, TLS settings, and authentication headers.
|
// It configures the connection URL, TLS settings, and authentication headers.
|
||||||
func (client *WebSocketClient) getOptions() *gws.ClientOption {
|
func (client *WebSocketClient) getOptions() *gws.ClientOption {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func TestNewWebSocketClient(t *testing.T) {
|
|||||||
hubURL: "http://localhost:8080",
|
hubURL: "http://localhost:8080",
|
||||||
token: "",
|
token: "",
|
||||||
expectError: true,
|
expectError: true,
|
||||||
errorMsg: "TOKEN environment variable not set",
|
errorMsg: "must set TOKEN or TOKEN_FILE",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,3 +389,150 @@ func TestWebSocketClient_ConnectRateLimit(t *testing.T) {
|
|||||||
err = client.Connect()
|
err = client.Connect()
|
||||||
assert.Error(t, err, "Connection should fail but not hang")
|
assert.Error(t, err, "Connection should fail but not hang")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGetToken tests the getToken function with various scenarios
|
||||||
|
func TestGetToken(t *testing.T) {
|
||||||
|
unsetEnvVars := func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
os.Unsetenv("TOKEN")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN_FILE")
|
||||||
|
os.Unsetenv("TOKEN_FILE")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("token from TOKEN environment variable", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
// Set TOKEN env var
|
||||||
|
expectedToken := "test-token-from-env"
|
||||||
|
os.Setenv("TOKEN", expectedToken)
|
||||||
|
defer os.Unsetenv("TOKEN")
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedToken, token)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("token from BESZEL_AGENT_TOKEN environment variable", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
// Set BESZEL_AGENT_TOKEN env var (should take precedence)
|
||||||
|
expectedToken := "test-token-from-beszel-env"
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", expectedToken)
|
||||||
|
defer os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedToken, token)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("token from TOKEN_FILE", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
// Create a temporary token file
|
||||||
|
expectedToken := "test-token-from-file"
|
||||||
|
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.Remove(tokenFile.Name())
|
||||||
|
|
||||||
|
_, err = tokenFile.WriteString(expectedToken)
|
||||||
|
require.NoError(t, err)
|
||||||
|
tokenFile.Close()
|
||||||
|
|
||||||
|
// Set TOKEN_FILE env var
|
||||||
|
os.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||||
|
defer os.Unsetenv("TOKEN_FILE")
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedToken, token)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("token from BESZEL_AGENT_TOKEN_FILE", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
// Create a temporary token file
|
||||||
|
expectedToken := "test-token-from-beszel-file"
|
||||||
|
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.Remove(tokenFile.Name())
|
||||||
|
|
||||||
|
_, err = tokenFile.WriteString(expectedToken)
|
||||||
|
require.NoError(t, err)
|
||||||
|
tokenFile.Close()
|
||||||
|
|
||||||
|
// Set BESZEL_AGENT_TOKEN_FILE env var (should take precedence)
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN_FILE", tokenFile.Name())
|
||||||
|
defer os.Unsetenv("BESZEL_AGENT_TOKEN_FILE")
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedToken, token)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("TOKEN takes precedence over TOKEN_FILE", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
// Create a temporary token file
|
||||||
|
fileToken := "token-from-file"
|
||||||
|
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.Remove(tokenFile.Name())
|
||||||
|
|
||||||
|
_, err = tokenFile.WriteString(fileToken)
|
||||||
|
require.NoError(t, err)
|
||||||
|
tokenFile.Close()
|
||||||
|
|
||||||
|
// Set both TOKEN and TOKEN_FILE
|
||||||
|
envToken := "token-from-env"
|
||||||
|
os.Setenv("TOKEN", envToken)
|
||||||
|
os.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("TOKEN")
|
||||||
|
os.Unsetenv("TOKEN_FILE")
|
||||||
|
}()
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, envToken, token, "TOKEN should take precedence over TOKEN_FILE")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error when neither TOKEN nor TOKEN_FILE is set", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, "", token)
|
||||||
|
assert.Contains(t, err.Error(), "must set TOKEN or TOKEN_FILE")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error when TOKEN_FILE points to non-existent file", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
// Set TOKEN_FILE to a non-existent file
|
||||||
|
os.Setenv("TOKEN_FILE", "/non/existent/file.txt")
|
||||||
|
defer os.Unsetenv("TOKEN_FILE")
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, "", token)
|
||||||
|
assert.Contains(t, err.Error(), "no such file or directory")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handles empty token file", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
// Create an empty token file
|
||||||
|
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.Remove(tokenFile.Name())
|
||||||
|
tokenFile.Close()
|
||||||
|
|
||||||
|
// Set TOKEN_FILE env var
|
||||||
|
os.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||||
|
defer os.Unsetenv("TOKEN_FILE")
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "", token, "Empty file should return empty string")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,35 +9,30 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// getDataDir returns the path to the data directory for the agent and an error
|
// getDataDir returns the path to the data directory for the agent and an error
|
||||||
// if the directory is not valid. Pass an empty string to attempt to find the
|
// if the directory is not valid. Attempts to find the optimal data directory if
|
||||||
// optimal data directory.
|
// no data directories are provided.
|
||||||
func getDataDir(dataDir string) (string, error) {
|
func getDataDir(dataDirs ...string) (string, error) {
|
||||||
if dataDir == "" {
|
if len(dataDirs) > 0 {
|
||||||
dataDir, _ = GetEnv("DATA_DIR")
|
return testDataDirs(dataDirs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dataDir, _ := GetEnv("DATA_DIR")
|
||||||
if dataDir != "" {
|
if dataDir != "" {
|
||||||
return testDataDirs([]string{dataDir})
|
dataDirs = append(dataDirs, dataDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
var dirsToTry []string
|
|
||||||
|
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
dirsToTry = []string{
|
dataDirs = append(dataDirs,
|
||||||
filepath.Join(os.Getenv("APPDATA"), "beszel-agent"),
|
filepath.Join(os.Getenv("APPDATA"), "beszel-agent"),
|
||||||
filepath.Join(os.Getenv("LOCALAPPDATA"), "beszel-agent"),
|
filepath.Join(os.Getenv("LOCALAPPDATA"), "beszel-agent"),
|
||||||
}
|
)
|
||||||
} else {
|
} else {
|
||||||
homeDir, err := os.UserHomeDir()
|
dataDirs = append(dataDirs, "/var/lib/beszel-agent")
|
||||||
if err != nil {
|
if homeDir, err := os.UserHomeDir(); err == nil {
|
||||||
return "", err
|
dataDirs = append(dataDirs, filepath.Join(homeDir, ".config", "beszel"))
|
||||||
}
|
|
||||||
dirsToTry = []string{
|
|
||||||
"/var/lib/beszel-agent",
|
|
||||||
filepath.Join(homeDir, ".config", "beszel"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return testDataDirs(dirsToTry)
|
return testDataDirs(dataDirs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDataDirs(paths []string) (string, error) {
|
func testDataDirs(paths []string) (string, error) {
|
||||||
|
|||||||
@@ -44,15 +44,15 @@ func TestGetDataDir(t *testing.T) {
|
|||||||
oldValue := os.Getenv("DATA_DIR")
|
oldValue := os.Getenv("DATA_DIR")
|
||||||
defer func() {
|
defer func() {
|
||||||
if oldValue == "" {
|
if oldValue == "" {
|
||||||
os.Unsetenv("DATA_DIR")
|
os.Unsetenv("BESZEL_AGENT_DATA_DIR")
|
||||||
} else {
|
} else {
|
||||||
os.Setenv("DATA_DIR", oldValue)
|
os.Setenv("BESZEL_AGENT_DATA_DIR", oldValue)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
os.Setenv("DATA_DIR", tempDir)
|
os.Setenv("BESZEL_AGENT_DATA_DIR", tempDir)
|
||||||
|
|
||||||
result, err := getDataDir("")
|
result, err := getDataDir()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, tempDir, result)
|
assert.Equal(t, tempDir, result)
|
||||||
})
|
})
|
||||||
@@ -79,7 +79,7 @@ func TestGetDataDir(t *testing.T) {
|
|||||||
|
|
||||||
// This will try platform-specific defaults, which may or may not work
|
// This will try platform-specific defaults, which may or may not work
|
||||||
// We're mainly testing that it doesn't panic and returns some result
|
// We're mainly testing that it doesn't panic and returns some result
|
||||||
result, err := getDataDir("")
|
result, err := getDataDir()
|
||||||
// We don't assert success/failure here since it depends on system permissions
|
// We don't assert success/failure here since it depends on system permissions
|
||||||
// Just verify we get a string result if no error
|
// Just verify we get a string result if no error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func TestHealth(t *testing.T) {
|
|||||||
// This test uses synctest to simulate time passing.
|
// This test uses synctest to simulate time passing.
|
||||||
// NOTE: This test requires GOEXPERIMENT=synctest to run.
|
// NOTE: This test requires GOEXPERIMENT=synctest to run.
|
||||||
t.Run("check with simulated time", func(t *testing.T) {
|
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.
|
// Update the file to set the initial timestamp.
|
||||||
require.NoError(t, Update(), "Update() failed inside synctest")
|
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
|
// reset high temp
|
||||||
a.systemInfo.DashboardTemp = 0
|
a.systemInfo.DashboardTemp = 0
|
||||||
|
|
||||||
temps, err := a.getTempsWithPanicRecovery(sensors.TemperaturesWithContext)
|
temps, err := a.getTempsWithPanicRecovery(getSensorTemps)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// retry once on panic (gopsutil/issues/1832)
|
// retry once on panic (gopsutil/issues/1832)
|
||||||
temps, err = a.getTempsWithPanicRecovery(sensors.TemperaturesWithContext)
|
temps, err = a.getTempsWithPanicRecovery(getSensorTemps)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("Error updating temperatures", "err", err)
|
slog.Warn("Error updating temperatures", "err", err)
|
||||||
if len(systemStats.Temperatures) > 0 {
|
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 (
|
import (
|
||||||
"beszel"
|
"beszel"
|
||||||
|
"beszel/internal/agent/battery"
|
||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -59,10 +60,10 @@ func (a *Agent) initializeSystemInfo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// zfs
|
// zfs
|
||||||
if _, err := getARCSize(); err == nil {
|
if _, err := getARCSize(); err != nil {
|
||||||
a.zfs = true
|
|
||||||
} else {
|
|
||||||
slog.Debug("Not monitoring ZFS ARC", "err", err)
|
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 {
|
func (a *Agent) getSystemStats() system.Stats {
|
||||||
systemStats := system.Stats{}
|
systemStats := system.Stats{}
|
||||||
|
|
||||||
|
// battery
|
||||||
|
if battery.HasReadableBattery() {
|
||||||
|
systemStats.Battery[0], systemStats.Battery[1], _ = battery.GetBatteryStats()
|
||||||
|
}
|
||||||
|
|
||||||
// cpu percent
|
// cpu percent
|
||||||
cpuPct, err := cpu.Percent(0, false)
|
cpuPct, err := cpu.Percent(0, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -80,10 +86,10 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
|
|
||||||
// load average
|
// load average
|
||||||
if avgstat, err := load.Avg(); err == nil {
|
if avgstat, err := load.Avg(); err == nil {
|
||||||
systemStats.LoadAvg1 = twoDecimals(avgstat.Load1)
|
systemStats.LoadAvg[0] = avgstat.Load1
|
||||||
systemStats.LoadAvg5 = twoDecimals(avgstat.Load5)
|
systemStats.LoadAvg[1] = avgstat.Load5
|
||||||
systemStats.LoadAvg15 = twoDecimals(avgstat.Load15)
|
systemStats.LoadAvg[2] = avgstat.Load15
|
||||||
slog.Debug("Load average", "5m", systemStats.LoadAvg5, "15m", systemStats.LoadAvg15)
|
slog.Debug("Load average", "5m", avgstat.Load5, "15m", avgstat.Load15)
|
||||||
} else {
|
} else {
|
||||||
slog.Error("Error getting load average", "err", err)
|
slog.Error("Error getting load average", "err", err)
|
||||||
}
|
}
|
||||||
@@ -255,9 +261,11 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
|
|
||||||
// update base system info
|
// update base system info
|
||||||
a.systemInfo.Cpu = systemStats.Cpu
|
a.systemInfo.Cpu = systemStats.Cpu
|
||||||
a.systemInfo.LoadAvg1 = systemStats.LoadAvg1
|
a.systemInfo.LoadAvg = systemStats.LoadAvg
|
||||||
a.systemInfo.LoadAvg5 = systemStats.LoadAvg5
|
// TODO: remove these in future release in favor of load avg array
|
||||||
a.systemInfo.LoadAvg15 = systemStats.LoadAvg15
|
a.systemInfo.LoadAvg1 = systemStats.LoadAvg[0]
|
||||||
|
a.systemInfo.LoadAvg5 = systemStats.LoadAvg[1]
|
||||||
|
a.systemInfo.LoadAvg15 = systemStats.LoadAvg[2]
|
||||||
a.systemInfo.MemPct = systemStats.MemPct
|
a.systemInfo.MemPct = systemStats.MemPct
|
||||||
a.systemInfo.DiskPct = systemStats.DiskPct
|
a.systemInfo.DiskPct = systemStats.DiskPct
|
||||||
a.systemInfo.Uptime, _ = host.Uptime()
|
a.systemInfo.Uptime, _ = host.Uptime()
|
||||||
|
|||||||
@@ -1,56 +1,150 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel"
|
"beszel/internal/ghupdate"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
|
||||||
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update updates beszel-agent to the latest version
|
// restarter knows how to restart the beszel-agent service.
|
||||||
func Update() {
|
type restarter interface {
|
||||||
var latest *selfupdate.Release
|
Restart() error
|
||||||
var found bool
|
}
|
||||||
var err error
|
|
||||||
currentVersion := semver.MustParse(beszel.Version)
|
type systemdRestarter struct{ cmd string }
|
||||||
fmt.Println("beszel-agent", currentVersion)
|
|
||||||
fmt.Println("Checking for updates...")
|
func (s *systemdRestarter) Restart() error {
|
||||||
updater, _ := selfupdate.NewUpdater(selfupdate.Config{
|
// Only restart if the service is active
|
||||||
Filters: []string{"beszel-agent"},
|
if err := exec.Command(s.cmd, "is-active", "beszel-agent.service").Run(); err != nil {
|
||||||
})
|
return nil
|
||||||
latest, found, err = updater.DetectLatest("henrygd/beszel")
|
}
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent.service via systemd…")
|
||||||
if err != nil {
|
return exec.Command(s.cmd, "restart", "beszel-agent.service").Run()
|
||||||
fmt.Println("Error checking for updates:", err)
|
}
|
||||||
os.Exit(1)
|
|
||||||
}
|
type openRCRestarter struct{ cmd string }
|
||||||
|
|
||||||
if !found {
|
func (o *openRCRestarter) Restart() error {
|
||||||
fmt.Println("No updates found")
|
if err := exec.Command(o.cmd, "status", "beszel-agent").Run(); err != nil {
|
||||||
os.Exit(0)
|
return nil
|
||||||
}
|
}
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via OpenRC…")
|
||||||
fmt.Println("Latest version:", latest.Version)
|
return exec.Command(o.cmd, "restart", "beszel-agent").Run()
|
||||||
|
}
|
||||||
if latest.Version.LTE(currentVersion) {
|
|
||||||
fmt.Println("You are up to date")
|
type openWRTRestarter struct{ cmd string }
|
||||||
return
|
|
||||||
}
|
func (w *openWRTRestarter) Restart() error {
|
||||||
|
if err := exec.Command(w.cmd, "running", "beszel-agent").Run(); err != nil {
|
||||||
var binaryPath string
|
return nil
|
||||||
fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version)
|
}
|
||||||
binaryPath, err = os.Executable()
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via procd…")
|
||||||
if err != nil {
|
return exec.Command(w.cmd, "restart", "beszel-agent").Run()
|
||||||
fmt.Println("Error getting binary path:", err)
|
}
|
||||||
os.Exit(1)
|
|
||||||
}
|
func detectRestarter() restarter {
|
||||||
err = selfupdate.UpdateTo(latest.AssetURL, binaryPath)
|
if path, err := exec.LookPath("systemctl"); err == nil {
|
||||||
if err != nil {
|
return &systemdRestarter{cmd: path}
|
||||||
fmt.Println("Please try rerunning with sudo. Error:", err)
|
}
|
||||||
os.Exit(1)
|
if path, err := exec.LookPath("rc-service"); err == nil {
|
||||||
}
|
return &openRCRestarter{cmd: path}
|
||||||
fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes))
|
}
|
||||||
|
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(useMirror bool) error {
|
||||||
|
exePath, _ := os.Executable()
|
||||||
|
|
||||||
|
dataDir, err := getDataDir()
|
||||||
|
if err != nil {
|
||||||
|
dataDir = os.TempDir()
|
||||||
|
}
|
||||||
|
updated, err := ghupdate.Update(ghupdate.Config{
|
||||||
|
ArchiveExecutable: "beszel-agent",
|
||||||
|
DataDir: dataDir,
|
||||||
|
UseMirror: useMirror,
|
||||||
|
})
|
||||||
|
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.ColorGreen, "Service restarted successfully")
|
||||||
|
}
|
||||||
|
} 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/nicholas-fedor/shoutrrr"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||||
)
|
)
|
||||||
@@ -47,9 +46,7 @@ type SystemAlertStats struct {
|
|||||||
NetSent float64 `json:"ns"`
|
NetSent float64 `json:"ns"`
|
||||||
NetRecv float64 `json:"nr"`
|
NetRecv float64 `json:"nr"`
|
||||||
Temperatures map[string]float32 `json:"t"`
|
Temperatures map[string]float32 `json:"t"`
|
||||||
LoadAvg1 float64 `json:"l1"`
|
LoadAvg [3]float64 `json:"la"`
|
||||||
LoadAvg5 float64 `json:"l5"`
|
|
||||||
LoadAvg15 float64 `json:"l15"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SystemAlertData struct {
|
type SystemAlertData struct {
|
||||||
@@ -208,16 +205,14 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
||||||
info, _ := e.RequestInfo()
|
var data struct {
|
||||||
if info.Auth == nil {
|
URL string `json:"url"`
|
||||||
return apis.NewForbiddenError("Forbidden", nil)
|
|
||||||
}
|
}
|
||||||
url := e.Request.URL.Query().Get("url")
|
err := e.BindBody(&data)
|
||||||
// log.Println("url", url)
|
if err != nil || data.URL == "" {
|
||||||
if url == "" {
|
return e.BadRequestError("URL is required", err)
|
||||||
return e.JSON(200, map[string]string{"err": "URL is required"})
|
|
||||||
}
|
}
|
||||||
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 {
|
if err != nil {
|
||||||
return e.JSON(200, map[string]string{"err": err.Error()})
|
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") {
|
if !e.Record.GetBool("triggered") {
|
||||||
return e.Next()
|
return e.Next()
|
||||||
}
|
}
|
||||||
_ = resolveAlertHistoryRecord(e.App, e.Record)
|
_ = resolveAlertHistoryRecord(e.App, e.Record.Id)
|
||||||
return e.Next()
|
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
|
// 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()
|
return e.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveAlertHistoryRecord sets the resolved field to the current time
|
// 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(
|
alertHistoryRecords, err := app.FindRecordsByFilter(
|
||||||
"alerts_history",
|
"alerts_history",
|
||||||
"alert_id={:alert_id} && resolved=null",
|
"alert_id={:alert_id} && resolved=null",
|
||||||
"-created",
|
"-created",
|
||||||
1,
|
1,
|
||||||
0,
|
0,
|
||||||
dbx.Params{"alert_id": alertRecord.Id},
|
dbx.Params{"alert_id": alertRecordID},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -55,13 +55,13 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
val = data.Info.DashboardTemp
|
val = data.Info.DashboardTemp
|
||||||
unit = "°C"
|
unit = "°C"
|
||||||
case "LoadAvg1":
|
case "LoadAvg1":
|
||||||
val = data.Info.LoadAvg1
|
val = data.Info.LoadAvg[0]
|
||||||
unit = ""
|
unit = ""
|
||||||
case "LoadAvg5":
|
case "LoadAvg5":
|
||||||
val = data.Info.LoadAvg5
|
val = data.Info.LoadAvg[1]
|
||||||
unit = ""
|
unit = ""
|
||||||
case "LoadAvg15":
|
case "LoadAvg15":
|
||||||
val = data.Info.LoadAvg15
|
val = data.Info.LoadAvg[2]
|
||||||
unit = ""
|
unit = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,11 +200,11 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
alert.mapSums[key] += temp
|
alert.mapSums[key] += temp
|
||||||
}
|
}
|
||||||
case "LoadAvg1":
|
case "LoadAvg1":
|
||||||
alert.val += stats.LoadAvg1
|
alert.val += stats.LoadAvg[0]
|
||||||
case "LoadAvg5":
|
case "LoadAvg5":
|
||||||
alert.val += stats.LoadAvg5
|
alert.val += stats.LoadAvg[1]
|
||||||
case "LoadAvg15":
|
case "LoadAvg15":
|
||||||
alert.val += stats.LoadAvg15
|
alert.val += stats.LoadAvg[2]
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -293,18 +293,11 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
// app.Logger().Error("failed to save alert record", "err", err)
|
// app.Logger().Error("failed to save alert record", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// expand the user relation and send the alert
|
am.SendAlert(AlertMessageData{
|
||||||
if errs := am.hub.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
UserID: alert.alertRecord.GetString("user"),
|
||||||
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
Title: subject,
|
||||||
return
|
Message: body,
|
||||||
}
|
Link: am.hub.MakeLink("system", systemName),
|
||||||
if user := alert.alertRecord.ExpandedOne("user"); user != nil {
|
LinkText: "View " + systemName,
|
||||||
am.SendAlert(AlertMessageData{
|
})
|
||||||
UserID: user.Id,
|
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,11 +31,15 @@ type Stats struct {
|
|||||||
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
|
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
|
||||||
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
|
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
|
||||||
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
|
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
|
||||||
LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty,omitzero"`
|
LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"`
|
||||||
LoadAvg5 float64 `json:"l5,omitempty" cbor:"24,keyasint,omitempty,omitzero"`
|
LoadAvg5 float64 `json:"l5,omitempty" cbor:"24,keyasint,omitempty"`
|
||||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty,omitzero"`
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty"`
|
||||||
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"26,keyasint,omitzero"` // [sent bytes, recv bytes]
|
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]
|
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"27,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||||
|
// 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]
|
||||||
|
MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GPUData struct {
|
type GPUData struct {
|
||||||
@@ -98,6 +102,8 @@ type Info struct {
|
|||||||
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
|
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
|
||||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
||||||
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
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
|
// 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
|
||||||
|
}
|
||||||
348
beszel/internal/ghupdate/ghupdate.go
Normal file
348
beszel/internal/ghupdate/ghupdate.go
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
// UseMirror specifies whether to use the beszel.dev mirror instead of GitHub API.
|
||||||
|
// When false (default), always uses api.github.com. When true, uses gh.beszel.dev.
|
||||||
|
UseMirror bool
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// Determine the API endpoint based on UseMirror flag
|
||||||
|
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", p.config.Owner, p.config.Repo)
|
||||||
|
if p.config.UseMirror {
|
||||||
|
useMirror = true
|
||||||
|
apiURL = fmt.Sprintf("https://gh.beszel.dev/repos/%s/%s/releases/latest?api=true", p.config.Owner, p.config.Repo)
|
||||||
|
ColorPrint(ColorYellow, "Using mirror for update.")
|
||||||
|
}
|
||||||
|
|
||||||
|
latest, err = fetchLatestRelease(
|
||||||
|
p.config.Context,
|
||||||
|
p.config.HttpClient,
|
||||||
|
apiURL,
|
||||||
|
)
|
||||||
|
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!")
|
||||||
|
|
||||||
|
// print the release notes
|
||||||
|
if latest.Body != "" {
|
||||||
|
fmt.Print("\n")
|
||||||
|
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/google/uuid"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/spf13/cast"
|
"github.com/spf13/cast"
|
||||||
"gopkg.in/yaml.v3"
|
"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
|
// Returns the current config.yml file as a JSON object
|
||||||
func GetYamlConfig(e *core.RequestEvent) error {
|
func GetYamlConfig(e *core.RequestEvent) error {
|
||||||
info, _ := e.RequestInfo()
|
if e.Auth.GetString("role") != "admin" {
|
||||||
if info.Auth == nil || info.Auth.GetString("role") != "admin" {
|
return e.ForbiddenError("Requires admin role", nil)
|
||||||
return apis.NewForbiddenError("Forbidden", nil)
|
|
||||||
}
|
}
|
||||||
configContent, err := generateYAML(e.App)
|
configContent, err := generateYAML(e.App)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -8,13 +8,10 @@ import (
|
|||||||
"beszel/internal/hub/systems"
|
"beszel/internal/hub/systems"
|
||||||
"beszel/internal/records"
|
"beszel/internal/records"
|
||||||
"beszel/internal/users"
|
"beszel/internal/users"
|
||||||
"beszel/site"
|
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
@@ -115,6 +112,8 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
|
|||||||
// set URL if BASE_URL env is set
|
// set URL if BASE_URL env is set
|
||||||
if h.appURL != "" {
|
if h.appURL != "" {
|
||||||
settings.Meta.AppURL = h.appURL
|
settings.Meta.AppURL = h.appURL
|
||||||
|
} else {
|
||||||
|
h.appURL = settings.Meta.AppURL
|
||||||
}
|
}
|
||||||
if err := e.App.Save(settings); err != nil {
|
if err := e.App.Save(settings); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -164,55 +163,6 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// startServer sets up the server for Beszel
|
|
||||||
func (h *Hub) startServer(se *core.ServeEvent) error {
|
|
||||||
// TODO: exclude dev server from production binary
|
|
||||||
switch h.IsDev() {
|
|
||||||
case true:
|
|
||||||
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
|
||||||
Scheme: "http",
|
|
||||||
Host: "localhost:5173",
|
|
||||||
})
|
|
||||||
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
|
||||||
proxy.ServeHTTP(e.Response, e.Request)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
default:
|
|
||||||
// parse app url
|
|
||||||
parsedURL, err := url.Parse(h.appURL)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// fix base paths in html if using subpath
|
|
||||||
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
|
||||||
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
|
|
||||||
indexContent := strings.ReplaceAll(string(indexFile), "./", basePath)
|
|
||||||
indexContent = strings.Replace(indexContent, "{{V}}", beszel.Version, 1)
|
|
||||||
indexContent = strings.Replace(indexContent, "{{HUB_URL}}", h.appURL, 1)
|
|
||||||
// set up static asset serving
|
|
||||||
staticPaths := [2]string{"/static/", "/assets/"}
|
|
||||||
serveStatic := apis.Static(site.DistDirFS, false)
|
|
||||||
// get CSP configuration
|
|
||||||
csp, cspExists := GetEnv("CSP")
|
|
||||||
// add route
|
|
||||||
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
|
||||||
// serve static assets if path is in staticPaths
|
|
||||||
for i := range staticPaths {
|
|
||||||
if strings.Contains(e.Request.URL.Path, staticPaths[i]) {
|
|
||||||
e.Response.Header().Set("Cache-Control", "public, max-age=2592000")
|
|
||||||
return serveStatic(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cspExists {
|
|
||||||
e.Response.Header().Del("X-Frame-Options")
|
|
||||||
e.Response.Header().Set("Content-Security-Policy", csp)
|
|
||||||
}
|
|
||||||
return e.HTML(http.StatusOK, indexContent)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// registerCronJobs sets up scheduled tasks
|
// registerCronJobs sets up scheduled tasks
|
||||||
func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
|
func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
|
||||||
// delete old system_stats and alerts_history records once every hour
|
// delete old system_stats and alerts_history records once every hour
|
||||||
@@ -224,48 +174,48 @@ func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
|
|||||||
|
|
||||||
// custom api routes
|
// custom api routes
|
||||||
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
||||||
// returns public key and version
|
// auth protected routes
|
||||||
se.Router.GET("/api/beszel/getkey", func(e *core.RequestEvent) error {
|
apiAuth := se.Router.Group("/api/beszel")
|
||||||
info, _ := e.RequestInfo()
|
apiAuth.Bind(apis.RequireAuth())
|
||||||
if info.Auth == nil {
|
// auth optional routes
|
||||||
return apis.NewForbiddenError("Forbidden", nil)
|
apiNoAuth := se.Router.Group("/api/beszel")
|
||||||
}
|
|
||||||
return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version})
|
// 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
|
// check if first time setup on login page
|
||||||
se.Router.GET("/api/beszel/first-run", func(e *core.RequestEvent) error {
|
apiNoAuth.GET("/first-run", func(e *core.RequestEvent) error {
|
||||||
total, err := h.CountRecords("users")
|
total, err := e.App.CountRecords("users")
|
||||||
return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0})
|
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
|
// send test notification
|
||||||
se.Router.GET("/api/beszel/send-test-notification", h.SendTestNotification)
|
apiAuth.POST("/test-notification", h.SendTestNotification)
|
||||||
// API endpoint to get config.yml content
|
// get config.yml content
|
||||||
se.Router.GET("/api/beszel/config-yaml", config.GetYamlConfig)
|
apiAuth.GET("/config-yaml", config.GetYamlConfig)
|
||||||
// handle agent websocket connection
|
// handle agent websocket connection
|
||||||
se.Router.GET("/api/beszel/agent-connect", h.handleAgentConnect)
|
apiNoAuth.GET("/agent-connect", h.handleAgentConnect)
|
||||||
// get or create universal tokens
|
// get or create universal tokens
|
||||||
se.Router.GET("/api/beszel/universal-token", h.getUniversalToken)
|
apiAuth.GET("/universal-token", h.getUniversalToken)
|
||||||
// create first user endpoint only needed if no users exist
|
// update / delete user alerts
|
||||||
if totalUsers, _ := h.CountRecords("users"); totalUsers == 0 {
|
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
||||||
se.Router.POST("/api/beszel/create-user", h.um.CreateFirstUser)
|
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler for universal token API endpoint (create, read, delete)
|
// Handler for universal token API endpoint (create, read, delete)
|
||||||
func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
|
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()
|
tokenMap := universalTokenMap.GetMap()
|
||||||
userID := info.Auth.Id
|
userID := e.Auth.Id
|
||||||
query := e.Request.URL.Query()
|
query := e.Request.URL.Query()
|
||||||
token := query.Get("token")
|
token := query.Get("token")
|
||||||
tokenSet := token != ""
|
|
||||||
|
|
||||||
if !tokenSet {
|
if token == "" {
|
||||||
// return existing token if it exists
|
// return existing token if it exists
|
||||||
if token, _, ok := tokenMap.GetByValue(userID); ok {
|
if token, _, ok := tokenMap.GetByValue(userID); ok {
|
||||||
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true})
|
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true})
|
||||||
|
|||||||
@@ -4,27 +4,37 @@
|
|||||||
package hub_test
|
package hub_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/tests"
|
beszelTests "beszel/internal/tests"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"bytes"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
|
"encoding/json"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
pbTests "github.com/pocketbase/pocketbase/tests"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getTestHub(t testing.TB) *tests.TestHub {
|
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
|
||||||
hub, _ := tests.NewTestHub(t.TempDir())
|
func jsonReader(v any) io.Reader {
|
||||||
return hub
|
data, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return bytes.NewReader(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMakeLink(t *testing.T) {
|
func TestMakeLink(t *testing.T) {
|
||||||
hub := getTestHub(t)
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -114,7 +124,7 @@ func TestMakeLink(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetSSHKey(t *testing.T) {
|
func TestGetSSHKey(t *testing.T) {
|
||||||
hub := getTestHub(t)
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
|
||||||
// Test Case 1: Key generation (no existing key)
|
// Test Case 1: Key generation (no existing key)
|
||||||
t.Run("KeyGeneration", func(t *testing.T) {
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
79
beszel/internal/hub/server_development.go
Normal file
79
beszel/internal/hub/server_development.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
//go:build development
|
||||||
|
|
||||||
|
package hub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wraps http.RoundTripper to modify dev proxy HTML responses
|
||||||
|
type responseModifier struct {
|
||||||
|
transport http.RoundTripper
|
||||||
|
hub *Hub
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
resp, err := rm.transport.RoundTrip(req)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
// Only modify HTML responses
|
||||||
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
if !strings.Contains(contentType, "text/html") {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
// Create a new response with the modified body
|
||||||
|
modifiedBody := rm.modifyHTML(string(body))
|
||||||
|
resp.Body = io.NopCloser(strings.NewReader(modifiedBody))
|
||||||
|
resp.ContentLength = int64(len(modifiedBody))
|
||||||
|
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(modifiedBody)))
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rm *responseModifier) modifyHTML(html string) string {
|
||||||
|
parsedURL, err := url.Parse(rm.hub.appURL)
|
||||||
|
if err != nil {
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
// fix base paths in html if using subpath
|
||||||
|
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
||||||
|
html = strings.ReplaceAll(html, "./", basePath)
|
||||||
|
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
|
||||||
|
html = strings.Replace(html, "{{HUB_URL}}", rm.hub.appURL, 1)
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
// startServer sets up the development server for Beszel
|
||||||
|
func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||||
|
slog.Info("starting server", "appURL", h.appURL)
|
||||||
|
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
||||||
|
Scheme: "http",
|
||||||
|
Host: "localhost:5173",
|
||||||
|
})
|
||||||
|
|
||||||
|
proxy.Transport = &responseModifier{
|
||||||
|
transport: http.DefaultTransport,
|
||||||
|
hub: h,
|
||||||
|
}
|
||||||
|
|
||||||
|
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
||||||
|
proxy.ServeHTTP(e.Response, e.Request)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
51
beszel/internal/hub/server_production.go
Normal file
51
beszel/internal/hub/server_production.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
//go:build !development
|
||||||
|
|
||||||
|
package hub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/site"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// startServer sets up the production server for Beszel
|
||||||
|
func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||||
|
// parse app url
|
||||||
|
parsedURL, err := url.Parse(h.appURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// fix base paths in html if using subpath
|
||||||
|
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
||||||
|
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
|
||||||
|
html := strings.ReplaceAll(string(indexFile), "./", basePath)
|
||||||
|
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
|
||||||
|
html = strings.Replace(html, "{{HUB_URL}}", h.appURL, 1)
|
||||||
|
// set up static asset serving
|
||||||
|
staticPaths := [2]string{"/static/", "/assets/"}
|
||||||
|
serveStatic := apis.Static(site.DistDirFS, false)
|
||||||
|
// get CSP configuration
|
||||||
|
csp, cspExists := GetEnv("CSP")
|
||||||
|
// add route
|
||||||
|
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
||||||
|
// serve static assets if path is in staticPaths
|
||||||
|
for i := range staticPaths {
|
||||||
|
if strings.Contains(e.Request.URL.Path, staticPaths[i]) {
|
||||||
|
e.Response.Header().Set("Cache-Control", "public, max-age=2592000")
|
||||||
|
return serveStatic(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cspExists {
|
||||||
|
e.Response.Header().Del("X-Frame-Options")
|
||||||
|
e.Response.Header().Set("Content-Security-Policy", csp)
|
||||||
|
}
|
||||||
|
return e.HTML(http.StatusOK, html)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ func TestSystemManagerNew(t *testing.T) {
|
|||||||
user, err := tests.CreateUser(hub, "test@test.com", "testtesttest")
|
user, err := tests.CreateUser(hub, "test@test.com", "testtesttest")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
synctest.Run(func() {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
sm.Initialize()
|
sm.Initialize()
|
||||||
|
|
||||||
record, err := tests.CreateRecord(hub, "systems", map[string]any{
|
record, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
@@ -110,9 +110,11 @@ func TestSystemManagerNew(t *testing.T) {
|
|||||||
err = hub.Delete(record)
|
err = hub.Delete(record)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after deletion")
|
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)
|
time.Sleep(time.Second)
|
||||||
synctest.Wait()
|
synctest.Wait()
|
||||||
|
|
||||||
|
|||||||
@@ -1,57 +1,85 @@
|
|||||||
package hub
|
package hub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel"
|
"beszel/internal/ghupdate"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"os/exec"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
|
||||||
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update updates beszel to the latest version
|
// Update updates beszel to the latest version
|
||||||
func Update(_ *cobra.Command, _ []string) {
|
func Update(cmd *cobra.Command, _ []string) {
|
||||||
var latest *selfupdate.Release
|
dataDir := os.TempDir()
|
||||||
var found bool
|
|
||||||
var err error
|
// set dataDir to ./beszel_data if it exists
|
||||||
currentVersion := semver.MustParse(beszel.Version)
|
if _, err := os.Stat("./beszel_data"); err == nil {
|
||||||
fmt.Println("beszel", currentVersion)
|
dataDir = "./beszel_data"
|
||||||
fmt.Println("Checking for updates...")
|
}
|
||||||
updater, _ := selfupdate.NewUpdater(selfupdate.Config{
|
|
||||||
Filters: []string{"beszel_"},
|
// Check if china-mirrors flag is set
|
||||||
|
useMirror, _ := cmd.Flags().GetBool("china-mirrors")
|
||||||
|
|
||||||
|
updated, err := ghupdate.Update(ghupdate.Config{
|
||||||
|
ArchiveExecutable: "beszel",
|
||||||
|
DataDir: dataDir,
|
||||||
|
UseMirror: useMirror,
|
||||||
})
|
})
|
||||||
latest, found, err = updater.DetectLatest("henrygd/beszel")
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Error checking for updates:", err)
|
log.Fatal(err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
if !updated {
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var binaryPath string
|
// make sure the file is executable
|
||||||
fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version)
|
exePath, err := os.Executable()
|
||||||
binaryPath, err = os.Executable()
|
if err == nil {
|
||||||
if err != nil {
|
if err := os.Chmod(exePath, 0755); err != nil {
|
||||||
fmt.Println("Error getting binary path:", err)
|
fmt.Printf("Warning: failed to set executable permissions: %v\n", err)
|
||||||
os.Exit(1)
|
}
|
||||||
}
|
}
|
||||||
err = selfupdate.UpdateTo(latest.AssetURL, binaryPath)
|
|
||||||
if err != nil {
|
// Try to restart the service if it's running
|
||||||
fmt.Println("Please try rerunning with sudo. Error:", err)
|
restartService()
|
||||||
os.Exit(1)
|
}
|
||||||
}
|
|
||||||
fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes))
|
// 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 {
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel service...")
|
||||||
|
restartCmd := exec.Command("systemctl", "restart", "beszel.service")
|
||||||
|
if err := restartCmd.Run(); err != nil {
|
||||||
|
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: Failed to restart service: %v\n", err)
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually: sudo systemctl restart beszel")
|
||||||
|
} else {
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorGreen, "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 {
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel service...")
|
||||||
|
restartCmd := exec.Command("rc-service", "beszel", "restart")
|
||||||
|
if err := restartCmd.Run(); err != nil {
|
||||||
|
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: Failed to restart service: %v\n", err)
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually: sudo rc-service beszel restart")
|
||||||
|
} else {
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorGreen, "Service restarted successfully")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "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{}
|
tempStats = system.Stats{}
|
||||||
sum := &sumStats
|
sum := &sumStats
|
||||||
stats := &tempStats
|
stats := &tempStats
|
||||||
|
// necessary because uint8 is not big enough for the sum
|
||||||
|
batterySum := 0
|
||||||
|
|
||||||
count := float64(len(records))
|
count := float64(len(records))
|
||||||
tempCount := float64(0)
|
tempCount := float64(0)
|
||||||
@@ -203,13 +205,16 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.DiskWritePs += stats.DiskWritePs
|
sum.DiskWritePs += stats.DiskWritePs
|
||||||
sum.NetworkSent += stats.NetworkSent
|
sum.NetworkSent += stats.NetworkSent
|
||||||
sum.NetworkRecv += stats.NetworkRecv
|
sum.NetworkRecv += stats.NetworkRecv
|
||||||
sum.LoadAvg1 += stats.LoadAvg1
|
sum.LoadAvg[0] += stats.LoadAvg[0]
|
||||||
sum.LoadAvg5 += stats.LoadAvg5
|
sum.LoadAvg[1] += stats.LoadAvg[1]
|
||||||
sum.LoadAvg15 += stats.LoadAvg15
|
sum.LoadAvg[2] += stats.LoadAvg[2]
|
||||||
sum.Bandwidth[0] += stats.Bandwidth[0]
|
sum.Bandwidth[0] += stats.Bandwidth[0]
|
||||||
sum.Bandwidth[1] += stats.Bandwidth[1]
|
sum.Bandwidth[1] += stats.Bandwidth[1]
|
||||||
|
batterySum += int(stats.Battery[0])
|
||||||
|
sum.Battery[1] = stats.Battery[1]
|
||||||
// Set peak values
|
// Set peak values
|
||||||
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
|
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
|
||||||
|
sum.MaxMem = max(sum.MaxMem, stats.MaxMem, stats.MemUsed)
|
||||||
sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent)
|
sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent)
|
||||||
sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv)
|
sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv)
|
||||||
sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs)
|
sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs)
|
||||||
@@ -285,11 +290,12 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
|
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
|
||||||
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
|
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
|
||||||
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
|
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
|
||||||
sum.LoadAvg1 = twoDecimals(sum.LoadAvg1 / count)
|
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
|
||||||
sum.LoadAvg5 = twoDecimals(sum.LoadAvg5 / count)
|
sum.LoadAvg[1] = twoDecimals(sum.LoadAvg[1] / count)
|
||||||
sum.LoadAvg15 = twoDecimals(sum.LoadAvg15 / count)
|
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
|
||||||
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
||||||
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
||||||
|
sum.Battery[0] = uint8(batterySum / int(count))
|
||||||
// Average temperatures
|
// Average temperatures
|
||||||
if sum.Temperatures != nil && tempCount > 0 {
|
if sum.Temperatures != nil && tempCount > 0 {
|
||||||
for key := range sum.Temperatures {
|
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 (
|
import (
|
||||||
"beszel/internal/hub"
|
"beszel/internal/hub"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tests"
|
"github.com/pocketbase/pocketbase/tests"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
_ "github.com/pocketbase/pocketbase/migrations"
|
_ "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)
|
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"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,18 +14,6 @@ type UserManager struct {
|
|||||||
app core.App
|
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 {
|
func NewUserManager(app core.App) *UserManager {
|
||||||
return &UserManager{
|
return &UserManager{
|
||||||
app: app,
|
app: app,
|
||||||
@@ -42,29 +31,26 @@ func (um *UserManager) InitializeUserRole(e *core.RecordEvent) error {
|
|||||||
// Initialize user settings with defaults if not set
|
// Initialize user settings with defaults if not set
|
||||||
func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
|
func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
|
||||||
record := e.Record
|
record := e.Record
|
||||||
// intialize settings with defaults
|
// intialize settings with defaults (zero values can be ignored)
|
||||||
settings := UserSettings{
|
settings := struct {
|
||||||
ChartTime: "1h",
|
ChartTime string `json:"chartTime"`
|
||||||
NotificationEmails: []string{},
|
Emails []string `json:"emails"`
|
||||||
NotificationWebhooks: []string{},
|
}{
|
||||||
|
ChartTime: "1h",
|
||||||
}
|
}
|
||||||
record.UnmarshalJSONField("settings", &settings)
|
record.UnmarshalJSONField("settings", &settings)
|
||||||
if len(settings.NotificationEmails) == 0 {
|
// get user email from auth record
|
||||||
// get user email from auth record
|
var user struct {
|
||||||
if errs := um.app.ExpandRecord(record, []string{"user"}, nil); len(errs) == 0 {
|
Email string `db:"email"`
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// if len(settings.NotificationWebhooks) == 0 {
|
err := e.App.DB().NewQuery("SELECT email FROM users WHERE id = {:id}").Bind(dbx.Params{
|
||||||
// settings.NotificationWebhooks = []string{""}
|
"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)
|
record.Set("settings", settings)
|
||||||
return e.Next()
|
return e.Next()
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
2569
beszel/site/package-lock.json
generated
2569
beszel/site/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.12.0",
|
"version": "0.12.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host",
|
||||||
"build": "lingui extract --overwrite && lingui compile && vite build",
|
"build": "lingui extract --overwrite && lingui compile && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"sync": "lingui extract --overwrite && lingui compile",
|
"sync": "lingui extract --overwrite && lingui compile",
|
||||||
@@ -13,26 +13,27 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@henrygd/queue": "^1.0.7",
|
"@henrygd/queue": "^1.0.7",
|
||||||
"@henrygd/semaphore": "^0.0.2",
|
"@henrygd/semaphore": "^0.0.2",
|
||||||
"@lingui/detect-locale": "^5.3.3",
|
"@lingui/detect-locale": "^5.4.1",
|
||||||
"@lingui/macro": "^5.3.3",
|
"@lingui/macro": "^5.4.1",
|
||||||
"@lingui/react": "^5.3.3",
|
"@lingui/react": "^5.4.1",
|
||||||
"@nanostores/react": "^0.7.3",
|
"@nanostores/react": "^0.7.3",
|
||||||
"@nanostores/router": "^0.11.0",
|
"@nanostores/router": "^0.11.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-direction": "^1.1.1",
|
"@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-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-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-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.5",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toast": "^1.2.14",
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -40,28 +41,26 @@
|
|||||||
"lucide-react": "^0.452.0",
|
"lucide-react": "^0.452.0",
|
||||||
"nanostores": "^0.11.4",
|
"nanostores": "^0.11.4",
|
||||||
"pocketbase": "^0.26.2",
|
"pocketbase": "^0.26.2",
|
||||||
"react": "^18.3.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.1.1",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"valibot": "^0.42.1"
|
"valibot": "^0.42.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lingui/cli": "^5.3.3",
|
"@lingui/cli": "^5.4.1",
|
||||||
"@lingui/swc-plugin": "^5.5.2",
|
"@lingui/swc-plugin": "^5.6.1",
|
||||||
"@lingui/vite-plugin": "^5.3.3",
|
"@lingui/vite-plugin": "^5.4.1",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@types/bun": "^1.2.19",
|
"@tailwindcss/vite": "^4.1.12",
|
||||||
"@types/react": "^18.3.23",
|
"@types/bun": "^1.2.20",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react": "^19.1.11",
|
||||||
"@vitejs/plugin-react-swc": "^3.11.0",
|
"@types/react-dom": "^19.1.7",
|
||||||
"autoprefixer": "^10.4.21",
|
"@vitejs/plugin-react-swc": "^4.0.1",
|
||||||
"postcss": "^8.5.6",
|
"tailwindcss": "^4.1.12",
|
||||||
"tailwindcss": "^3.4.17",
|
"tw-animate-css": "^1.3.7",
|
||||||
"tailwindcss-rtl": "^0.9.0",
|
"typescript": "^5.9.2",
|
||||||
"typescript": "^5.8.3",
|
"vite": "^7.1.3"
|
||||||
"vite": "^6.3.5"
|
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@nanostores/router": {
|
"@nanostores/router": {
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -13,13 +13,15 @@ import {
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { $publicKey, pb } from "@/lib/stores"
|
import { $publicKey } from "@/lib/stores"
|
||||||
import { cn, generateToken, isReadOnlyUser, tokenMap, useLocalStorage } from "@/lib/utils"
|
import { cn, generateToken, tokenMap, useBrowserStorage } from "@/lib/utils"
|
||||||
|
import { pb, isReadOnlyUser } from "@/lib/api"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { ChevronDownIcon, ExternalLinkIcon, PlusIcon } from "lucide-react"
|
import { ChevronDownIcon, ExternalLinkIcon, PlusIcon } from "lucide-react"
|
||||||
import { memo, useEffect, useRef, useState } from "react"
|
import { memo, useEffect, useRef, useState } from "react"
|
||||||
import { $router, basePath, Link, navigate } from "./router"
|
import { $router, basePath, Link, navigate } from "./router"
|
||||||
import { SystemRecord } from "@/types"
|
import { SystemRecord } from "@/types"
|
||||||
|
import { SystemStatus } from "@/lib/enums"
|
||||||
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "./ui/icons"
|
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "./ui/icons"
|
||||||
import { InputCopy } from "./ui/input-copy"
|
import { InputCopy } from "./ui/input-copy"
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
@@ -75,7 +77,7 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
|
|||||||
const port = useRef<HTMLInputElement>(null)
|
const port = useRef<HTMLInputElement>(null)
|
||||||
const [hostValue, setHostValue] = useState(system?.host ?? "")
|
const [hostValue, setHostValue] = useState(system?.host ?? "")
|
||||||
const isUnixSocket = hostValue.startsWith("/")
|
const isUnixSocket = hostValue.startsWith("/")
|
||||||
const [tab, setTab] = useLocalStorage("as-tab", "docker")
|
const [tab, setTab] = useBrowserStorage("as-tab", "docker")
|
||||||
const [token, setToken] = useState(system?.token ?? "")
|
const [token, setToken] = useState(system?.token ?? "")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -105,7 +107,7 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
|
|||||||
try {
|
try {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
if (system) {
|
if (system) {
|
||||||
await pb.collection("systems").update(system.id, { ...data, status: "pending" })
|
await pb.collection("systems").update(system.id, { ...data, status: SystemStatus.Pending })
|
||||||
} else {
|
} else {
|
||||||
const createdSystem = await pb.collection("systems").create(data)
|
const createdSystem = await pb.collection("systems").create(data)
|
||||||
await pb.collection("fingerprints").create({
|
await pb.collection("fingerprints").create({
|
||||||
@@ -131,7 +133,7 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
|
|||||||
>
|
>
|
||||||
<Tabs defaultValue={tab} onValueChange={setTab}>
|
<Tabs defaultValue={tab} onValueChange={setTab}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="mb-2">
|
<DialogTitle className="mb-2 max-w-100 truncate pr-8">
|
||||||
{system ? `${t`Edit`} ${system?.name}` : <Trans>Add New System</Trans>}
|
{system ? `${t`Edit`} ${system?.name}` : <Trans>Add New System</Trans>}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
@@ -165,9 +167,7 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
|
|||||||
<Trans>
|
<Trans>
|
||||||
Copy the installation command for the agent below, or register agents automatically with a{" "}
|
Copy the installation command for the agent below, or register agents automatically with a{" "}
|
||||||
<Link
|
<Link
|
||||||
onClick={() => {
|
onClick={() => setOpen(false)}
|
||||||
setOpen(false)
|
|
||||||
}}
|
|
||||||
href={getPagePath($router, "settings", { name: "tokens" })}
|
href={getPagePath($router, "settings", { name: "tokens" })}
|
||||||
className="link"
|
className="link"
|
||||||
>
|
>
|
||||||
@@ -274,7 +274,7 @@ interface CopyButtonProps {
|
|||||||
text: string
|
text: string
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
dropdownItems: DropdownItem[]
|
dropdownItems: DropdownItem[]
|
||||||
icon?: React.ReactElement
|
icon?: React.ReactElement<any>
|
||||||
}
|
}
|
||||||
|
|
||||||
const CopyButton = memo((props: CopyButtonProps) => {
|
const CopyButton = memo((props: CopyButtonProps) => {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { ColumnDef } from "@tanstack/react-table"
|
|||||||
import { AlertsHistoryRecord } from "@/types"
|
import { AlertsHistoryRecord } from "@/types"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
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 { Trans } from "@lingui/react/macro"
|
||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
|
|
||||||
@@ -15,7 +16,9 @@ export const alertsHistoryColumns: ColumnDef<AlertsHistoryRecord>[] = [
|
|||||||
<Trans>System</Trans>
|
<Trans>System</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <span className="ps-2">{row.original.expand?.system?.name || row.original.system}</span>,
|
cell: ({ row }) => (
|
||||||
|
<div className="ps-2 max-w-60 truncate">{row.original.expand?.system?.name || row.original.system}</div>
|
||||||
|
),
|
||||||
filterFn: (row, _, filterValue) => {
|
filterFn: (row, _, filterValue) => {
|
||||||
const display = row.original.expand?.system?.name || row.original.system || ""
|
const display = row.original.expand?.system?.name || row.original.system || ""
|
||||||
return display.toLowerCase().includes(filterValue.toLowerCase())
|
return display.toLowerCase().includes(filterValue.toLowerCase())
|
||||||
|
|||||||
@@ -1,153 +1,36 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { Trans } from "@lingui/react/macro"
|
|
||||||
import { memo, useMemo, useState } from "react"
|
import { memo, useMemo, useState } from "react"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { $alerts } from "@/lib/stores"
|
import { $alerts } from "@/lib/stores"
|
||||||
import {
|
import { BellIcon } from "lucide-react"
|
||||||
Dialog,
|
import { cn } from "@/lib/utils"
|
||||||
DialogTrigger,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { BellIcon, GlobeIcon, ServerIcon } from "lucide-react"
|
|
||||||
import { alertInfo, cn } from "@/lib/utils"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { AlertRecord, SystemRecord } from "@/types"
|
import { SystemRecord } from "@/types"
|
||||||
import { $router, Link } from "../router"
|
import { AlertDialogContent } from "./alerts-sheet"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
|
||||||
import { Checkbox } from "../ui/checkbox"
|
|
||||||
import { SystemAlert, SystemAlertGlobal } from "./alerts-system"
|
|
||||||
import { getPagePath } from "@nanostores/router"
|
|
||||||
|
|
||||||
export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
|
export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
|
||||||
const alerts = useStore($alerts)
|
|
||||||
const [opened, setOpened] = useState(false)
|
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(
|
return useMemo(
|
||||||
() => (
|
() => (
|
||||||
<Dialog>
|
<Sheet>
|
||||||
<DialogTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
|
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
|
||||||
<BellIcon
|
<BellIcon
|
||||||
className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
|
className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
|
||||||
"fill-primary": hasAlert,
|
"fill-primary": hasSystemAlert,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</SheetTrigger>
|
||||||
<DialogContent className="max-h-full sm:max-h-[95svh] overflow-auto max-w-[37rem]">
|
<SheetContent className="max-h-full overflow-auto w-145 !max-w-full p-4 sm:p-6">
|
||||||
{opened && <AlertDialogContent system={system} />}
|
{opened && <AlertDialogContent system={system} />}
|
||||||
</DialogContent>
|
</SheetContent>
|
||||||
</Dialog>
|
</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])
|
|
||||||
}
|
|
||||||
|
|||||||
299
beszel/site/src/components/alerts/alerts-sheet.tsx
Normal file
299
beszel/site/src/components/alerts/alerts-sheet.tsx
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans, Plural } from "@lingui/react/macro"
|
||||||
|
import { $alerts, $systems } 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"
|
||||||
|
import { pb } from "@/lib/api"
|
||||||
|
|
||||||
|
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" />
|
||||||
|
<span className="truncate max-w-60">{system.name}</span>
|
||||||
|
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||||
import { useYAxisWidth, cn, formatShortDate, chartMargin } from "@/lib/utils"
|
import { cn, formatShortDate, chartMargin } from "@/lib/utils"
|
||||||
|
import { useYAxisWidth } from "./hooks"
|
||||||
import { ChartData, SystemStatsRecord } from "@/types"
|
import { ChartData, SystemStatsRecord } from "@/types"
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
|
|
||||||
export type DataPoint = {
|
export type DataPoint = {
|
||||||
label: string
|
label: string
|
||||||
dataKey: (data: SystemStatsRecord) => number | undefined
|
dataKey: (data: SystemStatsRecord) => number | undefined
|
||||||
color: string
|
color: number | string
|
||||||
opacity: number
|
opacity: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ export default function AreaChartDefault({
|
|||||||
tickFormatter,
|
tickFormatter,
|
||||||
contentFormatter,
|
contentFormatter,
|
||||||
dataPoints,
|
dataPoints,
|
||||||
|
domain,
|
||||||
}: // logRender = false,
|
}: // logRender = false,
|
||||||
{
|
{
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
@@ -26,6 +28,7 @@ export default function AreaChartDefault({
|
|||||||
tickFormatter: (value: number, index: number) => string
|
tickFormatter: (value: number, index: number) => string
|
||||||
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
|
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
|
||||||
dataPoints?: DataPoint[]
|
dataPoints?: DataPoint[]
|
||||||
|
domain?: [number, number]
|
||||||
// logRender?: boolean
|
// logRender?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
@@ -51,7 +54,7 @@ export default function AreaChartDefault({
|
|||||||
orientation={chartData.orientation}
|
orientation={chartData.orientation}
|
||||||
className="tracking-tighter"
|
className="tracking-tighter"
|
||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
domain={[0, max ?? "auto"]}
|
domain={domain ?? [0, max ?? "auto"]}
|
||||||
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
|
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
@@ -68,7 +71,7 @@ export default function AreaChartDefault({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{dataPoints?.map((dataPoint, i) => {
|
{dataPoints?.map((dataPoint, i) => {
|
||||||
const color = `hsl(var(--chart-${dataPoint.color}))`
|
const color = `var(--chart-${dataPoint.color})`
|
||||||
return (
|
return (
|
||||||
<Area
|
<Area
|
||||||
key={i}
|
key={i}
|
||||||
@@ -87,5 +90,5 @@ export default function AreaChartDefault({
|
|||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}, [chartData.systemStats.length, yAxisWidth, maxToggled])
|
}, [chartData.systemStats.at(-1), yAxisWidth, maxToggled])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||||
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||||
import { memo, useMemo } from "react"
|
import { memo, useMemo } from "react"
|
||||||
import { useYAxisWidth, cn, formatShortDate, chartMargin, toFixedFloat, formatBytes, decimalString } from "@/lib/utils"
|
import { cn, formatShortDate, chartMargin, toFixedFloat, formatBytes, decimalString } from "@/lib/utils"
|
||||||
// import Spinner from '../spinner'
|
// import Spinner from '../spinner'
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { $containerFilter, $userSettings } from "@/lib/stores"
|
import { $containerFilter, $userSettings } from "@/lib/stores"
|
||||||
import { ChartData } from "@/types"
|
import { ChartData } from "@/types"
|
||||||
import { Separator } from "../ui/separator"
|
import { Separator } from "../ui/separator"
|
||||||
import { ChartType, Unit } from "@/lib/enums"
|
import { ChartType, Unit } from "@/lib/enums"
|
||||||
|
import { useYAxisWidth } from "./hooks"
|
||||||
|
|
||||||
export default memo(function ContainerChart({
|
export default memo(function ContainerChart({
|
||||||
dataKey,
|
dataKey,
|
||||||
chartData,
|
chartData,
|
||||||
chartType,
|
chartType,
|
||||||
|
chartConfig,
|
||||||
unit = "%",
|
unit = "%",
|
||||||
}: {
|
}: {
|
||||||
dataKey: string
|
dataKey: string
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
chartType: ChartType
|
chartType: ChartType
|
||||||
|
chartConfig: ChartConfig
|
||||||
unit?: string
|
unit?: string
|
||||||
}) {
|
}) {
|
||||||
const filter = useStore($containerFilter)
|
const filter = useStore($containerFilter)
|
||||||
@@ -28,45 +31,6 @@ export default memo(function ContainerChart({
|
|||||||
|
|
||||||
const isNetChart = chartType === ChartType.Network
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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]
|
|
||||||
const hue = ((i * 360) / length) % 360
|
|
||||||
config[key] = {
|
|
||||||
label: key,
|
|
||||||
color: `hsl(${hue}, 60%, 55%)`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return config satisfies ChartConfig
|
|
||||||
}, [chartData])
|
|
||||||
|
|
||||||
const { toolTipFormatter, dataFunction, tickFormatter } = useMemo(() => {
|
const { toolTipFormatter, dataFunction, tickFormatter } = useMemo(() => {
|
||||||
const obj = {} as {
|
const obj = {} as {
|
||||||
toolTipFormatter: (item: any, key: string) => React.ReactNode | string
|
toolTipFormatter: (item: any, key: string) => React.ReactNode | string
|
||||||
@@ -124,6 +88,15 @@ export default memo(function ContainerChart({
|
|||||||
return obj
|
return obj
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Filter with set lookup
|
||||||
|
const filteredKeys = useMemo(() => {
|
||||||
|
if (!filter) {
|
||||||
|
return new Set<string>()
|
||||||
|
}
|
||||||
|
const filterLower = filter.toLowerCase()
|
||||||
|
return new Set(Object.keys(chartConfig).filter((key) => !key.toLowerCase().includes(filterLower)))
|
||||||
|
}, [chartConfig, filter])
|
||||||
|
|
||||||
// console.log('rendered at', new Date())
|
// console.log('rendered at', new Date())
|
||||||
|
|
||||||
if (containerData.length === 0) {
|
if (containerData.length === 0) {
|
||||||
@@ -165,9 +138,9 @@ export default memo(function ContainerChart({
|
|||||||
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} />}
|
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} />}
|
||||||
/>
|
/>
|
||||||
{Object.keys(chartConfig).map((key) => {
|
{Object.keys(chartConfig).map((key) => {
|
||||||
const filtered = filter && !key.toLowerCase().includes(filter.toLowerCase())
|
const filtered = filteredKeys.has(key)
|
||||||
let fillOpacity = filtered ? 0.05 : 0.4
|
const fillOpacity = filtered ? 0.05 : 0.4
|
||||||
let strokeOpacity = filtered ? 0.1 : 1
|
const strokeOpacity = filtered ? 0.1 : 1
|
||||||
return (
|
return (
|
||||||
<Area
|
<Area
|
||||||
key={key}
|
key={key}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||||
import { useYAxisWidth, cn, formatShortDate, decimalString, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
|
import { cn, formatShortDate, decimalString, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||||
import { ChartData } from "@/types"
|
import { ChartData } from "@/types"
|
||||||
import { memo } from "react"
|
import { memo } from "react"
|
||||||
import { useLingui } from "@lingui/react/macro"
|
import { useLingui } from "@lingui/react/macro"
|
||||||
import { Unit } from "@/lib/enums"
|
import { Unit } from "@/lib/enums"
|
||||||
|
import { useYAxisWidth } from "./hooks"
|
||||||
|
|
||||||
export default memo(function DiskChart({
|
export default memo(function DiskChart({
|
||||||
dataKey,
|
dataKey,
|
||||||
@@ -69,9 +70,9 @@ export default memo(function DiskChart({
|
|||||||
dataKey={dataKey}
|
dataKey={dataKey}
|
||||||
name={t`Disk Usage`}
|
name={t`Disk Usage`}
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill="hsl(var(--chart-4))"
|
fill="var(--chart-4)"
|
||||||
fillOpacity={0.4}
|
fillOpacity={0.4}
|
||||||
stroke="hsl(var(--chart-4))"
|
stroke="var(--chart-4)"
|
||||||
// animationDuration={1200}
|
// animationDuration={1200}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import {
|
|||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
xAxis,
|
xAxis,
|
||||||
} from "@/components/ui/chart"
|
} from "@/components/ui/chart"
|
||||||
import { useYAxisWidth, cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils"
|
import { cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils"
|
||||||
import { ChartData } from "@/types"
|
import { ChartData } from "@/types"
|
||||||
import { memo, useMemo } from "react"
|
import { memo, useMemo } from "react"
|
||||||
|
import { useYAxisWidth } from "./hooks"
|
||||||
|
|
||||||
export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData }) {
|
export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData }) {
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
|
|||||||
107
beszel/site/src/components/charts/hooks.ts
Normal file
107
beszel/site/src/components/charts/hooks.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { ChartConfig } from "@/components/ui/chart"
|
||||||
|
import { ChartData } from "@/types"
|
||||||
|
|
||||||
|
/** Chart configurations for CPU, memory, and network usage charts */
|
||||||
|
export interface ContainerChartConfigs {
|
||||||
|
cpu: ChartConfig
|
||||||
|
memory: ChartConfig
|
||||||
|
network: ChartConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates chart configurations for container metrics visualization
|
||||||
|
* @param containerData - Array of container statistics data points
|
||||||
|
* @returns Chart configurations for CPU, memory, and network metrics
|
||||||
|
*/
|
||||||
|
export function useContainerChartConfigs(containerData: ChartData["containerData"]): ContainerChartConfigs {
|
||||||
|
return useMemo(() => {
|
||||||
|
const configs = {
|
||||||
|
cpu: {} as ChartConfig,
|
||||||
|
memory: {} as ChartConfig,
|
||||||
|
network: {} as ChartConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate usage metrics for each container
|
||||||
|
const totalUsage = {
|
||||||
|
cpu: new Map<string, number>(),
|
||||||
|
memory: new Map<string, number>(),
|
||||||
|
network: new Map<string, number>(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each data point to calculate totals
|
||||||
|
for (let i = 0; i < containerData.length; i++) {
|
||||||
|
const stats = containerData[i]
|
||||||
|
const containerNames = Object.keys(stats)
|
||||||
|
|
||||||
|
for (let j = 0; j < containerNames.length; j++) {
|
||||||
|
const containerName = containerNames[j]
|
||||||
|
// Skip metadata field
|
||||||
|
if (containerName === "created") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerStats = stats[containerName]
|
||||||
|
if (!containerStats) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accumulate metrics for CPU, memory, and network
|
||||||
|
const currentCpu = totalUsage.cpu.get(containerName) ?? 0
|
||||||
|
const currentMemory = totalUsage.memory.get(containerName) ?? 0
|
||||||
|
const currentNetwork = totalUsage.network.get(containerName) ?? 0
|
||||||
|
|
||||||
|
totalUsage.cpu.set(containerName, currentCpu + (containerStats.c ?? 0))
|
||||||
|
totalUsage.memory.set(containerName, currentMemory + (containerStats.m ?? 0))
|
||||||
|
totalUsage.network.set(containerName, currentNetwork + (containerStats.nr ?? 0) + (containerStats.ns ?? 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate chart configurations for each metric type
|
||||||
|
Object.entries(totalUsage).forEach(([chartType, usageMap]) => {
|
||||||
|
const sortedContainers = Array.from(usageMap.entries()).sort(([, a], [, b]) => b - a)
|
||||||
|
const chartConfig = {} as Record<string, { label: string; color: string }>
|
||||||
|
const count = sortedContainers.length
|
||||||
|
|
||||||
|
// Generate colors for each container
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const [containerName] = sortedContainers[i]
|
||||||
|
const hue = ((i * 360) / count) % 360
|
||||||
|
chartConfig[containerName] = {
|
||||||
|
label: containerName,
|
||||||
|
color: `hsl(${hue}, 60%, 55%)`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configs[chartType as keyof typeof configs] = chartConfig
|
||||||
|
})
|
||||||
|
|
||||||
|
return configs
|
||||||
|
}, [containerData])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the correct width of the y axis in recharts based on the longest label */
|
||||||
|
export function useYAxisWidth() {
|
||||||
|
const [yAxisWidth, setYAxisWidth] = useState(0)
|
||||||
|
let maxChars = 0
|
||||||
|
let timeout: ReturnType<typeof setTimeout>
|
||||||
|
function updateYAxisWidth(str: string) {
|
||||||
|
if (str.length > maxChars) {
|
||||||
|
maxChars = str.length
|
||||||
|
const div = document.createElement("div")
|
||||||
|
div.className = "text-xs tabular-nums tracking-tighter table sr-only"
|
||||||
|
div.innerHTML = str
|
||||||
|
clearTimeout(timeout)
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
document.body.appendChild(div)
|
||||||
|
const width = div.offsetWidth + 24
|
||||||
|
if (width > yAxisWidth) {
|
||||||
|
setYAxisWidth(div.offsetWidth + 24)
|
||||||
|
}
|
||||||
|
document.body.removeChild(div)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
return { yAxisWidth, updateYAxisWidth }
|
||||||
|
}
|
||||||
@@ -8,32 +8,32 @@ import {
|
|||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
xAxis,
|
xAxis,
|
||||||
} from "@/components/ui/chart"
|
} from "@/components/ui/chart"
|
||||||
import { useYAxisWidth, cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils"
|
import { cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils"
|
||||||
import { ChartData } from "@/types"
|
import { ChartData, SystemStats } from "@/types"
|
||||||
import { memo } from "react"
|
import { memo } from "react"
|
||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { useYAxisWidth } from "./hooks"
|
||||||
|
|
||||||
export default memo(function LoadAverageChart({ chartData }: { chartData: ChartData }) {
|
export default memo(function LoadAverageChart({ chartData }: { chartData: ChartData }) {
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
|
|
||||||
if (chartData.systemStats.length === 0) {
|
const keys: { legacy: keyof SystemStats; color: string; label: string }[] = [
|
||||||
return null
|
{
|
||||||
}
|
legacy: "l1",
|
||||||
|
|
||||||
const keys = {
|
|
||||||
l1: {
|
|
||||||
color: "hsl(271, 81%, 60%)", // Purple
|
color: "hsl(271, 81%, 60%)", // Purple
|
||||||
label: t({ message: `1 min`, comment: "Load average" }),
|
label: t({ message: `1 min`, comment: "Load average" }),
|
||||||
},
|
},
|
||||||
l5: {
|
{
|
||||||
|
legacy: "l5",
|
||||||
color: "hsl(217, 91%, 60%)", // Blue
|
color: "hsl(217, 91%, 60%)", // Blue
|
||||||
label: t({ message: `5 min`, comment: "Load average" }),
|
label: t({ message: `5 min`, comment: "Load average" }),
|
||||||
},
|
},
|
||||||
l15: {
|
{
|
||||||
|
legacy: "l15",
|
||||||
color: "hsl(25, 95%, 53%)", // Orange
|
color: "hsl(25, 95%, 53%)", // Orange
|
||||||
label: t({ message: `15 min`, comment: "Load average" }),
|
label: t({ message: `15 min`, comment: "Load average" }),
|
||||||
},
|
},
|
||||||
}
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -69,16 +69,22 @@ export default memo(function LoadAverageChart({ chartData }: { chartData: ChartD
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{Object.entries(keys).map(([key, value]: [string, { color: string; label: string }]) => {
|
{keys.map(({ legacy, color, label }, i) => {
|
||||||
|
const dataKey = (value: { stats: SystemStats }) => {
|
||||||
|
if (chartData.agentVersion.patch < 1) {
|
||||||
|
return value.stats?.[legacy]
|
||||||
|
}
|
||||||
|
return value.stats?.la?.[i] ?? value.stats?.[legacy]
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Line
|
<Line
|
||||||
key={key}
|
key={i}
|
||||||
dataKey={`stats.${key}`}
|
dataKey={dataKey}
|
||||||
name={value.label}
|
name={label}
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
dot={false}
|
dot={false}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
stroke={value.color}
|
stroke={color}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||||
import { useYAxisWidth, cn, decimalString, formatShortDate, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
|
import { cn, decimalString, formatShortDate, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||||
import { memo } from "react"
|
import { memo } from "react"
|
||||||
import { ChartData } from "@/types"
|
import { ChartData } from "@/types"
|
||||||
import { useLingui } from "@lingui/react/macro"
|
import { useLingui } from "@lingui/react/macro"
|
||||||
import { Unit } from "@/lib/enums"
|
import { Unit } from "@/lib/enums"
|
||||||
|
import { useYAxisWidth } from "./hooks"
|
||||||
|
|
||||||
export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
|
export default memo(function MemChart({ chartData, showMax }: { chartData: ChartData; showMax: boolean }) {
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
|
|
||||||
@@ -66,39 +67,39 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
|
|||||||
<Area
|
<Area
|
||||||
name={t`Used`}
|
name={t`Used`}
|
||||||
order={3}
|
order={3}
|
||||||
dataKey="stats.mu"
|
dataKey={({ stats }) => (showMax ? stats?.mm : stats?.mu)}
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill="hsl(var(--chart-2))"
|
fill="var(--chart-2)"
|
||||||
fillOpacity={0.4}
|
fillOpacity={0.4}
|
||||||
stroke="hsl(var(--chart-2))"
|
stroke="var(--chart-2)"
|
||||||
stackId="1"
|
stackId="1"
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
{chartData.systemStats.at(-1)?.stats.mz && (
|
{/* {chartData.systemStats.at(-1)?.stats.mz && ( */}
|
||||||
<Area
|
<Area
|
||||||
name="ZFS ARC"
|
name="ZFS ARC"
|
||||||
order={2}
|
order={2}
|
||||||
dataKey="stats.mz"
|
dataKey={({ stats }) => (showMax ? null : stats?.mz)}
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill="hsla(175 60% 45% / 0.8)"
|
fill="hsla(175 60% 45% / 0.8)"
|
||||||
fillOpacity={0.5}
|
fillOpacity={0.5}
|
||||||
stroke="hsla(175 60% 45% / 0.8)"
|
stroke="hsla(175 60% 45% / 0.8)"
|
||||||
stackId="1"
|
stackId="1"
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
)}
|
{/* )} */}
|
||||||
<Area
|
<Area
|
||||||
name={t`Cache / Buffers`}
|
name={t`Cache / Buffers`}
|
||||||
order={1}
|
order={1}
|
||||||
dataKey="stats.mb"
|
dataKey={({ stats }) => (showMax ? null : stats?.mb)}
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill="hsla(160 60% 45% / 0.5)"
|
fill="hsla(160 60% 45% / 0.5)"
|
||||||
fillOpacity={0.4}
|
fillOpacity={0.4}
|
||||||
// strokeOpacity={1}
|
|
||||||
stroke="hsla(160 60% 45% / 0.5)"
|
stroke="hsla(160 60% 45% / 0.5)"
|
||||||
stackId="1"
|
stackId="1"
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
|
{/* <ChartLegend content={<ChartLegendContent />} /> */}
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { t } from "@lingui/core/macro"
|
|||||||
|
|
||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||||
import { useYAxisWidth, cn, formatShortDate, decimalString, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
|
import { cn, formatShortDate, decimalString, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||||
import { ChartData } from "@/types"
|
import { ChartData } from "@/types"
|
||||||
import { memo } from "react"
|
import { memo } from "react"
|
||||||
import { $userSettings } from "@/lib/stores"
|
import { $userSettings } from "@/lib/stores"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { useYAxisWidth } from "./hooks"
|
||||||
|
|
||||||
export default memo(function SwapChart({ chartData }: { chartData: ChartData }) {
|
export default memo(function SwapChart({ chartData }: { chartData: ChartData }) {
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
@@ -58,9 +59,9 @@ export default memo(function SwapChart({ chartData }: { chartData: ChartData })
|
|||||||
dataKey="stats.su"
|
dataKey="stats.su"
|
||||||
name={t`Used`}
|
name={t`Used`}
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill="hsl(var(--chart-2))"
|
fill="var(--chart-2)"
|
||||||
fillOpacity={0.4}
|
fillOpacity={0.4}
|
||||||
stroke="hsl(var(--chart-2))"
|
stroke="var(--chart-2)"
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
|
|||||||
@@ -8,19 +8,12 @@ import {
|
|||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
xAxis,
|
xAxis,
|
||||||
} from "@/components/ui/chart"
|
} from "@/components/ui/chart"
|
||||||
import {
|
import { cn, formatShortDate, toFixedFloat, chartMargin, formatTemperature, decimalString } from "@/lib/utils"
|
||||||
useYAxisWidth,
|
|
||||||
cn,
|
|
||||||
formatShortDate,
|
|
||||||
toFixedFloat,
|
|
||||||
chartMargin,
|
|
||||||
formatTemperature,
|
|
||||||
decimalString,
|
|
||||||
} from "@/lib/utils"
|
|
||||||
import { ChartData } from "@/types"
|
import { ChartData } from "@/types"
|
||||||
import { memo, useMemo } from "react"
|
import { memo, useMemo } from "react"
|
||||||
import { $temperatureFilter, $userSettings } from "@/lib/stores"
|
import { $temperatureFilter, $userSettings } from "@/lib/stores"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { useYAxisWidth } from "./hooks"
|
||||||
|
|
||||||
export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) {
|
export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) {
|
||||||
const filter = useStore($temperatureFilter)
|
const filter = useStore($temperatureFilter)
|
||||||
|
|||||||
@@ -23,11 +23,13 @@ import {
|
|||||||
} from "@/components/ui/command"
|
} from "@/components/ui/command"
|
||||||
import { memo, useEffect, useMemo } from "react"
|
import { memo, useEffect, useMemo } from "react"
|
||||||
import { $systems } from "@/lib/stores"
|
import { $systems } from "@/lib/stores"
|
||||||
import { getHostDisplayValue, isAdmin, listen } from "@/lib/utils"
|
import { getHostDisplayValue, listen } from "@/lib/utils"
|
||||||
import { $router, basePath, navigate, prependBasePath } from "./router"
|
import { $router, basePath, navigate, prependBasePath } from "./router"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
import { DialogDescription } from "@radix-ui/react-dialog"
|
||||||
|
import { isAdmin } from "@/lib/api"
|
||||||
|
|
||||||
export default memo(function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) {
|
export default memo(function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -54,11 +56,9 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogDescription className="sr-only">Command palette</DialogDescription>
|
||||||
<CommandInput placeholder={t`Search for systems or settings...`} />
|
<CommandInput placeholder={t`Search for systems or settings...`} />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>
|
|
||||||
<Trans>No results found.</Trans>
|
|
||||||
</CommandEmpty>
|
|
||||||
{systems.length > 0 && (
|
{systems.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
@@ -71,7 +71,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Server className="me-2 size-4" />
|
<Server className="me-2 size-4" />
|
||||||
<span>{system.name}</span>
|
<span className="max-w-60 truncate">{system.name}</span>
|
||||||
<CommandShortcut>{getHostDisplayValue(system)}</CommandShortcut>
|
<CommandShortcut>{getHostDisplayValue(system)}</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
@@ -214,6 +214,9 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<CommandEmpty>
|
||||||
|
<Trans>No results found.</Trans>
|
||||||
|
</CommandEmpty>
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</CommandDialog>
|
</CommandDialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function LangToggle() {
|
|||||||
{languages.map(({ lang, label, e }) => (
|
{languages.map(({ lang, label, e }) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={lang}
|
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)}
|
onClick={() => dynamicActivate(lang)}
|
||||||
>
|
>
|
||||||
<span>{e}</span> {label}
|
<span>{e}</span> {label}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro"
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { LoaderCircle, LockIcon, LogInIcon, MailIcon } from "lucide-react"
|
import { LoaderCircle, LockIcon, LogInIcon, MailIcon } from "lucide-react"
|
||||||
import { $authenticated, pb } from "@/lib/stores"
|
import { $authenticated } from "@/lib/stores"
|
||||||
import * as v from "valibot"
|
import * as v from "valibot"
|
||||||
import { toast } from "../ui/use-toast"
|
import { toast } from "../ui/use-toast"
|
||||||
import { Dialog, DialogContent, DialogTrigger, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogTrigger, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
@@ -13,6 +13,7 @@ import { useCallback, useEffect, useState } from "react"
|
|||||||
import { AuthMethodsList, AuthProviderInfo, OAuth2AuthConfig } from "pocketbase"
|
import { AuthMethodsList, AuthProviderInfo, OAuth2AuthConfig } from "pocketbase"
|
||||||
import { $router, Link, prependBasePath } from "../router"
|
import { $router, Link, prependBasePath } from "../router"
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
import { pb } from "@/lib/api"
|
||||||
|
|
||||||
const honeypot = v.literal("")
|
const honeypot = v.literal("")
|
||||||
const emailSchema = v.pipe(v.string(), v.email(t`Invalid email address.`))
|
const emailSchema = v.pipe(v.string(), v.email(t`Invalid email address.`))
|
||||||
@@ -43,6 +44,14 @@ const showLoginFaliedToast = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getAuthProviderIcon = (provider: AuthProviderInfo) => {
|
||||||
|
let { name } = provider
|
||||||
|
if (name.startsWith("oidc")) {
|
||||||
|
name = "oidc"
|
||||||
|
}
|
||||||
|
return prependBasePath(`/_/images/oauth2/${name}.svg`)
|
||||||
|
}
|
||||||
|
|
||||||
export function UserAuthForm({
|
export function UserAuthForm({
|
||||||
className,
|
className,
|
||||||
isFirstRun,
|
isFirstRun,
|
||||||
@@ -165,8 +174,8 @@ export function UserAuthForm({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("grid gap-6", className)} {...props}>
|
<div className={cn("grid gap-6", className)} {...props}>
|
||||||
{passwordEnabled && (
|
{passwordEnabled && (
|
||||||
<>
|
<>
|
||||||
<form onSubmit={handleSubmit} onChange={() => setErrors({})}>
|
<form onSubmit={handleSubmit} onChange={() => setErrors({})}>
|
||||||
<div className="grid gap-2.5">
|
<div className="grid gap-2.5">
|
||||||
@@ -242,20 +251,20 @@ export function UserAuthForm({
|
|||||||
</form>
|
</form>
|
||||||
{(isFirstRun || oauthEnabled) && (
|
{(isFirstRun || oauthEnabled) && (
|
||||||
// only show 'continue with' during onboarding or if we have auth providers
|
// only show 'continue with' during onboarding or if we have auth providers
|
||||||
(<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<span className="w-full border-t" />
|
<span className="w-full border-t" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
<span className="bg-background px-2 text-muted-foreground">
|
<span className="bg-background px-2 text-muted-foreground">
|
||||||
<Trans>Or continue with</Trans>
|
<Trans>Or continue with</Trans>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>)
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{oauthEnabled && (
|
{oauthEnabled && (
|
||||||
<div className="grid gap-2 -mt-1">
|
<div className="grid gap-2 -mt-1">
|
||||||
{authMethods.oauth2.providers.map((provider) => (
|
{authMethods.oauth2.providers.map((provider) => (
|
||||||
<button
|
<button
|
||||||
@@ -273,28 +282,28 @@ export function UserAuthForm({
|
|||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
className="me-2 h-4 w-4 dark:brightness-0 dark:invert"
|
className="me-2 h-4 w-4 dark:brightness-0 dark:invert"
|
||||||
src={prependBasePath(`/_/images/oauth2/${provider.name}.svg`)}
|
src={getAuthProviderIcon(provider)}
|
||||||
alt=""
|
alt=""
|
||||||
// onError={(e) => {
|
// onError={(e) => {
|
||||||
// e.currentTarget.src = "/static/lock.svg"
|
// e.currentTarget.src = "/static/lock.svg"
|
||||||
// }}
|
// }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="translate-y-[1px]">{provider.displayName}</span>
|
<span className="translate-y-px">{provider.displayName}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!oauthEnabled && isFirstRun && (
|
{!oauthEnabled && isFirstRun && (
|
||||||
// only show GitHub button / dialog during onboarding
|
// only show GitHub button / dialog during onboarding
|
||||||
(<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<button type="button" className={cn(buttonVariants({ variant: "outline" }))}>
|
<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="" />
|
<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>
|
</button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent style={{ maxWidth: 440, width: "90%" }}>
|
<DialogContent style={{ maxWidth: 440, width: "90%" }}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>OAuth 2 / OIDC support</Trans>
|
<Trans>OAuth 2 / OIDC support</Trans>
|
||||||
@@ -318,9 +327,9 @@ export function UserAuthForm({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>)
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
{passwordEnabled && !isFirstRun && (
|
{passwordEnabled && !isFirstRun && (
|
||||||
<Link
|
<Link
|
||||||
href={getPagePath($router, "forgot_password")}
|
href={getPagePath($router, "forgot_password")}
|
||||||
className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity"
|
className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity"
|
||||||
@@ -328,6 +337,6 @@ export function UserAuthForm({
|
|||||||
<Trans>Forgot password?</Trans>
|
<Trans>Forgot password?</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro"
|
||||||
import { LoaderCircle, MailIcon, SendHorizonalIcon } from "lucide-react"
|
import { LoaderCircle, MailIcon, SendHorizonalIcon } from "lucide-react"
|
||||||
import { Input } from "../ui/input"
|
import { Input } from "../ui/input"
|
||||||
import { Label } from "../ui/label"
|
import { Label } from "../ui/label"
|
||||||
@@ -7,9 +7,9 @@ import { useCallback, useState } from "react"
|
|||||||
import { toast } from "../ui/use-toast"
|
import { toast } from "../ui/use-toast"
|
||||||
import { buttonVariants } from "../ui/button"
|
import { buttonVariants } from "../ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { pb } from "@/lib/stores"
|
|
||||||
import { Dialog, DialogHeader } from "../ui/dialog"
|
import { Dialog, DialogHeader } from "../ui/dialog"
|
||||||
import { DialogContent, DialogTrigger, DialogTitle } from "../ui/dialog"
|
import { DialogContent, DialogTrigger, DialogTitle } from "../ui/dialog"
|
||||||
|
import { pb } from "@/lib/api"
|
||||||
|
|
||||||
const showLoginFaliedToast = () => {
|
const showLoginFaliedToast = () => {
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro"
|
||||||
import { UserAuthForm } from "@/components/login/auth-form"
|
import { UserAuthForm } from "@/components/login/auth-form"
|
||||||
import { Logo } from "../logo"
|
import { Logo } from "../logo"
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { pb } from "@/lib/stores"
|
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import ForgotPassword from "./forgot-pass-form"
|
import ForgotPassword from "./forgot-pass-form"
|
||||||
import { $router } from "../router"
|
import { $router } from "../router"
|
||||||
import { AuthMethodsList } from "pocketbase"
|
import { AuthMethodsList } from "pocketbase"
|
||||||
import { useTheme } from "../theme-provider"
|
import { useTheme } from "../theme-provider"
|
||||||
|
import { pb } from "@/lib/api"
|
||||||
|
import { ModeToggle } from "../mode-toggle"
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
const page = useStore($router)
|
const page = useStore($router)
|
||||||
@@ -50,8 +51,11 @@ export default function () {
|
|||||||
<div
|
<div
|
||||||
className="grid gap-5 w-full px-4 mx-auto"
|
className="grid gap-5 w-full px-4 mx-auto"
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
style={{ maxWidth: "22em", "--border": theme == "light" ? "30 8% 80%" : "220 3% 20%" }}
|
style={{ maxWidth: "22em", "--border": theme == "light" ? "hsl(30, 8%, 70%)" : "hsl(220, 3%, 25%)" }}
|
||||||
>
|
>
|
||||||
|
<div className="absolute top-3 right-3">
|
||||||
|
<ModeToggle />
|
||||||
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="mb-3">
|
<h1 className="mb-3">
|
||||||
<Logo className="h-7 fill-foreground mx-auto" />
|
<Logo className="h-7 fill-foreground mx-auto" />
|
||||||
|
|||||||
@@ -1,56 +1,21 @@
|
|||||||
import { Trans } from "@lingui/react/macro";
|
import { t } from "@lingui/core/macro"
|
||||||
import { t } from "@lingui/core/macro";
|
import { MoonStarIcon, SunIcon } from "lucide-react"
|
||||||
import { LaptopIcon, MoonStarIcon, SunIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
|
||||||
import { useTheme } from "@/components/theme-provider"
|
import { useTheme } from "@/components/theme-provider"
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
export function ModeToggle() {
|
export function ModeToggle() {
|
||||||
const { theme, setTheme } = useTheme()
|
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 (
|
return (
|
||||||
<DropdownMenu>
|
<Button
|
||||||
<DropdownMenuTrigger asChild>
|
variant={"ghost"}
|
||||||
<Button variant={"ghost"} size="icon" aria-label={t`Toggle theme`}>
|
size="icon"
|
||||||
<SunIcon className="h-[1.2rem] w-[1.2rem] dark:opacity-0" />
|
aria-label={t`Toggle theme`}
|
||||||
<MoonStarIcon className="absolute h-[1.2rem] w-[1.2rem] opacity-0 dark:opacity-100" />
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||||
</Button>
|
>
|
||||||
</DropdownMenuTrigger>
|
<SunIcon className="h-[1.2rem] w-[1.2rem] transition-all -rotate-90 dark:opacity-0 dark:rotate-0" />
|
||||||
<DropdownMenuContent>
|
<MoonStarIcon className="absolute h-[1.2rem] w-[1.2rem] transition-all opacity-0 -rotate-90 dark:opacity-100 dark:rotate-0" />
|
||||||
{options.map((opt) => {
|
</Button>
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { useState, lazy, Suspense } from "react"
|
import { useState, lazy, Suspense } from "react"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
@@ -15,8 +15,8 @@ import { $router, basePath, Link, prependBasePath } from "./router"
|
|||||||
import { LangToggle } from "./lang-toggle"
|
import { LangToggle } from "./lang-toggle"
|
||||||
import { ModeToggle } from "./mode-toggle"
|
import { ModeToggle } from "./mode-toggle"
|
||||||
import { Logo } from "./logo"
|
import { Logo } from "./logo"
|
||||||
import { pb } from "@/lib/stores"
|
import { cn, runOnce } from "@/lib/utils"
|
||||||
import { cn, isReadOnlyUser, isAdmin, logOut } from "@/lib/utils"
|
import { isReadOnlyUser, isAdmin, logOut, pb } from "@/lib/api"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
@@ -36,12 +36,17 @@ const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0
|
|||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center h-14 md:h-16 bg-card px-4 pe-3 sm:px-6 border border-border/60 bt-0 rounded-md my-4">
|
<div className="flex items-center h-14 md:h-16 bg-card px-4 pe-3 sm:px-6 border border-border/60 bt-0 rounded-md my-4">
|
||||||
<Link href={basePath} aria-label="Home" className="p-2 ps-0 me-3">
|
<Link
|
||||||
|
href={basePath}
|
||||||
|
aria-label="Home"
|
||||||
|
className="p-2 ps-0 me-3"
|
||||||
|
onMouseEnter={runOnce(() => import("@/components/routes/home"))}
|
||||||
|
>
|
||||||
<Logo className="h-[1.1rem] md:h-5 fill-foreground" />
|
<Logo className="h-[1.1rem] md:h-5 fill-foreground" />
|
||||||
</Link>
|
</Link>
|
||||||
<SearchButton />
|
<SearchButton />
|
||||||
|
|
||||||
<div className="flex items-center ms-auto">
|
<div className="flex items-center ms-auto" onMouseEnter={() => import("@/components/routes/settings/general")}>
|
||||||
<LangToggle />
|
<LangToggle />
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -35,18 +35,20 @@ export const navigate = (urlString: string) => {
|
|||||||
$router.open(urlString)
|
$router.open(urlString)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClick(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {
|
export function Link(props: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||||
e.preventDefault()
|
return (
|
||||||
$router.open(new URL((e.currentTarget as HTMLAnchorElement).href).pathname)
|
<a
|
||||||
}
|
{...props}
|
||||||
|
onClick={(e) => {
|
||||||
export const Link = (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
e.preventDefault()
|
||||||
let clickFn = onClick
|
const href = props.href || ""
|
||||||
if (props.onClick) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
clickFn = (e) => {
|
window.open(href, "_blank")
|
||||||
onClick(e)
|
} else {
|
||||||
props.onClick?.(e)
|
navigate(href)
|
||||||
}
|
props.onClick?.(e)
|
||||||
}
|
}
|
||||||
return <a {...props} onClick={clickFn}></a>
|
}}
|
||||||
|
></a>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,68 +1,33 @@
|
|||||||
import { Suspense, lazy, memo, useEffect, useMemo } from "react"
|
import { Suspense, memo, useEffect, useMemo } from "react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
|
||||||
import { $alerts, $systems, pb } from "@/lib/stores"
|
import { $alerts, $allSystemsById } from "@/lib/stores"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { GithubIcon } from "lucide-react"
|
import { GithubIcon } from "lucide-react"
|
||||||
import { Separator } from "../ui/separator"
|
import { Separator } from "../ui/separator"
|
||||||
import { alertInfo, updateRecordList, updateSystemList } from "@/lib/utils"
|
import { AlertRecord } from "@/types"
|
||||||
import { AlertRecord, SystemRecord } from "@/types"
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import { $router, Link } from "../router"
|
import { $router, Link } from "../router"
|
||||||
import { Plural, Trans, useLingui } from "@lingui/react/macro"
|
import { Plural, Trans, useLingui } from "@lingui/react/macro"
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
import { alertInfo } from "@/lib/alerts"
|
||||||
|
import SystemsTable from "@/components/systems-table/systems-table"
|
||||||
|
|
||||||
const SystemsTable = lazy(() => import("../systems-table/systems-table"))
|
export default memo(function () {
|
||||||
|
|
||||||
export const Home = memo(() => {
|
|
||||||
const alerts = useStore($alerts)
|
|
||||||
const systems = useStore($systems)
|
|
||||||
const { t } = useLingui()
|
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(() => {
|
useEffect(() => {
|
||||||
document.title = t`Dashboard` + " / Beszel"
|
document.title = t`Dashboard` + " / Beszel"
|
||||||
}, [t])
|
}, [t])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// make sure we have the latest list of systems
|
|
||||||
updateSystemList()
|
|
||||||
|
|
||||||
// subscribe to real time updates for systems / alerts
|
|
||||||
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(
|
return useMemo(
|
||||||
() => (
|
() => (
|
||||||
<>
|
<>
|
||||||
{/* show active alerts */}
|
<ActiveAlerts />
|
||||||
{activeAlerts.length > 0 && <ActiveAlerts key={activeAlerts.length} activeAlerts={activeAlerts} />}
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<SystemsTable />
|
<SystemsTable />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 text-xs opacity-80">
|
<div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 mb-4 text-xs opacity-80">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/henrygd/beszel"
|
href="https://github.com/henrygd/beszel"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -81,55 +46,80 @@ export const Home = memo(() => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
[alertsKey]
|
[]
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const ActiveAlerts = memo(({ activeAlerts }: { activeAlerts: AlertRecord[] }) => {
|
const ActiveAlerts = () => {
|
||||||
return (
|
const alerts = useStore($alerts)
|
||||||
<Card className="mb-4">
|
const systems = useStore($allSystemsById)
|
||||||
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
|
||||||
<div className="px-2 sm:px-1">
|
const { activeAlerts, alertsKey } = useMemo(() => {
|
||||||
<CardTitle>
|
const activeAlerts: AlertRecord[] = []
|
||||||
<Trans>Active Alerts</Trans>
|
// key to prevent re-rendering if alerts change but active alerts didn't
|
||||||
</CardTitle>
|
const alertsKey: string[] = []
|
||||||
</div>
|
|
||||||
</CardHeader>
|
for (const systemId of Object.keys(alerts)) {
|
||||||
<CardContent className="max-sm:p-2">
|
for (const alert of alerts[systemId].values()) {
|
||||||
{activeAlerts.length > 0 && (
|
if (alert.triggered && alert.name in alertInfo) {
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
|
activeAlerts.push(alert)
|
||||||
{activeAlerts.map((alert) => {
|
alertsKey.push(`${alert.system}${alert.value}${alert.min}`)
|
||||||
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"
|
return { activeAlerts, alertsKey }
|
||||||
>
|
}, [alerts])
|
||||||
<info.icon className="h-4 w-4" />
|
|
||||||
<AlertTitle>
|
return useMemo(() => {
|
||||||
{alert.sysname} {info.name().toLowerCase().replace("cpu", "CPU")}
|
if (activeAlerts.length === 0) {
|
||||||
</AlertTitle>
|
return null
|
||||||
<AlertDescription>
|
}
|
||||||
{alert.name === "Status" ? (
|
return (
|
||||||
<Trans>Connection is down</Trans>
|
<Card className="mb-4">
|
||||||
) : (
|
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
||||||
<Trans>
|
<div className="px-2 sm:px-1">
|
||||||
Exceeds {alert.value}
|
<CardTitle>
|
||||||
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
|
<Trans>Active Alerts</Trans>
|
||||||
</Trans>
|
</CardTitle>
|
||||||
)}
|
|
||||||
</AlertDescription>
|
|
||||||
<Link
|
|
||||||
href={getPagePath($router, "system", { name: alert.sysname! })}
|
|
||||||
className="absolute inset-0 w-full h-full"
|
|
||||||
aria-label="View system"
|
|
||||||
></Link>
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</CardHeader>
|
||||||
</CardContent>
|
<CardContent className="max-sm:p-2">
|
||||||
</Card>
|
{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>
|
||||||
|
{systems[alert.system]?.name} {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: systems[alert.system]?.name })}
|
||||||
|
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 { pb } from "@/lib/api"
|
||||||
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 { AlertsHistoryRecord } from "@/types"
|
||||||
import {
|
import {
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -74,11 +75,11 @@ export default function AlertsHistoryDataTable() {
|
|||||||
}
|
}
|
||||||
// Initial load
|
// Initial load
|
||||||
pb.collection<AlertsHistoryRecord>("alerts_history")
|
pb.collection<AlertsHistoryRecord>("alerts_history")
|
||||||
.getFullList({
|
.getList(0, 200, {
|
||||||
...pbOptions,
|
...pbOptions,
|
||||||
sort: "-created",
|
sort: "-created",
|
||||||
})
|
})
|
||||||
.then((records) => setData(records))
|
.then(({ items }) => setData(items))
|
||||||
|
|
||||||
// Subscribe to changes
|
// Subscribe to changes
|
||||||
;(async () => {
|
;(async () => {
|
||||||
@@ -272,13 +273,13 @@ export default function AlertsHistoryDataTable() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<tr key={headerGroup.id} className="border-border/50">
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header) => (
|
||||||
<TableHead className="px-2" key={header.id}>
|
<TableHead className="px-2" key={header.id}>
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro"
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { isAdmin } from "@/lib/utils"
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { redirectPage } from "@nanostores/router"
|
import { redirectPage } from "@nanostores/router"
|
||||||
import { $router } from "@/components/router"
|
import { $router } from "@/components/router"
|
||||||
import { AlertCircleIcon, FileSlidersIcon, LoaderCircleIcon } from "lucide-react"
|
import { AlertCircleIcon, FileSlidersIcon, LoaderCircleIcon } from "lucide-react"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import { pb } from "@/lib/stores"
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { toast } from "@/components/ui/use-toast"
|
import { toast } from "@/components/ui/use-toast"
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
|
import { isAdmin, pb } from "@/lib/api"
|
||||||
|
|
||||||
export default function ConfigYaml() {
|
export default function ConfigYaml() {
|
||||||
const [configContent, setConfigContent] = useState<string>("")
|
const [configContent, setConfigContent] = useState<string>("")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useState } from "react"
|
|||||||
import languages from "@/lib/languages"
|
import languages from "@/lib/languages"
|
||||||
import { dynamicActivate } from "@/lib/i18n"
|
import { dynamicActivate } from "@/lib/i18n"
|
||||||
import { useLingui } from "@lingui/react/macro"
|
import { useLingui } from "@lingui/react/macro"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
import { Unit } from "@/lib/enums"
|
import { Unit } from "@/lib/enums"
|
||||||
|
|
||||||
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
|
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
|
||||||
@@ -38,8 +39,8 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
|||||||
</div>
|
</div>
|
||||||
<Separator className="my-4" />
|
<Separator className="my-4" />
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<div className="space-y-2">
|
<div className="grid gap-2">
|
||||||
<div className="mb-4">
|
<div className="mb-2">
|
||||||
<h3 className="mb-1 text-lg font-medium flex items-center gap-2">
|
<h3 className="mb-1 text-lg font-medium flex items-center gap-2">
|
||||||
<LanguagesIcon className="h-4 w-4" />
|
<LanguagesIcon className="h-4 w-4" />
|
||||||
<Trans>Language</Trans>
|
<Trans>Language</Trans>
|
||||||
@@ -72,8 +73,8 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-2">
|
<div className="grid gap-2">
|
||||||
<div className="mb-4">
|
<div className="mb-2">
|
||||||
<h3 className="mb-1 text-lg font-medium">
|
<h3 className="mb-1 text-lg font-medium">
|
||||||
<Trans>Chart options</Trans>
|
<Trans>Chart options</Trans>
|
||||||
</h3>
|
</h3>
|
||||||
@@ -101,8 +102,8 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-2">
|
<div className="grid gap-2">
|
||||||
<div className="mb-4">
|
<div className="mb-2">
|
||||||
<h3 className="mb-1 text-lg font-medium">
|
<h3 className="mb-1 text-lg font-medium">
|
||||||
<Trans comment="Temperature / network units">Unit preferences</Trans>
|
<Trans comment="Temperature / network units">Unit preferences</Trans>
|
||||||
</h3>
|
</h3>
|
||||||
@@ -111,7 +112,7 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid sm:grid-cols-3 gap-4">
|
<div className="grid sm:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="grid gap-2">
|
||||||
<Label className="block" htmlFor="unitTemp">
|
<Label className="block" htmlFor="unitTemp">
|
||||||
<Trans>Temperature unit</Trans>
|
<Trans>Temperature unit</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
@@ -133,8 +134,7 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="block" htmlFor="unitNet">
|
<Label className="block" htmlFor="unitNet">
|
||||||
<Trans comment="Context: Bytes or bits">Network unit</Trans>
|
<Trans comment="Context: Bytes or bits">Network unit</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
@@ -156,8 +156,7 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="block" htmlFor="unitDisk">
|
<Label className="block" htmlFor="unitDisk">
|
||||||
<Trans>Disk unit</Trans>
|
<Trans>Disk unit</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
@@ -182,6 +181,47 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<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}>
|
<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" />}
|
{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
|
||||||
<Trans>Save Settings</Trans>
|
<Trans>Save Settings</Trans>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { useEffect } from "react"
|
import { lazy, useEffect } from "react"
|
||||||
import { Separator } from "../../ui/separator"
|
import { Separator } from "../../ui/separator"
|
||||||
import { SidebarNav } from "./sidebar-nav.tsx"
|
import { SidebarNav } from "./sidebar-nav.tsx"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx"
|
||||||
@@ -8,15 +8,23 @@ import { useStore } from "@nanostores/react"
|
|||||||
import { $router } from "@/components/router.tsx"
|
import { $router } from "@/components/router.tsx"
|
||||||
import { getPagePath, redirectPage } from "@nanostores/router"
|
import { getPagePath, redirectPage } from "@nanostores/router"
|
||||||
import { BellIcon, FileSlidersIcon, FingerprintIcon, SettingsIcon, AlertOctagonIcon } from "lucide-react"
|
import { BellIcon, FileSlidersIcon, FingerprintIcon, SettingsIcon, AlertOctagonIcon } from "lucide-react"
|
||||||
import { $userSettings, pb } from "@/lib/stores.ts"
|
import { $userSettings } from "@/lib/stores.ts"
|
||||||
import { toast } from "@/components/ui/use-toast.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"
|
|
||||||
import { useLingui } from "@lingui/react/macro"
|
import { useLingui } from "@lingui/react/macro"
|
||||||
import Fingerprints from "./tokens-fingerprints.tsx"
|
import { pb } from "@/lib/api"
|
||||||
import AlertsHistoryDataTable from "./alerts-history-data-table"
|
|
||||||
|
const generalSettingsImport = () => import("./general.tsx")
|
||||||
|
const notificationsSettingsImport = () => import("./notifications.tsx")
|
||||||
|
const configYamlSettingsImport = () => import("./config-yaml.tsx")
|
||||||
|
const fingerprintsSettingsImport = () => import("./tokens-fingerprints.tsx")
|
||||||
|
const alertsHistoryDataTableSettingsImport = () => import("./alerts-history-data-table.tsx")
|
||||||
|
|
||||||
|
const GeneralSettings = lazy(generalSettingsImport)
|
||||||
|
const NotificationsSettings = lazy(notificationsSettingsImport)
|
||||||
|
const ConfigYamlSettings = lazy(configYamlSettingsImport)
|
||||||
|
const FingerprintsSettings = lazy(fingerprintsSettingsImport)
|
||||||
|
const AlertsHistoryDataTableSettings = lazy(alertsHistoryDataTableSettingsImport)
|
||||||
|
|
||||||
export async function saveSettings(newSettings: Partial<UserSettings>) {
|
export async function saveSettings(newSettings: Partial<UserSettings>) {
|
||||||
try {
|
try {
|
||||||
@@ -59,23 +67,27 @@ export default function SettingsLayout() {
|
|||||||
title: t`Notifications`,
|
title: t`Notifications`,
|
||||||
href: getPagePath($router, "settings", { name: "notifications" }),
|
href: getPagePath($router, "settings", { name: "notifications" }),
|
||||||
icon: BellIcon,
|
icon: BellIcon,
|
||||||
|
preload: notificationsSettingsImport,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t`Tokens & Fingerprints`,
|
title: t`Tokens & Fingerprints`,
|
||||||
href: getPagePath($router, "settings", { name: "tokens" }),
|
href: getPagePath($router, "settings", { name: "tokens" }),
|
||||||
icon: FingerprintIcon,
|
icon: FingerprintIcon,
|
||||||
noReadOnly: true,
|
noReadOnly: true,
|
||||||
|
preload: fingerprintsSettingsImport,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t`Alert History`,
|
title: t`Alert History`,
|
||||||
href: getPagePath($router, "settings", { name: "alert-history" }),
|
href: getPagePath($router, "settings", { name: "alert-history" }),
|
||||||
icon: AlertOctagonIcon,
|
icon: AlertOctagonIcon,
|
||||||
|
preload: alertsHistoryDataTableSettingsImport,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t`YAML Config`,
|
title: t`YAML Config`,
|
||||||
href: getPagePath($router, "settings", { name: "config" }),
|
href: getPagePath($router, "settings", { name: "config" }),
|
||||||
icon: FileSlidersIcon,
|
icon: FileSlidersIcon,
|
||||||
admin: true,
|
admin: true,
|
||||||
|
preload: configYamlSettingsImport,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -90,7 +102,7 @@ export default function SettingsLayout() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="pt-5 px-4 pb-8 min-h-96 sm:pt-6 sm:px-7">
|
<Card className="pt-5 px-4 pb-8 min-h-96 mb-14 sm:pt-6 sm:px-7">
|
||||||
<CardHeader className="p-0">
|
<CardHeader className="p-0">
|
||||||
<CardTitle className="mb-1">
|
<CardTitle className="mb-1">
|
||||||
<Trans>Settings</Trans>
|
<Trans>Settings</Trans>
|
||||||
@@ -120,14 +132,14 @@ function SettingsContent({ name }: { name: string }) {
|
|||||||
|
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "general":
|
case "general":
|
||||||
return <General userSettings={userSettings} />
|
return <GeneralSettings userSettings={userSettings} />
|
||||||
case "notifications":
|
case "notifications":
|
||||||
return <Notifications userSettings={userSettings} />
|
return <NotificationsSettings userSettings={userSettings} />
|
||||||
case "config":
|
case "config":
|
||||||
return <ConfigYaml />
|
return <ConfigYamlSettings />
|
||||||
case "tokens":
|
case "tokens":
|
||||||
return <Fingerprints />
|
return <FingerprintsSettings />
|
||||||
case "alert-history":
|
case "alert-history":
|
||||||
return <AlertsHistoryDataTable />
|
return <AlertsHistoryDataTableSettings />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Trans } from "@lingui/react/macro"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { pb } from "@/lib/stores"
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
import { BellIcon, LoaderCircleIcon, PlusIcon, SaveIcon, Trash2Icon } from "lucide-react"
|
import { BellIcon, LoaderCircleIcon, PlusIcon, SaveIcon, Trash2Icon } from "lucide-react"
|
||||||
@@ -13,8 +12,8 @@ import { InputTags } from "@/components/ui/input-tags"
|
|||||||
import { UserSettings } from "@/types"
|
import { UserSettings } from "@/types"
|
||||||
import { saveSettings } from "./layout"
|
import { saveSettings } from "./layout"
|
||||||
import * as v from "valibot"
|
import * as v from "valibot"
|
||||||
import { isAdmin } from "@/lib/utils"
|
|
||||||
import { prependBasePath } from "@/components/router"
|
import { prependBasePath } from "@/components/router"
|
||||||
|
import { isAdmin, pb } from "@/lib/api"
|
||||||
|
|
||||||
interface ShoutrrrUrlCardProps {
|
interface ShoutrrrUrlCardProps {
|
||||||
url: string
|
url: string
|
||||||
@@ -87,8 +86,8 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
|||||||
</div>
|
</div>
|
||||||
<Separator className="my-4" />
|
<Separator className="my-4" />
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="space-y-2">
|
<div className="grid gap-2">
|
||||||
<div className="mb-4">
|
<div className="mb-2">
|
||||||
<h3 className="mb-1 text-lg font-medium">
|
<h3 className="mb-1 text-lg font-medium">
|
||||||
<Trans>Email notifications</Trans>
|
<Trans>Email notifications</Trans>
|
||||||
</h3>
|
</h3>
|
||||||
@@ -178,7 +177,7 @@ const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) =
|
|||||||
|
|
||||||
const sendTestNotification = async () => {
|
const sendTestNotification = async () => {
|
||||||
setIsLoading(true)
|
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) {
|
if ("err" in res && !res.err) {
|
||||||
toast({
|
toast({
|
||||||
title: t`Test notification sent`,
|
title: t`Test notification sent`,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import { cn, isAdmin, isReadOnlyUser } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { isAdmin, isReadOnlyUser } from "@/lib/api"
|
||||||
import { buttonVariants } from "../../ui/button"
|
import { buttonVariants } from "../../ui/button"
|
||||||
import { $router, Link, navigate } from "../../router"
|
import { $router, Link, navigate } from "../../router"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
@@ -13,6 +14,7 @@ interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
|
|||||||
icon?: React.FC<React.SVGProps<SVGSVGElement>>
|
icon?: React.FC<React.SVGProps<SVGSVGElement>>
|
||||||
admin?: boolean
|
admin?: boolean
|
||||||
noReadOnly?: boolean
|
noReadOnly?: boolean
|
||||||
|
preload?: () => Promise<{ default: React.ComponentType<any> }>
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,12 +54,13 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
onMouseEnter={() => item.preload?.()}
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant: "ghost" }),
|
buttonVariants({ variant: "ghost" }),
|
||||||
"flex items-center gap-3 justify-start truncate",
|
"flex items-center gap-3 justify-start truncate duration-50",
|
||||||
page?.path === item.href ? "bg-muted hover:bg-muted" : "hover:bg-muted/50"
|
page?.path === item.href ? "bg-muted hover:bg-accent/70" : "hover:bg-accent/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.icon && <item.icon className="size-4 shrink-0" />}
|
{item.icon && <item.icon className="size-4 shrink-0" />}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro"
|
import { Trans, useLingui } from "@lingui/react/macro"
|
||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { $publicKey, pb } from "@/lib/stores"
|
import { $publicKey } from "@/lib/stores"
|
||||||
import { memo, useEffect, useMemo, useState } from "react"
|
import { memo, useEffect, useMemo, useState } from "react"
|
||||||
import { Table, TableCell, TableHead, TableBody, TableRow, TableHeader } from "@/components/ui/table"
|
import { Table, TableCell, TableHead, TableBody, TableRow, TableHeader } from "@/components/ui/table"
|
||||||
import { FingerprintRecord } from "@/types"
|
import { FingerprintRecord } from "@/types"
|
||||||
@@ -14,7 +14,8 @@ import {
|
|||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { toast } from "@/components/ui/use-toast"
|
import { toast } from "@/components/ui/use-toast"
|
||||||
import { cn, copyToClipboard, generateToken, getHubURL, isReadOnlyUser, tokenMap } from "@/lib/utils"
|
import { cn, copyToClipboard, generateToken, getHubURL, tokenMap } from "@/lib/utils"
|
||||||
|
import { isReadOnlyUser, pb } from "@/lib/api"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -42,6 +43,10 @@ const pbFingerprintOptions = {
|
|||||||
fields: "id,fingerprint,token,system,expand.system.name",
|
fields: "id,fingerprint,token,system,expand.system.name",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortFingerprints(fingerprints: FingerprintRecord[]) {
|
||||||
|
return fingerprints.sort((a, b) => a.expand.system.name.localeCompare(b.expand.system.name))
|
||||||
|
}
|
||||||
|
|
||||||
const SettingsFingerprintsPage = memo(() => {
|
const SettingsFingerprintsPage = memo(() => {
|
||||||
if (isReadOnlyUser()) {
|
if (isReadOnlyUser()) {
|
||||||
redirectPage($router, "settings", { name: "general" })
|
redirectPage($router, "settings", { name: "general" })
|
||||||
@@ -51,9 +56,10 @@ const SettingsFingerprintsPage = memo(() => {
|
|||||||
// Get fingerprint records on mount
|
// Get fingerprint records on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pb.collection("fingerprints")
|
pb.collection("fingerprints")
|
||||||
.getFullList(pbFingerprintOptions)
|
.getFullList<FingerprintRecord>(pbFingerprintOptions)
|
||||||
// @ts-ignore
|
.then((prints) => {
|
||||||
.then(setFingerprints)
|
setFingerprints(sortFingerprints(prints))
|
||||||
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Subscribe to fingerprint updates
|
// Subscribe to fingerprint updates
|
||||||
@@ -66,7 +72,7 @@ const SettingsFingerprintsPage = memo(() => {
|
|||||||
(res) => {
|
(res) => {
|
||||||
setFingerprints((currentFingerprints) => {
|
setFingerprints((currentFingerprints) => {
|
||||||
if (res.action === "create") {
|
if (res.action === "create") {
|
||||||
return [...currentFingerprints, res.record as FingerprintRecord]
|
return sortFingerprints([...currentFingerprints, res.record as FingerprintRecord])
|
||||||
}
|
}
|
||||||
if (res.action === "update") {
|
if (res.action === "update") {
|
||||||
return currentFingerprints.map((fingerprint) => {
|
return currentFingerprints.map((fingerprint) => {
|
||||||
@@ -266,7 +272,7 @@ const SectionTable = memo(({ fingerprints = [] }: { fingerprints: FingerprintRec
|
|||||||
<div className="rounded-md border overflow-hidden w-full mt-4">
|
<div className="rounded-md border overflow-hidden w-full mt-4">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<tr className="border-border/50">
|
||||||
{headerCols.map((col) => (
|
{headerCols.map((col) => (
|
||||||
<TableHead key={col.label} style={{ minWidth: col.w }}>
|
<TableHead key={col.label} style={{ minWidth: col.w }}>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
@@ -282,16 +288,18 @@ const SectionTable = memo(({ fingerprints = [] }: { fingerprints: FingerprintRec
|
|||||||
</span>
|
</span>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)}
|
)}
|
||||||
</TableRow>
|
</tr>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody className="whitespace-pre">
|
<TableBody className="whitespace-pre">
|
||||||
{fingerprints.map((fingerprint, i) => (
|
{fingerprints.map((fingerprint, i) => (
|
||||||
<TableRow key={i}>
|
<TableRow key={i}>
|
||||||
<TableCell className="font-medium ps-5 py-2.5">{fingerprint.expand.system.name}</TableCell>
|
<TableCell className="font-medium ps-5 py-2 max-w-60 truncate">
|
||||||
<TableCell className="font-mono text-[0.95em] py-2.5">{fingerprint.token}</TableCell>
|
{fingerprint.expand.system.name}
|
||||||
<TableCell className="font-mono text-[0.95em] py-2.5">{fingerprint.fingerprint}</TableCell>
|
</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 && (
|
{!isReadOnly && (
|
||||||
<TableCell className="py-2.5 px-4 xl:px-2">
|
<TableCell className="py-2 px-4 xl:px-2">
|
||||||
<ActionsButtonTable fingerprint={fingerprint} />
|
<ActionsButtonTable fingerprint={fingerprint} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,17 +2,18 @@ import { t } from "@lingui/core/macro"
|
|||||||
import { Plural, Trans } from "@lingui/react/macro"
|
import { Plural, Trans } from "@lingui/react/macro"
|
||||||
import {
|
import {
|
||||||
$systems,
|
$systems,
|
||||||
pb,
|
|
||||||
$chartTime,
|
$chartTime,
|
||||||
$containerFilter,
|
$containerFilter,
|
||||||
$userSettings,
|
$userSettings,
|
||||||
$direction,
|
$direction,
|
||||||
$maxValues,
|
$maxValues,
|
||||||
$temperatureFilter,
|
$temperatureFilter,
|
||||||
|
$allSystemsByName,
|
||||||
} from "@/lib/stores"
|
} from "@/lib/stores"
|
||||||
import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
|
import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
|
||||||
import { ChartType, Unit, Os } from "@/lib/enums"
|
import { useContainerChartConfigs } from "@/components/charts/hooks"
|
||||||
import React, { lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { ChartType, Unit, Os, SystemStatus } from "@/lib/enums"
|
||||||
|
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react"
|
||||||
import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card"
|
import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import Spinner from "../spinner"
|
import Spinner from "../spinner"
|
||||||
@@ -24,11 +25,12 @@ import {
|
|||||||
decimalString,
|
decimalString,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
getHostDisplayValue,
|
getHostDisplayValue,
|
||||||
getPbTimestamp,
|
|
||||||
listen,
|
listen,
|
||||||
|
parseSemVer,
|
||||||
toFixedFloat,
|
toFixedFloat,
|
||||||
useLocalStorage,
|
useBrowserStorage,
|
||||||
} from "@/lib/utils"
|
} from "@/lib/utils"
|
||||||
|
import { getPbTimestamp, pb } from "@/lib/api"
|
||||||
import { Separator } from "../ui/separator"
|
import { Separator } from "../ui/separator"
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
|
||||||
import { Button } from "../ui/button"
|
import { Button } from "../ui/button"
|
||||||
@@ -40,15 +42,16 @@ import { timeTicks } from "d3-time"
|
|||||||
import { useLingui } from "@lingui/react/macro"
|
import { useLingui } from "@lingui/react/macro"
|
||||||
import { $router, navigate } from "../router"
|
import { $router, navigate } from "../router"
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
import { batteryStateTranslations } from "@/lib/i18n"
|
||||||
const AreaChartDefault = lazy(() => import("../charts/area-chart"))
|
import AreaChartDefault from "@/components/charts/area-chart"
|
||||||
const ContainerChart = lazy(() => import("../charts/container-chart"))
|
import ContainerChart from "@/components/charts/container-chart"
|
||||||
const MemChart = lazy(() => import("../charts/mem-chart"))
|
import MemChart from "@/components/charts/mem-chart"
|
||||||
const DiskChart = lazy(() => import("../charts/disk-chart"))
|
import DiskChart from "@/components/charts/disk-chart"
|
||||||
const SwapChart = lazy(() => import("../charts/swap-chart"))
|
import SwapChart from "@/components/charts/swap-chart"
|
||||||
const TemperatureChart = lazy(() => import("../charts/temperature-chart"))
|
import TemperatureChart from "@/components/charts/temperature-chart"
|
||||||
const GpuPowerChart = lazy(() => import("../charts/gpu-power-chart"))
|
import GpuPowerChart from "@/components/charts/gpu-power-chart"
|
||||||
const LoadAverageChart = lazy(() => import("../charts/load-average-chart"))
|
import LoadAverageChart from "@/components/charts/load-average-chart"
|
||||||
|
import { subscribeKeys } from "nanostores"
|
||||||
|
|
||||||
const cache = new Map<string, any>()
|
const cache = new Map<string, any>()
|
||||||
|
|
||||||
@@ -117,13 +120,13 @@ function dockerOrPodman(str: string, system: SystemRecord) {
|
|||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SystemDetail({ name }: { name: string }) {
|
export default memo(function SystemDetail({ name }: { name: string }) {
|
||||||
const direction = useStore($direction)
|
const direction = useStore($direction)
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
const systems = useStore($systems)
|
const systems = useStore($systems)
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
const maxValues = useStore($maxValues)
|
const maxValues = useStore($maxValues)
|
||||||
const [grid, setGrid] = useLocalStorage("grid", true)
|
const [grid, setGrid] = useBrowserStorage("grid", true)
|
||||||
const [system, setSystem] = useState({} as SystemRecord)
|
const [system, setSystem] = useState({} as SystemRecord)
|
||||||
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
|
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
|
||||||
const [containerData, setContainerData] = useState([] as ChartData["containerData"])
|
const [containerData, setContainerData] = useState([] as ChartData["containerData"])
|
||||||
@@ -149,36 +152,13 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
}
|
}
|
||||||
}, [name])
|
}, [name])
|
||||||
|
|
||||||
// function resetCharts() {
|
// find matching system and update when it changes
|
||||||
// setSystemStats([])
|
|
||||||
// setContainerData([])
|
|
||||||
// }
|
|
||||||
|
|
||||||
// useEffect(resetCharts, [chartTime])
|
|
||||||
|
|
||||||
// find matching system
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (system.id && system.name === name) {
|
return subscribeKeys($allSystemsByName, [name], (newSystems) => {
|
||||||
return
|
const sys = newSystems[name]
|
||||||
}
|
sys?.id && setSystem(sys)
|
||||||
const matchingSystem = systems.find((s) => s.name === name) as SystemRecord
|
|
||||||
if (matchingSystem) {
|
|
||||||
setSystem(matchingSystem)
|
|
||||||
}
|
|
||||||
}, [name, system, systems])
|
|
||||||
|
|
||||||
// update system when new data is available
|
|
||||||
useEffect(() => {
|
|
||||||
if (!system.id) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pb.collection<SystemRecord>("systems").subscribe(system.id, (e) => {
|
|
||||||
setSystem(e.record)
|
|
||||||
})
|
})
|
||||||
return () => {
|
}, [name])
|
||||||
pb.collection("systems").unsubscribe(system.id)
|
|
||||||
}
|
|
||||||
}, [system.id])
|
|
||||||
|
|
||||||
const chartData: ChartData = useMemo(() => {
|
const chartData: ChartData = useMemo(() => {
|
||||||
const lastCreated = Math.max(
|
const lastCreated = Math.max(
|
||||||
@@ -191,9 +171,13 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
chartTime,
|
chartTime,
|
||||||
orientation: direction === "rtl" ? "right" : "left",
|
orientation: direction === "rtl" ? "right" : "left",
|
||||||
...getTimeData(chartTime, lastCreated),
|
...getTimeData(chartTime, lastCreated),
|
||||||
|
agentVersion: parseSemVer(system?.info?.v),
|
||||||
}
|
}
|
||||||
}, [systemStats, containerData, direction])
|
}, [systemStats, containerData, direction])
|
||||||
|
|
||||||
|
// Share chart config computation for all container charts
|
||||||
|
const containerChartConfigs = useContainerChartConfigs(containerData)
|
||||||
|
|
||||||
// get stats
|
// get stats
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!system.id || !chartTime) {
|
if (!system.id || !chartTime) {
|
||||||
@@ -284,9 +268,11 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
value: system.info.k,
|
value: system.info.k,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
let uptime: React.ReactNode
|
let uptime: React.ReactNode
|
||||||
if (system.info.u < 172800) {
|
if (system.info.u < 3600) {
|
||||||
|
const mins = Math.trunc(system.info.u / 60)
|
||||||
|
uptime = <Plural value={mins} one="# minute" other="# minutes" />
|
||||||
|
} else if (system.info.u < 172800) {
|
||||||
const hours = Math.trunc(system.info.u / 3600)
|
const hours = Math.trunc(system.info.u / 3600)
|
||||||
uptime = <Plural value={hours} one="# hour" other="# hours" />
|
uptime = <Plural value={hours} one="# hour" other="# hours" />
|
||||||
} else {
|
} else {
|
||||||
@@ -314,7 +300,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
Icon: any
|
Icon: any
|
||||||
hide?: boolean
|
hide?: boolean
|
||||||
}[]
|
}[]
|
||||||
}, [system.info])
|
}, [system.info, t])
|
||||||
|
|
||||||
/** Space for tooltip if more than 12 containers */
|
/** Space for tooltip if more than 12 containers */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -380,15 +366,15 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined)
|
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined)
|
||||||
|
|
||||||
let translatedStatus: string = system.status
|
let translatedStatus: string = system.status
|
||||||
if (system.status === "up") {
|
if (system.status === SystemStatus.Up) {
|
||||||
translatedStatus = t({ message: "Up", comment: "Context: System is 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" })
|
translatedStatus = t({ message: "Down", comment: "Context: System is down" })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div id="chartwrap" className="grid gap-4 mb-10 overflow-x-clip">
|
<div id="chartwrap" className="grid gap-4 mb-14 overflow-x-clip">
|
||||||
{/* system info */}
|
{/* system info */}
|
||||||
<Card>
|
<Card>
|
||||||
<div className="grid xl:flex gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
|
<div className="grid xl:flex gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
|
||||||
@@ -397,7 +383,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="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
|
||||||
<div className="capitalize flex gap-2 items-center">
|
<div className="capitalize flex gap-2 items-center">
|
||||||
<span className={cn("relative flex h-3 w-3")}>
|
<span className={cn("relative flex h-3 w-3")}>
|
||||||
{system.status === "up" && (
|
{system.status === SystemStatus.Up && (
|
||||||
<span
|
<span
|
||||||
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
|
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
|
||||||
style={{ animationDuration: "1.5s" }}
|
style={{ animationDuration: "1.5s" }}
|
||||||
@@ -405,10 +391,10 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className={cn("relative inline-flex rounded-full h-3 w-3", {
|
className={cn("relative inline-flex rounded-full h-3 w-3", {
|
||||||
"bg-green-500": system.status === "up",
|
"bg-green-500": system.status === SystemStatus.Up,
|
||||||
"bg-red-500": system.status === "down",
|
"bg-red-500": system.status === SystemStatus.Down,
|
||||||
"bg-primary/40": system.status === "paused",
|
"bg-primary/40": system.status === SystemStatus.Paused,
|
||||||
"bg-yellow-500": system.status === "pending",
|
"bg-yellow-500": system.status === SystemStatus.Pending,
|
||||||
})}
|
})}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
@@ -454,9 +440,9 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
onClick={() => setGrid(!grid)}
|
onClick={() => setGrid(!grid)}
|
||||||
>
|
>
|
||||||
{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>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -483,7 +469,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
{
|
{
|
||||||
label: t`CPU Usage`,
|
label: t`CPU Usage`,
|
||||||
dataKey: ({ stats }) => (showMax ? stats?.cpum : stats?.cpu),
|
dataKey: ({ stats }) => (showMax ? stats?.cpum : stats?.cpu),
|
||||||
color: "1",
|
color: 1,
|
||||||
opacity: 0.4,
|
opacity: 0.4,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
@@ -500,7 +486,12 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
description={t`Average CPU utilization of containers`}
|
description={t`Average CPU utilization of containers`}
|
||||||
cornerEl={containerFilterBar}
|
cornerEl={containerFilterBar}
|
||||||
>
|
>
|
||||||
<ContainerChart chartData={chartData} dataKey="c" chartType={ChartType.CPU} />
|
<ContainerChart
|
||||||
|
chartData={chartData}
|
||||||
|
dataKey="c"
|
||||||
|
chartType={ChartType.CPU}
|
||||||
|
chartConfig={containerChartConfigs.cpu}
|
||||||
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -509,8 +500,9 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
grid={grid}
|
grid={grid}
|
||||||
title={t`Memory Usage`}
|
title={t`Memory Usage`}
|
||||||
description={t`Precise utilization at the recorded time`}
|
description={t`Precise utilization at the recorded time`}
|
||||||
|
cornerEl={maxValSelect}
|
||||||
>
|
>
|
||||||
<MemChart chartData={chartData} />
|
<MemChart chartData={chartData} showMax={showMax} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
{containerFilterBar && (
|
{containerFilterBar && (
|
||||||
@@ -521,7 +513,12 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
description={dockerOrPodman(t`Memory usage of docker containers`, system)}
|
description={dockerOrPodman(t`Memory usage of docker containers`, system)}
|
||||||
cornerEl={containerFilterBar}
|
cornerEl={containerFilterBar}
|
||||||
>
|
>
|
||||||
<ContainerChart chartData={chartData} dataKey="m" chartType={ChartType.Memory} />
|
<ContainerChart
|
||||||
|
chartData={chartData}
|
||||||
|
dataKey="m"
|
||||||
|
chartType={ChartType.Memory}
|
||||||
|
chartConfig={containerChartConfigs.memory}
|
||||||
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -543,13 +540,13 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
{
|
{
|
||||||
label: t({ message: "Write", comment: "Disk write" }),
|
label: t({ message: "Write", comment: "Disk write" }),
|
||||||
dataKey: ({ stats }) => (showMax ? stats?.dwm : stats?.dw),
|
dataKey: ({ stats }) => (showMax ? stats?.dwm : stats?.dw),
|
||||||
color: "3",
|
color: 3,
|
||||||
opacity: 0.3,
|
opacity: 0.3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t({ message: "Read", comment: "Disk read" }),
|
label: t({ message: "Read", comment: "Disk read" }),
|
||||||
dataKey: ({ stats }) => (showMax ? stats?.drm : stats?.dr),
|
dataKey: ({ stats }) => (showMax ? stats?.drm : stats?.dr),
|
||||||
color: "1",
|
color: 1,
|
||||||
opacity: 0.3,
|
opacity: 0.3,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
@@ -584,7 +581,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
}
|
}
|
||||||
return data?.stats?.b?.[0] ?? data?.stats?.ns * 1024 * 1024
|
return data?.stats?.b?.[0] ?? data?.stats?.ns * 1024 * 1024
|
||||||
},
|
},
|
||||||
color: "5",
|
color: 5,
|
||||||
opacity: 0.2,
|
opacity: 0.2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -595,7 +592,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
}
|
}
|
||||||
return data?.stats?.b?.[1] ?? data?.stats?.nr * 1024 * 1024
|
return data?.stats?.b?.[1] ?? data?.stats?.nr * 1024 * 1024
|
||||||
},
|
},
|
||||||
color: "2",
|
color: 2,
|
||||||
opacity: 0.2,
|
opacity: 0.2,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
@@ -623,8 +620,12 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
description={dockerOrPodman(t`Network traffic of docker containers`, system)}
|
description={dockerOrPodman(t`Network traffic of docker containers`, system)}
|
||||||
cornerEl={containerFilterBar}
|
cornerEl={containerFilterBar}
|
||||||
>
|
>
|
||||||
{/* @ts-ignore */}
|
<ContainerChart
|
||||||
<ContainerChart chartData={chartData} chartType={ChartType.Network} dataKey="n" />
|
chartData={chartData}
|
||||||
|
chartType={ChartType.Network}
|
||||||
|
dataKey="n"
|
||||||
|
chartConfig={containerChartConfigs.network}
|
||||||
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -642,7 +643,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Load Average chart */}
|
{/* Load Average chart */}
|
||||||
{system.info.l1 !== undefined && (
|
{chartData.agentVersion?.minor >= 12 && (
|
||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
@@ -666,6 +667,35 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
</ChartCard>
|
</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 */}
|
{/* GPU power draw chart */}
|
||||||
{hasGpuPowerData && (
|
{hasGpuPowerData && (
|
||||||
<ChartCard
|
<ChartCard
|
||||||
@@ -698,7 +728,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
{
|
{
|
||||||
label: t`Usage`,
|
label: t`Usage`,
|
||||||
dataKey: ({ stats }) => stats?.g?.[id]?.u ?? 0,
|
dataKey: ({ stats }) => stats?.g?.[id]?.u ?? 0,
|
||||||
color: "1",
|
color: 1,
|
||||||
opacity: 0.35,
|
opacity: 0.35,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
@@ -718,7 +748,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
{
|
{
|
||||||
label: t`Usage`,
|
label: t`Usage`,
|
||||||
dataKey: ({ stats }) => stats?.g?.[id]?.mu ?? 0,
|
dataKey: ({ stats }) => stats?.g?.[id]?.mu ?? 0,
|
||||||
color: "2",
|
color: 2,
|
||||||
opacity: 0.25,
|
opacity: 0.25,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
@@ -770,13 +800,13 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
{
|
{
|
||||||
label: t`Write`,
|
label: t`Write`,
|
||||||
dataKey: ({ stats }) => stats?.efs?.[extraFsName]?.[showMax ? "wm" : "w"] ?? 0,
|
dataKey: ({ stats }) => stats?.efs?.[extraFsName]?.[showMax ? "wm" : "w"] ?? 0,
|
||||||
color: "3",
|
color: 3,
|
||||||
opacity: 0.3,
|
opacity: 0.3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t`Read`,
|
label: t`Read`,
|
||||||
dataKey: ({ stats }) => stats?.efs?.[extraFsName]?.[showMax ? "rm" : "r"] ?? 0,
|
dataKey: ({ stats }) => stats?.efs?.[extraFsName]?.[showMax ? "rm" : "r"] ?? 0,
|
||||||
color: "1",
|
color: 1,
|
||||||
opacity: 0.3,
|
opacity: 0.3,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
@@ -802,7 +832,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
{bottomSpacing > 0 && <span className="block" style={{ height: bottomSpacing }} />}
|
{bottomSpacing > 0 && <span className="block" style={{ height: bottomSpacing }} />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) {
|
function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) {
|
||||||
const containerFilter = useStore(store)
|
const containerFilter = useStore(store)
|
||||||
@@ -870,10 +900,10 @@ function ChartCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={cn("pb-2 sm:pb-4 odd:last-of-type:col-span-full", { "col-span-full": !grid })} ref={ref}>
|
<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>
|
<CardTitle className="text-xl sm:text-2xl">{title}</CardTitle>
|
||||||
<CardDescription>{description}</CardDescription>
|
<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>
|
</CardHeader>
|
||||||
<div className="ps-0 w-[calc(100%-1.5em)] h-48 md:h-52 relative group">
|
<div className="ps-0 w-[calc(100%-1.5em)] h-48 md:h-52 relative group">
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,453 @@
|
|||||||
|
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,
|
||||||
|
parseSemVer,
|
||||||
|
} from "@/lib/utils"
|
||||||
|
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { $longestSystemNameLen, $userSettings } 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"
|
||||||
|
import { isReadOnlyUser, pb } from "@/lib/api"
|
||||||
|
|
||||||
|
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,
|
||||||
|
size: 100,
|
||||||
|
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
|
||||||
|
const longestName = useStore($longestSystemNameLen)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="flex gap-2 items-center font-medium text-sm text-nowrap md:ps-1">
|
||||||
|
<IndicatorDot system={info.row.original} />
|
||||||
|
{/* NOTE: change to 1 ch if switching to monospace font */}
|
||||||
|
<span className="truncate" style={{ width: `${longestName / 1.1}ch` }}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</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 w-full">
|
||||||
|
<span className="min-w-8 shrink-0">{decimalString(val, val >= 10 ? 1 : 2)}%</span>
|
||||||
|
<span className="flex-1 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 {
|
import {
|
||||||
CellContext,
|
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
ColumnFiltersState,
|
ColumnFiltersState,
|
||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
@@ -9,15 +8,11 @@ import {
|
|||||||
VisibilityState,
|
VisibilityState,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
HeaderContext,
|
|
||||||
Row,
|
Row,
|
||||||
Table as TableType,
|
Table as TableType,
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
|
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
@@ -29,326 +24,84 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
|
||||||
|
|
||||||
import { SystemRecord } from "@/types"
|
import { SystemRecord } from "@/types"
|
||||||
import {
|
import {
|
||||||
MoreHorizontalIcon,
|
|
||||||
ArrowUpDownIcon,
|
ArrowUpDownIcon,
|
||||||
MemoryStickIcon,
|
|
||||||
CopyIcon,
|
|
||||||
PauseCircleIcon,
|
|
||||||
PlayCircleIcon,
|
|
||||||
Trash2Icon,
|
|
||||||
WifiIcon,
|
|
||||||
HardDriveIcon,
|
|
||||||
ServerIcon,
|
|
||||||
CpuIcon,
|
|
||||||
LayoutGridIcon,
|
LayoutGridIcon,
|
||||||
LayoutListIcon,
|
LayoutListIcon,
|
||||||
ArrowDownIcon,
|
ArrowDownIcon,
|
||||||
ArrowUpIcon,
|
ArrowUpIcon,
|
||||||
Settings2Icon,
|
Settings2Icon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
PenBoxIcon,
|
FilterIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { $systems, $userSettings, pb } from "@/lib/stores"
|
import { $pausedSystems, $downSystems, $upSystems, $systems } from "@/lib/stores"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import {
|
import { cn, runOnce, useBrowserStorage } from "@/lib/utils"
|
||||||
cn,
|
import { $router, Link } from "../router"
|
||||||
copyToClipboard,
|
|
||||||
isReadOnlyUser,
|
|
||||||
useLocalStorage,
|
|
||||||
formatTemperature,
|
|
||||||
decimalString,
|
|
||||||
formatBytes,
|
|
||||||
} from "@/lib/utils"
|
|
||||||
import AlertsButton from "../alerts/alert-button"
|
|
||||||
import { $router, Link, navigate } from "../router"
|
|
||||||
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons"
|
|
||||||
import { useLingui, Trans } from "@lingui/react/macro"
|
import { useLingui, Trans } from "@lingui/react/macro"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
||||||
import { Input } from "../ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { ClassValue } from "clsx"
|
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
import { SystemDialog } from "../add-system"
|
import SystemsTableColumns, { ActionsButton, IndicatorDot } from "./systems-table-columns"
|
||||||
import { Dialog } from "../ui/dialog"
|
import AlertButton from "../alerts/alert-button"
|
||||||
|
import { SystemStatus } from "@/lib/enums"
|
||||||
|
import { useVirtualizer, VirtualItem } from "@tanstack/react-virtual"
|
||||||
|
|
||||||
type ViewMode = "table" | "grid"
|
type ViewMode = "table" | "grid"
|
||||||
|
type StatusFilter = "all" | SystemRecord["status"]
|
||||||
|
|
||||||
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
|
const preloadSystemDetail = runOnce(() => import("@/components/routes/system.tsx"))
|
||||||
const val = (info.getValue() as number) || 0
|
|
||||||
return (
|
|
||||||
<div className="flex gap-2 items-center tabular-nums tracking-tight">
|
|
||||||
<span className="min-w-8">{decimalString(val, 1)}%</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() {
|
export default function SystemsTable() {
|
||||||
const data = useStore($systems)
|
const data = useStore($systems)
|
||||||
|
const downSystems = $downSystems.get()
|
||||||
|
const upSystems = $upSystems.get()
|
||||||
|
const pausedSystems = $pausedSystems.get()
|
||||||
const { i18n, t } = useLingui()
|
const { i18n, t } = useLingui()
|
||||||
const [filter, setFilter] = useState<string>()
|
const [filter, setFilter] = useState<string>()
|
||||||
const [sorting, setSorting] = useState<SortingState>([{ id: "system", desc: false }])
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
||||||
|
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||||
|
"sortMode",
|
||||||
|
[{ id: "system", desc: false }],
|
||||||
|
sessionStorage
|
||||||
|
)
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||||
const [columnVisibility, setColumnVisibility] = useLocalStorage<VisibilityState>("cols", {})
|
const [columnVisibility, setColumnVisibility] = useBrowserStorage<VisibilityState>("cols", {})
|
||||||
const [viewMode, setViewMode] = useLocalStorage<ViewMode>("viewMode", window.innerWidth > 1024 ? "table" : "grid")
|
|
||||||
|
|
||||||
const locale = i18n.locale
|
const locale = i18n.locale
|
||||||
|
|
||||||
|
// Filter data based on status filter
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
if (statusFilter === "all") {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
if (statusFilter === SystemStatus.Up) {
|
||||||
|
return Object.values(upSystems) ?? []
|
||||||
|
}
|
||||||
|
if (statusFilter === SystemStatus.Down) {
|
||||||
|
return Object.values(downSystems) ?? []
|
||||||
|
}
|
||||||
|
return Object.values(pausedSystems) ?? []
|
||||||
|
}, [data, statusFilter])
|
||||||
|
|
||||||
|
const [viewMode, setViewMode] = useBrowserStorage<ViewMode>(
|
||||||
|
"viewMode",
|
||||||
|
// show grid view on mobile if there are less than 200 systems (looks better but table is more efficient)
|
||||||
|
window.innerWidth < 1024 && filteredData.length < 200 ? "grid" : "table"
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filter !== undefined) {
|
if (filter !== undefined) {
|
||||||
table.getColumn("system")?.setFilterValue(filter)
|
table.getColumn("system")?.setFilterValue(filter)
|
||||||
}
|
}
|
||||||
}, [filter])
|
}, [filter])
|
||||||
|
|
||||||
const columnDefs = useMemo(() => {
|
const columnDefs = useMemo(() => SystemsTableColumns(viewMode), [viewMode])
|
||||||
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 }) => decimalString(info.cpu, info.cpu >= 10 ? 1 : 2),
|
|
||||||
id: "cpu",
|
|
||||||
name: () => t`CPU`,
|
|
||||||
cell: CellFormatter,
|
|
||||||
Icon: CpuIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// accessorKey: "info.mp",
|
|
||||||
accessorFn: (originalRow) => originalRow.info.mp,
|
|
||||||
id: "memory",
|
|
||||||
name: () => t`Memory`,
|
|
||||||
cell: CellFormatter,
|
|
||||||
Icon: MemoryStickIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorFn: (originalRow) => originalRow.info.dp,
|
|
||||||
id: "disk",
|
|
||||||
name: () => t`Disk`,
|
|
||||||
cell: CellFormatter,
|
|
||||||
Icon: HardDriveIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorFn: (originalRow) => originalRow.info.g,
|
|
||||||
id: "gpu",
|
|
||||||
name: () => "GPU",
|
|
||||||
cell: CellFormatter,
|
|
||||||
Icon: GpuIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "loadAverage",
|
|
||||||
accessorFn: ({ info }) => {
|
|
||||||
const { l1 = 0, l5 = 0, l15 = 0 } = info
|
|
||||||
return l1 + l5 + l15
|
|
||||||
},
|
|
||||||
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
|
|
||||||
if (sysInfo.l1 === undefined) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const { l1 = 0, l5 = 0, l15 = 0, t: cpuThreads = 1 } = sysInfo
|
|
||||||
const loadAverages = [l1, l5, l15]
|
|
||||||
|
|
||||||
function getDotColor() {
|
|
||||||
const max = Math.max(...loadAverages)
|
|
||||||
const normalized = max / cpuThreads
|
|
||||||
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: (originalRow) => originalRow.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: (originalRow) => originalRow.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 table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data: filteredData,
|
||||||
columns: columnDefs,
|
columns: columnDefs,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
@@ -362,7 +115,6 @@ export default function SystemsTable() {
|
|||||||
columnVisibility,
|
columnVisibility,
|
||||||
},
|
},
|
||||||
defaultColumn: {
|
defaultColumn: {
|
||||||
// sortDescFirst: true,
|
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
sortUndefined: "last",
|
sortUndefined: "last",
|
||||||
minSize: 0,
|
minSize: 0,
|
||||||
@@ -374,19 +126,25 @@ export default function SystemsTable() {
|
|||||||
const rows = table.getRowModel().rows
|
const rows = table.getRowModel().rows
|
||||||
const columns = table.getAllColumns()
|
const columns = table.getAllColumns()
|
||||||
const visibleColumns = table.getVisibleLeafColumns()
|
const visibleColumns = table.getVisibleLeafColumns()
|
||||||
|
|
||||||
|
const [upSystemsLength, downSystemsLength, pausedSystemsLength] = useMemo(() => {
|
||||||
|
return [Object.values(upSystems).length, Object.values(downSystems).length, Object.values(pausedSystems).length]
|
||||||
|
}, [upSystems, downSystems, pausedSystems])
|
||||||
|
|
||||||
// TODO: hiding temp then gpu messes up table headers
|
// TODO: hiding temp then gpu messes up table headers
|
||||||
const CardHead = useMemo(() => {
|
const CardHead = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
<CardHeader className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
<CardHeader className="pb-4.5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
||||||
<div className="grid md:flex gap-5 w-full items-end">
|
<div className="grid md:flex gap-5 w-full items-end">
|
||||||
<div className="px-2 sm:px-1">
|
<div className="px-2 sm:px-1">
|
||||||
<CardTitle className="mb-2.5">
|
<CardTitle className="mb-2">
|
||||||
<Trans>All Systems</Trans>
|
<Trans>All Systems</Trans>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription className="flex">
|
||||||
<Trans>Updated in real time. Click on a system to view information.</Trans>
|
<Trans>Click on a system to view more information.</Trans>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 ms-auto w-full md:w-80">
|
<div className="flex gap-2 ms-auto w-full md:w-80">
|
||||||
<Input placeholder={t`Filter...`} onChange={(e) => setFilter(e.target.value)} className="px-4" />
|
<Input placeholder={t`Filter...`} onChange={(e) => setFilter(e.target.value)} className="px-4" />
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -397,8 +155,8 @@ export default function SystemsTable() {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="h-72 md:h-auto min-w-48 md:min-w-auto overflow-y-auto">
|
<DropdownMenuContent align="end" className="h-72 md:h-auto min-w-48 md:min-w-auto overflow-y-auto">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 divide-y md:divide-s md:divide-y-0">
|
<div className="grid grid-cols-1 md:grid-cols-4 divide-y md:divide-s md:divide-y-0">
|
||||||
<div>
|
<div className="border-r">
|
||||||
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
|
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
|
||||||
<LayoutGridIcon className="size-4" />
|
<LayoutGridIcon className="size-4" />
|
||||||
<Trans>Layout</Trans>
|
<Trans>Layout</Trans>
|
||||||
@@ -420,7 +178,33 @@ export default function SystemsTable() {
|
|||||||
</DropdownMenuRadioGroup>
|
</DropdownMenuRadioGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="border-r">
|
||||||
|
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
|
||||||
|
<FilterIcon className="size-4" />
|
||||||
|
<Trans>Status</Trans>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
className="px-1 pb-1"
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(value) => setStatusFilter(value as StatusFilter)}
|
||||||
|
>
|
||||||
|
<DropdownMenuRadioItem value="all" onSelect={(e) => e.preventDefault()}>
|
||||||
|
<Trans>All Systems</Trans>
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="up" onSelect={(e) => e.preventDefault()}>
|
||||||
|
<Trans>Up ({upSystemsLength})</Trans>
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="down" onSelect={(e) => e.preventDefault()}>
|
||||||
|
<Trans>Down ({downSystemsLength})</Trans>
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="paused" onSelect={(e) => e.preventDefault()}>
|
||||||
|
<Trans>Paused ({pausedSystemsLength})</Trans>
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-r">
|
||||||
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
|
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
|
||||||
<ArrowUpDownIcon className="size-4" />
|
<ArrowUpDownIcon className="size-4" />
|
||||||
<Trans>Sort By</Trans>
|
<Trans>Sort By</Trans>
|
||||||
@@ -486,7 +270,16 @@ export default function SystemsTable() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
)
|
)
|
||||||
}, [visibleColumns.length, sorting, viewMode, locale])
|
}, [
|
||||||
|
visibleColumns.length,
|
||||||
|
sorting,
|
||||||
|
viewMode,
|
||||||
|
locale,
|
||||||
|
statusFilter,
|
||||||
|
upSystemsLength,
|
||||||
|
downSystemsLength,
|
||||||
|
pausedSystemsLength,
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -494,7 +287,7 @@ export default function SystemsTable() {
|
|||||||
<div className="p-6 pt-0 max-sm:py-3 max-sm:px-2">
|
<div className="p-6 pt-0 max-sm:py-3 max-sm:px-2">
|
||||||
{viewMode === "table" ? (
|
{viewMode === "table" ? (
|
||||||
// table layout
|
// table layout
|
||||||
<div className="rounded-md border overflow-hidden">
|
<div className="rounded-md">
|
||||||
<AllSystemsTable table={table} rows={rows} colLength={visibleColumns.length} />
|
<AllSystemsTable table={table} rows={rows} colLength={visibleColumns.length} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -516,36 +309,78 @@ export default function SystemsTable() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const AllSystemsTable = memo(
|
const AllSystemsTable = memo(function ({
|
||||||
({ table, rows, colLength }: { table: TableType<SystemRecord>; rows: Row<SystemRecord>[]; colLength: number }) => {
|
table,
|
||||||
return (
|
rows,
|
||||||
<Table>
|
colLength,
|
||||||
<SystemsTableHead table={table} colLength={colLength} />
|
}: {
|
||||||
<TableBody>
|
table: TableType<SystemRecord>
|
||||||
{rows.length ? (
|
rows: Row<SystemRecord>[]
|
||||||
rows.map((row) => (
|
colLength: number
|
||||||
<SystemTableRow key={row.original.id} row={row} length={rows.length} colLength={colLength} />
|
}) {
|
||||||
))
|
// The virtualizer will need a reference to the scrollable container element
|
||||||
) : (
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={colLength} className="h-24 text-center">
|
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
||||||
<Trans>No systems found.</Trans>
|
count: rows.length,
|
||||||
</TableCell>
|
estimateSize: () => (rows.length > 10 ? 56 : 60),
|
||||||
</TableRow>
|
getScrollElement: () => scrollRef.current,
|
||||||
)}
|
overscan: 5,
|
||||||
</TableBody>
|
})
|
||||||
</Table>
|
const virtualRows = virtualizer.getVirtualItems()
|
||||||
)
|
|
||||||
}
|
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
|
||||||
)
|
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
|
||||||
|
// don't set min height if there are less than 2 rows, do set if we need to display the empty state
|
||||||
|
(!rows.length || rows.length > 2) && "min-h-50"
|
||||||
|
)}
|
||||||
|
ref={scrollRef}
|
||||||
|
>
|
||||||
|
{/* add header height to table size */}
|
||||||
|
<div style={{ height: `${virtualizer.getTotalSize() + 50}px`, paddingTop, paddingBottom }}>
|
||||||
|
<table className="text-sm w-full h-full">
|
||||||
|
<SystemsTableHead table={table} colLength={colLength} />
|
||||||
|
<TableBody onMouseEnter={preloadSystemDetail}>
|
||||||
|
{rows.length ? (
|
||||||
|
virtualRows.map((virtualRow) => {
|
||||||
|
const row = rows[virtualRow.index] as Row<SystemRecord>
|
||||||
|
return (
|
||||||
|
<SystemTableRow
|
||||||
|
key={row.id}
|
||||||
|
row={row}
|
||||||
|
virtualRow={virtualRow}
|
||||||
|
length={rows.length}
|
||||||
|
colLength={colLength}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
||||||
|
<Trans>No systems found.</Trans>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
function SystemsTableHead({ table, colLength }: { table: TableType<SystemRecord>; colLength: number }) {
|
function SystemsTableHead({ table, colLength }: { table: TableType<SystemRecord>; colLength: number }) {
|
||||||
const { i18n } = useLingui()
|
const { i18n } = useLingui()
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
return (
|
return (
|
||||||
<TableHeader>
|
<TableHeader className="sticky top-0 z-20 w-full border-b-2">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => {
|
{headerGroup.headers.map((header) => {
|
||||||
return (
|
return (
|
||||||
<TableHead className="px-1.5" key={header.id}>
|
<TableHead className="px-1.5" key={header.id}>
|
||||||
@@ -553,47 +388,49 @@ function SystemsTableHead({ table, colLength }: { table: TableType<SystemRecord>
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</TableRow>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
)
|
)
|
||||||
}, [i18n.locale, colLength])
|
}, [i18n.locale, colLength])
|
||||||
}
|
}
|
||||||
|
|
||||||
const SystemTableRow = memo(
|
const SystemTableRow = memo(function ({
|
||||||
({ row, length, colLength }: { row: Row<SystemRecord>; length: number; colLength: number }) => {
|
row,
|
||||||
const system = row.original
|
virtualRow,
|
||||||
const { t } = useLingui()
|
colLength,
|
||||||
return useMemo(() => {
|
}: {
|
||||||
return (
|
row: Row<SystemRecord>
|
||||||
<TableRow
|
virtualRow: VirtualItem
|
||||||
// data-state={row.getIsSelected() && "selected"}
|
length: number
|
||||||
className={cn("cursor-pointer transition-opacity", {
|
colLength: number
|
||||||
"opacity-50": system.status === "paused",
|
}) {
|
||||||
})}
|
const system = row.original
|
||||||
onClick={(e) => {
|
const { t } = useLingui()
|
||||||
const target = e.target as HTMLElement
|
return useMemo(() => {
|
||||||
if (!target.closest("[data-nolink]") && e.currentTarget.contains(target)) {
|
return (
|
||||||
navigate(getPagePath($router, "system", { name: system.name }))
|
<TableRow
|
||||||
}
|
// data-state={row.getIsSelected() && "selected"}
|
||||||
}}
|
className={cn("cursor-pointer transition-opacity relative safari:transform-3d", {
|
||||||
>
|
"opacity-50": system.status === SystemStatus.Paused,
|
||||||
{row.getVisibleCells().map((cell) => (
|
})}
|
||||||
<TableCell
|
>
|
||||||
key={cell.id}
|
{row.getVisibleCells().map((cell) => (
|
||||||
style={{
|
<TableCell
|
||||||
width: cell.column.getSize(),
|
key={cell.id}
|
||||||
}}
|
style={{
|
||||||
className={cn("overflow-hidden relative", length > 10 ? "py-2" : "py-2.5")}
|
width: cell.column.getSize(),
|
||||||
>
|
height: virtualRow.size,
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
}}
|
||||||
</TableCell>
|
className="py-0"
|
||||||
))}
|
>
|
||||||
</TableRow>
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
)
|
</TableCell>
|
||||||
}, [system, system.status, colLength, t])
|
))}
|
||||||
}
|
</TableRow>
|
||||||
)
|
)
|
||||||
|
}, [system, system.status, colLength, t])
|
||||||
|
})
|
||||||
|
|
||||||
const SystemCard = memo(
|
const SystemCard = memo(
|
||||||
({ row, table, colLength }: { row: Row<SystemRecord>; table: TableType<SystemRecord>; colLength: number }) => {
|
({ row, table, colLength }: { row: Row<SystemRecord>; table: TableType<SystemRecord>; colLength: number }) => {
|
||||||
@@ -603,49 +440,58 @@ const SystemCard = memo(
|
|||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
onMouseEnter={preloadSystemDetail}
|
||||||
key={system.id}
|
key={system.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer hover:shadow-md transition-all bg-transparent w-full dark:border-border duration-200 relative",
|
"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,
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardHeader className="py-1 ps-5 pe-3 bg-muted/30 border-b border-border/60">
|
<CardHeader className="py-1 ps-5 pe-3 bg-muted/30 border-b border-border/60">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center gap-2 w-full overflow-hidden">
|
||||||
<CardTitle className="text-base tracking-normal shrink-1 text-primary/90 flex items-center min-h-10 gap-2.5 min-w-0">
|
<CardTitle className="text-base tracking-normal text-primary/90 flex items-center min-w-0 flex-1 gap-2.5">
|
||||||
<div className="flex items-center gap-2.5 min-w-0">
|
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||||
<IndicatorDot system={system} />
|
<IndicatorDot system={system} />
|
||||||
<CardTitle className="text-[.95em]/normal tracking-normal truncate text-primary/90">
|
<span className="text-[.95em]/normal tracking-normal text-primary/90 truncate">{system.name}</span>
|
||||||
{system.name}
|
|
||||||
</CardTitle>
|
|
||||||
</div>
|
</div>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{table.getColumn("actions")?.getIsVisible() && (
|
{table.getColumn("actions")?.getIsVisible() && (
|
||||||
<div className="flex gap-1 flex-shrink-0 relative z-10">
|
<div className="flex gap-1 shrink-0 relative z-10">
|
||||||
<AlertsButton system={system} />
|
<AlertButton system={system} />
|
||||||
<ActionsButton system={system} />
|
<ActionsButton system={system} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2.5 text-sm px-5 pt-3.5 pb-4">
|
<CardContent className="text-sm px-5 pt-3.5 pb-4">
|
||||||
{table.getAllColumns().map((column) => {
|
<div className="grid gap-2.5" style={{ gridTemplateColumns: "24px minmax(80px, max-content) 1fr" }}>
|
||||||
if (!column.getIsVisible() || column.id === "system" || column.id === "actions") return null
|
{table.getAllColumns().map((column) => {
|
||||||
const cell = row.getAllCells().find((cell) => cell.column.id === column.id)
|
if (!column.getIsVisible() || column.id === "system" || column.id === "actions") return null
|
||||||
if (!cell) return null
|
const cell = row.getAllCells().find((cell) => cell.column.id === column.id)
|
||||||
// @ts-ignore
|
if (!cell) return null
|
||||||
const { Icon, name } = column.columnDef as ColumnDef<SystemRecord, unknown>
|
// @ts-ignore
|
||||||
return (
|
const { Icon, name } = column.columnDef as ColumnDef<SystemRecord, unknown>
|
||||||
<div key={column.id} className="flex items-center gap-3">
|
return (
|
||||||
{Icon && <Icon className="size-4 text-muted-foreground" />}
|
<>
|
||||||
<div className="flex items-center gap-3 flex-1">
|
<div key={`${column.id}-icon`} className="flex items-center">
|
||||||
<span className="text-muted-foreground min-w-16">{name()}:</span>
|
{column.id === "lastSeen" ? (
|
||||||
<div className="flex-1">{flexRender(cell.column.columnDef.cell, cell.getContext())}</div>
|
<EyeIcon className="size-4 text-muted-foreground" />
|
||||||
</div>
|
) : (
|
||||||
</div>
|
Icon && <Icon className="size-4 text-muted-foreground" />
|
||||||
)
|
)}
|
||||||
})}
|
</div>
|
||||||
|
<div key={`${column.id}-label`} className="flex items-center text-muted-foreground pr-3">
|
||||||
|
{name()}:
|
||||||
|
</div>
|
||||||
|
<div key={`${column.id}-value`} className="flex items-center">
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<Link
|
<Link
|
||||||
href={getPagePath($router, "system", { name: row.original.name })}
|
href={getPagePath($router, "system", { name: row.original.name })}
|
||||||
@@ -658,116 +504,3 @@ const SystemCard = memo(
|
|||||||
}, [system, colLength, t])
|
}, [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
|
<AlertDialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -44,7 +44,7 @@ const AlertDialogContent = React.forwardRef<
|
|||||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
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"
|
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const badgeVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -13,7 +13,7 @@ const buttonVariants = cva(
|
|||||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
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",
|
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",
|
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",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@@ -5,16 +5,14 @@ import { cn } from "@/lib/utils"
|
|||||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
Card.displayName = "Card"
|
Card.displayName = "Card"
|
||||||
|
|
||||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => <div ref={ref} className={cn("grid gap-1.5 p-6", className)} {...props} />
|
||||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
CardHeader.displayName = "CardHeader"
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import * as RechartsPrimitive from "recharts"
|
|||||||
import { chartTimeData, cn } from "@/lib/utils"
|
import { chartTimeData, cn } from "@/lib/utils"
|
||||||
import { ChartData } from "@/types"
|
import { ChartData } from "@/types"
|
||||||
|
|
||||||
|
import type { JSX } from "react"
|
||||||
|
|
||||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
const THEMES = { light: "", dark: ".dark" } as const
|
const THEMES = { light: "", dark: ".dark" } as const
|
||||||
|
|
||||||
@@ -42,11 +44,12 @@ const ChartContainer = React.forwardRef<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
//<ChartContext.Provider value={{ config }}>
|
//<ChartContext.Provider value={{ config }}>
|
||||||
|
//</ChartContext.Provider>
|
||||||
<div
|
<div
|
||||||
data-chart={chartId}
|
data-chart={chartId}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -54,7 +57,6 @@ const ChartContainer = React.forwardRef<
|
|||||||
{/* <ChartStyle id={chartId} config={config} /> */}
|
{/* <ChartStyle id={chartId} config={config} /> */}
|
||||||
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
//</ChartContext.Provider>
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
ChartContainer.displayName = "Chart"
|
ChartContainer.displayName = "Chart"
|
||||||
@@ -169,7 +171,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -196,7 +198,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
<itemConfig.icon />
|
<itemConfig.icon />
|
||||||
) : (
|
) : (
|
||||||
<div
|
<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",
|
"h-2.5 w-2.5": indicator === "dot",
|
||||||
"w-1": indicator === "line",
|
"w-1": indicator === "line",
|
||||||
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
|
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
|
||||||
@@ -226,7 +228,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
{itemConfig?.label || item.name}
|
{itemConfig?.label || item.name}
|
||||||
</span>
|
</span>
|
||||||
{item.value !== undefined && (
|
{item.value !== undefined && (
|
||||||
<span className="font-medium tabular-nums text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
{content && typeof content === "function"
|
{content && typeof content === "function"
|
||||||
? content(item, key)
|
? content(item, key)
|
||||||
: item.value.toLocaleString() + (unit ? unit : "")}
|
: item.value.toLocaleString() + (unit ? unit : "")}
|
||||||
@@ -265,7 +267,7 @@ const ChartLegendContent = React.forwardRef<
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const Checkbox = React.forwardRef<
|
|||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,33 +1,41 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { DialogTitle, type DialogProps } from "@radix-ui/react-dialog"
|
|
||||||
import { Command as CommandPrimitive } from "cmdk"
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
import { Search } from "lucide-react"
|
import { SearchIcon } from "lucide-react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
|
||||||
const Command = React.forwardRef<
|
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
React.ElementRef<typeof CommandPrimitive>,
|
return (
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
<CommandPrimitive
|
||||||
>(({ className, ...props }, ref) => (
|
data-slot="command"
|
||||||
<CommandPrimitive
|
className={cn("bg-card flex h-full w-full flex-col overflow-hidden rounded-md", className)}
|
||||||
ref={ref}
|
{...props}
|
||||||
className={cn("flex h-full w-full flex-col overflow-hidden bg-popover text-popover-foreground", className)}
|
/>
|
||||||
{...props}
|
)
|
||||||
/>
|
}
|
||||||
))
|
|
||||||
Command.displayName = CommandPrimitive.displayName
|
|
||||||
|
|
||||||
interface CommandDialogProps extends DialogProps {}
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
className?: string
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Dialog {...props}>
|
<Dialog {...props}>
|
||||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
<DialogHeader className="sr-only">
|
||||||
<div className="sr-only">
|
<DialogTitle>{title}</DialogTitle>
|
||||||
<DialogTitle>Command</DialogTitle>
|
<DialogDescription>{description}</DialogDescription>
|
||||||
</div>
|
</DialogHeader>
|
||||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
<DialogContent className={cn("overflow-hidden p-0", className)}>
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
{children}
|
{children}
|
||||||
</Command>
|
</Command>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -35,89 +43,81 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const CommandInput = React.forwardRef<
|
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
return (
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
<div data-slot="command-input-wrapper" className="flex h-9 items-center gap-2 border-b px-3">
|
||||||
>(({ className, ...props }, ref) => (
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
<CommandPrimitive.Input
|
||||||
<Search className="me-2 h-4 w-4 shrink-0 opacity-50" />
|
data-slot="command-input"
|
||||||
<CommandPrimitive.Input
|
className={cn(
|
||||||
ref={ref}
|
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn("max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return <CommandPrimitive.Empty data-slot="command-empty" className="py-6 text-center text-sm" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
className={cn(
|
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",
|
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</div>
|
)
|
||||||
))
|
}
|
||||||
|
|
||||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
const CommandList = React.forwardRef<
|
<CommandPrimitive.Separator
|
||||||
React.ElementRef<typeof CommandPrimitive.List>,
|
data-slot="command-separator"
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
className={cn("bg-border -mx-1 h-px", className)}
|
||||||
>(({ className, ...props }, ref) => (
|
{...props}
|
||||||
<CommandPrimitive.List
|
/>
|
||||||
ref={ref}
|
)
|
||||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
}
|
||||||
{...props}
|
|
||||||
/>
|
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
))
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
CommandList.displayName = CommandPrimitive.List.displayName
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
const CommandEmpty = React.forwardRef<
|
"data-[selected=true]:bg-accent/70 data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
className
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
)}
|
||||||
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />)
|
{...props}
|
||||||
|
/>
|
||||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
)
|
||||||
|
}
|
||||||
const CommandGroup = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
return (
|
||||||
>(({ className, ...props }, ref) => (
|
<span
|
||||||
<CommandPrimitive.Group
|
data-slot="command-shortcut"
|
||||||
ref={ref}
|
className={cn("text-muted-foreground ml-auto text-xs tracking-wide", className)}
|
||||||
className={cn(
|
{...props}
|
||||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
/>
|
||||||
className
|
)
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
|
|
||||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
|
||||||
|
|
||||||
const CommandSeparator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
|
|
||||||
))
|
|
||||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
|
||||||
|
|
||||||
const CommandItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<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",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
|
|
||||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
|
||||||
|
|
||||||
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
|
||||||
return <span className={cn("ms-auto text-xs tracking-wide text-muted-foreground", className)} {...props} />
|
|
||||||
}
|
}
|
||||||
CommandShortcut.displayName = "CommandShortcut"
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Command,
|
Command,
|
||||||
|
|||||||
@@ -36,13 +36,13 @@ const DialogContent = React.forwardRef<
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{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" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
@@ -52,7 +52,7 @@ const DialogContent = React.forwardRef<
|
|||||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
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"
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
inset && "ps-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@@ -44,7 +44,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -61,7 +61,7 @@ const DropdownMenuContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -79,7 +79,7 @@ const DropdownMenuItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"cursor-pointer 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",
|
inset && "ps-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@@ -95,7 +95,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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-pointer select-none items-center rounded-sm py-1.5 ps-8 pe-2.5 text-sm outline-hidden focus:bg-accent/70 focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
@@ -118,7 +118,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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-pointer select-none items-center rounded-sm py-1.5 ps-8 pe-2.5 text-sm outline-hidden focus:bg-accent/70 focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...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>
|
<Input readOnly id={id} name={name} value={value} required></Input>
|
||||||
<div
|
<div
|
||||||
className={
|
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>
|
></div>
|
||||||
<TooltipProvider delayDuration={100} disableHoverableContent>
|
<TooltipProvider delayDuration={100} disableHoverableContent>
|
||||||
<Tooltip>
|
<Tooltip disableHoverableContent={true}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const InputTags = React.forwardRef<HTMLInputElement, InputTagsProps>(
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -53,7 +53,7 @@ const InputTags = React.forwardRef<HTMLInputElement, InputTagsProps>(
|
|||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
<input
|
<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}
|
value={pendingDataPoint}
|
||||||
onChange={(e) => setPendingDataPoint(e.target.value)}
|
onChange={(e) => setPendingDataPoint(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
|||||||
@@ -2,21 +2,19 @@ import * as React from "react"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
|
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
className={cn(
|
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",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
Input.displayName = "Input"
|
|
||||||
|
|
||||||
export { Input }
|
export { Input }
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -66,7 +66,7 @@ const SelectContent = React.forwardRef<
|
|||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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" &&
|
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",
|
"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
|
className
|
||||||
@@ -79,7 +79,7 @@ const SelectContent = React.forwardRef<
|
|||||||
className={cn(
|
className={cn(
|
||||||
"p-1",
|
"p-1",
|
||||||
position === "popper" &&
|
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}
|
{children}
|
||||||
@@ -105,7 +105,7 @@ const SelectItem = React.forwardRef<
|
|||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user