Compare commits

..

1 Commits

Author SHA1 Message Date
henrygd
47360c5bf1 update to json/v2 - pocketbase collection errors :( 2025-08-20 20:13:01 -04:00
142 changed files with 5129 additions and 7687 deletions

View File

@@ -93,9 +93,7 @@ jobs:
# https://github.com/docker/login-action # https://github.com/docker/login-action
- name: Login to Docker Hub - name: Login to Docker Hub
env: if: github.event_name != 'pull_request'
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] }}
@@ -110,6 +108,6 @@ jobs:
context: "${{ matrix.context }}" context: "${{ matrix.context }}"
file: ${{ matrix.dockerfile }} file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v7' }} platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v7' }}
push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }} push: ${{ github.ref_type == 'tag' }}
tags: ${{ steps.metadata.outputs.tags }} tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }} labels: ${{ steps.metadata.outputs.labels }}

View File

@@ -51,4 +51,3 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.TOKEN || secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.TOKEN || secrets.GITHUB_TOKEN }}
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
IS_FORK: ${{ github.repository_owner != 'henrygd' }}

View File

@@ -38,25 +38,12 @@ 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
@@ -67,7 +54,7 @@ builds:
archives: archives:
- id: beszel-agent - id: beszel-agent
formats: [tar.gz] formats: [tar.gz]
ids: builds:
- beszel-agent - beszel-agent
name_template: >- name_template: >-
{{ .Binary }}_ {{ .Binary }}_
@@ -79,7 +66,7 @@ archives:
- id: beszel - id: beszel
formats: [tar.gz] formats: [tar.gz]
ids: builds:
- beszel - beszel
name_template: >- name_template: >-
{{ .Binary }}_ {{ .Binary }}_
@@ -98,7 +85,7 @@ nfpms:
API access. API access.
maintainer: henrygd <hank@henrygd.me> maintainer: henrygd <hank@henrygd.me>
section: net section: net
ids: builds:
- beszel-agent - beszel-agent
formats: formats:
- deb - deb
@@ -135,7 +122,6 @@ 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:
@@ -169,7 +155,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: "{{ if .Env.IS_FORK }}true{{ else }}auto{{ end }}" skip_upload: auto
extra_install: | extra_install: |
(bin/"beszel-agent-launcher").write <<~EOS (bin/"beszel-agent-launcher").write <<~EOS
#!/bin/bash #!/bin/bash
@@ -201,7 +187,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: "{{ if .Env.IS_FORK }}true{{ else }}auto{{ end }}" skip_upload: auto
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

View File

@@ -7,7 +7,7 @@ SKIP_WEB ?= false
# Set executable extension based on target OS # Set executable extension based on target OS
EXE_EXT := $(if $(filter windows,$(OS)),.exe,) 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 .PHONY: tidy build-agent build-hub build clean lint dev-server dev-agent dev-hub dev generate-locales
.DEFAULT_GOAL := build .DEFAULT_GOAL := build
clean: clean:
@@ -17,7 +17,7 @@ clean:
lint: lint:
golangci-lint run golangci-lint run
test: export GOEXPERIMENT=synctest test: export GOEXPERIMENT=synctest,jsonv2
test: test:
go test -tags=testing ./... go test -tags=testing ./...
@@ -53,10 +53,6 @@ build-agent: tidy build-dotnet-conditional
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)$(EXE_EXT) -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
generate-locales: generate-locales:
@@ -74,14 +70,16 @@ dev-server: generate-locales
fi fi
dev-hub: export ENV=dev dev-hub: export ENV=dev
dev-hub: export GOEXPERIMENT=jsonv2
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 -tags development . 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 . serve --http 0.0.0.0:8090"; \
else \ else \
cd ./cmd/hub && go run -tags development . serve --http 0.0.0.0:8090; \ cd ./cmd/hub && go run . serve --http 0.0.0.0:8090; \
fi fi
dev-agent: export GOEXPERIMENT=jsonv2
dev-agent: dev-agent:
@if command -v entr >/dev/null 2>&1; then \ @if command -v entr >/dev/null 2>&1; then \
find ./cmd/agent/*.go ./internal/agent/*.go | entr -r go run beszel/cmd/agent; \ find ./cmd/agent/*.go ./internal/agent/*.go | entr -r go run beszel/cmd/agent; \

View File

@@ -4,12 +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" "strings"
"github.com/spf13/pflag"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@@ -17,24 +17,43 @@ 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() {
builder := strings.Builder{}
builder.WriteString("Usage: ")
builder.WriteString(os.Args[0])
builder.WriteString(" [command] [flags]\n")
builder.WriteString("\nCommands:\n")
builder.WriteString(" health Check if the agent is running\n")
builder.WriteString(" help Display this help message\n")
builder.WriteString(" update Update to the latest version\n")
builder.WriteString("\nFlags:\n")
fmt.Print(builder.String())
flag.PrintDefaults()
}
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 {
@@ -44,57 +63,7 @@ func (opts *cmdOptions) parse() bool {
return true return true
} }
// pflag.CommandLine.ParseErrorsWhitelist.UnknownFlags = true flag.Parse()
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
} }

View File

@@ -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
pflag.CommandLine = pflag.NewFlagSet(os.Args[0], pflag.ExitOnError) flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
}() }()
tests := []struct { tests := []struct {
@@ -269,22 +269,6 @@ 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"},
@@ -293,22 +277,6 @@ 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"},
@@ -322,12 +290,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
pflag.CommandLine = pflag.NewFlagSet(tt.args[0], pflag.ExitOnError) flag.CommandLine = flag.NewFlagSet(tt.args[0], flag.ExitOnError)
os.Args = tt.args os.Args = tt.args
var opts cmdOptions var opts cmdOptions
opts.parse() opts.parse()
pflag.Parse() flag.Parse()
assert.Equal(t, tt.expected, opts) assert.Equal(t, tt.expected, opts)
}) })

View File

@@ -45,13 +45,11 @@ 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
updateCmd := &cobra.Command{ baseApp.RootCmd.AddCommand(&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())

View File

@@ -10,7 +10,7 @@ COPY internal ./internal
# Build # Build
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 GOEXPERIMENT=jsonv2 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent
RUN rm -rf /tmp/* RUN rm -rf /tmp/*

View File

@@ -10,12 +10,12 @@ COPY internal ./internal
# Build # Build
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 GOEXPERIMENT=jsonv2 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent
# -------------------------- # --------------------------
# Final image: GPU-enabled agent with nvidia-smi # Final image: GPU-enabled agent with nvidia-smi
# -------------------------- # --------------------------
FROM nvidia/cuda:12.2.2-base-ubuntu22.04 FROM nvidia/cuda:12.9.1-base-ubuntu22.04
COPY --from=builder /agent /agent COPY --from=builder /agent /agent
ENTRYPOINT ["/agent"] ENTRYPOINT ["/agent"]

View File

@@ -22,7 +22,7 @@ RUN update-ca-certificates
# Build # Build
ARG TARGETOS TARGETARCH ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /beszel ./cmd/hub RUN CGO_ENABLED=0 GOEXPERIMENT=jsonv2 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /beszel ./cmd/hub
# ? ------------------------- # ? -------------------------
FROM scratch FROM scratch

View File

@@ -7,19 +7,18 @@ 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.17 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.3 github.com/pocketbase/pocketbase v0.29.2
github.com/shirou/gopsutil/v4 v4.25.6 github.com/rhysd/go-github-selfupdate v1.2.3
github.com/shirou/gopsutil/v4 v4.25.7
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/spf13/pflag v1.0.7 github.com/stretchr/testify v1.10.0
github.com/stretchr/testify v1.11.0
golang.org/x/crypto v0.41.0 golang.org/x/crypto v0.41.0
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
@@ -41,17 +40,23 @@ require (
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.3.0 // 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-20250821153705-5981dea3221d // indirect github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // 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.13 // 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.30.0 // indirect golang.org/x/image v0.30.0 // indirect
@@ -60,8 +65,6 @@ require (
golang.org/x/sync v0.16.0 // indirect golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect golang.org/x/text v0.28.0 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
howett.net/plist v1.0.1 // indirect
modernc.org/libc v1.66.3 // indirect modernc.org/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

View File

@@ -13,8 +13,6 @@ 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=
@@ -27,6 +25,7 @@ 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=
@@ -49,26 +48,39 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v
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.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-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-20250821153705-5981dea3221d h1:vFzYZc8yji+9DmNRhpEbs8VBK4CgV/DPfGzeVJSSp/8= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM= github.com/lxzan/gws v1.8.9 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=
@@ -79,8 +91,11 @@ 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=
@@ -88,17 +103,19 @@ 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.3 h1:Mj8o5awsbVJIdIoTuQNhfC2oL/c4aImQ3RyfFZlzFVg= github.com/pocketbase/pocketbase v0.29.2 h1:MghVgLYy/xh9lBwHtteNSYjYOvHKYD+dS9pzUzOP79Q=
github.com/pocketbase/pocketbase v0.29.3/go.mod h1:oGpT67LObxCFK4V2fSL7J9YnPbBnnshOpJ5v3zcneww= github.com/pocketbase/pocketbase v0.29.2/go.mod h1:QZPKtMCWfiDJb0aLhwgj7ZOr6O8tusbui2EhTFAHThU=
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=
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= github.com/shirou/gopsutil/v4 v4.25.7 h1:bNb2JuqKuAu3tRlPv5piSmBZyMfecwQ+t/ILq+1JqVM=
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= github.com/shirou/gopsutil/v4 v4.25.7/go.mod h1:XV/egmwJtd3ZQjBpJVY5kndsiOO4IRqy9TQnmm6VP7U=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
@@ -108,12 +125,17 @@ 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.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw=
github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE=
github.com/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.13 h1:ar98gWrjf4H1ev05fYP/o29PDZw9DrI3niHtnEqyuXA=
github.com/ulikunitz/xz v0.5.13/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=
@@ -121,6 +143,7 @@ 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.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
@@ -130,53 +153,71 @@ golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c= golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= 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.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= 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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.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.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.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.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= 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/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.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.3 h1:yEN8dzrkRFnn4PUUKXLYIqVf2PJYAEjMTFjO3BDGc3I=
modernc.org/cc/v4 v4.26.3/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=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/fileutil v1.3.15 h1:rJAXTP6ilMW/1+kzDiqmBlHLWszheUFXIyGQIAvjJpY=
modernc.org/fileutil v1.3.15/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= 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.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/libc v1.66.7 h1:rjhZ8OSCybKWxS1CJr0hikpEi6Vg+944Ouyrd+bQsoY=
modernc.org/libc v1.66.7/go.mod h1:ln6tbWX0NH+mzApEoDRvilBvAWFt1HX7AUA4VDdVDPM=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=

View File

@@ -14,7 +14,7 @@ import (
) )
func TestSessionCache_GetSet(t *testing.T) { func TestSessionCache_GetSet(t *testing.T) {
synctest.Test(t, func(t *testing.T) { synctest.Run(func() {
cache := NewSessionCache(69 * time.Second) cache := NewSessionCache(69 * time.Second)
testData := &system.CombinedData{ testData := &system.CombinedData{

View File

@@ -1,53 +0,0 @@
//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
}

View File

@@ -1,13 +0,0 @@
//go:build freebsd
package battery
import "errors"
func HasReadableBattery() bool {
return false
}
func GetBatteryStats() (uint8, uint8, error) {
return 0, 0, errors.ErrUnsupported
}

View File

@@ -4,7 +4,7 @@ import (
"beszel/internal/entities/container" "beszel/internal/entities/container"
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json/v2"
"fmt" "fmt"
"log/slog" "log/slog"
"net" "net"
@@ -29,7 +29,6 @@ type dockerManager struct {
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly) goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
isWindows bool // Whether the Docker Engine API is running on Windows isWindows bool // Whether the Docker Engine API is running on Windows
buf *bytes.Buffer // Buffer to store and read response bodies buf *bytes.Buffer // Buffer to store and read response bodies
decoder *json.Decoder // Reusable JSON decoder that reads from buf
apiStats *container.ApiStats // Reusable API stats object apiStats *container.ApiStats // Reusable API stats object
} }
@@ -343,17 +342,16 @@ func newDockerManager(a *Agent) *dockerManager {
// Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe. // Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe.
func (dm *dockerManager) decode(resp *http.Response, d any) error { func (dm *dockerManager) decode(resp *http.Response, d any) error {
if dm.buf == nil { if dm.buf == nil {
// initialize buffer with 256kb starting size // initialize buffer with 128kb starting size
dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*256)) dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*128))
dm.decoder = json.NewDecoder(dm.buf)
} }
defer resp.Body.Close() defer resp.Body.Close()
defer dm.buf.Reset() dm.buf.Reset()
_, err := dm.buf.ReadFrom(resp.Body) _, err := dm.buf.ReadFrom(resp.Body)
if err != nil { if err != nil {
return err return err
} }
return dm.decoder.Decode(d) return json.Unmarshal(dm.buf.Bytes(), d)
} }
// Test docker / podman sockets and return if one exists // Test docker / podman sockets and return if one exists

View File

@@ -4,7 +4,7 @@ import (
"beszel/internal/entities/system" "beszel/internal/entities/system"
"bufio" "bufio"
"bytes" "bytes"
"encoding/json" "encoding/json/v2"
"fmt" "fmt"
"os/exec" "os/exec"
"regexp" "regexp"
@@ -50,7 +50,7 @@ type GPUManager struct {
// RocmSmiJson represents the JSON structure of rocm-smi output // RocmSmiJson represents the JSON structure of rocm-smi output
type RocmSmiJson struct { type RocmSmiJson struct {
ID string `json:"GUID"` ID string `json:"GUID"`
Name string `json:"Card series"` Name string `json:"Card Series"`
Temperature string `json:"Temperature (Sensor edge) (C)"` Temperature string `json:"Temperature (Sensor edge) (C)"`
MemoryUsed string `json:"VRAM Total Used Memory (B)"` MemoryUsed string `json:"VRAM Total Used Memory (B)"`
MemoryTotal string `json:"VRAM Total Memory (B)"` MemoryTotal string `json:"VRAM Total Memory (B)"`

View File

@@ -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.Test(t, func(t *testing.T) { synctest.Run(func() {
// 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")

View File

@@ -4,7 +4,8 @@ import (
"beszel" "beszel"
"beszel/internal/common" "beszel/internal/common"
"beszel/internal/entities/system" "beszel/internal/entities/system"
"encoding/json" "encoding/json/jsontext"
"encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -144,7 +145,7 @@ func (a *Agent) writeToSession(w io.Writer, stats *system.CombinedData, hubVersi
if hubVersion.GTE(beszel.MinVersionCbor) { if hubVersion.GTE(beszel.MinVersionCbor) {
return cbor.NewEncoder(w).Encode(stats) return cbor.NewEncoder(w).Encode(stats)
} }
return json.NewEncoder(w).Encode(stats) return json.MarshalEncode(jsontext.NewEncoder(w), stats)
} }
// extractHubVersion extracts the beszel version from SSH client version string. // extractHubVersion extracts the beszel version from SSH client version string.

View File

@@ -5,7 +5,7 @@ import (
"beszel/internal/entities/system" "beszel/internal/entities/system"
"context" "context"
"crypto/ed25519" "crypto/ed25519"
"encoding/json" "encoding/json/v2"
"fmt" "fmt"
"net" "net"
"os" "os"

View File

@@ -2,7 +2,6 @@ package agent
import ( import (
"beszel" "beszel"
"beszel/internal/agent/battery"
"beszel/internal/entities/system" "beszel/internal/entities/system"
"bufio" "bufio"
"fmt" "fmt"
@@ -60,10 +59,10 @@ func (a *Agent) initializeSystemInfo() {
} }
// zfs // zfs
if _, err := getARCSize(); err != nil { if _, err := getARCSize(); err == nil {
slog.Debug("Not monitoring ZFS ARC", "err", err)
} else {
a.zfs = true a.zfs = true
} else {
slog.Debug("Not monitoring ZFS ARC", "err", err)
} }
} }
@@ -71,11 +70,6 @@ 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 {
@@ -86,6 +80,7 @@ func (a *Agent) getSystemStats() system.Stats {
// load average // load average
if avgstat, err := load.Avg(); err == nil { if avgstat, err := load.Avg(); err == nil {
// TODO: remove these in future release in favor of load avg array
systemStats.LoadAvg[0] = avgstat.Load1 systemStats.LoadAvg[0] = avgstat.Load1
systemStats.LoadAvg[1] = avgstat.Load5 systemStats.LoadAvg[1] = avgstat.Load5
systemStats.LoadAvg[2] = avgstat.Load15 systemStats.LoadAvg[2] = avgstat.Load15

View File

@@ -1,150 +1,56 @@
package agent package agent
import ( import (
"beszel/internal/ghupdate" "beszel"
"fmt" "fmt"
"log"
"os" "os"
"os/exec"
"strings" "strings"
"github.com/blang/semver"
"github.com/rhysd/go-github-selfupdate/selfupdate"
) )
// restarter knows how to restart the beszel-agent service. // Update updates beszel-agent to the latest version
type restarter interface { func Update() {
Restart() error var latest *selfupdate.Release
} var found bool
var err error
type systemdRestarter struct{ cmd string } currentVersion := semver.MustParse(beszel.Version)
fmt.Println("beszel-agent", currentVersion)
func (s *systemdRestarter) Restart() error { fmt.Println("Checking for updates...")
// Only restart if the service is active updater, _ := selfupdate.NewUpdater(selfupdate.Config{
if err := exec.Command(s.cmd, "is-active", "beszel-agent.service").Run(); err != nil { Filters: []string{"beszel-agent"},
return nil
}
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent.service via systemd…")
return exec.Command(s.cmd, "restart", "beszel-agent.service").Run()
}
type openRCRestarter struct{ cmd string }
func (o *openRCRestarter) Restart() error {
if err := exec.Command(o.cmd, "status", "beszel-agent").Run(); err != nil {
return nil
}
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via OpenRC…")
return exec.Command(o.cmd, "restart", "beszel-agent").Run()
}
type openWRTRestarter struct{ cmd string }
func (w *openWRTRestarter) Restart() error {
if err := exec.Command(w.cmd, "running", "beszel-agent").Run(); err != nil {
return nil
}
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via procd…")
return exec.Command(w.cmd, "restart", "beszel-agent").Run()
}
func detectRestarter() restarter {
if path, err := exec.LookPath("systemctl"); err == nil {
return &systemdRestarter{cmd: path}
}
if path, err := exec.LookPath("rc-service"); err == nil {
return &openRCRestarter{cmd: path}
}
if path, err := exec.LookPath("service"); err == nil {
return &openWRTRestarter{cmd: path}
}
return nil
}
// Update checks GitHub for a newer release of beszel-agent, applies it,
// fixes SELinux context if needed, and restarts the service.
func Update(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,
}) })
latest, found, err = updater.DetectLatest("henrygd/beszel")
if err != nil { if err != nil {
log.Fatal(err) fmt.Println("Error checking for updates:", err)
} os.Exit(1)
if !updated {
return nil
} }
// make sure the file is executable if !found {
if err := os.Chmod(exePath, 0755); err != nil { fmt.Println("No updates found")
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to set executable permissions: %v", err) os.Exit(0)
}
// 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 fmt.Println("Latest version:", latest.Version)
if err := handleSELinuxContext(exePath); err != nil {
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: SELinux context handling: %v", err) if latest.Version.LTE(currentVersion) {
fmt.Println("You are up to date")
return
} }
// 7) Restart service if running under a recognised init system var binaryPath string
if r := detectRestarter(); r != nil { fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version)
if err := r.Restart(); err != nil { binaryPath, err = os.Executable()
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 { if err != nil {
// SELinux not enabled or getenforce not available fmt.Println("Error getting binary path:", err)
return nil os.Exit(1)
} }
state := strings.TrimSpace(string(out)) err = selfupdate.UpdateTo(latest.AssetURL, binaryPath)
if state == "Disabled" { if err != nil {
return nil fmt.Println("Please try rerunning with sudo. Error:", err)
os.Exit(1)
} }
fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes))
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
} }

View File

@@ -2,7 +2,7 @@ package alerts
import ( import (
"beszel/internal/entities/system" "beszel/internal/entities/system"
"encoding/json" "encoding/json/v2"
"fmt" "fmt"
"strings" "strings"
"time" "time"

View File

@@ -36,10 +36,8 @@ type Stats struct {
LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty"` 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"` LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current] // TODO: remove other load fields in future release in favor of load avg array
MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"`
} }
type GPUData struct { type GPUData struct {
@@ -102,8 +100,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"` LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
// TODO: remove load fields in future release in favor of load avg array
} }
// Final data structure to return to the hub // Final data structure to return to the hub

View File

@@ -1,140 +0,0 @@
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
}

View File

@@ -1,348 +0,0 @@
// 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)
}

View File

@@ -1,45 +0,0 @@
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")
}
}

View File

@@ -1,36 +0,0 @@
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)
}

View File

@@ -8,10 +8,13 @@ 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"
@@ -19,7 +22,6 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
@@ -113,8 +115,6 @@ 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,6 +164,55 @@ 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
@@ -300,30 +349,3 @@ func (h *Hub) MakeLink(parts ...string) string {
} }
return base return base
} }
type SystemInfo struct {
Name string `json:"name"`
Id string `json:"id"`
Status string `json:"status"`
Port uint16 `json:"port"`
Host string `json:"host"`
Info string `json:"info"`
}
func (h *Hub) getUserSystemsFromRequest(req *http.Request) ([]SystemInfo, error) {
systems := []SystemInfo{}
token, err := req.Cookie("beszauth")
if err != nil {
return systems, err
}
if token.Value != "" {
user, err := h.FindAuthRecordByToken(token.Value)
if err != nil {
return systems, err
}
h.DB().NewQuery("SELECT s.id, s.info, s.status, s.name, s.port, s.host FROM systems s JOIN json_each(s.users) AS je WHERE je.value = {:user_id}").Bind(dbx.Params{
"user_id": user.Id,
}).All(&systems)
}
return systems, err
}

View File

@@ -9,7 +9,7 @@ import (
"bytes" "bytes"
"crypto/ed25519" "crypto/ed25519"
"encoding/json" "encoding/json/v2"
"encoding/pem" "encoding/pem"
"io" "io"
"net/http" "net/http"

View File

@@ -1,101 +0,0 @@
//go:build development
package hub
import (
"beszel"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"github.com/pocketbase/pocketbase/core"
)
// responseModifier wraps an http.RoundTripper to modify HTML responses
type responseModifier struct {
transport http.RoundTripper
hub *Hub
}
// RoundTrip implements http.RoundTripper interface with response modification
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
}
// Read the response body
body, err := io.ReadAll(resp.Body)
if err != nil {
return resp, err
}
resp.Body.Close()
// Modify the HTML content here
modifiedBody := rm.modifyHTML(string(body), req)
// Create a new response with the modified 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
}
// modifyHTML applies modifications to HTML content
func (rm *responseModifier) modifyHTML(html string, req *http.Request) 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)
slog.Info("modifying HTML", "appURL", rm.hub.appURL)
html = strings.Replace(html, "{{HUB_URL}}", rm.hub.appURL, 1)
systems, err := rm.hub.getUserSystemsFromRequest(req)
if err != nil {
return html
}
systemsJson, err := json.Marshal(systems)
if err != nil {
return html
}
html = strings.Replace(html, "'{SYSTEMS}'", string(systemsJson), 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",
})
// Set up custom transport with response modification
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
}

View File

@@ -1,62 +0,0 @@
//go:build !development
package hub
import (
"beszel"
"beszel/site"
"encoding/json"
"io/fs"
"log/slog"
"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)
}
systems, err := h.getUserSystemsFromRequest(e.Request)
if err != nil {
slog.Error("error getting user systems", "error", err)
}
systemsJson, err := json.Marshal(systems)
if err != nil {
slog.Error("error marshalling user systems", "error", err)
}
html = strings.Replace(html, "'{SYSTEMS}'", string(systemsJson), 1)
return e.HTML(http.StatusOK, html)
})
return nil
}

View File

@@ -5,7 +5,8 @@ import (
"beszel/internal/entities/system" "beszel/internal/entities/system"
"beszel/internal/hub/ws" "beszel/internal/hub/ws"
"context" "context"
"encoding/json" "encoding/json/jsontext"
"encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"math/rand" "math/rand"
@@ -275,7 +276,7 @@ func (sys *System) fetchDataViaSSH() (*system.CombinedData, error) {
if sys.agentVersion.GTE(beszel.MinVersionCbor) { if sys.agentVersion.GTE(beszel.MinVersionCbor) {
err = cbor.NewDecoder(stdout).Decode(sys.data) err = cbor.NewDecoder(stdout).Decode(sys.data)
} else { } else {
err = json.NewDecoder(stdout).Decode(sys.data) err = json.UnmarshalDecode(jsontext.NewDecoder(stdout), sys.data)
} }
if err != nil { if err != nil {

View File

@@ -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.Test(t, func(t *testing.T) { synctest.Run(func() {
sm.Initialize() sm.Initialize()
record, err := tests.CreateRecord(hub, "systems", map[string]any{ record, err := tests.CreateRecord(hub, "systems", map[string]any{
@@ -110,11 +110,9 @@ 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()

View File

@@ -1,85 +1,57 @@
package hub package hub
import ( import (
"beszel/internal/ghupdate" "beszel"
"fmt" "fmt"
"log"
"os" "os"
"os/exec" "strings"
"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(cmd *cobra.Command, _ []string) { func Update(_ *cobra.Command, _ []string) {
dataDir := os.TempDir() var latest *selfupdate.Release
var found bool
// set dataDir to ./beszel_data if it exists var err error
if _, err := os.Stat("./beszel_data"); err == nil { currentVersion := semver.MustParse(beszel.Version)
dataDir = "./beszel_data" fmt.Println("beszel", currentVersion)
} fmt.Println("Checking for updates...")
updater, _ := selfupdate.NewUpdater(selfupdate.Config{
// Check if china-mirrors flag is set Filters: []string{"beszel_"},
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 {
log.Fatal(err) fmt.Println("Error checking for updates:", 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
} }
// make sure the file is executable var binaryPath string
exePath, err := os.Executable() fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version)
if err == nil { binaryPath, err = os.Executable()
if err := os.Chmod(exePath, 0755); err != nil { if err != nil {
fmt.Printf("Warning: failed to set executable permissions: %v\n", err) fmt.Println("Error getting binary path:", err)
os.Exit(1)
} }
err = selfupdate.UpdateTo(latest.AssetURL, binaryPath)
if err != nil {
fmt.Println("Please try rerunning with sudo. Error:", err)
os.Exit(1)
} }
fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes))
// Try to restart the service if it's running
restartService()
}
// restartService attempts to restart the beszel service
func restartService() {
// Check if we're running as a service by looking for systemd
if _, err := exec.LookPath("systemctl"); err == nil {
// Check if beszel service exists and is active
cmd := exec.Command("systemctl", "is-active", "beszel.service")
if err := cmd.Run(); err == nil {
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.")
} }

View File

@@ -4,7 +4,7 @@ package records
import ( import (
"beszel/internal/entities/container" "beszel/internal/entities/container"
"beszel/internal/entities/system" "beszel/internal/entities/system"
"encoding/json" "encoding/json/v2"
"fmt" "fmt"
"log" "log"
"math" "math"
@@ -172,8 +172,6 @@ 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)
@@ -210,11 +208,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
sum.LoadAvg[2] += stats.LoadAvg[2] 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)
@@ -295,7 +290,6 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / 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 {

View File

@@ -1,15 +1,12 @@
package tests package tests
import ( import (
"bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"maps" "maps"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"time" "time"
@@ -251,7 +248,7 @@ func (scenario *ApiScenario) test(t testing.TB) {
} }
} else { } else {
// normalize json response format // normalize json response format
buffer := new(bytes.Buffer) /* buffer := new(bytes.Buffer)
err := json.Compact(buffer, recorder.Body.Bytes()) err := json.Compact(buffer, recorder.Body.Bytes())
var normalizedBody string var normalizedBody string
if err != nil { if err != nil {
@@ -274,6 +271,7 @@ func (scenario *ApiScenario) test(t testing.TB) {
break break
} }
} }
*/
} }
remainingEvents := maps.Clone(testApp.EventCalls) remainingEvents := maps.Clone(testApp.EventCalls)

Binary file not shown.

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
{ {
"name": "beszel", "name": "beszel",
"private": true, "private": true,
"version": "0.12.6", "version": "0.12.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite",
"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,27 +13,26 @@
"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.4.1", "@lingui/detect-locale": "^5.3.3",
"@lingui/macro": "^5.4.1", "@lingui/macro": "^5.3.3",
"@lingui/react": "^5.4.1", "@lingui/react": "^5.3.3",
"@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.15", "@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-direction": "^1.1.1", "@radix-ui/react-direction": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.7",
"@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",
@@ -41,26 +40,28 @@
"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": "^19.1.1", "react": "^18.3.1",
"react-dom": "^19.1.1", "react-dom": "^18.3.1",
"recharts": "^2.15.4", "recharts": "^2.15.4",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"valibot": "^0.42.1" "valibot": "^0.42.1"
}, },
"devDependencies": { "devDependencies": {
"@lingui/cli": "^5.4.1", "@lingui/cli": "^5.3.3",
"@lingui/swc-plugin": "^5.6.1", "@lingui/swc-plugin": "^5.5.2",
"@lingui/vite-plugin": "^5.4.1", "@lingui/vite-plugin": "^5.3.3",
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/vite": "^4.1.12", "@types/bun": "^1.2.19",
"@types/bun": "^1.2.20", "@types/react": "^18.3.23",
"@types/react": "^19.1.11", "@types/react-dom": "^18.3.7",
"@types/react-dom": "^19.1.7", "@vitejs/plugin-react-swc": "^3.11.0",
"@vitejs/plugin-react-swc": "^4.0.1", "autoprefixer": "^10.4.21",
"tailwindcss": "^4.1.12", "postcss": "^8.5.6",
"tw-animate-css": "^1.3.7", "tailwindcss": "^3.4.17",
"typescript": "^5.9.2", "tailwindcss-rtl": "^0.9.0",
"vite": "^7.1.3" "typescript": "^5.8.3",
"vite": "^6.3.5"
}, },
"overrides": { "overrides": {
"@nanostores/router": { "@nanostores/router": {

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -13,15 +13,13 @@ 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 } from "@/lib/stores" import { $publicKey, pb } from "@/lib/stores"
import { cn, generateToken, tokenMap, useBrowserStorage } from "@/lib/utils" import { cn, generateToken, isReadOnlyUser, tokenMap, useLocalStorage } 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"
@@ -77,7 +75,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] = useBrowserStorage("as-tab", "docker") const [tab, setTab] = useLocalStorage("as-tab", "docker")
const [token, setToken] = useState(system?.token ?? "") const [token, setToken] = useState(system?.token ?? "")
useEffect(() => { useEffect(() => {
@@ -107,7 +105,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: SystemStatus.Pending }) await pb.collection("systems").update(system.id, { ...data, status: "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({
@@ -133,7 +131,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 max-w-100 truncate pr-8"> <DialogTitle className="mb-2">
{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">
@@ -167,7 +165,9 @@ 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={() => setOpen(false)} onClick={() => {
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<any> icon?: React.ReactElement
} }
const CopyButton = memo((props: CopyButtonProps) => { const CopyButton = memo((props: CopyButtonProps) => {

View File

@@ -2,8 +2,7 @@ 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 { formatShortDate, toFixedFloat, formatDuration, cn } from "@/lib/utils" import { alertInfo, 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"
@@ -16,9 +15,7 @@ export const alertsHistoryColumns: ColumnDef<AlertsHistoryRecord>[] = [
<Trans>System</Trans> <Trans>System</Trans>
</Button> </Button>
), ),
cell: ({ row }) => ( cell: ({ row }) => <span className="ps-2">{row.original.expand?.system?.name || row.original.system}</span>,
<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())

View File

@@ -2,22 +2,23 @@ import { t } from "@lingui/core/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 { Dialog, DialogTrigger, DialogContent } from "@/components/ui/dialog"
import { BellIcon } from "lucide-react" import { BellIcon } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { SystemRecord } from "@/types" import { SystemRecord } from "@/types"
import { AlertDialogContent } from "./alerts-sheet" import { AlertDialogContent } from "./alerts-dialog"
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
export default memo(function AlertsButton({ system }: { system: SystemRecord }) { export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
const [opened, setOpened] = useState(false) const [opened, setOpened] = useState(false)
const alerts = useStore($alerts) const alerts = useStore($alerts)
const hasSystemAlert = alerts[system.id]?.size > 0 const hasSystemAlert = alerts[system.id]?.size > 0
return useMemo( return useMemo(
() => ( () => (
<Sheet> <Dialog>
<SheetTrigger asChild> <DialogTrigger 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", {
@@ -25,12 +26,32 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
})} })}
/> />
</Button> </Button>
</SheetTrigger> </DialogTrigger>
<SheetContent className="max-h-full overflow-auto w-145 !max-w-full p-4 sm:p-6"> <DialogContent className="max-h-full sm:max-h-[95svh] overflow-auto max-w-[37rem]">
{opened && <AlertDialogContent system={system} />} {opened && <AlertDialogContent system={system} />}
</SheetContent> </DialogContent>
</Sheet> </Dialog>
), ),
[opened, hasSystemAlert] [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]
// )
}) })

View File

@@ -1,8 +1,7 @@
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro"
import { Trans, Plural } from "@lingui/react/macro" import { Trans, Plural } from "@lingui/react/macro"
import { $alerts, $systems } from "@/lib/stores" import { $alerts, $systems, pb } from "@/lib/stores"
import { cn, debounce } from "@/lib/utils" import { alertInfo, cn, debounce } from "@/lib/utils"
import { alertInfo } from "@/lib/alerts"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { AlertInfo, AlertRecord, SystemRecord } from "@/types" import { AlertInfo, AlertRecord, SystemRecord } from "@/types"
import { lazy, memo, Suspense, useMemo, useState } from "react" import { lazy, memo, Suspense, useMemo, useState } from "react"
@@ -15,7 +14,6 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { ServerIcon, GlobeIcon } from "lucide-react" import { ServerIcon, GlobeIcon } from "lucide-react"
import { $router, Link } from "@/components/router" import { $router, Link } from "@/components/router"
import { DialogHeader } from "@/components/ui/dialog" import { DialogHeader } from "@/components/ui/dialog"
import { pb } from "@/lib/api"
const Slider = lazy(() => import("@/components/ui/slider")) const Slider = lazy(() => import("@/components/ui/slider"))
@@ -96,7 +94,7 @@ export const AlertDialogContent = memo(function AlertDialogContent({ system }: {
<TabsList className="mb-1 -mt-0.5"> <TabsList className="mb-1 -mt-0.5">
<TabsTrigger value="system"> <TabsTrigger value="system">
<ServerIcon className="me-2 h-3.5 w-3.5" /> <ServerIcon className="me-2 h-3.5 w-3.5" />
<span className="truncate max-w-60">{system.name}</span> {system.name}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="global"> <TabsTrigger value="global">
<GlobeIcon className="me-1.5 h-3.5 w-3.5" /> <GlobeIcon className="me-1.5 h-3.5 w-3.5" />

View File

@@ -1,14 +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 { cn, formatShortDate, chartMargin } from "@/lib/utils" import { useYAxisWidth, 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: number | string color: string
opacity: number opacity: number
} }
@@ -19,7 +18,6 @@ export default function AreaChartDefault({
tickFormatter, tickFormatter,
contentFormatter, contentFormatter,
dataPoints, dataPoints,
domain,
}: // logRender = false, }: // logRender = false,
{ {
chartData: ChartData chartData: ChartData
@@ -28,7 +26,6 @@ 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()
@@ -54,7 +51,7 @@ export default function AreaChartDefault({
orientation={chartData.orientation} orientation={chartData.orientation}
className="tracking-tighter" className="tracking-tighter"
width={yAxisWidth} width={yAxisWidth}
domain={domain ?? [0, max ?? "auto"]} 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}
@@ -71,7 +68,7 @@ export default function AreaChartDefault({
} }
/> />
{dataPoints?.map((dataPoint, i) => { {dataPoints?.map((dataPoint, i) => {
const color = `var(--chart-${dataPoint.color})` const color = `hsl(var(--chart-${dataPoint.color}))`
return ( return (
<Area <Area
key={i} key={i}

View File

@@ -1,26 +1,23 @@
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 { cn, formatShortDate, chartMargin, toFixedFloat, formatBytes, decimalString } from "@/lib/utils" import { useYAxisWidth, 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)
@@ -31,6 +28,40 @@ export default memo(function ContainerChart({
const isNetChart = chartType === ChartType.Network const isNetChart = chartType === ChartType.Network
const chartConfig = useMemo(() => {
const config = {} as Record<string, { label: string; color: string }>
const totalUsage = new Map<string, number>()
// calculate total usage of each container
for (const stats of containerData) {
for (const key in stats) {
if (!key || key === "created") continue
const currentTotal = totalUsage.get(key) ?? 0
const increment = isNetChart
? (stats[key]?.nr ?? 0) + (stats[key]?.ns ?? 0)
: // @ts-ignore
stats[key]?.[dataKey] ?? 0
totalUsage.set(key, currentTotal + increment)
}
}
// Sort keys and generate colors based on usage
const sortedEntries = Array.from(totalUsage.entries()).sort(([, a], [, b]) => b - a)
const length = sortedEntries.length
sortedEntries.forEach(([key], i) => {
const hue = ((i * 360) / length) % 360
config[key] = {
label: key,
color: `hsl(${hue}, 60%, 55%)`,
}
})
return config satisfies ChartConfig
}, [chartData])
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
@@ -88,14 +119,7 @@ export default memo(function ContainerChart({
return obj return obj
}, []) }, [])
// Filter with set lookup const filterLower = filter?.toLowerCase()
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())
@@ -138,9 +162,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 = filteredKeys.has(key) const filtered = filterLower && !key.toLowerCase().includes(filterLower)
const fillOpacity = filtered ? 0.05 : 0.4 let fillOpacity = filtered ? 0.05 : 0.4
const strokeOpacity = filtered ? 0.1 : 1 let strokeOpacity = filtered ? 0.1 : 1
return ( return (
<Area <Area
key={key} key={key}

View File

@@ -1,11 +1,10 @@
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 { cn, formatShortDate, decimalString, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils" import { useYAxisWidth, 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,
@@ -70,9 +69,9 @@ export default memo(function DiskChart({
dataKey={dataKey} dataKey={dataKey}
name={t`Disk Usage`} name={t`Disk Usage`}
type="monotoneX" type="monotoneX"
fill="var(--chart-4)" fill="hsl(var(--chart-4))"
fillOpacity={0.4} fillOpacity={0.4}
stroke="var(--chart-4)" stroke="hsl(var(--chart-4))"
// animationDuration={1200} // animationDuration={1200}
isAnimationActive={false} isAnimationActive={false}
/> />

View File

@@ -8,10 +8,9 @@ import {
ChartTooltipContent, ChartTooltipContent,
xAxis, xAxis,
} from "@/components/ui/chart" } from "@/components/ui/chart"
import { cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils" import { useYAxisWidth, 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()

View File

@@ -1,107 +0,0 @@
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 }
}

View File

@@ -8,11 +8,10 @@ import {
ChartTooltipContent, ChartTooltipContent,
xAxis, xAxis,
} from "@/components/ui/chart" } from "@/components/ui/chart"
import { cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils" import { useYAxisWidth, cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils"
import { ChartData, SystemStats } 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()

View File

@@ -1,13 +1,12 @@
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 { cn, decimalString, formatShortDate, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils" import { useYAxisWidth, 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, showMax }: { chartData: ChartData; showMax: boolean }) { export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { t } = useLingui() const { t } = useLingui()
@@ -67,19 +66,19 @@ export default memo(function MemChart({ chartData, showMax }: { chartData: Chart
<Area <Area
name={t`Used`} name={t`Used`}
order={3} order={3}
dataKey={({ stats }) => (showMax ? stats?.mm : stats?.mu)} dataKey="stats.mu"
type="monotoneX" type="monotoneX"
fill="var(--chart-2)" fill="hsl(var(--chart-2))"
fillOpacity={0.4} fillOpacity={0.4}
stroke="var(--chart-2)" stroke="hsl(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 }) => (showMax ? null : stats?.mz)} dataKey="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}
@@ -87,19 +86,19 @@ export default memo(function MemChart({ chartData, showMax }: { chartData: Chart
stackId="1" stackId="1"
isAnimationActive={false} isAnimationActive={false}
/> />
{/* )} */} )}
<Area <Area
name={t`Cache / Buffers`} name={t`Cache / Buffers`}
order={1} order={1}
dataKey={({ stats }) => (showMax ? null : stats?.mb)} dataKey="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>

View File

@@ -2,12 +2,11 @@ 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 { cn, formatShortDate, decimalString, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils" import { useYAxisWidth, 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()
@@ -59,9 +58,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="var(--chart-2)" fill="hsl(var(--chart-2))"
fillOpacity={0.4} fillOpacity={0.4}
stroke="var(--chart-2)" stroke="hsl(var(--chart-2))"
isAnimationActive={false} isAnimationActive={false}
/> />
</AreaChart> </AreaChart>

View File

@@ -8,12 +8,19 @@ import {
ChartTooltipContent, ChartTooltipContent,
xAxis, xAxis,
} from "@/components/ui/chart" } from "@/components/ui/chart"
import { cn, formatShortDate, toFixedFloat, chartMargin, formatTemperature, decimalString } from "@/lib/utils" import {
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)

View File

@@ -23,13 +23,11 @@ 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, listen } from "@/lib/utils" import { getHostDisplayValue, isAdmin, 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(() => {
@@ -56,9 +54,11 @@ 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 className="max-w-60 truncate">{system.name}</span> <span>{system.name}</span>
<CommandShortcut>{getHostDisplayValue(system)}</CommandShortcut> <CommandShortcut>{getHostDisplayValue(system)}</CommandShortcut>
</CommandItem> </CommandItem>
))} ))}
@@ -214,9 +214,6 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
</CommandGroup> </CommandGroup>
</> </>
)} )}
<CommandEmpty>
<Trans>No results found.</Trans>
</CommandEmpty>
</CommandList> </CommandList>
</CommandDialog> </CommandDialog>
) )

View File

@@ -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 cursor-pointer", lang === i18n.locale && "bg-accent/70 font-medium")} className={cn("px-2.5 flex gap-2.5", lang === i18n.locale && "font-semibold")}
onClick={() => dynamicActivate(lang)} onClick={() => dynamicActivate(lang)}
> >
<span>{e}</span> {label} <span>{e}</span> {label}

View File

@@ -5,7 +5,7 @@ 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 } from "@/lib/stores" import { $authenticated, pb } 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,7 +13,6 @@ 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.`))
@@ -289,7 +288,7 @@ export function UserAuthForm({
// }} // }}
/> />
)} )}
<span className="translate-y-px">{provider.displayName}</span> <span className="translate-y-[1px]">{provider.displayName}</span>
</button> </button>
))} ))}
</div> </div>
@@ -300,7 +299,7 @@ export function UserAuthForm({
<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-px">GitHub</span> <span className="translate-y-[1px]">GitHub</span>
</button> </button>
</DialogTrigger> </DialogTrigger>
<DialogContent style={{ maxWidth: 440, width: "90%" }}> <DialogContent style={{ maxWidth: 440, width: "90%" }}>

View File

@@ -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({

View File

@@ -1,14 +1,13 @@
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)
@@ -51,11 +50,8 @@ 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" ? "hsl(30, 8%, 70%)" : "hsl(220, 3%, 25%)" }} style={{ maxWidth: "22em", "--border": theme == "light" ? "30 8% 80%" : "220 3% 20%" }}
> >
<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" />

View File

@@ -1,21 +1,56 @@
import { t } from "@lingui/core/macro" import { Trans } from "@lingui/react/macro";
import { MoonStarIcon, SunIcon } from "lucide-react" import { t } from "@lingui/core/macro";
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 (
<Button <DropdownMenu>
variant={"ghost"} <DropdownMenuTrigger asChild>
size="icon" <Button variant={"ghost"} size="icon" aria-label={t`Toggle theme`}>
aria-label={t`Toggle theme`} <SunIcon className="h-[1.2rem] w-[1.2rem] dark:opacity-0" />
onClick={() => setTheme(theme === "dark" ? "light" : "dark")} <MoonStarIcon className="absolute h-[1.2rem] w-[1.2rem] opacity-0 dark:opacity-100" />
>
<SunIcon className="h-[1.2rem] w-[1.2rem] transition-all -rotate-90 dark:opacity-0 dark:rotate-0" />
<MoonStarIcon className="absolute h-[1.2rem] w-[1.2rem] transition-all opacity-0 -rotate-90 dark:opacity-100 dark:rotate-0" />
</Button> </Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{options.map((opt) => {
const selected = opt.theme === theme
return (
<DropdownMenuItem
key={opt.theme}
className={cn("px-2.5", selected ? "font-semibold" : "")}
onClick={() => setTheme(opt.theme as "dark" | "light" | "system")}
>
<opt.Icon className={cn("me-2 h-4 w-4 opacity-80", selected && "opacity-100")} />
{opt.label}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
) )
} }

View File

@@ -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 { cn, runOnce } from "@/lib/utils" import { pb } from "@/lib/stores"
import { isReadOnlyUser, isAdmin, logOut, pb } from "@/lib/api" import { cn, isReadOnlyUser, isAdmin, logOut } from "@/lib/utils"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
@@ -36,17 +36,12 @@ 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 <Link href={basePath} aria-label="Home" className="p-2 ps-0 me-3">
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" onMouseEnter={() => import("@/components/routes/settings/general")}> <div className="flex items-center ms-auto">
<LangToggle /> <LangToggle />
<ModeToggle /> <ModeToggle />
<Link <Link

View File

@@ -35,20 +35,18 @@ export const navigate = (urlString: string) => {
$router.open(urlString) $router.open(urlString)
} }
export function Link(props: React.AnchorHTMLAttributes<HTMLAnchorElement>) { function onClick(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {
return (
<a
{...props}
onClick={(e) => {
e.preventDefault() e.preventDefault()
const href = props.href || "" $router.open(new URL((e.currentTarget as HTMLAnchorElement).href).pathname)
if (e.ctrlKey || e.metaKey) { }
window.open(href, "_blank")
} else { export const Link = (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
navigate(href) let clickFn = onClick
if (props.onClick) {
clickFn = (e) => {
onClick(e)
props.onClick?.(e) props.onClick?.(e)
} }
}} }
></a> return <a {...props} onClick={clickFn}></a>
)
} }

View File

@@ -1,24 +1,38 @@
import { Suspense, memo, useEffect, useMemo } from "react" import { Suspense, lazy, memo, useEffect, useMemo } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
import { $alerts, $allSystemsById } from "@/lib/stores" import { $alerts, $systems, pb } 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 { AlertRecord } from "@/types" import { alertInfo, getSystemNameFromId, updateRecordList, updateSystemList } from "@/lib/utils"
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"
export default memo(function () { const SystemsTable = lazy(() => import("../systems-table/systems-table"))
export const Home = memo(() => {
const { t } = useLingui() const { t } = useLingui()
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)
})
return () => {
pb.collection("systems").unsubscribe("*")
}
}, [])
return useMemo( return useMemo(
() => ( () => (
<> <>
@@ -27,7 +41,7 @@ export default memo(function () {
<SystemsTable /> <SystemsTable />
</Suspense> </Suspense>
<div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 mb-4 text-xs opacity-80"> <div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 text-xs opacity-80">
<a <a
href="https://github.com/henrygd/beszel" href="https://github.com/henrygd/beszel"
target="_blank" target="_blank"
@@ -52,7 +66,6 @@ export default memo(function () {
const ActiveAlerts = () => { const ActiveAlerts = () => {
const alerts = useStore($alerts) const alerts = useStore($alerts)
const systems = useStore($allSystemsById)
const { activeAlerts, alertsKey } = useMemo(() => { const { activeAlerts, alertsKey } = useMemo(() => {
const activeAlerts: AlertRecord[] = [] const activeAlerts: AlertRecord[] = []
@@ -92,11 +105,11 @@ const ActiveAlerts = () => {
return ( return (
<Alert <Alert
key={alert.id} key={alert.id}
className="hover:-translate-y-px duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black/5" className="hover:-translate-y-[1px] duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black"
> >
<info.icon className="h-4 w-4" /> <info.icon className="h-4 w-4" />
<AlertTitle> <AlertTitle>
{systems[alert.system]?.name} {info.name().toLowerCase().replace("cpu", "CPU")} {getSystemNameFromId(alert.system)} {info.name().toLowerCase().replace("cpu", "CPU")}
</AlertTitle> </AlertTitle>
<AlertDescription> <AlertDescription>
{alert.name === "Status" ? ( {alert.name === "Status" ? (
@@ -109,7 +122,7 @@ const ActiveAlerts = () => {
)} )}
</AlertDescription> </AlertDescription>
<Link <Link
href={getPagePath($router, "system", { name: systems[alert.system]?.name })} href={getPagePath($router, "system", { name: getSystemNameFromId(alert.system) })}
className="absolute inset-0 w-full h-full" className="absolute inset-0 w-full h-full"
aria-label="View system" aria-label="View system"
></Link> ></Link>

View File

@@ -1,6 +1,5 @@
import { pb } from "@/lib/api" import { pb } from "@/lib/stores"
import { cn, formatDuration, formatShortDate } from "@/lib/utils" import { alertInfo, cn, formatDuration, formatShortDate } from "@/lib/utils"
import { alertInfo } from "@/lib/alerts"
import { AlertsHistoryRecord } from "@/types" import { AlertsHistoryRecord } from "@/types"
import { import {
getCoreRowModel, getCoreRowModel,
@@ -273,13 +272,13 @@ export default function AlertsHistoryDataTable() {
<Table> <Table>
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="border-border/50"> <TableRow key={headerGroup.id}>
{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>
))} ))}
</tr> </TableRow>
))} ))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>

View File

@@ -1,16 +1,17 @@
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>("")

View File

@@ -39,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="grid gap-2"> <div className="space-y-2">
<div className="mb-2"> <div className="mb-4">
<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>
@@ -73,8 +73,8 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
</Select> </Select>
</div> </div>
<Separator /> <Separator />
<div className="grid gap-2"> <div className="space-y-2">
<div className="mb-2"> <div className="mb-4">
<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>
@@ -102,8 +102,8 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
</p> </p>
</div> </div>
<Separator /> <Separator />
<div className="grid gap-2"> <div className="space-y-2">
<div className="mb-2"> <div className="mb-4">
<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>
@@ -112,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="grid gap-2"> <div className="space-y-2">
<Label className="block" htmlFor="unitTemp"> <Label className="block" htmlFor="unitTemp">
<Trans>Temperature unit</Trans> <Trans>Temperature unit</Trans>
</Label> </Label>
@@ -134,7 +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,7 +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>
@@ -181,8 +181,8 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
</div> </div>
</div> </div>
<Separator /> <Separator />
<div className="grid gap-2"> <div className="space-y-2">
<div className="mb-2"> <div className="mb-4">
<h3 className="mb-1 text-lg font-medium"> <h3 className="mb-1 text-lg font-medium">
<Trans>Warning thresholds</Trans> <Trans>Warning thresholds</Trans>
</h3> </h3>
@@ -191,7 +191,7 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
</p> </p>
</div> </div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4 items-end"> <div className="grid grid-cols-2 lg:grid-cols-3 gap-4 items-end">
<div className="grid gap-2"> <div className="space-y-1">
<Label htmlFor="colorWarn"> <Label htmlFor="colorWarn">
<Trans>Warning (%)</Trans> <Trans>Warning (%)</Trans>
</Label> </Label>
@@ -205,7 +205,7 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
defaultValue={userSettings.colorWarn ?? 65} defaultValue={userSettings.colorWarn ?? 65}
/> />
</div> </div>
<div className="grid gap-1"> <div className="space-y-1">
<Label htmlFor="colorCrit"> <Label htmlFor="colorCrit">
<Trans>Critical (%)</Trans> <Trans>Critical (%)</Trans>
</Label> </Label>

View File

@@ -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 { lazy, useEffect } from "react" import { 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,23 +8,15 @@ 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 } from "@/lib/stores.ts" import { $userSettings, pb } from "@/lib/stores.ts"
import { toast } from "@/components/ui/use-toast.ts" import { toast } from "@/components/ui/use-toast.ts"
import { UserSettings } from "@/types" 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 { pb } from "@/lib/api" import Fingerprints from "./tokens-fingerprints.tsx"
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 {
@@ -67,27 +59,23 @@ 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,
}, },
] ]
@@ -102,7 +90,7 @@ export default function SettingsLayout() {
}, []) }, [])
return ( return (
<Card className="pt-5 px-4 pb-8 min-h-96 mb-14 sm:pt-6 sm:px-7"> <Card className="pt-5 px-4 pb-8 min-h-96 sm:pt-6 sm:px-7">
<CardHeader className="p-0"> <CardHeader className="p-0">
<CardTitle className="mb-1"> <CardTitle className="mb-1">
<Trans>Settings</Trans> <Trans>Settings</Trans>
@@ -132,14 +120,14 @@ function SettingsContent({ name }: { name: string }) {
switch (name) { switch (name) {
case "general": case "general":
return <GeneralSettings userSettings={userSettings} /> return <General userSettings={userSettings} />
case "notifications": case "notifications":
return <NotificationsSettings userSettings={userSettings} /> return <Notifications userSettings={userSettings} />
case "config": case "config":
return <ConfigYamlSettings /> return <ConfigYaml />
case "tokens": case "tokens":
return <FingerprintsSettings /> return <Fingerprints />
case "alert-history": case "alert-history":
return <AlertsHistoryDataTableSettings /> return <AlertsHistoryDataTable />
} }
} }

View File

@@ -3,6 +3,7 @@ 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"
@@ -12,8 +13,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
@@ -86,8 +87,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="grid gap-2"> <div className="space-y-2">
<div className="mb-2"> <div className="mb-4">
<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>

View File

@@ -1,6 +1,5 @@
import React from "react" import React from "react"
import { cn } from "@/lib/utils" import { cn, isAdmin, isReadOnlyUser } 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"
@@ -14,7 +13,6 @@ 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> }>
}[] }[]
} }
@@ -54,7 +52,6 @@ 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(

View File

@@ -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 } from "@/lib/stores" import { $publicKey, pb } 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,8 +14,7 @@ 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, tokenMap } from "@/lib/utils" import { cn, copyToClipboard, generateToken, getHubURL, isReadOnlyUser, tokenMap } from "@/lib/utils"
import { isReadOnlyUser, pb } from "@/lib/api"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -272,7 +271,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>
<tr className="border-border/50"> <TableRow>
{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">
@@ -288,18 +287,16 @@ const SectionTable = memo(({ fingerprints = [] }: { fingerprints: FingerprintRec
</span> </span>
</TableHead> </TableHead>
)} )}
</tr> </TableRow>
</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 max-w-60 truncate"> <TableCell className="font-medium ps-5 py-2.5">{fingerprint.expand.system.name}</TableCell>
{fingerprint.expand.system.name} <TableCell className="font-mono text-[0.95em] py-2.5">{fingerprint.token}</TableCell>
</TableCell> <TableCell className="font-mono text-[0.95em] py-2.5">{fingerprint.fingerprint}</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 px-4 xl:px-2"> <TableCell className="py-2.5 px-4 xl:px-2">
<ActionsButtonTable fingerprint={fingerprint} /> <ActionsButtonTable fingerprint={fingerprint} />
</TableCell> </TableCell>
)} )}

View File

@@ -2,18 +2,17 @@ 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 { useContainerChartConfigs } from "@/components/charts/hooks" import { ChartType, Unit, Os } from "@/lib/enums"
import { ChartType, Unit, Os, SystemStatus } from "@/lib/enums" import React, { lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
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"
@@ -25,12 +24,12 @@ import {
decimalString, decimalString,
formatBytes, formatBytes,
getHostDisplayValue, getHostDisplayValue,
getPbTimestamp,
listen, listen,
parseSemVer, parseSemVer,
toFixedFloat, toFixedFloat,
useBrowserStorage, useLocalStorage,
} 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"
@@ -42,16 +41,15 @@ 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"
import AreaChartDefault from "@/components/charts/area-chart" const AreaChartDefault = lazy(() => import("../charts/area-chart"))
import ContainerChart from "@/components/charts/container-chart" const ContainerChart = lazy(() => import("../charts/container-chart"))
import MemChart from "@/components/charts/mem-chart" const MemChart = lazy(() => import("../charts/mem-chart"))
import DiskChart from "@/components/charts/disk-chart" const DiskChart = lazy(() => import("../charts/disk-chart"))
import SwapChart from "@/components/charts/swap-chart" const SwapChart = lazy(() => import("../charts/swap-chart"))
import TemperatureChart from "@/components/charts/temperature-chart" const TemperatureChart = lazy(() => import("../charts/temperature-chart"))
import GpuPowerChart from "@/components/charts/gpu-power-chart" const GpuPowerChart = lazy(() => import("../charts/gpu-power-chart"))
import LoadAverageChart from "@/components/charts/load-average-chart" const LoadAverageChart = lazy(() => import("../charts/load-average-chart"))
import { subscribeKeys } from "nanostores"
const cache = new Map<string, any>() const cache = new Map<string, any>()
@@ -120,13 +118,13 @@ function dockerOrPodman(str: string, system: SystemRecord) {
return str return str
} }
export default memo(function SystemDetail({ name }: { name: string }) { export default 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] = useBrowserStorage("grid", true) const [grid, setGrid] = useLocalStorage("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"])
@@ -152,13 +150,36 @@ export default memo(function SystemDetail({ name }: { name: string }) {
} }
}, [name]) }, [name])
// find matching system and update when it changes // function resetCharts() {
// setSystemStats([])
// setContainerData([])
// }
// useEffect(resetCharts, [chartTime])
// find matching system
useEffect(() => { useEffect(() => {
return subscribeKeys($allSystemsByName, [name], (newSystems) => { if (system.id && system.name === name) {
const sys = newSystems[name] return
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)
}) })
}, [name]) return () => {
pb.collection("systems").unsubscribe(system.id)
}
}, [system.id])
const chartData: ChartData = useMemo(() => { const chartData: ChartData = useMemo(() => {
const lastCreated = Math.max( const lastCreated = Math.max(
@@ -175,9 +196,6 @@ export default memo(function SystemDetail({ name }: { name: string }) {
} }
}, [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) {
@@ -268,11 +286,9 @@ export default memo(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 < 3600) { if (system.info.u < 172800) {
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 {
@@ -300,7 +316,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
Icon: any Icon: any
hide?: boolean hide?: boolean
}[] }[]
}, [system.info, t]) }, [system.info])
/** Space for tooltip if more than 12 containers */ /** Space for tooltip if more than 12 containers */
useEffect(() => { useEffect(() => {
@@ -366,15 +382,15 @@ export default memo(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 === SystemStatus.Up) { if (system.status === "up") {
translatedStatus = t({ message: "Up", comment: "Context: System is up" }) translatedStatus = t({ message: "Up", comment: "Context: System is up" })
} else if (system.status === SystemStatus.Down) { } else if (system.status === "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-14 overflow-x-clip"> <div id="chartwrap" className="grid gap-4 mb-10 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">
@@ -383,7 +399,7 @@ export default memo(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 === SystemStatus.Up && ( {system.status === "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" }}
@@ -391,10 +407,10 @@ export default memo(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 === SystemStatus.Up, "bg-green-500": system.status === "up",
"bg-red-500": system.status === SystemStatus.Down, "bg-red-500": system.status === "down",
"bg-primary/40": system.status === SystemStatus.Paused, "bg-primary/40": system.status === "paused",
"bg-yellow-500": system.status === SystemStatus.Pending, "bg-yellow-500": system.status === "pending",
})} })}
></span> ></span>
</span> </span>
@@ -469,7 +485,7 @@ export default memo(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,
}, },
]} ]}
@@ -486,12 +502,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
description={t`Average CPU utilization of containers`} description={t`Average CPU utilization of containers`}
cornerEl={containerFilterBar} cornerEl={containerFilterBar}
> >
<ContainerChart <ContainerChart chartData={chartData} dataKey="c" chartType={ChartType.CPU} />
chartData={chartData}
dataKey="c"
chartType={ChartType.CPU}
chartConfig={containerChartConfigs.cpu}
/>
</ChartCard> </ChartCard>
)} )}
@@ -500,9 +511,8 @@ export default memo(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} showMax={showMax} /> <MemChart chartData={chartData} />
</ChartCard> </ChartCard>
{containerFilterBar && ( {containerFilterBar && (
@@ -513,12 +523,7 @@ export default memo(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 <ContainerChart chartData={chartData} dataKey="m" chartType={ChartType.Memory} />
chartData={chartData}
dataKey="m"
chartType={ChartType.Memory}
chartConfig={containerChartConfigs.memory}
/>
</ChartCard> </ChartCard>
)} )}
@@ -540,13 +545,13 @@ export default memo(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,
}, },
]} ]}
@@ -581,7 +586,7 @@ export default memo(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,
}, },
{ {
@@ -592,7 +597,7 @@ export default memo(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,
}, },
]} ]}
@@ -620,12 +625,8 @@ export default memo(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}
> >
<ContainerChart {/* @ts-ignore */}
chartData={chartData} <ContainerChart chartData={chartData} chartType={ChartType.Network} dataKey="n" />
chartType={ChartType.Network}
dataKey="n"
chartConfig={containerChartConfigs.network}
/>
</ChartCard> </ChartCard>
</div> </div>
)} )}
@@ -667,35 +668,6 @@ export default memo(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
@@ -728,7 +700,7 @@ export default memo(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,
}, },
]} ]}
@@ -748,7 +720,7 @@ export default memo(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,
}, },
]} ]}
@@ -800,13 +772,13 @@ export default memo(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,
}, },
]} ]}
@@ -832,7 +804,7 @@ export default memo(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)
@@ -900,10 +872,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 gap-1 relative max-sm:py-3 max-sm:px-4"> <CardHeader className="pb-5 pt-4 relative space-y-1 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-3.5 sm:end-3.5">{cornerEl}</div>} {cornerEl && <div className="relative py-1 block sm:w-44 sm:absolute sm:top-2.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">
{ {

View File

@@ -23,11 +23,12 @@ import {
formatBytes, formatBytes,
formatTemperature, formatTemperature,
getMeterState, getMeterState,
isReadOnlyUser,
parseSemVer, parseSemVer,
} from "@/lib/utils" } from "@/lib/utils"
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons" import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { $longestSystemNameLen, $userSettings } from "@/lib/stores" import { $userSettings, pb } from "@/lib/stores"
import { Trans, useLingui } from "@lingui/react/macro" import { Trans, useLingui } from "@lingui/react/macro"
import { useMemo, useRef, useState } from "react" import { useMemo, useRef, useState } from "react"
import { memo } from "react" import { memo } from "react"
@@ -53,16 +54,13 @@ import {
} from "../ui/alert-dialog" } from "../ui/alert-dialog"
import { buttonVariants } from "../ui/button" import { buttonVariants } from "../ui/button"
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro"
import { MeterState, SystemStatus } from "@/lib/enums" import { MeterState } from "@/lib/enums"
import { $router, Link } from "../router"
import { getPagePath } from "@nanostores/router"
import { isReadOnlyUser, pb } from "@/lib/api"
const STATUS_COLORS = { const STATUS_COLORS = {
[SystemStatus.Up]: "bg-green-500", up: "bg-green-500",
[SystemStatus.Down]: "bg-red-500", down: "bg-red-500",
[SystemStatus.Paused]: "bg-primary/40", paused: "bg-primary/40",
[SystemStatus.Pending]: "bg-yellow-500", pending: "bg-yellow-500",
} as const } as const
/** /**
@@ -72,8 +70,7 @@ const STATUS_COLORS = {
export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<SystemRecord>[] { export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<SystemRecord>[] {
return [ return [
{ {
// size: 200, size: 200,
size: 100,
minSize: 0, minSize: 0,
accessorKey: "name", accessorKey: "name",
id: "system", id: "system",
@@ -83,9 +80,9 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
let filterInputLower = "" let filterInputLower = ""
const nameCache = new Map<string, string>() const nameCache = new Map<string, string>()
const statusTranslations = { const statusTranslations = {
[SystemStatus.Up]: t`Up`.toLowerCase(), up: t`Up`.toLowerCase(),
[SystemStatus.Down]: t`Down`.toLowerCase(), down: t`Down`.toLowerCase(),
[SystemStatus.Paused]: t`Paused`.toLowerCase(), paused: t`Paused`.toLowerCase(),
} as const } as const
// match filter value against name or translated status // match filter value against name or translated status
@@ -110,26 +107,12 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
enableHiding: false, enableHiding: false,
invertSorting: false, invertSorting: false,
Icon: ServerIcon, Icon: ServerIcon,
cell: (info) => { cell: (info) => (
const { name } = info.row.original <span className="flex gap-2 items-center font-medium text-sm text-nowrap md:ps-1 md:pe-5">
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} /> <IndicatorDot system={info.row.original} />
{/* NOTE: change to 1 ch if switching to monospace font */} {info.getValue() as string}
<span className="truncate" style={{ width: `${longestName / 1.1}ch` }}>
{name}
</span> </span>
</span> ),
<Link
href={getPagePath($router, "system", { name })}
className="inset-0 absolute size-full"
aria-label={name}
></Link>
</>
)
},
header: sortableHeader, header: sortableHeader,
}, },
{ {
@@ -191,7 +174,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
} }
const max = Math.max(...loadAverages) const max = Math.max(...loadAverages)
if (max === 0 && (status === SystemStatus.Paused || minor < 12)) { if (max === 0 && (status === "paused" || minor < 12)) {
return null return null
} }
@@ -202,10 +185,10 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
<div className="flex items-center gap-[.35em] w-full tabular-nums tracking-tight"> <div className="flex items-center gap-[.35em] w-full tabular-nums tracking-tight">
<span <span
className={cn("inline-block size-2 rounded-full me-0.5", { className={cn("inline-block size-2 rounded-full me-0.5", {
[STATUS_COLORS[SystemStatus.Up]]: threshold === MeterState.Good, [STATUS_COLORS.up]: threshold === MeterState.Good,
[STATUS_COLORS[SystemStatus.Pending]]: threshold === MeterState.Warn, [STATUS_COLORS.pending]: threshold === MeterState.Warn,
[STATUS_COLORS[SystemStatus.Down]]: threshold === MeterState.Crit, [STATUS_COLORS.down]: threshold === MeterState.Crit,
[STATUS_COLORS[SystemStatus.Paused]]: status !== SystemStatus.Up, [STATUS_COLORS.paused]: status !== "up",
})} })}
/> />
{loadAverages?.map((la, i) => ( {loadAverages?.map((la, i) => (
@@ -225,7 +208,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
cell(info) { cell(info) {
const sys = info.row.original const sys = info.row.original
const userSettings = useStore($userSettings, { keys: ["unitNet"] }) const userSettings = useStore($userSettings, { keys: ["unitNet"] })
if (sys.status === SystemStatus.Paused) { if (sys.status === "paused") {
return null return null
} }
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, false) const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, false)
@@ -274,13 +257,13 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
} }
const system = info.row.original const system = info.row.original
return ( return (
<span className={cn("flex gap-1.5 items-center md:pe-5 tabular-nums", viewMode === "table" && "ps-0.5")}> <span className={cn("flex gap-2 items-center md:pe-5 tabular-nums", viewMode === "table" && "ps-0.5")}>
<IndicatorDot <IndicatorDot
system={system} system={system}
className={ className={
(system.status !== SystemStatus.Up && STATUS_COLORS[SystemStatus.Paused]) || (system.status !== "up" && STATUS_COLORS.paused) ||
(version === globalThis.BESZEL.HUB_VERSION && STATUS_COLORS[SystemStatus.Up]) || (version === globalThis.BESZEL.HUB_VERSION && STATUS_COLORS.up) ||
STATUS_COLORS[SystemStatus.Pending] STATUS_COLORS.pending
} }
/> />
<span className="truncate max-w-14">{info.getValue() as string}</span> <span className="truncate max-w-14">{info.getValue() as string}</span>
@@ -294,7 +277,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
name: () => t({ message: "Actions", comment: "Table column" }), name: () => t({ message: "Actions", comment: "Table column" }),
size: 50, size: 50,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="relative z-10 flex justify-end items-center gap-1 -ms-3"> <div className="flex justify-end items-center gap-1 -ms-3">
<AlertButton system={row.original} /> <AlertButton system={row.original} />
<ActionsButton system={row.original} /> <ActionsButton system={row.original} />
</div> </div>
@@ -323,18 +306,22 @@ function sortableHeader(context: HeaderContext<SystemRecord, unknown>) {
function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) { function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
const val = Number(info.getValue()) || 0 const val = Number(info.getValue()) || 0
const threshold = getMeterState(val) const threshold = getMeterState(val)
const meterClass = cn( return (
"h-full", <div className="flex gap-2 items-center tabular-nums tracking-tight">
(info.row.original.status !== SystemStatus.Up && STATUS_COLORS.paused) || <span className="min-w-8">{decimalString(val, val >= 10 ? 1 : 2)}%</span>
<span className="grow min-w-8 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
<span
className={cn(
"absolute inset-0 w-full h-full origin-left",
(info.row.original.status !== "up" && STATUS_COLORS.paused) ||
(threshold === MeterState.Good && STATUS_COLORS.up) || (threshold === MeterState.Good && STATUS_COLORS.up) ||
(threshold === MeterState.Warn && STATUS_COLORS.pending) || (threshold === MeterState.Warn && STATUS_COLORS.pending) ||
STATUS_COLORS.down STATUS_COLORS.down
) )}
return ( style={{
<div className="flex gap-2 items-center tabular-nums tracking-tight w-full"> transform: `scalex(${val / 100})`,
<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>
<span className={meterClass} style={{ width: `${val}%` }}></span>
</span> </span>
</div> </div>
) )
@@ -344,7 +331,7 @@ export function IndicatorDot({ system, className }: { system: SystemRecord; clas
className ||= STATUS_COLORS[system.status as keyof typeof STATUS_COLORS] || "" className ||= STATUS_COLORS[system.status as keyof typeof STATUS_COLORS] || ""
return ( return (
<span <span
className={cn("shrink-0 size-2 rounded-full", className)} className={cn("flex-shrink-0 size-2 rounded-full", className)}
// style={{ marginBottom: "-1px" }} // style={{ marginBottom: "-1px" }}
/> />
) )
@@ -362,7 +349,7 @@ export const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
<> <>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size={"icon"}> <Button variant="ghost" size={"icon"} data-nolink>
<span className="sr-only"> <span className="sr-only">
<Trans>Open menu</Trans> <Trans>Open menu</Trans>
</span> </span>
@@ -385,11 +372,11 @@ export const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
className={cn(isReadOnlyUser() && "hidden")} className={cn(isReadOnlyUser() && "hidden")}
onClick={() => { onClick={() => {
pb.collection("systems").update(id, { pb.collection("systems").update(id, {
status: status === SystemStatus.Paused ? SystemStatus.Pending : SystemStatus.Paused, status: status === "paused" ? "pending" : "paused",
}) })
}} }}
> >
{status === SystemStatus.Paused ? ( {status === "paused" ? (
<> <>
<PlayCircleIcon className="me-2.5 size-4" /> <PlayCircleIcon className="me-2.5 size-4" />
<Trans>Resume</Trans> <Trans>Resume</Trans>

View File

@@ -11,8 +11,11 @@ import {
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 } from "@/components/ui/button"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
@@ -33,65 +36,32 @@ import {
ArrowUpIcon, ArrowUpIcon,
Settings2Icon, Settings2Icon,
EyeIcon, EyeIcon,
FilterIcon,
} from "lucide-react" } from "lucide-react"
import { memo, useEffect, useMemo, useRef, useState } from "react" import { memo, useEffect, useMemo, useState } from "react"
import { $pausedSystems, $downSystems, $upSystems, $systems } from "@/lib/stores" import { $systems } from "@/lib/stores"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { cn, runOnce, useBrowserStorage } from "@/lib/utils" import { cn, useLocalStorage } from "@/lib/utils"
import { $router, Link } from "../router" import { $router, Link, navigate } from "../router"
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 "@/components/ui/input" import { Input } from "../ui/input"
import { getPagePath } from "@nanostores/router" import { getPagePath } from "@nanostores/router"
import SystemsTableColumns, { ActionsButton, IndicatorDot } from "./systems-table-columns" import SystemsTableColumns, { ActionsButton, IndicatorDot } from "./systems-table-columns"
import AlertButton from "../alerts/alert-button" 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"]
const preloadSystemDetail = runOnce(() => import("@/components/routes/system.tsx"))
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 [statusFilter, setStatusFilter] = useState<StatusFilter>("all") const [sorting, setSorting] = useState<SortingState>([{ id: "system", desc: false }])
const [sorting, setSorting] = useBrowserStorage<SortingState>(
"sortMode",
[{ id: "system", desc: false }],
sessionStorage
)
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]) const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useBrowserStorage<VisibilityState>("cols", {}) const [columnVisibility, setColumnVisibility] = useLocalStorage<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)
@@ -101,7 +71,7 @@ export default function SystemsTable() {
const columnDefs = useMemo(() => SystemsTableColumns(viewMode), [viewMode]) const columnDefs = useMemo(() => SystemsTableColumns(viewMode), [viewMode])
const table = useReactTable({ const table = useReactTable({
data: filteredData, data,
columns: columnDefs, columns: columnDefs,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
onSortingChange: setSorting, onSortingChange: setSorting,
@@ -115,6 +85,7 @@ export default function SystemsTable() {
columnVisibility, columnVisibility,
}, },
defaultColumn: { defaultColumn: {
// sortDescFirst: true,
invertSorting: true, invertSorting: true,
sortUndefined: "last", sortUndefined: "last",
minSize: 0, minSize: 0,
@@ -126,25 +97,19 @@ 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-4.5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1"> <CardHeader className="pb-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"> <CardTitle className="mb-2.5">
<Trans>All Systems</Trans> <Trans>All Systems</Trans>
</CardTitle> </CardTitle>
<CardDescription className="flex"> <CardDescription>
<Trans>Click on a system to view more information.</Trans> <Trans>Updated in real time. Click on a system to view 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>
@@ -155,8 +120,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-4 divide-y md:divide-s md:divide-y-0"> <div className="grid grid-cols-1 md:grid-cols-3 divide-y md:divide-s md:divide-y-0">
<div className="border-r"> <div>
<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>
@@ -178,33 +143,7 @@ export default function SystemsTable() {
</DropdownMenuRadioGroup> </DropdownMenuRadioGroup>
</div> </div>
<div className="border-r"> <div>
<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>
@@ -270,16 +209,7 @@ 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>
@@ -287,7 +217,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"> <div className="rounded-md border overflow-hidden">
<AllSystemsTable table={table} rows={rows} colLength={visibleColumns.length} /> <AllSystemsTable table={table} rows={rows} colLength={visibleColumns.length} />
</div> </div>
) : ( ) : (
@@ -309,78 +239,36 @@ export default function SystemsTable() {
) )
} }
const AllSystemsTable = memo(function ({ const AllSystemsTable = memo(
table, ({ table, rows, colLength }: { table: TableType<SystemRecord>; rows: Row<SystemRecord>[]; colLength: number }) => {
rows,
colLength,
}: {
table: TableType<SystemRecord>
rows: Row<SystemRecord>[]
colLength: number
}) {
// The virtualizer will need a reference to the scrollable container element
const scrollRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
count: rows.length,
estimateSize: () => (rows.length > 10 ? 56 : 60),
getScrollElement: () => scrollRef.current,
overscan: 5,
})
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 ( return (
<div <Table>
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} /> <SystemsTableHead table={table} colLength={colLength} />
<TableBody onMouseEnter={preloadSystemDetail}> <TableBody>
{rows.length ? ( {rows.length ? (
virtualRows.map((virtualRow) => { rows.map((row) => (
const row = rows[virtualRow.index] as Row<SystemRecord> <SystemTableRow key={row.original.id} row={row} length={rows.length} colLength={colLength} />
return ( ))
<SystemTableRow
key={row.id}
row={row}
virtualRow={virtualRow}
length={rows.length}
colLength={colLength}
/>
)
})
) : ( ) : (
<TableRow> <TableRow>
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none"> <TableCell colSpan={colLength} className="h-24 text-center">
<Trans>No systems found.</Trans> <Trans>No systems found.</Trans>
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
</TableBody> </TableBody>
</table> </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 className="sticky top-0 z-20 w-full border-b-2"> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}> <TableRow 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}>
@@ -388,41 +276,38 @@ function SystemsTableHead({ table, colLength }: { table: TableType<SystemRecord>
</TableHead> </TableHead>
) )
})} })}
</tr> </TableRow>
))} ))}
</TableHeader> </TableHeader>
) )
}, [i18n.locale, colLength]) }, [i18n.locale, colLength])
} }
const SystemTableRow = memo(function ({ const SystemTableRow = memo(
row, ({ row, length, colLength }: { row: Row<SystemRecord>; length: number; colLength: number }) => {
virtualRow,
colLength,
}: {
row: Row<SystemRecord>
virtualRow: VirtualItem
length: number
colLength: number
}) {
const system = row.original const system = row.original
const { t } = useLingui() const { t } = useLingui()
return useMemo(() => { return useMemo(() => {
return ( return (
<TableRow <TableRow
// data-state={row.getIsSelected() && "selected"} // data-state={row.getIsSelected() && "selected"}
className={cn("cursor-pointer transition-opacity relative safari:transform-3d", { className={cn("cursor-pointer transition-opacity", {
"opacity-50": system.status === SystemStatus.Paused, "opacity-50": system.status === "paused",
})} })}
onClick={(e) => {
const target = e.target as HTMLElement
if (!target.closest("[data-nolink]") && e.currentTarget.contains(target)) {
navigate(getPagePath($router, "system", { name: system.name }))
}
}}
> >
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<TableCell <TableCell
key={cell.id} key={cell.id}
style={{ style={{
width: cell.column.getSize(), width: cell.column.getSize(),
height: virtualRow.size,
}} }}
className="py-0" className={cn("overflow-hidden relative", length > 10 ? "py-2" : "py-2.5")}
> >
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell> </TableCell>
@@ -430,7 +315,8 @@ const SystemTableRow = memo(function ({
</TableRow> </TableRow>
) )
}, [system, system.status, colLength, t]) }, [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 }) => {
@@ -440,33 +326,33 @@ 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 === SystemStatus.Paused, "opacity-50": system.status === "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 gap-2 w-full overflow-hidden"> <div className="flex items-center justify-between gap-2">
<CardTitle className="text-base tracking-normal text-primary/90 flex items-center min-w-0 flex-1 gap-2.5"> <CardTitle className="text-base tracking-normal shrink-1 text-primary/90 flex items-center min-h-10 gap-2.5 min-w-0">
<div className="flex items-center gap-2.5 min-w-0 flex-1"> <div className="flex items-center gap-2.5 min-w-0">
<IndicatorDot system={system} /> <IndicatorDot system={system} />
<span className="text-[.95em]/normal tracking-normal text-primary/90 truncate">{system.name}</span> <CardTitle className="text-[.95em]/normal tracking-normal truncate text-primary/90">
{system.name}
</CardTitle>
</div> </div>
</CardTitle> </CardTitle>
{table.getColumn("actions")?.getIsVisible() && ( {table.getColumn("actions")?.getIsVisible() && (
<div className="flex gap-1 shrink-0 relative z-10"> <div className="flex gap-1 flex-shrink-0 relative z-10">
<AlertButton system={system} /> <AlertButton system={system} />
<ActionsButton system={system} /> <ActionsButton system={system} />
</div> </div>
)} )}
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="text-sm px-5 pt-3.5 pb-4"> <CardContent className="space-y-2.5 text-sm px-5 pt-3.5 pb-4">
<div className="grid gap-2.5" style={{ gridTemplateColumns: "24px minmax(80px, max-content) 1fr" }}>
{table.getAllColumns().map((column) => { {table.getAllColumns().map((column) => {
if (!column.getIsVisible() || column.id === "system" || column.id === "actions") return null if (!column.getIsVisible() || column.id === "system" || column.id === "actions") return null
const cell = row.getAllCells().find((cell) => cell.column.id === column.id) const cell = row.getAllCells().find((cell) => cell.column.id === column.id)
@@ -474,24 +360,15 @@ const SystemCard = memo(
// @ts-ignore // @ts-ignore
const { Icon, name } = column.columnDef as ColumnDef<SystemRecord, unknown> const { Icon, name } = column.columnDef as ColumnDef<SystemRecord, unknown>
return ( return (
<> <div key={column.id} className="flex items-center gap-3">
<div key={`${column.id}-icon`} className="flex items-center"> {Icon && <Icon className="size-4 text-muted-foreground" />}
{column.id === "lastSeen" ? ( <div className="flex items-center gap-3 flex-1">
<EyeIcon className="size-4 text-muted-foreground" /> <span className="text-muted-foreground min-w-16">{name()}:</span>
) : ( <div className="flex-1">{flexRender(cell.column.columnDef.cell, cell.getContext())}</div>
Icon && <Icon className="size-4 text-muted-foreground" />
)}
</div> </div>
<div key={`${column.id}-label`} className="flex items-center text-muted-foreground pr-3">
{name()}:
</div> </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 })}

View File

@@ -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-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", "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",
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("grid gap-2 text-center sm:text-start", className)} {...props} /> <div className={cn("flex flex-col space-y-2 text-center sm:text-start", className)} {...props} />
) )
AlertDialogHeader.displayName = "AlertDialogHeader" AlertDialogHeader.displayName = "AlertDialogHeader"

View File

@@ -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-hidden 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-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{ {
variants: { variants: {
variant: { variant: {

View File

@@ -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-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer", "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",
{ {
variants: { variants: {
variant: { variant: {

View File

@@ -5,14 +5,16 @@ 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-xs", className)} className={cn("rounded-lg border border-border/60 bg-card text-card-foreground shadow-sm", 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) => <div ref={ref} className={cn("grid gap-1.5 p-6", className)} {...props} /> ({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
) )
CardHeader.displayName = "CardHeader" CardHeader.displayName = "CardHeader"

View File

@@ -4,8 +4,6 @@ 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
@@ -44,12 +42,11 @@ 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-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", "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",
className className
)} )}
{...props} {...props}
@@ -57,6 +54,7 @@ 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"
@@ -171,7 +169,7 @@ const ChartTooltipContent = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"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", "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",
className className
)} )}
> >
@@ -198,7 +196,7 @@ const ChartTooltipContent = React.forwardRef<
<itemConfig.icon /> <itemConfig.icon />
) : ( ) : (
<div <div
className={cn("shrink-0 rounded-[2px] border-border bg-(--color-bg)", { className={cn("shrink-0 rounded-[2px] border-[--color-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",
@@ -228,7 +226,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 text-foreground"> <span className="font-medium tabular-nums 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 : "")}
@@ -267,7 +265,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 ps-4", "flex items-center justify-center gap-4 gap-y-1 flex-wrap",
verticalAlign === "top" ? "pb-3" : "pt-3", verticalAlign === "top" ? "pb-3" : "pt-3",
className className
)} )}

View File

@@ -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-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", "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]:bg-primary data-[state=checked]:text-primary-foreground",
className className
)} )}

View File

@@ -1,41 +1,33 @@
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 { SearchIcon } from "lucide-react" import { Search } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent } from "@/components/ui/dialog"
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) { const Command = React.forwardRef<
return ( React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive <CommandPrimitive
data-slot="command" ref={ref}
className={cn("bg-card flex h-full w-full flex-col overflow-hidden rounded-md", className)} className={cn("flex h-full w-full flex-col overflow-hidden bg-popover text-popover-foreground", className)}
{...props} {...props}
/> />
) ))
} Command.displayName = CommandPrimitive.displayName
function CommandDialog({ interface CommandDialogProps extends DialogProps {}
title = "Command Palette",
description = "Search for a command to run...", const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return ( return (
<Dialog {...props}> <Dialog {...props}>
<DialogHeader className="sr-only"> <DialogContent className="overflow-hidden p-0 shadow-lg">
<DialogTitle>{title}</DialogTitle> <div className="sr-only">
<DialogDescription>{description}</DialogDescription> <DialogTitle>Command</DialogTitle>
</DialogHeader> </div>
<DialogContent className={cn("overflow-hidden p-0", className)}> <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">
<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>
@@ -43,81 +35,89 @@ function CommandDialog({
) )
} }
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) { const CommandInput = React.forwardRef<
return ( React.ElementRef<typeof CommandPrimitive.Input>,
<div data-slot="command-input-wrapper" className="flex h-9 items-center gap-2 border-b px-3"> React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
<SearchIcon className="size-4 shrink-0 opacity-50" /> >(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="me-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input <CommandPrimitive.Input
data-slot="command-input" ref={ref}
className={cn( className={cn(
"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", "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",
className className
)} )}
{...props} {...props}
/> />
</div> </div>
) ))
}
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) { CommandInput.displayName = CommandPrimitive.Input.displayName
return (
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List <CommandPrimitive.List
data-slot="command-list" ref={ref}
className={cn("max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", className)} className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props} {...props}
/> />
) ))
}
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) { CommandList.displayName = CommandPrimitive.List.displayName
return <CommandPrimitive.Empty data-slot="command-empty" className="py-6 text-center text-sm" {...props} />
}
function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) { const CommandEmpty = React.forwardRef<
return ( React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />)
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group <CommandPrimitive.Group
data-slot="command-group" ref={ref}
className={cn( className={cn(
"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", "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 className
)} )}
{...props} {...props}
/> />
) ))
}
function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) { CommandGroup.displayName = CommandPrimitive.Group.displayName
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) { const CommandSeparator = React.forwardRef<
return ( 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 <CommandPrimitive.Item
data-slot="command-item" ref={ref}
className={cn( className={cn(
"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", "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/70 aria-selected:opacity-90 data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50",
className className
)} )}
{...props} {...props}
/> />
) ))
}
function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) { CommandItem.displayName = CommandPrimitive.Item.displayName
return (
<span const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
data-slot="command-shortcut" return <span className={cn("ms-auto text-xs tracking-wide text-muted-foreground", className)} {...props} />
className={cn("text-muted-foreground ml-auto text-xs tracking-wide", className)}
{...props}
/>
)
} }
CommandShortcut.displayName = "CommandShortcut"
export { export {
Command, Command,

View File

@@ -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-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", "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",
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-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"> <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">
<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("grid gap-1.5 text-center sm:text-start", className)} {...props} /> <div className={cn("flex flex-col space-y-1.5 text-center sm:text-start", className)} {...props} />
) )
DialogHeader.displayName = "DialogHeader" DialogHeader.displayName = "DialogHeader"

View File

@@ -25,7 +25,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
ref={ref} ref={ref}
className={cn( className={cn(
"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", "flex cursor-default select-none items-center rounded-sm px-2.5 py-1.5 text-sm outline-none 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-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", "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",
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-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", "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",
className className
)} )}
{...props} {...props}
@@ -79,7 +79,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"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", "relative flex cursor-default select-none items-center rounded-sm px-2.5 py-1.5 text-sm outline-none 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-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", "relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none 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-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", "relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none focus:bg-accent/70 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className className
)} )}
{...props} {...props}

View File

@@ -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-linear-to-r rtl:bg-linear-to-l from-transparent to-background to-65% absolute top-2 end-1 pointer-events-none" "h-6 w-24 bg-gradient-to-r rtl:bg-gradient-to-l from-transparent to-background to-65% absolute top-2 end-1 pointer-events-none"
} }
></div> ></div>
<TooltipProvider delayDuration={100} disableHoverableContent> <TooltipProvider delayDuration={100} disableHoverableContent>
<Tooltip disableHoverableContent={true}> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
type="button" type="button"

View File

@@ -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-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", "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",
className className
)} )}
> >
@@ -53,7 +53,7 @@ const InputTags = React.forwardRef<HTMLInputElement, InputTagsProps>(
</Badge> </Badge>
))} ))}
<input <input
className="flex-1 outline-hidden bg-background placeholder:text-muted-foreground" className="flex-1 outline-none 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) => {

View File

@@ -2,19 +2,21 @@ import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) { export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
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-hidden 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-none 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 }

View File

@@ -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-hidden 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-none 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-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", "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",
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-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)" "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--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-hidden focus:bg-accent/70 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-none focus:bg-accent/70 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className className
)} )}
{...props} {...props}

View File

@@ -11,7 +11,7 @@ const Separator = React.forwardRef<
ref={ref} ref={ref}
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-px w-full" : "h-full w-px", className)} className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className)}
{...props} {...props}
/> />
)) ))

View File

@@ -1,101 +0,0 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in duration-500 isolate data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/40",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-[400ms]",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-header" className={cn("flex flex-col gap-1.5 p-4", className)} {...props} />
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-footer" className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription }

View File

@@ -15,7 +15,7 @@ const Slider = React.forwardRef<
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary"> <SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" /> <SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track> </SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" /> <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root> </SliderPrimitive.Root>
)) ))
Slider.displayName = SliderPrimitive.Root.displayName Slider.displayName = SliderPrimitive.Root.displayName

View File

@@ -9,7 +9,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SwitchPrimitives.Root <SwitchPrimitives.Root
className={cn( className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className className
)} )}
{...props} {...props}
@@ -17,7 +17,7 @@ const Switch = React.forwardRef<
> >
<SwitchPrimitives.Thumb <SwitchPrimitives.Thumb
className={cn( className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=checked]:rtl:-translate-x-5 data-[state=unchecked]:translate-x-0" "pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 rtl:data-[state=checked]:-translate-x-5 data-[state=unchecked]:translate-x-0"
)} )}
/> />
</SwitchPrimitives.Root> </SwitchPrimitives.Root>

View File

@@ -13,11 +13,7 @@ Table.displayName = "Table"
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>( const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<thead <thead ref={ref} className={cn("bg-muted/30 [&_tr]:border-b", className)} {...props} />
ref={ref}
className={cn("bg-table-header border-b border-border/50 [&_tr]:border-b", className)}
{...props}
/>
) )
) )
TableHeader.displayName = "TableHeader" TableHeader.displayName = "TableHeader"
@@ -31,7 +27,7 @@ TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>( const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<tfoot ref={ref} className={cn("border-t bg-muted/50 font-medium last:[&>tr]:border-b-0", className)} {...props} /> <tfoot ref={ref} className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)} {...props} />
) )
) )
TableFooter.displayName = "TableFooter" TableFooter.displayName = "TableFooter"
@@ -41,7 +37,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
<tr <tr
ref={ref} ref={ref}
className={cn( className={cn(
"border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:bg-muted!", "border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:!bg-muted",
className className
)} )}
{...props} {...props}

View File

@@ -27,7 +27,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs cursor-pointer", "inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className className
)} )}
{...props} {...props}
@@ -42,7 +42,7 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content <TabsPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"mt-2 ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className className
)} )}
{...props} {...props}

View File

@@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ classNa
return ( return (
<textarea <textarea
className={cn( className={cn(
"flex min-h-14 w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", "flex min-h-14 w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className className
)} )}
ref={ref} ref={ref}

View File

@@ -14,7 +14,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport <ToastPrimitives.Viewport
ref={ref} ref={ref}
className={cn( className={cn(
"fixed top-0 z-100 flex max-h-dvh w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", "fixed top-0 z-[100] flex max-h-dvh w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className className
)} )}
{...props} {...props}
@@ -23,7 +23,7 @@ const ToastViewport = React.forwardRef<
ToastViewport.displayName = ToastPrimitives.Viewport.displayName ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva( const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pe-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-(--radix-toast-swipe-end-x) data-[swipe=move]:translate-x-(--radix-toast-swipe-move-x) data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full sm:data-[state=open]:slide-in-from-bottom-full", "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pe-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{ {
variants: { variants: {
variant: { variant: {
@@ -52,7 +52,7 @@ const ToastAction = React.forwardRef<
<ToastPrimitives.Action <ToastPrimitives.Action
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 hover:group-[.destructive]:border-destructive/30 hover:group-[.destructive]:bg-destructive hover:group-[.destructive]:text-destructive-foreground focus:group-[.destructive]:ring-destructive", "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className className
)} )}
{...props} {...props}
@@ -67,7 +67,7 @@ const ToastClose = React.forwardRef<
<ToastPrimitives.Close <ToastPrimitives.Close
ref={ref} ref={ref}
className={cn( className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-hidden focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 hover:group-[.destructive]:text-red-50 focus:group-[.destructive]:ring-red-400 focus:group-[.destructive]:ring-offset-red-600", "absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className className
)} )}
toast-close="" toast-close=""

View File

@@ -3,47 +3,26 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) { const TooltipProvider = TooltipPrimitive.Provider
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) { const Tooltip = TooltipPrimitive.Root
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) { const TooltipTrigger = TooltipPrimitive.Trigger
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({ const TooltipContent = React.forwardRef<
className, React.ElementRef<typeof TooltipPrimitive.Content>,
sideOffset = 0, React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
children, >(({ className, sideOffset = 4, ...props }, ref) => (
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content <TooltipPrimitive.Content
data-slot="tooltip-content" ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground border animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-sm text-balance", "z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className className
)} )}
{...props} {...props}
>
{children}
<TooltipPrimitive.Arrow
className="bg-popover border z-50 fill-popover size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] will-change-transform"
style={{ clipPath: "inset(25% 0 0 25%)" }}
/> />
</TooltipPrimitive.Content> ))
</TooltipPrimitive.Portal> TooltipContent.displayName = TooltipPrimitive.Content.displayName
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -1,115 +1,62 @@
@import "tailwindcss"; @tailwind base;
@import "tw-animate-css"; @tailwind components;
@tailwind utilities;
@custom-variant light (&:is(.light *));
@custom-variant dark (&:is(.dark *));
@custom-variant safari (@supports (hanging-punctuation: first) and (-webkit-appearance: none));
@layer base {
:root { :root {
--background: hsl(30 8% 98%); --background: 30 8% 98%;
--foreground: hsl(30 0% 10%); --foreground: 30 0% 0%;
--card: hsl(30 0% 100%); --card: 30 0% 100%;
--card-foreground: hsl(240 6% 12%); --card-foreground: 240 6.67% 2.94%;
--popover: hsl(30 0% 100%); --popover: 30 0% 100%;
--popover-foreground: hsl(240 10% 6.2%); --popover-foreground: 240 10% 6.2%;
--primary: hsl(240 5.88% 10%); --primary: 240 5.88% 10%;
--primary-foreground: hsl(30 0% 100%); --primary-foreground: 30 0% 100%;
--secondary: hsl(240 4.76% 95.88%); --secondary: 240 4.76% 95.88%;
--secondary-foreground: hsl(240 5.88% 10%); --secondary-foreground: 240 5.88% 10%;
--muted: hsl(26 6% 94%); --muted: 26 6% 94%;
--muted-foreground: hsl(24 2.79% 35.1%); --muted-foreground: 24 2.79% 35.1%;
--accent: hsl(20 23.08% 94%); --accent: 20 23.08% 94%;
--accent-foreground: hsl(240 5.88% 10%); --accent-foreground: 240 5.88% 10%;
--destructive: hsl(0 66% 53%); --destructive: 0 66% 53%;
--destructive-foreground: hsl(0 0% 97%); --destructive-foreground: 0 0% 98.04%;
--border: hsl(30 8.11% 85.49%); --border: 30 8.11% 85.49%;
--input: hsl(30 4.29% 72.55%); --input: 30 4.29% 72.55%;
--ring: hsl(30 3.97% 49.41%); --ring: 30 3.97% 49.41%;
--radius: 0.8rem; --radius: 0.8rem;
--chart-1: hsl(220 70% 50%); /* charts */
--chart-2: hsl(160 60% 45%); --chart-1: 220 70% 50%;
--chart-3: hsl(30 80% 55%); --chart-2: 160 60% 45%;
--chart-4: hsl(280 65% 60%); --chart-3: 30 80% 55%;
--chart-5: hsl(340 75% 55%); --chart-4: 280 65% 60%;
--table-header: hsl(225, 6%, 97%); --chart-5: 340 75% 55%;
} }
.dark { .dark {
color-scheme: dark; color-scheme: dark;
--background: hsl(220 5.5% 9%); --background: 220 5.5% 9%;
--foreground: hsl(220 2% 97%); --foreground: 220 2% 97%;
--card: hsl(220 5.5% 10.5%); --card: 220 5.5% 10.5%;
--card-foreground: hsl(220 2% 97%); --card-foreground: 220 2% 97%;
--popover: hsl(220 5.5% 9%); --popover: 220 5.5% 9%;
--popover-foreground: hsl(220 2% 97%); --popover-foreground: 220 2% 97%;
--primary: hsl(220 2% 96%); --primary: 220 2% 96%;
--primary-foreground: hsl(220 4% 10%); --primary-foreground: 220 4% 10%;
--secondary: hsl(220 4% 16%); --secondary: 220 4% 16%;
--secondary-foreground: hsl(220 0% 98%); --secondary-foreground: 220 0% 98%;
--muted: hsl(220 6% 16%); --muted: 220 6% 16%;
--muted-foreground: hsl(220 4% 67%); --muted-foreground: 220 4% 67%;
--accent: hsl(220 5% 15.5%); --accent: 220 5% 15.5%;
--accent-foreground: hsl(220 2% 98%); --accent-foreground: 220 2% 98%;
--destructive: hsl(0 62% 46%); --destructive: 0 62% 46%;
--border: hsl(220 3% 16%); --destructive-foreground: 0 0% 97%;
--input: hsl(220 4% 22%); --border: 220 3% 16%;
--ring: hsl(220 4% 80%); --input: 220 4% 22%;
--table-header: hsl(220, 6%, 13%); --ring: 220 4% 80%;
--radius: 0.8rem; --radius: 0.8rem;
} }
@theme inline {
--font-sans: Inter, InterVariable, sans-serif;
--breakpoint-xs: 26.6rem;
--breakpoint-450: 28rem;
--breakpoint-2xl: 90rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-green-50: hsl(140 60% 95%);
--color-green-100: hsl(140 50% 90%);
--color-green-200: hsl(140 49% 80%);
--color-green-300: hsl(140 48% 70%);
--color-green-400: hsl(140 49% 60%);
--color-green-500: hsl(140 50% 48%);
--color-green-600: hsl(140 52% 38%);
--color-green-700: hsl(140 53% 29%);
--color-green-800: hsl(140 54% 20%);
--color-green-900: hsl(140 54% 12%);
--color-green-950: hsl(140 57% 6%);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-table-header: var(--table-header);
} }
@layer utilities {
/* Fonts */ /* Fonts */
@supports (font-variation-settings: normal) { @supports (font-variation-settings: normal) {
:root { :root {
@@ -123,40 +70,32 @@
font-display: swap; font-display: swap;
src: url("/static/InterVariable.woff2?v=4.0") format("woff2"); src: url("/static/InterVariable.woff2?v=4.0") format("woff2");
} }
}
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border;
overflow-anchor: none; overflow-anchor: none;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
button {
cursor: pointer;
}
} }
@utility container { @layer utilities {
@apply max-w-360 mx-auto px-4; .link {
}
@utility link {
@apply text-primary font-medium underline-offset-4 hover:underline; @apply text-primary font-medium underline-offset-4 hover:underline;
} }
@utility ns-dialog {
/* New system dialog width */ /* New system dialog width */
.ns-dialog {
min-width: 30.3rem; min-width: 30.3rem;
:where(:lang(zh), :lang(zh-CN), :lang(ko)) & { }
:where(:lang(zh), :lang(zh-CN), :lang(ko)) .ns-dialog {
min-width: 27.9rem; min-width: 27.9rem;
} }
} }
.recharts-tooltip-wrapper { .recharts-tooltip-wrapper {
z-index: 1; z-index: 1;
@apply tabular-nums;
} }
.recharts-yAxis { .recharts-yAxis {

View File

@@ -1,171 +0,0 @@
import type { AlertInfo, AlertRecord } from "@/types"
import type { RecordSubscription } from "pocketbase"
import { $alerts } from "@/lib/stores"
import { EthernetIcon } from "@/components/ui/icons"
import { ServerIcon, CpuIcon, MemoryStickIcon, HardDriveIcon, ThermometerIcon, HourglassIcon } from "lucide-react"
import { t } from "@lingui/core/macro"
import { pb } from "./api"
/** Alert info for each alert type */
export const alertInfo: Record<string, AlertInfo> = {
Status: {
name: () => t`Status`,
unit: "",
icon: ServerIcon,
desc: () => t`Triggers when status switches between up and down`,
/** "for x minutes" is appended to desc when only one value */
singleDesc: () => t`System` + " " + t`Down`,
},
CPU: {
name: () => t`CPU Usage`,
unit: "%",
icon: CpuIcon,
desc: () => t`Triggers when CPU usage exceeds a threshold`,
},
Memory: {
name: () => t`Memory Usage`,
unit: "%",
icon: MemoryStickIcon,
desc: () => t`Triggers when memory usage exceeds a threshold`,
},
Disk: {
name: () => t`Disk Usage`,
unit: "%",
icon: HardDriveIcon,
desc: () => t`Triggers when usage of any disk exceeds a threshold`,
},
Bandwidth: {
name: () => t`Bandwidth`,
unit: " MB/s",
icon: EthernetIcon,
desc: () => t`Triggers when combined up/down exceeds a threshold`,
max: 125,
},
Temperature: {
name: () => t`Temperature`,
unit: "°C",
icon: ThermometerIcon,
desc: () => t`Triggers when any sensor exceeds a threshold`,
},
LoadAvg1: {
name: () => t`Load Average 1m`,
unit: "",
icon: HourglassIcon,
max: 100,
min: 0.1,
start: 10,
step: 0.1,
desc: () => t`Triggers when 1 minute load average exceeds a threshold`,
},
LoadAvg5: {
name: () => t`Load Average 5m`,
unit: "",
icon: HourglassIcon,
max: 100,
min: 0.1,
start: 10,
step: 0.1,
desc: () => t`Triggers when 5 minute load average exceeds a threshold`,
},
LoadAvg15: {
name: () => t`Load Average 15m`,
unit: "",
icon: HourglassIcon,
min: 0.1,
max: 100,
start: 10,
step: 0.1,
desc: () => t`Triggers when 15 minute load average exceeds a threshold`,
},
} as const
/** Helper to manage user alerts */
export const alertManager = (() => {
const collection = pb.collection<AlertRecord>("alerts")
let unsub: () => void
/** Fields to fetch from alerts collection */
const fields = "id,name,system,value,min,triggered"
/** Fetch alerts from collection */
async function fetchAlerts(): Promise<AlertRecord[]> {
return await collection.getFullList<AlertRecord>({ fields, sort: "updated" })
}
/** Format alerts into a map of system id to alert name to alert record */
function add(alerts: AlertRecord[]) {
for (const alert of alerts) {
const systemId = alert.system
const systemAlerts = $alerts.get()[systemId] ?? new Map()
const newAlerts = new Map(systemAlerts)
newAlerts.set(alert.name, alert)
$alerts.setKey(systemId, newAlerts)
}
}
function remove(alerts: Pick<AlertRecord, "name" | "system">[]) {
for (const alert of alerts) {
const systemId = alert.system
const systemAlerts = $alerts.get()[systemId]
const newAlerts = new Map(systemAlerts)
newAlerts.delete(alert.name)
$alerts.setKey(systemId, newAlerts)
}
}
const actionFns = {
create: add,
update: add,
delete: remove,
}
// batch alert updates to prevent unnecessary re-renders when adding many alerts at once
const batchUpdate = (() => {
const batch = new Map<string, RecordSubscription<AlertRecord>>()
let timeout: ReturnType<typeof setTimeout>
return (data: RecordSubscription<AlertRecord>) => {
const { record } = data
batch.set(`${record.system}${record.name}`, data)
clearTimeout(timeout!)
timeout = setTimeout(() => {
const groups = { create: [], update: [], delete: [] } as Record<string, AlertRecord[]>
for (const { action, record } of batch.values()) {
groups[action]?.push(record)
}
for (const key in groups) {
if (groups[key].length) {
actionFns[key as keyof typeof actionFns]?.(groups[key])
}
}
batch.clear()
}, 50)
}
})()
async function subscribe() {
unsub = await collection.subscribe("*", batchUpdate, { fields })
}
function unsubscribe() {
unsub?.()
}
async function refresh() {
const records = await fetchAlerts()
add(records)
}
return {
/** Add alerts to store */
add,
/** Remove alerts from store */
remove,
/** Subscribe to alerts */
subscribe,
/** Unsubscribe from alerts */
unsubscribe,
/** Refresh alerts with latest data from hub */
refresh,
}
})()

View File

@@ -1,73 +0,0 @@
import { ChartTimes, UserSettings } from "@/types"
import { $alerts, $allSystemsByName, $userSettings } from "./stores"
import { toast } from "@/components/ui/use-toast"
import { t } from "@lingui/core/macro"
import { chartTimeData } from "./utils"
import PocketBase from "pocketbase"
import { basePath } from "@/components/router"
/** PocketBase JS Client */
export const pb = new PocketBase(basePath)
export const isAdmin = () => pb.authStore.record?.role === "admin"
export const isReadOnlyUser = () => pb.authStore.record?.role === "readonly"
export const updateCookieToken = () => {
console.log("setting token", pb.authStore.token)
document.cookie = `beszauth=${pb.authStore.token}; path=/; expires=${new Date(
Date.now() + 7 * 24 * 60 * 60 * 1000
).toString()}`
}
export const verifyAuth = () => {
pb.collection("users")
.authRefresh()
.catch(() => {
logOut()
toast({
title: t`Failed to authenticate`,
description: t`Please log in again`,
variant: "destructive",
})
})
}
/** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */
export async function logOut() {
$allSystemsByName.set({})
$alerts.set({})
$userSettings.set({} as UserSettings)
sessionStorage.setItem("lo", "t") // prevent auto login on logout
pb.authStore.clear()
pb.realtime.unsubscribe()
}
/** Fetch or create user settings in database */
export async function updateUserSettings() {
try {
const req = await pb.collection("user_settings").getFirstListItem("", { fields: "settings" })
$userSettings.set(req.settings)
return
} catch (e) {
console.error("get settings", e)
}
// create user settings if error fetching existing
try {
const createdSettings = await pb.collection("user_settings").create({ user: pb.authStore.record!.id })
$userSettings.set(createdSettings.settings)
} catch (e) {
console.error("create settings", e)
}
}
export function getPbTimestamp(timeString: ChartTimes, d?: Date) {
d ||= chartTimeData[timeString].getOffset(new Date())
const year = d.getUTCFullYear()
const month = String(d.getUTCMonth() + 1).padStart(2, "0")
const day = String(d.getUTCDate()).padStart(2, "0")
const hours = String(d.getUTCHours()).padStart(2, "0")
const minutes = String(d.getUTCMinutes()).padStart(2, "0")
const seconds = String(d.getUTCSeconds()).padStart(2, "0")
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}

View File

@@ -28,21 +28,3 @@ export enum MeterState {
Warn, Warn,
Crit, Crit,
} }
/** System status states */
export enum SystemStatus {
Up = "up",
Down = "down",
Pending = "pending",
Paused = "paused",
}
/** Battery state */
export enum BatteryState {
Unknown,
Empty,
Full,
Charging,
Discharging,
Idle,
}

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